feat(dify-agent): add shell layer (#36838)

This commit is contained in:
盐粒 Yanli
2026-06-02 16:54:52 +09:00
committed by GitHub
parent dea4e66456
commit eae44cfecb
29 changed files with 3273 additions and 72 deletions
+25
View File
@@ -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"]
@@ -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`.
@@ -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": "<redacted>"},
),
),
]
),
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`.
+3
View File
@@ -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
+1
View File
@@ -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",
]
+3 -2
View File
@@ -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
@@ -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``.
+28 -5
View File
@@ -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}
+78 -21
View File
@@ -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]
+5 -2
View File
@@ -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
+63 -21
View File
@@ -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]:
@@ -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"]
@@ -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"]
@@ -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/<session_id>`` 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/<session_id>`` 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",
]
@@ -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),
)
+18 -10
View File
@@ -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.")
+4 -1
View File
@@ -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] = {}
+6 -2
View File
@@ -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)
@@ -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(
@@ -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,
*,
@@ -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"})
@@ -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},
}
)
@@ -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())
@@ -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"
@@ -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
@@ -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
@@ -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)
@@ -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']",
],
)
+140
View File
@@ -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"