From eae44cfecb65957d00cfcb727217652e04be57e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9B=90=E7=B2=92=20Yanli?= Date: Tue, 2 Jun 2026 16:54:52 +0900 Subject: [PATCH] feat(dify-agent): add shell layer (#36838) --- dify-agent/docker/shellctl/Dockerfile | 25 + .../concepts/run-lifecycle/index.md | 195 +++++ .../user-manual/shell-layer/index.md | 202 +++++ dify-agent/mkdocs.yml | 3 + dify-agent/pyproject.toml | 1 + dify-agent/src/agenton/__init__.py | 5 +- dify-agent/src/agenton/compositor/__init__.py | 3 +- dify-agent/src/agenton/compositor/core.py | 33 +- dify-agent/src/agenton/compositor/run.py | 99 ++- dify-agent/src/agenton/compositor/schemas.py | 7 +- dify-agent/src/agenton/layers/base.py | 84 +- .../src/dify_agent/layers/shell/__init__.py | 10 + .../src/dify_agent/layers/shell/configs.py | 25 + .../src/dify_agent/layers/shell/layer.py | 733 ++++++++++++++++++ .../dify_agent/runtime/compositor_factory.py | 36 +- dify-agent/src/dify_agent/runtime/runner.py | 28 +- dify-agent/src/dify_agent/server/app.py | 5 +- dify-agent/src/dify_agent/server/settings.py | 8 +- .../agenton/compositor/test_direct_deps.py | 10 + .../local/agenton/compositor/test_enter.py | 583 +++++++++++++- .../dify_agent/layers/shell/test_configs.py | 20 + .../dify_agent/layers/shell/test_layer.py | 588 ++++++++++++++ .../runtime/test_compositor_factory.py | 73 ++ .../local/dify_agent/runtime/test_runner.py | 280 +++++++ .../tests/local/dify_agent/server/test_app.py | 14 + .../local/dify_agent/server/test_settings.py | 33 + .../dify_agent/test_client_safe_exports.py | 97 +++ .../dify_agent/test_import_boundaries.py | 5 + dify-agent/uv.lock | 140 ++++ 29 files changed, 3273 insertions(+), 72 deletions(-) create mode 100644 dify-agent/docker/shellctl/Dockerfile create mode 100644 dify-agent/docs/dify-agent/concepts/run-lifecycle/index.md create mode 100644 dify-agent/docs/dify-agent/user-manual/shell-layer/index.md create mode 100644 dify-agent/src/dify_agent/layers/shell/__init__.py create mode 100644 dify-agent/src/dify_agent/layers/shell/configs.py create mode 100644 dify-agent/src/dify_agent/layers/shell/layer.py create mode 100644 dify-agent/tests/local/dify_agent/layers/shell/test_configs.py create mode 100644 dify-agent/tests/local/dify_agent/layers/shell/test_layer.py create mode 100644 dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py create mode 100644 dify-agent/tests/local/dify_agent/server/test_settings.py create mode 100644 dify-agent/tests/local/dify_agent/test_client_safe_exports.py diff --git a/dify-agent/docker/shellctl/Dockerfile b/dify-agent/docker/shellctl/Dockerfile new file mode 100644 index 0000000000..c4bb051203 --- /dev/null +++ b/dify-agent/docker/shellctl/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + tmux \ + && rm -rf /var/lib/apt/lists/* + +RUN python -m pip install --no-cache-dir \ + shell-session-manager==2.1.1 \ + uv + +RUN useradd --create-home --shell /bin/sh dify + +USER dify +WORKDIR /home/dify + +EXPOSE 5004 + +CMD ["shellctl", "serve", "--listen", "0.0.0.0:5004"] diff --git a/dify-agent/docs/dify-agent/concepts/run-lifecycle/index.md b/dify-agent/docs/dify-agent/concepts/run-lifecycle/index.md new file mode 100644 index 0000000000..dce524e5b3 --- /dev/null +++ b/dify-agent/docs/dify-agent/concepts/run-lifecycle/index.md @@ -0,0 +1,195 @@ +# Agent Run Lifecycle + +This page explains, from a caller's perspective, how an `agent run` relates to a +`workflow run` and how callers control Agenton layer exit behavior with exit +signals. + +## Relationship between agent runs and workflow runs + +A `workflow run` is one full workflow execution. An `agent run` is one Agent +execution started by an Agent node while the workflow is running. They are not a +one-to-one mapping: one `workflow run` often contains multiple `agent run`s. + +### First entry into an Agent node + +When a `workflow run` first reaches an Agent node, the caller starts the first +`agent run` for that node. + +The `agent run` enters the layers defined in its composition: + +- If the request does not include `session_snapshot`, each layer enters with a + fresh state and initializes its own runtime state. +- If the request includes a previously returned `session_snapshot`, each layer + restores its runtime state from that snapshot and continues from there. + +After entering layers, the Agent runs the LLM and tool calls until the current +`agent run` reaches a terminal result. This means the `agent run` has ended; it +does not necessarily mean the outer workflow has ended. + +### Ending with a `final_output` tool call + +If the Agent ends with a `final_output` tool call, the Agent node has produced +its final output for this pass. The caller should read the terminal output of the +current `agent run` and let the `workflow run` continue to downstream nodes. + +The current `agent run` has ended, but the returned `session_snapshot` can still +be saved. If the same `workflow run` may enter the same Agent session again, the +caller should keep using that snapshot. + +### Ending with a human tool call + +If the Agent ends with a human tool call, the Agent needs human input before the +business process can continue. A common misconception is to treat this as a +paused agent run. **Agent runs do not have a pause state.** With a human tool, the +current `agent run` has ended; the outer `workflow run` is what should be paused. + +The caller should handle this flow as follows: + +1. Read the current `agent run` result and detect the HITL (human-in-the-loop) + requirement. +2. Enter workflow HITL handling and pause graphon. +3. Wait for the human input to be completed. +4. When resuming the workflow, insert the human tool response into the same Agent + session's history layer. +5. Start a second `agent run` on the same Agent node and reuse the same history + session. + +In other words, a human tool does not mean “pause this agent run until it is +resumed.” It means “this agent run ended with a result that requires human +input.” After the caller completes HITL handling, it should create a new +`agent run` using the same history/session snapshot to continue. + +### Entering another Agent node + +When the same `workflow run` continues and reaches another Agent node, it starts +another `agent run`. That next Agent node may be a different Agent, or it may be +the same Agent reused by a roaster. + +Therefore, callers should save and pass `session_snapshot` by Agent session, not +assume that one `workflow run` has only one `agent run`. + +## Agent run exit signals + +When an `agent run` ends, Dify Agent exits the layers that were entered by the +current run. Callers control whether each layer is suspended or deleted through +`CreateRunRequest.on_exit`. + +Exit signals control the **layer lifecycle state**, not the execution state of an +`agent run`. The default policy is `suspend`, so a successful `agent run` returns +a reusable `session_snapshot`. + +### Default: suspend layers + +If a request does not explicitly set `on_exit`, it is equivalent to: + +```json +{ + "on_exit": { + "default": "suspend", + "layers": {} + } +} +``` + +This means every entered layer exits as `suspended` and is written into the +returned `session_snapshot`. The caller can submit that snapshot in the next +`agent run` to resume those layers. + +For normal Agent execution inside a workflow, including both `final_output` and +human-tool endings, callers should keep the default suspend policy unless they +know the Agent session will never be resumed. + +### Delete layers when the workflow run ends + +When the whole `workflow run` has ended, the caller should start one more cleanup +`agent run`: + +- Reuse the last available `session_snapshot`. +- Omit the LLM layer, because this run is only for entering and cleaning existing + state; it does not need to call the model again. +- Exit layers with the `delete` signal. + +The cleanup request should use an exit signal like this: + +```json +{ + "on_exit": { + "default": "delete", + "layers": {} + } +} +``` + +After this run, the corresponding layers exit through the delete path. A snapshot +returned after deletion should not be used to resume the Agent session again. + +### Override selected layers + +The caller can also suspend by default while deleting only selected layers: + +```json +{ + "on_exit": { + "default": "suspend", + "layers": { + "temporary_context": "delete" + } + } +} +``` + +Only `temporary_context` exits with `delete`; all other active layers exit with +the default `suspend` behavior. + +## Exit signal API reference + +Fields related to exit control in `CreateRunRequest`: + +| Field | Type | Required | Meaning | +| --- | --- | --- | --- | +| `session_snapshot` | `CompositorSessionSnapshot \| None` | no | The session snapshot returned by the previous `agent run`. It resumes the same Agent session. | +| `on_exit` | `LayerExitSignals` | no | The exit policy used when this `agent run` exits layers. If omitted, all active layers are suspended by default. | + +`LayerExitSignals` has this structure: + +| Field | Type | Default | Meaning | +| --- | --- | --- | --- | +| `default` | `"suspend" \| "delete"` | `"suspend"` | Exit intent for layers not explicitly listed in `layers`. | +| `layers` | `dict[str, "suspend" \| "delete"]` | `{}` | Per-layer exit intent overrides by layer name. Each key must refer to a layer name in the current composition. | + +Exit intent semantics: + +| Exit intent | Layer exit state | Effect | +| --- | --- | --- | +| `suspend` | `suspended` | Keep the layer runtime state and make the returned `session_snapshot` usable by a later `agent run`. | +| `delete` | `closed` | Delete/close the layer context. The corresponding layer snapshot should not be resumed again. | + +Python DTO example: + +```python {test="skip" lint="skip"} +from agenton.layers import ExitIntent +from dify_agent.protocol import CreateRunRequest, LayerExitSignals + + +request = CreateRunRequest( + composition=composition, + session_snapshot=previous_snapshot, + on_exit=LayerExitSignals( + default=ExitIntent.SUSPEND, + layers={ + "temporary_context": ExitIntent.DELETE, + }, + ), +) +``` + +Notes: + +- `on_exit` only controls layer exit behavior; it does not cancel an `agent run`. +- Agent runs do not have a pause state. Human-tool waiting is handled by the + outer workflow/HITL flow. +- Keys in `on_exit.layers` must refer to layer names in the current composition. +- Use `suspend` and save the returned `session_snapshot` when the same Agent + session needs to continue later. +- After the whole `workflow run` ends, start one more cleanup run without an LLM + layer and use `delete`. diff --git a/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md b/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md new file mode 100644 index 0000000000..795c20b675 --- /dev/null +++ b/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md @@ -0,0 +1,202 @@ +# Shell layer + +The shell layer lets a Dify Agent run expose a `shellctl`-backed workspace to the +model. This page is for Dify Agent clients that build `CreateRunRequest` +payloads. It explains how to add the layer to a run composition and how the +server-side runtime must be wired. + +The layer type id is `dify.shell`. Its public config is intentionally empty: + +```python +from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig +from dify_agent.protocol import RunLayerSpec + +RunLayerSpec( + name="shell", + type=DIFY_SHELL_LAYER_TYPE_ID, + config=DifyShellLayerConfig(), +) +``` + +Server-only settings, such as the `shellctl` HTTP entrypoint and auth token, are +injected by the Dify Agent runtime provider. They are not part of +`DifyShellLayerConfig` and should not be submitted by clients in the public run +request. + +## Runtime requirements + +When a run includes `dify.shell`, the Dify Agent server must construct its layer +providers with a non-empty shellctl entrypoint: + +```python +from dify_agent.runtime.compositor_factory import create_default_layer_providers + +layer_providers = create_default_layer_providers( + plugin_daemon_url="http://localhost:5002", + plugin_daemon_api_key="replace-with-plugin-daemon-key", + shellctl_entrypoint="http://127.0.0.1:5004", + shellctl_auth_token="replace-with-shellctl-token", # optional; defaults to no token +) +``` + +In the FastAPI server, these values are read from environment-backed +`ServerSettings` fields: + +```env +DIFY_AGENT_SHELLCTL_ENTRYPOINT=http://127.0.0.1:5004 +DIFY_AGENT_SHELLCTL_AUTH_TOKEN=replace-with-shellctl-token +``` + +`DIFY_AGENT_SHELLCTL_AUTH_TOKEN` defaults to `None`/empty, which keeps the shell +client on the no-token path. Set it only when the shellctl server is started with +bearer authentication. + +## Client request shape + +A client adds the shell layer as an ordinary composition layer. The shell layer +does not need dependencies. A typical run still also includes: + +- a prompt layer that supplies the task; +- an execution-context layer carrying tenant/user context; +- an LLM layer named `llm`. + +When clients want the shell workspace and shellctl job records to be removed at +the end of the run, set `on_exit.default` to `delete`. + +## Example: CSV analysis run + +The following example mirrors a verified run with a real Gemini model and a +temporary shellctl server. The client gives the model a small CSV-shaped dataset +and asks for computed metrics without prescribing the exact shell commands. + +### Request + +```python {test="skip" lint="skip"} +from agenton.layers import ExitIntent +from agenton_collections.layers.plain import PromptLayerConfig +from dify_agent.layers.dify_plugin.configs import DifyPluginLLMLayerConfig +from dify_agent.layers.execution_context import ( + DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + DifyExecutionContextLayerConfig, +) +from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig +from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID +from dify_agent.protocol.schemas import CreateRunRequest, LayerExitSignals, RunComposition, RunLayerSpec + + +request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type="plain.prompt", + config=PromptLayerConfig( + prefix="You are a practical data analyst. Give a concise final answer.", + user="""Analyze this small sales dataset with pandas. Use any local computation you think is useful. + +region,product,units,unit_price +north,widget,12,3.50 +north,gadget,5,9.00 +south,widget,7,3.50 +south,gadget,9,9.00 +west,widget,4,3.50 +west,gadget,11,9.00 + +Report the total revenue, the region with the most revenue, total units by +product, and a SHA-256 hash of the CSV content.""", + ), + ), + RunLayerSpec( + name="shell", + type=DIFY_SHELL_LAYER_TYPE_ID, + config=DifyShellLayerConfig(), + ), + RunLayerSpec( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig( + tenant_id="92cca973-2d6f-45e0-906e-0b7eda5f2ccf", + invoke_from="workflow_run", + ), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type="dify.plugin.llm", + deps={"execution_context": "execution_context"}, + config=DifyPluginLLMLayerConfig( + plugin_id="langgenius/gemini", + model_provider="google", + model="gemini-3.5-flash", + credentials={"google_api_key": ""}, + ), + ), + ] + ), + on_exit=LayerExitSignals(default=ExitIntent.DELETE), +) +``` + +The same request serialized as JSON has these important layer entries: + +```json +{ + "composition": { + "schema_version": 1, + "layers": [ + {"name": "prompt", "type": "plain.prompt"}, + {"name": "shell", "type": "dify.shell", "config": {}}, + {"name": "execution_context", "type": "dify.execution_context"}, + { + "name": "llm", + "type": "dify.plugin.llm", + "deps": {"execution_context": "execution_context"} + } + ] + }, + "on_exit": {"default": "delete", "layers": {}} +} +``` + +### Final answer + +The terminal `run_succeeded` output was: + +````markdown +Here is the analysis of the sales dataset: + +* **Total Revenue:** **$305.50** +* **Top Region:** **west** with **$113.00** +* **Total Units by Product:** gadget: 25 units, widget: 23 units +* **SHA-256 Hash:** `e86521a0d759037a09b059cb3cb2419f0a3f06e674db8151ccf2f93811dac0b8` +```` + +## Running shellctl in Docker + +Build the shellctl image from the Dify Agent package root: + +```bash +docker build -f docker/shellctl/Dockerfile -t dify-agent-shellctl:local . +``` + +Run it with a bearer token and publish the API on localhost: + +```bash +docker run --rm --name dify-agent-shellctl \ + -e SHELLCTL_AUTH_TOKEN=replace-with-a-token \ + -p 127.0.0.1:5004:5004 \ + dify-agent-shellctl:local +``` + +The image starts `shellctl serve --listen 0.0.0.0:5004` as the non-root +`dify` user and leaves shellctl state/runtime directories at their package +defaults. + +## Docker image contents + +The provided `docker/shellctl/Dockerfile` installs: + +- `tmux`, required by `shellctl` to manage shell jobs; +- `shell-session-manager==2.1.1`, which provides the `shellctl` CLI/server; +- `uv`, so uv shebang scripts with PEP 723 metadata can run inside the shell + workspace; +- a non-root default user named `dify`. diff --git a/dify-agent/mkdocs.yml b/dify-agent/mkdocs.yml index ab66d3e72c..579cffe536 100644 --- a/dify-agent/mkdocs.yml +++ b/dify-agent/mkdocs.yml @@ -14,10 +14,13 @@ nav: - Examples: agenton/examples/index.md - Dify Agent: - Overview: dify-agent/index.md + - Concepts: + - Agent Run Lifecycle: dify-agent/concepts/run-lifecycle/index.md - User Manual: - Get Started: dify-agent/get-started/index.md - Prompt Layer: dify-agent/user-manual/prompt-layer/index.md - Execution Context Layer: dify-agent/user-manual/execution-context-layer/index.md + - Shell Layer: dify-agent/user-manual/shell-layer/index.md - Plugin LLM Layer: dify-agent/user-manual/plugin-llm-layer/index.md - Plugin Tool Layer: dify-agent/user-manual/plugin-tool-layer/index.md - History Layer: dify-agent/user-manual/history-layer/index.md diff --git a/dify-agent/pyproject.toml b/dify-agent/pyproject.toml index 7975b042d4..4205b738ce 100644 --- a/dify-agent/pyproject.toml +++ b/dify-agent/pyproject.toml @@ -19,6 +19,7 @@ server = [ "pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0", "pydantic-settings>=2.12.0,<3.0.0", "redis>=7.4.0,<8.0.0", + "shell-session-manager==2.1.1", "uvicorn[standard]==0.46.0", ] diff --git a/dify-agent/src/agenton/__init__.py b/dify-agent/src/agenton/__init__.py index 9c9bdfee40..13f624c6bc 100644 --- a/dify-agent/src/agenton/__init__.py +++ b/dify-agent/src/agenton/__init__.py @@ -2,8 +2,9 @@ Agenton core composes reusable stateless layer graph plans, creates a fresh ``CompositorRun`` for each invocation, hydrates and advances serializable layer -``runtime_state`` through run slots, and emits session snapshots. It intentionally -does not own resources, handles, clients, cleanup callbacks, or any other +``runtime_state`` through run slots, enters each layer's active-scope +``resource_context()``, and emits session snapshots. It intentionally never +serializes resources, handles, clients, cleanup callbacks, or any other non-serializable runtime object. Each ``Compositor`` stores only graph nodes and layer providers. Every enter call diff --git a/dify-agent/src/agenton/compositor/__init__.py b/dify-agent/src/agenton/compositor/__init__.py index 55019d7b61..17829fe3e4 100644 --- a/dify-agent/src/agenton/compositor/__init__.py +++ b/dify-agent/src/agenton/compositor/__init__.py @@ -11,7 +11,8 @@ types. ``Compositor`` itself stores no live layer instances, run lifecycle state, session state, resources, or handles. Each ``enter(...)`` call creates a fresh ``CompositorRun`` with new layer instances, direct dependency binding, optional -snapshot hydration, and the next ``session_snapshot`` after exit. +snapshot hydration, entered per-layer ``resource_context()`` scopes, and the +next ``session_snapshot`` after exit. ``LifecycleState.ACTIVE`` remains internal-only and session snapshots contain only ordered layer lifecycle state plus serializable ``runtime_state``. diff --git a/dify-agent/src/agenton/compositor/core.py b/dify-agent/src/agenton/compositor/core.py index 4e77e9c1e0..b948ace52e 100644 --- a/dify-agent/src/agenton/compositor/core.py +++ b/dify-agent/src/agenton/compositor/core.py @@ -4,7 +4,8 @@ transformers. Each ``enter(...)`` call validates node-name keyed configs before any provider factory runs, optionally validates and hydrates a session snapshot, creates fresh layer instances, binds direct dependencies, and returns a new -``CompositorRun`` for that invocation only. +``CompositorRun`` for that invocation only. Dependency targets must point to +preceding graph nodes so resource scopes can nest in dependency order. ``Compositor.from_config(...)`` resolves serializable provider type ids rather than import paths. Named ``node_providers`` override type-id providers for the @@ -49,8 +50,9 @@ class LayerNode: ``implementation`` may be a layer class or an explicit ``LayerProvider``. ``deps`` maps dependency field names on this node's layer class to other - compositor node names. ``metadata`` is graph description data only; it is - not passed to provider factories and is never included in session snapshots. + preceding compositor node names. ``metadata`` is graph description data + only; it is not passed to provider factories and is never included in + session snapshots. """ name: str @@ -89,7 +91,8 @@ class Compositor(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT, ``tool_transformer`` are post-aggregation hooks on each run. Use two type arguments for identity aggregation, four when prompt/tool layer item types differ from exposed item types, or all six when user prompt item types also - differ. + differ. Graph order is meaningful for lifecycle nesting, so dependency edges + must point only to earlier nodes. """ __slots__ = ("_nodes", "prompt_transformer", "tool_transformer", "user_prompt_transformer") @@ -188,10 +191,14 @@ class Compositor(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT, """ run = self._create_run(configs=configs, session_snapshot=session_snapshot) await run._enter_layers() + body_error: BaseException | None = None try: yield run + except BaseException as exc: + body_error = exc + raise finally: - await run._exit_layers() + await run._exit_layers(body_error) def _create_run( self, @@ -254,6 +261,8 @@ class Compositor(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT, raise ValueError(f"Duplicate layer name '{node.name}'.") layer_names.add(node.name) + layer_index_by_name = {node.name: index for index, node in enumerate(self._nodes)} + for node in self._nodes: declared_deps = node.provider.layer_type.dependency_names() unknown_dep_keys = set(node.deps) - declared_deps @@ -265,6 +274,20 @@ class Compositor(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT, names = ", ".join(sorted(missing_targets)) raise ValueError(f"Layer '{node.name}' depends on undefined layer names: {names}.") + non_preceding_targets = { + dep_name: target_name + for dep_name, target_name in node.deps.items() + if layer_index_by_name[target_name] >= layer_index_by_name[node.name] + } + if non_preceding_targets: + targets = ", ".join( + f"{dep_name}->{target_name}" for dep_name, target_name in sorted(non_preceding_targets.items()) + ) + raise ValueError( + f"Layer '{node.name}' dependencies must target preceding layer nodes in compositor order: " + f"{targets}." + ) + def _validate_run_configs(self, configs: Mapping[str, LayerConfigInput] | None) -> dict[str, LayerConfigInput]: config_by_name = dict(configs or {}) known_names = {node.name for node in self._nodes} diff --git a/dify-agent/src/agenton/compositor/run.py b/dify-agent/src/agenton/compositor/run.py index a50e017736..95fa345159 100644 --- a/dify-agent/src/agenton/compositor/run.py +++ b/dify-agent/src/agenton/compositor/run.py @@ -1,11 +1,18 @@ """Active compositor run lifecycle, snapshots, and aggregation. ``CompositorRun`` is the only compositor object that exposes live layer -instances. It owns invocation-local lifecycle state, per-layer exit intent, and -the next ``session_snapshot`` after exit. Layers enter in graph order and exit -in reverse graph order. Prompt aggregation preserves graph ordering: prefix -prompts first-to-last, suffix prompts last-to-first, user prompts first-to-last, -and tools in graph order. +instances. It owns invocation-local lifecycle state, per-layer exit intent, +entered layer resource scopes, and the next ``session_snapshot`` after exit. +Layers enter in graph order and exit in reverse graph order. A layer's +``resource_context()`` wraps that layer's enter hook, the active run body while +the layer remains active, and the layer's exit hook. Prompt aggregation +preserves graph ordering: prefix prompts first-to-last, suffix prompts +last-to-first, user prompts first-to-last, and tools in graph order. + +Enter hooks transition a slot to ``LifecycleState.ACTIVE`` only after returning +successfully. If ``on_context_create`` or ``on_context_resume`` raises, the run +still exits any already-entered resource contexts, but it does not call the +layer's normal suspend/delete hook for that failed enter attempt. Prompt, user prompt, and tool transformers run only after layer-level wrapping and run-level aggregation. When no transformer is installed, the wrapped items @@ -14,6 +21,7 @@ are returned unchanged. from collections import OrderedDict from collections.abc import Sequence +from contextlib import AbstractAsyncContextManager from dataclasses import dataclass from typing import Any, Generic, cast, overload @@ -36,11 +44,12 @@ from .types import ( @dataclass(slots=True) class LayerRunSlot: - """Invocation-local lifecycle and exit state for one fresh layer instance.""" + """Invocation-local lifecycle, resource scope, and exit state for one layer.""" layer: Layer[Any, Any, Any, Any, Any, Any] lifecycle_state: LifecycleState exit_intent: ExitIntent = ExitIntent.DELETE + active_resource_context: AbstractAsyncContextManager[None] | None = None @dataclass(slots=True) @@ -123,40 +132,57 @@ class CompositorRun(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPrompt await self._enter_slot(slot) entered_slots.append(slot) except BaseException as enter_error: - hook_error = await self._exit_slots_reversed(entered_slots) + hook_error = await self._exit_slots_reversed(entered_slots, wrapped_error=enter_error) self.session_snapshot = self.snapshot_session() if hook_error is not None: raise hook_error from enter_error raise - async def _exit_layers(self) -> None: - hook_error = await self._exit_slots_reversed(list(self.slots.values())) + async def _exit_layers(self, wrapped_error: BaseException | None = None) -> None: + hook_error = await self._exit_slots_reversed(list(self.slots.values()), wrapped_error=wrapped_error) self.session_snapshot = self.snapshot_session() if hook_error is not None: raise hook_error async def _enter_slot(self, slot: LayerRunSlot) -> None: - if slot.lifecycle_state is LifecycleState.NEW: - slot.exit_intent = ExitIntent.DELETE - await slot.layer.on_context_create() - slot.lifecycle_state = LifecycleState.ACTIVE - return - if slot.lifecycle_state is LifecycleState.SUSPENDED: - slot.exit_intent = ExitIntent.DELETE - await slot.layer.on_context_resume() - slot.lifecycle_state = LifecycleState.ACTIVE - return - raise RuntimeError(f"Cannot enter layer from lifecycle state '{slot.lifecycle_state}'.") + resource_context = slot.layer.resource_context() + await resource_context.__aenter__() + slot.active_resource_context = resource_context + try: + if slot.lifecycle_state is LifecycleState.NEW: + slot.exit_intent = ExitIntent.DELETE + await slot.layer.on_context_create() + slot.lifecycle_state = LifecycleState.ACTIVE + return + if slot.lifecycle_state is LifecycleState.SUSPENDED: + slot.exit_intent = ExitIntent.DELETE + await slot.layer.on_context_resume() + slot.lifecycle_state = LifecycleState.ACTIVE + return + raise RuntimeError(f"Cannot enter layer from lifecycle state '{slot.lifecycle_state}'.") + except BaseException as enter_error: + resource_error = await self._exit_resource_context(slot, wrapped_error=enter_error) + if resource_error is not None: + raise resource_error from enter_error + raise - async def _exit_slots_reversed(self, slots: Sequence[LayerRunSlot]) -> BaseException | None: + async def _exit_slots_reversed( + self, + slots: Sequence[LayerRunSlot], + *, + wrapped_error: BaseException | None = None, + ) -> BaseException | None: hook_error: BaseException | None = None + propagating_error = wrapped_error for slot in reversed(slots): if slot.lifecycle_state is not LifecycleState.ACTIVE: continue + slot_error: BaseException | None = None if slot.exit_intent is ExitIntent.SUSPEND: try: await slot.layer.on_context_suspend() except BaseException as exc: + slot_error = exc hook_error = hook_error or exc finally: slot.lifecycle_state = LifecycleState.SUSPENDED @@ -164,12 +190,43 @@ class CompositorRun(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPrompt try: await slot.layer.on_context_delete() except BaseException as exc: + slot_error = exc hook_error = hook_error or exc finally: slot.lifecycle_state = LifecycleState.CLOSED + slot_scope_error = slot_error or propagating_error + resource_error = await self._exit_resource_context(slot, wrapped_error=slot_scope_error) + if resource_error is not None: + hook_error = hook_error or resource_error + propagating_error = resource_error + continue + propagating_error = slot_scope_error + return hook_error + async def _exit_resource_context( + self, + slot: LayerRunSlot, + *, + wrapped_error: BaseException | None, + ) -> BaseException | None: + resource_context = slot.active_resource_context + if resource_context is None: + return None + + slot.active_resource_context = None + exc_type = type(wrapped_error) if wrapped_error is not None else None + exc_traceback = wrapped_error.__traceback__ if wrapped_error is not None else None + try: + # Resource scopes exist for deterministic live-resource cleanup. They + # should observe the exception leaving the wrapped scope, but Agenton + # still preserves its own hook/body error propagation rules. + await resource_context.__aexit__(exc_type, wrapped_error, exc_traceback) + except BaseException as exc: + return exc + return None + def _set_layer_exit_intent(self, name: str, intent: ExitIntent) -> None: try: slot = self.slots[name] diff --git a/dify-agent/src/agenton/compositor/schemas.py b/dify-agent/src/agenton/compositor/schemas.py index 8b58eaf20e..9bf7c7da92 100644 --- a/dify-agent/src/agenton/compositor/schemas.py +++ b/dify-agent/src/agenton/compositor/schemas.py @@ -4,6 +4,8 @@ Graph config and session snapshots are separate boundaries on purpose. Graph config describes only reusable composition state: schema version, ordered node names, provider type ids, dependency mappings, and metadata. Session snapshots carry only ordered layer lifecycle state plus serializable ``runtime_state``. +Live resources acquired inside ``Layer.resource_context()`` are active-scope +only and can never appear in these DTOs. External DTOs are revalidated even when callers pass an already-constructed Pydantic model instance. These models are mutable, so dumping and validating @@ -99,8 +101,9 @@ class CompositorSessionSnapshot(BaseModel): """Serializable compositor session snapshot. Snapshots include ordered layer lifecycle state and serializable runtime - state only. Live resources, handles, dependencies, prompts, tools, and - config are outside Agenton snapshots and are never captured here. + state only. Live resources from ``Layer.resource_context()``, handles, + dependencies, prompts, tools, and config are outside Agenton snapshots and + are never captured here. """ schema_version: int = 1 diff --git a/dify-agent/src/agenton/layers/base.py b/dify-agent/src/agenton/layers/base.py index c6f26a4dbc..73f9f49014 100644 --- a/dify-agent/src/agenton/layers/base.py +++ b/dify-agent/src/agenton/layers/base.py @@ -1,10 +1,11 @@ """Invocation-scoped core layer abstractions and typed dependency binding. -Agenton core deliberately manages only three concerns: stateless layer graph -composition, serializable ``runtime_state`` lifecycle, and session snapshots. It -does not own live resources, process handles, HTTP clients, cleanup stacks, or -any other non-serializable runtime object. Those belong to application layers or -integration code outside the core. +Agenton core deliberately manages four concerns: stateless layer graph +composition, serializable ``runtime_state`` lifecycle, per-active-invocation +resource scopes, and session snapshots. Live resources remain layer-owned: +Agenton may enter ``Layer.resource_context()`` for the active scope, but it +never serializes or snapshots clients, process handles, cleanup stacks, or any +other non-serializable runtime object. Layers declare their dependency shape with ``Layer[DepsT, PromptT, UserPromptT, ToolT, ConfigT, RuntimeStateT]``. @@ -24,18 +25,27 @@ when possible, while still allowing subclasses to set them explicitly for unusual inheritance patterns. ``Layer`` is an invocation-scoped business object. It owns ``config``, direct -``deps``, and serializable ``runtime_state`` plus prompt/tool authoring surfaces, -but it does not own lifecycle state, exit intent, graph owner tokens, entry -stacks, resources, or cleanup callbacks. ``CompositorRun`` owns lifecycle state -and exit intent for one entry. ``SessionSnapshot`` objects are the only supported -cross-call state carrier. +``deps``, serializable ``runtime_state``, prompt/tool authoring surfaces, and +any live resource fields managed by ``resource_context()``. It does not own +lifecycle state, exit intent, graph owner tokens, or entry stacks. +``CompositorRun`` owns lifecycle state and exit intent for one entry and +orchestrates entering and exiting each layer's resource scope. ``SessionSnapshot`` +objects are the only supported cross-call state carrier. Lifecycle hooks are no-argument business hooks on the layer instance: ``on_context_create/resume/suspend/delete(self)``. They should read dependencies from ``self.deps`` and read or mutate serializable invocation state through -``self.runtime_state``. Resource acquisition and deterministic cleanup should be -handled outside Agenton core, for example by integration-specific context -managers that wrap compositor entry. +``self.runtime_state``. ``resource_context(self)`` is the symmetric active-scope +API for live resources. Agenton enters it before ``on_context_create`` or +``on_context_resume`` and exits it after ``on_context_suspend`` or +``on_context_delete``. Create-versus-resume differences stay in the business +hooks; ``resource_context`` should manage only live resource setup and cleanup. +Agenton marks a slot ``ACTIVE`` only after ``on_context_create`` or +``on_context_resume`` returns successfully. If either enter hook raises, normal +``on_context_suspend``/``on_context_delete`` hooks do not run for that failed +attempt. Enter hooks therefore own any business compensation or idempotency for +partial side effects, while Agenton guarantees only ``resource_context()`` +cleanup, not hook rollback. ``Layer`` is framework-neutral over system prompt, user prompt, and tool item types. The native ``prefix_prompts``, ``suffix_prompts``, ``user_prompts``, and @@ -47,11 +57,13 @@ native values without changing layer implementations. from abc import ABC, abstractmethod from collections.abc import Mapping, Sequence +from contextlib import asynccontextmanager from dataclasses import dataclass from enum import StrEnum from types import UnionType from typing import ( Any, + AsyncIterator, ClassVar, Generic, Union, @@ -183,10 +195,11 @@ class Layer( snapshot, and then runs no-argument lifecycle hooks. The run owns lifecycle state and exit intent; layers never expose a public entry context manager. - Live resources and handles are intentionally outside this abstraction. Only - ``runtime_state`` is managed and snapshotted by Agenton core. Lifecycle hooks - should operate on ``self`` and keep any non-serializable cleanup policy in - integration code that wraps the compositor. + ``runtime_state`` is the only mutable data Agenton snapshots across calls. + Live resources belong on the layer instance itself and should be acquired in + ``resource_context()``. Agenton keeps that resource scope active while the + corresponding enter hook, run body, and exit hook execute, then tears it + down deterministically even when later hooks or the body fail. """ deps_type: type[_DepsT] @@ -267,17 +280,46 @@ class Layer( resolved_deps[name] = deps[name] self.deps = self.deps_type(**resolved_deps) + @asynccontextmanager + async def resource_context(self) -> AsyncIterator[None]: + """Wrap one active invocation with live non-serializable resources. + + Agenton enters this no-argument context before ``on_context_create`` or + ``on_context_resume`` and exits it after ``on_context_suspend`` or + ``on_context_delete``. Use it for live clients, process handles, or + other non-serializable objects stored on ``self``. Keep create-versus- + resume business differences in the corresponding lifecycle hooks. + """ + yield + async def on_context_create(self) -> None: - """Run when the run slot enters from ``LifecycleState.NEW``.""" + """Run when the run slot enters from ``LifecycleState.NEW``. + + ``resource_context()`` is already active for this layer when this hook + runs. If this hook raises, the layer never becomes ``ACTIVE`` and no + normal ``on_context_delete()`` hook runs for that failed enter attempt. + """ async def on_context_delete(self) -> None: - """Run when the run slot exits with ``ExitIntent.DELETE``.""" + """Run when the run slot exits with ``ExitIntent.DELETE``. + + ``resource_context()`` remains active while this hook runs. + """ async def on_context_suspend(self) -> None: - """Run when the run slot exits with ``ExitIntent.SUSPEND``.""" + """Run when the run slot exits with ``ExitIntent.SUSPEND``. + + ``resource_context()`` remains active while this hook runs. + """ async def on_context_resume(self) -> None: - """Run when the run slot enters from ``LifecycleState.SUSPENDED``.""" + """Run when the run slot enters from ``LifecycleState.SUSPENDED``. + + ``resource_context()`` is already active for this layer when this hook + runs. If this hook raises, the layer never becomes ``ACTIVE`` and no + normal ``on_context_suspend()`` or ``on_context_delete()`` hook runs for + that failed resume attempt. + """ @property def prefix_prompts(self) -> Sequence[_PromptT]: diff --git a/dify-agent/src/dify_agent/layers/shell/__init__.py b/dify-agent/src/dify_agent/layers/shell/__init__.py new file mode 100644 index 0000000000..fd579570ea --- /dev/null +++ b/dify-agent/src/dify_agent/layers/shell/__init__.py @@ -0,0 +1,10 @@ +"""Client-safe exports for the Dify shell layer DTOs. + +The runtime layer implementation lives in ``layer.py`` and imports shellctl +client code plus server-side lifecycle behavior. Keep this package root +import-safe for client code that only needs to build run requests. +""" + +from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig + +__all__ = ["DIFY_SHELL_LAYER_TYPE_ID", "DifyShellLayerConfig"] diff --git a/dify-agent/src/dify_agent/layers/shell/configs.py b/dify-agent/src/dify_agent/layers/shell/configs.py new file mode 100644 index 0000000000..18f607ded2 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/shell/configs.py @@ -0,0 +1,25 @@ +"""Client-safe DTOs for the Dify shell Agenton layer. + +This first shell layer version intentionally has no public configuration beyond +its stable type id. Server-only shellctl connection settings are injected by the +runtime provider factory so client code cannot accidentally depend on process +environment or transport details. +""" + +from typing import ClassVar, Final + +from pydantic import ConfigDict + +from agenton.layers import LayerConfig + + +DIFY_SHELL_LAYER_TYPE_ID: Final[str] = "dify.shell" + + +class DifyShellLayerConfig(LayerConfig): + """Empty public config for the shellctl-backed Dify shell layer.""" + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +__all__ = ["DIFY_SHELL_LAYER_TYPE_ID", "DifyShellLayerConfig"] diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py new file mode 100644 index 0000000000..d265075631 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/shell/layer.py @@ -0,0 +1,733 @@ +"""Shellctl-backed Dify shell layer. + +``DifyShellLayer`` is a stateful pydantic-ai tool layer that exposes exactly +``shell.run``, ``shell.wait``, ``shell.input``, and ``shell.interrupt``. The +layer persists only JSON-safe shell session state in ``runtime_state`` and keeps +its live shellctl HTTP client on the layer instance only while +``resource_context()`` is active. Agenton enters that resource scope before +``on_context_create`` or ``on_context_resume`` and exits it after +``on_context_suspend`` or ``on_context_delete``, so business hooks and shell +tools can rely on a live client without ever serializing it into snapshots. + +The runtime state tracks shellctl job ids for both user-visible shell jobs and +internal lifecycle jobs such as workspace mkdir/cleanup commands. Those internal +jobs are intentionally not deleted ad hoc; shellctl job-state deletion is +centralized in ``on_context_delete`` so one lifecycle hook owns exit-time +cleanup for successful create/resume flows. If ``on_context_create`` or a later +side-effecting ``on_context_resume`` attempt fails after issuing shellctl jobs, +Agenton still exits ``resource_context()`` but never transitions the layer to +``ACTIVE``. In that failed-enter path, normal suspend/delete hooks do not run, +so the enter hook itself must perform best-effort business compensation before +re-raising the failure. +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Callable, Sequence +from contextlib import asynccontextmanager +import logging +import re +import secrets +import time +from dataclasses import dataclass +from typing import ClassVar, NotRequired, Protocol, TypedDict + +from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator, model_validator +from pydantic_ai import Tool +from shell_session_manager.shellctl.client import ShellctlClient, ShellctlClientError +from shell_session_manager.shellctl.shared import ( + DEFAULT_TERMINATE_GRACE_SECONDS, + DEFAULT_TIMEOUT_SECONDS, + DeleteJobResponse, + JobResult, + JobStatusView, +) +from typing_extensions import Self, override + +from agenton.layers import NoLayerDeps, PydanticAILayer, PydanticAIPrompt, PydanticAITool +from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig + + +logger = logging.getLogger(__name__) + +_WORKSPACE_ROOT = "~/workspace" +_WORKSPACE_COLLISION_EXIT_CODE = 17 +_SESSION_TIME_HEX_MASK = 0xFFFFF +_SESSION_RANDOM_HEX_LENGTH = 2 +_SESSION_ID_ATTEMPT_LIMIT = 256 +_SESSION_ID_PATTERN = re.compile(r"^[0-9a-f]{7}$") +_SHELL_LAYER_PREFIX_PROMPT = """You have access to a shell layer. It provides four tools: + +1. shell.run + Start a new shell job in the current isolated workspace. + Use it to execute commands or scripts. + +2. shell.wait + Wait for more output or completion from an existing shell job. + Use it when shell.run returns done=false. + +3. shell.input + Send stdin text to a running shell job, then wait for new output. + Use it for interactive commands that are waiting for input. + +4. shell.interrupt + Interrupt a running shell job. + Use it to stop a long-running, stuck, or no-longer-needed command. + +Common arguments: + +- script: + The command or script to execute. Used by shell.run. + +- job_id: + The id of a shell job returned by shell.run. + Use it with shell.wait, shell.input, and shell.interrupt. + Never invent a job_id. + +- timeout: + Maximum time, in seconds, to wait for output or completion for this tool call. + A timeout does not necessarily mean the job has stopped; if done=false, use shell.wait again. + +- text: + Text to send to the running process stdin. Used by shell.input. + Include "\\n" if the process expects Enter. + +- grace_seconds: + Time to wait after interrupting before forceful cleanup. Used by shell.interrupt. + +Usage rules: + +- Start with shell.run. +- If shell.run returns done=false, call shell.wait with the returned job_id. +- Use shell.input only when the job is running and waiting for stdin. +- Use shell.interrupt when a job is stuck or should be stopped. + +The script argument of shell.run can be a normal shell script, or a shebang script. +If the first line is a shebang, the shell layer executes the script directly. + +Tips: + +- When using Python, prefer a uv script with a PEP 723 dependency header. + + Example: + +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "httpx==0.28.1", +# "rich>=13.8.0", +# ] +# /// + +import httpx +from rich import print + +response = httpx.get("https://example.com", timeout=10) +print(f"[green]status:[/green] {response.status_code}")""" + + +class ShellJobObservation(TypedDict): + """JSON-safe output-oriented shell tool observation.""" + + job_id: str + status: str + done: bool + exit_code: int | None + output: str + offset: int + truncated: bool + output_path: str + + +class ShellJobStatusObservation(TypedDict): + """JSON-safe status-only shell tool observation.""" + + job_id: str + status: str + done: bool + exit_code: int | None + offset: int + + +class ShellToolErrorObservation(TypedDict): + """Tool-visible failure payload for expected shell-layer errors.""" + + error: str + job_id: NotRequired[str] + + +type ShellRunToolResult = ShellJobObservation | ShellToolErrorObservation +type ShellInterruptToolResult = ShellJobStatusObservation | ShellToolErrorObservation + + +class ShellctlClientProtocol(Protocol): + """Boundary that the shell layer needs from a shellctl client.""" + + async def run( + self, + script: str, + *, + cwd: str | None = None, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + ) -> JobResult: ... + + async def wait( + self, + job_id: str, + *, + offset: int, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + ) -> JobResult: ... + + async def input( + self, + job_id: str, + text: str, + *, + offset: int, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + ) -> JobResult: ... + + async def terminate( + self, + job_id: str, + grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS, + ) -> JobStatusView: ... + + async def delete( + self, + job_id: str, + *, + force: bool = False, + grace_seconds: float | None = None, + ) -> DeleteJobResponse: ... + + async def close(self) -> None: ... + + +type ShellctlClientFactory = Callable[[str], ShellctlClientProtocol] + + +class DifyShellRuntimeState(BaseModel): + """Serializable shell session state stored in Agenton snapshots. + + ``job_ids`` and ``job_offsets`` contain both user-facing jobs and internal + lifecycle jobs so resumed sessions can still clean up shellctl state that was + created before suspension. Callers should replace the stored list/dict values + rather than mutating them in place so Pydantic assignment validation keeps + guarding the serialized state. Hydrated public snapshots must keep + ``session_id`` in the proposal's safe lowercase-hex format and must keep + ``workspace_cwd`` exactly aligned with ``~/workspace/`` so resume + and delete paths cannot escape the isolated workspace root or inject shell + syntax into lifecycle commands. Shellctl job ids remain opaque strings here; + the layer only enforces uniqueness plus the invariant that any stored offset + entry must belong to a tracked job id in the same runtime state. + """ + + session_id: str | None = None + workspace_cwd: str | None = None + job_ids: list[str] = Field(default_factory=list) + job_offsets: dict[str, NonNegativeInt] = Field(default_factory=dict) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", validate_assignment=True) + + @field_validator("session_id") + @classmethod + def validate_session_id(cls, value: str | None) -> str | None: + """Accept only the short lowercase-hex session ids defined by the proposal.""" + if value is None: + return value + return _validated_session_id(value) + + @field_validator("job_ids") + @classmethod + def validate_job_ids(cls, value: list[str]) -> list[str]: + """Keep tracked shellctl job ids unique within one serialized session.""" + if len(value) != len(set(value)): + raise ValueError("job_ids must not contain duplicates.") + return value + + @model_validator(mode="after") + def validate_workspace_and_offsets(self) -> Self: + """Keep resumed workspace identity and tracked offset keys self-consistent.""" + if self.workspace_cwd is not None: + if self.session_id is None: + raise ValueError("workspace_cwd requires a matching session_id.") + expected_workspace = _workspace_cwd(self.session_id) + if self.workspace_cwd != expected_workspace: + raise ValueError( + f"workspace_cwd must equal {expected_workspace!r} for session_id {self.session_id!r}." + ) + unknown_offset_job_ids = set(self.job_offsets) - set(self.job_ids) + if unknown_offset_job_ids: + names = ", ".join(sorted(unknown_offset_job_ids)) + raise ValueError(f"job_offsets contains unknown job ids: {names}.") + return self + + +@dataclass(slots=True) +class DifyShellLayer(PydanticAILayer[NoLayerDeps, object, DifyShellLayerConfig, DifyShellRuntimeState]): + """Shell tool layer backed by a live shellctl client while active. + + The mutable serializable state lives in ``runtime_state``; the live client is + intentionally kept off-snapshot in ``_shellctl_client``. Tool methods update + tracked job ids and output offsets after every successful shellctl response so + later ``shell.wait``/``shell.input`` calls can resume from the last known + offset without exposing offsets as model-controlled inputs. + """ + + type_id: ClassVar[str | None] = DIFY_SHELL_LAYER_TYPE_ID + + config: DifyShellLayerConfig + shellctl_entrypoint: str + shellctl_client_factory: ShellctlClientFactory + _shellctl_client: ShellctlClientProtocol | None = None + + @classmethod + @override + def from_config(cls, config: DifyShellLayerConfig) -> Self: + """Reject construction that omits server-injected shellctl settings.""" + del config + raise TypeError("DifyShellLayer requires server-side shellctl settings and must use a provider factory.") + + @classmethod + def from_config_with_settings( + cls, + config: DifyShellLayerConfig, + *, + shellctl_entrypoint: str | None, + shellctl_client_factory: ShellctlClientFactory, + ) -> Self: + """Create the layer from public config plus server-only shellctl settings.""" + normalized_entrypoint = (shellctl_entrypoint or "").strip() + if not normalized_entrypoint: + raise ValueError( + "DifyShellLayer requires a non-empty DIFY_AGENT_SHELLCTL_ENTRYPOINT when the 'dify.shell' layer is used." + ) + return cls( + config=config, + shellctl_entrypoint=normalized_entrypoint, + shellctl_client_factory=shellctl_client_factory, + ) + + @property + @override + def prefix_prompts(self) -> Sequence[PydanticAIPrompt[object]]: + return [_shell_layer_prefix_prompt] + + @property + @override + def tools(self) -> Sequence[PydanticAITool[object]]: + return [ + Tool(self._tool_run, name="shell.run"), + Tool(self._tool_wait, name="shell.wait"), + Tool(self._tool_input, name="shell.input"), + Tool(self._tool_interrupt, name="shell.interrupt"), + ] + + @override + @asynccontextmanager + async def resource_context(self) -> AsyncGenerator[None]: + """Hold one live shellctl client for one active Agenton layer scope. + + The shellctl client is a non-serializable live resource, so Agenton owns + only the timing of this scope, not the client itself. Business hooks and + tools should call ``_require_client()`` to ensure they are running inside + an active resource scope. + """ + if self._shellctl_client is not None: + raise RuntimeError("DifyShellLayer resource_context() is already active for this layer instance.") + + client = self.shellctl_client_factory(self.shellctl_entrypoint) + self._shellctl_client = client + try: + yield + finally: + self._shellctl_client = None + await client.close() + + @override + async def on_context_create(self) -> None: + """Allocate a new workspace session using the active live shellctl client. + + If workspace setup partially succeeds and this hook later raises, the + layer never becomes ``ACTIVE``. In that path Agenton still exits + ``resource_context()``, but ``on_context_delete()`` will not run, so this + hook must clean up any tracked shellctl job artifacts before re-raising. + """ + try: + _ = self._require_client() + session_id, workspace_cwd = await self._allocate_workspace() + except BaseException: + await self._cleanup_create_failure() + raise + self.runtime_state = DifyShellRuntimeState.model_validate( + { + **self.runtime_state.model_dump(mode="python"), + "session_id": session_id, + "workspace_cwd": workspace_cwd, + } + ) + + @override + async def on_context_resume(self) -> None: + """Resume an existing serialized shell session inside an active resource scope. + + If a future resume path adds self-heal side effects before raising, this + hook must compensate for them itself because failed resume attempts never + transition the slot back to ``ACTIVE`` and therefore do not receive a + normal suspend/delete hook. + """ + _ = self._require_client() + _ = self._require_session_identity() + + @override + async def on_context_suspend(self) -> None: + """Preserve workspace and job state while the live client remains active. + + ``resource_context()`` owns client teardown after this hook returns. + """ + _ = self._require_client() + + @override + async def on_context_delete(self) -> None: + """Best-effort cleanup for workspace deletion and tracked shellctl jobs. + + Workspace removal must happen before tracked shellctl job deletion because + the cleanup itself is implemented as an internal shellctl run. That means + deleting job state first would prevent the layer from issuing the + proposal-required ``rm -rf`` cleanup job and then cleaning up that final + job record along with the rest of the session's tracked shellctl state. + ``resource_context()`` closes the live client only after this hook + finishes. + """ + _ = self._require_client() + + cleanup_job_id: str | None = None + identity = self._try_session_identity() + if identity is not None: + session_id, _workspace_cwd = identity + try: + cleanup_result = await self._run_internal_job_to_completion( + _workspace_cleanup_script(session_id=session_id), + cwd=None, + ) + cleanup_job_id = cleanup_result["job_id"] + if cleanup_result["exit_code"] != 0: + logger.warning( + "Shell workspace cleanup job %s for session %s exited with code %s.", + cleanup_job_id, + session_id, + cleanup_result["exit_code"], + ) + except (RuntimeError, ValueError, ShellctlClientError) as exc: + logger.warning("Failed to remove shell workspace for session %s: %s", session_id, exc) + + tracked_job_ids = _deduplicate_preserving_order( + [*self.runtime_state.job_ids, *([cleanup_job_id] if cleanup_job_id is not None else [])] + ) + await self._delete_tracked_jobs_best_effort(tracked_job_ids) + self._clear_tracked_jobs() + + async def _tool_run(self, script: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult: + """Start a new shell job inside the session workspace.""" + try: + client = self._require_client() + result = await client.run(script, cwd=self._require_workspace_cwd(), timeout=timeout) + self._track_job_result(result) + return _job_result_observation(result) + except (RuntimeError, ValueError, ShellctlClientError) as exc: + return _tool_error(str(exc)) + + async def _tool_wait(self, job_id: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult: + """Wait for more output or completion from a tracked shell job.""" + try: + client = self._require_client() + offset = self._tracked_offset(job_id) + result = await client.wait(job_id, offset=offset, timeout=timeout) + self._track_job_result(result) + return _job_result_observation(result) + except (RuntimeError, ValueError, ShellctlClientError) as exc: + return _tool_error(str(exc), job_id=job_id) + + async def _tool_input(self, job_id: str, text: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult: + """Send text input to a tracked shell job and wait for output.""" + try: + client = self._require_client() + offset = self._tracked_offset(job_id) + result = await client.input(job_id, text, offset=offset, timeout=timeout) + self._track_job_result(result) + return _job_result_observation(result) + except (RuntimeError, ValueError, ShellctlClientError) as exc: + return _tool_error(str(exc), job_id=job_id) + + async def _tool_interrupt( + self, + job_id: str, + grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS, + ) -> ShellInterruptToolResult: + """Interrupt a tracked shell job without removing its persisted shellctl state.""" + try: + client = self._require_client() + self._ensure_tracked_job(job_id) + result = await client.terminate(job_id, grace_seconds=grace_seconds) + self._track_job_status(result) + return _job_status_observation(result) + except (RuntimeError, ValueError, ShellctlClientError) as exc: + return _tool_error(str(exc), job_id=job_id) + + async def _allocate_workspace(self) -> tuple[str, str]: + """Allocate a unique ``~/workspace/`` directory by mkdir collision checks.""" + for _attempt in range(_SESSION_ID_ATTEMPT_LIMIT): + session_id = _generate_session_id() + mkdir_result = await self._run_internal_job_to_completion( + _workspace_mkdir_script(session_id=session_id), + cwd=None, + ) + if mkdir_result["exit_code"] == _WORKSPACE_COLLISION_EXIT_CODE: + continue + if mkdir_result["exit_code"] != 0: + raise RuntimeError( + f"Failed to create shell workspace {_workspace_cwd(session_id)}: {mkdir_result['status']} exit_code={mkdir_result['exit_code']}" + ) + return session_id, _workspace_cwd(session_id) + raise RuntimeError("Failed to allocate a unique shell workspace session id after 256 attempts.") + + async def _cleanup_create_failure(self) -> None: + """Best-effort shellctl job cleanup for create failures before ACTIVE state. + + Agenton only calls ``on_context_delete`` for layers that successfully + entered ``ACTIVE``. If ``on_context_create`` fails after issuing one or + more internal shellctl jobs, those tracked job artifacts would otherwise + leak because no later lifecycle hook owns them. ``resource_context()`` + still closes the live client for this failed enter attempt after the hook + unwinds. + """ + if not self.runtime_state.job_ids: + return + try: + await self._delete_tracked_jobs_best_effort(self.runtime_state.job_ids) + finally: + self._clear_tracked_jobs() + + async def _run_internal_job_to_completion( + self, + script: str, + *, + cwd: str | None, + ) -> ShellJobObservation: + """Run an internal lifecycle command, track it, and wait for completion.""" + client = self._require_client() + result = await client.run(script, cwd=cwd, timeout=DEFAULT_TIMEOUT_SECONDS) + self._track_job_result(result) + while not result.done: + result = await client.wait( + result.job_id, + offset=self._tracked_offset(result.job_id), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + self._track_job_result(result) + return _job_result_observation(result) + + def _require_client(self) -> ShellctlClientProtocol: + """Return the live client or reject tool/lifecycle use without one.""" + if self._shellctl_client is None: + raise RuntimeError( + "DifyShellLayer requires an active shellctl client inside resource_context(); " + + "enter the layer through Agenton or wrap direct hook/tool usage in resource_context()." + ) + return self._shellctl_client + + def _require_workspace_cwd(self) -> str: + """Return the configured workspace directory for user-facing shell jobs.""" + _session_id, workspace_cwd = self._require_session_identity() + return workspace_cwd + + def _require_session_identity(self) -> tuple[str, str]: + """Return the stored session id and workspace path or raise for corrupt state.""" + identity = self._try_session_identity() + if identity is None: + raise ValueError("DifyShellLayer runtime state is missing session_id or workspace_cwd.") + session_id, workspace_cwd = identity + expected_workspace = _workspace_cwd(session_id) + if workspace_cwd != expected_workspace: + raise ValueError( + f"DifyShellLayer runtime state has inconsistent workspace_cwd {workspace_cwd!r}; expected {expected_workspace!r}." + ) + return session_id, workspace_cwd + + def _try_session_identity(self) -> tuple[str, str] | None: + session_id = self.runtime_state.session_id + workspace_cwd = self.runtime_state.workspace_cwd + if session_id is None or workspace_cwd is None: + return None + return session_id, workspace_cwd + + def _ensure_tracked_job(self, job_id: str) -> None: + """Reject tool access to job ids not tracked in the current runtime state. + + This first version treats shellctl job ids as opaque strings and uses + membership in ``runtime_state.job_ids`` as the tool-access boundary for + wait/input/interrupt operations. + """ + if job_id not in self.runtime_state.job_ids: + raise ValueError(f"Unknown shell job id for this session: {job_id}.") + + def _tracked_offset(self, job_id: str) -> int: + """Return the stored offset for a tracked job, defaulting legacy state to zero.""" + self._ensure_tracked_job(job_id) + return int(self.runtime_state.job_offsets.get(job_id, 0)) + + def _track_job_result(self, result: JobResult) -> None: + """Track one output-oriented shellctl result in serializable runtime state.""" + self._remember_job_id(result.job_id) + self._remember_job_offset(result.job_id, result.offset) + + def _track_job_status(self, result: JobStatusView) -> None: + """Track status-only shellctl results that still carry the latest offset.""" + self._remember_job_id(result.job_id) + self._remember_job_offset(result.job_id, result.offset) + + def _remember_job_id(self, job_id: str) -> None: + if job_id in self.runtime_state.job_ids: + return + self.runtime_state.job_ids = [*self.runtime_state.job_ids, job_id] + + def _remember_job_offset(self, job_id: str, offset: int) -> None: + job_offsets = dict(self.runtime_state.job_offsets) + job_offsets[job_id] = offset + self.runtime_state.job_offsets = job_offsets + + async def _delete_tracked_jobs_best_effort(self, job_ids: Sequence[str]) -> None: + """Force-delete tracked shellctl jobs, ignoring already-missing ones.""" + client = self._require_client() + for job_id in _deduplicate_preserving_order(job_ids): + try: + _ = await client.delete(job_id, force=True) + except ShellctlClientError as exc: + if exc.code == "job_not_found": + continue + logger.warning( + "Failed to delete shellctl job %s for session %s: %s", + job_id, + self.runtime_state.session_id, + exc, + ) + except RuntimeError as exc: + logger.warning( + "Failed to delete shellctl job %s for session %s: %s", + job_id, + self.runtime_state.session_id, + exc, + ) + + def _clear_tracked_jobs(self) -> None: + self.runtime_state.job_offsets = {} + self.runtime_state.job_ids = [] + + +def _shell_layer_prefix_prompt() -> str: + """Return the static model-facing shell tool usage guidance.""" + return _SHELL_LAYER_PREFIX_PROMPT + + +def create_shellctl_client_factory(*, token: str) -> ShellctlClientFactory: + """Return the default shellctl client factory used by server-side providers.""" + + def factory(entrypoint: str) -> ShellctlClientProtocol: + return ShellctlClient(entrypoint, token=token) + + return factory + + +def _job_result_observation(result: JobResult) -> ShellJobObservation: + return { + "job_id": result.job_id, + "status": result.status.value, + "done": result.done, + "exit_code": result.exit_code, + "output": result.output, + "offset": result.offset, + "truncated": result.truncated, + "output_path": result.output_path, + } + + +def _job_status_observation(result: JobStatusView) -> ShellJobStatusObservation: + return { + "job_id": result.job_id, + "status": result.status.value, + "done": result.done, + "exit_code": result.exit_code, + "offset": result.offset, + } + + +def _tool_error(message: str, *, job_id: str | None = None) -> ShellToolErrorObservation: + result: ShellToolErrorObservation = {"error": message} + if job_id is not None: + result["job_id"] = job_id + return result + + +def _generate_session_id() -> str: + time_component = int(time.time()) & _SESSION_TIME_HEX_MASK + random_component = secrets.token_hex(1) + if len(random_component) != _SESSION_RANDOM_HEX_LENGTH: + raise RuntimeError("Expected a one-byte random hex suffix for Dify shell session ids.") + return f"{time_component:05x}{random_component}" + + +def _workspace_cwd(session_id: str) -> str: + return f"{_WORKSPACE_ROOT}/{_validated_session_id(session_id)}" + + +def _workspace_mkdir_script(*, session_id: str) -> str: + """Return the internal mkdir command used for proposal-defined collision checks. + + The parent ``$HOME/workspace`` directory is created with ``mkdir -p`` so it + can already exist, but the final session directory intentionally uses plain + ``mkdir``. That second call is the collision detector: when the target + already exists, the script maps that case to ``_WORKSPACE_COLLISION_EXIT_CODE`` + so ``on_context_create()`` can retry with a different random suffix instead + of silently reusing another session's workspace. + """ + safe_session_id = _validated_session_id(session_id) + workspace_dir = f'$HOME/workspace/{safe_session_id}' + return ( + 'mkdir -p "$HOME/workspace"; ' + f'if mkdir "{workspace_dir}"; then exit 0; fi; ' + f'if [ -e "{workspace_dir}" ]; then exit {_WORKSPACE_COLLISION_EXIT_CODE}; fi; ' + 'exit 1' + ) + + +def _workspace_cleanup_script(*, session_id: str) -> str: + return f'rm -rf -- "$HOME/workspace/{_validated_session_id(session_id)}"' + + +def _validated_session_id(session_id: str) -> str: + if not _SESSION_ID_PATTERN.fullmatch(session_id): + raise ValueError("session_id must match the 5+2 lowercase hex format '<5 hex><2 hex>'.") + return session_id + + +def _deduplicate_preserving_order(values: Sequence[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for value in values: + if value in seen: + continue + seen.add(value) + result.append(value) + return result + + +__all__ = [ + "DifyShellLayer", + "DifyShellRuntimeState", + "ShellctlClientFactory", + "ShellctlClientProtocol", + "create_shellctl_client_factory", +] diff --git a/dify-agent/src/dify_agent/runtime/compositor_factory.py b/dify-agent/src/dify_agent/runtime/compositor_factory.py index f3cc3b37b3..88dfd65e9a 100644 --- a/dify-agent/src/dify_agent/runtime/compositor_factory.py +++ b/dify-agent/src/dify_agent/runtime/compositor_factory.py @@ -2,18 +2,22 @@ Only explicitly allowed provider type ids are constructible here. The default provider set contains prompt layers, the optional pydantic-ai history layer, the -state-free Dify structured output layer, the Dify execution-context layer, and -the Dify plugin business-layer family: +state-free Dify structured output layer, the Dify execution-context layer, the +stateful Dify shell layer, and the Dify plugin business-layer family: - ``dify.execution_context`` for shared tenant/user/run daemon context, +- ``dify.shell`` for shellctl-backed shell job control, - ``dify.plugin.llm`` for plugin-backed model selection, and - ``dify.plugin.tools`` for prepared plugin tool exposure. Public DTOs provide Dify context plus plugin/model/tool data, while server-only plugin daemon settings are injected through the provider factory for -``DifyExecutionContextLayer``. The resulting ``Compositor`` remains Agenton -state-only: live resources such as the plugin daemon HTTP client are supplied -later by the runtime and never enter providers, layers, or session snapshots. +``DifyExecutionContextLayer`` and the optional shellctl entrypoint/auth token plus +client factory are injected for ``DifyShellLayer``. The resulting ``Compositor`` +remains Agenton state-only at the snapshot boundary: live resources such as +HTTP clients are injected by runtime-owned providers, may be held on active +layer instances inside ``resource_context()``, and never enter session +snapshots. """ from collections.abc import Mapping, Sequence @@ -31,6 +35,8 @@ from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer from dify_agent.layers.output.output_layer import DifyOutputLayer +from dify_agent.layers.shell.configs import DifyShellLayerConfig +from dify_agent.layers.shell.layer import DifyShellLayer, create_shellctl_client_factory type DifyAgentLayerProvider = LayerProvider[Any] @@ -40,8 +46,18 @@ def create_default_layer_providers( *, plugin_daemon_url: str = "http://localhost:5002", plugin_daemon_api_key: str = "", + shellctl_entrypoint: str | None = None, + shellctl_auth_token: str | None = None, ) -> tuple[DifyAgentLayerProvider, ...]: - """Return the server provider set of safe config-constructible layers.""" + """Return the server provider set of safe config-constructible layers. + + ``shellctl_auth_token`` defaults to no token. Passing an explicit empty string + to ``create_shellctl_client_factory`` prevents ``ShellctlClient`` from falling + back to the Dify Agent process's ``SHELLCTL_AUTH_TOKEN`` environment variable; + deployments that enable shellctl bearer auth must set the Dify Agent server + setting explicitly. + """ + shellctl_token = shellctl_auth_token or "" return ( LayerProvider.from_layer_type(PromptLayer), LayerProvider.from_layer_type(PydanticAIHistoryLayer), @@ -54,6 +70,14 @@ def create_default_layer_providers( daemon_api_key=plugin_daemon_api_key, ), ), + LayerProvider.from_factory( + layer_type=DifyShellLayer, + create=lambda config: DifyShellLayer.from_config_with_settings( + DifyShellLayerConfig.model_validate(config), + shellctl_entrypoint=shellctl_entrypoint, + shellctl_client_factory=create_shellctl_client_factory(token=shellctl_token), + ), + ), LayerProvider.from_layer_type(DifyPluginLLMLayer), LayerProvider.from_layer_type(DifyPluginToolsLayer), ) diff --git a/dify-agent/src/dify_agent/runtime/runner.py b/dify-agent/src/dify_agent/runtime/runner.py index 11e99bb838..9458b5e7e3 100644 --- a/dify-agent/src/dify_agent/runtime/runner.py +++ b/dify-agent/src/dify_agent/runtime/runner.py @@ -110,16 +110,20 @@ class AgentRunRunner: async def _run_agent(self) -> tuple[JsonValue, CompositorSessionSnapshot]: """Run pydantic-ai inside an entered Agenton run. - Known input-shaped Agenton enter-time runtime errors, such as trying to - resume a ``CLOSED`` snapshot layer, are normalized to - ``AgentRunValidationError``. Output/history-layer graph invariants are - validated from the public composition before entering Agenton so - misnamed or extra reserved layers never silently degrade. Later runtime - failures still propagate as execution errors so they become terminal - failed runs rather than client validation responses. Structured output - uses a resolved contract whose type itself encodes both the model-facing - schema and the runtime validation hooks, so invalid model outputs can be - corrected before Dify Agent emits success. + Known request-shaped Agenton enter-time failures are normalized to + ``AgentRunValidationError``. That includes the existing small class of + enter-time ``RuntimeError`` values reported by Agenton plus + layer-construction or snapshot-hydration ``ValueError`` failures that + arise before the run becomes active, such as missing shell settings for a + requested ``dify.shell`` layer or malformed serialized shell offsets. + Output/history-layer graph invariants are validated from the public + composition before entering Agenton so misnamed or extra reserved layers + never silently degrade. Later runtime failures still propagate as + execution errors so they become terminal failed runs rather than client + validation responses. Structured output uses a resolved contract whose + type itself encodes both the model-facing schema and the runtime + validation hooks, so invalid model outputs can be corrected before Dify + Agent emits success. """ try: validate_output_layer_composition(self.request.composition) @@ -172,6 +176,10 @@ class AgentRunRunner: if not entered_run and is_agenton_enter_validation_runtime_error(exc): raise AgentRunValidationError(str(exc)) from exc raise + except ValueError as exc: + if not entered_run: + raise AgentRunValidationError(str(exc)) from exc + raise if run.session_snapshot is None: raise RuntimeError("Agenton run did not produce a session snapshot after exit.") diff --git a/dify-agent/src/dify_agent/server/app.py b/dify-agent/src/dify_agent/server/app.py index 9b5cf2a9c4..99cc9fa8a2 100644 --- a/dify-agent/src/dify_agent/server/app.py +++ b/dify-agent/src/dify_agent/server/app.py @@ -6,7 +6,8 @@ route wiring, and a process-local scheduler. Run execution happens in background cancel the agent runtime. Redis persists run records and per-run event streams with configured retention only; it is not used as a job queue. Agenton layers and providers stay state-only: they borrow the lifespan-owned plugin daemon client -through the runner and never create or close it themselves. +through the runner and receive shell-layer server settings through provider +construction rather than reading environment variables themselves. """ from collections.abc import AsyncGenerator @@ -29,6 +30,8 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: layer_providers = create_default_layer_providers( plugin_daemon_url=resolved_settings.plugin_daemon_url, plugin_daemon_api_key=resolved_settings.plugin_daemon_api_key, + shellctl_entrypoint=resolved_settings.shellctl_entrypoint, + shellctl_auth_token=resolved_settings.shellctl_auth_token, ) state: dict[str, object] = {} diff --git a/dify-agent/src/dify_agent/server/settings.py b/dify-agent/src/dify_agent/server/settings.py index 18c0ce7747..9d48c3bfe6 100644 --- a/dify-agent/src/dify_agent/server/settings.py +++ b/dify-agent/src/dify_agent/server/settings.py @@ -3,7 +3,9 @@ Plugin daemon HTTP client settings describe the single FastAPI lifespan-owned ``httpx.AsyncClient`` shared by local run tasks. Layers and Agenton providers do not own that client, so these settings are process resource limits rather than -per-run lifecycle knobs. +per-run lifecycle knobs. Optional shell-layer settings stay here as well because +the server injects them into layer providers instead of letting runtime modules +read process environment variables directly. """ from typing import ClassVar @@ -15,7 +17,7 @@ DEFAULT_RUN_RETENTION_SECONDS = 3 * 24 * 60 * 60 class ServerSettings(BaseSettings): - """Environment-backed settings for Redis, scheduling, and plugin daemon access.""" + """Environment-backed settings for Redis, scheduling, plugin, and shell access.""" redis_url: str = "redis://localhost:6379/0" redis_prefix: str = "dify-agent" @@ -23,6 +25,8 @@ class ServerSettings(BaseSettings): run_retention_seconds: int = Field(default=DEFAULT_RUN_RETENTION_SECONDS, ge=1) plugin_daemon_url: str = "http://localhost:5002" plugin_daemon_api_key: str = "" + shellctl_entrypoint: str | None = None + shellctl_auth_token: str | None = None plugin_daemon_connect_timeout: float = Field(default=10.0, ge=0) plugin_daemon_read_timeout: float = Field(default=600.0, ge=0) plugin_daemon_write_timeout: float = Field(default=30.0, ge=0) diff --git a/dify-agent/tests/local/agenton/compositor/test_direct_deps.py b/dify-agent/tests/local/agenton/compositor/test_direct_deps.py index d89d4bb7e2..b7ae26ec5c 100644 --- a/dify-agent/tests/local/agenton/compositor/test_direct_deps.py +++ b/dify-agent/tests/local/agenton/compositor/test_direct_deps.py @@ -113,6 +113,16 @@ def test_undefined_dependency_target_is_rejected_for_compositor_construction() - Compositor([LayerNode("consumer", RenamedConsumerLayer, deps={"renamed": "missing_target"})]) +def test_dependency_target_must_precede_dependent_layer_in_graph_order() -> None: + with pytest.raises(ValueError, match="must target preceding layer nodes"): + Compositor( + [ + LayerNode("consumer", RenamedConsumerLayer, deps={"renamed": "target"}), + LayerNode("target", _object_provider("value")), + ] + ) + + def test_duplicate_layer_node_name_is_rejected() -> None: with pytest.raises(ValueError, match="Duplicate layer name 'same'"): Compositor( diff --git a/dify-agent/tests/local/agenton/compositor/test_enter.py b/dify-agent/tests/local/agenton/compositor/test_enter.py index 154bc8b0d7..d22b91a94a 100644 --- a/dify-agent/tests/local/agenton/compositor/test_enter.py +++ b/dify-agent/tests/local/agenton/compositor/test_enter.py @@ -1,5 +1,6 @@ import asyncio -from collections.abc import Iterator +from collections.abc import AsyncGenerator, Iterator +from contextlib import asynccontextmanager from dataclasses import dataclass, field from itertools import count @@ -388,6 +389,586 @@ def test_closed_snapshot_enter_is_rejected_before_hooks_run() -> None: assert created_layers[0].events == [] +class ResourceState(BaseModel): + created_with_resource: bool = False + deleted_with_resource: bool = False + saw_dependency_resource: bool = False + + model_config = ConfigDict(extra="forbid", validate_assignment=True) + + +@dataclass(slots=True) +class ParentResourceLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, ResourceState]): + events: list[str] = field(default_factory=list) + live_resource: object | None = None + + @override + @asynccontextmanager + async def resource_context(self) -> AsyncGenerator[None]: + self.events.append("parent.resource.enter") + self.live_resource = object() + try: + yield + finally: + self.events.append("parent.resource.exit") + self.live_resource = None + + @override + async def on_context_create(self) -> None: + assert self.live_resource is not None + self.events.append("parent.create") + self.runtime_state.created_with_resource = True + + @override + async def on_context_delete(self) -> None: + assert self.live_resource is not None + self.events.append("parent.delete") + self.runtime_state.deleted_with_resource = True + + @override + async def on_context_suspend(self) -> None: + assert self.live_resource is not None + self.events.append("parent.suspend") + + +class ChildResourceDeps(NoLayerDeps): + parent: ParentResourceLayer # pyright: ignore[reportUninitializedInstanceVariable] + + +@dataclass(slots=True) +class ChildResourceLayer(PlainLayer[ChildResourceDeps, EmptyLayerConfig, ResourceState]): + events: list[str] = field(default_factory=list) + live_resource: object | None = None + + @override + @asynccontextmanager + async def resource_context(self) -> AsyncGenerator[None]: + self.events.append("child.resource.enter") + self.live_resource = object() + try: + yield + finally: + self.events.append("child.resource.exit") + self.live_resource = None + + @override + async def on_context_create(self) -> None: + assert self.live_resource is not None + self.events.append("child.create") + self.runtime_state.created_with_resource = True + self.runtime_state.saw_dependency_resource = self.deps.parent.live_resource is not None + + @override + async def on_context_delete(self) -> None: + assert self.live_resource is not None + self.events.append("child.delete") + self.runtime_state.deleted_with_resource = True + + +@dataclass(slots=True) +class CreateFailureResourceLayer(PlainLayer[NoLayerDeps]): + events: list[str] = field(default_factory=list) + live_resource: bool = False + + @override + @asynccontextmanager + async def resource_context(self) -> AsyncGenerator[None]: + self.events.append("resource.enter") + self.live_resource = True + try: + yield + finally: + self.events.append("resource.exit") + self.live_resource = False + + @override + async def on_context_create(self) -> None: + assert self.live_resource is True + self.events.append("create") + raise RuntimeError("create failed") + + @override + async def on_context_delete(self) -> None: + self.events.append("delete") + + +@dataclass(slots=True) +class DeleteFailureResourceLayer(PlainLayer[NoLayerDeps]): + events: list[str] = field(default_factory=list) + live_resource: bool = False + + @override + @asynccontextmanager + async def resource_context(self) -> AsyncGenerator[None]: + self.events.append("resource.enter") + self.live_resource = True + try: + yield + finally: + self.events.append("resource.exit") + self.live_resource = False + + @override + async def on_context_create(self) -> None: + assert self.live_resource is True + self.events.append("create") + + @override + async def on_context_delete(self) -> None: + assert self.live_resource is True + self.events.append("delete") + raise RuntimeError("delete failed") + + +class ResumeResourceState(BaseModel): + created_with_resource: bool = False + resumed_with_resource: bool = False + suspended_with_resource: bool = False + deleted_with_resource: bool = False + + model_config = ConfigDict(extra="forbid", validate_assignment=True) + + +@dataclass(slots=True) +class SuspendResumeResourceLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, ResumeResourceState]): + events: list[str] + next_resource_id: Iterator[int] + live_resource: str | None = None + + @override + @asynccontextmanager + async def resource_context(self) -> AsyncGenerator[None]: + self.live_resource = f"resource-{next(self.next_resource_id)}" + self.events.append(f"resource.enter:{self.live_resource}") + try: + yield + finally: + assert self.live_resource is not None + self.events.append(f"resource.exit:{self.live_resource}") + self.live_resource = None + + @override + async def on_context_create(self) -> None: + assert self.live_resource is not None + self.events.append(f"create:{self.live_resource}") + self.runtime_state.created_with_resource = True + + @override + async def on_context_resume(self) -> None: + assert self.live_resource is not None + self.events.append(f"resume:{self.live_resource}") + self.runtime_state.resumed_with_resource = True + + @override + async def on_context_suspend(self) -> None: + assert self.live_resource is not None + self.events.append(f"suspend:{self.live_resource}") + self.runtime_state.suspended_with_resource = True + + @override + async def on_context_delete(self) -> None: + assert self.live_resource is not None + self.events.append(f"delete:{self.live_resource}") + self.runtime_state.deleted_with_resource = True + + +class CreateFailureChildResourceLayer(ChildResourceLayer): + @override + async def on_context_create(self) -> None: + assert self.live_resource is not None + self.events.append("child.create") + raise RuntimeError("child create failed") + + +class SuspendFailureChildResourceLayer(ChildResourceLayer): + @override + async def on_context_suspend(self) -> None: + assert self.live_resource is not None + self.events.append("child.suspend") + raise RuntimeError("child suspend failed") + + +def test_resource_context_wraps_hooks_and_body_in_dependency_order() -> None: + events: list[str] = [] + compositor = Compositor( + [ + LayerNode( + "parent", + LayerProvider.from_factory( + layer_type=ParentResourceLayer, + create=lambda config: ParentResourceLayer(events), + ), + ), + LayerNode( + "child", + LayerProvider.from_factory( + layer_type=ChildResourceLayer, + create=lambda config: ChildResourceLayer(events), + ), + deps={"parent": "parent"}, + ), + ] + ) + + async def run() -> CompositorSessionSnapshot: + async with compositor.enter() as active_run: + parent = active_run.get_layer("parent", ParentResourceLayer) + child = active_run.get_layer("child", ChildResourceLayer) + assert parent.live_resource is not None + assert child.live_resource is not None + assert child.deps.parent is parent + events.append("body") + assert active_run.session_snapshot is not None + assert parent.live_resource is None + assert child.live_resource is None + return active_run.session_snapshot + + snapshot = asyncio.run(run()) + + assert events == [ + "parent.resource.enter", + "parent.create", + "child.resource.enter", + "child.create", + "body", + "child.delete", + "child.resource.exit", + "parent.delete", + "parent.resource.exit", + ] + assert snapshot.model_dump(mode="json") == { + "schema_version": 1, + "layers": [ + { + "name": "parent", + "lifecycle_state": "closed", + "runtime_state": { + "created_with_resource": True, + "deleted_with_resource": True, + "saw_dependency_resource": False, + }, + }, + { + "name": "child", + "lifecycle_state": "closed", + "runtime_state": { + "created_with_resource": True, + "deleted_with_resource": True, + "saw_dependency_resource": True, + }, + }, + ], + } + + +def test_resource_context_wraps_resume_and_suspend_with_fresh_resource_scope() -> None: + events: list[str] = [] + resource_ids = count(1) + created_layers: list[SuspendResumeResourceLayer] = [] + + def create_layer(config: EmptyLayerConfig) -> SuspendResumeResourceLayer: + layer = SuspendResumeResourceLayer(events=events, next_resource_id=resource_ids) + created_layers.append(layer) + return layer + + compositor = Compositor( + [LayerNode("trace", LayerProvider.from_factory(layer_type=SuspendResumeResourceLayer, create=create_layer))] + ) + + async def run() -> tuple[CompositorSessionSnapshot, CompositorSessionSnapshot]: + async with compositor.enter() as first_run: + first_layer = first_run.get_layer("trace", SuspendResumeResourceLayer) + assert first_layer.live_resource == "resource-1" + first_run.suspend_on_exit() + assert first_run.session_snapshot is not None + + async with compositor.enter(session_snapshot=first_run.session_snapshot) as resumed_run: + resumed_layer = resumed_run.get_layer("trace", SuspendResumeResourceLayer) + assert resumed_layer.live_resource == "resource-2" + assert resumed_layer.live_resource != "resource-1" + assert resumed_run.session_snapshot is not None + return first_run.session_snapshot, resumed_run.session_snapshot + + suspended_snapshot, resumed_snapshot = asyncio.run(run()) + + assert len(created_layers) == 2 + assert all(layer.live_resource is None for layer in created_layers) + assert events == [ + "resource.enter:resource-1", + "create:resource-1", + "suspend:resource-1", + "resource.exit:resource-1", + "resource.enter:resource-2", + "resume:resource-2", + "delete:resource-2", + "resource.exit:resource-2", + ] + assert suspended_snapshot.model_dump(mode="json") == { + "schema_version": 1, + "layers": [ + { + "name": "trace", + "lifecycle_state": "suspended", + "runtime_state": { + "created_with_resource": True, + "resumed_with_resource": False, + "suspended_with_resource": True, + "deleted_with_resource": False, + }, + } + ], + } + assert resumed_snapshot.model_dump(mode="json") == { + "schema_version": 1, + "layers": [ + { + "name": "trace", + "lifecycle_state": "closed", + "runtime_state": { + "created_with_resource": True, + "resumed_with_resource": True, + "suspended_with_resource": True, + "deleted_with_resource": True, + }, + } + ], + } + + +def test_resource_context_exits_when_run_body_raises() -> None: + events: list[str] = [] + created_layers: list[ParentResourceLayer | ChildResourceLayer] = [] + + def create_parent(config: EmptyLayerConfig) -> ParentResourceLayer: + layer = ParentResourceLayer(events) + created_layers.append(layer) + return layer + + def create_child(config: EmptyLayerConfig) -> ChildResourceLayer: + layer = ChildResourceLayer(events) + created_layers.append(layer) + return layer + + compositor = Compositor( + [ + LayerNode("parent", LayerProvider.from_factory(layer_type=ParentResourceLayer, create=create_parent)), + LayerNode( + "child", + LayerProvider.from_factory(layer_type=ChildResourceLayer, create=create_child), + deps={"parent": "parent"}, + ), + ] + ) + + async def run() -> None: + async with compositor.enter(): + events.append("body") + raise RuntimeError("body failed") + + with pytest.raises(RuntimeError, match="body failed"): + asyncio.run(run()) + + assert [layer.live_resource for layer in created_layers] == [None, None] + assert events == [ + "parent.resource.enter", + "parent.create", + "child.resource.enter", + "child.create", + "body", + "child.delete", + "child.resource.exit", + "parent.delete", + "parent.resource.exit", + ] + + +def test_resource_context_exits_when_run_body_is_cancelled() -> None: + events: list[str] = [] + created_layers: list[ParentResourceLayer | ChildResourceLayer] = [] + + def create_parent(config: EmptyLayerConfig) -> ParentResourceLayer: + layer = ParentResourceLayer(events) + created_layers.append(layer) + return layer + + def create_child(config: EmptyLayerConfig) -> ChildResourceLayer: + layer = ChildResourceLayer(events) + created_layers.append(layer) + return layer + + compositor = Compositor( + [ + LayerNode("parent", LayerProvider.from_factory(layer_type=ParentResourceLayer, create=create_parent)), + LayerNode( + "child", + LayerProvider.from_factory(layer_type=ChildResourceLayer, create=create_child), + deps={"parent": "parent"}, + ), + ] + ) + + async def run() -> None: + async with compositor.enter(): + events.append("body") + task = asyncio.current_task() + assert task is not None + task.cancel() + await asyncio.sleep(0) + + with pytest.raises(asyncio.CancelledError): + asyncio.run(run()) + + assert [layer.live_resource for layer in created_layers] == [None, None] + assert events == [ + "parent.resource.enter", + "parent.create", + "child.resource.enter", + "child.create", + "body", + "child.delete", + "child.resource.exit", + "parent.delete", + "parent.resource.exit", + ] + + +def test_dependency_resource_contexts_exit_when_child_create_fails() -> None: + events: list[str] = [] + created_layers: list[ParentResourceLayer | CreateFailureChildResourceLayer] = [] + + def create_parent(config: EmptyLayerConfig) -> ParentResourceLayer: + layer = ParentResourceLayer(events) + created_layers.append(layer) + return layer + + def create_child(config: EmptyLayerConfig) -> CreateFailureChildResourceLayer: + layer = CreateFailureChildResourceLayer(events) + created_layers.append(layer) + return layer + + compositor = Compositor( + [ + LayerNode("parent", LayerProvider.from_factory(layer_type=ParentResourceLayer, create=create_parent)), + LayerNode( + "child", + LayerProvider.from_factory(layer_type=CreateFailureChildResourceLayer, create=create_child), + deps={"parent": "parent"}, + ), + ] + ) + + with pytest.raises(RuntimeError, match="child create failed"): + asyncio.run(_enter_once(compositor)) + + assert [layer.live_resource for layer in created_layers] == [None, None] + assert events == [ + "parent.resource.enter", + "parent.create", + "child.resource.enter", + "child.create", + "child.resource.exit", + "parent.delete", + "parent.resource.exit", + ] + + +def test_dependency_resource_contexts_exit_when_child_suspend_fails() -> None: + events: list[str] = [] + created_layers: list[ParentResourceLayer | SuspendFailureChildResourceLayer] = [] + + def create_parent(config: EmptyLayerConfig) -> ParentResourceLayer: + layer = ParentResourceLayer(events) + created_layers.append(layer) + return layer + + def create_child(config: EmptyLayerConfig) -> SuspendFailureChildResourceLayer: + layer = SuspendFailureChildResourceLayer(events) + created_layers.append(layer) + return layer + + compositor = Compositor( + [ + LayerNode("parent", LayerProvider.from_factory(layer_type=ParentResourceLayer, create=create_parent)), + LayerNode( + "child", + LayerProvider.from_factory(layer_type=SuspendFailureChildResourceLayer, create=create_child), + deps={"parent": "parent"}, + ), + ] + ) + + async def run() -> None: + async with compositor.enter() as active_run: + events.append("body") + active_run.suspend_on_exit() + + with pytest.raises(RuntimeError, match="child suspend failed"): + asyncio.run(run()) + + assert [layer.live_resource for layer in created_layers] == [None, None] + assert events == [ + "parent.resource.enter", + "parent.create", + "child.resource.enter", + "child.create", + "body", + "child.suspend", + "child.resource.exit", + "parent.suspend", + "parent.resource.exit", + ] + + +def test_resource_context_exits_when_create_hook_raises() -> None: + created_layers: list[CreateFailureResourceLayer] = [] + + def create_layer(config: EmptyLayerConfig) -> CreateFailureResourceLayer: + layer = CreateFailureResourceLayer() + created_layers.append(layer) + return layer + + compositor = Compositor( + [ + LayerNode( + "trace", + LayerProvider.from_factory(layer_type=CreateFailureResourceLayer, create=create_layer), + ) + ] + ) + + with pytest.raises(RuntimeError, match="create failed"): + asyncio.run(_enter_once(compositor)) + + assert len(created_layers) == 1 + assert created_layers[0].events == ["resource.enter", "create", "resource.exit"] + assert created_layers[0].live_resource is False + + +def test_resource_context_exits_when_delete_hook_raises() -> None: + deleted_layers: list[DeleteFailureResourceLayer] = [] + + def create_layer(config: EmptyLayerConfig) -> DeleteFailureResourceLayer: + layer = DeleteFailureResourceLayer() + deleted_layers.append(layer) + return layer + + compositor = Compositor( + [ + LayerNode( + "trace", + LayerProvider.from_factory(layer_type=DeleteFailureResourceLayer, create=create_layer), + ) + ] + ) + + with pytest.raises(RuntimeError, match="delete failed"): + asyncio.run(_enter_once(compositor)) + + assert len(deleted_layers) == 1 + assert deleted_layers[0].events == ["resource.enter", "create", "delete", "resource.exit"] + assert deleted_layers[0].live_resource is False + + async def _enter_once( compositor: Compositor, *, diff --git a/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py b/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py new file mode 100644 index 0000000000..ec1810c636 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py @@ -0,0 +1,20 @@ +import pytest +from pydantic import ValidationError + +import dify_agent.layers.shell as shell_exports +from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig + + +def test_shell_package_exports_client_safe_config_symbols_only() -> None: + assert shell_exports.__all__ == ["DIFY_SHELL_LAYER_TYPE_ID", "DifyShellLayerConfig"] + assert DIFY_SHELL_LAYER_TYPE_ID == "dify.shell" + assert not hasattr(shell_exports, "DifyShellLayer") + + +def test_shell_layer_config_is_empty_and_forbids_unknown_fields() -> None: + config = DifyShellLayerConfig() + + assert config.model_dump() == {} + + with pytest.raises(ValidationError): + _ = DifyShellLayerConfig.model_validate({"entrypoint": "http://shellctl"}) 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 new file mode 100644 index 0000000000..a2ab4e435c --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py @@ -0,0 +1,588 @@ +import asyncio +from collections.abc import Callable +import secrets +import time +from dataclasses import dataclass + +import pytest + +from agenton.compositor import Compositor, LayerNode, LayerProvider +from agenton.layers import LifecycleState +from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig +from dify_agent.layers.shell.layer import DifyShellLayer, DifyShellRuntimeState, ShellctlClientFactory +from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, JobStatusName, JobStatusView + + +def _job_result( + job_id: str, + *, + status: JobStatusName = JobStatusName.RUNNING, + done: bool = False, + exit_code: int | None = None, + output: str = "", + offset: int = 0, + truncated: bool = False, + output_path: str = "/tmp/output.log", +) -> JobResult: + return JobResult( + job_id=job_id, + status=status, + done=done, + exit_code=exit_code, + output=output, + offset=offset, + truncated=truncated, + output_path=output_path, + ) + + +def _job_status( + job_id: str, + *, + status: JobStatusName = JobStatusName.RUNNING, + done: bool = False, + exit_code: int | None = None, + offset: int = 0, +) -> JobStatusView: + return JobStatusView( + job_id=job_id, + status=status, + done=done, + exit_code=exit_code, + created_at="2026-05-28T12:00:00Z", + started_at="2026-05-28T12:00:01Z", + ended_at="2026-05-28T12:00:02Z" if done else None, + offset=offset, + ) + + +def _assert_error_observation(result: object, *, job_id: str | None = None, includes: str | None = None) -> None: + assert isinstance(result, dict) + assert isinstance(result.get("error"), str) + assert result["error"] + if job_id is None: + assert "job_id" not in result + else: + assert result.get("job_id") == job_id + if includes is not None: + assert includes in result["error"] + + +@dataclass(slots=True) +class RunCall: + script: str + cwd: str | None + timeout: float + + +@dataclass(slots=True) +class WaitCall: + job_id: str + offset: int + timeout: float + + +@dataclass(slots=True) +class InputCall: + job_id: str + text: str + offset: int + timeout: float + + +@dataclass(slots=True) +class TerminateCall: + job_id: str + grace_seconds: float + + +@dataclass(slots=True) +class DeleteCall: + job_id: str + force: bool + grace_seconds: float | None + + +class FakeShellctlClient: + run_calls: list[RunCall] + wait_calls: list[WaitCall] + input_calls: list[InputCall] + terminate_calls: list[TerminateCall] + delete_calls: list[DeleteCall] + events: list[tuple[str, str]] + closed: bool + + def __init__( + self, + *, + run_handler: Callable[[str, str | None, float], JobResult] | None = None, + wait_handler: Callable[[str, int, float], JobResult] | None = None, + input_handler: Callable[[str, str, int, float], JobResult] | None = None, + terminate_handler: Callable[[str, float], JobStatusView] | None = None, + delete_handler: Callable[[str, bool, float | None], DeleteJobResponse] | None = None, + ) -> None: + self._run_handler = run_handler + self._wait_handler = wait_handler + self._input_handler = input_handler + self._terminate_handler = terminate_handler + self._delete_handler = delete_handler + self.run_calls = [] + self.wait_calls = [] + self.input_calls = [] + self.terminate_calls = [] + self.delete_calls = [] + self.events = [] + self.closed = False + + async def run(self, script: str, *, cwd: str | None = None, timeout: float = 10.0) -> JobResult: + self.run_calls.append(RunCall(script=script, cwd=cwd, timeout=timeout)) + self.events.append(("run", script)) + if self._run_handler is None: + raise AssertionError("Unexpected run() call") + return self._run_handler(script, cwd, timeout) + + async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0) -> JobResult: + self.wait_calls.append(WaitCall(job_id=job_id, offset=offset, timeout=timeout)) + self.events.append(("wait", job_id)) + if self._wait_handler is None: + raise AssertionError("Unexpected wait() call") + return self._wait_handler(job_id, offset, timeout) + + async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0) -> JobResult: + self.input_calls.append(InputCall(job_id=job_id, text=text, offset=offset, timeout=timeout)) + self.events.append(("input", job_id)) + if self._input_handler is None: + raise AssertionError("Unexpected input() call") + return self._input_handler(job_id, text, offset, timeout) + + async def terminate(self, job_id: str, grace_seconds: float = 2.0) -> JobStatusView: + self.terminate_calls.append(TerminateCall(job_id=job_id, grace_seconds=grace_seconds)) + self.events.append(("terminate", job_id)) + if self._terminate_handler is None: + raise AssertionError("Unexpected terminate() call") + return self._terminate_handler(job_id, grace_seconds) + + async def delete( + self, + job_id: str, + *, + force: bool = False, + grace_seconds: float | None = None, + ) -> DeleteJobResponse: + self.delete_calls.append(DeleteCall(job_id=job_id, force=force, grace_seconds=grace_seconds)) + self.events.append(("delete", job_id)) + if self._delete_handler is None: + return DeleteJobResponse(job_id=job_id) + return self._delete_handler(job_id, force, grace_seconds) + + async def close(self) -> None: + self.closed = True + self.events.append(("close", "client")) + + +def _shell_layer(*, client_factory: ShellctlClientFactory) -> DifyShellLayer: + return DifyShellLayer.from_config_with_settings( + DifyShellLayerConfig(), + shellctl_entrypoint="http://shellctl", + shellctl_client_factory=client_factory, + ) + + +def _shell_provider(*, client_factory: ShellctlClientFactory) -> LayerProvider[DifyShellLayer]: + return LayerProvider.from_factory( + layer_type=DifyShellLayer, + create=lambda config: DifyShellLayer.from_config_with_settings( + DifyShellLayerConfig.model_validate(config), + shellctl_entrypoint="http://shellctl", + shellctl_client_factory=client_factory, + ), + ) + + +def test_shell_type_id_constant_matches_implementation_class() -> None: + assert DIFY_SHELL_LAYER_TYPE_ID == DifyShellLayer.type_id + + +def test_shell_layer_create_generates_5_plus_2_hex_session_id_and_retries_workspace_collision( + monkeypatch: pytest.MonkeyPatch, +) -> None: + random_suffixes = iter(["aa", "bb"]) + monkeypatch.setattr(time, "time", lambda: 0x12345F) + monkeypatch.setattr(secrets, "token_hex", lambda nbytes: next(random_suffixes)) + + def run_handler(script: str, cwd: str | None, timeout: float) -> JobResult: + assert cwd is None + assert timeout == 30.0 + if "2345faa" in script: + return _job_result("mkdir-collision", status=JobStatusName.EXITED, done=True, exit_code=17) + if "2345fbb" in script: + return _job_result("mkdir-success", status=JobStatusName.RUNNING, done=False, offset=4) + raise AssertionError(f"Unexpected script: {script}") + + def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult: + assert job_id == "mkdir-success" + assert offset == 4 + assert timeout == 30.0 + return _job_result("mkdir-success", status=JobStatusName.EXITED, done=True, exit_code=0, offset=8) + + client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler) + layer = _shell_layer(client_factory=lambda _entrypoint: client) + + async def scenario() -> None: + async with layer.resource_context(): + await layer.on_context_create() + assert client.closed is False + + asyncio.run(scenario()) + + assert layer.runtime_state.session_id == "2345fbb" + assert layer.runtime_state.workspace_cwd == "~/workspace/2345fbb" + assert layer.runtime_state.job_ids == ["mkdir-collision", "mkdir-success"] + assert layer.runtime_state.job_offsets == {"mkdir-collision": 0, "mkdir-success": 8} + assert 'mkdir "$HOME/workspace/2345fbb"' in client.run_calls[1].script + assert 'mkdir -p "$HOME/workspace/2345fbb"' not in client.run_calls[1].script + assert client.closed is True + + +def test_shell_layer_suspend_leaves_client_open_until_resource_context_exits() -> None: + client = FakeShellctlClient() + layer = _shell_layer(client_factory=lambda _entrypoint: client) + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + + async def scenario() -> None: + async with layer.resource_context(): + await layer.on_context_suspend() + assert client.closed is False + + asyncio.run(scenario()) + + assert client.closed is True + + +def test_shell_layer_suspend_and_resume_reuse_state_with_fresh_clients() -> None: + first_client = FakeShellctlClient( + run_handler=lambda _script, _cwd, _timeout: _job_result( + "mkdir-job", + status=JobStatusName.EXITED, + done=True, + exit_code=0, + ) + ) + second_client = FakeShellctlClient() + created_entrypoints: list[str] = [] + clients = iter([first_client, second_client]) + + def factory(entrypoint: str) -> FakeShellctlClient: + created_entrypoints.append(entrypoint) + return next(clients) + + compositor = Compositor([LayerNode("shell", _shell_provider(client_factory=factory))]) + async def scenario() -> None: + async with compositor.enter(configs={"shell": DifyShellLayerConfig()}) as run: + shell_layer = run.get_layer("shell", DifyShellLayer) + initial_session_id = shell_layer.runtime_state.session_id + assert initial_session_id is not None + assert shell_layer.runtime_state.workspace_cwd == f"~/workspace/{initial_session_id}" + shell_layer.runtime_state.job_ids = [*shell_layer.runtime_state.job_ids, "user-job"] + shell_layer.runtime_state.job_offsets = { + **shell_layer.runtime_state.job_offsets, + "user-job": 42, + } + assert first_client.closed is False + run.suspend_layer_on_exit("shell") + + assert run.session_snapshot is not None + assert first_client.closed is True + assert run.session_snapshot.layers[0].lifecycle_state is LifecycleState.SUSPENDED + + async with compositor.enter( + configs={"shell": DifyShellLayerConfig()}, + session_snapshot=run.session_snapshot, + ) as resumed_run: + resumed_shell = resumed_run.get_layer("shell", DifyShellLayer) + assert second_client.closed is False + assert resumed_shell.runtime_state.session_id == initial_session_id + assert resumed_shell.runtime_state.workspace_cwd == f"~/workspace/{initial_session_id}" + assert set(resumed_shell.runtime_state.job_ids) == {"mkdir-job", "user-job"} + assert resumed_shell.runtime_state.job_offsets == {"mkdir-job": 0, "user-job": 42} + resumed_run.suspend_layer_on_exit("shell") + + assert second_client.closed is True + + asyncio.run(scenario()) + + assert created_entrypoints == ["http://shellctl", "http://shellctl"] + + +def test_shell_layer_delete_removes_workspace_then_force_deletes_tracked_jobs_and_closes_client() -> None: + def run_handler(script: str, cwd: str | None, timeout: float) -> JobResult: + assert script == 'rm -rf -- "$HOME/workspace/abc12ff"' + assert cwd is None + assert timeout == 30.0 + return _job_result("cleanup-job", status=JobStatusName.RUNNING, done=False, offset=3) + + def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult: + assert job_id == "cleanup-job" + assert offset == 3 + assert timeout == 30.0 + return _job_result("cleanup-job", status=JobStatusName.EXITED, done=True, exit_code=0, offset=5) + + client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler) + layer = _shell_layer(client_factory=lambda _entrypoint: client) + + async def scenario() -> None: + async with layer.resource_context(): + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + layer.runtime_state.job_ids = ["user-job", "mkdir-job"] + layer.runtime_state.job_offsets = {"user-job": 9, "mkdir-job": 1} + await layer.on_context_delete() + assert client.closed is False + + asyncio.run(scenario()) + + assert client.events[:2] == [("run", 'rm -rf -- "$HOME/workspace/abc12ff"'), ("wait", "cleanup-job")] + assert {call.job_id for call in client.delete_calls} == {"user-job", "mkdir-job", "cleanup-job"} + assert all(client.events.index(("delete", call.job_id)) > client.events.index(("wait", "cleanup-job")) for call in client.delete_calls) + assert all(call.force is True for call in client.delete_calls) + assert layer.runtime_state.job_ids == [] + assert layer.runtime_state.job_offsets == {} + assert client.closed is True + + +def test_shell_layer_create_failure_force_deletes_internal_jobs_before_reraising() -> None: + client = FakeShellctlClient( + run_handler=lambda _script, _cwd, _timeout: _job_result( + "mkdir-failed", + status=JobStatusName.EXITED, + done=True, + exit_code=1, + ) + ) + layer = _shell_layer(client_factory=lambda _entrypoint: client) + + async def scenario() -> None: + with pytest.raises(RuntimeError, match="Failed to create shell workspace"): + async with layer.resource_context(): + await layer.on_context_create() + + asyncio.run(scenario()) + + assert [call.job_id for call in client.delete_calls] == ["mkdir-failed"] + assert all(call.force is True for call in client.delete_calls) + assert layer.runtime_state.job_ids == [] + assert layer.runtime_state.job_offsets == {} + assert client.closed is True + + +def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -> None: + def run_handler(script: str, cwd: str | None, timeout: float) -> JobResult: + assert script == "pwd" + assert cwd == "~/workspace/abc12ff" + assert timeout == 2.5 + return _job_result( + "user-job", + status=JobStatusName.RUNNING, + done=False, + offset=10, + output="/home/test\n", + ) + + def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult: + assert job_id == "user-job" + assert offset == 10 + assert timeout == 4.0 + return _job_result( + "user-job", + status=JobStatusName.RUNNING, + done=False, + offset=18, + output="more\n", + ) + + def input_handler(job_id: str, text: str, offset: int, timeout: float) -> JobResult: + assert job_id == "user-job" + assert text == "ls\n" + assert offset == 18 + assert timeout == 5.0 + return _job_result( + "user-job", + status=JobStatusName.EXITED, + done=True, + exit_code=0, + offset=22, + output="file.txt\n", + ) + + def terminate_handler(job_id: str, grace_seconds: float) -> JobStatusView: + assert job_id == "user-job" + assert grace_seconds == 1.5 + return _job_status( + "user-job", + status=JobStatusName.TERMINATED, + done=True, + exit_code=130, + offset=22, + ) + + client = FakeShellctlClient( + run_handler=run_handler, + wait_handler=wait_handler, + input_handler=input_handler, + terminate_handler=terminate_handler, + ) + layer = _shell_layer(client_factory=lambda _entrypoint: client) + tools = {tool.name: tool for tool in layer.tools} + + async def scenario() -> None: + async with layer.resource_context(): + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + + run_tool_def = await tools["shell.run"].prepare_tool_def(None) # pyright: ignore[reportArgumentType] + wait_tool_def = await tools["shell.wait"].prepare_tool_def(None) # pyright: ignore[reportArgumentType] + input_tool_def = await tools["shell.input"].prepare_tool_def(None) # pyright: ignore[reportArgumentType] + interrupt_tool_def = await tools["shell.interrupt"].prepare_tool_def(None) # pyright: ignore[reportArgumentType] + + run_result = await tools["shell.run"].function_schema.call( + {"script": "pwd", "timeout": 2.5}, + None, # pyright: ignore[reportArgumentType] + ) + wait_result = await tools["shell.wait"].function_schema.call( + {"job_id": "user-job", "timeout": 4.0}, + None, # pyright: ignore[reportArgumentType] + ) + input_result = await tools["shell.input"].function_schema.call( + {"job_id": "user-job", "text": "ls\n", "timeout": 5.0}, + None, # pyright: ignore[reportArgumentType] + ) + interrupt_result = await tools["shell.interrupt"].function_schema.call( + {"job_id": "user-job", "grace_seconds": 1.5}, + None, # pyright: ignore[reportArgumentType] + ) + + assert run_tool_def is not None + assert wait_tool_def is not None + assert input_tool_def is not None + assert interrupt_tool_def is not None + assert "offset" not in run_tool_def.parameters_json_schema.get("properties", {}) + assert "offset" not in wait_tool_def.parameters_json_schema.get("properties", {}) + assert "offset" not in input_tool_def.parameters_json_schema.get("properties", {}) + assert "offset" not in interrupt_tool_def.parameters_json_schema.get("properties", {}) + assert set(tools) == {"shell.run", "shell.wait", "shell.input", "shell.interrupt"} + assert run_result["job_id"] == "user-job" + assert run_result["offset"] == 10 + assert wait_result["offset"] == 18 + assert input_result["offset"] == 22 + assert interrupt_result == { + "job_id": "user-job", + "status": "terminated", + "done": True, + "exit_code": 130, + "offset": 22, + } + assert client.closed is False + + asyncio.run(scenario()) + + assert layer.runtime_state.job_ids == ["user-job"] + assert layer.runtime_state.job_offsets == {"user-job": 22} + assert client.closed is True + + +def test_shell_layer_tools_reject_untracked_job_ids_without_shellctl_calls() -> None: + client = FakeShellctlClient() + layer = _shell_layer(client_factory=lambda _entrypoint: client) + tools = {tool.name: tool for tool in layer.tools} + + async def scenario() -> None: + async with layer.resource_context(): + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + + wait_result = await tools["shell.wait"].function_schema.call( + {"job_id": "missing-job"}, + None, # pyright: ignore[reportArgumentType] + ) + input_result = await tools["shell.input"].function_schema.call( + {"job_id": "missing-job", "text": "hello"}, + None, # pyright: ignore[reportArgumentType] + ) + interrupt_result = await tools["shell.interrupt"].function_schema.call( + {"job_id": "missing-job"}, + None, # pyright: ignore[reportArgumentType] + ) + + _assert_error_observation(wait_result, job_id="missing-job") + _assert_error_observation(input_result, job_id="missing-job") + _assert_error_observation(interrupt_result, job_id="missing-job") + + asyncio.run(scenario()) + + assert client.wait_calls == [] + assert client.input_calls == [] + assert client.terminate_calls == [] + + +def test_shell_layer_hooks_and_tools_fail_clearly_outside_active_resource_context() -> None: + client = FakeShellctlClient() + layer = _shell_layer(client_factory=lambda _entrypoint: client) + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + tools = {tool.name: tool for tool in layer.tools} + + async def scenario() -> None: + with pytest.raises(RuntimeError, match="resource_context"): + await layer.on_context_suspend() + + run_result = await tools["shell.run"].function_schema.call( + {"script": "pwd"}, + None, # pyright: ignore[reportArgumentType] + ) + _assert_error_observation(run_result, includes="resource_context") + + asyncio.run(scenario()) + + assert client.run_calls == [] + + +def test_shell_runtime_state_rejects_unsafe_resumed_workspace_identity() -> None: + with pytest.raises(ValueError, match="session_id must match"): + _ = DifyShellRuntimeState.model_validate( + { + "session_id": "../../tmp", + "workspace_cwd": "~/workspace/../../tmp", + "job_ids": [], + "job_offsets": {}, + } + ) + + with pytest.raises(ValueError, match="workspace_cwd must equal"): + _ = DifyShellRuntimeState.model_validate( + { + "session_id": "abc12ff", + "workspace_cwd": "~/workspace/def34aa", + "job_ids": [], + "job_offsets": {}, + } + ) + + +def test_shell_runtime_state_treats_job_ids_as_opaque_strings_and_rejects_unknown_offset_keys() -> None: + state = DifyShellRuntimeState.model_validate( + { + "session_id": "abc12ff", + "workspace_cwd": "~/workspace/abc12ff", + "job_ids": ['job"bad with spaces'], + "job_offsets": {'job"bad with spaces': 0}, + } + ) + + assert state.job_ids == ['job"bad with spaces'] + assert state.job_offsets == {'job"bad with spaces': 0} + + with pytest.raises(ValueError, match="unknown job ids"): + _ = DifyShellRuntimeState.model_validate( + { + "session_id": "abc12ff", + "workspace_cwd": "~/workspace/abc12ff", + "job_ids": ["job-1"], + "job_offsets": {"job-2": 3}, + } + ) diff --git a/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py b/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py new file mode 100644 index 0000000000..799ec94292 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py @@ -0,0 +1,73 @@ +import pytest + +import dify_agent.runtime.compositor_factory as compositor_factory_module +from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig +from dify_agent.layers.shell.layer import DifyShellLayer +from dify_agent.runtime.compositor_factory import create_default_layer_providers + + +class FakeFactoryClient: + async def close(self) -> None: + return None + + +def test_default_layer_providers_register_shell_layer_with_configured_token_factory( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured_tokens: list[str] = [] + captured_entrypoints: list[str] = [] + fake_client = FakeFactoryClient() + + def fake_create_shellctl_client_factory(*, token: str): + captured_tokens.append(token) + + def factory(entrypoint: str) -> FakeFactoryClient: + captured_entrypoints.append(entrypoint) + return fake_client + + return factory + + monkeypatch.setattr(compositor_factory_module, "create_shellctl_client_factory", fake_create_shellctl_client_factory) + + providers = create_default_layer_providers( + shellctl_entrypoint="http://shellctl.example", + shellctl_auth_token="shell-secret", + ) + shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID) + shell_layer = shell_provider.create_layer(DifyShellLayerConfig()) + + assert isinstance(shell_layer, DifyShellLayer) + assert shell_layer.shellctl_entrypoint == "http://shellctl.example" + assert captured_tokens == ["shell-secret"] + assert shell_layer.shellctl_client_factory(shell_layer.shellctl_entrypoint) is fake_client + assert captured_entrypoints == ["http://shellctl.example"] + + +def test_default_layer_providers_keep_empty_shellctl_token_by_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured_tokens: list[str] = [] + + def fake_create_shellctl_client_factory(*, token: str): + captured_tokens.append(token) + + def factory(_entrypoint: str) -> FakeFactoryClient: + return FakeFactoryClient() + + return factory + + monkeypatch.setattr(compositor_factory_module, "create_shellctl_client_factory", fake_create_shellctl_client_factory) + + providers = create_default_layer_providers(shellctl_entrypoint="http://shellctl.example") + shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID) + _ = shell_provider.create_layer(DifyShellLayerConfig()) + + assert captured_tokens == [""] + + +def test_shell_provider_rejects_blank_settings_entrypoint_only_when_shell_layer_is_created() -> None: + providers = create_default_layer_providers(shellctl_entrypoint=" ") + shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID) + + with pytest.raises(ValueError, match="DIFY_AGENT_SHELLCTL_ENTRYPOINT"): + _ = shell_provider.create_layer(DifyShellLayerConfig()) 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 6683f982a8..4a899f0a79 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_runner.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_runner.py @@ -25,6 +25,8 @@ from agenton.layers import ExitIntent, LifecycleState from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, PydanticAIHistoryRuntimeState from agenton_collections.layers.plain import PromptLayerConfig, ToolsLayer from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig +from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig +from dify_agent.layers.shell.layer import DifyShellLayer from dify_agent.layers.dify_plugin.configs import ( DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, DifyPluginLLMLayerConfig, @@ -48,12 +50,60 @@ from dify_agent.protocol.schemas import ( from dify_agent.runtime.event_sink import InMemoryRunEventSink from dify_agent.runtime.compositor_factory import create_default_layer_providers from dify_agent.runtime.runner import AgentRunRunner, AgentRunValidationError +from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, JobStatusName, JobStatusView class StaticToolsTestLayer(ToolsLayer): type_id: ClassVar[str] = "test.static.tools" +class FakeRunnerShellctlClient: + run_calls: list[tuple[str, str | None, float]] + closed: bool + + def __init__(self) -> None: + self.run_calls = [] + self.closed = False + + async def run(self, script: str, *, cwd: str | None = None, timeout: float = 10.0) -> JobResult: + self.run_calls.append((script, cwd, timeout)) + return JobResult( + job_id="mkdir-job", + status=JobStatusName.EXITED, + done=True, + exit_code=0, + output_path="/tmp/output.log", + output="", + offset=0, + truncated=False, + ) + + async def close(self) -> None: + self.closed = True + + async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0) -> JobResult: + del job_id, offset, timeout + raise AssertionError("wait() should not be called in this test") + + async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0) -> JobResult: + del job_id, text, offset, timeout + raise AssertionError("input() should not be called in this test") + + async def terminate(self, job_id: str, grace_seconds: float = 2.0) -> JobStatusView: + del job_id, grace_seconds + raise AssertionError("terminate() should not be called in this test") + + async def delete( + self, + job_id: str, + *, + force: bool = False, + grace_seconds: float | None = None, + ) -> DeleteJobResponse: + del job_id, force, grace_seconds + raise AssertionError("delete() should not be called in this test") + + def _request( user: str | list[str] = "hello", *, @@ -597,6 +647,116 @@ def test_runner_rejects_duplicate_tool_names_between_static_and_dynamic_tools( assert sink.statuses["run-static-dynamic-duplicate-tools"] == "failed" +def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + create_agent_called = False + shell_client = FakeRunnerShellctlClient() + + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType] + + async def fake_get_tools(_self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]: + assert http_client.is_closed is False + + async def duplicate_shell_run() -> str: + return "tool" + + return [Tool(duplicate_shell_run, name="shell.run")] + + def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> object: + del model, tools, output_type + nonlocal create_agent_called + create_agent_called = True + raise AssertionError("create_agent should not be called when duplicate tool names are detected") + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + monkeypatch.setattr(DifyPluginToolsLayer, "get_tools", fake_get_tools) + monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent) + + shell_provider = LayerProvider.from_factory( + layer_type=DifyShellLayer, + create=lambda config: DifyShellLayer.from_config_with_settings( + DifyShellLayerConfig.model_validate(config), + shellctl_entrypoint="http://shellctl", + shellctl_client_factory=lambda _entrypoint: shell_client, + ), + ) + layer_providers = tuple( + provider for provider in create_default_layer_providers(shellctl_entrypoint="http://unused") + if provider.type_id != DIFY_SHELL_LAYER_TYPE_ID + ) + (shell_provider,) + + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type="plain.prompt", + config=PromptLayerConfig(prefix="system", user="hello"), + ), + RunLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, config=DifyShellLayerConfig()), + RunLayerSpec( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type="dify.plugin.llm", + deps={"execution_context": "execution_context"}, + config=DifyPluginLLMLayerConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="demo-model", + credentials={"api_key": "secret"}, + ), + ), + RunLayerSpec( + name="tools", + type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, + deps={"execution_context": "execution_context"}, + config=DifyPluginToolsLayerConfig( + tools=[ + DifyPluginToolConfig( + plugin_id="langgenius/tools", + provider="search", + tool_name="web_search", + credential_type="api-key", + parameters=_prepared_plugin_tool_parameters(), + parameters_json_schema=_prepared_plugin_tool_schema(), + ) + ] + ), + ), + ] + ) + ) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises( + AgentRunValidationError, + match="unique tool names across all layers, got duplicates: shell.run", + ): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-shell-duplicate-tools", + plugin_daemon_http_client=client, + layer_providers=layer_providers, + ).run() + + asyncio.run(scenario()) + + assert create_agent_called is False + assert shell_client.closed is True + assert [event.type for event in sink.events["run-shell-duplicate-tools"]] == ["run_started", "run_failed"] + assert sink.statuses["run-shell-duplicate-tools"] == "failed" + + def test_runner_passes_temporary_system_prompt_prefix_without_history_layer(monkeypatch: pytest.MonkeyPatch) -> None: model = RecordingTestModel(custom_output_text="done") @@ -1433,3 +1593,123 @@ def test_runner_rejects_closed_session_snapshot_as_validation_error() -> None: assert [event.type for event in sink.events["run-closed-snapshot"]] == ["run_started", "run_failed"] assert sink.statuses["run-closed-snapshot"] == "failed" + + +def test_runner_treats_missing_shell_entrypoint_as_validation_error() -> None: + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type="plain.prompt", + config=PromptLayerConfig(prefix="system", user="hello"), + ), + RunLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, config=DifyShellLayerConfig()), + RunLayerSpec( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type="dify.plugin.llm", + deps={"execution_context": "execution_context"}, + config=DifyPluginLLMLayerConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="demo-model", + credentials={"api_key": "secret"}, + ), + ), + ] + ) + ) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(AgentRunValidationError, match="DIFY_AGENT_SHELLCTL_ENTRYPOINT"): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-missing-shell-entrypoint", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert [event.type for event in sink.events["run-missing-shell-entrypoint"]] == ["run_started", "run_failed"] + assert sink.statuses["run-missing-shell-entrypoint"] == "failed" + + +def test_runner_treats_invalid_shell_snapshot_offsets_as_validation_error() -> None: + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type="plain.prompt", + config=PromptLayerConfig(prefix="system", user="hello"), + ), + RunLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, config=DifyShellLayerConfig()), + RunLayerSpec( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type="dify.plugin.llm", + deps={"execution_context": "execution_context"}, + config=DifyPluginLLMLayerConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="demo-model", + credentials={"api_key": "secret"}, + ), + ), + ] + ), + session_snapshot=CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot(name="prompt", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), + LayerSessionSnapshot( + name="shell", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={ + "session_id": "abc12ff", + "workspace_cwd": "~/workspace/abc12ff", + "job_ids": ["job-1"], + "job_offsets": {"job-1": -1}, + }, + ), + LayerSessionSnapshot( + name="execution_context", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={}, + ), + LayerSessionSnapshot( + name=DIFY_AGENT_MODEL_LAYER_ID, + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={}, + ), + ] + ), + ) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(AgentRunValidationError, match="job_offsets"): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-invalid-shell-offset", + plugin_daemon_http_client=client, + layer_providers=create_default_layer_providers(shellctl_entrypoint="http://shellctl"), + ).run() + + asyncio.run(scenario()) + + assert [event.type for event in sink.events["run-invalid-shell-offset"]] == ["run_started", "run_failed"] + assert sink.statuses["run-invalid-shell-offset"] == "failed" diff --git a/dify-agent/tests/local/dify_agent/server/test_app.py b/dify-agent/tests/local/dify_agent/server/test_app.py index a0415058d4..2ed57a963c 100644 --- a/dify-agent/tests/local/dify_agent/server/test_app.py +++ b/dify-agent/tests/local/dify_agent/server/test_app.py @@ -1,13 +1,17 @@ from __future__ import annotations +import asyncio from typing import ClassVar import pytest from fastapi.testclient import TestClient +from shell_session_manager.shellctl.client import ShellctlClient import dify_agent.server.app as app_module from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer +from dify_agent.layers.shell import DifyShellLayerConfig +from dify_agent.layers.shell.layer import DifyShellLayer from dify_agent.runtime.compositor_factory import DifyAgentLayerProvider from dify_agent.server.app import create_app, create_plugin_daemon_http_client from dify_agent.server.settings import ServerSettings @@ -133,6 +137,8 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt run_retention_seconds=7, plugin_daemon_url="http://plugin-daemon", plugin_daemon_api_key="daemon-secret", + shellctl_entrypoint="http://shellctl", + shellctl_auth_token="shell-secret", plugin_daemon_connect_timeout=1, plugin_daemon_read_timeout=2, plugin_daemon_write_timeout=3, @@ -154,9 +160,17 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt execution_context_layer = execution_context_provider.create_layer( DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run") ) + shell_provider = next(provider for provider in layer_providers if provider.type_id == "dify.shell") + shell_layer = shell_provider.create_layer(DifyShellLayerConfig()) assert isinstance(execution_context_layer, DifyExecutionContextLayer) + assert isinstance(shell_layer, DifyShellLayer) assert execution_context_layer.daemon_url == "http://plugin-daemon" assert execution_context_layer.daemon_api_key == "daemon-secret" + assert shell_layer.shellctl_entrypoint == "http://shellctl" + shellctl_client = shell_layer.shellctl_client_factory("http://shellctl") + assert isinstance(shellctl_client, ShellctlClient) + assert shellctl_client.token == "shell-secret" + asyncio.run(shellctl_client.close()) http_client = scheduler.plugin_daemon_http_client assert http_client is fake_http_client assert http_client.is_closed is False diff --git a/dify-agent/tests/local/dify_agent/server/test_settings.py b/dify-agent/tests/local/dify_agent/server/test_settings.py new file mode 100644 index 0000000000..86fd862927 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/server/test_settings.py @@ -0,0 +1,33 @@ +from pathlib import Path + +import pytest + +from dify_agent.server.settings import ServerSettings + + +def test_server_settings_reads_shellctl_entrypoint_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DIFY_AGENT_SHELLCTL_ENTRYPOINT", "http://shellctl.example") + + settings = ServerSettings() + + assert settings.shellctl_entrypoint == "http://shellctl.example" + + +def test_server_settings_reads_shellctl_auth_token_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DIFY_AGENT_SHELLCTL_AUTH_TOKEN", "shell-secret") + + settings = ServerSettings() + + assert settings.shellctl_auth_token == "shell-secret" + + +def test_server_settings_defaults_shellctl_auth_token_to_none( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.delenv("DIFY_AGENT_SHELLCTL_AUTH_TOKEN", raising=False) + monkeypatch.chdir(tmp_path) + + settings = ServerSettings() + + assert settings.shellctl_auth_token is None diff --git a/dify-agent/tests/local/dify_agent/test_client_safe_exports.py b/dify-agent/tests/local/dify_agent/test_client_safe_exports.py new file mode 100644 index 0000000000..8c12d9346c --- /dev/null +++ b/dify-agent/tests/local/dify_agent/test_client_safe_exports.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from pathlib import Path +import shutil +import subprocess +import textwrap + +import pytest + + +def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Path) -> None: + """Install the package without extras and verify client-facing imports work.""" + uv = shutil.which("uv") + if uv is None: + pytest.skip("uv is required to verify default-dependency imports in an isolated environment") + + project_root = Path(__file__).resolve().parents[3] + venv_path = tmp_path / "client-default-venv" + python_path = venv_path / "bin" / "python" + + subprocess.run([uv, "venv", str(venv_path)], cwd=project_root, check=True) + subprocess.run( + [uv, "pip", "install", "--python", str(python_path), "."], + cwd=project_root, + check=True, + ) + + script = textwrap.dedent( + """ + from __future__ import annotations + + import importlib + from importlib.metadata import PackageNotFoundError, distribution + from pathlib import Path + import re + import sys + import tomllib + + + def requirement_name(requirement: str) -> str: + match = re.match(r"\\s*([A-Za-z0-9_.-]+)", requirement) + if match is None: + raise AssertionError(f"Cannot parse requirement name: {requirement!r}") + return match.group(1).lower().replace("_", "-") + + + project_root = Path(sys.argv[1]) + pyproject = tomllib.loads((project_root / "pyproject.toml").read_text()) + default_dependency_names = { + requirement_name(requirement) + for requirement in pyproject["project"].get("dependencies", []) + } + server_dependency_names = { + requirement_name(requirement) + for requirement in pyproject["project"].get("optional-dependencies", {}).get("server", []) + } + server_only_dependency_names = server_dependency_names - default_dependency_names + + agenton_layers = importlib.import_module("agenton.layers") + agenton_compositor = importlib.import_module("agenton.compositor") + agenton_collections = importlib.import_module("agenton_collections") + plain_layers = importlib.import_module("agenton_collections.layers.plain") + pydantic_ai_layers = importlib.import_module("agenton_collections.layers.pydantic_ai") + dify_agent = importlib.import_module("dify_agent") + client_module = importlib.import_module("dify_agent.client") + protocol_module = importlib.import_module("dify_agent.protocol") + shell_module = importlib.import_module("dify_agent.layers.shell") + execution_context_module = importlib.import_module("dify_agent.layers.execution_context") + plugin_module = importlib.import_module("dify_agent.layers.dify_plugin") + output_module = importlib.import_module("dify_agent.layers.output") + + assert agenton_layers.ExitIntent is not None + assert agenton_layers.LayerConfig is not None + assert agenton_compositor.CompositorSessionSnapshot is not None + assert agenton_collections.PromptLayer is plain_layers.PromptLayer + assert plain_layers.PromptLayerConfig is not None + assert pydantic_ai_layers.PydanticAIHistoryLayer is not None + assert dify_agent.Client is client_module.Client + assert protocol_module.CreateRunRequest is not None + assert protocol_module.RunComposition is not None + assert protocol_module.RunLayerSpec is not None + assert shell_module.DifyShellLayerConfig is not None + assert execution_context_module.DifyExecutionContextLayerConfig is not None + assert plugin_module.DifyPluginLLMLayerConfig is not None + assert output_module.DifyOutputLayerConfig is not None + + unexpectedly_installed = [] + for dependency_name in sorted(server_only_dependency_names): + try: + distribution(dependency_name) + except PackageNotFoundError: + continue + unexpectedly_installed.append(dependency_name) + assert unexpectedly_installed == [] + """ + ) + subprocess.run([str(python_path), "-c", script, str(project_root)], cwd=project_root, check=True) diff --git a/dify-agent/tests/local/dify_agent/test_import_boundaries.py b/dify-agent/tests/local/dify_agent/test_import_boundaries.py index 92a3d45f2a..5f18f738b2 100644 --- a/dify-agent/tests/local/dify_agent/test_import_boundaries.py +++ b/dify-agent/tests/local/dify_agent/test_import_boundaries.py @@ -83,6 +83,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> "dify_agent.layers.dify_plugin.llm_layer", "dify_agent.layers.dify_plugin.tools_layer", "dify_agent.layers.output.output_layer", + "dify_agent.layers.shell.layer", "dify_agent.runtime", "dify_agent.server", "fastapi", @@ -91,18 +92,22 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> "openai", "pydantic_settings", "redis", + "shell_session_manager.shellctl.client", + "shell_session_manager.shellctl.server", ], imports=[ "dify_agent.protocol", "dify_agent.layers.execution_context", "dify_agent.layers.dify_plugin", "dify_agent.layers.output", + "dify_agent.layers.shell", ], assertions=[ "assert hasattr(dify_agent_protocol, 'PydanticAIStreamRunEvent')", "assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig']", "assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']", "assert dify_agent_layers_output.__all__ == ['DIFY_OUTPUT_LAYER_TYPE_ID', 'DifyOutputLayerConfig']", + "assert dify_agent_layers_shell.__all__ == ['DIFY_SHELL_LAYER_TYPE_ID', 'DifyShellLayerConfig']", ], ) diff --git a/dify-agent/uv.lock b/dify-agent/uv.lock index f18d4e3e4a..f306cad0a3 100644 --- a/dify-agent/uv.lock +++ b/dify-agent/uv.lock @@ -19,6 +19,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, ] +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -589,6 +598,7 @@ server = [ { name = "pydantic-ai-slim", extra = ["anthropic", "google", "openai"] }, { name = "pydantic-settings" }, { name = "redis" }, + { name = "shell-session-manager" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -619,6 +629,7 @@ requires-dist = [ { name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" }, { name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" }, { name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" }, + { name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.1.1" }, { name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" }, ] @@ -811,6 +822,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/89/a6340afdaf5169d17a318e00fc685fb67ed99baa602c2cbbbf6af6a76096/graphon-0.2.2-py3-none-any.whl", hash = "sha256:754e544d08779138f99eac6547ab08559463680e2c76488b05e1c978210392b4", size = 340808, upload-time = "2026-04-17T08:52:26.5Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, + { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, + { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, + { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" }, + { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, + { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, + { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, + { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, +] + [[package]] name = "griffelib" version = "2.0.2" @@ -2946,6 +3012,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] +[[package]] +name = "shell-session-manager" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiosqlite" }, + { name = "anyio" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "sqlmodel" }, + { name = "typer" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/64/8d12611e48553d61423d5e302d178e67bd968a35f1709e26024f4e04fc3b/shell_session_manager-2.1.1.tar.gz", hash = "sha256:bf490809161244beb95cabad62d32a59b351b7b5993e375d49b6fcf3835ae31c", size = 47064, upload-time = "2026-05-29T20:04:27.625Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/74/64d6db5888f6e7c7dcf0b4960e9ffa8c38425fa906cd60e99ed0bd88def7/shell_session_manager-2.1.1-py3-none-any.whl", hash = "sha256:6b53c813ac386bbf3244c375edf9cce675c89a2041d33a969ef69d8d74f89ac6", size = 45742, upload-time = "2026-05-29T20:04:26.551Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -3055,6 +3140,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/78/d1a1a026ef3af911159398c939b1509d5c36fe524c7b644f34a5146c4e16/spacy_loggers-1.0.5-py3-none-any.whl", hash = "sha256:196284c9c446cc0cdb944005384270d775fdeaf4f494d8e269466cfa497ef645", size = 22343, upload-time = "2023-09-11T12:26:50.586Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" }, + { url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" }, + { url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" }, + { url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" }, + { url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" }, + { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" }, +] + +[[package]] +name = "sqlmodel" +version = "0.0.38" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/0d/26ec1329960ea9430131fe63f63a95ea4cb8971d49c891ff7e1f3255421c/sqlmodel-0.0.38.tar.gz", hash = "sha256:d583ec237b14103809f74e8630032bc40ab68cd6b754a610f0813c56911a547b", size = 86710, upload-time = "2026-04-02T21:03:55.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/c7/10c60af0607ab6fa136264f7f39d205932218516226d38585324ffda705d/sqlmodel-0.0.38-py3-none-any.whl", hash = "sha256:84e3fa990a77395461ded72a6c73173438ce8449d5c1c4d97fbff1b1df692649", size = 27294, upload-time = "2026-04-02T21:03:56.406Z" }, +] + [[package]] name = "srsly" version = "2.5.3"