mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 13:39:26 +00:00
feat(channels): select custom agents per conversation (#5168)
* feat(channels): select custom agents per conversation Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> * fix(channels): reserve agent slash command across clients Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> * fix(tui): hide reserved slash commands from skills Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> * fix(channels): preserve selected agent across clients --------- Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
This commit is contained in:
parent
d7afdbf9a3
commit
e3df6ea4a8
@ -713,10 +713,15 @@ Once a channel is connected, you can interact with DeerFlow directly from the ch
|
||||
| `/status` | Show current thread info |
|
||||
| `/models` | List available models |
|
||||
| `/memory` | View memory |
|
||||
| `/agent list` | List your Custom Agents |
|
||||
| `/agent use <name>` | Start a new conversation with a Custom Agent |
|
||||
| `/help` | Show help |
|
||||
|
||||
> Messages without a command prefix are treated as regular chat — DeerFlow creates a thread and responds conversationally.
|
||||
|
||||
Agent selection is conversation-scoped: `/agent use <name>` starts a fresh conversation and pins that Custom Agent in the thread metadata. Existing conversations never switch agents midway, the selection survives a Gateway restart, and opening the IM-created thread in the Web UI continues through the same Custom Agent.
|
||||
Use `/agent use lead_agent` to return to the default agent in a new conversation.
|
||||
|
||||
#### Request Trace Correlation
|
||||
|
||||
Every Gateway HTTP response carries an `X-Trace-Id` header. The id is inherited
|
||||
|
||||
@ -7,7 +7,7 @@ Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk
|
||||
**Components**:
|
||||
- `message_bus.py` - Async pub/sub hub (`InboundMessage` → queue → dispatcher; `OutboundMessage` → callbacks → channels)
|
||||
- `store.py` - JSON-file persistence mapping `channel_name:chat_id[:topic_id]` → `thread_id` (keys are `channel:chat` for root conversations and `channel:chat:topic` for threaded conversations). Every access to `_data` must be protected by `_lock`; `list_entries()` snapshots keys and copied entries under the lock, then formats the result after releasing it so concurrent channel threads cannot resize the dictionary during iteration without extending the critical section.
|
||||
- `manager.py` - Core dispatcher: creates threads via `client.threads.create()`, routes commands including `/goal` (setting a goal persists it through Gateway and then routes the objective as a chat turn), keeps Slack/Discord on `client.runs.wait()`, uses `client.runs.stream(["messages-tuple", "values"])` for Feishu/Telegram incremental outbound updates, serializes same-thread Feishu turns in-manager when the channel's `ChannelRunPolicy.serialize_thread_runs=True` so rapid follow-ups queue instead of tripping the runtime busy reply, and switches to `client.runs.create()` (fire-and-forget, returns once the run is `pending`) for channels whose `ChannelRunPolicy.fire_and_forget=True` so long autonomous runs do not hit the SDK default 300s `httpx.ReadTimeout`
|
||||
- `manager.py` - Core dispatcher: creates threads via `client.threads.create()`, routes commands including `/goal` (setting a goal persists it through Gateway and then routes the objective as a chat turn) and `/agent` (`list` is owner-scoped; `use` creates a fresh thread and persists the Custom Agent selection under both the channel restart key and Web's canonical routing metadata so existing checkpoint lineage never changes agent across IM/Web), keeps Slack/Discord on `client.runs.wait()`, uses `client.runs.stream(["messages-tuple", "values"])` for Feishu/Telegram incremental outbound updates, serializes same-thread Feishu turns in-manager when the channel's `ChannelRunPolicy.serialize_thread_runs=True` so rapid follow-ups queue instead of tripping the runtime busy reply, and switches to `client.runs.create()` (fire-and-forget, returns once the run is `pending`) for channels whose `ChannelRunPolicy.fire_and_forget=True` so long autonomous runs do not hit the SDK default 300s `httpx.ReadTimeout`
|
||||
A swallowed streaming failure publishes its final outbound before releasing the inbound dedupe key, so a provider redelivery can retry without overtaking the terminal reply.
|
||||
**What may be published from the stream is an allowlist, not a denylist** (`_accumulate_stream_text`): only assistant message types — LangChain serializes `AIMessage.type` as `"ai"` and `AIMessageChunk.type` as `"AIMessageChunk"`, plus the OpenAI-style `"assistant"` spelling for foreign runtimes — become displayable text. The previous rule rejected only payloads whose `type` contained `"tool"` and therefore published everything else, which leaked DeerFlow's hidden model context to every streaming IM channel: `DynamicContextMiddleware` injects recalled memory as a hidden `HumanMessage` (`type == "human"`) and rewrites the user's own turn into a new `HumanMessage`, `DurableContextMiddleware` injects a hidden `<durable_context_data>` `HumanMessage`, and LangGraph fans those state writes out on the `messages-tuple` stream. Proved live on a Buzz relay, which published a `<memory>` fact block and, in another run, a verbatim echo of the user's own message as the assistant's reply. Matching is by prefix (`ai` / `assistant`), never substring, because ordinary words contain `"ai"` (`chain`, `domain`). The message type is resolved by `_stream_payload_type`, which handles both the `model_dump()` shape DeerFlow's own gateway emits and LangChain's `to_json()` constructor shape (whose top-level `type` is the literal `"constructor"`, with the class name at the tail of the `id` path). A bare `str` payload is no longer accepted at all: it carries no type information, so it cannot be attributed to the assistant, and nothing in DeerFlow produces one (`runtime/serialization.py::serialize_messages_tuple` always emits `[message_dict, metadata]`).
|
||||
- `base.py` - Abstract `Channel` base class (start/stop/send lifecycle). Provider callbacks that submit coroutines from SDK threads must use `_submit_threadsafe_coroutine()`: it creates and retains the real `asyncio.Task` on the owner loop instead of treating `run_coroutine_threadsafe()`'s proxy Future as a completion signal. Submission is closed atomically with shutdown, and `stop()` must call `_close_and_drain_threadsafe_futures()` before tearing down SDK resources.
|
||||
@ -33,7 +33,7 @@ Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk
|
||||
7. Feishu channel sends one running reply card up front, then patches the same card for each outbound update (card JSON sets `config.update_multi=true` for Feishu's patch API requirement). Messages already sent inside an existing Feishu topic carry a compact source-message preview in that card, and queued same-thread follow-ups patch their own source message's card from queued → running → final without falling back to the generic busy reply.
|
||||
8. Telegram streaming: the "Working on it..." placeholder message is registered as the stream target; non-final updates `editMessageText` it in place (channel-side throttle: 1s in private chats, 3s in groups due to Telegram's 20 msg/min group cap; 4096-char truncation; rate-limited updates dropped); the final update performs the last edit and splits >4096 texts into follow-up messages
|
||||
9. DingTalk AI Card mode (when `card_template_id` configured): `runs.stream()` → create card with initial text → stream updates via `PUT /v1.0/card/streaming` → finalize on `is_final=True`. Falls back to `sampleMarkdown` if card creation or streaming fails
|
||||
10. For commands (`/new`, `/status`, `/models`, `/memory`, `/goal`, `/help`): handle locally or query Gateway API
|
||||
10. For commands (`/new`, `/status`, `/models`, `/memory`, `/goal`, `/agent`, `/help`): handle locally or query Gateway API. `/agent list` reads only the effective owner's Custom Agents; `/agent use <name>` validates in the same owner bucket, creates a new thread, and persists `channel_agent_name` in its metadata. A Custom Agent also writes canonical `metadata.agent_name`, which makes thread-search results route Web continuation through `/workspace/agents/<name>/chats/<thread_id>`; `lead_agent` deliberately omits that canonical key and stays on the ordinary chat route. The manager caches `channel_agent_name` for the hot path and reloads it after restart before routing a resumed turn. An explicit selection also normalizes `agent_name` across top-level run context plus the existing RunnableConfig `context` and `configurable` carriers (or clears all three for `lead_agent`) before Gateway's `setdefault` compatibility merge, so stale channel defaults cannot silently win.
|
||||
11. Outbound → channel callbacks → platform reply
|
||||
- GitHub is the exception: the channel logs the final assistant message and does **not** auto-post it to GitHub. Agents use the sandbox `gh` CLI (`gh issue comment`, `gh pr comment`, `gh pr create`, etc.) for intentional writeback, so silence is cheap when several agents fan out on the same event.
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
KNOWN_CHANNEL_COMMANDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"/agent",
|
||||
"/bootstrap",
|
||||
"/goal",
|
||||
"/new",
|
||||
|
||||
@ -40,7 +40,7 @@ from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, gene
|
||||
# ChannelManager construction sees the same policy map as gateway bootstrap.
|
||||
from app.gateway.github import run_policy as _github_run_policy # noqa: F401
|
||||
from app.gateway.internal_auth import create_internal_auth_headers
|
||||
from deerflow.config.agents_config import load_agent_config
|
||||
from deerflow.config.agents_config import list_custom_agents, load_agent_config
|
||||
from deerflow.config.paths import make_safe_user_id
|
||||
from deerflow.runtime import END_SENTINEL, StreamBridge
|
||||
from deerflow.runtime.goal import parse_goal_command
|
||||
@ -59,6 +59,10 @@ DEFAULT_ASSISTANT_ID = "lead_agent"
|
||||
DEFAULT_CHANNEL_MAX_CONCURRENCY = 5
|
||||
DEFAULT_CHANNEL_SHUTDOWN_GRACE_PERIOD_SECONDS = 3.0
|
||||
CUSTOM_AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
|
||||
CHANNEL_AGENT_METADATA_KEY = "channel_agent_name"
|
||||
THREAD_AGENT_METADATA_KEY = "agent_name"
|
||||
MAX_CHANNEL_AGENT_LIST_ITEMS = 50
|
||||
MAX_CHANNEL_AGENT_DESCRIPTION_CHARS = 120
|
||||
|
||||
# Lead-agent recursion budget (LangGraph super-steps for the lead graph only).
|
||||
# This is independent of subagent depth: a `task()` dispatch runs the whole
|
||||
@ -341,6 +345,37 @@ def _normalize_custom_agent_name(raw_value: str) -> str:
|
||||
return normalized
|
||||
|
||||
|
||||
def _apply_explicit_agent_choice(
|
||||
run_config: dict[str, Any],
|
||||
run_context: dict[str, Any],
|
||||
agent_name: str | None,
|
||||
) -> None:
|
||||
"""Pin or clear an explicit channel agent in every runtime carrier.
|
||||
|
||||
Gateway accepts ``agent_name`` from the request's top-level context and
|
||||
from either RunnableConfig container. Its compatibility merge preserves
|
||||
existing values with ``setdefault``, so an explicit ``/agent use`` choice
|
||||
must normalize all three carriers before the request crosses that boundary.
|
||||
``None`` represents an explicit reset to the default lead agent.
|
||||
"""
|
||||
carriers = [run_context]
|
||||
for section in ("configurable", "context"):
|
||||
value = run_config.get(section)
|
||||
if isinstance(value, Mapping):
|
||||
# Session layers own their nested dictionaries. Copy before changing
|
||||
# one so selecting an agent for a conversation cannot mutate the
|
||||
# manager's reusable channel configuration.
|
||||
copied = dict(value)
|
||||
run_config[section] = copied
|
||||
carriers.append(copied)
|
||||
|
||||
for carrier in carriers:
|
||||
if agent_name is None:
|
||||
carrier.pop("agent_name", None)
|
||||
else:
|
||||
carrier["agent_name"] = agent_name
|
||||
|
||||
|
||||
def _extract_response_text(result: dict | list) -> str:
|
||||
"""Extract the last AI message text from a LangGraph runs.wait result.
|
||||
|
||||
@ -1025,6 +1060,11 @@ class ChannelManager:
|
||||
self._get_stream_bridge = get_stream_bridge
|
||||
self._client = None # lazy init — langgraph_sdk async client
|
||||
self._channel_metadata_synced: set[str] = set()
|
||||
# Explicit /agent selections are pinned to the newly-created thread.
|
||||
# Cache the durable thread metadata so the hot path does not GET the
|
||||
# same thread before every turn; None distinguishes a checked default
|
||||
# thread from a thread that has not been inspected yet.
|
||||
self._thread_agent_names: dict[str, str | None] = {}
|
||||
# Per-conversation locks so concurrent inbound messages for the same
|
||||
# chat don't race to create duplicate threads (see _get_or_create_thread).
|
||||
self._thread_create_locks: dict[tuple[str, str, str | None], asyncio.Lock] = {}
|
||||
@ -1410,7 +1450,8 @@ class ChannelManager:
|
||||
if isinstance(meta_assistant_id, str) and meta_assistant_id.strip():
|
||||
message_assistant_id = meta_assistant_id
|
||||
|
||||
assistant_id = message_assistant_id or user_layer.get("assistant_id") or channel_layer.get("assistant_id") or self._default_session.get("assistant_id") or self._assistant_id
|
||||
thread_assistant_id = self._thread_agent_names.get(thread_id)
|
||||
assistant_id = message_assistant_id or thread_assistant_id or user_layer.get("assistant_id") or channel_layer.get("assistant_id") or self._default_session.get("assistant_id") or self._assistant_id
|
||||
if not isinstance(assistant_id, str) or not assistant_id.strip():
|
||||
assistant_id = self._assistant_id
|
||||
|
||||
@ -1461,12 +1502,23 @@ class ChannelManager:
|
||||
run_context_identity,
|
||||
)
|
||||
|
||||
explicit_agent_choice = message_assistant_id is not None or thread_assistant_id is not None
|
||||
# Custom agents are implemented as lead_agent + agent_name context.
|
||||
# Keep backward compatibility for channel configs that set
|
||||
# assistant_id: <custom-agent-name> by routing through lead_agent.
|
||||
if assistant_id != DEFAULT_ASSISTANT_ID:
|
||||
run_context.setdefault("agent_name", _normalize_custom_agent_name(assistant_id))
|
||||
normalized_agent_name = _normalize_custom_agent_name(assistant_id)
|
||||
if explicit_agent_choice:
|
||||
_apply_explicit_agent_choice(run_config, run_context, normalized_agent_name)
|
||||
else:
|
||||
run_context.setdefault("agent_name", normalized_agent_name)
|
||||
assistant_id = DEFAULT_ASSISTANT_ID
|
||||
elif explicit_agent_choice:
|
||||
# An explicit lead_agent selection is also a real pin: discard a
|
||||
# configured agent in every Gateway-supported carrier so
|
||||
# /agent use lead_agent cannot claim to reset the conversation
|
||||
# while silently routing elsewhere.
|
||||
_apply_explicit_agent_choice(run_config, run_context, None)
|
||||
|
||||
# Apply per-channel run policy (recursion_limit bump for webhook
|
||||
# channels, etc.). Looking the policy up by channel_name keeps
|
||||
@ -1535,8 +1587,13 @@ class ChannelManager:
|
||||
)
|
||||
return policy
|
||||
|
||||
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 ""
|
||||
def _resolve_available_skill_names(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
thread_id: str | None = None,
|
||||
) -> set[str] | None:
|
||||
if thread_id is 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"}
|
||||
@ -1976,9 +2033,52 @@ class ChannelManager:
|
||||
user_id=msg.user_id,
|
||||
)
|
||||
|
||||
async def _create_thread(self, client, msg: InboundMessage) -> str:
|
||||
def _remember_thread_agent(self, thread_id: str, agent_name: str | None) -> None:
|
||||
if len(self._thread_agent_names) > 4096:
|
||||
self._thread_agent_names.clear()
|
||||
self._thread_agent_names[thread_id] = agent_name
|
||||
|
||||
async def _load_thread_agent(self, client, msg: InboundMessage, thread_id: str) -> str | None:
|
||||
"""Load an explicit channel agent selection from durable thread metadata."""
|
||||
if thread_id in self._thread_agent_names:
|
||||
return self._thread_agent_names[thread_id]
|
||||
|
||||
get_kwargs: dict[str, Any] = {}
|
||||
if owner_headers := _owner_headers(msg):
|
||||
get_kwargs["headers"] = owner_headers
|
||||
thread = await client.threads.get(thread_id, **get_kwargs)
|
||||
metadata = thread.get("metadata") if isinstance(thread, Mapping) else None
|
||||
raw_agent_name = metadata.get(CHANNEL_AGENT_METADATA_KEY) if isinstance(metadata, Mapping) else None
|
||||
agent_name: str | None = None
|
||||
if isinstance(raw_agent_name, str) and raw_agent_name.strip():
|
||||
if raw_agent_name.strip().lower() == DEFAULT_ASSISTANT_ID:
|
||||
agent_name = DEFAULT_ASSISTANT_ID
|
||||
else:
|
||||
try:
|
||||
agent_name = _normalize_custom_agent_name(raw_agent_name)
|
||||
except InvalidChannelSessionConfigError as exc:
|
||||
raise InvalidChannelSessionConfigError("This conversation has an invalid stored agent selection. Use /agent use <name> to start a valid conversation.") from exc
|
||||
self._remember_thread_agent(thread_id, agent_name)
|
||||
return agent_name
|
||||
|
||||
async def _create_thread(
|
||||
self,
|
||||
client,
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
agent_name: str | None = None,
|
||||
) -> str:
|
||||
"""Create a new thread through Gateway and store the mapping."""
|
||||
metadata = _thread_channel_metadata(msg)
|
||||
if agent_name is not None:
|
||||
metadata[CHANNEL_AGENT_METADATA_KEY] = agent_name
|
||||
# Web thread search returns metadata but no run context. Persist the
|
||||
# canonical key consumed by ``pathOfThread`` so opening this IM
|
||||
# conversation in the browser keeps the same custom agent. The lead
|
||||
# agent deliberately has no canonical key: it uses the ordinary chat
|
||||
# route rather than a non-existent custom-agent route.
|
||||
if agent_name != DEFAULT_ASSISTANT_ID:
|
||||
metadata[THREAD_AGENT_METADATA_KEY] = agent_name
|
||||
owner_headers = _owner_headers(msg)
|
||||
# Some channels (notably GitHub) supply a deterministic preferred
|
||||
# thread id so a (repo, PR/issue number) always lands on the same
|
||||
@ -2035,9 +2135,11 @@ class ChannelManager:
|
||||
exc.__class__.__name__,
|
||||
)
|
||||
await self._store_thread_id(msg, preferred_thread_id)
|
||||
self._remember_thread_agent(preferred_thread_id, agent_name)
|
||||
return preferred_thread_id
|
||||
thread_id = thread["thread_id"]
|
||||
await self._store_thread_id(msg, thread_id)
|
||||
self._remember_thread_agent(thread_id, agent_name)
|
||||
logger.info("[Manager] new thread created through Gateway: thread_id=%s for chat_id=%s topic_id=%s", thread_id, msg.chat_id, msg.topic_id)
|
||||
return thread_id
|
||||
|
||||
@ -2116,6 +2218,7 @@ class ChannelManager:
|
||||
if not created:
|
||||
logger.info("[Manager] reusing thread: thread_id=%s for topic_id=%s", thread_id, msg.topic_id)
|
||||
await self._update_thread_channel_metadata(client, msg, thread_id)
|
||||
await self._load_thread_agent(client, msg, thread_id)
|
||||
|
||||
serial_state, queued = self._begin_serialized_thread_run(
|
||||
channel_name=msg.channel_name,
|
||||
@ -2490,6 +2593,8 @@ class ChannelManager:
|
||||
reply = await self._fetch_gateway("/api/models", "models", msg=msg)
|
||||
elif reply is None and command == "memory":
|
||||
reply = await self._fetch_gateway("/api/memory", "memory", msg=msg)
|
||||
elif reply is None and command == "agent":
|
||||
reply = await self._handle_agent_command(msg, parts[1] if len(parts) > 1 else "")
|
||||
elif reply is None and command == "goal":
|
||||
reply = await self._handle_goal_command(msg, parts[1] if len(parts) > 1 else "")
|
||||
if reply is None:
|
||||
@ -2503,14 +2608,19 @@ class ChannelManager:
|
||||
"/status — Show current thread info\n"
|
||||
"/models — List available models\n"
|
||||
"/memory — Show memory status\n"
|
||||
"/agent list — List your Custom Agents\n"
|
||||
"/agent use <name> — Start a new conversation with an agent\n"
|
||||
"/<skill-name> <task> — Activate an enabled skill for one turn\n"
|
||||
"/help — Show this help"
|
||||
)
|
||||
elif reply is None:
|
||||
thread_id = await self._lookup_thread_id(msg)
|
||||
if thread_id:
|
||||
await self._load_thread_agent(self._get_client(), msg, thread_id)
|
||||
slash_resolution = await asyncio.to_thread(
|
||||
lambda: _resolve_slash_skill_command(
|
||||
raw_text,
|
||||
self._resolve_available_skill_names(msg),
|
||||
self._resolve_available_skill_names(msg, thread_id),
|
||||
self._get_skill_storage,
|
||||
)
|
||||
)
|
||||
@ -2537,6 +2647,54 @@ class ChannelManager:
|
||||
)
|
||||
await self.bus.publish_outbound(outbound)
|
||||
|
||||
async def _handle_agent_command(self, msg: InboundMessage, args: str) -> str:
|
||||
"""List owner-scoped agents or pin one to a fresh conversation."""
|
||||
parts = args.split()
|
||||
if len(parts) == 1 and parts[0].lower() == "list":
|
||||
user_id = _channel_storage_user_id(msg)
|
||||
try:
|
||||
agents = await asyncio.to_thread(list_custom_agents, user_id=user_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to list custom agents for channel command")
|
||||
return "Failed to list agents."
|
||||
|
||||
rows = ["• lead_agent — Default agent"]
|
||||
sorted_agents = sorted(agents, key=lambda agent: agent.name)
|
||||
for agent in sorted_agents[:MAX_CHANNEL_AGENT_LIST_ITEMS]:
|
||||
description = " ".join((agent.description or "").split())[:MAX_CHANNEL_AGENT_DESCRIPTION_CHARS]
|
||||
rows.append(f"• {agent.name} — {description}" if description else f"• {agent.name}")
|
||||
if len(sorted_agents) > MAX_CHANNEL_AGENT_LIST_ITEMS:
|
||||
rows.append(f"… and {len(sorted_agents) - MAX_CHANNEL_AGENT_LIST_ITEMS} more")
|
||||
return "Available agents:\n" + "\n".join(rows)
|
||||
|
||||
if len(parts) == 2 and parts[0].lower() == "use":
|
||||
raw_name = parts[1]
|
||||
if raw_name.lower() == DEFAULT_ASSISTANT_ID:
|
||||
agent_name = DEFAULT_ASSISTANT_ID
|
||||
display_name = DEFAULT_ASSISTANT_ID
|
||||
else:
|
||||
try:
|
||||
agent_name = _normalize_custom_agent_name(raw_name)
|
||||
except InvalidChannelSessionConfigError:
|
||||
return "Invalid agent name. Use letters, digits, and hyphens only."
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
load_agent_config,
|
||||
agent_name,
|
||||
user_id=_channel_storage_user_id(msg),
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return f"Agent '{agent_name}' was not found. Use /agent list to see available agents."
|
||||
except Exception:
|
||||
logger.exception("Failed to load custom agent for channel command")
|
||||
return f"Failed to select agent '{agent_name}'."
|
||||
display_name = agent_name
|
||||
|
||||
await self._create_thread(self._get_client(), msg, agent_name=agent_name)
|
||||
return f"Agent '{display_name}' selected. New conversation started."
|
||||
|
||||
return "Usage: /agent list or /agent use <name>"
|
||||
|
||||
async def _goal_request(
|
||||
self,
|
||||
method: str,
|
||||
|
||||
@ -112,6 +112,7 @@ class TelegramChannel(Channel):
|
||||
app.add_handler(CommandHandler("status", self._cmd_generic))
|
||||
app.add_handler(CommandHandler("models", self._cmd_generic))
|
||||
app.add_handler(CommandHandler("memory", self._cmd_generic))
|
||||
app.add_handler(CommandHandler("agent", self._cmd_generic))
|
||||
app.add_handler(CommandHandler("goal", self._cmd_generic))
|
||||
app.add_handler(CommandHandler("help", self._cmd_generic))
|
||||
|
||||
|
||||
@ -16,6 +16,13 @@ A user-owned IM channel connection is a **per-DeerFlow-user bind layer** layered
|
||||
2. **One-time bind codes** — the browser Connect flow mints a short-lived `secrets.token_urlsafe(16)` code (600 s TTL, single-use) and surfaces it only in the initiating user's browser. The platform worker consumes `/connect <code>` (Telegram uses `/start <code>` over a deep link) before applying any `allowed_users` filter, so a not-yet-allowlisted user can complete their first bind.
|
||||
3. **Strict ownership transfer** — the latest successful bind wins; `upsert_connection` revokes other owners' active rows for the same external identity. The DB-enforced partial unique index `uq_channel_connection_active_identity` (`WHERE status != 'revoked'`) makes the invariant race-free across concurrent writers.
|
||||
|
||||
### Conversation-scoped Custom Agents
|
||||
|
||||
Connected users can run `/agent list` to inspect the Custom Agents in their own DeerFlow user bucket, then `/agent use <name>` to start a new conversation with one.
|
||||
The selection is written to the new Gateway thread's channel metadata and, for a Custom Agent, to the canonical `agent_name` routing metadata used by the Web UI. It is cached by `ChannelManager` for subsequent turns; on restart, the manager reads the channel metadata before the first resumed turn. Opening that thread from Web search therefore continues under the same Custom Agent instead of falling back to the default runtime.
|
||||
Because selecting an agent always creates a new thread instead of mutating the current one, an existing conversation keeps its original runtime, prompt, skills, and checkpoint lineage.
|
||||
`/agent use lead_agent` starts a new conversation with the default agent.
|
||||
|
||||
Connect codes are deliberately **bind-time defenses**, not chat-time defenses. After binding, ordinary `allowed_users` continue to gate regular messages exactly as before.
|
||||
|
||||
## Connect-code Flow
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
- **Deferred discovery** (`skills.deferred_discovery: true`): Skills are listed by name only in a compact `<skill_index>` block, keeping the system prompt prefix-cache friendly. The agent calls the `describe_skill` tool at runtime to fetch full metadata for skills it wants to use, then loads the SKILL.md via `read_file`. Two new modules support this path:
|
||||
- `skills/catalog.py` — `SkillCatalog` (immutable, searchable; query forms: `select:a,b`, `+prefix`, free-text regex); `select:` returns all requested skills without a result cap; other modes cap at `MAX_RESULTS=5`.
|
||||
- `skills/describe.py` — `build_describe_skill_tool(catalog)` builds the `describe_skill` tool as a closure; `build_skill_search_setup(skills, enabled, ...)` produces a `SkillSearchSetup(describe_skill_tool, skill_names)` that is wired into both the LangGraph agent factory (`agent.py`) and the embedded client (`client.py`).
|
||||
- **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`, `/goal`), disabled skills, and skills outside a custom agent's whitelist.
|
||||
- **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`, `/goal`, `/agent`), disabled skills, and skills outside a custom agent's whitelist.
|
||||
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
|
||||
- **Managed integrations**: Lark/Feishu CLI support installs one global official `lark-*` pack as read-only `SkillCategory.INTEGRATION` entries under `/mnt/skills/integrations/lark-cli/...`; enabled flags, app configuration, and OAuth data remain per-user. Install resolves the newest `larksuite/cli` release from GitHub (`releases/latest`) at install time (falling back to a bottom-line pinned version if the lookup fails) rather than hard-coding the pack version; integrity relies on the official host + structural archive guards + a recorded hash of the effective installed tree after shared guidance injection (not a pinned archive-byte SHA, which GitHub does not keep stable). The Gateway image still installs a pinned `@larksuite/cli` binary, so `get_lark_integration_status` surfaces `latest_available_version` and `runtime_version_mismatch` for the UI. AIO installs additionally verify and publish official Linux amd64/arm64 binaries under `{DEER_FLOW_HOME}/integrations/lark-cli/sandbox-cli`, mounted read-only at `/mnt/integrations/lark-cli/runtime`; `/mnt/integrations/lark-cli/config` (app credentials, incl. the long-lived `appSecret`) is mounted **read-only** into the sandbox, its empty `config/locks` subdirectory is over-mounted writable for `lark-cli` coordination files, and `/mnt/integrations/lark-cli/data` (refreshable OAuth tokens) stays writable, all mapping to owner-only per-user directories. **Sandbox trust boundary:** the credential-bearing config and data dirs are still *readable* by arbitrary sandbox processes (the agent's `bash` tool, or code reached via prompt-injection in a tool result), so the app secret and tokens are exposed to sandbox-side code even though they never reach the browser — the read-only config mount only prevents in-sandbox tampering, not read/exfiltration. The sidecar credential-broker (Pattern B, issue #4338) is the fix that removes these plaintext mounts from sandbox execution: set `LARK_CLI_BROKER_IMAGE` on the provisioner (see `docker/lark-cli-broker/`) and the Gateway sends `provision_lark_cli_broker` on sandbox create. The provisioner then runs a `lark-cli-broker` sidecar that owns the per-user `config`/`config/locks`/`data` mounts (mounted into the **sidecar only**, at `/var/lark/{config,config/locks,data}` with only the nested locks mount writable) and serves the `lark-cli` command surface on Pod loopback (`http://127.0.0.1:8788`); a shim init container (`install-shim`) writes a forwarding `lark-cli` into the shared runtime `emptyDir`, so the sandbox gets `DEERFLOW_LARK_BROKER_URL` + a shim on PATH but **no** credential files. The on-PATH `bin/lark-cli` is a `/bin/sh` launcher that resolves a Python 3 interpreter and execs the Python shim body (`bin/lark-cli-shim.py`) beside it, so broker mode does not silently ENOEXEC on a sandbox image without a `#!/usr/bin/env python3`-resolvable interpreter — it fails loudly (exit 127, actionable message) and can be pinned with `DEERFLOW_LARK_BROKER_PYTHON`. The broker runs `lark-cli` in the sidecar's cwd and cannot see the sandbox filesystem, so cwd is intentionally **not** forwarded and file-I/O subcommands relative to the sandbox cwd are unsupported (command surface only). An optional `DEERFLOW_LARK_BROKER_DENY_SUBCOMMANDS` denylist (comma-separated command prefixes, forwarded from the provisioner) lets the broker refuse secret-dumping subcommands before spawning the binary. `lark_cli_env_overlay(broker=True)` therefore omits `LARKSUITE_CLI_CONFIG_DIR`/`DATA_DIR`; `sandbox_lark_broker_active()` (TTL-cached provisioner `/api/capabilities` probe, tight timeout + longer negative caching on the bash hot path) selects broker vs. binary mode for both the bash env overlay and status. `DEER_FLOW_LARK_CLI_SANDBOX_RUNTIME_DIR` supplies a validated, symlink-free pre-staged runtime for air-gapped deployments. For the remote provisioner (K8s), the runtime binary is otherwise provisioned by an optional init container + shared `emptyDir` (Pattern A): set `LARK_CLI_INIT_IMAGE` on the provisioner (see `docker/lark-cli-init/`) and the Gateway sends `provision_lark_cli_runtime` on sandbox create once the pack is installed, so remote installs skip the Gateway-side GitHub download entirely. Broker (Pattern B) supersedes the init-container binary (Pattern A) when both images are configured. `get_lark_integration_status(check_runtime=True)` surfaces `sandbox_runtime_mode` (`none` / `gateway-download` / `init-container` / `broker`) and `sandbox_runtime_ready` (remote modes read the provisioner `GET /api/capabilities`: `lark_cli_init_image` / `lark_cli_broker_image`) so a green UI can't hide a chat-time `lark-cli: command not found`. Cheap status probes are explicitly not live-verified; users authorize or reconnect through the browser device-flow endpoints instead of running terminal commands.
|
||||
- **SkillScan**: `packages/harness/deerflow/skills/skillscan/` is the native deterministic scanner for `.skill` archives and agent-managed skill writes. It runs offline before the LLM scanner, emits structured findings (`rule_id`, `severity`, `file`, `line`, `message`, `remediation`, redacted `evidence` — category/analyzer are encoded in the `rule_id` prefix), blocks `CRITICAL`, and passes warning findings into `scan_skill_content()`. The moderation adapter must normalize both plain-text responses and LangChain Responses API text blocks before parsing the required JSON decision. `scan_archive_preflight()` / `scan_skill_dir()` are pure sync functions (dispatch off the event loop); `enforce_static_scan()` applies the blocking policy and the `skill_scan.enabled` kill switch. The Python instance-client signal deliberately follows only a one-level, same-scope evidence chain (PR #4265 review): a proven imported constructor bound to a simple name, optional name-to-name alias propagation, rebinding invalidation, and a constructor-supported outbound method or context-manager use; bare canonical-looking names never fall back to module identity. Nested scopes never inherit client handles and inherit only constructor aliases proven stable by a binding-only enclosing-scope prepass. Comprehensions, walrus-bearing statements, annotations, executable expressions inside complex binding targets, unsupported operations, and ambiguous flows produce no finding from this signal; skipped constructs invalidate all names they may bind, while representative false negatives are pinned by `test_python_declared_false_negatives_stay_unreported`. Compound bodies are walked from isolated copies so wrapping code in `if True:` is not a bypass, while copied scope entries, binding-only prepasses, and AST visits consume a deterministic work budget and the walk stops after its first sink. Budget or recursion exhaustion skips only this best-effort signal and retains deterministic findings already collected for the file. Do not add Semgrep/OpenGrep or YAML rule-engine dependencies to the core path; Phase 1 rule specs live in Python constants next to their analyzers in `skillscan/orchestrator.py`.
|
||||
|
||||
@ -14,7 +14,7 @@ from deerflow.skills.types import Skill
|
||||
#: (``tests/test_slash_skill_contract.py`` here, ``slash-contract.test.ts`` on
|
||||
#: the frontend), so a reserved command or grammar change in only one language
|
||||
#: fails CI.
|
||||
RESERVED_SLASH_SKILL_NAMES = frozenset({"bootstrap", "goal", "help", "memory", "models", "new", "status"})
|
||||
RESERVED_SLASH_SKILL_NAMES = frozenset({"agent", "bootstrap", "goal", "help", "memory", "models", "new", "status"})
|
||||
_SLASH_SKILL_RE = re.compile(r"^/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+|$)")
|
||||
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ A terminal-native UI over the embedded harness, exposed as the `deerflow` consol
|
||||
- `cli.py` — `plan_launch()` (pure launch-mode decision) + headless `--print` / `--json` + `main()` entry point. TTY → TUI, else headless help. `--tui-transparent` / `DEER_FLOW_TUI_TRANSPARENT` opt into terminal-default backgrounds without changing the solid-theme default. Uses an **absolute** `from deerflow.tui.app import run_tui` so the `app.py` module name doesn't trip `test_harness_boundary.py` (which records relative import module names verbatim).
|
||||
- `view_state.py` — `ViewState` + `reduce(state, action)`, the testable heart. Rows: user / assistant / tool / system. Title captured from `values` events.
|
||||
- `runtime.py` — `translate(StreamEvent) -> [Action]` (pure) + `stream_actions()` which brackets a run with `RunStarted`/`RunEnded` and turns model errors into an `AssistantError` row.
|
||||
- `message_format.py` / `command_registry.py` / `input_history.py` / `render.py` / `theme.py` — pure helpers (tool summaries, slash registry + `resolve()`, ↑/↓ history, Rich renderers).
|
||||
- `message_format.py` / `command_registry.py` / `input_history.py` / `render.py` / `theme.py` — pure helpers (tool summaries, slash registry + `resolve()`, ↑/↓ history, Rich renderers). The command registry must exclude the shared `RESERVED_SLASH_SKILL_NAMES` from both its picker and resolver so the TUI cannot advertise a skill activation that the agent runtime rejects.
|
||||
- `app.py` — Textual `App`. Runs `DeerFlowClient.stream()` (sync) on a worker thread and marshals actions to the UI thread via `call_from_thread`. Slash palette with `/goal` management + model/thread modal pickers; routes idle display-only `/clear` through `ClearRows` without replacing the active thread, and blocks state-resetting local commands like `/new` and `/clear` with the standard "Still working" message during an active run; priority key bindings gated by `check_action` so they never steal keys from overlays or the composer. Application-level PageUp/PageDown bindings scroll the transcript while preserving composer focus; streaming follows output only while the transcript remains at the bottom.
|
||||
- `session.py` / `persistence.py` — builds the client + checkpointer and the `ThreadMetaWriter`.
|
||||
|
||||
|
||||
@ -16,6 +16,8 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from deerflow.skills.slash import RESERVED_SLASH_SKILL_NAMES
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Command:
|
||||
@ -75,7 +77,7 @@ def build_registry(skills: list[dict]) -> list[Command]:
|
||||
if not skill.get("enabled", False):
|
||||
continue
|
||||
name = skill.get("name")
|
||||
if not name or name in _BUILTIN_NAMES:
|
||||
if not name or name in _BUILTIN_NAMES or name in RESERVED_SLASH_SKILL_NAMES:
|
||||
continue
|
||||
commands.append(Command(name=name, description=skill.get("description", "") or "", category="skill"))
|
||||
return commands
|
||||
@ -122,7 +124,7 @@ def resolve(text: str, skills: list[str] | None = None) -> Resolution:
|
||||
if name in _BUILTIN_NAMES:
|
||||
return Resolution(kind="builtin", name=name, args=args)
|
||||
|
||||
if skills and name in skills:
|
||||
if skills and name in skills and name not in RESERVED_SLASH_SKILL_NAMES:
|
||||
return Resolution(kind="skill", name=name, args=args)
|
||||
|
||||
return Resolution(kind="unknown", name=name, args=args)
|
||||
|
||||
@ -31,6 +31,7 @@ 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("/agent list")
|
||||
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")
|
||||
@ -2425,6 +2426,7 @@ class TestChannelManager:
|
||||
manager._get_client = MagicMock(return_value=object())
|
||||
manager._get_or_create_thread = AsyncMock(return_value=(thread_id, False))
|
||||
manager._update_thread_channel_metadata = AsyncMock()
|
||||
manager._load_thread_agent = AsyncMock(return_value=None)
|
||||
manager._publish_progress_update = AsyncMock(side_effect=asyncio.CancelledError())
|
||||
manager._handle_chat_on_thread = AsyncMock()
|
||||
|
||||
@ -3138,6 +3140,250 @@ class TestChannelManager:
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_handle_command_agent_list_is_owner_scoped(self, monkeypatch):
|
||||
from app.channels.manager import ChannelManager
|
||||
|
||||
seen_user_ids = []
|
||||
|
||||
def fake_list_custom_agents(*, user_id=None):
|
||||
seen_user_ids.append(user_id)
|
||||
return [
|
||||
SimpleNamespace(name="researcher", description="Researches sources"),
|
||||
SimpleNamespace(name="writer", description=""),
|
||||
]
|
||||
|
||||
monkeypatch.setattr("app.channels.manager.list_custom_agents", fake_list_custom_agents)
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
manager = ChannelManager(bus=bus, store=store)
|
||||
outbound_received = []
|
||||
|
||||
async def capture_outbound(message):
|
||||
outbound_received.append(message)
|
||||
|
||||
bus.subscribe_outbound(capture_outbound)
|
||||
|
||||
await manager._handle_command(
|
||||
InboundMessage(
|
||||
channel_name="test",
|
||||
chat_id="chat1",
|
||||
user_id="platform-user",
|
||||
owner_user_id="deerflow-user-1",
|
||||
text="/agent list",
|
||||
msg_type=InboundMessageType.COMMAND,
|
||||
)
|
||||
)
|
||||
|
||||
assert seen_user_ids == ["deerflow-user-1"]
|
||||
assert outbound_received[0].text == ("Available agents:\n• lead_agent — Default agent\n• researcher — Researches sources\n• writer")
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_handle_command_agent_use_starts_pinned_conversation(self, monkeypatch):
|
||||
from app.channels.manager import ChannelManager
|
||||
|
||||
loaded = []
|
||||
|
||||
def fake_load_agent_config(name, *, user_id=None):
|
||||
loaded.append((name, user_id))
|
||||
return SimpleNamespace(name=name)
|
||||
|
||||
monkeypatch.setattr("app.channels.manager.load_agent_config", fake_load_agent_config)
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
store.set_thread_id("test", "chat1", "old-thread")
|
||||
manager = ChannelManager(bus=bus, store=store)
|
||||
mock_client = _make_mock_langgraph_client(thread_id="research-thread")
|
||||
manager._client = mock_client
|
||||
msg = InboundMessage(
|
||||
channel_name="test",
|
||||
chat_id="chat1",
|
||||
user_id="platform-user",
|
||||
owner_user_id="deerflow-user-1",
|
||||
text="/agent use Researcher",
|
||||
msg_type=InboundMessageType.COMMAND,
|
||||
)
|
||||
|
||||
reply = await manager._handle_agent_command(msg, "use Researcher")
|
||||
|
||||
assert loaded == [("researcher", "deerflow-user-1")]
|
||||
assert store.get_thread_id("test", "chat1") == "research-thread"
|
||||
create_kwargs = mock_client.threads.create.call_args.kwargs
|
||||
assert create_kwargs["metadata"]["channel_agent_name"] == "researcher"
|
||||
assert create_kwargs["metadata"]["agent_name"] == "researcher"
|
||||
assert reply == "Agent 'researcher' selected. New conversation started."
|
||||
_, _, run_context = manager._resolve_run_params(msg, "research-thread")
|
||||
assert run_context["agent_name"] == "researcher"
|
||||
|
||||
_run(go())
|
||||
|
||||
@pytest.mark.parametrize("config_carrier", ["context", "configurable"])
|
||||
def test_agent_use_custom_agent_overrides_every_gateway_config_carrier(self, monkeypatch, config_carrier):
|
||||
"""The command pin must win after the real Gateway config merge.
|
||||
|
||||
Channel session config can carry ``agent_name`` in either RunnableConfig
|
||||
container. Leaving an inherited value in one container makes Gateway's
|
||||
``setdefault`` merge preserve a stale agent even though the command
|
||||
reports that the new agent was selected.
|
||||
"""
|
||||
from app.channels.manager import ChannelManager
|
||||
from app.gateway.services import build_run_config, merge_run_context_overrides
|
||||
from deerflow.agents.lead_agent.agent import _get_runtime_config
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.channels.manager.load_agent_config",
|
||||
lambda name, *, user_id=None: SimpleNamespace(name=name),
|
||||
)
|
||||
|
||||
async def go():
|
||||
manager = ChannelManager(
|
||||
bus=MessageBus(),
|
||||
store=ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json"),
|
||||
channel_sessions={
|
||||
"test": {
|
||||
"config": {config_carrier: {"agent_name": "configured-writer"}},
|
||||
}
|
||||
},
|
||||
)
|
||||
manager._client = _make_mock_langgraph_client(thread_id="research-thread")
|
||||
msg = InboundMessage(
|
||||
channel_name="test",
|
||||
chat_id="chat1",
|
||||
user_id="platform-user",
|
||||
owner_user_id="deerflow-user-1",
|
||||
text="/agent use researcher",
|
||||
msg_type=InboundMessageType.COMMAND,
|
||||
)
|
||||
|
||||
await manager._handle_agent_command(msg, "use researcher")
|
||||
assistant_id, run_config, run_context = manager._resolve_run_params(msg, "research-thread")
|
||||
gateway_config = build_run_config(
|
||||
"research-thread",
|
||||
run_config,
|
||||
None,
|
||||
assistant_id=assistant_id,
|
||||
)
|
||||
merge_run_context_overrides(gateway_config, run_context, internal=True)
|
||||
|
||||
assert gateway_config["configurable"]["agent_name"] == "researcher"
|
||||
assert gateway_config["context"]["agent_name"] == "researcher"
|
||||
assert _get_runtime_config(gateway_config)["agent_name"] == "researcher"
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_selected_agent_is_restored_from_thread_metadata(self):
|
||||
from app.channels.manager import ChannelManager
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
manager = ChannelManager(
|
||||
bus=bus,
|
||||
store=ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json"),
|
||||
channel_sessions={
|
||||
"test": {
|
||||
"assistant_id": "configured-writer",
|
||||
"context": {"agent_name": "configured-context-agent"},
|
||||
}
|
||||
},
|
||||
)
|
||||
mock_client = _make_mock_langgraph_client(thread_id="research-thread")
|
||||
mock_client.threads.get.return_value = {
|
||||
"thread_id": "research-thread",
|
||||
"metadata": {"channel_agent_name": "researcher"},
|
||||
}
|
||||
manager._client = mock_client
|
||||
msg = InboundMessage(
|
||||
channel_name="test",
|
||||
chat_id="chat1",
|
||||
user_id="platform-user",
|
||||
owner_user_id="deerflow-user-1",
|
||||
text="Continue",
|
||||
)
|
||||
|
||||
await manager._load_thread_agent(mock_client, msg, "research-thread")
|
||||
mock_client.threads.get.assert_awaited_once()
|
||||
_, _, run_context = manager._resolve_run_params(msg, "research-thread")
|
||||
assert run_context["agent_name"] == "researcher"
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_agent_use_lead_agent_overrides_configured_default(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,
|
||||
channel_sessions={"test": {"assistant_id": "configured-writer"}},
|
||||
)
|
||||
mock_client = _make_mock_langgraph_client(thread_id="default-thread")
|
||||
manager._client = mock_client
|
||||
msg = InboundMessage(
|
||||
channel_name="test",
|
||||
chat_id="chat1",
|
||||
user_id="platform-user",
|
||||
text="/agent use lead_agent",
|
||||
msg_type=InboundMessageType.COMMAND,
|
||||
)
|
||||
|
||||
await manager._handle_agent_command(msg, "use lead_agent")
|
||||
|
||||
create_metadata = mock_client.threads.create.call_args.kwargs["metadata"]
|
||||
assert create_metadata["channel_agent_name"] == "lead_agent"
|
||||
assert "agent_name" not in create_metadata
|
||||
_, _, run_context = manager._resolve_run_params(msg, "default-thread")
|
||||
assert "agent_name" not in run_context
|
||||
|
||||
_run(go())
|
||||
|
||||
@pytest.mark.parametrize("config_carrier", ["context", "configurable"])
|
||||
def test_agent_use_lead_agent_clears_every_gateway_config_carrier(self, config_carrier):
|
||||
"""Resetting to lead_agent must remove every inherited custom-agent pin."""
|
||||
from app.channels.manager import ChannelManager
|
||||
from app.gateway.services import build_run_config, merge_run_context_overrides
|
||||
from deerflow.agents.lead_agent.agent import _get_runtime_config
|
||||
|
||||
async def go():
|
||||
manager = ChannelManager(
|
||||
bus=MessageBus(),
|
||||
store=ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json"),
|
||||
channel_sessions={
|
||||
"test": {
|
||||
"config": {config_carrier: {"agent_name": "configured-writer"}},
|
||||
}
|
||||
},
|
||||
)
|
||||
manager._client = _make_mock_langgraph_client(thread_id="default-thread")
|
||||
msg = InboundMessage(
|
||||
channel_name="test",
|
||||
chat_id="chat1",
|
||||
user_id="platform-user",
|
||||
text="/agent use lead_agent",
|
||||
msg_type=InboundMessageType.COMMAND,
|
||||
)
|
||||
|
||||
await manager._handle_agent_command(msg, "use lead_agent")
|
||||
assistant_id, run_config, run_context = manager._resolve_run_params(msg, "default-thread")
|
||||
gateway_config = build_run_config(
|
||||
"default-thread",
|
||||
run_config,
|
||||
None,
|
||||
assistant_id=assistant_id,
|
||||
)
|
||||
merge_run_context_overrides(gateway_config, run_context, internal=True)
|
||||
|
||||
assert "agent_name" not in gateway_config["configurable"]
|
||||
assert "agent_name" not in gateway_config["context"]
|
||||
assert "agent_name" not in _get_runtime_config(gateway_config)
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_each_topic_creates_new_thread(self):
|
||||
"""Messages with distinct topic_ids should each create a new DeerFlow thread."""
|
||||
from app.channels.manager import ChannelManager
|
||||
|
||||
@ -89,7 +89,7 @@ def test_parse_slash_skill_reference_rejects_invalid_names():
|
||||
|
||||
|
||||
def test_resolve_slash_skill_ignores_reserved_control_commands(tmp_path):
|
||||
for command in ["bootstrap", "goal", "help", "memory", "models", "new", "status"]:
|
||||
for command in ["agent", "bootstrap", "goal", "help", "memory", "models", "new", "status"]:
|
||||
skill = _make_skill(tmp_path, command)
|
||||
|
||||
assert resolve_slash_skill(f"/{command} create an agent", [skill]) is None
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"""Tests for the slash-command registry (pure)."""
|
||||
|
||||
from deerflow.skills.slash import RESERVED_SLASH_SKILL_NAMES
|
||||
from deerflow.tui.command_registry import (
|
||||
BUILTIN_COMMANDS,
|
||||
build_registry,
|
||||
@ -118,6 +119,21 @@ def test_goal_builtin_takes_precedence_over_skill():
|
||||
assert resolve("/goal finish", skills=["goal"]).kind == "builtin"
|
||||
|
||||
|
||||
def test_build_registry_never_exposes_reserved_commands_as_skills():
|
||||
registry = build_registry([{"name": name, "description": "reserved", "enabled": True} for name in RESERVED_SLASH_SKILL_NAMES])
|
||||
|
||||
skill_names = {command.name for command in registry if command.category == "skill"}
|
||||
assert skill_names.isdisjoint(RESERVED_SLASH_SKILL_NAMES)
|
||||
|
||||
|
||||
def test_resolve_never_classifies_reserved_commands_as_skills():
|
||||
reserved_names = sorted(RESERVED_SLASH_SKILL_NAMES)
|
||||
|
||||
for name in reserved_names:
|
||||
resolved = resolve(f"/{name} task", skills=reserved_names)
|
||||
assert resolved.kind != "skill", name
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# /help text <-> registry parity
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
{
|
||||
"version": 1,
|
||||
"description": "Cross-language contract fixture for the leading /skill activation gate. The backend parser (deerflow/skills/slash.py) and the frontend display parser (frontend/src/core/skills/slash.ts) must agree on which leading /word tokens are reserved control commands and on the exact skill-name grammar, so the transcript only renders an activation chip for text the backend would actually treat as a /skill activation.",
|
||||
"reserved_slash_skill_names": ["bootstrap", "goal", "help", "memory", "models", "new", "status"],
|
||||
"reserved_slash_skill_names": [
|
||||
"agent",
|
||||
"bootstrap",
|
||||
"goal",
|
||||
"help",
|
||||
"memory",
|
||||
"models",
|
||||
"new",
|
||||
"status"
|
||||
],
|
||||
"skill_name_pattern": "^/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\\s+|$)"
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import type { Skill } from "./type";
|
||||
* command or changing the name grammar in only one language fails CI.
|
||||
*/
|
||||
export const RESERVED_SLASH_SKILL_NAMES = new Set([
|
||||
"agent",
|
||||
"bootstrap",
|
||||
"goal",
|
||||
"help",
|
||||
|
||||
@ -47,6 +47,57 @@ test.describe("Agent chat", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("continues an IM-selected thread with the same agent from the sidebar", async ({
|
||||
page,
|
||||
}) => {
|
||||
const threadId = "00000000-0000-0000-0000-000000000168";
|
||||
let streamBody: Record<string, unknown> | undefined;
|
||||
mockLangGraphAPI(page, {
|
||||
agents: [
|
||||
{
|
||||
name: "researcher",
|
||||
description: "Research agent selected from an IM channel",
|
||||
},
|
||||
],
|
||||
threads: [
|
||||
{
|
||||
thread_id: threadId,
|
||||
title: "IM research conversation",
|
||||
metadata: {
|
||||
channel_source: { type: "im_channel", provider: "telegram" },
|
||||
channel_agent_name: "researcher",
|
||||
agent_name: "researcher",
|
||||
},
|
||||
},
|
||||
],
|
||||
runStreamHandler: async (route) => {
|
||||
streamBody = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await handleRunStream(route);
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto("/workspace/chats/new");
|
||||
const threadLink = page
|
||||
.locator("[data-sidebar='sidebar']")
|
||||
.locator(`a[href='/workspace/agents/researcher/chats/${threadId}']`);
|
||||
await expect(threadLink).toBeVisible({ timeout: 15_000 });
|
||||
await threadLink.click();
|
||||
await page.waitForURL(`**/workspace/agents/researcher/chats/${threadId}`);
|
||||
|
||||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||
await textarea.fill("Continue this research");
|
||||
await textarea.press("Enter");
|
||||
|
||||
await expect.poll(() => streamBody).toBeDefined();
|
||||
expect(streamBody).toMatchObject({
|
||||
context: {
|
||||
agent_name: "researcher",
|
||||
thread_id: threadId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("mobile agent welcome keeps the sidebar trigger clickable", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@ -58,11 +58,17 @@ test("uses provided context when pathOfThread is called with a thread id", () =>
|
||||
);
|
||||
});
|
||||
|
||||
test("uses agent chat route when thread metadata has agent_name", () => {
|
||||
test("routes an IM-selected thread to its custom agent from search metadata", () => {
|
||||
expect(
|
||||
pathOfThread({
|
||||
thread_id: "thread-456",
|
||||
metadata: { agent_name: "coder" },
|
||||
// Thread-search results do not include run context. The channel manager
|
||||
// therefore persists both its restart key and this canonical routing key.
|
||||
metadata: {
|
||||
channel_source: { type: "im_channel", provider: "telegram" },
|
||||
channel_agent_name: "coder",
|
||||
agent_name: "coder",
|
||||
},
|
||||
}),
|
||||
).toBe("/workspace/agents/coder/chats/thread-456");
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user