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

This commit is contained in:
盐粒 Yanli authored and GitHub committed 2026-06-02 07:54:52 +00:00
1 parent dea4e66456
commit eae44cfecb
29 files changed
+3273 -72

No files matched your search

@@ -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`.