mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-10 09:25:57 +00:00
fix(skills): harden slash skill activation across chat channels (#3466)
* support slash skill activation * format slash skill activation * Preserve slash skill activation with uploads * Address slash skill review feedback * Address slash skill follow-up review * Fix lazy slash skill storage resolution * Keep slash skill activation out of system prompt * Address slash skill review issues * fix: harden slash skill command handling * feat(frontend): add slash skill autocomplete * fix: address slash skill review feedback * fix: preserve slash skill text for IM uploads
This commit is contained in:
@@ -585,6 +585,8 @@ A standard Agent Skill is a structured capability module — a Markdown file tha
|
|||||||
|
|
||||||
Skills are loaded progressively — only when the task needs them, not all at once. This keeps the context window lean and makes DeerFlow work well even with token-sensitive models.
|
Skills are loaded progressively — only when the task needs them, not all at once. This keeps the context window lean and makes DeerFlow work well even with token-sensitive models.
|
||||||
|
|
||||||
|
Users can explicitly activate an enabled skill for a single turn by starting the request with `/skill-name`, for example `/data-analysis analyze uploads/foo.csv`. DeerFlow loads that skill's `SKILL.md` as hidden current-turn context while leaving the base prompt limited to skill metadata. Slash activation respects disabled skills, custom-agent skill whitelists, and existing channel commands such as `/new` and `/help`.
|
||||||
|
|
||||||
When you install `.skill` archives through the Gateway, DeerFlow accepts standard optional frontmatter metadata such as `version`, `author`, and `compatibility` instead of rejecting otherwise valid external skills.
|
When you install `.skill` archives through the Gateway, DeerFlow accepts standard optional frontmatter metadata such as `version`, `author`, and `compatibility` instead of rejecting otherwise valid external skills.
|
||||||
|
|
||||||
Tools follow the same philosophy. DeerFlow comes with a core toolset — web search, web fetch, file operations, bash execution — and supports custom tools via MCP servers and Python functions. Swap anything. Add anything.
|
Tools follow the same philosophy. DeerFlow comes with a core toolset — web search, web fetch, file operations, bash execution — and supports custom tools via MCP servers and Python functions. Swap anything. Add anything.
|
||||||
|
|||||||
+12
-10
@@ -202,16 +202,17 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
|
|||||||
6. **GuardrailMiddleware** - Pre-tool-call authorization via pluggable `GuardrailProvider` protocol (optional, if `guardrails.enabled` in config). Evaluates each tool call and returns error ToolMessage on deny. Three provider options: built-in `AllowlistProvider` (zero deps), OAP policy providers (e.g. `aport-agent-guardrails`), or custom providers. See [docs/GUARDRAILS.md](docs/GUARDRAILS.md) for setup, usage, and how to implement a provider.
|
6. **GuardrailMiddleware** - Pre-tool-call authorization via pluggable `GuardrailProvider` protocol (optional, if `guardrails.enabled` in config). Evaluates each tool call and returns error ToolMessage on deny. Three provider options: built-in `AllowlistProvider` (zero deps), OAP policy providers (e.g. `aport-agent-guardrails`), or custom providers. See [docs/GUARDRAILS.md](docs/GUARDRAILS.md) for setup, usage, and how to implement a provider.
|
||||||
7. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution continues
|
7. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution continues
|
||||||
8. **ToolErrorHandlingMiddleware** - Converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting
|
8. **ToolErrorHandlingMiddleware** - Converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting
|
||||||
9. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
9. **SkillActivationMiddleware** - Detects strict `/skill-name task` syntax on the latest real user message, resolves only enabled and runtime-allowed skills, reads `SKILL.md` from trusted skill storage, injects the skill body as hidden current-turn model context, and records a `middleware:skill_activation` audit event with skill name, category, path, and content hash
|
||||||
10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
10. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
||||||
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional); subagent usage is cached by `tool_call_id` only while token usage is enabled and merged back into the dispatching AIMessage by message position rather than message id
|
11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
||||||
12. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
|
12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional); subagent usage is cached by `tool_call_id` only while token usage is enabled and merged back into the dispatching AIMessage by message position rather than message id
|
||||||
13. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
|
13. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
|
||||||
14. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
|
14. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
|
||||||
15. **DeferredToolFilterMiddleware** - Hides deferred (MCP) tool schemas from the bound model using a build-time deferred-name set + catalog hash, reading per-thread promotions from `ThreadState.promoted` (hash-scoped, no ContextVar); a tool becomes bound on subsequent turns after `tool_search` returns its schema (optional, if `tool_search.enabled`)
|
15. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
|
||||||
16. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if `subagent_enabled`)
|
16. **DeferredToolFilterMiddleware** - Hides deferred (MCP) tool schemas from the bound model using a build-time deferred-name set + catalog hash, reading per-thread promotions from `ThreadState.promoted` (hash-scoped, no ContextVar); a tool becomes bound on subsequent turns after `tool_search` returns its schema (optional, if `tool_search.enabled`)
|
||||||
17. **LoopDetectionMiddleware** - Detects repeated tool-call loops; hard-stop responses clear both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer
|
17. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if `subagent_enabled`)
|
||||||
18. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
18. **LoopDetectionMiddleware** - Detects repeated tool-call loops; hard-stop responses clear both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer
|
||||||
|
19. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
||||||
|
|
||||||
### Configuration System
|
### Configuration System
|
||||||
|
|
||||||
@@ -348,6 +349,7 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
|
|||||||
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools)
|
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools)
|
||||||
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json
|
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json
|
||||||
- **Injection**: Enabled skills listed in agent system prompt with container paths
|
- **Injection**: Enabled skills listed in agent system prompt with container paths
|
||||||
|
- **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved channel commands (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`), disabled skills, and skills outside a custom agent's whitelist.
|
||||||
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
|
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
|
||||||
|
|
||||||
### Model Factory (`packages/harness/deerflow/models/factory.py`)
|
### Model Factory (`packages/harness/deerflow/models/factory.py`)
|
||||||
|
|||||||
@@ -18,3 +18,10 @@ KNOWN_CHANNEL_COMMANDS: frozenset[str] = frozenset(
|
|||||||
"/help",
|
"/help",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_known_channel_command(text: str) -> bool:
|
||||||
|
"""Return whether text starts with a registered channel control command."""
|
||||||
|
if not text.startswith("/"):
|
||||||
|
return False
|
||||||
|
return text.split(maxsplit=1)[0].lower() in KNOWN_CHANNEL_COMMANDS
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from typing import Any
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.channels.base import Channel
|
from app.channels.base import Channel
|
||||||
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
|
from app.channels.commands import is_known_channel_command
|
||||||
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -59,9 +59,7 @@ def _normalize_allowed_users(allowed_users: Any) -> set[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _is_dingtalk_command(text: str) -> bool:
|
def _is_dingtalk_command(text: str) -> bool:
|
||||||
if not text.startswith("/"):
|
return is_known_channel_command(text)
|
||||||
return False
|
|
||||||
return text.split(maxsplit=1)[0].lower() in KNOWN_CHANNEL_COMMANDS
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_text_from_rich_text(rich_text_list: list) -> str:
|
def _extract_text_from_rich_text(rich_text_list: list) -> str:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.channels.base import Channel
|
from app.channels.base import Channel
|
||||||
|
from app.channels.commands import is_known_channel_command
|
||||||
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -300,7 +301,7 @@ class DiscordChannel(Channel):
|
|||||||
|
|
||||||
# If this is a known active thread, process normally
|
# If this is a known active thread, process normally
|
||||||
if thread_id in self._active_thread_ids:
|
if thread_id in self._active_thread_ids:
|
||||||
msg_type = InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT
|
msg_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT
|
||||||
inbound = self._make_inbound(
|
inbound = self._make_inbound(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=str(message.author.id),
|
user_id=str(message.author.id),
|
||||||
@@ -407,7 +408,7 @@ class DiscordChannel(Channel):
|
|||||||
chat_id = channel_id
|
chat_id = channel_id
|
||||||
typing_target = message.channel # Type into the channel
|
typing_target = message.channel # Type into the channel
|
||||||
|
|
||||||
msg_type = InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT
|
msg_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT
|
||||||
inbound = self._make_inbound(
|
inbound = self._make_inbound(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=str(message.author.id),
|
user_id=str(message.author.id),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import time
|
|||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from app.channels.base import Channel
|
from app.channels.base import Channel
|
||||||
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
|
from app.channels.commands import is_known_channel_command
|
||||||
from app.channels.message_bus import (
|
from app.channels.message_bus import (
|
||||||
PENDING_CLARIFICATION_METADATA_KEY,
|
PENDING_CLARIFICATION_METADATA_KEY,
|
||||||
RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY,
|
RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY,
|
||||||
@@ -30,9 +30,7 @@ PENDING_CLARIFICATION_TTL_SECONDS = 30 * 60
|
|||||||
|
|
||||||
|
|
||||||
def _is_feishu_command(text: str) -> bool:
|
def _is_feishu_command(text: str) -> bool:
|
||||||
if not text.startswith("/"):
|
return is_known_channel_command(text)
|
||||||
return False
|
|
||||||
return text.split(maxsplit=1)[0].lower() in KNOWN_CHANNEL_COMMANDS
|
|
||||||
|
|
||||||
|
|
||||||
class FeishuChannel(Channel):
|
class FeishuChannel(Channel):
|
||||||
|
|||||||
+129
-15
@@ -8,6 +8,7 @@ import mimetypes
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable, Mapping
|
from collections.abc import Awaitable, Callable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -26,8 +27,13 @@ from app.channels.message_bus import (
|
|||||||
from app.channels.store import ChannelStore
|
from app.channels.store import ChannelStore
|
||||||
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
|
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
|
||||||
from app.gateway.internal_auth import create_internal_auth_headers
|
from app.gateway.internal_auth import create_internal_auth_headers
|
||||||
|
from deerflow.config.agents_config import load_agent_config
|
||||||
from deerflow.config.paths import make_safe_user_id
|
from deerflow.config.paths import make_safe_user_id
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
from deerflow.skills.slash import parse_slash_skill_reference
|
||||||
|
from deerflow.skills.storage import get_or_new_skill_storage
|
||||||
|
from deerflow.skills.storage.skill_storage import SkillStorage
|
||||||
|
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -124,6 +130,16 @@ class InvalidChannelSessionConfigError(ValueError):
|
|||||||
"""Raised when IM channel session overrides contain invalid agent config."""
|
"""Raised when IM channel session overrides contain invalid agent config."""
|
||||||
|
|
||||||
|
|
||||||
|
class SlashSkillCommandResolutionError(RuntimeError):
|
||||||
|
"""Raised when IM slash-skill command resolution cannot complete safely."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _SlashSkillCommandResolution:
|
||||||
|
route_to_chat: bool = False
|
||||||
|
failure_message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _is_thread_busy_error(exc: BaseException | None) -> bool:
|
def _is_thread_busy_error(exc: BaseException | None) -> bool:
|
||||||
if exc is None:
|
if exc is None:
|
||||||
return False
|
return False
|
||||||
@@ -410,6 +426,46 @@ def _format_artifact_text(artifacts: list[str]) -> str:
|
|||||||
_OUTPUTS_VIRTUAL_PREFIX = "/mnt/user-data/outputs/"
|
_OUTPUTS_VIRTUAL_PREFIX = "/mnt/user-data/outputs/"
|
||||||
|
|
||||||
|
|
||||||
|
def _unknown_command_reply(command: str | None = None) -> str:
|
||||||
|
available = " | ".join(sorted(KNOWN_CHANNEL_COMMANDS))
|
||||||
|
if command:
|
||||||
|
return f"Unknown command: /{command}. Available commands: {available}"
|
||||||
|
return f"Unknown command. Available commands: {available}"
|
||||||
|
|
||||||
|
|
||||||
|
def _human_input_message(content: str, *, original_content: str | None = None) -> dict[str, Any]:
|
||||||
|
message: dict[str, Any] = {"role": "human", "content": content}
|
||||||
|
if original_content is not None and original_content != content:
|
||||||
|
message["additional_kwargs"] = {ORIGINAL_USER_CONTENT_KEY: original_content}
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_slash_skill_command(
|
||||||
|
text: str,
|
||||||
|
available_skills: set[str] | None = None,
|
||||||
|
storage: SkillStorage | Callable[[], SkillStorage] | None = None,
|
||||||
|
) -> _SlashSkillCommandResolution | None:
|
||||||
|
reference = parse_slash_skill_reference(text)
|
||||||
|
if reference is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
resolved_storage = storage() if callable(storage) else storage or get_or_new_skill_storage()
|
||||||
|
skills = resolved_storage.load_skills(enabled_only=False)
|
||||||
|
|
||||||
|
skill = next((candidate for candidate in skills if candidate.name == reference.name), None)
|
||||||
|
if skill is None:
|
||||||
|
return None
|
||||||
|
if not skill.enabled:
|
||||||
|
return _SlashSkillCommandResolution(failure_message=f"Skill `/{reference.name}` is installed but disabled. Enable it before using slash activation.")
|
||||||
|
if available_skills is not None and reference.name not in available_skills:
|
||||||
|
return _SlashSkillCommandResolution(failure_message=f"Skill `/{reference.name}` is not available for this agent.")
|
||||||
|
|
||||||
|
return _SlashSkillCommandResolution(route_to_chat=True)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("[Manager] failed to resolve slash skill command")
|
||||||
|
raise SlashSkillCommandResolutionError("Failed to resolve slash skill command. Please check the skill configuration.") from exc
|
||||||
|
|
||||||
|
|
||||||
def _resolve_attachments(thread_id: str, artifacts: list[str]) -> list[ResolvedAttachment]:
|
def _resolve_attachments(thread_id: str, artifacts: list[str]) -> list[ResolvedAttachment]:
|
||||||
"""Resolve virtual artifact paths to host filesystem paths with metadata.
|
"""Resolve virtual artifact paths to host filesystem paths with metadata.
|
||||||
|
|
||||||
@@ -624,6 +680,7 @@ class ChannelManager:
|
|||||||
self._default_session = _as_dict(default_session)
|
self._default_session = _as_dict(default_session)
|
||||||
self._channel_sessions = dict(channel_sessions or {})
|
self._channel_sessions = dict(channel_sessions or {})
|
||||||
self._client = None # lazy init — langgraph_sdk async client
|
self._client = None # lazy init — langgraph_sdk async client
|
||||||
|
self._skill_storage: SkillStorage | None = None
|
||||||
self._csrf_token = generate_csrf_token()
|
self._csrf_token = generate_csrf_token()
|
||||||
self._semaphore: asyncio.Semaphore | None = None
|
self._semaphore: asyncio.Semaphore | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
@@ -696,6 +753,21 @@ class ChannelManager:
|
|||||||
|
|
||||||
return assistant_id, run_config, run_context
|
return assistant_id, run_config, run_context
|
||||||
|
|
||||||
|
def _resolve_available_skill_names(self, msg: InboundMessage) -> set[str] | None:
|
||||||
|
thread_id = self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id) or ""
|
||||||
|
_, _, run_context = self._resolve_run_params(msg, thread_id)
|
||||||
|
if run_context.get("is_bootstrap"):
|
||||||
|
return {"bootstrap"}
|
||||||
|
|
||||||
|
agent_name = run_context.get("agent_name")
|
||||||
|
if not isinstance(agent_name, str) or not agent_name.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
agent_config = load_agent_config(_normalize_custom_agent_name(agent_name))
|
||||||
|
if agent_config and agent_config.skills is not None:
|
||||||
|
return set(agent_config.skills)
|
||||||
|
return None
|
||||||
|
|
||||||
# -- LangGraph SDK client (lazy) ----------------------------------------
|
# -- LangGraph SDK client (lazy) ----------------------------------------
|
||||||
|
|
||||||
def _get_client(self):
|
def _get_client(self):
|
||||||
@@ -713,6 +785,11 @@ class ChannelManager:
|
|||||||
)
|
)
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
|
def _get_skill_storage(self) -> SkillStorage:
|
||||||
|
if self._skill_storage is None:
|
||||||
|
self._skill_storage = get_or_new_skill_storage()
|
||||||
|
return self._skill_storage
|
||||||
|
|
||||||
# -- lifecycle ---------------------------------------------------------
|
# -- lifecycle ---------------------------------------------------------
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
@@ -782,6 +859,14 @@ class ChannelManager:
|
|||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
await self._send_error(msg, str(exc))
|
await self._send_error(msg, str(exc))
|
||||||
|
except SlashSkillCommandResolutionError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Slash skill command resolution failed for %s (chat=%s): %s",
|
||||||
|
msg.channel_name,
|
||||||
|
msg.chat_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
await self._send_error(msg, str(exc))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Error handling message from %s (chat=%s)",
|
"Error handling message from %s (chat=%s)",
|
||||||
@@ -836,9 +921,11 @@ class ChannelManager:
|
|||||||
if extra_context:
|
if extra_context:
|
||||||
run_context.update(extra_context)
|
run_context.update(extra_context)
|
||||||
|
|
||||||
|
original_text = msg.text
|
||||||
uploaded = await _ingest_inbound_files(thread_id, msg)
|
uploaded = await _ingest_inbound_files(thread_id, msg)
|
||||||
if uploaded:
|
if uploaded:
|
||||||
msg.text = f"{_format_uploaded_files_block(uploaded)}\n\n{msg.text}".strip()
|
msg.text = f"{_format_uploaded_files_block(uploaded)}\n\n{msg.text}".strip()
|
||||||
|
human_message = _human_input_message(msg.text, original_content=original_text)
|
||||||
|
|
||||||
if self._channel_supports_streaming(msg.channel_name):
|
if self._channel_supports_streaming(msg.channel_name):
|
||||||
await self._handle_streaming_chat(
|
await self._handle_streaming_chat(
|
||||||
@@ -848,6 +935,7 @@ class ChannelManager:
|
|||||||
assistant_id,
|
assistant_id,
|
||||||
run_config,
|
run_config,
|
||||||
run_context,
|
run_context,
|
||||||
|
human_message,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -856,7 +944,7 @@ class ChannelManager:
|
|||||||
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": [human_message]},
|
||||||
config=run_config,
|
config=run_config,
|
||||||
context=run_context,
|
context=run_context,
|
||||||
multitask_strategy="reject",
|
multitask_strategy="reject",
|
||||||
@@ -909,6 +997,7 @@ class ChannelManager:
|
|||||||
assistant_id: str,
|
assistant_id: str,
|
||||||
run_config: dict[str, Any],
|
run_config: dict[str, Any],
|
||||||
run_context: dict[str, Any],
|
run_context: dict[str, Any],
|
||||||
|
human_message: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
logger.info("[Manager] invoking runs.stream(thread_id=%s, text=%r)", thread_id, msg.text[:100])
|
logger.info("[Manager] invoking runs.stream(thread_id=%s, text=%r)", thread_id, msg.text[:100])
|
||||||
|
|
||||||
@@ -924,7 +1013,7 @@ class ChannelManager:
|
|||||||
async for chunk in client.runs.stream(
|
async for chunk in client.runs.stream(
|
||||||
thread_id,
|
thread_id,
|
||||||
assistant_id,
|
assistant_id,
|
||||||
input={"messages": [{"role": "human", "content": msg.text}]},
|
input={"messages": [human_message]},
|
||||||
config=run_config,
|
config=run_config,
|
||||||
context=run_context,
|
context=run_context,
|
||||||
stream_mode=["messages-tuple", "values"],
|
stream_mode=["messages-tuple", "values"],
|
||||||
@@ -1011,11 +1100,20 @@ class ChannelManager:
|
|||||||
# -- command handling --------------------------------------------------
|
# -- command handling --------------------------------------------------
|
||||||
|
|
||||||
async def _handle_command(self, msg: InboundMessage) -> None:
|
async def _handle_command(self, msg: InboundMessage) -> None:
|
||||||
text = msg.text.strip()
|
raw_text = msg.text
|
||||||
|
text = raw_text.strip()
|
||||||
parts = text.split(maxsplit=1)
|
parts = text.split(maxsplit=1)
|
||||||
command = parts[0].lower().lstrip("/")
|
reply: str | None = None
|
||||||
|
if not parts:
|
||||||
|
command = None
|
||||||
|
reply = _unknown_command_reply()
|
||||||
|
else:
|
||||||
|
command = parts[0].lower().removeprefix("/")
|
||||||
|
|
||||||
if command == "bootstrap":
|
if reply is None and not raw_text.startswith("/"):
|
||||||
|
reply = _unknown_command_reply(command)
|
||||||
|
|
||||||
|
if reply is None and command == "bootstrap":
|
||||||
from dataclasses import replace as _dc_replace
|
from dataclasses import replace as _dc_replace
|
||||||
|
|
||||||
chat_text = parts[1] if len(parts) > 1 else "Initialize workspace"
|
chat_text = parts[1] if len(parts) > 1 else "Initialize workspace"
|
||||||
@@ -1023,7 +1121,7 @@ class ChannelManager:
|
|||||||
await self._handle_chat(chat_msg, extra_context={"is_bootstrap": True})
|
await self._handle_chat(chat_msg, extra_context={"is_bootstrap": True})
|
||||||
return
|
return
|
||||||
|
|
||||||
if command == "new":
|
if reply is None and command == "new":
|
||||||
# Create a new thread through Gateway
|
# Create a new thread through Gateway
|
||||||
client = self._get_client()
|
client = self._get_client()
|
||||||
thread = await client.threads.create()
|
thread = await client.threads.create()
|
||||||
@@ -1036,14 +1134,14 @@ class ChannelManager:
|
|||||||
user_id=msg.user_id,
|
user_id=msg.user_id,
|
||||||
)
|
)
|
||||||
reply = "New conversation started."
|
reply = "New conversation started."
|
||||||
elif command == "status":
|
elif reply is None and command == "status":
|
||||||
thread_id = self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id)
|
thread_id = self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id)
|
||||||
reply = f"Active thread: {thread_id}" if thread_id else "No active conversation."
|
reply = f"Active thread: {thread_id}" if thread_id else "No active conversation."
|
||||||
elif command == "models":
|
elif reply is None and command == "models":
|
||||||
reply = await self._fetch_gateway("/api/models", "models")
|
reply = await self._fetch_gateway("/api/models", "models")
|
||||||
elif command == "memory":
|
elif reply is None and command == "memory":
|
||||||
reply = await self._fetch_gateway("/api/memory", "memory")
|
reply = await self._fetch_gateway("/api/memory", "memory")
|
||||||
elif command == "help":
|
elif reply is None and command == "help":
|
||||||
reply = (
|
reply = (
|
||||||
"Available commands:\n"
|
"Available commands:\n"
|
||||||
"/bootstrap — Start a bootstrap session (enables agent setup)\n"
|
"/bootstrap — Start a bootstrap session (enables agent setup)\n"
|
||||||
@@ -1051,16 +1149,32 @@ class ChannelManager:
|
|||||||
"/status — Show current thread info\n"
|
"/status — Show current thread info\n"
|
||||||
"/models — List available models\n"
|
"/models — List available models\n"
|
||||||
"/memory — Show memory status\n"
|
"/memory — Show memory status\n"
|
||||||
|
"/<skill-name> <task> — Activate an enabled skill for one turn\n"
|
||||||
"/help — Show this help"
|
"/help — Show this help"
|
||||||
)
|
)
|
||||||
else:
|
elif reply is None:
|
||||||
available = " | ".join(sorted(KNOWN_CHANNEL_COMMANDS))
|
slash_resolution = await asyncio.to_thread(
|
||||||
reply = f"Unknown command: /{command}. Available commands: {available}"
|
lambda: _resolve_slash_skill_command(
|
||||||
|
raw_text,
|
||||||
|
self._resolve_available_skill_names(msg),
|
||||||
|
self._get_skill_storage,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if slash_resolution and slash_resolution.failure_message:
|
||||||
|
reply = slash_resolution.failure_message
|
||||||
|
elif slash_resolution and slash_resolution.route_to_chat:
|
||||||
|
from dataclasses import replace as _dc_replace
|
||||||
|
|
||||||
|
chat_msg = _dc_replace(msg, msg_type=InboundMessageType.CHAT)
|
||||||
|
await self._handle_chat(chat_msg)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
reply = _unknown_command_reply(command)
|
||||||
|
|
||||||
outbound = OutboundMessage(
|
outbound = OutboundMessage(
|
||||||
channel_name=msg.channel_name,
|
channel_name=msg.channel_name,
|
||||||
chat_id=msg.chat_id,
|
chat_id=msg.chat_id,
|
||||||
thread_id=self.store.get_thread_id(msg.channel_name, msg.chat_id) or "",
|
thread_id=self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id) or "",
|
||||||
text=reply,
|
text=reply,
|
||||||
thread_ts=msg.thread_ts,
|
thread_ts=msg.thread_ts,
|
||||||
metadata=_slim_metadata(msg.metadata),
|
metadata=_slim_metadata(msg.metadata),
|
||||||
@@ -1098,7 +1212,7 @@ class ChannelManager:
|
|||||||
outbound = OutboundMessage(
|
outbound = OutboundMessage(
|
||||||
channel_name=msg.channel_name,
|
channel_name=msg.channel_name,
|
||||||
chat_id=msg.chat_id,
|
chat_id=msg.chat_id,
|
||||||
thread_id=self.store.get_thread_id(msg.channel_name, msg.chat_id) or "",
|
thread_id=self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id) or "",
|
||||||
text=error_text,
|
text=error_text,
|
||||||
thread_ts=msg.thread_ts,
|
thread_ts=msg.thread_ts,
|
||||||
metadata=_slim_metadata(msg.metadata),
|
metadata=_slim_metadata(msg.metadata),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Any
|
|||||||
from markdown_to_mrkdwn import SlackMarkdownConverter
|
from markdown_to_mrkdwn import SlackMarkdownConverter
|
||||||
|
|
||||||
from app.channels.base import Channel
|
from app.channels.base import Channel
|
||||||
|
from app.channels.commands import is_known_channel_command
|
||||||
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -32,6 +33,20 @@ def _normalize_allowed_users(allowed_users: Any) -> set[str]:
|
|||||||
return {str(user_id) for user_id in values if str(user_id)}
|
return {str(user_id) for user_id in values if str(user_id)}
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_leading_slack_bot_mention(text: str, bot_user_id: str | None) -> str:
|
||||||
|
if not bot_user_id:
|
||||||
|
return text
|
||||||
|
if not text.startswith("<@"):
|
||||||
|
return text
|
||||||
|
end = text.find(">")
|
||||||
|
if end <= 2:
|
||||||
|
return text
|
||||||
|
mentioned_user_id = text[2:end].split("|", 1)[0].lstrip("!")
|
||||||
|
if mentioned_user_id != bot_user_id:
|
||||||
|
return text
|
||||||
|
return text[end + 1 :].lstrip()
|
||||||
|
|
||||||
|
|
||||||
class SlackChannel(Channel):
|
class SlackChannel(Channel):
|
||||||
"""Slack IM channel using Socket Mode (WebSocket, no public IP).
|
"""Slack IM channel using Socket Mode (WebSocket, no public IP).
|
||||||
|
|
||||||
@@ -49,6 +64,8 @@ class SlackChannel(Channel):
|
|||||||
self._web_client = None
|
self._web_client = None
|
||||||
self._loop: asyncio.AbstractEventLoop | None = None
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
self._allowed_users = _normalize_allowed_users(config.get("allowed_users", []))
|
self._allowed_users = _normalize_allowed_users(config.get("allowed_users", []))
|
||||||
|
configured_bot_user_id = config.get("bot_user_id")
|
||||||
|
self._bot_user_id = str(configured_bot_user_id).lstrip("@") if configured_bot_user_id else None
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
if self._running:
|
if self._running:
|
||||||
@@ -72,6 +89,17 @@ class SlackChannel(Channel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._web_client = WebClient(token=bot_token)
|
self._web_client = WebClient(token=bot_token)
|
||||||
|
if self._bot_user_id is None:
|
||||||
|
try:
|
||||||
|
auth_info = await asyncio.to_thread(self._web_client.auth_test)
|
||||||
|
user_id = auth_info.get("user_id") if isinstance(auth_info, dict) else None
|
||||||
|
if user_id is None:
|
||||||
|
auth_get = getattr(auth_info, "get", None)
|
||||||
|
user_id = auth_get("user_id") if callable(auth_get) else None
|
||||||
|
if isinstance(user_id, str) and user_id:
|
||||||
|
self._bot_user_id = user_id
|
||||||
|
except Exception:
|
||||||
|
logger.warning("[Slack] failed to resolve bot user id; app mention text may include the bot mention", exc_info=True)
|
||||||
self._socket_client = SocketModeClient(
|
self._socket_client = SocketModeClient(
|
||||||
app_token=app_token,
|
app_token=app_token,
|
||||||
web_client=self._web_client,
|
web_client=self._web_client,
|
||||||
@@ -210,6 +238,12 @@ class SlackChannel(Channel):
|
|||||||
if event_type != "events_api":
|
if event_type != "events_api":
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if self._bot_user_id is None:
|
||||||
|
authorization = next((item for item in req.payload.get("authorizations", []) if isinstance(item, dict)), None)
|
||||||
|
user_id = authorization.get("user_id") if authorization else None
|
||||||
|
if isinstance(user_id, str) and user_id:
|
||||||
|
self._bot_user_id = user_id
|
||||||
|
|
||||||
event = req.payload.get("event", {})
|
event = req.payload.get("event", {})
|
||||||
etype = event.get("type", "")
|
etype = event.get("type", "")
|
||||||
|
|
||||||
@@ -233,13 +267,15 @@ class SlackChannel(Channel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
text = event.get("text", "").strip()
|
text = event.get("text", "").strip()
|
||||||
|
if event.get("type") == "app_mention":
|
||||||
|
text = _strip_leading_slack_bot_mention(text, self._bot_user_id)
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
|
|
||||||
channel_id = event.get("channel", "")
|
channel_id = event.get("channel", "")
|
||||||
thread_ts = event.get("thread_ts") or event.get("ts", "")
|
thread_ts = event.get("thread_ts") or event.get("ts", "")
|
||||||
|
|
||||||
if text.startswith("/"):
|
if is_known_channel_command(text):
|
||||||
msg_type = InboundMessageType.COMMAND
|
msg_type = InboundMessageType.COMMAND
|
||||||
else:
|
else:
|
||||||
msg_type = InboundMessageType.CHAT
|
msg_type = InboundMessageType.CHAT
|
||||||
|
|||||||
@@ -60,12 +60,17 @@ class TelegramChannel(Channel):
|
|||||||
|
|
||||||
# Command handlers
|
# Command handlers
|
||||||
app.add_handler(CommandHandler("start", self._cmd_start))
|
app.add_handler(CommandHandler("start", self._cmd_start))
|
||||||
|
app.add_handler(CommandHandler("bootstrap", self._cmd_generic))
|
||||||
app.add_handler(CommandHandler("new", self._cmd_generic))
|
app.add_handler(CommandHandler("new", self._cmd_generic))
|
||||||
app.add_handler(CommandHandler("status", self._cmd_generic))
|
app.add_handler(CommandHandler("status", self._cmd_generic))
|
||||||
app.add_handler(CommandHandler("models", self._cmd_generic))
|
app.add_handler(CommandHandler("models", self._cmd_generic))
|
||||||
app.add_handler(CommandHandler("memory", self._cmd_generic))
|
app.add_handler(CommandHandler("memory", self._cmd_generic))
|
||||||
app.add_handler(CommandHandler("help", self._cmd_generic))
|
app.add_handler(CommandHandler("help", self._cmd_generic))
|
||||||
|
|
||||||
|
# Slash skill commands are dynamic and cannot all be pre-registered
|
||||||
|
# with Telegram, so route unknown slash commands through chat handling.
|
||||||
|
app.add_handler(MessageHandler(filters.TEXT & filters.COMMAND, self._on_text))
|
||||||
|
|
||||||
# General message handler
|
# General message handler
|
||||||
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._on_text))
|
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._on_text))
|
||||||
|
|
||||||
@@ -228,6 +233,33 @@ class TelegramChannel(Channel):
|
|||||||
return True
|
return True
|
||||||
return user_id in self._allowed_users
|
return user_id in self._allowed_users
|
||||||
|
|
||||||
|
def _get_bot_username(self, context) -> str | None:
|
||||||
|
bot = getattr(context, "bot", None)
|
||||||
|
username = getattr(bot, "username", None)
|
||||||
|
if not username and self._application is not None:
|
||||||
|
username = getattr(getattr(self._application, "bot", None), "username", None)
|
||||||
|
return str(username) if username else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strip_bot_username_from_leading_command(text: str, bot_username: str | None) -> str:
|
||||||
|
username = (bot_username or "").lstrip("@").lower()
|
||||||
|
if not username or not text.startswith("/"):
|
||||||
|
return text
|
||||||
|
|
||||||
|
parts = text.split(maxsplit=1)
|
||||||
|
command_token = parts[0]
|
||||||
|
if "@" not in command_token:
|
||||||
|
return text
|
||||||
|
|
||||||
|
command_name, addressed_username = command_token[1:].rsplit("@", 1)
|
||||||
|
if not command_name or addressed_username.lower() != username:
|
||||||
|
return text
|
||||||
|
|
||||||
|
normalized = f"/{command_name}"
|
||||||
|
if len(parts) > 1:
|
||||||
|
normalized = f"{normalized} {parts[1]}"
|
||||||
|
return normalized
|
||||||
|
|
||||||
async def _cmd_start(self, update, context) -> None:
|
async def _cmd_start(self, update, context) -> None:
|
||||||
"""Handle /start command."""
|
"""Handle /start command."""
|
||||||
if not self._check_user(update.effective_user.id):
|
if not self._check_user(update.effective_user.id):
|
||||||
@@ -243,7 +275,7 @@ class TelegramChannel(Channel):
|
|||||||
if not self._check_user(update.effective_user.id):
|
if not self._check_user(update.effective_user.id):
|
||||||
return
|
return
|
||||||
|
|
||||||
text = update.message.text
|
text = self._strip_bot_username_from_leading_command(update.message.text.strip(), self._get_bot_username(context))
|
||||||
chat_id = str(update.effective_chat.id)
|
chat_id = str(update.effective_chat.id)
|
||||||
user_id = str(update.effective_user.id)
|
user_id = str(update.effective_user.id)
|
||||||
msg_id = str(update.message.message_id)
|
msg_id = str(update.message.message_id)
|
||||||
@@ -279,7 +311,7 @@ class TelegramChannel(Channel):
|
|||||||
if not self._check_user(update.effective_user.id):
|
if not self._check_user(update.effective_user.id):
|
||||||
return
|
return
|
||||||
|
|
||||||
text = update.message.text.strip()
|
text = self._strip_bot_username_from_leading_command(update.message.text.strip(), self._get_bot_username(context))
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from cryptography.hazmat.primitives import padding
|
|||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
|
||||||
from app.channels.base import Channel
|
from app.channels.base import Channel
|
||||||
|
from app.channels.commands import is_known_channel_command
|
||||||
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -620,7 +621,7 @@ class WechatChannel(Channel):
|
|||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=chat_id,
|
user_id=chat_id,
|
||||||
text=text,
|
text=text,
|
||||||
msg_type=InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT,
|
msg_type=InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT,
|
||||||
thread_ts=thread_ts,
|
thread_ts=thread_ts,
|
||||||
files=files,
|
files=files,
|
||||||
metadata={
|
metadata={
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from collections.abc import Awaitable, Callable
|
|||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from app.channels.base import Channel
|
from app.channels.base import Channel
|
||||||
|
from app.channels.commands import is_known_channel_command
|
||||||
from app.channels.message_bus import (
|
from app.channels.message_bus import (
|
||||||
InboundMessageType,
|
InboundMessageType,
|
||||||
MessageBus,
|
MessageBus,
|
||||||
@@ -270,7 +271,7 @@ class WeComChannel(Channel):
|
|||||||
|
|
||||||
user_id = (body.get("from") or {}).get("userid")
|
user_id = (body.get("from") or {}).get("userid")
|
||||||
|
|
||||||
inbound_type = InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT
|
inbound_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT
|
||||||
inbound = self._make_inbound(
|
inbound = self._make_inbound(
|
||||||
chat_id=user_id, # keep user's conversation in memory
|
chat_id=user_id, # keep user's conversation in memory
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ from deerflow.tracing import build_tracing_callbacks
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BOOTSTRAP_SKILL_NAMES = {"bootstrap"}
|
||||||
|
|
||||||
|
|
||||||
def _get_runtime_config(config: RunnableConfig) -> dict:
|
def _get_runtime_config(config: RunnableConfig) -> dict:
|
||||||
"""Merge legacy configurable options with LangGraph runtime context."""
|
"""Merge legacy configurable options with LangGraph runtime context."""
|
||||||
@@ -271,6 +273,7 @@ def build_middlewares(
|
|||||||
agent_name: str | None = None,
|
agent_name: str | None = None,
|
||||||
custom_middlewares: list[AgentMiddleware] | None = None,
|
custom_middlewares: list[AgentMiddleware] | None = None,
|
||||||
*,
|
*,
|
||||||
|
available_skills: set[str] | None = None,
|
||||||
app_config: AppConfig | None = None,
|
app_config: AppConfig | None = None,
|
||||||
deferred_setup=None,
|
deferred_setup=None,
|
||||||
):
|
):
|
||||||
@@ -302,6 +305,13 @@ def build_middlewares(
|
|||||||
|
|
||||||
middlewares.append(DynamicContextMiddleware(agent_name=agent_name, app_config=resolved_app_config))
|
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
|
# Add summarization middleware if enabled
|
||||||
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
|
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
|
||||||
if summarization_middleware is not None:
|
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:
|
def _available_skill_names(agent_config, is_bootstrap: bool) -> set[str] | None:
|
||||||
if is_bootstrap:
|
if is_bootstrap:
|
||||||
return {"bootstrap"}
|
return set(_BOOTSTRAP_SKILL_NAMES)
|
||||||
if agent_config and agent_config.skills is not None:
|
if agent_config and agent_config.skills is not None:
|
||||||
return set(agent_config.skills)
|
return set(agent_config.skills)
|
||||||
return None
|
return None
|
||||||
@@ -475,17 +485,25 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
|
|
||||||
if is_bootstrap:
|
if is_bootstrap:
|
||||||
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
|
# 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]
|
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)
|
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)
|
final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled)
|
||||||
return create_agent(
|
return create_agent(
|
||||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False),
|
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False),
|
||||||
tools=final_tools,
|
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(
|
system_prompt=apply_prompt_template(
|
||||||
subagent_enabled=subagent_enabled,
|
subagent_enabled=subagent_enabled,
|
||||||
max_concurrent_subagents=max_concurrent_subagents,
|
max_concurrent_subagents=max_concurrent_subagents,
|
||||||
available_skills=set(["bootstrap"]),
|
available_skills=set(_BOOTSTRAP_SKILL_NAMES),
|
||||||
app_config=resolved_app_config,
|
app_config=resolved_app_config,
|
||||||
deferred_names=setup.deferred_names,
|
deferred_names=setup.deferred_names,
|
||||||
),
|
),
|
||||||
@@ -502,12 +520,19 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
return create_agent(
|
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),
|
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,
|
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(
|
system_prompt=apply_prompt_template(
|
||||||
subagent_enabled=subagent_enabled,
|
subagent_enabled=subagent_enabled,
|
||||||
max_concurrent_subagents=max_concurrent_subagents,
|
max_concurrent_subagents=max_concurrent_subagents,
|
||||||
agent_name=agent_name,
|
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,
|
app_config=resolved_app_config,
|
||||||
deferred_names=setup.deferred_names,
|
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
|
4. Load referenced resources only when needed during execution
|
||||||
5. Follow the skill's instructions precisely
|
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}
|
**Skills are located at:** {container_base_path}
|
||||||
{skill_evolution_section}
|
{skill_evolution_section}
|
||||||
{skills_list}
|
{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.config.paths import Paths, get_paths
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
from deerflow.utils.file_conversion import extract_outline
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -265,6 +266,8 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
|||||||
|
|
||||||
# Extract original content - handle both string and list formats
|
# Extract original content - handle both string and list formats
|
||||||
original_content = last_message.content
|
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):
|
if isinstance(original_content, str):
|
||||||
# Simple case: string content, just prepend files message
|
# Simple case: string content, just prepend files message
|
||||||
updated_content = f"{files_message}\n\n{original_content}"
|
updated_content = f"{files_message}\n\n{original_content}"
|
||||||
@@ -285,7 +288,7 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
|||||||
content=updated_content,
|
content=updated_content,
|
||||||
id=last_message.id,
|
id=last_message.id,
|
||||||
name=last_message.name,
|
name=last_message.name,
|
||||||
additional_kwargs=last_message.additional_kwargs,
|
additional_kwargs=additional_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
messages[last_message_index] = updated_message
|
messages[last_message_index] = updated_message
|
||||||
|
|||||||
@@ -247,7 +247,15 @@ class DeerFlowClient:
|
|||||||
# Attaching them again on the model would emit duplicate spans.
|
# Attaching them again on the model would emit duplicate spans.
|
||||||
"model": create_chat_model(name=model_name, thinking_enabled=thinking_enabled, attach_tracing=False),
|
"model": create_chat_model(name=model_name, thinking_enabled=thinking_enabled, attach_tracing=False),
|
||||||
"tools": final_tools,
|
"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(
|
"system_prompt": apply_prompt_template(
|
||||||
subagent_enabled=subagent_enabled,
|
subagent_enabled=subagent_enabled,
|
||||||
max_concurrent_subagents=max_concurrent_subagents,
|
max_concurrent_subagents=max_concurrent_subagents,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -21,6 +21,42 @@ from app.channels.message_bus import (
|
|||||||
ResolvedAttachment,
|
ResolvedAttachment,
|
||||||
)
|
)
|
||||||
from app.channels.store import ChannelStore
|
from app.channels.store import ChannelStore
|
||||||
|
from deerflow.skills.types import Skill, SkillCategory
|
||||||
|
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
|
||||||
|
|
||||||
|
|
||||||
|
def test_known_channel_command_detection_only_matches_control_commands():
|
||||||
|
from app.channels.commands import is_known_channel_command
|
||||||
|
|
||||||
|
assert is_known_channel_command("/new")
|
||||||
|
assert is_known_channel_command("/HELP now")
|
||||||
|
assert not is_known_channel_command("/mnt/user-data/uploads/report.pdf")
|
||||||
|
assert not is_known_channel_command("/data-analysis analyze uploads/foo.csv")
|
||||||
|
assert not is_known_channel_command(" /new")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_channel_skill(tmp_path: Path, name: str, *, enabled: bool = True) -> Skill:
|
||||||
|
skill_dir = tmp_path / name
|
||||||
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
skill_file = skill_dir / "SKILL.md"
|
||||||
|
skill_file.write_text(f"# {name}\n", encoding="utf-8")
|
||||||
|
return Skill(
|
||||||
|
name=name,
|
||||||
|
description=f"Description for {name}",
|
||||||
|
license="MIT",
|
||||||
|
skill_dir=skill_dir,
|
||||||
|
skill_file=skill_file,
|
||||||
|
relative_path=Path(name),
|
||||||
|
category=SkillCategory.CUSTOM,
|
||||||
|
enabled=enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_channel_skill_storage(skills: list[Skill]):
|
||||||
|
return SimpleNamespace(
|
||||||
|
load_skills=lambda *, enabled_only: [skill for skill in skills if skill.enabled] if enabled_only else skills,
|
||||||
|
get_container_root=lambda: "/mnt/skills",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _run(coro):
|
def _run(coro):
|
||||||
@@ -1334,6 +1370,496 @@ class TestChannelManager:
|
|||||||
|
|
||||||
_run(go())
|
_run(go())
|
||||||
|
|
||||||
|
def test_handle_command_blank_text_is_reported_without_running_agent(self):
|
||||||
|
from app.channels.manager import ChannelManager
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||||
|
manager = ChannelManager(bus=bus, store=store)
|
||||||
|
|
||||||
|
mock_client = _make_mock_langgraph_client()
|
||||||
|
manager._client = mock_client
|
||||||
|
|
||||||
|
outbound_received = []
|
||||||
|
|
||||||
|
async def capture_outbound(msg):
|
||||||
|
outbound_received.append(msg)
|
||||||
|
|
||||||
|
bus.subscribe_outbound(capture_outbound)
|
||||||
|
await manager.start()
|
||||||
|
|
||||||
|
inbound = InboundMessage(
|
||||||
|
channel_name="test",
|
||||||
|
chat_id="chat1",
|
||||||
|
user_id="user1",
|
||||||
|
text=" ",
|
||||||
|
msg_type=InboundMessageType.COMMAND,
|
||||||
|
)
|
||||||
|
await bus.publish_inbound(inbound)
|
||||||
|
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||||
|
await manager.stop()
|
||||||
|
|
||||||
|
mock_client.runs.wait.assert_not_called()
|
||||||
|
assert outbound_received[0].text.startswith("Unknown command.")
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_handle_command_rejects_multi_slash_control_command(self):
|
||||||
|
from app.channels.manager import ChannelManager
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||||
|
manager = ChannelManager(bus=bus, store=store)
|
||||||
|
|
||||||
|
mock_client = _make_mock_langgraph_client()
|
||||||
|
manager._client = mock_client
|
||||||
|
|
||||||
|
outbound_received = []
|
||||||
|
|
||||||
|
async def capture_outbound(msg):
|
||||||
|
outbound_received.append(msg)
|
||||||
|
|
||||||
|
bus.subscribe_outbound(capture_outbound)
|
||||||
|
await manager.start()
|
||||||
|
|
||||||
|
inbound = InboundMessage(
|
||||||
|
channel_name="test",
|
||||||
|
chat_id="chat1",
|
||||||
|
user_id="user1",
|
||||||
|
text="//help",
|
||||||
|
msg_type=InboundMessageType.COMMAND,
|
||||||
|
)
|
||||||
|
await bus.publish_inbound(inbound)
|
||||||
|
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||||
|
await manager.stop()
|
||||||
|
|
||||||
|
mock_client.runs.wait.assert_not_called()
|
||||||
|
assert outbound_received[0].text.startswith("Unknown command: //help.")
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_handle_command_requires_control_command_at_start(self):
|
||||||
|
from app.channels.manager import ChannelManager
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||||
|
manager = ChannelManager(bus=bus, store=store)
|
||||||
|
|
||||||
|
mock_client = _make_mock_langgraph_client(thread_id="new-thread-456")
|
||||||
|
manager._client = mock_client
|
||||||
|
|
||||||
|
outbound_received = []
|
||||||
|
|
||||||
|
async def capture_outbound(msg):
|
||||||
|
outbound_received.append(msg)
|
||||||
|
|
||||||
|
bus.subscribe_outbound(capture_outbound)
|
||||||
|
await manager.start()
|
||||||
|
|
||||||
|
inbound = InboundMessage(
|
||||||
|
channel_name="test",
|
||||||
|
chat_id="chat1",
|
||||||
|
user_id="user1",
|
||||||
|
text=" /new",
|
||||||
|
msg_type=InboundMessageType.COMMAND,
|
||||||
|
)
|
||||||
|
await bus.publish_inbound(inbound)
|
||||||
|
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||||
|
await manager.stop()
|
||||||
|
|
||||||
|
mock_client.threads.create.assert_not_called()
|
||||||
|
assert store.get_thread_id("test", "chat1") is None
|
||||||
|
assert outbound_received[0].text.startswith("Unknown command: /new.")
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_handle_command_outbound_thread_id_uses_topic_thread(self):
|
||||||
|
from app.channels.manager import ChannelManager
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||||
|
manager = ChannelManager(bus=bus, store=store)
|
||||||
|
store.set_thread_id("test", "chat1", "base-thread")
|
||||||
|
store.set_thread_id("test", "chat1", "topic-thread", topic_id="topic-1")
|
||||||
|
|
||||||
|
outbound_received = []
|
||||||
|
|
||||||
|
async def capture_outbound(msg):
|
||||||
|
outbound_received.append(msg)
|
||||||
|
|
||||||
|
bus.subscribe_outbound(capture_outbound)
|
||||||
|
await manager.start()
|
||||||
|
|
||||||
|
inbound = InboundMessage(
|
||||||
|
channel_name="test",
|
||||||
|
chat_id="chat1",
|
||||||
|
user_id="user1",
|
||||||
|
text="/status",
|
||||||
|
msg_type=InboundMessageType.COMMAND,
|
||||||
|
topic_id="topic-1",
|
||||||
|
)
|
||||||
|
await bus.publish_inbound(inbound)
|
||||||
|
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||||
|
await manager.stop()
|
||||||
|
|
||||||
|
assert outbound_received[0].text == "Active thread: topic-thread"
|
||||||
|
assert outbound_received[0].thread_id == "topic-thread"
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_handle_command_slash_skill_routes_to_chat(self, tmp_path):
|
||||||
|
from app.channels.manager import ChannelManager
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||||
|
manager = ChannelManager(bus=bus, store=store)
|
||||||
|
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis")])
|
||||||
|
|
||||||
|
mock_client = _make_mock_langgraph_client()
|
||||||
|
manager._client = mock_client
|
||||||
|
|
||||||
|
outbound_received = []
|
||||||
|
|
||||||
|
async def capture_outbound(msg):
|
||||||
|
outbound_received.append(msg)
|
||||||
|
|
||||||
|
bus.subscribe_outbound(capture_outbound)
|
||||||
|
await manager.start()
|
||||||
|
|
||||||
|
inbound = InboundMessage(
|
||||||
|
channel_name="test",
|
||||||
|
chat_id="chat1",
|
||||||
|
user_id="user1",
|
||||||
|
text="/data-analysis analyze uploads/foo.csv",
|
||||||
|
msg_type=InboundMessageType.COMMAND,
|
||||||
|
)
|
||||||
|
await bus.publish_inbound(inbound)
|
||||||
|
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||||
|
await manager.stop()
|
||||||
|
|
||||||
|
mock_client.runs.wait.assert_called_once()
|
||||||
|
call_args = mock_client.runs.wait.call_args
|
||||||
|
assert call_args[1]["input"]["messages"][0]["content"] == "/data-analysis analyze uploads/foo.csv"
|
||||||
|
assert outbound_received[0].text == "Hello from agent!"
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_handle_command_slash_skill_with_attachment_preserves_original_content(self, monkeypatch, tmp_path):
|
||||||
|
from app.channels.manager import ChannelManager
|
||||||
|
|
||||||
|
async def fake_ingest(thread_id, msg):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"filename": "report.pdf",
|
||||||
|
"size": 12,
|
||||||
|
"path": "/mnt/user-data/uploads/report.pdf",
|
||||||
|
"is_image": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.channels.manager._ingest_inbound_files", fake_ingest)
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||||
|
manager = ChannelManager(bus=bus, store=store)
|
||||||
|
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis")])
|
||||||
|
|
||||||
|
mock_client = _make_mock_langgraph_client()
|
||||||
|
manager._client = mock_client
|
||||||
|
|
||||||
|
outbound_received = []
|
||||||
|
|
||||||
|
async def capture_outbound(msg):
|
||||||
|
outbound_received.append(msg)
|
||||||
|
|
||||||
|
bus.subscribe_outbound(capture_outbound)
|
||||||
|
await manager.start()
|
||||||
|
|
||||||
|
original_text = "/data-analysis analyze report.pdf"
|
||||||
|
inbound = InboundMessage(
|
||||||
|
channel_name="test",
|
||||||
|
chat_id="chat1",
|
||||||
|
user_id="user1",
|
||||||
|
text=original_text,
|
||||||
|
files=[{"filename": "report.pdf"}],
|
||||||
|
msg_type=InboundMessageType.COMMAND,
|
||||||
|
)
|
||||||
|
await bus.publish_inbound(inbound)
|
||||||
|
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||||
|
await manager.stop()
|
||||||
|
|
||||||
|
mock_client.runs.wait.assert_called_once()
|
||||||
|
human_message = mock_client.runs.wait.call_args[1]["input"]["messages"][0]
|
||||||
|
assert human_message["content"].startswith("<uploaded_files>")
|
||||||
|
assert original_text in human_message["content"]
|
||||||
|
assert human_message["additional_kwargs"][ORIGINAL_USER_CONTENT_KEY] == original_text
|
||||||
|
assert outbound_received[0].text == "Hello from agent!"
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_streaming_slash_skill_with_attachment_preserves_original_content(self, monkeypatch, tmp_path):
|
||||||
|
from app.channels.manager import ChannelManager
|
||||||
|
|
||||||
|
async def fake_ingest(thread_id, msg):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"filename": "report.pdf",
|
||||||
|
"size": 12,
|
||||||
|
"path": "/mnt/user-data/uploads/report.pdf",
|
||||||
|
"is_image": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.channels.manager._ingest_inbound_files", fake_ingest)
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||||
|
manager = ChannelManager(bus=bus, store=store)
|
||||||
|
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis")])
|
||||||
|
|
||||||
|
mock_client = _make_mock_langgraph_client()
|
||||||
|
mock_client.runs.stream = MagicMock(
|
||||||
|
return_value=_make_async_iterator(
|
||||||
|
[
|
||||||
|
_make_stream_part(
|
||||||
|
"values",
|
||||||
|
{"messages": [{"type": "ai", "content": "streamed response"}]},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
manager._client = mock_client
|
||||||
|
|
||||||
|
outbound_received = []
|
||||||
|
|
||||||
|
async def capture_outbound(msg):
|
||||||
|
outbound_received.append(msg)
|
||||||
|
|
||||||
|
bus.subscribe_outbound(capture_outbound)
|
||||||
|
await manager.start()
|
||||||
|
|
||||||
|
original_text = "/data-analysis analyze report.pdf"
|
||||||
|
inbound = InboundMessage(
|
||||||
|
channel_name="feishu",
|
||||||
|
chat_id="chat1",
|
||||||
|
user_id="user1",
|
||||||
|
text=original_text,
|
||||||
|
files=[{"filename": "report.pdf"}],
|
||||||
|
msg_type=InboundMessageType.COMMAND,
|
||||||
|
)
|
||||||
|
await bus.publish_inbound(inbound)
|
||||||
|
await _wait_for(lambda: any(message.is_final for message in outbound_received))
|
||||||
|
await manager.stop()
|
||||||
|
|
||||||
|
mock_client.runs.stream.assert_called_once()
|
||||||
|
human_message = mock_client.runs.stream.call_args[1]["input"]["messages"][0]
|
||||||
|
assert human_message["content"].startswith("<uploaded_files>")
|
||||||
|
assert original_text in human_message["content"]
|
||||||
|
assert human_message["additional_kwargs"][ORIGINAL_USER_CONTENT_KEY] == original_text
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_handle_command_slash_skill_requires_command_at_start(self, tmp_path):
|
||||||
|
from app.channels.manager import ChannelManager
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||||
|
manager = ChannelManager(bus=bus, store=store)
|
||||||
|
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis")])
|
||||||
|
|
||||||
|
mock_client = _make_mock_langgraph_client()
|
||||||
|
manager._client = mock_client
|
||||||
|
|
||||||
|
outbound_received = []
|
||||||
|
|
||||||
|
async def capture_outbound(msg):
|
||||||
|
outbound_received.append(msg)
|
||||||
|
|
||||||
|
bus.subscribe_outbound(capture_outbound)
|
||||||
|
await manager.start()
|
||||||
|
|
||||||
|
inbound = InboundMessage(
|
||||||
|
channel_name="test",
|
||||||
|
chat_id="chat1",
|
||||||
|
user_id="user1",
|
||||||
|
text=" /data-analysis analyze uploads/foo.csv",
|
||||||
|
msg_type=InboundMessageType.COMMAND,
|
||||||
|
)
|
||||||
|
await bus.publish_inbound(inbound)
|
||||||
|
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||||
|
await manager.stop()
|
||||||
|
|
||||||
|
mock_client.runs.wait.assert_not_called()
|
||||||
|
assert outbound_received[0].text.startswith("Unknown command: /data-analysis.")
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_handle_command_slash_skill_respects_custom_agent_skill_whitelist(self, monkeypatch, tmp_path):
|
||||||
|
from app.channels.manager import ChannelManager
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.channels.manager.load_agent_config", lambda name: SimpleNamespace(skills=["frontend-design"]))
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||||
|
manager = ChannelManager(
|
||||||
|
bus=bus,
|
||||||
|
store=store,
|
||||||
|
default_session={"assistant_id": "analyst-agent"},
|
||||||
|
)
|
||||||
|
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis")])
|
||||||
|
|
||||||
|
mock_client = _make_mock_langgraph_client()
|
||||||
|
manager._client = mock_client
|
||||||
|
|
||||||
|
outbound_received = []
|
||||||
|
|
||||||
|
async def capture_outbound(msg):
|
||||||
|
outbound_received.append(msg)
|
||||||
|
|
||||||
|
bus.subscribe_outbound(capture_outbound)
|
||||||
|
await manager.start()
|
||||||
|
|
||||||
|
inbound = InboundMessage(
|
||||||
|
channel_name="test",
|
||||||
|
chat_id="chat1",
|
||||||
|
user_id="user1",
|
||||||
|
text="/data-analysis analyze uploads/foo.csv",
|
||||||
|
msg_type=InboundMessageType.COMMAND,
|
||||||
|
)
|
||||||
|
await bus.publish_inbound(inbound)
|
||||||
|
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||||
|
await manager.stop()
|
||||||
|
|
||||||
|
mock_client.runs.wait.assert_not_called()
|
||||||
|
assert outbound_received[0].text == "Skill `/data-analysis` is not available for this agent."
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_handle_command_slash_skill_reports_disabled_skill(self, tmp_path):
|
||||||
|
from app.channels.manager import ChannelManager
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||||
|
manager = ChannelManager(bus=bus, store=store)
|
||||||
|
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis", enabled=False)])
|
||||||
|
|
||||||
|
mock_client = _make_mock_langgraph_client()
|
||||||
|
manager._client = mock_client
|
||||||
|
|
||||||
|
outbound_received = []
|
||||||
|
|
||||||
|
async def capture_outbound(msg):
|
||||||
|
outbound_received.append(msg)
|
||||||
|
|
||||||
|
bus.subscribe_outbound(capture_outbound)
|
||||||
|
await manager.start()
|
||||||
|
|
||||||
|
inbound = InboundMessage(
|
||||||
|
channel_name="test",
|
||||||
|
chat_id="chat1",
|
||||||
|
user_id="user1",
|
||||||
|
text="/data-analysis analyze uploads/foo.csv",
|
||||||
|
msg_type=InboundMessageType.COMMAND,
|
||||||
|
)
|
||||||
|
await bus.publish_inbound(inbound)
|
||||||
|
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||||
|
await manager.stop()
|
||||||
|
|
||||||
|
mock_client.runs.wait.assert_not_called()
|
||||||
|
assert outbound_received[0].text == "Skill `/data-analysis` is installed but disabled. Enable it before using slash activation."
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_handle_command_uninstalled_slash_skill_stays_unknown_command(self, tmp_path):
|
||||||
|
from app.channels.manager import ChannelManager
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||||
|
manager = ChannelManager(bus=bus, store=store)
|
||||||
|
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "frontend-design")])
|
||||||
|
|
||||||
|
mock_client = _make_mock_langgraph_client()
|
||||||
|
manager._client = mock_client
|
||||||
|
|
||||||
|
outbound_received = []
|
||||||
|
|
||||||
|
async def capture_outbound(msg):
|
||||||
|
outbound_received.append(msg)
|
||||||
|
|
||||||
|
bus.subscribe_outbound(capture_outbound)
|
||||||
|
await manager.start()
|
||||||
|
|
||||||
|
inbound = InboundMessage(
|
||||||
|
channel_name="test",
|
||||||
|
chat_id="chat1",
|
||||||
|
user_id="user1",
|
||||||
|
text="/data-analysis analyze uploads/foo.csv",
|
||||||
|
msg_type=InboundMessageType.COMMAND,
|
||||||
|
)
|
||||||
|
await bus.publish_inbound(inbound)
|
||||||
|
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||||
|
await manager.stop()
|
||||||
|
|
||||||
|
mock_client.runs.wait.assert_not_called()
|
||||||
|
assert outbound_received[0].text.startswith("Unknown command: /data-analysis.")
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_handle_command_slash_skill_resolution_error_is_reported(self, monkeypatch):
|
||||||
|
from app.channels.manager import ChannelManager, SlashSkillCommandResolutionError
|
||||||
|
|
||||||
|
def fail_resolution(text, available_skills=None, storage=None):
|
||||||
|
raise SlashSkillCommandResolutionError("Failed to resolve slash skill command. Please check the skill configuration.")
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.channels.manager._resolve_slash_skill_command", fail_resolution)
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||||
|
manager = ChannelManager(bus=bus, store=store)
|
||||||
|
store.set_thread_id("test", "chat1", "base-thread")
|
||||||
|
store.set_thread_id("test", "chat1", "topic-thread", topic_id="topic-1")
|
||||||
|
|
||||||
|
mock_client = _make_mock_langgraph_client()
|
||||||
|
manager._client = mock_client
|
||||||
|
|
||||||
|
outbound_received = []
|
||||||
|
|
||||||
|
async def capture_outbound(msg):
|
||||||
|
outbound_received.append(msg)
|
||||||
|
|
||||||
|
bus.subscribe_outbound(capture_outbound)
|
||||||
|
await manager.start()
|
||||||
|
|
||||||
|
inbound = InboundMessage(
|
||||||
|
channel_name="test",
|
||||||
|
chat_id="chat1",
|
||||||
|
user_id="user1",
|
||||||
|
text="/data-analysis analyze uploads/foo.csv",
|
||||||
|
msg_type=InboundMessageType.COMMAND,
|
||||||
|
topic_id="topic-1",
|
||||||
|
)
|
||||||
|
await bus.publish_inbound(inbound)
|
||||||
|
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||||
|
await manager.stop()
|
||||||
|
|
||||||
|
mock_client.runs.wait.assert_not_called()
|
||||||
|
assert outbound_received[0].text == "Failed to resolve slash skill command. Please check the skill configuration."
|
||||||
|
assert outbound_received[0].thread_id == "topic-thread"
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
def test_handle_command_new(self):
|
def test_handle_command_new(self):
|
||||||
from app.channels.manager import ChannelManager
|
from app.channels.manager import ChannelManager
|
||||||
|
|
||||||
@@ -2440,6 +2966,36 @@ class TestWeComChannel:
|
|||||||
|
|
||||||
_run(go())
|
_run(go())
|
||||||
|
|
||||||
|
def test_publish_ws_inbound_treats_slash_prefixed_paths_as_chat(self, monkeypatch):
|
||||||
|
from app.channels.wecom import WeComChannel
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
channel = WeComChannel(bus, config={})
|
||||||
|
channel._ws_client = SimpleNamespace(reply_stream=AsyncMock())
|
||||||
|
|
||||||
|
monkeypatch.setitem(
|
||||||
|
__import__("sys").modules,
|
||||||
|
"aibot",
|
||||||
|
SimpleNamespace(generate_req_id=lambda prefix: "stream-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
frame = {
|
||||||
|
"body": {
|
||||||
|
"msgid": "msg-1",
|
||||||
|
"from": {"userid": "user-1"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await channel._publish_ws_inbound(frame, "/mnt/user-data/uploads/report.pdf")
|
||||||
|
|
||||||
|
inbound = bus.publish_inbound.await_args.args[0]
|
||||||
|
assert inbound.text == "/mnt/user-data/uploads/report.pdf"
|
||||||
|
assert inbound.msg_type == InboundMessageType.CHAT
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
def test_on_outbound_sends_attachment_before_clearing_context(self, tmp_path):
|
def test_on_outbound_sends_attachment_before_clearing_context(self, tmp_path):
|
||||||
from app.channels.wecom import WeComChannel
|
from app.channels.wecom import WeComChannel
|
||||||
|
|
||||||
@@ -2788,6 +3344,219 @@ class TestSlackAllowedUsers:
|
|||||||
assert inbound.chat_id == "C123"
|
assert inbound.chat_id == "C123"
|
||||||
assert inbound.text == "hello from slack"
|
assert inbound.text == "hello from slack"
|
||||||
|
|
||||||
|
def test_app_mention_strips_leading_bot_mention_before_command_detection(self):
|
||||||
|
from app.channels.slack import SlackChannel
|
||||||
|
|
||||||
|
bus = MessageBus()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
channel = SlackChannel(bus=bus, config={"bot_user_id": "UBOT"})
|
||||||
|
channel._loop = MagicMock()
|
||||||
|
channel._loop.is_running.return_value = True
|
||||||
|
channel._add_reaction = MagicMock()
|
||||||
|
channel._send_running_reply = MagicMock()
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"type": "app_mention",
|
||||||
|
"user": "U123456",
|
||||||
|
"text": "<@UBOT> /help",
|
||||||
|
"channel": "C123",
|
||||||
|
"ts": "1710000000.000100",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.channels.slack.asyncio.run_coroutine_threadsafe",
|
||||||
|
side_effect=self._submit_coro,
|
||||||
|
):
|
||||||
|
channel._handle_message_event(event)
|
||||||
|
|
||||||
|
inbound = bus.publish_inbound.call_args.args[0]
|
||||||
|
assert inbound.text == "/help"
|
||||||
|
assert inbound.msg_type == InboundMessageType.COMMAND
|
||||||
|
|
||||||
|
def test_app_mention_strips_labelled_leading_bot_mention(self):
|
||||||
|
from app.channels.slack import SlackChannel
|
||||||
|
|
||||||
|
bus = MessageBus()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
channel = SlackChannel(bus=bus, config={"bot_user_id": "UBOT"})
|
||||||
|
channel._loop = MagicMock()
|
||||||
|
channel._loop.is_running.return_value = True
|
||||||
|
channel._add_reaction = MagicMock()
|
||||||
|
channel._send_running_reply = MagicMock()
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"type": "app_mention",
|
||||||
|
"user": "U123456",
|
||||||
|
"text": "<@UBOT|deerflow> /help",
|
||||||
|
"channel": "C123",
|
||||||
|
"ts": "1710000000.000100",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.channels.slack.asyncio.run_coroutine_threadsafe",
|
||||||
|
side_effect=self._submit_coro,
|
||||||
|
):
|
||||||
|
channel._handle_message_event(event)
|
||||||
|
|
||||||
|
inbound = bus.publish_inbound.call_args.args[0]
|
||||||
|
assert inbound.text == "/help"
|
||||||
|
assert inbound.msg_type == InboundMessageType.COMMAND
|
||||||
|
|
||||||
|
def test_app_mention_strips_leading_bot_mention_before_slash_skill(self):
|
||||||
|
from app.channels.slack import SlackChannel
|
||||||
|
|
||||||
|
bus = MessageBus()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
channel = SlackChannel(bus=bus, config={"bot_user_id": "UBOT"})
|
||||||
|
channel._loop = MagicMock()
|
||||||
|
channel._loop.is_running.return_value = True
|
||||||
|
channel._add_reaction = MagicMock()
|
||||||
|
channel._send_running_reply = MagicMock()
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"type": "app_mention",
|
||||||
|
"user": "U123456",
|
||||||
|
"text": "<@UBOT> /data-analysis analyze uploads/foo.csv",
|
||||||
|
"channel": "C123",
|
||||||
|
"ts": "1710000000.000100",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.channels.slack.asyncio.run_coroutine_threadsafe",
|
||||||
|
side_effect=self._submit_coro,
|
||||||
|
):
|
||||||
|
channel._handle_message_event(event)
|
||||||
|
|
||||||
|
inbound = bus.publish_inbound.call_args.args[0]
|
||||||
|
assert inbound.text == "/data-analysis analyze uploads/foo.csv"
|
||||||
|
assert inbound.msg_type == InboundMessageType.CHAT
|
||||||
|
|
||||||
|
def test_app_mention_preserves_following_user_mention(self):
|
||||||
|
from app.channels.slack import SlackChannel
|
||||||
|
|
||||||
|
bus = MessageBus()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
channel = SlackChannel(bus=bus, config={"bot_user_id": "UBOT"})
|
||||||
|
channel._loop = MagicMock()
|
||||||
|
channel._loop.is_running.return_value = True
|
||||||
|
channel._add_reaction = MagicMock()
|
||||||
|
channel._send_running_reply = MagicMock()
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"type": "app_mention",
|
||||||
|
"user": "U123456",
|
||||||
|
"text": "<@UBOT> <@UASSIGNEE> please review this",
|
||||||
|
"channel": "C123",
|
||||||
|
"ts": "1710000000.000100",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.channels.slack.asyncio.run_coroutine_threadsafe",
|
||||||
|
side_effect=self._submit_coro,
|
||||||
|
):
|
||||||
|
channel._handle_message_event(event)
|
||||||
|
|
||||||
|
inbound = bus.publish_inbound.call_args.args[0]
|
||||||
|
assert inbound.text == "<@UASSIGNEE> please review this"
|
||||||
|
assert inbound.msg_type == InboundMessageType.CHAT
|
||||||
|
|
||||||
|
def test_app_mention_preserves_leading_non_bot_mention_when_bot_id_known(self):
|
||||||
|
from app.channels.slack import SlackChannel
|
||||||
|
|
||||||
|
bus = MessageBus()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
channel = SlackChannel(bus=bus, config={"bot_user_id": "UBOT"})
|
||||||
|
channel._loop = MagicMock()
|
||||||
|
channel._loop.is_running.return_value = True
|
||||||
|
channel._add_reaction = MagicMock()
|
||||||
|
channel._send_running_reply = MagicMock()
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"type": "app_mention",
|
||||||
|
"user": "U123456",
|
||||||
|
"text": "<@UASSIGNEE> <@UBOT> please review this",
|
||||||
|
"channel": "C123",
|
||||||
|
"ts": "1710000000.000100",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.channels.slack.asyncio.run_coroutine_threadsafe",
|
||||||
|
side_effect=self._submit_coro,
|
||||||
|
):
|
||||||
|
channel._handle_message_event(event)
|
||||||
|
|
||||||
|
inbound = bus.publish_inbound.call_args.args[0]
|
||||||
|
assert inbound.text == "<@UASSIGNEE> <@UBOT> please review this"
|
||||||
|
assert inbound.msg_type == InboundMessageType.CHAT
|
||||||
|
|
||||||
|
def test_app_mention_preserves_leading_non_bot_mention_when_bot_id_unknown(self):
|
||||||
|
from app.channels.slack import SlackChannel
|
||||||
|
|
||||||
|
bus = MessageBus()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
channel = SlackChannel(bus=bus, config={})
|
||||||
|
channel._loop = MagicMock()
|
||||||
|
channel._loop.is_running.return_value = True
|
||||||
|
channel._add_reaction = MagicMock()
|
||||||
|
channel._send_running_reply = MagicMock()
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"type": "app_mention",
|
||||||
|
"user": "U123456",
|
||||||
|
"text": "<@UASSIGNEE> /help <@UBOT>",
|
||||||
|
"channel": "C123",
|
||||||
|
"ts": "1710000000.000100",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.channels.slack.asyncio.run_coroutine_threadsafe",
|
||||||
|
side_effect=self._submit_coro,
|
||||||
|
):
|
||||||
|
channel._handle_message_event(event)
|
||||||
|
|
||||||
|
inbound = bus.publish_inbound.call_args.args[0]
|
||||||
|
assert inbound.text == "<@UASSIGNEE> /help <@UBOT>"
|
||||||
|
assert inbound.msg_type == InboundMessageType.CHAT
|
||||||
|
|
||||||
|
def test_socket_event_resolves_bot_user_id_before_app_mention_command_detection(self):
|
||||||
|
from app.channels.slack import SlackChannel
|
||||||
|
|
||||||
|
bus = MessageBus()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
channel = SlackChannel(bus=bus, config={})
|
||||||
|
channel._SocketModeResponse = lambda envelope_id: SimpleNamespace(envelope_id=envelope_id)
|
||||||
|
channel._loop = MagicMock()
|
||||||
|
channel._loop.is_running.return_value = True
|
||||||
|
channel._add_reaction = MagicMock()
|
||||||
|
channel._send_running_reply = MagicMock()
|
||||||
|
|
||||||
|
client = SimpleNamespace(send_socket_mode_response=MagicMock())
|
||||||
|
req = SimpleNamespace(
|
||||||
|
envelope_id="env-1",
|
||||||
|
type="events_api",
|
||||||
|
payload={
|
||||||
|
"authorizations": [{"user_id": "UBOT"}],
|
||||||
|
"event": {
|
||||||
|
"type": "app_mention",
|
||||||
|
"user": "U123456",
|
||||||
|
"text": "<@UBOT> /help",
|
||||||
|
"channel": "C123",
|
||||||
|
"ts": "1710000000.000100",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.channels.slack.asyncio.run_coroutine_threadsafe",
|
||||||
|
side_effect=self._submit_coro,
|
||||||
|
):
|
||||||
|
channel._on_socket_event(client, req)
|
||||||
|
|
||||||
|
inbound = bus.publish_inbound.call_args.args[0]
|
||||||
|
assert channel._bot_user_id == "UBOT"
|
||||||
|
assert inbound.text == "/help"
|
||||||
|
assert inbound.msg_type == InboundMessageType.COMMAND
|
||||||
|
|
||||||
def test_scalar_allowed_users_warns_and_matches_stringified_event_user_id(self, caplog):
|
def test_scalar_allowed_users_warns_and_matches_stringified_event_user_id(self, caplog):
|
||||||
from app.channels.slack import SlackChannel
|
from app.channels.slack import SlackChannel
|
||||||
|
|
||||||
@@ -2861,6 +3630,86 @@ class TestSlackAllowedUsers:
|
|||||||
|
|
||||||
|
|
||||||
class TestTelegramSendRetry:
|
class TestTelegramSendRetry:
|
||||||
|
def test_start_registers_known_channel_commands(self, monkeypatch):
|
||||||
|
import sys
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
|
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
|
||||||
|
from app.channels.telegram import TelegramChannel
|
||||||
|
|
||||||
|
class FakeFilter:
|
||||||
|
def __init__(self, expr: str):
|
||||||
|
self.expr = expr
|
||||||
|
|
||||||
|
def __and__(self, other):
|
||||||
|
return FakeFilter(f"{self.expr}&{other.expr}")
|
||||||
|
|
||||||
|
def __invert__(self):
|
||||||
|
return FakeFilter(f"~{self.expr}")
|
||||||
|
|
||||||
|
class FakeApplication:
|
||||||
|
def __init__(self):
|
||||||
|
self.handlers = []
|
||||||
|
|
||||||
|
def add_handler(self, handler):
|
||||||
|
self.handlers.append(handler)
|
||||||
|
|
||||||
|
fake_app = FakeApplication()
|
||||||
|
|
||||||
|
class FakeApplicationBuilder:
|
||||||
|
def token(self, token):
|
||||||
|
assert token == "test-token"
|
||||||
|
return self
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
return fake_app
|
||||||
|
|
||||||
|
def fake_command_handler(command, callback):
|
||||||
|
return SimpleNamespace(kind="command", command=command, callback=callback)
|
||||||
|
|
||||||
|
def fake_message_handler(filter_expr, callback):
|
||||||
|
return SimpleNamespace(kind="message", filter_expr=filter_expr, callback=callback)
|
||||||
|
|
||||||
|
telegram_mod = ModuleType("telegram")
|
||||||
|
telegram_ext_mod = ModuleType("telegram.ext")
|
||||||
|
telegram_ext_mod.ApplicationBuilder = FakeApplicationBuilder
|
||||||
|
telegram_ext_mod.CommandHandler = fake_command_handler
|
||||||
|
telegram_ext_mod.MessageHandler = fake_message_handler
|
||||||
|
telegram_ext_mod.filters = SimpleNamespace(TEXT=FakeFilter("TEXT"), COMMAND=FakeFilter("COMMAND"))
|
||||||
|
telegram_mod.ext = telegram_ext_mod
|
||||||
|
monkeypatch.setitem(sys.modules, "telegram", telegram_mod)
|
||||||
|
monkeypatch.setitem(sys.modules, "telegram.ext", telegram_ext_mod)
|
||||||
|
|
||||||
|
class FakeThread:
|
||||||
|
def __init__(self, *, target, daemon):
|
||||||
|
self.target = target
|
||||||
|
self.daemon = daemon
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def join(self, timeout=None):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.channels.telegram.threading.Thread", FakeThread)
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||||
|
|
||||||
|
await ch.start()
|
||||||
|
try:
|
||||||
|
registered_commands = {handler.command for handler in fake_app.handlers if handler.kind == "command"}
|
||||||
|
expected_commands = {command.removeprefix("/") for command in KNOWN_CHANNEL_COMMANDS}
|
||||||
|
assert expected_commands <= registered_commands
|
||||||
|
assert "start" in registered_commands
|
||||||
|
message_filters = {handler.filter_expr.expr for handler in fake_app.handlers if handler.kind == "message"}
|
||||||
|
assert {"TEXT&COMMAND", "TEXT&~COMMAND"} <= message_filters
|
||||||
|
finally:
|
||||||
|
await ch.stop()
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
def test_retries_on_failure_then_succeeds(self):
|
def test_retries_on_failure_then_succeeds(self):
|
||||||
from app.channels.telegram import TelegramChannel
|
from app.channels.telegram import TelegramChannel
|
||||||
|
|
||||||
@@ -2984,6 +3833,47 @@ class TestTelegramPrivateChatThread:
|
|||||||
|
|
||||||
_run(go())
|
_run(go())
|
||||||
|
|
||||||
|
def test_private_chat_slash_skill_text_routes_as_chat(self):
|
||||||
|
from app.channels.telegram import TelegramChannel
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||||
|
ch._main_loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
update = _make_telegram_update("private", message_id=12, text="/data-analysis analyze uploads/foo.csv")
|
||||||
|
await ch._on_text(update, None)
|
||||||
|
|
||||||
|
msg = await asyncio.wait_for(bus.get_inbound(), timeout=2)
|
||||||
|
assert msg.text == "/data-analysis analyze uploads/foo.csv"
|
||||||
|
assert msg.msg_type == InboundMessageType.CHAT
|
||||||
|
assert msg.topic_id is None
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_slash_skill_addressed_to_telegram_bot_strips_username(self):
|
||||||
|
from app.channels.telegram import TelegramChannel
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||||
|
ch._main_loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
update = _make_telegram_update(
|
||||||
|
"group",
|
||||||
|
message_id=13,
|
||||||
|
text="/data-analysis@DeerFlowBot analyze uploads/foo.csv",
|
||||||
|
)
|
||||||
|
context = SimpleNamespace(bot=SimpleNamespace(username="DeerFlowBot"))
|
||||||
|
await ch._on_text(update, context)
|
||||||
|
|
||||||
|
msg = await asyncio.wait_for(bus.get_inbound(), timeout=2)
|
||||||
|
assert msg.text == "/data-analysis analyze uploads/foo.csv"
|
||||||
|
assert msg.msg_type == InboundMessageType.CHAT
|
||||||
|
assert msg.topic_id == "13"
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
def test_private_chat_with_reply_still_uses_none_topic(self):
|
def test_private_chat_with_reply_still_uses_none_topic(self):
|
||||||
from app.channels.telegram import TelegramChannel
|
from app.channels.telegram import TelegramChannel
|
||||||
|
|
||||||
@@ -3099,6 +3989,25 @@ class TestTelegramPrivateChatThread:
|
|||||||
|
|
||||||
_run(go())
|
_run(go())
|
||||||
|
|
||||||
|
def test_cmd_generic_strips_addressed_telegram_bot_username(self):
|
||||||
|
from app.channels.telegram import TelegramChannel
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||||
|
ch._main_loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
update = _make_telegram_update("group", message_id=33, text="/status@DeerFlowBot")
|
||||||
|
context = SimpleNamespace(bot=SimpleNamespace(username="DeerFlowBot"))
|
||||||
|
await ch._cmd_generic(update, context)
|
||||||
|
|
||||||
|
msg = await asyncio.wait_for(bus.get_inbound(), timeout=2)
|
||||||
|
assert msg.text == "/status"
|
||||||
|
assert msg.topic_id == "33"
|
||||||
|
assert msg.msg_type == InboundMessageType.COMMAND
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
|
||||||
class TestTelegramProcessingOrder:
|
class TestTelegramProcessingOrder:
|
||||||
"""Ensure 'working on it...' is sent before inbound is published."""
|
"""Ensure 'working on it...' is sent before inbound is published."""
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.channels.discord import DiscordChannel
|
from app.channels.discord import DiscordChannel
|
||||||
from app.channels.manager import CHANNEL_CAPABILITIES
|
from app.channels.manager import CHANNEL_CAPABILITIES
|
||||||
from app.channels.message_bus import MessageBus
|
from app.channels.message_bus import InboundMessageType, MessageBus
|
||||||
from app.channels.service import _CHANNEL_REGISTRY
|
from app.channels.service import _CHANNEL_REGISTRY
|
||||||
|
|
||||||
|
|
||||||
@@ -21,3 +25,64 @@ def test_discord_channel_init() -> None:
|
|||||||
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
|
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
|
||||||
|
|
||||||
assert channel.name == "discord"
|
assert channel.name == "discord"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_discord_message(text: str):
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=111,
|
||||||
|
content=text,
|
||||||
|
author=SimpleNamespace(id=123, bot=False, display_name="alice"),
|
||||||
|
guild=SimpleNamespace(id=321),
|
||||||
|
channel=SimpleNamespace(id=456),
|
||||||
|
add_reaction=lambda _emoji: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discord_bot_mention_slash_skill_routes_as_chat() -> None:
|
||||||
|
bus = MessageBus()
|
||||||
|
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
|
||||||
|
captured = []
|
||||||
|
channel._running = True
|
||||||
|
channel._client = SimpleNamespace(user=SimpleNamespace(id=999, mention="<@999>"))
|
||||||
|
channel._discord_module = SimpleNamespace(Thread=type("FakeThread", (), {}))
|
||||||
|
channel._publish = captured.append
|
||||||
|
|
||||||
|
async def noop(*_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
channel._start_typing = noop
|
||||||
|
channel._add_reaction = noop
|
||||||
|
|
||||||
|
await channel._on_message(_make_discord_message("<@999> /data-analysis analyze uploads/foo.csv"))
|
||||||
|
|
||||||
|
assert len(captured) == 1
|
||||||
|
inbound = captured[0]
|
||||||
|
assert inbound.text == "/data-analysis analyze uploads/foo.csv"
|
||||||
|
assert inbound.msg_type == InboundMessageType.CHAT
|
||||||
|
assert inbound.topic_id == "456"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discord_bot_mention_known_command_routes_as_command() -> None:
|
||||||
|
bus = MessageBus()
|
||||||
|
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
|
||||||
|
captured = []
|
||||||
|
channel._running = True
|
||||||
|
channel._client = SimpleNamespace(user=SimpleNamespace(id=999, mention="<@999>"))
|
||||||
|
channel._discord_module = SimpleNamespace(Thread=type("FakeThread", (), {}))
|
||||||
|
channel._publish = captured.append
|
||||||
|
|
||||||
|
async def noop(*_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
channel._start_typing = noop
|
||||||
|
channel._add_reaction = noop
|
||||||
|
|
||||||
|
await channel._on_message(_make_discord_message("<@999> /help"))
|
||||||
|
|
||||||
|
assert len(captured) == 1
|
||||||
|
inbound = captured[0]
|
||||||
|
assert inbound.text == "/help"
|
||||||
|
assert inbound.msg_type == InboundMessageType.COMMAND
|
||||||
|
assert inbound.topic_id == "456"
|
||||||
|
|||||||
@@ -60,6 +60,17 @@ def test_get_skills_prompt_section_returns_all_when_available_skills_is_none(mon
|
|||||||
assert "skill2" in result
|
assert "skill2" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_skills_prompt_section_includes_slash_activation_guidance(monkeypatch):
|
||||||
|
skills = [_make_skill("data-analysis")]
|
||||||
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)
|
||||||
|
|
||||||
|
result = get_skills_prompt_section(available_skills={"data-analysis"})
|
||||||
|
|
||||||
|
assert "Explicit Slash Skill Activation" in result
|
||||||
|
assert "The runtime injects the activated skill content" in result
|
||||||
|
assert "do not call `read_file` for that SKILL.md again" in result
|
||||||
|
|
||||||
|
|
||||||
def test_get_skills_prompt_section_includes_self_evolution_rules(monkeypatch):
|
def test_get_skills_prompt_section_includes_self_evolution_rules(monkeypatch):
|
||||||
skills = [_make_skill("skill1")]
|
skills = [_make_skill("skill1")]
|
||||||
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)
|
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)
|
||||||
|
|||||||
@@ -0,0 +1,557 @@
|
|||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from langchain.agents.middleware.types import ModelRequest
|
||||||
|
from langchain_core.messages import AIMessage, HumanMessage
|
||||||
|
|
||||||
|
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
|
||||||
|
from deerflow.agents.middlewares import skill_activation_middleware as middleware_module
|
||||||
|
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware, is_slash_skill_activation_reminder
|
||||||
|
from deerflow.skills.slash import RESERVED_SLASH_SKILL_NAMES, parse_slash_skill_reference, resolve_slash_skill
|
||||||
|
from deerflow.skills.types import Skill, SkillCategory
|
||||||
|
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
|
||||||
|
|
||||||
|
|
||||||
|
def _make_skill(tmp_path: Path, name: str, content: str = "skill body") -> Skill:
|
||||||
|
skill_dir = tmp_path / name
|
||||||
|
skill_dir.mkdir()
|
||||||
|
skill_file = skill_dir / "SKILL.md"
|
||||||
|
skill_file.write_text(content, encoding="utf-8")
|
||||||
|
return Skill(
|
||||||
|
name=name,
|
||||||
|
description=f"Description for {name}",
|
||||||
|
license="MIT",
|
||||||
|
skill_dir=skill_dir,
|
||||||
|
skill_file=skill_file,
|
||||||
|
relative_path=Path(name),
|
||||||
|
category=SkillCategory.CUSTOM,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_storage(tmp_path: Path, skills: list[Skill]):
|
||||||
|
return SimpleNamespace(
|
||||||
|
load_skills=lambda *, enabled_only: [skill for skill in skills if skill.enabled] if enabled_only else skills,
|
||||||
|
get_container_root=lambda: "/mnt/skills",
|
||||||
|
get_skills_root_path=lambda: tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_model_request(messages: list[HumanMessage], *, runtime=None) -> ModelRequest:
|
||||||
|
return ModelRequest(
|
||||||
|
model=object(),
|
||||||
|
messages=messages,
|
||||||
|
state={"messages": list(messages)},
|
||||||
|
runtime=runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_slash_skill_reference_extracts_name_and_remaining_text():
|
||||||
|
parsed = parse_slash_skill_reference("/data-analysis analyze uploads/foo.csv")
|
||||||
|
|
||||||
|
assert parsed is not None
|
||||||
|
assert parsed.name == "data-analysis"
|
||||||
|
assert parsed.remaining_text == "analyze uploads/foo.csv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_slash_skill_reference_accepts_skill_name_without_task():
|
||||||
|
parsed = parse_slash_skill_reference("/data-analysis")
|
||||||
|
|
||||||
|
assert parsed is not None
|
||||||
|
assert parsed.name == "data-analysis"
|
||||||
|
assert parsed.remaining_text == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_slash_skill_reference_rejects_invalid_names():
|
||||||
|
assert parse_slash_skill_reference("/DataAnalysis run") is None
|
||||||
|
assert parse_slash_skill_reference("/data_analysis run") is None
|
||||||
|
assert parse_slash_skill_reference("please use /data-analysis") is None
|
||||||
|
assert parse_slash_skill_reference(" /data-analysis run") is None
|
||||||
|
assert parse_slash_skill_reference("/data-analysis分析这个文档") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_slash_skill_ignores_reserved_control_commands(tmp_path):
|
||||||
|
for command in ["bootstrap", "help", "memory", "models", "new", "status"]:
|
||||||
|
skill = _make_skill(tmp_path, command)
|
||||||
|
|
||||||
|
assert resolve_slash_skill(f"/{command} create an agent", [skill]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reserved_slash_skill_names_match_channel_commands():
|
||||||
|
assert RESERVED_SLASH_SKILL_NAMES == {command.removeprefix("/") for command in KNOWN_CHANNEL_COMMANDS}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_slash_skill_respects_available_skill_whitelist(tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis")
|
||||||
|
|
||||||
|
assert resolve_slash_skill("/data-analysis run", [skill], available_skills=set()) is None
|
||||||
|
|
||||||
|
resolved = resolve_slash_skill("/data-analysis run", [skill], available_skills={"data-analysis"})
|
||||||
|
assert resolved is not None
|
||||||
|
assert resolved.skill.name == "data-analysis"
|
||||||
|
assert resolved.remaining_text == "run"
|
||||||
|
assert resolved.container_file_path == "/mnt/skills/custom/data-analysis/SKILL.md"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_slash_skill_rejects_disabled_skills(tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis")
|
||||||
|
skill.enabled = False
|
||||||
|
|
||||||
|
assert resolve_slash_skill("/data-analysis run", [skill]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_injects_hidden_human_context_for_model_call(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
|
||||||
|
request = _make_model_request([original])
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def handler(model_request: ModelRequest):
|
||||||
|
captured["messages"] = model_request.messages
|
||||||
|
return AIMessage(content="ok")
|
||||||
|
|
||||||
|
result = middleware.wrap_model_call(request, handler)
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
assert result.content == "ok"
|
||||||
|
activation_msg, user_msg = captured["messages"]
|
||||||
|
assert is_slash_skill_activation_reminder(activation_msg)
|
||||||
|
assert activation_msg.additional_kwargs["hide_from_ui"] is True
|
||||||
|
assert "Use pandas." in activation_msg.content
|
||||||
|
assert "<user_request>\nanalyze uploads/foo.csv\n</user_request>" in activation_msg.content
|
||||||
|
assert user_msg.content == original.content
|
||||||
|
assert request.state["messages"] == [original]
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_does_not_duplicate_existing_activation(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
|
||||||
|
first_capture = {}
|
||||||
|
|
||||||
|
def first_handler(model_request: ModelRequest):
|
||||||
|
first_capture["messages"] = model_request.messages
|
||||||
|
return AIMessage(content="ok")
|
||||||
|
|
||||||
|
first_result = middleware.wrap_model_call(_make_model_request([original]), first_handler)
|
||||||
|
|
||||||
|
assert isinstance(first_result, AIMessage)
|
||||||
|
activation_msg, user_msg = first_capture["messages"]
|
||||||
|
assert is_slash_skill_activation_reminder(activation_msg)
|
||||||
|
|
||||||
|
second_capture = {}
|
||||||
|
|
||||||
|
def second_handler(model_request: ModelRequest):
|
||||||
|
second_capture["messages"] = model_request.messages
|
||||||
|
return AIMessage(content="ok")
|
||||||
|
|
||||||
|
second_result = middleware.wrap_model_call(_make_model_request([activation_msg, user_msg]), second_handler)
|
||||||
|
|
||||||
|
assert isinstance(second_result, AIMessage)
|
||||||
|
assert second_capture["messages"] == [activation_msg, user_msg]
|
||||||
|
assert sum(is_slash_skill_activation_reminder(message) for message in second_capture["messages"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_does_not_duplicate_activation_separated_by_hidden_context(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
|
||||||
|
first_capture = {}
|
||||||
|
|
||||||
|
def first_handler(model_request: ModelRequest):
|
||||||
|
first_capture["messages"] = model_request.messages
|
||||||
|
return AIMessage(content="ok")
|
||||||
|
|
||||||
|
middleware.wrap_model_call(_make_model_request([original]), first_handler)
|
||||||
|
activation_msg, user_msg = first_capture["messages"]
|
||||||
|
hidden_context = HumanMessage(content="dynamic context", additional_kwargs={"hide_from_ui": True})
|
||||||
|
second_capture = {}
|
||||||
|
|
||||||
|
def second_handler(model_request: ModelRequest):
|
||||||
|
second_capture["messages"] = model_request.messages
|
||||||
|
return AIMessage(content="ok")
|
||||||
|
|
||||||
|
second_result = middleware.wrap_model_call(_make_model_request([activation_msg, hidden_context, user_msg]), second_handler)
|
||||||
|
|
||||||
|
assert isinstance(second_result, AIMessage)
|
||||||
|
assert second_capture["messages"] == [activation_msg, hidden_context, user_msg]
|
||||||
|
assert sum(is_slash_skill_activation_reminder(message) for message in second_capture["messages"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_dedupes_immediately_previous_activation_without_target_id(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
legacy_activation_msg = SkillActivationMiddleware._make_activation_message(
|
||||||
|
HumanMessage(content="/data-analysis analyze uploads/foo.csv"),
|
||||||
|
"existing activation context",
|
||||||
|
)
|
||||||
|
target = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def handler(model_request: ModelRequest):
|
||||||
|
captured["messages"] = model_request.messages
|
||||||
|
return AIMessage(content="ok")
|
||||||
|
|
||||||
|
result = middleware.wrap_model_call(_make_model_request([legacy_activation_msg, target]), handler)
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
assert captured["messages"] == [legacy_activation_msg, target]
|
||||||
|
assert sum(is_slash_skill_activation_reminder(message) for message in captured["messages"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_async_injects_hidden_human_context_for_model_call(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
|
||||||
|
request = _make_model_request([original])
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def handler(model_request: ModelRequest):
|
||||||
|
captured["messages"] = model_request.messages
|
||||||
|
return AIMessage(content="ok")
|
||||||
|
|
||||||
|
result = asyncio.run(middleware.awrap_model_call(request, handler))
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
assert result.content == "ok"
|
||||||
|
activation_msg, user_msg = captured["messages"]
|
||||||
|
assert is_slash_skill_activation_reminder(activation_msg)
|
||||||
|
assert activation_msg.additional_kwargs["hide_from_ui"] is True
|
||||||
|
assert "Use pandas." in activation_msg.content
|
||||||
|
assert "<user_request>\nanalyze uploads/foo.csv\n</user_request>" in activation_msg.content
|
||||||
|
assert user_msg.content == original.content
|
||||||
|
assert request.state["messages"] == [original]
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_uses_fallback_when_task_text_is_empty(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
original = HumanMessage(content="/data-analysis", id="msg-1")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def handler(model_request: ModelRequest):
|
||||||
|
captured["messages"] = model_request.messages
|
||||||
|
return AIMessage(content="ok")
|
||||||
|
|
||||||
|
result = middleware.wrap_model_call(_make_model_request([original]), handler)
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
activation_msg = captured["messages"][0]
|
||||||
|
assert "No additional task text was provided after the slash skill command." in activation_msg.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_uses_original_user_content_when_uploads_are_injected(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
original = HumanMessage(
|
||||||
|
content="<uploaded_files>\n- report.pdf\n</uploaded_files>\n\n/data-analysis 分析这个文档",
|
||||||
|
id="msg-1",
|
||||||
|
additional_kwargs={ORIGINAL_USER_CONTENT_KEY: "/data-analysis 分析这个文档"},
|
||||||
|
)
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def handler(model_request: ModelRequest):
|
||||||
|
captured["messages"] = model_request.messages
|
||||||
|
return AIMessage(content="ok")
|
||||||
|
|
||||||
|
result = middleware.wrap_model_call(_make_model_request([original]), handler)
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
assert result.content == "ok"
|
||||||
|
activation_msg, user_msg = captured["messages"]
|
||||||
|
assert is_slash_skill_activation_reminder(activation_msg)
|
||||||
|
assert "Use pandas." in activation_msg.content
|
||||||
|
assert "<user_request>\n分析这个文档\n</user_request>" in activation_msg.content
|
||||||
|
assert user_msg.content == original.content
|
||||||
|
assert user_msg.additional_kwargs[ORIGINAL_USER_CONTENT_KEY] == "/data-analysis 分析这个文档"
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_activates_from_list_content(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
original = HumanMessage(content=[{"type": "text", "text": "/data-analysis analyze uploads/foo.csv"}], id="msg-1")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def handler(model_request: ModelRequest):
|
||||||
|
captured["messages"] = model_request.messages
|
||||||
|
return AIMessage(content="ok")
|
||||||
|
|
||||||
|
result = middleware.wrap_model_call(_make_model_request([original]), handler)
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
activation_msg, user_msg = captured["messages"]
|
||||||
|
assert is_slash_skill_activation_reminder(activation_msg)
|
||||||
|
assert "<user_request>\nanalyze uploads/foo.csv\n</user_request>" in activation_msg.content
|
||||||
|
assert user_msg.content == original.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_records_activation_audit_event(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
recorded = []
|
||||||
|
journal = SimpleNamespace(record_middleware=lambda *args, **kwargs: recorded.append((args, kwargs)))
|
||||||
|
runtime = SimpleNamespace(context={"__run_journal": journal})
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
|
||||||
|
|
||||||
|
def handler(model_request: ModelRequest):
|
||||||
|
return AIMessage(content="ok")
|
||||||
|
|
||||||
|
result = middleware.wrap_model_call(_make_model_request([original], runtime=runtime), handler)
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
assert len(recorded) == 1
|
||||||
|
args, kwargs = recorded[0]
|
||||||
|
assert args == ("skill_activation",)
|
||||||
|
assert kwargs["name"] == "SkillActivationMiddleware"
|
||||||
|
assert kwargs["hook"] == "wrap_model_call"
|
||||||
|
assert kwargs["action"] == "activate"
|
||||||
|
assert kwargs["changes"] == {
|
||||||
|
"skill_name": "data-analysis",
|
||||||
|
"category": "custom",
|
||||||
|
"path": "/mnt/skills/custom/data-analysis/SKILL.md",
|
||||||
|
"content_hash": hashlib.sha256(b"# Data Analysis\nUse pandas.").hexdigest(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_async_records_activation_audit_event(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
recorded = []
|
||||||
|
journal = SimpleNamespace(record_middleware=lambda *args, **kwargs: recorded.append((args, kwargs)))
|
||||||
|
runtime = SimpleNamespace(context={"__run_journal": journal})
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
|
||||||
|
|
||||||
|
async def handler(model_request: ModelRequest):
|
||||||
|
return AIMessage(content="ok")
|
||||||
|
|
||||||
|
result = asyncio.run(middleware.awrap_model_call(_make_model_request([original], runtime=runtime), handler))
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
assert len(recorded) == 1
|
||||||
|
args, kwargs = recorded[0]
|
||||||
|
assert args == ("skill_activation",)
|
||||||
|
assert kwargs["hook"] == "awrap_model_call"
|
||||||
|
assert kwargs["changes"]["skill_name"] == "data-analysis"
|
||||||
|
assert kwargs["changes"]["content_hash"] == hashlib.sha256(b"# Data Analysis\nUse pandas.").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_ignores_activation_audit_errors(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
journal = SimpleNamespace(record_middleware=lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("db down")))
|
||||||
|
runtime = SimpleNamespace(context={"__run_journal": journal})
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
|
||||||
|
|
||||||
|
def handler(model_request: ModelRequest):
|
||||||
|
return AIMessage(content="ok")
|
||||||
|
|
||||||
|
result = middleware.wrap_model_call(_make_model_request([original], runtime=runtime), handler)
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
assert result.content == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_activates_only_latest_real_user_message(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
old_slash = HumanMessage(content="/data-analysis old request", id="msg-1")
|
||||||
|
latest_user = HumanMessage(content="continue normally", id="msg-2")
|
||||||
|
request = _make_model_request([old_slash, AIMessage(content="done"), latest_user])
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def handler(model_request: ModelRequest):
|
||||||
|
captured["messages"] = model_request.messages
|
||||||
|
return AIMessage(content="ok")
|
||||||
|
|
||||||
|
result = middleware.wrap_model_call(request, handler)
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
assert captured["messages"] == request.messages
|
||||||
|
assert not any(is_slash_skill_activation_reminder(message) for message in captured["messages"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_ignores_hidden_and_summary_user_messages(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
real_user = HumanMessage(content="continue normally", id="msg-1")
|
||||||
|
hidden_slash = HumanMessage(content="/data-analysis hidden request", id="msg-2", additional_kwargs={"hide_from_ui": True})
|
||||||
|
summary_slash = HumanMessage(content="/data-analysis summary request", id="msg-3", name="summary")
|
||||||
|
request = _make_model_request([real_user, hidden_slash, summary_slash])
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def handler(model_request: ModelRequest):
|
||||||
|
captured["messages"] = model_request.messages
|
||||||
|
return AIMessage(content="ok")
|
||||||
|
|
||||||
|
result = middleware.wrap_model_call(request, handler)
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
assert captured["messages"] == request.messages
|
||||||
|
assert not any(is_slash_skill_activation_reminder(message) for message in captured["messages"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_returns_clear_error_for_disallowed_skill(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis")
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
middleware = SkillActivationMiddleware(available_skills={"frontend-design"})
|
||||||
|
original = HumanMessage(content="/data-analysis run")
|
||||||
|
|
||||||
|
def handler(model_request: ModelRequest):
|
||||||
|
raise AssertionError("handler should not be called for invalid slash skills")
|
||||||
|
|
||||||
|
result = middleware.wrap_model_call(_make_model_request([original]), handler)
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
assert "not available for this agent" in result.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_returns_clear_error_for_missing_skill(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, []))
|
||||||
|
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
original = HumanMessage(content="/data-analysis run")
|
||||||
|
|
||||||
|
def handler(model_request: ModelRequest):
|
||||||
|
raise AssertionError("handler should not be called for missing slash skills")
|
||||||
|
|
||||||
|
result = middleware.wrap_model_call(_make_model_request([original]), handler)
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
assert "not installed" in result.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_returns_clear_error_for_disabled_skill(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis")
|
||||||
|
skill.enabled = False
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
original = HumanMessage(content="/data-analysis run")
|
||||||
|
|
||||||
|
def handler(model_request: ModelRequest):
|
||||||
|
raise AssertionError("handler should not be called for disabled slash skills")
|
||||||
|
|
||||||
|
result = middleware.wrap_model_call(_make_model_request([original]), handler)
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
assert "installed but disabled" in result.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_escapes_activation_content(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(
|
||||||
|
tmp_path,
|
||||||
|
"data-analysis",
|
||||||
|
content="# Data Analysis\nUse <xml> & avoid </skill> collisions.\n----- END SKILL.md -----",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
original = HumanMessage(content="/data-analysis analyze </user_request>")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def handler(model_request: ModelRequest):
|
||||||
|
captured["messages"] = model_request.messages
|
||||||
|
return AIMessage(content="ok")
|
||||||
|
|
||||||
|
result = middleware.wrap_model_call(_make_model_request([original]), handler)
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
activation_msg = captured["messages"][0]
|
||||||
|
assert '<skill_content encoding="xml-escaped">' in activation_msg.content
|
||||||
|
assert "analyze </user_request>" in activation_msg.content
|
||||||
|
assert "Use <xml> & avoid </skill> collisions." in activation_msg.content
|
||||||
|
assert "----- BEGIN SKILL.md -----" not in activation_msg.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_rejects_skill_file_outside_skills_root(monkeypatch, tmp_path):
|
||||||
|
skills_root = tmp_path / "skills"
|
||||||
|
skill_dir = skills_root / "custom" / "data-analysis"
|
||||||
|
skill_dir.mkdir(parents=True)
|
||||||
|
outside_dir = tmp_path / "outside"
|
||||||
|
outside_dir.mkdir()
|
||||||
|
outside_file = outside_dir / "SKILL.md"
|
||||||
|
outside_file.write_text("# Leaked\nDo not read me.", encoding="utf-8")
|
||||||
|
(skill_dir / "SKILL.md").symlink_to(outside_file)
|
||||||
|
skill = Skill(
|
||||||
|
name="data-analysis",
|
||||||
|
description="Description for data-analysis",
|
||||||
|
license="MIT",
|
||||||
|
skill_dir=skill_dir,
|
||||||
|
skill_file=skill_dir / "SKILL.md",
|
||||||
|
relative_path=Path("data-analysis"),
|
||||||
|
category=SkillCategory.CUSTOM,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(skills_root, [skill]))
|
||||||
|
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
|
||||||
|
def handler(model_request: ModelRequest):
|
||||||
|
raise AssertionError("handler should not be called when SKILL.md fails safety checks")
|
||||||
|
|
||||||
|
result = middleware.wrap_model_call(_make_model_request([HumanMessage(content="/data-analysis run")]), handler)
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
assert "could not be loaded safely" in result.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_reports_missing_skill_file_safely(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis")
|
||||||
|
skill.skill_file.unlink()
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
|
||||||
|
def handler(model_request: ModelRequest):
|
||||||
|
raise AssertionError("handler should not be called when SKILL.md is missing")
|
||||||
|
|
||||||
|
result = middleware.wrap_model_call(_make_model_request([HumanMessage(content="/data-analysis run")]), handler)
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
assert "could not be loaded safely" in result.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_activation_middleware_reports_invalid_utf8_skill_file_safely(monkeypatch, tmp_path):
|
||||||
|
skill = _make_skill(tmp_path, "data-analysis")
|
||||||
|
skill.skill_file.write_bytes(b"\xff\xfe\x00")
|
||||||
|
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||||
|
|
||||||
|
middleware = SkillActivationMiddleware()
|
||||||
|
|
||||||
|
def handler(model_request: ModelRequest):
|
||||||
|
raise AssertionError("handler should not be called when SKILL.md is not valid UTF-8")
|
||||||
|
|
||||||
|
result = middleware.wrap_model_call(_make_model_request([HumanMessage(content="/data-analysis run")]), handler)
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
assert "could not be loaded safely" in result.content
|
||||||
@@ -14,6 +14,7 @@ from langchain_core.messages import AIMessage, HumanMessage
|
|||||||
|
|
||||||
from deerflow.agents.middlewares.uploads_middleware import UploadsMiddleware
|
from deerflow.agents.middlewares.uploads_middleware import UploadsMiddleware
|
||||||
from deerflow.config.paths import Paths
|
from deerflow.config.paths import Paths
|
||||||
|
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
|
||||||
|
|
||||||
THREAD_ID = "thread-abc123"
|
THREAD_ID = "thread-abc123"
|
||||||
|
|
||||||
@@ -263,6 +264,22 @@ class TestBeforeAgent:
|
|||||||
assert "<uploaded_files>" in combined_text
|
assert "<uploaded_files>" in combined_text
|
||||||
assert "analyse this" in combined_text
|
assert "analyse this" in combined_text
|
||||||
|
|
||||||
|
def test_list_content_preserves_original_slash_skill_text(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
uploads_dir = _uploads_dir(tmp_path)
|
||||||
|
(uploads_dir / "data.csv").write_bytes(b"a,b")
|
||||||
|
|
||||||
|
msg = _human(
|
||||||
|
[{"type": "text", "text": "/data-analysis analyze data.csv"}],
|
||||||
|
files=[{"filename": "data.csv", "size": 3, "path": "/mnt/user-data/uploads/data.csv"}],
|
||||||
|
)
|
||||||
|
result = mw.before_agent(self._state(msg), _runtime())
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
updated_msg = result["messages"][-1]
|
||||||
|
assert isinstance(updated_msg.content, list)
|
||||||
|
assert updated_msg.additional_kwargs[ORIGINAL_USER_CONTENT_KEY] == "/data-analysis analyze data.csv"
|
||||||
|
|
||||||
def test_preserves_additional_kwargs_on_updated_message(self, tmp_path):
|
def test_preserves_additional_kwargs_on_updated_message(self, tmp_path):
|
||||||
mw = _middleware(tmp_path)
|
mw = _middleware(tmp_path)
|
||||||
uploads_dir = _uploads_dir(tmp_path)
|
uploads_dir = _uploads_dir(tmp_path)
|
||||||
@@ -278,6 +295,37 @@ class TestBeforeAgent:
|
|||||||
assert updated_kwargs.get("files") == files_meta
|
assert updated_kwargs.get("files") == files_meta
|
||||||
assert updated_kwargs.get("element") == "task"
|
assert updated_kwargs.get("element") == "task"
|
||||||
|
|
||||||
|
def test_preserves_original_user_content_before_upload_context(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
uploads_dir = _uploads_dir(tmp_path)
|
||||||
|
(uploads_dir / "report.pdf").write_bytes(b"pdf")
|
||||||
|
|
||||||
|
msg = _human(
|
||||||
|
"/data-analysis 分析这个文档",
|
||||||
|
files=[{"filename": "report.pdf", "size": 3, "path": "/mnt/user-data/uploads/report.pdf"}],
|
||||||
|
)
|
||||||
|
result = mw.before_agent(self._state(msg), _runtime())
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
updated_msg = result["messages"][-1]
|
||||||
|
assert updated_msg.content.startswith("<uploaded_files>")
|
||||||
|
assert updated_msg.additional_kwargs[ORIGINAL_USER_CONTENT_KEY] == "/data-analysis 分析这个文档"
|
||||||
|
|
||||||
|
def test_preserves_existing_original_user_content_marker(self, tmp_path):
|
||||||
|
mw = _middleware(tmp_path)
|
||||||
|
uploads_dir = _uploads_dir(tmp_path)
|
||||||
|
(uploads_dir / "report.pdf").write_bytes(b"pdf")
|
||||||
|
|
||||||
|
msg = _human(
|
||||||
|
"<uploaded_files>\nold\n</uploaded_files>\n\n/data-analysis run",
|
||||||
|
files=[{"filename": "report.pdf", "size": 3, "path": "/mnt/user-data/uploads/report.pdf"}],
|
||||||
|
**{ORIGINAL_USER_CONTENT_KEY: "/data-analysis run"},
|
||||||
|
)
|
||||||
|
result = mw.before_agent(self._state(msg), _runtime())
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result["messages"][-1].additional_kwargs[ORIGINAL_USER_CONTENT_KEY] == "/data-analysis run"
|
||||||
|
|
||||||
def test_uploaded_files_returned_in_state_update(self, tmp_path):
|
def test_uploaded_files_returned_in_state_update(self, tmp_path):
|
||||||
mw = _middleware(tmp_path)
|
mw = _middleware(tmp_path)
|
||||||
uploads_dir = _uploads_dir(tmp_path)
|
uploads_dir = _uploads_dir(tmp_path)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export default tseslint.config(
|
|||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
".next",
|
".next",
|
||||||
|
"playwright-report",
|
||||||
|
"test-results",
|
||||||
"src/components/ui/**",
|
"src/components/ui/**",
|
||||||
"src/components/ai-elements/**",
|
"src/components/ai-elements/**",
|
||||||
"*.js",
|
"*.js",
|
||||||
|
|||||||
@@ -881,6 +881,7 @@ export type PromptInputTextareaProps = ComponentProps<
|
|||||||
|
|
||||||
export const PromptInputTextarea = ({
|
export const PromptInputTextarea = ({
|
||||||
onChange,
|
onChange,
|
||||||
|
onKeyDown,
|
||||||
className,
|
className,
|
||||||
placeholder = "What would you like to know?",
|
placeholder = "What would you like to know?",
|
||||||
...props
|
...props
|
||||||
@@ -891,6 +892,10 @@ export const PromptInputTextarea = ({
|
|||||||
const [isComposing, setIsComposing] = useState(false);
|
const [isComposing, setIsComposing] = useState(false);
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||||
|
onKeyDown?.(e);
|
||||||
|
if (e.defaultPrevented) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
if (isIMEComposing(e, isComposing)) {
|
if (isIMEComposing(e, isComposing)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
|
type KeyboardEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -59,6 +60,8 @@ import { fetch } from "@/core/api/fetcher";
|
|||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
|
import type { Skill } from "@/core/skills";
|
||||||
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import type { AgentThreadContext } from "@/core/threads";
|
import type { AgentThreadContext } from "@/core/threads";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -86,6 +89,48 @@ import { Tooltip } from "./tooltip";
|
|||||||
|
|
||||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||||
|
|
||||||
|
const MAX_SKILL_SUGGESTIONS = 6;
|
||||||
|
|
||||||
|
function getLeadingSlashSkillQuery(value: string): string | null {
|
||||||
|
if (!value.startsWith("/")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = value.slice(1);
|
||||||
|
if (query.includes("/") || /\s/.test(query)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMatchingSkillSuggestions(skills: Skill[], query: string): Skill[] {
|
||||||
|
const normalizedQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
return skills
|
||||||
|
.map((skill, index) => ({
|
||||||
|
skill,
|
||||||
|
index,
|
||||||
|
name: skill.name.toLowerCase(),
|
||||||
|
}))
|
||||||
|
.filter(({ skill, name }) => {
|
||||||
|
if (!skill.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !normalizedQuery || name.includes(normalizedQuery);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aStartsWith = a.name.startsWith(normalizedQuery);
|
||||||
|
const bStartsWith = b.name.startsWith(normalizedQuery);
|
||||||
|
if (aStartsWith !== bStartsWith) {
|
||||||
|
return aStartsWith ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.index - b.index;
|
||||||
|
})
|
||||||
|
.slice(0, MAX_SKILL_SUGGESTIONS)
|
||||||
|
.map(({ skill }) => skill);
|
||||||
|
}
|
||||||
|
|
||||||
function getResolvedMode(
|
function getResolvedMode(
|
||||||
mode: InputMode | undefined,
|
mode: InputMode | undefined,
|
||||||
supportsThinking: boolean,
|
supportsThinking: boolean,
|
||||||
@@ -153,11 +198,17 @@ export function InputBox({
|
|||||||
const { models } = useModels();
|
const { models } = useModels();
|
||||||
const { thread, isMock } = useThread();
|
const { thread, isMock } = useThread();
|
||||||
const { textInput } = usePromptInputController();
|
const { textInput } = usePromptInputController();
|
||||||
|
const { skills } = useSkills();
|
||||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
const [followups, setFollowups] = useState<string[]>([]);
|
const [followups, setFollowups] = useState<string[]>([]);
|
||||||
const [followupsHidden, setFollowupsHidden] = useState(false);
|
const [followupsHidden, setFollowupsHidden] = useState(false);
|
||||||
const [followupsLoading, setFollowupsLoading] = useState(false);
|
const [followupsLoading, setFollowupsLoading] = useState(false);
|
||||||
|
const [textareaFocused, setTextareaFocused] = useState(false);
|
||||||
|
const [skillSuggestionIndex, setSkillSuggestionIndex] = useState(0);
|
||||||
|
const [dismissedSkillSuggestionValue, setDismissedSkillSuggestionValue] =
|
||||||
|
useState<string | null>(null);
|
||||||
const lastGeneratedForAiIdRef = useRef<string | null>(null);
|
const lastGeneratedForAiIdRef = useRef<string | null>(null);
|
||||||
const wasStreamingRef = useRef(false);
|
const wasStreamingRef = useRef(false);
|
||||||
const messagesRef = useRef(thread.messages);
|
const messagesRef = useRef(thread.messages);
|
||||||
@@ -347,9 +398,98 @@ export function InputBox({
|
|||||||
setTimeout(() => requestFormSubmit(), 0);
|
setTimeout(() => requestFormSubmit(), 0);
|
||||||
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
||||||
|
|
||||||
|
const slashSkillQuery = useMemo(
|
||||||
|
() => getLeadingSlashSkillQuery(textInput.value ?? ""),
|
||||||
|
[textInput.value],
|
||||||
|
);
|
||||||
|
const skillSuggestions = useMemo(
|
||||||
|
() =>
|
||||||
|
slashSkillQuery === null
|
||||||
|
? []
|
||||||
|
: getMatchingSkillSuggestions(skills, slashSkillQuery),
|
||||||
|
[skills, slashSkillQuery],
|
||||||
|
);
|
||||||
|
const showSkillSuggestions =
|
||||||
|
!disabled &&
|
||||||
|
textareaFocused &&
|
||||||
|
slashSkillQuery !== null &&
|
||||||
|
skillSuggestions.length > 0 &&
|
||||||
|
dismissedSkillSuggestionValue !== textInput.value;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSkillSuggestionIndex(0);
|
||||||
|
}, [slashSkillQuery, skillSuggestions.length]);
|
||||||
|
|
||||||
|
const applySkillSuggestion = useCallback(
|
||||||
|
(skill: Skill) => {
|
||||||
|
const nextValue = `/${skill.name} `;
|
||||||
|
textInput.setInput(nextValue);
|
||||||
|
setDismissedSkillSuggestionValue(nextValue);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(nextValue.length, nextValue.length);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[textInput],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSkillSuggestionKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (!showSkillSuggestions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
setSkillSuggestionIndex(
|
||||||
|
(index) => (index + 1) % skillSuggestions.length,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setSkillSuggestionIndex(
|
||||||
|
(index) =>
|
||||||
|
(index - 1 + skillSuggestions.length) % skillSuggestions.length,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Enter" || event.key === "Tab") {
|
||||||
|
if (event.shiftKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const selectedSkill = skillSuggestions[skillSuggestionIndex];
|
||||||
|
if (selectedSkill) {
|
||||||
|
applySkillSuggestion(selectedSkill);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
setDismissedSkillSuggestionValue(textInput.value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
applySkillSuggestion,
|
||||||
|
showSkillSuggestions,
|
||||||
|
skillSuggestionIndex,
|
||||||
|
skillSuggestions,
|
||||||
|
textInput.value,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const showFollowups =
|
const showFollowups =
|
||||||
!disabled &&
|
!disabled &&
|
||||||
!isWelcomeMode &&
|
!isWelcomeMode &&
|
||||||
|
!showSkillSuggestions &&
|
||||||
!followupsHidden &&
|
!followupsHidden &&
|
||||||
(followupsLoading || followups.length > 0);
|
(followupsLoading || followups.length > 0);
|
||||||
|
|
||||||
@@ -478,6 +618,48 @@ export function InputBox({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showSkillSuggestions && (
|
||||||
|
<div className="absolute right-0 bottom-full left-0 z-40 mb-2 px-1">
|
||||||
|
<div
|
||||||
|
aria-label="Skill suggestions"
|
||||||
|
className="bg-popover/95 text-popover-foreground border-border max-h-72 overflow-y-auto rounded-xl border p-1 shadow-lg backdrop-blur-sm"
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
{skillSuggestions.map((skill, index) => {
|
||||||
|
const selected = index === skillSuggestionIndex;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-selected={selected}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-12 w-full min-w-0 cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors",
|
||||||
|
selected
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-popover-foreground hover:bg-accent/70 hover:text-accent-foreground",
|
||||||
|
)}
|
||||||
|
key={skill.name}
|
||||||
|
onClick={() => applySkillSuggestion(skill)}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onMouseEnter={() => setSkillSuggestionIndex(index)}
|
||||||
|
role="option"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<SparklesIcon className="text-muted-foreground size-4 shrink-0" />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-medium">
|
||||||
|
/{skill.name}
|
||||||
|
</span>
|
||||||
|
{skill.description && (
|
||||||
|
<span className="text-muted-foreground block truncate text-xs">
|
||||||
|
{skill.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<PromptInput
|
<PromptInput
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background/85 rounded-2xl backdrop-blur-sm transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl",
|
"bg-background/85 rounded-2xl backdrop-blur-sm transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl",
|
||||||
@@ -506,6 +688,10 @@ export function InputBox({
|
|||||||
placeholder={t.inputBox.placeholder}
|
placeholder={t.inputBox.placeholder}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
defaultValue={initialValue}
|
defaultValue={initialValue}
|
||||||
|
onBlur={() => setTextareaFocused(false)}
|
||||||
|
onFocus={() => setTextareaFocused(true)}
|
||||||
|
onKeyDown={handleSkillSuggestionKeyDown}
|
||||||
|
ref={textareaRef}
|
||||||
/>
|
/>
|
||||||
</PromptInputBody>
|
</PromptInputBody>
|
||||||
<PromptInputFooter className="flex">
|
<PromptInputFooter className="flex">
|
||||||
@@ -860,11 +1046,13 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
</PromptInput>
|
</PromptInput>
|
||||||
|
|
||||||
{isWelcomeMode && searchParams.get("mode") !== "skill" && (
|
{isWelcomeMode &&
|
||||||
<div className="flex items-center justify-center pt-2">
|
searchParams.get("mode") !== "skill" &&
|
||||||
<SuggestionList />
|
!showSkillSuggestions && (
|
||||||
</div>
|
<div className="flex items-center justify-center pt-2">
|
||||||
)}
|
<SuggestionList />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -469,10 +469,14 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isHiddenFromUIMessage(message: Message) {
|
export function isHiddenFromUIMessage(message: Message) {
|
||||||
|
const content = extractTextFromMessage(message);
|
||||||
return (
|
return (
|
||||||
message.additional_kwargs?.hide_from_ui === true ||
|
message.additional_kwargs?.hide_from_ui === true ||
|
||||||
(typeof message.name === "string" &&
|
(typeof message.name === "string" &&
|
||||||
HIDDEN_CONTROL_MESSAGE_NAMES.has(message.name))
|
HIDDEN_CONTROL_MESSAGE_NAMES.has(message.name)) ||
|
||||||
|
(message.type === "human" &&
|
||||||
|
content.includes("<slash_skill_activation>") &&
|
||||||
|
stripUploadedFilesTag(content).length === 0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,12 +492,13 @@ export interface FileInMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip <uploaded_files> tag from message content.
|
* Strip backend-injected human context tags from message content.
|
||||||
* Returns the content with the tag removed.
|
* Kept under its historical name because callers use it for uploaded-file
|
||||||
|
* display cleanup.
|
||||||
*/
|
*/
|
||||||
export function stripUploadedFilesTag(content: string): string {
|
export function stripUploadedFilesTag(content: string): string {
|
||||||
return content
|
return content
|
||||||
.replace(/<uploaded_files>[\s\S]*?<\/uploaded_files>/g, "")
|
.replace(/<(uploaded_files|slash_skill_activation)>[\s\S]*?<\/\1>/g, "")
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,6 +509,7 @@ export function stripUploadedFilesTag(content: string): string {
|
|||||||
* These markers are *not* user copy — they come from:
|
* These markers are *not* user copy — they come from:
|
||||||
*
|
*
|
||||||
* - ``UploadsMiddleware`` → ``<uploaded_files>``
|
* - ``UploadsMiddleware`` → ``<uploaded_files>``
|
||||||
|
* - ``SkillActivationMiddleware`` → ``<slash_skill_activation>``
|
||||||
* - ``DynamicContextMiddleware`` → ``<system-reminder>`` (carrying
|
* - ``DynamicContextMiddleware`` → ``<system-reminder>`` (carrying
|
||||||
* ``<memory>`` / ``<current_date>`` inside)
|
* ``<memory>`` / ``<current_date>`` inside)
|
||||||
* - ``TodoListMiddleware`` / ``LoopDetectionMiddleware`` style reminders
|
* - ``TodoListMiddleware`` / ``LoopDetectionMiddleware`` style reminders
|
||||||
@@ -517,6 +523,7 @@ export function stripUploadedFilesTag(content: string): string {
|
|||||||
*/
|
*/
|
||||||
export const INTERNAL_MARKER_TAGS = [
|
export const INTERNAL_MARKER_TAGS = [
|
||||||
"uploaded_files",
|
"uploaded_files",
|
||||||
|
"slash_skill_activation",
|
||||||
"system-reminder",
|
"system-reminder",
|
||||||
"memory",
|
"memory",
|
||||||
"current_date",
|
"current_date",
|
||||||
|
|||||||
@@ -24,6 +24,61 @@ test.describe("Chat workspace", () => {
|
|||||||
await expect(textarea).toHaveValue("Hello, DeerFlow!");
|
await expect(textarea).toHaveValue("Hello, DeerFlow!");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("suggests matching skills after a leading slash", async ({ page }) => {
|
||||||
|
await page.goto("/workspace/chats/new");
|
||||||
|
|
||||||
|
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||||
|
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await textarea.fill("/dat");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("option", { name: /data-analysis/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("option", { name: /disabled-skill/i }),
|
||||||
|
).toBeHidden();
|
||||||
|
|
||||||
|
await textarea.press("Enter");
|
||||||
|
|
||||||
|
await expect(textarea).toHaveValue("/data-analysis ");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps Shift+Enter as newline while skill suggestions are visible", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/workspace/chats/new");
|
||||||
|
|
||||||
|
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||||
|
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await textarea.fill("/dat");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("option", { name: /data-analysis/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await textarea.press("Shift+Enter");
|
||||||
|
|
||||||
|
await expect(textarea).toHaveValue("/dat\n");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("option", { name: /data-analysis/i }),
|
||||||
|
).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not suggest skills for slash text away from the prompt start", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/workspace/chats/new");
|
||||||
|
|
||||||
|
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||||
|
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await textarea.fill("please /dat");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("option", { name: /data-analysis/i }),
|
||||||
|
).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
test("sending a message triggers API call and shows response", async ({
|
test("sending a message triggers API call and shows response", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -49,6 +104,150 @@ test.describe("Chat workspace", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("slash skill command is submitted as normal chat text", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const slashCommand = "/data-analysis analyze uploads/foo.csv";
|
||||||
|
let submittedText: string | undefined;
|
||||||
|
await page.route("**/runs/stream", (route) => {
|
||||||
|
const body = route.request().postDataJSON() as {
|
||||||
|
input?: { messages?: Array<{ content?: unknown }> };
|
||||||
|
};
|
||||||
|
const content = body.input?.messages?.at(-1)?.content;
|
||||||
|
if (typeof content === "string") {
|
||||||
|
submittedText = content;
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
submittedText = content
|
||||||
|
.map((block) =>
|
||||||
|
typeof block === "object" &&
|
||||||
|
block !== null &&
|
||||||
|
"text" in block &&
|
||||||
|
typeof block.text === "string"
|
||||||
|
? block.text
|
||||||
|
: "",
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
return handleRunStream(route);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/workspace/chats/new");
|
||||||
|
|
||||||
|
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||||
|
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await textarea.fill(slashCommand);
|
||||||
|
await textarea.press("Enter");
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => submittedText, { timeout: 10_000 })
|
||||||
|
.toBe(slashCommand);
|
||||||
|
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("slash skill command with attachment preserves command text and file metadata", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const slashCommand = "/data-analysis analyze report.docx";
|
||||||
|
let uploadCalled = false;
|
||||||
|
let submittedText: string | undefined;
|
||||||
|
let submittedFiles:
|
||||||
|
| Array<{ filename?: string; path?: string; status?: string }>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
await page.route("**/api/threads/*/uploads", async (route) => {
|
||||||
|
uploadCalled = true;
|
||||||
|
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.route("**/runs/stream", (route) => {
|
||||||
|
const body = route.request().postDataJSON() as {
|
||||||
|
input?: {
|
||||||
|
messages?: Array<{
|
||||||
|
content?: unknown;
|
||||||
|
additional_kwargs?: {
|
||||||
|
files?: Array<{
|
||||||
|
filename?: string;
|
||||||
|
path?: string;
|
||||||
|
status?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const message = body.input?.messages?.at(-1);
|
||||||
|
const content = message?.content;
|
||||||
|
if (typeof content === "string") {
|
||||||
|
submittedText = content;
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
submittedText = content
|
||||||
|
.map((block) =>
|
||||||
|
typeof block === "object" &&
|
||||||
|
block !== null &&
|
||||||
|
"text" in block &&
|
||||||
|
typeof block.text === "string"
|
||||||
|
? block.text
|
||||||
|
: "",
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
submittedFiles = message?.additional_kwargs?.files;
|
||||||
|
return handleRunStream(route);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/workspace/chats/new");
|
||||||
|
|
||||||
|
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||||
|
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await page.getByLabel("Upload files").setInputFiles({
|
||||||
|
name: "report.docx",
|
||||||
|
mimeType:
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
buffer: Buffer.from("fake docx"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await textarea.fill(slashCommand);
|
||||||
|
await textarea.press("Enter");
|
||||||
|
|
||||||
|
await expect.poll(() => uploadCalled, { timeout: 10_000 }).toBeTruthy();
|
||||||
|
await expect
|
||||||
|
.poll(() => submittedText, { timeout: 10_000 })
|
||||||
|
.toBe(slashCommand);
|
||||||
|
await expect
|
||||||
|
.poll(() => submittedFiles, { timeout: 10_000 })
|
||||||
|
.toEqual([
|
||||||
|
{
|
||||||
|
filename: "report.docx",
|
||||||
|
size: 12,
|
||||||
|
path: "/mnt/user-data/uploads/report.docx",
|
||||||
|
status: "uploaded",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps attachments visible while upload submit is pending", async ({
|
test("keeps attachments visible while upload submit is pending", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -35,11 +35,41 @@ export type MockAgent = {
|
|||||||
system_prompt?: string;
|
system_prompt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MockSkill = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category?: string;
|
||||||
|
license?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type MockAPIOptions = {
|
export type MockAPIOptions = {
|
||||||
threads?: MockThread[];
|
threads?: MockThread[];
|
||||||
agents?: MockAgent[];
|
agents?: MockAgent[];
|
||||||
|
skills?: MockSkill[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SKILLS: MockSkill[] = [
|
||||||
|
{
|
||||||
|
name: "data-analysis",
|
||||||
|
description: "Analyze structured data and produce charts.",
|
||||||
|
category: "public",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "frontend-design",
|
||||||
|
description: "Create polished frontend interfaces.",
|
||||||
|
category: "public",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disabled-skill",
|
||||||
|
description: "Hidden from slash autocomplete.",
|
||||||
|
category: "public",
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// mockLangGraphAPI
|
// mockLangGraphAPI
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -52,6 +82,7 @@ export type MockAPIOptions = {
|
|||||||
export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||||
const threads = options?.threads ?? [];
|
const threads = options?.threads ?? [];
|
||||||
const agents = options?.agents ?? [];
|
const agents = options?.agents ?? [];
|
||||||
|
const skills = options?.skills ?? DEFAULT_SKILLS;
|
||||||
|
|
||||||
// Thread search — sidebar thread list & chats list page
|
// Thread search — sidebar thread list & chats list page
|
||||||
void page.route("**/api/langgraph/threads/search", (route) => {
|
void page.route("**/api/langgraph/threads/search", (route) => {
|
||||||
@@ -259,6 +290,18 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
return route.fallback();
|
return route.fallback();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Skills list — settings page and slash autocomplete
|
||||||
|
void page.route("**/api/skills", (route) => {
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ skills }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
// Follow-up suggestions — input box auto-suggest after AI response
|
// Follow-up suggestions — input box auto-suggest after AI response
|
||||||
void page.route("**/api/threads/*/suggestions", (route) => {
|
void page.route("**/api/threads/*/suggestions", (route) => {
|
||||||
if (route.request().method() === "POST") {
|
if (route.request().method() === "POST") {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
hasContent,
|
hasContent,
|
||||||
hasReasoning,
|
hasReasoning,
|
||||||
isAssistantMessageGroupStreaming,
|
isAssistantMessageGroupStreaming,
|
||||||
|
stripUploadedFilesTag,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
|
|
||||||
function aiMessage(content: string): Message {
|
function aiMessage(content: string): Message {
|
||||||
@@ -173,6 +174,38 @@ describe("inline <think> tag splitting", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("human message internal context stripping", () => {
|
||||||
|
test("strips slash skill activation context from display content", () => {
|
||||||
|
const content =
|
||||||
|
"<slash_skill_activation>\n<skill_content># Secret SKILL.md</skill_content>\n</slash_skill_activation>\nreal user task";
|
||||||
|
|
||||||
|
expect(stripUploadedFilesTag(content)).toBe("real user task");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hides leaked slash skill activation messages with no user text", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: "slash-activation",
|
||||||
|
type: "human",
|
||||||
|
content:
|
||||||
|
"<slash_skill_activation>\n<skill_content># Secret SKILL.md</skill_content>\n</slash_skill_activation>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ai-1",
|
||||||
|
type: "ai",
|
||||||
|
content: "Public answer",
|
||||||
|
},
|
||||||
|
] as Message[];
|
||||||
|
|
||||||
|
const groups = getMessageGroups(messages);
|
||||||
|
|
||||||
|
expect(groups.map((group) => group.type)).toEqual(["assistant"]);
|
||||||
|
expect(
|
||||||
|
groups.flatMap((group) => group.messages).map((message) => message.id),
|
||||||
|
).toEqual(["ai-1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("hides internal todo reminder messages from message groups", () => {
|
test("hides internal todo reminder messages from message groups", () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -260,6 +260,22 @@ describe("formatThreadAsJSON", () => {
|
|||||||
expect(raw).toContain("real user text");
|
expect(raw).toContain("real user text");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips <slash_skill_activation> as defence in depth", () => {
|
||||||
|
// Slash activation normally rides in a hidden HumanMessage. If a replay
|
||||||
|
// or state merge loses the flag, export must still not leak full SKILL.md
|
||||||
|
// content into a user-visible transcript.
|
||||||
|
const leaky = human("real user task", {
|
||||||
|
id: "leak-slash-skill",
|
||||||
|
content:
|
||||||
|
"<slash_skill_activation>\n<skill_content># Secret SKILL.md\nUse internal source.</skill_content>\n</slash_skill_activation>\nreal user task",
|
||||||
|
} as unknown as Partial<Message>);
|
||||||
|
const raw = formatThreadAsJSON(makeThread(), [leaky]);
|
||||||
|
expect(raw).not.toContain("<slash_skill_activation>");
|
||||||
|
expect(raw).not.toContain("Secret SKILL.md");
|
||||||
|
expect(raw).not.toContain("internal source");
|
||||||
|
expect(raw).toContain("real user task");
|
||||||
|
});
|
||||||
|
|
||||||
it("sanitises tool message content when includeToolMessages is true", () => {
|
it("sanitises tool message content when includeToolMessages is true", () => {
|
||||||
const message = {
|
const message = {
|
||||||
id: "t-leak",
|
id: "t-leak",
|
||||||
|
|||||||
Reference in New Issue
Block a user