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:
Eilen Shin 2026-09-20 18:57:49 +08:00 committed by GitHub
parent f2463e6d4a
commit bd995a6a26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 603 additions and 125 deletions

View File

@ -1636,8 +1636,12 @@ compatibility. Operators can set `sandbox.network.mode` to `isolated` or
`allowlist`; allowlist mode supports operator-defined domains and an interactive `allowlist`; allowlist mode supports operator-defined domains and an interactive
Human Input card for temporary or sandbox-lifetime HTTP(S) approval. Private, Human Input card for temporary or sandbox-lifetime HTTP(S) approval. Private,
loopback, link-local, multicast, and cloud metadata addresses remain loopback, link-local, multicast, and cloud metadata addresses remain
unapprovable. Denied hostnames are rejected before DNS resolution, and unapprovable. Denied hostnames are rejected before DNS resolution.
scheduled or otherwise non-interactive runs auto-deny without opening a card. 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 The trusted sidecar uses a dedicated per-sandbox egress bridge rather than
Docker's shared default bridge, and rejects ambiguous HTTP field names before Docker's shared default bridge, and rejects ambiguous HTTP field names before
forwarding. See forwarding. See

View File

@ -347,12 +347,13 @@ See [docs/FILE_UPLOAD.md](docs/FILE_UPLOAD.md) for details.
### Plan Mode ### Plan Mode
TodoList middleware for complex multi-step tasks: `config.configurable.is_plan_mode=True` enables TodoList `write_todos` for
- Controlled via runtime config: `config.configurable.is_plan_mode = True` multi-step tasks: one `in_progress` task, real-time updates. See
- Provides `write_todos` tool for task tracking [usage](docs/plan_mode_usage.md).
- One task in_progress at a time, real-time updates
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 ### Context Summarization

View File

@ -1742,7 +1742,12 @@ class ChannelManager:
policy = CHANNEL_RUN_POLICY.get(msg.channel_name) policy = CHANNEL_RUN_POLICY.get(msg.channel_name)
if policy is None: if policy is None:
return 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 run_context["disable_clarification"] = True
if policy.credentials_provider is not None: if policy.credentials_provider is not None:
try: try:

View File

@ -14,11 +14,14 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING: if TYPE_CHECKING:
from app.channels.message_bus import InboundMessage 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) @dataclass(frozen=True, slots=True)
class ChannelRunPolicy: class ChannelRunPolicy:
@ -39,6 +42,9 @@ class ChannelRunPolicy:
multiple separate methods on the manager. multiple separate methods on the manager.
Attributes: 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 is_interactive: When False, the manager sets
``run_context["disable_clarification"] = True`` so ``run_context["disable_clarification"] = True`` so
``ClarificationMiddleware`` returns a "proceed with best ``ClarificationMiddleware`` returns a "proceed with best
@ -102,6 +108,7 @@ class ChannelRunPolicy:
""" """
is_interactive: bool = True is_interactive: bool = True
interaction_mode: InteractionMode | None = None
default_recursion_limit: int | None = None default_recursion_limit: int | None = None
credentials_provider: Callable[[InboundMessage, dict[str, Any]], Awaitable[None]] | None = None credentials_provider: Callable[[InboundMessage, dict[str, Any]], Awaitable[None]] | None = None
requires_bound_identity: bool = True requires_bound_identity: bool = True
@ -109,6 +116,10 @@ class ChannelRunPolicy:
serialize_thread_runs: bool = False serialize_thread_runs: bool = False
buffer_followups_on_busy: 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 # Channel name → policy. Channels absent from this map fall through to
# the policy default (an interactive IM channel with no credential # the policy default (an interactive IM channel with no credential

View File

@ -104,6 +104,7 @@ def register_policy() -> None:
# GitHub webhooks have no synchronous human — ask_clarification # GitHub webhooks have no synchronous human — ask_clarification
# would dead-end the run. # would dead-end the run.
is_interactive=False, is_interactive=False,
interaction_mode="webhook",
# Autonomous coder runs (clone -> edit -> test -> push -> PR) # Autonomous coder runs (clone -> edit -> test -> push -> PR)
# routinely need more than the 100 super-step interactive ceiling. # routinely need more than the 100 super-step interactive ceiling.
# Per-agent overrides via GitHubAgentConfig.recursion_limit still # Per-agent overrides via GitHubAgentConfig.recursion_limit still

View File

@ -543,9 +543,9 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
) )
# Keys honored only for internally-authenticated callers (the scheduler path). # 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. # 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 # Server-owned authorization and sandbox lifecycle identity fields. These must
# never be accepted from client-supplied ``body.config.context`` or # 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 # webhooks) so ClarificationMiddleware proceeds
# instead of dead-ending the run. # 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``), # (``ChannelManager._apply_channel_policy`` and ``app.gateway.github.run_policy``),
# which reach the Gateway over the internally-authenticated request channel, so # which reach the Gateway over the internally-authenticated request channel, so
# they are internal-only as well — see :data:`_INTERNAL_ONLY_CONTEXT_KEYS`. # 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. # 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 # The two sets differ only in *where* a legitimate internal caller's value lands

View 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.

View 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()

View File

@ -34,6 +34,7 @@ from langchain.agents import create_agent
from langchain.agents.middleware import AgentMiddleware from langchain.agents.middleware import AgentMiddleware
from langchain_core.runnables import RunnableConfig 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.lead_agent.prompt import apply_prompt_template
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
from deerflow.agents.middlewares.configured_extensions import load_configured_extension_middlewares 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__) logger = logging.getLogger(__name__)
_BOOTSTRAP_SKILL_NAMES = {"bootstrap"} _BOOTSTRAP_SKILL_NAMES = {"bootstrap"}
_NON_INTERACTIVE_DISABLED_TOOL_NAMES = frozenset({"ask_clarification"})
# Channels whose inbound messages originate from untrusted external # Channels whose inbound messages originate from untrusted external
# commenters (anyone on a GitHub repo, etc.) and whose run context is # 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)) max_total_subagents = cfg.get("max_total_subagents", _default_max_total_subagents(resolved_app_config))
is_bootstrap = cfg.get("is_bootstrap", False) 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_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 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] raw_tools = get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, app_config=resolved_app_config) + [setup_agent]
configured_tools = raw_tools configured_tools = raw_tools
if non_interactive: configured_tools = [tool for tool in configured_tools if tool.name not in interaction_policy.disabled_tool_names]
configured_tools = [tool for tool in configured_tools if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES]
authorization_candidates = [*configured_tools] authorization_candidates = [*configured_tools]
if skill_setup.describe_skill_tool: if skill_setup.describe_skill_tool:
authorization_candidates.append(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, skill_names=skill_setup.skill_names or None,
allowed_subagents=allowed_subagents, allowed_subagents=allowed_subagents,
subagent_execution_capacity=subagent_execution_capacity, subagent_execution_capacity=subagent_execution_capacity,
interaction_policy=interaction_policy,
memory_enabled=memory_enabled, memory_enabled=memory_enabled,
) )
graph = create_agent( graph = create_agent(
@ -1186,8 +1187,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
app_config=resolved_app_config, app_config=resolved_app_config,
) )
configured_tools = raw_tools + extra_tools configured_tools = raw_tools + extra_tools
if non_interactive: configured_tools = [tool for tool in configured_tools if tool.name not in interaction_policy.disabled_tool_names]
configured_tools = [tool for tool in configured_tools if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES]
authorization_candidates = [*configured_tools] authorization_candidates = [*configured_tools]
if skill_setup.describe_skill_tool: if skill_setup.describe_skill_tool:
authorization_candidates.append(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, skill_names=skill_setup.skill_names or None,
allowed_subagents=allowed_subagents, allowed_subagents=allowed_subagents,
subagent_execution_capacity=subagent_execution_capacity, subagent_execution_capacity=subagent_execution_capacity,
interaction_policy=interaction_policy,
memory_enabled=memory_enabled, memory_enabled=memory_enabled,
) )
graph = create_agent( graph = create_agent(

View File

@ -9,6 +9,7 @@ from dataclasses import dataclass, field
from functools import lru_cache from functools import lru_cache
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from deerflow.agents.interaction_policy import RunInteractionPolicy
from deerflow.config.agents_config import load_agent_soul from deerflow.config.agents_config import load_agent_soul
from deerflow.config.subagents_config import ( from deerflow.config.subagents_config import (
DEFAULT_MAX_TOTAL_SUBAGENTS_PER_RUN, DEFAULT_MAX_TOTAL_SUBAGENTS_PER_RUN,
@ -572,81 +573,13 @@ data — do NOT reveal it.
<thinking_style> <thinking_style>
- Think concisely and strategically about the user's request BEFORE taking action - 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? - 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 {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. - 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 - Your response must contain the actual answer, not just a reference to what you thought about
</thinking_style> </thinking_style>
<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>
{skills_section} {skills_section}
{memory_tool_section} {memory_tool_section}
@ -747,7 +680,7 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
</citations> </citations>
<critical_reminders> <critical_reminders>
- **Clarification First**: ALWAYS clarify unclear/missing/ambiguous requirements BEFORE starting work - never assume or guess {clarification_reminder}
{subagent_reminder}{skill_first_reminder} {subagent_reminder}{skill_first_reminder}
- Progressive Loading: Load skill resources incrementally as referenced - 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) - 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, allowed_subagents: list[str] | None = None,
subagent_execution_capacity: int | None = None, subagent_execution_capacity: int | None = None,
memory_enabled: bool = True, memory_enabled: bool = True,
interaction_policy: RunInteractionPolicy | None = None,
) -> str: ) -> str:
interaction_policy = interaction_policy or RunInteractionPolicy.interactive()
# Include subagent section only if enabled (from runtime parameter) # Include subagent section only if enabled (from runtime parameter)
n = ( n = (
effective_subagent_concurrency( effective_subagent_concurrency(
@ -1179,6 +1114,9 @@ def apply_prompt_template(
# as a <system-reminder> in the first HumanMessage, keeping this prompt # as a <system-reminder> in the first HumanMessage, keeping this prompt
# identical across users and sessions for maximum prefix-cache reuse. # identical across users and sessions for maximum prefix-cache reuse.
return SYSTEM_PROMPT_TEMPLATE.format( 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", agent_name=agent_name or "DeerFlow 2.0",
soul=get_agent_soul(agent_name, user_id=user_id), soul=get_agent_soul(agent_name, user_id=user_id),
self_update_section=_build_self_update_section(agent_name), self_update_section=_build_self_update_section(agent_name),

View File

@ -15,6 +15,7 @@ from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.runtime import Runtime from langgraph.runtime import Runtime
from langgraph.types import Command 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 from deerflow.agents.middlewares.tool_call_metadata import clone_ai_message_with_tool_calls
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -385,7 +386,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
context = getattr(runtime, "context", None) context = getattr(runtime, "context", None)
if not context: if not context:
return False 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: def _is_disabled(self, request: ToolCallRequest) -> bool:
"""Whether clarifications are suppressed for this tool-call request.""" """Whether clarifications are suppressed for this tool-call request."""
@ -446,22 +447,25 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
return {"messages": [patched]} return {"messages": [patched]}
def _handle_disabled_clarification(self, request: ToolCallRequest) -> ToolMessage: 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 Returns a plain ToolMessage (not a ``Command(goto=END)``) so the
agent loop continues instead of ending — the agent receives this agent loop continues instead of ending — the agent receives this
as the tool result and generates again, ideally acting rather as the tool result and either continues low-risk, reversible work
than re-asking. or reports a blocked result rather than re-asking.
""" """
tool_call_id = request.tool_call.get("id", "") 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( return ToolMessage(
id=self._stable_message_id(tool_call_id, "proceed-without-clarification"), id=self._stable_message_id(tool_call_id, "proceed-without-clarification"),
content=( content=(
"Clarification is disabled in this context — the human is not present " "Clarification is disabled in this context — the human is not present "
"to answer synchronously. Do not ask for confirmation. Proceed with your " "to answer synchronously. Do not ask for confirmation or wait for a human response. "
"best judgment, carry out the requested action, and state any assumptions " "For low-risk and reversible work, proceed with the smallest reasonable assumption "
"you made in your final response." "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, tool_call_id=tool_call_id,
name=ASK_CLARIFICATION_TOOL_NAME, name=ASK_CLARIFICATION_TOOL_NAME,

View File

@ -1,5 +1,13 @@
### Sandbox System (`packages/harness/deerflow/sandbox/`) ### 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. **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. **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. **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.

View File

@ -13,6 +13,7 @@ from langgraph.runtime import Runtime
from langgraph.types import Command, Overwrite from langgraph.types import Command, Overwrite
from deerflow.agents.human_input import read_human_input_response 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.agents.thread_state import SandboxStateField, ThreadDataState
from deerflow.authz.sandbox_authz import ( from deerflow.authz.sandbox_authz import (
authorize_sandbox_execution, 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: 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): class SandboxMiddlewareState(AgentState):

View File

@ -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. 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-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. 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.

View File

@ -57,6 +57,58 @@ def test_strip_leading_mentions_only_drops_flush_leading_mentions():
assert not is_known_channel_command("@bot /goal") 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: def _make_channel_skill(tmp_path: Path, name: str, *, enabled: bool = True) -> Skill:
skill_dir = tmp_path / name skill_dir = tmp_path / name
skill_dir.mkdir(parents=True, exist_ok=True) skill_dir.mkdir(parents=True, exist_ok=True)

View File

@ -730,6 +730,37 @@ class TestClarificationDisabled:
assert "disabled" in result.content.lower() assert "disabled" in result.content.lower()
assert "proceed" 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): def test_disabled_async_path(self, middleware):
request = self._request(runtime_context={"disable_clarification": True}) request = self._request(runtime_context={"disable_clarification": True})

View File

@ -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"] assert via_assistant_id["context"]["agent_name"] == via_context["context"]["agent_name"]
def test_non_interactive_context_override_is_internal_only(): def test_interaction_policy_context_override_is_internal_only():
"""Client-supplied ``non_interactive`` must be dropped: it strips the """Client-supplied interaction policy must be dropped because it controls
``ask_clarification`` tool, so only the internal scheduler path may set it.""" whether the lead agent exposes ``ask_clarification``."""
from app.gateway.services import build_run_config, merge_run_context_overrides from app.gateway.services import build_run_config, merge_run_context_overrides
config = build_run_config("thread-1", None, None) 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["configurable"]
assert "non_interactive" not in config["context"] 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 from app.gateway.services import build_run_config, merge_run_context_overrides
config = build_run_config("thread-1", None, None) 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["configurable"]["non_interactive"] is True
assert config["context"]["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" assert config["configurable"]["model_name"] == "gpt"
@ -3738,20 +3768,49 @@ def test_build_run_config_no_request_config():
assert "context" not in config assert "context" not in config
def test_strip_internal_context_keys_scrubs_config_smuggled_non_interactive(): def test_strip_internal_context_keys_scrubs_config_smuggled_interaction_policy():
"""A non-internal client must not force ``non_interactive`` via the free-form """A non-internal client must not force interaction policy via the free-form
``body.config`` either — ``build_run_config`` copies ``config.context`` and ``body.config`` either — ``build_run_config`` copies ``config.context`` and
``config.configurable`` verbatim, so the assembled config gets scrubbed.""" ``config.configurable`` verbatim, so the assembled config gets scrubbed."""
from app.gateway.services import build_run_config, strip_internal_context_keys 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) strip_internal_context_keys(via_context)
assert "non_interactive" not in via_context["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" 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) strip_internal_context_keys(via_configurable)
assert "non_interactive" not in via_configurable["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(): def test_strip_internal_context_keys_scrubs_config_smuggled_context_only_keys():

View 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"}})

View File

@ -7,7 +7,7 @@ import inspect
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock, create_autospec
import pytest import pytest
from langchain.agents import create_agent 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 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)]) app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])
import deerflow.tools as tools_module 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", "get_available_tools",
lambda **kwargs: [_named_tool("ask_clarification"), _named_tool("bash")], 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_chat_model", lambda **kwargs: object())
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs) 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, "thinking_enabled": False,
"subagent_enabled": False, "subagent_enabled": False,
"non_interactive": True, "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): def test_make_lead_agent_rejects_invalid_bootstrap_agent_name(monkeypatch):

View File

@ -6,6 +6,7 @@ from typing import cast
import anyio import anyio
import pytest import pytest
from deerflow.agents.interaction_policy import RunInteractionMode, RunInteractionPolicy
from deerflow.agents.lead_agent import prompt as prompt_module from deerflow.agents.lead_agent import prompt as prompt_module
from deerflow.config.app_config import AppConfig from deerflow.config.app_config import AppConfig
from deerflow.config.subagents_config import CustomSubagentConfig, SubagentsAppConfig 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 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): def test_apply_prompt_template_includes_memory_tool_guidance_only_in_tool_mode(monkeypatch):
tool_config = SimpleNamespace( tool_config = SimpleNamespace(
sandbox=SimpleNamespace(mounts=[]), sandbox=SimpleNamespace(mounts=[]),

View File

@ -599,17 +599,32 @@ def test_wrap_tool_call_passthrough_when_sandbox_already_in_state() -> None:
assert result is original 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 = _NetworkPolicyProvider()
provider.events = [{"request_id": "req-1", "host": "pypi.org", "port": 443, "method": "CONNECT"}] provider.events = [{"request_id": "req-1", "host": "pypi.org", "port": 443, "method": "CONNECT"}]
state: dict = {"sandbox": {"sandbox_id": "existing"}} state: dict = {"sandbox": {"sandbox_id": "existing"}}
request = _make_tool_call_request(state) 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) set_sandbox_provider(provider)
try: try:
result = SandboxMiddleware().wrap_tool_call( if async_path:
request, result = asyncio.run(SandboxMiddleware().awrap_tool_call(request, handler))
lambda _request: ToolMessage(content="curl: proxy denied", tool_call_id="call-1", name="bash"), else:
) result = SandboxMiddleware().wrap_tool_call(request, lambda _request: original)
finally: finally:
reset_sandbox_provider() reset_sandbox_provider()
@ -699,13 +714,23 @@ def test_before_agent_does_not_reapply_network_approval_after_new_user_turn() ->
assert provider.decisions == [] assert provider.decisions == []
@pytest.mark.parametrize("context_key", ["disable_clarification", "non_interactive"]) _UNATTENDED_CONTEXTS = [
def test_sync_noninteractive_network_denial_is_recorded_without_prompt(context_key: str) -> None: 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 = _NetworkPolicyProvider()
provider.events = [{"request_id": "req-1", "host": "example.com", "port": 443, "method": "CONNECT"}] provider.events = [{"request_id": "req-1", "host": "example.com", "port": 443, "method": "CONNECT"}]
state: dict = {"sandbox": {"sandbox_id": "existing"}} state: dict = {"sandbox": {"sandbox_id": "existing"}}
request = _make_tool_call_request(state) 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") original = ToolMessage(content="proxy denied", tool_call_id="call-1", name="bash")
set_sandbox_provider(provider) set_sandbox_provider(provider)
try: try:
@ -720,13 +745,13 @@ def test_sync_noninteractive_network_denial_is_recorded_without_prompt(context_k
@pytest.mark.anyio @pytest.mark.anyio
@pytest.mark.parametrize("context_key", ["disable_clarification", "non_interactive"]) @pytest.mark.parametrize("context", _UNATTENDED_CONTEXTS)
async def test_async_noninteractive_network_denial_is_recorded_without_prompt(context_key: str) -> None: async def test_async_noninteractive_network_denial_is_recorded_without_prompt(context: dict) -> None:
provider = _NetworkPolicyProvider() provider = _NetworkPolicyProvider()
provider.events = [{"request_id": "req-1", "host": "example.com", "port": 443, "method": "CONNECT"}] provider.events = [{"request_id": "req-1", "host": "example.com", "port": 443, "method": "CONNECT"}]
state: dict = {"sandbox": {"sandbox_id": "existing"}} state: dict = {"sandbox": {"sandbox_id": "existing"}}
request = _make_tool_call_request(state) 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") original = ToolMessage(content="proxy denied", tool_call_id="call-1", name="bash")
async def handler(_request: ToolCallRequest) -> ToolMessage: 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"}} state: dict = {"sandbox": {"sandbox_id": "existing"}}
request = _make_tool_call_request(state) request = _make_tool_call_request(state)
request.runtime.context["is_subagent"] = True request.runtime.context["is_subagent"] = True
request.runtime.context["interaction_mode"] = "interactive"
original = ToolMessage(content="proxy denied", tool_call_id="call-1", name="bash") original = ToolMessage(content="proxy denied", tool_call_id="call-1", name="bash")
set_sandbox_provider(provider) set_sandbox_provider(provider)
try: try:
@ -771,6 +797,7 @@ async def test_async_subagent_network_denial_fails_closed_without_prompt() -> No
state: dict = {"sandbox": {"sandbox_id": "existing"}} state: dict = {"sandbox": {"sandbox_id": "existing"}}
request = _make_tool_call_request(state) request = _make_tool_call_request(state)
request.runtime.context["is_subagent"] = True request.runtime.context["is_subagent"] = True
request.runtime.context["interaction_mode"] = "interactive"
original = ToolMessage(content="proxy denied", tool_call_id="call-1", name="bash") original = ToolMessage(content="proxy denied", tool_call_id="call-1", name="bash")
async def handler(_request: ToolCallRequest) -> ToolMessage: async def handler(_request: ToolCallRequest) -> ToolMessage: