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: