"""Build ``dify-agent`` run requests from API-side product concepts. This module is intentionally an adapter, not a wire DTO package. The emitted object is always ``dify_agent.protocol.CreateRunRequest`` so the Agent backend protocol has a single owner. API-only context such as Agent Soul vs workflow job prompt is preserved in layer names and metadata until the dedicated product schemas land in later phases. Dify-owned execution identifiers are emitted as an explicit ``dify.execution_context`` layer so the run request stays fully composition-driven. """ from __future__ import annotations import re from collections.abc import Mapping from typing import ClassVar, Literal from agenton.compositor import CompositorSessionSnapshot from agenton.layers import ExitIntent from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig from dify_agent.layers.config import DIFY_CONFIG_LAYER_TYPE_ID, DifyConfigLayerConfig from dify_agent.layers.dify_core_tools import DIFY_CORE_TOOLS_LAYER_TYPE_ID, DifyCoreToolsLayerConfig from dify_agent.layers.dify_plugin import ( DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, DifyPluginCredentialValue, DifyPluginLLMLayerConfig, DifyPluginToolsLayerConfig, ) from dify_agent.layers.drive import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig from dify_agent.layers.execution_context import ( DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig, ) from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig from dify_agent.layers.runtime import DIFY_RUNTIME_LAYER_TYPE_ID, DifyRuntimeLayerConfig from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig from dify_agent.protocol import ( DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID, CreateRunRequest, DeferredToolResultsPayload, LayerExitSignals, RunComposition, RunLayerSpec, ) from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator AGENT_SOUL_PROMPT_LAYER_ID = "agent_soul_prompt" WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt" WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt" AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt" DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context" DIFY_RUNTIME_LAYER_ID = "runtime" DIFY_CONFIG_LAYER_ID = "config" DIFY_DRIVE_LAYER_ID = "drive" DIFY_PLUGIN_TOOLS_LAYER_ID = "tools" DIFY_CORE_TOOLS_LAYER_ID = "core_tools" DIFY_KNOWLEDGE_BASE_LAYER_ID = "knowledge" DIFY_ASK_HUMAN_LAYER_ID = "ask_human" DIFY_SHELL_LAYER_ID = "shell" type AgentConfigVersionKind = Literal["snapshot", "draft", "build_draft"] def _shell_layer_deps() -> dict[str, str]: return { "execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID, "runtime": DIFY_RUNTIME_LAYER_ID, } def _drive_layer_deps() -> dict[str, str]: return {"shell": DIFY_SHELL_LAYER_ID} def _config_layer_deps() -> dict[str, str]: return {"shell": DIFY_SHELL_LAYER_ID} def _shell_config_with_drive_ref( shell_config: DifyShellLayerConfig | None, drive_config: DifyDriveLayerConfig | None, ) -> DifyShellLayerConfig: config = shell_config or DifyShellLayerConfig() if drive_config is None: return config return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref}) def _markdown_backtick_fence(text: str) -> str: """Choose a fence that will not terminate inside the prompt body.""" longest_backtick_run = max((len(match.group(0)) for match in re.finditer(r"`+", text)), default=0) return "`" * max(3, longest_backtick_run + 1) _BUILD_DRAFT_AGENT_SOUL_PROMPT = """You are running in build mode. Objective: - Improve this agent's working environment, configuration, tools, files, notes, and context so it can handle the intended task well. Guidance: - Treat the intended task as context for setup work, validation, and configuration decisions. - Perform concrete investigative or setup steps when they help improve or verify the agent configuration. - Use the installed `dify-agent` CLI when you need to inspect or persist Agent configuration.""" def _wrap_build_draft_agent_soul_prompt(prompt: str | None) -> str: """Reframe build-draft Agent Soul prompts as preparation work for a future run.""" prompt_body = (prompt or "").strip() if not prompt_body: return _BUILD_DRAFT_AGENT_SOUL_PROMPT + "\n\nIntended task for later normal runs:\nNo task prompt was provided." fence = _markdown_backtick_fence(prompt_body) return ( _BUILD_DRAFT_AGENT_SOUL_PROMPT + f"\n\nIntended task for later normal runs:\n{fence}text\n{prompt_body}\n{fence}" ) def _agent_soul_prompt_for_layer( prompt: str | None, *, config_version_kind: AgentConfigVersionKind, ) -> str | None: """Preserve normal snapshot/draft prompts and only wrap build-draft prompts. The API-side layer adapter is the product boundary where Agent Soul text becomes the model-facing system-prompt layer. ``snapshot`` and normal ``draft`` runs pass through the original effective prompt unchanged, while ``build_draft`` always emits a setup prompt. When an original prompt is present, it is reframed as future-run context and embedded in a fenced block; when it is blank, the setup instruction is still kept. """ if config_version_kind != "build_draft": if prompt is None: return None if not prompt.strip(): return None return prompt return _wrap_build_draft_agent_soul_prompt(prompt) class AgentBackendModelConfig(BaseModel): """API-side model/plugin selection before it is converted to Dify Agent layers.""" plugin_id: str model_provider: str model: str credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict) model_settings: dict[str, JsonValue] = Field(default_factory=dict) model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") # ``DifyPluginLLMLayerConfig.model_settings`` is pydantic_ai's ``ModelSettings`` # TypedDict (closed: unknown keys are rejected, explicit ``None`` values fail the # per-field type checks). Agent Soul model settings carry a wider, nullable shape # (``stop`` / ``response_format`` plus null-padded fields), so the layer config # only receives the keys the runtime contract accepts. _AGENT_MODEL_SETTINGS_PASSTHROUGH_KEYS = ( "temperature", "top_p", "presence_penalty", "frequency_penalty", "max_tokens", ) def _agent_model_settings(settings: Mapping[str, JsonValue]) -> dict[str, JsonValue] | None: sanitized: dict[str, JsonValue] = { key: settings[key] for key in _AGENT_MODEL_SETTINGS_PASSTHROUGH_KEYS if settings.get(key) is not None } stop = settings.get("stop") if isinstance(stop, list) and stop: sanitized["stop_sequences"] = stop return sanitized or None class AgentBackendOutputConfig(BaseModel): """API-side structured output declaration for the conventional output layer. The structured-output tool name is fixed to ``final_output`` inside ``dify_agent.layers.output`` so callers only control the JSON Schema plus optional description/strictness metadata. """ json_schema: dict[str, JsonValue] description: str | None = None strict: bool | None = None model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") class AgentBackendWorkflowNodeRunInput(BaseModel): """Inputs needed to build the first workflow-node-oriented Agent backend run request.""" model: AgentBackendModelConfig execution_context: DifyExecutionContextLayerConfig backend_binding_ref: str = Field(min_length=1) workflow_node_job_prompt: str user_prompt: str agent_soul_prompt: str | None = None agent_config_version_kind: AgentConfigVersionKind = "snapshot" idempotency_key: str | None = None output: AgentBackendOutputConfig | None = None tools: DifyPluginToolsLayerConfig | None = None core_tools: DifyCoreToolsLayerConfig | None = None knowledge: DifyKnowledgeBaseLayerConfig | None = None config_layer_config: DifyConfigLayerConfig | None = None # Drive Skills & Files declaration (dify.drive) — an index the agent pulls # through the back proxy, never inline content. drive_config: DifyDriveLayerConfig | None = None # Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when # the Agent Soul configures human involvement; a deferred call ends the run and # the workflow pauses via the existing HITL form mechanism (ENG-635). ask_human_config: DifyAskHumanLayerConfig | None = None # Inject the sandboxed shell graph. Requires a deployment-selected runtime # backend plus the product-resolved persistent Binding. include_shell: bool = False shell_config: DifyShellLayerConfig | None = None session_snapshot: CompositorSessionSnapshot | None = None # Human tool results fed back into a continuation run after a HITL submission # (ENG-638). Keyed by the original deferred tool_call_id. deferred_tool_results: DeferredToolResultsPayload | None = None include_history: bool = True metadata: dict[str, JsonValue] = Field(default_factory=dict) model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True) @field_validator("workflow_node_job_prompt", "user_prompt") @classmethod def _reject_blank_prompt(cls, value: str) -> str: if not value.strip(): raise ValueError("prompt must not be blank") return value class AgentBackendAgentAppRunInput(BaseModel): """Inputs to build one Agent App conversation-turn run request. Unlike the workflow-node input there is no workflow-node-job prompt and no previous-node context: the user prompt is the chat message, and multi-turn continuity comes from ``session_snapshot`` + the history layer keyed by the conversation. """ model: AgentBackendModelConfig execution_context: DifyExecutionContextLayerConfig backend_binding_ref: str = Field(min_length=1) user_prompt: str agent_soul_prompt: str | None = None agent_config_version_kind: AgentConfigVersionKind = "snapshot" idempotency_key: str | None = None output: AgentBackendOutputConfig | None = None tools: DifyPluginToolsLayerConfig | None = None core_tools: DifyCoreToolsLayerConfig | None = None knowledge: DifyKnowledgeBaseLayerConfig | None = None config_layer_config: DifyConfigLayerConfig | None = None # Drive Skills & Files declaration (dify.drive) — an index the agent pulls # through the back proxy, never inline content. drive_config: DifyDriveLayerConfig | None = None # Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when # the Agent Soul configures human involvement (ENG-635). ask_human_config: DifyAskHumanLayerConfig | None = None # Inject the sandboxed shell graph. Requires a deployment-selected runtime # backend plus the product-resolved persistent Binding. include_shell: bool = False shell_config: DifyShellLayerConfig | None = None session_snapshot: CompositorSessionSnapshot | None = None # Human tool results fed back into a continuation run after a HITL submission # (ENG-638). Keyed by the original deferred tool_call_id. deferred_tool_results: DeferredToolResultsPayload | None = None include_history: bool = True metadata: dict[str, JsonValue] = Field(default_factory=dict) model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True) @field_validator("user_prompt") @classmethod def _reject_blank_prompt(cls, value: str) -> str: if not value.strip(): raise ValueError("prompt must not be blank") return value class AgentBackendRunRequestBuilder: """Converts API product state into the public ``dify-agent`` run protocol.""" def build_for_agent_app(self, run_input: AgentBackendAgentAppRunInput) -> CreateRunRequest: """Build an Agent App conversation-turn run request. Layer graph: optional Agent Soul system prompt → user prompt → execution context → optional shell / config / drive / history (multi-turn) → LLM → optional plugin-direct tools / core-routed tools / knowledge search / ask_human / structured output. Mirrors the workflow-node layer ordering minus the workflow-job / previous-node prompt. """ layers: list[RunLayerSpec] = [] agent_soul_prompt = _agent_soul_prompt_for_layer( run_input.agent_soul_prompt, config_version_kind=run_input.agent_config_version_kind, ) if agent_soul_prompt: layers.append( RunLayerSpec( name=AGENT_SOUL_PROMPT_LAYER_ID, type=PLAIN_PROMPT_LAYER_TYPE_ID, metadata={**run_input.metadata, "origin": "agent_soul"}, config=PromptLayerConfig(prefix=agent_soul_prompt), ) ) layers.extend( [ RunLayerSpec( name=AGENT_APP_USER_PROMPT_LAYER_ID, type=PLAIN_PROMPT_LAYER_TYPE_ID, metadata={**run_input.metadata, "origin": "agent_app_user_prompt"}, config=PromptLayerConfig(user=run_input.user_prompt), ), RunLayerSpec( name=DIFY_EXECUTION_CONTEXT_LAYER_ID, type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, metadata=run_input.metadata, config=run_input.execution_context, ), ] ) include_shell = ( run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None ) if include_shell: layers.append( RunLayerSpec( name=DIFY_RUNTIME_LAYER_ID, type=DIFY_RUNTIME_LAYER_TYPE_ID, metadata=run_input.metadata, config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref), ) ) # Sandboxed bash workspace (dify.shell). It enters before config/drive # so eager pulls materialize content in the same filesystem used by # model commands. layers.append( RunLayerSpec( name=DIFY_SHELL_LAYER_ID, type=DIFY_SHELL_LAYER_TYPE_ID, deps=_shell_layer_deps(), metadata=run_input.metadata, config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config), ) ) if run_input.config_layer_config is not None: layers.append( RunLayerSpec( name=DIFY_CONFIG_LAYER_ID, type=DIFY_CONFIG_LAYER_TYPE_ID, deps=_config_layer_deps(), metadata=run_input.metadata, config=run_input.config_layer_config, ) ) if run_input.drive_config is not None: # Drive Skills & Files declaration (dify.drive): the catalog plus # prompt-mentioned entries eagerly pulled through the shell layer. layers.append( RunLayerSpec( name=DIFY_DRIVE_LAYER_ID, type=DIFY_DRIVE_LAYER_TYPE_ID, deps=_drive_layer_deps(), metadata=run_input.metadata, config=run_input.drive_config, ) ) if run_input.include_history: layers.append( RunLayerSpec( name=DIFY_AGENT_HISTORY_LAYER_ID, type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, metadata={**run_input.metadata, "origin": "agent_session_history"}, ) ) layers.append( RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID, deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, metadata=run_input.metadata, config=DifyPluginLLMLayerConfig( plugin_id=run_input.model.plugin_id, model_provider=run_input.model.model_provider, model=run_input.model.model, credentials=run_input.model.credentials, model_settings=_agent_model_settings(run_input.model.model_settings), ), ) ) if run_input.tools is not None and run_input.tools.tools: plugin_tool_deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} if include_shell: plugin_tool_deps["shell"] = DIFY_SHELL_LAYER_ID layers.append( RunLayerSpec( name=DIFY_PLUGIN_TOOLS_LAYER_ID, type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, deps=plugin_tool_deps, metadata=run_input.metadata, config=run_input.tools, ) ) if run_input.core_tools is not None and run_input.core_tools.tools: layers.append( RunLayerSpec( name=DIFY_CORE_TOOLS_LAYER_ID, type=DIFY_CORE_TOOLS_LAYER_TYPE_ID, deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, metadata=run_input.metadata, config=run_input.core_tools, ) ) if run_input.knowledge is not None and run_input.knowledge.sets: layers.append( RunLayerSpec( name=DIFY_KNOWLEDGE_BASE_LAYER_ID, type=DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, metadata=run_input.metadata, config=run_input.knowledge, ) ) if run_input.ask_human_config is not None: # Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends # the run with a deferred_tool_call; the caller pauses (workflow HITL) and # later resumes with deferred_tool_results. Needs the history layer above. layers.append( RunLayerSpec( name=DIFY_ASK_HUMAN_LAYER_ID, type=DIFY_ASK_HUMAN_LAYER_TYPE_ID, metadata=run_input.metadata, config=run_input.ask_human_config, ) ) if run_input.output is not None: layers.append( RunLayerSpec( name=DIFY_AGENT_OUTPUT_LAYER_ID, type=DIFY_OUTPUT_LAYER_TYPE_ID, metadata=run_input.metadata, config=DifyOutputLayerConfig( json_schema=run_input.output.json_schema, description=run_input.output.description, strict=run_input.output.strict, ), ) ) return CreateRunRequest( composition=RunComposition(layers=layers), idempotency_key=run_input.idempotency_key, metadata=run_input.metadata, session_snapshot=run_input.session_snapshot, deferred_tool_results=run_input.deferred_tool_results, on_exit=LayerExitSignals(default=ExitIntent.SUSPEND), ) def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) -> CreateRunRequest: """Build a workflow Agent Node run request without defining another wire schema. Layer graph mirrors the workflow surface: prompts → execution context → optional shell / config / drive / history → LLM → optional plugin-direct tools / core-routed tools / knowledge search / ask_human / structured output. """ layers: list[RunLayerSpec] = [] agent_soul_prompt = _agent_soul_prompt_for_layer( run_input.agent_soul_prompt, config_version_kind=run_input.agent_config_version_kind, ) if agent_soul_prompt: layers.append( RunLayerSpec( name=AGENT_SOUL_PROMPT_LAYER_ID, type=PLAIN_PROMPT_LAYER_TYPE_ID, metadata={**run_input.metadata, "origin": "agent_soul"}, config=PromptLayerConfig(prefix=agent_soul_prompt), ) ) layers.extend( [ RunLayerSpec( name=WORKFLOW_NODE_JOB_PROMPT_LAYER_ID, type=PLAIN_PROMPT_LAYER_TYPE_ID, metadata={**run_input.metadata, "origin": "workflow_node_job"}, config=PromptLayerConfig(user=run_input.workflow_node_job_prompt), ), RunLayerSpec( name=WORKFLOW_USER_PROMPT_LAYER_ID, type=PLAIN_PROMPT_LAYER_TYPE_ID, metadata={**run_input.metadata, "origin": "workflow_user_prompt"}, config=PromptLayerConfig(user=run_input.user_prompt), ), RunLayerSpec( name=DIFY_EXECUTION_CONTEXT_LAYER_ID, type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, metadata=run_input.metadata, config=run_input.execution_context, ), ] ) include_shell = ( run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None ) if include_shell: layers.append( RunLayerSpec( name=DIFY_RUNTIME_LAYER_ID, type=DIFY_RUNTIME_LAYER_TYPE_ID, metadata=run_input.metadata, config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref), ) ) # Sandboxed bash workspace (dify.shell). It enters before drive so # drive can materialize mentioned targets with `dify-agent drive pull` # in the same shell-visible filesystem used by model commands. layers.append( RunLayerSpec( name=DIFY_SHELL_LAYER_ID, type=DIFY_SHELL_LAYER_TYPE_ID, deps=_shell_layer_deps(), metadata=run_input.metadata, config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config), ) ) if run_input.config_layer_config is not None: layers.append( RunLayerSpec( name=DIFY_CONFIG_LAYER_ID, type=DIFY_CONFIG_LAYER_TYPE_ID, deps=_config_layer_deps(), metadata=run_input.metadata, config=run_input.config_layer_config, ) ) if run_input.drive_config is not None: # Drive Skills & Files declaration (dify.drive): the catalog plus # prompt-mentioned entries eagerly pulled through the shell layer. layers.append( RunLayerSpec( name=DIFY_DRIVE_LAYER_ID, type=DIFY_DRIVE_LAYER_TYPE_ID, deps=_drive_layer_deps(), metadata=run_input.metadata, config=run_input.drive_config, ) ) if run_input.include_history: layers.append( RunLayerSpec( name=DIFY_AGENT_HISTORY_LAYER_ID, type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, metadata={**run_input.metadata, "origin": "agent_session_history"}, ) ) layers.extend( [ RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID, deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, metadata=run_input.metadata, config=DifyPluginLLMLayerConfig( plugin_id=run_input.model.plugin_id, model_provider=run_input.model.model_provider, model=run_input.model.model, credentials=run_input.model.credentials, model_settings=_agent_model_settings(run_input.model.model_settings), ), ), ] ) if run_input.tools is not None and run_input.tools.tools: plugin_tool_deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} if include_shell: plugin_tool_deps["shell"] = DIFY_SHELL_LAYER_ID layers.append( RunLayerSpec( name=DIFY_PLUGIN_TOOLS_LAYER_ID, type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, deps=plugin_tool_deps, metadata=run_input.metadata, config=run_input.tools, ) ) if run_input.core_tools is not None and run_input.core_tools.tools: layers.append( RunLayerSpec( name=DIFY_CORE_TOOLS_LAYER_ID, type=DIFY_CORE_TOOLS_LAYER_TYPE_ID, deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, metadata=run_input.metadata, config=run_input.core_tools, ) ) if run_input.knowledge is not None and run_input.knowledge.sets: layers.append( RunLayerSpec( name=DIFY_KNOWLEDGE_BASE_LAYER_ID, type=DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, metadata=run_input.metadata, config=run_input.knowledge, ) ) if run_input.ask_human_config is not None: # Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends # the run with a deferred_tool_call; the caller pauses (workflow HITL) and # later resumes with deferred_tool_results. Needs the history layer above. layers.append( RunLayerSpec( name=DIFY_ASK_HUMAN_LAYER_ID, type=DIFY_ASK_HUMAN_LAYER_TYPE_ID, metadata=run_input.metadata, config=run_input.ask_human_config, ) ) if run_input.output is not None: layers.append( RunLayerSpec( name=DIFY_AGENT_OUTPUT_LAYER_ID, type=DIFY_OUTPUT_LAYER_TYPE_ID, metadata=run_input.metadata, config=DifyOutputLayerConfig( json_schema=run_input.output.json_schema, description=run_input.output.description, strict=run_input.output.strict, ), ) ) return CreateRunRequest( composition=RunComposition(layers=layers), idempotency_key=run_input.idempotency_key, metadata=run_input.metadata, session_snapshot=run_input.session_snapshot, deferred_tool_results=run_input.deferred_tool_results, on_exit=LayerExitSignals(default=ExitIntent.SUSPEND), ) _SENSITIVE_KEY_PARTS = ("secret", "credential", "token", "password", "api_key") def redact_for_agent_backend_log(value: object) -> object: """Return a JSON-like copy with credential-bearing keys redacted for logs/tests.""" if isinstance(value, BaseModel): return redact_for_agent_backend_log(value.model_dump(mode="json", warnings=False)) if isinstance(value, dict): redacted: dict[object, object] = {} for key, item in value.items(): key_text = str(key).lower() if any(part in key_text for part in _SENSITIVE_KEY_PARTS): redacted[key] = "[REDACTED]" else: redacted[key] = redact_for_agent_backend_log(item) return redacted if isinstance(value, list): return [redact_for_agent_backend_log(item) for item in value] return value