mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 05:56:18 +00:00
fix(agent): align unattended prompt with tool policy (#4919)
* fix(agent): align autonomous interaction guidance * fix(agent): harden interaction policy selection * fix(gateway): protect legacy interaction flags * fix(channels): honor explicit interaction mode * docs(agent): reduce inherited guidance size * fix(agent): honor unattended policy across approval paths --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
f2463e6d4a
commit
bd995a6a26
@ -1636,8 +1636,12 @@ compatibility. Operators can set `sandbox.network.mode` to `isolated` or
|
||||
`allowlist`; allowlist mode supports operator-defined domains and an interactive
|
||||
Human Input card for temporary or sandbox-lifetime HTTP(S) approval. Private,
|
||||
loopback, link-local, multicast, and cloud metadata addresses remain
|
||||
unapprovable. Denied hostnames are rejected before DNS resolution, and
|
||||
scheduled or otherwise non-interactive runs auto-deny without opening a card.
|
||||
unapprovable. Denied hostnames are rejected before DNS resolution.
|
||||
Runs in `scheduled`, `webhook`, or `autonomous` interaction mode auto-deny
|
||||
without opening a card. These unattended runs proceed with minimal assumptions
|
||||
only for low-risk, reversible work; high-risk or irreversible work without
|
||||
sufficient authorization returns a structured `BLOCKED` result naming the
|
||||
missing decision, even if the model attempts to ask for clarification.
|
||||
The trusted sidecar uses a dedicated per-sandbox egress bridge rather than
|
||||
Docker's shared default bridge, and rejects ambiguous HTTP field names before
|
||||
forwarding. See
|
||||
|
||||
@ -347,12 +347,13 @@ See [docs/FILE_UPLOAD.md](docs/FILE_UPLOAD.md) for details.
|
||||
|
||||
### Plan Mode
|
||||
|
||||
TodoList middleware for complex multi-step tasks:
|
||||
- Controlled via runtime config: `config.configurable.is_plan_mode = True`
|
||||
- Provides `write_todos` tool for task tracking
|
||||
- One task in_progress at a time, real-time updates
|
||||
`config.configurable.is_plan_mode=True` enables TodoList `write_todos` for
|
||||
multi-step tasks: one `in_progress` task, real-time updates. See
|
||||
[usage](docs/plan_mode_usage.md).
|
||||
|
||||
See [docs/plan_mode_usage.md](docs/plan_mode_usage.md) for details.
|
||||
### Run Interaction Policy
|
||||
|
||||
Interaction-sensitive changes must follow [policy](docs/RUN_INTERACTION_POLICY.md).
|
||||
|
||||
### Context Summarization
|
||||
|
||||
|
||||
@ -1742,7 +1742,12 @@ class ChannelManager:
|
||||
policy = CHANNEL_RUN_POLICY.get(msg.channel_name)
|
||||
if policy is None:
|
||||
return None
|
||||
if not policy.is_interactive:
|
||||
if policy.interaction_mode is not None:
|
||||
run_context["interaction_mode"] = policy.interaction_mode
|
||||
# Keep legacy consumers (including sandbox network approval) aligned.
|
||||
if policy.interaction_mode != "interactive":
|
||||
run_context["disable_clarification"] = True
|
||||
elif not policy.is_interactive:
|
||||
run_context["disable_clarification"] = True
|
||||
if policy.credentials_provider is not None:
|
||||
try:
|
||||
|
||||
@ -14,11 +14,14 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.channels.message_bus import InboundMessage
|
||||
|
||||
InteractionMode = Literal["interactive", "webhook", "scheduled", "autonomous"]
|
||||
_VALID_INTERACTION_MODES = frozenset({"interactive", "webhook", "scheduled", "autonomous"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ChannelRunPolicy:
|
||||
@ -39,6 +42,9 @@ class ChannelRunPolicy:
|
||||
multiple separate methods on the manager.
|
||||
|
||||
Attributes:
|
||||
interaction_mode: Explicit interaction mode forwarded to the lead agent.
|
||||
``None`` means the mode was not declared and preserves legacy
|
||||
``is_interactive`` behavior.
|
||||
is_interactive: When False, the manager sets
|
||||
``run_context["disable_clarification"] = True`` so
|
||||
``ClarificationMiddleware`` returns a "proceed with best
|
||||
@ -102,6 +108,7 @@ class ChannelRunPolicy:
|
||||
"""
|
||||
|
||||
is_interactive: bool = True
|
||||
interaction_mode: InteractionMode | None = None
|
||||
default_recursion_limit: int | None = None
|
||||
credentials_provider: Callable[[InboundMessage, dict[str, Any]], Awaitable[None]] | None = None
|
||||
requires_bound_identity: bool = True
|
||||
@ -109,6 +116,10 @@ class ChannelRunPolicy:
|
||||
serialize_thread_runs: bool = False
|
||||
buffer_followups_on_busy: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.interaction_mode is not None and self.interaction_mode not in _VALID_INTERACTION_MODES:
|
||||
raise ValueError(f"Unknown channel interaction mode: {self.interaction_mode!r}")
|
||||
|
||||
|
||||
# Channel name → policy. Channels absent from this map fall through to
|
||||
# the policy default (an interactive IM channel with no credential
|
||||
|
||||
@ -104,6 +104,7 @@ def register_policy() -> None:
|
||||
# GitHub webhooks have no synchronous human — ask_clarification
|
||||
# would dead-end the run.
|
||||
is_interactive=False,
|
||||
interaction_mode="webhook",
|
||||
# Autonomous coder runs (clone -> edit -> test -> push -> PR)
|
||||
# routinely need more than the 100 super-step interactive ceiling.
|
||||
# Per-agent overrides via GitHubAgentConfig.recursion_limit still
|
||||
|
||||
@ -543,9 +543,9 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||
)
|
||||
|
||||
# Keys honored only for internally-authenticated callers (the scheduler path).
|
||||
# ``non_interactive`` strips ``ask_clarification`` from the lead-agent toolset;
|
||||
# ``interaction_mode`` and ``non_interactive`` control clarification availability;
|
||||
# arbitrary HTTP/IM clients must not be able to force autonomous execution.
|
||||
_CONTEXT_INTERNAL_CALLER_KEYS: frozenset[str] = frozenset({"non_interactive"})
|
||||
_CONTEXT_INTERNAL_CALLER_KEYS: frozenset[str] = frozenset({"interaction_mode", "non_interactive"})
|
||||
|
||||
# Server-owned authorization and sandbox lifecycle identity fields. These must
|
||||
# never be accepted from client-supplied ``body.config.context`` or
|
||||
@ -597,11 +597,13 @@ _SERVER_OWNED_RUNTIME_CONTEXT_KEYS: frozenset[str] = (
|
||||
# webhooks) so ClarificationMiddleware proceeds
|
||||
# instead of dead-ending the run.
|
||||
#
|
||||
# Both are produced server-side by the channel run policies
|
||||
# ``channel_name`` — trusted channel identity used by interaction policy.
|
||||
#
|
||||
# These are produced server-side by the channel run policies
|
||||
# (``ChannelManager._apply_channel_policy`` and ``app.gateway.github.run_policy``),
|
||||
# which reach the Gateway over the internally-authenticated request channel, so
|
||||
# they are internal-only as well — see :data:`_INTERNAL_ONLY_CONTEXT_KEYS`.
|
||||
_CONTEXT_RUNTIME_ONLY_KEYS: frozenset[str] = frozenset({"github_token", "disable_clarification"})
|
||||
_CONTEXT_RUNTIME_ONLY_KEYS: frozenset[str] = frozenset({"github_token", "disable_clarification", "channel_name"})
|
||||
|
||||
# Every run-context key an external client may never supply, in either section.
|
||||
# The two sets differ only in *where* a legitimate internal caller's value lands
|
||||
|
||||
36
backend/docs/RUN_INTERACTION_POLICY.md
Normal file
36
backend/docs/RUN_INTERACTION_POLICY.md
Normal file
@ -0,0 +1,36 @@
|
||||
# Run Interaction Policy
|
||||
|
||||
The harness resolves a `RunInteractionPolicy` from the run context and uses it
|
||||
as the single source for lead-agent tool visibility, clarification middleware
|
||||
behavior, sandbox network approval eligibility, and system-prompt guidance.
|
||||
The implementation lives in `deerflow/agents/interaction_policy.py`.
|
||||
|
||||
## Modes and precedence
|
||||
|
||||
Interactive runs may ask a human for clarification. Runs in `scheduled`,
|
||||
`webhook`, or `autonomous` mode must use the available context, make minimal
|
||||
reversible assumptions, list material assumptions, or return a structured
|
||||
`BLOCKED` result naming the missing decision for high-risk or irreversible
|
||||
work without sufficient authorization. They must never wait for a synchronous
|
||||
human response. Suppressed clarification tool results must preserve this same
|
||||
boundary, not tell the model to unconditionally carry out an ambiguous action.
|
||||
|
||||
An explicit `interaction_mode` takes precedence over legacy context hints.
|
||||
An unknown explicit mode is a configuration error, never an interactive fallback.
|
||||
Without an explicit mode, the resolver checks `non_interactive` (scheduled),
|
||||
then `channel_name="github"` (webhook), then `disable_clarification`
|
||||
(autonomous); otherwise it selects interactive. These legacy flags remain
|
||||
supported while entry points migrate to explicit modes.
|
||||
|
||||
Sync and async sandbox network approval paths use the same resolved policy.
|
||||
Unattended runs deny pending network requests without opening a Human Input
|
||||
card. Subagents always deny pending requests, even in an interactive run.
|
||||
|
||||
## Gateway trust boundary
|
||||
|
||||
`interaction_mode`, `non_interactive`, `disable_clarification`, and
|
||||
`channel_name` are internal-caller-only Gateway context keys. The latter two
|
||||
remain runtime-only and are never copied into checkpoint-persisted
|
||||
`configurable`. Public run requests cannot use these keys to disable
|
||||
clarification, impersonate a webhook channel, or override a scheduled run back
|
||||
to interactive behavior.
|
||||
182
backend/packages/harness/deerflow/agents/interaction_policy.py
Normal file
182
backend/packages/harness/deerflow/agents/interaction_policy.py
Normal file
@ -0,0 +1,182 @@
|
||||
"""Shared interaction policy for lead-agent tools and prompt guidance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
ASK_CLARIFICATION_TOOL_NAME = "ask_clarification"
|
||||
|
||||
|
||||
class RunInteractionMode(StrEnum):
|
||||
"""Interaction modes that can be selected by a trusted run entry point."""
|
||||
|
||||
INTERACTIVE = "interactive"
|
||||
AUTONOMOUS = "autonomous"
|
||||
WEBHOOK = "webhook"
|
||||
SCHEDULED = "scheduled"
|
||||
|
||||
|
||||
_INTERACTIVE_CLARIFICATION_SYSTEM = """<clarification_system>
|
||||
**WORKFLOW PRIORITY: CLARIFY → PLAN → ACT**
|
||||
1. **FIRST**: Analyze the request in your thinking - identify what's unclear, missing, or ambiguous
|
||||
2. **SECOND**: If clarification is needed, call `ask_clarification` tool IMMEDIATELY - do NOT start working
|
||||
3. **THIRD**: Only after all clarifications are resolved, proceed with planning and execution
|
||||
|
||||
**CRITICAL RULE: Clarification ALWAYS comes BEFORE action. Never start working and clarify mid-execution.**
|
||||
|
||||
**MANDATORY Clarification Scenarios - You MUST call ask_clarification BEFORE starting work when:**
|
||||
|
||||
1. **Missing Information** (`missing_info`): Required details not provided
|
||||
- Example: User says "create a web scraper" but doesn't specify the target website
|
||||
- Example: "Deploy the app" without specifying environment
|
||||
- **REQUIRED ACTION**: Call ask_clarification to get the missing information
|
||||
|
||||
2. **Ambiguous Requirements** (`ambiguous_requirement`): Multiple valid interpretations exist
|
||||
- Example: "Optimize the code" could mean performance, readability, or memory usage
|
||||
- Example: "Make it better" is unclear what aspect to improve
|
||||
- **REQUIRED ACTION**: Call ask_clarification to clarify the exact requirement
|
||||
|
||||
3. **Approach Choices** (`approach_choice`): Several valid approaches exist
|
||||
- Example: "Add authentication" could use JWT, OAuth, session-based, or API keys
|
||||
- Example: "Store data" could use database, files, cache, etc.
|
||||
- **REQUIRED ACTION**: Call ask_clarification to let user choose the approach
|
||||
|
||||
4. **Risky Operations** (`risk_confirmation`): Destructive actions need confirmation
|
||||
- Example: Deleting files, modifying production configs, database operations
|
||||
- Example: Overwriting existing code or data
|
||||
- **REQUIRED ACTION**: Call ask_clarification to get explicit confirmation
|
||||
|
||||
5. **Suggestions** (`suggestion`): You have a recommendation but want approval
|
||||
- Example: "I recommend refactoring this code. Should I proceed?"
|
||||
- **REQUIRED ACTION**: Call ask_clarification to get approval
|
||||
|
||||
**STRICT ENFORCEMENT:**
|
||||
- ❌ DO NOT start working and then ask for clarification mid-execution - clarify FIRST
|
||||
- ❌ DO NOT skip clarification for "efficiency" - accuracy matters more than speed
|
||||
- ❌ DO NOT make assumptions when information is missing - ALWAYS ask
|
||||
- ❌ DO NOT proceed with guesses - STOP and call ask_clarification first
|
||||
- ❌ DO NOT call any other tool in the same turn as ask_clarification — sibling calls are dropped
|
||||
- ✅ Analyze the request in thinking → Identify unclear aspects → Ask BEFORE any action
|
||||
- ✅ If you identify the need for clarification in your thinking, you MUST call the tool IMMEDIATELY
|
||||
- ✅ After calling ask_clarification, execution will be interrupted automatically
|
||||
- ✅ Wait for user response - do NOT continue with assumptions
|
||||
|
||||
**How to Use:**
|
||||
```python
|
||||
ask_clarification(
|
||||
question="Your specific question here?",
|
||||
clarification_type="missing_info", # or other type
|
||||
context="Why you need this information", # optional but recommended
|
||||
options=["option1", "option2"] # optional, for choices
|
||||
)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
User: "Deploy the application"
|
||||
You (thinking): Missing environment info - I MUST ask for clarification
|
||||
You (action): ask_clarification(
|
||||
question="Which environment should I deploy to?",
|
||||
clarification_type="approach_choice",
|
||||
context="I need to know the target environment for proper configuration",
|
||||
options=["development", "staging", "production"]
|
||||
)
|
||||
[Execution stops - wait for user response]
|
||||
|
||||
User: "staging"
|
||||
You: "Deploying to staging..." [proceed]
|
||||
</clarification_system>"""
|
||||
|
||||
_AUTONOMOUS_CLARIFICATION_SYSTEM = """<clarification_system>
|
||||
**WORKFLOW PRIORITY: ASSESS -> CHOOSE THE LOWEST-RISK PATH -> ACT**
|
||||
|
||||
There is no human available to answer a synchronous question during this run.
|
||||
Do not wait for clarification or approval. Resolve ambiguity from the request,
|
||||
{context_sources}.
|
||||
|
||||
- For low-risk and reversible work, make the smallest reasonable assumption and continue.
|
||||
- State every material assumption in the final result.
|
||||
- For high-risk or irreversible work without sufficient authorization, do not guess:
|
||||
stop with a concise structured `BLOCKED` result that names the missing decision.
|
||||
- Prefer inspection and read-only checks before changing state.
|
||||
</clarification_system>"""
|
||||
|
||||
_AUTONOMOUS_CONTEXT_SOURCES = "the available run context and existing configuration"
|
||||
_WEBHOOK_CONTEXT_SOURCES = "the issue, pull request, repository, event context, and existing configuration"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RunInteractionPolicy:
|
||||
"""The single source for interaction-sensitive tools and prompt guidance."""
|
||||
|
||||
mode: RunInteractionMode
|
||||
|
||||
@property
|
||||
def allows_clarification(self) -> bool:
|
||||
return self.mode is RunInteractionMode.INTERACTIVE
|
||||
|
||||
@property
|
||||
def disabled_tool_names(self) -> frozenset[str]:
|
||||
if self.allows_clarification:
|
||||
return frozenset()
|
||||
return frozenset({ASK_CLARIFICATION_TOOL_NAME})
|
||||
|
||||
@property
|
||||
def thinking_guidance(self) -> str:
|
||||
if self.allows_clarification:
|
||||
return "- **PRIORITY CHECK: If anything is unclear, missing, or has multiple interpretations, you MUST ask for clarification FIRST - do NOT proceed with work**"
|
||||
return "- **INTERACTION CHECK: This run has no synchronous human. Resolve ambiguity with the run context, choose the lowest-risk reversible path, and record material assumptions.**"
|
||||
|
||||
@property
|
||||
def clarification_system(self) -> str:
|
||||
if self.allows_clarification:
|
||||
return _INTERACTIVE_CLARIFICATION_SYSTEM
|
||||
context_sources = _WEBHOOK_CONTEXT_SOURCES if self.mode is RunInteractionMode.WEBHOOK else _AUTONOMOUS_CONTEXT_SOURCES
|
||||
return _AUTONOMOUS_CLARIFICATION_SYSTEM.format(context_sources=context_sources)
|
||||
|
||||
@property
|
||||
def clarification_reminder(self) -> str:
|
||||
if self.allows_clarification:
|
||||
return "- **Clarification First**: ALWAYS clarify unclear/missing/ambiguous requirements BEFORE starting work - never assume or guess"
|
||||
return "- **Autonomous Interaction**: Do not wait for a human response; make minimal reversible assumptions, list them, or return a structured `BLOCKED` result for high-risk ambiguity"
|
||||
|
||||
@classmethod
|
||||
def interactive(cls) -> RunInteractionPolicy:
|
||||
return cls(RunInteractionMode.INTERACTIVE)
|
||||
|
||||
|
||||
def resolve_run_interaction_policy(config: Mapping[str, Any] | None) -> RunInteractionPolicy:
|
||||
"""Resolve one policy from legacy runtime flags and channel context.
|
||||
|
||||
``non_interactive`` is the scheduler's trusted legacy flag. GitHub and
|
||||
other webhook channels currently use ``disable_clarification`` and/or
|
||||
``channel_name``; both remain supported while callers migrate to an
|
||||
explicit mode.
|
||||
"""
|
||||
|
||||
merged: dict[str, Any] = {}
|
||||
if config:
|
||||
configurable = config.get("configurable")
|
||||
context = config.get("context")
|
||||
if isinstance(configurable, Mapping):
|
||||
merged.update(configurable)
|
||||
if isinstance(context, Mapping):
|
||||
merged.update(context)
|
||||
|
||||
raw_mode = merged.get("interaction_mode")
|
||||
if raw_mode is not None:
|
||||
try:
|
||||
return RunInteractionPolicy(RunInteractionMode(str(raw_mode)))
|
||||
except ValueError as exc:
|
||||
valid_modes = ", ".join(mode.value for mode in RunInteractionMode)
|
||||
raise ValueError(f"Invalid interaction_mode {raw_mode!r}; expected one of: {valid_modes}") from exc
|
||||
|
||||
if merged.get("non_interactive"):
|
||||
return RunInteractionPolicy(RunInteractionMode.SCHEDULED)
|
||||
if merged.get("channel_name") == "github":
|
||||
return RunInteractionPolicy(RunInteractionMode.WEBHOOK)
|
||||
if merged.get("disable_clarification"):
|
||||
return RunInteractionPolicy(RunInteractionMode.AUTONOMOUS)
|
||||
return RunInteractionPolicy.interactive()
|
||||
@ -34,6 +34,7 @@ from langchain.agents import create_agent
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from deerflow.agents.interaction_policy import resolve_run_interaction_policy
|
||||
from deerflow.agents.lead_agent.prompt import apply_prompt_template
|
||||
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||
from deerflow.agents.middlewares.configured_extensions import load_configured_extension_middlewares
|
||||
@ -77,7 +78,6 @@ from deerflow.tracing import build_tracing_callbacks
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BOOTSTRAP_SKILL_NAMES = {"bootstrap"}
|
||||
_NON_INTERACTIVE_DISABLED_TOOL_NAMES = frozenset({"ask_clarification"})
|
||||
|
||||
# Channels whose inbound messages originate from untrusted external
|
||||
# commenters (anyone on a GitHub repo, etc.) and whose run context is
|
||||
@ -946,7 +946,8 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
|
||||
)
|
||||
max_total_subagents = cfg.get("max_total_subagents", _default_max_total_subagents(resolved_app_config))
|
||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
||||
non_interactive = bool(cfg.get("non_interactive", False))
|
||||
interaction_policy = resolve_run_interaction_policy(config)
|
||||
non_interactive = not interaction_policy.allows_clarification
|
||||
agent_name = validate_agent_name(cfg.get("agent_name"))
|
||||
|
||||
agent_config = load_agent_config(agent_name, user_id=resolved_user_id) if not is_bootstrap else None
|
||||
@ -1058,8 +1059,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
|
||||
)
|
||||
raw_tools = get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, app_config=resolved_app_config) + [setup_agent]
|
||||
configured_tools = raw_tools
|
||||
if non_interactive:
|
||||
configured_tools = [tool for tool in configured_tools if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES]
|
||||
configured_tools = [tool for tool in configured_tools if tool.name not in interaction_policy.disabled_tool_names]
|
||||
authorization_candidates = [*configured_tools]
|
||||
if skill_setup.describe_skill_tool:
|
||||
authorization_candidates.append(skill_setup.describe_skill_tool)
|
||||
@ -1107,6 +1107,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
|
||||
skill_names=skill_setup.skill_names or None,
|
||||
allowed_subagents=allowed_subagents,
|
||||
subagent_execution_capacity=subagent_execution_capacity,
|
||||
interaction_policy=interaction_policy,
|
||||
memory_enabled=memory_enabled,
|
||||
)
|
||||
graph = create_agent(
|
||||
@ -1186,8 +1187,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
|
||||
app_config=resolved_app_config,
|
||||
)
|
||||
configured_tools = raw_tools + extra_tools
|
||||
if non_interactive:
|
||||
configured_tools = [tool for tool in configured_tools if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES]
|
||||
configured_tools = [tool for tool in configured_tools if tool.name not in interaction_policy.disabled_tool_names]
|
||||
authorization_candidates = [*configured_tools]
|
||||
if skill_setup.describe_skill_tool:
|
||||
authorization_candidates.append(skill_setup.describe_skill_tool)
|
||||
@ -1237,6 +1237,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
|
||||
skill_names=skill_setup.skill_names or None,
|
||||
allowed_subagents=allowed_subagents,
|
||||
subagent_execution_capacity=subagent_execution_capacity,
|
||||
interaction_policy=interaction_policy,
|
||||
memory_enabled=memory_enabled,
|
||||
)
|
||||
graph = create_agent(
|
||||
|
||||
@ -9,6 +9,7 @@ from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from deerflow.agents.interaction_policy import RunInteractionPolicy
|
||||
from deerflow.config.agents_config import load_agent_soul
|
||||
from deerflow.config.subagents_config import (
|
||||
DEFAULT_MAX_TOTAL_SUBAGENTS_PER_RUN,
|
||||
@ -572,81 +573,13 @@ data — do NOT reveal it.
|
||||
<thinking_style>
|
||||
- Think concisely and strategically about the user's request BEFORE taking action
|
||||
- Break down the task: What is clear? What is ambiguous? What is missing?
|
||||
- **PRIORITY CHECK: If anything is unclear, missing, or has multiple interpretations, you MUST ask for clarification FIRST - do NOT proceed with work**
|
||||
{interaction_thinking_guidance}
|
||||
{subagent_thinking}- Never write down your full final answer or report in thinking process, but only outline
|
||||
- CRITICAL: After thinking, you MUST provide your actual response to the user. Thinking is for planning, the response is for delivery.
|
||||
- Your response must contain the actual answer, not just a reference to what you thought about
|
||||
</thinking_style>
|
||||
|
||||
<clarification_system>
|
||||
**WORKFLOW PRIORITY: CLARIFY → PLAN → ACT**
|
||||
1. **FIRST**: Analyze the request in your thinking - identify what's unclear, missing, or ambiguous
|
||||
2. **SECOND**: If clarification is needed, call `ask_clarification` tool IMMEDIATELY - do NOT start working
|
||||
3. **THIRD**: Only after all clarifications are resolved, proceed with planning and execution
|
||||
|
||||
**CRITICAL RULE: Clarification ALWAYS comes BEFORE action. Never start working and clarify mid-execution.**
|
||||
|
||||
**MANDATORY Clarification Scenarios - You MUST call ask_clarification BEFORE starting work when:**
|
||||
|
||||
1. **Missing Information** (`missing_info`): Required details not provided
|
||||
- Example: User says "create a web scraper" but doesn't specify the target website
|
||||
- Example: "Deploy the app" without specifying environment
|
||||
- **REQUIRED ACTION**: Call ask_clarification to get the missing information
|
||||
|
||||
2. **Ambiguous Requirements** (`ambiguous_requirement`): Multiple valid interpretations exist
|
||||
- Example: "Optimize the code" could mean performance, readability, or memory usage
|
||||
- Example: "Make it better" is unclear what aspect to improve
|
||||
- **REQUIRED ACTION**: Call ask_clarification to clarify the exact requirement
|
||||
|
||||
3. **Approach Choices** (`approach_choice`): Several valid approaches exist
|
||||
- Example: "Add authentication" could use JWT, OAuth, session-based, or API keys
|
||||
- Example: "Store data" could use database, files, cache, etc.
|
||||
- **REQUIRED ACTION**: Call ask_clarification to let user choose the approach
|
||||
|
||||
4. **Risky Operations** (`risk_confirmation`): Destructive actions need confirmation
|
||||
- Example: Deleting files, modifying production configs, database operations
|
||||
- Example: Overwriting existing code or data
|
||||
- **REQUIRED ACTION**: Call ask_clarification to get explicit confirmation
|
||||
|
||||
5. **Suggestions** (`suggestion`): You have a recommendation but want approval
|
||||
- Example: "I recommend refactoring this code. Should I proceed?"
|
||||
- **REQUIRED ACTION**: Call ask_clarification to get approval
|
||||
|
||||
**STRICT ENFORCEMENT:**
|
||||
- ❌ DO NOT start working and then ask for clarification mid-execution - clarify FIRST
|
||||
- ❌ DO NOT skip clarification for "efficiency" - accuracy matters more than speed
|
||||
- ❌ DO NOT make assumptions when information is missing - ALWAYS ask
|
||||
- ❌ DO NOT proceed with guesses - STOP and call ask_clarification first
|
||||
- ❌ DO NOT call any other tool in the same turn as ask_clarification — sibling calls are dropped
|
||||
- ✅ Analyze the request in thinking → Identify unclear aspects → Ask BEFORE any action
|
||||
- ✅ If you identify the need for clarification in your thinking, you MUST call the tool IMMEDIATELY
|
||||
- ✅ After calling ask_clarification, execution will be interrupted automatically
|
||||
- ✅ Wait for user response - do NOT continue with assumptions
|
||||
|
||||
**How to Use:**
|
||||
```python
|
||||
ask_clarification(
|
||||
question="Your specific question here?",
|
||||
clarification_type="missing_info", # or other type
|
||||
context="Why you need this information", # optional but recommended
|
||||
options=["option1", "option2"] # optional, for choices
|
||||
)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
User: "Deploy the application"
|
||||
You (thinking): Missing environment info - I MUST ask for clarification
|
||||
You (action): ask_clarification(
|
||||
question="Which environment should I deploy to?",
|
||||
clarification_type="approach_choice",
|
||||
context="I need to know the target environment for proper configuration",
|
||||
options=["development", "staging", "production"]
|
||||
)
|
||||
[Execution stops - wait for user response]
|
||||
|
||||
User: "staging"
|
||||
You: "Deploying to staging..." [proceed]
|
||||
</clarification_system>
|
||||
{clarification_system}
|
||||
|
||||
{skills_section}
|
||||
{memory_tool_section}
|
||||
@ -747,7 +680,7 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
|
||||
</citations>
|
||||
|
||||
<critical_reminders>
|
||||
- **Clarification First**: ALWAYS clarify unclear/missing/ambiguous requirements BEFORE starting work - never assume or guess
|
||||
{clarification_reminder}
|
||||
{subagent_reminder}{skill_first_reminder}
|
||||
- Progressive Loading: Load skill resources incrementally as referenced
|
||||
- Output Files: Final deliverables must be in `/mnt/user-data/outputs` (⚠️ Skills are NOT deliverables — use `skill_manage` tool instead)
|
||||
@ -1090,7 +1023,9 @@ def apply_prompt_template(
|
||||
allowed_subagents: list[str] | None = None,
|
||||
subagent_execution_capacity: int | None = None,
|
||||
memory_enabled: bool = True,
|
||||
interaction_policy: RunInteractionPolicy | None = None,
|
||||
) -> str:
|
||||
interaction_policy = interaction_policy or RunInteractionPolicy.interactive()
|
||||
# Include subagent section only if enabled (from runtime parameter)
|
||||
n = (
|
||||
effective_subagent_concurrency(
|
||||
@ -1179,6 +1114,9 @@ def apply_prompt_template(
|
||||
# as a <system-reminder> in the first HumanMessage, keeping this prompt
|
||||
# identical across users and sessions for maximum prefix-cache reuse.
|
||||
return SYSTEM_PROMPT_TEMPLATE.format(
|
||||
interaction_thinking_guidance=interaction_policy.thinking_guidance,
|
||||
clarification_system=interaction_policy.clarification_system,
|
||||
clarification_reminder=interaction_policy.clarification_reminder,
|
||||
agent_name=agent_name or "DeerFlow 2.0",
|
||||
soul=get_agent_soul(agent_name, user_id=user_id),
|
||||
self_update_section=_build_self_update_section(agent_name),
|
||||
|
||||
@ -15,6 +15,7 @@ from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
from langgraph.runtime import Runtime
|
||||
from langgraph.types import Command
|
||||
|
||||
from deerflow.agents.interaction_policy import resolve_run_interaction_policy
|
||||
from deerflow.agents.middlewares.tool_call_metadata import clone_ai_message_with_tool_calls
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -385,7 +386,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
context = getattr(runtime, "context", None)
|
||||
if not context:
|
||||
return False
|
||||
return bool(context.get("disable_clarification"))
|
||||
return not resolve_run_interaction_policy({"context": context}).allows_clarification
|
||||
|
||||
def _is_disabled(self, request: ToolCallRequest) -> bool:
|
||||
"""Whether clarifications are suppressed for this tool-call request."""
|
||||
@ -446,22 +447,25 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
return {"messages": [patched]}
|
||||
|
||||
def _handle_disabled_clarification(self, request: ToolCallRequest) -> ToolMessage:
|
||||
"""Suppress a clarification and tell the agent to proceed.
|
||||
"""Suppress clarification without granting permission to act.
|
||||
|
||||
Returns a plain ToolMessage (not a ``Command(goto=END)``) so the
|
||||
agent loop continues instead of ending — the agent receives this
|
||||
as the tool result and generates again, ideally acting rather
|
||||
than re-asking.
|
||||
as the tool result and either continues low-risk, reversible work
|
||||
or reports a blocked result rather than re-asking.
|
||||
"""
|
||||
tool_call_id = request.tool_call.get("id", "")
|
||||
logger.info("ask_clarification suppressed (disable_clarification set); instructing agent to proceed")
|
||||
logger.info("ask_clarification suppressed by run interaction policy; applying unattended risk guidance")
|
||||
return ToolMessage(
|
||||
id=self._stable_message_id(tool_call_id, "proceed-without-clarification"),
|
||||
content=(
|
||||
"Clarification is disabled in this context — the human is not present "
|
||||
"to answer synchronously. Do not ask for confirmation. Proceed with your "
|
||||
"best judgment, carry out the requested action, and state any assumptions "
|
||||
"you made in your final response."
|
||||
"to answer synchronously. Do not ask for confirmation or wait for a human response. "
|
||||
"For low-risk and reversible work, proceed with the smallest reasonable assumption "
|
||||
"supported by the available context, and state all material assumptions in your final response. "
|
||||
"For high-risk or irreversible work without sufficient authorization, do not guess or act: "
|
||||
"stop with a concise structured BLOCKED result naming the missing decision. "
|
||||
"Prefer inspection and read-only checks before changing state."
|
||||
),
|
||||
tool_call_id=tool_call_id,
|
||||
name=ASK_CLARIFICATION_TOOL_NAME,
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
### Sandbox System (`packages/harness/deerflow/sandbox/`)
|
||||
|
||||
**Network approval interaction policy**: Sync and async `SandboxMiddleware` tool
|
||||
wrappers use `resolve_run_interaction_policy()` to decide whether a lead run can
|
||||
open a network approval card. Explicit `autonomous`, `webhook`, and `scheduled`
|
||||
modes auto-deny pending requests, as do the legacy unattended flags and GitHub
|
||||
channel fallback. An explicit `interactive` mode takes precedence over those
|
||||
legacy hints. Subagents always auto-deny, including in interactive runs; never
|
||||
consume events into a human-input card when no human can respond.
|
||||
|
||||
**Interface**: `Sandbox`: `execute_command(command, env=None)`, additive `execute_command_in_scope(..., scope_id=...)` / `release_command_scope(scope_id)`, `read_file`, `write_file`, `list_dir`, `glob`, `grep`. Scoped hooks default to pass-through without server-side sessions, preserving third-party subclasses. `grep` accepts a text file or directory tree. Per-call `env` injects secrets: `LocalSandbox` merges into the subprocess environment; `AioSandbox` uses fresh `bash.exec(env=...)` sessions. `list_dir`: missing path → `FileNotFoundError`; command/client failure → `OSError`, never `[]` (`ls_tool`: `(empty)`). Remote `glob`/`grep` share it via `sandbox/remote_search.py`: missing root → `FileNotFoundError`, failed search → `OSError`; only a genuine no-match returns `[]`. The parser takes the command's `limit` and reports `truncated` when output passed it, which providers return after Python-side filtering; tools call an empty truncated result incomplete. Remote `grep(glob=...)` scopes like `glob()` (root-relative `path_matches`), never by basename alone. Remotes use `sandbox/remote_list_dir.py`: capture `find`'s status, not `| head`'s (`sh -lc` lacks `pipefail`); missing binary → `OSError`; truncation SIGPIPE → success.
|
||||
**Provider Pattern**: `SandboxProvider` exposes `acquire`, `acquire_async`, `get`, `release`. Async agent/tool paths use async hooks to keep Docker creation, discovery, cross-process locking, readiness polling, and release off-loop. Set `supports_agent_skill_isolation=True` only when the whole tool surface enforces explicit lead Agent policy: bind mounts use prepared thread roots; upload providers implement `sync_agent_skills`. Host-backed providers report false if an enabled shell bypasses path mappings. Under explicit policy, middleware rejects unsupported providers before acquire.
|
||||
**Shared components** (RFC #4741): remote IDs use `derive_sandbox_scope_token` (`sandbox/identity.py`); preserve its keyword-only SHA-256/16-hex contract to avoid orphaning containers. `AcquireSerializer` (`sandbox/acquire_serialization.py`) serializes selected acquire/release transitions with a bounded, refcounted per-key `threading.Lock` table and dedicated bounded executor (no event-loop/default-executor blocking). Workers own cancellation cleanup without waiting for cancelled tasks to resume; provider `shutdown()`/`reset()` calls idempotent `close()`. Keys: AIO `(user_id, thread_id)`, E2B `(user_id, thread_id, skills_root)`, BoxLite/Tenki/OpenSandbox derived id. Random-UUID `thread_id=None` acquires bypass serialization.
|
||||
|
||||
@ -13,6 +13,7 @@ from langgraph.runtime import Runtime
|
||||
from langgraph.types import Command, Overwrite
|
||||
|
||||
from deerflow.agents.human_input import read_human_input_response
|
||||
from deerflow.agents.interaction_policy import resolve_run_interaction_policy
|
||||
from deerflow.agents.thread_state import SandboxStateField, ThreadDataState
|
||||
from deerflow.authz.sandbox_authz import (
|
||||
authorize_sandbox_execution,
|
||||
@ -38,7 +39,7 @@ _NETWORK_POLICY_DECISIONS = frozenset({"deny", "allow_temporary", "allow_sandbox
|
||||
|
||||
|
||||
def _network_approval_is_non_interactive(context: Mapping[str, object]) -> bool:
|
||||
return bool(context.get("disable_clarification") or context.get("non_interactive"))
|
||||
return not resolve_run_interaction_policy({"context": context}).allows_clarification
|
||||
|
||||
|
||||
class SandboxMiddlewareState(AgentState):
|
||||
|
||||
@ -29,7 +29,7 @@ the same host reader serves; keep reading guidance separate from permission enfo
|
||||
The ordinary `task` boundary carries one narrow parent-loop middleware recorder into the isolated subagent runtime under separate loop-detection, tool-promotion, and tool-progress keys. It schedules only `record_middleware` calls back onto the loop that owns `RunJournal`, keeps an execution-local atomic promotion claim so parallel searches do not double-report one new schema, is fenced and drained once before `task` returns, and never exposes the journal or event store to the child loop. Durable batch tasks have no parent run journal and do not use this bridge.
|
||||
|
||||
Scheduled-task runtime note:
|
||||
- Scheduled background runs set `context.non_interactive=true` and therefore exclude `ask_clarification` from the lead-agent tool list. This keeps scheduler-triggered runs from stalling on human confirmation mid-execution. `non_interactive` is an internal-only context key: it is merged from `body.context` only when the request authenticated as the process-internal user (the scheduler path), never from arbitrary HTTP/IM clients.
|
||||
- Scheduled background runs resolve to the `scheduled` interaction policy through trusted `context.non_interactive=true` and therefore exclude `ask_clarification` from the lead-agent tool list. The legacy `context.non_interactive=true` key remains accepted only for internally authenticated scheduler calls during migration; arbitrary HTTP/IM clients cannot set it.
|
||||
|
||||
Durable MCP task-management tools are added only while the process-local task submitter is installed. They expose bounded local task fields, including whether cancellation was requested, but never the remote handle. Cancellation records that request durably and returns immediately; the background service owns the remote call and retries. These remain ordinary business tools under an active skill's `allowed-tools` policy and must be declared explicitly.
|
||||
|
||||
|
||||
@ -57,6 +57,58 @@ def test_strip_leading_mentions_only_drops_flush_leading_mentions():
|
||||
assert not is_known_channel_command("@bot /goal")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["interactive", "autonomous", "webhook", "scheduled"])
|
||||
def test_channel_policy_explicit_interaction_mode_overrides_legacy_flag(tmp_path, mode):
|
||||
from app.channels.manager import ChannelManager
|
||||
from app.channels.run_policy import CHANNEL_RUN_POLICY, ChannelRunPolicy
|
||||
|
||||
channel_name = "policy-explicit-interactive"
|
||||
previous = CHANNEL_RUN_POLICY.get(channel_name)
|
||||
CHANNEL_RUN_POLICY[channel_name] = ChannelRunPolicy(is_interactive=False, interaction_mode=mode)
|
||||
try:
|
||||
manager = ChannelManager(bus=MessageBus(), store=ChannelStore(path=tmp_path / "store.json"))
|
||||
msg = InboundMessage(channel_name=channel_name, chat_id="chat", user_id="user", text="hello")
|
||||
context: dict[str, object] = {}
|
||||
asyncio.run(manager._apply_channel_policy(msg, context))
|
||||
from app.gateway.services import merge_run_context_overrides
|
||||
from deerflow.agents.interaction_policy import resolve_run_interaction_policy
|
||||
|
||||
config = {}
|
||||
merge_run_context_overrides(config, context, internal=True)
|
||||
assert resolve_run_interaction_policy(config).mode.value == mode
|
||||
assert context["interaction_mode"] == mode
|
||||
if mode == "interactive":
|
||||
assert "disable_clarification" not in context
|
||||
else:
|
||||
assert context["disable_clarification"] is True
|
||||
finally:
|
||||
if previous is None:
|
||||
CHANNEL_RUN_POLICY.pop(channel_name, None)
|
||||
else:
|
||||
CHANNEL_RUN_POLICY[channel_name] = previous
|
||||
|
||||
|
||||
def test_channel_policy_legacy_noninteractive_remains_supported(tmp_path):
|
||||
from app.channels.manager import ChannelManager
|
||||
from app.channels.run_policy import CHANNEL_RUN_POLICY, ChannelRunPolicy
|
||||
|
||||
channel_name = "policy-legacy-noninteractive"
|
||||
previous = CHANNEL_RUN_POLICY.get(channel_name)
|
||||
CHANNEL_RUN_POLICY[channel_name] = ChannelRunPolicy(is_interactive=False)
|
||||
try:
|
||||
manager = ChannelManager(bus=MessageBus(), store=ChannelStore(path=tmp_path / "store.json"))
|
||||
msg = InboundMessage(channel_name=channel_name, chat_id="chat", user_id="user", text="hello")
|
||||
context: dict[str, object] = {}
|
||||
asyncio.run(manager._apply_channel_policy(msg, context))
|
||||
assert context["disable_clarification"] is True
|
||||
assert "interaction_mode" not in context
|
||||
finally:
|
||||
if previous is None:
|
||||
CHANNEL_RUN_POLICY.pop(channel_name, None)
|
||||
else:
|
||||
CHANNEL_RUN_POLICY[channel_name] = previous
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@ -730,6 +730,37 @@ class TestClarificationDisabled:
|
||||
assert "disabled" in result.content.lower()
|
||||
assert "proceed" in result.content.lower()
|
||||
|
||||
@pytest.mark.parametrize("mode", ["autonomous", "webhook", "scheduled"])
|
||||
@pytest.mark.parametrize("async_path", [False, True])
|
||||
def test_unattended_fallback_preserves_risk_and_authorization_boundaries(self, middleware, mode, async_path):
|
||||
import asyncio
|
||||
|
||||
from langchain_core.messages import ToolMessage
|
||||
|
||||
request = self._request(runtime_context={"interaction_mode": mode})
|
||||
request.tool_call["args"]["question"] = "May I delete the production database?"
|
||||
|
||||
async def handler(_req):
|
||||
return pytest.fail("handler should not be called")
|
||||
|
||||
if async_path:
|
||||
result = asyncio.run(middleware.awrap_tool_call(request, handler))
|
||||
else:
|
||||
result = middleware.wrap_tool_call(request, lambda _req: pytest.fail("handler should not be called"))
|
||||
|
||||
assert isinstance(result, ToolMessage)
|
||||
assert result.artifact is None
|
||||
assert result.tool_call_id == "call-clarify-1"
|
||||
assert "low-risk" in result.content
|
||||
assert "reversible" in result.content
|
||||
assert "high-risk" in result.content
|
||||
assert "irreversible" in result.content
|
||||
assert "authorization" in result.content
|
||||
assert "BLOCKED" in result.content
|
||||
assert "missing decision" in result.content
|
||||
assert "assumptions" in result.content
|
||||
assert "carry out the requested action" not in result.content
|
||||
|
||||
def test_disabled_async_path(self, middleware):
|
||||
request = self._request(runtime_context={"disable_clarification": True})
|
||||
|
||||
|
||||
@ -1267,26 +1267,56 @@ def test_build_run_config_dual_write_matches_merge_run_context_overrides_shape()
|
||||
assert via_assistant_id["context"]["agent_name"] == via_context["context"]["agent_name"]
|
||||
|
||||
|
||||
def test_non_interactive_context_override_is_internal_only():
|
||||
"""Client-supplied ``non_interactive`` must be dropped: it strips the
|
||||
``ask_clarification`` tool, so only the internal scheduler path may set it."""
|
||||
def test_interaction_policy_context_override_is_internal_only():
|
||||
"""Client-supplied interaction policy must be dropped because it controls
|
||||
whether the lead agent exposes ``ask_clarification``."""
|
||||
from app.gateway.services import build_run_config, merge_run_context_overrides
|
||||
|
||||
config = build_run_config("thread-1", None, None)
|
||||
merge_run_context_overrides(config, {"non_interactive": True})
|
||||
merge_run_context_overrides(
|
||||
config,
|
||||
{
|
||||
"non_interactive": True,
|
||||
"interaction_mode": "scheduled",
|
||||
"disable_clarification": True,
|
||||
"channel_name": "github",
|
||||
},
|
||||
)
|
||||
|
||||
assert "non_interactive" not in config["configurable"]
|
||||
assert "non_interactive" not in config["context"]
|
||||
assert "interaction_mode" not in config["configurable"]
|
||||
assert "interaction_mode" not in config["context"]
|
||||
assert "disable_clarification" not in config["configurable"]
|
||||
assert "disable_clarification" not in config["context"]
|
||||
assert "channel_name" not in config["configurable"]
|
||||
assert "channel_name" not in config["context"]
|
||||
|
||||
|
||||
def test_non_interactive_context_override_honored_for_internal_caller():
|
||||
def test_interaction_policy_context_override_honored_for_internal_caller():
|
||||
from app.gateway.services import build_run_config, merge_run_context_overrides
|
||||
|
||||
config = build_run_config("thread-1", None, None)
|
||||
merge_run_context_overrides(config, {"non_interactive": True, "model_name": "gpt"}, internal=True)
|
||||
merge_run_context_overrides(
|
||||
config,
|
||||
{
|
||||
"non_interactive": True,
|
||||
"interaction_mode": "scheduled",
|
||||
"disable_clarification": True,
|
||||
"channel_name": "github",
|
||||
"model_name": "gpt",
|
||||
},
|
||||
internal=True,
|
||||
)
|
||||
|
||||
assert config["configurable"]["non_interactive"] is True
|
||||
assert config["context"]["non_interactive"] is True
|
||||
assert config["configurable"]["interaction_mode"] == "scheduled"
|
||||
assert config["context"]["interaction_mode"] == "scheduled"
|
||||
assert config["context"]["disable_clarification"] is True
|
||||
assert config["context"]["channel_name"] == "github"
|
||||
assert "disable_clarification" not in config["configurable"]
|
||||
assert "channel_name" not in config["configurable"]
|
||||
assert config["configurable"]["model_name"] == "gpt"
|
||||
|
||||
|
||||
@ -3738,20 +3768,49 @@ def test_build_run_config_no_request_config():
|
||||
assert "context" not in config
|
||||
|
||||
|
||||
def test_strip_internal_context_keys_scrubs_config_smuggled_non_interactive():
|
||||
"""A non-internal client must not force ``non_interactive`` via the free-form
|
||||
def test_strip_internal_context_keys_scrubs_config_smuggled_interaction_policy():
|
||||
"""A non-internal client must not force interaction policy via the free-form
|
||||
``body.config`` either — ``build_run_config`` copies ``config.context`` and
|
||||
``config.configurable`` verbatim, so the assembled config gets scrubbed."""
|
||||
from app.gateway.services import build_run_config, strip_internal_context_keys
|
||||
|
||||
via_context = build_run_config("thread-1", {"context": {"non_interactive": True, "model_name": "gpt"}}, None)
|
||||
via_context = build_run_config(
|
||||
"thread-1",
|
||||
{
|
||||
"context": {
|
||||
"non_interactive": True,
|
||||
"interaction_mode": "scheduled",
|
||||
"disable_clarification": True,
|
||||
"channel_name": "github",
|
||||
"model_name": "gpt",
|
||||
}
|
||||
},
|
||||
None,
|
||||
)
|
||||
strip_internal_context_keys(via_context)
|
||||
assert "non_interactive" not in via_context["context"]
|
||||
assert "interaction_mode" not in via_context["context"]
|
||||
assert "disable_clarification" not in via_context["context"]
|
||||
assert "channel_name" not in via_context["context"]
|
||||
assert via_context["context"]["model_name"] == "gpt"
|
||||
|
||||
via_configurable = build_run_config("thread-1", {"configurable": {"non_interactive": True}}, None)
|
||||
via_configurable = build_run_config(
|
||||
"thread-1",
|
||||
{
|
||||
"configurable": {
|
||||
"non_interactive": True,
|
||||
"interaction_mode": "interactive",
|
||||
"disable_clarification": True,
|
||||
"channel_name": "github",
|
||||
}
|
||||
},
|
||||
None,
|
||||
)
|
||||
strip_internal_context_keys(via_configurable)
|
||||
assert "non_interactive" not in via_configurable["configurable"]
|
||||
assert "interaction_mode" not in via_configurable["configurable"]
|
||||
assert "disable_clarification" not in via_configurable["configurable"]
|
||||
assert "channel_name" not in via_configurable["configurable"]
|
||||
|
||||
|
||||
def test_strip_internal_context_keys_scrubs_config_smuggled_context_only_keys():
|
||||
|
||||
51
backend/tests/test_interaction_policy.py
Normal file
51
backend/tests/test_interaction_policy.py
Normal file
@ -0,0 +1,51 @@
|
||||
import pytest
|
||||
|
||||
from deerflow.agents.interaction_policy import (
|
||||
ASK_CLARIFICATION_TOOL_NAME,
|
||||
RunInteractionMode,
|
||||
RunInteractionPolicy,
|
||||
resolve_run_interaction_policy,
|
||||
)
|
||||
|
||||
|
||||
def test_interactive_policy_keeps_clarification_available():
|
||||
policy = resolve_run_interaction_policy({})
|
||||
|
||||
assert policy.mode is RunInteractionMode.INTERACTIVE
|
||||
assert policy.allows_clarification
|
||||
assert policy.disabled_tool_names == frozenset()
|
||||
assert "MUST call ask_clarification" in policy.clarification_system
|
||||
|
||||
|
||||
def test_scheduled_policy_disables_tool_and_uses_autonomous_guidance():
|
||||
policy = resolve_run_interaction_policy({"context": {"non_interactive": True}})
|
||||
|
||||
assert policy.mode is RunInteractionMode.SCHEDULED
|
||||
assert not policy.allows_clarification
|
||||
assert policy.disabled_tool_names == frozenset({ASK_CLARIFICATION_TOOL_NAME})
|
||||
assert "MUST call ask_clarification" not in policy.clarification_system
|
||||
assert "minimal reversible assumptions" in policy.clarification_reminder
|
||||
|
||||
|
||||
def test_github_policy_resolves_as_webhook_without_legacy_flag():
|
||||
policy = resolve_run_interaction_policy({"context": {"channel_name": "github", "disable_clarification": True}})
|
||||
|
||||
assert policy.mode is RunInteractionMode.WEBHOOK
|
||||
assert not policy.allows_clarification
|
||||
assert "issue, pull request, repository, event context" in policy.clarification_system
|
||||
|
||||
|
||||
def test_explicit_mode_takes_precedence_over_legacy_flags():
|
||||
policy = resolve_run_interaction_policy(
|
||||
{
|
||||
"configurable": {"interaction_mode": "autonomous"},
|
||||
"context": {"non_interactive": True, "channel_name": "github"},
|
||||
}
|
||||
)
|
||||
|
||||
assert policy == RunInteractionPolicy(RunInteractionMode.AUTONOMOUS)
|
||||
|
||||
|
||||
def test_invalid_explicit_mode_fails_closed():
|
||||
with pytest.raises(ValueError, match="Invalid interaction_mode 'Scheduled'"):
|
||||
resolve_run_interaction_policy({"context": {"interaction_mode": "Scheduled"}})
|
||||
@ -7,7 +7,7 @@ import inspect
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, create_autospec
|
||||
|
||||
import pytest
|
||||
from langchain.agents import create_agent
|
||||
@ -663,7 +663,8 @@ def test_make_lead_agent_reads_runtime_options_from_context(monkeypatch):
|
||||
assert result["model"] is not None
|
||||
|
||||
|
||||
def test_make_lead_agent_filters_clarification_tool_for_non_interactive_runs(monkeypatch):
|
||||
@pytest.mark.parametrize("is_bootstrap", [False, True])
|
||||
def test_make_lead_agent_filters_clarification_tool_for_non_interactive_runs(monkeypatch, is_bootstrap):
|
||||
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])
|
||||
|
||||
import deerflow.tools as tools_module
|
||||
@ -679,7 +680,18 @@ def test_make_lead_agent_filters_clarification_tool_for_non_interactive_runs(mon
|
||||
"get_available_tools",
|
||||
lambda **kwargs: [_named_tool("ask_clarification"), _named_tool("bash")],
|
||||
)
|
||||
monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda config, model_name, agent_name=None, **kwargs: [])
|
||||
captured_prompt_policy = {}
|
||||
monkeypatch.setattr(lead_agent_module, "build_middlewares", create_autospec(lead_agent_module.build_middlewares, return_value=[]))
|
||||
|
||||
def _capture_prompt_policy(**kwargs):
|
||||
captured_prompt_policy["policy"] = kwargs["interaction_policy"]
|
||||
return "prompt"
|
||||
|
||||
monkeypatch.setattr(
|
||||
lead_agent_module,
|
||||
"apply_prompt_template",
|
||||
_capture_prompt_policy,
|
||||
)
|
||||
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: object())
|
||||
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
|
||||
|
||||
@ -690,11 +702,13 @@ def test_make_lead_agent_filters_clarification_tool_for_non_interactive_runs(mon
|
||||
"thinking_enabled": False,
|
||||
"subagent_enabled": False,
|
||||
"non_interactive": True,
|
||||
"is_bootstrap": is_bootstrap,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert [tool.name for tool in result["tools"]] == ["bash"]
|
||||
assert [tool.name for tool in result["tools"]] == (["bash", "setup_agent"] if is_bootstrap else ["bash"])
|
||||
assert captured_prompt_policy["policy"].mode.value == "scheduled"
|
||||
|
||||
|
||||
def test_make_lead_agent_rejects_invalid_bootstrap_agent_name(monkeypatch):
|
||||
|
||||
@ -6,6 +6,7 @@ from typing import cast
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from deerflow.agents.interaction_policy import RunInteractionMode, RunInteractionPolicy
|
||||
from deerflow.agents.lead_agent import prompt as prompt_module
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.subagents_config import CustomSubagentConfig, SubagentsAppConfig
|
||||
@ -105,6 +106,55 @@ def test_apply_prompt_template_includes_relative_path_guidance(monkeypatch):
|
||||
assert "`hello.txt`, `../uploads/data.csv`, and `../outputs/report.md`" in prompt
|
||||
|
||||
|
||||
def test_apply_prompt_template_uses_non_interactive_clarification_guidance(monkeypatch):
|
||||
config = SimpleNamespace(
|
||||
sandbox=SimpleNamespace(mounts=[]),
|
||||
skills=SimpleNamespace(container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/skills")),
|
||||
skill_evolution=SimpleNamespace(enabled=False),
|
||||
tool_search=SimpleNamespace(enabled=False),
|
||||
memory=SimpleNamespace(enabled=False, mode="middleware", injection_enabled=False),
|
||||
acp_agents={},
|
||||
)
|
||||
policy = RunInteractionPolicy(RunInteractionMode.SCHEDULED)
|
||||
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None, **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "get_skills_prompt_section", lambda *args, **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "_build_acp_section", lambda **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "_build_custom_mounts_section", lambda **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "_build_memory_tool_section", lambda **kwargs: "")
|
||||
|
||||
prompt = prompt_module.apply_prompt_template(app_config=config, interaction_policy=policy)
|
||||
|
||||
assert "There is no human available to answer a synchronous question" in prompt
|
||||
assert "MUST call ask_clarification" not in prompt
|
||||
assert "Do not wait for a human response" in prompt
|
||||
|
||||
|
||||
def test_apply_prompt_template_preserves_interactive_clarification_guidance(monkeypatch):
|
||||
config = SimpleNamespace(
|
||||
sandbox=SimpleNamespace(mounts=[]),
|
||||
skills=SimpleNamespace(container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/skills")),
|
||||
skill_evolution=SimpleNamespace(enabled=False),
|
||||
tool_search=SimpleNamespace(enabled=False),
|
||||
memory=SimpleNamespace(enabled=False, mode="middleware", injection_enabled=False),
|
||||
acp_agents={},
|
||||
)
|
||||
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None, **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "get_skills_prompt_section", lambda *args, **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "_build_acp_section", lambda **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "_build_custom_mounts_section", lambda **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "_build_memory_tool_section", lambda **kwargs: "")
|
||||
|
||||
prompt = prompt_module.apply_prompt_template(app_config=config)
|
||||
|
||||
assert "**WORKFLOW PRIORITY: CLARIFY → PLAN → ACT**" in prompt
|
||||
assert "DO NOT call any other tool in the same turn as ask_clarification" in prompt
|
||||
assert "❌ DO NOT make assumptions when information is missing - ALWAYS ask" in prompt
|
||||
assert '**Example:**\nUser: "Deploy the application"' in prompt
|
||||
assert 'User: "staging"\nYou: "Deploying to staging..." [proceed]' in prompt
|
||||
|
||||
|
||||
def test_apply_prompt_template_includes_memory_tool_guidance_only_in_tool_mode(monkeypatch):
|
||||
tool_config = SimpleNamespace(
|
||||
sandbox=SimpleNamespace(mounts=[]),
|
||||
|
||||
@ -599,17 +599,32 @@ def test_wrap_tool_call_passthrough_when_sandbox_already_in_state() -> None:
|
||||
assert result is original
|
||||
|
||||
|
||||
def test_wrap_tool_call_turns_trusted_proxy_denial_into_human_input() -> None:
|
||||
@pytest.mark.parametrize("async_path", [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
"context",
|
||||
[
|
||||
{},
|
||||
{"interaction_mode": "interactive"},
|
||||
{"interaction_mode": "interactive", "non_interactive": True, "disable_clarification": True, "channel_name": "github"},
|
||||
],
|
||||
)
|
||||
def test_wrap_tool_call_turns_trusted_proxy_denial_into_human_input(context: dict, async_path: bool) -> None:
|
||||
provider = _NetworkPolicyProvider()
|
||||
provider.events = [{"request_id": "req-1", "host": "pypi.org", "port": 443, "method": "CONNECT"}]
|
||||
state: dict = {"sandbox": {"sandbox_id": "existing"}}
|
||||
request = _make_tool_call_request(state)
|
||||
request.runtime.context.update(context)
|
||||
original = ToolMessage(content="curl: proxy denied", tool_call_id="call-1", name="bash")
|
||||
|
||||
async def handler(_request: ToolCallRequest) -> ToolMessage:
|
||||
return original
|
||||
|
||||
set_sandbox_provider(provider)
|
||||
try:
|
||||
result = SandboxMiddleware().wrap_tool_call(
|
||||
request,
|
||||
lambda _request: ToolMessage(content="curl: proxy denied", tool_call_id="call-1", name="bash"),
|
||||
)
|
||||
if async_path:
|
||||
result = asyncio.run(SandboxMiddleware().awrap_tool_call(request, handler))
|
||||
else:
|
||||
result = SandboxMiddleware().wrap_tool_call(request, lambda _request: original)
|
||||
finally:
|
||||
reset_sandbox_provider()
|
||||
|
||||
@ -699,13 +714,23 @@ def test_before_agent_does_not_reapply_network_approval_after_new_user_turn() ->
|
||||
assert provider.decisions == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("context_key", ["disable_clarification", "non_interactive"])
|
||||
def test_sync_noninteractive_network_denial_is_recorded_without_prompt(context_key: str) -> None:
|
||||
_UNATTENDED_CONTEXTS = [
|
||||
pytest.param({"disable_clarification": True}, id="legacy-disable-clarification"),
|
||||
pytest.param({"non_interactive": True}, id="legacy-non-interactive"),
|
||||
pytest.param({"channel_name": "github"}, id="github-channel"),
|
||||
pytest.param({"interaction_mode": "webhook"}, id="webhook"),
|
||||
pytest.param({"interaction_mode": "scheduled"}, id="scheduled"),
|
||||
pytest.param({"interaction_mode": "autonomous"}, id="autonomous"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("context", _UNATTENDED_CONTEXTS)
|
||||
def test_sync_noninteractive_network_denial_is_recorded_without_prompt(context: dict) -> None:
|
||||
provider = _NetworkPolicyProvider()
|
||||
provider.events = [{"request_id": "req-1", "host": "example.com", "port": 443, "method": "CONNECT"}]
|
||||
state: dict = {"sandbox": {"sandbox_id": "existing"}}
|
||||
request = _make_tool_call_request(state)
|
||||
request.runtime.context[context_key] = True
|
||||
request.runtime.context.update(context)
|
||||
original = ToolMessage(content="proxy denied", tool_call_id="call-1", name="bash")
|
||||
set_sandbox_provider(provider)
|
||||
try:
|
||||
@ -720,13 +745,13 @@ def test_sync_noninteractive_network_denial_is_recorded_without_prompt(context_k
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("context_key", ["disable_clarification", "non_interactive"])
|
||||
async def test_async_noninteractive_network_denial_is_recorded_without_prompt(context_key: str) -> None:
|
||||
@pytest.mark.parametrize("context", _UNATTENDED_CONTEXTS)
|
||||
async def test_async_noninteractive_network_denial_is_recorded_without_prompt(context: dict) -> None:
|
||||
provider = _NetworkPolicyProvider()
|
||||
provider.events = [{"request_id": "req-1", "host": "example.com", "port": 443, "method": "CONNECT"}]
|
||||
state: dict = {"sandbox": {"sandbox_id": "existing"}}
|
||||
request = _make_tool_call_request(state)
|
||||
request.runtime.context[context_key] = True
|
||||
request.runtime.context.update(context)
|
||||
original = ToolMessage(content="proxy denied", tool_call_id="call-1", name="bash")
|
||||
|
||||
async def handler(_request: ToolCallRequest) -> ToolMessage:
|
||||
@ -750,6 +775,7 @@ def test_subagent_network_denial_fails_closed_without_prompt() -> None:
|
||||
state: dict = {"sandbox": {"sandbox_id": "existing"}}
|
||||
request = _make_tool_call_request(state)
|
||||
request.runtime.context["is_subagent"] = True
|
||||
request.runtime.context["interaction_mode"] = "interactive"
|
||||
original = ToolMessage(content="proxy denied", tool_call_id="call-1", name="bash")
|
||||
set_sandbox_provider(provider)
|
||||
try:
|
||||
@ -771,6 +797,7 @@ async def test_async_subagent_network_denial_fails_closed_without_prompt() -> No
|
||||
state: dict = {"sandbox": {"sandbox_id": "existing"}}
|
||||
request = _make_tool_call_request(state)
|
||||
request.runtime.context["is_subagent"] = True
|
||||
request.runtime.context["interaction_mode"] = "interactive"
|
||||
original = ToolMessage(content="proxy denied", tool_call_id="call-1", name="bash")
|
||||
|
||||
async def handler(_request: ToolCallRequest) -> ToolMessage:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user