mirror of
https://github.com/langgenius/dify.git
synced 2026-07-20 09:38:32 +08:00
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:
parent
f8d47616c1
commit
1b3e8e9943
@ -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`.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
29
.github/workflows/main-ci.yml
vendored
29
.github/workflows/main-ci.yml
vendored
@ -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: |
|
||||
|
||||
51
.github/workflows/web-e2e.yml
vendored
51
.github/workflows/web-e2e.yml
vendored
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -230,6 +230,7 @@ class RequestRequestUploadFile(BaseModel):
|
||||
|
||||
filename: str
|
||||
mimetype: str
|
||||
conversation_id: str | None = None
|
||||
|
||||
|
||||
class RequestDownloadFileMapping(BaseModel):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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}'
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
2
api/uv.lock
generated
@ -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" },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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("'", "'\\''") + "'"
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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"}})
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
@ -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]
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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']",
|
||||
|
||||
@ -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
8
dify-agent/uv.lock
generated
@ -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
50
e2e/.env.example
Normal 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
3
e2e/.gitignore
vendored
@ -5,3 +5,6 @@ test-results/
|
||||
cucumber-report/
|
||||
.logs/
|
||||
.generated-test-materials/
|
||||
seed-report/
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
120
e2e/features/agent-v2/support/preflight/agent-backend.ts
Normal file
120
e2e/features/agent-v2/support/preflight/agent-backend.ts
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 "')}".`,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user