diff --git a/README.md b/README.md
index 85c2ecc5f..2a29f965f 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index cf3c38536..58500cdab 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -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
diff --git a/backend/app/channels/manager.py b/backend/app/channels/manager.py
index 454ae4846..233ccb2f8 100644
--- a/backend/app/channels/manager.py
+++ b/backend/app/channels/manager.py
@@ -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:
diff --git a/backend/app/channels/run_policy.py b/backend/app/channels/run_policy.py
index 963954dcd..3ab3de174 100644
--- a/backend/app/channels/run_policy.py
+++ b/backend/app/channels/run_policy.py
@@ -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
diff --git a/backend/app/gateway/github/run_policy.py b/backend/app/gateway/github/run_policy.py
index cf45cbd2d..f67685f67 100644
--- a/backend/app/gateway/github/run_policy.py
+++ b/backend/app/gateway/github/run_policy.py
@@ -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
diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py
index 67dd0ca0f..ec62d226e 100644
--- a/backend/app/gateway/services.py
+++ b/backend/app/gateway/services.py
@@ -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
diff --git a/backend/docs/RUN_INTERACTION_POLICY.md b/backend/docs/RUN_INTERACTION_POLICY.md
new file mode 100644
index 000000000..933576ec3
--- /dev/null
+++ b/backend/docs/RUN_INTERACTION_POLICY.md
@@ -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.
diff --git a/backend/packages/harness/deerflow/agents/interaction_policy.py b/backend/packages/harness/deerflow/agents/interaction_policy.py
new file mode 100644
index 000000000..45ecff037
--- /dev/null
+++ b/backend/packages/harness/deerflow/agents/interaction_policy.py
@@ -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 = """
+**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]
+"""
+
+_AUTONOMOUS_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.
+"""
+
+_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()
diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py
index 14988500a..980e23ca3 100644
--- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py
+++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py
@@ -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(
diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py
index ac6c86d84..a8c233e5f 100644
--- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py
+++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py
@@ -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.
- 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
-
-**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}
{memory_tool_section}
@@ -747,7 +680,7 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
-- **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 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),
diff --git a/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py
index 623ec821a..712aae7ea 100644
--- a/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py
+++ b/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py
@@ -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,
diff --git a/backend/packages/harness/deerflow/sandbox/AGENTS.md b/backend/packages/harness/deerflow/sandbox/AGENTS.md
index 45aedc63e..2bb68505f 100644
--- a/backend/packages/harness/deerflow/sandbox/AGENTS.md
+++ b/backend/packages/harness/deerflow/sandbox/AGENTS.md
@@ -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.
diff --git a/backend/packages/harness/deerflow/sandbox/middleware.py b/backend/packages/harness/deerflow/sandbox/middleware.py
index bc2234f6e..0e29de3be 100644
--- a/backend/packages/harness/deerflow/sandbox/middleware.py
+++ b/backend/packages/harness/deerflow/sandbox/middleware.py
@@ -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):
diff --git a/backend/packages/harness/deerflow/tools/AGENTS.md b/backend/packages/harness/deerflow/tools/AGENTS.md
index 2aa6281bf..1caf34729 100644
--- a/backend/packages/harness/deerflow/tools/AGENTS.md
+++ b/backend/packages/harness/deerflow/tools/AGENTS.md
@@ -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.
diff --git a/backend/tests/test_channels.py b/backend/tests/test_channels.py
index 381ab78a5..193619721 100644
--- a/backend/tests/test_channels.py
+++ b/backend/tests/test_channels.py
@@ -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)
diff --git a/backend/tests/test_clarification_middleware.py b/backend/tests/test_clarification_middleware.py
index 0c43ff77b..d11702bf1 100644
--- a/backend/tests/test_clarification_middleware.py
+++ b/backend/tests/test_clarification_middleware.py
@@ -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})
diff --git a/backend/tests/test_gateway_services.py b/backend/tests/test_gateway_services.py
index 60a30da54..477a99e71 100644
--- a/backend/tests/test_gateway_services.py
+++ b/backend/tests/test_gateway_services.py
@@ -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():
diff --git a/backend/tests/test_interaction_policy.py b/backend/tests/test_interaction_policy.py
new file mode 100644
index 000000000..a456eaba5
--- /dev/null
+++ b/backend/tests/test_interaction_policy.py
@@ -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"}})
diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py
index 657afb2ee..73bea0aed 100644
--- a/backend/tests/test_lead_agent_model_resolution.py
+++ b/backend/tests/test_lead_agent_model_resolution.py
@@ -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):
diff --git a/backend/tests/test_lead_agent_prompt.py b/backend/tests/test_lead_agent_prompt.py
index 619a20fc3..5f0555192 100644
--- a/backend/tests/test_lead_agent_prompt.py
+++ b/backend/tests/test_lead_agent_prompt.py
@@ -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=[]),
diff --git a/backend/tests/test_sandbox_middleware.py b/backend/tests/test_sandbox_middleware.py
index c8efe015b..6b61a7742 100644
--- a/backend/tests/test_sandbox_middleware.py
+++ b/backend/tests/test_sandbox_middleware.py
@@ -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: