mirror of
https://github.com/langgenius/dify.git
synced 2026-06-03 08:16:37 +08:00
feat(dify-agent): add shell layer (#36838)
This commit is contained in:
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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``.
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
|
||||
@@ -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']",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
Generated
+140
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user