chore(agent-v2): sync daily changes (#38298)

Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: 盐粒 Yanli <mail@yanli.one>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: zyssyz123 <916125788@qq.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: 盐粒 Yanli <yanli@dify.ai>
Co-authored-by: 林玮 (Jade Lin) <linw1995@icloud.com>
This commit is contained in:
yyh 2026-07-05 16:09:38 +08:00 committed by GitHub
parent f8d47616c1
commit 1b3e8e9943
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
303 changed files with 11391 additions and 1912 deletions

View File

@ -45,6 +45,7 @@ Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance.
- Inspect the target feature area.
- Reuse an existing step when wording and behavior already match.
- Add a new step only for a genuinely new user action or assertion.
- Before adding several similar steps, scan the target capability for an existing domain noun that can be parameterized without hiding behavior.
- Keep edits close to the current capability folder unless the step is broadly reusable.
2. Write behavior-first scenarios.
- Describe user-observable behavior, not DOM mechanics.
@ -53,12 +54,16 @@ Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance.
3. Write step definitions in the local style.
- Keep one step to one user-visible action or one assertion.
- Prefer Cucumber Expressions such as `{string}` and `{int}`.
- Use a bounded regex only when the accepted values are a small explicit domain set and Cucumber Expressions would make the Gherkin less natural.
- Do not create one-off steps for each case variant when the same domain action or outcome applies to named surfaces, modes, or resources.
- Scope locators to stable containers when the page has repeated elements.
- Avoid page-object layers or extra helper abstractions unless repeated complexity clearly justifies them.
4. Use Playwright in the local style.
- Prefer user-facing locators: `getByRole`, `getByLabel`, `getByPlaceholder`, `getByText`, then `getByTestId` for explicit contracts.
- Use web-first `expect(...)` assertions.
- Do not use `waitForTimeout`, manual polling, or raw visibility checks when a locator action or retrying assertion already expresses the behavior.
- Use `expect.poll` for API persistence, backend eventual consistency, captured browser events, or other non-DOM state; prefer locator assertions for DOM readiness and visible UI state.
- If a product element has real user-facing semantics but no accessible name, prefer fixing that accessible contract over adding a test id.
5. Validate narrowly.
- Run the narrowest tagged scenario or flow that exercises the change.
- Run `vpr lint --fix --quiet` from the repository root and `pnpm -C e2e type-check`.

View File

@ -39,6 +39,7 @@ Prefer reuse when:
- the user action is genuinely the same
- the expected outcome is genuinely the same
- the wording stays natural across features
- the parameter is a real product domain value such as a named surface, mode, resource, or status
Write a new step when:
@ -46,6 +47,8 @@ Write a new step when:
- reusing the old wording would make the scenario misleading
- a supposedly generic step would become an implementation-detail wrapper
Do not optimize for a low step count by making vague steps. Optimize for a small set of truthful, domain-owned steps.
### 4. Prefer Cucumber Expressions
Use Cucumber Expressions for parameters unless regex is clearly necessary.
@ -59,6 +62,8 @@ Common examples:
Keep expressions readable. If a step needs complicated parsing logic, first ask whether the scenario wording should be simpler.
Use regex for a bounded natural-language alternative only when it keeps Gherkin readable, for example `/(Web app|Backend service API)/`. Avoid broad regexes that accept unowned language.
### 5. Keep step definitions thin and meaningful
Step definitions are glue between Gherkin and automation, not a second abstraction language.

View File

@ -41,6 +41,7 @@ Also remember:
- repeated content usually needs scoping to a stable container
- exact text matching is often too brittle when role/name or label already exists
- `getByTestId` is acceptable when semantics are weak but the contract is intentional
- when a real UI region, card, status, or icon lacks an accessible name, prefer adding that semantic contract in product code before falling back to `getByTestId`
### 3. Use web-first assertions
@ -62,6 +63,8 @@ Avoid:
If a condition genuinely needs custom retry logic, use Playwright's polling/assertion tools deliberately and keep that choice local and explicit.
Use `expect.poll` for non-DOM truth such as API state, backend eventual consistency, generated resources, or captured browser events. For DOM state, use locator assertions so Playwright can apply actionability and web-first retry semantics.
### 4. Let actions wait for actionability
Locator actions already wait for the element to be actionable. Do not preface every click/fill with extra timing logic unless the action needs a specific visible/ready assertion for clarity.

View File

@ -44,6 +44,7 @@ jobs:
api-changed: ${{ steps.changes.outputs.api }}
cli-changed: ${{ steps.changes.outputs.cli }}
e2e-changed: ${{ steps.changes.outputs.e2e }}
external-e2e-changed: ${{ steps.changes.outputs.external_e2e }}
web-changed: ${{ steps.changes.outputs.web }}
vdb-changed: ${{ steps.changes.outputs.vdb }}
migration-changed: ${{ steps.changes.outputs.migration }}
@ -99,6 +100,25 @@ jobs:
- 'docker/envs/middleware.env.example'
- '.github/workflows/web-e2e.yml'
- '.github/actions/setup-web/**'
external_e2e:
- 'e2e/features/agent-v2/**'
- 'e2e/scripts/**'
- 'e2e/support/**'
- '.github/workflows/web-e2e.yml'
- '.github/actions/setup-web/**'
- 'dify-agent/**'
- 'api/clients/agent_backend/**'
- 'api/core/app/apps/agent_app/**'
- 'api/core/workflow/nodes/agent_v2/**'
- 'api/controllers/console/agent/**'
- 'api/services/agent/**'
- 'api/core/plugin/**'
- 'api/services/plugin/**'
- 'api/core/tools/**'
- 'api/services/tools/**'
- 'web/features/agent-v2/**'
- 'web/app/(commonLayout)/roster/**'
- 'web/app/components/workflow/nodes/agent-v2/**'
vdb:
- 'api/core/rag/datasource/**'
- 'api/tests/integration_tests/vdb/**'
@ -322,15 +342,18 @@ jobs:
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed == 'true'
if: needs.pre_job.outputs.should_skip != 'true' && (needs.check-changes.outputs.e2e-changed == 'true' || needs.check-changes.outputs.external-e2e-changed == 'true')
uses: ./.github/workflows/web-e2e.yml
with:
run-external-runtime: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.check-changes.outputs.external-e2e-changed == 'true' }}
secrets: inherit
web-e2e-skip:
name: Skip Web Full-Stack E2E
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true' && needs.check-changes.outputs.external-e2e-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped web full-stack e2e
@ -349,7 +372,7 @@ jobs:
- name: Finalize Web Full-Stack E2E status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.e2e-changed }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.e2e-changed == 'true' || needs.check-changes.outputs.external-e2e-changed == 'true' }}
RUN_RESULT: ${{ needs.web-e2e-run.result }}
SKIP_RESULT: ${{ needs.web-e2e-skip.result }}
run: |

View File

@ -2,6 +2,11 @@ name: Web Full-Stack E2E
on:
workflow_call:
inputs:
run-external-runtime:
required: false
type: boolean
default: false
permissions:
contents: read
@ -32,7 +37,9 @@ jobs:
with:
enable-cache: true
python-version: "3.12"
cache-dependency-glob: api/uv.lock
cache-dependency-glob: |
api/uv.lock
dify-agent/uv.lock
- name: Install API dependencies
run: uv sync --project api --dev
@ -51,12 +58,52 @@ jobs:
E2E_INIT_PASSWORD: E2eInit12345
run: vp run e2e:full
- name: Run external runtime E2E tests
if: ${{ inputs.run-external-runtime }}
working-directory: ./e2e
env:
E2E_ADMIN_EMAIL: e2e-admin@example.com
E2E_ADMIN_NAME: E2E Admin
E2E_ADMIN_PASSWORD: E2eAdmin12345
E2E_AGENT_DECISION_MODEL_NAME: ${{ vars.E2E_AGENT_DECISION_MODEL_NAME || 'gpt-5.5' }}
E2E_AGENT_DECISION_MODEL_PROVIDER: ${{ vars.E2E_AGENT_DECISION_MODEL_PROVIDER || 'openai' }}
E2E_AGENT_DECISION_MODEL_TYPE: ${{ vars.E2E_AGENT_DECISION_MODEL_TYPE || 'llm' }}
E2E_EXTERNAL_RUNTIME_SEED_SPECS: ${{ vars.E2E_EXTERNAL_RUNTIME_SEED_SPECS }}
E2E_EXTERNAL_RUNTIME_TAGS: ${{ vars.E2E_EXTERNAL_RUNTIME_TAGS }}
E2E_FORCE_WEB_BUILD: "1"
E2E_INIT_PASSWORD: E2eInit12345
E2E_MARKETPLACE_API_URL: ${{ vars.E2E_MARKETPLACE_API_URL }}
E2E_MARKETPLACE_PLUGIN_IDS: ${{ vars.E2E_MARKETPLACE_PLUGIN_IDS }}
E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS: ${{ vars.E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS }}
E2E_MODEL_PROVIDER_CREDENTIALS_JSON: ${{ secrets.E2E_MODEL_PROVIDER_CREDENTIALS_JSON }}
E2E_START_AGENT_BACKEND: ${{ vars.E2E_START_AGENT_BACKEND || '1' }}
E2E_STABLE_MODEL_NAME: ${{ vars.E2E_STABLE_MODEL_NAME || 'gpt-5-nano' }}
E2E_STABLE_MODEL_PROVIDER: ${{ vars.E2E_STABLE_MODEL_PROVIDER || 'openai' }}
E2E_STABLE_MODEL_TYPE: ${{ vars.E2E_STABLE_MODEL_TYPE || 'llm' }}
run: |
if [[ -z "${E2E_MODEL_PROVIDER_CREDENTIALS_JSON}" ]]; then
echo "E2E_MODEL_PROVIDER_CREDENTIALS_JSON is required for external runtime E2E." >&2
exit 1
fi
if [[ -d cucumber-report ]]; then
rm -rf cucumber-report-non-external
mv cucumber-report cucumber-report-non-external
fi
trap 'vp run e2e:middleware:down' EXIT
vp run e2e:middleware:up
vp run e2e:external:prepare
vp run e2e:external
- name: Upload Cucumber report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cucumber-report
path: e2e/cucumber-report
path: |
e2e/cucumber-report
e2e/cucumber-report-non-external
retention-days: 7
- name: Upload E2E logs

View File

@ -67,6 +67,7 @@ class AgentBackendRunSucceededInternalEvent(AgentBackendInternalEventBase):
type: Literal[AgentBackendInternalEventType.RUN_SUCCEEDED] = AgentBackendInternalEventType.RUN_SUCCEEDED
output: JsonValue
session_snapshot: CompositorSessionSnapshot
usage: dict[str, JsonValue] | None = None
class AgentBackendDeferredToolCallInternalEvent(AgentBackendInternalEventBase):
@ -76,6 +77,7 @@ class AgentBackendDeferredToolCallInternalEvent(AgentBackendInternalEventBase):
deferred_tool_call: DeferredToolCallPayload
message: str | None = None
session_snapshot: CompositorSessionSnapshot
usage: dict[str, JsonValue] | None = None
class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase):
@ -140,6 +142,7 @@ class AgentBackendRunEventAdapter:
deferred_tool_call=event.data.deferred_tool_call,
message=_deferred_tool_call_message(event.data.deferred_tool_call),
session_snapshot=event.data.session_snapshot,
usage=_agent_run_usage(event.data.usage),
)
]
return [
@ -148,6 +151,7 @@ class AgentBackendRunEventAdapter:
source_event_id=event.id,
output=event.data.output,
session_snapshot=event.data.session_snapshot,
usage=_agent_run_usage(event.data.usage),
)
]
case RunFailedEvent():
@ -184,3 +188,13 @@ def _deferred_tool_call_message(payload: DeferredToolCallPayload) -> str:
return title
return f"Agent backend requested external input via deferred tool '{payload.tool_name}'."
def _agent_run_usage(usage: object | None) -> dict[str, JsonValue] | None:
"""Return JSON-safe usage metadata from optional Agent backend usage."""
if usage is None:
return None
dumped = _EVENT_DATA_ADAPTER.dump_python(usage, mode="json")
if not isinstance(dumped, dict):
return None
return cast(dict[str, JsonValue], dumped)

View File

@ -171,7 +171,7 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
knowledge: DifyKnowledgeBaseLayerConfig | None = None
config_layer_config: DifyConfigLayerConfig | None = None
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED.
# through the back proxy, never inline content.
drive_config: DifyDriveLayerConfig | None = None
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
# the Agent Soul configures human involvement; a deferred call ends the run and
@ -220,7 +220,7 @@ class AgentBackendAgentAppRunInput(BaseModel):
knowledge: DifyKnowledgeBaseLayerConfig | None = None
config_layer_config: DifyConfigLayerConfig | None = None
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED.
# through the back proxy, never inline content.
drive_config: DifyDriveLayerConfig | None = None
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
# the Agent Soul configures human involvement (ENG-635).
@ -254,10 +254,11 @@ class AgentBackendRunRequestBuilder:
"""Build an Agent App conversation-turn run request.
Layer graph: optional Agent Soul system prompt user prompt
execution context optional history (multi-turn) LLM optional
plugin-direct tools / core-routed tools / knowledge search
optional structured output. Mirrors the workflow-node layer ordering
minus the workflow-job / previous-node prompt.
execution context optional shell / config / drive / history
(multi-turn) LLM optional plugin-direct tools / core-routed tools /
knowledge search / ask_human / structured output. Mirrors the
workflow-node layer ordering minus the workflow-job / previous-node
prompt.
"""
layers: list[RunLayerSpec] = []
if run_input.agent_soul_prompt:
@ -354,11 +355,14 @@ class AgentBackendRunRequestBuilder:
)
if run_input.tools is not None and run_input.tools.tools:
plugin_tool_deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
if include_shell:
plugin_tool_deps["shell"] = DIFY_SHELL_LAYER_ID
layers.append(
RunLayerSpec(
name=DIFY_PLUGIN_TOOLS_LAYER_ID,
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
deps=plugin_tool_deps,
metadata=run_input.metadata,
config=run_input.tools,
)
@ -474,9 +478,9 @@ class AgentBackendRunRequestBuilder:
"""Build a workflow Agent Node run request without defining another wire schema.
Layer graph mirrors the workflow surface: prompts execution context
optional drive/history LLM optional plugin-direct tools /
core-routed tools / knowledge search optional auxiliary layers such
as ask_human, shell, and structured output.
optional shell / config / drive / history LLM optional
plugin-direct tools / core-routed tools / knowledge search /
ask_human / structured output.
"""
layers: list[RunLayerSpec] = []
if run_input.agent_soul_prompt:
@ -581,11 +585,14 @@ class AgentBackendRunRequestBuilder:
)
if run_input.tools is not None and run_input.tools.tools:
plugin_tool_deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
if include_shell:
plugin_tool_deps["shell"] = DIFY_SHELL_LAYER_ID
layers.append(
RunLayerSpec(
name=DIFY_PLUGIN_TOOLS_LAYER_ID,
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
deps=plugin_tool_deps,
metadata=run_input.metadata,
config=run_input.tools,
)

View File

@ -1,4 +1,4 @@
from pydantic import Field
from pydantic import Field, NonNegativeFloat
from pydantic_settings import BaseSettings
@ -32,12 +32,10 @@ class AgentBackendConfig(BaseSettings):
default=False,
)
AGENT_DRIVE_MANIFEST_ENABLED: bool = Field(
AGENT_APP_TEXT_DELTA_DEBOUNCE_SECONDS: NonNegativeFloat = Field(
description=(
"Inject the dify.drive layer (Skills & Files drive manifest declaration) "
"into Agent runs. The declaration is an index only — the agent backend "
"pulls the actual SKILL.md / files through the back proxy. Set this to "
"false only when temporarily rolling back the drive integration."
"Buffer Agent App assistant text deltas for up to this many seconds before "
"publishing SSE chunks. Set to 0 to publish each delta immediately."
),
default=True,
default=0.5,
)

View File

@ -91,33 +91,31 @@ class AgentIdPath(BaseModel):
class AgentAppCreatePayload(BaseModel):
name: str = Field(..., min_length=1, description="Agent name")
description: str | None = Field(default=None, description="Agent description (max 400 chars)", max_length=400)
role: str = Field(..., min_length=1, description="Agent role", max_length=255)
role: str | None = Field(default=None, description="Agent role", max_length=255)
icon_type: IconType | None = Field(default=None, description="Icon type")
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("role")
@classmethod
def validate_role(cls, value: str) -> str:
role = value.strip()
if not role:
raise ValueError("Agent role is required.")
return role
def validate_role(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip()
# Keep agent-app roster DTOs agent-specific instead of reusing the shared
# /apps response/request models. The roster surface needs Agent-only fields such
# as `role`, while the generic console/apps contracts must stay unchanged.
class AgentAppUpdatePayload(GenericUpdateAppPayload):
role: str = Field(..., min_length=1, description="Agent role", max_length=255)
role: str | None = Field(default=None, description="Agent role", max_length=255)
@field_validator("role")
@classmethod
def validate_role(cls, value: str) -> str:
role = value.strip()
if not role:
raise ValueError("Agent role is required.")
return role
def validate_role(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip()
class AgentAppCopyPayload(BaseModel):
@ -133,10 +131,7 @@ class AgentAppCopyPayload(BaseModel):
def validate_role(cls, value: str | None) -> str | None:
if value is None:
return None
role = value.strip()
if not role:
raise ValueError("Agent role is required when provided.")
return role
return value.strip()
class AgentApiStatusPayload(BaseModel):
@ -531,6 +526,7 @@ class AgentAppListApi(Resource):
page=args.page,
limit=args.limit,
mode="agent",
sort_by=args.sort_by,
name=args.name,
tag_ids=args.tag_ids,
creator_ids=args.creator_ids,
@ -565,7 +561,7 @@ class AgentAppListApi(Resource):
name=args.name,
description=args.description,
mode="agent",
agent_role=args.role,
agent_role=args.role or "",
icon_type=args.icon_type,
icon=args.icon,
icon_background=args.icon_background,

View File

@ -1,7 +1,7 @@
import json
import logging
from collections.abc import Generator
from typing import Any, Literal
from collections.abc import Generator, Iterator, Mapping
from typing import Any, Literal, Protocol, runtime_checkable
from uuid import UUID
from flask import request
@ -60,6 +60,11 @@ from services.errors.llm import InvokeRateLimitError
logger = logging.getLogger(__name__)
@runtime_checkable
class _ClosableStream(Protocol):
def close(self) -> None: ...
def _resolve_debugger_chat_streaming(
*, app_mode: AppMode, response_mode: str, response_mode_provided: bool = True
) -> bool:
@ -118,15 +123,12 @@ edit workspace files, run validation or debugging commands, make exploratory che
Use only the current Build chat message history to identify changes that need to be persisted. Do not inspect, test, or
validate old config unless the message history already shows that the old config is invalid.
Persist only the build-draft config resources that need to change, using the Agent config CLI usage provided in the
runtime prompt:
Only update the build-draft config note when the current Build chat contains durable context that later runs need.
Do not create, update, delete, inspect, or fill gaps in other Agent config resources, including config files, config
skills, config env, tools, models, knowledge, or prompt settings.
- config files for reusable artifacts that should be available later,
- config skills for reusable procedures or tools that should be available later,
- config env when environment keys or values need to be recorded,
- config note for concise durable context when useful.
When updating the config note, record only durable context needed by later runs, such as:
When updating the config note with the Agent config CLI usage provided in the runtime prompt, record only durable
context needed by later runs, such as:
- what you installed or configured outside the workspace for this agent,
- where those external updates live, including CLI tools, packages, and persistent $HOME paths,
@ -437,7 +439,6 @@ def _drain_streaming_generate_response(response: RateLimitGenerator | Generator[
changes the HTTP boundary: it drains the SSE stream server-side and returns
success after the generated build-chat message reaches ``message_end``.
"""
close = getattr(response, "close", None)
try:
for chunk in response:
for raw_event in chunk.split("\n\n"):
@ -467,8 +468,8 @@ def _drain_streaming_generate_response(response: RateLimitGenerator | Generator[
if payload_event == "error":
raise CompletionRequestError(str(payload.get("message") or "Build chat finalization failed."))
finally:
if callable(close):
close()
if isinstance(response, _ClosableStream):
response.close()
raise CompletionRequestError("Build chat finalization did not complete.")
@ -531,6 +532,8 @@ def _generate_chat_message_response(
args=args,
streaming=streaming,
)
if AppMode.value_of(app_model.mode) == AppMode.AGENT and streaming:
response = _raise_agent_stream_error_before_response(response)
return helper.compact_generate_response(response)
@ -543,3 +546,67 @@ def _stop_chat_message(*, current_user_id: str, app_model: App, task_id: str):
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
def _raise_agent_stream_error_before_response(response):
"""Surface immediate Agent App stream errors as HTTP errors before SSE starts.
The shared streaming helper always returns HTTP 200 once the SSE response is
created. Agent v2 configuration errors, such as an invalid model API key,
can be the first real stream event after the initial ping; pre-reading that
first non-ping event lets the console API return the existing 400 error
contract instead of a successful HTTP response carrying only an SSE error.
"""
if isinstance(response, Mapping):
return response
buffered: list[str] = []
iterator = iter(response)
while True:
try:
chunk = next(iterator)
except StopIteration:
return iter(buffered)
if not isinstance(chunk, str):
return _prepend_stream_chunks(buffered, chunk, iterator)
if _is_sse_ping(chunk):
buffered.append(chunk)
continue
error_payload = _extract_sse_error_payload(chunk)
if error_payload is not None:
if isinstance(response, _ClosableStream):
response.close()
message = error_payload.get("message")
raise CompletionRequestError(str(message or "Agent App chat failed."))
return _prepend_stream_chunks(buffered, chunk, iterator)
def _prepend_stream_chunks(buffered: list[Any], first: Any, iterator: Iterator[Any]) -> Generator[Any, None, None]:
yield from buffered
yield first
yield from iterator
def _is_sse_ping(chunk: str) -> bool:
return chunk.strip() == "event: ping"
def _extract_sse_error_payload(chunk: str) -> dict[str, Any] | None:
for raw_event in chunk.split("\n\n"):
data_lines: list[str] = []
for line in raw_event.splitlines():
if line.startswith("data: "):
data_lines.append(line.removeprefix("data: "))
if not data_lines:
continue
try:
payload = json.loads("\n".join(data_lines))
except json.JSONDecodeError:
continue
if isinstance(payload, dict) and payload.get("event") == "error":
return payload
return None

View File

@ -28,6 +28,7 @@ class PluginUploadQuery(BaseModel):
sign: str = Field(..., description="HMAC signature")
tenant_id: str = Field(..., description="Tenant identifier")
user_id: str | None = Field(default=None, description="User identifier")
conversation_id: str | None = Field(default=None, description="Conversation identifier")
register_schema_models(files_ns, PluginUploadQuery)
@ -92,6 +93,7 @@ class PluginUploadFileApi(Resource):
mimetype=mimetype,
tenant_id=tenant_id,
user_id=user.id,
conversation_id=args.conversation_id,
timestamp=timestamp,
nonce=nonce,
sign=sign,
@ -105,7 +107,7 @@ class PluginUploadFileApi(Resource):
file_binary=file.stream.read(),
mimetype=mimetype,
filename=filename,
conversation_id=None,
conversation_id=args.conversation_id,
)
extension = guess_extension(tool_file.mimetype) or ".bin"

View File

@ -434,6 +434,7 @@ class PluginUploadFileRequestApi(Resource):
mimetype=payload.mimetype,
tenant_id=tenant_model.id,
user_id=user_model.id,
conversation_id=payload.conversation_id,
)
return BaseBackwardsInvocationResponse(data={"url": url}).model_dump()

View File

@ -507,6 +507,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
),
event_adapter=AgentBackendRunEventAdapter(),
session_store=AgentAppRuntimeSessionStore(),
text_delta_debounce_seconds=dify_config.AGENT_APP_TEXT_DELTA_DEBOUNCE_SECONDS,
)
def _run_input_guards(

View File

@ -16,6 +16,8 @@ from __future__ import annotations
import json
import logging
import time
from collections.abc import Mapping
from decimal import Decimal
from typing import Any, Literal
@ -74,12 +76,23 @@ def _prompt_messages_from_query(user_query: str | None) -> list[PromptMessage]:
return [UserPromptMessage(content=user_query)]
def _llm_usage_from_agent_backend(usage: Mapping[str, Any] | None) -> LLMUsage | None:
if usage is None:
return None
try:
return LLMUsage.from_metadata(usage)
except (TypeError, ValueError):
logger.warning("Failed to parse Agent backend usage metadata: %s", usage, exc_info=True)
return None
def publish_text_answer(
*,
queue_manager: AppQueueManager,
model_name: str,
answer: str,
user_query: str | None = None,
usage: LLMUsage | None = None,
) -> None:
"""Publish a complete assistant answer as one chunk + message-end.
@ -99,6 +112,7 @@ def publish_text_answer(
model_name=model_name,
answer=answer,
user_query=user_query,
usage=usage,
)
@ -127,6 +141,7 @@ def publish_message_end(
model_name: str,
answer: str,
user_query: str | None = None,
usage: LLMUsage | None = None,
) -> None:
"""Publish the terminal assistant result without emitting another delta."""
prompt_messages = _prompt_messages_from_query(user_query)
@ -136,13 +151,46 @@ def publish_message_end(
model=model_name,
prompt_messages=prompt_messages,
message=AssistantPromptMessage(content=answer),
usage=LLMUsage.empty_usage(),
usage=usage or LLMUsage.empty_usage(),
),
),
PublishFrom.APPLICATION_MANAGER,
)
class _TextDeltaDebouncer:
"""Batch assistant text deltas on stream-event boundaries for final SSE output."""
def __init__(self, *, debounce_seconds: float) -> None:
self._debounce_seconds = debounce_seconds
self._parts: list[str] = []
self._first_pending_at: float | None = None
def push(self, delta: str) -> str | None:
if not delta:
return None
if self._debounce_seconds <= 0:
return delta
now = time.monotonic()
if not self._parts:
self._first_pending_at = now
self._parts.append(delta)
if self._first_pending_at is not None and now - self._first_pending_at >= self._debounce_seconds:
return self.flush()
return None
def flush(self) -> str | None:
if not self._parts:
return None
text = "".join(self._parts)
self._parts = []
self._first_pending_at = None
return text
class _AgentProcessRecorder:
"""Persist Agent v2 thinking/tool process events through the legacy thought model."""
@ -430,11 +478,13 @@ class AgentAppRunner:
agent_backend_client: AgentBackendRunClient,
event_adapter: AgentBackendRunEventAdapter,
session_store: AgentAppRuntimeSessionStore,
text_delta_debounce_seconds: float,
) -> None:
self._request_builder = request_builder
self._agent_backend_client = agent_backend_client
self._event_adapter = event_adapter
self._session_store = session_store
self._text_delta_debounce_seconds = text_delta_debounce_seconds
def run(
self,
@ -512,6 +562,7 @@ class AgentAppRunner:
answer=answer,
query=query,
streamed_answer=streamed_answer,
usage=_llm_usage_from_agent_backend(terminal.usage),
)
self._save_session(
scope=scope,
@ -724,19 +775,39 @@ class AgentAppRunner:
model_name: str,
query: str | None,
):
"""Consume backend events while preserving raw recorder granularity.
Process events are recorded immediately for observability. Only the
final assistant text deltas sent through the EasyUI queue are debounced,
with flushes happening on later stream events or terminal boundaries.
"""
terminal = None
streamed_answer_parts: list[str] = []
text_delta_debouncer = _TextDeltaDebouncer(debounce_seconds=self._text_delta_debounce_seconds)
process_recorder = _AgentProcessRecorder(
dify_context=dify_context,
message_id=message_id,
queue_manager=queue_manager,
)
def flush_pending_text() -> None:
pending_text = text_delta_debouncer.flush()
if pending_text:
publish_text_delta(
queue_manager=queue_manager,
model_name=model_name,
delta=pending_text,
user_query=query,
)
for public_event in self._agent_backend_client.stream_events(run_id):
if queue_manager.is_stopped():
flush_pending_text()
self._cancel_run(run_id)
raise GenerateTaskStoppedError()
for internal_event in self._event_adapter.adapt(public_event):
if queue_manager.is_stopped():
flush_pending_text()
self._cancel_run(run_id)
raise GenerateTaskStoppedError()
if internal_event.type in (
@ -758,18 +829,22 @@ class AgentAppRunner:
text_delta = self._extract_stream_text_delta(internal_event)
if text_delta:
streamed_answer_parts.append(text_delta)
publish_text_delta(
queue_manager=queue_manager,
model_name=model_name,
delta=text_delta,
user_query=query,
)
debounced_delta = text_delta_debouncer.push(text_delta)
if debounced_delta:
publish_text_delta(
queue_manager=queue_manager,
model_name=model_name,
delta=debounced_delta,
user_query=query,
)
continue
continue
flush_pending_text()
terminal = internal_event
break
if terminal is not None:
break
flush_pending_text()
return terminal, "".join(streamed_answer_parts)
def _cancel_run(self, run_id: str) -> None:
@ -793,10 +868,20 @@ class AgentAppRunner:
answer: str,
query: str | None,
streamed_answer: str,
usage: LLMUsage | None,
) -> None:
"""Finish a successful streamed turn without duplicating the final text."""
if not answer and streamed_answer:
answer = streamed_answer
if not streamed_answer:
self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer, query=query)
publish_text_answer(
queue_manager=queue_manager,
model_name=model_name,
answer=answer,
user_query=query,
usage=usage,
)
return
if answer.startswith(streamed_answer):
@ -812,7 +897,13 @@ class AgentAppRunner:
"using terminal output for message persistence."
)
publish_message_end(queue_manager=queue_manager, model_name=model_name, answer=answer, user_query=query)
publish_message_end(
queue_manager=queue_manager,
model_name=model_name,
answer=answer,
user_query=query,
usage=usage,
)
def _save_session(
self,
@ -852,6 +943,8 @@ class AgentAppRunner:
configured the value is a JSON object, which we serialize so the chat
message always has a string body.
"""
if output is None:
return ""
if isinstance(output, str):
return output
if isinstance(output, dict):

View File

@ -46,7 +46,7 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import (
)
from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig
from models.provider_ids import ModelProviderID
from services.agent.prompt_mentions import build_soul_mention_resolver, expand_prompt_mentions
from services.agent.prompt_mentions import expand_prompt_mentions
class AgentAppRuntimeRequestBuildError(ValueError):
@ -124,17 +124,14 @@ class AgentAppRuntimeRequestBuilder:
"cli_tool_count": len(agent_soul.tools.cli_tools),
}
config_layer_config = None
soul_prompt_resolver = build_soul_mention_resolver(agent_soul)
if dify_config.AGENT_DRIVE_MANIFEST_ENABLED:
config_layer_config, config_warnings = build_config_layer_config(
agent_soul,
agent_id=context.agent_id,
config_version_id=context.agent_config_snapshot_id,
config_version_kind=context.agent_config_version_kind,
)
append_runtime_warnings(metadata, config_warnings)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
config_layer_config, config_warnings = build_config_layer_config(
agent_soul,
agent_id=context.agent_id,
config_version_id=context.agent_config_snapshot_id,
config_version_kind=context.agent_config_version_kind,
)
append_runtime_warnings(metadata, config_warnings)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
knowledge_config = build_knowledge_layer_config(agent_soul)
request = self._request_builder.build_for_agent_app(

View File

@ -1,5 +1,6 @@
import logging
from collections.abc import Sequence
from urllib.parse import urlencode
import httpx
from yarl import URL
@ -16,7 +17,8 @@ MARKETPLACE_TIMEOUT = 30
def get_plugin_pkg_url(plugin_unique_identifier: str) -> str:
return str((marketplace_api_url / "api/v1/plugins/download").with_query(unique_identifier=plugin_unique_identifier))
query = urlencode({"unique_identifier": plugin_unique_identifier})
return f"{marketplace_api_url / 'api/v1/plugins/download'}?{query}"
def download_plugin_pkg(plugin_unique_identifier: str):

View File

@ -230,6 +230,7 @@ class RequestRequestUploadFile(BaseModel):
filename: str
mimetype: str
conversation_id: str | None = None
class RequestDownloadFileMapping(BaseModel):

View File

@ -902,7 +902,10 @@ class PluginService:
tenant_id,
plugin_unique_identifiers,
PluginInstallationSource.Package,
[{}],
[
{"plugin_unique_identifier": plugin_unique_identifier}
for plugin_unique_identifier in plugin_unique_identifiers
],
)
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
return result

View File

@ -1159,6 +1159,39 @@ class DatasetRetrieval:
all_documents.extend(documents)
def _run_retriever_thread(
self,
*,
flask_app: Flask,
dataset_id: str,
query: str | None,
top_k: int,
all_documents: list[Document],
document_ids_filter: list[str] | None,
metadata_condition: MetadataFilteringCondition | None,
attachment_ids: list[str] | None,
cancel_event: threading.Event | None,
thread_exceptions: list[Exception] | None,
) -> None:
try:
with session_factory.create_session() as session:
self._retriever(
flask_app=flask_app,
session=session,
dataset_id=dataset_id,
query=query or "",
top_k=top_k,
all_documents=all_documents,
document_ids_filter=document_ids_filter,
metadata_condition=metadata_condition,
attachment_ids=attachment_ids,
)
except Exception as e:
if cancel_event:
cancel_event.set()
if thread_exceptions is not None:
thread_exceptions.append(e)
def to_dataset_retriever_tool(
self,
session: Session,
@ -1797,7 +1830,7 @@ class DatasetRetrieval:
else:
continue
retrieval_thread = threading.Thread(
target=self._retriever,
target=self._run_retriever_thread,
kwargs={
"flask_app": flask_app,
"dataset_id": dataset.id,
@ -1807,6 +1840,8 @@ class DatasetRetrieval:
"document_ids_filter": document_ids_filter,
"metadata_condition": metadata_condition,
"attachment_ids": [attachment_id] if attachment_id else None,
"cancel_event": cancel_event,
"thread_exceptions": thread_exceptions,
},
)
threads.append(retrieval_thread)

View File

@ -64,34 +64,45 @@ def verify_tool_file_signature(file_id: str, timestamp: str, nonce: str, sign: s
return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT
def get_signed_file_url_for_plugin(filename: str, mimetype: str, tenant_id: str, user_id: str) -> str:
def get_signed_file_url_for_plugin(
filename: str, mimetype: str, tenant_id: str, user_id: str, conversation_id: str | None = None
) -> str:
"""Build the signed upload URL used by the plugin-facing file upload endpoint."""
base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL
upload_url = f"{base_url}/files/upload/for-plugin"
timestamp = str(int(time.time()))
nonce = os.urandom(16).hex()
data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{timestamp}|{nonce}"
data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{conversation_id or ''}|{timestamp}|{nonce}"
sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
encoded_sign = base64.urlsafe_b64encode(sign).decode()
query = urllib.parse.urlencode(
{
"timestamp": timestamp,
"nonce": nonce,
"sign": encoded_sign,
"user_id": user_id,
"tenant_id": tenant_id,
}
)
query_params = {
"timestamp": timestamp,
"nonce": nonce,
"sign": encoded_sign,
"user_id": user_id,
"tenant_id": tenant_id,
}
if conversation_id:
query_params["conversation_id"] = conversation_id
query = urllib.parse.urlencode(query_params)
return f"{upload_url}?{query}"
def verify_plugin_file_signature(
*, filename: str, mimetype: str, tenant_id: str, user_id: str, timestamp: str, nonce: str, sign: str
*,
filename: str,
mimetype: str,
tenant_id: str,
user_id: str,
conversation_id: str | None = None,
timestamp: str,
nonce: str,
sign: str,
) -> bool:
"""Verify the signature used by the plugin-facing file upload endpoint."""
data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{timestamp}|{nonce}"
data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{conversation_id or ''}|{timestamp}|{nonce}"
recalculated_sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode()

View File

@ -11,6 +11,7 @@ from dify_agent.layers.dify_plugin import (
DifyPluginToolCredentialType,
DifyPluginToolParameter,
DifyPluginToolParameterForm,
DifyPluginToolParameterType,
DifyPluginToolsLayerConfig,
)
from sqlalchemy import select
@ -351,7 +352,9 @@ class WorkflowAgentDifyToolsBuilder:
@staticmethod
def _tool_layer_destination(tool_config: AgentSoulDifyToolConfig) -> Literal["plugin", "core"]:
provider_type = ToolProviderType.value_of(tool_config.provider_type)
if provider_type is ToolProviderType.PLUGIN:
if provider_type is ToolProviderType.PLUGIN or (
provider_type is ToolProviderType.BUILT_IN and _is_plugin_provider_id(tool_config.provider_id)
):
return "plugin"
if provider_type in {
ToolProviderType.BUILT_IN,
@ -404,7 +407,7 @@ class WorkflowAgentDifyToolsBuilder:
credentials=self._normalize_credentials(runtime.credentials, tool_name=exposed_name),
runtime_parameters=runtime_parameters,
parameters=parameters,
parameters_json_schema=tool_runtime.get_llm_parameters_json_schema(),
parameters_json_schema=self._plugin_parameters_json_schema(tool_runtime, parameters),
)
def _to_core_backend_tool_config(
@ -456,6 +459,41 @@ class WorkflowAgentDifyToolsBuilder:
description = tool_runtime.entity.description.llm
return description
@staticmethod
def _plugin_parameters_json_schema(
tool_runtime: Tool,
parameters: list[DifyPluginToolParameter],
) -> dict[str, Any]:
schema = tool_runtime.get_llm_parameters_json_schema()
properties = schema.setdefault("properties", {})
required = schema.setdefault("required", [])
if not isinstance(properties, dict) or not isinstance(required, list):
raise WorkflowAgentDifyToolsBuildError(
"agent_tool_declaration_invalid",
f"Dify Plugin Tool {tool_runtime.entity.identity.name!r} has invalid parameter schema.",
)
for parameter in parameters:
if parameter.form is not DifyPluginToolParameterForm.LLM:
continue
if parameter.type is DifyPluginToolParameterType.FILE:
properties[parameter.name] = _plugin_file_input_schema(parameter.llm_description or "")
elif parameter.type in {
DifyPluginToolParameterType.FILES,
DifyPluginToolParameterType.SYSTEM_FILES,
}:
properties[parameter.name] = {
"type": "array",
"items": _plugin_file_input_schema(parameter.llm_description or ""),
"description": parameter.llm_description or "",
}
else:
continue
if parameter.required and parameter.name not in required:
required.append(parameter.name)
return schema
@staticmethod
def _runtime_parameters(
tool_runtime: Tool,
@ -498,3 +536,44 @@ class WorkflowAgentDifyToolsBuilder:
),
)
return normalized
def _is_plugin_provider_id(provider_id: str | None) -> bool:
if not provider_id:
return False
parts = provider_id.split("/")
return len(parts) == 3 and all(parts)
def _plugin_file_input_schema(description: str) -> dict[str, Any]:
return {
"description": description,
"anyOf": [
{
"type": "string",
"minLength": 1,
"description": "HTTP(S) URL or sandbox-local file path.",
},
{
"type": "object",
"additionalProperties": False,
"required": ["transfer_method", "url"],
"properties": {
"transfer_method": {"type": "string", "enum": ["remote_url"]},
"url": {"type": "string", "minLength": 1},
},
},
{
"type": "object",
"additionalProperties": False,
"required": ["transfer_method", "reference"],
"properties": {
"transfer_method": {
"type": "string",
"enum": ["local_file", "tool_file", "datasource_file"],
},
"reference": {"type": "string", "minLength": 1},
},
},
],
}

View File

@ -333,6 +333,8 @@ class WorkflowAgentOutputAdapter:
session_snapshot = None
if isinstance(event, AgentBackendRunSucceededInternalEvent | AgentBackendDeferredToolCallInternalEvent):
session_snapshot = event.session_snapshot
if event.usage is not None:
agent_backend["usage"] = dict(event.usage)
if session_snapshot is not None:
agent_backend["session_snapshot"] = {
"layer_count": len(session_snapshot.layers),

View File

@ -206,17 +206,14 @@ class WorkflowAgentRuntimeRequestBuilder:
"cli_tool_count": len(agent_soul.tools.cli_tools),
}
config_layer_config: DifyConfigLayerConfig | None = None
soul_prompt_resolver = build_soul_mention_resolver(agent_soul)
if dify_config.AGENT_DRIVE_MANIFEST_ENABLED:
config_layer_config, config_warnings = build_config_layer_config(
agent_soul,
agent_id=context.agent.id,
config_version_id=context.snapshot.id,
config_version_kind="snapshot",
)
append_runtime_warnings(metadata, config_warnings)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
config_layer_config, config_warnings = build_config_layer_config(
agent_soul,
agent_id=context.agent.id,
config_version_id=context.snapshot.id,
config_version_kind="snapshot",
)
append_runtime_warnings(metadata, config_warnings)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
knowledge_config = build_knowledge_layer_config(agent_soul)
@ -717,7 +714,7 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi
for tool in (_shell_cli_tool(item) for item in agent_soul.tools.cli_tools if _cli_tool_enabled(item))
if tool is not None
],
env=[env for env in (_shell_env_var(item) for item in agent_soul.env.variables) if env is not None],
env=_shell_env_vars(agent_soul.env.variables, agent_soul.env.secret_refs),
secret_refs=[
secret for secret in (_shell_secret_ref(item) for item in agent_soul.env.secret_refs) if secret is not None
],
@ -864,8 +861,13 @@ def build_config_layer_config(
agent_id: str | None = None,
config_version_id: str | None = None,
config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot",
) -> tuple[DifyConfigLayerConfig | None, list[dict[str, str]]]:
"""Derive prompt-mentioned eager-pull names from Agent Soul."""
) -> tuple[DifyConfigLayerConfig, list[dict[str, str]]]:
"""Build the always-present Agent config layer from Agent Soul state.
The ``dify.config`` layer must exist for every Agent v2 runtime request so
the backend can expose the config CLI/help surface even when the current
Agent Soul has no config assets, note, or prompt mentions.
"""
ordered_mentions = list(
dict.fromkeys(
@ -874,14 +876,6 @@ def build_config_layer_config(
if mention.kind in {MentionKind.SKILL, MentionKind.FILE} and mention.ref_id
)
)
if (
not agent_soul.config_skills
and not agent_soul.config_files
and not agent_soul.config_note
and not ordered_mentions
):
return None, []
skill_names = {skill.name for skill in agent_soul.config_skills}
file_names = {file_ref.name for file_ref in agent_soul.config_files}
warnings: list[dict[str, str]] = []
@ -968,11 +962,7 @@ def _shell_cli_tool(item: object) -> DifyShellCliToolConfig | None:
if not commands and not isinstance(name, str):
return None
tool_env = data.get("env") if isinstance(data.get("env"), Mapping) else {}
env = [
env_var
for env_var in (_shell_env_var(item) for item in _env_entries(tool_env, "variables"))
if env_var is not None
]
env = _shell_env_vars(_env_entries(tool_env, "variables"), _env_entries(tool_env, "secret_refs"))
secret_refs = [
secret_ref
for secret_ref in (_shell_secret_ref(item) for item in _env_entries(tool_env, "secret_refs"))
@ -995,6 +985,12 @@ def _env_entries(env: object, key: str) -> list[object]:
return entries
def _shell_env_vars(variables: Sequence[object], secret_refs: Sequence[object]) -> list[DifyShellEnvVarConfig]:
env_vars = [_shell_env_var(item) for item in variables]
secret_env_vars = [_shell_env_var(item) for item in secret_refs if _has_secret_value(item)]
return [env for env in [*env_vars, *secret_env_vars] if env is not None]
def _shell_env_var(item: object) -> DifyShellEnvVarConfig | None:
data = _plain_mapping(item)
name = _name_from_mapping(data)
@ -1011,13 +1007,15 @@ def _shell_secret_ref(item: object) -> DifyShellSecretRefConfig | None:
name = _name_from_mapping(data)
if name is None:
return None
ref = (
data.get("ref")
or data.get("value")
or data.get("id")
or data.get("credential_id")
or data.get("provider_credential_id")
)
# Inline Composer values are passed as env vars because the agent-backend
# secret ref schema only accepts short backend-managed reference IDs.
if _has_secret_value(item):
return None
ref = data.get("ref") or data.get("credential_id") or data.get("provider_credential_id")
if ref is None:
ref = data.get("id")
if ref is None:
return None
return DifyShellSecretRefConfig(name=name, ref=str(ref) if ref is not None else None)
@ -1029,6 +1027,12 @@ def _plain_mapping(item: object) -> dict[str, Any]:
return {}
def _has_secret_value(item: object) -> bool:
data = _plain_mapping(item)
value = data.get("value")
return isinstance(value, str) and bool(value)
def _name_from_mapping(item: Mapping[str, Any]) -> str | None:
for key in ("name", "key", "env_name", "variable"):
value = item.get(key)

View File

@ -250,9 +250,10 @@ class AgentSecretRefConfig(AgentFlexibleConfig):
env_name: str | None = Field(default=None, max_length=255)
variable: str | None = Field(default=None, max_length=255)
type: str | None = Field(default=None, max_length=64)
# UI-facing selected secret reference. This is a credential/ref id, not the
# plaintext secret value; runtime maps it to the shell-layer ``ref``.
value: str | None = Field(default=None, max_length=255)
# User-provided secret value. Long API tokens are valid here; runtime maps
# this field into a shell env var, while ref/id/credential_id fields keep the
# backend-managed secret reference path.
value: str | None = None
id: str | None = Field(default=None, max_length=255)
ref: str | None = Field(default=None, max_length=255)
credential_id: str | None = Field(default=None, max_length=255)
@ -515,9 +516,18 @@ class AgentTextToSpeechFeatureConfig(AgentFeatureToggleConfig):
autoPlay: str | None = None
class AgentSuggestedQuestionsAfterAnswerModelConfig(AgentFlexibleConfig):
"""Legacy Chat App model config used only for follow-up question generation."""
provider: str = Field(min_length=1, max_length=255)
name: str = Field(min_length=1, max_length=255)
mode: str | None = Field(default=None, max_length=64)
completion_params: dict[str, Any] | None = None
class AgentSuggestedQuestionsAfterAnswerFeatureConfig(AgentFeatureToggleConfig):
prompt: str | None = None
model: AgentSoulModelConfig | None = None
model: AgentSuggestedQuestionsAfterAnswerModelConfig | None = None
class AgentModerationIOConfig(AgentFlexibleConfig):

View File

@ -12935,7 +12935,7 @@ Default namespace
| icon_background | string | Icon background color | No |
| icon_type | [IconType](#icontype) | Icon type | No |
| name | string | Agent name | Yes |
| role | string | Agent role | Yes |
| role | string | Agent role | No |
#### AgentAppDetailWithSite
@ -13062,7 +13062,7 @@ default (the config form sends the full desired feature state on save).
| icon_type | [IconType](#icontype) | Icon type | No |
| max_active_requests | integer | Maximum active requests | No |
| name | string | App name | Yes |
| role | string | Agent role | Yes |
| role | string | Agent role | No |
| use_icon_as_answer_icon | boolean | Use icon as answer icon | No |
#### AgentAverageResponseTimeStatisticResponse
@ -14569,9 +14569,20 @@ Soft lifecycle state for Agent records.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| enabled | boolean | | No |
| model | [AgentSoulModelConfig](#agentsoulmodelconfig) | | No |
| model | [AgentSuggestedQuestionsAfterAnswerModelConfig](#agentsuggestedquestionsafteranswermodelconfig) | | No |
| prompt | string | | No |
#### AgentSuggestedQuestionsAfterAnswerModelConfig
Legacy Chat App model config used only for follow-up question generation.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| completion_params | object | | No |
| mode | string | | No |
| name | string | | Yes |
| provider | string | | Yes |
#### AgentTextToSpeechFeatureConfig
| Name | Type | Description | Required |

View File

@ -619,9 +619,7 @@ WHERE
@staticmethod
def _statistics_message_scope_sql(source_filter: AgentSourceFilter) -> str:
app_scope = "m.app_id = :app_id"
if source_filter.invoke_from is None:
app_scope += " AND m.invoke_from != :debugger"
else:
if source_filter.invoke_from is not None:
app_scope += " AND m.invoke_from = :source"
workflow_binding_filters = []
if source_filter.app_id:

View File

@ -175,7 +175,10 @@ class SkillPackageService:
continue
raise SkillPackageError(
"files_outside_skill_root",
"skill archive contains files outside the selected skill root",
(
"skill package must contain exactly one skill; "
"multiple skill folders in one archive are not supported"
),
status_code=400,
)
normalized_path = safe_path.removeprefix(skill_root_prefix)

View File

@ -496,13 +496,16 @@ class AgentConfigService:
user_id=user_id,
)
self._require_writable(target, surface=surface)
skill_ref, _package = self._skill_normalizer.normalize(
content=content,
filename=filename,
requested_name=None,
tenant_id=tenant_id,
user_id=user_id,
)
try:
skill_ref, _package = self._skill_normalizer.normalize(
content=content,
filename=filename,
requested_name=None,
tenant_id=tenant_id,
user_id=user_id,
)
except SkillPackageError as exc:
raise AgentConfigServiceError(exc.code, exc.message, status_code=exc.status_code) from exc
agent_soul = target.agent_soul.model_copy(deep=True)
existing = {item.name: item for item in agent_soul.config_skills}
order = [item.name for item in agent_soul.config_skills]

View File

@ -1,6 +1,7 @@
import pytest
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol import (
AgentRunUsage,
DeferredToolCallPayload,
PydanticAIStreamRunEvent,
RunCancelledEvent,
@ -59,7 +60,11 @@ def test_event_adapter_maps_run_succeeded_to_final_output():
RunSucceededEvent(
id="3-0",
run_id="run-1",
data=RunSucceededEventData(output={"summary": "done"}, session_snapshot=snapshot),
data=RunSucceededEventData(
output={"summary": "done"},
session_snapshot=snapshot,
usage=AgentRunUsage(prompt_tokens=2, completion_tokens=3),
),
)
)
@ -69,6 +74,7 @@ def test_event_adapter_maps_run_succeeded_to_final_output():
source_event_id="3-0",
output={"summary": "done"},
session_snapshot=snapshot,
usage={"prompt_tokens": 2, "completion_tokens": 3, "total_tokens": 5},
)
]

View File

@ -318,7 +318,9 @@ def test_agent_app_list_and_create_use_agent_route(
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
)
with app.test_request_context("/console/api/agent?page=1&limit=10&mode=workflow"):
with app.test_request_context(
"/console/api/agent?page=1&limit=10&mode=workflow&sort_by=recently_created&is_created_by_me=true"
):
listed = unwrap(AgentAppListApi.get)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id))
assert listed["page"] == 1
@ -343,6 +345,8 @@ def test_agent_app_list_and_create_use_agent_route(
list_call = cast(dict[str, object], captured["list"])
list_params = cast(Any, list_call["params"])
assert list_params.mode == "agent"
assert list_params.sort_by == "recently_created"
assert list_params.is_created_by_me is True
assert list_params.status == "normal"
with app.test_request_context(
@ -370,20 +374,54 @@ def test_agent_app_list_and_create_use_agent_route(
assert create_params.agent_role == "Coordinator"
def test_agent_app_create_requires_role(app: Flask, account_id: str) -> None:
with app.test_request_context(
"/console/api/agent",
json={"name": "Iris", "description": "Agent app", "icon_type": "emoji", "icon": "robot"},
):
with pytest.raises(ValueError, match="Field required"):
unwrap(AgentAppListApi.post)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id))
def test_agent_app_create_payload_allows_optional_role() -> None:
omitted = roster_controller.AgentAppCreatePayload.model_validate(
{"name": "Iris", "description": "Agent app", "icon_type": "emoji", "icon": "robot"}
)
blank = roster_controller.AgentAppCreatePayload.model_validate(
{"name": "Iris", "description": "Agent app", "role": " ", "icon_type": "emoji", "icon": "robot"}
)
assert omitted.role is None
assert blank.role == ""
def test_agent_app_create_omits_optional_role_as_empty_string(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
) -> None:
captured: dict[str, object] = {}
class FakeAppService:
def create_app(self, tenant_id: str, params: object, account: object) -> object:
captured["create"] = {"tenant_id": tenant_id, "params": params, "account": account}
return _app_detail_obj(id="app-created", bound_agent_id="agent-created")
monkeypatch.setattr(roster_controller, "AppService", FakeAppService)
monkeypatch.setattr(
roster_controller,
"_serialize_agent_app_detail",
lambda app_model, **_kwargs: {"id": "agent-created", "app_id": app_model.id},
)
current_user = SimpleNamespace(id=account_id)
with app.test_request_context(
"/console/api/agent",
json={"name": "Iris", "description": "Agent app", "role": " ", "icon_type": "emoji", "icon": "robot"},
json={
"name": "No-role Iris",
"description": "Agent app",
"icon_type": "emoji",
"icon": "robot",
},
):
with pytest.raises(ValueError, match="Agent role is required"):
unwrap(AgentAppListApi.post)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id))
created, status = unwrap(AgentAppListApi.post)(AgentAppListApi(), "tenant-1", current_user)
assert status == 201
assert created == {"id": "agent-created", "app_id": "app-created"}
create_call = cast(dict[str, object], captured["create"])
create_params = cast(Any, create_call["params"])
assert create_call["tenant_id"] == "tenant-1"
assert create_call["account"] is current_user
assert create_params.agent_role == ""
def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
@ -805,7 +843,7 @@ def test_agent_api_status_and_key_routes_resolve_backing_app(
}
def test_agent_app_update_rejects_empty_role(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_agent_app_update_allows_empty_role(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
app_model = _app_detail_obj(id="app-1", bound_agent_id=agent_id)
captured: dict[str, object] = {}
@ -820,11 +858,28 @@ def test_agent_app_update_rejects_empty_role(app: Flask, monkeypatch: pytest.Mon
"get_app_backing_agent",
lambda _self, **kwargs: SimpleNamespace(
id=agent_id,
app_id="app-1",
backing_app_id=None,
role="",
debug_conversation_id="debug-conversation-detail",
active_config_snapshot_id=None,
),
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_or_create_agent_app_debug_conversation_id",
lambda _self, **kwargs: "debug-conversation-detail",
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"count_agent_app_debug_conversation_messages",
lambda _self, **kwargs: 0,
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"active_config_is_published",
lambda _self, **kwargs: False,
)
monkeypatch.setattr(
roster_controller.FeatureService,
"get_system_features",
@ -845,8 +900,11 @@ def test_agent_app_update_rejects_empty_role(app: Flask, monkeypatch: pytest.Mon
"/console/api/agent/00000000-0000-0000-0000-000000000001",
json={"name": "Renamed", "description": "", "role": "", "icon_type": "emoji", "icon": "R"},
):
with pytest.raises(ValueError, match="String should have at least 1 character"):
unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", SimpleNamespace(id="account-1"), agent_id)
updated = unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", SimpleNamespace(id="account-1"), agent_id)
assert updated["role"] == ""
update_call = cast(dict[str, object], captured["update"])
assert cast(dict[str, object], update_call["args"])["role"] == ""
def test_invite_options_get_parses_app_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
@ -1369,6 +1427,56 @@ def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id(
assert stop_call == {"current_user_id": account_id, "app_model": app_model, "task_id": "task-1"}
def test_agent_chat_stream_preflight_raises_first_error_event() -> None:
class ClosableStream:
def __init__(self) -> None:
self.closed = False
self._chunks = iter(
[
"event: ping\n\n",
(
'data: {"event":"error","message":"Incorrect API key provided",'
'"code":"completion_request_error","status":400}\n\n'
),
]
)
def __iter__(self):
return self
def __next__(self) -> str:
return next(self._chunks)
def close(self) -> None:
self.closed = True
stream = ClosableStream()
with pytest.raises(CompletionRequestError) as exc_info:
completion_controller._raise_agent_stream_error_before_response(stream)
assert "Incorrect API key provided" in exc_info.value.description
assert stream.closed is True
def test_agent_chat_stream_preflight_preserves_first_normal_event() -> None:
stream = iter(
[
"event: ping\n\n",
'data: {"event":"message","answer":"hello"}\n\n',
'data: {"event":"message_end"}\n\n',
]
)
wrapped = completion_controller._raise_agent_stream_error_before_response(stream)
assert list(wrapped) == [
"event: ping\n\n",
'data: {"event":"message","answer":"hello"}\n\n',
'data: {"event":"message_end"}\n\n',
]
def test_agent_build_chat_finalize_route_resolves_app_from_agent_id(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
) -> None:

View File

@ -65,6 +65,7 @@ class TestPluginUploadFileApi:
"sign": "sig",
"tenant_id": "tenant-1",
"user_id": "user-1",
"conversation_id": "conversation-1",
},
file=dummy_file,
)
@ -83,6 +84,10 @@ class TestPluginUploadFileApi:
assert result["id"] == "file-id"
assert result["reference"] == build_file_reference(record_id="file-id")
assert result["preview_url"] == "signed-url"
mock_verify_signature.assert_called_once()
assert mock_verify_signature.call_args.kwargs["conversation_id"] == "conversation-1"
tool_file_manager_instance.create_file_by_raw.assert_called_once()
assert tool_file_manager_instance.create_file_by_raw.call_args.kwargs["conversation_id"] == "conversation-1"
def test_missing_file(self):
module.request = fake_request(

View File

@ -272,6 +272,7 @@ class TestPluginUploadFileRequestApi:
mock_payload = MagicMock()
mock_payload.filename = "test.pdf"
mock_payload.mimetype = "application/pdf"
mock_payload.conversation_id = "conversation-id"
# Act
raw_post = _extract_raw_post(PluginUploadFileRequestApi)
@ -279,7 +280,11 @@ class TestPluginUploadFileRequestApi:
# Assert
mock_get_url.assert_called_once_with(
filename="test.pdf", mimetype="application/pdf", tenant_id="tenant-id", user_id="user-id"
filename="test.pdf",
mimetype="application/pdf",
tenant_id="tenant-id",
user_id="user-id",
conversation_id="conversation-id",
)
assert result["data"]["url"] == "https://storage.example.com/signed-upload-url"

View File

@ -14,6 +14,7 @@ import pytest
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.layers.ask_human import AskHumanToolResult
from dify_agent.protocol import (
AgentRunUsage,
CancelRunRequest,
CancelRunResponse,
PydanticAIStreamRunEvent,
@ -59,13 +60,6 @@ class _FakeCredentialsProvider:
return {"openai_api_key": "sk-test"}
@pytest.fixture(autouse=True)
def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
class _NoToolsBuilder:
def build_layers(self, **kwargs):
del kwargs
@ -75,12 +69,16 @@ class _NoToolsBuilder:
class _FakeQueueManager:
def __init__(self) -> None:
self.events: list[Any] = []
self._stop_requested = False
def publish(self, event: Any, _from: Any) -> None:
self.events.append(event)
def is_stopped(self) -> bool:
return False
return self._stop_requested
def request_stop(self) -> None:
self._stop_requested = True
class _StoppedQueueManager(_FakeQueueManager):
@ -143,10 +141,65 @@ class _StreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
data=RunSucceededEventData(
output={"text": "hello agent"},
session_snapshot=CompositorSessionSnapshot(layers=[]),
usage=AgentRunUsage(prompt_tokens=3, completion_tokens=5),
),
)
class _StreamingRecordingFakeAgentBackendRunClient(_RecordingFakeAgentBackendRunClient):
@override
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
del after
created_at = datetime(2026, 1, 1, tzinfo=UTC)
yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at)
yield PydanticAIStreamRunEvent(
id="2-0",
run_id=run_id,
created_at=created_at,
data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello ")),
)
yield PydanticAIStreamRunEvent(
id="3-0",
run_id=run_id,
created_at=created_at,
data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="agent")),
)
yield RunSucceededEvent(
id="4-0",
run_id=run_id,
created_at=created_at,
data=RunSucceededEventData(
output={"text": "hello agent"},
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
)
class _StreamingStopAfterFirstDeltaFakeAgentBackendRunClient(_RecordingFakeAgentBackendRunClient):
def __init__(self, *, queue_manager: _FakeQueueManager, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._queue_manager = queue_manager
@override
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
del after
created_at = datetime(2026, 1, 1, tzinfo=UTC)
yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at)
yield PydanticAIStreamRunEvent(
id="2-0",
run_id=run_id,
created_at=created_at,
data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello ")),
)
self._queue_manager.request_stop()
yield PydanticAIStreamRunEvent(
id="3-0",
run_id=run_id,
created_at=created_at,
data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="agent")),
)
class _StreamingPartStartFakeAgentBackendRunClient(FakeAgentBackendRunClient):
@override
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
@ -170,6 +223,46 @@ class _StreamingPartStartFakeAgentBackendRunClient(FakeAgentBackendRunClient):
)
class _NullOutputFakeAgentBackendRunClient(FakeAgentBackendRunClient):
@override
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
del after
created_at = datetime(2026, 1, 1, tzinfo=UTC)
yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at)
yield RunSucceededEvent(
id="2-0",
run_id=run_id,
created_at=created_at,
data=RunSucceededEventData(
output=None,
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
)
class _StreamingTextNullOutputFakeAgentBackendRunClient(FakeAgentBackendRunClient):
@override
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
del after
created_at = datetime(2026, 1, 1, tzinfo=UTC)
yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at)
yield PydanticAIStreamRunEvent(
id="2-0",
run_id=run_id,
created_at=created_at,
data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="streamed answer")),
)
yield RunSucceededEvent(
id="3-0",
run_id=run_id,
created_at=created_at,
data=RunSucceededEventData(
output=None,
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
)
class _ProcessStreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
@override
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
@ -276,6 +369,19 @@ class _FakeSessionStore:
)
class _MonotonicClock:
def __init__(self, *values: float) -> None:
self._values = list(values)
self._index = 0
def __call__(self) -> float:
if self._index >= len(self._values):
return self._values[-1]
value = self._values[self._index]
self._index += 1
return value
def _soul() -> AgentSoulConfig:
return AgentSoulConfig.model_validate(
{
@ -299,7 +405,12 @@ def _dify_ctx() -> Any:
)
def _runner(client: FakeAgentBackendRunClient, store: _FakeSessionStore) -> AgentAppRunner:
def _runner(
client: FakeAgentBackendRunClient,
store: _FakeSessionStore,
*,
text_delta_debounce_seconds: float | None = 0,
) -> AgentAppRunner:
return AgentAppRunner(
request_builder=AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
@ -308,6 +419,7 @@ def _runner(client: FakeAgentBackendRunClient, store: _FakeSessionStore) -> Agen
agent_backend_client=client,
event_adapter=AgentBackendRunEventAdapter(),
session_store=store, # type: ignore[arg-type]
text_delta_debounce_seconds=text_delta_debounce_seconds,
)
@ -374,15 +486,10 @@ def test_successful_turn_publishes_chunk_and_message_end_and_saves_session():
assert saved_scope.agent_config_snapshot_id == "snap-1"
assert saved_run_id == "fake-run-1"
assert saved_snapshot is not None
assert saved_specs
# A successful turn carries no ask_human pause correlation.
assert pending_form_id is None
assert pending_tool_call_id is None
assert [spec.name for spec in saved_specs] == [
"agent_soul_prompt",
"agent_app_user_prompt",
"execution_context",
"history",
]
def test_successful_turn_forwards_agent_backend_stream_text_deltas_without_duplicate_terminal_chunk():
@ -390,13 +497,16 @@ def test_successful_turn_forwards_agent_backend_stream_text_deltas_without_dupli
store = _FakeSessionStore()
qm = _FakeQueueManager()
_run(_runner(client, store), qm)
_run(_runner(client, store, text_delta_debounce_seconds=0), qm)
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
assert [event.chunk.delta.message.content for event in chunk_events] == ["hello ", "agent"]
assert len(end_events) == 1
assert end_events[0].llm_result.message.content == "hello agent"
assert end_events[0].llm_result.usage.prompt_tokens == 3
assert end_events[0].llm_result.usage.completion_tokens == 5
assert end_events[0].llm_result.usage.total_tokens == 8
assert store.saved
@ -405,7 +515,7 @@ def test_successful_turn_forwards_part_start_text_and_publishes_missing_terminal
store = _FakeSessionStore()
qm = _FakeQueueManager()
_run(_runner(client, store), qm)
_run(_runner(client, store, text_delta_debounce_seconds=0), qm)
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
@ -414,6 +524,34 @@ def test_successful_turn_forwards_part_start_text_and_publishes_missing_terminal
assert end_events[0].llm_result.message.content == "hello agent"
def test_successful_turn_with_null_terminal_output_publishes_empty_answer_not_literal_null():
client = _NullOutputFakeAgentBackendRunClient()
store = _FakeSessionStore()
qm = _FakeQueueManager()
_run(_runner(client, store), qm)
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
assert chunk_events == []
assert len(end_events) == 1
assert end_events[0].llm_result.message.content == ""
def test_successful_turn_with_streamed_text_and_null_terminal_output_keeps_streamed_answer():
client = _StreamingTextNullOutputFakeAgentBackendRunClient()
store = _FakeSessionStore()
qm = _FakeQueueManager()
_run(_runner(client, store, text_delta_debounce_seconds=0), qm)
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
assert [event.chunk.delta.message.content for event in chunk_events] == ["streamed answer"]
assert len(end_events) == 1
assert end_events[0].llm_result.message.content == "streamed answer"
def test_successful_turn_persists_thinking_and_tool_process_events(monkeypatch):
fake_session = _FakeDbSession()
monkeypatch.setattr(app_runner_module.db, "session", fake_session)
@ -421,7 +559,7 @@ def test_successful_turn_persists_thinking_and_tool_process_events(monkeypatch):
store = _FakeSessionStore()
qm = _FakeQueueManager()
_run(_runner(client, store), qm)
_run(_runner(client, store, text_delta_debounce_seconds=0), qm)
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
assert [event.chunk.delta.message.content for event in chunk_events] == ["final answer"]
@ -436,6 +574,50 @@ def test_successful_turn_persists_thinking_and_tool_process_events(monkeypatch):
assert rows[1].observation == "ok"
def test_streaming_turn_batches_text_deltas_within_debounce_window(monkeypatch):
monkeypatch.setattr(app_runner_module.time, "monotonic", _MonotonicClock(0.0, 0.2))
client = _StreamingFakeAgentBackendRunClient()
store = _FakeSessionStore()
qm = _FakeQueueManager()
_run(_runner(client, store, text_delta_debounce_seconds=0.5), qm)
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
assert [event.chunk.delta.message.content for event in chunk_events] == ["hello agent"]
assert len(end_events) == 1
assert end_events[0].llm_result.message.content == "hello agent"
def test_streaming_turn_flushes_pending_text_before_terminal_success(monkeypatch):
monkeypatch.setattr(app_runner_module.time, "monotonic", _MonotonicClock(0.0))
client = _StreamingPartStartFakeAgentBackendRunClient()
store = _FakeSessionStore()
qm = _FakeQueueManager()
_run(_runner(client, store, text_delta_debounce_seconds=0.5), qm)
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
assert [event.chunk.delta.message.content for event in chunk_events] == ["hello", " agent"]
assert len(end_events) == 1
assert end_events[0].llm_result.message.content == "hello agent"
def test_streaming_turn_flushes_pending_text_before_stop_and_cancel(monkeypatch):
monkeypatch.setattr(app_runner_module.time, "monotonic", _MonotonicClock(0.0))
store = _FakeSessionStore()
qm = _FakeQueueManager()
client = _StreamingStopAfterFirstDeltaFakeAgentBackendRunClient(queue_manager=qm)
with pytest.raises(GenerateTaskStoppedError):
_run(_runner(client, store, text_delta_debounce_seconds=0.5), qm)
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
assert [event.chunk.delta.message.content for event in chunk_events] == ["hello "]
assert client.cancelled_run_ids == ["fake-run-1"]
def test_tool_result_without_identity_does_not_attach_to_previous_tool(monkeypatch):
fake_session = _FakeDbSession()
monkeypatch.setattr(app_runner_module.db, "session", fake_session)
@ -611,6 +793,7 @@ def test_stopped_task_cancels_agent_backend_run_and_skips_session_save():
def test_extract_answer_handles_plain_string_and_dict():
assert AgentAppRunner._extract_answer(None) == ""
assert AgentAppRunner._extract_answer("plain text") == "plain text"
assert AgentAppRunner._extract_answer({"text": "hi"}) == "hi"
assert AgentAppRunner._extract_answer({"a": 1}) == '{"a": 1}'

View File

@ -191,6 +191,8 @@ class TestAgentAppRuntimeRequestBuilder:
"agent_soul_prompt",
"agent_app_user_prompt",
"execution_context",
DIFY_SHELL_LAYER_ID,
DIFY_CONFIG_LAYER_ID,
"history",
"llm",
]
@ -394,10 +396,7 @@ def _soul_with_model_and_skill() -> AgentSoulConfig:
class TestAgentAppConfigLayer:
def test_config_layer_injected_when_flag_enabled(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
def test_config_layer_injected(self):
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
@ -423,10 +422,7 @@ class TestAgentAppConfigLayer:
assert names.index(DIFY_SHELL_LAYER_ID) == names.index("execution_context") + 1
assert names.index(DIFY_CONFIG_LAYER_ID) == names.index(DIFY_SHELL_LAYER_ID) + 1
def test_no_config_layer_when_agent_soul_has_no_config_assets(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
def test_config_layer_present_when_agent_soul_has_no_config_assets(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
@ -436,14 +432,20 @@ class TestAgentAppConfigLayer:
result = builder.build(_ctx(_soul_with_model()))
layers = {layer.name: layer for layer in result.request.composition.layers}
assert DIFY_CONFIG_LAYER_ID not in layers
assert layers[DIFY_CONFIG_LAYER_ID].config.model_dump(mode="json") == {
"agent_id": "agent-1",
"config_version": {"id": "snap-1", "kind": "snapshot", "writable": False},
"skills": [],
"files": [],
"env_keys": [],
"note": "",
"mentioned_skill_names": [],
"mentioned_file_names": [],
}
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": "execution_context"}
assert layers[DIFY_SHELL_LAYER_ID].config.agent_stub_drive_ref is None
def test_config_layer_for_build_draft_marks_config_writable(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
def test_config_layer_for_build_draft_marks_config_writable(self):
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
@ -470,17 +472,6 @@ class TestAgentAppConfigLayer:
"mentioned_file_names": [],
}
def test_no_config_layer_when_flag_disabled(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(_soul_with_model_and_skill()))
assert all(layer.name != DIFY_CONFIG_LAYER_ID for layer in result.request.composition.layers)
@pytest.mark.parametrize(
("system_prompt", "expected_prefix"),
[
@ -496,13 +487,9 @@ class TestAgentAppConfigLayer:
)
def test_agent_app_runtime_expands_config_mentions_in_agent_soul_prompt(
self,
monkeypatch: pytest.MonkeyPatch,
system_prompt: str,
expected_prefix: str,
):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
soul = _soul_with_model_and_skill()
soul.prompt.system_prompt = system_prompt
builder = AgentAppRuntimeRequestBuilder(
@ -518,11 +505,7 @@ class TestAgentAppConfigLayer:
def test_agent_app_runtime_missing_config_mentions_fall_back_without_marker_leak(
self,
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:ghost-skill:Ghost Skill§], [§file:ghost.txt:Ghost File§], and [§file:no-label.txt§]."
@ -537,3 +520,8 @@ class TestAgentAppConfigLayer:
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert prompt_layer.config.prefix == "Use Ghost Skill, Ghost File, and no-label.txt."
assert "" not in prompt_layer.config.prefix
assert [warning["code"] for warning in result.metadata["runtime_support"]["unsupported_runtime_warnings"]] == [
"mention_target_missing",
"mention_target_missing",
"mention_target_missing",
]

View File

@ -14,22 +14,22 @@ from core.helper.marketplace import (
def test_get_plugin_pkg_url_contains_unique_identifier() -> None:
url = get_plugin_pkg_url("plugin@1.0.0")
url = get_plugin_pkg_url("langgenius/openai:0.4.2@checksum")
assert "api/v1/plugins/download" in url
assert "unique_identifier=plugin@1.0.0" in url
assert "unique_identifier=langgenius%2Fopenai%3A0.4.2%40checksum" in url
def test_download_plugin_pkg_delegates_with_configured_size(mocker: MockerFixture) -> None:
mocked_download = mocker.patch("core.helper.marketplace.download_with_size_limit", return_value=b"pkg")
mocker.patch("core.helper.marketplace.dify_config.PLUGIN_MAX_PACKAGE_SIZE", 1234)
result = download_plugin_pkg("plugin.a.b")
result = download_plugin_pkg("langgenius/openai:0.4.2@checksum")
assert result == b"pkg"
mocked_download.assert_called_once()
called_url, called_limit = mocked_download.call_args.args
assert "unique_identifier=plugin.a.b" in called_url
assert "unique_identifier=langgenius%2Fopenai%3A0.4.2%40checksum" in called_url
assert called_limit == 1234

View File

@ -95,6 +95,16 @@ def _metadata_condition() -> AppMetadataFilteringCondition:
return AppMetadataFilteringCondition(logical_operator="and", conditions=[])
@contextmanager
def _patched_retriever_session():
session = MagicMock()
session_ctx = MagicMock()
session_ctx.__enter__.return_value = session
session_ctx.__exit__.return_value = None
with patch("core.rag.retrieval.dataset_retrieval.session_factory.create_session", return_value=session_ctx):
yield session
def create_side_effect_for_search(documents: list[Document]):
"""
Create a side effect function for mocking search methods.
@ -1682,7 +1692,15 @@ class TestRetrievalService:
# Mock _retriever to return documents
def side_effect_retriever(
flask_app, dataset_id, query, top_k, all_documents, document_ids_filter, metadata_condition, attachment_ids
flask_app,
session,
dataset_id,
query,
top_k,
all_documents,
document_ids_filter,
metadata_condition,
attachment_ids,
):
all_documents.extend([doc1, doc2])
@ -1694,23 +1712,24 @@ class TestRetrievalService:
all_documents = []
# Act - Call with dataset_count = 1
dataset_retrieval._multiple_retrieve_thread(
flask_app=mock_flask_app,
available_datasets=[mock_dataset],
metadata_condition=None,
metadata_filter_document_ids=None,
all_documents=all_documents,
tenant_id=tenant_id,
reranking_enable=True,
reranking_mode="reranking_model",
reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"},
weights=None,
top_k=5,
score_threshold=0.5,
query="test query",
attachment_id=None,
dataset_count=1, # Single dataset - should skip second reranking
)
with _patched_retriever_session():
dataset_retrieval._multiple_retrieve_thread(
flask_app=mock_flask_app,
available_datasets=[mock_dataset],
metadata_condition=None,
metadata_filter_document_ids=None,
all_documents=all_documents,
tenant_id=tenant_id,
reranking_enable=True,
reranking_mode="reranking_model",
reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"},
weights=None,
top_k=5,
score_threshold=0.5,
query="test query",
attachment_id=None,
dataset_count=1, # Single dataset - should skip second reranking
)
# Assert
# DataPostProcessor should NOT be called (second reranking skipped)
@ -1757,7 +1776,15 @@ class TestRetrievalService:
# Mock _retriever to return documents
def side_effect_retriever(
flask_app, dataset_id, query, top_k, all_documents, document_ids_filter, metadata_condition, attachment_ids
flask_app,
session,
dataset_id,
query,
top_k,
all_documents,
document_ids_filter,
metadata_condition,
attachment_ids,
):
all_documents.extend([doc1, doc2])
@ -1793,23 +1820,24 @@ class TestRetrievalService:
mock_dataset2.provider = "dify"
# Act - Call with dataset_count = 2
dataset_retrieval._multiple_retrieve_thread(
flask_app=mock_flask_app,
available_datasets=[mock_dataset, mock_dataset2],
metadata_condition=None,
metadata_filter_document_ids=None,
all_documents=all_documents,
tenant_id=tenant_id,
reranking_enable=True,
reranking_mode="reranking_model",
reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"},
weights=None,
top_k=5,
score_threshold=0.5,
query="test query",
attachment_id=None,
dataset_count=2, # Multiple datasets - should perform second reranking
)
with _patched_retriever_session():
dataset_retrieval._multiple_retrieve_thread(
flask_app=mock_flask_app,
available_datasets=[mock_dataset, mock_dataset2],
metadata_condition=None,
metadata_filter_document_ids=None,
all_documents=all_documents,
tenant_id=tenant_id,
reranking_enable=True,
reranking_mode="reranking_model",
reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"},
weights=None,
top_k=5,
score_threshold=0.5,
query="test query",
attachment_id=None,
dataset_count=2, # Multiple datasets - should perform second reranking
)
# Assert
# DataPostProcessor SHOULD be called (second reranking performed)
@ -1867,7 +1895,15 @@ class TestRetrievalService:
# Mock _retriever to return documents
def side_effect_retriever(
flask_app, dataset_id, query, top_k, all_documents, document_ids_filter, metadata_condition, attachment_ids
flask_app,
session,
dataset_id,
query,
top_k,
all_documents,
document_ids_filter,
metadata_condition,
attachment_ids,
):
all_documents.extend([doc1, doc2])
@ -1889,23 +1925,24 @@ class TestRetrievalService:
all_documents = []
# Act - Call with dataset_count = 1
dataset_retrieval._multiple_retrieve_thread(
flask_app=mock_flask_app,
available_datasets=[mock_dataset],
metadata_condition=None,
metadata_filter_document_ids=None,
all_documents=all_documents,
tenant_id=tenant_id,
reranking_enable=True, # Reranking enabled but should be skipped for single dataset
reranking_mode="reranking_model",
reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"},
weights=None,
top_k=5,
score_threshold=0.5,
query="test query",
attachment_id=None,
dataset_count=1,
)
with _patched_retriever_session():
dataset_retrieval._multiple_retrieve_thread(
flask_app=mock_flask_app,
available_datasets=[mock_dataset],
metadata_condition=None,
metadata_filter_document_ids=None,
all_documents=all_documents,
tenant_id=tenant_id,
reranking_enable=True, # Reranking enabled but should be skipped for single dataset
reranking_mode="reranking_model",
reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"},
weights=None,
top_k=5,
score_threshold=0.5,
query="test query",
attachment_id=None,
dataset_count=1,
)
# Assert
# DataPostProcessor should NOT be called
@ -3720,7 +3757,15 @@ class TestKnowledgeRetrievalRegression:
)
def fake_retriever(
flask_app, dataset_id, query, top_k, all_documents, document_ids_filter, metadata_condition, attachment_ids
flask_app,
session,
dataset_id,
query,
top_k,
all_documents,
document_ids_filter,
metadata_condition,
attachment_ids,
):
all_documents.append(document)
@ -3744,32 +3789,35 @@ class TestKnowledgeRetrievalRegression:
thread_exceptions: list[Exception] = []
def target():
with patch.object(dataset_retrieval, "_retriever", side_effect=fake_retriever):
with patch(
with (
patch.object(dataset_retrieval, "_retriever", side_effect=fake_retriever),
patch(
"core.rag.retrieval.dataset_retrieval.DataPostProcessor",
ContextRequiredPostProcessor,
):
dataset_retrieval._multiple_retrieve_thread(
flask_app=flask_app,
available_datasets=[mock_dataset, secondary_dataset],
metadata_condition=None,
metadata_filter_document_ids=None,
all_documents=all_documents,
tenant_id=tenant_id,
reranking_enable=True,
reranking_mode="reranking_model",
reranking_model={
"reranking_provider_name": "cohere",
"reranking_model_name": "rerank-v2",
},
weights=None,
top_k=3,
score_threshold=0.0,
query="test query",
attachment_id=None,
dataset_count=2, # force reranking branch
thread_exceptions=thread_exceptions, # ✅ key
)
),
_patched_retriever_session(),
):
dataset_retrieval._multiple_retrieve_thread(
flask_app=flask_app,
available_datasets=[mock_dataset, secondary_dataset],
metadata_condition=None,
metadata_filter_document_ids=None,
all_documents=all_documents,
tenant_id=tenant_id,
reranking_enable=True,
reranking_mode="reranking_model",
reranking_model={
"reranking_provider_name": "cohere",
"reranking_model_name": "rerank-v2",
},
weights=None,
top_k=3,
score_threshold=0.0,
query="test query",
attachment_id=None,
dataset_count=2, # force reranking branch
thread_exceptions=thread_exceptions,
)
t = threading.Thread(target=target)
t.start()
@ -3781,6 +3829,53 @@ class TestKnowledgeRetrievalRegression:
# Current buggy code should record an exception (not raise it)
assert not thread_exceptions, thread_exceptions
def test_run_retriever_thread_provides_session_to_retriever(self):
dataset_retrieval = DatasetRetrieval()
all_documents: list[Document] = []
with _patched_retriever_session() as session:
with patch.object(dataset_retrieval, "_retriever") as mock_retriever:
dataset_retrieval._run_retriever_thread(
flask_app=_FakeFlaskApp(),
dataset_id="dataset-1",
query="test query",
top_k=3,
all_documents=all_documents,
document_ids_filter=None,
metadata_condition=None,
attachment_ids=None,
cancel_event=None,
thread_exceptions=[],
)
mock_retriever.assert_called_once()
assert mock_retriever.call_args.kwargs["session"] is session
def test_run_retriever_thread_records_retriever_exception(self):
dataset_retrieval = DatasetRetrieval()
all_documents: list[Document] = []
cancel_event = threading.Event()
thread_exceptions: list[Exception] = []
expected_error = RuntimeError("retrieval failed")
with _patched_retriever_session():
with patch.object(dataset_retrieval, "_retriever", side_effect=expected_error):
dataset_retrieval._run_retriever_thread(
flask_app=_FakeFlaskApp(),
dataset_id="dataset-1",
query="test query",
top_k=3,
all_documents=all_documents,
document_ids_filter=None,
metadata_condition=None,
attachment_ids=None,
cancel_event=cancel_event,
thread_exceptions=thread_exceptions,
)
assert cancel_event.is_set()
assert thread_exceptions == [expected_error]
class _FakeFlaskApp:
def app_context(self):

View File

@ -138,6 +138,7 @@ def test_get_signed_file_url_for_plugin_and_verify_roundtrip(monkeypatch: pytest
mimetype="application/pdf",
tenant_id="tenant-id",
user_id="user-id",
conversation_id="conversation-id",
)
parsed = urlparse(url)
query = parse_qs(parsed.query)
@ -146,12 +147,14 @@ def test_get_signed_file_url_for_plugin_and_verify_roundtrip(monkeypatch: pytest
assert parsed.path == "/files/upload/for-plugin"
assert query["tenant_id"] == ["tenant-id"]
assert query["user_id"] == ["user-id"]
assert query["conversation_id"] == ["conversation-id"]
assert (
verify_plugin_file_signature(
filename="report.pdf",
mimetype="application/pdf",
tenant_id="tenant-id",
user_id="user-id",
conversation_id="conversation-id",
timestamp=query["timestamp"][0],
nonce=query["nonce"][0],
sign=query["sign"][0],

View File

@ -2,7 +2,6 @@ from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, patch
import pytest
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.layers.ask_human import AskHumanToolResult
from dify_agent.protocol import RunStartedEvent, RunSucceededEvent, RunSucceededEventData
@ -51,13 +50,6 @@ class FakeCredentialsProvider:
return {"api_key": "secret-key"}
@pytest.fixture(autouse=True)
def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
def _restored_file(*, transfer_method: FileTransferMethod, reference: str) -> File:
return File(
type=FileType.DOCUMENT,
@ -281,7 +273,8 @@ def test_agent_node_run_maps_successful_agent_backend_run_to_node_result():
assert agent_log["agent_backend"]["run_id"] == "fake-run-1"
assert agent_log["agent_backend"]["status"] == "succeeded"
assert result.process_data["agent_id"] == "agent-1"
assert result.inputs["agent_backend_request"]["composition"]["layers"][5]["config"]["credentials"] == "[REDACTED]"
layers = {layer["name"]: layer for layer in result.inputs["agent_backend_request"]["composition"]["layers"]}
assert layers["llm"]["config"]["credentials"] == "[REDACTED]"
def test_agent_node_run_normalizes_declared_file_output_with_canonical_mapping():

View File

@ -148,6 +148,31 @@ def _file_tool() -> FakeTool:
return FakeTool(entity=entity, runtime=runtime)
def _files_tool() -> FakeTool:
parameters = [
ToolParameter(
name="documents",
label=I18nObject(en_US="Documents"),
type=ToolParameter.ToolParameterType.FILES,
form=ToolParameter.ToolParameterForm.LLM,
required=True,
llm_description="The documents to inspect.",
)
]
entity = ToolEntity(
identity=ToolIdentity(
author="langgenius",
name="inspect",
label=I18nObject(en_US="Inspect"),
provider="documents",
),
description=ToolDescription(human=I18nObject(en_US="Inspect"), llm="Inspect documents."),
parameters=parameters,
)
runtime = ToolRuntime(tenant_id="tenant-1", user_id="user-1", credentials={}, runtime_parameters={})
return FakeTool(entity=entity, runtime=runtime)
def _tts_tool() -> FakeTool:
parameters = [
ToolParameter(
@ -274,6 +299,103 @@ def test_builds_core_tool_with_file_llm_parameter():
assert runtime_provider.last_use_default_for_missing_form_parameters is True
def test_builds_plugin_tool_with_file_llm_parameter_schema():
runtime_provider = FakeRuntimeProvider(_file_tool())
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
{
"provider_id": "langgenius/audio/audio",
"provider_type": "plugin",
"tool_name": "asr",
"credential_type": "unauthorized",
}
]
}
)
result = _build(builder, tools)
assert result is not None
schema = result.tools[0].parameters_json_schema
file_schema = schema["properties"]["audio_file"]
assert file_schema["anyOf"][0]["type"] == "string"
assert file_schema["anyOf"][1]["properties"]["transfer_method"]["enum"] == ["remote_url"]
assert file_schema["anyOf"][2]["properties"]["transfer_method"]["enum"] == [
"local_file",
"tool_file",
"datasource_file",
]
assert schema["required"] == ["audio_file"]
def test_builds_plugin_tool_with_files_llm_parameter_schema():
runtime_provider = FakeRuntimeProvider(_files_tool())
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
{
"provider_id": "langgenius/documents/documents",
"provider_type": "plugin",
"tool_name": "inspect",
"credential_type": "unauthorized",
}
]
}
)
result = _build(builder, tools)
assert result is not None
schema = result.tools[0].parameters_json_schema
files_schema = schema["properties"]["documents"]
assert files_schema["type"] == "array"
assert files_schema["items"]["anyOf"][0]["description"] == "HTTP(S) URL or sandbox-local file path."
assert schema["required"] == ["documents"]
def test_builds_builtin_compat_plugin_tool_with_files_llm_parameter_schema():
runtime_provider = FakeRuntimeProvider(_files_tool())
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
{
"provider_id": "langgenius/dify-gmail/dify-gmail",
"provider_type": "builtin",
"provider": "langgenius/dify-gmail/dify-gmail",
"tool_name": "add_attachment_to_draft",
"credential_type": "api-key",
"credential_id": "credential-1",
}
]
}
)
result = builder.build_layers(
tenant_id="tenant-1",
app_id="app-1",
user_id="user-1",
tools=tools,
invoke_from=InvokeFrom.DEBUGGER,
)
assert result.plugin_tools is not None
assert result.core_tools is None
prepared = result.plugin_tools.tools[0]
assert prepared.plugin_id == "langgenius/dify-gmail"
assert prepared.provider == "dify-gmail"
assert prepared.tool_name == "add_attachment_to_draft"
files_schema = prepared.parameters_json_schema["properties"]["documents"]
assert files_schema["type"] == "array"
assert files_schema["items"]["anyOf"][0]["description"] == "HTTP(S) URL or sandbox-local file path."
assert prepared.parameters_json_schema["required"] == ["documents"]
assert runtime_provider.last_agent_tool is not None
assert runtime_provider.last_agent_tool.provider_type.value == "builtin"
def test_build_layers_routes_plugin_direct_and_builtin_via_core() -> None:
runtime_provider = FakeRuntimeProvider(_tool())
builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider)

View File

@ -47,13 +47,6 @@ class FakeCredentialsProvider:
return {"api_key": "secret-key"}
@pytest.fixture(autouse=True)
def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
class CapturingCredentialsProvider:
def __init__(self) -> None:
self.provider_name: str | None = None
@ -491,7 +484,8 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys():
"secret_refs": [
{"variable": "TOKEN", "credential_id": "credential-1"},
{"name": "API_KEY", "provider_credential_id": "credential-2"},
{"name": "EDITABLE_TOKEN", "value": "credential-3"},
{"name": "EDITABLE_TOKEN", "value": "inline-secret-value"},
{"name": "LEGACY_SECRET_REF", "id": "credential-3"},
{"ref": "missing-name"},
],
},
@ -508,11 +502,12 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys():
assert config["env"] == [
{"name": "PROJECT_NAME", "value": "demo"},
{"name": "RETRY_COUNT", "value": "3"},
{"name": "EDITABLE_TOKEN", "value": "inline-secret-value"},
]
assert config["secret_refs"] == [
{"name": "TOKEN", "ref": "credential-1"},
{"name": "API_KEY", "ref": "credential-2"},
{"name": "EDITABLE_TOKEN", "ref": "credential-3"},
{"name": "LEGACY_SECRET_REF", "ref": "credential-3"},
]
assert config["sandbox"] is None
@ -613,7 +608,37 @@ def test_build_shell_layer_config_maps_cli_tool_scoped_env():
]
def test_builds_workflow_run_request_with_dify_plugin_tools_layer():
def test_build_shell_layer_config_maps_cli_tool_inline_secret_value_to_env():
agent_soul = AgentSoulConfig.model_validate(
{
"tools": {
"cli_tools": [
{
"name": "github",
"command": "apt-get install -y gh",
"env": {
"secret_refs": [{"name": "GITHUB_TOKEN", "value": "ghp_" + "x" * 300}],
},
}
]
}
}
)
config = build_shell_layer_config(agent_soul).model_dump(mode="json")
assert config["cli_tools"] == [
{
"name": "github",
"install_commands": ["apt-get install -y gh"],
"env": [{"name": "GITHUB_TOKEN", "value": "ghp_" + "x" * 300}],
"secret_refs": [],
}
]
def test_builds_workflow_run_request_with_dify_plugin_tools_layer(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True)
context = _context()
snapshot = AgentConfigSnapshot(
id="snapshot-1",
@ -649,7 +674,10 @@ def test_builds_workflow_run_request_with_dify_plugin_tools_layer():
dumped = result.request.model_dump(mode="json")
layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]}
assert layers[DIFY_PLUGIN_TOOLS_LAYER_ID]["type"] == "dify.plugin.tools"
assert layers[DIFY_PLUGIN_TOOLS_LAYER_ID]["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
assert layers[DIFY_PLUGIN_TOOLS_LAYER_ID]["deps"] == {
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
"shell": DIFY_SHELL_LAYER_ID,
}
assert layers[DIFY_PLUGIN_TOOLS_LAYER_ID]["config"]["tools"][0]["tool_name"] == "current_time"
assert result.metadata["agent_tools"] == {
"dify_tool_count": 1,
@ -1300,7 +1328,7 @@ def test_build_config_layer_config_includes_soul_context_and_mentions():
assert warnings == []
def test_build_config_layer_config_returns_none_for_empty_agent_soul():
def test_build_config_layer_config_returns_empty_config_for_empty_agent_soul():
from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config
soul = AgentSoulConfig(
@ -1308,30 +1336,43 @@ def test_build_config_layer_config_returns_none_for_empty_agent_soul():
)
config, warnings = build_config_layer_config(soul)
assert config is None
assert config is not None
assert config.model_dump(mode="json") == {
"agent_id": None,
"config_version": {"id": None, "kind": "snapshot", "writable": False},
"skills": [],
"files": [],
"env_keys": [],
"note": "",
"mentioned_skill_names": [],
"mentioned_file_names": [],
}
assert warnings == []
def test_workflow_run_request_has_no_config_layer_with_empty_agent_soul(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
def test_workflow_run_request_has_config_layer_with_empty_agent_soul(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True)
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(_context())
dumped = result.request.model_dump(mode="json")
layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]}
assert DIFY_CONFIG_LAYER_ID not in layers
assert layers[DIFY_CONFIG_LAYER_ID]["config"] == {
"agent_id": "agent-1",
"config_version": {"id": "snapshot-1", "kind": "snapshot", "writable": False},
"skills": [],
"files": [],
"env_keys": [],
"note": "",
"mentioned_skill_names": [],
"mentioned_file_names": [],
}
assert layers[DIFY_SHELL_LAYER_ID]["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
assert layers[DIFY_SHELL_LAYER_ID]["config"]["agent_stub_drive_ref"] is None
def test_workflow_run_request_contains_config_layer_when_flag_enabled(monkeypatch: pytest.MonkeyPatch):
def test_workflow_run_request_contains_config_layer():
"""Contract test: locks the dify.config composition shape against cross-package drift."""
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
context = _context()
context.snapshot.config_snapshot = _soul_with_config_assets()
@ -1372,10 +1413,7 @@ def test_workflow_run_request_contains_config_layer_when_flag_enabled(monkeypatc
assert any(spec.name == DIFY_CONFIG_LAYER_ID and spec.type == "dify.config" for spec in specs)
def test_workflow_runtime_expands_config_mentions_in_agent_soul_prompt(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
def test_workflow_runtime_expands_config_mentions_in_agent_soul_prompt():
context = _context()
context.snapshot.config_snapshot = _soul_with_config_assets()
@ -1386,10 +1424,7 @@ def test_workflow_runtime_expands_config_mentions_in_agent_soul_prompt(monkeypat
assert "" not in soul_prompt.config.prefix
def test_workflow_runtime_missing_config_mentions_fall_back_to_label_then_name(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
def test_workflow_runtime_missing_config_mentions_fall_back_to_label_then_name():
context = _context()
context.snapshot.config_snapshot = AgentSoulConfig(
prompt={
@ -1405,20 +1440,11 @@ def test_workflow_runtime_missing_config_mentions_fall_back_to_label_then_name(m
soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert soul_prompt.config.prefix == "Use Ghost Skill, Ghost File, and no-label.txt."
assert "" not in soul_prompt.config.prefix
def test_workflow_run_request_has_no_config_layer_when_flag_disabled(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
context = _context()
context.snapshot.config_snapshot = _soul_with_config_assets()
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
dumped = result.request.model_dump(mode="json")
assert all(layer["name"] != DIFY_CONFIG_LAYER_ID for layer in dumped["composition"]["layers"])
assert result.metadata["runtime_support"]["unsupported_runtime_warnings"] == []
assert [warning["code"] for warning in result.metadata["runtime_support"]["unsupported_runtime_warnings"]] == [
"mention_target_missing",
"mention_target_missing",
"mention_target_missing",
]
def test_build_config_layer_config_missing_mentions_warn_without_catalog():

View File

@ -124,6 +124,38 @@ def test_agent_app_soul_allows_app_features_and_variables():
assert payload.agent_soul.app_variables[0].name == "company_name"
def test_agent_app_soul_accepts_legacy_follow_up_model_config():
payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.AGENT_APP,
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION,
"agent_soul": {
"app_features": {
"suggested_questions_after_answer": {
"enabled": True,
"prompt": "Suggest useful follow-up questions.",
"model": {
"provider": "openai",
"name": "gpt-4o-mini",
"mode": "chat",
"completion_params": {"temperature": 0.7, "max_tokens": 128},
},
},
},
},
}
)
assert payload.agent_soul is not None
follow_up = payload.agent_soul.app_features.suggested_questions_after_answer
assert follow_up is not None
assert follow_up.enabled is True
assert follow_up.model is not None
assert follow_up.model.provider == "openai"
assert follow_up.model.name == "gpt-4o-mini"
assert follow_up.model.completion_params == {"temperature": 0.7, "max_tokens": 128}
def test_composer_save_payload_accepts_new_roster_metadata():
payload = ComposerSavePayload.model_validate(
{

View File

@ -50,6 +50,23 @@ def test_resolve_source_filters_accepts_multiple_structured_sources() -> None:
assert AgentObservabilityService.resolve_source_filters(("all", "webapp:app-1"))[0].kind == "all"
def test_statistics_all_source_includes_debugger_messages() -> None:
source_filter = AgentObservabilityService.resolve_source_filter("all")
scope_sql = AgentObservabilityService._statistics_message_scope_sql(source_filter)
assert "m.app_id = :app_id" in scope_sql
assert "m.invoke_from != :debugger" not in scope_sql
def test_statistics_explicit_source_filters_invoke_from() -> None:
source_filter = AgentObservabilityService.resolve_source_filter("debugger")
scope_sql = AgentObservabilityService._statistics_message_scope_sql(source_filter)
assert "m.invoke_from = :source" in scope_sql
def test_apply_status_filter_accepts_multiple_statuses() -> None:
class FakeStmt:
def __init__(self):

View File

@ -2826,11 +2826,12 @@ def test_composer_validator_rejects_unauthorized_secret_and_cli_tool():
def test_composer_validator_accepts_valid_shell_env_and_cli():
"""Valid shell identifiers + a disabled empty CLI tool pass validation."""
long_secret = "sk-" + "x" * 512
config = ComposerConfigValidator.validate_agent_soul_dict(
{
"env": {
"variables": [{"name": "MY_VAR", "value": "v"}],
"secret_refs": [{"name": "API_TOKEN", "value": "credential-1"}],
"secret_refs": [{"name": "API_TOKEN", "value": long_secret}],
},
"tools": {
"cli_tools": [
@ -2855,7 +2856,7 @@ def test_composer_validator_accepts_valid_shell_env_and_cli():
)
assert {variable.name for variable in config.env.variables} == {"MY_VAR"}
assert {secret.name for secret in config.env.secret_refs} == {"API_TOKEN"}
assert config.env.secret_refs[0].value == "credential-1"
assert config.env.secret_refs[0].value == long_secret
assert config.tools.cli_tools[0].env.variables[0].name == "JQ_COLOR"
assert config.tools.cli_tools[0].env.secret_refs[0].name == "JQ_TOKEN"
assert config.tools.cli_tools[0].env.secret_refs[0].value == "credential-2"

View File

@ -8,6 +8,7 @@ import zstandard
from pydantic import TypeAdapter
from redis import RedisError
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.entities.plugin_daemon import PluginInstallTask, PluginInstallTaskStatus, PluginModelProviderEntity
from graphon.model_runtime.entities.common_entities import I18nObject
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity
@ -888,9 +889,27 @@ class TestPluginModelProviderCacheInvalidation:
from core.plugin.plugin_service import PluginService
result = PluginService.install_from_local_pkg("tenant-1", ["langgenius/openai:1.0.0"])
result = PluginService.install_from_local_pkg(
"tenant-1",
[
"langgenius/openai:1.0.0",
"langgenius/tavily:1.0.0",
],
)
assert result == "task-id"
installer.install_from_identifiers.assert_called_once_with(
"tenant-1",
[
"langgenius/openai:1.0.0",
"langgenius/tavily:1.0.0",
],
PluginInstallationSource.Package,
[
{"plugin_unique_identifier": "langgenius/openai:1.0.0"},
{"plugin_unique_identifier": "langgenius/tavily:1.0.0"},
],
)
invalidate_cache.assert_called_once_with("tenant-1")
def test_upgrade_plugin_with_github_invalidates_model_provider_cache_for_tenant(self) -> None:

View File

@ -342,6 +342,38 @@ def test_push_file_for_console_uses_service_owned_upload_lookup_and_naming() ->
session.commit.assert_called_once()
def test_upload_skill_for_console_maps_package_validation_failures() -> None:
session = MagicMock()
service = AgentConfigService()
target = _target(kind=AgentConfigVersionKind.DRAFT, writable=False)
message = "skill package must contain exactly one skill; multiple skill folders in one archive are not supported"
with (
patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)),
patch.object(service, "_resolve_target_in_session", return_value=target),
patch.object(
service._skill_normalizer,
"normalize",
side_effect=SkillPackageError("files_outside_skill_root", message, status_code=400),
),
):
with pytest.raises(AgentConfigServiceError, match="exactly one skill") as exc_info:
service.upload_skill_for_console(
tenant_id=TENANT,
agent_id=AGENT,
user_id=USER,
config_version_id="draft-1",
config_version_kind=AgentConfigVersionKind.DRAFT,
content=b"bad-archive",
filename="skills.zip",
)
assert exc_info.value.code == "files_outside_skill_root"
assert exc_info.value.message == message
assert exc_info.value.status_code == 400
session.commit.assert_not_called()
def test_apply_skill_updates_rejects_non_tool_file_refs() -> None:
service = AgentConfigService()

View File

@ -5,7 +5,6 @@ import pytest
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from core.app.file_access import FileAccessScope
from core.workflow.file_reference import build_file_reference
from services.file_request_service import FileRequestService
@ -25,7 +24,7 @@ def test_request_download_url_builds_file_under_bound_scope(
fake_file = MagicMock(filename="report.pdf", mime_type="application/pdf", size=123)
access_controller = MagicMock()
service = FileRequestService(access_controller=access_controller)
reference = build_file_reference(record_id="tool-file-1")
reference = "dify-file-ref:tool-file-1"
with (
patch("services.file_request_service.bind_file_access_scope", return_value=nullcontext()) as bind_scope,

2
api/uv.lock generated
View File

@ -1306,7 +1306,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.3.0" },
{ name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.4.0" },
{ name = "typer", specifier = ">=0.16.1,<0.17" },
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" },

View File

@ -4,7 +4,7 @@
# docker build -f docker/local-sandbox/Dockerfile -t dify-agent-local-sandbox:local .
#
# This image merges the former shellctl-only image with the sandbox-visible
# Agent Stub client CLI. It runs shellctl by default, and shellctl-managed jobs
# Agent Stub client CLI. It runs `shellctl serve` by default, and shellctl-managed jobs
# can call `dify-agent ...` without installing extra packages at runtime.
FROM python:3.12-slim-bookworm AS base
@ -13,7 +13,7 @@ ARG NODE_VERSION=22.22.1
ARG PNPM_VERSION=11.9.0
ARG UV_VERSION=0.8.9
ARG DIFY_AGENT_TOOL_SPEC=.[grpc]
ARG SHELL_SESSION_MANAGER_TOOL_SPEC=shell-session-manager
ARG SHELL_SESSION_MANAGER_TOOL_SPEC=shell-session-manager==2.4.0
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
@ -83,6 +83,7 @@ COPY --from=tools ${UV_TOOL_BIN_DIR} ${UV_TOOL_BIN_DIR}
RUN useradd --create-home --shell /bin/sh dify \
&& mkdir -p /mnt/drive \
&& chown dify:dify /home \
&& chown -R dify:dify /home/dify /mnt/drive
USER dify

View File

@ -230,7 +230,7 @@ The provided `docker/local-sandbox/Dockerfile` installs:
- `tmux`, required by `shellctl` to manage shell jobs;
- common shell workspace tools: `git`, `openssh-client`, `jq`, `ripgrep`,
`unzip`, `zip`, `file`, `procps`, and `less`;
- `shell-session-manager==2.3.0` as a standalone uv tool, which provides the
- `shell-session-manager==2.3.1` as a standalone uv tool, which provides the
`shellctl` CLI/server;
- `uv`, so uv shebang scripts with PEP 723 metadata can run inside the shell
workspace and Python CLI tools can be installed with isolated tool

View File

@ -27,7 +27,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.3.0",
"shell-session-manager==2.4.0",
"uvicorn[standard]==0.46.0",
]

View File

@ -565,13 +565,7 @@ def _chunk_to_stream_events(
elif isinstance(message.content, list):
for part in _map_assistant_content_to_response_parts(message.content):
if isinstance(part, TextPart):
events.extend(
parts_manager.handle_text_delta(
vendor_part_id=None,
content=part.content,
provider_name=provider_name,
)
)
events.extend(embedded_thinking_parser.parse(parts_manager, part.content, provider_name))
else:
events.append(parts_manager.handle_part(vendor_part_id=None, part=part))

View File

@ -1,3 +1,11 @@
"""Shellctl-backed shell provider adapter for dify-agent.
The shell-session-manager SDK owns the HTTP timeout policy for long-polling
shellctl requests. This adapter stays narrowly focused on translating SDK and
transport failures into ``ShellProviderError`` so the shell layer can return
tool observations instead of aborting the agent loop.
"""
from __future__ import annotations
import base64
@ -10,6 +18,8 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Protocol, TypeVar
import httpx
from dify_agent.adapters.shell.protocols import (
ShellCommandProtocol,
ShellCommandResult,
@ -168,11 +178,11 @@ class ShellctlCommands(ShellCommandProtocol):
grace_seconds: float | None = None,
) -> None:
try:
_ = await self.client.delete(job_id, force=force, grace_seconds=grace_seconds)
except RuntimeError as exc:
if getattr(exc, "code", None) == "job_not_found":
_ = await _run_client_call(self.client.delete(job_id, force=force, grace_seconds=grace_seconds))
except ShellProviderError as exc:
if exc.code == "job_not_found":
return
raise _map_error(exc) from exc
raise
@dataclass(slots=True)
@ -274,8 +284,14 @@ class _CompletedShellctlJob:
async def _run_client_call(awaitable: Awaitable[ResultT]) -> ResultT:
"""Map shellctl client boundary failures into provider-layer errors."""
try:
return await awaitable
except httpx.TimeoutException as exc:
raise ShellProviderError(str(exc), code="timeout") from exc
except httpx.RequestError as exc:
raise ShellProviderError(str(exc), code="request_error") from exc
except RuntimeError as exc:
raise _map_error(exc) from exc
@ -338,7 +354,7 @@ async def _run_to_completion(
finally:
if job_id is not None:
try:
await client.delete(job_id, force=True)
await _run_client_call(client.delete(job_id, force=True))
except RuntimeError as exc:
logger.warning("Failed to delete shellctl job %s: %s", job_id, exc)

View File

@ -2,9 +2,7 @@
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
from tempfile import TemporaryDirectory
@ -36,7 +34,6 @@ from dify_agent.agent_stub.protocol.agent_stub import (
_DEFAULT_CONFIG_BASE = Path("./.dify_conf")
_SKILL_MD_FILENAME = "SKILL.md"
_SAFE_ENV_VALUE = re.compile(r"^[A-Za-z0-9_./:@+-]+$")
class ConfigSkillPullResult(BaseModel):
@ -141,19 +138,6 @@ def pull_config_files_from_environment(
return ConfigFilePullResult(items=items)
def pull_config_env_from_environment(local_path: str | None = None) -> Path:
manifest = manifest_from_environment()
target_path = Path(local_path or (_DEFAULT_CONFIG_BASE / ".env")).expanduser().resolve()
target_path.parent.mkdir(parents=True, exist_ok=True)
lines: list[str] = []
for key in manifest.env_keys:
if key not in os.environ:
raise AgentStubValidationError(f"config env key is not present in the current environment: {key}")
lines.append(f"{key}={_format_env_value(os.environ[key])}")
target_path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
return target_path
def pull_config_note_from_environment(local_path: str | None = None) -> Path:
manifest = manifest_from_environment()
target_path = Path(local_path or (_DEFAULT_CONFIG_BASE / "note.md")).expanduser().resolve()
@ -167,22 +151,19 @@ def push_config_note_from_environment(local_path: str | None) -> AgentStubConfig
return _push_config_from_environment(note=note)
def push_config_env_from_environment(local_path: str | None) -> AgentStubConfigManifestResponse:
env_text = _read_text_input(local_path, _DEFAULT_CONFIG_BASE / ".env")
def push_config_env_from_environment(local_path: str) -> AgentStubConfigManifestResponse:
env_text = _read_text_input(local_path, default_path=None)
return _push_config_from_environment(env_text=env_text)
def push_config_files_from_environment(paths: list[str], name: str | None) -> AgentStubConfigManifestResponse:
def push_config_files_from_environment(paths: list[str]) -> AgentStubConfigManifestResponse:
_require_non_empty_inputs(paths, kind="file path")
override_name = None if name is None else _require_config_entry_name(name, kind="file")
if override_name is not None and len(paths) != 1:
raise AgentStubValidationError("--name requires exactly one PATH")
items = [
_PreparedPushItem(
name=override_name if index == 0 and override_name is not None else _infer_name_from_path(source_path),
name=_infer_name_from_path(source_path),
path=source_path,
)
for index, source_path in enumerate(_resolve_input_paths(paths))
for source_path in _resolve_input_paths(paths)
]
return _push_config_from_environment(files=[_build_file_push_item(item=item) for item in items])
@ -236,10 +217,15 @@ def _push_config_from_environment(
)
def _read_text_input(path_value: str | None, default_path: Path) -> str:
def _read_text_input(path_value: str | None, default_path: Path | None) -> str:
if path_value == "-":
return os.fdopen(os.dup(0), encoding="utf-8").read()
source_path = Path(path_value or default_path).expanduser().resolve()
if path_value is None:
if default_path is None:
raise AgentStubValidationError("local file path or '-' is required")
source_path = default_path.expanduser().resolve()
else:
source_path = Path(path_value).expanduser().resolve()
if not source_path.is_file():
raise AgentStubValidationError(f"local file not found: {source_path}")
return source_path.read_text(encoding="utf-8")
@ -297,17 +283,10 @@ def _require_non_empty_inputs(values: list[str], *, kind: str) -> None:
raise AgentStubValidationError(f"at least one {kind} is required")
def _format_env_value(value: str) -> str:
if _SAFE_ENV_VALUE.fullmatch(value):
return value
return json.dumps(value)
__all__ = [
"ConfigFilePullResult",
"ConfigSkillPullResult",
"manifest_from_environment",
"pull_config_env_from_environment",
"pull_config_files_from_environment",
"pull_config_note_from_environment",
"pull_config_skills_from_environment",

View File

@ -22,7 +22,6 @@ from dify_agent.agent_stub.cli._config import (
delete_config_files_from_environment,
delete_config_skills_from_environment,
manifest_from_environment,
pull_config_env_from_environment,
pull_config_files_from_environment,
pull_config_note_from_environment,
pull_config_skills_from_environment,
@ -68,9 +67,7 @@ config_skills_app = typer.Typer(help="Pull or update config skills through the A
config_files_app = typer.Typer(help="Pull or update config files through the Agent Stub.", rich_markup_mode=None)
config_skill_pull_alias_app = typer.Typer(help="Pull config skills through the Agent Stub.", rich_markup_mode=None)
config_file_pull_alias_app = typer.Typer(help="Pull config files through the Agent Stub.", rich_markup_mode=None)
config_env_app = typer.Typer(
help="Pull or update config env variables visible to the current run.", rich_markup_mode=None
)
config_env_app = typer.Typer(help="Update config env variables visible to the current run.", rich_markup_mode=None)
config_note_app = typer.Typer(help="Pull or update the current config note.", rich_markup_mode=None)
drive_app = typer.Typer(help="List, pull, or push agent drive files through the Agent Stub.", rich_markup_mode=None)
app.add_typer(file_app, name="file")
@ -147,9 +144,13 @@ def config_skill_pull_alias(
@config_skills_app.command("push")
def config_skills_push(
paths: list[str] = typer.Argument(..., metavar="PATH"),
paths: list[str] = typer.Argument(..., metavar="PATH..."),
) -> None:
"""Upload one or more local skill directories into the current config manifest."""
"""Upload one or more local skill directories into the current config manifest.
Pass a directory such as ./skills/researcher that contains SKILL.md. Other files in that directory are
archived with the skill. Pushing a skill with an existing name replaces that config skill.
"""
_run_config_skills_push(paths=paths)
@ -183,15 +184,15 @@ def config_file_pull_alias(
@config_files_app.command("push")
def config_files_push(
paths: list[str] = typer.Argument(..., metavar="PATH"),
name: str | None = typer.Option(
None,
"--name",
help="Override the target config file name when uploading exactly one local file.",
),
paths: list[str] = typer.Argument(..., metavar="PATH..."),
) -> None:
"""Upload one or more local files into the current config manifest."""
_run_config_files_push(paths=paths, name=name)
"""Upload one or more local files into the current config manifest.
Pass one or more regular local files such as ./guide.md and ./policy.txt. To upload a folder, compress it
first and pass the archive file. The config file name is the local basename. Pushing a file with an existing config
name replaces that config file.
"""
_run_config_files_push(paths=paths)
@config_files_app.command("delete")
@ -202,19 +203,15 @@ def config_files_delete(
_run_config_files_delete(names=names)
@config_env_app.command("pull")
def config_env_pull(
local_path: str | None = typer.Option(None, "--to", help="Local dotenv file path."),
) -> None:
"""Export visible config env values into ./.dify_conf/.env by default."""
_run_config_env_pull(local_path=local_path)
@config_env_app.command("push")
def config_env_push(
local_path: str | None = typer.Argument(None, metavar="PATH|-"),
local_path: str = typer.Argument(..., metavar="PATH|-"),
) -> None:
"""Replace visible config env values from one local dotenv file or stdin."""
"""Set or delete config env entries from one local dotenv file or stdin.
Pass dotenv text with lines like API_KEY=value and OLD_KEY=. KEY=value sets or updates an entry, KEY= deletes
it, and omitted keys are unchanged.
"""
_run_config_env_push(local_path=local_path)
@ -228,9 +225,13 @@ def config_note_pull(
@config_note_app.command("push")
def config_note_push(
local_path: str | None = typer.Argument(None, metavar="PATH|-"),
local_path: str | None = typer.Argument(None, metavar="[PATH|-]"),
) -> None:
"""Replace the current config note from one local text file or stdin."""
"""Replace the current config note from one local text file or stdin.
Pass a plain text or Markdown note file, or use - to read the note from stdin. When PATH is omitted, the
command reads ./.dify_conf/note.md.
"""
_run_config_note_push(local_path=local_path)
@ -448,18 +449,6 @@ def _run_config_file_pull(*, names: list[str] | None, local_dir: str | None, jso
typer.echo(item.path)
def _run_config_env_pull(*, local_path: str | None) -> None:
try:
path = pull_config_env_from_environment(local_path=local_path)
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(2) from exc
except AgentStubClientError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(1) from exc
typer.echo(str(path))
def _run_config_note_pull(*, local_path: str | None) -> None:
try:
path = pull_config_note_from_environment(local_path=local_path)
@ -484,7 +473,7 @@ def _run_config_note_push(*, local_path: str | None) -> None:
typer.echo(response.model_dump_json())
def _run_config_env_push(*, local_path: str | None) -> None:
def _run_config_env_push(*, local_path: str) -> None:
try:
response = push_config_env_from_environment(local_path=local_path)
except MissingAgentStubEnvironmentError as exc:
@ -496,9 +485,9 @@ def _run_config_env_push(*, local_path: str | None) -> None:
typer.echo(response.model_dump_json())
def _run_config_files_push(*, paths: list[str], name: str | None) -> None:
def _run_config_files_push(*, paths: list[str]) -> None:
try:
response = push_config_files_from_environment(paths=paths, name=name)
response = push_config_files_from_environment(paths=paths)
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(2) from exc

View File

@ -289,7 +289,7 @@ def request_agent_stub_config_env_update_sync(
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
):
"""Replace or delete config env entries through the HTTP transport."""
"""Set or delete config env entries through the HTTP transport."""
endpoint = _parse_endpoint(url)
if endpoint.is_grpc:
raise AgentStubValidationError("Agent Stub config operations require an HTTP Agent Stub URL")

View File

@ -338,7 +338,7 @@ def request_agent_stub_config_env_update_http_sync(
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
) -> dict[str, object]:
"""Replace or delete config env entries through the HTTP Agent Stub endpoint."""
"""Set or delete config env entries through the HTTP Agent Stub endpoint."""
request_model = AgentStubConfigEnvUpdateRequest(env_text=env_text)
response = _send_agent_stub_json(
method="PATCH",

View File

@ -84,8 +84,8 @@ class DifyApiAgentStubFileRequestHandler:
"""Call Dify API inner file request endpoints on behalf of the sandbox.
The upload path calls ``/inner/api/upload/file/request`` and injects the
authenticated execution context's ``tenant_id`` and ``user_id`` along with
the requested filename and mimetype. The download path calls
authenticated execution context's ``tenant_id``, ``user_id``, and optional
``conversation_id`` along with the requested filename and mimetype. The download path calls
``/inner/api/download/file/request`` and injects ``tenant_id``,
``user_id``, ``user_from``, and ``invoke_from`` plus the validated public
file mapping.
@ -126,6 +126,7 @@ class DifyApiAgentStubFileRequestHandler:
"user_id": execution_context.user_id,
"filename": request.filename,
"mimetype": request.mimetype,
"conversation_id": execution_context.conversation_id,
}
data = await self._post_inner_api("/inner/api/upload/file/request", payload)
upload_url = data.get("url")

View File

@ -19,6 +19,7 @@ from dify_agent.layers.config.configs import (
from dify_agent.layers.shell.layer import DifyShellLayer
_CONFIG_CONTEXT_HEADING = "Agent config context from the current Agent Soul:"
_CONFIG_CONTEXT_COMMAND = "$ dify-agent config manifest"
_CONFIG_CLI_USAGE_PROMPT = """Agent config CLI usage is available inside shell jobs. The command help below is generated
from the same `dify-agent` CLI definitions available in shell jobs.
@ -30,7 +31,6 @@ _CONFIG_CLI_HELP_COMMANDS: dict[str, tuple[str, ...]] = {
"dify-agent config manifest --help": ("config", "manifest"),
"dify-agent config skills pull --help": ("config", "skills", "pull"),
"dify-agent config files pull --help": ("config", "files", "pull"),
"dify-agent config env pull --help": ("config", "env", "pull"),
"dify-agent config note pull --help": ("config", "note", "pull"),
}
_CONFIG_CLI_MUTATION_HELP_COMMANDS: dict[str, tuple[str, ...]] = {
@ -124,7 +124,9 @@ class DifyConfigLayer(PlainLayer[DifyConfigDeps, DifyConfigLayerConfig, DifyConf
def build_suffix_prompt(self) -> str:
sections: list[str] = []
if self.runtime_state.config_context_json:
sections.append(f"{_CONFIG_CONTEXT_HEADING}\n{self.runtime_state.config_context_json}")
sections.append(
f"{_CONFIG_CONTEXT_COMMAND}\n{_CONFIG_CONTEXT_HEADING}\n{self.runtime_state.config_context_json}"
)
usage_lines = [_CONFIG_CLI_USAGE_PROMPT]
if cli_help := self._format_config_cli_help():
usage_lines.append(cli_help)

View File

@ -7,19 +7,22 @@ only validates hidden/manual inputs, prepares invocation arguments, and maps
daemon responses into agent-friendly observations.
Like the LLM layer, this layer never owns live HTTP clients. The runtime passes
the FastAPI lifespan-owned shared client into ``get_tools`` so the layer can
build Pydantic AI tool adapters on demand.
the FastAPI lifespan-owned shared plugin-daemon and Dify API clients into
``get_tools`` so the layer can build Pydantic AI tool adapters on demand.
"""
from __future__ import annotations
from copy import deepcopy
import json
import mimetypes
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import ClassVar
from urllib.parse import urlparse
import httpx
from pydantic import BaseModel, ConfigDict, JsonValue, ValidationError
from pydantic_ai import RunContext, Tool
from pydantic_ai.tools import ToolDefinition
from typing_extensions import Self, override
@ -38,7 +41,9 @@ from dify_agent.layers.dify_plugin.tool_client import (
DifyPluginToolClientError,
DifyPluginToolInvokeMessage,
)
from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
from dify_agent.layers.shell.layer import DifyShellLayer
# Plugin tools intentionally do not expose a per-tool strictness override in the
@ -46,12 +51,107 @@ from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
# registers those tools in loose mode so daemon tool invocation stays tolerant of
# plugin schema differences and older API-prepared payloads.
PLUGIN_TOOL_STRICT = False
PLUGIN_FILE_DEFAULT_TYPE = "custom"
_FILE_UPLOAD_BEGIN = "<<<DIFY_PLUGIN_TOOL_FILE_UPLOAD_BEGIN>>>"
_FILE_UPLOAD_END = "<<<DIFY_PLUGIN_TOOL_FILE_UPLOAD_END>>>"
_FILE_UPLOAD_TIMEOUT_SECONDS = 60.0
_SUPPORTED_REMOTE_URL_PREFIXES = ("http://", "https://")
class DifyPluginToolsDeps(LayerDeps):
"""Dependencies required by ``DifyPluginToolsLayer``."""
execution_context: DifyExecutionContextLayer # pyright: ignore[reportUninitializedInstanceVariable]
shell: DifyShellLayer | None # pyright: ignore[reportUninitializedInstanceVariable]
class DifyPluginToolsClientConfigurationError(ValueError):
"""Raised when local plugin-tool file conversion preconditions are missing."""
class _BackwardsInvocationEnvelope(BaseModel):
data: JsonValue = None
error: str | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
class _DownloadFileResponse(BaseModel):
filename: str
mime_type: str | None = None
size: int
download_url: str
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@dataclass(slots=True)
class _DifyPluginToolFileClient:
"""Resolve Dify file mappings into signed URLs for plugin file parameters."""
base_url: str
api_key: str = field(repr=False)
http_client: httpx.AsyncClient = field(repr=False)
def __post_init__(self) -> None:
self.base_url = self.base_url.rstrip("/")
async def request_download(
self,
*,
execution_context: DifyExecutionContextLayerConfig,
file_mapping: Mapping[str, object],
) -> _DownloadFileResponse:
missing_fields = []
if execution_context.user_id is None:
missing_fields.append("user_id")
if execution_context.user_from is None:
missing_fields.append("user_from")
if missing_fields:
missing = ", ".join(missing_fields)
raise DifyPluginToolsClientConfigurationError(
f"Missing required execution context fields for file parameters: {missing}."
)
payload = {
"tenant_id": execution_context.tenant_id,
"user_id": execution_context.user_id,
"user_from": execution_context.user_from,
"invoke_from": execution_context.invoke_from,
"file": dict(file_mapping),
}
try:
response = await self.http_client.post(
f"{self.base_url}/inner/api/download/file/request",
headers={
"X-Inner-Api-Key": self.api_key,
"Content-Type": "application/json",
},
json=payload,
)
except (httpx.InvalidURL, httpx.UnsupportedProtocol) as exc:
raise DifyPluginToolClientError(f"Dify API file download is misconfigured: {exc}") from exc
except httpx.TimeoutException as exc:
raise DifyPluginToolClientError("Dify API file download request timed out.") from exc
except httpx.RequestError as exc:
raise DifyPluginToolClientError(f"Dify API file download request failed: {exc}") from exc
if response.status_code >= 400:
raise DifyPluginToolClientError(
response.text or f"HTTP {response.status_code}", status_code=response.status_code
)
try:
envelope = _BackwardsInvocationEnvelope.model_validate_json(response.text)
except ValidationError as exc:
raise DifyPluginToolClientError("Invalid Dify API file download response.") from exc
if envelope.error:
raise DifyPluginToolClientError(envelope.error, status_code=response.status_code)
if not isinstance(envelope.data, dict):
raise DifyPluginToolClientError("Dify API file download response is missing data.")
try:
return _DownloadFileResponse.model_validate(envelope.data)
except ValidationError as exc:
raise DifyPluginToolClientError("Invalid Dify API file download data.") from exc
@dataclass(slots=True)
@ -61,17 +161,52 @@ class DifyPluginToolsLayer(PlainLayer[DifyPluginToolsDeps, DifyPluginToolsLayerC
type_id: ClassVar[str | None] = DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID
config: DifyPluginToolsLayerConfig
inner_api_url: str
inner_api_key: str
@classmethod
@override
def from_config(cls, config: DifyPluginToolsLayerConfig) -> Self:
"""Create the tools layer from validated public config."""
return cls(config=DifyPluginToolsLayerConfig.model_validate(config))
"""Reject construction without server-injected Dify API settings."""
del config
raise TypeError("DifyPluginToolsLayer requires server-side Dify API settings and must use a provider factory.")
async def get_tools(self, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
@classmethod
def from_config_with_settings(
cls,
config: DifyPluginToolsLayerConfig,
*,
inner_api_url: str,
inner_api_key: str,
) -> Self:
return cls(
config=DifyPluginToolsLayerConfig.model_validate(config),
inner_api_url=inner_api_url,
inner_api_key=inner_api_key,
)
async def get_tools(
self,
*,
http_client: httpx.AsyncClient,
dify_api_http_client: httpx.AsyncClient,
) -> list[Tool[object]]:
"""Build Pydantic AI tool adapters from prepared plugin tool config."""
if dify_api_http_client.is_closed:
raise RuntimeError("DifyPluginToolsLayer.get_tools() requires an open shared Dify API HTTP client.")
tool_clients: dict[str, DifyPluginDaemonToolClient] = {}
tools: list[Tool[object]] = []
file_client = _DifyPluginToolFileClient(
base_url=self.inner_api_url,
api_key=self.inner_api_key,
http_client=dify_api_http_client,
)
file_context = _PluginToolFileContext(
file_client=file_client,
execution_context=self.deps.execution_context.config,
shell=self.deps.shell,
)
for tool_config in self.config.tools:
client = tool_clients.get(tool_config.plugin_id)
@ -89,6 +224,7 @@ class DifyPluginToolsLayer(PlainLayer[DifyPluginToolsDeps, DifyPluginToolsLayerC
client=client,
tool_config=tool_config,
effective_parameters=effective_parameters,
file_context=file_context,
)
)
@ -117,6 +253,7 @@ def _build_pydantic_ai_tool(
client: DifyPluginDaemonToolClient,
tool_config: DifyPluginToolConfig,
effective_parameters: Sequence[DifyPluginToolParameter],
file_context: "_PluginToolFileContext",
) -> Tool[object]:
tool_name = tool_config.name or tool_config.tool_name
tool_description = tool_config.description or tool_name
@ -124,7 +261,12 @@ def _build_pydantic_ai_tool(
async def invoke_tool(_ctx: RunContext[object], **tool_arguments: object) -> str:
try:
merged_arguments = _prepare_tool_arguments(effective_parameters, tool_config, tool_arguments)
merged_arguments = await _prepare_tool_arguments(
effective_parameters,
tool_config,
tool_arguments,
file_context=file_context,
)
messages = await client.invoke(
provider=tool_config.provider,
tool_name=tool_config.tool_name,
@ -162,10 +304,12 @@ def _build_pydantic_ai_tool(
)
def _prepare_tool_arguments(
async def _prepare_tool_arguments(
effective_parameters: Sequence[DifyPluginToolParameter],
tool_config: DifyPluginToolConfig,
tool_arguments: Mapping[str, object],
*,
file_context: "_PluginToolFileContext",
) -> dict[str, object]:
"""Build the daemon invocation payload from prepared config + model args.
@ -193,14 +337,103 @@ def _prepare_tool_arguments(
raise ValueError(f"tool parameter {parameter.name} not found in tool config")
else:
continue
prepared_arguments[parameter.name] = _cast_tool_parameter_value(parameter.type, value)
prepared_arguments[parameter.name] = await _cast_tool_parameter_value(
parameter.type,
value,
file_context=file_context,
)
for key, value in merged_arguments.items():
prepared_arguments.setdefault(key, value)
return prepared_arguments
def _cast_tool_parameter_value(parameter_type: DifyPluginToolParameterType, value: object) -> object:
@dataclass(slots=True)
class _PluginToolFileContext:
file_client: _DifyPluginToolFileClient
execution_context: DifyExecutionContextLayerConfig
shell: DifyShellLayer | None
async def to_plugin_file_parameter(self, value: object) -> dict[str, object]:
if isinstance(value, str):
if _is_remote_url(value):
return _plugin_file_parameter_from_url(value)
mapping = await self._upload_sandbox_path(value)
return await self._plugin_file_parameter_from_mapping(mapping)
if isinstance(value, Mapping):
if _is_plugin_file_parameter(value):
return _normalize_plugin_file_parameter(value)
return await self._plugin_file_parameter_from_mapping(value)
raise ValueError("file parameter must be an HTTP(S) URL, sandbox path, or file mapping.")
async def _upload_sandbox_path(self, path: str) -> dict[str, object]:
if self.shell is None:
raise ValueError("sandbox file path parameters require an active shell layer.")
script = (
"python - <<'PY'\n"
"import json\n"
"import subprocess\n"
"import sys\n\n"
f"command = ['dify-agent', 'file', 'upload', {json.dumps(path)}]\n"
"completed = subprocess.run(command, capture_output=True, text=True, check=False)\n"
"if completed.returncode != 0:\n"
" print(completed.stderr or completed.stdout or f'upload exited with code {completed.returncode}', file=sys.stderr)\n"
" sys.exit(completed.returncode or 1)\n"
"try:\n"
" payload = json.loads(completed.stdout)\n"
"except ValueError as exc:\n"
" print(f'upload returned invalid JSON: {exc}', file=sys.stderr)\n"
" sys.exit(1)\n"
f"print('{_FILE_UPLOAD_BEGIN}' + json.dumps(payload, separators=(',', ':')) + '{_FILE_UPLOAD_END}')\n"
"PY"
)
result = await self.shell.run_remote_script_complete(
script,
timeout=_FILE_UPLOAD_TIMEOUT_SECONDS,
inject_agent_stub_env=True,
)
if result.exit_code != 0:
raise ValueError(
"sandbox file upload failed: "
+ f"{result.status} exit_code={result.exit_code} output_complete={result.output_complete} "
+ result.output.strip()
)
return _parse_uploaded_file_mapping(result.output)
async def _plugin_file_parameter_from_mapping(self, mapping: Mapping[str, object]) -> dict[str, object]:
transfer_method = mapping.get("transfer_method")
if transfer_method == "remote_url":
url = mapping.get("url")
if not isinstance(url, str) or not url:
raise ValueError("remote_url file mapping requires url.")
return _plugin_file_parameter_from_url(url)
if transfer_method not in {"local_file", "tool_file", "datasource_file"}:
raise ValueError(
"file mapping transfer_method must be local_file, tool_file, datasource_file, or remote_url."
)
reference = mapping.get("reference")
if not isinstance(reference, str) or not reference:
raise ValueError(f"{transfer_method} file mapping requires reference.")
download = await self.file_client.request_download(
execution_context=self.execution_context,
file_mapping={
"transfer_method": transfer_method,
"reference": reference,
},
)
return _plugin_file_parameter_from_download(download)
async def _cast_tool_parameter_value(
parameter_type: DifyPluginToolParameterType,
value: object,
*,
file_context: _PluginToolFileContext,
) -> object:
"""Cast prepared tool argument values into daemon-facing wire shapes.
The API side prepares declaration metadata, but the actual invocation payload
@ -235,13 +468,14 @@ def _cast_tool_parameter_value(parameter_type: DifyPluginToolParameterType, valu
return float(value) if "." in value else int(value)
return value
case DifyPluginToolParameterType.SYSTEM_FILES | DifyPluginToolParameterType.FILES:
return value if isinstance(value, list) else [value]
values = value if isinstance(value, list) else [value]
return [await file_context.to_plugin_file_parameter(item) for item in values]
case DifyPluginToolParameterType.FILE:
if isinstance(value, list):
if len(value) != 1:
raise ValueError("This parameter only accepts one file but got multiple files while invoking.")
return value[0]
return value
return await file_context.to_plugin_file_parameter(value[0])
return await file_context.to_plugin_file_parameter(value)
case DifyPluginToolParameterType.MODEL_SELECTOR | DifyPluginToolParameterType.APP_SELECTOR:
if not isinstance(value, dict):
raise ValueError("The selector must be a dictionary.")
@ -276,6 +510,112 @@ def _cast_tool_parameter_value(parameter_type: DifyPluginToolParameterType, valu
raise AssertionError(f"Unsupported tool parameter type: {parameter_type}")
def _is_remote_url(value: str) -> bool:
return value.lower().startswith(_SUPPORTED_REMOTE_URL_PREFIXES)
def _is_plugin_file_parameter(value: Mapping[str, object]) -> bool:
url = value.get("url")
return isinstance(url, str) and bool(url) and "transfer_method" not in value
def _normalize_plugin_file_parameter(value: Mapping[str, object]) -> dict[str, object]:
url = value.get("url")
if not isinstance(url, str) or not url:
raise ValueError("plugin file parameter requires url.")
return _plugin_file_parameter(
url=url,
filename=value.get("filename"),
mime_type=value.get("mime_type"),
extension=value.get("extension"),
size=value.get("size"),
file_type=value.get("type"),
)
def _plugin_file_parameter_from_url(url: str) -> dict[str, object]:
if not _is_remote_url(url):
raise ValueError("remote file URL must start with http:// or https://.")
filename = _filename_from_url(url)
mime_type = mimetypes.guess_type(filename or url)[0] if filename else None
return _plugin_file_parameter(
url=url,
filename=filename,
mime_type=mime_type,
extension=_extension_from_filename(filename),
size=-1,
file_type=PLUGIN_FILE_DEFAULT_TYPE,
)
def _plugin_file_parameter_from_download(download: _DownloadFileResponse) -> dict[str, object]:
return _plugin_file_parameter(
url=download.download_url,
filename=download.filename,
mime_type=download.mime_type,
extension=_extension_from_filename(download.filename),
size=download.size,
file_type=PLUGIN_FILE_DEFAULT_TYPE,
)
def _plugin_file_parameter(
*,
url: str,
filename: object,
mime_type: object,
extension: object,
size: object,
file_type: object,
) -> dict[str, object]:
normalized: dict[str, object] = {
"dify_model_identity": "__dify__file__",
"type": file_type if isinstance(file_type, str) and file_type else PLUGIN_FILE_DEFAULT_TYPE,
"url": url,
}
if isinstance(filename, str) and filename:
normalized["filename"] = filename
if isinstance(mime_type, str) and mime_type:
normalized["mime_type"] = mime_type
if isinstance(extension, str) and extension:
normalized["extension"] = extension
if isinstance(size, int):
normalized["size"] = size
return normalized
def _filename_from_url(url: str) -> str | None:
parsed = urlparse(url)
filename = parsed.path.rsplit("/", 1)[-1]
return filename or None
def _extension_from_filename(filename: str | None) -> str | None:
if not filename or "." not in filename:
return None
suffix = "." + filename.rsplit(".", 1)[-1]
return suffix if len(suffix) > 1 else None
def _parse_uploaded_file_mapping(output: str) -> dict[str, object]:
begin_index = output.find(_FILE_UPLOAD_BEGIN)
end_index = output.find(_FILE_UPLOAD_END, begin_index + len(_FILE_UPLOAD_BEGIN))
if begin_index < 0 or end_index < 0:
raise ValueError("sandbox file upload did not return a framed file mapping.")
raw_payload = output[begin_index + len(_FILE_UPLOAD_BEGIN) : end_index]
try:
payload = json.loads(raw_payload)
except json.JSONDecodeError as exc:
raise ValueError("sandbox file upload returned invalid file mapping JSON.") from exc
if not isinstance(payload, dict):
raise ValueError("sandbox file upload returned a non-object file mapping.")
transfer_method = payload.get("transfer_method")
reference = payload.get("reference")
if transfer_method != "tool_file" or not isinstance(reference, str) or not reference:
raise ValueError("sandbox file upload returned an invalid tool_file mapping.")
return {"transfer_method": "tool_file", "reference": reference}
def _tool_error_text(*, tool_name: str, error: DifyPluginToolClientError) -> str:
"""Map expected daemon/tool failures into agent-visible observation text.

View File

@ -1,4 +1,15 @@
"""Shell runtime layer backed by a live shell provider resource."""
"""Shell runtime layer backed by a live shell provider resource.
Shell command execution requires a bound execution-context layer with a safe
``agent_id``. The layer uses the current bound execution context to run
commands with ``HOME=/home/<agent_id>`` and a home-rooted workspace path. The
persisted runtime state intentionally keeps the historical
``~/workspace/<session>`` identity so existing session snapshots stay
compatible while live command execution no longer depends on the sandbox user's
ambient home directory. Entering or re-entering the layer re-ensures the live
home/workspace directories for the currently bound ``agent_id`` before user
commands are sent.
"""
from __future__ import annotations
@ -10,13 +21,22 @@ import re
import secrets
import time
from dataclasses import dataclass
from typing import ClassVar, Literal, NotRequired, TypedDict
from typing import ClassVar, Literal, NotRequired, Protocol, TypedDict, runtime_checkable
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator, model_validator
from pydantic_ai import Tool
from typing_extensions import Self, override
from agenton.layers import LayerDeps, PydanticAILayer, PydanticAIPrompt, PydanticAITool
from agenton.layers import (
EmptyLayerConfig,
EmptyRuntimeState,
LayerDeps,
NoLayerDeps,
PlainLayer,
PydanticAILayer,
PydanticAIPrompt,
PydanticAITool,
)
from dify_agent.adapters.shell.protocols import (
CompleteShellCommandResult,
ShellCommandProtocol,
@ -26,20 +46,35 @@ from dify_agent.adapters.shell.protocols import (
ShellResourceProtocol,
)
from dify_agent.agent_stub.server.shell_agent_stub_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
from dify_agent.layers.shell.output_text import normalized_output_text, utf8_prefix, utf8_suffix
try:
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
except ModuleNotFoundError:
class DifyExecutionContextLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, EmptyRuntimeState]):
"""Minimal fallback for shell-only imports without server extras installed."""
logger = logging.getLogger(__name__)
@runtime_checkable
class _HasErrorCode(Protocol):
code: object
DEFAULT_TIMEOUT_SECONDS = 30.0
DEFAULT_TERMINATE_GRACE_SECONDS = 10.0
_WORKSPACE_ROOT = "~/workspace"
_WORKSPACE_DIR_NAME = "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}$")
_AGENT_HOME_SEGMENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
_SHELL_OUTPUT_PROMPT_EDGE_BYTES = 8 * 1024
_SHELLCTL_OUTPUT_LIMIT_BYTES = 2 * _SHELL_OUTPUT_PROMPT_EDGE_BYTES
_REMOTE_COMPLETE_OUTPUT_MAX_BYTES = 1024 * 1024
@ -123,6 +158,8 @@ from rich import print
response = httpx.get("https://example.com", timeout=10)
print(f"[green]status:[/green] {response.status_code}")"""
_SHELL_LAYER_SUFFIX_PROMPT = """Environment variables may contain API keys, tokens, or credentials.
You may refer to environment variable names when needed."""
class ShellToolErrorObservation(TypedDict):
@ -219,6 +256,11 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
def prefix_prompts(self) -> Sequence[PydanticAIPrompt[object]]:
return [_shell_layer_prefix_prompt]
@property
@override
def suffix_prompts(self) -> Sequence[PydanticAIPrompt[object]]:
return [_shell_layer_suffix_prompt]
@property
@override
def tools(self) -> Sequence[PydanticAITool[object]]:
@ -248,7 +290,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
session_id: str | None = None
try:
session_id, workspace_cwd = await self._allocate_workspace()
await self._bootstrap_workspace(workspace_cwd)
await self._bootstrap_workspace(session_id)
except BaseException:
if session_id is not None:
await self._cleanup_workspace_best_effort(session_id)
@ -264,7 +306,8 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
@override
async def on_context_resume(self) -> None:
_ = self._require_resource()
_ = self._require_session_identity()
session_id, _workspace_cwd = self._require_session_identity()
await self._ensure_live_workspace_exists(session_id)
@override
async def on_context_suspend(self) -> None:
@ -295,7 +338,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
result = await self._require_resource().commands.run(
_wrap_user_script(script, self.config),
cwd=self._require_workspace_cwd(),
env=self._build_user_shell_run_env(),
env=self._build_shell_command_env(include_agent_stub_env=True),
timeout=timeout,
)
observation = await render_prompt_observation_from_result(
@ -316,7 +359,9 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
observation.text,
)
except (RuntimeError, ValueError) as exc:
return _tool_error(str(exc))
return _tool_error_from_exception(exc)
except Exception as exc:
return _tool_unexpected_error("shell_run", exc, session_id=self.runtime_state.session_id)
async def _tool_wait(self, job_id: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult:
try:
@ -340,7 +385,9 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
observation.text,
)
except (RuntimeError, ValueError) as exc:
return _tool_error(str(exc), job_id=job_id)
return _tool_error_from_exception(exc, job_id=job_id)
except Exception as exc:
return _tool_unexpected_error("shell_wait", exc, session_id=self.runtime_state.session_id, job_id=job_id)
async def _tool_input(self, job_id: str, text: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult:
try:
@ -364,7 +411,9 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
observation.text,
)
except (RuntimeError, ValueError) as exc:
return _tool_error(str(exc), job_id=job_id)
return _tool_error_from_exception(exc, job_id=job_id)
except Exception as exc:
return _tool_unexpected_error("shell_input", exc, session_id=self.runtime_state.session_id, job_id=job_id)
async def _tool_interrupt(
self,
@ -378,6 +427,9 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
self._remember_job_offset(result.job_id, result.offset)
output_path: str | None = None
try:
# Once the interrupt itself succeeds, resolving the output path is
# best-effort metadata enrichment and must not turn the interrupt
# into a failed tool result.
output_path = (await self._require_resource().commands.tail(job_id)).output_path
except (RuntimeError, ValueError) as exc:
logger.warning(
@ -386,6 +438,12 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
self.runtime_state.session_id,
exc,
)
except Exception:
logger.exception(
"Failed to fetch output path for interrupted shell job %s in session %s",
job_id,
self.runtime_state.session_id,
)
return _tagged_shell_observation(
_metadata_dict(
job_id=result.job_id,
@ -397,7 +455,11 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
"Job was interrupted.",
)
except (RuntimeError, ValueError) as exc:
return _tool_error(str(exc), job_id=job_id)
return _tool_error_from_exception(exc, job_id=job_id)
except Exception as exc:
return _tool_unexpected_error(
"shell_interrupt", exc, session_id=self.runtime_state.session_id, job_id=job_id
)
async def run_remote_script_complete(
self,
@ -407,16 +469,14 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
max_output_bytes: int = _REMOTE_COMPLETE_OUTPUT_MAX_BYTES,
inject_agent_stub_env: bool = False,
) -> CompleteRemoteCommandResult:
env = None
if inject_agent_stub_env:
env = self._build_user_shell_run_env()
if env is None:
raise RuntimeError("Agent Stub environment injection is not available for this shell session.")
return await execute_complete_with_commands(
self._require_resource().commands,
script,
cwd=self._require_workspace_cwd(),
env=env,
env=self._build_shell_command_env(
include_agent_stub_env=inject_agent_stub_env,
require_agent_stub_env=inject_agent_stub_env,
),
timeout=timeout,
max_output_bytes=max_output_bytes,
)
@ -448,10 +508,11 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
return session_id, _workspace_cwd(session_id)
raise RuntimeError("Failed to allocate a unique shell workspace session id after 256 attempts.")
async def _bootstrap_workspace(self, workspace_cwd: str) -> None:
async def _bootstrap_workspace(self, session_id: str) -> None:
bootstrap_script = _workspace_bootstrap_script(self.config)
if not bootstrap_script:
return
workspace_cwd = _workspace_cwd_for_home(self._shell_home_dir(), session_id)
result = await self._run_internal_script_complete(bootstrap_script, cwd=workspace_cwd)
if result.exit_code != 0 or not result.output_complete:
raise RuntimeError(
@ -464,6 +525,14 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
except (RuntimeError, ValueError) as exc:
logger.warning("Failed to remove shell workspace for session %s after create failure: %s", session_id, exc)
async def _ensure_live_workspace_exists(self, session_id: str) -> None:
result = await self._run_internal_script_complete(_workspace_ensure_script(session_id=session_id), cwd=None)
if result.exit_code != 0 or not result.output_complete:
raise RuntimeError(
f"Failed to ensure shell workspace {_workspace_cwd_for_home(self._shell_home_dir(), session_id)} "
+ f"exists: {result.status} exit_code={result.exit_code}"
)
async def _run_internal_script_complete(
self,
script: str,
@ -474,7 +543,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
self._require_resource().commands,
script,
cwd=cwd,
env=None,
env=self._build_shell_command_env(include_agent_stub_env=False),
timeout=DEFAULT_TIMEOUT_SECONDS,
max_output_bytes=_REMOTE_COMPLETE_OUTPUT_MAX_BYTES,
)
@ -488,8 +557,8 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
return self._shell_resource
def _require_workspace_cwd(self) -> str:
_session_id, workspace_cwd = self._require_session_identity()
return workspace_cwd
session_id, _workspace_cwd = self._require_session_identity()
return _workspace_cwd_for_home(self._shell_home_dir(), session_id)
def _require_session_identity(self) -> tuple[str, str]:
identity = self._try_session_identity()
@ -545,16 +614,45 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
self.runtime_state.job_offsets = {}
self.runtime_state.job_ids = []
def _build_user_shell_run_env(self) -> dict[str, str] | None:
def _shell_home_dir(self) -> str:
return _shell_home_dir_for_agent_id(self._require_current_execution_agent_id())
def _current_execution_agent_id(self) -> str | None:
execution_context_layer = self.deps.execution_context
execution_context = execution_context_layer.config if execution_context_layer is not None else None
return build_shell_agent_stub_env(
return execution_context.agent_id if execution_context is not None else None
def _require_current_execution_agent_id(self) -> str:
agent_id = self._current_execution_agent_id()
if agent_id is None:
raise ValueError("ShellLayer command execution requires execution_context.agent_id.")
return _validated_agent_home_segment(agent_id)
def _build_shell_command_env(
self,
*,
include_agent_stub_env: bool,
require_agent_stub_env: bool = False,
) -> dict[str, str]:
env = _shell_config_env(self.config)
env["HOME"] = self._shell_home_dir()
if not include_agent_stub_env:
return env
execution_context_layer = self.deps.execution_context
execution_context = execution_context_layer.config if execution_context_layer is not None else None
agent_stub_env = build_shell_agent_stub_env(
agent_stub_api_base_url=self.agent_stub_api_base_url,
agent_stub_drive_ref=self.config.agent_stub_drive_ref,
execution_context=execution_context,
token_factory=self.agent_stub_token_factory,
session_id=self.runtime_state.session_id,
)
if agent_stub_env is None:
if not require_agent_stub_env:
return env
raise RuntimeError("Agent Stub environment injection is not available for this shell session.")
env.update(agent_stub_env)
return env
async def execute_complete_with_commands(
@ -663,6 +761,10 @@ def _shell_layer_prefix_prompt() -> str:
return _SHELL_LAYER_PREFIX_PROMPT
def _shell_layer_suffix_prompt() -> str:
return _SHELL_LAYER_SUFFIX_PROMPT
def _metadata_dict(
*,
job_id: str,
@ -689,6 +791,35 @@ def _tool_error(message: str, *, job_id: str | None = None) -> ShellToolErrorObs
return result
def _tool_error_from_exception(exc: Exception, *, job_id: str | None = None) -> ShellToolErrorObservation:
# Expected provider/runtime failures stay inside the tool contract and are
# returned to the model as observations. The broader Exception fallback
# below handles unexpected failures; BaseException, including cancellation,
# is intentionally left uncaught at the tool boundary.
if isinstance(exc, _HasErrorCode) and isinstance(exc.code, str) and exc.code:
return _tool_error(f"{exc.code}: {exc}", job_id=job_id)
return _tool_error(str(exc), job_id=job_id)
def _tool_unexpected_error(
tool_name: str,
exc: Exception,
*,
session_id: str | None,
job_id: str | None = None,
) -> ShellToolErrorObservation:
# Unexpected Exception still becomes a tool observation so one shell tool
# failure does not abort the agent loop, but it is logged with traceback for
# debugging. BaseException is intentionally not caught by callers.
logger.exception(
"Unexpected shell tool failure: tool=%s session_id=%s job_id=%s",
tool_name,
session_id,
job_id,
)
return _tool_error_from_exception(exc, job_id=job_id)
def _generate_session_id() -> str:
time_component = int(time.time()) & _SESSION_TIME_HEX_MASK
random_component = secrets.token_hex(1)
@ -701,6 +832,22 @@ def _workspace_cwd(session_id: str) -> str:
return f"{_WORKSPACE_ROOT}/{_validated_session_id(session_id)}"
def _shell_home_dir_for_agent_id(agent_id: str | None) -> str:
if agent_id is None:
raise ValueError("ShellLayer command execution requires execution_context.agent_id.")
return f"/home/{_validated_agent_home_segment(agent_id)}"
def _validated_agent_home_segment(agent_id: str) -> str:
if agent_id in {".", ".."} or not _AGENT_HOME_SEGMENT_PATTERN.fullmatch(agent_id):
raise ValueError("execution_context.agent_id must be a safe single path segment for shell HOME.")
return agent_id
def _workspace_cwd_for_home(home_dir: str, session_id: str) -> str:
return f"{home_dir}/{_WORKSPACE_DIR_NAME}/{_validated_session_id(session_id)}"
def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str:
install_commands = [command for tool in config.cli_tools for command in tool.install_commands]
if not install_commands:
@ -711,13 +858,11 @@ def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str:
def _shell_config_export_lines(config: DifyShellLayerConfig) -> list[str]:
lines: list[str] = []
for env_var in config.env:
lines.append(f"export {env_var.name}={_shquote(env_var.value)}")
# Plain env values travel through ShellCommandProtocol.run(env=...) so inline
# secrets are not rendered into generated shell scripts.
for secret_ref in config.secret_refs:
lines.append(f'export {secret_ref.name}="${{{secret_ref.name}:-}}"')
for tool in config.cli_tools:
for env_var in tool.env:
lines.append(f"export {env_var.name}={_shquote(env_var.value)}")
for secret_ref in tool.secret_refs:
lines.append(f'export {secret_ref.name}="${{{secret_ref.name}:-}}"')
if config.sandbox is not None:
@ -729,6 +874,16 @@ def _shell_config_export_lines(config: DifyShellLayerConfig) -> list[str]:
return lines
def _shell_config_env(config: DifyShellLayerConfig) -> dict[str, str]:
env: dict[str, str] = {}
for env_var in config.env:
env[env_var.name] = env_var.value
for tool in config.cli_tools:
for env_var in tool.env:
env[env_var.name] = env_var.value
return env
def _wrap_user_script(script: str, config: DifyShellLayerConfig) -> str:
lines = _shell_config_export_lines(config)
if not lines:
@ -751,6 +906,10 @@ def _workspace_cleanup_script(*, session_id: str) -> str:
return f'rm -rf -- "$HOME/workspace/{_validated_session_id(session_id)}"'
def _workspace_ensure_script(*, session_id: str) -> str:
return f'mkdir -p "$HOME/workspace/{_validated_session_id(session_id)}"'
def _shquote(value: str) -> str:
return "'" + value.replace("'", "'\\''") + "'"

View File

@ -9,6 +9,7 @@ from .schemas import (
DIFY_AGENT_MODEL_LAYER_ID,
DIFY_AGENT_OUTPUT_LAYER_ID,
RUN_EVENT_ADAPTER,
AgentRunUsage,
BaseRunEvent,
CancelRunRequest,
CancelRunResponse,
@ -55,6 +56,7 @@ from .sandbox import (
__all__ = [
"BaseRunEvent",
"AgentRunUsage",
"CancelRunRequest",
"CancelRunResponse",
"CreateRunRequest",

View File

@ -252,12 +252,29 @@ class DeferredToolCallPayload(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentRunUsage(BaseModel):
"""Token usage reported by the model request behind one Agent run."""
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@model_validator(mode="after")
def _derive_total_tokens(self) -> AgentRunUsage:
if self.total_tokens == 0 and (self.prompt_tokens > 0 or self.completion_tokens > 0):
self.total_tokens = self.prompt_tokens + self.completion_tokens
return self
class RunSucceededEventData(BaseModel):
"""Terminal success payload for final output or deferred tool continuation."""
output: JsonValue | None = None
deferred_tool_call: DeferredToolCallPayload | None = None
session_snapshot: CompositorSessionSnapshot
usage: AgentRunUsage | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@ -276,6 +293,8 @@ class RunSucceededEventData(BaseModel):
data["output"] = self.output
if "deferred_tool_call" in self.model_fields_set:
data["deferred_tool_call"] = self.deferred_tool_call
if self.usage is not None:
data["usage"] = self.usage
return data
@ -361,6 +380,7 @@ class RunEventsResponse(BaseModel):
__all__ = [
"BaseRunEvent",
"AgentRunUsage",
"CancelRunRequest",
"CancelRunResponse",
"CreateRunRequest",

View File

@ -42,6 +42,7 @@ from dify_agent.layers.config.layer import DifyConfigLayer
from dify_agent.layers.dify_core_tools.configs import DifyCoreToolsLayerConfig
from dify_agent.layers.dify_core_tools.layer import DifyCoreToolsLayer
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
from dify_agent.layers.dify_plugin.configs import DifyPluginToolsLayerConfig
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
from dify_agent.layers.drive.layer import DifyDriveLayer
from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig
@ -128,7 +129,14 @@ def create_default_layer_providers(
),
),
LayerProvider.from_layer_type(DifyPluginLLMLayer),
LayerProvider.from_layer_type(DifyPluginToolsLayer),
LayerProvider.from_factory(
layer_type=DifyPluginToolsLayer,
create=lambda config: DifyPluginToolsLayer.from_config_with_settings(
DifyPluginToolsLayerConfig.model_validate(config),
inner_api_url=inner_api_url,
inner_api_key=inner_api_key,
),
),
LayerProvider.from_factory(
layer_type=DifyCoreToolsLayer,
create=lambda config: DifyCoreToolsLayer.from_config_with_settings(

View File

@ -17,6 +17,7 @@ from pydantic_ai.messages import AgentStreamEvent
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol.schemas import (
AgentRunUsage,
DeferredToolCallPayload,
EmptyRunEventData,
PydanticAIStreamRunEvent,
@ -103,6 +104,7 @@ async def emit_run_succeeded(
output: JsonValue | None | object = _UNSET,
deferred_tool_call: DeferredToolCallPayload | object = _UNSET,
session_snapshot: CompositorSessionSnapshot,
usage: AgentRunUsage | None = None,
) -> str:
"""Emit the terminal success event with output or deferred continuation.
@ -112,13 +114,15 @@ async def emit_run_succeeded(
Without that sentinel, ``output=None`` would be indistinguishable from
output field absent, which would break nullable-success payloads.
"""
data: dict[str, JsonValue | DeferredToolCallPayload | CompositorSessionSnapshot | None] = {
data: dict[str, JsonValue | DeferredToolCallPayload | CompositorSessionSnapshot | AgentRunUsage | None] = {
"session_snapshot": session_snapshot,
}
if output is not _UNSET:
data["output"] = cast(JsonValue | None, output)
if deferred_tool_call is not _UNSET:
data["deferred_tool_call"] = cast(DeferredToolCallPayload, deferred_tool_call)
if usage is not None:
data["usage"] = usage
return await emit_run_event(
sink,

View File

@ -24,10 +24,10 @@ both the JSON-safe final output or deferred tool call and the session snapshot;
there are no separate output or snapshot events to correlate.
"""
from collections.abc import AsyncIterable
from collections.abc import AsyncIterable, Callable
from collections import Counter
from dataclasses import dataclass
from typing import Any, Literal, cast
from typing import Any, Literal, Protocol, cast, runtime_checkable
import httpx
from pydantic import JsonValue, TypeAdapter
@ -43,6 +43,7 @@ from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
from dify_agent.layers.knowledge.layer import DifyKnowledgeBaseLayer
from dify_agent.protocol.schemas import (
AgentRunUsage,
CreateRunRequest,
DIFY_AGENT_MODEL_LAYER_ID,
DeferredToolCallPayload,
@ -72,6 +73,26 @@ from dify_agent.runtime.user_prompt_validation import EMPTY_USER_PROMPTS_ERROR,
_AGENT_OUTPUT_ADAPTER = TypeAdapter(object)
@runtime_checkable
class _HasUsage(Protocol):
usage: object
@runtime_checkable
class _HasInputTokens(Protocol):
input_tokens: object
@runtime_checkable
class _HasOutputTokens(Protocol):
output_tokens: object
@runtime_checkable
class _HasTotalTokens(Protocol):
total_tokens: object
class AgentRunValidationError(ValueError):
"""Raised when a run request is valid JSON but cannot execute."""
@ -84,6 +105,7 @@ class RunSuccessOutcome:
output: JsonValue | None
deferred_tool_call: DeferredToolCallPayload | None
session_snapshot: CompositorSessionSnapshot
usage: AgentRunUsage | None
class AgentRunRunner:
@ -136,6 +158,7 @@ class AgentRunRunner:
else {"deferred_tool_call": outcome.deferred_tool_call}
),
session_snapshot=outcome.session_snapshot,
usage=outcome.usage,
)
await self.sink.update_status(self.run_id, "succeeded")
@ -171,6 +194,7 @@ class AgentRunRunner:
output: JsonValue | None = None
deferred_tool_call: DeferredToolCallPayload | None = None
result_kind: Literal["output", "deferred_tool_call"] | None = None
usage: AgentRunUsage | None = None
try:
async with compositor.enter(configs=layer_configs, session_snapshot=self.request.session_snapshot) as run:
entered_run = True
@ -218,6 +242,7 @@ class AgentRunRunner:
deferred_tool_results=deferred_tool_results,
event_stream_handler=handle_events,
)
usage = _serialize_agent_usage(_result_usage(result))
append_successful_run_history(history_layer, result.new_messages())
if isinstance(result.output, DeferredToolRequests):
if ask_human_layer is None:
@ -252,6 +277,7 @@ class AgentRunRunner:
output=output,
deferred_tool_call=deferred_tool_call,
session_snapshot=run.session_snapshot,
usage=usage,
)
@ -260,6 +286,34 @@ def _serialize_agent_output(output: object) -> JsonValue:
return cast(JsonValue, _AGENT_OUTPUT_ADAPTER.dump_python(output, mode="json"))
def _result_usage(result: object) -> object | None:
"""Return pydantic-ai result usage across method/property API variants."""
if not isinstance(result, _HasUsage):
return None
usage = result.usage
if isinstance(usage, _HasInputTokens) or isinstance(usage, _HasOutputTokens):
return usage
if callable(usage):
usage_getter = cast(Callable[[], object], usage)
return usage_getter()
return usage
def _serialize_agent_usage(usage: object | None) -> AgentRunUsage | None:
"""Convert pydantic-ai request usage into the public Agent run usage shape."""
if usage is None:
return None
input_tokens = int(usage.input_tokens or 0) if isinstance(usage, _HasInputTokens) else 0
output_tokens = int(usage.output_tokens or 0) if isinstance(usage, _HasOutputTokens) else 0
total_tokens = int(usage.total_tokens or 0) if isinstance(usage, _HasTotalTokens) else 0
return AgentRunUsage(
prompt_tokens=input_tokens,
completion_tokens=output_tokens,
total_tokens=total_tokens,
)
def _resolve_agent_output_type(output_type: OutputSpec[object], allow_deferred_tools: bool) -> OutputSpec[object]:
"""Return the run output type, optionally augmented with deferred-tool support."""
if not allow_deferred_tools:
@ -285,7 +339,12 @@ async def _resolve_run_tools(
for slot in run.slots.values():
layer = slot.layer
if isinstance(layer, DifyPluginToolsLayer):
resolved_tools.extend(await layer.get_tools(http_client=plugin_daemon_http_client))
resolved_tools.extend(
await layer.get_tools(
http_client=plugin_daemon_http_client,
dify_api_http_client=dify_api_http_client,
)
)
if isinstance(layer, DifyCoreToolsLayer):
resolved_tools.extend(await layer.get_tools(http_client=dify_api_http_client))
if isinstance(layer, DifyKnowledgeBaseLayer):

View File

@ -9,7 +9,11 @@ minimal compositor from ``SandboxLocator``, enters the saved
The scripts still frame their structured payloads with a PTY-safe
base64-between-sentinels envelope. shellctl jobs are tmux-backed, so raw JSON can
be wrapped or surrounded by prompt noise; the framing keeps list/read/upload
responses parseable without falling back to direct shellctl file access.
responses parseable without falling back to direct shellctl file access. Path
arguments resolve from the saved shell workspace cwd, and ``~`` resolves through
the shell layer's injected sandbox ``HOME``. The scripts do not re-impose a
workspace-root boundary, so callers can use ``../`` or ``~/...`` when the sandbox
filesystem layout expects it.
"""
from __future__ import annotations
@ -63,21 +67,9 @@ def emit(payload):
print(BEGIN + blob + END)
def resolve_target(root: Path, raw_path: str) -> tuple[Path | None, str]:
target = (root / raw_path).resolve()
if target != root and root not in target.parents:
emit({"error": "invalid_sandbox_path", "message": "path escapes the sandbox workspace"})
return None, raw_path
return target, raw_path
root = Path.cwd().resolve()
raw_path = sys.argv[1]
limit = int(sys.argv[2])
target_info = resolve_target(root, raw_path)
if target_info[0] is None:
sys.exit(0)
target, normalized_path = target_info
target = Path(raw_path).expanduser().resolve()
if not target.exists():
emit({"error": "sandbox_path_not_found", "message": "path not found in sandbox"})
@ -109,7 +101,7 @@ for child in sorted(target.iterdir(), key=lambda item: item.name)[:limit]:
emit(
{
"path": normalized_path,
"path": raw_path,
"entries": entries,
"truncated": len(list(target.iterdir())) > limit,
}
@ -132,13 +124,9 @@ def emit(payload):
print(BEGIN + blob + END)
root = Path.cwd().resolve()
raw_path = sys.argv[1]
max_bytes = int(sys.argv[2])
target = (root / raw_path).resolve()
if target != root and root not in target.parents:
emit({"error": "invalid_sandbox_path", "message": "path escapes the sandbox workspace"})
sys.exit(0)
target = Path(raw_path).expanduser().resolve()
if not target.exists():
emit({"error": "sandbox_path_not_found", "message": "path not found in sandbox"})
sys.exit(0)
@ -194,12 +182,8 @@ def emit(payload):
print(BEGIN + blob + END)
root = Path.cwd().resolve()
raw_path = sys.argv[1]
target = (root / raw_path).resolve()
if target != root and root not in target.parents:
emit({"error": "invalid_sandbox_path", "message": "path escapes the sandbox workspace"})
sys.exit(0)
target = Path(raw_path).expanduser().resolve()
if not target.exists():
emit({"error": "sandbox_path_not_found", "message": "path not found in sandbox"})
sys.exit(0)
@ -310,15 +294,25 @@ class SandboxFileService:
def _normalize_sandbox_path(path: str, *, allow_current_directory: bool) -> str:
"""Reject only syntactically unsafe paths and preserve relative traversal.
The remote scripts run with the saved workspace cwd, so ``../`` remains a
valid sandbox-relative path when callers need to reach sibling directories.
``~`` and ``~/...`` are also accepted and resolved by the embedded scripts
against the shell layer's sandbox ``HOME``.
"""
normalized = (path or "").strip()
if normalized in {"", ".", "./"}:
if allow_current_directory:
return "."
raise SandboxFileError("invalid_sandbox_path", "path must not be blank", status_code=400)
if normalized.startswith("/") or normalized.startswith("~"):
if normalized.startswith("/"):
raise SandboxFileError(
"invalid_sandbox_path", "path must be relative to the sandbox workspace", status_code=400
)
if normalized.startswith("~") and normalized != "~" and not normalized.startswith("~/"):
raise SandboxFileError("invalid_sandbox_path", "path must use ~ or ~/ for the sandbox home", status_code=400)
if "\x00" in normalized or any(ord(ch) < 0x20 for ch in normalized):
raise SandboxFileError("invalid_sandbox_path", "path contains unsupported control characters", status_code=400)
return normalized

View File

@ -5,6 +5,7 @@ from typing import cast
from unittest.mock import patch
import httpx
from graphon.model_runtime.entities.message_entities import TextPromptMessageContent
from pydantic_ai.exceptions import ModelHTTPError, UserError
from pydantic_ai.messages import (
InstructionPart,
@ -311,6 +312,62 @@ class DifyLLMAdapterModelTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.usage.input_tokens, 11)
self.assertEqual(response.usage.output_tokens, 7)
async def test_request_stream_splits_embedded_thinking_tags_from_text_content_parts(self) -> None:
def handler(_request: httpx.Request) -> httpx.Response:
return build_stream_response(
LLMResultChunk(
model="demo-model",
delta=LLMResultChunkDelta(
index=0,
message=AssistantPromptMessage(
content=[TextPromptMessageContent(data="before<think>reasoning")],
tool_calls=[],
),
),
),
LLMResultChunk(
model="demo-model",
delta=LLMResultChunkDelta(
index=1,
message=AssistantPromptMessage(
content=[TextPromptMessageContent(data=" continues</think>after")],
tool_calls=[],
),
),
),
LLMResultChunk(
model="demo-model",
delta=LLMResultChunkDelta(
index=2,
message=AssistantPromptMessage(content="", tool_calls=[]),
usage=make_usage(prompt_tokens=6, completion_tokens=4),
finish_reason="stop",
),
),
)
async with self.mock_daemon_stream(httpx.MockTransport(handler)):
adapter = DifyLLMAdapterModel(
"demo-model",
self.make_provider(),
model_provider="openai",
credentials={"api_key": "secret"},
)
async with adapter.request_stream(
[ModelRequest(parts=[UserPromptPart("hello")])],
model_settings=None,
model_request_parameters=ModelRequestParameters(),
) as stream:
events = [event async for event in stream]
response = stream.get()
self.assertTrue(events)
self.assertEqual([part.part_kind for part in response.parts], ["text", "thinking", "text"])
self.assertEqual(cast(TextPart, response.parts[0]).content, "before")
self.assertEqual(cast(ThinkingPart, response.parts[1]).content, "reasoning continues")
self.assertEqual(cast(TextPart, response.parts[2]).content, "after")
async def test_request_stream_yields_response_parts_and_usage(self) -> None:
def handler(_request: httpx.Request) -> httpx.Response:
return build_stream_response(

View File

@ -7,13 +7,14 @@ import base64
from collections.abc import Callable
from dataclasses import dataclass, field
import httpx
import pytest
from dify_agent.adapters.shell import shellctl
from dify_agent.adapters.shell.config import ShellAdapterSettings
from dify_agent.adapters.shell.factory import create_shell_provider
from dify_agent.adapters.shell.protocols import ShellCommandResult
from dify_agent.adapters.shell.shellctl import ShellFileTransferError, ShellProviderError, ShellctlProvider
from dify_agent.adapters.shell.protocols import ShellCommandResult, ShellProviderError
from dify_agent.adapters.shell.shellctl import ShellFileTransferError, ShellctlProvider
@dataclass(slots=True)
@ -228,6 +229,82 @@ def test_commands_forward_parameters_and_map_metadata() -> None:
assert client.delete_calls == [("run-job", True, 2.0)]
def test_commands_map_http_timeout_to_shell_provider_error() -> None:
request = httpx.Request("POST", "http://shellctl.example/v1/jobs")
client = FakeShellctlClient(
run_handler=lambda script, cwd, env, timeout: (_ for _ in ()).throw(
httpx.ReadTimeout("timed out", request=request)
)
)
async def scenario() -> None:
resource = await _provider(client).create()
with pytest.raises(ShellProviderError, match="timed out") as exc_info:
await resource.commands.run("pwd", timeout=2.5)
assert exc_info.value.code == "timeout"
asyncio.run(scenario())
def test_commands_map_http_request_error_to_shell_provider_error() -> None:
request = httpx.Request("POST", "http://shellctl.example/v1/jobs/run")
client = FakeShellctlClient(
wait_handler=lambda job_id, offset, timeout: (_ for _ in ()).throw(
httpx.ConnectError("connection failed", request=request)
)
)
async def scenario() -> None:
resource = await _provider(client).create()
with pytest.raises(ShellProviderError, match="connection failed") as exc_info:
await resource.commands.wait("run-job", offset=3, timeout=4.0)
assert exc_info.value.code == "request_error"
asyncio.run(scenario())
def test_delete_maps_http_timeout_to_shell_provider_error() -> None:
request = httpx.Request("DELETE", "http://shellctl.example/v1/jobs/run-job")
@dataclass(slots=True)
class DeleteTimeoutClient(FakeShellctlClient):
async def delete(self, job_id, *, force=False, grace_seconds=None):
self.delete_calls.append((job_id, force, grace_seconds))
raise httpx.ReadTimeout("delete timed out", request=request)
client = DeleteTimeoutClient()
async def scenario() -> None:
resource = await _provider(client).create()
with pytest.raises(ShellProviderError, match="delete timed out") as exc_info:
await resource.commands.delete("run-job", force=True, grace_seconds=2.0)
assert exc_info.value.code == "timeout"
asyncio.run(scenario())
assert client.delete_calls == [("run-job", True, 2.0)]
def test_delete_maps_http_request_error_to_shell_provider_error() -> None:
request = httpx.Request("DELETE", "http://shellctl.example/v1/jobs/run-job")
@dataclass(slots=True)
class DeleteRequestErrorClient(FakeShellctlClient):
async def delete(self, job_id, *, force=False, grace_seconds=None):
self.delete_calls.append((job_id, force, grace_seconds))
raise httpx.ConnectError("delete connection failed", request=request)
client = DeleteRequestErrorClient()
async def scenario() -> None:
resource = await _provider(client).create()
with pytest.raises(ShellProviderError, match="delete connection failed") as exc_info:
await resource.commands.delete("run-job", force=True, grace_seconds=2.0)
assert exc_info.value.code == "request_error"
asyncio.run(scenario())
assert client.delete_calls == [("run-job", True, 2.0)]
def test_files_upload_and_download_still_work() -> None:
content = b"hello \x00 world"
encoded = base64.b64encode(content).decode("ascii")

View File

@ -77,34 +77,16 @@ def test_pull_config_files_from_environment_downloads_visible_files(
assert (tmp_path / "guide.txt").read_bytes() == b"guide-bytes"
def test_pull_config_env_from_environment_writes_only_declared_keys(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setattr(config_cli, "manifest_from_environment", _manifest_payload)
monkeypatch.setenv("API_KEY", "plain")
monkeypatch.setenv("JSON_VALUE", "two words")
result = config_cli.pull_config_env_from_environment(local_path=str(tmp_path / ".env"))
assert result.read_text(encoding="utf-8") == 'API_KEY=plain\nJSON_VALUE="two words"\n'
def test_pull_config_env_and_note_use_hidden_default_dir(
def test_pull_config_note_uses_hidden_default_dir(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(config_cli, "manifest_from_environment", _manifest_payload)
monkeypatch.setenv("API_KEY", "plain")
monkeypatch.setenv("JSON_VALUE", "two words")
env_path = config_cli.pull_config_env_from_environment()
note_path = config_cli.pull_config_note_from_environment()
assert env_path == (tmp_path / ".dify_conf" / ".env").resolve()
assert note_path == (tmp_path / ".dify_conf" / "note.md").resolve()
assert env_path.read_text(encoding="utf-8") == 'API_KEY=plain\nJSON_VALUE="two words"\n'
assert note_path.read_text(encoding="utf-8") == "Use carefully."
@ -177,31 +159,6 @@ def test_push_config_note_from_environment_reads_stdin(monkeypatch: pytest.Monke
assert request.note == "from-stdin"
def test_push_config_env_from_environment_reads_default_file(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.chdir(tmp_path)
env_path = tmp_path / ".dify_conf" / ".env"
env_path.parent.mkdir()
env_path.write_text("API_KEY=value\n", encoding="utf-8")
monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment())
captured: dict[str, object] = {}
def fake_push_sync(**kwargs):
captured.update(kwargs)
return _manifest_payload()
monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync)
config_cli.push_config_env_from_environment(None)
request = cast(AgentStubConfigPushRequest, captured["request"])
assert request.env_text == "API_KEY=value\n"
assert request.note is None
def test_push_config_env_from_environment_reads_explicit_file(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
@ -264,7 +221,7 @@ def test_push_config_files_from_environment_builds_upload_items(
monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync)
config_cli.push_config_files_from_environment([str(file_path)], None)
config_cli.push_config_files_from_environment([str(file_path)])
request = cast(AgentStubConfigPushRequest, captured["request"])
assert request.files[0].name == "guide.txt"
@ -273,19 +230,9 @@ def test_push_config_files_from_environment_builds_upload_items(
assert request.skills == []
def test_push_config_files_from_environment_validates_name_usage(tmp_path: Path) -> None:
first = tmp_path / "a.txt"
second = tmp_path / "b.txt"
first.write_text("a", encoding="utf-8")
second.write_text("b", encoding="utf-8")
with pytest.raises(AgentStubValidationError, match="--name requires exactly one PATH"):
config_cli.push_config_files_from_environment([str(first), str(second)], "renamed.txt")
def test_push_config_files_from_environment_rejects_empty_paths() -> None:
with pytest.raises(AgentStubValidationError, match="at least one file path is required"):
config_cli.push_config_files_from_environment([], None)
config_cli.push_config_files_from_environment([])
def test_delete_config_files_from_environment_builds_delete_items(monkeypatch: pytest.MonkeyPatch) -> None:

View File

@ -299,12 +299,11 @@ def test_cli_config_manifest_omits_hash_fields(
"push_config_note_from_environment",
{"local_path": "/tmp/note.md"},
),
(["config", "env", "push"], "push_config_env_from_environment", {"local_path": None}),
(["config", "env", "push", "/tmp/.env"], "push_config_env_from_environment", {"local_path": "/tmp/.env"}),
(
["config", "files", "push", "/tmp/guide.txt", "--name", "runtime.txt"],
["config", "files", "push", "/tmp/guide.txt"],
"push_config_files_from_environment",
{"paths": ["/tmp/guide.txt"], "name": "runtime.txt"},
{"paths": ["/tmp/guide.txt"]},
),
(
["config", "files", "delete", "old.txt", "legacy.txt"],

View File

@ -23,6 +23,7 @@ def _principal() -> AgentStubPrincipal:
user_id="user-1",
user_from="account",
workflow_id="workflow-1",
conversation_id="conversation-1",
agent_mode="workflow_run",
invoke_from="service-api",
),
@ -54,6 +55,7 @@ def test_dify_api_agent_stub_file_handler_injects_execution_context_for_upload(m
"user_id": "user-1",
"filename": "report.pdf",
"mimetype": "application/pdf",
"conversation_id": "conversation-1",
}
return httpx.Response(200, json={"data": {"url": "https://files.example.com/upload"}})

View File

@ -137,9 +137,9 @@ class DisconnectingSyncStream(httpx.SyncByteStream):
def test_sse_decoder_accepts_function_tool_result_part_alias(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(client_module, "_FUNCTION_TOOL_RESULT_PAYLOAD_KEY", "result")
monkeypatch.setattr(client_module, "_function_tool_result_payload_key_cache", "part")
decoder = client_module._SSEDecoder()
payload = _function_tool_result_payload("part")
payload = _function_tool_result_payload("result")
assert decoder.feed_line(f"data: {json.dumps(payload)}") is None
event = decoder.feed_line("")
@ -153,7 +153,7 @@ def test_sse_decoder_accepts_function_tool_result_part_alias(monkeypatch: pytest
def test_function_tool_result_payload_normalization_supports_old_part_schema(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(client_module, "_FUNCTION_TOOL_RESULT_PAYLOAD_KEY", "part")
monkeypatch.setattr(client_module, "_function_tool_result_payload_key_cache", "part")
payload = _function_tool_result_payload("result")
normalized = client_module._normalize_run_event_payload_for_local_pydantic_ai(payload)

View File

@ -1,32 +0,0 @@
from __future__ import annotations
import sys
import types
from agenton.layers import EmptyLayerConfig, EmptyRuntimeState, NoLayerDeps, PlainLayer
from pydantic import BaseModel
class _BaseSettings(BaseModel):
"""Minimal test stub for environments without pydantic-settings installed."""
class _SettingsConfigDict(dict[str, object]):
"""Minimal callable mapping used by ShellAdapterSettings.model_config."""
if "pydantic_settings" not in sys.modules:
stub = types.ModuleType("pydantic_settings")
setattr(stub, "BaseSettings", _BaseSettings)
setattr(stub, "SettingsConfigDict", _SettingsConfigDict)
sys.modules["pydantic_settings"] = stub
if "dify_agent.layers.execution_context.layer" not in sys.modules:
execution_context_stub = types.ModuleType("dify_agent.layers.execution_context.layer")
class DifyExecutionContextLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, EmptyRuntimeState]):
"""Minimal test stub for shell-layer type-only imports."""
setattr(execution_context_stub, "DifyExecutionContextLayer", DifyExecutionContextLayer)
sys.modules["dify_agent.layers.execution_context.layer"] = execution_context_stub

View File

@ -1,5 +1,6 @@
import asyncio
import json
from types import SimpleNamespace
import httpx
import pytest
@ -19,7 +20,7 @@ from dify_agent.layers.dify_plugin.configs import (
DifyPluginToolsLayerConfig,
)
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer, _PluginToolFileContext
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
@ -91,6 +92,17 @@ def _execution_context_provider() -> LayerProvider[DifyExecutionContextLayer]:
)
def _tools_provider() -> LayerProvider[DifyPluginToolsLayer]:
return LayerProvider.from_factory(
layer_type=DifyPluginToolsLayer,
create=lambda config: DifyPluginToolsLayer.from_config_with_settings(
DifyPluginToolsLayerConfig.model_validate(config),
inner_api_url="http://dify-api",
inner_api_key="inner-secret",
),
)
def _prepared_tool_parameters() -> list[DifyPluginToolParameter]:
return [
DifyPluginToolParameter(
@ -151,6 +163,42 @@ def _llm_only_parameter(*, name: str, description: str, default: JsonValue = Non
)
def _file_parameter(
*,
name: str = "source",
parameter_type: DifyPluginToolParameterType = DifyPluginToolParameterType.FILE,
) -> DifyPluginToolParameter:
return DifyPluginToolParameter(
name=name,
type=parameter_type,
form=DifyPluginToolParameterForm.LLM,
required=True,
llm_description="Source file",
)
def _file_tools_config(
*,
parameter_type: DifyPluginToolParameterType = DifyPluginToolParameterType.FILE,
) -> DifyPluginToolsLayerConfig:
return DifyPluginToolsLayerConfig(
tools=[
DifyPluginToolConfig(
plugin_id="langgenius/tools",
provider="search",
tool_name="read_file",
credential_type="api-key",
parameters=[_file_parameter(parameter_type=parameter_type)],
parameters_json_schema={
"type": "object",
"properties": {"source": {"type": "string"}},
"required": ["source"],
},
)
]
)
def _invoke_stream_response(
*,
error_payload: dict[str, object] | None = None,
@ -204,6 +252,28 @@ def _tool_transport(
return httpx.MockTransport(handler)
def _file_tool_transport(
*,
expected_source: object,
download_response: dict[str, object] | None = None,
) -> httpx.MockTransport:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/inner/api/download/file/request"):
assert download_response is not None
payload = json.loads(request.content.decode("utf-8"))
assert payload["file"] == {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}
return httpx.Response(200, json={"data": download_response})
if request.url.path.endswith("/dispatch/tool/invoke"):
payload = json.loads(request.content.decode("utf-8"))
assert payload["data"]["tool_parameters"]["source"] == expected_source
return _invoke_stream_response()
raise AssertionError(f"Unexpected request path: {request.url.path}")
return httpx.MockTransport(handler)
def test_dify_plugin_type_id_constants_match_implementation_classes() -> None:
assert DIFY_PLUGIN_LLM_LAYER_TYPE_ID == DifyPluginLLMLayer.type_id
assert DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID == DifyPluginToolsLayer.type_id
@ -245,7 +315,7 @@ def test_dify_plugin_tools_layer_uses_prepared_tool_definition_and_invokes_daemo
compositor = Compositor(
[
LayerNode("execution_context", _execution_context_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}),
]
)
async with httpx.AsyncClient(transport=_tool_transport()) as client:
@ -253,7 +323,7 @@ def test_dify_plugin_tools_layer_uses_prepared_tool_definition_and_invokes_daemo
configs={"execution_context": _execution_context_config(), "tools": _tools_config()}
) as run:
tools_layer = run.get_layer("tools", DifyPluginToolsLayer)
tool = (await tools_layer.get_tools(http_client=client))[0]
tool = (await tools_layer.get_tools(http_client=client, dify_api_http_client=client))[0]
tool_def = await tool.prepare_tool_def(None) # pyright: ignore[reportArgumentType]
result = await tool.function_schema.call(
@ -322,14 +392,16 @@ def test_dify_plugin_tools_layer_uses_each_tool_plugin_id_for_transport() -> Non
compositor = Compositor(
[
LayerNode("execution_context", _execution_context_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}),
]
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
async with compositor.enter(
configs={"execution_context": _execution_context_config(), "tools": tools_config}
) as run:
tools = await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client)
tools = await run.get_layer("tools", DifyPluginToolsLayer).get_tools(
http_client=client, dify_api_http_client=client
)
await tools[0].function_schema.call({"query": "first"}, None) # pyright: ignore[reportArgumentType]
await tools[1].function_schema.call({"query": "second"}, None) # pyright: ignore[reportArgumentType]
@ -432,14 +504,18 @@ def test_dify_plugin_tools_layer_casts_prepared_parameter_values_before_invocati
compositor = Compositor(
[
LayerNode("execution_context", _execution_context_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}),
]
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
async with compositor.enter(
configs={"execution_context": _execution_context_config(), "tools": tools_config}
) as run:
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
tool = (
await run.get_layer("tools", DifyPluginToolsLayer).get_tools(
http_client=client, dify_api_http_client=client
)
)[0]
result = await tool.function_schema.call(
{
@ -496,14 +572,18 @@ def test_dify_plugin_tools_layer_sends_prepared_parameter_defaults_to_daemon() -
compositor = Compositor(
[
LayerNode("execution_context", _execution_context_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}),
]
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
async with compositor.enter(
configs={"execution_context": _execution_context_config(), "tools": tools_config}
) as run:
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
tool = (
await run.get_layer("tools", DifyPluginToolsLayer).get_tools(
http_client=client, dify_api_http_client=client
)
)[0]
result = await tool.function_schema.call(
{"query": "dify"},
@ -515,12 +595,289 @@ def test_dify_plugin_tools_layer_sends_prepared_parameter_defaults_to_daemon() -
asyncio.run(scenario())
def test_dify_plugin_tools_layer_converts_remote_url_file_string() -> None:
async def scenario() -> None:
expected_source = {
"dify_model_identity": "__dify__file__",
"type": "custom",
"url": "https://example.com/report.pdf",
"filename": "report.pdf",
"mime_type": "application/pdf",
"extension": ".pdf",
"size": -1,
}
compositor = Compositor(
[
LayerNode("execution_context", _execution_context_provider()),
LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}),
]
)
async with httpx.AsyncClient(transport=_file_tool_transport(expected_source=expected_source)) as client:
async with compositor.enter(
configs={"execution_context": _execution_context_config(), "tools": _file_tools_config()}
) as run:
tool = (
await run.get_layer("tools", DifyPluginToolsLayer).get_tools(
http_client=client,
dify_api_http_client=client,
)
)[0]
result = await tool.function_schema.call(
{"source": "https://example.com/report.pdf"},
None, # pyright: ignore[reportArgumentType]
)
assert result == 'found {"count": 1}'
asyncio.run(scenario())
def test_dify_plugin_tools_layer_converts_remote_url_file_mapping() -> None:
async def scenario() -> None:
expected_source = {
"dify_model_identity": "__dify__file__",
"type": "custom",
"url": "https://example.com/report.pdf",
"filename": "report.pdf",
"mime_type": "application/pdf",
"extension": ".pdf",
"size": -1,
}
compositor = Compositor(
[
LayerNode("execution_context", _execution_context_provider()),
LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}),
]
)
async with httpx.AsyncClient(transport=_file_tool_transport(expected_source=expected_source)) as client:
async with compositor.enter(
configs={"execution_context": _execution_context_config(), "tools": _file_tools_config()}
) as run:
tool = (
await run.get_layer("tools", DifyPluginToolsLayer).get_tools(
http_client=client,
dify_api_http_client=client,
)
)[0]
result = await tool.function_schema.call(
{"source": {"transfer_method": "remote_url", "url": "https://example.com/report.pdf"}},
None, # pyright: ignore[reportArgumentType]
)
assert result == 'found {"count": 1}'
asyncio.run(scenario())
def test_dify_plugin_tools_layer_resolves_tool_file_mapping_before_invocation() -> None:
async def scenario() -> None:
expected_source = {
"dify_model_identity": "__dify__file__",
"type": "custom",
"url": "https://signed.example/report.pdf",
"filename": "report.pdf",
"mime_type": "application/pdf",
"extension": ".pdf",
"size": 42,
}
compositor = Compositor(
[
LayerNode("execution_context", _execution_context_provider()),
LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}),
]
)
async with httpx.AsyncClient(
transport=_file_tool_transport(
expected_source=expected_source,
download_response={
"filename": "report.pdf",
"mime_type": "application/pdf",
"size": 42,
"download_url": "https://signed.example/report.pdf",
},
)
) as client:
async with compositor.enter(
configs={"execution_context": _execution_context_config(), "tools": _file_tools_config()}
) as run:
tool = (
await run.get_layer("tools", DifyPluginToolsLayer).get_tools(
http_client=client,
dify_api_http_client=client,
)
)[0]
result = await tool.function_schema.call(
{"source": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}},
None, # pyright: ignore[reportArgumentType]
)
assert result == 'found {"count": 1}'
asyncio.run(scenario())
def test_dify_plugin_tools_layer_converts_files_parameter_values() -> None:
async def scenario() -> None:
expected_source = [
{
"dify_model_identity": "__dify__file__",
"type": "custom",
"url": "https://example.com/a.txt",
"filename": "a.txt",
"mime_type": "text/plain",
"extension": ".txt",
"size": -1,
},
{
"dify_model_identity": "__dify__file__",
"type": "custom",
"url": "https://example.com/b.txt",
"filename": "b.txt",
"mime_type": "text/plain",
"extension": ".txt",
"size": -1,
},
]
compositor = Compositor(
[
LayerNode("execution_context", _execution_context_provider()),
LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}),
]
)
async with httpx.AsyncClient(transport=_file_tool_transport(expected_source=expected_source)) as client:
async with compositor.enter(
configs={
"execution_context": _execution_context_config(),
"tools": _file_tools_config(parameter_type=DifyPluginToolParameterType.FILES),
}
) as run:
tool = (
await run.get_layer("tools", DifyPluginToolsLayer).get_tools(
http_client=client,
dify_api_http_client=client,
)
)[0]
result = await tool.function_schema.call(
{"source": ["https://example.com/a.txt", "https://example.com/b.txt"]},
None, # pyright: ignore[reportArgumentType]
)
assert result == 'found {"count": 1}'
asyncio.run(scenario())
def test_dify_plugin_tools_layer_rejects_multiple_values_for_single_file() -> None:
async def scenario() -> None:
compositor = Compositor(
[
LayerNode("execution_context", _execution_context_provider()),
LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}),
]
)
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(500))) as client:
async with compositor.enter(
configs={"execution_context": _execution_context_config(), "tools": _file_tools_config()}
) as run:
tool = (
await run.get_layer("tools", DifyPluginToolsLayer).get_tools(
http_client=client,
dify_api_http_client=client,
)
)[0]
result = await tool.function_schema.call(
{"source": ["https://example.com/a.txt", "https://example.com/b.txt"]},
None, # pyright: ignore[reportArgumentType]
)
assert "only accepts one file" in result
asyncio.run(scenario())
def test_dify_plugin_tools_layer_rejects_path_without_shell() -> None:
async def scenario() -> None:
compositor = Compositor(
[
LayerNode("execution_context", _execution_context_provider()),
LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}),
]
)
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(500))) as client:
async with compositor.enter(
configs={"execution_context": _execution_context_config(), "tools": _file_tools_config()}
) as run:
tool = (
await run.get_layer("tools", DifyPluginToolsLayer).get_tools(
http_client=client,
dify_api_http_client=client,
)
)[0]
result = await tool.function_schema.call(
{"source": "outputs/report.pdf"},
None, # pyright: ignore[reportArgumentType]
)
assert "require an active shell layer" in result
asyncio.run(scenario())
def test_plugin_tool_file_context_uploads_sandbox_path_and_resolves_signed_url() -> None:
class FakeShell:
script: str | None = None
async def run_remote_script_complete(self, script: str, **_kwargs: object) -> object:
self.script = script
return SimpleNamespace(
exit_code=0,
status="done",
output_complete=True,
output=(
"noise\n"
"<<<DIFY_PLUGIN_TOOL_FILE_UPLOAD_BEGIN>>>"
'{"transfer_method":"tool_file","reference":"dify-file-ref:file-1"}'
"<<<DIFY_PLUGIN_TOOL_FILE_UPLOAD_END>>>\n"
),
)
class FakeFileClient:
async def request_download(self, **_kwargs: object) -> object:
return SimpleNamespace(
filename="report.pdf",
mime_type="application/pdf",
size=42,
download_url="https://signed.example/report.pdf",
)
async def scenario() -> None:
shell = FakeShell()
context = _PluginToolFileContext(
file_client=FakeFileClient(), # type: ignore[arg-type]
execution_context=_execution_context_config(),
shell=shell, # type: ignore[arg-type]
)
result = await context.to_plugin_file_parameter("outputs/report.pdf")
assert result == {
"dify_model_identity": "__dify__file__",
"type": "custom",
"url": "https://signed.example/report.pdf",
"filename": "report.pdf",
"mime_type": "application/pdf",
"extension": ".pdf",
"size": 42,
}
assert shell.script is not None
assert "outputs/report.pdf" in shell.script
asyncio.run(scenario())
def test_dify_plugin_tools_layer_requires_hidden_runtime_parameters_in_prepared_config() -> None:
async def scenario() -> None:
compositor = Compositor(
[
LayerNode("execution_context", _execution_context_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}),
]
)
async with httpx.AsyncClient(transport=_tool_transport()) as client:
@ -531,7 +888,9 @@ def test_dify_plugin_tools_layer_requires_hidden_runtime_parameters_in_prepared_
}
) as run:
with pytest.raises(ValueError, match="requires non-LLM runtime_parameters for: auth_scope"):
await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client)
await run.get_layer("tools", DifyPluginToolsLayer).get_tools(
http_client=client, dify_api_http_client=client
)
asyncio.run(scenario())
@ -541,7 +900,7 @@ def test_dify_plugin_tools_layer_returns_agent_friendly_error_text() -> None:
compositor = Compositor(
[
LayerNode("execution_context", _execution_context_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}),
]
)
async with httpx.AsyncClient(
@ -555,7 +914,11 @@ def test_dify_plugin_tools_layer_returns_agent_friendly_error_text() -> None:
async with compositor.enter(
configs={"execution_context": _execution_context_config(), "tools": _tools_config()}
) as run:
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
tool = (
await run.get_layer("tools", DifyPluginToolsLayer).get_tools(
http_client=client, dify_api_http_client=client
)
)[0]
result = await tool.function_schema.call(
{"query": "dify", "region": "global"},
None, # pyright: ignore[reportArgumentType]
@ -571,7 +934,7 @@ def test_dify_plugin_tools_layer_propagates_unexpected_transport_errors() -> Non
compositor = Compositor(
[
LayerNode("execution_context", _execution_context_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}),
]
)
@ -585,7 +948,11 @@ def test_dify_plugin_tools_layer_propagates_unexpected_transport_errors() -> Non
async with compositor.enter(
configs={"execution_context": _execution_context_config(), "tools": _tools_config()}
) as run:
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
tool = (
await run.get_layer("tools", DifyPluginToolsLayer).get_tools(
http_client=client, dify_api_http_client=client
)
)[0]
with pytest.raises(RuntimeError, match="unexpected transport failure"):
await tool.function_schema.call(
@ -633,14 +1000,18 @@ def test_dify_plugin_tools_layer_maps_nested_plugin_invoke_errors_to_agent_text(
compositor = Compositor(
[
LayerNode("execution_context", _execution_context_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}),
]
)
async with httpx.AsyncClient(transport=_tool_transport(invoke_error_payload=invoke_error_payload)) as client:
async with compositor.enter(
configs={"execution_context": _execution_context_config(), "tools": _tools_config()}
) as run:
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
tool = (
await run.get_layer("tools", DifyPluginToolsLayer).get_tools(
http_client=client, dify_api_http_client=client
)
)[0]
result = await tool.function_schema.call(
{"query": "dify", "region": "global"},
None, # pyright: ignore[reportArgumentType]
@ -656,14 +1027,18 @@ def test_dify_plugin_tools_layer_merges_blob_chunks_before_observation_conversio
compositor = Compositor(
[
LayerNode("execution_context", _execution_context_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}),
]
)
async with httpx.AsyncClient(transport=_tool_transport(chunked_blob=True)) as client:
async with compositor.enter(
configs={"execution_context": _execution_context_config(), "tools": _tools_config()}
) as run:
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
tool = (
await run.get_layer("tools", DifyPluginToolsLayer).get_tools(
http_client=client, dify_api_http_client=client
)
)[0]
result = await tool.function_schema.call(
{"query": "dify", "region": "global"},
None, # pyright: ignore[reportArgumentType]

View File

@ -8,6 +8,7 @@ from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
def test_execution_context_package_exports_client_safe_config_symbols_only() -> None:
assert execution_context_exports.__all__ == [
"DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID",
"DifyExecutionContextAgentConfigVersionKind",
"DifyExecutionContextAgentMode",
"DifyExecutionContextInvokeFrom",
"DifyExecutionContextLayerConfig",

View File

@ -9,7 +9,7 @@ from typing import cast
import pytest
import dify_agent.layers.shell.layer as shell_layer_module
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellEnvVarConfig, DifyShellLayerConfig
from dify_agent.layers.shell.layer import (
CompleteRemoteCommandResult,
DEFAULT_TERMINATE_GRACE_SECONDS,
@ -20,9 +20,11 @@ from dify_agent.adapters.shell.protocols import (
ShellCommandResult,
ShellCommandStatus,
ShellFileTransferProtocol,
ShellProviderError,
ShellProviderProtocol,
ShellResourceProtocol,
)
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
def _command_result(
@ -82,6 +84,17 @@ def _assert_error_observation(result: object, *, job_id: str | None = None, incl
assert includes in result["error"]
def _capture_logged_exceptions(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, tuple[object, ...]]]:
logged: list[tuple[str, tuple[object, ...]]] = []
def fake_exception(message: str, *args: object, **kwargs: object) -> None:
del kwargs
logged.append((message, args))
monkeypatch.setattr(shell_layer_module.logger, "exception", fake_exception)
return logged
@dataclass(slots=True)
class RunCall:
script: str
@ -123,6 +136,10 @@ class DeleteCall:
grace_seconds: float | None
class _UnexpectedToolError(Exception):
pass
class FakeFiles(ShellFileTransferProtocol):
async def upload(self, *, content: bytes, remote_path: str, cwd: str | None = None) -> None:
raise AssertionError("resource.files should not be used by production shell layer logic")
@ -216,6 +233,43 @@ def _layer(
return layer, provider
@dataclass(slots=True)
class _ExecutionContextStub:
config: DifyExecutionContextLayerConfig
def _bind_execution_context(layer: DifyShellLayer, *, agent_id: str | None = "agent-1") -> None:
layer.deps.execution_context = cast(
object, _ExecutionContextStub(config=_execution_context_config(agent_id=agent_id))
)
def _execution_context_config(*, agent_id: str | None = None) -> DifyExecutionContextLayerConfig:
return DifyExecutionContextLayerConfig(
tenant_id="tenant-1",
user_id="user-1",
user_from="account",
agent_id=agent_id,
agent_mode="agent_app",
invoke_from="service-api",
)
def _runtime_state(
*,
session_id: str = "abc12ff",
workspace_cwd: str = "~/workspace/abc12ff",
job_ids: list[str] | None = None,
job_offsets: dict[str, int] | None = None,
) -> DifyShellRuntimeState:
return DifyShellRuntimeState(
session_id=session_id,
workspace_cwd=workspace_cwd,
job_ids=[] if job_ids is None else job_ids,
job_offsets={} if job_offsets is None else job_offsets,
)
def test_shell_type_id_constant_matches_implementation_class() -> None:
assert DIFY_SHELL_LAYER_TYPE_ID == DifyShellLayer.type_id
@ -235,13 +289,15 @@ def test_resource_context_calls_provider_create_and_resource_close() -> None:
def test_shell_layer_create_allocates_workspace_and_bootstraps(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(shell_layer_module.time, "time", lambda: int("abc12", 16))
monkeypatch.setattr(shell_layer_module.secrets, "token_hex", lambda _nbytes: "ff")
expected_home = "/home/agent-1"
expected_workspace_cwd = "/home/agent-1/workspace/abc12ff"
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
assert env is None
assert env == {"HOME": expected_home}
if cwd is None:
assert 'mkdir -p "$HOME/workspace"' in script
return _command_result("mkdir-job", status="exited", done=True, exit_code=0)
assert cwd == "~/workspace/abc12ff"
assert cwd == expected_workspace_cwd
assert "apt-get install -y ripgrep" in script
return _command_result("bootstrap-job", status="exited", done=True, exit_code=0)
@ -251,6 +307,7 @@ def test_shell_layer_create_allocates_workspace_and_bootstraps(monkeypatch: pyte
cli_tools=[{"name": "ripgrep", "install_commands": ["apt-get install -y ripgrep"]}],
),
)
_bind_execution_context(layer)
async def scenario() -> None:
async with layer.resource_context():
@ -259,13 +316,56 @@ def test_shell_layer_create_allocates_workspace_and_bootstraps(monkeypatch: pyte
asyncio.run(scenario())
assert layer.runtime_state == DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
assert layer.runtime_state.session_id == "abc12ff"
assert layer.runtime_state.workspace_cwd == "~/workspace/abc12ff"
assert [call.job_id for call in provider.resource.commands.delete_calls] == ["mkdir-job", "bootstrap-job"]
def test_shell_layer_uses_agent_specific_home_and_workspace_cwd(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(shell_layer_module.time, "time", lambda: int("abc12", 16))
monkeypatch.setattr(shell_layer_module.secrets, "token_hex", lambda _nbytes: "ff")
expected_home = "/home/agent-1"
expected_workspace_cwd = "/home/agent-1/workspace/abc12ff"
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
del timeout
if script.startswith('mkdir -p "$HOME/workspace";'):
assert cwd is None
assert env == {"HOME": expected_home}
return _command_result("mkdir-job", status="exited", done=True, exit_code=0)
if script == "pwd":
assert cwd == expected_workspace_cwd
assert env == {"HOME": expected_home}
return _command_result("user-job", status="exited", done=True, exit_code=0, output=expected_home, offset=13)
raise AssertionError(f"Unexpected script: {script!r}")
commands = FakeCommands(run_handler=run_handler)
layer, _provider = _layer(commands=commands)
_bind_execution_context(layer)
tools = {tool.name: tool for tool in layer.tools}
async def scenario() -> None:
async with layer.resource_context():
await layer.on_context_create()
run_result = await tools["shell_run"].function_schema.call({"script": "pwd"}, None) # pyright: ignore[reportArgumentType]
metadata, output = _parse_tagged_observation(run_result)
assert metadata["job_id"] == "user-job"
assert output == expected_home
asyncio.run(scenario())
assert layer.runtime_state.session_id == "abc12ff"
assert layer.runtime_state.workspace_cwd == "~/workspace/abc12ff"
assert layer.runtime_state.job_ids == ["user-job"]
assert [call.job_id for call in commands.delete_calls] == ["mkdir-job"]
def test_shell_layer_suspend_does_not_close_before_resource_context_exits() -> None:
layer, provider = _layer(commands=FakeCommands())
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
layer.runtime_state = _runtime_state()
async def scenario() -> None:
async with layer.resource_context():
@ -276,6 +376,46 @@ def test_shell_layer_suspend_does_not_close_before_resource_context_exits() -> N
asyncio.run(scenario())
def test_shell_layer_resume_recreates_live_home_and_workspace() -> None:
expected_home = "/home/agent-1"
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
del timeout
assert script == 'mkdir -p "$HOME/workspace/abc12ff"'
assert cwd is None
assert env == {"HOME": expected_home}
return _command_result("resume-job", status="exited", done=True, exit_code=0)
commands = FakeCommands(run_handler=run_handler)
layer, provider = _layer(commands=commands)
_bind_execution_context(layer)
layer.runtime_state = _runtime_state()
async def scenario() -> None:
async with layer.resource_context():
await layer.on_context_resume()
asyncio.run(scenario())
assert [call.job_id for call in commands.delete_calls] == ["resume-job"]
assert provider.resource.closed is True
def test_shell_layer_resume_requires_agent_id_for_live_workspace_reentry() -> None:
commands = FakeCommands()
layer, _provider = _layer(commands=commands)
_bind_execution_context(layer, agent_id=None)
layer.runtime_state = _runtime_state()
async def scenario() -> None:
async with layer.resource_context():
with pytest.raises(ValueError, match="requires execution_context\\.agent_id"):
await layer.on_context_resume()
asyncio.run(scenario())
assert commands.run_calls == []
def test_shell_layer_delete_cleans_workspace_and_tracked_jobs() -> None:
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
del env, timeout
@ -285,12 +425,8 @@ def test_shell_layer_delete_cleans_workspace_and_tracked_jobs() -> None:
commands = FakeCommands(run_handler=run_handler)
layer, provider = _layer(commands=commands)
layer.runtime_state = DifyShellRuntimeState(
session_id="abc12ff",
workspace_cwd="~/workspace/abc12ff",
job_ids=["user-job"],
job_offsets={"user-job": 9},
)
_bind_execution_context(layer)
layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 9})
async def scenario() -> None:
async with layer.resource_context():
@ -307,8 +443,8 @@ def test_shell_layer_delete_cleans_workspace_and_tracked_jobs() -> None:
def test_shell_layer_tools_map_inputs_and_maintain_offsets_with_tail_end() -> None:
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
assert script == "pwd"
assert cwd == "~/workspace/abc12ff"
assert env is None
assert cwd == "/home/agent-1/workspace/abc12ff"
assert env == {"HOME": "/home/agent-1"}
return _command_result(
"user-job",
status="running",
@ -354,8 +490,9 @@ def test_shell_layer_tools_map_inputs_and_maintain_offsets_with_tail_end() -> No
interrupt_handler=interrupt_handler,
)
layer, provider = _layer(commands=commands)
_bind_execution_context(layer)
tools = {tool.name: tool for tool in layer.tools}
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
layer.runtime_state = _runtime_state()
async def scenario() -> None:
async with layer.resource_context():
@ -410,8 +547,8 @@ def test_shell_layer_tools_map_inputs_and_maintain_offsets_with_tail_end() -> No
def test_shell_run_keeps_original_offset_when_tail_lookup_fails_for_truncated_output() -> None:
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
assert script == "pwd"
assert cwd == "~/workspace/abc12ff"
assert env is None
assert cwd == "/home/agent-1/workspace/abc12ff"
assert env == {"HOME": "/home/agent-1"}
return _command_result(
"user-job",
status="running",
@ -427,8 +564,9 @@ def test_shell_run_keeps_original_offset_when_tail_lookup_fails_for_truncated_ou
commands = FakeCommands(run_handler=run_handler, tail_handler=tail_handler)
layer, provider = _layer(commands=commands)
_bind_execution_context(layer)
tools = {tool.name: tool for tool in layer.tools}
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
layer.runtime_state = _runtime_state()
async def scenario() -> None:
async with layer.resource_context():
@ -465,8 +603,9 @@ def test_shell_run_formats_large_non_truncated_output_without_tail_lookup() -> N
)
)
layer, _provider = _layer(commands=commands)
_bind_execution_context(layer)
tools = {tool.name: tool for tool in layer.tools}
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
layer.runtime_state = _runtime_state()
async def scenario() -> None:
async with layer.resource_context():
@ -488,12 +627,7 @@ def test_shell_interrupt_succeeds_when_tail_lookup_fails() -> None:
)
layer, _provider = _layer(commands=commands)
tools = {tool.name: tool for tool in layer.tools}
layer.runtime_state = DifyShellRuntimeState(
session_id="abc12ff",
workspace_cwd="~/workspace/abc12ff",
job_ids=["user-job"],
job_offsets={"user-job": 22},
)
layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 22})
async def scenario() -> None:
async with layer.resource_context():
@ -510,14 +644,270 @@ def test_shell_interrupt_succeeds_when_tail_lookup_fails() -> None:
asyncio.run(scenario())
def test_shell_run_returns_provider_timeout_error_observation_without_unexpected_logging(
monkeypatch: pytest.MonkeyPatch,
) -> None:
logged = _capture_logged_exceptions(monkeypatch)
commands = FakeCommands(
run_handler=lambda script, cwd, env, timeout: (_ for _ in ()).throw(
ShellProviderError("provider timed out", code="timeout")
)
)
layer, _provider = _layer(commands=commands)
_bind_execution_context(layer)
tools = {tool.name: tool for tool in layer.tools}
layer.runtime_state = _runtime_state()
async def scenario() -> None:
async with layer.resource_context():
result = await tools["shell_run"].function_schema.call({"script": "pwd"}, None) # pyright: ignore[reportArgumentType]
_assert_error_observation(result, includes="timeout")
asyncio.run(scenario())
assert logged == []
def test_shell_wait_returns_provider_request_error_observation_with_job_id_and_no_unexpected_logging(
monkeypatch: pytest.MonkeyPatch,
) -> None:
logged = _capture_logged_exceptions(monkeypatch)
commands = FakeCommands(
wait_handler=lambda job_id, offset, timeout: (_ for _ in ()).throw(
ShellProviderError("provider unavailable", code="request_error")
)
)
layer, _provider = _layer(commands=commands)
tools = {tool.name: tool for tool in layer.tools}
layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 3})
async def scenario() -> None:
async with layer.resource_context():
result = await tools["shell_wait"].function_schema.call(
{"job_id": "user-job", "timeout": 4.0},
None, # pyright: ignore[reportArgumentType]
)
_assert_error_observation(result, job_id="user-job", includes="request_error")
asyncio.run(scenario())
assert logged == []
def test_shell_input_returns_provider_timeout_error_observation_with_job_id_and_no_unexpected_logging(
monkeypatch: pytest.MonkeyPatch,
) -> None:
logged = _capture_logged_exceptions(monkeypatch)
commands = FakeCommands(
input_handler=lambda job_id, text, offset, timeout: (_ for _ in ()).throw(
ShellProviderError("provider timed out", code="timeout")
)
)
layer, _provider = _layer(commands=commands)
tools = {tool.name: tool for tool in layer.tools}
layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 6})
async def scenario() -> None:
async with layer.resource_context():
result = await tools["shell_input"].function_schema.call(
{"job_id": "user-job", "text": "ls\n", "timeout": 5.0},
None, # pyright: ignore[reportArgumentType]
)
_assert_error_observation(result, job_id="user-job", includes="timeout")
asyncio.run(scenario())
assert logged == []
def test_shell_interrupt_returns_provider_request_error_observation_with_job_id_and_no_unexpected_logging(
monkeypatch: pytest.MonkeyPatch,
) -> None:
logged = _capture_logged_exceptions(monkeypatch)
commands = FakeCommands(
interrupt_handler=lambda job_id, grace_seconds: (_ for _ in ()).throw(
ShellProviderError("provider unavailable", code="request_error")
)
)
layer, _provider = _layer(commands=commands)
tools = {tool.name: tool for tool in layer.tools}
layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 22})
async def scenario() -> None:
async with layer.resource_context():
result = await tools["shell_interrupt"].function_schema.call(
{"job_id": "user-job", "grace_seconds": 1.5},
None, # pyright: ignore[reportArgumentType]
)
_assert_error_observation(result, job_id="user-job", includes="request_error")
asyncio.run(scenario())
assert logged == []
def test_shell_run_returns_error_observation_and_logs_unexpected_exception(
monkeypatch: pytest.MonkeyPatch,
) -> None:
logged = _capture_logged_exceptions(monkeypatch)
commands = FakeCommands(
run_handler=lambda script, cwd, env, timeout: (_ for _ in ()).throw(_UnexpectedToolError("boom"))
)
layer, _provider = _layer(commands=commands)
_bind_execution_context(layer)
tools = {tool.name: tool for tool in layer.tools}
layer.runtime_state = _runtime_state()
async def scenario() -> None:
async with layer.resource_context():
result = await tools["shell_run"].function_schema.call({"script": "pwd"}, None) # pyright: ignore[reportArgumentType]
_assert_error_observation(result, includes="boom")
asyncio.run(scenario())
assert logged == [
("Unexpected shell tool failure: tool=%s session_id=%s job_id=%s", ("shell_run", "abc12ff", None))
]
def test_shell_wait_returns_error_observation_and_logs_unexpected_exception(
monkeypatch: pytest.MonkeyPatch,
) -> None:
logged = _capture_logged_exceptions(monkeypatch)
commands = FakeCommands(
wait_handler=lambda job_id, offset, timeout: (_ for _ in ()).throw(_UnexpectedToolError("wait exploded"))
)
layer, _provider = _layer(commands=commands)
tools = {tool.name: tool for tool in layer.tools}
layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 3})
async def scenario() -> None:
async with layer.resource_context():
result = await tools["shell_wait"].function_schema.call(
{"job_id": "user-job", "timeout": 4.0},
None, # pyright: ignore[reportArgumentType]
)
_assert_error_observation(result, job_id="user-job", includes="wait exploded")
asyncio.run(scenario())
assert logged == [
("Unexpected shell tool failure: tool=%s session_id=%s job_id=%s", ("shell_wait", "abc12ff", "user-job"))
]
def test_shell_input_returns_error_observation_and_logs_unexpected_exception(
monkeypatch: pytest.MonkeyPatch,
) -> None:
logged = _capture_logged_exceptions(monkeypatch)
commands = FakeCommands(
input_handler=lambda job_id, text, offset, timeout: (_ for _ in ()).throw(
_UnexpectedToolError("stdin exploded")
)
)
layer, _provider = _layer(commands=commands)
tools = {tool.name: tool for tool in layer.tools}
layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 6})
async def scenario() -> None:
async with layer.resource_context():
result = await tools["shell_input"].function_schema.call(
{"job_id": "user-job", "text": "ls\n", "timeout": 5.0},
None, # pyright: ignore[reportArgumentType]
)
_assert_error_observation(result, job_id="user-job", includes="stdin exploded")
asyncio.run(scenario())
assert logged == [
(
"Unexpected shell tool failure: tool=%s session_id=%s job_id=%s",
("shell_input", "abc12ff", "user-job"),
)
]
def test_shell_interrupt_returns_error_observation_and_logs_unexpected_exception(
monkeypatch: pytest.MonkeyPatch,
) -> None:
logged = _capture_logged_exceptions(monkeypatch)
commands = FakeCommands(
interrupt_handler=lambda job_id, grace_seconds: (_ for _ in ()).throw(
_UnexpectedToolError("interrupt exploded")
)
)
layer, _provider = _layer(commands=commands)
tools = {tool.name: tool for tool in layer.tools}
layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 22})
async def scenario() -> None:
async with layer.resource_context():
result = await tools["shell_interrupt"].function_schema.call(
{"job_id": "user-job", "grace_seconds": 1.5},
None, # pyright: ignore[reportArgumentType]
)
_assert_error_observation(result, job_id="user-job", includes="interrupt exploded")
asyncio.run(scenario())
assert logged == [
(
"Unexpected shell tool failure: tool=%s session_id=%s job_id=%s",
("shell_interrupt", "abc12ff", "user-job"),
)
]
def test_shell_interrupt_logs_unexpected_tail_failure_but_still_succeeds(
monkeypatch: pytest.MonkeyPatch,
) -> None:
logged = _capture_logged_exceptions(monkeypatch)
commands = FakeCommands(
interrupt_handler=lambda job_id, grace_seconds: _command_status(job_id, offset=22),
tail_handler=lambda job_id: (_ for _ in ()).throw(_UnexpectedToolError("tail exploded")),
)
layer, _provider = _layer(commands=commands)
tools = {tool.name: tool for tool in layer.tools}
layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 22})
async def scenario() -> None:
async with layer.resource_context():
result = await tools["shell_interrupt"].function_schema.call({"job_id": "user-job"}, None) # pyright: ignore[reportArgumentType]
metadata, output = _parse_tagged_observation(result)
assert metadata == {
"job_id": "user-job",
"status": "terminated",
"done": True,
"exit_code": 130,
}
assert output == "Job was interrupted."
asyncio.run(scenario())
assert logged == [
(
"Failed to fetch output path for interrupted shell job %s in session %s",
("user-job", "abc12ff"),
)
]
def test_shell_run_propagates_cancelled_error() -> None:
commands = FakeCommands(
run_handler=lambda script, cwd, env, timeout: (_ for _ in ()).throw(asyncio.CancelledError())
)
layer, _provider = _layer(commands=commands)
_bind_execution_context(layer)
tools = {tool.name: tool for tool in layer.tools}
layer.runtime_state = _runtime_state()
async def scenario() -> None:
async with layer.resource_context():
with pytest.raises(asyncio.CancelledError):
await tools["shell_run"].function_schema.call({"script": "pwd"}, None) # pyright: ignore[reportArgumentType]
asyncio.run(scenario())
def test_run_remote_script_complete_uses_read_output_before_wait_and_deletes_job() -> None:
events: list[str] = []
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
events.append("run")
assert script == "printf 'abcdefghi'"
assert cwd == "~/workspace/abc12ff"
assert env is None
assert cwd == "/home/agent-1/workspace/abc12ff"
assert env == {"HOME": "/home/agent-1"}
return _command_result("remote-job", status="running", done=False, output="abc", offset=3, truncated=True)
def wait_handler(job_id: str, offset: int, timeout: float) -> ShellCommandResult:
@ -531,7 +921,8 @@ def test_run_remote_script_complete_uses_read_output_before_wait_and_deletes_job
commands = FakeCommands(run_handler=run_handler, wait_handler=wait_handler)
layer, _provider = _layer(commands=commands)
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
_bind_execution_context(layer)
layer.runtime_state = _runtime_state()
async def scenario() -> None:
async with layer.resource_context():
@ -546,20 +937,126 @@ def test_run_remote_script_complete_uses_read_output_before_wait_and_deletes_job
assert [call.job_id for call in commands.delete_calls] == ["remote-job"]
def test_run_remote_script_complete_uses_agent_specific_home_and_workspace_cwd() -> None:
expected_home = "/home/agent-1"
expected_workspace_cwd = "/home/agent-1/workspace/abc12ff"
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
del timeout
assert script == "pwd"
assert cwd == expected_workspace_cwd
assert env == {"HOME": expected_home}
return _command_result(
"remote-job",
status="exited",
done=True,
exit_code=0,
output=expected_home,
offset=len(expected_home),
)
commands = FakeCommands(run_handler=run_handler)
layer, _provider = _layer(commands=commands)
_bind_execution_context(layer)
layer.runtime_state = _runtime_state()
async def scenario() -> None:
async with layer.resource_context():
result = await layer.run_remote_script_complete("pwd")
assert isinstance(result, CompleteRemoteCommandResult)
assert result.output == expected_home
asyncio.run(scenario())
assert [call.job_id for call in commands.delete_calls] == ["remote-job"]
def test_run_remote_script_complete_passes_config_env_values_to_shellctl_env() -> None:
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
del timeout
assert script == "printenv API_TOKEN"
assert cwd == "/home/agent-1/workspace/abc12ff"
assert env == {"HOME": "/home/agent-1", "API_TOKEN": "inline-secret-value"}
return _command_result(
"remote-job",
status="exited",
done=True,
exit_code=0,
output="inline-secret-value\n",
offset=len("inline-secret-value\n"),
)
commands = FakeCommands(run_handler=run_handler)
layer, _provider = _layer(
commands=commands,
config=DifyShellLayerConfig(env=[DifyShellEnvVarConfig(name="API_TOKEN", value="inline-secret-value")]),
)
_bind_execution_context(layer)
layer.runtime_state = _runtime_state()
async def scenario() -> None:
async with layer.resource_context():
result = await layer.run_remote_script_complete("printenv API_TOKEN")
assert isinstance(result, CompleteRemoteCommandResult)
assert result.output == "inline-secret-value\n"
asyncio.run(scenario())
assert [call.job_id for call in commands.delete_calls] == ["remote-job"]
def test_run_remote_script_uses_agent_specific_home_and_workspace_cwd() -> None:
expected_home = "/home/agent-1"
expected_workspace_cwd = "/home/agent-1/workspace/abc12ff"
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
del timeout
assert script == "pwd"
assert cwd == expected_workspace_cwd
assert env == {"HOME": expected_home}
return _command_result(
"remote-job",
status="exited",
done=True,
exit_code=0,
output=expected_home,
offset=len(expected_home),
)
commands = FakeCommands(run_handler=run_handler)
layer, _provider = _layer(commands=commands)
_bind_execution_context(layer)
layer.runtime_state = _runtime_state()
async def scenario() -> None:
async with layer.resource_context():
result = await layer.run_remote_script("pwd")
assert isinstance(result, CompleteRemoteCommandResult)
assert result.output == expected_home
asyncio.run(scenario())
assert [call.job_id for call in commands.delete_calls] == ["remote-job"]
def test_run_remote_script_complete_returns_incomplete_reason_for_output_limit() -> None:
commands = FakeCommands(
run_handler=lambda script, cwd, env, timeout: _command_result(
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
del script, timeout
assert cwd == "/home/agent-1/workspace/abc12ff"
assert env == {"HOME": "/home/agent-1"}
return _command_result(
"remote-job",
status="running",
done=False,
output="hello world",
offset=11,
truncated=True,
),
)
commands = FakeCommands(
run_handler=run_handler,
interrupt_handler=lambda job_id, grace_seconds: _command_status(job_id, status="terminated", offset=11),
)
layer, _provider = _layer(commands=commands)
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
_bind_execution_context(layer)
layer.runtime_state = _runtime_state()
async def scenario() -> None:
async with layer.resource_context():
@ -589,8 +1086,8 @@ def test_run_remote_script_complete_returns_incomplete_reason_for_timeout(
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
nonlocal now
assert script == "sleep 10"
assert cwd == "~/workspace/abc12ff"
assert env is None
assert cwd == "/home/agent-1/workspace/abc12ff"
assert env == {"HOME": "/home/agent-1"}
assert timeout == pytest.approx(60.0, rel=0, abs=0.01)
now = 161.0
return _command_result("remote-job", status="running", done=False, output="hello", offset=5)
@ -606,7 +1103,8 @@ def test_run_remote_script_complete_returns_incomplete_reason_for_timeout(
),
)
layer, _provider = _layer(commands=commands)
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
_bind_execution_context(layer)
layer.runtime_state = _runtime_state()
async def scenario() -> None:
async with layer.resource_context():
@ -629,7 +1127,7 @@ def test_shell_layer_rejects_untracked_job_ids_without_provider_calls() -> None:
commands = FakeCommands()
layer, _provider = _layer(commands=commands)
tools = {tool.name: tool for tool in layer.tools}
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
layer.runtime_state = _runtime_state()
async def scenario() -> None:
async with layer.resource_context():
@ -649,10 +1147,26 @@ def test_shell_layer_rejects_untracked_job_ids_without_provider_calls() -> None:
assert commands.interrupt_calls == []
def test_shell_layer_requires_agent_id_for_live_command_execution(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(shell_layer_module.time, "time", lambda: int("abc12", 16))
monkeypatch.setattr(shell_layer_module.secrets, "token_hex", lambda _nbytes: "ff")
commands = FakeCommands()
layer, _provider = _layer(commands=commands)
_bind_execution_context(layer, agent_id=None)
async def scenario() -> None:
async with layer.resource_context():
with pytest.raises(ValueError, match="requires execution_context\\.agent_id"):
await layer.on_context_create()
asyncio.run(scenario())
assert commands.run_calls == []
def test_shell_layer_hooks_and_tools_fail_clearly_outside_active_resource_context() -> None:
layer, _provider = _layer(commands=FakeCommands())
tools = {tool.name: tool for tool in layer.tools}
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
layer.runtime_state = _runtime_state()
async def scenario() -> None:
result = await tools["shell_run"].function_schema.call({"script": "pwd"}, None) # pyright: ignore[reportArgumentType]

View File

@ -12,6 +12,7 @@ from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PL
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID
from dify_agent.protocol.schemas import (
AgentRunUsage,
RUN_EVENT_ADAPTER,
CreateRunRequest,
DeferredToolCallPayload,
@ -171,6 +172,7 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_
"conversation_id": None,
"agent_id": None,
"agent_config_version_id": None,
"agent_config_version_kind": None,
"agent_mode": "workflow_run",
"invoke_from": "service-api",
"trace_id": "trace-1",
@ -403,6 +405,27 @@ def test_run_succeeded_event_round_trips_explicit_json_null_output() -> None:
assert b'"deferred_tool_call"' not in payload
def test_run_succeeded_event_round_trips_usage() -> None:
event = RunSucceededEvent(
run_id="run-usage",
data=RunSucceededEventData(
output="done",
session_snapshot=CompositorSessionSnapshot(layers=[]),
usage=AgentRunUsage(prompt_tokens=3, completion_tokens=5),
),
)
payload = RUN_EVENT_ADAPTER.dump_json(event)
decoded = RUN_EVENT_ADAPTER.validate_json(payload)
assert isinstance(decoded, RunSucceededEvent)
assert decoded.data.usage is not None
assert decoded.data.usage.prompt_tokens == 3
assert decoded.data.usage.completion_tokens == 5
assert decoded.data.usage.total_tokens == 8
assert b'"usage"' in payload
def test_on_exit_accept_layer_overrides() -> None:
request = CreateRunRequest.model_validate(
{

View File

@ -384,6 +384,8 @@ def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPa
terminal = sink.events["run-1"][-1]
assert isinstance(terminal, RunSucceededEvent)
assert terminal.data.output == "done"
assert terminal.data.usage is not None
assert terminal.data.usage.total_tokens > 0
assert [layer.name for layer in terminal.data.session_snapshot.layers] == [
"prompt",
"renamed-execution-context",

View File

@ -3,15 +3,19 @@ from __future__ import annotations
import asyncio
import base64
import json
import os
from pathlib import Path
import subprocess
import sys
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Literal
from typing import Literal, cast
import pytest
from agenton.compositor import CompositorSessionSnapshot, LayerProvider
from agenton.compositor.schemas import LayerSessionSnapshot
from agenton.layers.base import LifecycleState
from dify_agent.adapters.shell.shellctl import ShellctlProvider
from dify_agent.adapters.shell.shellctl import ShellctlClientProtocol, ShellctlProvider
from dify_agent.agent_stub.server.shell_agent_stub_env import (
AGENT_STUB_API_BASE_URL_ENV_VAR,
AGENT_STUB_AUTH_JWE_ENV_VAR,
@ -32,10 +36,13 @@ from dify_agent.protocol import (
build_sandbox_locator_from_run_request,
)
from dify_agent.server.sandbox_files import (
_LIST_SCRIPT,
SandboxFileError,
SandboxFileService,
_OUTPUT_BEGIN,
_OUTPUT_END,
_READ_SCRIPT,
_UPLOAD_SCRIPT,
_decode_sandbox_payload,
_shell_result_details,
)
@ -57,35 +64,35 @@ class _Job:
class RunCall:
script: str
cwd: str | None
env: Mapping[str, str] | None
env: dict[str, str] | None
timeout: float
class FakeShellctlClient:
def __init__(self, *, run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], _Job]) -> None:
def __init__(self, *, run_handler: Callable[[str, str | None, dict[str, str] | None, float], _Job]) -> None:
self.run_handler = run_handler
self.run_calls: list[RunCall] = []
self.delete_calls: list[str] = []
async def run(
self, script: str, *, cwd: str | None = None, env: Mapping[str, str] | None = None, timeout: float = 10.0
):
self, script: str, *, cwd: str | None = None, env: dict[str, str] | None = None, timeout: float = 10.0
) -> _Job:
self.run_calls.append(RunCall(script=script, cwd=cwd, env=env, timeout=timeout))
return self.run_handler(script, cwd, env, timeout)
async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0):
async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0) -> _Job:
raise AssertionError(f"Unexpected wait() call for {job_id} offset={offset} timeout={timeout}")
async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0):
async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0) -> _Job:
raise AssertionError(f"Unexpected input() call for {job_id} text={text!r}")
async def tail(self, job_id: str):
async def tail(self, job_id: str) -> _Job:
raise AssertionError(f"Unexpected tail() call for {job_id}")
async def terminate(self, job_id: str, grace_seconds: float = 10.0):
async def terminate(self, job_id: str, grace_seconds: float = 10.0) -> _Job:
raise AssertionError(f"Unexpected terminate() call for {job_id} grace={grace_seconds}")
async def delete(self, job_id: str, *, force: bool = False, grace_seconds: float | None = None):
async def delete(self, job_id: str, *, force: bool = False, grace_seconds: float | None = None) -> None:
del force, grace_seconds
self.delete_calls.append(job_id)
return None
@ -125,6 +132,28 @@ def _complete_result(
)
def _run_embedded_script(
script_source: str,
*,
args: list[str],
cwd: Path,
env: Mapping[str, str] | None = None,
) -> dict[str, object]:
merged_env = dict(os.environ)
if env is not None:
merged_env.update(env)
completed = subprocess.run(
[sys.executable, "-", *args],
input=script_source,
text=True,
capture_output=True,
cwd=cwd,
env=merged_env,
check=False,
)
return _decode_sandbox_payload(_complete_result(output=completed.stdout, exit_code=completed.returncode))
def _execution_context() -> DifyExecutionContextLayerConfig:
return DifyExecutionContextLayerConfig(
tenant_id="tenant-1",
@ -169,7 +198,7 @@ def _locator() -> SandboxLocator:
def _service(
run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], _Job],
run_handler: Callable[[str, str | None, dict[str, str] | None, float], _Job],
) -> tuple[SandboxFileService, FakeShellctlClient]:
client = FakeShellctlClient(run_handler=run_handler)
execution_context_provider = LayerProvider.from_factory(
@ -187,7 +216,7 @@ def _service(
shell_provider=ShellctlProvider(
entrypoint="http://shellctl",
token="",
client_factory=lambda: client,
client_factory=lambda: cast(ShellctlClientProtocol, cast(object, client)),
),
agent_stub_api_base_url="https://agent.example.com/agent-stub",
agent_stub_token_factory=lambda execution_context, *, session_id: (
@ -198,6 +227,19 @@ def _service(
return SandboxFileService(layer_providers=(execution_context_provider, shell_provider)), client
def _sandbox_python_run_call(client: FakeShellctlClient) -> RunCall:
for run_call in reversed(client.run_calls):
if run_call.script.startswith("python3 - "):
return run_call
raise AssertionError("sandbox python script was not executed")
def _sandbox_list_entries(payload: dict[str, object]) -> list[object]:
entries = payload.get("entries")
assert isinstance(entries, list)
return entries
def test_list_files_runs_fixed_script_and_parses_response() -> None:
service, client = _service(
lambda script, cwd, env, timeout: _Job(
@ -215,13 +257,153 @@ def test_list_files_runs_fixed_script_and_parses_response() -> None:
result = asyncio.run(service.list_files(SandboxListRequest(locator=_locator(), path=".")))
assert result.entries[0].name == "notes.txt"
assert client.run_calls[0].cwd == "~/workspace/abc12ff"
assert client.run_calls[0].env is None
assert "python3 - . 1000 <<'PY'" in client.run_calls[0].script
assert client.delete_calls == ["sandbox-job"]
script_call = _sandbox_python_run_call(client)
assert script_call.cwd == "/home/agent-1/workspace/abc12ff"
assert script_call.env == {"HOME": "/home/agent-1"}
assert "python3 - . 1000 <<'PY'" in script_call.script
assert client.delete_calls[-1] == "sandbox-job"
@pytest.mark.parametrize("bad_path", ["/etc/passwd", "~/secret-dir", "bad\x00path"])
def test_list_files_allows_parent_relative_paths() -> None:
service, client = _service(
lambda script, cwd, env, timeout: _Job(
job_id="sandbox-job",
output=_wrap({"path": "../shared", "entries": [], "truncated": False}),
)
)
result = asyncio.run(service.list_files(SandboxListRequest(locator=_locator(), path="../shared")))
assert result.path == "../shared"
assert "python3 - ../shared 1000 <<'PY'" in _sandbox_python_run_call(client).script
@pytest.mark.parametrize(
("path", "expected_command"),
[
("~", "python3 - '~' 1000 <<'PY'"),
("~/shared", "python3 - '~/shared' 1000 <<'PY'"),
],
)
def test_list_files_allows_home_relative_paths(path: str, expected_command: str) -> None:
service, client = _service(
lambda script, cwd, env, timeout: _Job(
job_id="sandbox-job",
output=_wrap({"path": path, "entries": [], "truncated": False}),
)
)
result = asyncio.run(service.list_files(SandboxListRequest(locator=_locator(), path=path)))
assert result.path == path
assert expected_command in _sandbox_python_run_call(client).script
def test_embedded_scripts_allow_parent_relative_paths(tmp_path: Path) -> None:
workspace_dir = tmp_path / "workspace"
cwd = workspace_dir / "run"
shared_dir = workspace_dir / "shared"
cwd.mkdir(parents=True)
shared_dir.mkdir()
notes_path = shared_dir / "notes.txt"
notes_path.write_text("hello", encoding="utf-8")
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
fake_dify_agent = bin_dir / "dify-agent"
fake_dify_agent.write_text(
"\n".join(
[
"#!/usr/bin/env python3",
"import json",
"import sys",
'if sys.argv[1:] != ["file", "upload", "../shared/notes.txt"]:',
' raise SystemExit(f"unexpected args: {sys.argv[1:]!r}")',
'print(json.dumps({"transfer_method": "tool_file", "reference": "file-ref"}))',
]
)
+ "\n",
encoding="utf-8",
)
fake_dify_agent.chmod(0o755)
list_payload = _run_embedded_script(_LIST_SCRIPT, args=["../shared", "1000"], cwd=cwd)
read_payload = _run_embedded_script(_READ_SCRIPT, args=["../shared/notes.txt", "8"], cwd=cwd)
upload_payload = _run_embedded_script(
_UPLOAD_SCRIPT,
args=["../shared/notes.txt"],
cwd=cwd,
env={"PATH": f"{bin_dir}{os.pathsep}{os.environ.get('PATH', os.defpath)}"},
)
assert list_payload["path"] == "../shared"
assert any(
isinstance(entry, dict) and entry.get("name") == "notes.txt" for entry in _sandbox_list_entries(list_payload)
)
assert read_payload == {
"path": "../shared/notes.txt",
"size": 5,
"truncated": False,
"binary": False,
"text": "hello",
}
assert upload_payload == {
"path": "../shared/notes.txt",
"file": {"transfer_method": "tool_file", "reference": "file-ref"},
}
def test_embedded_scripts_expand_home_relative_paths(tmp_path: Path) -> None:
cwd = tmp_path / "workspace" / "run"
cwd.mkdir(parents=True)
home_dir = tmp_path / "home" / "agent-1"
shared_dir = home_dir / "shared"
shared_dir.mkdir(parents=True)
notes_path = shared_dir / "notes.txt"
notes_path.write_text("hello", encoding="utf-8")
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
fake_dify_agent = bin_dir / "dify-agent"
fake_dify_agent.write_text(
"\n".join(
[
"#!/usr/bin/env python3",
"import json",
"import sys",
'if sys.argv[1:] != ["file", "upload", "~/shared/notes.txt"]:',
' raise SystemExit(f"unexpected args: {sys.argv[1:]!r}")',
'print(json.dumps({"transfer_method": "tool_file", "reference": "file-ref"}))',
]
)
+ "\n",
encoding="utf-8",
)
fake_dify_agent.chmod(0o755)
script_env = {"HOME": str(home_dir), "PATH": f"{bin_dir}{os.pathsep}{os.environ.get('PATH', os.defpath)}"}
list_payload = _run_embedded_script(_LIST_SCRIPT, args=["~/shared", "1000"], cwd=cwd, env=script_env)
read_payload = _run_embedded_script(_READ_SCRIPT, args=["~/shared/notes.txt", "8"], cwd=cwd, env=script_env)
upload_payload = _run_embedded_script(_UPLOAD_SCRIPT, args=["~/shared/notes.txt"], cwd=cwd, env=script_env)
assert list_payload["path"] == "~/shared"
assert any(
isinstance(entry, dict) and entry.get("name") == "notes.txt" for entry in _sandbox_list_entries(list_payload)
)
assert read_payload == {
"path": "~/shared/notes.txt",
"size": 5,
"truncated": False,
"binary": False,
"text": "hello",
}
assert upload_payload == {
"path": "~/shared/notes.txt",
"file": {"transfer_method": "tool_file", "reference": "file-ref"},
}
@pytest.mark.parametrize("bad_path", ["/etc/passwd", "~other/secret-dir", "bad\x00path"])
def test_list_files_rejects_invalid_paths_before_shell_execution(bad_path: str) -> None:
service, client = _service(lambda script, cwd, env, timeout: _Job(job_id="sandbox-job", output="unused"))
@ -262,8 +444,10 @@ def test_upload_injects_agent_stub_env_and_returns_mapping() -> None:
assert result.file.transfer_method == "tool_file"
assert result.file.reference == "file-ref"
assert client.run_calls[0].cwd == "~/workspace/abc12ff"
assert client.run_calls[0].env == {
script_call = _sandbox_python_run_call(client)
assert script_call.cwd == "/home/agent-1/workspace/abc12ff"
assert script_call.env == {
"HOME": "/home/agent-1",
AGENT_STUB_API_BASE_URL_ENV_VAR: "https://agent.example.com/agent-stub",
AGENT_STUB_AUTH_JWE_ENV_VAR: "token-for:tenant-1:abc12ff",
AGENT_STUB_DRIVE_BASE_ENV_VAR: "/mnt/drive/agent-1",
@ -291,4 +475,38 @@ def test_read_file_uses_complete_mode_and_parses_response() -> None:
result = asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="notes.txt", max_bytes=8)))
assert result.text == "hello"
assert "python3 - notes.txt 8 <<'PY'" in client.run_calls[0].script
assert "python3 - notes.txt 8 <<'PY'" in _sandbox_python_run_call(client).script
@pytest.mark.parametrize(
("sandbox_request", "expected_command"),
[
(SandboxReadRequest(locator=_locator(), path="../notes.txt", max_bytes=8), "python3 - ../notes.txt 8 <<'PY'"),
(SandboxUploadRequest(locator=_locator(), path="../report.txt"), "python3 - ../report.txt <<'PY'"),
(SandboxReadRequest(locator=_locator(), path="~/notes.txt", max_bytes=8), "python3 - '~/notes.txt' 8 <<'PY'"),
(SandboxUploadRequest(locator=_locator(), path="~/report.txt"), "python3 - '~/report.txt' <<'PY'"),
],
)
def test_read_and_upload_allow_relative_paths(
sandbox_request: SandboxReadRequest | SandboxUploadRequest, expected_command: str
) -> None:
expected_path = sandbox_request.path
service, client = _service(
lambda script, cwd, env, timeout: _Job(
job_id="sandbox-job",
output=_wrap(
{"path": expected_path, "size": 5, "truncated": False, "binary": False, "text": "hello"}
if isinstance(sandbox_request, SandboxReadRequest)
else {"path": expected_path, "file": {"transfer_method": "tool_file", "reference": "file-ref"}}
),
)
)
if isinstance(sandbox_request, SandboxReadRequest):
result = asyncio.run(service.read_file(sandbox_request))
assert result.path == expected_path
else:
result = asyncio.run(service.upload_file(sandbox_request))
assert result.path == expected_path
assert expected_command in _sandbox_python_run_call(client).script

View File

@ -112,7 +112,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
assertions=[
"assert hasattr(dify_agent_protocol, 'PydanticAIStreamRunEvent')",
"assert dify_agent_layers_drive.__all__ == ['DIFY_DRIVE_LAYER_TYPE_ID', 'DifyDriveLayerConfig', 'DifyDriveSkillConfig']",
"assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextAgentMode', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig', 'DifyExecutionContextUserFrom']",
"assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextAgentConfigVersionKind', 'DifyExecutionContextAgentMode', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig', 'DifyExecutionContextUserFrom']",
"assert dify_agent_layers_ask_human.__all__ == ['AskHumanAction', 'AskHumanActionStyle', 'AskHumanField', 'AskHumanFieldType', 'AskHumanFileField', 'AskHumanFileListField', 'AskHumanParagraphField', 'AskHumanResultStatus', 'AskHumanSelectField', 'AskHumanSelectOption', 'AskHumanSelectedAction', 'AskHumanToolArgs', 'AskHumanToolResult', 'AskHumanUrgency', 'DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION', 'DIFY_ASK_HUMAN_LAYER_TYPE_ID', 'DifyAskHumanLayerConfig']",
"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_knowledge.__all__ == ['DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID', 'DifyKnowledgeBaseLayerConfig', 'DifyKnowledgeDatasetConfig', 'DifyKnowledgeEagerResult', 'DifyKnowledgeMetadataCondition', 'DifyKnowledgeMetadataConditions', 'DifyKnowledgeMetadataFilteringConfig', 'DifyKnowledgeModelConfig', 'DifyKnowledgeQueryConfig', 'DifyKnowledgeRerankingModelConfig', 'DifyKnowledgeRetrievalConfig', 'DifyKnowledgeRuntimeState', 'DifyKnowledgeSetConfig']",

View File

@ -9,7 +9,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2]
CLIENT_SHARED_DTO_DEPENDENCIES = {
"httpx==0.28.1",
"pydantic>=2.12.5,<2.13",
"pydantic-ai-slim>=1.85.1,<2.0.0",
"pydantic-ai-slim>=1.102.0,<2.0.0",
"typer>=0.16.1,<0.17",
"typing-extensions>=4.12.2,<5.0.0",
}
@ -23,7 +23,7 @@ SERVER_RUNTIME_DEPENDENCIES = {
"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.3.0",
"shell-session-manager==2.3.1",
"uvicorn[standard]==0.46.0",
}
@ -105,4 +105,5 @@ def test_local_sandbox_dockerfile_installs_stub_client_and_shellctl() -> None:
assert "VIRTUAL_ENV" not in dockerfile
assert "uv sync" not in dockerfile
assert "mkdir -p /mnt/drive" in dockerfile
assert "chown dify:dify /home" in dockerfile
assert '["shellctl", "serve", "--listen", "0.0.0.0:5004"]' in dockerfile

8
dify-agent/uv.lock generated
View File

@ -650,7 +650,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.3.0" },
{ name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.4.0" },
{ name = "typer", specifier = ">=0.16.1,<0.17" },
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" },
@ -3503,7 +3503,7 @@ wheels = [
[[package]]
name = "shell-session-manager"
version = "2.3.0"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiosqlite" },
@ -3515,9 +3515,9 @@ dependencies = [
{ name = "typer" },
{ name = "uvicorn" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ed/27/64b6086f54a700836c3fe6fc403e1a180a5c0285b528588663d0de951a6b/shell_session_manager-2.3.0.tar.gz", hash = "sha256:59598f4824623053405a6d0ba85c931e263597752aa6b91e67b0e523474dccf5", size = 51361, upload-time = "2026-06-28T18:32:57.249Z" }
sdist = { url = "https://files.pythonhosted.org/packages/46/c4/5ad9b6a3f8a4ca78acd4fa7a44f55b3f41ce7d539d648d9beee30789dd5b/shell_session_manager-2.4.0.tar.gz", hash = "sha256:cffeff439a149fa0626dd6fe940cd2d010005d97b429769e607a2b09ae64cb84", size = 64571, upload-time = "2026-07-03T19:11:18.931Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/26/07e4815e630316f1840e36b686f91bd389940437e38b897a0231c487298f/shell_session_manager-2.3.0-py3-none-any.whl", hash = "sha256:7727cc328661d93dd40d6f42d2c6b46c95d49717698558e81d78b41a42501b4f", size = 48900, upload-time = "2026-06-28T18:32:55.937Z" },
{ url = "https://files.pythonhosted.org/packages/be/8c/d2f6bb7d461810c22d10b775aeec3b4374eec7fb9fa503d00653d45cd8b3/shell_session_manager-2.4.0-py3-none-any.whl", hash = "sha256:5bb2bb43def96b354817ba6a45ab88fd236897a07c7f6c94b53f3f0cf30fabe6", size = 56513, upload-time = "2026-07-03T19:11:17.506Z" },
]
[[package]]

50
e2e/.env.example Normal file
View File

@ -0,0 +1,50 @@
# Copy this file to e2e/.env.local for local runs.
# Do not commit e2e/.env.local.
E2E_BASE_URL=http://127.0.0.1:3000
E2E_API_URL=http://127.0.0.1:5001
E2E_STABLE_MODEL_PROVIDER=openai
E2E_STABLE_MODEL_NAME=gpt-5-nano
E2E_STABLE_MODEL_TYPE=llm
E2E_AGENT_DECISION_MODEL_PROVIDER=openai
E2E_AGENT_DECISION_MODEL_NAME=gpt-5.5
E2E_AGENT_DECISION_MODEL_TYPE=llm
E2E_MODEL_PROVIDER_CREDENTIALS_JSON='{"openai_api_key":"replace-with-real-key"}'
# Optional: external runtime CI/local profile. Defaults prepare Agent V2 runtime
# seed resources and then runs every @external-model/@external-tool scenario.
# E2E_EXTERNAL_RUNTIME_SEED_SPECS=agent-v2:external-runtime
# E2E_EXTERNAL_RUNTIME_TAGS=(@external-model or @external-tool) and not @feature-gated and not @skip and not @preview
# Optional: start the standalone dify-agent backend for Agent V2 runtime scenarios.
# This is required for scenarios tagged @agent-backend-runtime. It is separate
# from @external-model because other external-model scenarios may not use dify-agent.
# When enabled, the E2E runner also starts the shellctl local sandbox required by
# dify-agent's dify.shell/config runtime layer.
# E2E_START_AGENT_BACKEND=1
# E2E_AGENT_BACKEND_PORT=5050
# E2E_AGENT_BACKEND_URL=http://127.0.0.1:5050
# E2E_SHELLCTL_PORT=5004
# E2E_SHELLCTL_URL=http://127.0.0.1:5004
# E2E_SHELLCTL_IMAGE=dify-agent-local-sandbox:e2e
# E2E_SHELLCTL_AUTH_TOKEN=
# Optional: Agent V2 seed installs the latest OpenAI, JSON Process, and Tavily
# marketplace plugins by default. Override ids when testing a different provider
# set, or pin exact marketplace package identifiers for reproducible CI runs.
# E2E_MARKETPLACE_API_URL=https://marketplace.dify.ai
# E2E_MARKETPLACE_PLUGIN_IDS=langgenius/openai,langgenius/json_process,langgenius/tavily
# E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS=
# Optional: required only for OAuth tool fixture scenarios.
# E2E_OAUTH_TOOL_CREDENTIAL_ID=
# E2E_OAUTH_TOOL_PROVIDER=
# E2E_OAUTH_TOOL_NAME=
# Optional: required only for broken model recovery scenarios.
# E2E_BROKEN_MODEL_PROVIDER=
# E2E_BROKEN_MODEL_NAME=broken-model
# E2E_BROKEN_MODEL_TYPE=llm

3
e2e/.gitignore vendored
View File

@ -5,3 +5,6 @@ test-results/
cucumber-report/
.logs/
.generated-test-materials/
seed-report/
.env.local
.env.*.local

View File

@ -53,6 +53,12 @@ pnpm -C e2e e2e:full
# run a tagged subset
pnpm -C e2e e2e -- --tags @smoke
# prepare external runtime seed resources for opt-in external suites
pnpm -C e2e e2e:external:prepare
# run scenarios that call real external providers
pnpm -C e2e e2e:external
# headed browser
pnpm -C e2e e2e:headed -- --tags @smoke
@ -74,8 +80,8 @@ flowchart TD
C --> D["Cucumber loads config, steps, and support modules"]
D --> E["BeforeAll bootstraps shared auth state via /install"]
E --> F{"Which command is running?"}
F -->|`pnpm -C e2e e2e`| G["Run config default tags: not @fresh and not @skip and not @preview"]
F -->|`pnpm -C e2e e2e:full*`| H["Override tags to not @skip and not @preview"]
F -->|`pnpm -C e2e e2e`| G["Run config default tags: not @fresh and not @skip and not @preview and not @external-model and not @external-tool"]
F -->|`pnpm -C e2e e2e:full*`| H["Override tags to not @skip and not @preview and not @external-model and not @external-tool"]
G --> I["Per-scenario BrowserContext from shared browser"]
H --> I
I --> J["Failure artifacts written to cucumber-report/artifacts"]
@ -105,7 +111,7 @@ Behavior depends on instance state:
- uninitialized instance: completes install and stores authenticated state
- initialized instance: signs in and reuses authenticated state
Because of that, the `@fresh` install scenario only runs in the `pnpm -C e2e e2e:full*` flows. The default `pnpm -C e2e e2e*` flows exclude `@fresh` and `@preview` via Cucumber config tags so they can be re-run against an already initialized instance while keeping Builder Preview scenarios opt-in.
Because of that, the `@fresh` install scenario only runs in the `pnpm -C e2e e2e:full*` flows. The default `pnpm -C e2e e2e*` flows exclude `@fresh`, `@preview`, `@external-model`, and `@external-tool` via Cucumber config tags so they can be re-run against an already initialized instance while keeping Builder Preview and real external runtime scenarios opt-in.
Reset all persisted E2E state:
@ -201,8 +207,14 @@ Feature: Create dataset
- `@unauthenticated` — uses a clean BrowserContext with no cookies or storage
- `@authenticated` — optional intent tag for readability or selective runs; it does not currently change hook behavior on its own
- `@fresh` — only runs in `e2e:full` mode (requires uninitialized instance)
- `@external-model` — scenario execution can call a real model provider. Use this only for runtime requests, not for scenarios that only require an active model fixture.
- `@external-tool` — scenario execution can call a real third-party tool provider. Use this only for runtime tool execution, not for plugin installation, discovery, or local deterministic tools.
- `@skip` — excluded from all runs
External runtime commands are opt-in. `pnpm -C e2e e2e:external:prepare` reads `E2E_EXTERNAL_RUNTIME_SEED_SPECS`, defaulting to `agent-v2:external-runtime`, and runs the matching seed packs before the external suite. `pnpm -C e2e e2e:external` reads `E2E_EXTERNAL_RUNTIME_TAGS`, defaulting to `(@external-model or @external-tool) and not @feature-gated and not @skip and not @preview`.
Some external runtime scenarios need feature-owned services in addition to a real model or tool provider. Do not overload `@external-model` or `@external-tool` to mean those services are available. For Agent v2, scenarios that require the standalone `dify-agent` run server use the feature tag `@agent-backend-runtime` plus the explicit step `the Agent v2 runtime backend is available`. Run them with `E2E_START_AGENT_BACKEND=1` to let E2E start `dify-agent` and the shellctl local sandbox required by its `dify.config`/`dify.shell` runtime layers, or set `E2E_AGENT_BACKEND_URL`/`AGENT_BACKEND_BASE_URL` when an existing server should be reused.
Keep scenarios short and declarative. Each step should describe **what** the user does, not **how** the UI works.
### Step definition conventions

View File

@ -1,8 +1,10 @@
import type { IConfiguration } from '@cucumber/cucumber'
import './scripts/env-register'
const hasCliTags = process.argv.some(arg => arg === '--tags' || arg.startsWith('--tags='))
const defaultNonExternalTags = 'not @fresh and not @skip and not @preview and not @external-model and not @external-tool'
const defaultTags = process.env.E2E_CUCUMBER_TAGS
|| (hasCliTags ? undefined : 'not @fresh and not @skip and not @preview')
|| (hasCliTags ? undefined : defaultNonExternalTags)
const config = {
format: [

View File

@ -19,8 +19,8 @@ Use API setup for prerequisite state, then use Playwright only for user-observab
Use tags in three layers:
- Capability tags describe the product area: `@build`, `@files`, `@advanced-settings`, `@agent-edit`, `@publish`, `@access-point`, `@output-variables`, and similar tags.
- Execution-scope tags describe how the scenario should be selected: `@core`, `@infra`, `@web-app-runtime`, `@service-api-runtime`, `@preview`, and `@feature-gated`.
- Narrow fixture or sub-surface tags describe a specific dependency or slice: `@stable-model`, `@skill-fixture`, `@web-app-access`, `@workflow-reference`, `@files-limits`, and similar tags.
- Execution-scope tags describe how the scenario should be selected: `@core`, `@infra`, `@web-app-runtime`, `@service-api-runtime`, `@agent-backend-runtime`, `@preview`, `@feature-gated`, `@external-model`, and `@external-tool`.
- Narrow fixture or sub-surface tags describe a specific dependency or slice: `@stable-model`, `@agent-decision-model`, `@skill-fixture`, `@web-app-access`, `@workflow-reference`, `@files-limits`, and similar tags.
- `@agent-v2` — required capability tag for all Agent v2 scenarios.
- `@core` — stable non-runtime scenario expected to run in the regular Agent v2 suite when its explicit preconditions are met. Do not apply `@core` to Preview/Test Run, Web app chat runtime, or Backend service API chat runtime scenarios.
@ -28,7 +28,7 @@ Use tags in three layers:
- `@build` — Build mode and Build draft behavior.
- `@build-unavailable-resources` — feature-gated Build chat recovery when the user requests unavailable Skills or Tools.
- `@files` — Files section upload, display, and fixture behavior.
- `@files-limits` — feature-gated file format, size, count, and in-progress upload limit behavior.
- `@files-limits` — file limit behavior. Multiple-file drop is stable core coverage; format, size, count, and in-progress upload recovery remain feature-gated until their product contracts are stable.
- `@knowledge` — Knowledge Retrieval configuration display, persistence, and reference cleanup.
- `@advanced-settings` — Env Editor, Content Moderation, and related Advanced Settings behavior.
- `@agent-create` — Agent Roster creation and initial Configure navigation.
@ -36,17 +36,22 @@ Use tags in three layers:
- `@publish` — publish and publish-bar state.
- `@access-point` — Web app, Backend service API, and Workflow access surfaces.
- `@stable-model` — active model fixture dependency. Apply this to every scenario that includes `the Agent Builder stable chat model is available` or otherwise requires an active model configured in the workspace.
- `@agent-decision-model` — stronger active model fixture dependency for scenarios that specifically validate Agent autonomous planning or resource-selection behavior, such as generated-query Knowledge Retrieval. Do not use it for scenarios that only need a model to exist or answer a deterministic prompt.
- `@tool-fixture` — preseeded Tool dependency such as `JSON Process / JSON Replace` or `Tavily / Tavily Search`.
- `@skill-fixture` — checked-in or preseeded Skill dependency such as `e2e-summary-skill`.
- `@knowledge-fixture` — preseeded dataset dependency such as `E2E Agent Knowledge Base`.
- `@full-config-agent` — fixed `E2E New Agent Builder Full Config` Agent dependency.
- `@tool-states-agent` — fixed `E2E New Agent Builder Tool States` Agent dependency.
- `@oauth-tool-agent` — fixed `E2E Agent With OAuth Tool` Agent dependency for OAuth2 tool credential preservation.
- `@file-tree-fixture` — fixed file-tree Agent drive/config-files dependency.
- `@dual-retrieval-fixture` — fixed dual Knowledge Retrieval Agent dependency.
- `@backend-api-access` — fixed or scenario-owned Backend service API access dependency.
- `@published-web-app` — fixed or scenario-owned published Web app access dependency.
- `@web-app-runtime` — published public Web app runtime behavior. Use it for scenarios that open the public Web app and assert real chat responses. Access Point URL, launch, customization, and settings surfaces remain `@access-point` behavior unless they send messages through the public Web app.
- `@service-api-runtime` — Backend service API runtime behavior. Use it for scenarios that call the published service API and assert real chat responses. Endpoint display, copy, API key, and API reference surfaces remain `@access-point` behavior.
- `@agent-backend-runtime` — standalone `dify-agent` run-server dependency. Apply it only when the scenario goes through Agent v2 runtime execution that requires `AGENT_BACKEND_BASE_URL`, such as Build chat/finalize, published Web app chat, or Backend service API chat. This is separate from `@external-model`; a scenario can call a real model without using `dify-agent`, and a future Agent v2 runtime scenario might exercise `dify-agent` without a paid model provider.
- `@external-model` — package-wide external runtime tag. In Agent v2, apply it to Build chat generation/finalization, published Web app chat, Backend service API chat, and other Agent runtime requests that can invoke a real model provider. Do not use this for scenarios that only require a configured active model.
- `@external-tool` — package-wide external runtime tag. In Agent v2, apply it only when the scenario executes a real external tool provider at runtime. Do not use this for plugin installation, tool discovery, tool configuration persistence, or local deterministic tools.
- `@feature-gated` — product capability is optional. This tag alone does not skip execution; the scenario must include an explicit step that returns `skipped` with a blocked-precondition reason when the feature is unavailable.
Use feature-level `@core` only when every scenario in the file is stable, non-runtime, and not feature-gated. If a feature file mixes stable scenarios with runtime, Preview, or feature-gated scenarios, put `@core` only on the stable scenarios. Keep runtime tags scenario-level so the regular core suite cannot inherit them accidentally.
@ -107,6 +112,8 @@ Use `a basic configured Agent v2 test agent has been created via API` when a sce
Use `a runnable Agent v2 test agent has been created via API` after `the Agent Builder stable chat model is available` when a scenario needs a real model-backed Agent. The step writes the preflight model into the Agent Soul model config through `features/agent-v2/support/agent-soul.ts` with deterministic E2E model settings; do not duplicate provider/model payload construction in individual steps.
Use `a runnable Agent v2 test agent using the agent-decision model has been created via API` after `the Agent Builder agent-decision chat model is available` only when the scenario is asserting Agent autonomous decision quality rather than generic runtime availability. Keep this scoped to scenarios tagged `@agent-decision-model`.
Use `the Agent v2 configuration should be saved automatically` after UI edits that rely on Configure autosave. It waits for the user-visible publish bar saved state; do not replace it with network-idle waits or internal store checks.
API setup is acceptable for creating scenario-owned Agents, enabling Backend service API, writing composer drafts, seeding Build drafts, and preparing fixed state. The scenario must still assert user-visible behavior or a real persisted product contract through the public Console API. Do not assert only that a setup API call succeeded.
@ -153,7 +160,11 @@ Agent Builder preflight is read-only. It checks long-lived seed resources and re
Treat preseeded Agent Builder resources as environment contracts. Preflight can report that a stable model, dataset, Skill, Tool credential, fixed Agent, published Web app, or workflow reference is missing, inactive, not indexed, or drifted, but it must not repair that drift during a scenario. Seed scripts, CI bootstrap, or the documented environment maintenance flow own creating and keeping those resources valid.
Use `the Agent Builder stable chat model is available` before scenarios that need a real Agent Soul model configuration. This includes true runtime scenarios, model-backed build-mode assertions, and Workflow Agent v2 node setup because the backend rejects Agent nodes without model config. Do not add the model preflight to pure navigation or identity checks unless the setup API itself requires model config. `E2E_STABLE_MODEL_PROVIDER`, `E2E_STABLE_MODEL_NAME`, and optional `E2E_STABLE_MODEL_TYPE` are selectors for a model already configured in the workspace; they are not provider credentials. The step defaults to `openai` / `gpt-5.4-mini` / `llm`, verifies the selected model is present and `active` through `/console/api/workspaces/current/models/model-types/{type}`, then stores it on `DifyWorld.agentBuilder.preflight.stableModel`.
Use `the Agent v2 runtime backend is available` before scenarios tagged `@agent-backend-runtime`. The step checks the standalone `dify-agent` run server through `E2E_AGENT_BACKEND_URL`, `AGENT_BACKEND_BASE_URL`, or the default `http://127.0.0.1:5050` when `E2E_START_AGENT_BACKEND=1`. The E2E runner starts this service with `uv run --project dify-agent --extra server uvicorn dify_agent.server.app:app --host 127.0.0.1 --port 5050` only when `E2E_START_AGENT_BACKEND=1` is explicit. Because Agent App runtime always carries the `dify.config` layer and that layer depends on `dify.shell`, the same runner path also starts the `dify-agent/docker/local-sandbox` shellctl container on `E2E_SHELLCTL_PORT` (default `5004`) and injects `DIFY_AGENT_SHELLCTL_ENTRYPOINT` into the Agent backend. Missing or unreachable runtime backend or shellctl sandbox should be reported as a blocked precondition, not discovered later as a `/build-chat/finalize` 400 or Web app response timeout.
Use `the Agent Builder stable chat model is available` before scenarios that need a real Agent Soul model configuration. This includes true runtime scenarios, model-backed build-mode assertions, and Workflow Agent v2 node setup because the backend rejects Agent nodes without model config. Do not add the model preflight to pure navigation or identity checks unless the setup API itself requires model config. `E2E_STABLE_MODEL_PROVIDER`, `E2E_STABLE_MODEL_NAME`, and optional `E2E_STABLE_MODEL_TYPE` are selectors for a model already configured in the workspace; they are not provider credentials. The step defaults to `openai` / `gpt-5-nano` / `llm`, verifies the selected model is present and `active` through `/console/api/workspaces/current/models/model-types/{type}`, then stores it on `DifyWorld.agentBuilder.preflight.stableModel`.
Use `the Agent Builder agent-decision chat model is available` before scenarios that need a stronger model to exercise Agent autonomous planning, generated query selection, or tool/resource choice. `E2E_AGENT_DECISION_MODEL_PROVIDER`, `E2E_AGENT_DECISION_MODEL_NAME`, and optional `E2E_AGENT_DECISION_MODEL_TYPE` are selectors for a second active model fixture, defaulting to `openai` / `gpt-5.5` / `llm`. The step stores the model on `DifyWorld.agentBuilder.preflight.agentDecisionModel`. Do not use this fixture as a broad replacement for `@stable-model`; it is intentionally narrower and costlier.
Keep `@stable-model` on Build draft apply scenarios that click `Apply`. The current product path calls `/build-chat/finalize` before applying the draft, and the backend returns `model is required` when the Agent Soul has no model config. Discard-only and pending-draft isolation scenarios can stay model-free when they do not finalize the Build draft.
@ -163,8 +174,12 @@ Override the default selector only when a scenario or environment explicitly nee
```bash
E2E_STABLE_MODEL_PROVIDER=openai
E2E_STABLE_MODEL_NAME=gpt-5.4-mini
E2E_STABLE_MODEL_NAME=gpt-5-nano
E2E_STABLE_MODEL_TYPE=llm
E2E_AGENT_DECISION_MODEL_PROVIDER=openai
E2E_AGENT_DECISION_MODEL_NAME=gpt-5.5
E2E_AGENT_DECISION_MODEL_TYPE=llm
```
Dify may expose OpenAI as either `openai` or a plugin provider ID such as `langgenius/openai/openai`. The preflight accepts both forms for selection and stores the actual Console API provider ID for Agent Soul setup.
@ -187,6 +202,8 @@ Use `the Agent Builder preseeded Agent "{agent}" includes the core fixture confi
Use `the Agent Builder preseeded Agent "{agent}" includes the tool state fixture configuration` for the fixed Tool States Agent prerequisite. It composes the Summary Skill, JSON Replace tool, and Tavily Search tool preflights, then reads `/console/api/agent/{agent_id}/composer` to verify the Agent Soul includes JSON Replace, Tavily Search, and a Tavily credential reference. This proves the seed is configured to exercise tool status UI; keep actual invalid-credential errors in dependent user-visible configuration or runtime scenarios.
Use `the Agent Builder preseeded Agent "{agent}" includes an OAuth2 tool credential` for the fixed OAuth Tool Agent prerequisite. It reads the Agent composer and verifies at least one Dify tool has `credential_type: oauth2` with a `credential_ref.id`. Scenarios that need to save or mutate this configuration should copy that tool config into a scenario-owned Agent instead of mutating the fixed preseeded Agent.
Use `the Agent Builder preseeded Agent "{agent}" includes the dual retrieval fixture configuration` for the fixed Dual Retrieval Agent prerequisite. It composes the indexed knowledge-base preflight, then reads `/console/api/agent/{agent_id}/composer` to verify `agent_soul.knowledge.sets` includes both an Agent-decide generated query set and a custom user-query set using the fixed custom query.
Use `the Agent Builder preseeded Agent "{agent}" includes the file tree fixture files` for file-tree display prerequisites. It verifies the Agent drive contains every file from `agentBuilderFileTreeFixtureFiles` through `/console/api/agent/{agent_id}/drive/files?prefix=files/`.
@ -213,6 +230,6 @@ Order blocked steps by the real owner of the first unresolved condition. If a sc
Use partial coverage only when current product behavior is intentionally narrower than the written requirement and the test still asserts a real user-visible behavior. Example: Files are currently flat in Agent config files, so the flat Files list can be asserted while tree display remains blocked until product support exists.
File format, size, count, and in-progress upload limit cases are feature-gated until the product exposes stable Agent config file restrictions and user-visible recovery/error states. Do not convert `@files-limits` scenarios to passing tests by relying on default environment behavior; first align the product contract or seed configuration.
Multiple-file drop is already covered as stable `@core @files-limits` behavior. File format, size, count, and in-progress upload limit cases remain feature-gated until the product exposes stable Agent config file restrictions and user-visible recovery/error states. Do not convert those gated `@files-limits` scenarios to passing tests by relying on default environment behavior; first align the product contract or seed configuration.
Do not mark a scenario as complete if it only proves setup state and does not assert the user-visible behavior or persisted product contract required by the case.

View File

@ -162,10 +162,11 @@ Feature: Agent v2 Access Point
When I refresh the current page
Then Agent v2 Backend service API access should be in service
@service-api-runtime @backend-api-access @stable-model
@service-api-runtime @external-model @agent-backend-runtime @backend-api-access @stable-model
Scenario: Published Agent v2 answers through Backend service API
Given I am signed in as the default E2E admin
And the Agent Builder stable chat model is available
And the Agent v2 runtime backend is available
And a runnable Agent v2 test agent has been created via API
And Agent v2 Backend service API access has been enabled with a key via API
When I open the Agent v2 configure page
@ -174,10 +175,11 @@ Feature: Agent v2 Access Point
When I send the Agent v2 Backend service API minimal request
Then the Agent v2 Backend service API request should succeed with the normal E2E marker
@service-api-runtime @backend-api-access @stable-model
@service-api-runtime @external-model @agent-backend-runtime @backend-api-access @stable-model
Scenario: Disabled Backend service API rejects requests and restored access succeeds
Given I am signed in as the default E2E admin
And the Agent Builder stable chat model is available
And the Agent v2 runtime backend is available
And a runnable Agent v2 test agent has been created via API
And the Agent v2 draft has been published via API
And Agent v2 Backend service API access has been enabled with a key via API

View File

@ -1,19 +1,17 @@
@agent-v2 @authenticated @build
Feature: Agent v2 build draft
@core @stable-model @tool-fixture @skill-fixture
@external-model @agent-backend-runtime @stable-model
Scenario: Generating a Build draft leaves the normal Agent configuration unchanged
Given I am signed in as the default E2E admin
And the Agent Builder stable chat model is available
And the Agent Builder preseeded tool "JSON Process / JSON Replace" is available
And the Agent v2 runtime backend is available
And a runnable Agent v2 test agent has been created via API
And the e2e-summary-skill Skill is available to the Agent v2 test agent
When I open the Agent v2 configure page
And I generate an Agent v2 Build draft from the fixed instruction
Then I should see the Agent v2 Build draft pending changes
And I should see the Agent v2 Build mode confirmation state
And the normal Agent v2 draft should still use the normal E2E prompt
And the normal Agent v2 draft should not include the e2e-summary-skill Skill
And the normal Agent v2 draft should not include the Agent Builder JSON Replace tool
@core
Scenario: Discarding a Build draft keeps the original Agent configuration
@ -58,10 +56,11 @@ Feature: Agent v2 build draft
And the Agent v2 draft should not include the supported Build draft config
And the Agent v2 Build draft should no longer be active
@core @stable-model
@external-model @agent-backend-runtime @stable-model
Scenario: Applying a pending Build draft updates the normal Agent configuration
Given I am signed in as the default E2E admin
And the Agent Builder stable chat model is available
And the Agent v2 runtime backend is available
And a runnable Agent v2 test agent has been created via API
And an Agent v2 Build draft uses the updated E2E prompt with the stable E2E model
When I open the Agent v2 configure page
@ -76,10 +75,11 @@ Feature: Agent v2 build draft
Then I should see the updated E2E prompt in the Agent v2 prompt editor
And the Agent v2 Build draft should no longer be active
@core @stable-model @skill-fixture
@external-model @agent-backend-runtime @stable-model @skill-fixture
Scenario: Applying a Build draft updates supported configuration sections
Given I am signed in as the default E2E admin
And the Agent Builder stable chat model is available
And the Agent v2 runtime backend is available
And a runnable Agent v2 test agent has been created via API
And an Agent v2 Build draft adds the supported E2E files, skills, and env
When I open the Agent v2 configure page
@ -103,10 +103,11 @@ Feature: Agent v2 build draft
And I should see the supported E2E environment variable in Advanced Settings
And the Agent v2 Build draft should no longer be active
@core @stable-model @skill-fixture
@external-model @agent-backend-runtime @stable-model @skill-fixture
Scenario: Applying a Build draft with an existing Skill keeps a single Skill entry
Given I am signed in as the default E2E admin
And the Agent Builder stable chat model is available
And the Agent v2 runtime backend is available
And a runnable Agent v2 test agent has been created via API
And an Agent v2 Build draft includes the existing e2e-summary-skill Skill
When I open the Agent v2 configure page

View File

@ -52,13 +52,14 @@ Feature: Agent v2 files
When I open the Agent v2 configure page
Then Agent v2 oversized file rejection should be available
@files-limits @feature-gated
Scenario: Agent v2 single-batch file count limits are enforced
@core @files-limits
Scenario: Dropping multiple Agent v2 files at once is rejected
Given I am signed in as the default E2E admin
And Agent v2 single-batch file count limits are available
And a basic configured Agent v2 test agent has been created via API
When I open the Agent v2 configure page
Then Agent v2 single-batch file count limits should be available
And I drop multiple Agent v2 files into the Files upload dialog
Then the Agent v2 Files upload dialog should reject the multiple-file drop
And I should not see the dropped Agent v2 files in the Files section
@files-limits @feature-gated
Scenario: Agent v2 total file count limits are enforced

View File

@ -24,12 +24,13 @@ Feature: Agent v2 Knowledge Retrieval
When I refresh the current page
Then I should see the Agent v2 Custom query Knowledge Retrieval settings
@service-api-runtime @stable-model @backend-api-access
@service-api-runtime @external-model @agent-backend-runtime @agent-decision-model @backend-api-access
Scenario: Agent decide Knowledge Retrieval answers through Backend service API
Given I am signed in as the default E2E admin
And the Agent Builder stable chat model is available
And the Agent Builder agent-decision chat model is available
And the Agent v2 runtime backend is available
And the Agent Builder preseeded dataset "E2E Agent Knowledge Base" is indexed and ready
And a runnable Agent v2 test agent has been created via API
And a runnable Agent v2 test agent using the agent-decision model has been created via API
And Agent v2 Backend service API access has been enabled with a key via API
When I open the Agent v2 configure page
And I add the Agent Builder knowledge base as an Agent decide Knowledge Retrieval
@ -40,10 +41,11 @@ Feature: Agent v2 Knowledge Retrieval
When I send the Agent v2 Backend service API knowledge request
Then the Agent v2 Backend service API response should include the knowledge E2E marker
@service-api-runtime @stable-model @backend-api-access
@service-api-runtime @external-model @agent-backend-runtime @stable-model @backend-api-access
Scenario: Custom query Knowledge Retrieval answers through Backend service API
Given I am signed in as the default E2E admin
And the Agent Builder stable chat model is available
And the Agent v2 runtime backend is available
And the Agent Builder preseeded dataset "E2E Agent Knowledge Base" is indexed and ready
And a runnable Agent v2 test agent has been created via API
And Agent v2 Backend service API access has been enabled with a key via API

View File

@ -14,6 +14,11 @@ Feature: Agent Builder preseeded environment
Given I am signed in as the default E2E admin
And the Agent Builder stable chat model is available
@agent-decision-model
Scenario: Agent-decision chat model is available
Given I am signed in as the default E2E admin
And the Agent Builder agent-decision chat model is available
@broken-model
Scenario: Broken chat model is available for recovery scenarios
Given I am signed in as the default E2E admin
@ -79,6 +84,11 @@ Feature: Agent Builder preseeded environment
Given I am signed in as the default E2E admin
And the Agent Builder preseeded Agent "E2E New Agent Builder Tool States" includes the tool state fixture configuration
@oauth-tool-agent
Scenario: OAuth2 tool Agent includes credential fixture configuration
Given I am signed in as the default E2E admin
And the Agent Builder preseeded Agent "E2E Agent With OAuth Tool" includes an OAuth2 tool credential
@file-tree-fixture
Scenario: File tree Agent includes fixture files
Given I am signed in as the default E2E admin

View File

@ -58,10 +58,11 @@ Feature: Agent v2 publish
And the normal Agent v2 draft should use the normal E2E prompt
And the Agent v2 publish action should be available for unpublished changes
@web-app-runtime @published-web-app @stable-model
@web-app-runtime @external-model @agent-backend-runtime @published-web-app @stable-model
Scenario: Published Agent v2 answers through Web app
Given I am signed in as the default E2E admin
And the Agent Builder stable chat model is available
And the Agent v2 runtime backend is available
And a runnable Agent v2 test agent has been created via API
And Agent v2 Web app access has been enabled via API
When I open the Agent v2 configure page
@ -72,10 +73,11 @@ Feature: Agent v2 publish
Then the Agent v2 Web app response should include the normal E2E marker
When I close the Agent v2 Web app
@web-app-runtime @published-web-app @stable-model
@web-app-runtime @external-model @agent-backend-runtime @published-web-app @stable-model
Scenario: Published Web app remains isolated from unpublished Agent v2 draft edits
Given I am signed in as the default E2E admin
And the Agent Builder stable chat model is available
And the Agent v2 runtime backend is available
And a runnable Agent v2 test agent has been created via API
And Agent v2 Web app access has been enabled via API
When I open the Agent v2 configure page
@ -90,10 +92,11 @@ Feature: Agent v2 publish
And the Agent v2 Web app response should not include the updated E2E marker
When I close the Agent v2 Web app
@web-app-runtime @published-web-app @stable-model
@web-app-runtime @external-model @agent-backend-runtime @published-web-app @stable-model
Scenario: Published Web app uses the latest Agent v2 published configuration
Given I am signed in as the default E2E admin
And the Agent Builder stable chat model is available
And the Agent v2 runtime backend is available
And a runnable Agent v2 test agent has been created via API
And Agent v2 Web app access has been enabled via API
When I open the Agent v2 configure page

View File

@ -6,6 +6,7 @@ import type {
ChatRequestPayloadWithUser,
PostChatMessagesResponse,
} from '@dify/contracts/api/service/types.gen'
import type { APIResponse } from '@playwright/test'
import { request } from '@playwright/test'
import { createApiContext, expectApiResponseOK, setAppSiteEnabled } from '../../../support/api'
import { getTestAgent } from './agent'
@ -16,6 +17,84 @@ export type AgentServiceApiChatResult = {
status: number
}
type ServiceApiSseEvent = {
data: unknown
event?: string
}
async function parseServiceApiChatResponse(response: APIResponse) {
const contentType = response.headers()['content-type'] ?? ''
const text = await response.text().catch(() => '')
if (contentType.includes('text/event-stream'))
return parseServiceApiSseText(text)
if (contentType.includes('application/json')) {
try {
return JSON.parse(text) as unknown
}
catch {
return { message: text }
}
}
try {
return JSON.parse(text) as unknown
}
catch {
return { message: text }
}
}
function parseServiceApiSseText(text: string) {
const events: ServiceApiSseEvent[] = []
const answers: string[] = []
for (const block of text.split(/\r?\n\r?\n/)) {
const lines = block.split(/\r?\n/)
const eventName = lines
.find(line => line.startsWith('event:'))
?.slice('event:'.length)
.trim()
const dataText = lines
.filter(line => line.startsWith('data:'))
.map(line => line.slice('data:'.length).trimStart())
.join('\n')
if (!dataText)
continue
let data: unknown = dataText
try {
data = JSON.parse(dataText) as unknown
}
catch {
data = dataText
}
events.push({
data,
...(eventName ? { event: eventName } : {}),
})
if (
data
&& typeof data === 'object'
&& !Array.isArray(data)
&& 'answer' in data
&& typeof data.answer === 'string'
) {
answers.push(data.answer)
}
}
return {
answer: answers.join(''),
events,
raw: text,
}
}
export async function setAgentSiteAccessAndGetURL(
agentId: string,
enabled: boolean,
@ -77,7 +156,7 @@ export async function sendAgentServiceApiChatMessage({
const body = {
inputs: {},
query,
response_mode: 'blocking',
response_mode: 'streaming',
user: 'e2e-agent-access-point',
} satisfies ChatRequestPayloadWithUser
@ -88,9 +167,7 @@ export async function sendAgentServiceApiChatMessage({
Authorization: `Bearer ${apiKey}`,
},
})
const responseBody = await response.json().catch(async () => ({
message: await response.text().catch(() => ''),
}))
const responseBody = await parseServiceApiChatResponse(response)
return {
body: responseBody as PostChatMessagesResponse | unknown,

View File

@ -5,10 +5,12 @@ export const agentBuilderPreseededResources = {
tavilySearchTool: 'Tavily / Tavily Search',
agentKnowledgeBase: 'E2E Agent Knowledge Base',
indexingKnowledgeBase: 'E2E Agent Knowledge Base Indexing',
agentDecisionChatModel: 'E2E Agent Decision Chat Model',
brokenModelProvider: 'E2E Broken Model Provider',
brokenModel: 'e2e-broken-model',
fullConfigAgent: 'E2E New Agent Builder Full Config',
toolStatesAgent: 'E2E New Agent Builder Tool States',
oauthToolAgent: 'E2E Agent With OAuth Tool',
fileTreeAgent: 'E2E Agent With File Tree',
dualRetrievalAgent: 'E2E Agent With Dual Retrieval',
publishedWebAppAgent: 'E2E Agent Published Web App',
@ -23,7 +25,8 @@ export const agentBuilderFixedInputs = {
missingSkillSearch: 'E2E_NOT_EXIST_SKILL',
missingToolSearch: 'E2E_NOT_EXIST_TOOL',
missingToolSearchWithSuffix: 'E2E_NOT_EXIST_TOOL_12345',
customKnowledgeQuery: 'Dify Agent E2E 测试暗号',
customKnowledgeQuery: 'Dify Agent E2E knowledge marker',
knowledgeRuntimeQuery: 'Use the connected knowledge source to find the Dify Agent E2E knowledge marker.',
envPlainKey: 'E2E_AGENT_FLAG',
envPlainValue: 'enabled',
envModeKey: 'E2E_AGENT_MODE',

View File

@ -42,6 +42,11 @@ export const updatedAgentSoulConfig: AgentSoulConfig = {
},
}
const stableAgentModelSettings = {
max_tokens: 4096,
temperature: 0,
}
const getAgentModelPluginId = (provider: string) => {
const [organization, pluginName] = provider.split('/').filter(Boolean)
@ -71,10 +76,7 @@ export function createAgentSoulConfigWithModel(
plugin_id: getAgentModelPluginId(model.provider),
model_provider: model.provider,
model: model.name,
model_settings: {
temperature: 0,
max_tokens: 512,
},
model_settings: stableAgentModelSettings,
},
}
}
@ -103,3 +105,19 @@ export function createAgentSoulConfigWithKnowledgeDataset(
},
}
}
export function createAgentSoulConfigWithDifyTool(
agentSoul: AgentSoulConfig,
tool: NonNullable<NonNullable<AgentSoulConfig['tools']>['dify_tools']>[number],
): AgentSoulConfig {
return {
...agentSoul,
tools: {
...agentSoul.tools,
dify_tools: [
...(agentSoul.tools?.dify_tools ?? []),
tool,
],
},
}
}

View File

@ -0,0 +1,120 @@
import type { DifyWorld } from '../../../support/world'
import { skipBlockedPrecondition } from './common'
const isTruthyEnv = (value: string | undefined) => value === '1' || value === 'true'
const getDefaultAgentBackendURL = () => {
const port = process.env.E2E_AGENT_BACKEND_PORT?.trim() || '5050'
return `http://127.0.0.1:${port}`
}
const getShellctlURL = () => {
const explicitE2EURL = process.env.E2E_SHELLCTL_URL?.trim()
if (explicitE2EURL)
return explicitE2EURL.replace(/\/$/, '')
const explicitAgentURL = process.env.DIFY_AGENT_SHELLCTL_ENTRYPOINT?.trim()
if (explicitAgentURL)
return explicitAgentURL.replace(/\/$/, '')
if (isTruthyEnv(process.env.E2E_START_AGENT_BACKEND)) {
const port = process.env.E2E_SHELLCTL_PORT?.trim() || '5004'
return `http://127.0.0.1:${port}`
}
return undefined
}
const getAgentBackendURL = () => {
const explicitE2EURL = process.env.E2E_AGENT_BACKEND_URL?.trim()
if (explicitE2EURL)
return explicitE2EURL.replace(/\/$/, '')
const explicitAPIURL = process.env.AGENT_BACKEND_BASE_URL?.trim()
if (explicitAPIURL)
return explicitAPIURL.replace(/\/$/, '')
if (isTruthyEnv(process.env.E2E_START_AGENT_BACKEND))
return getDefaultAgentBackendURL()
return undefined
}
const checkRuntimeOpenApi = async ({
remediation,
title,
url,
world,
}: {
remediation: string
title: string
url: string
world: DifyWorld
}) => {
const healthURL = `${url}/openapi.json`
try {
const response = await fetch(healthURL)
if (response.ok)
return undefined
return skipBlockedPrecondition(
world,
`${title} did not respond successfully at ${healthURL}: ${response.status} ${response.statusText}.`,
{
owner: 'e2e/runtime',
remediation,
},
)
}
catch (error) {
const message = error instanceof Error ? error.message : String(error)
return skipBlockedPrecondition(
world,
`${title} is unreachable at ${healthURL}: ${message}.`,
{
owner: 'e2e/runtime',
remediation,
},
)
}
}
export async function skipMissingAgentBackendRuntime(world: DifyWorld) {
const agentBackendURL = getAgentBackendURL()
if (!agentBackendURL) {
return skipBlockedPrecondition(
world,
'Agent v2 runtime backend is not configured. This scenario needs the standalone dify-agent run server, not just an active model provider.',
{
owner: 'e2e/runtime',
remediation: 'Run with E2E_START_AGENT_BACKEND=1 to let E2E start dify-agent, or set E2E_AGENT_BACKEND_URL/AGENT_BACKEND_BASE_URL to an existing dify-agent server.',
},
)
}
const agentBackendBlock = await checkRuntimeOpenApi({
remediation: 'Start a healthy dify-agent server and make sure AGENT_BACKEND_BASE_URL points to it before running Agent v2 runtime scenarios.',
title: 'Agent v2 runtime backend',
url: agentBackendURL,
world,
})
if (agentBackendBlock)
return agentBackendBlock
const shellctlURL = getShellctlURL()
if (shellctlURL) {
const shellctlBlock = await checkRuntimeOpenApi({
remediation: 'Start the shellctl local sandbox, or run with E2E_START_AGENT_BACKEND=1 so E2E starts it together with dify-agent.',
title: 'Agent v2 shellctl sandbox',
url: shellctlURL,
world,
})
if (shellctlBlock)
return shellctlBlock
}
return agentBackendURL
}

View File

@ -2,6 +2,7 @@ import type {
AgentAppComposerResponse,
AgentDriveListResponse,
AgentDriveSkillListResponse,
AgentSoulConfig,
} from '@dify/contracts/api/console/agent/types.gen'
import type { DifyWorld } from '../../../support/world'
import type { PreseededResource } from './common'
@ -33,8 +34,11 @@ import {
hasUnauthorizedToolCredentialState,
skipMissingPreseededTool,
splitToolDisplayName,
splitToolResourceId,
} from './tools'
type AgentSoulDifyToolConfig = NonNullable<NonNullable<AgentSoulConfig['tools']>['dify_tools']>[number]
const hasKnowledgeDataset = (
soul: Record<string, unknown>,
dataset: PreseededResource,
@ -84,6 +88,14 @@ const hasKnowledgeSet = (
})
}
const getOAuth2ToolCredentialFixture = (toolItems: unknown[]) =>
toolItems.find((item) => {
const record = asRecord(item)
return record.credential_type === 'oauth2'
&& Boolean(asString(asRecord(record.credential_ref).id))
}) as AgentSoulDifyToolConfig | undefined
export async function skipMissingPreseededAgent(
world: DifyWorld,
resourceName: string,
@ -223,7 +235,7 @@ export async function skipMissingPreseededFullConfigAgentCoreConfiguration(
if (!hasNamedOrKeyedEntry(skills, agentBuilderPreseededResources.summarySkill))
missing.push(agentBuilderPreseededResources.summarySkill)
const [providerName = '', toolName = ''] = jsonTool.id.split('/')
const { providerName, toolName } = splitToolResourceId(jsonTool.id)
const parsedTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool)
if (
parsedTool.ok
@ -297,7 +309,7 @@ export async function skipMissingPreseededToolStatesAgentConfiguration(
if (!hasNamedOrKeyedEntry(skills, agentBuilderPreseededResources.summarySkill))
missing.push(agentBuilderPreseededResources.summarySkill)
const [jsonProviderName = '', jsonToolName = ''] = jsonTool.id.split('/')
const { providerName: jsonProviderName, toolName: jsonToolName } = splitToolResourceId(jsonTool.id)
const parsedJsonTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool)
if (
parsedJsonTool.ok
@ -311,7 +323,7 @@ export async function skipMissingPreseededToolStatesAgentConfiguration(
missing.push(agentBuilderPreseededResources.jsonReplaceTool)
}
const [tavilyProviderName = '', tavilyToolName = ''] = tavilyTool.id.split('/')
const { providerName: tavilyProviderName, toolName: tavilyToolName } = splitToolResourceId(tavilyTool.id)
const parsedTavilyTool = splitToolDisplayName(agentBuilderPreseededResources.tavilySearchTool)
const tavilyEntry = parsedTavilyTool.ok
? findToolEntry(toolItems, {
@ -343,6 +355,59 @@ export async function skipMissingPreseededToolStatesAgentConfiguration(
}
}
export async function skipMissingPreseededOAuthToolAgentConfiguration(
world: DifyWorld,
agentName: string,
): Promise<'skipped' | PreseededResource> {
const agent = await skipMissingPreseededAgent(world, agentName)
if (agent === 'skipped')
return agent
const ctx = await createApiContext()
try {
const response = await ctx.get(`/console/api/agent/${agent.id}/composer`)
await expectApiResponseOK(response, `Check preseeded Agent OAuth2 tool configuration ${agentName}`)
const body = (await response.json()) as AgentAppComposerResponse
const toolItems = asArray(asRecord(body.agent_soul?.tools).dify_tools)
const oauthTool = getOAuth2ToolCredentialFixture(toolItems)
if (!oauthTool) {
return skipBlockedPrecondition(
world,
`Preseeded Agent "${agentName}" is missing an OAuth2 tool with a credential reference.`,
{
owner: 'seed',
remediation: 'Seed an Agent v2 fixture that includes a built-in OAuth2 tool with credential_type oauth2 and credential_ref.id.',
},
)
}
return agent
}
finally {
await ctx.dispose()
}
}
export async function getPreseededOAuthToolConfig(agentId: string): Promise<AgentSoulDifyToolConfig> {
const ctx = await createApiContext()
try {
const response = await ctx.get(`/console/api/agent/${agentId}/composer`)
await expectApiResponseOK(response, `Get preseeded Agent OAuth2 tool config ${agentId}`)
const body = (await response.json()) as AgentAppComposerResponse
const toolItems = asArray(asRecord(body.agent_soul?.tools).dify_tools)
const oauthTool = getOAuth2ToolCredentialFixture(toolItems)
if (!oauthTool)
throw new Error(`Preseeded Agent ${agentId} does not include an OAuth2 tool credential fixture.`)
return oauthTool
}
finally {
await ctx.dispose()
}
}
export async function skipMissingPreseededDualRetrievalAgentConfiguration(
world: DifyWorld,
agentName: string,

View File

@ -9,6 +9,7 @@ import type { PreseededResource } from './common'
import { createApiContext, expectApiResponseOK } from '../../../../support/api'
import {
agentBuilderExpectedTokens,
agentBuilderFixedInputs,
agentBuilderPreseededResources,
} from '../agent-builder-resources'
import {
@ -84,10 +85,10 @@ const getDatasetDocuments = async (datasetId: string, resourceName: string) => {
}
}
const datasetHasEnabledSegmentContainingToken = async (
const datasetHasEnabledSegmentContainingTokens = async (
datasetId: string,
resourceName: string,
expectedToken: string,
expectedTokens: string[],
) => {
const documents = await getDatasetDocuments(datasetId, resourceName)
const ctx = await createApiContext()
@ -95,7 +96,7 @@ const datasetHasEnabledSegmentContainingToken = async (
for (const document of documents) {
const query = buildQuery({
enabled: 'true',
keyword: expectedToken,
keyword: agentBuilderExpectedTokens.knowledgeReply,
limit: '20',
page: '1',
})
@ -110,9 +111,9 @@ const datasetHasEnabledSegmentContainingToken = async (
const matchingSegment = body.data.find(
segment =>
segment.enabled
&& (
&& expectedTokens.every(expectedToken =>
segment.content.includes(expectedToken)
|| segment.keywords?.some(keyword => keyword.includes(expectedToken))
|| segment.keywords?.some(keyword => keyword.includes(expectedToken)),
),
)
@ -184,18 +185,23 @@ export async function skipMissingReadyPreseededDataset(
}
if (resourceName === agentBuilderPreseededResources.agentKnowledgeBase) {
const hasExpectedToken = await datasetHasEnabledSegmentContainingToken(
const requiredTokens = [
agentBuilderFixedInputs.customKnowledgeQuery,
agentBuilderFixedInputs.knowledgeRuntimeQuery,
agentBuilderExpectedTokens.knowledgeReply,
]
const hasExpectedToken = await datasetHasEnabledSegmentContainingTokens(
resource.id,
resourceName,
agentBuilderExpectedTokens.knowledgeReply,
requiredTokens,
)
if (!hasExpectedToken) {
return skipBlockedPrecondition(
world,
`Preseeded dataset "${resourceName}" has no enabled segment containing "${agentBuilderExpectedTokens.knowledgeReply}".`,
`Preseeded dataset "${resourceName}" has no enabled segment containing "${requiredTokens.join('" and "')}".`,
{
remediation: `Seed the dataset from the Agent Builder knowledge fixture and wait until an enabled segment contains "${agentBuilderExpectedTokens.knowledgeReply}".`,
remediation: `Seed the dataset from the Agent Builder knowledge fixture and wait until an enabled segment contains "${requiredTokens.join('" and "')}".`,
},
)
}

View File

@ -7,13 +7,19 @@ import { skipBlockedPrecondition } from './common'
const stableChatModelProviderEnv = 'E2E_STABLE_MODEL_PROVIDER'
const stableChatModelNameEnv = 'E2E_STABLE_MODEL_NAME'
const stableChatModelTypeEnv = 'E2E_STABLE_MODEL_TYPE'
const agentDecisionChatModelProviderEnv = 'E2E_AGENT_DECISION_MODEL_PROVIDER'
const agentDecisionChatModelNameEnv = 'E2E_AGENT_DECISION_MODEL_NAME'
const agentDecisionChatModelTypeEnv = 'E2E_AGENT_DECISION_MODEL_TYPE'
const brokenChatModelProviderEnv = 'E2E_BROKEN_MODEL_PROVIDER'
const brokenChatModelNameEnv = 'E2E_BROKEN_MODEL_NAME'
const brokenChatModelTypeEnv = 'E2E_BROKEN_MODEL_TYPE'
const activeModelStatus = 'active'
const defaultStableChatModelProvider = 'openai'
const defaultStableChatModelName = 'gpt-5.4-mini'
const defaultStableChatModelName = 'gpt-5-nano'
const defaultStableChatModelType = 'llm'
const defaultAgentDecisionChatModelProvider = 'openai'
const defaultAgentDecisionChatModelName = 'gpt-5.5'
const defaultAgentDecisionChatModelType = 'llm'
const defaultBrokenChatModelName = agentBuilderPreseededResources.brokenModel
const getProviderAlias = (provider: string) => provider.split('/').filter(Boolean).at(-1) ?? provider
@ -48,6 +54,23 @@ export function readAgentBuilderStableChatModelConfig(): ModelPreflightConfig {
}
}
export function readAgentBuilderAgentDecisionChatModelConfig(): ModelPreflightConfig {
const provider = process.env[agentDecisionChatModelProviderEnv]?.trim()
|| defaultAgentDecisionChatModelProvider
const name = process.env[agentDecisionChatModelNameEnv]?.trim()
|| defaultAgentDecisionChatModelName
const type = process.env[agentDecisionChatModelTypeEnv]?.trim()
|| defaultAgentDecisionChatModelType
return {
ok: true,
provider,
resourceName: agentBuilderPreseededResources.agentDecisionChatModel,
type,
value: name,
}
}
export function readAgentBuilderBrokenChatModelConfig(): ModelPreflightConfig {
const provider = process.env[brokenChatModelProviderEnv]?.trim()
const name = process.env[brokenChatModelNameEnv]?.trim() || defaultBrokenChatModelName
@ -129,6 +152,14 @@ export async function skipMissingAgentBuilderStableChatModel(
})
}
export async function skipMissingAgentBuilderAgentDecisionChatModel(
world: DifyWorld,
): Promise<'skipped' | NonNullable<DifyWorld['agentBuilder']['preflight']['stableModel']>> {
return skipMissingAgentBuilderModel(world, readAgentBuilderAgentDecisionChatModelConfig(), {
requireActive: true,
})
}
export async function skipMissingAgentBuilderBrokenChatModel(
world: DifyWorld,
): Promise<'skipped' | NonNullable<DifyWorld['agentBuilder']['preflight']['stableModel']>> {

Some files were not shown because too many files have changed in this diff Show More