From 4d0bc32dd364a16363ee7accc8092de2f8d7a498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9B=90=E7=B2=92=20Yanli?= Date: Thu, 6 Aug 2026 18:53:03 +0800 Subject: [PATCH] fix(dify-agent): align agent and shell execution limits (#40043) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- dify-agent/.example.env | 3 +- .../concepts/runtime-resources/index.md | 6 ++-- dify-agent/docs/dify-agent/guide/index.md | 14 ++++++--- .../src/dify_agent/layers/shell/layer.py | 2 +- dify-agent/src/dify_agent/runtime/runner.py | 3 ++ .../src/dify_agent/runtime_backend/e2b.py | 1 + dify-agent/src/shellctl/client/sdk.py | 3 +- dify-agent/src/shellctl/shared/__init__.py | 9 ++++++ dify-agent/src/shellctl/shared/constants.py | 11 ++++++- dify-agent/src/shellctl/shared/schemas.py | 10 +++--- .../test_working_environment.py | 13 ++++++-- .../dify_agent/layers/shell/test_layer.py | 1 + .../local/dify_agent/runtime/test_runner.py | 31 +++++++++++++++++++ .../dify_agent/runtime_backend/test_e2b.py | 11 ++++--- .../runtime_backend/test_profile.py | 3 +- .../local/shellctl/test_shellctl_shared.py | 14 +++++++++ docker/.env.example | 1 + docker/docker-compose-template.yaml | 1 + docker/docker-compose.yaml | 1 + .../envs/core-services/dify-agent.env.example | 1 + 20 files changed, 115 insertions(+), 24 deletions(-) diff --git a/dify-agent/.example.env b/dify-agent/.example.env index df154cf129a..e2973a0785a 100644 --- a/dify-agent/.example.env +++ b/dify-agent/.example.env @@ -42,7 +42,8 @@ DIFY_AGENT_ENTERPRISE_SANDBOX_PROXY_TIMEOUT=60 # E2B backend: API key, prepared shellctl template, and active Binding policy. DIFY_AGENT_E2B_API_KEY= DIFY_AGENT_E2B_TEMPLATE=difys-default-team/dify-agent-local-sandbox -# Maximum continuous active time. Binding resources pause; temporary Home initialization resources are killed. +# Maximum continuous active time for the RuntimeLease that spans one complete Agent run. +# Binding resources pause; temporary Home initialization resources are killed. # This is not a retention TTL for paused resources or immutable snapshots. DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS=3600 DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN= diff --git a/dify-agent/docs/dify-agent/concepts/runtime-resources/index.md b/dify-agent/docs/dify-agent/concepts/runtime-resources/index.md index 9e075deae80..ec68abaf5e5 100644 --- a/dify-agent/docs/dify-agent/concepts/runtime-resources/index.md +++ b/dify-agent/docs/dify-agent/concepts/runtime-resources/index.md @@ -192,8 +192,10 @@ its Binding and Workspace are one Sandbox. It also rejects binding-only destroy. Neither path creates a fallback Workspace or switches backends. `DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS` limits continuous active time for an E2B -resource. Runtime resources pause on timeout. It is not a retention TTL and -does not delete paused resources or immutable snapshots. +resource to one hour. The limit covers the complete Agent run held by one +RuntimeLease rather than an individual tool call. Runtime resources pause on +timeout. It is not a retention TTL and does not delete paused resources or +immutable snapshots. See the [Shell layer](../../user-manual/shell-layer/index.md) for request composition and the [Operations Guide](../../guide/index.md) for Local and E2B diff --git a/dify-agent/docs/dify-agent/guide/index.md b/dify-agent/docs/dify-agent/guide/index.md index 3ff80e1e28f..14b86650c51 100644 --- a/dify-agent/docs/dify-agent/guide/index.md +++ b/dify-agent/docs/dify-agent/guide/index.md @@ -51,7 +51,7 @@ also reads `.env` and `dify-agent/.env` when present. | `DIFY_AGENT_ENTERPRISE_SANDBOX_PROXY_TIMEOUT` | `60` | Enterprise shellctl-proxy timeout in seconds. | | `DIFY_AGENT_E2B_API_KEY` | empty | E2B API key; required for E2B. | | `DIFY_AGENT_E2B_TEMPLATE` | `difys-default-team/dify-agent-local-sandbox` | Prepared E2B template containing shellctl and the deployment-default Home environment. | -| `DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS` | `3600` | Maximum continuous active time, up to 3600 seconds. Binding resources pause on timeout. This is not a retention TTL. | +| `DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS` | `3600` | Maximum continuous active time for the RuntimeLease spanning one complete Agent run. Binding resources pause on timeout. This is not a retention TTL. | | `DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN` | empty | Optional bearer token expected by shellctl inside the E2B template. | | `DIFY_AGENT_E2B_SHELLCTL_PORT` | `5004` | shellctl port exposed by the E2B template. | | `DIFY_AGENT_SANDBOX_FILE_UPLOAD_MAX_BYTES` | `52428800` | Standalone Dify Agent maximum for whole-file Workspace upload capture; 50 MiB by default. Docker Compose derives it from `PLUGIN_MAX_FILE_SIZE`. | @@ -236,16 +236,16 @@ Run the real E2B contract with an explicit test credential and template: cd dify-agent DIFY_AGENT_TEST_E2B_API_KEY="$E2B_API_TOKEN" \ DIFY_AGENT_TEST_E2B_TEMPLATE=difys-default-team/dify-agent-local-sandbox \ -DIFY_AGENT_TEST_E2B_ACTIVE_TIMEOUT_SECONDS=900 \ pdm run pytest --import-mode=importlib \ tests/integration/dify_agent/runtime_backend/test_working_environment.py \ -k e2b -q -rs ``` The Local auth token is optional when shellctl has authentication disabled. -The E2B test timeout value `900` means up to 15 minutes of continuous active -test time; it is not a post-test retention TTL. Both contracts create unique -resources and perform explicit cleanup in `finally` blocks. +The E2B contract uses the one-hour `E2B_MAX_ACTIVE_TIMEOUT_SECONDS` RuntimeLease +limit. This is continuous active test time, not a post-test retention TTL. Both +contracts create unique resources and perform explicit cleanup in `finally` +blocks. ## Scheduling and shutdown semantics @@ -255,6 +255,10 @@ automatic retry layer. Request-shaped runtime failures such as bad composition, prompt, output, or snapshot inputs are reported later as failed runs rather than rejected synchronously once the request DTO itself is accepted. +Each run explicitly limits Pydantic AI to 100 model-request steps. Tool calls do +not have a separate count limit, but every model request used to continue the +tool loop consumes one of those steps. + During FastAPI shutdown the scheduler rejects new runs, waits up to `DIFY_AGENT_SHUTDOWN_GRACE_SECONDS` for active tasks, then cancels remaining tasks and attempts to finalize them as failed. Success, failure, cancellation, and this diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py index f987ad07371..852fcefcaea 100644 --- a/dify-agent/src/dify_agent/layers/shell/layer.py +++ b/dify-agent/src/dify_agent/layers/shell/layer.py @@ -48,7 +48,7 @@ class _HasErrorCode(Protocol): DEFAULT_TIMEOUT_SECONDS = 30.0 DEFAULT_TERMINATE_GRACE_SECONDS = 10.0 -_SHELL_OUTPUT_PROMPT_EDGE_BYTES = 8 * 1024 +_SHELL_OUTPUT_PROMPT_EDGE_BYTES = 4 * 1024 _SHELLCTL_OUTPUT_LIMIT_BYTES = 2 * _SHELL_OUTPUT_PROMPT_EDGE_BYTES _REMOTE_COMPLETE_OUTPUT_MAX_BYTES = 1024 * 1024 _REMOTE_COMMAND_TIMEOUT_SECONDS = 60.0 diff --git a/dify-agent/src/dify_agent/runtime/runner.py b/dify-agent/src/dify_agent/runtime/runner.py index 509e984b140..7f16f451ccd 100644 --- a/dify-agent/src/dify_agent/runtime/runner.py +++ b/dify-agent/src/dify_agent/runtime/runner.py @@ -40,6 +40,7 @@ from pydantic_ai.exceptions import ModelHTTPError from pydantic_ai.messages import AgentStreamEvent, PartDeltaEvent, PartStartEvent, TextPart, TextPartDelta from pydantic_ai.output import OutputSpec from pydantic_ai.tools import DeferredToolRequests, DeferredToolResults +from pydantic_ai.usage import UsageLimits from agenton.compositor import CompositorSessionSnapshot, LayerConfigInput, LayerProviderInput from agenton.layers.types import PydanticAITool @@ -79,6 +80,7 @@ from dify_agent.runtime.user_prompt_validation import EMPTY_USER_PROMPTS_ERROR, _AGENT_OUTPUT_ADAPTER = TypeAdapter(object) +_MAX_AGENT_STEPS_PER_RUN = 100 @runtime_checkable @@ -324,6 +326,7 @@ class AgentRunRunner: message_history=message_history, deferred_tool_results=deferred_tool_results, event_stream_handler=handle_events, + usage_limits=UsageLimits(request_limit=_MAX_AGENT_STEPS_PER_RUN), ) complete_usage = model.accumulated_usage if isinstance(model, _HasAccumulatedUsage) else None usage = _serialize_agent_usage(complete_usage if complete_usage is not None else _result_usage(result)) diff --git a/dify-agent/src/dify_agent/runtime_backend/e2b.py b/dify-agent/src/dify_agent/runtime_backend/e2b.py index fdcaaa0c1fe..f20a9e06aee 100644 --- a/dify-agent/src/dify_agent/runtime_backend/e2b.py +++ b/dify-agent/src/dify_agent/runtime_backend/e2b.py @@ -40,6 +40,7 @@ from dify_agent.runtime_backend.shellctl import ShellctlRuntimeLease, create_own if TYPE_CHECKING: from e2b.connection_config import ApiParams +# One RuntimeLease spans the complete Agent run, not one Shell tool call. E2B_MAX_ACTIVE_TIMEOUT_SECONDS = 60 * 60 _SHELLCTL_READY_MAX_ATTEMPTS = 3 _SHELLCTL_READY_RETRY_INTERVAL_SECONDS = 0.5 diff --git a/dify-agent/src/shellctl/client/sdk.py b/dify-agent/src/shellctl/client/sdk.py index d007fff551d..cc8ee05ca9f 100644 --- a/dify-agent/src/shellctl/client/sdk.py +++ b/dify-agent/src/shellctl/client/sdk.py @@ -23,6 +23,7 @@ from shellctl.shared.constants import ( DEFAULT_OUTPUT_LIMIT_BYTES, DEFAULT_TERMINATE_GRACE_SECONDS, DEFAULT_TIMEOUT_SECONDS, + SHELL_TOOL_HTTP_TIMEOUT_GRACE_SECONDS, ) from shellctl.shared.schemas import ( DeleteJobResponse, @@ -71,7 +72,7 @@ class ShellctlClient: token: str | None = None, client: httpx.AsyncClient | None = None, transport: httpx.AsyncBaseTransport | None = None, - request_timeout_grace_seconds: float = 10.0, + request_timeout_grace_seconds: float = SHELL_TOOL_HTTP_TIMEOUT_GRACE_SECONDS, ) -> None: self.base_url = base_url.rstrip("/") self.output_limit = output_limit diff --git a/dify-agent/src/shellctl/shared/__init__.py b/dify-agent/src/shellctl/shared/__init__.py index 83419e4e691..e53a46d5e27 100644 --- a/dify-agent/src/shellctl/shared/__init__.py +++ b/dify-agent/src/shellctl/shared/__init__.py @@ -31,6 +31,9 @@ if TYPE_CHECKING: MAX_LIST_LIMIT, MAX_OUTPUT_LIMIT_BYTES, MAX_WAIT_TIMEOUT_SECONDS, + SHELL_TOOL_HARD_TIMEOUT_SECONDS, + SHELL_TOOL_HTTP_TIMEOUT_GRACE_SECONDS, + SHELL_TOOL_TIMEOUT_WITH_HTTP_GRACE_SECONDS, SESSION_NAME_PREFIX, ) from shellctl.shared.output import ( @@ -87,6 +90,9 @@ __all__ = [ "MAX_LIST_LIMIT", "MAX_OUTPUT_LIMIT_BYTES", "MAX_WAIT_TIMEOUT_SECONDS", + "SHELL_TOOL_HARD_TIMEOUT_SECONDS", + "SHELL_TOOL_HTTP_TIMEOUT_GRACE_SECONDS", + "SHELL_TOOL_TIMEOUT_WITH_HTTP_GRACE_SECONDS", "SESSION_NAME_PREFIX", "TERMINAL_JOB_STATUSES", "DeleteJobResponse", @@ -137,6 +143,9 @@ _EXPORTS = { "MAX_LIST_LIMIT": "shellctl.shared.constants", "MAX_OUTPUT_LIMIT_BYTES": "shellctl.shared.constants", "MAX_WAIT_TIMEOUT_SECONDS": "shellctl.shared.constants", + "SHELL_TOOL_HARD_TIMEOUT_SECONDS": "shellctl.shared.constants", + "SHELL_TOOL_HTTP_TIMEOUT_GRACE_SECONDS": "shellctl.shared.constants", + "SHELL_TOOL_TIMEOUT_WITH_HTTP_GRACE_SECONDS": "shellctl.shared.constants", "SESSION_NAME_PREFIX": "shellctl.shared.constants", "OutputWindow": "shellctl.shared.output", "read_output_window": "shellctl.shared.output", diff --git a/dify-agent/src/shellctl/shared/constants.py b/dify-agent/src/shellctl/shared/constants.py index 83eb78160f5..36cf2112e14 100644 --- a/dify-agent/src/shellctl/shared/constants.py +++ b/dify-agent/src/shellctl/shared/constants.py @@ -12,7 +12,13 @@ DEFAULT_BASE_URL = "http://127.0.0.1:8765" DEFAULT_OUTPUT_LIMIT_BYTES = 1024 * 8 MAX_OUTPUT_LIMIT_BYTES = 1024 * 1024 DEFAULT_TIMEOUT_SECONDS = 30.0 -MAX_WAIT_TIMEOUT_SECONDS = 5.0 * 60.0 +# Single source of truth for the hard business timeout exposed by Shell tools. +SHELL_TOOL_HARD_TIMEOUT_SECONDS = 5.0 * 60.0 +# Transport grace lets the HTTP response arrive after the Shell wait budget expires. +SHELL_TOOL_HTTP_TIMEOUT_GRACE_SECONDS = 10.0 +SHELL_TOOL_TIMEOUT_WITH_HTTP_GRACE_SECONDS = SHELL_TOOL_HARD_TIMEOUT_SECONDS + SHELL_TOOL_HTTP_TIMEOUT_GRACE_SECONDS +# Backward-compatible shellctl name; the Shell tool constant above owns the value. +MAX_WAIT_TIMEOUT_SECONDS = SHELL_TOOL_HARD_TIMEOUT_SECONDS DEFAULT_IDLE_FLUSH_SECONDS = 0.5 DEFAULT_TERMINATE_GRACE_SECONDS = 5.0 DEFAULT_TERMINAL_COLS = 120 @@ -45,5 +51,8 @@ __all__ = [ "MAX_LIST_LIMIT", "MAX_OUTPUT_LIMIT_BYTES", "MAX_WAIT_TIMEOUT_SECONDS", + "SHELL_TOOL_HARD_TIMEOUT_SECONDS", + "SHELL_TOOL_HTTP_TIMEOUT_GRACE_SECONDS", + "SHELL_TOOL_TIMEOUT_WITH_HTTP_GRACE_SECONDS", "SESSION_NAME_PREFIX", ] diff --git a/dify-agent/src/shellctl/shared/schemas.py b/dify-agent/src/shellctl/shared/schemas.py index 0120b8b8a5f..0bbaf323a18 100644 --- a/dify-agent/src/shellctl/shared/schemas.py +++ b/dify-agent/src/shellctl/shared/schemas.py @@ -15,7 +15,7 @@ from shellctl.shared.constants import ( DEFAULT_TERMINATE_GRACE_SECONDS, DEFAULT_TIMEOUT_SECONDS, MAX_OUTPUT_LIMIT_BYTES, - MAX_WAIT_TIMEOUT_SECONDS, + SHELL_TOOL_HARD_TIMEOUT_SECONDS, ) @@ -139,7 +139,7 @@ class RunJobRequest(ShellctlModel): cwd: str | None = None env: dict[str, str] | None = None terminal: TerminalSize | None = None - timeout: float = Field(default=DEFAULT_TIMEOUT_SECONDS, gt=0, le=MAX_WAIT_TIMEOUT_SECONDS) + timeout: float = Field(default=DEFAULT_TIMEOUT_SECONDS, gt=0, le=SHELL_TOOL_HARD_TIMEOUT_SECONDS) output_limit: int = Field(default=DEFAULT_OUTPUT_LIMIT_BYTES, ge=1, le=MAX_OUTPUT_LIMIT_BYTES) idle_flush_seconds: float = Field(default=DEFAULT_IDLE_FLUSH_SECONDS, ge=0, le=30) @@ -171,7 +171,7 @@ class RunJobRequest(ShellctlModel): class WaitJobRequest(ShellctlModel): """HTTP request body for `POST /v1/jobs/{job_id}/wait`.""" - timeout: float = Field(default=DEFAULT_TIMEOUT_SECONDS, ge=0, le=MAX_WAIT_TIMEOUT_SECONDS) + timeout: float = Field(default=DEFAULT_TIMEOUT_SECONDS, ge=0, le=SHELL_TOOL_HARD_TIMEOUT_SECONDS) offset: int = Field(ge=0) output_limit: int = Field(default=DEFAULT_OUTPUT_LIMIT_BYTES, ge=1, le=MAX_OUTPUT_LIMIT_BYTES) idle_flush_seconds: float = Field(default=DEFAULT_IDLE_FLUSH_SECONDS, ge=0, le=30) @@ -181,7 +181,7 @@ class InputJobRequest(ShellctlModel): """HTTP request body for `POST /v1/jobs/{job_id}/input`.""" text: str - timeout: float = Field(default=DEFAULT_TIMEOUT_SECONDS, gt=0, le=MAX_WAIT_TIMEOUT_SECONDS) + timeout: float = Field(default=DEFAULT_TIMEOUT_SECONDS, gt=0, le=SHELL_TOOL_HARD_TIMEOUT_SECONDS) offset: int = Field(ge=0) output_limit: int = Field(default=DEFAULT_OUTPUT_LIMIT_BYTES, ge=1, le=MAX_OUTPUT_LIMIT_BYTES) idle_flush_seconds: float = Field(default=DEFAULT_IDLE_FLUSH_SECONDS, ge=0, le=30) @@ -190,7 +190,7 @@ class InputJobRequest(ShellctlModel): class TerminateJobRequest(ShellctlModel): """HTTP request body for `POST /v1/jobs/{job_id}/terminate`.""" - grace_seconds: float = Field(default=DEFAULT_TERMINATE_GRACE_SECONDS, ge=0, le=300) + grace_seconds: float = Field(default=DEFAULT_TERMINATE_GRACE_SECONDS, ge=0, le=SHELL_TOOL_HARD_TIMEOUT_SECONDS) __all__ = [ diff --git a/dify-agent/tests/integration/dify_agent/runtime_backend/test_working_environment.py b/dify-agent/tests/integration/dify_agent/runtime_backend/test_working_environment.py index 3da6909fd42..8340beefd1a 100644 --- a/dify-agent/tests/integration/dify_agent/runtime_backend/test_working_environment.py +++ b/dify-agent/tests/integration/dify_agent/runtime_backend/test_working_environment.py @@ -13,7 +13,12 @@ from dify_agent.runtime_backend import ( ExecutionBindingDestroySpec, HomeSnapshotCreateSpec, ) -from dify_agent.runtime_backend.e2b import E2BExecutionBindingBackend, E2BHomeSnapshotBackend, E2BSDKControlPlane +from dify_agent.runtime_backend.e2b import ( + E2B_MAX_ACTIVE_TIMEOUT_SECONDS, + E2BExecutionBindingBackend, + E2BHomeSnapshotBackend, + E2BSDKControlPlane, +) from dify_agent.runtime_backend.local import LocalExecutionBindingBackend pytestmark = pytest.mark.integration @@ -106,7 +111,11 @@ async def test_e2b_binding_checkpoint_and_collection() -> None: marker = uuid.uuid4().hex control = E2BSDKControlPlane(api_key=api_key) snapshots = E2BHomeSnapshotBackend(control_plane=control) - bindings = E2BExecutionBindingBackend(control_plane=control, template=template, active_timeout_seconds=3600) + bindings = E2BExecutionBindingBackend( + control_plane=control, + template=template, + active_timeout_seconds=E2B_MAX_ACTIVE_TIMEOUT_SECONDS, + ) checkpoint_ref: str | None = None allocation = None checkpoint_allocation = None diff --git a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py index a358d0725ce..626b377c423 100644 --- a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py @@ -588,6 +588,7 @@ def test_shell_run_formats_large_non_truncated_output_without_tail_lookup() -> N metadata, output = _parse_tagged_observation(result) assert metadata["output_path"] == "/tmp/large.log" assert output.startswith("head-y") + assert "max output size is limited to 8192 bytes" in output assert output.endswith("(check the /tmp/large.log for full output)") assert "-tail" in output diff --git a/dify-agent/tests/local/dify_agent/runtime/test_runner.py b/dify-agent/tests/local/dify_agent/runtime/test_runner.py index 533d62c14af..f9c580f3116 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_runner.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_runner.py @@ -22,6 +22,7 @@ from pydantic_ai.messages import ( from pydantic_ai.models import ModelRequestParameters from pydantic_ai.models.test import TestModel from pydantic_ai.tools import DeferredToolRequests, DeferredToolResults +from pydantic_ai.usage import UsageLimits from pydantic_ai.settings import ModelSettings from agenton.compositor import CompositorSessionSnapshot, LayerProvider, LayerSessionSnapshot @@ -608,6 +609,36 @@ def test_runner_preserves_explicit_json_null_output(monkeypatch: pytest.MonkeyPa assert sink.statuses["run-null-output"] == "succeeded" +def test_runner_passes_explicit_step_limit_to_agent(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return TestModel(custom_output_text="unused") # pyright: ignore[reportReturnType] + + class FakeAgent: + async def run(self, *_args: object, **kwargs: object) -> FakeAgentRunResult: + usage_limits = cast(UsageLimits, kwargs["usage_limits"]) + assert usage_limits.request_limit == 100 + return FakeAgentRunResult("done", []) + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + monkeypatch.setattr("dify_agent.runtime.runner.create_agent", lambda *_args, **_kwargs: FakeAgent()) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + await AgentRunRunner( + sink=sink, + request=_request(), + run_id="run-explicit-step-limit", + plugin_daemon_http_client=client, + dify_api_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert sink.statuses["run-explicit-step-limit"] == "succeeded" + + def test_runner_emits_deferred_tool_call_and_persists_pending_history(monkeypatch: pytest.MonkeyPatch) -> None: captured_output_types: list[object] = [] captured_user_prompts: list[object] = [] diff --git a/dify-agent/tests/local/dify_agent/runtime_backend/test_e2b.py b/dify-agent/tests/local/dify_agent/runtime_backend/test_e2b.py index 085462b5146..247491b0b9c 100644 --- a/dify-agent/tests/local/dify_agent/runtime_backend/test_e2b.py +++ b/dify-agent/tests/local/dify_agent/runtime_backend/test_e2b.py @@ -19,6 +19,7 @@ from dify_agent.runtime_backend import ( ) from dify_agent.runtime_backend import e2b as e2b_module from dify_agent.runtime_backend.e2b import ( + E2B_MAX_ACTIVE_TIMEOUT_SECONDS, E2BExecutionBindingBackend, E2BHomeSnapshotBackend, E2BRuntimeLease, @@ -133,7 +134,7 @@ def _connected_backend(*, pause_error: Exception | None = None) -> tuple[E2BExec E2BExecutionBindingBackend( control_plane=control, # pyright: ignore[reportArgumentType] template="prepared-template", - active_timeout_seconds=3600, + active_timeout_seconds=E2B_MAX_ACTIVE_TIMEOUT_SECONDS, ), sandbox, ) @@ -148,7 +149,7 @@ async def test_e2b_binding_uses_default_template_or_exact_snapshot_and_couples_r bindings = E2BExecutionBindingBackend( control_plane=control, # pyright: ignore[reportArgumentType] template="prepared-template", - active_timeout_seconds=3600, + active_timeout_seconds=E2B_MAX_ACTIVE_TIMEOUT_SECONDS, ) default_allocation = await bindings.create_binding( @@ -199,7 +200,7 @@ async def test_e2b_rejects_shared_workspace_and_binding_only_destroy() -> None: backend = E2BExecutionBindingBackend( control_plane=control, # pyright: ignore[reportArgumentType] template="prepared-template", - active_timeout_seconds=3600, + active_timeout_seconds=E2B_MAX_ACTIVE_TIMEOUT_SECONDS, ) spec = ExecutionBindingCreateSpec( tenant_id="tenant-1", @@ -224,7 +225,7 @@ async def test_e2b_binding_create_kills_sandbox_when_initialization_fails() -> N backend = E2BExecutionBindingBackend( control_plane=control, # pyright: ignore[reportArgumentType] template="prepared-template", - active_timeout_seconds=3600, + active_timeout_seconds=E2B_MAX_ACTIVE_TIMEOUT_SECONDS, ) with pytest.raises(BindingCreateError, match="pause failed"): @@ -255,7 +256,7 @@ async def test_e2b_missing_explicit_snapshot_does_not_fall_back_to_template() -> backend = E2BExecutionBindingBackend( control_plane=control, # pyright: ignore[reportArgumentType] template="prepared-template", - active_timeout_seconds=3600, + active_timeout_seconds=E2B_MAX_ACTIVE_TIMEOUT_SECONDS, ) with pytest.raises(BindingCreateError, match="snapshot unavailable"): diff --git a/dify-agent/tests/local/dify_agent/runtime_backend/test_profile.py b/dify-agent/tests/local/dify_agent/runtime_backend/test_profile.py index 465731a4c0e..fe6704ac969 100644 --- a/dify-agent/tests/local/dify_agent/runtime_backend/test_profile.py +++ b/dify-agent/tests/local/dify_agent/runtime_backend/test_profile.py @@ -12,11 +12,12 @@ from dify_agent.runtime_backend.profile import ( ) -def test_e2b_backend_uses_prepared_dify_template_by_default() -> None: +def test_e2b_backend_uses_prepared_dify_template_and_one_hour_lease_by_default() -> None: settings = RuntimeBackendSettings(runtime_backend="e2b", e2b_api_key="secret") assert settings.e2b_template == "difys-default-team/dify-agent-local-sandbox" assert settings.e2b_template == DEFAULT_E2B_TEMPLATE + assert E2B_MAX_ACTIVE_TIMEOUT_SECONDS == 60 * 60 assert settings.e2b_active_timeout_seconds == E2B_MAX_ACTIVE_TIMEOUT_SECONDS diff --git a/dify-agent/tests/local/shellctl/test_shellctl_shared.py b/dify-agent/tests/local/shellctl/test_shellctl_shared.py index 8034ea3d9ef..38fd14987c4 100644 --- a/dify-agent/tests/local/shellctl/test_shellctl_shared.py +++ b/dify-agent/tests/local/shellctl/test_shellctl_shared.py @@ -7,13 +7,27 @@ from pydantic import ValidationError from shellctl.shared import ( JOB_ID_ALPHABET, + MAX_WAIT_TIMEOUT_SECONDS, RunJobRequest, + SHELL_TOOL_HARD_TIMEOUT_SECONDS, + SHELL_TOOL_HTTP_TIMEOUT_GRACE_SECONDS, + SHELL_TOOL_TIMEOUT_WITH_HTTP_GRACE_SECONDS, generate_job_id, read_output_window, tail_output_window, ) +def test_shell_tool_timeout_budget_has_one_source_of_truth() -> None: + assert MAX_WAIT_TIMEOUT_SECONDS == SHELL_TOOL_HARD_TIMEOUT_SECONDS == 300 + assert SHELL_TOOL_HTTP_TIMEOUT_GRACE_SECONDS == 10 + assert ( + SHELL_TOOL_TIMEOUT_WITH_HTTP_GRACE_SECONDS + == SHELL_TOOL_HARD_TIMEOUT_SECONDS + SHELL_TOOL_HTTP_TIMEOUT_GRACE_SECONDS + == 310 + ) + + def test_generate_job_id_matches_proposal_format() -> None: job_id = generate_job_id(now=datetime(2026, 5, 21, 15, 30, tzinfo=UTC)) diff --git a/docker/.env.example b/docker/.env.example index d7ac8555e55..2066467ae04 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -280,6 +280,7 @@ DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN= # E2B_API_KEY and E2B_API_TOKEN remain accepted as deployment-level fallbacks. DIFY_AGENT_E2B_API_KEY= DIFY_AGENT_E2B_TEMPLATE=difys-default-team/dify-agent-local-sandbox +# One-hour RuntimeLease limit spanning a complete Agent run. DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS=3600 DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN= DIFY_AGENT_E2B_SHELLCTL_PORT=5004 diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 0c864209cad..dba43e0736b 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -676,6 +676,7 @@ services: DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN: ${DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN:-${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-}} DIFY_AGENT_E2B_API_KEY: ${DIFY_AGENT_E2B_API_KEY:-${E2B_API_KEY:-${E2B_API_TOKEN:-}}} DIFY_AGENT_E2B_TEMPLATE: ${DIFY_AGENT_E2B_TEMPLATE:-difys-default-team/dify-agent-local-sandbox} + # One-hour RuntimeLease limit spanning a complete Agent run. DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS: ${DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS:-3600} DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN: ${DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN:-} DIFY_AGENT_E2B_SHELLCTL_PORT: ${DIFY_AGENT_E2B_SHELLCTL_PORT:-5004} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index ef3f92a3ae9..cb4da10a3f3 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -682,6 +682,7 @@ services: DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN: ${DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN:-${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-}} DIFY_AGENT_E2B_API_KEY: ${DIFY_AGENT_E2B_API_KEY:-${E2B_API_KEY:-${E2B_API_TOKEN:-}}} DIFY_AGENT_E2B_TEMPLATE: ${DIFY_AGENT_E2B_TEMPLATE:-difys-default-team/dify-agent-local-sandbox} + # One-hour RuntimeLease limit spanning a complete Agent run. DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS: ${DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS:-3600} DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN: ${DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN:-} DIFY_AGENT_E2B_SHELLCTL_PORT: ${DIFY_AGENT_E2B_SHELLCTL_PORT:-5004} diff --git a/docker/envs/core-services/dify-agent.env.example b/docker/envs/core-services/dify-agent.env.example index aa96908531b..9d0562aaa3c 100644 --- a/docker/envs/core-services/dify-agent.env.example +++ b/docker/envs/core-services/dify-agent.env.example @@ -29,6 +29,7 @@ DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN= # E2B_API_KEY and E2B_API_TOKEN remain accepted as deployment-level fallbacks. DIFY_AGENT_E2B_API_KEY= DIFY_AGENT_E2B_TEMPLATE=difys-default-team/dify-agent-local-sandbox +# One-hour RuntimeLease limit spanning a complete Agent run. DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS=3600 DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN= DIFY_AGENT_E2B_SHELLCTL_PORT=5004