mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
feat(dify-agent): add shell layer (#36838)
This commit is contained in:
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`.
|
||||
Reference in new issue
Block a user