diff --git a/web/app/components/tools/labels/__tests__/selector.spec.tsx b/web/app/components/tools/labels/__tests__/selector.spec.tsx
index 85b8ea6f164..92568734ed8 100644
--- a/web/app/components/tools/labels/__tests__/selector.spec.tsx
+++ b/web/app/components/tools/labels/__tests__/selector.spec.tsx
@@ -18,28 +18,12 @@ vi.mock('@/app/components/plugins/hooks', () => ({
}),
}))
-// Mock useDebounceFn to store the function and allow manual triggering
-let debouncedFn: (() => void) | null = null
-vi.mock('ahooks', () => ({
- useDebounceFn: (fn: () => void) => {
- debouncedFn = fn
- return {
- run: () => {
- // Schedule to run after React state updates
- setTimeout(() => debouncedFn?.(), 0)
- },
- cancel: vi.fn(),
- }
- },
-}))
-
describe('LabelSelector', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
- debouncedFn = null
})
afterEach(() => {
@@ -198,7 +182,7 @@ describe('LabelSelector', () => {
const searchInput = screen.getByRole('searchbox', { name: 'common.operation.search' })
// Filter by 'rag' which only matches 'rag' name
fireEvent.change(searchInput, { target: { value: 'rag' } })
- vi.advanceTimersByTime(10)
+ vi.advanceTimersByTime(500)
})
// Only RAG should be visible (rag contains 'rag')
@@ -220,7 +204,7 @@ describe('LabelSelector', () => {
await act(async () => {
const searchInput = screen.getByRole('searchbox', { name: 'common.operation.search' })
fireEvent.change(searchInput, { target: { value: 'nonexistent' } })
- vi.advanceTimersByTime(10)
+ vi.advanceTimersByTime(500)
})
expect(screen.getByText('common.tag.noTag')).toBeInTheDocument()
@@ -240,7 +224,7 @@ describe('LabelSelector', () => {
const searchInput = screen.getByRole('searchbox', { name: 'common.operation.search' })
// First filter to show only RAG
fireEvent.change(searchInput, { target: { value: 'rag' } })
- vi.advanceTimersByTime(10)
+ vi.advanceTimersByTime(500)
})
expect(screen.getByText('RAG')).toBeInTheDocument()
diff --git a/web/app/components/tools/labels/selector.tsx b/web/app/components/tools/labels/selector.tsx
index 4f283a5d635..beb6ee3daae 100644
--- a/web/app/components/tools/labels/selector.tsx
+++ b/web/app/components/tools/labels/selector.tsx
@@ -2,7 +2,7 @@ import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
-import { useDebounceFn } from 'ahooks'
+import { useDebouncedValue } from 'foxact/use-debounced-value'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Tag03 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
@@ -21,18 +21,8 @@ function LabelSelector({ value, onChange }: LabelSelectorProps) {
const { tags: labelList } = useTags()
const [keywords, setKeywords] = useState('')
- const [searchKeywords, setSearchKeywords] = useState('')
- const { run: handleSearch } = useDebounceFn(
- () => {
- setSearchKeywords(keywords)
- },
- { wait: 500 },
- )
-
- const handleKeywordsChange = (value: string) => {
- setKeywords(value)
- handleSearch()
- }
+ const debouncedKeywords = useDebouncedValue(keywords, 500)
+ const searchKeywords = keywords ? debouncedKeywords : ''
const filteredLabelList = labelList.filter((label) => label.name.includes(searchKeywords))
const selectedLabels = value.map((v) => labelList.find((l) => l.name === v)?.label).join(', ')
@@ -66,7 +56,7 @@ function LabelSelector({ value, onChange }: LabelSelectorProps) {
>
-
+
$['createTool.toolInput.labelPlaceholder'], { ns: 'tools' })}
diff --git a/web/features/home/home-content/home-content.tsx b/web/features/home/home-content/home-content.tsx
index 58271e49f59..449bd77a9f1 100644
--- a/web/features/home/home-content/home-content.tsx
+++ b/web/features/home/home-content/home-content.tsx
@@ -6,7 +6,7 @@ import type { StepByStepTourTaskId } from '@/app/components/step-by-step-tour/ty
import type { TrackCreateAppParams } from '@/utils/create-app-tracking'
import { cn } from '@langgenius/dify-ui/cn'
import { useQueryClient, useSuspenseQueries, useSuspenseQuery } from '@tanstack/react-query'
-import { useDebounceFn } from 'ahooks'
+import { useDebouncedValue } from 'foxact/use-debounced-value'
import { useAtomValue, useSetAtom } from 'jotai'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -111,19 +111,8 @@ export function HomeContent() {
)
const [keywords, setKeywords] = useState('')
- const [searchKeywords, setSearchKeywords] = useState('')
-
- const { run: handleSearch } = useDebounceFn(
- () => {
- setSearchKeywords(keywords)
- },
- { wait: 500 },
- )
-
- const handleKeywordsChange = (value: string) => {
- setKeywords(value)
- handleSearch()
- }
+ const debouncedKeywords = useDebouncedValue(keywords, 500)
+ const searchKeywords = keywords ? debouncedKeywords : ''
const [currCategory, setCurrCategory] = useQueryState('category', {
defaultValue: allCategoriesEn,
@@ -437,7 +426,7 @@ export function HomeContent() {
currCategory={activeCategory}
keywords={keywords}
onCategoryChange={setCurrCategory}
- onKeywordsChange={handleKeywordsChange}
+ onKeywordsChange={setKeywords}
/>
From 73b15fc5625dee2bdba5fbffc474a7492f470765 Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Wed, 19 Aug 2026 06:30:37 +0000
Subject: [PATCH 05/18] refactor(web): compose reset password fields (#40952)
---
oxlint-suppressions.json | 10 --
.../set-password/page.tsx | 108 ++++++++++--------
.../set-password/__tests__/page.spec.tsx | 31 +++++
web/app/reset-password/set-password/page.tsx | 108 ++++++++++--------
4 files changed, 149 insertions(+), 108 deletions(-)
diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json
index 447d378d032..0bfb65d1eb8 100644
--- a/oxlint-suppressions.json
+++ b/oxlint-suppressions.json
@@ -103,11 +103,6 @@
"count": 1
}
},
- "web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/(shareLayout)/webapp-signin/check-code/page.tsx": {
"no-restricted-imports": {
"count": 1
@@ -5184,11 +5179,6 @@
"count": 1
}
},
- "web/app/reset-password/set-password/page.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/signin/layout.tsx": {
"typescript/no-explicit-any": {
"count": 1
diff --git a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx
index 08200092f9d..204954e9e19 100644
--- a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx
+++ b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx
@@ -1,12 +1,15 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
+import { Field, FieldDescription, FieldLabel } from '@langgenius/dify-ui/field'
+import { Form } from '@langgenius/dify-ui/form'
+import { IconButton } from '@langgenius/dify-ui/icon-button'
+import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCheckboxCircleFill } from '@remixicon/react'
import { useCountDown } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import Input from '@/app/components/base/input'
import { validPassword } from '@/config'
import useDocumentTitle from '@/hooks/use-document-title'
import { useRouter, useSearchParams } from '@/next/navigation'
@@ -96,68 +99,75 @@ const ChangePasswordForm = () => {
-
- {/* Password */}
-
-
-
-
+
+ setPassword(e.target.value)}
+ onValueChange={setPassword}
placeholder={t(($) => $.passwordPlaceholder, { ns: 'login' }) || ''}
/>
-
-
-
-
-
-
+
+
+
+
+
{t(($) => $['error.passwordInvalid'], { ns: 'login' })}
-
-
- {/* Confirm Password */}
-
-
-
-
+
+ setConfirmPassword(e.target.value)}
+ onValueChange={setConfirmPassword}
placeholder={t(($) => $.confirmPasswordPlaceholder, { ns: 'login' }) || ''}
/>
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
)}
@@ -165,7 +175,7 @@ const ChangePasswordForm = () => {
-
+
{t(($) => $.passwordChangedTip, { ns: 'login' })}
diff --git a/web/app/reset-password/set-password/__tests__/page.spec.tsx b/web/app/reset-password/set-password/__tests__/page.spec.tsx
index bf7f0463fea..2f9d96f4e6e 100644
--- a/web/app/reset-password/set-password/__tests__/page.spec.tsx
+++ b/web/app/reset-password/set-password/__tests__/page.spec.tsx
@@ -1,4 +1,5 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import useDocumentTitle from '@/hooks/use-document-title'
import { useRouter, useSearchParams } from '@/next/navigation'
import { changePasswordWithToken } from '@/service/common'
@@ -81,6 +82,36 @@ describe('Reset Password Set Password Page', () => {
expect(mockUseDocumentTitle).toHaveBeenCalledWith('login.changePassword')
})
+ it('supports password reveal and native form submission', async () => {
+ const user = userEvent.setup()
+ render()
+
+ const passwordInput = screen.getByLabelText('common.account.newPassword')
+ const confirmPasswordInput = screen.getByLabelText('common.account.confirmPassword')
+
+ expect(passwordInput).toHaveAttribute('autocomplete', 'new-password')
+ expect(confirmPasswordInput).toHaveAttribute('autocomplete', 'new-password')
+
+ await user.type(passwordInput, 'ValidPass123!')
+ await user.click(screen.getAllByRole('button', { name: 'login.showPassword' })[0]!)
+
+ expect(passwordInput).toHaveAttribute('type', 'text')
+ expect(screen.getByRole('button', { name: 'login.hidePassword' })).toBeInTheDocument()
+
+ await user.type(confirmPasswordInput, 'ValidPass123!{Enter}')
+
+ await waitFor(() => {
+ expect(mockChangePasswordWithToken).toHaveBeenCalledWith({
+ url: '/forgot-password/resets',
+ body: {
+ token: 'reset-token',
+ new_password: 'ValidPass123!',
+ password_confirm: 'ValidPass123!',
+ },
+ })
+ })
+ })
+
describe('Post-reset navigation', () => {
it('should preserve redirect_url when the user returns to sign in manually', async () => {
setSearchParams({ token: 'reset-token', redirect_url: redirectUrl })
diff --git a/web/app/reset-password/set-password/page.tsx b/web/app/reset-password/set-password/page.tsx
index e5f09fc4a66..7ea375cf4a8 100644
--- a/web/app/reset-password/set-password/page.tsx
+++ b/web/app/reset-password/set-password/page.tsx
@@ -1,12 +1,15 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
+import { Field, FieldDescription, FieldLabel } from '@langgenius/dify-ui/field'
+import { Form } from '@langgenius/dify-ui/form'
+import { IconButton } from '@langgenius/dify-ui/icon-button'
+import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCheckboxCircleFill } from '@remixicon/react'
import { useCountDown } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import Input from '@/app/components/base/input'
import { validPassword } from '@/config'
import useDocumentTitle from '@/hooks/use-document-title'
import { useRouter, useSearchParams } from '@/next/navigation'
@@ -109,68 +112,75 @@ const ChangePasswordForm = () => {
-
- {/* Password */}
-
-
-
-
+
+ setPassword(e.target.value)}
+ onValueChange={setPassword}
placeholder={t(($) => $.passwordPlaceholder, { ns: 'login' }) || ''}
/>
-
-
-
-
-
-
+
+
+
+
+
{t(($) => $['error.passwordInvalid'], { ns: 'login' })}
-
-
- {/* Confirm Password */}
-
-
-
-
+
+ setConfirmPassword(e.target.value)}
+ onValueChange={setConfirmPassword}
placeholder={t(($) => $.confirmPasswordPlaceholder, { ns: 'login' }) || ''}
/>
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
)}
@@ -178,7 +188,7 @@ const ChangePasswordForm = () => {
-
+
{t(($) => $.passwordChangedTip, { ns: 'login' })}
From 297fdc7cb852a0505c959f7cb61eb5215619bbf5 Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Wed, 19 Aug 2026 06:30:37 +0000
Subject: [PATCH 06/18] refactor(web): migrate HTTP timeout inputs (#40953)
---
oxlint-suppressions.json | 5 -
.../http/components/timeout/index.spec.tsx | 93 +++++++++++++++++++
.../nodes/http/components/timeout/index.tsx | 42 ++++-----
3 files changed, 111 insertions(+), 29 deletions(-)
create mode 100644 web/app/components/workflow/nodes/http/components/timeout/index.spec.tsx
diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json
index 0bfb65d1eb8..ff3bb9e8481 100644
--- a/oxlint-suppressions.json
+++ b/oxlint-suppressions.json
@@ -4004,11 +4004,6 @@
"count": 1
}
},
- "web/app/components/workflow/nodes/http/components/timeout/index.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/components/workflow/nodes/http/panel.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 2
diff --git a/web/app/components/workflow/nodes/http/components/timeout/index.spec.tsx b/web/app/components/workflow/nodes/http/components/timeout/index.spec.tsx
new file mode 100644
index 00000000000..9a2422e48de
--- /dev/null
+++ b/web/app/components/workflow/nodes/http/components/timeout/index.spec.tsx
@@ -0,0 +1,93 @@
+import type { ReactNode } from 'react'
+import type { Timeout as TimeoutPayload } from '../../types'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { useState } from 'react'
+import { withSelectorKey } from '@/test/i18n-mock'
+import Timeout from './index'
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: withSelectorKey((key: string) => key),
+ }),
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: { nodesDefaultConfigs: object }) => unknown) =>
+ selector({ nodesDefaultConfigs: {} }),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/collapse', () => ({
+ FieldCollapse: ({ children, title }: { children: ReactNode; title: string }) => (
+
+ ),
+}))
+
+type TimeoutHarnessProps = {
+ onChange: (payload: TimeoutPayload) => void
+ readonly?: boolean
+}
+
+function TimeoutHarness({ onChange, readonly = false }: TimeoutHarnessProps) {
+ const [payload, setPayload] = useState({ connect: 5, read: 10, write: 15 })
+
+ return (
+ {
+ setPayload(nextPayload)
+ onChange(nextPayload)
+ }}
+ />
+ )
+}
+
+describe('HTTP timeout fields', () => {
+ it('associates every timeout with its visible label and description', () => {
+ render()
+
+ const connectInput = screen.getByRole('textbox', {
+ name: 'nodes.http.timeout.connectLabel',
+ })
+ const readInput = screen.getByRole('textbox', { name: 'nodes.http.timeout.readLabel' })
+ const writeInput = screen.getByRole('textbox', { name: 'nodes.http.timeout.writeLabel' })
+
+ expect(connectInput).toHaveAccessibleDescription('nodes.http.timeout.connectPlaceholder')
+ expect(readInput).toHaveAccessibleDescription('nodes.http.timeout.readPlaceholder')
+ expect(writeInput).toHaveAccessibleDescription('nodes.http.timeout.writePlaceholder')
+ })
+
+ it('stores integer values and maps an empty field to the backend default', async () => {
+ const user = userEvent.setup()
+ const onChange = vi.fn()
+ render()
+ const connectInput = screen.getByRole('textbox', {
+ name: 'nodes.http.timeout.connectLabel',
+ })
+
+ await user.clear(connectInput)
+
+ expect(onChange).toHaveBeenLastCalledWith({ connect: undefined, read: 10, write: 15 })
+
+ await user.type(connectInput, '8.9')
+
+ expect(onChange).toHaveBeenLastCalledWith({ connect: 9, read: 10, write: 15 })
+ })
+
+ it('does not update read-only timeout fields', async () => {
+ const user = userEvent.setup()
+ const onChange = vi.fn()
+ render()
+ const connectInput = screen.getByRole('textbox', {
+ name: 'nodes.http.timeout.connectLabel',
+ })
+
+ expect(connectInput).toHaveAttribute('readonly')
+
+ await user.type(connectInput, '8')
+
+ expect(onChange).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/workflow/nodes/http/components/timeout/index.tsx b/web/app/components/workflow/nodes/http/components/timeout/index.tsx
index 88cb805aeb5..b5f3ec19c32 100644
--- a/web/app/components/workflow/nodes/http/components/timeout/index.tsx
+++ b/web/app/components/workflow/nodes/http/components/timeout/index.tsx
@@ -1,9 +1,10 @@
'use client'
import type { FC } from 'react'
import type { Timeout as TimeoutPayloadType } from '../../types'
+import { Field, FieldDescription, FieldLabel } from '@langgenius/dify-ui/field'
+import { NumberField, NumberFieldGroup, NumberFieldInput } from '@langgenius/dify-ui/number-field'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
-import Input from '@/app/components/base/input'
import { FieldCollapse } from '@/app/components/workflow/nodes/_base/components/collapse'
import { useStore } from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
@@ -28,33 +29,26 @@ const InputField: FC<{
max: number
}> = ({ title, description, placeholder, value, onChange, readOnly, min, max }) => {
return (
-
+ format={{ maximumFractionDigits: 0 }}
+ readOnly={readOnly}
+ onValueChange={(nextValue) => onChange(nextValue ?? undefined)}
+ >
+
+
+
+
+
)
}
From 8874f3c80be6661d4f6a63ed23d4eae57688dafe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=9B=90=E7=B2=92=20Yanli?=
Date: Wed, 19 Aug 2026 06:34:36 +0000
Subject: [PATCH 07/18] refactor(agent): remove Agent Drive (#40887)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
---
api/clients/agent_backend/request_builder.py | 74 +-
api/controllers/console/__init__.py | 2 -
api/controllers/console/agent/composer.py | 30 +-
api/controllers/console/app/agent.py | 466 +-----
.../console/app/agent_drive_inspector.py | 434 ------
api/controllers/files/__init__.py | 3 +-
api/controllers/files/agent_drive_archive.py | 69 -
api/controllers/inner_api/__init__.py | 2 -
.../inner_api/plugin/agent_drive.py | 105 --
...17_1740-89919253ca7a_remove_agent_drive.py | 109 ++
api/models/__init__.py | 4 -
api/models/agent.py | 52 -
api/models/agent_config_entities.py | 28 -
api/openapi/markdown/console-openapi.md | 575 --------
api/services/agent/composer_service.py | 205 +--
.../agent/config_skill_normalize_service.py | 7 +-
api/services/agent/dsl_service.py | 15 +-
api/services/agent/prompt_mentions.py | 6 +-
api/services/agent/skill_package_service.py | 11 +-
.../agent/skill_standardize_service.py | 135 --
.../agent/skill_tool_inference_service.py | 179 ---
.../agent/workflow_publish_service.py | 13 +-
api/services/agent_config_service.py | 14 +-
api/services/agent_drive_service.py | 1254 -----------------
api/tasks/delete_conversation_task.py | 23 +-
.../tasks/test_delete_conversation_task.py | 178 ---
api/tests/unit_tests/.ruff.toml | 2 -
.../agent_backend/test_request_builder.py | 40 +-
.../console/agent/test_agent_controllers.py | 24 -
.../console/app/test_agent_drive_inspector.py | 310 ----
.../console/app/test_agent_skills.py | 423 ------
.../inner_api/plugin/test_agent_drive.py | 172 ---
.../agent_app/test_runtime_request_builder.py | 1 -
.../agent_v2/test_runtime_request_builder.py | 1 -
...est_agent_drive_skill_metadata_refactor.py | 122 --
.../migrations/test_remove_agent_drive.py | 184 +++
api/tests/unit_tests/pyrefly.toml | 5 -
.../agent/test_agent_composer_entities.py | 18 -
.../services/agent/test_agent_dsl_service.py | 33 +-
.../services/agent/test_agent_services.py | 470 +-----
.../services/agent/test_prompt_mentions.py | 8 -
.../agent/test_skill_standardize_service.py | 140 --
.../test_skill_tool_inference_service.py | 188 ---
.../agent/test_workflow_publish_service.py | 5 -
.../services/test_agent_drive_service.py | 952 -------------
.../tasks/test_delete_conversation_task.py | 23 +-
dify-agent-runtime/cmd/dify-agent-cli/main.go | 58 +-
.../cmd/dify-agent-cli/main_test.go | 22 +-
dify-agent-runtime/docker/Dockerfile | 3 +-
.../internal/agentcli/archive.go | 23 +
.../internal/agentcli/client.go | 4 -
.../internal/agentcli/client_http.go | 39 -
.../internal/agentcli/config.go | 29 +-
.../internal/agentcli/config_test.go | 111 ++
dify-agent-runtime/internal/agentcli/drive.go | 353 -----
dify-agent-runtime/internal/agentcli/env.go | 11 -
.../internal/agentcli/env_test.go | 14 -
dify-agent-runtime/internal/envvar/envvar.go | 7 -
.../internal/landlock/config.go | 2 +-
dify-agent/.example.env | 2 +-
.../user-manual/shell-layer/index.md | 1 -
.../src/dify_agent/agent_stub/_constants.py | 15 -
.../agent_stub/_drive_materialization.py | 176 ---
.../agent_stub/protocol/__init__.py | 22 -
.../agent_stub/protocol/agent_stub.py | 99 --
.../agent_stub/server/agent_stub_drive.py | 190 ---
.../src/dify_agent/agent_stub/server/app.py | 3 +-
.../agent_stub/server/control_plane.py | 45 +-
.../dify_agent/agent_stub/server/router.py | 5 +-
.../agent_stub/server/routes/agent_stub.py | 33 +-
.../src/dify_agent/agent_stub/shell_env.py | 17 +-
.../dify_agent/layers/_agent_cli_help.json | 4 -
.../src/dify_agent/layers/drive/__init__.py | 17 -
.../src/dify_agent/layers/drive/configs.py | 55 -
.../src/dify_agent/layers/drive/layer.py | 268 ----
.../src/dify_agent/layers/shell/configs.py | 5 +-
.../src/dify_agent/layers/shell/layer.py | 1 -
.../dify_agent/runtime/compositor_factory.py | 2 -
dify-agent/src/dify_agent/server/app.py | 2 -
dify-agent/src/dify_agent/server/settings.py | 15 -
.../protocol/test_agent_stub_protocol.py | 64 -
.../agent_stub/server/test_agent_stub_app.py | 56 -
.../server/test_agent_stub_drive.py | 269 ----
.../server/test_agent_stub_routes.py | 143 +-
.../dify_agent/layers/config/test_layer.py | 2 +-
.../local/dify_agent/layers/drive/__init__.py | 0
.../dify_agent/layers/drive/test_configs.py | 57 -
.../dify_agent/layers/drive/test_layer.py | 237 ----
.../dify_agent/layers/shell/test_configs.py | 3 -
.../runtime/test_compositor_factory.py | 14 +
.../tests/local/dify_agent/server/test_app.py | 63 -
.../dify_agent/server/test_binding_files.py | 1 -
.../local/dify_agent/server/test_settings.py | 29 -
.../dify_agent/test_client_safe_exports.py | 2 -
.../dify_agent/test_import_boundaries.py | 3 -
e2e/features/agent-v2/AGENTS.md | 2 +-
.../{agent-drive.ts => config-assets.ts} | 50 -
.../agent-v2/support/fixtures/agents.ts | 41 -
.../agent-v2/support/fixtures/common.ts | 4 +-
e2e/features/agent-v2/support/seed.ts | 20 +-
.../agent-v2/build-draft.steps.ts | 2 +-
.../agent-v2/configure-helpers.ts | 2 +-
.../agent-v2/configure.steps.ts | 29 -
e2e/features/support/hooks.ts | 9 -
e2e/features/support/world.ts | 6 -
.../generated/api/console/agent/orpc.gen.ts | 338 +----
.../generated/api/console/agent/types.gen.ts | 368 +----
.../generated/api/console/agent/zod.gen.ts | 389 +----
.../generated/api/console/apps/orpc.gen.ts | 890 ++++--------
.../generated/api/console/apps/types.gen.ts | 385 +----
.../generated/api/console/apps/zod.gen.ts | 418 +-----
.../api/console/snippets/types.gen.ts | 49 +-
.../generated/api/console/snippets/zod.gen.ts | 74 +-
113 files changed, 991 insertions(+), 11795 deletions(-)
delete mode 100644 api/controllers/console/app/agent_drive_inspector.py
delete mode 100644 api/controllers/files/agent_drive_archive.py
delete mode 100644 api/controllers/inner_api/plugin/agent_drive.py
create mode 100644 api/migrations/versions/2026_08_17_1740-89919253ca7a_remove_agent_drive.py
delete mode 100644 api/services/agent/skill_standardize_service.py
delete mode 100644 api/services/agent/skill_tool_inference_service.py
delete mode 100644 api/services/agent_drive_service.py
delete mode 100644 api/tests/test_containers_integration_tests/tasks/test_delete_conversation_task.py
delete mode 100644 api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py
delete mode 100644 api/tests/unit_tests/controllers/console/app/test_agent_skills.py
delete mode 100644 api/tests/unit_tests/controllers/inner_api/plugin/test_agent_drive.py
delete mode 100644 api/tests/unit_tests/migrations/test_agent_drive_skill_metadata_refactor.py
create mode 100644 api/tests/unit_tests/migrations/test_remove_agent_drive.py
delete mode 100644 api/tests/unit_tests/services/agent/test_skill_standardize_service.py
delete mode 100644 api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py
delete mode 100644 api/tests/unit_tests/services/test_agent_drive_service.py
delete mode 100644 dify-agent-runtime/internal/agentcli/drive.go
delete mode 100644 dify-agent/src/dify_agent/agent_stub/_constants.py
delete mode 100644 dify-agent/src/dify_agent/agent_stub/_drive_materialization.py
delete mode 100644 dify-agent/src/dify_agent/agent_stub/server/agent_stub_drive.py
delete mode 100644 dify-agent/src/dify_agent/layers/drive/__init__.py
delete mode 100644 dify-agent/src/dify_agent/layers/drive/configs.py
delete mode 100644 dify-agent/src/dify_agent/layers/drive/layer.py
delete mode 100644 dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_drive.py
delete mode 100644 dify-agent/tests/local/dify_agent/layers/drive/__init__.py
delete mode 100644 dify-agent/tests/local/dify_agent/layers/drive/test_configs.py
delete mode 100644 dify-agent/tests/local/dify_agent/layers/drive/test_layer.py
rename e2e/features/agent-v2/support/{agent-drive.ts => config-assets.ts} (75%)
diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py
index 57cbd3be926..2f4d09ae9d5 100644
--- a/api/clients/agent_backend/request_builder.py
+++ b/api/clients/agent_backend/request_builder.py
@@ -28,7 +28,6 @@ from dify_agent.layers.dify_plugin import (
DifyPluginLLMLayerConfig,
DifyPluginToolsLayerConfig,
)
-from dify_agent.layers.drive import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
from dify_agent.layers.execution_context import (
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
DifyExecutionContextLayerConfig,
@@ -56,7 +55,6 @@ AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
DIFY_RUNTIME_LAYER_ID = "runtime"
DIFY_CONFIG_LAYER_ID = "config"
-DIFY_DRIVE_LAYER_ID = "drive"
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
DIFY_CORE_TOOLS_LAYER_ID = "core_tools"
DIFY_KNOWLEDGE_BASE_LAYER_ID = "knowledge"
@@ -72,24 +70,10 @@ def _shell_layer_deps() -> dict[str, str]:
}
-def _drive_layer_deps() -> dict[str, str]:
- return {"shell": DIFY_SHELL_LAYER_ID}
-
-
def _config_layer_deps() -> dict[str, str]:
return {"shell": DIFY_SHELL_LAYER_ID}
-def _shell_config_with_drive_ref(
- shell_config: DifyShellLayerConfig | None,
- drive_config: DifyDriveLayerConfig | None,
-) -> DifyShellLayerConfig:
- config = shell_config or DifyShellLayerConfig()
- if drive_config is None:
- return config
- return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref})
-
-
def _markdown_backtick_fence(text: str) -> str:
"""Choose a fence that will not terminate inside the prompt body."""
longest_backtick_run = max((len(match.group(0)) for match in re.finditer(r"`+", text)), default=0)
@@ -224,9 +208,6 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
core_tools: DifyCoreToolsLayerConfig | None = None
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.
- 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
# the workflow pauses via the existing HITL form mechanism (ENG-635).
@@ -273,9 +254,6 @@ class AgentBackendAgentAppRunInput(BaseModel):
core_tools: DifyCoreToolsLayerConfig | None = None
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.
- 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).
ask_human_config: DifyAskHumanLayerConfig | None = None
@@ -307,7 +285,7 @@ class AgentBackendRunRequestBuilder:
"""Build an Agent App conversation-turn run request.
Layer graph: optional Agent Soul system prompt → user prompt →
- execution context → optional shell / config / drive / history
+ execution context → optional shell / config / 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
@@ -345,9 +323,7 @@ class AgentBackendRunRequestBuilder:
]
)
- include_shell = (
- run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
- )
+ include_shell = run_input.include_shell or run_input.config_layer_config is not None
if include_shell:
layers.append(
RunLayerSpec(
@@ -357,16 +333,15 @@ class AgentBackendRunRequestBuilder:
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
)
)
- # Sandboxed bash workspace (dify.shell). It enters before config/drive
- # so eager pulls materialize content in the same filesystem used by
- # model commands.
+ # Sandboxed bash workspace (dify.shell). It enters before config so
+ # eager pulls materialize content in the same filesystem used by model commands.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps=_shell_layer_deps(),
metadata=run_input.metadata,
- config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
+ config=run_input.shell_config or DifyShellLayerConfig(),
)
)
@@ -381,19 +356,6 @@ class AgentBackendRunRequestBuilder:
)
)
- if run_input.drive_config is not None:
- # Drive Skills & Files declaration (dify.drive): the catalog plus
- # prompt-mentioned entries eagerly pulled through the shell layer.
- layers.append(
- RunLayerSpec(
- name=DIFY_DRIVE_LAYER_ID,
- type=DIFY_DRIVE_LAYER_TYPE_ID,
- deps=_drive_layer_deps(),
- metadata=run_input.metadata,
- config=run_input.drive_config,
- )
- )
-
if run_input.include_history:
layers.append(
RunLayerSpec(
@@ -495,7 +457,7 @@ class AgentBackendRunRequestBuilder:
"""Build a workflow Agent Node run request without defining another wire schema.
Layer graph mirrors the workflow surface: prompts → execution context →
- optional shell / config / drive / history → LLM → optional
+ optional shell / config / history → LLM → optional
plugin-direct tools / core-routed tools / knowledge search /
ask_human / structured output.
"""
@@ -537,9 +499,7 @@ class AgentBackendRunRequestBuilder:
]
)
- include_shell = (
- run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
- )
+ include_shell = run_input.include_shell or run_input.config_layer_config is not None
if include_shell:
layers.append(
RunLayerSpec(
@@ -549,16 +509,15 @@ class AgentBackendRunRequestBuilder:
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
)
)
- # Sandboxed bash workspace (dify.shell). It enters before drive so
- # drive can materialize mentioned targets with `dify-agent drive pull`
- # in the same shell-visible filesystem used by model commands.
+ # Sandboxed bash workspace (dify.shell). It enters before config so
+ # eager pulls materialize content in the same filesystem used by model commands.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps=_shell_layer_deps(),
metadata=run_input.metadata,
- config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
+ config=run_input.shell_config or DifyShellLayerConfig(),
)
)
@@ -573,19 +532,6 @@ class AgentBackendRunRequestBuilder:
)
)
- if run_input.drive_config is not None:
- # Drive Skills & Files declaration (dify.drive): the catalog plus
- # prompt-mentioned entries eagerly pulled through the shell layer.
- layers.append(
- RunLayerSpec(
- name=DIFY_DRIVE_LAYER_ID,
- type=DIFY_DRIVE_LAYER_TYPE_ID,
- deps=_drive_layer_deps(),
- metadata=run_input.metadata,
- config=run_input.drive_config,
- )
- )
-
if run_input.include_history:
layers.append(
RunLayerSpec(
diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py
index 45063841c96..afad88a8ce3 100644
--- a/api/controllers/console/__init__.py
+++ b/api/controllers/console/__init__.py
@@ -72,7 +72,6 @@ from .app import (
agent_app_feature,
agent_app_sandbox,
agent_config_inspector,
- agent_drive_inspector,
annotation,
app,
audio,
@@ -176,7 +175,6 @@ __all__ = [
"agent_app_sandbox",
"agent_composer",
"agent_config_inspector",
- "agent_drive_inspector",
"agent_providers",
"agent_roster",
"annotation",
diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py
index 76d7863776e..66192940fc1 100644
--- a/api/controllers/console/agent/composer.py
+++ b/api/controllers/console/agent/composer.py
@@ -182,14 +182,7 @@ class WorkflowAgentComposerValidateApi(Resource):
AgentComposerService.validate_knowledge_datasets(
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
)
- findings = AgentComposerService.collect_validation_findings(
- session=session,
- tenant_id=tenant_id,
- payload=req_data,
- agent_id=AgentComposerService.resolve_workflow_node_agent_id(
- session=session, tenant_id=tenant_id, app_id=app_model.id, node_id=node_id
- ),
- )
+ findings = AgentComposerService.collect_validation_findings(payload=req_data)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@@ -413,22 +406,12 @@ class SnippetAgentComposerValidateApi(Resource):
@with_session(write=False)
@model_validate(ComposerSavePayload)
def post(self, req_data: ComposerSavePayload, session: Session, tenant_id: str, snippet_id: UUID, node_id: str):
- app_id = _require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id)
+ _require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id)
ComposerConfigValidator.validate_publish_payload(req_data)
AgentComposerService.validate_knowledge_datasets(
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
)
- findings = AgentComposerService.collect_validation_findings(
- session=session,
- tenant_id=tenant_id,
- payload=req_data,
- agent_id=AgentComposerService.resolve_workflow_node_agent_id(
- session=session,
- tenant_id=tenant_id,
- app_id=app_id,
- node_id=node_id,
- ),
- )
+ findings = AgentComposerService.collect_validation_findings(payload=req_data)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@@ -580,12 +563,7 @@ class AgentComposerValidateApi(Resource):
AgentComposerService.validate_knowledge_datasets(
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
)
- findings = AgentComposerService.collect_validation_findings(
- session=session,
- tenant_id=tenant_id,
- payload=req_data,
- agent_id=str(agent_id),
- )
+ findings = AgentComposerService.collect_validation_findings(payload=req_data)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py
index 325e747b3be..4fc9c7f6a6e 100644
--- a/api/controllers/console/app/agent.py
+++ b/api/controllers/console/app/agent.py
@@ -1,21 +1,12 @@
from typing import Any
-from uuid import UUID
-from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
-from sqlalchemy import select
from sqlalchemy.orm import Session
-from controllers.common.schema import (
- query_params_from_model,
- query_params_from_request,
- register_response_schema_models,
- register_schema_models,
-)
+from controllers.common.schema import query_params_from_model, register_response_schema_models
from controllers.common.session import with_session
from controllers.console import console_ns
-from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
@@ -24,41 +15,13 @@ from controllers.console.wraps import (
model_validate,
rbac_permission_required,
setup_required,
- with_current_tenant_id,
- with_current_user,
)
from fields.base import ResponseModel
from libs.helper import uuid_value
from libs.login import login_required
-from models import Account
-from models.model import App, AppMode, UploadFile
-from services.agent.composer_service import AgentComposerService
-from services.agent.skill_package_service import SkillManifest, SkillPackageError
-from services.agent.skill_standardize_service import SkillStandardizeService
-from services.agent.skill_tool_inference_service import (
- SkillToolInferenceError,
- SkillToolInferenceResult,
- SkillToolInferenceService,
-)
-from services.agent_drive_service import (
- AgentDriveError,
- AgentDriveService,
- DriveCommitItem,
- DriveFileRef,
- normalize_drive_key,
-)
+from models.model import App, AppMode
from services.agent_service import AgentService
-_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
-_AGENT_SKILL_UPLOAD_PARAMS = {
- "file": {
- "in": "formData",
- "type": "file",
- "required": True,
- "description": "Skill package (.zip or .skill).",
- }
-}
-
class AgentLogQuery(BaseModel):
message_id: str = Field(..., description="Message UUID")
@@ -70,27 +33,6 @@ class AgentLogQuery(BaseModel):
return uuid_value(value)
-class AgentDriveFilePayload(BaseModel):
- upload_file_id: str = Field(..., description="UploadFile UUID from POST /console/api/files/upload")
-
- @field_validator("upload_file_id")
- @classmethod
- def validate_upload_file_id(cls, value: str) -> str:
- return uuid_value(value)
-
-
-class AgentDriveMutationQuery(BaseModel):
- node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
-
-
-class AgentDriveDeleteFileQuery(AgentDriveMutationQuery):
- key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
-
-
-class AgentDriveDeleteFileByAgentQuery(BaseModel):
- key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
-
-
class AgentLogMetaResponse(ResponseModel):
status: str
executor: str
@@ -128,204 +70,7 @@ class AgentLogResponse(ResponseModel):
files: list[Any] = Field(default_factory=list)
-class AgentUploadedSkillResponse(ResponseModel):
- name: str
- description: str
- path: str
- skill_md_key: str
- archive_key: str | None = None
-
-
-class AgentSkillUploadResponse(ResponseModel):
- skill: AgentUploadedSkillResponse
- manifest: SkillManifest
-
-
-class AgentDriveFileResponse(ResponseModel):
- name: str
- drive_key: str
- file_id: str
- size: int | None = None
- mime_type: str | None = None
-
-
-class AgentDriveFileCommitResponse(ResponseModel):
- file: AgentDriveFileResponse
-
-
-class AgentDriveDeleteResponse(ResponseModel):
- result: str
- removed_keys: list[str] = Field(default_factory=list)
-
-
-register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery)
-register_response_schema_models(
- console_ns,
- AgentDriveDeleteResponse,
- AgentDriveFileCommitResponse,
- AgentDriveFileResponse,
- AgentLogResponse,
- AgentUploadedSkillResponse,
- AgentSkillUploadResponse,
- SkillToolInferenceResult,
-)
-
-
-def _resolve_agent_id(session: Session, app_model: App, node_id: str | None) -> str | None:
- if node_id and app_model.mode != AppMode.AGENT:
- return AgentComposerService.resolve_workflow_node_agent_id(
- session=session, tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
- )
- return app_model.bound_agent_id_with_session(session=session)
-
-
-def _agent_not_bound() -> tuple[dict[str, str], int]:
- return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
-
-
-def _upload_skill_for_app(*, session: Session, current_user: Account, app_model: App):
- """Upload one skill package and commit its normalized files into the agent drive."""
-
- query = query_params_from_request(AgentDriveMutationQuery)
- agent_id = _resolve_agent_id(session, app_model, query.node_id)
- if not agent_id:
- return _agent_not_bound()
- if "file" not in request.files:
- return {"code": "no_file", "message": "no skill file uploaded"}, 400
- if len(request.files) > 1:
- return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400
-
- upload = request.files["file"]
- content = upload.stream.read()
- try:
- result = SkillStandardizeService().standardize(
- content=content,
- filename=upload.filename or "",
- tenant_id=app_model.tenant_id,
- user_id=current_user.id,
- agent_id=agent_id,
- session=session,
- )
- except (SkillPackageError, AgentDriveError) as exc:
- return {"code": exc.code, "message": exc.message}, exc.status_code
- return result, 201
-
-
-def _commit_drive_file_for_app(*, session: Session, current_user: Account, app_model: App, allow_node_id: bool = True):
- payload = AgentDriveFilePayload.model_validate(console_ns.payload or {})
- query = query_params_from_request(AgentDriveMutationQuery)
- node_id = query.node_id if allow_node_id else None
- agent_id = _resolve_agent_id(session, app_model, node_id)
- if not agent_id:
- return _agent_not_bound()
-
- upload_file = session.scalar(
- select(UploadFile).where(
- UploadFile.id == payload.upload_file_id,
- UploadFile.tenant_id == app_model.tenant_id,
- )
- )
- if upload_file is None:
- return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404
-
- try:
- key = normalize_drive_key(f"files/{upload_file.name}")
- committed = AgentDriveService().commit(
- tenant_id=app_model.tenant_id,
- user_id=current_user.id,
- agent_id=agent_id,
- items=[
- DriveCommitItem(
- key=key,
- file_ref=DriveFileRef(kind="upload_file", id=upload_file.id),
- # ADD FILE uploads exist solely to live in the drive, so the
- # drive owns (and physically cleans) the value on delete.
- value_owned_by_drive=True,
- )
- ],
- session=session,
- )
- except AgentDriveError as exc:
- return {"code": exc.code, "message": exc.message}, exc.status_code
-
- row = committed[0]
- return {
- "file": {
- "name": upload_file.name,
- "drive_key": row["key"],
- "file_id": upload_file.id,
- "size": row.get("size"),
- "mime_type": row.get("mime_type"),
- },
- }, 201
-
-
-def _delete_drive_file_for_app(*, session: Session, current_user: Account, app_model: App, allow_node_id: bool = True):
- query = query_params_from_request(AgentDriveDeleteFileQuery)
- node_id = query.node_id if allow_node_id else None
- agent_id = _resolve_agent_id(session, app_model, node_id)
- if not agent_id:
- return _agent_not_bound()
- try:
- key = normalize_drive_key(query.key)
- except AgentDriveError as exc:
- return {"code": exc.code, "message": exc.message}, exc.status_code
-
- try:
- result = AgentDriveService().commit(
- tenant_id=app_model.tenant_id,
- user_id=current_user.id,
- agent_id=agent_id,
- items=[DriveCommitItem(key=key, file_ref=None)],
- session=session,
- )
- except AgentDriveError as exc:
- return {"code": exc.code, "message": exc.message}, exc.status_code
- removed_keys = [item["key"] for item in result if item.get("removed")]
- return {"result": "success", "removed_keys": removed_keys}
-
-
-def _delete_skill_for_app(
- *, session: Session, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True
-):
- query = query_params_from_request(AgentDriveMutationQuery)
- node_id = query.node_id if allow_node_id else None
- agent_id = _resolve_agent_id(session, app_model, node_id)
- if not agent_id:
- return _agent_not_bound()
- if "/" in slug or not slug.strip():
- return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
-
- try:
- result = AgentDriveService().commit(
- tenant_id=app_model.tenant_id,
- user_id=current_user.id,
- agent_id=agent_id,
- items=[
- DriveCommitItem(key=f"{slug}/SKILL.md", file_ref=None),
- DriveCommitItem(key=f"{slug}/.DIFY-SKILL-FULL.zip", file_ref=None),
- ],
- session=session,
- )
- except AgentDriveError as exc:
- return {"code": exc.code, "message": exc.message}, exc.status_code
- removed_keys = [item["key"] for item in result if item.get("removed")]
- return {"result": "success", "removed_keys": removed_keys}
-
-
-def _infer_skill_tools_for_app(*, session: Session, app_model: App, slug: str):
- query = query_params_from_request(AgentDriveMutationQuery)
- agent_id = _resolve_agent_id(session, app_model, query.node_id)
- if not agent_id:
- return _agent_not_bound()
- if "/" in slug or not slug.strip():
- return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
- try:
- return SkillToolInferenceService().infer(
- tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug, session=session
- )
- except SkillToolInferenceError as exc:
- return {"code": exc.code, "message": exc.message}, exc.status_code
+register_response_schema_models(console_ns, AgentLogResponse)
@console_ns.route("/apps//agent/logs")
@@ -344,209 +89,6 @@ class AgentLogApi(Resource):
@get_app_model(mode=[AppMode.AGENT_CHAT])
@model_validate(AgentLogQuery)
def get(self, req_data: AgentLogQuery, session: Session, app_model: App):
- """Get agent logs"""
+ """Get agent logs."""
return AgentService.get_agent_logs(app_model, req_data.conversation_id, req_data.message_id, session)
-
-
-@console_ns.route("/agent//skills/upload")
-class AgentSkillUploadByAgentApi(Resource):
- @console_ns.doc("upload_agent_skill_by_agent")
- @console_ns.doc(description="Upload + standardize a Skill into an Agent App drive")
- @console_ns.doc(consumes=["multipart/form-data"], params={"agent_id": "Agent ID", **_AGENT_SKILL_UPLOAD_PARAMS})
- @console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
- @console_ns.response(400, "Invalid skill package or no bound agent")
- @setup_required
- @login_required
- @account_initialization_required
- @with_current_user
- @with_current_tenant_id
- @with_session
- def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
- app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
- return _upload_skill_for_app(session=session, current_user=current_user, app_model=app_model)
-
-
-@console_ns.route("/apps//agent/skills/upload")
-class AgentSkillUploadApi(Resource):
- @console_ns.doc("upload_agent_skill")
- @console_ns.doc(description="Upload + standardize a Skill into the agent drive")
- @console_ns.doc(
- consumes=["multipart/form-data"],
- params={
- "app_id": "Application ID",
- **query_params_from_model(AgentDriveMutationQuery),
- **_AGENT_SKILL_UPLOAD_PARAMS,
- },
- )
- @console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
- @console_ns.response(400, "Invalid skill package or no bound agent")
- @setup_required
- @login_required
- @account_initialization_required
- @with_current_user
- @with_session
- @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
- def post(self, session: Session, current_user: Account, app_model: App):
- """Upload a Skill, validate it, and commit drive-backed skill files."""
- return _upload_skill_for_app(session=session, current_user=current_user, app_model=app_model)
-
-
-@console_ns.route("/agent//files")
-class AgentDriveFilesByAgentApi(Resource):
- @console_ns.doc("commit_agent_drive_file_by_agent")
- @console_ns.doc(description="Commit an uploaded file into the Agent App drive under files/")
- @console_ns.doc(params={"agent_id": "Agent ID"})
- @console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
- @console_ns.response(
- 201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
- )
- @setup_required
- @login_required
- @account_initialization_required
- @with_current_user
- @with_current_tenant_id
- @with_session
- def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
- app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
- return _commit_drive_file_for_app(
- session=session, current_user=current_user, app_model=app_model, allow_node_id=False
- )
-
- @console_ns.doc("delete_agent_drive_file_by_agent")
- @console_ns.doc(description="Delete one Agent App drive file by key")
- @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveDeleteFileByAgentQuery)})
- @console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
- @setup_required
- @login_required
- @account_initialization_required
- @with_current_user
- @with_current_tenant_id
- @with_session
- def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
- app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
- return _delete_drive_file_for_app(
- session=session, current_user=current_user, app_model=app_model, allow_node_id=False
- )
-
-
-@console_ns.route("/apps//agent/files")
-class AgentDriveFilesApi(Resource):
- @console_ns.doc("commit_agent_drive_file")
- @console_ns.doc(description="Commit an uploaded file into the agent drive under files/ (ENG-625 D3)")
- @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)})
- @console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
- @console_ns.response(
- 201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
- )
- @setup_required
- @login_required
- @account_initialization_required
- @with_current_user
- @with_session
- @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
- def post(self, session: Session, current_user: Account, app_model: App):
- """ADD FILE: commit one uploaded file into the bound agent's drive."""
- return _commit_drive_file_for_app(session=session, current_user=current_user, app_model=app_model)
-
- @console_ns.doc("delete_agent_drive_file")
- @console_ns.doc(description="Delete one drive file by key via drive commit-null semantics")
- @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveDeleteFileQuery)})
- @console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
- @setup_required
- @login_required
- @account_initialization_required
- @with_current_user
- @with_session
- @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
- def delete(self, session: Session, current_user: Account, app_model: App):
- return _delete_drive_file_for_app(session=session, current_user=current_user, app_model=app_model)
-
-
-@console_ns.route("/agent//skills/")
-class AgentSkillByAgentApi(Resource):
- @console_ns.doc("delete_agent_skill_by_agent")
- @console_ns.doc(description="Delete a standardized skill from an Agent App drive")
- @console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
- @console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
- @setup_required
- @login_required
- @account_initialization_required
- @with_current_user
- @with_current_tenant_id
- @with_session
- def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID, slug: str):
- app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
- return _delete_skill_for_app(
- session=session, current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False
- )
-
-
-@console_ns.route("/apps//agent/skills/")
-class AgentSkillApi(Resource):
- @console_ns.doc("delete_agent_skill")
- @console_ns.doc(description="Delete a standardized skill by removing its known drive keys via commit-null")
- @console_ns.doc(
- params={
- "app_id": "Application ID",
- "slug": "Skill slug (single path segment)",
- **query_params_from_model(AgentDriveMutationQuery),
- }
- )
- @console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
- @setup_required
- @login_required
- @account_initialization_required
- @with_current_user
- @with_session
- @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
- def delete(self, session: Session, current_user: Account, app_model: App, slug: str):
- return _delete_skill_for_app(session=session, current_user=current_user, app_model=app_model, slug=slug)
-
-
-@console_ns.route("/agent//skills//infer-tools")
-class AgentSkillInferToolsByAgentApi(Resource):
- @console_ns.doc("infer_agent_skill_tools_by_agent")
- @console_ns.doc(description="Infer CLI tool + ENV suggestions from a standardized Agent App skill")
- @console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
- @console_ns.response(
- 200,
- "Inference result (draft suggestions, nothing persisted)",
- console_ns.models[SkillToolInferenceResult.__name__],
- )
- @setup_required
- @login_required
- @account_initialization_required
- @with_current_tenant_id
- @with_session(write=False)
- def post(self, session: Session, tenant_id: str, agent_id: UUID, slug: str):
- app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
- return _infer_skill_tools_for_app(session=session, app_model=app_model, slug=slug)
-
-
-@console_ns.route("/apps//agent/skills//infer-tools")
-class AgentSkillInferToolsApi(Resource):
- @console_ns.doc("infer_agent_skill_tools")
- @console_ns.doc(
- description="Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)"
- )
- @console_ns.doc(
- params={
- "app_id": "Application ID",
- "slug": "Skill slug (single path segment)",
- **query_params_from_model(AgentDriveMutationQuery),
- }
- )
- @console_ns.response(
- 200,
- "Inference result (draft suggestions, nothing persisted)",
- console_ns.models[SkillToolInferenceResult.__name__],
- )
- @setup_required
- @login_required
- @account_initialization_required
- @with_session(write=False)
- @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
- def post(self, session: Session, app_model: App, slug: str):
- """Suggest CLI tools/env for a skill. Saving still goes through composer validation."""
- return _infer_skill_tools_for_app(session=session, app_model=app_model, slug=slug)
diff --git a/api/controllers/console/app/agent_drive_inspector.py b/api/controllers/console/app/agent_drive_inspector.py
deleted file mode 100644
index e682953c015..00000000000
--- a/api/controllers/console/app/agent_drive_inspector.py
+++ /dev/null
@@ -1,434 +0,0 @@
-"""Console read-only inspector for the agent drive (ENG-624).
-
-``agent-drive`` looks at the *static* drive assets (standardized skills and
-committed files); the sibling ``agent-sandbox`` routes look at a *runtime*
-sandbox workspace. Unlike the sandbox routes this never proxies to the agent
-backend — drive data lives in the API's own DB/storage, served straight from
-``AgentDriveService``. Download hands the browser an **external** signed URL
-(the inner manifest hands agents internal ones — the two must never mix).
-"""
-
-from __future__ import annotations
-
-import json
-from collections.abc import Mapping
-from typing import Any
-from uuid import UUID
-
-from flask import Response
-from flask_restx import Resource
-from pydantic import BaseModel, Field
-from sqlalchemy.orm import Session
-
-from controllers.common.schema import (
- query_params_from_model,
- query_params_from_request,
- register_response_schema_models,
-)
-from controllers.common.session import with_session
-from controllers.console import console_ns
-from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
-from controllers.console.app.wraps import get_app_model
-from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
-from fields.base import ResponseModel
-from libs.login import login_required
-from models.model import App, AppMode
-from services.agent.composer_service import AgentComposerService
-from services.agent_drive_service import AgentDriveError, AgentDriveService
-
-
-class AgentDriveListQuery(BaseModel):
- prefix: str = Field(default="", description="Key prefix filter: '/' for one skill, 'files/' for files")
- node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
-
-
-class AgentDriveListByAgentQuery(BaseModel):
- prefix: str = Field(default="", description="Key prefix filter: '/' for one skill, 'files/' for files")
-
-
-class AgentDriveFileQuery(BaseModel):
- key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
- node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
-
-
-class AgentDriveFileByAgentQuery(BaseModel):
- key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
-
-
-class AgentDriveSkillInspectQuery(BaseModel):
- node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
-
-
-class AgentDriveItemResponse(ResponseModel):
- key: str
- size: int | None = None
- mime_type: str | None = None
- hash: str | None = None
- file_kind: str
- created_at: int | None = None
- is_skill: bool | None = None
- skill_metadata: str | None = None
-
-
-class AgentDriveListResponse(ResponseModel):
- items: list[AgentDriveItemResponse] = Field(default_factory=list)
-
-
-class AgentDriveSkillItemResponse(ResponseModel):
- path: str
- skill_md_key: str
- archive_key: str | None = None
- name: str
- description: str
- size: int | None = None
- mime_type: str | None = None
- hash: str | None = None
- created_at: int | None = None
-
-
-class AgentDriveSkillListResponse(ResponseModel):
- items: list[AgentDriveSkillItemResponse] = Field(default_factory=list)
-
-
-class AgentDriveSkillFileResponse(ResponseModel):
- path: str
- name: str
- type: str
- drive_key: str | None = None
- available_in_drive: bool
-
-
-class AgentDriveSkillMarkdownResponse(ResponseModel):
- key: str
- size: int | None = None
- truncated: bool
- binary: bool
- text: str | None = None
-
-
-class AgentDriveSkillInspectResponse(ResponseModel):
- path: str
- skill_md_key: str
- archive_key: str | None = None
- name: str
- description: str
- size: int | None = None
- mime_type: str | None = None
- hash: str | None = None
- created_at: int | None = None
- source: str
- files: list[AgentDriveSkillFileResponse] = Field(default_factory=list)
- file_tree: list[dict[str, Any]] = Field(default_factory=list)
- skill_md: AgentDriveSkillMarkdownResponse
- warnings: list[str] = Field(default_factory=list)
-
-
-class AgentDrivePreviewResponse(ResponseModel):
- key: str
- size: int | None = None
- truncated: bool
- binary: bool
- text: str | None = None
-
-
-class AgentDriveDownloadResponse(ResponseModel):
- url: str
-
-
-register_response_schema_models(
- console_ns,
- AgentDriveDownloadResponse,
- AgentDriveListResponse,
- AgentDrivePreviewResponse,
- AgentDriveSkillInspectResponse,
- AgentDriveSkillListResponse,
-)
-
-
-def _resolve_agent_id(session: Session, app_model: App, node_id: str | None) -> str | None:
- """Agent identity for the drive: app-bound agent, or the workflow node binding."""
- if node_id:
- return AgentComposerService.resolve_workflow_node_agent_id(
- session=session, tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
- )
- return app_model.bound_agent_id_with_session(session=session)
-
-
-def _agent_not_bound() -> tuple[dict[str, object], int]:
- return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
-
-
-def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]:
- return {"code": exc.code, "message": exc.message}, exc.status_code
-
-
-def _json_response(data: Mapping[str, Any]):
- return Response(
- response=json.dumps(data, ensure_ascii=False, separators=(",", ":")),
- content_type="application/json; charset=utf-8",
- )
-
-
-_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
-
-
-@console_ns.route("/agent//drive/files")
-class AgentDriveListByAgentApi(Resource):
- @console_ns.doc("list_agent_drive_files_by_agent")
- @console_ns.doc(description="List agent drive entries for an Agent App")
- @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveListByAgentQuery)})
- @console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
- @setup_required
- @login_required
- @account_initialization_required
- @with_current_tenant_id
- @with_session(write=False)
- def get(self, session: Session, tenant_id: str, agent_id: UUID):
- query = query_params_from_request(AgentDriveListByAgentQuery)
- resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
- try:
- items = AgentDriveService().manifest(
- tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix, session=session
- )
- except AgentDriveError as exc:
- return _handle(exc)
- return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
-
-
-@console_ns.route("/agent//drive/skills")
-class AgentDriveSkillListByAgentApi(Resource):
- @console_ns.doc("list_agent_drive_skills_by_agent")
- @console_ns.doc(description="List drive-backed skills for an Agent App")
- @console_ns.doc(params={"agent_id": "Agent ID"})
- @console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
- @setup_required
- @login_required
- @account_initialization_required
- @with_current_tenant_id
- @with_session(write=False)
- def get(self, session: Session, tenant_id: str, agent_id: UUID):
- resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
- try:
- items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id), session=session)
- except AgentDriveError as exc:
- return _handle(exc)
- return {"items": items}
-
-
-@console_ns.route("/agent//drive/skills//inspect")
-class AgentDriveSkillInspectByAgentApi(Resource):
- @console_ns.doc("inspect_agent_drive_skill_by_agent")
- @console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
- @console_ns.doc(params={"agent_id": "Agent ID", "skill_path": "Skill path/slug, e.g. tender-analyzer"})
- @console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
- @setup_required
- @login_required
- @account_initialization_required
- @with_current_tenant_id
- @with_session(write=False)
- def get(self, session: Session, tenant_id: str, agent_id: UUID, skill_path: str):
- resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
- try:
- return _json_response(
- AgentDriveService().inspect_skill(
- tenant_id=tenant_id,
- agent_id=str(agent_id),
- skill_path=skill_path,
- session=session,
- )
- )
- except AgentDriveError as exc:
- return _handle(exc)
-
-
-@console_ns.route("/agent//drive/files/preview")
-class AgentDrivePreviewByAgentApi(Resource):
- @console_ns.doc("preview_agent_drive_file_by_agent")
- @console_ns.doc(description="Truncated text preview of one Agent App drive value")
- @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
- @console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
- @setup_required
- @login_required
- @account_initialization_required
- @with_current_tenant_id
- @with_session(write=False)
- def get(self, session: Session, tenant_id: str, agent_id: UUID):
- query = query_params_from_request(AgentDriveFileByAgentQuery)
- resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
- try:
- return AgentDriveService().preview(
- tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=session
- )
- except AgentDriveError as exc:
- return _handle(exc)
-
-
-@console_ns.route("/agent//drive/files/download")
-class AgentDriveDownloadByAgentApi(Resource):
- @console_ns.doc("download_agent_drive_file_by_agent")
- @console_ns.doc(description="Time-limited external signed URL for one Agent App drive value")
- @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
- @console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
- @setup_required
- @login_required
- @account_initialization_required
- @with_current_tenant_id
- @with_session(write=False)
- def get(self, session: Session, tenant_id: str, agent_id: UUID):
- query = query_params_from_request(AgentDriveFileByAgentQuery)
- resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
- try:
- url = AgentDriveService().download_url(
- tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=session
- )
- except AgentDriveError as exc:
- return _handle(exc)
- return {"url": url}
-
-
-@console_ns.route("/apps//agent/drive/files")
-class AgentDriveListApi(Resource):
- @console_ns.doc("list_agent_drive_files")
- @console_ns.doc(description="List agent drive entries (read-only inspector; one endpoint for both tabs)")
- @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
- @console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
- @setup_required
- @login_required
- @account_initialization_required
- @with_session(write=False)
- @get_app_model(mode=_WORKFLOW_APP_MODES)
- def get(self, session: Session, app_model: App):
- query = query_params_from_request(AgentDriveListQuery)
- agent_id = _resolve_agent_id(session, app_model, query.node_id)
- if not agent_id:
- return _agent_not_bound()
- try:
- items = AgentDriveService().manifest(
- tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix, session=session
- )
- except AgentDriveError as exc:
- return _handle(exc)
- # the inner manifest exposes file_id for agent-side pulls; the console
- # inspector is a pure read surface and does not need value pointers
- return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
-
-
-@console_ns.route("/apps//agent/drive/skills")
-class AgentDriveSkillListApi(Resource):
- @console_ns.doc("list_agent_drive_skills")
- @console_ns.doc(description="List drive-backed skills for the bound agent")
- @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
- @console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
- @setup_required
- @login_required
- @account_initialization_required
- @with_session(write=False)
- @get_app_model(mode=_WORKFLOW_APP_MODES)
- def get(self, session: Session, app_model: App):
- query = query_params_from_request(AgentDriveListQuery)
- agent_id = _resolve_agent_id(session, app_model, query.node_id)
- if not agent_id:
- return _agent_not_bound()
- try:
- items = AgentDriveService().list_skills(tenant_id=app_model.tenant_id, agent_id=agent_id, session=session)
- except AgentDriveError as exc:
- return _handle(exc)
- return {"items": items}
-
-
-@console_ns.route("/apps//agent/drive/skills//inspect")
-class AgentDriveSkillInspectApi(Resource):
- @console_ns.doc("inspect_agent_drive_skill")
- @console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
- @console_ns.doc(
- params={
- "app_id": "Application ID",
- "skill_path": "Skill path/slug, e.g. tender-analyzer",
- **query_params_from_model(AgentDriveSkillInspectQuery),
- }
- )
- @console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
- @setup_required
- @login_required
- @account_initialization_required
- @with_session(write=False)
- @get_app_model(mode=_WORKFLOW_APP_MODES)
- def get(self, session: Session, app_model: App, skill_path: str):
- query = query_params_from_request(AgentDriveSkillInspectQuery)
- agent_id = _resolve_agent_id(session, app_model, query.node_id)
- if not agent_id:
- return _agent_not_bound()
- try:
- return _json_response(
- AgentDriveService().inspect_skill(
- tenant_id=app_model.tenant_id,
- agent_id=agent_id,
- skill_path=skill_path,
- session=session,
- )
- )
- except AgentDriveError as exc:
- return _handle(exc)
-
-
-@console_ns.route("/apps//agent/drive/files/preview")
-class AgentDrivePreviewApi(Resource):
- @console_ns.doc("preview_agent_drive_file")
- @console_ns.doc(description="Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)")
- @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
- @console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
- @setup_required
- @login_required
- @account_initialization_required
- @with_session(write=False)
- @get_app_model(mode=_WORKFLOW_APP_MODES)
- def get(self, session: Session, app_model: App):
- query = query_params_from_request(AgentDriveFileQuery)
- agent_id = _resolve_agent_id(session, app_model, query.node_id)
- if not agent_id:
- return _agent_not_bound()
- try:
- return AgentDriveService().preview(
- tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=session
- )
- except AgentDriveError as exc:
- return _handle(exc)
-
-
-@console_ns.route("/apps//agent/drive/files/download")
-class AgentDriveDownloadApi(Resource):
- @console_ns.doc("download_agent_drive_file")
- @console_ns.doc(description="Time-limited external signed URL for one drive value (no streaming proxy)")
- @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
- @console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
- @setup_required
- @login_required
- @account_initialization_required
- @with_session(write=False)
- @get_app_model(mode=_WORKFLOW_APP_MODES)
- def get(self, session: Session, app_model: App):
- query = query_params_from_request(AgentDriveFileQuery)
- agent_id = _resolve_agent_id(session, app_model, query.node_id)
- if not agent_id:
- return _agent_not_bound()
- try:
- url = AgentDriveService().download_url(
- tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=session
- )
- except AgentDriveError as exc:
- return _handle(exc)
- return {"url": url}
-
-
-__all__ = [
- "AgentDriveDownloadApi",
- "AgentDriveDownloadByAgentApi",
- "AgentDriveListApi",
- "AgentDriveListByAgentApi",
- "AgentDrivePreviewApi",
- "AgentDrivePreviewByAgentApi",
- "AgentDriveSkillInspectApi",
- "AgentDriveSkillInspectByAgentApi",
- "AgentDriveSkillListApi",
- "AgentDriveSkillListByAgentApi",
-]
diff --git a/api/controllers/files/__init__.py b/api/controllers/files/__init__.py
index 5d26308e430..f8976b86b9f 100644
--- a/api/controllers/files/__init__.py
+++ b/api/controllers/files/__init__.py
@@ -14,12 +14,11 @@ api = ExternalApi(
files_ns = Namespace("files", description="File operations", path="/")
-from . import agent_drive_archive, image_preview, tool_files, upload
+from . import image_preview, tool_files, upload
api.add_namespace(files_ns)
__all__ = [
- "agent_drive_archive",
"api",
"bp",
"files_ns",
diff --git a/api/controllers/files/agent_drive_archive.py b/api/controllers/files/agent_drive_archive.py
deleted file mode 100644
index 8ecec2e9a4c..00000000000
--- a/api/controllers/files/agent_drive_archive.py
+++ /dev/null
@@ -1,69 +0,0 @@
-from urllib.parse import quote
-
-from flask import Response, request
-from flask_restx import Resource
-from pydantic import BaseModel, Field
-from werkzeug.exceptions import Forbidden, NotFound
-
-from controllers.common.file_response import enforce_download_for_html
-from controllers.common.schema import register_schema_models
-from controllers.files import files_ns
-from extensions.ext_database import db
-from models.agent import AgentDriveFileKind
-from services.agent_drive_service import AgentDriveError, AgentDriveService
-
-
-class AgentDriveArchiveMemberQuery(BaseModel):
- tenant_id: str = Field(..., description="Tenant ID")
- agent_id: str = Field(..., description="Agent ID")
- key: str = Field(..., description="Virtual drive key")
- archive_file_kind: AgentDriveFileKind = Field(..., description="Archive file kind")
- archive_file_id: str = Field(..., description="Archive file id")
- member_path: str = Field(..., description="Zip member path")
- timestamp: str = Field(..., description="Unix timestamp")
- nonce: str = Field(..., description="Random nonce")
- sign: str = Field(..., description="HMAC signature")
- as_attachment: bool = Field(default=False, description="Download as attachment")
-
-
-register_schema_models(files_ns, AgentDriveArchiveMemberQuery)
-
-
-@files_ns.route("/agent-drive/archive-member")
-class AgentDriveArchiveMemberApi(Resource):
- @files_ns.doc("get_agent_drive_archive_member")
- @files_ns.doc(description="Download a lazily resolved Agent Skill archive member by signed parameters")
- def get(self):
- args = AgentDriveArchiveMemberQuery.model_validate(request.args.to_dict(flat=True))
- if not AgentDriveService.verify_archive_member_signature(
- tenant_id=args.tenant_id,
- agent_id=args.agent_id,
- key=args.key,
- archive_file_kind=args.archive_file_kind,
- archive_file_id=args.archive_file_id,
- member_path=args.member_path,
- timestamp=args.timestamp,
- nonce=args.nonce,
- sign=args.sign,
- ):
- raise Forbidden("Invalid request.")
- try:
- payload, mime_type, filename = AgentDriveService().load_archive_member_for_signed_request(
- tenant_id=args.tenant_id,
- agent_id=args.agent_id,
- key=args.key,
- archive_file_kind=args.archive_file_kind,
- archive_file_id=args.archive_file_id,
- member_path=args.member_path,
- session=db.session(),
- )
- except AgentDriveError as exc:
- raise NotFound(exc.message) from exc
-
- response = Response(payload, mimetype=mime_type, direct_passthrough=True, headers={})
- response.headers["Content-Length"] = str(len(payload))
- if args.as_attachment and filename:
- encoded_filename = quote(filename)
- response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
- enforce_download_for_html(response, mime_type=mime_type, filename=filename, extension="")
- return response
diff --git a/api/controllers/inner_api/__init__.py b/api/controllers/inner_api/__init__.py
index 32dabf5bb01..5c82f3757e3 100644
--- a/api/controllers/inner_api/__init__.py
+++ b/api/controllers/inner_api/__init__.py
@@ -23,7 +23,6 @@ from .agent import tools as _agent_tools
from .app import dsl as _app_dsl
from .knowledge import retrieval as _knowledge_retrieval
from .plugin import agent_config as _agent_config
-from .plugin import agent_drive as _agent_drive
from .plugin import plugin as _plugin
from .workspace import workspace as _workspace
@@ -31,7 +30,6 @@ api.add_namespace(inner_api_ns)
__all__ = [
"_agent_config",
- "_agent_drive",
"_agent_files",
"_agent_llm",
"_agent_tools",
diff --git a/api/controllers/inner_api/plugin/agent_drive.py b/api/controllers/inner_api/plugin/agent_drive.py
deleted file mode 100644
index e06720a8e99..00000000000
--- a/api/controllers/inner_api/plugin/agent_drive.py
+++ /dev/null
@@ -1,105 +0,0 @@
-"""Inner API for the agent drive (agent 网盘) control plane.
-
-These endpoints are called by the dify-agent server (not the sandbox) with the
-inner API key. The drive ref is the URL segment ``agent-``; the
-path-like file key travels in the query/body, never as a URL path segment (so
-its ``/`` characters do not collide with routing). Drive-owned semantics:
-tenant scoped, no user-level FileAccessScope. Commit still canonicalizes the
-trusted execution-context user through the same EndUser lookup as plugin file
-upload before validating ToolFile ownership.
-"""
-
-from flask import request
-from flask_restx import Resource
-from pydantic import BaseModel, ValidationError
-
-from controllers.console.wraps import setup_required
-from controllers.inner_api import inner_api_ns
-from controllers.inner_api.plugin.wraps import get_user
-from controllers.inner_api.wraps import plugin_inner_api_only
-from extensions.ext_database import db
-from services.agent_drive_service import (
- AgentDriveError,
- AgentDriveService,
- DriveCommitItem,
- parse_agent_drive_ref,
-)
-
-
-class _CommitRequest(BaseModel):
- tenant_id: str
- user_id: str
- items: list[DriveCommitItem]
-
-
-def _error_response(exc: AgentDriveError) -> tuple[dict[str, str], int]:
- return {"code": exc.code, "message": exc.message}, exc.status_code
-
-
-@inner_api_ns.route("/drive//manifest")
-class AgentDriveManifestApi(Resource):
- @setup_required
- @plugin_inner_api_only
- @inner_api_ns.doc("agent_drive_manifest")
- @inner_api_ns.doc(description="List an agent drive (optionally with download URLs)")
- def get(self, drive_ref: str):
- try:
- agent_id = parse_agent_drive_ref(drive_ref)
- tenant_id = (request.args.get("tenant_id") or "").strip()
- if not tenant_id:
- raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
- include_download_url = (request.args.get("include_download_url") or "").lower() in ("1", "true", "yes")
- items = AgentDriveService().manifest(
- tenant_id=tenant_id,
- agent_id=agent_id,
- prefix=request.args.get("prefix", ""),
- include_download_url=include_download_url,
- session=db.session(),
- )
- except AgentDriveError as exc:
- return _error_response(exc)
- return {"items": items}
-
-
-@inner_api_ns.route("/drive//skills")
-class AgentDriveSkillsApi(Resource):
- @setup_required
- @plugin_inner_api_only
- @inner_api_ns.doc("agent_drive_skills")
- @inner_api_ns.doc(description="List the skill catalog of an agent drive")
- def get(self, drive_ref: str):
- try:
- agent_id = parse_agent_drive_ref(drive_ref)
- tenant_id = (request.args.get("tenant_id") or "").strip()
- if not tenant_id:
- raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
- items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=agent_id, session=db.session())
- except AgentDriveError as exc:
- return _error_response(exc)
- return {"items": items}
-
-
-@inner_api_ns.route("/drive//commit")
-class AgentDriveCommitApi(Resource):
- @setup_required
- @plugin_inner_api_only
- @inner_api_ns.doc("agent_drive_commit")
- @inner_api_ns.doc(description="Commit a batch of file refs into an agent drive")
- def post(self, drive_ref: str):
- try:
- agent_id = parse_agent_drive_ref(drive_ref)
- try:
- body = _CommitRequest.model_validate(request.get_json(silent=True) or {})
- except ValidationError as exc:
- raise AgentDriveError("invalid_request", str(exc), status_code=400) from exc
- user = get_user(body.tenant_id, body.user_id)
- items = AgentDriveService().commit(
- tenant_id=body.tenant_id,
- user_id=user.id,
- agent_id=agent_id,
- items=body.items,
- session=db.session(),
- )
- except AgentDriveError as exc:
- return _error_response(exc)
- return {"items": items}
diff --git a/api/migrations/versions/2026_08_17_1740-89919253ca7a_remove_agent_drive.py b/api/migrations/versions/2026_08_17_1740-89919253ca7a_remove_agent_drive.py
new file mode 100644
index 00000000000..5515c171d37
--- /dev/null
+++ b/api/migrations/versions/2026_08_17_1740-89919253ca7a_remove_agent_drive.py
@@ -0,0 +1,109 @@
+"""remove agent drive
+
+Revision ID: 89919253ca7a
+Revises: 56124e050600
+Create Date: 2026-08-17 17:40:52.081816
+
+"""
+
+import json
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import mysql
+
+from models.types import StringUUID
+
+# revision identifiers, used by Alembic.
+revision = "89919253ca7a"
+down_revision = "56124e050600"
+branch_labels = None
+depends_on = None
+
+
+def _rewrite_json_rows(table_name: str, column_name: str, transform) -> None:
+ # Offline SQL generation cannot run this read-modify-write cleanup.
+ if op.get_context().as_sql:
+ return
+
+ connection = op.get_bind()
+ rows = connection.execute(sa.text(f"SELECT id, {column_name} FROM {table_name}"))
+ for row_id, raw_value in rows:
+ if raw_value is None:
+ continue
+ value = json.loads(raw_value)
+ if not transform(value):
+ continue
+ connection.execute(
+ sa.text(f"UPDATE {table_name} SET {column_name} = :value WHERE id = :id"),
+ {"id": row_id, "value": json.dumps(value, ensure_ascii=False, separators=(",", ":"))},
+ )
+
+
+def _remove_soul_files(value: object) -> bool:
+ if not isinstance(value, dict) or "files" not in value:
+ return False
+ del value["files"]
+ return True
+
+
+def _remove_node_job_drive_keys(value: object) -> bool:
+ if not isinstance(value, dict):
+ return False
+ changed = False
+ metadata = value.get("metadata")
+ if isinstance(metadata, dict):
+ file_refs = metadata.get("file_refs")
+ if isinstance(file_refs, list):
+ for file_ref in file_refs:
+ if isinstance(file_ref, dict) and "drive_key" in file_ref:
+ del file_ref["drive_key"]
+ changed = True
+ declared_outputs = value.get("declared_outputs")
+ if isinstance(declared_outputs, list):
+ for output in declared_outputs:
+ if not isinstance(output, dict):
+ continue
+ check = output.get("check")
+ if not isinstance(check, dict):
+ continue
+ benchmark_file_ref = check.get("benchmark_file_ref")
+ if isinstance(benchmark_file_ref, dict) and "drive_key" in benchmark_file_ref:
+ del benchmark_file_ref["drive_key"]
+ changed = True
+ return changed
+
+
+def upgrade() -> None:
+ _rewrite_json_rows("agent_config_snapshots", "config_snapshot", _remove_soul_files)
+ _rewrite_json_rows("agent_config_drafts", "config_snapshot", _remove_soul_files)
+ _rewrite_json_rows("workflow_agent_node_bindings", "node_job_config", _remove_node_job_drive_keys)
+ op.drop_table("agent_drive_files")
+
+
+def downgrade() -> None:
+ op.create_table(
+ "agent_drive_files",
+ sa.Column("tenant_id", StringUUID(), nullable=False),
+ sa.Column("agent_id", StringUUID(), nullable=False),
+ sa.Column("key", sa.String(length=512), nullable=False),
+ sa.Column("file_kind", sa.String(length=32), nullable=False),
+ sa.Column("file_id", StringUUID(), nullable=False),
+ sa.Column("value_owned_by_drive", sa.Boolean(), server_default=sa.text("false"), nullable=False),
+ sa.Column("is_skill", sa.Boolean(), server_default=sa.text("false"), nullable=False),
+ sa.Column("skill_metadata", sa.Text().with_variant(mysql.LONGTEXT(), "mysql"), nullable=True),
+ sa.Column("size", sa.BigInteger(), nullable=True),
+ sa.Column("hash", sa.String(length=255), nullable=True),
+ sa.Column("mime_type", sa.String(length=255), nullable=True),
+ sa.Column("created_by", StringUUID(), nullable=True),
+ sa.Column("id", StringUUID(), nullable=False),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+ sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+ sa.PrimaryKeyConstraint("id", name="agent_drive_file_pkey"),
+ sa.UniqueConstraint("tenant_id", "agent_id", "key", name="agent_drive_file_scope_key_unique"),
+ )
+ op.create_index(
+ "agent_drive_files_tenant_agent_is_skill_key_idx",
+ "agent_drive_files",
+ ["tenant_id", "agent_id", "is_skill", "key"],
+ )
diff --git a/api/models/__init__.py b/api/models/__init__.py
index b0c3058f0b7..49ee03c406f 100644
--- a/api/models/__init__.py
+++ b/api/models/__init__.py
@@ -17,8 +17,6 @@ from .agent import (
AgentConfigSnapshot,
AgentConfigVersionKind,
AgentDebugConversation,
- AgentDriveFile,
- AgentDriveFileKind,
AgentHomeSnapshot,
AgentIconType,
AgentKind,
@@ -168,8 +166,6 @@ __all__ = [
"AgentConfigSnapshot",
"AgentConfigVersionKind",
"AgentDebugConversation",
- "AgentDriveFile",
- "AgentDriveFileKind",
"AgentHomeSnapshot",
"AgentIconType",
"AgentKind",
diff --git a/api/models/agent.py b/api/models/agent.py
index a5863dd1259..d981194c714 100644
--- a/api/models/agent.py
+++ b/api/models/agent.py
@@ -536,55 +536,3 @@ class AgentWorkspaceBinding(DefaultFieldsMixin, Base):
retired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
pending_form_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
pending_tool_call_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
-
-
-class AgentDriveFileKind(StrEnum):
- """Kind of existing file record an agent-drive KV entry points at."""
-
- UPLOAD_FILE = "upload_file"
- TOOL_FILE = "tool_file"
-
-
-class AgentDriveFile(DefaultFieldsMixin, Base):
- """Per-agent path-like KV index into existing file records (agent 网盘 / agent drive).
-
- A row maps a path-like ``key`` to a *pointer* (``file_kind`` + ``file_id``) at an
- existing ``UploadFile`` / ``ToolFile`` — it never stores file bytes. Scope/ownership
- is ``tenant_id -> agent-`` (the drive ref; no standalone ``drive_id`` this
- phase). ``key`` is opaque/path-like and carries no directory, permission, or
- parent-child semantics on the API side; it maps 1:1 to a sandbox-relative path when
- synced. ``value_owned_by_drive`` gates physical cleanup: only drive-owned values
- (created by the agent runtime or Skill standardization, not shared with other
- business records) have their storage object + record deleted when the KV entry is
- overwritten or removed; otherwise only the KV row is dropped. Skills are represented
- by the canonical ``/SKILL.md`` row with ``is_skill=True`` and a serialized
- ``skill_metadata`` string. Lifecycle never relies on ``UploadFile.used/used_by``
- (not a reliable refcount).
- """
-
- __tablename__ = "agent_drive_files"
- __table_args__ = (
- sa.PrimaryKeyConstraint("id", name="agent_drive_file_pkey"),
- UniqueConstraint("tenant_id", "agent_id", "key", name="agent_drive_file_scope_key_unique"),
- Index("agent_drive_files_tenant_agent_is_skill_key_idx", "tenant_id", "agent_id", "is_skill", "key"),
- )
-
- tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
- # drive ref = agent-; this phase has no standalone drive_id.
- agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
- # path-like opaque key; not a filesystem (no dir/permission/parent semantics).
- # Bounded at 512 so the (tenant_id, agent_id, key) unique index stays within
- # MySQL's 3072-byte index limit (CHAR(36)*2 + VARCHAR(512) utf8mb4 = 2336).
- key: Mapped[str] = mapped_column(String(512), nullable=False)
- file_kind: Mapped[AgentDriveFileKind] = mapped_column(EnumText(AgentDriveFileKind, length=32), nullable=False)
- # points at UploadFile.id / ToolFile.id (the value), never the bytes.
- file_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
- value_owned_by_drive: Mapped[bool] = mapped_column(
- sa.Boolean, nullable=False, default=False, server_default=sa.text("false")
- )
- is_skill: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=False, server_default=sa.text("false"))
- skill_metadata: Mapped[str | None] = mapped_column(LongText, nullable=True)
- size: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True)
- hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
- mime_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
- created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py
index fc845144e68..a81e51d62a5 100644
--- a/api/models/agent_config_entities.py
+++ b/api/models/agent_config_entities.py
@@ -150,33 +150,6 @@ class AgentFileRefConfig(AgentFlexibleConfig):
transfer_method: str | None = Field(default=None, max_length=64)
url: str | None = None
remote_url: str | None = None
- # Drive key once the file is committed to the agent drive ("files/",
- # ENG-625). Files without it are plain upload references and stay invisible
- # to the runtime drive manifest.
- drive_key: str | None = Field(default=None, max_length=512)
-
-
-class AgentSkillRefConfig(AgentFlexibleConfig):
- id: str | None = Field(default=None, max_length=255)
- name: str | None = Field(default=None, max_length=255)
- description: str | None = None
- file_id: str | None = Field(default=None, max_length=255)
- path: str | None = None
- # Standardization outputs (ENG-594) — previously riding along via
- # ``extra="allow"``, promoted to the explicit schema because the runtime
- # drive manifest (ENG-623) keys off them.
- skill_md_key: str | None = Field(default=None, max_length=512)
- skill_md_file_id: str | None = Field(default=None, max_length=255)
- full_archive_key: str | None = Field(default=None, max_length=512)
- full_archive_file_id: str | None = Field(default=None, max_length=255)
- # Zip member path listing from standardization (ENG-371): lets infer-tools
- # show the model strong signals like ``scripts/*.sh`` without unpacking.
- manifest_files: list[str] | None = None
-
-
-class AgentSoulFilesConfig(BaseModel):
- skills: list[AgentSkillRefConfig] = Field(default_factory=list)
- files: list[AgentFileRefConfig] = Field(default_factory=list)
def validate_config_name(name: str) -> str:
@@ -820,7 +793,6 @@ class AgentSoulConfig(BaseModel):
config_skills: list[AgentConfigSkillRefConfig] = Field(default_factory=list)
config_files: list[AgentConfigFileRefConfig] = Field(default_factory=list)
config_note: str = ""
- files: AgentSoulFilesConfig = Field(default_factory=AgentSoulFilesConfig)
sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig)
memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig)
model: AgentSoulModelConfig | None = None
diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md
index 92db2caf77c..29559ee641a 100644
--- a/api/openapi/markdown/console-openapi.md
+++ b/api/openapi/markdown/console-openapi.md
@@ -972,85 +972,6 @@ Stop a running Agent App chat message generation
| 200 | Agent debug conversation refreshed | **application/json**: [AgentDebugConversationRefreshResponse](#agentdebugconversationrefreshresponse)
|
| 403 | Insufficient permissions | |
-### [GET] /agent/{agent_id}/drive/files
-List agent drive entries for an Agent App
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| agent_id | path | Agent ID | Yes | string (uuid) |
-| prefix | query | Key prefix filter: '/' for one skill, 'files/' for files | No | string |
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 200 | Drive entries | **application/json**: [AgentDriveListResponse](#agentdrivelistresponse)
|
-
-### [GET] /agent/{agent_id}/drive/files/download
-Time-limited external signed URL for one Agent App drive value
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| agent_id | path | Agent ID | Yes | string (uuid) |
-| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string |
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 200 | Signed URL | **application/json**: [AgentDriveDownloadResponse](#agentdrivedownloadresponse)
|
-
-### [GET] /agent/{agent_id}/drive/files/preview
-Truncated text preview of one Agent App drive value
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| agent_id | path | Agent ID | Yes | string (uuid) |
-| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string |
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 200 | Preview | **application/json**: [AgentDrivePreviewResponse](#agentdrivepreviewresponse)
|
-
-### [GET] /agent/{agent_id}/drive/skills
-List drive-backed skills for an Agent App
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| agent_id | path | Agent ID | Yes | string (uuid) |
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 200 | Drive skills | **application/json**: [AgentDriveSkillListResponse](#agentdriveskilllistresponse)
|
-
-### [GET] /agent/{agent_id}/drive/skills/{skill_path}/inspect
-Inspect one drive-backed skill for slash-menu hover/detail UI
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| agent_id | path | Agent ID | Yes | string (uuid) |
-| skill_path | path | Skill path/slug, e.g. tender-analyzer | Yes | string |
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 200 | Drive skill inspect view | **application/json**: [AgentDriveSkillInspectResponse](#agentdriveskillinspectresponse)
|
-
### [POST] /agent/{agent_id}/features
Update an Agent App's presentation features (opener, follow-up, citations, ...)
@@ -1096,43 +1017,6 @@ Create or update Agent App message feedback
| 200 | Feedback updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
|
| 404 | Agent or message not found | |
-### [DELETE] /agent/{agent_id}/files
-Delete one Agent App drive file by key
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| agent_id | path | Agent ID | Yes | string (uuid) |
-| key | query | Drive key, e.g. files/sample.pdf | Yes | string |
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 200 | File removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)
|
-
-### [POST] /agent/{agent_id}/files
-Commit an uploaded file into the Agent App drive under files/
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| agent_id | path | Agent ID | Yes | string (uuid) |
-
-#### Request Body
-
-| Required | Schema |
-| -------- | ------ |
-| Yes | **application/json**: [AgentDriveFilePayload](#agentdrivefilepayload)
|
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)
|
-
### [GET] /agent/{agent_id}/log-sources
#### Parameters
@@ -1322,60 +1206,6 @@ Read a text/binary preview file in an Agent App conversation sandbox
| ---- | ----------- | ------ |
| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)
|
-### [POST] /agent/{agent_id}/skills/upload
-Upload + standardize a Skill into an Agent App drive
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| agent_id | path | Agent ID | Yes | string (uuid) |
-
-#### Request Body
-
-| Required | Schema |
-| -------- | ------ |
-| Yes | **multipart/form-data**: { **"file"**: binary }
|
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 201 | Skill uploaded into drive | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)
|
-| 400 | Invalid skill package or no bound agent | |
-
-### [DELETE] /agent/{agent_id}/skills/{slug}
-Delete a standardized skill from an Agent App drive
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| agent_id | path | Agent ID | Yes | string (uuid) |
-| slug | path | Skill slug (single path segment) | Yes | string |
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 200 | Skill removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)
|
-
-### [POST] /agent/{agent_id}/skills/{slug}/infer-tools
-Infer CLI tool + ENV suggestions from a standardized Agent App skill
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| agent_id | path | Agent ID | Yes | string (uuid) |
-| slug | path | Skill slug (single path segment) | Yes | string |
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)
|
-
### [GET] /agent/{agent_id}/statistics/summary
#### Parameters
@@ -2192,132 +2022,6 @@ Run draft workflow for advanced chat application
| ---- | ----------- | ------ |
| 200 | Config skill inspect view | **application/json**: [AgentConfigSkillInspectResponse](#agentconfigskillinspectresponse)
|
-### [GET] /apps/{app_id}/agent/drive/files
-List agent drive entries (read-only inspector; one endpoint for both tabs)
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| app_id | path | Application ID | Yes | string (uuid) |
-| node_id | query | Workflow node ID (workflow composer variant) | No | string |
-| prefix | query | Key prefix filter: '/' for one skill, 'files/' for files | No | string |
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 200 | Drive entries | **application/json**: [AgentDriveListResponse](#agentdrivelistresponse)
|
-
-### [GET] /apps/{app_id}/agent/drive/files/download
-Time-limited external signed URL for one drive value (no streaming proxy)
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| app_id | path | Application ID | Yes | string (uuid) |
-| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string |
-| node_id | query | Workflow node ID (workflow composer variant) | No | string |
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 200 | Signed URL | **application/json**: [AgentDriveDownloadResponse](#agentdrivedownloadresponse)
|
-
-### [GET] /apps/{app_id}/agent/drive/files/preview
-Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| app_id | path | Application ID | Yes | string (uuid) |
-| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string |
-| node_id | query | Workflow node ID (workflow composer variant) | No | string |
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 200 | Preview | **application/json**: [AgentDrivePreviewResponse](#agentdrivepreviewresponse)
|
-
-### [GET] /apps/{app_id}/agent/drive/skills
-List drive-backed skills for the bound agent
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| app_id | path | Application ID | Yes | string (uuid) |
-| node_id | query | Workflow node ID (workflow composer variant) | No | string |
-| prefix | query | Key prefix filter: '/' for one skill, 'files/' for files | No | string |
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 200 | Drive skills | **application/json**: [AgentDriveSkillListResponse](#agentdriveskilllistresponse)
|
-
-### [GET] /apps/{app_id}/agent/drive/skills/{skill_path}/inspect
-Inspect one drive-backed skill for slash-menu hover/detail UI
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| app_id | path | Application ID | Yes | string (uuid) |
-| skill_path | path | Skill path/slug, e.g. tender-analyzer | Yes | string |
-| node_id | query | Workflow node ID (workflow composer variant) | No | string |
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 200 | Drive skill inspect view | **application/json**: [AgentDriveSkillInspectResponse](#agentdriveskillinspectresponse)
|
-
-### [DELETE] /apps/{app_id}/agent/files
-Delete one drive file by key via drive commit-null semantics
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| app_id | path | Application ID | Yes | string (uuid) |
-| key | query | Drive key, e.g. files/sample.pdf | Yes | string |
-| node_id | query | Workflow node ID (workflow composer variant) | No | string |
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 200 | File removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)
|
-
-### [POST] /apps/{app_id}/agent/files
-**ADD FILE: commit one uploaded file into the bound agent's drive**
-
-Commit an uploaded file into the agent drive under files/ (ENG-625 D3)
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| app_id | path | Application ID | Yes | string (uuid) |
-| node_id | query | Workflow node ID (workflow composer variant) | No | string |
-
-#### Request Body
-
-| Required | Schema |
-| -------- | ------ |
-| Yes | **application/json**: [AgentDriveFilePayload](#agentdrivefilepayload)
|
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)
|
-
### [GET] /apps/{app_id}/agent/logs
**Get agent logs**
@@ -2338,68 +2042,6 @@ Get agent execution logs for an application
| 200 | Agent logs retrieved successfully | **application/json**: [AgentLogResponse](#agentlogresponse)
|
| 400 | Invalid request parameters | |
-### [POST] /apps/{app_id}/agent/skills/upload
-**Upload a Skill, validate it, and commit drive-backed skill files**
-
-Upload + standardize a Skill into the agent drive
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| app_id | path | Application ID | Yes | string (uuid) |
-| node_id | query | Workflow node ID (workflow composer variant) | No | string |
-
-#### Request Body
-
-| Required | Schema |
-| -------- | ------ |
-| Yes | **multipart/form-data**: { **"file"**: binary }
|
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 201 | Skill uploaded into drive | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)
|
-| 400 | Invalid skill package or no bound agent | |
-
-### [DELETE] /apps/{app_id}/agent/skills/{slug}
-Delete a standardized skill by removing its known drive keys via commit-null
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| app_id | path | Application ID | Yes | string (uuid) |
-| slug | path | Skill slug (single path segment) | Yes | string |
-| node_id | query | Workflow node ID (workflow composer variant) | No | string |
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 200 | Skill removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)
|
-
-### [POST] /apps/{app_id}/agent/skills/{slug}/infer-tools
-**Suggest CLI tools/env for a skill**
-
-Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)
-Saving still goes through composer validation.
-
-#### Parameters
-
-| Name | Located in | Description | Required | Schema |
-| ---- | ---------- | ----------- | -------- | ------ |
-| app_id | path | Application ID | Yes | string (uuid) |
-| slug | path | Skill slug (single path segment) | Yes | string |
-| node_id | query | Workflow node ID (workflow composer variant) | No | string |
-
-#### Responses
-
-| Code | Description | Schema |
-| ---- | ----------- | ------ |
-| 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)
|
-
### [POST] /apps/{app_id}/annotation-reply/{action}
Enable or disable annotation reply for an app
@@ -13990,135 +13632,6 @@ Stable Agent Soul reference to one normalized skill archive.
| debug_conversation_id | string | | Yes |
| debug_conversation_message_count | integer | | No |
-#### AgentDriveDeleteFileByAgentQuery
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| key | string | Drive key, e.g. files/sample.pdf | Yes |
-
-#### AgentDriveDeleteResponse
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| removed_keys | [ string ] | | No |
-| result | string | | Yes |
-
-#### AgentDriveDownloadResponse
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| url | string | | Yes |
-
-#### AgentDriveFileCommitResponse
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| file | [AgentDriveFileResponse](#agentdrivefileresponse) | | Yes |
-
-#### AgentDriveFilePayload
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| upload_file_id | string | UploadFile UUID from POST /console/api/files/upload | Yes |
-
-#### AgentDriveFileResponse
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| drive_key | string | | Yes |
-| file_id | string | | Yes |
-| mime_type | string | | No |
-| name | string | | Yes |
-| size | integer | | No |
-
-#### AgentDriveItemResponse
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| created_at | integer | | No |
-| file_kind | string | | Yes |
-| hash | string | | No |
-| is_skill | boolean | | No |
-| key | string | | Yes |
-| mime_type | string | | No |
-| size | integer | | No |
-| skill_metadata | string | | No |
-
-#### AgentDriveListResponse
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| items | [ [AgentDriveItemResponse](#agentdriveitemresponse) ] | | No |
-
-#### AgentDrivePreviewResponse
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| binary | boolean | | Yes |
-| key | string | | Yes |
-| size | integer | | No |
-| text | string | | No |
-| truncated | boolean | | Yes |
-
-#### AgentDriveSkillFileResponse
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| available_in_drive | boolean | | Yes |
-| drive_key | string | | No |
-| name | string | | Yes |
-| path | string | | Yes |
-| type | string | | Yes |
-
-#### AgentDriveSkillInspectResponse
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| archive_key | string | | No |
-| created_at | integer | | No |
-| description | string | | Yes |
-| file_tree | [ object ] | | No |
-| files | [ [AgentDriveSkillFileResponse](#agentdriveskillfileresponse) ] | | No |
-| hash | string | | No |
-| mime_type | string | | No |
-| name | string | | Yes |
-| path | string | | Yes |
-| size | integer | | No |
-| skill_md | [AgentDriveSkillMarkdownResponse](#agentdriveskillmarkdownresponse) | | Yes |
-| skill_md_key | string | | Yes |
-| source | string | | Yes |
-| warnings | [ string ] | | No |
-
-#### AgentDriveSkillItemResponse
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| archive_key | string | | No |
-| created_at | integer | | No |
-| description | string | | Yes |
-| hash | string | | No |
-| mime_type | string | | No |
-| name | string | | Yes |
-| path | string | | Yes |
-| size | integer | | No |
-| skill_md_key | string | | Yes |
-
-#### AgentDriveSkillListResponse
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| items | [ [AgentDriveSkillItemResponse](#agentdriveskillitemresponse) ] | | No |
-
-#### AgentDriveSkillMarkdownResponse
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| binary | boolean | | Yes |
-| key | string | | Yes |
-| size | integer | | No |
-| text | string | | No |
-| truncated | boolean | | Yes |
-
#### AgentEnvVariableConfig
| Name | Type | Description | Required |
@@ -14142,7 +13655,6 @@ Stable Agent Soul reference to one normalized skill archive.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
-| drive_key | string | | No |
| file_id | string | | No |
| id | string | | No |
| name | string | | No |
@@ -14499,13 +14011,6 @@ section may be empty, which is how callers express "no knowledge layer".
| status | string | | Yes |
| total_tokens | integer | | Yes |
-#### AgentLogQuery
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| conversation_id | string | Conversation UUID | Yes |
-| message_id | string | Message UUID | Yes |
-
#### AgentLogResponse
| Name | Type | Description | Required |
@@ -14763,28 +14268,6 @@ Visibility and lifecycle scope of an Agent record.
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
-#### AgentSkillRefConfig
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| description | string | | No |
-| file_id | string | | No |
-| full_archive_file_id | string | | No |
-| full_archive_key | string | | No |
-| id | string | | No |
-| manifest_files | [ string ] | | No |
-| name | string | | No |
-| path | string | | No |
-| skill_md_file_id | string | | No |
-| skill_md_key | string | | No |
-
-#### AgentSkillUploadResponse
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| manifest | [SkillManifest](#skillmanifest) | | Yes |
-| skill | [AgentUploadedSkillResponse](#agentuploadedskillresponse) | | Yes |
-
#### AgentSoulAppFeaturesConfig
| Name | Type | Description | Required |
@@ -14808,7 +14291,6 @@ Visibility and lifecycle scope of an Agent record.
| config_note | string | | No |
| config_skills | [ [AgentConfigSkillRefConfig](#agentconfigskillrefconfig) ] | | No |
| env | [AgentSoulEnvConfig](#agentsoulenvconfig) | | No |
-| files | [AgentSoulFilesConfig](#agentsoulfilesconfig) | | No |
| human | [AgentSoulHumanConfig](#agentsoulhumanconfig) | | No |
| knowledge | [AgentSoulKnowledgeConfig](#agentsoulknowledgeconfig) | | No |
| memory | [AgentSoulMemoryConfig](#agentsoulmemoryconfig) | | No |
@@ -14866,13 +14348,6 @@ old Agent tool payloads can be read while new payloads stay explicit.
| secret_refs | [ [AgentSecretRefConfig](#agentsecretrefconfig) ] | | No |
| variables | [ [AgentEnvVariableConfig](#agentenvvariableconfig) ] | | No |
-#### AgentSoulFilesConfig
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| files | [ [AgentFileRefConfig](#agentfilerefconfig) ] | | No |
-| skills | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No |
-
#### AgentSoulHumanConfig
| Name | Type | Description | Required |
@@ -15119,16 +14594,6 @@ Legacy Chat App model config used only for follow-up question generation.
| tool_output | object | | Yes |
| tool_parameters | object | | Yes |
-#### AgentUploadedSkillResponse
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| archive_key | string | | No |
-| description | string | | Yes |
-| name | string | | Yes |
-| path | string | | Yes |
-| skill_md_key | string | | Yes |
-
#### AgentUserSatisfactionRateStatisticResponse
| Name | Type | Description | Required |
@@ -16158,17 +15623,6 @@ Button styles for user actions.
| ---- | ---- | ----------- | -------- |
| content | string | Child chunk text content. | Yes |
-#### CliToolSuggestion
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| command | string | | No |
-| description | string | | No |
-| env_suggestions | [ [EnvSuggestion](#envsuggestion) ] | | No |
-| inferred_from | string | | No |
-| install_commands | [ string ] | | No |
-| name | string | | Yes |
-
#### CloudPlan
Enum representing user plan types in the cloud platform.
@@ -17961,14 +17415,6 @@ declaration of an endpoint group
| name | string | | Yes |
| settings | object | | Yes |
-#### EnvSuggestion
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| key | string | | Yes |
-| reason | string | | No |
-| secret_likely | boolean | | No |
-
#### EnvironmentVariableItemPayload
| Name | Type | Description | Required |
@@ -21871,27 +21317,6 @@ Simple provider entity response.
| title | string | | Yes |
| use_icon_as_answer_icon | boolean | | Yes |
-#### SkillManifest
-
-Validated metadata extracted from a Skill package.
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| description | string | | Yes |
-| entry_path | string | | Yes |
-| files | [ string ] | | Yes |
-| hash | string | | Yes |
-| name | string | | Yes |
-| size | integer | | Yes |
-
-#### SkillToolInferenceResult
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| cli_tools | [ [CliToolSuggestion](#clitoolsuggestion) ] | | No |
-| inferable | boolean | | Yes |
-| reason | string | | No |
-
#### SnippetDependencyCheckResponse
| Name | Type | Description | Required |
diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py
index d29d8ed6b41..064a6dc8e56 100644
--- a/api/services/agent/composer_service.py
+++ b/api/services/agent/composer_service.py
@@ -5,7 +5,6 @@ from typing import Any
from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
-from sqlalchemy.sql.elements import ColumnElement
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
from libs.helper import to_timestamp
@@ -20,7 +19,6 @@ from models.agent import (
AgentConfigSnapshot,
AgentConfigVersionKind,
AgentDebugConversation,
- AgentDriveFile,
AgentIconType,
AgentKind,
AgentScope,
@@ -279,12 +277,7 @@ class AgentComposerService:
state = cls._serialize_workflow_state(
session=session, binding=binding, agent=agent, version=version, account_id=account_id
)
- state["validation"] = cls.collect_validation_findings(
- session=session,
- tenant_id=tenant_id,
- payload=payload,
- agent_id=binding.agent_id,
- )
+ state["validation"] = cls.collect_validation_findings(payload=payload)
session.commit()
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
tenant_id=tenant_id,
@@ -365,16 +358,6 @@ class AgentComposerService:
icon=source_agent.icon,
icon_background=source_agent.icon_background,
)
- cls._copy_agent_drive_rows(
- session=session,
- tenant_id=tenant_id,
- source_agent_id=source_agent.id,
- target_agent_id=inline_agent.id,
- account_id=account_id,
- agent_soul=agent_soul,
- node_job=WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict),
- )
-
binding.binding_type = WorkflowAgentBindingType.INLINE_AGENT
binding.agent_id = inline_agent.id
binding.current_snapshot_id = inline_agent.active_config_snapshot_id
@@ -581,12 +564,7 @@ class AgentComposerService:
session.flush()
state = cls.load_agent_composer(session=session, tenant_id=tenant_id, agent_id=agent.id)
- state["validation"] = cls.collect_validation_findings(
- session=session,
- tenant_id=tenant_id,
- payload=payload,
- agent_id=agent.id,
- )
+ state["validation"] = cls.collect_validation_findings(payload=payload)
return state
@classmethod
@@ -1051,12 +1029,9 @@ class AgentComposerService:
def collect_validation_findings(
cls,
*,
- session: Session,
- tenant_id: str,
payload: ComposerSavePayload,
- agent_id: str | None = None,
) -> dict[str, Any]:
- """ENG-617 soft findings, with DB-backed dataset and drive mention checks."""
+ """Collect non-blocking composer validation findings."""
existing_knowledge_set_ids = (
{knowledge_set.id for knowledge_set in payload.agent_soul.knowledge.sets}
if payload.agent_soul is not None
@@ -1066,15 +1041,6 @@ class AgentComposerService:
payload,
existing_knowledge_set_ids=existing_knowledge_set_ids,
)
- if agent_id and payload.agent_soul is not None:
- findings["warnings"].extend(
- cls._drive_mention_findings(
- session=session,
- tenant_id=tenant_id,
- agent_id=agent_id,
- prompt=payload.agent_soul.prompt.system_prompt,
- )
- )
return findings
@classmethod
@@ -1099,21 +1065,6 @@ class AgentComposerService:
+ ", ".join(missing_ids)
)
- @classmethod
- def resolve_bound_agent_id(cls, *, session: Session, tenant_id: str, app_id: str) -> str | None:
- """The Agent App's bound roster agent id, if any (validate-endpoint context)."""
- return session.scalar(
- select(Agent.id)
- .where(
- Agent.tenant_id == tenant_id,
- Agent.app_id == app_id,
- Agent.scope == AgentScope.ROSTER,
- Agent.status == AgentStatus.ACTIVE,
- )
- .order_by(Agent.created_at.desc())
- .limit(1)
- )
-
@classmethod
def resolve_workflow_node_agent_id(
cls, *, session: Session, tenant_id: str, app_id: str, node_id: str
@@ -1128,54 +1079,6 @@ class AgentComposerService:
)
return binding.agent_id if binding else None
- @classmethod
- def _drive_mention_findings(
- cls,
- *,
- session: Session,
- tenant_id: str,
- agent_id: str,
- prompt: str,
- ) -> list[dict[str, str | None]]:
- """Soft warnings for missing drive-backed prompt mentions."""
- from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
- from services.agent_drive_service import decode_drive_mention_ref
-
- wanted_keys: dict[str, tuple[str, str]] = {}
- for mention in parse_prompt_mentions(prompt):
- if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}:
- continue
- decoded_key = decode_drive_mention_ref(mention.ref_id)
- if not decoded_key:
- continue
- wanted_keys[decoded_key] = (mention.kind.value, mention.label or decoded_key)
- if not wanted_keys:
- return []
-
- existing_keys = set(
- session.scalars(
- select(AgentDriveFile.key).where(
- AgentDriveFile.tenant_id == tenant_id,
- AgentDriveFile.agent_id == agent_id,
- AgentDriveFile.key.in_(sorted(wanted_keys)),
- )
- )
- )
- findings: list[dict[str, str | None]] = []
- for key, (kind, display) in wanted_keys.items():
- if key in existing_keys:
- continue
- findings.append(
- {
- "code": "mention_target_missing",
- "surface": "agent_soul",
- "kind": kind,
- "id": key,
- "message": f"{kind} '{display}' has no drive entry for key '{key}'.",
- }
- )
- return findings
-
@classmethod
def get_workflow_candidates(
cls, *, session: Session, tenant_id: str, app_id: str, node_id: str, user_id: str
@@ -1721,15 +1624,6 @@ class AgentComposerService:
operation=AgentConfigRevisionOperation.SAVE_TO_ROSTER,
version_note=payload.version_note,
)
- cls._copy_agent_drive_rows(
- session=session,
- tenant_id=tenant_id,
- source_agent_id=source_agent.id,
- target_agent_id=roster_agent.id,
- account_id=account_id,
- agent_soul=agent_soul,
- node_job=payload.node_job or WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict),
- )
binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT
binding.agent_id = roster_agent.id
binding.current_snapshot_id = roster_agent.active_config_snapshot_id
@@ -1801,99 +1695,6 @@ class AgentComposerService:
agent.active_config_is_published = True
return agent
- @classmethod
- def _copy_agent_drive_rows(
- cls,
- *,
- session: Session,
- tenant_id: str,
- source_agent_id: str,
- target_agent_id: str,
- account_id: str,
- agent_soul: AgentSoulConfig,
- node_job: WorkflowNodeJobConfig | None = None,
- ) -> None:
- exact_keys, prefixes = cls._drive_copy_scopes_from_agent_configs(agent_soul=agent_soul, node_job=node_job)
- predicates: list[ColumnElement[bool]] = []
- if exact_keys:
- predicates.append(AgentDriveFile.key.in_(sorted(exact_keys)))
- predicates.extend(AgentDriveFile.key.startswith(prefix) for prefix in sorted(prefixes))
- if not predicates:
- return
-
- source_rows = list(
- session.scalars(
- select(AgentDriveFile).where(
- AgentDriveFile.tenant_id == tenant_id,
- AgentDriveFile.agent_id == source_agent_id,
- or_(*predicates),
- )
- ).all()
- )
- if not source_rows:
- return
-
- existing_target_keys = set(
- session.scalars(
- select(AgentDriveFile.key).where(
- AgentDriveFile.tenant_id == tenant_id,
- AgentDriveFile.agent_id == target_agent_id,
- AgentDriveFile.key.in_([row.key for row in source_rows]),
- )
- ).all()
- )
- for row in source_rows:
- if row.key in existing_target_keys:
- continue
- session.add(
- AgentDriveFile(
- tenant_id=tenant_id,
- agent_id=target_agent_id,
- key=row.key,
- file_kind=row.file_kind,
- file_id=row.file_id,
- value_owned_by_drive=row.value_owned_by_drive,
- is_skill=row.is_skill,
- skill_metadata=row.skill_metadata,
- size=row.size,
- hash=row.hash,
- mime_type=row.mime_type,
- created_by=account_id,
- )
- )
-
- @staticmethod
- def _drive_copy_scopes_from_agent_configs(
- *, agent_soul: AgentSoulConfig, node_job: WorkflowNodeJobConfig | None = None
- ) -> tuple[set[str], set[str]]:
- from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
- from services.agent_drive_service import decode_drive_mention_ref
-
- exact_keys: set[str] = set()
- prefixes: set[str] = set()
-
- for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt):
- if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}:
- continue
- drive_key = decode_drive_mention_ref(mention.ref_id)
- if not drive_key:
- continue
- if mention.kind == MentionKind.SKILL and "/" in drive_key:
- prefixes.add(f"{drive_key.rsplit('/', 1)[0]}/")
- else:
- exact_keys.add(drive_key)
-
- if node_job is not None:
- for file_ref in node_job.metadata.file_refs or []:
- if file_ref.drive_key:
- exact_keys.add(file_ref.drive_key)
- for output in node_job.declared_outputs:
- benchmark_ref = output.check.benchmark_file_ref if output.check and output.check.enabled else None
- if benchmark_ref and benchmark_ref.drive_key:
- exact_keys.add(benchmark_ref.drive_key)
-
- return exact_keys, prefixes
-
@classmethod
def _create_roster_agent_for_composer(
cls,
diff --git a/api/services/agent/config_skill_normalize_service.py b/api/services/agent/config_skill_normalize_service.py
index 8c06dc2bcc8..7093b49ce1c 100644
--- a/api/services/agent/config_skill_normalize_service.py
+++ b/api/services/agent/config_skill_normalize_service.py
@@ -1,9 +1,8 @@
"""Normalize uploaded config skills into one canonical ToolFile reference.
-Config skills are Agent Soul-backed assets, not drive rows. This service keeps
-the existing skill package validation rules, enforces the requested stable name,
-stores the normalized archive as one ToolFile, and returns the persisted Soul
-reference metadata used by ``AgentConfigService``.
+This service keeps the existing skill package validation rules, enforces the
+requested stable name, stores the normalized archive as one ToolFile, and
+returns the persisted Soul reference metadata used by ``AgentConfigService``.
"""
from __future__ import annotations
diff --git a/api/services/agent/dsl_service.py b/api/services/agent/dsl_service.py
index 1b7a09cf493..87ce85ddd84 100644
--- a/api/services/agent/dsl_service.py
+++ b/api/services/agent/dsl_service.py
@@ -3,8 +3,7 @@
Agent runtime configuration is split across immutable Soul snapshots and
workflow-node bindings, while App and Snippet DSLs must be independent of the
source workspace's database identifiers. This module owns that translation.
-It deliberately excludes drive payloads and stored credentials from portable
-packages; same-workspace copies may use the separate server-side clone path.
+It deliberately excludes stored credentials from portable packages.
"""
from __future__ import annotations
@@ -327,7 +326,6 @@ class AgentDslService:
node_id: str,
source_agent: Agent,
source_snapshot: AgentConfigSnapshot,
- node_job: WorkflowNodeJobConfig,
account_id: str,
) -> tuple[Agent, AgentConfigSnapshot]:
"""Clone a same-workspace Inline Agent for a pasted target node."""
@@ -350,17 +348,6 @@ class AgentDslService:
source=AgentSource.WORKFLOW,
operation=AgentConfigRevisionOperation.CREATE_VERSION,
)
- from services.agent.composer_service import AgentComposerService
-
- AgentComposerService._copy_agent_drive_rows(
- tenant_id=workflow.tenant_id,
- source_agent_id=source_agent.id,
- target_agent_id=agent.id,
- account_id=account_id,
- agent_soul=soul,
- node_job=node_job,
- session=self.session,
- )
return agent, snapshot
def extract_package_dependencies(self, packages: Mapping[str, AgentPackage]) -> list[str]:
diff --git a/api/services/agent/prompt_mentions.py b/api/services/agent/prompt_mentions.py
index a3690f4093a..3be3a53666e 100644
--- a/api/services/agent/prompt_mentions.py
+++ b/api/services/agent/prompt_mentions.py
@@ -66,9 +66,7 @@ _RESIDUAL_MENTION_PATTERN = re.compile(r"\[§([A-Za-z_][A-Za-z0-9_]*:[^§]*?)§\
WORKFLOW_VARIABLE_PATTERN = re.compile(r"\{\{#([^{}#]+?\.[^{}#]+?)#\}\}")
MAX_MENTIONS_PER_PROMPT = 200
-# Drive keys are validated up to 512 Unicode code points before URL encoding.
-# Worst case, one code point becomes 4 UTF-8 bytes and each byte becomes a
-# 3-character ``%XX`` escape, so a valid encoded drive key can reach 6144 chars.
+# Mention ids are bounded independently of their owning configuration schema.
MAX_MENTION_REF_ID_LENGTH = 6144
MAX_MENTION_LABEL_LENGTH = 255
@@ -241,7 +239,7 @@ def scrub_mention_markers(text: str) -> str:
def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
- """Resolve non-drive soul-surface mentions to canonical display names."""
+ """Resolve Soul-surface mentions to canonical display names."""
def _resolve(mention: PromptMention) -> str | None:
match mention.kind:
diff --git a/api/services/agent/skill_package_service.py b/api/services/agent/skill_package_service.py
index fbfd2ababfc..f28ff2aa236 100644
--- a/api/services/agent/skill_package_service.py
+++ b/api/services/agent/skill_package_service.py
@@ -1,4 +1,4 @@
-"""Validate and normalize uploaded Skill packages for drive standardization.
+"""Validate and normalize uploaded Skill packages.
A Skill is a ``.zip`` / ``.skill`` archive that must contain a ``SKILL.md`` entry
file (Anthropic Skills convention: YAML frontmatter with ``name`` + ``description``,
@@ -10,8 +10,7 @@ archive-root ``SKILL.md`` bytes.
It does NOT execute or load the skill — the agent backend owns execution. It also
does not persist anything into Agent Soul or bind anything to config versions;
-``SkillStandardizeService`` consumes the normalized package and commits the
-canonical drive rows instead.
+``ConfigSkillNormalizeService`` consumes the normalized package for Agent config.
"""
from __future__ import annotations
@@ -63,7 +62,7 @@ class SkillManifest(BaseModel):
class NormalizedSkillPackage(BaseModel):
- """Canonical skill package bytes and metadata ready to store in agent drive."""
+ """Canonical skill package bytes and metadata ready to store as Agent config."""
manifest: SkillManifest
archive_bytes: bytes
@@ -72,10 +71,10 @@ class NormalizedSkillPackage(BaseModel):
class SkillPackageService:
- """Validate Skill archives and produce the normalized package stored in drive."""
+ """Validate Skill archives and produce a normalized package."""
def validate_and_normalize(self, *, content: bytes, filename: str) -> NormalizedSkillPackage:
- """Return the canonical drive package for an uploaded skill archive.
+ """Return the canonical package for an uploaded skill archive.
The shallowest ``SKILL.md`` defines the skill root. When exactly one
depth-2 ``/SKILL.md`` exists, normalization strips that top-level
diff --git a/api/services/agent/skill_standardize_service.py b/api/services/agent/skill_standardize_service.py
deleted file mode 100644
index 2639f7a9a18..00000000000
--- a/api/services/agent/skill_standardize_service.py
+++ /dev/null
@@ -1,135 +0,0 @@
-"""Standardize an uploaded Skill into the agent drive (ENG-594).
-
-A validated Skill package is normalized into two **drive-owned** objects committed
-to the agent drive (Agent Files §5.4 / §4):
-
-* ``/SKILL.md`` — the canonical entry, the source of truth for loading.
-* ``/.DIFY-SKILL-FULL.zip`` — the full archive, kept only to restore the
- complete skill contents.
-
-The archive's member list is stored in skill metadata and resolved lazily for
-inspect/preview/runtime. Upload must not eagerly materialize every archive member
-as a separate ToolFile; small archives with many files would otherwise perform
-hundreds of storage writes and DB commits inside the request.
-"""
-
-from __future__ import annotations
-
-import re
-from typing import Any
-
-from sqlalchemy.orm import Session
-
-from core.tools.tool_file_manager import ToolFileManager
-from services.agent.skill_package_service import SkillPackageService
-from services.agent_drive_service import AgentDriveService, DriveCommitItem, DriveFileRef, DriveSkillMetadata
-
-_FULL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip"
-_SKILL_MD_NAME = "SKILL.md"
-_SLUG_RE = re.compile(r"[^a-z0-9._-]+")
-
-
-def slugify_skill_name(name: str) -> str:
- slug = _SLUG_RE.sub("-", (name or "").strip().lower()).strip("-._")
- return slug or "skill"
-
-
-class SkillStandardizeService:
- """Persist a normalized skill package into drive-owned files for one agent.
-
- Instances are intentionally stateful: ``standardize()`` updates
- ``last_committed_items`` with the drive commit result for the most recent call.
- """
-
- def __init__(
- self,
- *,
- package_service: SkillPackageService | None = None,
- drive_service: AgentDriveService | None = None,
- tool_file_manager: ToolFileManager | None = None,
- ) -> None:
- self._package = package_service or SkillPackageService()
- self._drive = drive_service or AgentDriveService()
- self._tool_files = tool_file_manager or ToolFileManager()
- self.last_committed_items: list[dict[str, Any]] = []
-
- def standardize(
- self,
- *,
- content: bytes,
- filename: str,
- tenant_id: str,
- user_id: str,
- agent_id: str,
- session: Session,
- ) -> dict[str, Any]:
- """Create two ToolFiles, commit two drive-owned keys, and return skill metadata.
-
- This writes ``/SKILL.md`` and ``/.DIFY-SKILL-FULL.zip``,
- stores the drive commit rows in ``last_committed_items``, and returns the
- console response shape ``{"skill": ..., "manifest": ...}``.
- """
- package = self._package.validate_and_normalize(content=content, filename=filename)
- manifest = package.manifest
- slug = slugify_skill_name(manifest.name)
-
- # Drive-owned files: canonical SKILL.md and the full archive. The
- # archive member tree is preserved in metadata and resolved lazily.
- md_tool_file = self._tool_files.create_file_by_raw(
- user_id=user_id,
- tenant_id=tenant_id,
- conversation_id=None,
- file_binary=package.skill_md_bytes,
- mimetype="text/markdown",
- filename=_SKILL_MD_NAME,
- )
- archive_tool_file = self._tool_files.create_file_by_raw(
- user_id=user_id,
- tenant_id=tenant_id,
- conversation_id=None,
- file_binary=package.archive_bytes,
- mimetype="application/zip",
- filename=_FULL_ARCHIVE_NAME,
- )
-
- skill_md_key = f"{slug}/{_SKILL_MD_NAME}"
- archive_key = f"{slug}/{_FULL_ARCHIVE_NAME}"
- committed_items = self._drive.commit(
- tenant_id=tenant_id,
- user_id=user_id,
- agent_id=agent_id,
- items=[
- DriveCommitItem(
- key=skill_md_key,
- file_ref=DriveFileRef(kind="tool_file", id=md_tool_file.id),
- value_owned_by_drive=True,
- is_skill=True,
- skill_metadata=DriveSkillMetadata(
- name=manifest.name,
- description=manifest.description,
- manifest_files=manifest.files,
- ),
- ),
- DriveCommitItem(
- key=archive_key,
- file_ref=DriveFileRef(kind="tool_file", id=archive_tool_file.id),
- value_owned_by_drive=True,
- ),
- ],
- session=session,
- )
- self.last_committed_items = committed_items
-
- return {
- "skill": {
- "name": manifest.name,
- "description": manifest.description,
- "path": slug,
- "skill_md_key": skill_md_key,
- "archive_key": archive_key,
- },
- "manifest": manifest.model_dump(),
- }
-
-
-__all__ = ["SkillStandardizeService", "slugify_skill_name"]
diff --git a/api/services/agent/skill_tool_inference_service.py b/api/services/agent/skill_tool_inference_service.py
deleted file mode 100644
index 7ce53dd4666..00000000000
--- a/api/services/agent/skill_tool_inference_service.py
+++ /dev/null
@@ -1,179 +0,0 @@
-"""Infer CLI tool + ENV suggestions from a standardized skill (ENG-371).
-
-Reads the skill's SKILL.md from the agent drive, asks the tenant's default
-reasoning model once (a plain LLM call, never an agent run), and returns
-*draft* suggestions only — nothing is persisted here. The frontend prefills
-the TOOLS box (``inferred from `` badge) and the Pre-Authorize ENV
-panel, and saving still goes through the composer's full shell/env/secret/
-dangerous-command validation, so inference opens no bypass.
-
-ENV suggestions carry only ``key`` + ``reason`` — the model never produces a
-value; users fill those in themselves and the runtime injects ``$VAR`` only.
-"""
-
-from __future__ import annotations
-
-import json
-import logging
-from typing import Any
-
-import json_repair
-from pydantic import BaseModel, Field, ValidationError
-from sqlalchemy.orm import Session
-
-from core.errors.error import ProviderTokenNotInitError
-from core.model_manager import ModelManager
-from graphon.model_runtime.entities.message_entities import SystemPromptMessage, UserPromptMessage
-from graphon.model_runtime.entities.model_entities import ModelType
-from services.agent_drive_service import AgentDriveError, AgentDriveService
-
-logger = logging.getLogger(__name__)
-
-
-class SkillToolInferenceError(Exception):
- """Stable-code error for the infer-tools endpoint."""
-
- def __init__(self, code: str, message: str, *, status_code: int = 400) -> None:
- self.code = code
- self.message = message
- self.status_code = status_code
- super().__init__(message)
-
-
-class EnvSuggestion(BaseModel):
- key: str
- reason: str = ""
- secret_likely: bool = False
-
-
-class CliToolSuggestion(BaseModel):
- name: str
- description: str = ""
- command: str = ""
- install_commands: list[str] = Field(default_factory=list)
- env_suggestions: list[EnvSuggestion] = Field(default_factory=list)
- inferred_from: str = ""
-
-
-class SkillToolInferenceResult(BaseModel):
- inferable: bool
- cli_tools: list[CliToolSuggestion] = Field(default_factory=list)
- reason: str | None = None
-
-
-_SYSTEM_PROMPT = """\
-You analyze an agent skill document (SKILL.md) and infer which command-line \
-tools the skill depends on at runtime, so a user can pre-install them in the \
-agent's sandbox.
-
-Rules:
-- Only suggest tools the document explicitly uses or clearly requires; never guess.
-- For each tool give: name, a one-line reason-style description referencing the \
-document, the base command, and install commands for a Debian-based sandbox \
-(apt-get / pip / npm).
-- If a step needs an environment variable (an API key, token, endpoint), add it \
-to env_suggestions with the variable key and the reason. NEVER produce a value. \
-Mark secret_likely=true for credentials.
-- If the document describes no external command-line dependency, return \
-{"inferable": false, "cli_tools": [], "reason": ""}.
-
-Respond with JSON only, matching exactly:
-{"inferable": bool,
- "cli_tools": [{"name": str, "description": str, "command": str,
- "install_commands": [str], "env_suggestions":
- [{"key": str, "reason": str, "secret_likely": bool}]}],
- "reason": str | null}
-"""
-
-
-class SkillToolInferenceService:
- """Single-shot LLM inference over a drive-stored SKILL.md."""
-
- def __init__(self, *, drive_service: AgentDriveService | None = None) -> None:
- self._drive = drive_service or AgentDriveService()
-
- def infer(self, *, tenant_id: str, agent_id: str, slug: str, session: Session) -> dict[str, Any]:
- skill_md = self._load_skill_md(tenant_id=tenant_id, agent_id=agent_id, slug=slug, session=session)
-
- user_prompt = f"SKILL.md of skill '{slug}':\n\n{skill_md}"
-
- raw = self._invoke(tenant_id=tenant_id, user_prompt=user_prompt)
- try:
- result = self._parse(raw)
- except (ValidationError, ValueError):
- logger.warning("skill tool inference output unparsable, retrying once")
- raw = self._invoke(tenant_id=tenant_id, user_prompt=user_prompt)
- try:
- result = self._parse(raw)
- except (ValidationError, ValueError) as exc:
- raise SkillToolInferenceError(
- "inference_failed",
- "inference_failed: the model output could not be parsed into tool suggestions.",
- status_code=422,
- ) from exc
-
- for tool in result.cli_tools:
- tool.inferred_from = slug
- return result.model_dump(mode="json")
-
- def _load_skill_md(self, *, tenant_id: str, agent_id: str, slug: str, session: Session) -> str:
- try:
- preview = self._drive.preview(
- tenant_id=tenant_id, agent_id=agent_id, key=f"{slug}/SKILL.md", session=session
- )
- except AgentDriveError as exc:
- if exc.code == "drive_key_not_found":
- raise SkillToolInferenceError(
- "skill_not_found", f"skill_not_found: no drive entry for skill '{slug}'.", status_code=404
- ) from exc
- raise SkillToolInferenceError(exc.code, exc.message, status_code=exc.status_code) from exc
- if preview["binary"] or not preview["text"]:
- raise SkillToolInferenceError(
- "skill_not_found", f"skill_not_found: SKILL.md of '{slug}' is not readable text.", status_code=404
- )
- return str(preview["text"])
-
- @staticmethod
- def _invoke(*, tenant_id: str, user_prompt: str) -> str:
- try:
- model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
- model_instance = model_manager.get_default_model_instance(tenant_id=tenant_id, model_type=ModelType.LLM)
- except ProviderTokenNotInitError as exc:
- raise SkillToolInferenceError(
- "default_model_not_configured",
- "default_model_not_configured: the workspace has no default reasoning model.",
- status_code=400,
- ) from exc
- try:
- response = model_instance.invoke_llm(
- prompt_messages=[
- SystemPromptMessage(content=_SYSTEM_PROMPT),
- UserPromptMessage(content=user_prompt),
- ],
- model_parameters={"temperature": 0.1},
- stream=False,
- )
- except Exception as exc:
- raise SkillToolInferenceError(
- "inference_failed", f"inference_failed: model invocation failed: {exc}", status_code=422
- ) from exc
- return response.message.get_text_content()
-
- @staticmethod
- def _parse(raw: str) -> SkillToolInferenceResult:
- try:
- parsed = json.loads(raw)
- except json.JSONDecodeError:
- parsed = json_repair.loads(raw)
- if not isinstance(parsed, dict):
- raise ValueError("model output is not a JSON object")
- return SkillToolInferenceResult.model_validate(parsed)
-
-
-__all__ = [
- "CliToolSuggestion",
- "EnvSuggestion",
- "SkillToolInferenceError",
- "SkillToolInferenceResult",
- "SkillToolInferenceService",
-]
diff --git a/api/services/agent/workflow_publish_service.py b/api/services/agent/workflow_publish_service.py
index b43f3091f88..954a3819774 100644
--- a/api/services/agent/workflow_publish_service.py
+++ b/api/services/agent/workflow_publish_service.py
@@ -186,22 +186,17 @@ class WorkflowAgentPublishService:
node_job=node_job,
)
ComposerConfigValidator.validate_publish_payload(payload)
- # ENG-623 §4.4: drive-backed refs must point at real drive rows before
- # publishing. This stays out of composer save so autosave/save-draft can
- # persist incomplete refs and surface them as non-blocking findings.
- cls._require_drive_refs_resolved_for_publish(session=session, binding=binding, agent_soul=agent_soul)
+ cls._require_config_asset_refs_resolved_for_publish(binding=binding, agent_soul=agent_soul)
@classmethod
- def _require_drive_refs_resolved_for_publish(
+ def _require_config_asset_refs_resolved_for_publish(
cls,
*,
- session: Session,
binding: WorkflowAgentNodeBinding,
agent_soul: AgentSoulConfig,
) -> None:
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
- del session
configured_skill_names = {item.name for item in agent_soul.config_skills if not item.is_missing}
configured_file_names = {item.name for item in agent_soul.config_files if not item.is_missing}
missing_refs: list[str] = []
@@ -359,7 +354,6 @@ class WorkflowAgentPublishService:
node_id=node_id,
source_agent_id=agent_id,
source_snapshot_id=current_snapshot_id,
- node_job=node_job_config,
account_id=account_id,
)
resolved_binding_type = WorkflowAgentBindingType.INLINE_AGENT
@@ -422,7 +416,6 @@ class WorkflowAgentPublishService:
node_id: str,
source_agent_id: str,
source_snapshot_id: str,
- node_job: WorkflowNodeJobConfig,
account_id: str,
) -> tuple[Agent, str]:
source_agent = session.scalar(
@@ -456,7 +449,6 @@ class WorkflowAgentPublishService:
node_id=node_id,
source_agent=source_agent,
source_snapshot=source_snapshot,
- node_job=node_job,
account_id=account_id,
)
return agent, snapshot.id
@@ -709,7 +701,6 @@ class WorkflowAgentPublishService:
node_id=source.node_id,
source_agent_id=agent_id,
source_snapshot_id=snapshot_id,
- node_job=WorkflowNodeJobConfig.model_validate(source.node_job_config_dict),
account_id=account_id,
)
agent_id = agent.id
diff --git a/api/services/agent_config_service.py b/api/services/agent_config_service.py
index 76edc3b199c..f17847ab3a1 100644
--- a/api/services/agent_config_service.py
+++ b/api/services/agent_config_service.py
@@ -50,7 +50,6 @@ from models.model import UploadFile
from models.tools import ToolFile
from services.agent.config_skill_normalize_service import ConfigSkillNormalizeService
from services.agent.skill_package_service import SkillPackageError
-from services.agent_drive_service import DriveFileRef
class AgentConfigVersionKind(StrEnum):
@@ -64,6 +63,13 @@ class AgentConfigMutationSurface(StrEnum):
CONSOLE = "console"
+class ConfigFileRef(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ kind: Literal["upload_file", "tool_file"]
+ id: str
+
+
class AgentConfigServiceError(Exception):
"""Config operation failure mapped to HTTP status at controller boundaries."""
@@ -82,14 +88,14 @@ class ConfigPushFileItem(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
- file_ref: DriveFileRef | None = None
+ file_ref: ConfigFileRef | None = None
class ConfigPushSkillItem(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
- file_ref: DriveFileRef | None = None
+ file_ref: ConfigFileRef | None = None
class ConfigPushPayload(BaseModel):
@@ -991,7 +997,7 @@ class AgentConfigService:
session: Session,
*,
tenant_id: str,
- file_ref: DriveFileRef,
+ file_ref: ConfigFileRef,
) -> tuple[int | None, str | None, str | None]:
if file_ref.kind == "tool_file":
tool_file = self._require_tool_file_source(
diff --git a/api/services/agent_drive_service.py b/api/services/agent_drive_service.py
deleted file mode 100644
index 0e4722d1018..00000000000
--- a/api/services/agent_drive_service.py
+++ /dev/null
@@ -1,1254 +0,0 @@
-"""Agent 网盘 (agent drive) service — manifest/catalog + commit lifecycle.
-
-The agent drive is a per-agent path-like KV index over existing UploadFile /
-ToolFile records (see ``AgentDriveFile``). This service is the control plane:
-
-* ``manifest`` lists a drive (optionally with download URLs). Download URLs use
- **drive-owned** semantics — tenant-scoped resolution only, NOT a user-level
- ``FileAccessScope`` (Agent Files §3.1.2). We reuse the standard
- ``file_factory.build_from_mapping`` + ``resolve_file_url`` rebuild, which always
- filters by ``tenant_id`` in the builders, so omitting the scope is safe.
-* ``commit`` is the single mutation entry point for writes and removals.
- ``file_ref=None`` removes an exact key idempotently; otherwise the service
- binds the referenced UploadFile/ToolFile to the key. Source ToolFiles must
- belong to the current run user. Overwriting a key whose previous value is
- ``value_owned_by_drive`` physically cleans the old value (storage + record),
- unless another drive entry still references it. Re-committing the same
- ``key -> file_ref`` is idempotent and still refreshes skill metadata.
-"""
-
-from __future__ import annotations
-
-import base64
-import hashlib
-import hmac
-import io
-import json
-import logging
-import mimetypes
-import os
-import re
-import time
-import urllib.parse
-import zipfile
-from typing import Any, Literal, TypedDict
-from urllib.parse import unquote
-
-from pydantic import BaseModel, ConfigDict, field_validator
-from sqlalchemy import func, select
-from sqlalchemy.exc import DataError, SQLAlchemyError
-from sqlalchemy.orm import Session
-
-from configs import dify_config
-from core.app.file_access.controller import DatabaseFileAccessController
-from extensions.ext_storage import storage
-from factories import file_factory
-from libs.uuid_utils import uuidv7
-from models.agent import Agent, AgentDriveFile, AgentDriveFileKind
-from models.model import UploadFile
-from models.tools import ToolFile
-
-logger = logging.getLogger(__name__)
-
-_MAX_KEY_LENGTH = 512
-_DRIVE_REF_PREFIX = "agent-"
-_SKILL_MD_SUFFIX = "/SKILL.md"
-_SKILL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip"
-_ARCHIVE_MEMBER_DOWNLOAD_PURPOSE = "agent-drive-archive-member"
-
-
-class AgentDriveError(Exception):
- """A drive operation failure mapped to an HTTP status by the controller."""
-
- code: str
- message: str
- status_code: int
-
- def __init__(self, code: str, message: str, *, status_code: int = 400) -> None:
- super().__init__(message)
- self.code = code
- self.message = message
- self.status_code = status_code
-
-
-class DriveFileRef(BaseModel):
- model_config = ConfigDict(extra="forbid")
-
- kind: Literal["upload_file", "tool_file"]
- id: str
-
-
-class DriveSkillMetadata(BaseModel):
- """Validated skill catalog metadata stored as a JSON string on the drive row."""
-
- model_config = ConfigDict(extra="forbid")
-
- name: str
- description: str = ""
- # Safe archive member paths captured during skill standardization. The drive
- # stores only canonical SKILL.md + full archive, so the UI uses this manifest
- # to show the original uploaded package contents.
- manifest_files: list[str] | None = None
-
- @field_validator("name")
- @classmethod
- def _validate_name(cls, value: str) -> str:
- normalized = value.strip()
- if not normalized:
- raise ValueError("skill metadata name must not be blank")
- return normalized
-
-
-class DriveCommitItem(BaseModel):
- model_config = ConfigDict(extra="forbid")
-
- key: str
- file_ref: DriveFileRef | None = None
- # Drive-owned values may be physically cleaned on overwrite/removal; refs to
- # files shared with other business records should set this False.
- value_owned_by_drive: bool = True
- is_skill: bool = False
- skill_metadata: DriveSkillMetadata | None = None
-
-
-class AgentDriveSkillInfo(TypedDict):
- path: str
- skill_md_key: str
- archive_key: str | None
- name: str
- description: str
- size: int | None
- mime_type: str | None
- hash: str | None
- created_at: int | None
-
-
-class AgentDriveSkillFileInfo(TypedDict):
- path: str
- name: str
- type: str
- drive_key: str | None
- available_in_drive: bool
-
-
-class AgentDriveSkillInspectInfo(TypedDict):
- path: str
- skill_md_key: str
- archive_key: str | None
- name: str
- description: str
- size: int | None
- mime_type: str | None
- hash: str | None
- created_at: int | None
- source: str
- files: list[AgentDriveSkillFileInfo]
- file_tree: list[dict[str, Any]]
- skill_md: dict[str, Any]
- warnings: list[str]
-
-
-def decode_drive_mention_ref(ref_id: str) -> str:
- """Decode the prompt token's URL-encoded drive-key field."""
-
- return unquote(ref_id or "")
-
-
-def parse_agent_drive_ref(drive_ref: str) -> str:
- """Parse an ``agent-`` URL drive ref into the agent id."""
- if not drive_ref.startswith(_DRIVE_REF_PREFIX):
- raise AgentDriveError("invalid_drive_ref", "drive ref must be 'agent-'", status_code=400)
- agent_id = drive_ref[len(_DRIVE_REF_PREFIX) :]
- if not agent_id:
- raise AgentDriveError("invalid_drive_ref", "drive ref must include an agent id", status_code=400)
- return agent_id
-
-
-def normalize_drive_key(key: str) -> str:
- """Validate + normalize a path-like drive key (Agent Files §6 key safety).
-
- The key maps back to a sandbox-relative file path, so reject anything that
- could escape or break the path: empty, too long, NUL/control chars, absolute
- paths, or ``..`` segments. Collapse repeated slashes and strip a leading one.
- """
- if not isinstance(key, str) or not key.strip():
- raise AgentDriveError("invalid_key", "drive key must be a non-empty string", status_code=400)
- if len(key) > _MAX_KEY_LENGTH:
- raise AgentDriveError("invalid_key", f"drive key exceeds {_MAX_KEY_LENGTH} chars", status_code=400)
- if "\x00" in key or any(ord(ch) < 0x20 for ch in key):
- raise AgentDriveError("invalid_key", "drive key contains control characters", status_code=400)
- normalized = re.sub(r"/{2,}", "/", key.strip()).lstrip("/")
- segments = normalized.split("/")
- if any(segment == ".." for segment in segments):
- raise AgentDriveError("invalid_key", "drive key must not contain '..' segments", status_code=400)
- if not normalized:
- raise AgentDriveError("invalid_key", "drive key must be a non-empty path", status_code=400)
- return normalized
-
-
-class AgentDriveService:
- """List/commit files in a per-agent drive (tenant_id -> agent-)."""
-
- def manifest(
- self,
- *,
- tenant_id: str,
- agent_id: str,
- session: Session,
- prefix: str = "",
- include_download_url: bool = False,
- ) -> list[dict[str, Any]]:
- self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
- stmt = (
- select(AgentDriveFile)
- .where(AgentDriveFile.tenant_id == tenant_id, AgentDriveFile.agent_id == agent_id)
- .order_by(AgentDriveFile.key)
- )
- if prefix:
- stmt = stmt.where(AgentDriveFile.key.startswith(prefix))
- rows = list(session.scalars(stmt))
- items: list[dict[str, Any]] = []
- for row in rows:
- item: dict[str, Any] = {
- "key": row.key,
- "size": row.size,
- "hash": row.hash,
- "mime_type": row.mime_type,
- "file_kind": row.file_kind.value,
- "file_id": row.file_id,
- "is_skill": row.is_skill,
- "skill_metadata": row.skill_metadata,
- "created_at": int(row.created_at.timestamp()) if row.created_at else None,
- }
- if include_download_url:
- item["download_url"] = self._resolve_download_url(
- tenant_id=tenant_id, file_kind=row.file_kind, file_id=row.file_id
- )
- items.append(item)
- return items
-
- def commit(
- self,
- *,
- tenant_id: str,
- user_id: str,
- agent_id: str,
- items: list[DriveCommitItem],
- session: Session,
- ) -> list[dict[str, Any]]:
- if not items:
- raise AgentDriveError("empty_commit", "commit requires at least one item", status_code=400)
- committed: list[dict[str, Any]] = []
- pending_storage_deletes: list[str] = []
- self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
- for item in items:
- committed.append(
- self._commit_one(
- session,
- tenant_id=tenant_id,
- user_id=user_id,
- agent_id=agent_id,
- item=item,
- pending_storage_deletes=pending_storage_deletes,
- )
- )
- session.commit()
- for storage_key in pending_storage_deletes:
- self._delete_storage(storage_key)
- return committed
-
- def delete(
- self,
- *,
- tenant_id: str,
- agent_id: str,
- session: Session,
- prefix: str | None = None,
- key: str | None = None,
- ) -> list[str]:
- """Delete drive entries by exact ``key`` or by ``prefix`` (ENG-625 D5).
-
- Drive-owned values get their backing record + storage object cleaned via
- the same ``_cleanup_value`` path commit-overwrite uses; shared values only
- lose the KV row. Idempotent: deleting nothing returns ``[]``.
- """
- if (prefix is None) == (key is None):
- raise AgentDriveError("invalid_delete_scope", "delete requires exactly one of prefix or key")
- removed_keys: list[str] = []
- pending_storage_deletes: list[str] = []
- self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
- stmt = select(AgentDriveFile).where(
- AgentDriveFile.tenant_id == tenant_id,
- AgentDriveFile.agent_id == agent_id,
- )
- if key is not None:
- stmt = stmt.where(AgentDriveFile.key == normalize_drive_key(key))
- else:
- stmt = stmt.where(AgentDriveFile.key.startswith(normalize_drive_key(prefix or "")))
- rows = list(session.scalars(stmt))
- for row in rows:
- if row.value_owned_by_drive:
- self._cleanup_value(
- session,
- tenant_id=tenant_id,
- file_kind=row.file_kind,
- file_id=row.file_id,
- exclude_row_id=row.id,
- pending_storage_deletes=pending_storage_deletes,
- )
- removed_keys.append(row.key)
- session.delete(row)
- session.commit()
- for storage_key in pending_storage_deletes:
- self._delete_storage(storage_key)
- return removed_keys
-
- def list_skills(self, *, tenant_id: str, agent_id: str, session: Session) -> list[AgentDriveSkillInfo]:
- """Return the drive-backed skill catalog derived from canonical ``SKILL.md`` rows."""
-
- self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
- skill_rows = list(
- session.scalars(
- select(AgentDriveFile)
- .where(
- AgentDriveFile.tenant_id == tenant_id,
- AgentDriveFile.agent_id == agent_id,
- AgentDriveFile.is_skill.is_(True),
- )
- .order_by(AgentDriveFile.key)
- )
- )
- archive_keys = set(
- session.scalars(
- select(AgentDriveFile.key).where(
- AgentDriveFile.tenant_id == tenant_id,
- AgentDriveFile.agent_id == agent_id,
- AgentDriveFile.key.in_([self._skill_archive_key(row.key) for row in skill_rows]),
- )
- )
- )
-
- skills: list[AgentDriveSkillInfo] = []
- for row in skill_rows:
- metadata = self._parse_skill_metadata(row.key, row.skill_metadata)
- archive_key = self._skill_archive_key(row.key)
- skills.append(
- {
- "path": self._skill_path_from_key(row.key),
- "skill_md_key": row.key,
- "archive_key": archive_key if archive_key in archive_keys else None,
- "name": metadata.name,
- "description": metadata.description,
- "size": row.size,
- "mime_type": row.mime_type,
- "hash": row.hash,
- "created_at": int(row.created_at.timestamp()) if row.created_at else None,
- }
- )
- return skills
-
- def inspect_skill(
- self, *, tenant_id: str, agent_id: str, skill_path: str, session: Session
- ) -> AgentDriveSkillInspectInfo:
- """Return the UI-facing skill inspect view for slash-menu hover/detail."""
-
- skill_path = normalize_drive_key(skill_path)
- skill_md_key = skill_path if skill_path.endswith(_SKILL_MD_SUFFIX) else f"{skill_path}{_SKILL_MD_SUFFIX}"
- skill_path = self._skill_path_from_key(skill_md_key)
- catalog = next(
- (
- item
- for item in self.list_skills(tenant_id=tenant_id, agent_id=agent_id, session=session)
- if item["path"] == skill_path
- ),
- None,
- )
- if catalog is None:
- raise AgentDriveError("skill_not_found", "no drive-backed skill for this path", status_code=404)
-
- manifest_files = self._manifest_files_from_skill_metadata(
- tenant_id=tenant_id,
- agent_id=agent_id,
- skill_md_key=skill_md_key,
- session=session,
- )
- drive_items = self.manifest(tenant_id=tenant_id, agent_id=agent_id, prefix=f"{skill_path}/", session=session)
- drive_keys = {item["key"] for item in drive_items}
- preview = self.preview(tenant_id=tenant_id, agent_id=agent_id, key=skill_md_key, session=session)
- files, warnings = self._skill_file_entries(
- skill_path=skill_path,
- skill_md_key=skill_md_key,
- manifest_files=manifest_files,
- drive_keys=drive_keys,
- archive_available=catalog["archive_key"] in drive_keys if catalog["archive_key"] else False,
- )
- return {
- **catalog,
- "source": "skill_md",
- "files": files,
- "file_tree": self._build_file_tree(files),
- "skill_md": preview,
- "warnings": warnings,
- }
-
- def _commit_one(
- self,
- session: Session,
- *,
- tenant_id: str,
- user_id: str,
- agent_id: str,
- item: DriveCommitItem,
- pending_storage_deletes: list[str],
- ) -> dict[str, Any]:
- key = normalize_drive_key(item.key)
- if item.file_ref is None:
- return self._remove_one(
- session,
- tenant_id=tenant_id,
- agent_id=agent_id,
- key=key,
- pending_storage_deletes=pending_storage_deletes,
- )
-
- skill_metadata = self._validate_skill_commit_fields(key=key, item=item)
- file_kind = AgentDriveFileKind(item.file_ref.kind)
- file_id = item.file_ref.id
- size, mime_type, file_hash = self._validate_source(
- session,
- tenant_id=tenant_id,
- user_id=user_id,
- file_kind=file_kind,
- file_id=file_id,
- take_ownership=item.value_owned_by_drive,
- )
-
- existing = session.scalar(
- select(AgentDriveFile).where(
- AgentDriveFile.tenant_id == tenant_id,
- AgentDriveFile.agent_id == agent_id,
- AgentDriveFile.key == key,
- )
- )
- if existing is not None:
- # Idempotent re-commit of the same value: leave it (do not clean).
- if existing.file_kind == file_kind and existing.file_id == file_id:
- existing.value_owned_by_drive = item.value_owned_by_drive
- existing.is_skill = item.is_skill
- existing.skill_metadata = skill_metadata
- existing.size = size
- existing.mime_type = mime_type
- existing.hash = file_hash
- return self._row_dict(existing)
- # Overwrite: clean the previous drive-owned value if no longer referenced.
- if existing.value_owned_by_drive:
- self._cleanup_value(
- session,
- tenant_id=tenant_id,
- file_kind=existing.file_kind,
- file_id=existing.file_id,
- exclude_row_id=existing.id,
- pending_storage_deletes=pending_storage_deletes,
- )
- existing.file_kind = file_kind
- existing.file_id = file_id
- existing.value_owned_by_drive = item.value_owned_by_drive
- existing.is_skill = item.is_skill
- existing.skill_metadata = skill_metadata
- existing.size = size
- existing.hash = file_hash
- existing.mime_type = mime_type
- return self._row_dict(existing)
-
- row = AgentDriveFile(
- id=str(uuidv7()),
- tenant_id=tenant_id,
- agent_id=agent_id,
- key=key,
- file_kind=file_kind,
- file_id=file_id,
- value_owned_by_drive=item.value_owned_by_drive,
- is_skill=item.is_skill,
- skill_metadata=skill_metadata,
- size=size,
- hash=file_hash,
- mime_type=mime_type,
- created_by=user_id,
- )
- session.add(row)
- return self._row_dict(row)
-
- def _remove_one(
- self,
- session: Session,
- *,
- tenant_id: str,
- agent_id: str,
- key: str,
- pending_storage_deletes: list[str],
- ) -> dict[str, Any]:
- existing = session.scalar(
- select(AgentDriveFile).where(
- AgentDriveFile.tenant_id == tenant_id,
- AgentDriveFile.agent_id == agent_id,
- AgentDriveFile.key == key,
- )
- )
- if existing is None:
- return {"key": key, "removed": True, "noop": True}
- result = {
- "key": key,
- "removed": True,
- "file_kind": existing.file_kind.value,
- "file_id": existing.file_id,
- "value_owned_by_drive": existing.value_owned_by_drive,
- "is_skill": existing.is_skill,
- "skill_metadata": existing.skill_metadata,
- }
- if existing.value_owned_by_drive:
- self._cleanup_value(
- session,
- tenant_id=tenant_id,
- file_kind=existing.file_kind,
- file_id=existing.file_id,
- exclude_row_id=existing.id,
- pending_storage_deletes=pending_storage_deletes,
- )
- session.delete(existing)
- return result
-
- @staticmethod
- def _row_dict(row: AgentDriveFile) -> dict[str, Any]:
- return {
- "key": row.key,
- "file_kind": row.file_kind.value,
- "file_id": row.file_id,
- "size": row.size,
- "mime_type": row.mime_type,
- "value_owned_by_drive": row.value_owned_by_drive,
- "is_skill": row.is_skill,
- "skill_metadata": row.skill_metadata,
- }
-
- @staticmethod
- def _skill_path_from_key(key: str) -> str:
- if not key.endswith(_SKILL_MD_SUFFIX):
- raise AgentDriveError(
- "invalid_skill_key",
- "skill rows must use the canonical '/SKILL.md' key",
- status_code=500,
- )
- path = key[: -len(_SKILL_MD_SUFFIX)]
- if not path:
- raise AgentDriveError(
- "invalid_skill_key",
- "skill rows must use the canonical '/SKILL.md' key",
- status_code=500,
- )
- return path
-
- @classmethod
- def _skill_archive_key(cls, key: str) -> str:
- return f"{cls._skill_path_from_key(key)}/{_SKILL_ARCHIVE_NAME}"
-
- @classmethod
- def _validate_skill_commit_fields(cls, *, key: str, item: DriveCommitItem) -> str | None:
- if not item.is_skill:
- if item.skill_metadata is not None:
- raise AgentDriveError(
- "invalid_skill_metadata",
- "skill metadata is only allowed for canonical skill rows",
- status_code=400,
- )
- return None
- cls._skill_path_from_key(key)
- if item.skill_metadata is None:
- raise AgentDriveError(
- "invalid_skill_metadata",
- "skill metadata is required for canonical skill rows",
- status_code=400,
- )
- return json.dumps(
- item.skill_metadata.model_dump(mode="json", exclude_none=True),
- separators=(",", ":"),
- sort_keys=True,
- )
-
- @staticmethod
- def _parse_skill_metadata(key: str, raw_metadata: str | None) -> DriveSkillMetadata:
- if raw_metadata is None:
- raise AgentDriveError(
- "invalid_skill_metadata",
- f"skill row '{key}' is missing required metadata",
- status_code=500,
- )
- try:
- return DriveSkillMetadata.model_validate(json.loads(raw_metadata))
- except (ValueError, TypeError) as exc:
- raise AgentDriveError(
- "invalid_skill_metadata",
- f"skill row '{key}' has invalid stored metadata",
- status_code=500,
- ) from exc
-
- @staticmethod
- def _manifest_files_from_skill_metadata(
- *, tenant_id: str, agent_id: str, skill_md_key: str, session: Session
- ) -> list[str] | None:
- row = session.scalar(
- select(AgentDriveFile).where(
- AgentDriveFile.tenant_id == tenant_id,
- AgentDriveFile.agent_id == agent_id,
- AgentDriveFile.key == skill_md_key,
- AgentDriveFile.is_skill.is_(True),
- )
- )
- if row is None:
- return None
- try:
- metadata = AgentDriveService._parse_skill_metadata(row.key, row.skill_metadata)
- except Exception:
- logger.warning("drive skill inspect: malformed skill metadata for %s", skill_md_key, exc_info=True)
- return None
- return [str(item) for item in (metadata.manifest_files or []) if str(item).strip()] or None
-
- @classmethod
- def _skill_file_entries(
- cls,
- *,
- skill_path: str,
- skill_md_key: str,
- manifest_files: list[str] | None,
- drive_keys: set[str],
- archive_available: bool = False,
- ) -> tuple[list[AgentDriveSkillFileInfo], list[str]]:
- warnings: list[str] = []
- if manifest_files:
- paths = sorted({normalize_drive_key(path) for path in manifest_files})
- else:
- paths = sorted(
- {
- key.removeprefix(f"{skill_path}/")
- for key in drive_keys
- if not key.endswith(f"/{_SKILL_ARCHIVE_NAME}")
- }
- )
- warnings.append("manifest_files_unavailable")
-
- files: list[AgentDriveSkillFileInfo] = []
- for path in paths:
- if path == _SKILL_ARCHIVE_NAME:
- continue
- drive_key = f"{skill_path}/{path}"
- available_in_drive = drive_key in drive_keys or (archive_available and path != _SKILL_ARCHIVE_NAME)
- files.append(
- {
- "path": path,
- "name": path.rsplit("/", 1)[-1],
- "type": "file",
- "drive_key": drive_key if available_in_drive else None,
- "available_in_drive": available_in_drive,
- }
- )
- if "SKILL.md" not in {file["path"] for file in files}:
- files.insert(
- 0,
- {
- "path": "SKILL.md",
- "name": "SKILL.md",
- "type": "file",
- "drive_key": skill_md_key,
- "available_in_drive": skill_md_key in drive_keys,
- },
- )
- return files, warnings
-
- @staticmethod
- def _build_file_tree(files: list[AgentDriveSkillFileInfo]) -> list[dict[str, Any]]:
- root: dict[str, Any] = {}
- for file in files:
- cursor = root
- parts = [part for part in file["path"].split("/") if part]
- path_parts: list[str] = []
- for part in parts[:-1]:
- path_parts.append(part)
- directory = cursor.setdefault(
- part,
- {
- "name": part,
- "path": "/".join(path_parts),
- "type": "directory",
- "children": {},
- },
- )
- cursor = directory["children"]
- leaf_name = parts[-1] if parts else file["name"]
- cursor[leaf_name] = {
- "name": leaf_name,
- "path": file["path"],
- "type": file["type"],
- "drive_key": file["drive_key"],
- "available_in_drive": file["available_in_drive"],
- }
-
- def serialize(node: dict[str, Any]) -> list[dict[str, Any]]:
- result: list[dict[str, Any]] = []
- for item in sorted(node.values(), key=lambda value: (value["type"] != "directory", value["name"])):
- if item["type"] == "directory":
- children = serialize(item["children"])
- result.append(
- {
- "name": item["name"],
- "path": item["path"],
- "type": "directory",
- "children": children,
- }
- )
- else:
- result.append(item)
- return result
-
- return serialize(root)
-
- @staticmethod
- def _assert_agent_belongs_to_tenant(session: Session, *, tenant_id: str, agent_id: str) -> None:
- try:
- found_agent_id = session.scalar(select(Agent.id).where(Agent.id == agent_id, Agent.tenant_id == tenant_id))
- except (DataError, SQLAlchemyError) as exc:
- session.rollback()
- raise AgentDriveError(
- "agent_not_found", "agent drive does not belong to this tenant", status_code=404
- ) from exc
- if found_agent_id is None:
- raise AgentDriveError("agent_not_found", "agent drive does not belong to this tenant", status_code=404)
-
- def _validate_source(
- self,
- session: Session,
- *,
- tenant_id: str,
- user_id: str,
- file_kind: AgentDriveFileKind,
- file_id: str,
- take_ownership: bool = False,
- ) -> tuple[int | None, str | None, str | None]:
- """Verify the source file exists for the tenant (and user, for ToolFile).
-
- Malformed ids (e.g. a non-UUID hitting a UUID column) are treated as a
- missing source rather than crashing the commit with a 500.
- """
- try:
- if file_kind == AgentDriveFileKind.TOOL_FILE:
- tool_file = session.scalar(
- select(ToolFile)
- .where(
- ToolFile.id == file_id,
- ToolFile.tenant_id == tenant_id,
- ToolFile.user_id == user_id,
- )
- .with_for_update()
- )
- if tool_file is None:
- raise AgentDriveError(
- "source_not_found", "source ToolFile not found for this tenant/user", status_code=404
- )
- if take_ownership:
- tool_file.conversation_id = None
- return tool_file.size, tool_file.mimetype, None
- upload_file = session.scalar(
- select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id)
- )
- except (DataError, SQLAlchemyError) as exc:
- session.rollback()
- raise AgentDriveError("source_not_found", "source file ref is invalid", status_code=404) from exc
- if upload_file is None:
- raise AgentDriveError("source_not_found", "source UploadFile not found for this tenant", status_code=404)
- return upload_file.size, upload_file.mime_type, upload_file.hash
-
- def _cleanup_value(
- self,
- session: Session,
- *,
- tenant_id: str,
- file_kind: AgentDriveFileKind,
- file_id: str,
- exclude_row_id: str,
- pending_storage_deletes: list[str],
- ) -> None:
- """Physically delete a drive-owned value, unless another drive entry references it."""
- still_referenced = session.scalar(
- select(func.count())
- .select_from(AgentDriveFile)
- .where(
- AgentDriveFile.tenant_id == tenant_id,
- AgentDriveFile.file_kind == file_kind,
- AgentDriveFile.file_id == file_id,
- AgentDriveFile.id != exclude_row_id,
- )
- )
- if still_referenced:
- return
- if file_kind == AgentDriveFileKind.TOOL_FILE:
- tool_file = session.scalar(select(ToolFile).where(ToolFile.id == file_id, ToolFile.tenant_id == tenant_id))
- if tool_file is not None:
- pending_storage_deletes.append(tool_file.file_key)
- session.delete(tool_file)
- return
- upload_file = session.scalar(
- select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id)
- )
- if upload_file is not None:
- pending_storage_deletes.append(upload_file.key)
- session.delete(upload_file)
-
- @staticmethod
- def _delete_storage(storage_key: str | None) -> None:
- if not storage_key:
- return
- try:
- storage.delete(storage_key)
- except Exception:
- # Best-effort: a missing/already-deleted object must not abort the commit.
- logger.warning("failed to delete drive storage object %s", storage_key, exc_info=True)
-
- @staticmethod
- def _resolve_download_url(
- *,
- tenant_id: str,
- file_kind: AgentDriveFileKind,
- file_id: str,
- for_external: bool = False,
- as_attachment: bool = False,
- ) -> str | None:
- """Signed URL for a drive value. ``for_external`` selects the audience:
- the inner manifest hands agents *internal* URLs, while the console
- inspector must hand browsers *external* ones — never mix the two."""
- if file_kind == AgentDriveFileKind.TOOL_FILE:
- mapping: dict[str, Any] = {"transfer_method": "tool_file", "tool_file_id": file_id}
- else:
- mapping = {"transfer_method": "local_file", "upload_file_id": file_id}
- controller = DatabaseFileAccessController()
- # Keep workflow runtime wiring lazy: importing this service is part of
- # Agent v2 node bootstrap, while ``core.app.workflow`` re-exports the
- # node factory. A module-level import here would close that cycle.
- from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
-
- runtime = DifyWorkflowFileRuntime(file_access_controller=controller)
- try:
- if file_kind == AgentDriveFileKind.UPLOAD_FILE:
- return runtime.resolve_upload_file_url(
- upload_file_id=file_id,
- for_external=for_external,
- as_attachment=as_attachment,
- )
- # No FileAccessScope bound -> drive-owned: the builders still filter by
- # tenant_id, so resolution is tenant-scoped without user-level checks.
- file = file_factory.build_from_mapping(mapping=mapping, tenant_id=tenant_id, access_controller=controller)
- url = runtime.resolve_file_url(file=file, for_external=for_external)
- if as_attachment and url:
- parsed = urllib.parse.urlsplit(url)
- query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
- query.append(("as_attachment", "true"))
- return urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query)))
- return url
- except ValueError:
- return None
-
- # ── console drive inspector (ENG-624) ────────────────────────────────────
-
- # SKILL.md is the primary preview use case; 64 KiB covers it with headroom
- # while keeping the console payload bounded.
- PREVIEW_MAX_BYTES = 64 * 1024
-
- def _require_row(self, session: Session, *, tenant_id: str, agent_id: str, key: str) -> AgentDriveFile:
- row = session.scalar(
- select(AgentDriveFile).where(
- AgentDriveFile.tenant_id == tenant_id,
- AgentDriveFile.agent_id == agent_id,
- AgentDriveFile.key == normalize_drive_key(key),
- )
- )
- if row is None:
- raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404)
- return row
-
- def _storage_key_for_row(self, session: Session, *, tenant_id: str, row: AgentDriveFile) -> str:
- return self._storage_key_for_ref(
- session,
- tenant_id=tenant_id,
- file_kind=row.file_kind,
- file_id=row.file_id,
- )
-
- def _storage_key_for_ref(
- self,
- session: Session,
- *,
- tenant_id: str,
- file_kind: AgentDriveFileKind,
- file_id: str,
- ) -> str:
- if file_kind == AgentDriveFileKind.TOOL_FILE:
- tool_file = session.scalar(select(ToolFile).where(ToolFile.id == file_id, ToolFile.tenant_id == tenant_id))
- if tool_file is None:
- raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404)
- return tool_file.file_key
- upload_file = session.scalar(
- select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id)
- )
- if upload_file is None:
- raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404)
- return upload_file.key
-
- def _archive_member_for_key(
- self,
- session: Session,
- *,
- tenant_id: str,
- agent_id: str,
- key: str,
- ) -> tuple[AgentDriveFile, str]:
- normalized_key = normalize_drive_key(key)
- if "/" not in normalized_key:
- raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404)
- skill_path, member_path = normalized_key.split("/", 1)
- if member_path in {_SKILL_ARCHIVE_NAME, ""}:
- raise AgentDriveError("drive_key_not_found", "no archive member for this key", status_code=404)
-
- skill_md_key = f"{skill_path}{_SKILL_MD_SUFFIX}"
- skill_row = session.scalar(
- select(AgentDriveFile).where(
- AgentDriveFile.tenant_id == tenant_id,
- AgentDriveFile.agent_id == agent_id,
- AgentDriveFile.key == skill_md_key,
- AgentDriveFile.is_skill.is_(True),
- )
- )
- if skill_row is None:
- raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404)
- metadata = self._parse_skill_metadata(skill_row.key, skill_row.skill_metadata)
- manifest_files = {normalize_drive_key(path) for path in (metadata.manifest_files or [])}
- if member_path not in manifest_files:
- raise AgentDriveError("drive_key_not_found", "archive member is not part of this skill", status_code=404)
- archive_row = session.scalar(
- select(AgentDriveFile).where(
- AgentDriveFile.tenant_id == tenant_id,
- AgentDriveFile.agent_id == agent_id,
- AgentDriveFile.key == self._skill_archive_key(skill_md_key),
- )
- )
- if archive_row is None:
- raise AgentDriveError("drive_key_not_found", "skill archive is missing", status_code=404)
- return archive_row, member_path
-
- def _load_archive_member_bytes(
- self,
- *,
- tenant_id: str,
- archive_file_kind: AgentDriveFileKind,
- archive_file_id: str,
- member_path: str,
- session: Session,
- ) -> bytes:
- member_path = normalize_drive_key(member_path)
- storage_key = self._storage_key_for_ref(
- session,
- tenant_id=tenant_id,
- file_kind=archive_file_kind,
- file_id=archive_file_id,
- )
- archive_bytes = b"".join(storage.load_stream(storage_key))
- try:
- with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive:
- member = next(
- (
- info
- for info in archive.infolist()
- if not info.is_dir() and normalize_drive_key(info.filename) == member_path
- ),
- None,
- )
- if member is None:
- raise AgentDriveError(
- "drive_key_not_found", "archive member is missing from the skill archive", status_code=404
- )
- return archive.read(member)
- except zipfile.BadZipFile as exc:
- raise AgentDriveError("invalid_skill_archive", "skill archive is not a valid zip", status_code=500) from exc
-
- @classmethod
- def _preview_bytes(cls, *, key: str, size: int | None, payload: bytes) -> dict[str, Any]:
- truncated = len(payload) > cls.PREVIEW_MAX_BYTES
- sample = payload[: cls.PREVIEW_MAX_BYTES]
- if b"\x00" in sample:
- return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
- try:
- text = sample.decode("utf-8")
- except UnicodeDecodeError:
- if truncated:
- try:
- text = sample[:-3].decode("utf-8", errors="strict")
- except UnicodeDecodeError:
- return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
- else:
- return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
- return {"key": key, "size": size, "truncated": truncated, "binary": False, "text": text}
-
- def preview(self, *, tenant_id: str, agent_id: str, key: str, session: Session) -> dict[str, Any]:
- """Truncated text preview of one drive value (binary-safe, never 500s on size)."""
- self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
- try:
- row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
- storage_key = self._storage_key_for_row(session, tenant_id=tenant_id, row=row)
- size = row.size
- response_key = row.key
- archive_ref: tuple[AgentDriveFile, str] | None = None
- except AgentDriveError:
- archive_ref = self._archive_member_for_key(
- session,
- tenant_id=tenant_id,
- agent_id=agent_id,
- key=key,
- )
- storage_key = None
- size = None
- response_key = normalize_drive_key(key)
-
- if archive_ref is not None:
- archive_row, member_path = archive_ref
- payload = self._load_archive_member_bytes(
- tenant_id=tenant_id,
- archive_file_kind=archive_row.file_kind,
- archive_file_id=archive_row.file_id,
- member_path=member_path,
- session=session,
- )
- return self._preview_bytes(key=response_key, size=len(payload), payload=payload)
-
- data = bytearray()
- assert storage_key is not None
- for chunk in storage.load_stream(storage_key):
- data.extend(chunk)
- if len(data) > self.PREVIEW_MAX_BYTES:
- break
- return self._preview_bytes(key=response_key, size=size, payload=bytes(data))
-
- def preview_archive_member_for_ref(
- self,
- *,
- tenant_id: str,
- agent_id: str,
- key: str,
- archive_file_kind: AgentDriveFileKind,
- archive_file_id: str,
- member_path: str,
- session: Session,
- ) -> dict[str, Any]:
- self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
- payload = self._load_archive_member_bytes(
- tenant_id=tenant_id,
- archive_file_kind=archive_file_kind,
- archive_file_id=archive_file_id,
- member_path=member_path,
- session=session,
- )
- return self._preview_bytes(key=normalize_drive_key(key), size=len(payload), payload=payload)
-
- def download_url(self, *, tenant_id: str, agent_id: str, key: str, session: Session) -> str:
- """External signed URL for a browser download of one drive value."""
- self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
- try:
- row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
- except AgentDriveError:
- archive_row, member_path = self._archive_member_for_key(
- session,
- tenant_id=tenant_id,
- agent_id=agent_id,
- key=key,
- )
- return self.sign_archive_member_url(
- tenant_id=tenant_id,
- agent_id=agent_id,
- key=key,
- archive_file_kind=archive_row.file_kind,
- archive_file_id=archive_row.file_id,
- member_path=member_path,
- for_external=True,
- as_attachment=True,
- )
- url = self._resolve_download_url(
- tenant_id=tenant_id,
- file_kind=row.file_kind,
- file_id=row.file_id,
- for_external=True,
- as_attachment=True,
- )
- if url is None:
- raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404)
- return url
-
- def download_url_archive_member_for_ref(
- self,
- *,
- tenant_id: str,
- agent_id: str,
- key: str,
- archive_file_kind: AgentDriveFileKind,
- archive_file_id: str,
- member_path: str,
- session: Session,
- for_external: bool = True,
- ) -> str:
- self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
- return self.sign_archive_member_url(
- tenant_id=tenant_id,
- agent_id=agent_id,
- key=key,
- archive_file_kind=archive_file_kind,
- archive_file_id=archive_file_id,
- member_path=member_path,
- for_external=for_external,
- as_attachment=True,
- )
-
- @staticmethod
- def _secret_key() -> bytes:
- return dify_config.SECRET_KEY.encode()
-
- @classmethod
- def _archive_member_signature_payload(
- cls,
- *,
- tenant_id: str,
- agent_id: str,
- key: str,
- archive_file_kind: AgentDriveFileKind,
- archive_file_id: str,
- member_path: str,
- timestamp: str,
- nonce: str,
- ) -> str:
- return "|".join(
- [
- _ARCHIVE_MEMBER_DOWNLOAD_PURPOSE,
- tenant_id,
- agent_id,
- normalize_drive_key(key),
- archive_file_kind.value,
- archive_file_id,
- normalize_drive_key(member_path),
- timestamp,
- nonce,
- ]
- )
-
- @classmethod
- def _sign_archive_member_payload(cls, payload: str) -> str:
- digest = hmac.new(cls._secret_key(), payload.encode(), hashlib.sha256).digest()
- return base64.urlsafe_b64encode(digest).decode()
-
- @classmethod
- def sign_archive_member_url(
- cls,
- *,
- tenant_id: str,
- agent_id: str,
- key: str,
- archive_file_kind: AgentDriveFileKind,
- archive_file_id: str,
- member_path: str,
- for_external: bool,
- as_attachment: bool = False,
- ) -> str:
- base_url = dify_config.FILES_URL if for_external else (dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL)
- timestamp = str(int(time.time()))
- nonce = os.urandom(16).hex()
- payload = cls._archive_member_signature_payload(
- tenant_id=tenant_id,
- agent_id=agent_id,
- key=key,
- archive_file_kind=archive_file_kind,
- archive_file_id=archive_file_id,
- member_path=member_path,
- timestamp=timestamp,
- nonce=nonce,
- )
- query = urllib.parse.urlencode(
- {
- "tenant_id": tenant_id,
- "agent_id": agent_id,
- "key": normalize_drive_key(key),
- "archive_file_kind": archive_file_kind.value,
- "archive_file_id": archive_file_id,
- "member_path": normalize_drive_key(member_path),
- "timestamp": timestamp,
- "nonce": nonce,
- "sign": cls._sign_archive_member_payload(payload),
- "as_attachment": str(as_attachment).lower(),
- }
- )
- return f"{base_url}/files/agent-drive/archive-member?{query}"
-
- @classmethod
- def verify_archive_member_signature(
- cls,
- *,
- tenant_id: str,
- agent_id: str,
- key: str,
- archive_file_kind: AgentDriveFileKind,
- archive_file_id: str,
- member_path: str,
- timestamp: str,
- nonce: str,
- sign: str,
- ) -> bool:
- payload = cls._archive_member_signature_payload(
- tenant_id=tenant_id,
- agent_id=agent_id,
- key=key,
- archive_file_kind=archive_file_kind,
- archive_file_id=archive_file_id,
- member_path=member_path,
- timestamp=timestamp,
- nonce=nonce,
- )
- if sign != cls._sign_archive_member_payload(payload):
- return False
- current_time = int(time.time())
- return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT
-
- def load_archive_member_for_signed_request(
- self,
- *,
- tenant_id: str,
- agent_id: str,
- key: str,
- archive_file_kind: AgentDriveFileKind,
- archive_file_id: str,
- member_path: str,
- session: Session,
- ) -> tuple[bytes, str, str]:
- self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
- payload = self._load_archive_member_bytes(
- tenant_id=tenant_id,
- archive_file_kind=archive_file_kind,
- archive_file_id=archive_file_id,
- member_path=member_path,
- session=session,
- )
- mime_type = mimetypes.guess_type(member_path)[0] or "application/octet-stream"
- filename = normalize_drive_key(key).rsplit("/", 1)[-1]
- return payload, mime_type, filename
-
-
-__all__ = [
- "AgentDriveError",
- "AgentDriveService",
- "DriveCommitItem",
- "DriveFileRef",
- "DriveSkillMetadata",
- "decode_drive_mention_ref",
- "normalize_drive_key",
- "parse_agent_drive_ref",
-]
diff --git a/api/tasks/delete_conversation_task.py b/api/tasks/delete_conversation_task.py
index c9bf4ce9f17..4576582c90d 100644
--- a/api/tasks/delete_conversation_task.py
+++ b/api/tasks/delete_conversation_task.py
@@ -24,7 +24,6 @@ from models import (
PinnedConversation,
SavedMessage,
)
-from models.agent import AgentDriveFile, AgentDriveFileKind
from models.human_input import HumanInputDelivery, HumanInputFormRecipient
from models.tools import ToolConversationVariables, ToolFile
@@ -49,9 +48,7 @@ def _cleanup_conversation_related_data(conversation_id: str) -> bool:
"""Physically remove a soft-deleted conversation and its owned resources.
The storage object is deleted before its ``ToolFile`` row so a failed attempt
- retains the durable ``file_key`` needed by the next retry. ToolFiles promoted
- to Agent Drive are detached from the conversation, and their Drive references
- take over lifecycle ownership.
+ retains the durable ``file_key`` needed by the next retry.
"""
with session_factory.create_session() as session:
@@ -68,25 +65,7 @@ def _cleanup_conversation_related_data(conversation_id: str) -> bool:
.with_for_update()
)
)
- tool_file_ids = [tool_file.id for tool_file in tool_files]
- drive_files = list(
- session.scalars(
- select(AgentDriveFile)
- .where(
- AgentDriveFile.file_kind == AgentDriveFileKind.TOOL_FILE,
- AgentDriveFile.file_id.in_(tool_file_ids),
- )
- .order_by(AgentDriveFile.id)
- .with_for_update()
- )
- )
- drive_tool_file_ids = {drive_file.file_id for drive_file in drive_files}
- for drive_file in drive_files:
- drive_file.value_owned_by_drive = True
for tool_file in tool_files:
- if tool_file.id in drive_tool_file_ids:
- tool_file.conversation_id = None
- continue
_delete_storage_object(tool_file.file_key)
session.delete(tool_file)
diff --git a/api/tests/test_containers_integration_tests/tasks/test_delete_conversation_task.py b/api/tests/test_containers_integration_tests/tasks/test_delete_conversation_task.py
deleted file mode 100644
index b7b9e934cc4..00000000000
--- a/api/tests/test_containers_integration_tests/tasks/test_delete_conversation_task.py
+++ /dev/null
@@ -1,178 +0,0 @@
-from threading import Event, Thread
-from unittest.mock import patch
-
-from sqlalchemy import event, select
-from sqlalchemy.orm import Session
-
-from models import AppMode, Conversation, ToolFile
-from models.agent import AgentDriveFile, AgentDriveFileKind
-from models.enums import ConversationFromSource, ConversationStatus
-from tasks.delete_conversation_task import _cleanup_conversation_related_data
-
-TENANT_ID = "11111111-1111-1111-1111-111111111111"
-APP_ID = "22222222-2222-2222-2222-222222222222"
-ACCOUNT_ID = "33333333-3333-3333-3333-333333333333"
-CONVERSATION_ID = "44444444-4444-4444-4444-444444444444"
-AGENT_ID = "55555555-5555-5555-5555-555555555555"
-
-
-def test_cleanup_deletes_owned_storage_and_preserves_drive_file(
- db_session_with_containers: Session,
-) -> None:
- conversation = Conversation(
- id=CONVERSATION_ID,
- app_id=APP_ID,
- mode=AppMode.CHAT,
- name="Deleted conversation",
- inputs={},
- status=ConversationStatus.NORMAL,
- from_source=ConversationFromSource.CONSOLE,
- from_account_id=ACCOUNT_ID,
- is_deleted=True,
- )
- owned_file = ToolFile(
- user_id=ACCOUNT_ID,
- tenant_id=TENANT_ID,
- conversation_id=CONVERSATION_ID,
- file_key=f"tools/{TENANT_ID}/owned.txt",
- mimetype="text/plain",
- name="owned.txt",
- size=5,
- )
- drive_file = ToolFile(
- user_id=ACCOUNT_ID,
- tenant_id=TENANT_ID,
- conversation_id=CONVERSATION_ID,
- file_key=f"tools/{TENANT_ID}/drive.txt",
- mimetype="text/plain",
- name="drive.txt",
- size=5,
- )
- db_session_with_containers.add_all([conversation, owned_file, drive_file])
- db_session_with_containers.flush()
- drive_entry = AgentDriveFile(
- tenant_id=TENANT_ID,
- agent_id=AGENT_ID,
- key="drive.txt",
- file_kind=AgentDriveFileKind.TOOL_FILE,
- file_id=drive_file.id,
- value_owned_by_drive=False,
- is_skill=False,
- )
- db_session_with_containers.add(drive_entry)
- db_session_with_containers.commit()
- owned_file_id = owned_file.id
- drive_file_id = drive_file.id
-
- with patch("tasks.delete_conversation_task.storage") as storage_mock:
- assert _cleanup_conversation_related_data(CONVERSATION_ID) is True
-
- storage_mock.delete.assert_called_once_with(f"tools/{TENANT_ID}/owned.txt")
- db_session_with_containers.expire_all()
- assert db_session_with_containers.get(Conversation, CONVERSATION_ID) is None
- assert db_session_with_containers.get(ToolFile, owned_file_id) is None
- preserved = db_session_with_containers.get(ToolFile, drive_file_id)
- assert preserved is not None
- assert preserved.conversation_id is None
- preserved_drive_entry = db_session_with_containers.scalar(
- select(AgentDriveFile).where(AgentDriveFile.file_id == drive_file_id)
- )
- assert preserved_drive_entry is not None
- assert preserved_drive_entry.value_owned_by_drive is True
-
-
-def test_cleanup_preserves_drive_file_committed_while_waiting_for_tool_file_lock(
- db_session_with_containers: Session,
-) -> None:
- conversation = Conversation(
- id=CONVERSATION_ID,
- app_id=APP_ID,
- mode=AppMode.CHAT,
- name="Deleted conversation",
- inputs={},
- status=ConversationStatus.NORMAL,
- from_source=ConversationFromSource.CONSOLE,
- from_account_id=ACCOUNT_ID,
- is_deleted=True,
- )
- drive_file = ToolFile(
- user_id=ACCOUNT_ID,
- tenant_id=TENANT_ID,
- conversation_id=CONVERSATION_ID,
- file_key=f"tools/{TENANT_ID}/concurrent-drive.txt",
- mimetype="text/plain",
- name="concurrent-drive.txt",
- size=5,
- )
- db_session_with_containers.add_all([conversation, drive_file])
- db_session_with_containers.commit()
- drive_file_id = drive_file.id
-
- engine = db_session_with_containers.get_bind()
- drive_session = Session(engine)
- locked_file = drive_session.scalar(select(ToolFile).where(ToolFile.id == drive_file_id).with_for_update())
- assert locked_file is not None
- drive_session.add(
- AgentDriveFile(
- tenant_id=TENANT_ID,
- agent_id=AGENT_ID,
- key="concurrent-drive.txt",
- file_kind=AgentDriveFileKind.TOOL_FILE,
- file_id=drive_file_id,
- value_owned_by_drive=False,
- is_skill=False,
- )
- )
- drive_session.flush()
-
- cleanup_result: list[bool] = []
- cleanup_errors: list[BaseException] = []
-
- def run_cleanup() -> None:
- try:
- cleanup_result.append(_cleanup_conversation_related_data(CONVERSATION_ID))
- except BaseException as error:
- cleanup_errors.append(error)
-
- tool_file_lock_started = Event()
-
- def signal_tool_file_lock(
- _connection,
- _cursor,
- statement: str,
- _parameters,
- _context,
- _executemany,
- ) -> None:
- normalized_statement = statement.lower()
- if "from tool_files" in normalized_statement and "for update" in normalized_statement:
- tool_file_lock_started.set()
-
- event.listen(engine, "before_cursor_execute", signal_tool_file_lock)
- cleanup_thread = Thread(target=run_cleanup)
- try:
- with patch("tasks.delete_conversation_task.storage") as storage_mock:
- cleanup_thread.start()
- assert tool_file_lock_started.wait(timeout=5)
- drive_session.commit()
- cleanup_thread.join(timeout=5)
- finally:
- event.remove(engine, "before_cursor_execute", signal_tool_file_lock)
- drive_session.rollback()
- drive_session.close()
- cleanup_thread.join(timeout=5)
-
- assert not cleanup_thread.is_alive()
- assert cleanup_errors == []
- assert cleanup_result == [True]
- storage_mock.delete.assert_not_called()
-
- db_session_with_containers.expire_all()
- preserved = db_session_with_containers.get(ToolFile, drive_file_id)
- assert preserved is not None
- assert preserved.conversation_id is None
- preserved_drive_entry = db_session_with_containers.scalar(
- select(AgentDriveFile).where(AgentDriveFile.file_id == drive_file_id)
- )
- assert preserved_drive_entry is not None
- assert preserved_drive_entry.value_owned_by_drive is True
diff --git a/api/tests/unit_tests/.ruff.toml b/api/tests/unit_tests/.ruff.toml
index 38033743c9a..aa04b37fd7c 100644
--- a/api/tests/unit_tests/.ruff.toml
+++ b/api/tests/unit_tests/.ruff.toml
@@ -15,9 +15,7 @@ extend-select = ["ANN401", "ARG"]
"controllers/console/agent/test_agent_controllers.py" = ["ARG001", "ARG002", "ARG003", "ARG005", "TID251"]
"controllers/console/app/test_agent_app_sandbox.py" = ["ARG002", "ARG005"]
"controllers/console/app/test_agent_config_inspector.py" = ["ARG005"]
-"controllers/console/app/test_agent_drive_inspector.py" = ["ARG005"]
"controllers/console/app/test_agent_manage_guard.py" = ["ARG001"]
-"controllers/console/app/test_agent_skills.py" = ["ARG005"]
"controllers/console/app/test_annotation_security.py" = ["ARG002"]
"controllers/console/app/test_app_apis.py" = ["ARG001", "ARG002"]
"controllers/console/app/test_app_import_api.py" = ["ARG001", "ARG002", "ARG005"]
diff --git a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py
index 4f259ade3d6..978b522d509 100644
--- a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py
+++ b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py
@@ -16,7 +16,6 @@ from dify_agent.layers.dify_plugin import (
DifyPluginToolConfig,
DifyPluginToolsLayerConfig,
)
-from dify_agent.layers.drive import DifyDriveLayerConfig
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID
@@ -44,7 +43,7 @@ from clients.agent_backend import (
AgentBackendWorkflowNodeRunInput,
redact_for_agent_backend_log,
)
-from clients.agent_backend.request_builder import DIFY_DRIVE_LAYER_ID, DIFY_SHELL_LAYER_ID
+from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID
def _run_input() -> AgentBackendWorkflowNodeRunInput:
@@ -363,25 +362,6 @@ def test_workflow_request_builder_adds_shell_layer_when_include_shell():
assert shell_config.env[0].name == "PROJECT_NAME"
-def test_workflow_request_builder_binds_drive_to_shell_when_configured():
- run_input = _run_input()
- run_input.include_shell = True
- run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1")
-
- request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input)
- layers = {layer.name: layer for layer in request.composition.layers}
- layer_names = [layer.name for layer in request.composition.layers]
-
- assert layers[DIFY_SHELL_LAYER_ID].deps == {
- "execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
- "runtime": "runtime",
- }
- shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config)
- assert shell_config.agent_stub_drive_ref == "agent-agent-1"
- assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID}
- assert layer_names.index(DIFY_SHELL_LAYER_ID) < layer_names.index(DIFY_DRIVE_LAYER_ID)
-
-
def test_agent_app_request_builder_omits_shell_layer_by_default():
request = AgentBackendRunRequestBuilder().build_for_agent_app(_agent_app_input())
assert DIFY_SHELL_LAYER_ID not in {layer.name for layer in request.composition.layers}
@@ -417,24 +397,6 @@ def test_agent_app_request_builder_adds_shell_layer_when_include_shell():
assert shell_config.env[0].name == "APP_ENV"
-def test_agent_app_request_builder_binds_drive_to_shell_when_configured():
- run_input = _agent_app_input(include_shell=True)
- run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1")
-
- request = AgentBackendRunRequestBuilder().build_for_agent_app(run_input)
- layers = {layer.name: layer for layer in request.composition.layers}
- layer_names = [layer.name for layer in request.composition.layers]
-
- assert layers[DIFY_SHELL_LAYER_ID].deps == {
- "execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
- "runtime": "runtime",
- }
- shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config)
- assert shell_config.agent_stub_drive_ref == "agent-agent-1"
- assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID}
- assert layer_names.index(DIFY_SHELL_LAYER_ID) < layer_names.index(DIFY_DRIVE_LAYER_ID)
-
-
def test_agent_app_request_builder_adds_knowledge_layer_when_configured():
run_input = _agent_app_input()
run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate(
diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py
index d91005fa377..9cd219971db 100644
--- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py
+++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py
@@ -15,7 +15,6 @@ from controllers.console.agent import roster as roster_controller
from controllers.console.agent.composer import (
AgentComposerApi,
AgentComposerCandidatesApi,
- AgentComposerValidateApi,
WorkflowAgentComposerApi,
WorkflowAgentComposerCandidatesApi,
WorkflowAgentComposerCopyFromRosterApi,
@@ -260,10 +259,7 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
"/agent//build-draft",
"/agent//build-draft/apply",
"/agent//referencing-workflows",
- "/agent//drive/files",
"/agent//sandbox/files",
- "/agent//skills/upload",
- "/agent//files",
"/agent//api-access",
"/agent//api-enable",
"/agent//api-keys",
@@ -1328,10 +1324,6 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
lambda **kwargs: _workflow_composer_response(save_options=[kwargs["payload"].save_strategy.value]),
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
- monkeypatch.setattr(
- composer_controller.AgentComposerService, "resolve_workflow_node_agent_id", lambda **kwargs: None
- )
- monkeypatch.setattr(composer_controller.AgentComposerService, "resolve_bound_agent_id", lambda **kwargs: None)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"get_workflow_candidates",
@@ -1514,10 +1506,6 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
captured["save"] = kwargs
return _agent_app_composer_response()
- def collect_validation_findings(**kwargs: object) -> dict:
- captured["validate"] = kwargs
- return {"warnings": [], "knowledge_retrieval_placeholder": []}
-
def get_agent_app_candidates(**kwargs: object) -> dict:
captured["candidates"] = kwargs
return _candidates_response("agent_app")
@@ -1525,9 +1513,6 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
monkeypatch.setattr(composer_controller.AgentComposerService, "load_agent_composer", load_agent_composer)
monkeypatch.setattr(composer_controller.AgentComposerService, "save_agent_composer", save_agent_composer)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
- monkeypatch.setattr(
- composer_controller.AgentComposerService, "collect_validation_findings", collect_validation_findings
- )
monkeypatch.setattr(composer_controller.AgentComposerService, "get_agent_app_candidates", get_agent_app_candidates)
composer = unwrap(AgentComposerApi.get)(AgentComposerApi(), MagicMock(), "tenant-1", agent_id)
assert composer["variant"] == "agent_app"
@@ -1545,15 +1530,6 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
assert saved_composer["variant"] == "agent_app"
assert saved_composer["active_config_is_published"] is True
assert cast(dict[str, object], captured["save"])["agent_id"] == agent_id
- assert unwrap(AgentComposerValidateApi.post)(
- AgentComposerValidateApi(), composer_save_payload, MagicMock(), "tenant-1", agent_id
- ) == {
- "result": "success",
- "errors": [],
- "warnings": [],
- "knowledge_retrieval_placeholder": [],
- }
- assert cast(dict[str, object], captured["validate"])["agent_id"] == agent_id
candidates = unwrap(AgentComposerCandidatesApi.get)(
AgentComposerCandidatesApi(), MagicMock(), "tenant-1", account_id, agent_id
)
diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py b/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py
deleted file mode 100644
index ad38a927fcd..00000000000
--- a/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py
+++ /dev/null
@@ -1,310 +0,0 @@
-"""Unit tests for the console agent drive inspector (ENG-624).
-
-Handlers are unwrapped past the login/app-model decorators and invoked inside a
-bare Flask request context with the drive service mocked — covering agent
-resolution, query handling, and error mapping, not auth.
-"""
-
-from __future__ import annotations
-
-from inspect import unwrap
-from types import SimpleNamespace
-from unittest.mock import MagicMock, patch
-
-from flask import Flask
-from sqlalchemy.orm import Session
-
-from controllers.console.app import agent_drive_inspector as inspector
-from controllers.console.app.agent_drive_inspector import (
- AgentDriveDownloadApi,
- AgentDriveDownloadByAgentApi,
- AgentDriveListApi,
- AgentDriveListByAgentApi,
- AgentDrivePreviewApi,
- AgentDrivePreviewByAgentApi,
- AgentDriveSkillInspectApi,
- AgentDriveSkillInspectByAgentApi,
- AgentDriveSkillListApi,
- AgentDriveSkillListByAgentApi,
-)
-from services.agent_drive_service import AgentDriveError
-
-_MOD = "controllers.console.app.agent_drive_inspector"
-app = Flask(__name__)
-
-
-def _raw(method):
- return unwrap(method)
-
-
-_APP = SimpleNamespace(
- id="app-1",
- tenant_id="tenant-1",
- bound_agent_id_with_session=lambda *, session: "agent-1",
-)
-
-
-def test_resolve_bound_agent_uses_injected_session(unbound_session: Session):
- resolver = MagicMock(return_value="agent-1")
- app_model = SimpleNamespace(bound_agent_id_with_session=resolver)
- result = inspector._resolve_agent_id(unbound_session, app_model, None)
-
- assert result == "agent-1"
- resolver.assert_called_once_with(session=unbound_session)
- assert resolver.call_args.kwargs["session"] is unbound_session
-
-
-def test_list_filters_value_pointers_out_of_console_payload(unbound_session: Session):
- raw = _raw(AgentDriveListApi.get)
- with app.test_request_context("/?prefix=pdf-toolkit/"):
- with patch(f"{_MOD}.AgentDriveService") as drive:
- drive.return_value.manifest.return_value = [
- {
- "key": "pdf-toolkit/SKILL.md",
- "size": 5,
- "hash": "h",
- "mime_type": "text/markdown",
- "file_kind": "tool_file",
- "file_id": "tf-1",
- "created_at": 1718000000,
- }
- ]
- body = raw(AgentDriveListApi(), unbound_session, _APP)
- assert body["items"][0]["key"] == "pdf-toolkit/SKILL.md"
- assert "file_id" not in body["items"][0]
- assert drive.return_value.manifest.call_args.kwargs["prefix"] == "pdf-toolkit/"
-
-
-def test_list_by_agent_filters_value_pointers_out_of_console_payload(unbound_session: Session):
- raw = _raw(AgentDriveListByAgentApi.get)
- with app.test_request_context("/?prefix=pdf-toolkit/"):
- with (
- patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
- patch(f"{_MOD}.AgentDriveService") as drive,
- ):
- drive.return_value.manifest.return_value = [
- {
- "key": "pdf-toolkit/SKILL.md",
- "size": 5,
- "hash": "h",
- "mime_type": "text/markdown",
- "file_kind": "tool_file",
- "file_id": "tf-1",
- "created_at": 1718000000,
- }
- ]
- body = raw(AgentDriveListByAgentApi(), unbound_session, "tenant-1", "agent-1")
- assert body["items"][0]["key"] == "pdf-toolkit/SKILL.md"
- assert "file_id" not in body["items"][0]
- resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
- assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "agent-1"
- assert drive.return_value.manifest.call_args.kwargs["session"] is unbound_session
-
-
-def test_list_resolves_workflow_node_binding_agent(unbound_session: Session):
- raw = _raw(AgentDriveListApi.get)
- with app.test_request_context("/?node_id=agent-node-1"):
- with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive:
- composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
- drive.return_value.manifest.return_value = []
- raw(AgentDriveListApi(), unbound_session, _APP)
- assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "wf-agent-9"
- assert composer.resolve_workflow_node_agent_id.call_args.kwargs["node_id"] == "agent-node-1"
-
-
-def test_skill_list_by_agent_calls_service(unbound_session: Session):
- raw = _raw(AgentDriveSkillListByAgentApi.get)
- with app.test_request_context("/"):
- with (
- patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
- patch(f"{_MOD}.AgentDriveService") as drive,
- ):
- drive.return_value.list_skills.return_value = [
- {
- "path": "pdf-toolkit",
- "skill_md_key": "pdf-toolkit/SKILL.md",
- "archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip",
- "name": "PDF Toolkit",
- "description": "Work with PDFs.",
- "size": 5,
- "mime_type": "text/markdown",
- "hash": None,
- "created_at": 1718000000,
- }
- ]
- body = raw(AgentDriveSkillListByAgentApi(), unbound_session, "tenant-1", "agent-1")
- assert body["items"][0]["path"] == "pdf-toolkit"
- resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
- assert drive.return_value.list_skills.call_args.kwargs["agent_id"] == "agent-1"
- assert drive.return_value.list_skills.call_args.kwargs["session"] is unbound_session
-
-
-def test_skill_list_resolves_workflow_node_binding_agent(unbound_session: Session):
- raw = _raw(AgentDriveSkillListApi.get)
- with app.test_request_context("/?node_id=agent-node-1"):
- with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive:
- composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
- drive.return_value.list_skills.return_value = []
- body = raw(AgentDriveSkillListApi(), unbound_session, _APP)
- assert body == {"items": []}
- assert drive.return_value.list_skills.call_args.kwargs["agent_id"] == "wf-agent-9"
-
-
-def test_skill_inspect_by_agent_returns_strict_json_response(unbound_session: Session):
- raw = _raw(AgentDriveSkillInspectByAgentApi.get)
- payload = {
- "path": "pdf-toolkit",
- "skill_md_key": "pdf-toolkit/SKILL.md",
- "archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip",
- "name": "PDF Toolkit",
- "description": "Work with PDFs.",
- "size": 5,
- "mime_type": "text/markdown",
- "hash": None,
- "created_at": 1718000000,
- "source": "skill_md",
- "files": [
- {
- "path": "SKILL.md",
- "name": "SKILL.md",
- "type": "file",
- "drive_key": "pdf-toolkit/SKILL.md",
- "available_in_drive": True,
- }
- ],
- "file_tree": [],
- "skill_md": {
- "key": "pdf-toolkit/SKILL.md",
- "size": 5,
- "truncated": False,
- "binary": False,
- "text": "# PDF Toolkit\nUse it.\n",
- },
- "warnings": [],
- }
- with app.test_request_context("/"):
- with (
- patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
- patch(f"{_MOD}.AgentDriveService") as drive,
- ):
- drive.return_value.inspect_skill.return_value = payload
- response = raw(AgentDriveSkillInspectByAgentApi(), unbound_session, "tenant-1", "agent-1", "pdf-toolkit")
- assert response.status_code == 200
- assert response.get_json()["skill_md"]["text"] == "# PDF Toolkit\nUse it.\n"
- assert b"# PDF Toolkit\\nUse it.\\n" in response.get_data()
- assert drive.return_value.inspect_skill.call_args.kwargs["session"] is unbound_session
-
-
-def test_skill_inspect_resolves_workflow_node_binding_agent(unbound_session: Session):
- raw = _raw(AgentDriveSkillInspectApi.get)
- payload = {
- "path": "pdf-toolkit",
- "skill_md_key": "pdf-toolkit/SKILL.md",
- "archive_key": None,
- "name": "PDF Toolkit",
- "description": "",
- "size": 5,
- "mime_type": "text/markdown",
- "hash": None,
- "created_at": None,
- "source": "skill_md",
- "files": [],
- "file_tree": [],
- "skill_md": {"key": "pdf-toolkit/SKILL.md", "size": 5, "truncated": False, "binary": False, "text": "# hi"},
- "warnings": [],
- }
- with app.test_request_context("/?node_id=agent-node-1"):
- with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive:
- composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
- drive.return_value.inspect_skill.return_value = payload
- response = raw(AgentDriveSkillInspectApi(), unbound_session, _APP, "pdf-toolkit")
- assert response.get_json()["path"] == "pdf-toolkit"
- assert drive.return_value.inspect_skill.call_args.kwargs["agent_id"] == "wf-agent-9"
-
-
-def test_list_400_when_no_agent_bound(unbound_session: Session):
- raw = _raw(AgentDriveListApi.get)
- resolver = MagicMock(return_value=None)
- app_without_agent = SimpleNamespace(bound_agent_id_with_session=resolver)
- with app.test_request_context("/"):
- body, status = raw(AgentDriveListApi(), unbound_session, app_without_agent)
- assert status == 400
- assert body["code"] == "agent_not_bound"
- resolver.assert_called_once_with(session=unbound_session)
-
-
-def test_preview_passes_through_and_maps_errors(unbound_session: Session):
- raw = _raw(AgentDrivePreviewApi.get)
- with app.test_request_context("/?key=pdf-toolkit/SKILL.md"):
- with patch(f"{_MOD}.AgentDriveService") as drive:
- drive.return_value.preview.return_value = {
- "key": "pdf-toolkit/SKILL.md",
- "size": 5,
- "truncated": False,
- "binary": False,
- "text": "# hi",
- }
- body = raw(AgentDrivePreviewApi(), unbound_session, _APP)
- assert body["text"] == "# hi"
- with app.test_request_context("/?key=ghost/SKILL.md"):
- with patch(f"{_MOD}.AgentDriveService") as drive:
- drive.return_value.preview.side_effect = AgentDriveError(
- "drive_key_not_found", "no drive entry", status_code=404
- )
- body, status = raw(AgentDrivePreviewApi(), unbound_session, _APP)
- assert status == 404
- assert body["code"] == "drive_key_not_found"
-
-
-def test_preview_by_agent_passes_through_and_maps_errors(unbound_session: Session):
- raw = _raw(AgentDrivePreviewByAgentApi.get)
- with app.test_request_context("/?key=pdf-toolkit/SKILL.md"):
- with (
- patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
- patch(f"{_MOD}.AgentDriveService") as drive,
- ):
- drive.return_value.preview.return_value = {
- "key": "pdf-toolkit/SKILL.md",
- "size": 5,
- "truncated": False,
- "binary": False,
- "text": "# hi",
- }
- body = raw(AgentDrivePreviewByAgentApi(), unbound_session, "tenant-1", "agent-1")
- assert body["text"] == "# hi"
- resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
- assert drive.return_value.preview.call_args.kwargs["session"] is unbound_session
- with app.test_request_context("/?key=ghost/SKILL.md"):
- with (
- patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
- patch(f"{_MOD}.AgentDriveService") as drive,
- ):
- drive.return_value.preview.side_effect = AgentDriveError(
- "drive_key_not_found", "no drive entry", status_code=404
- )
- body, status = raw(AgentDrivePreviewByAgentApi(), unbound_session, "tenant-1", "agent-1")
- assert status == 404
- assert body["code"] == "drive_key_not_found"
-
-
-def test_download_returns_signed_url_json(unbound_session: Session):
- raw = _raw(AgentDriveDownloadApi.get)
- with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"):
- with patch(f"{_MOD}.AgentDriveService") as drive:
- drive.return_value.download_url.return_value = "https://signed.example/zip"
- body = raw(AgentDriveDownloadApi(), unbound_session, _APP)
- assert body == {"url": "https://signed.example/zip"}
-
-
-def test_download_by_agent_returns_signed_url_json(unbound_session: Session):
- raw = _raw(AgentDriveDownloadByAgentApi.get)
- with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"):
- with (
- patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
- patch(f"{_MOD}.AgentDriveService") as drive,
- ):
- drive.return_value.download_url.return_value = "https://signed.example/zip"
- body = raw(AgentDriveDownloadByAgentApi(), unbound_session, "tenant-1", "agent-1")
- assert body == {"url": "https://signed.example/zip"}
- resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
- assert drive.return_value.download_url.call_args.kwargs["session"] is unbound_session
diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_skills.py b/api/tests/unit_tests/controllers/console/app/test_agent_skills.py
deleted file mode 100644
index f0496fda088..00000000000
--- a/api/tests/unit_tests/controllers/console/app/test_agent_skills.py
+++ /dev/null
@@ -1,423 +0,0 @@
-"""Unit tests for the console agent Skill endpoints (ENG-370 / ENG-594).
-
-Handlers are unwrapped past the login/app-model decorators and invoked inside a
-bare Flask request context with the services mocked — covering request handling
-+ error mapping, not auth.
-"""
-
-from __future__ import annotations
-
-import io
-from datetime import UTC, datetime
-from inspect import unwrap
-from types import SimpleNamespace
-from unittest.mock import MagicMock, patch
-from uuid import uuid4
-
-import pytest
-from flask import Flask
-from sqlalchemy.orm import Session
-
-from controllers.console.app import agent as agent_controller
-from controllers.console.app.agent import (
- AgentDriveFilesByAgentApi,
- AgentSkillByAgentApi,
- AgentSkillInferToolsByAgentApi,
- AgentSkillUploadApi,
- AgentSkillUploadByAgentApi,
-)
-from extensions.storage.storage_type import StorageType
-from models.enums import CreatorUserRole
-from models.model import AppMode, UploadFile
-from services.agent.skill_package_service import SkillPackageError
-from services.agent_drive_service import AgentDriveError
-
-_MOD = "controllers.console.app.agent"
-app = Flask(__name__)
-_TENANT_ID = "00000000-0000-0000-0000-000000000010"
-_UPLOAD_FILE_ID = "0fa6f9bc-3416-4476-8857-a13129704dd9"
-
-
-def _raw(method):
- return unwrap(method)
-
-
-def _file_ctx(*, files: dict[str, bytes] | None = None):
- data = {name: (io.BytesIO(content), name) for name, content in (files or {}).items()}
- return app.test_request_context("/", method="POST", data=data, content_type="multipart/form-data")
-
-
-_USER = SimpleNamespace(id="user-1")
-_APP = SimpleNamespace(
- id="app-1",
- tenant_id=_TENANT_ID,
- mode=AppMode.AGENT,
- bound_agent_id_with_session=lambda *, session: "agent-1",
-)
-_WORKFLOW_APP = SimpleNamespace(
- id="app-1",
- tenant_id=_TENANT_ID,
- mode=AppMode.WORKFLOW,
- bound_agent_id_with_session=lambda *, session: None,
-)
-
-
-def _persist_upload(session: Session, *, name: str = "sample.pdf") -> UploadFile:
- upload = UploadFile(
- tenant_id=_TENANT_ID,
- storage_type=StorageType.LOCAL,
- key=f"uploads/{name}",
- name=name,
- size=5,
- extension="pdf",
- mime_type="application/pdf",
- created_by_role=CreatorUserRole.ACCOUNT,
- created_by=str(uuid4()),
- created_at=datetime.now(UTC),
- used=False,
- )
- upload.id = _UPLOAD_FILE_ID
- session.add(upload)
- session.commit()
- return upload
-
-
-def test_resolve_bound_agent_uses_injected_session(unbound_session: Session):
- resolver = MagicMock(return_value="agent-1")
- app_model = SimpleNamespace(bound_agent_id_with_session=resolver)
- result = agent_controller._resolve_agent_id(unbound_session, app_model, None)
-
- assert result == "agent-1"
- resolver.assert_called_once_with(session=unbound_session)
- assert resolver.call_args.kwargs["session"] is unbound_session
-
-
-def test_upload_standardizes_into_drive_and_returns_skill_ref(unbound_session: Session):
- raw = _raw(AgentSkillUploadApi.post)
- with _file_ctx(files={"file": b"zip-bytes"}):
- with patch(f"{_MOD}.SkillStandardizeService") as svc:
- svc.return_value.standardize.return_value = {
- "skill": {"path": "skill-a", "skill_md_key": "skill-a/SKILL.md"},
- "manifest": {"name": "Skill A"},
- }
- body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP)
- assert status == 201
- assert body["skill"] == {"path": "skill-a", "skill_md_key": "skill-a/SKILL.md"}
- assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1"
-
-
-def test_upload_by_agent_resolves_app_and_standardizes_into_drive(unbound_session: Session):
- raw = _raw(AgentSkillUploadByAgentApi.post)
- with _file_ctx(files={"file": b"zip-bytes"}):
- with (
- patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
- patch(f"{_MOD}.SkillStandardizeService") as svc,
- ):
- svc.return_value.standardize.return_value = {"skill": {"path": "skill-a"}, "manifest": {}}
- body, status = raw(AgentSkillUploadByAgentApi(), unbound_session, "tenant-1", _USER, "agent-1")
- assert status == 201
- assert body["skill"] == {"path": "skill-a"}
- resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
- assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1"
-
-
-def test_upload_no_file_is_400(unbound_session: Session):
- raw = _raw(AgentSkillUploadApi.post)
- with _file_ctx(files={}):
- body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP)
- assert status == 400
- assert body["code"] == "no_file"
-
-
-def test_upload_maps_package_error(unbound_session: Session):
- raw = _raw(AgentSkillUploadApi.post)
- with _file_ctx(files={"file": b"bad"}):
- with patch(f"{_MOD}.SkillStandardizeService") as svc:
- svc.return_value.standardize.side_effect = SkillPackageError(
- "missing_skill_md", "no SKILL.md", status_code=400
- )
- body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP)
- assert status == 400
- assert body["code"] == "missing_skill_md"
-
-
-def test_upload_no_bound_agent_is_400(unbound_session: Session):
- raw = _raw(AgentSkillUploadApi.post)
- resolver = MagicMock(return_value=None)
- app_without_agent = SimpleNamespace(bound_agent_id_with_session=resolver)
- with _file_ctx(files={"file": b"zip"}):
- body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, app_without_agent)
- assert status == 400
- assert body["code"] == "agent_not_bound"
- resolver.assert_called_once_with(session=unbound_session)
-
-
-def test_upload_resolves_workflow_node_agent(unbound_session: Session):
- raw = _raw(AgentSkillUploadApi.post)
- with app.test_request_context(
- "/?node_id=agent-node-1", method="POST", data={"file": (io.BytesIO(b"zip"), "skill.zip")}
- ):
- with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.SkillStandardizeService") as svc:
- composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
- svc.return_value.standardize.return_value = {"skill": {"path": "s"}, "manifest": {}}
- body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _WORKFLOW_APP)
- assert status == 201
- assert body["skill"] == {"path": "s"}
- assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "wf-agent-1"
-
-
-def test_upload_maps_drive_error(unbound_session: Session):
- raw = _raw(AgentSkillUploadApi.post)
- with _file_ctx(files={"file": b"zip"}):
- with patch(f"{_MOD}.SkillStandardizeService") as svc:
- svc.return_value.standardize.side_effect = AgentDriveError("source_not_found", "nope", status_code=404)
- body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP)
- assert status == 404
- assert body["code"] == "source_not_found"
-
-
-def _json_ctx(payload: dict | None = None, *, method: str = "POST", query_string: str = ""):
- return app.test_request_context(f"/?{query_string}", method=method, json=payload or {})
-
-
-def test_files_commit_validates_upload_and_returns_drive_ref(sqlite_session: Session):
- from controllers.console.app.agent import AgentDriveFilesApi
-
- raw = _raw(AgentDriveFilesApi.post)
- upload = _persist_upload(sqlite_session, name="sample qna.pdf")
- with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}):
- with patch(f"{_MOD}.console_ns") as ns, patch(f"{_MOD}.AgentDriveService") as drive:
- ns.payload = {"upload_file_id": _UPLOAD_FILE_ID}
- drive.return_value.commit.return_value = [
- {"key": "files/sample qna.pdf", "size": 5, "mime_type": "application/pdf"}
- ]
- body, status = raw(AgentDriveFilesApi(), sqlite_session, _USER, _APP)
- assert status == 201
- assert body["file"]["drive_key"] == "files/sample qna.pdf"
- assert body["file"]["file_id"] == upload.id
- item = drive.return_value.commit.call_args.kwargs["items"][0]
- assert item.value_owned_by_drive is True
- assert item.file_ref.kind == "upload_file"
-
-
-def test_files_by_agent_commit_uses_agent_route_and_ignores_node_id(sqlite_session: Session):
- raw = _raw(AgentDriveFilesByAgentApi.post)
- _persist_upload(sqlite_session)
- with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}, query_string="node_id=ignored"):
- with (
- patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
- patch(f"{_MOD}.console_ns") as ns,
- patch(f"{_MOD}.AgentDriveService") as drive,
- ):
- ns.payload = {"upload_file_id": _UPLOAD_FILE_ID}
- drive.return_value.commit.return_value = [
- {"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"}
- ]
- body, status = raw(AgentDriveFilesByAgentApi(), sqlite_session, "tenant-1", _USER, "agent-1")
- assert status == 201
- resolve_app.assert_called_once_with(session=sqlite_session, tenant_id="tenant-1", agent_id="agent-1")
-
-
-def test_files_commit_404_when_upload_not_in_tenant(sqlite_session: Session):
- from controllers.console.app.agent import AgentDriveFilesApi
-
- raw = _raw(AgentDriveFilesApi.post)
- other_upload = _persist_upload(sqlite_session)
- other_upload.tenant_id = str(uuid4())
- sqlite_session.commit()
- with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}):
- with patch(f"{_MOD}.console_ns") as ns:
- ns.payload = {"upload_file_id": _UPLOAD_FILE_ID}
- body, status = raw(AgentDriveFilesApi(), sqlite_session, _USER, _APP)
- assert status == 404
- assert body["code"] == "upload_file_not_found"
-
-
-def test_files_commit_resolves_workflow_node_agent(sqlite_session: Session):
- from controllers.console.app.agent import AgentDriveFilesApi
-
- raw = _raw(AgentDriveFilesApi.post)
- _persist_upload(sqlite_session)
- with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}, query_string="node_id=agent-node-1"):
- with (
- patch(f"{_MOD}.console_ns") as ns,
- patch(f"{_MOD}.AgentDriveService") as drive,
- patch(f"{_MOD}.AgentComposerService") as composer,
- ):
- ns.payload = {"upload_file_id": _UPLOAD_FILE_ID}
- composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
- drive.return_value.commit.return_value = [
- {"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"}
- ]
- body, status = raw(AgentDriveFilesApi(), sqlite_session, _USER, _WORKFLOW_APP)
- assert status == 201
- assert drive.return_value.commit.call_args.kwargs["agent_id"] == "wf-agent-1"
-
-
-def test_files_delete_updates_soul_then_drive(unbound_session: Session):
- from controllers.console.app.agent import AgentDriveFilesApi
-
- raw = _raw(AgentDriveFilesApi.delete)
- calls: list[str] = []
- with _json_ctx(method="DELETE", query_string="key=files/sample.pdf"):
- with patch(f"{_MOD}.AgentDriveService") as drive:
- drive.return_value.commit.side_effect = lambda **kw: (
- calls.append("drive") or [{"key": "files/sample.pdf", "removed": True}]
- )
- body = raw(AgentDriveFilesApi(), unbound_session, _USER, _APP)
- assert calls == ["drive"]
- assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]}
-
-
-def test_files_by_agent_delete_uses_agent_route_and_ignores_node_id(unbound_session: Session):
- raw = _raw(AgentDriveFilesByAgentApi.delete)
- with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=ignored"):
- with (
- patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
- patch(f"{_MOD}.AgentDriveService") as drive,
- ):
- drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}]
- body = raw(AgentDriveFilesByAgentApi(), unbound_session, "tenant-1", _USER, "agent-1")
- assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]}
- resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
-
-
-def test_files_delete_resolves_workflow_node_agent(unbound_session: Session):
- from controllers.console.app.agent import AgentDriveFilesApi
-
- raw = _raw(AgentDriveFilesApi.delete)
- with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=agent-node-1"):
- with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive:
- composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
- drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}]
- body = raw(AgentDriveFilesApi(), unbound_session, _USER, _WORKFLOW_APP)
- assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]}
- assert drive.return_value.commit.call_args.kwargs["agent_id"] == "wf-agent-1"
-
-
-def test_files_delete_survives_drive_failure(unbound_session: Session):
- from controllers.console.app.agent import AgentDriveFilesApi
-
- raw = _raw(AgentDriveFilesApi.delete)
- with _json_ctx(method="DELETE", query_string="key=files/sample.pdf"):
- with patch(f"{_MOD}.AgentDriveService") as drive:
- drive.return_value.commit.side_effect = RuntimeError("storage down")
- with pytest.raises(RuntimeError, match="storage down"):
- raw(AgentDriveFilesApi(), unbound_session, _USER, _APP)
-
-
-def test_skill_delete_uses_slug_prefix_and_is_idempotent(unbound_session: Session):
- from controllers.console.app.agent import AgentSkillApi
-
- raw = _raw(AgentSkillApi.delete)
- with _json_ctx(method="DELETE"):
- with patch(f"{_MOD}.AgentDriveService") as drive:
- drive.return_value.commit.return_value = [
- {"key": "tender-analyzer/SKILL.md", "removed": True},
- {"key": "tender-analyzer/.DIFY-SKILL-FULL.zip", "removed": True},
- ]
- body = raw(AgentSkillApi(), unbound_session, _USER, _APP, "tender-analyzer")
- assert body == {
- "result": "success",
- "removed_keys": ["tender-analyzer/SKILL.md", "tender-analyzer/.DIFY-SKILL-FULL.zip"],
- }
-
-
-def test_skill_delete_by_agent_uses_agent_route(unbound_session: Session):
- raw = _raw(AgentSkillByAgentApi.delete)
- with _json_ctx(method="DELETE", query_string="node_id=ignored"):
- with (
- patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
- patch(f"{_MOD}.AgentDriveService") as drive,
- ):
- drive.return_value.commit.return_value = [{"key": "tender-analyzer/SKILL.md", "removed": True}]
- body = raw(AgentSkillByAgentApi(), unbound_session, "tenant-1", _USER, "agent-1", "tender-analyzer")
- assert body == {"result": "success", "removed_keys": ["tender-analyzer/SKILL.md"]}
- resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
-
-
-def test_skill_delete_rejects_path_like_slug(unbound_session: Session):
- from controllers.console.app.agent import AgentSkillApi
-
- raw = _raw(AgentSkillApi.delete)
- with _json_ctx(method="DELETE"):
- body, status = raw(AgentSkillApi(), unbound_session, _USER, _APP, "a/b")
- assert status == 400
- assert body["code"] == "drive_key_invalid"
-
-
-def test_infer_tools_returns_draft_suggestions(unbound_session: Session):
- from controllers.console.app.agent import AgentSkillInferToolsApi
-
- raw = _raw(AgentSkillInferToolsApi.post)
- with _json_ctx():
- with patch(f"{_MOD}.SkillToolInferenceService") as svc:
- svc.return_value.infer.return_value = {
- "inferable": True,
- "cli_tools": [{"name": "ffmpeg", "inferred_from": "audio-transcribe"}],
- "reason": None,
- }
- body = raw(AgentSkillInferToolsApi(), unbound_session, _APP, "audio-transcribe")
- assert body["inferable"] is True
- assert svc.return_value.infer.call_args.kwargs["slug"] == "audio-transcribe"
-
-
-def test_infer_tools_by_agent_uses_agent_route(unbound_session: Session):
- raw = _raw(AgentSkillInferToolsByAgentApi.post)
- with _json_ctx(query_string="node_id=ignored"):
- with (
- patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
- patch(f"{_MOD}.SkillToolInferenceService") as svc,
- ):
- svc.return_value.infer.return_value = {"inferable": True, "cli_tools": [], "reason": None}
- body = raw(
- AgentSkillInferToolsByAgentApi(),
- unbound_session,
- "tenant-1",
- "agent-1",
- "audio-transcribe",
- )
- assert body["inferable"] is True
- resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
- assert svc.return_value.infer.call_args.kwargs["agent_id"] == "agent-1"
-
-
-def test_infer_tools_resolves_workflow_node_agent(unbound_session: Session):
- from controllers.console.app.agent import AgentSkillInferToolsApi
-
- raw = _raw(AgentSkillInferToolsApi.post)
- with _json_ctx(query_string="node_id=agent-node-1"):
- with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.SkillToolInferenceService") as svc:
- composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
- svc.return_value.infer.return_value = {"inferable": False, "cli_tools": [], "reason": "none"}
- body = raw(AgentSkillInferToolsApi(), unbound_session, _WORKFLOW_APP, "audio-transcribe")
- assert body["inferable"] is False
- assert svc.return_value.infer.call_args.kwargs["agent_id"] == "wf-agent-1"
-
-
-def test_infer_tools_maps_inference_errors(unbound_session: Session):
- from controllers.console.app.agent import AgentSkillInferToolsApi
- from services.agent.skill_tool_inference_service import SkillToolInferenceError
-
- raw = _raw(AgentSkillInferToolsApi.post)
- with _json_ctx():
- with patch(f"{_MOD}.SkillToolInferenceService") as svc:
- svc.return_value.infer.side_effect = SkillToolInferenceError(
- "default_model_not_configured", "no model", status_code=400
- )
- body, status = raw(AgentSkillInferToolsApi(), unbound_session, _APP, "audio-transcribe")
- assert status == 400
- assert body["code"] == "default_model_not_configured"
-
-
-def test_infer_tools_rejects_path_like_slug_and_unbound_app(unbound_session: Session):
- from controllers.console.app.agent import AgentSkillInferToolsApi
-
- raw = _raw(AgentSkillInferToolsApi.post)
- with _json_ctx():
- body, status = raw(AgentSkillInferToolsApi(), unbound_session, _APP, "a/b")
- assert (status, body["code"]) == (400, "drive_key_invalid")
- app_without_agent = SimpleNamespace(bound_agent_id_with_session=MagicMock(return_value=None))
- with _json_ctx():
- body, status = raw(AgentSkillInferToolsApi(), unbound_session, app_without_agent, "x")
- assert (status, body["code"]) == (400, "agent_not_bound")
diff --git a/api/tests/unit_tests/controllers/inner_api/plugin/test_agent_drive.py b/api/tests/unit_tests/controllers/inner_api/plugin/test_agent_drive.py
deleted file mode 100644
index 08b336e1d31..00000000000
--- a/api/tests/unit_tests/controllers/inner_api/plugin/test_agent_drive.py
+++ /dev/null
@@ -1,172 +0,0 @@
-"""Unit tests for the agent drive inner-API controller (ENG-591).
-
-Handlers are unwrapped past the auth/setup decorators and invoked inside a bare
-Flask request context, with AgentDriveService mocked — so this covers the
-controller's request parsing + error mapping, not auth (tested separately).
-"""
-
-from __future__ import annotations
-
-import inspect
-from unittest.mock import ANY, patch
-
-import pytest
-from flask import Flask
-
-from controllers.inner_api.plugin.agent_drive import AgentDriveCommitApi, AgentDriveManifestApi, AgentDriveSkillsApi
-from models.enums import EndUserType
-from models.model import EndUser
-from services.agent_drive_service import AgentDriveError
-
-_MOD = "controllers.inner_api.plugin.agent_drive"
-app = Flask(__name__)
-
-
-def _raw(method):
- return inspect.unwrap(method)
-
-
-def _end_user(user_id: str) -> EndUser:
- return EndUser(
- id=user_id,
- tenant_id="tenant-1",
- type=EndUserType.SERVICE_API,
- session_id="session-1",
- )
-
-
-def test_manifest_parses_query_and_returns_items():
- raw = _raw(AgentDriveManifestApi.get)
- with app.test_request_context("/?tenant_id=tenant-1&prefix=docs/&include_download_url=true"):
- with patch(f"{_MOD}.AgentDriveService") as svc:
- svc.return_value.manifest.return_value = [{"key": "docs/a.txt"}]
- result = raw(AgentDriveManifestApi(), "agent-agent-1")
- assert result == {"items": [{"key": "docs/a.txt"}]}
- svc.return_value.manifest.assert_called_once_with(
- tenant_id="tenant-1", agent_id="agent-1", prefix="docs/", include_download_url=True, session=ANY
- )
-
-
-def test_manifest_missing_tenant_id_is_400():
- raw = _raw(AgentDriveManifestApi.get)
- with app.test_request_context("/"):
- body, status = raw(AgentDriveManifestApi(), "agent-agent-1")
- assert status == 400
- assert body["code"] == "missing_tenant_id"
-
-
-def test_manifest_bad_drive_ref_is_400():
- raw = _raw(AgentDriveManifestApi.get)
- with app.test_request_context("/?tenant_id=tenant-1"):
- body, status = raw(AgentDriveManifestApi(), "not-an-agent-ref")
- assert status == 400
- assert body["code"] == "invalid_drive_ref"
-
-
-def test_skills_requires_tenant_id_and_returns_items():
- raw = _raw(AgentDriveSkillsApi.get)
-
- with app.test_request_context("/"):
- body, status = raw(AgentDriveSkillsApi(), "agent-agent-1")
- assert status == 400
- assert body["code"] == "missing_tenant_id"
-
- with app.test_request_context("/?tenant_id=tenant-1"):
- with patch(f"{_MOD}.AgentDriveService") as svc:
- svc.return_value.list_skills.return_value = [
- {
- "path": "tender-analyzer",
- "skill_md_key": "tender-analyzer/SKILL.md",
- "archive_key": None,
- "name": "Tender Analyzer",
- "description": "Parses RFPs.",
- }
- ]
- result = raw(AgentDriveSkillsApi(), "agent-agent-1")
-
- assert result == {
- "items": [
- {
- "path": "tender-analyzer",
- "skill_md_key": "tender-analyzer/SKILL.md",
- "archive_key": None,
- "name": "Tender Analyzer",
- "description": "Parses RFPs.",
- }
- ]
- }
- assert svc.return_value.list_skills.call_args.kwargs == {
- "tenant_id": "tenant-1",
- "agent_id": "agent-1",
- "session": ANY,
- }
-
-
-def test_commit_parses_body_and_returns_items():
- raw = _raw(AgentDriveCommitApi.post)
- payload = {
- "tenant_id": "tenant-1",
- "user_id": "user-1",
- "items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}],
- }
- with app.test_request_context("/", method="POST", json=payload):
- with (
- patch(f"{_MOD}.get_user", return_value=_end_user("user-1")) as get_user,
- patch(f"{_MOD}.AgentDriveService") as svc,
- ):
- svc.return_value.commit.return_value = [{"key": "a.txt"}]
- result = raw(AgentDriveCommitApi(), "agent-agent-1")
- assert result == {"items": [{"key": "a.txt"}]}
- assert get_user.call_args.args == ("tenant-1", "user-1")
- assert svc.return_value.commit.call_args.kwargs["agent_id"] == "agent-1"
- assert svc.return_value.commit.call_args.kwargs["user_id"] == "user-1"
-
-
-def test_commit_canonicalizes_user_before_service_call():
- raw = _raw(AgentDriveCommitApi.post)
- payload = {
- "tenant_id": "tenant-1",
- "user_id": "session-1",
- "items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}],
- }
- with app.test_request_context("/", method="POST", json=payload):
- with (
- patch(f"{_MOD}.get_user", return_value=_end_user("end-user-1")),
- patch(f"{_MOD}.AgentDriveService") as svc,
- ):
- svc.return_value.commit.return_value = [{"key": "a.txt"}]
- result = raw(AgentDriveCommitApi(), "agent-agent-1")
-
- assert result == {"items": [{"key": "a.txt"}]}
- assert svc.return_value.commit.call_args.kwargs["user_id"] == "end-user-1"
-
-
-def test_commit_invalid_body_is_400():
- raw = _raw(AgentDriveCommitApi.post)
- with app.test_request_context("/", method="POST", json={"tenant_id": "t"}): # missing user_id/items
- body, status = raw(AgentDriveCommitApi(), "agent-agent-1")
- assert status == 400
- assert body["code"] == "invalid_request"
-
-
-def test_commit_maps_service_error():
- raw = _raw(AgentDriveCommitApi.post)
- payload = {
- "tenant_id": "tenant-1",
- "user_id": "user-1",
- "items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}],
- }
- with app.test_request_context("/", method="POST", json=payload):
- with (
- patch(f"{_MOD}.get_user", return_value=_end_user("user-1")),
- patch(f"{_MOD}.AgentDriveService") as svc,
- ):
- svc.return_value.commit.side_effect = AgentDriveError("source_not_found", "nope", status_code=404)
- body, status = raw(AgentDriveCommitApi(), "agent-agent-1")
- assert status == 404
- assert body["code"] == "source_not_found"
-
-
-@pytest.mark.parametrize("api_cls", [AgentDriveManifestApi, AgentDriveSkillsApi, AgentDriveCommitApi])
-def test_endpoints_have_handlers(api_cls):
- assert callable(getattr(api_cls(), "get", None) or getattr(api_cls(), "post", None))
diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py
index 65e3d8f84d3..d40db52fbc1 100644
--- a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py
+++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py
@@ -495,7 +495,6 @@ class TestAgentAppConfigLayer:
"execution_context": "execution_context",
"runtime": "runtime",
}
- assert layers[DIFY_SHELL_LAYER_ID].config.agent_stub_drive_ref is None
def test_config_layer_for_build_draft_marks_config_writable(self):
builder = AgentAppRuntimeRequestBuilder(
diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py
index 292d1adf31b..a8ac2f9934c 100644
--- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py
+++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py
@@ -1476,7 +1476,6 @@ def test_workflow_run_request_has_config_layer_with_empty_agent_soul(monkeypatch
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
"runtime": "runtime",
}
- assert layers[DIFY_SHELL_LAYER_ID]["config"]["agent_stub_drive_ref"] is None
def test_workflow_run_request_contains_config_layer():
diff --git a/api/tests/unit_tests/migrations/test_agent_drive_skill_metadata_refactor.py b/api/tests/unit_tests/migrations/test_agent_drive_skill_metadata_refactor.py
deleted file mode 100644
index 691a1c61cc5..00000000000
--- a/api/tests/unit_tests/migrations/test_agent_drive_skill_metadata_refactor.py
+++ /dev/null
@@ -1,122 +0,0 @@
-from __future__ import annotations
-
-import importlib.util
-import json
-from pathlib import Path
-
-import sqlalchemy as sa
-from alembic.migration import MigrationContext
-from alembic.operations import Operations
-
-_MIGRATION_PATH = (
- Path(__file__).resolve().parents[3]
- / "migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py"
-)
-
-
-def _load_migration_module():
- spec = importlib.util.spec_from_file_location("agent_drive_skill_metadata_refactor", _MIGRATION_PATH)
- if spec is None or spec.loader is None:
- raise RuntimeError("failed to load migration module")
- module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module)
- return module
-
-
-def _create_pre_upgrade_schema(engine: sa.Engine) -> None:
- metadata = sa.MetaData()
- sa.Table(
- "agent_drive_files",
- metadata,
- sa.Column("tenant_id", sa.String(36), nullable=False),
- sa.Column("agent_id", sa.String(36), nullable=False),
- sa.Column("key", sa.String(512), nullable=False),
- sa.Column("file_kind", sa.String(32), nullable=False),
- sa.Column("file_id", sa.String(36), nullable=False),
- sa.Column("value_owned_by_drive", sa.Boolean(), nullable=False, server_default=sa.text("false")),
- sa.Column("size", sa.BigInteger(), nullable=True),
- sa.Column("hash", sa.String(255), nullable=True),
- sa.Column("mime_type", sa.String(255), nullable=True),
- sa.Column("created_by", sa.String(36), nullable=True),
- sa.Column("id", sa.String(36), primary_key=True),
- sa.Column("created_at", sa.DateTime(), nullable=False),
- sa.Column("updated_at", sa.DateTime(), nullable=False),
- sa.UniqueConstraint("tenant_id", "agent_id", "key", name="agent_drive_file_scope_key_unique"),
- )
- sa.Table(
- "agent_config_snapshots",
- metadata,
- sa.Column("id", sa.String(36), primary_key=True),
- sa.Column("config_snapshot", sa.Text(), nullable=False),
- )
- metadata.create_all(engine)
-
-
-def _run_migration_step(module: object, engine: sa.Engine, step_name: str) -> None:
- with engine.begin() as connection:
- context = MigrationContext.configure(connection)
- operations = Operations(context)
- original_op = module.op
- module.op = operations
- try:
- getattr(module, step_name)()
- finally:
- module.op = original_op
-
-
-def test_upgrade_adds_skill_columns_and_index_and_preserves_snapshot_data() -> None:
- engine = sa.create_engine("sqlite:///:memory:")
- _create_pre_upgrade_schema(engine)
- snapshot = {
- "prompt": {"system_prompt": "Use [§skill:legacy:Legacy§]"},
- "skills_files": {"skills": [{"name": "Legacy"}], "files": [{"name": "u.pdf"}]},
- }
- with engine.begin() as connection:
- connection.execute(
- sa.text("INSERT INTO agent_config_snapshots (id, config_snapshot) VALUES (:id, :config_snapshot)"),
- {"id": "snap-1", "config_snapshot": json.dumps(snapshot)},
- )
-
- module = _load_migration_module()
- _run_migration_step(module, engine, "upgrade")
-
- inspector = sa.inspect(engine)
- columns = {column["name"] for column in inspector.get_columns("agent_drive_files")}
- assert {"is_skill", "skill_metadata"}.issubset(columns)
- indexes = {index["name"] for index in inspector.get_indexes("agent_drive_files")}
- assert "agent_drive_files_tenant_agent_is_skill_key_idx" in indexes
-
- with engine.begin() as connection:
- stored_snapshot = connection.execute(
- sa.text("SELECT config_snapshot FROM agent_config_snapshots WHERE id = :id"),
- {"id": "snap-1"},
- ).scalar_one()
- assert json.loads(stored_snapshot) == snapshot
-
-
-def test_downgrade_drops_skill_columns_and_index_without_reconstructing_legacy_data() -> None:
- engine = sa.create_engine("sqlite:///:memory:")
- _create_pre_upgrade_schema(engine)
- with engine.begin() as connection:
- connection.execute(
- sa.text("INSERT INTO agent_config_snapshots (id, config_snapshot) VALUES (:id, :config_snapshot)"),
- {"id": "snap-1", "config_snapshot": json.dumps({"prompt": {"system_prompt": "hello"}})},
- )
-
- module = _load_migration_module()
- _run_migration_step(module, engine, "upgrade")
- _run_migration_step(module, engine, "downgrade")
-
- inspector = sa.inspect(engine)
- columns = {column["name"] for column in inspector.get_columns("agent_drive_files")}
- assert "is_skill" not in columns
- assert "skill_metadata" not in columns
- indexes = {index["name"] for index in inspector.get_indexes("agent_drive_files")}
- assert "agent_drive_files_tenant_agent_is_skill_key_idx" not in indexes
-
- with engine.begin() as connection:
- stored_snapshot = connection.execute(
- sa.text("SELECT config_snapshot FROM agent_config_snapshots WHERE id = :id"),
- {"id": "snap-1"},
- ).scalar_one()
- assert "skills_files" not in json.loads(stored_snapshot)
diff --git a/api/tests/unit_tests/migrations/test_remove_agent_drive.py b/api/tests/unit_tests/migrations/test_remove_agent_drive.py
new file mode 100644
index 00000000000..a6b7ce10d13
--- /dev/null
+++ b/api/tests/unit_tests/migrations/test_remove_agent_drive.py
@@ -0,0 +1,184 @@
+from __future__ import annotations
+
+import importlib.util
+import json
+from io import StringIO
+from pathlib import Path
+from types import ModuleType
+
+import pytest
+import sqlalchemy as sa
+from alembic.migration import MigrationContext
+from alembic.operations import Operations
+
+_MIGRATION_PATH = (
+ Path(__file__).resolve().parents[3] / "migrations/versions/2026_08_17_1740-89919253ca7a_remove_agent_drive.py"
+)
+
+
+def _load_migration_module() -> ModuleType:
+ spec = importlib.util.spec_from_file_location("remove_agent_drive", _MIGRATION_PATH)
+ if spec is None or spec.loader is None:
+ raise RuntimeError("failed to load migration module")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def _create_pre_upgrade_schema(engine: sa.Engine) -> None:
+ metadata = sa.MetaData()
+ sa.Table("agent_drive_files", metadata, sa.Column("id", sa.String(36), primary_key=True))
+ for table_name in ("agent_config_snapshots", "agent_config_drafts"):
+ sa.Table(
+ table_name,
+ metadata,
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("config_snapshot", sa.Text(), nullable=False),
+ )
+ sa.Table(
+ "workflow_agent_node_bindings",
+ metadata,
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("node_job_config", sa.Text(), nullable=False),
+ )
+ metadata.create_all(engine)
+
+
+def _run_migration_step(module: ModuleType, engine: sa.Engine, step_name: str) -> None:
+ migration_step = module.__dict__[step_name]
+ if not callable(migration_step):
+ raise TypeError(f"migration step {step_name!r} is not callable")
+
+ with engine.begin() as connection:
+ operations = Operations(MigrationContext.configure(connection))
+ original_op = module.__dict__["op"]
+ module.__dict__["op"] = operations
+ try:
+ migration_step()
+ finally:
+ module.__dict__["op"] = original_op
+
+
+def test_upgrade_removes_agent_drive_schema_and_legacy_json_fields() -> None:
+ engine = sa.create_engine("sqlite:///:memory:")
+ _create_pre_upgrade_schema(engine)
+ soul = {
+ "files": {"skills": [{"name": "legacy"}]},
+ "config_skills": [{"name": "current", "file_id": "tool-1"}],
+ "prompt": {"system_prompt": "hello"},
+ }
+ node_job = {
+ "metadata": {
+ "file_refs": [
+ {"id": "upload-1", "drive_key": "files/input.pdf"},
+ {"id": "upload-2"},
+ ]
+ },
+ "declared_outputs": [
+ {
+ "name": "report",
+ "type": "file",
+ "check": {"benchmark_file_ref": {"id": "upload-3", "drive_key": "files/reference.pdf"}},
+ }
+ ],
+ }
+ with engine.begin() as connection:
+ for table_name in ("agent_config_snapshots", "agent_config_drafts"):
+ connection.execute(
+ sa.text(f"INSERT INTO {table_name} (id, config_snapshot) VALUES (:id, :value)"),
+ {"id": table_name, "value": json.dumps(soul)},
+ )
+ connection.execute(
+ sa.text("INSERT INTO workflow_agent_node_bindings (id, node_job_config) VALUES (:id, :value)"),
+ {"id": "binding-1", "value": json.dumps(node_job)},
+ )
+
+ module = _load_migration_module()
+ _run_migration_step(module, engine, "upgrade")
+
+ assert "agent_drive_files" not in sa.inspect(engine).get_table_names()
+ with engine.begin() as connection:
+ for table_name in ("agent_config_snapshots", "agent_config_drafts"):
+ stored = connection.execute(sa.text(f"SELECT config_snapshot FROM {table_name}")).scalar_one()
+ value = json.loads(stored)
+ assert "files" not in value
+ assert value["config_skills"] == soul["config_skills"]
+ assert value["prompt"] == soul["prompt"]
+ stored_node_job = connection.execute(
+ sa.text("SELECT node_job_config FROM workflow_agent_node_bindings")
+ ).scalar_one()
+
+ migrated_node_job = json.loads(stored_node_job)
+ assert migrated_node_job["metadata"]["file_refs"] == [{"id": "upload-1"}, {"id": "upload-2"}]
+ assert migrated_node_job["declared_outputs"][0]["check"]["benchmark_file_ref"] == {"id": "upload-3"}
+
+ _run_migration_step(module, engine, "downgrade")
+ inspector = sa.inspect(engine)
+ assert "agent_drive_files" in inspector.get_table_names()
+ assert {
+ "tenant_id",
+ "agent_id",
+ "key",
+ "file_kind",
+ "file_id",
+ "value_owned_by_drive",
+ "is_skill",
+ "skill_metadata",
+ }.issubset({column["name"] for column in inspector.get_columns("agent_drive_files")})
+ assert "agent_drive_file_scope_key_unique" in {
+ constraint["name"] for constraint in inspector.get_unique_constraints("agent_drive_files")
+ }
+ assert "agent_drive_files_tenant_agent_is_skill_key_idx" in {
+ index["name"] for index in inspector.get_indexes("agent_drive_files")
+ }
+
+
+def test_upgrade_supports_offline_sql_generation() -> None:
+ module = _load_migration_module()
+ output = StringIO()
+ migration_context = MigrationContext.configure(
+ dialect_name="postgresql",
+ opts={"as_sql": True, "output_buffer": output},
+ )
+ operations = Operations(migration_context)
+ migration_step = module.__dict__["upgrade"]
+ if not callable(migration_step):
+ raise TypeError("migration upgrade is not callable")
+
+ original_op = module.__dict__["op"]
+ module.__dict__["op"] = operations
+ try:
+ migration_step()
+ finally:
+ module.__dict__["op"] = original_op
+
+ generated_sql = output.getvalue()
+ assert "DROP TABLE agent_drive_files" in generated_sql
+ assert "SELECT id" not in generated_sql
+
+
+@pytest.mark.parametrize(
+ ("table_name", "column_name"),
+ [
+ pytest.param("agent_config_snapshots", "config_snapshot", id="config-snapshot"),
+ pytest.param("workflow_agent_node_bindings", "node_job_config", id="node-job-config"),
+ ],
+)
+def test_upgrade_rejects_invalid_json_without_rewriting(table_name: str, column_name: str) -> None:
+ engine = sa.create_engine("sqlite:///:memory:")
+ _create_pre_upgrade_schema(engine)
+ invalid_json = "not-json"
+ with engine.begin() as connection:
+ connection.execute(
+ sa.text(f"INSERT INTO {table_name} (id, {column_name}) VALUES (:id, :value)"),
+ {"id": "invalid-row", "value": invalid_json},
+ )
+
+ module = _load_migration_module()
+ with pytest.raises(json.JSONDecodeError):
+ _run_migration_step(module, engine, "upgrade")
+
+ with engine.begin() as connection:
+ stored = connection.execute(sa.text(f"SELECT {column_name} FROM {table_name}")).scalar_one()
+ assert stored == invalid_json
+ assert "agent_drive_files" in sa.inspect(engine).get_table_names()
diff --git a/api/tests/unit_tests/pyrefly.toml b/api/tests/unit_tests/pyrefly.toml
index 76d1a8c5e76..2b0337fde19 100644
--- a/api/tests/unit_tests/pyrefly.toml
+++ b/api/tests/unit_tests/pyrefly.toml
@@ -39,9 +39,7 @@ project-excludes = [
"controllers/console/agent/test_agent_controllers.py",
"controllers/console/app/test_agent_app_sandbox.py",
"controllers/console/app/test_agent_config_inspector.py",
- "controllers/console/app/test_agent_drive_inspector.py",
"controllers/console/app/test_agent_manage_guard.py",
- "controllers/console/app/test_agent_skills.py",
"controllers/console/app/test_annotation_api.py",
"controllers/console/app/test_annotation_security.py",
"controllers/console/app/test_app_apis.py",
@@ -146,7 +144,6 @@ project-excludes = [
"controllers/files/test_upload.py",
"controllers/inner_api/app/test_dsl.py",
"controllers/inner_api/plugin/test_agent_config.py",
- "controllers/inner_api/plugin/test_agent_drive.py",
"controllers/inner_api/plugin/test_plugin.py",
"controllers/inner_api/plugin/test_plugin_wraps.py",
"controllers/inner_api/test_auth_wraps.py",
@@ -723,7 +720,6 @@ project-excludes = [
"libs/test_workspace_member_helper.py",
"libs/test_workspace_permission.py",
"libs/test_yarl.py",
- "migrations/test_agent_drive_skill_metadata_refactor.py",
"migrations/test_uuidv7_pg18_migration.py",
"models/test_account_models.py",
"models/test_agent.py",
@@ -819,7 +815,6 @@ project-excludes = [
"services/test_agent_app_feature_service.py",
"services/test_agent_app_sandbox_service.py",
"services/test_agent_config_service.py",
- "services/test_agent_drive_service.py",
"services/test_annotation_service.py",
"services/test_api_token_service.py",
"services/test_app_generate_service.py",
diff --git a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py
index efc29ffb602..f18d58e3032 100644
--- a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py
+++ b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py
@@ -44,24 +44,6 @@ def test_workflow_variant_rejects_agent_app_only_fields():
)
-def test_workflow_variant_accepts_agent_soul_files_section():
- payload = ComposerSavePayload.model_validate(
- {
- "variant": ComposerVariant.WORKFLOW,
- "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY,
- "agent_soul": {
- "schema_version": 1,
- "prompt": {"system_prompt": "jjjj"},
- "files": {"skills": [], "files": []},
- },
- }
- )
-
- assert payload.agent_soul is not None
- assert payload.agent_soul.files.skills == []
- assert payload.agent_soul.files.files == []
-
-
def test_agent_app_variant_rejects_workflow_node_job():
with pytest.raises(ValueError):
ComposerSavePayload.model_validate(
diff --git a/api/tests/unit_tests/services/agent/test_agent_dsl_service.py b/api/tests/unit_tests/services/agent/test_agent_dsl_service.py
index fa188ecb8f8..545db0a93b3 100644
--- a/api/tests/unit_tests/services/agent/test_agent_dsl_service.py
+++ b/api/tests/unit_tests/services/agent/test_agent_dsl_service.py
@@ -18,7 +18,7 @@ from models.agent import (
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
-from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
+from models.agent_config_entities import AgentConfigFileRefConfig, AgentConfigSkillRefConfig, AgentSoulConfig
from services.agent.dsl_entities import (
AGENT_NODE_JOB_DSL_KEY,
AGENT_PACKAGE_REF_KEY,
@@ -465,44 +465,41 @@ def test_import_workflow_packages_rejects_invalid_package_binding(binding: dict,
)
-def test_clone_inline_binding_copies_soul_and_drive_rows(monkeypatch: pytest.MonkeyPatch) -> None:
+def test_clone_inline_binding_copies_soul() -> None:
session = Mock()
service = AgentDslService(session)
target_agent = SimpleNamespace(id="target-agent")
target_snapshot = SimpleNamespace(id="target-snapshot")
service._create_workflow_only_agent = Mock(return_value=(target_agent, target_snapshot))
- copy_rows = Mock()
- monkeypatch.setattr("services.agent.composer_service.AgentComposerService._copy_agent_drive_rows", copy_rows)
source_agent = _agent()
- source_snapshot = SimpleNamespace(
- config_snapshot_dict=AgentSoulConfig(config_note="source").model_dump(mode="json")
+ source_soul = AgentSoulConfig(
+ config_note="source",
+ config_skills=[AgentConfigSkillRefConfig(name="summarizer", file_id="skill-file-1")],
+ config_files=[AgentConfigFileRefConfig(name="brief.pdf", file_kind="upload_file", file_id="config-file-1")],
)
+ source_snapshot = SimpleNamespace(config_snapshot_dict=source_soul.model_dump(mode="json"))
workflow = SimpleNamespace(tenant_id="tenant-1", app_id="app-1", id="workflow-1")
- node_job = WorkflowNodeJobConfig(workflow_prompt="work")
result = service.clone_inline_binding_for_node(
workflow=workflow,
node_id="target-node",
source_agent=source_agent,
source_snapshot=source_snapshot,
- node_job=node_job,
account_id="account-1",
)
assert result == (target_agent, target_snapshot)
create_kwargs = service._create_workflow_only_agent.call_args.kwargs
assert create_kwargs["metadata"].name == source_agent.name
- assert create_kwargs["soul"].config_note == "source"
+ cloned_soul = create_kwargs["soul"]
+ assert cloned_soul.config_note == "source"
+ assert [(item.name, item.file_kind, item.file_id) for item in cloned_soul.config_skills] == [
+ ("summarizer", "tool_file", "skill-file-1")
+ ]
+ assert [(item.name, item.file_kind, item.file_id) for item in cloned_soul.config_files] == [
+ ("brief.pdf", "upload_file", "config-file-1")
+ ]
assert create_kwargs["source"] == AgentSource.WORKFLOW
- copy_rows.assert_called_once_with(
- tenant_id="tenant-1",
- source_agent_id="agent-1",
- target_agent_id="target-agent",
- account_id="account-1",
- agent_soul=create_kwargs["soul"],
- node_job=node_job,
- session=session,
- )
def test_extract_package_dependencies_covers_model_tools_and_knowledge(monkeypatch: pytest.MonkeyPatch) -> None:
diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py
index 5dbce1c2549..2042dbee794 100644
--- a/api/tests/unit_tests/services/agent/test_agent_services.py
+++ b/api/tests/unit_tests/services/agent/test_agent_services.py
@@ -20,8 +20,6 @@ from models.agent import (
AgentConfigSnapshot,
AgentConfigVersionKind,
AgentDebugConversation,
- AgentDriveFile,
- AgentDriveFileKind,
AgentHomeSnapshot,
AgentKind,
AgentScope,
@@ -2379,7 +2377,6 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk
scope=AgentScope.WORKFLOW_ONLY,
)
create_roster_calls = []
- copy_drive_calls = []
monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", lambda **kwargs: workflow_agent)
def fake_create_roster_agent_for_composer(**kwargs):
@@ -2391,11 +2388,6 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk
"_create_roster_agent_for_composer",
fake_create_roster_agent_for_composer,
)
- monkeypatch.setattr(
- AgentComposerService,
- "_copy_agent_drive_rows",
- lambda **kwargs: copy_drive_calls.append(kwargs),
- )
monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent)
monkeypatch.setattr(
AgentComposerService,
@@ -2496,17 +2488,6 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk
assert create_roster_calls[1]["role"] == "Copied role"
assert create_roster_calls[1]["icon"] == "copied"
assert create_roster_calls[1]["icon_background"] == "#E0F2FE"
- copy_drive_calls[0].pop("session", None)
- assert copy_drive_calls == [
- {
- "tenant_id": "tenant-1",
- "source_agent_id": "roster-agent-1",
- "target_agent_id": "roster-agent-1",
- "account_id": "account-1",
- "agent_soul": payload.agent_soul,
- "node_job": payload.node_job,
- }
- ]
def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
@@ -2914,11 +2895,7 @@ def test_copy_workflow_composer_from_roster_creates_inline_agent_and_preserves_n
captured["create"] = kwargs
return inline_agent
- def fake_copy_drive_rows(**kwargs):
- captured["drive"] = kwargs
-
monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", fake_create_workflow_only_agent)
- monkeypatch.setattr(AgentComposerService, "_copy_agent_drive_rows", fake_copy_drive_rows)
monkeypatch.setattr(
AgentComposerService,
"_serialize_workflow_state",
@@ -2950,9 +2927,6 @@ def test_copy_workflow_composer_from_roster_creates_inline_agent_and_preserves_n
assert create_kwargs["agent_soul"].prompt.system_prompt == "copy me"
assert create_kwargs["name"] == "Nadia"
assert create_kwargs["role"] == "Clarifies tenders"
- drive_kwargs = captured["drive"]
- assert drive_kwargs["source_agent_id"] == "roster-agent-1"
- assert drive_kwargs["target_agent_id"] == "inline-agent-1"
def test_copy_workflow_composer_from_roster_rejects_stale_source_snapshot(
@@ -3196,191 +3170,6 @@ def test_copy_workflow_composer_from_roster_rejects_invalid_source_binding(
)
-def test_copy_agent_drive_rows_copies_skill_prefix_and_files(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
- session = sqlite_session
- skill_row = AgentDriveFile(
- tenant_id="tenant-1",
- agent_id="roster-agent-1",
- key="tender-analyzer/SKILL.md",
- file_kind="tool_file",
- file_id="tool-file-1",
- value_owned_by_drive=True,
- is_skill=True,
- skill_metadata='{"name":"Tender Analyzer"}',
- size=10,
- mime_type="text/markdown",
- )
- script_row = AgentDriveFile(
- tenant_id="tenant-1",
- agent_id="roster-agent-1",
- key="tender-analyzer/scripts/run.sh",
- file_kind="tool_file",
- file_id="tool-file-2",
- value_owned_by_drive=True,
- size=20,
- mime_type="text/x-shellscript",
- )
- file_row = AgentDriveFile(
- tenant_id="tenant-1",
- agent_id="roster-agent-1",
- key="files/qna.pdf",
- file_kind="upload_file",
- file_id="upload-file-1",
- value_owned_by_drive=False,
- size=30,
- mime_type="application/pdf",
- )
- session.add_all([skill_row, script_row, file_row])
- session.commit()
- agent_soul = AgentSoulConfig.model_validate(
- {
- "prompt": {
- "system_prompt": "[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]",
- },
- }
- )
- node_job = WorkflowNodeJobConfig.model_validate(
- {"metadata": {"file_refs": [{"name": "qna.pdf", "drive_key": "files/qna.pdf"}]}}
- )
-
- AgentComposerService._copy_agent_drive_rows(
- session=session,
- tenant_id="tenant-1",
- source_agent_id="roster-agent-1",
- target_agent_id="inline-agent-1",
- account_id="account-1",
- agent_soul=agent_soul,
- node_job=node_job,
- )
-
- session.flush()
- copied = list(
- session.scalars(
- select(AgentDriveFile).where(
- AgentDriveFile.tenant_id == "tenant-1",
- AgentDriveFile.agent_id == "inline-agent-1",
- )
- )
- )
- assert {row.key for row in copied} == {
- "tender-analyzer/SKILL.md",
- "tender-analyzer/scripts/run.sh",
- "files/qna.pdf",
- }
- assert {row.agent_id for row in copied} == {"inline-agent-1"}
- copied_by_key = {row.key: row for row in copied}
- assert copied_by_key["tender-analyzer/SKILL.md"].file_id == "tool-file-1"
- assert copied_by_key["tender-analyzer/SKILL.md"].is_skill is True
- assert copied_by_key["files/qna.pdf"].value_owned_by_drive is False
-
-
-def test_copy_agent_drive_rows_skips_when_no_referenced_drive_keys(
- monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
-):
- session = sqlite_session
- agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "No drive mentions."}})
-
- AgentComposerService._copy_agent_drive_rows(
- session=session,
- tenant_id="tenant-1",
- source_agent_id="roster-agent-1",
- target_agent_id="inline-agent-1",
- account_id="account-1",
- agent_soul=agent_soul,
- )
-
- assert not session.new
-
-
-def test_copy_agent_drive_rows_skips_existing_target_keys(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
- session = sqlite_session
- source_row = AgentDriveFile(
- tenant_id="tenant-1",
- agent_id="roster-agent-1",
- key="files/qna.pdf",
- file_kind="upload_file",
- file_id="upload-file-1",
- value_owned_by_drive=False,
- size=30,
- mime_type="application/pdf",
- )
- target_row = AgentDriveFile(
- tenant_id="tenant-1",
- agent_id="inline-agent-1",
- key=source_row.key,
- file_kind=source_row.file_kind,
- file_id=source_row.file_id,
- value_owned_by_drive=source_row.value_owned_by_drive,
- size=source_row.size,
- mime_type=source_row.mime_type,
- )
- session.add_all([source_row, target_row])
- session.commit()
- agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "[§file:files/qna.pdf:qna.pdf§]"}})
-
- AgentComposerService._copy_agent_drive_rows(
- session=session,
- tenant_id="tenant-1",
- source_agent_id="roster-agent-1",
- target_agent_id="inline-agent-1",
- account_id="account-1",
- agent_soul=agent_soul,
- )
-
- session.flush()
- target_rows = list(
- session.scalars(
- select(AgentDriveFile).where(
- AgentDriveFile.tenant_id == "tenant-1",
- AgentDriveFile.agent_id == "inline-agent-1",
- )
- )
- )
- assert [row.key for row in target_rows] == ["files/qna.pdf"]
-
-
-def test_drive_copy_scopes_include_declared_output_benchmark_files():
- agent_soul = AgentSoulConfig.model_validate(
- {
- "prompt": {
- "system_prompt": (
- "[§file:files/source.pdf:source.pdf§] "
- "[§knowledge:dataset-1:Docs§] "
- "[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]"
- )
- },
- }
- )
- node_job = WorkflowNodeJobConfig.model_validate(
- {
- "declared_outputs": [
- {
- "name": "qna_report",
- "type": "file",
- "check": {
- "enabled": True,
- "prompt": "Compare the generated file with the benchmark.",
- "benchmark_file_ref": {"name": "expected.pdf", "drive_key": "files/expected.pdf"},
- },
- },
- {
- "name": "summary",
- "type": "string",
- "check": {"enabled": False, "benchmark_file_ref": {"drive_key": "files/ignored.pdf"}},
- },
- ],
- }
- )
-
- exact_keys, prefixes = AgentComposerService._drive_copy_scopes_from_agent_configs(
- agent_soul=agent_soul,
- node_job=node_job,
- )
-
- assert exact_keys == {"files/source.pdf", "files/expected.pdf"}
- assert prefixes == {"tender-analyzer/"}
-
-
def test_composer_create_agents_syncs_active_config_has_model(
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
@@ -5835,7 +5624,7 @@ class TestWorkflowAgentDraftBindingSync:
draft_workflow=self._agent_workflow(),
)
- def test_publish_validation_rejects_dangling_agent_soul_drive_refs(self, sqlite_session: Session):
+ def test_publish_validation_rejects_dangling_agent_soul_config_refs(self, sqlite_session: Session):
session = sqlite_session
binding = self._agent_binding()
agent_soul = AgentSoulConfig.model_validate(
@@ -5845,7 +5634,7 @@ class TestWorkflowAgentDraftBindingSync:
"model_provider": "openai",
"model": "gpt-4o",
},
- "prompt": {"system_prompt": "Use [§skill:research%2FSKILL.md:Research§]."},
+ "prompt": {"system_prompt": "Use [§skill:research:Research§]."},
}
)
agent = self._publish_agent()
@@ -7045,135 +6834,6 @@ def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatc
assert {entry["granularity"] for entry in entries[1:]} == {"tool"}
-# ── ENG-623 §4.4: drive-backed prompt mention validation ─────────────────────
-
-
-def _drive_soul(**overrides):
- from services.entities.agent_entities import AgentSoulConfig
-
- base = {
- "prompt": {
- "system_prompt": (
- "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]."
- )
- },
- }
- base.update(overrides)
- return AgentSoulConfig.model_validate(base)
-
-
-def _session_with_drive_keys(sqlite_session: Session, existing_keys: list[str]) -> Session:
- session = sqlite_session
- session.add_all(
- [
- AgentDriveFile(
- id=f"drive-file-{index}",
- tenant_id="tenant-1",
- agent_id="agent-1",
- key=key,
- file_kind=AgentDriveFileKind.UPLOAD_FILE,
- file_id=f"upload-{index}",
- )
- for index, key in enumerate(existing_keys, start=1)
- ]
- )
- session.commit()
- return session
-
-
-def test_drive_mention_findings_reports_missing_keys(sqlite_session: Session):
- session = _session_with_drive_keys(sqlite_session, ["tender-analyzer/SKILL.md"])
-
- findings = AgentComposerService._drive_mention_findings(
- session=session,
- tenant_id="tenant-1",
- agent_id="agent-1",
- prompt=_drive_soul().prompt.system_prompt,
- )
-
- assert [(f["code"], f["id"]) for f in findings] == [("mention_target_missing", "files/sample.pdf")]
- assert findings[0]["kind"] == "file"
- assert str(findings[0]["message"]).startswith("file 'sample.pdf' has no drive entry")
-
-
-def test_drive_mention_findings_clean_when_all_keys_exist(sqlite_session: Session):
- session = _session_with_drive_keys(
- sqlite_session,
- ["tender-analyzer/SKILL.md", "files/sample.pdf"],
- )
-
- assert (
- AgentComposerService._drive_mention_findings(
- session=session,
- tenant_id="tenant-1",
- agent_id="agent-1",
- prompt=_drive_soul().prompt.system_prompt,
- )
- == []
- )
-
-
-def test_drive_mention_findings_skips_prompt_without_drive_mentions(sqlite_session: Session):
- session = sqlite_session
- # No drive-backed mention at all -> no DB roundtrip, no findings.
- soul = _drive_soul(prompt={"system_prompt": "Use [§knowledge:kb-1:Docs§]."})
- findings = AgentComposerService._drive_mention_findings(
- session=session,
- tenant_id="tenant-1",
- agent_id="agent-1",
- prompt=soul.prompt.system_prompt,
- )
- assert findings == []
-
-
-def test_collect_validation_findings_appends_drive_mention_findings_with_agent_context(
- sqlite_session: Session,
-):
- from services.entities.agent_entities import ComposerSavePayload
-
- session = _session_with_drive_keys(sqlite_session, [])
- payload = ComposerSavePayload.model_validate(
- {
- "variant": "agent_app",
- "save_strategy": "save_to_current_version",
- "agent_soul": _drive_soul().model_dump(mode="json"),
- }
- )
-
- findings = AgentComposerService.collect_validation_findings(
- session=session, tenant_id="tenant-1", payload=payload, agent_id="agent-1"
- )
-
- codes = {w["code"] for w in findings["warnings"]}
- assert codes >= {"mention_target_missing"}
- assert {w["id"] for w in findings["warnings"] if w["code"] == "mention_target_missing"} == {
- "tender-analyzer/SKILL.md",
- "files/sample.pdf",
- }
- # without agent context the drive check is skipped entirely
- findings_no_agent = AgentComposerService.collect_validation_findings(
- session=session, tenant_id="tenant-1", payload=payload
- )
- assert all(w["code"] != "mention_target_missing" for w in findings_no_agent["warnings"])
-
-
-# ── ENG-623/625: resolver helpers + save-path drive guard ────────────────────
-
-
-def test_resolve_bound_agent_id_queries_active_roster_agent(sqlite_session: Session):
- session = sqlite_session
- session.add(
- _agent(
- agent_id="agent-9",
- tenant_id="t-1",
- source=AgentSource.ROSTER,
- app_id="app-1",
- )
- )
- session.commit()
- assert AgentComposerService.resolve_bound_agent_id(session=session, tenant_id="t-1", app_id="app-1") == "agent-9"
-
-
def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding(
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
):
@@ -7207,129 +6867,3 @@ def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding(
AgentComposerService.resolve_workflow_node_agent_id(session=session, tenant_id="t", app_id="a", node_id="n")
== "agent-7"
)
-
-
-def test_save_workflow_composer_reports_drive_mentions_for_inline_node_job_only(
- monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
-):
- payload = ComposerSavePayload.model_validate(
- {
- "variant": "workflow",
- "save_strategy": "node_job_only",
- "agent_soul": _drive_soul().model_dump(mode="json"),
- "soul_lock": {"locked": False},
- }
- )
- binding = WorkflowAgentNodeBinding(
- tenant_id="t-1",
- app_id="app-1",
- workflow_id="wf-1",
- workflow_version="draft",
- node_id="n-1",
- binding_type=WorkflowAgentBindingType.INLINE_AGENT,
- agent_id="agent-1",
- current_snapshot_id="version-1",
- )
- session = sqlite_session
- monkeypatch.setattr(
- AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
- )
- monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding))
- monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding))
- monkeypatch.setattr(
- AgentComposerService,
- "_get_agent_if_present",
- classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")),
- )
- monkeypatch.setattr(
- AgentComposerService,
- "_get_version_if_present",
- classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")),
- )
- monkeypatch.setattr(
- AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"})
- )
- guarded: dict[str, str] = {}
-
- def fake_collect(cls, *, session, tenant_id, payload, agent_id=None):
- guarded["tenant_id"] = tenant_id
- guarded["agent_id"] = agent_id
- return {"warnings": [{"code": "mention_target_missing", "id": "files/sample.pdf"}]}
-
- monkeypatch.setattr(AgentComposerService, "collect_validation_findings", classmethod(fake_collect))
-
- result = AgentComposerService.save_workflow_composer(
- session=session,
- tenant_id="t-1",
- app_id="app-1",
- node_id="n-1",
- account_id="acc-1",
- payload=payload,
- )
-
- assert result == {
- "state": "ok",
- "validation": {"warnings": [{"code": "mention_target_missing", "id": "files/sample.pdf"}]},
- }
- assert guarded == {"tenant_id": "t-1", "agent_id": "agent-1"}
-
-
-def test_save_workflow_composer_reports_drive_mentions_for_roster_node_job_only(
- monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
-):
- payload = ComposerSavePayload.model_validate(
- {
- "variant": "workflow",
- "save_strategy": "node_job_only",
- "agent_soul": _drive_soul().model_dump(mode="json"),
- "soul_lock": {"locked": False},
- }
- )
- binding = WorkflowAgentNodeBinding(
- tenant_id="t-1",
- app_id="app-1",
- workflow_id="wf-1",
- workflow_version="draft",
- node_id="n-1",
- binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
- agent_id="agent-1",
- current_snapshot_id="version-1",
- )
- session = sqlite_session
- monkeypatch.setattr(
- AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
- )
- monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding))
- monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding))
- monkeypatch.setattr(
- AgentComposerService,
- "_get_agent_if_present",
- classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")),
- )
- monkeypatch.setattr(
- AgentComposerService,
- "_get_version_if_present",
- classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")),
- )
- monkeypatch.setattr(
- AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"})
- )
- captured: dict[str, str | None] = {}
-
- def fake_collect(cls, *, session, tenant_id, payload, agent_id=None):
- captured["agent_id"] = agent_id
- return {"warnings": []}
-
- monkeypatch.setattr(AgentComposerService, "collect_validation_findings", classmethod(fake_collect))
-
- result = AgentComposerService.save_workflow_composer(
- session=session,
- tenant_id="t-1",
- app_id="app-1",
- node_id="n-1",
- account_id="acc-1",
- payload=payload,
- )
-
- assert result == {"state": "ok", "validation": {"warnings": []}}
- assert captured["agent_id"] == "agent-1"
diff --git a/api/tests/unit_tests/services/agent/test_prompt_mentions.py b/api/tests/unit_tests/services/agent/test_prompt_mentions.py
index 48e4978a3bc..c6fb262572c 100644
--- a/api/tests/unit_tests/services/agent/test_prompt_mentions.py
+++ b/api/tests/unit_tests/services/agent/test_prompt_mentions.py
@@ -7,8 +7,6 @@ guarantees no mention-shaped marker survives to the model.
from __future__ import annotations
-from urllib.parse import quote
-
import pytest
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig, WorkflowPreviousNodeOutputRef
@@ -65,12 +63,6 @@ def test_parse_skips_oversized_id_or_label():
assert parse_prompt_mentions(f"[§skill:{long_id}§]") == []
-def test_parse_accepts_long_unicode_encoded_drive_key_within_drive_limit():
- encoded_drive_key = quote("你" * 512)
- mentions = parse_prompt_mentions(f"[§skill:{encoded_drive_key}:Long Skill§]")
- assert [(mention.kind, mention.ref_id) for mention in mentions] == [(MentionKind.SKILL, encoded_drive_key)]
-
-
# ── expand + scrub ────────────────────────────────────────────────────────────
diff --git a/api/tests/unit_tests/services/agent/test_skill_standardize_service.py b/api/tests/unit_tests/services/agent/test_skill_standardize_service.py
deleted file mode 100644
index 922c018c729..00000000000
--- a/api/tests/unit_tests/services/agent/test_skill_standardize_service.py
+++ /dev/null
@@ -1,140 +0,0 @@
-"""Unit tests for Skill standardization into the agent drive (ENG-594)."""
-
-from __future__ import annotations
-
-import io
-import zipfile
-from unittest.mock import MagicMock
-
-import pytest
-from sqlalchemy import select
-from sqlalchemy.orm import Session
-
-from models.agent import Agent, AgentDriveFile, AgentDriveFileKind, AgentScope, AgentSource
-from models.tools import ToolFile
-from services.agent.skill_standardize_service import SkillStandardizeService, slugify_skill_name
-from services.agent_drive_service import DriveSkillMetadata
-
-_TENANT_ID = "11111111-1111-1111-1111-111111111111"
-_AGENT_ID = "22222222-2222-2222-2222-222222222222"
-_USER_ID = "33333333-3333-3333-3333-333333333333"
-
-_SKILL_MD = b"""---
-name: PDF Toolkit
-description: Work with PDFs.
----
-
-# PDF Toolkit
-"""
-
-
-def _zip(members: dict[str, bytes]) -> bytes:
- buffer = io.BytesIO()
- with zipfile.ZipFile(buffer, "w") as archive:
- for name, data in members.items():
- archive.writestr(name, data)
- return buffer.getvalue()
-
-
-def test_slugify_skill_name():
- assert slugify_skill_name("PDF Toolkit") == "pdf-toolkit"
- assert slugify_skill_name(" Weird/Name!! ") == "weird-name"
- assert slugify_skill_name("") == "skill"
-
-
-@pytest.mark.parametrize("sqlite_session", [(Agent, ToolFile, AgentDriveFile)], indirect=True)
-def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_manifest(sqlite_session: Session):
- content = _zip({"pdf-toolkit/SKILL.md": _SKILL_MD, "pdf-toolkit/scripts/run.py": b"print('x')\n"})
-
- agent = Agent(
- id=_AGENT_ID,
- tenant_id=_TENANT_ID,
- name="Drive Agent",
- scope=AgentScope.ROSTER,
- source=AgentSource.AGENT_APP,
- )
- md_tool_file = ToolFile(
- user_id=_USER_ID,
- tenant_id=_TENANT_ID,
- conversation_id=None,
- file_key="tools/skill-md",
- mimetype="text/markdown",
- name="SKILL.md",
- size=len(_SKILL_MD),
- )
- archive_tool_file = ToolFile(
- user_id=_USER_ID,
- tenant_id=_TENANT_ID,
- conversation_id=None,
- file_key="tools/skill-archive",
- mimetype="application/zip",
- name=".DIFY-SKILL-FULL.zip",
- size=len(content),
- )
- sqlite_session.add_all([agent, md_tool_file, archive_tool_file])
- sqlite_session.commit()
-
- tool_files = MagicMock()
- tool_files.create_file_by_raw.side_effect = [md_tool_file, archive_tool_file]
-
- service = SkillStandardizeService(tool_file_manager=tool_files)
- result = service.standardize(
- content=content,
- filename="skill.zip",
- tenant_id=_TENANT_ID,
- user_id=_USER_ID,
- agent_id=_AGENT_ID,
- session=sqlite_session,
- )
- assert not sqlite_session.in_transaction()
-
- # ToolFiles: SKILL.md and the full archive. Archive members stay lazy.
- assert tool_files.create_file_by_raw.call_count == 2
- md_call, zip_call = tool_files.create_file_by_raw.call_args_list
- assert md_call.kwargs["mimetype"] == "text/markdown"
- assert md_call.kwargs["file_binary"] == _SKILL_MD
- assert zip_call.kwargs["mimetype"] == "application/zip"
- assert zip_call.kwargs["file_binary"] != content
- with zipfile.ZipFile(io.BytesIO(zip_call.kwargs["file_binary"])) as archive:
- assert sorted(info.filename for info in archive.infolist() if not info.is_dir()) == [
- "SKILL.md",
- "scripts/run.py",
- ]
-
- # Committed as drive-owned with the standardized keys. Member paths are
- # carried in metadata for inspect/preview/runtime lazy resolution.
- rows = {
- row.key: row
- for row in sqlite_session.scalars(
- select(AgentDriveFile).where(
- AgentDriveFile.tenant_id == _TENANT_ID,
- AgentDriveFile.agent_id == _AGENT_ID,
- )
- )
- }
- assert set(rows) == {"pdf-toolkit/SKILL.md", "pdf-toolkit/.DIFY-SKILL-FULL.zip"}
- skill_row = rows["pdf-toolkit/SKILL.md"]
- archive_row = rows["pdf-toolkit/.DIFY-SKILL-FULL.zip"]
- assert skill_row.file_kind == AgentDriveFileKind.TOOL_FILE
- assert skill_row.file_id == md_tool_file.id
- assert skill_row.value_owned_by_drive is True
- assert skill_row.is_skill is True
- assert skill_row.skill_metadata is not None
- skill_metadata = DriveSkillMetadata.model_validate_json(skill_row.skill_metadata)
- assert skill_metadata.name == "PDF Toolkit"
- assert skill_metadata.manifest_files == ["SKILL.md", "scripts/run.py"]
- assert archive_row.file_kind == AgentDriveFileKind.TOOL_FILE
- assert archive_row.file_id == archive_tool_file.id
- assert archive_row.value_owned_by_drive is True
- assert archive_row.is_skill is False
- assert len(service.last_committed_items) == 2
-
- # The returned upload response carries only the drive-derived fields the UI needs.
- skill = result["skill"]
- assert skill["path"] == "pdf-toolkit"
- assert skill["name"] == "PDF Toolkit"
- assert skill["archive_key"] == "pdf-toolkit/.DIFY-SKILL-FULL.zip"
- assert skill["skill_md_key"] == "pdf-toolkit/SKILL.md"
- assert result["manifest"]["entry_path"] == "SKILL.md"
- assert result["manifest"]["files"] == ["SKILL.md", "scripts/run.py"]
- assert "_committed_items" not in result
diff --git a/api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py b/api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py
deleted file mode 100644
index 68c07abb377..00000000000
--- a/api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py
+++ /dev/null
@@ -1,188 +0,0 @@
-"""Unit tests for skill → CLI tool inference (ENG-371)."""
-
-from __future__ import annotations
-
-from unittest.mock import MagicMock, patch
-
-import pytest
-from sqlalchemy.orm import Session
-
-from services.agent.skill_tool_inference_service import (
- SkillToolInferenceError,
- SkillToolInferenceService,
-)
-from services.agent_drive_service import AgentDriveError
-
-_MOD = "services.agent.skill_tool_inference_service"
-
-_SKILL_MD_PREVIEW = {
- "key": "audio-transcribe/SKILL.md",
- "size": 100,
- "truncated": False,
- "binary": False,
- "text": "# Audio Transcribe\nStep 2 runs ffmpeg, step 3 calls the whisper API.",
-}
-
-
-def _service(preview=_SKILL_MD_PREVIEW):
- drive = MagicMock()
- drive.preview.return_value = preview
- return SkillToolInferenceService(drive_service=drive), drive
-
-
-@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
-def test_infer_returns_suggestions_with_inferred_from(monkeypatch, sqlite_session: Session):
- service, drive = _service()
- raw = (
- '{"inferable": true, "reason": null, "cli_tools": [{"name": "ffmpeg",'
- ' "description": "transcoding for step 2", "command": "ffmpeg",'
- ' "install_commands": ["apt-get install -y ffmpeg"],'
- ' "env_suggestions": [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": true}]}]}'
- )
- with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
- result = service.infer(
- tenant_id="t-1",
- agent_id="a-1",
- slug="audio-transcribe",
- session=sqlite_session,
- )
-
- assert result["inferable"] is True
- tool = result["cli_tools"][0]
- assert tool["name"] == "ffmpeg"
- assert tool["inferred_from"] == "audio-transcribe"
- assert tool["env_suggestions"] == [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": True}]
- drive.preview.assert_called_once_with(
- tenant_id="t-1", agent_id="a-1", key="audio-transcribe/SKILL.md", session=sqlite_session
- )
- assert not sqlite_session.in_transaction()
-
-
-@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
-def test_infer_threads_skill_md_into_the_prompt(monkeypatch, sqlite_session: Session):
- service, _ = _service()
- captured: dict[str, str] = {}
-
- def fake_invoke(*, tenant_id, user_prompt):
- captured["prompt"] = user_prompt
- return '{"inferable": false, "cli_tools": [], "reason": "none"}'
-
- with patch.object(SkillToolInferenceService, "_invoke", staticmethod(fake_invoke)):
- service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
-
- assert "Files inside the skill package" not in captured["prompt"]
- assert "ffmpeg" in captured["prompt"] # SKILL.md body present
- assert not sqlite_session.in_transaction()
-
-
-@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
-def test_infer_not_inferable_passes_reason_through(monkeypatch, sqlite_session: Session):
- service, _ = _service()
- raw = '{"inferable": false, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}'
- with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
- result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
- assert result == {"inferable": False, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}
- assert not sqlite_session.in_transaction()
-
-
-@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
-def test_infer_retries_once_then_422(monkeypatch, sqlite_session: Session):
- service, _ = _service()
- calls: list[int] = []
-
- def bad_invoke(**kwargs):
- calls.append(1)
- return "not json at all ]["
-
- with patch.object(SkillToolInferenceService, "_invoke", staticmethod(bad_invoke)):
- with pytest.raises(SkillToolInferenceError) as exc_info:
- service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
-
- assert len(calls) == 2 # one retry
- assert exc_info.value.code == "inference_failed"
- assert exc_info.value.status_code == 422
- assert not sqlite_session.in_transaction()
-
-
-@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
-def test_infer_repairs_slightly_malformed_json(monkeypatch, sqlite_session: Session):
- service, _ = _service()
- raw = 'Here you go: {"inferable": true, "cli_tools": [], "reason": null,}'
- with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
- result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
- assert result["inferable"] is True
- assert not sqlite_session.in_transaction()
-
-
-@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
-def test_missing_skill_maps_to_404(sqlite_session: Session):
- drive = MagicMock()
- drive.preview.side_effect = AgentDriveError("drive_key_not_found", "nope", status_code=404)
- service = SkillToolInferenceService(drive_service=drive)
-
- with pytest.raises(SkillToolInferenceError) as exc_info:
- service.infer(tenant_id="t-1", agent_id="a-1", slug="ghost", session=sqlite_session)
- assert exc_info.value.code == "skill_not_found"
- assert exc_info.value.status_code == 404
- assert not sqlite_session.in_transaction()
-
-
-@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
-def test_binary_skill_md_maps_to_404(sqlite_session: Session):
- service, _ = _service(preview={"key": "x/SKILL.md", "size": 1, "truncated": False, "binary": True, "text": None})
- with pytest.raises(SkillToolInferenceError) as exc_info:
- service.infer(tenant_id="t-1", agent_id="a-1", slug="x", session=sqlite_session)
- assert exc_info.value.code == "skill_not_found"
- assert not sqlite_session.in_transaction()
-
-
-# ── real-path coverage: _invoke / passthrough ────────────────────────────────
-
-
-def test_invoke_maps_missing_default_model_to_400(monkeypatch: pytest.MonkeyPatch):
- import services.agent.skill_tool_inference_service as module
- from core.errors.error import ProviderTokenNotInitError
-
- fake_manager = MagicMock()
- fake_manager.get_default_model_instance.side_effect = ProviderTokenNotInitError("no default")
- monkeypatch.setattr(module.ModelManager, "for_tenant", classmethod(lambda cls, tenant_id: fake_manager))
-
- with pytest.raises(SkillToolInferenceError) as exc_info:
- SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x")
- assert exc_info.value.code == "default_model_not_configured"
- assert exc_info.value.status_code == 400
-
-
-def test_invoke_maps_model_failure_to_422_and_success_returns_text(monkeypatch: pytest.MonkeyPatch):
- import services.agent.skill_tool_inference_service as module
-
- fake_manager = MagicMock()
- fake_instance = MagicMock()
- fake_manager.get_default_model_instance.return_value = fake_instance
- monkeypatch.setattr(module.ModelManager, "for_tenant", classmethod(lambda cls, tenant_id: fake_manager))
-
- fake_instance.invoke_llm.side_effect = RuntimeError("provider down")
- with pytest.raises(SkillToolInferenceError) as exc_info:
- SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x")
- assert exc_info.value.code == "inference_failed"
- assert exc_info.value.status_code == 422
-
- fake_instance.invoke_llm.side_effect = None
- fake_instance.invoke_llm.return_value.message.get_text_content.return_value = '{"inferable": false}'
- raw = SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x")
- assert raw == '{"inferable": false}'
- call = fake_instance.invoke_llm.call_args.kwargs
- assert call["model_parameters"] == {"temperature": 0.1}
- assert call["stream"] is False
-
-
-@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
-def test_load_skill_md_passes_through_non_missing_drive_errors(sqlite_session: Session):
- drive = MagicMock()
- drive.preview.side_effect = AgentDriveError("agent_not_found", "tenant mismatch", status_code=404)
- service = SkillToolInferenceService(drive_service=drive)
-
- with pytest.raises(SkillToolInferenceError) as exc_info:
- service.infer(tenant_id="t-1", agent_id="a-1", slug="x", session=sqlite_session)
- assert exc_info.value.code == "agent_not_found"
- assert not sqlite_session.in_transaction()
diff --git a/api/tests/unit_tests/services/agent/test_workflow_publish_service.py b/api/tests/unit_tests/services/agent/test_workflow_publish_service.py
index 70cd220877a..87f376e1c4b 100644
--- a/api/tests/unit_tests/services/agent/test_workflow_publish_service.py
+++ b/api/tests/unit_tests/services/agent/test_workflow_publish_service.py
@@ -6,7 +6,6 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from models.agent import Agent, WorkflowAgentBindingType, WorkflowAgentNodeBinding
-from models.agent_config_entities import WorkflowNodeJobConfig
from models.enums import AppStatus
from models.model import App, AppMode
from models.workflow import Workflow, WorkflowType
@@ -326,7 +325,6 @@ def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.M
target_snapshot = SimpleNamespace(id="target-snapshot")
clone = Mock(return_value=(target_agent, target_snapshot))
monkeypatch.setattr(AgentDslService, "clone_inline_binding_for_node", clone)
- node_job = WorkflowNodeJobConfig(workflow_prompt="work")
result = WorkflowAgentPublishService._clone_inline_graph_binding_for_node(
session=session,
@@ -334,7 +332,6 @@ def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.M
node_id="target-node",
source_agent_id="source-agent",
source_snapshot_id="source-snapshot",
- node_job=node_job,
account_id="account-1",
)
@@ -344,7 +341,6 @@ def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.M
node_id="target-node",
source_agent=source_agent,
source_snapshot=source_snapshot,
- node_job=node_job,
account_id="account-1",
)
@@ -361,7 +357,6 @@ def test_clone_inline_graph_binding_for_node_rejects_missing_source(scalar_resul
node_id="target-node",
source_agent_id="source-agent",
source_snapshot_id="source-snapshot",
- node_job=WorkflowNodeJobConfig(),
account_id="account-1",
)
diff --git a/api/tests/unit_tests/services/test_agent_drive_service.py b/api/tests/unit_tests/services/test_agent_drive_service.py
deleted file mode 100644
index e917745b4e7..00000000000
--- a/api/tests/unit_tests/services/test_agent_drive_service.py
+++ /dev/null
@@ -1,952 +0,0 @@
-"""Unit tests for the agent drive service (ENG-591).
-
-Pure helpers (key safety / drive-ref parsing) plus the commit/manifest lifecycle
-exercised against the project's in-memory SQLite engine with seeded ToolFiles.
-"""
-
-from __future__ import annotations
-
-import datetime
-import io
-import zipfile
-from collections.abc import Generator
-from unittest.mock import patch
-
-import pytest
-from sqlalchemy import delete, event, select
-from sqlalchemy.exc import DataError
-from sqlalchemy.orm import Session
-
-from core.db.session_factory import session_factory
-from extensions.storage.storage_type import StorageType
-from models.agent import Agent, AgentDriveFile, AgentDriveFileKind, AgentScope, AgentSource
-from models.enums import CreatorUserRole
-from models.model import UploadFile
-from models.tools import ToolFile
-from services.agent_drive_service import (
- AgentDriveError,
- AgentDriveService,
- DriveCommitItem,
- DriveSkillMetadata,
- normalize_drive_key,
- parse_agent_drive_ref,
-)
-
-TENANT = "11111111-1111-1111-1111-111111111111"
-AGENT = "22222222-2222-2222-2222-222222222222"
-USER = "33333333-3333-3333-3333-333333333333"
-
-
-# ── pure helpers ──────────────────────────────────────────────────────────────
-
-
-def test_parse_agent_drive_ref():
- assert parse_agent_drive_ref("agent-abc") == "abc"
- for bad in ["abc", "agent-", ""]:
- with pytest.raises(AgentDriveError):
- parse_agent_drive_ref(bad)
-
-
-def test_normalize_drive_key_ok_and_collapses_slashes():
- assert normalize_drive_key("a/b/c.txt") == "a/b/c.txt"
- assert normalize_drive_key("/a//b.txt") == "a/b.txt"
- assert normalize_drive_key("skill-name/SKILL.md") == "skill-name/SKILL.md"
-
-
-@pytest.mark.parametrize("bad", ["", " ", "a/../b", "../etc", "a/\x00b", "a" * 1100])
-def test_normalize_drive_key_rejects_unsafe(bad: str):
- with pytest.raises(AgentDriveError):
- normalize_drive_key(bad)
-
-
-# ── service lifecycle (in-memory ORM) ─────────────────────────────────────────
-
-
-@pytest.fixture(autouse=True)
-def _tables() -> Generator[None, None, None]:
- engine = session_factory.get_session_maker().kw["bind"]
- for model in (Agent, ToolFile, UploadFile, AgentDriveFile):
- model.__table__.create(bind=engine, checkfirst=True)
- _seed_agent()
- yield
- with session_factory.create_session() as session:
- session.execute(delete(AgentDriveFile))
- session.execute(delete(UploadFile))
- session.execute(delete(ToolFile))
- session.execute(delete(Agent))
- session.commit()
- AgentDriveFile.__table__.drop(bind=engine, checkfirst=True)
-
-
-def _seed_agent(*, tenant_id: str = TENANT, agent_id: str = AGENT) -> None:
- agent = Agent(
- id=agent_id,
- tenant_id=tenant_id,
- name="Drive Agent",
- scope=AgentScope.ROSTER,
- source=AgentSource.AGENT_APP,
- )
- with session_factory.create_session() as session:
- session.add(agent)
- session.commit()
-
-
-def _seed_tool_file(*, user_id: str = USER, name: str = "f.txt", conversation_id: str | None = None) -> str:
- tool_file = ToolFile(
- user_id=user_id,
- tenant_id=TENANT,
- conversation_id=conversation_id,
- file_key=f"tools/{TENANT}/{name}",
- mimetype="text/plain",
- name=name,
- size=5,
- )
- with session_factory.create_session() as session:
- session.add(tool_file)
- session.commit()
- return tool_file.id
-
-
-def _zip_bytes(members: dict[str, bytes]) -> bytes:
- buffer = io.BytesIO()
- with zipfile.ZipFile(buffer, "w") as archive:
- for name, data in members.items():
- archive.writestr(name, data)
- return buffer.getvalue()
-
-
-def _commit(key: str, tool_file_id: str, *, owned: bool = True):
- return AgentDriveService().commit(
- tenant_id=TENANT,
- user_id=USER,
- agent_id=AGENT,
- items=[
- DriveCommitItem(
- key=key,
- file_ref={"kind": "tool_file", "id": tool_file_id},
- value_owned_by_drive=owned,
- )
- ],
- session=session_factory.create_session(),
- )
-
-
-def test_commit_then_manifest_lists_the_entry():
- tf = _seed_tool_file()
- _commit("data/report.txt", tf)
-
- items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session())
- assert [i["key"] for i in items] == ["data/report.txt"]
- assert items[0]["file_kind"] == "tool_file"
- assert items[0]["file_id"] == tf
- assert items[0]["mime_type"] == "text/plain"
-
- # prefix filter
- assert (
- AgentDriveService().manifest(
- tenant_id=TENANT, agent_id=AGENT, prefix="data/", session=session_factory.create_session()
- )
- != []
- )
- assert (
- AgentDriveService().manifest(
- tenant_id=TENANT, agent_id=AGENT, prefix="other/", session=session_factory.create_session()
- )
- == []
- )
-
-
-def test_commit_owned_tool_file_detaches_conversation_ownership():
- conversation_id = "44444444-4444-4444-4444-444444444444"
- tool_file_id = _seed_tool_file(conversation_id=conversation_id)
-
- _commit("data/report.txt", tool_file_id, owned=True)
-
- with session_factory.create_session() as session:
- tool_file = session.get(ToolFile, tool_file_id)
- assert tool_file is not None
- assert tool_file.conversation_id is None
-
-
-def test_commit_shared_tool_file_keeps_conversation_ownership():
- conversation_id = "44444444-4444-4444-4444-444444444444"
- tool_file_id = _seed_tool_file(conversation_id=conversation_id)
-
- _commit("data/report.txt", tool_file_id, owned=False)
-
- with session_factory.create_session() as session:
- tool_file = session.get(ToolFile, tool_file_id)
- assert tool_file is not None
- assert tool_file.conversation_id == conversation_id
-
-
-def test_commit_skill_row_persists_metadata_and_lists_catalog() -> None:
- tf = _seed_tool_file(name="SKILL.md")
- AgentDriveService().commit(
- tenant_id=TENANT,
- user_id=USER,
- agent_id=AGENT,
- items=[
- DriveCommitItem(
- key="tender-analyzer/SKILL.md",
- file_ref={"kind": "tool_file", "id": tf},
- is_skill=True,
- skill_metadata=DriveSkillMetadata(name="Tender Analyzer", description="Parses RFPs."),
- )
- ],
- session=session_factory.create_session(),
- )
-
- with session_factory.create_session() as session:
- row = session.scalar(select(AgentDriveFile).where(AgentDriveFile.key == "tender-analyzer/SKILL.md"))
- assert row is not None
- assert row.is_skill is True
- assert row.skill_metadata == '{"description":"Parses RFPs.","name":"Tender Analyzer"}'
-
- skills = AgentDriveService().list_skills(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session())
- assert len(skills) == 1
- assert skills[0]["path"] == "tender-analyzer"
- assert skills[0]["skill_md_key"] == "tender-analyzer/SKILL.md"
- assert skills[0]["archive_key"] is None
- assert skills[0]["name"] == "Tender Analyzer"
- assert skills[0]["description"] == "Parses RFPs."
- assert skills[0]["size"] == 5
- assert skills[0]["mime_type"] == "text/plain"
-
-
-def test_commit_rejects_skill_row_without_skill_metadata() -> None:
- tf = _seed_tool_file(name="SKILL.md")
-
- with pytest.raises(AgentDriveError) as exc_info:
- AgentDriveService().commit(
- tenant_id=TENANT,
- user_id=USER,
- agent_id=AGENT,
- items=[
- DriveCommitItem(
- key="tender-analyzer/SKILL.md",
- file_ref={"kind": "tool_file", "id": tf},
- is_skill=True,
- )
- ],
- session=session_factory.create_session(),
- )
-
- assert exc_info.value.code == "invalid_skill_metadata"
-
-
-@pytest.mark.parametrize("raw_metadata", [None, '{"description":"oops"}'])
-def test_list_skills_raises_controlled_error_for_invalid_stored_metadata(raw_metadata: str | None) -> None:
- tf = _seed_tool_file(name="SKILL.md")
-
- with session_factory.create_session() as session:
- session.add(
- AgentDriveFile(
- id="44444444-4444-4444-4444-444444444444",
- tenant_id=TENANT,
- agent_id=AGENT,
- key="broken-skill/SKILL.md",
- file_kind=AgentDriveFileKind.TOOL_FILE,
- file_id=tf,
- value_owned_by_drive=True,
- is_skill=True,
- skill_metadata=raw_metadata,
- size=5,
- mime_type="text/plain",
- created_by=USER,
- )
- )
- session.commit()
-
- with pytest.raises(AgentDriveError) as exc_info:
- AgentDriveService().list_skills(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session())
-
- assert exc_info.value.code == "invalid_skill_metadata"
-
-
-def test_commit_rejects_non_skill_row_with_skill_metadata() -> None:
- tf = _seed_tool_file()
- with pytest.raises(AgentDriveError, match="skill metadata"):
- AgentDriveService().commit(
- tenant_id=TENANT,
- user_id=USER,
- agent_id=AGENT,
- items=[
- DriveCommitItem(
- key="files/report.txt",
- file_ref={"kind": "tool_file", "id": tf},
- skill_metadata=DriveSkillMetadata(name="Bad", description=""),
- )
- ],
- session=session_factory.create_session(),
- )
-
-
-def test_commit_rejects_non_canonical_skill_key() -> None:
- tf = _seed_tool_file(name="README.md")
- with pytest.raises(AgentDriveError, match="canonical"):
- AgentDriveService().commit(
- tenant_id=TENANT,
- user_id=USER,
- agent_id=AGENT,
- items=[
- DriveCommitItem(
- key="tender-analyzer/README.md",
- file_ref={"kind": "tool_file", "id": tf},
- is_skill=True,
- skill_metadata=DriveSkillMetadata(name="Tender Analyzer", description=""),
- )
- ],
- session=session_factory.create_session(),
- )
-
-
-def test_commit_rejects_tool_file_not_owned_by_user():
- other = _seed_tool_file(user_id="99999999-9999-9999-9999-999999999999")
- with pytest.raises(AgentDriveError) as exc_info:
- _commit("x.txt", other)
- assert exc_info.value.status_code == 404
- assert exc_info.value.code == "source_not_found"
-
-
-def test_commit_rejects_agent_from_another_tenant():
- tf = _seed_tool_file()
- with pytest.raises(AgentDriveError) as exc_info:
- AgentDriveService().commit(
- tenant_id="99999999-9999-9999-9999-999999999999",
- user_id=USER,
- agent_id=AGENT,
- items=[
- DriveCommitItem(
- key="x.txt",
- file_ref={"kind": "tool_file", "id": tf},
- value_owned_by_drive=True,
- )
- ],
- session=session_factory.create_session(),
- )
- assert exc_info.value.status_code == 404
- assert exc_info.value.code == "agent_not_found"
-
-
-def test_overwrite_cleans_old_drive_owned_value():
- tf1 = _seed_tool_file(name="v1.txt")
- tf2 = _seed_tool_file(name="v2.txt")
- _commit("doc.txt", tf1, owned=True)
-
- with patch("services.agent_drive_service.storage") as storage_mock:
- _commit("doc.txt", tf2, owned=True)
- storage_mock.delete.assert_called_once()
-
- # old ToolFile physically removed; key now points at tf2
- with session_factory.create_session() as session:
- assert session.scalar(select(ToolFile).where(ToolFile.id == tf1)) is None
- assert session.scalar(select(ToolFile).where(ToolFile.id == tf2)) is not None
- rows = list(session.scalars(select(AgentDriveFile).where(AgentDriveFile.key == "doc.txt")))
- assert len(rows) == 1
- assert rows[0].file_id == tf2
-
-
-def test_batch_failure_does_not_delete_old_storage_before_commit():
- tf1 = _seed_tool_file(name="v1.txt")
- tf2 = _seed_tool_file(name="v2.txt")
- _commit("doc.txt", tf1, owned=True)
-
- with patch("services.agent_drive_service.storage") as storage_mock:
- with session_factory.create_session() as session:
- with pytest.raises(AgentDriveError):
- AgentDriveService().commit(
- tenant_id=TENANT,
- user_id=USER,
- agent_id=AGENT,
- items=[
- DriveCommitItem(
- key="doc.txt",
- file_ref={"kind": "tool_file", "id": tf2},
- value_owned_by_drive=True,
- ),
- DriveCommitItem(
- key="bad.txt",
- file_ref={"kind": "tool_file", "id": "44444444-4444-4444-4444-444444444444"},
- value_owned_by_drive=True,
- ),
- ],
- session=session,
- )
- session.rollback()
- storage_mock.delete.assert_not_called()
-
- with session_factory.create_session() as session:
- row = session.scalar(select(AgentDriveFile).where(AgentDriveFile.key == "doc.txt"))
- assert row is not None
- assert row.file_id == tf1
- assert session.scalar(select(ToolFile).where(ToolFile.id == tf1)) is not None
- assert session.scalar(select(ToolFile).where(ToolFile.id == tf2)) is not None
-
-
-def test_validate_source_db_error_maps_to_404():
- """A database UUID failure maps to 404 and rolls back the real transaction."""
-
- rollback_events: list[Session] = []
-
- def raise_data_error(_orm_execute_state: object) -> None:
- raise DataError("bad uuid", {}, Exception("invalid input syntax for uuid"))
-
- def record_rollback(session: Session) -> None:
- rollback_events.append(session)
-
- with session_factory.create_session() as session:
- session.begin()
- event.listen(session, "do_orm_execute", raise_data_error)
- event.listen(session, "after_rollback", record_rollback)
- try:
- with pytest.raises(AgentDriveError) as exc_info:
- AgentDriveService()._validate_source(
- session,
- tenant_id=TENANT,
- user_id="not-a-uuid",
- file_kind=AgentDriveFileKind.TOOL_FILE,
- file_id="also-bad",
- )
- finally:
- event.remove(session, "do_orm_execute", raise_data_error)
- event.remove(session, "after_rollback", record_rollback)
-
- assert exc_info.value.status_code == 404
- assert exc_info.value.code == "source_not_found"
- assert rollback_events == [session]
- assert not session.in_transaction()
-
-
-def test_recommit_same_value_is_idempotent_and_keeps_value():
- tf = _seed_tool_file()
- _commit("a.txt", tf)
- _commit("a.txt", tf) # no error, no cleanup
-
- with session_factory.create_session() as session:
- assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is not None
- rows = list(session.scalars(select(AgentDriveFile).where(AgentDriveFile.key == "a.txt")))
- assert len(rows) == 1
-
-
-def test_recommit_same_skill_value_updates_metadata_without_cleaning_backing_file() -> None:
- tf = _seed_tool_file(name="SKILL.md")
- AgentDriveService().commit(
- tenant_id=TENANT,
- user_id=USER,
- agent_id=AGENT,
- items=[
- DriveCommitItem(
- key="tender-analyzer/SKILL.md",
- file_ref={"kind": "tool_file", "id": tf},
- value_owned_by_drive=True,
- is_skill=True,
- skill_metadata=DriveSkillMetadata(name="Tender Analyzer", description="v1"),
- )
- ],
- session=session_factory.create_session(),
- )
-
- with patch("services.agent_drive_service.storage") as storage_mock:
- AgentDriveService().commit(
- tenant_id=TENANT,
- user_id=USER,
- agent_id=AGENT,
- items=[
- DriveCommitItem(
- key="tender-analyzer/SKILL.md",
- file_ref={"kind": "tool_file", "id": tf},
- value_owned_by_drive=False,
- is_skill=True,
- skill_metadata=DriveSkillMetadata(name="Tender Analyzer v2", description="v2"),
- )
- ],
- session=session_factory.create_session(),
- )
- storage_mock.delete.assert_not_called()
-
- with session_factory.create_session() as session:
- row = session.scalar(select(AgentDriveFile).where(AgentDriveFile.key == "tender-analyzer/SKILL.md"))
- assert row is not None
- assert row.file_id == tf
- assert row.value_owned_by_drive is False
- assert row.skill_metadata == '{"description":"v2","name":"Tender Analyzer v2"}'
- assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is not None
-
-
-def _seed_upload_file(*, name: str = "u.txt") -> str:
- upload = UploadFile(
- tenant_id=TENANT,
- storage_type=StorageType.LOCAL,
- key=f"upload_files/{TENANT}/{name}",
- name=name,
- size=7,
- extension="txt",
- mime_type="text/plain",
- created_by_role=CreatorUserRole.ACCOUNT,
- created_by=USER,
- created_at=datetime.datetime.now(tz=datetime.UTC),
- used=False,
- )
- with session_factory.create_session() as session:
- session.add(upload)
- session.commit()
- return upload.id
-
-
-def _commit_upload(key: str, upload_file_id: str, *, owned: bool = True):
- return AgentDriveService().commit(
- tenant_id=TENANT,
- user_id=USER,
- agent_id=AGENT,
- items=[
- DriveCommitItem(
- key=key,
- file_ref={"kind": "upload_file", "id": upload_file_id},
- value_owned_by_drive=owned,
- )
- ],
- session=session_factory.create_session(),
- )
-
-
-def test_commit_upload_file_source_and_manifest():
- uf = _seed_upload_file()
- _commit_upload("docs/u.txt", uf)
-
- items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session())
- assert items[0]["file_kind"] == "upload_file"
- assert items[0]["file_id"] == uf
- assert items[0]["mime_type"] == "text/plain"
-
-
-def test_commit_rejects_missing_upload_file():
- with pytest.raises(AgentDriveError) as exc_info:
- _commit_upload("x.txt", "44444444-4444-4444-4444-444444444444")
- assert exc_info.value.status_code == 404
- assert exc_info.value.code == "source_not_found"
-
-
-def test_overwrite_cleans_old_upload_file_value():
- u1 = _seed_upload_file(name="v1.txt")
- u2 = _seed_upload_file(name="v2.txt")
- _commit_upload("doc.txt", u1, owned=True)
-
- with patch("services.agent_drive_service.storage") as storage_mock:
- _commit_upload("doc.txt", u2, owned=True)
- storage_mock.delete.assert_called_once()
-
- with session_factory.create_session() as session:
- assert session.scalar(select(UploadFile).where(UploadFile.id == u1)) is None
- assert session.scalar(select(UploadFile).where(UploadFile.id == u2)) is not None
-
-
-def test_manifest_includes_internal_download_url():
- tf = _seed_tool_file()
- _commit("data/r.txt", tf)
-
- with (
- patch("services.agent_drive_service.file_factory.build_from_mapping", return_value=object()),
- patch("core.app.workflow.file_runtime.DifyWorkflowFileRuntime") as runtime_cls,
- ):
- runtime_cls.return_value.resolve_file_url.return_value = "http://internal/files/x?sign=1"
- items = AgentDriveService().manifest(
- tenant_id=TENANT, agent_id=AGENT, include_download_url=True, session=session_factory.create_session()
- )
-
- assert items[0]["download_url"] == "http://internal/files/x?sign=1"
- # drive-owned resolution: internal URL (for_external=False)
- assert runtime_cls.return_value.resolve_file_url.call_args.kwargs["for_external"] is False
-
-
-def test_manifest_download_url_none_when_unresolvable():
- tf = _seed_tool_file()
- _commit("data/r.txt", tf)
-
- with patch(
- "services.agent_drive_service.file_factory.build_from_mapping",
- side_effect=ValueError("not found"),
- ):
- items = AgentDriveService().manifest(
- tenant_id=TENANT, agent_id=AGENT, include_download_url=True, session=session_factory.create_session()
- )
- assert items[0]["download_url"] is None
-
-
-# ── ENG-625 D5: delete ────────────────────────────────────────────────────────
-
-
-def test_delete_by_key_cleans_drive_owned_value():
- tf = _seed_tool_file(name="doomed.txt")
- _commit("files/doomed.txt", tf, owned=True)
-
- with patch("services.agent_drive_service.storage") as storage_mock:
- removed = AgentDriveService().commit(
- tenant_id=TENANT,
- user_id=USER,
- agent_id=AGENT,
- items=[DriveCommitItem(key="files/doomed.txt", file_ref=None)],
- session=session_factory.create_session(),
- )
- storage_mock.delete.assert_called_once()
-
- assert removed == [
- {
- "key": "files/doomed.txt",
- "file_kind": "tool_file",
- "file_id": tf,
- "value_owned_by_drive": True,
- "is_skill": False,
- "skill_metadata": None,
- "removed": True,
- }
- ]
- with session_factory.create_session() as session:
- assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is None
- assert list(session.scalars(select(AgentDriveFile))) == []
-
-
-def test_commit_null_batch_removes_multiple_skill_keys():
- md = _seed_tool_file(name="SKILL.md")
- zf = _seed_tool_file(name="full.zip")
- _commit("tender-analyzer/SKILL.md", md, owned=True)
- _commit("tender-analyzer/.DIFY-SKILL-FULL.zip", zf, owned=True)
- other = _seed_tool_file(name="other.txt")
- _commit("files/other.txt", other, owned=True)
-
- with patch("services.agent_drive_service.storage"):
- removed = AgentDriveService().commit(
- tenant_id=TENANT,
- user_id=USER,
- agent_id=AGENT,
- items=[
- DriveCommitItem(key="tender-analyzer/SKILL.md", file_ref=None),
- DriveCommitItem(key="tender-analyzer/.DIFY-SKILL-FULL.zip", file_ref=None),
- ],
- session=session_factory.create_session(),
- )
-
- assert sorted(item["key"] for item in removed) == [
- "tender-analyzer/.DIFY-SKILL-FULL.zip",
- "tender-analyzer/SKILL.md",
- ]
- with session_factory.create_session() as session:
- # both skill ToolFiles physically removed, the unrelated file untouched
- assert session.scalar(select(ToolFile).where(ToolFile.id == md)) is None
- assert session.scalar(select(ToolFile).where(ToolFile.id == zf)) is None
- assert session.scalar(select(ToolFile).where(ToolFile.id == other)) is not None
- keys = [row.key for row in session.scalars(select(AgentDriveFile))]
- assert keys == ["files/other.txt"]
-
-
-def test_commit_null_is_idempotent_for_missing_keys():
- removed = AgentDriveService().commit(
- tenant_id=TENANT,
- user_id=USER,
- agent_id=AGENT,
- items=[DriveCommitItem(key="files/never-there.txt", file_ref=None)],
- session=session_factory.create_session(),
- )
- assert removed == [{"key": "files/never-there.txt", "removed": True, "noop": True}]
-
-
-def test_commit_null_keeps_shared_value_records():
- tf = _seed_tool_file(name="shared.txt")
- _commit("files/shared.txt", tf, owned=False)
-
- with patch("services.agent_drive_service.storage") as storage_mock:
- removed = AgentDriveService().commit(
- tenant_id=TENANT,
- user_id=USER,
- agent_id=AGENT,
- items=[DriveCommitItem(key="files/shared.txt", file_ref=None)],
- session=session_factory.create_session(),
- )
- storage_mock.delete.assert_not_called()
-
- assert removed[0]["key"] == "files/shared.txt"
- with session_factory.create_session() as session:
- # only the KV row dropped; the shared ToolFile survives
- assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is not None
-
-
-def test_restandardize_same_slug_overwrites_both_keys_and_cleans_old_toolfiles():
- """ENG-625 §5.3 replacement semantics: re-standardizing a same-name skill
- overwrites /SKILL.md and /.DIFY-SKILL-FULL.zip, physically
- cleaning both old drive-owned ToolFiles."""
- old_md = _seed_tool_file(name="SKILL.md")
- old_zip = _seed_tool_file(name="full-v1.zip")
- _commit("pdf-toolkit/SKILL.md", old_md, owned=True)
- _commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", old_zip, owned=True)
-
- new_md = _seed_tool_file(name="SKILL-v2.md")
- new_zip = _seed_tool_file(name="full-v2.zip")
- with patch("services.agent_drive_service.storage") as storage_mock:
- _commit("pdf-toolkit/SKILL.md", new_md, owned=True)
- _commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", new_zip, owned=True)
- assert storage_mock.delete.call_count == 2
-
- with session_factory.create_session() as session:
- assert session.scalar(select(ToolFile).where(ToolFile.id == old_md)) is None
- assert session.scalar(select(ToolFile).where(ToolFile.id == old_zip)) is None
- rows = {row.key: row.file_id for row in session.scalars(select(AgentDriveFile))}
- assert rows == {
- "pdf-toolkit/SKILL.md": new_md,
- "pdf-toolkit/.DIFY-SKILL-FULL.zip": new_zip,
- }
-
-
-# ── ENG-624: console drive inspector (service layer) ─────────────────────────
-
-
-def test_preview_returns_text_with_truncation_flags():
- tf = _seed_tool_file(name="SKILL.md")
- _commit("pdf-toolkit/SKILL.md", tf)
-
- with patch("services.agent_drive_service.storage") as storage_mock:
- storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\nUse responsibly.\n"])
- result = AgentDriveService().preview(
- tenant_id=TENANT, agent_id=AGENT, key="pdf-toolkit/SKILL.md", session=session_factory.create_session()
- )
-
- assert result == {
- "key": "pdf-toolkit/SKILL.md",
- "size": 5,
- "truncated": False,
- "binary": False,
- "text": "# PDF Toolkit\nUse responsibly.\n",
- }
-
-
-def test_preview_marks_binary_and_oversized_content():
- tf = _seed_tool_file(name="blob.bin")
- _commit("files/blob.bin", tf)
-
- with patch("services.agent_drive_service.storage") as storage_mock:
- storage_mock.load_stream.return_value = iter([b"\x00\x01\x02"])
- binary = AgentDriveService().preview(
- tenant_id=TENANT, agent_id=AGENT, key="files/blob.bin", session=session_factory.create_session()
- )
- assert binary["binary"] is True
- assert binary["text"] is None
-
- with patch("services.agent_drive_service.storage") as storage_mock:
- storage_mock.load_stream.return_value = iter([b"x" * (AgentDriveService.PREVIEW_MAX_BYTES + 10)])
- oversized = AgentDriveService().preview(
- tenant_id=TENANT, agent_id=AGENT, key="files/blob.bin", session=session_factory.create_session()
- )
- assert oversized["truncated"] is True
- assert oversized["binary"] is False
- assert len(oversized["text"]) == AgentDriveService.PREVIEW_MAX_BYTES
-
-
-def test_preview_unknown_key_is_404():
- with pytest.raises(AgentDriveError) as exc_info:
- AgentDriveService().preview(
- tenant_id=TENANT, agent_id=AGENT, key="ghost/SKILL.md", session=session_factory.create_session()
- )
- assert exc_info.value.code == "drive_key_not_found"
- assert exc_info.value.status_code == 404
-
-
-def test_preview_rejects_cross_tenant_agent():
- with pytest.raises(AgentDriveError) as exc_info:
- AgentDriveService().preview(
- tenant_id="99999999-9999-9999-9999-999999999999",
- agent_id=AGENT,
- key="pdf-toolkit/SKILL.md",
- session=session_factory.create_session(),
- )
- assert exc_info.value.code == "agent_not_found"
-
-
-def test_download_url_signs_external_audience():
- tf = _seed_tool_file(name="full.zip")
- _commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", tf)
-
- with patch.object(AgentDriveService, "_resolve_download_url", return_value="https://signed.example/x") as resolver:
- url = AgentDriveService().download_url(
- tenant_id=TENANT,
- agent_id=AGENT,
- key="pdf-toolkit/.DIFY-SKILL-FULL.zip",
- session=session_factory.create_session(),
- )
-
- assert url == "https://signed.example/x"
- # console downloads are for browsers: external signing, never the internal URL
- assert resolver.call_args.kwargs["for_external"] is True
- assert resolver.call_args.kwargs["as_attachment"] is True
-
-
-def test_upload_file_download_url_uses_attachment_filename():
- upload_file_id = _seed_upload_file(name="report.pdf")
- _commit_upload("files/report.pdf", upload_file_id)
-
- with patch("core.app.workflow.file_runtime.DifyWorkflowFileRuntime") as runtime_cls:
- runtime_cls.return_value.resolve_upload_file_url.return_value = "https://files.example/report.pdf"
- url = AgentDriveService().download_url(
- tenant_id=TENANT, agent_id=AGENT, key="files/report.pdf", session=session_factory.create_session()
- )
-
- assert url == "https://files.example/report.pdf"
- assert runtime_cls.return_value.resolve_upload_file_url.call_args.kwargs["for_external"] is True
- assert runtime_cls.return_value.resolve_upload_file_url.call_args.kwargs["as_attachment"] is True
-
-
-def test_manifest_items_carry_created_at_for_inspector():
- tf = _seed_tool_file()
- _commit("files/x.txt", tf)
- items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session())
- assert items[0]["created_at"] is None or isinstance(items[0]["created_at"], int)
-
-
-# ── DIFY-2517: skill catalog / inspect ───────────────────────────────────────
-
-
-def _commit_skill(*, manifest_files: list[str] | None = None) -> None:
- md = _seed_tool_file(name="SKILL.md")
- zf = _seed_tool_file(name="full.zip")
- AgentDriveService().commit(
- tenant_id=TENANT,
- user_id=USER,
- agent_id=AGENT,
- items=[
- DriveCommitItem(
- key="pdf-toolkit/SKILL.md",
- file_ref={"kind": "tool_file", "id": md},
- value_owned_by_drive=True,
- is_skill=True,
- skill_metadata=DriveSkillMetadata(
- name="PDF Toolkit",
- description="Work with PDFs.",
- manifest_files=manifest_files,
- ),
- ),
- DriveCommitItem(
- key="pdf-toolkit/.DIFY-SKILL-FULL.zip",
- file_ref={"kind": "tool_file", "id": zf},
- value_owned_by_drive=True,
- ),
- ],
- session=session_factory.create_session(),
- )
-
-
-def test_list_skills_uses_canonical_skill_rows():
- _commit_skill(manifest_files=["SKILL.md", "scripts/run.py"])
-
- skills = AgentDriveService().list_skills(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session())
-
- created_at = skills[0].pop("created_at")
- assert skills == [
- {
- "path": "pdf-toolkit",
- "skill_md_key": "pdf-toolkit/SKILL.md",
- "archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip",
- "name": "PDF Toolkit",
- "description": "Work with PDFs.",
- "size": 5,
- "mime_type": "text/plain",
- "hash": None,
- }
- ]
- assert created_at is None or isinstance(created_at, int)
-
-
-def test_inspect_skill_returns_manifest_files_and_file_tree():
- _commit_skill(manifest_files=["SKILL.md", "references/guide.md", "scripts/run.py"])
-
- with patch("services.agent_drive_service.storage") as storage_mock:
- storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\n"])
- result = AgentDriveService().inspect_skill(
- tenant_id=TENANT, agent_id=AGENT, skill_path="pdf-toolkit", session=session_factory.create_session()
- )
-
- assert result["source"] == "skill_md"
- assert result["warnings"] == []
- assert [file["path"] for file in result["files"]] == ["SKILL.md", "references/guide.md", "scripts/run.py"]
- assert result["files"][0]["available_in_drive"] is True
- assert result["files"][1]["available_in_drive"] is True
- assert result["files"][1]["drive_key"] == "pdf-toolkit/references/guide.md"
- assert result["file_tree"][0]["name"] == "references"
- assert result["file_tree"][1]["name"] == "scripts"
- assert result["file_tree"][2]["name"] == "SKILL.md"
- assert result["skill_md"]["text"] == "# PDF Toolkit\n"
-
-
-def test_inspect_skill_falls_back_to_drive_keys_when_manifest_missing():
- _commit_skill(manifest_files=None)
-
- with patch("services.agent_drive_service.storage") as storage_mock:
- storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\n"])
- result = AgentDriveService().inspect_skill(
- tenant_id=TENANT, agent_id=AGENT, skill_path="pdf-toolkit", session=session_factory.create_session()
- )
-
- assert result["warnings"] == ["manifest_files_unavailable"]
- assert [file["path"] for file in result["files"]] == ["SKILL.md"]
-
-
-def test_preview_skill_archive_member_from_manifest_without_drive_row():
- _commit_skill(manifest_files=["SKILL.md", "references/guide.md"])
- archive = _zip_bytes({"SKILL.md": b"# PDF Toolkit\n", "references/guide.md": b"Guide content\n"})
-
- with patch("services.agent_drive_service.storage") as storage_mock:
- storage_mock.load_stream.return_value = iter([archive])
- result = AgentDriveService().preview(
- tenant_id=TENANT,
- agent_id=AGENT,
- key="pdf-toolkit/references/guide.md",
- session=session_factory.create_session(),
- )
-
- assert result == {
- "key": "pdf-toolkit/references/guide.md",
- "size": len(b"Guide content\n"),
- "truncated": False,
- "binary": False,
- "text": "Guide content\n",
- }
-
-
-def test_download_url_signs_skill_archive_member_from_manifest_without_drive_row():
- _commit_skill(manifest_files=["SKILL.md", "references/guide.md"])
-
- with patch.object(
- AgentDriveService,
- "sign_archive_member_url",
- return_value="https://signed.example/member",
- ) as sign:
- url = AgentDriveService().download_url(
- tenant_id=TENANT,
- agent_id=AGENT,
- key="pdf-toolkit/references/guide.md",
- session=session_factory.create_session(),
- )
-
- assert url == "https://signed.example/member"
- kwargs = sign.call_args.kwargs
- assert kwargs["key"] == "pdf-toolkit/references/guide.md"
- assert kwargs["member_path"] == "references/guide.md"
- assert kwargs["for_external"] is True
-
-
-def test_skill_metadata_rejects_non_canonical_rows():
- tf = _seed_tool_file(name="not-skill.md")
- with pytest.raises(AgentDriveError) as exc_info:
- AgentDriveService().commit(
- tenant_id=TENANT,
- user_id=USER,
- agent_id=AGENT,
- items=[
- DriveCommitItem(
- key="files/not-skill.md",
- file_ref={"kind": "tool_file", "id": tf},
- value_owned_by_drive=True,
- is_skill=True,
- skill_metadata=DriveSkillMetadata(name="Bad"),
- )
- ],
- session=session_factory.create_session(),
- )
- assert exc_info.value.code == "invalid_skill_key"
diff --git a/api/tests/unit_tests/tasks/test_delete_conversation_task.py b/api/tests/unit_tests/tasks/test_delete_conversation_task.py
index d928549975b..e3e5d84488c 100644
--- a/api/tests/unit_tests/tasks/test_delete_conversation_task.py
+++ b/api/tests/unit_tests/tasks/test_delete_conversation_task.py
@@ -27,7 +27,7 @@ from models import (
PinnedConversation,
SavedMessage,
)
-from models.agent import AgentConfigDraftType, AgentDriveFile, AgentDriveFileKind
+from models.agent import AgentConfigDraftType
from models.enums import (
ConversationFromSource,
ConversationStatus,
@@ -93,14 +93,13 @@ def _tool_file(*, name: str, conversation_id: str | None = CONVERSATION_ID) -> T
)
-def test_cleanup_removes_owned_resources_and_preserves_drive_files(sqlite_session: Session) -> None:
+def test_cleanup_removes_owned_resources(sqlite_session: Session) -> None:
conversation = _conversation(CONVERSATION_ID, deleted=True)
other_conversation = _conversation(OTHER_CONVERSATION_ID, deleted=False)
message = _message()
owned_file = _tool_file(name="owned.txt")
- drive_file = _tool_file(name="drive.txt")
other_file = _tool_file(name="other.txt", conversation_id=OTHER_CONVERSATION_ID)
- sqlite_session.add_all([conversation, other_conversation, message, owned_file, drive_file, other_file])
+ sqlite_session.add_all([conversation, other_conversation, message, owned_file, other_file])
sqlite_session.flush()
message_chain = MessageChain(message_id=MESSAGE_ID, type=MessageChainType.SYSTEM, input=None, output=None)
@@ -202,15 +201,6 @@ def test_cleanup_removes_owned_resources_and_preserves_drive_files(sqlite_sessio
draft_type=AgentConfigDraftType.DEBUG_BUILD,
conversation_id=CONVERSATION_ID,
),
- AgentDriveFile(
- tenant_id=TENANT_ID,
- agent_id=AGENT_ID,
- key="drive.txt",
- file_kind=AgentDriveFileKind.TOOL_FILE,
- file_id=drive_file.id,
- value_owned_by_drive=False,
- is_skill=False,
- ),
HumanInputFormRecipient(
form_id=form.id,
delivery_id=delivery.id,
@@ -230,7 +220,6 @@ def test_cleanup_removes_owned_resources_and_preserves_drive_files(sqlite_sessio
form_id = form.id
owned_file_id = owned_file.id
owned_file_key = owned_file.file_key
- drive_file_id = drive_file.id
other_file_id = other_file.id
with patch("tasks.delete_conversation_task.storage") as storage_mock:
@@ -245,12 +234,6 @@ def test_cleanup_removes_owned_resources_and_preserves_drive_files(sqlite_sessio
)
assert sqlite_session.scalar(select(HumanInputForm).where(HumanInputForm.id == form_id)) is None
assert sqlite_session.get(ToolFile, owned_file_id) is None
- preserved_drive_file = sqlite_session.get(ToolFile, drive_file_id)
- assert preserved_drive_file is not None
- assert preserved_drive_file.conversation_id is None
- preserved_drive_entry = sqlite_session.scalar(select(AgentDriveFile).where(AgentDriveFile.file_id == drive_file_id))
- assert preserved_drive_entry is not None
- assert preserved_drive_entry.value_owned_by_drive is True
assert sqlite_session.get(ToolFile, other_file_id) is not None
assert sqlite_session.get(Conversation, OTHER_CONVERSATION_ID) is not None
diff --git a/dify-agent-runtime/cmd/dify-agent-cli/main.go b/dify-agent-runtime/cmd/dify-agent-cli/main.go
index 08bf33f3589..6f28da7432f 100644
--- a/dify-agent-runtime/cmd/dify-agent-cli/main.go
+++ b/dify-agent-runtime/cmd/dify-agent-cli/main.go
@@ -1,6 +1,6 @@
// dify-agent-cli is the Go replacement for the Python dify-agent CLI.
// It communicates with the Agent Stub server via HTTP to provide
-// connect, file, drive, and config operations inside the sandbox container.
+// connect, file, and config operations inside the sandbox container.
package main
import (
@@ -17,7 +17,6 @@ import (
var knownRootCommands = map[string]struct{}{
"config": {},
"connect": {},
- "drive": {},
"file": {},
}
@@ -76,7 +75,6 @@ func newRootCommand() *cobra.Command {
root.AddCommand(
newConnectCommand(),
newFileCommand(),
- newDriveCommand(),
newConfigCommand(),
)
return root
@@ -142,60 +140,6 @@ func newFileCommand() *cobra.Command {
return cmd
}
-func newDriveCommand() *cobra.Command {
- cmd := &cobra.Command{
- Use: "drive",
- Short: "List, pull, or push agent drive files through the Agent Stub.",
- }
-
- var listJSON bool
- list := &cobra.Command{
- Use: "list [REMOTE_PREFIX]",
- Short: "List drive files visible to the current sandbox execution.",
- Args: cobra.MaximumNArgs(1),
- RunE: withEnv(func(env *agentcli.Environment, args []string, _ *cobra.Command) error {
- prefix := ""
- if len(args) > 0 {
- prefix = args[0]
- }
- return agentcli.RunDriveList(env, prefix, listJSON)
- }),
- }
- list.Flags().BoolVar(&listJSON, "json", false, "Emit the drive manifest as JSON.")
-
- var pullTo string
- var pullJSON bool
- pull := &cobra.Command{
- Use: "pull [REMOTE]...",
- Short: "Pull one or more drive keys/prefixes into one local directory tree.",
- RunE: withEnv(func(env *agentcli.Environment, args []string, _ *cobra.Command) error {
- localBase := pullTo
- if localBase == "" {
- localBase = agentcli.ReadDriveBase()
- }
- return agentcli.RunDrivePull(env, args, localBase, pullJSON)
- }),
- }
- pull.Flags().StringVar(&pullTo, "to", "", "Local base directory for pulled drive files.")
- pull.Flags().BoolVar(&pullJSON, "json", false, "Emit the pull result as JSON.")
-
- var pushKind string
- var pushJSON bool
- push := &cobra.Command{
- Use: "push LOCAL_PATH REMOTE_PATH",
- Short: "Upload one local file or directory into the agent drive.",
- Args: cobra.ExactArgs(2),
- RunE: withEnv(func(env *agentcli.Environment, args []string, _ *cobra.Command) error {
- return agentcli.RunDrivePush(env, args[0], args[1], pushKind)
- }),
- }
- push.Flags().StringVar(&pushKind, "kind", "", "Directory upload kind: skill or dir.")
- push.Flags().BoolVar(&pushJSON, "json", false, "Accepted for consistency; drive push output is already emitted as JSON.")
-
- cmd.AddCommand(list, pull, push)
- return cmd
-}
-
func newConfigCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
diff --git a/dify-agent-runtime/cmd/dify-agent-cli/main_test.go b/dify-agent-runtime/cmd/dify-agent-cli/main_test.go
index 11326c9a8c5..2007058cb2a 100644
--- a/dify-agent-runtime/cmd/dify-agent-cli/main_test.go
+++ b/dify-agent-runtime/cmd/dify-agent-cli/main_test.go
@@ -38,7 +38,7 @@ func TestCommandHelp(t *testing.T) {
{
name: "root",
args: []string{"--help"},
- want: []string{"Usage:", "dify-agent", "config", "connect", "drive", "file"},
+ want: []string{"Usage:", "dify-agent", "config", "connect", "file"},
},
{
name: "connect",
@@ -70,26 +70,6 @@ func TestCommandHelp(t *testing.T) {
args: []string{"file", "public-url", "--help"},
want: []string{"dify-agent file public-url", "Create a browser-visible download URL"},
},
- {
- name: "drive",
- args: []string{"drive", "--help"},
- want: []string{"dify-agent drive", "list", "pull", "push"},
- },
- {
- name: "drive list",
- args: []string{"drive", "list", "--help"},
- want: []string{"dify-agent drive list", "List drive files", "--json"},
- },
- {
- name: "drive pull",
- args: []string{"drive", "pull", "--help"},
- want: []string{"dify-agent drive pull", "Pull one or more drive", "--to", "--json"},
- },
- {
- name: "drive push",
- args: []string{"drive", "push", "--help"},
- want: []string{"dify-agent drive push", "Upload one local file or directory", "--kind", "--json"},
- },
{
name: "config",
args: []string{"config", "--help"},
diff --git a/dify-agent-runtime/docker/Dockerfile b/dify-agent-runtime/docker/Dockerfile
index ca058727e45..b0ed2e499fa 100644
--- a/dify-agent-runtime/docker/Dockerfile
+++ b/dify-agent-runtime/docker/Dockerfile
@@ -71,9 +71,8 @@ COPY --from=go-builder /bin/shellctl-runner /usr/local/bin/shellctl-runner
COPY --from=go-builder /bin/dify-agent /usr/local/bin/dify-agent
RUN useradd --create-home --shell /bin/sh dify \
- && mkdir -p /mnt/drive \
&& chown dify:dify /home \
- && chown -R dify:dify /home/dify /mnt/drive
+ && chown -R dify:dify /home/dify
USER dify
WORKDIR /home/dify
diff --git a/dify-agent-runtime/internal/agentcli/archive.go b/dify-agent-runtime/internal/agentcli/archive.go
index 3a458009d71..e4b1632d03e 100644
--- a/dify-agent-runtime/internal/agentcli/archive.go
+++ b/dify-agent-runtime/internal/agentcli/archive.go
@@ -135,3 +135,26 @@ func extractZip(archivePath string, targetDir string) error {
}
return nil
}
+
+func shouldSkipDir(name string) bool {
+ skip := map[string]bool{
+ ".git": true, "__pycache__": true, ".pytest_cache": true,
+ ".mypy_cache": true, ".ruff_cache": true, ".venv": true, "node_modules": true,
+ }
+ return skip[name]
+}
+
+func buildSkillArchive(dirPath string) (string, error) {
+ tmpFile, err := os.CreateTemp("", "skill-archive-*.zip")
+ if err != nil {
+ return "", fmt.Errorf("create temp archive: %w", err)
+ }
+ archivePath := tmpFile.Name()
+ _ = tmpFile.Close()
+
+ if err := createZipArchive(archivePath, dirPath); err != nil {
+ _ = os.Remove(archivePath)
+ return "", err
+ }
+ return archivePath, nil
+}
diff --git a/dify-agent-runtime/internal/agentcli/client.go b/dify-agent-runtime/internal/agentcli/client.go
index 8e84991ab72..e58efd50cff 100644
--- a/dify-agent-runtime/internal/agentcli/client.go
+++ b/dify-agent-runtime/internal/agentcli/client.go
@@ -10,10 +10,6 @@ type StubClient interface {
CreateToolFileUploadURL(ctx context.Context, filename, mimetype string) (string, error)
CreateFileDownloadURL(ctx context.Context, transferMethod string, reference, url *string, forFrontend bool) (*FileDownloadResponse, error)
- // Drive operations (HTTP-only control-plane)
- GetDriveManifest(ctx context.Context, prefix string, includeDownloadURL bool) (*DriveManifestResponse, error)
- CommitDrive(ctx context.Context, items []DriveCommitItem) ([]byte, error)
-
// Config operations (HTTP-only control-plane)
GetConfigManifest(ctx context.Context) ([]byte, error)
CreateConfigDownloadURL(ctx context.Context, kind, name string) (*FileDownloadResponse, error)
diff --git a/dify-agent-runtime/internal/agentcli/client_http.go b/dify-agent-runtime/internal/agentcli/client_http.go
index 14abb3f368b..495bf0a822c 100644
--- a/dify-agent-runtime/internal/agentcli/client_http.go
+++ b/dify-agent-runtime/internal/agentcli/client_http.go
@@ -127,45 +127,6 @@ func (c *httpStubClient) CreateFileDownloadURL(_ context.Context, transferMethod
return &resp, nil
}
-func (c *httpStubClient) GetDriveManifest(_ context.Context, prefix string, includeDownloadURL bool) (*DriveManifestResponse, error) {
- params := map[string]string{
- "prefix": prefix,
- }
- if includeDownloadURL {
- params["include_download_url"] = "true"
- } else {
- params["include_download_url"] = "false"
- }
-
- body, statusCode, err := c.http.getJSON("/drive/manifest", params)
- if err != nil {
- return nil, err
- }
- if err := checkHTTPError(body, statusCode, "drive manifest"); err != nil {
- return nil, err
- }
-
- var manifest DriveManifestResponse
- if err := json.Unmarshal(body, &manifest); err != nil {
- return nil, fmt.Errorf("parse drive manifest: %w", err)
- }
- return &manifest, nil
-}
-
-func (c *httpStubClient) CommitDrive(_ context.Context, items []DriveCommitItem) ([]byte, error) {
- payload := map[string]any{
- "items": items,
- }
- body, statusCode, err := c.http.postJSON("/drive/commit", payload)
- if err != nil {
- return nil, err
- }
- if err := checkHTTPError(body, statusCode, "drive commit"); err != nil {
- return nil, err
- }
- return body, nil
-}
-
func (c *httpStubClient) GetConfigManifest(_ context.Context) ([]byte, error) {
body, statusCode, err := c.http.getJSON("/config/manifest", nil)
if err != nil {
diff --git a/dify-agent-runtime/internal/agentcli/config.go b/dify-agent-runtime/internal/agentcli/config.go
index 69c038a8e2f..17b4b27b731 100644
--- a/dify-agent-runtime/internal/agentcli/config.go
+++ b/dify-agent-runtime/internal/agentcli/config.go
@@ -10,6 +10,11 @@ import (
const defaultConfigBase = ".dify_conf"
+type ConfigFileRef struct {
+ Kind string `json:"kind"`
+ ID string `json:"id"`
+}
+
// RunConfigManifest executes the `config manifest` command.
func RunConfigManifest(env *Environment) error {
client, err := NewStubClient(env)
@@ -216,8 +221,8 @@ func RunConfigSkillsPush(env *Environment, paths []string) error {
defer func() { _ = client.Close() }()
type skillPushItem struct {
- Name string `json:"name"`
- FileRef *DriveFileRef `json:"file_ref"`
+ Name string `json:"name"`
+ FileRef *ConfigFileRef `json:"file_ref"`
}
var skills []skillPushItem
@@ -243,7 +248,7 @@ func RunConfigSkillsPush(env *Environment, paths []string) error {
defer func() { _ = os.Remove(archivePath) }()
name := filepath.Base(absPath)
- fileRef, err := uploadAndPrepareConfigItem(client, archivePath)
+ fileRef, err := uploadConfigFile(client, archivePath)
if err != nil {
return fmt.Errorf("upload config skill %q: %w", name, err)
}
@@ -280,8 +285,8 @@ func RunConfigFilesPush(env *Environment, paths []string) error {
defer func() { _ = client.Close() }()
type filePushItem struct {
- Name string `json:"name"`
- FileRef *DriveFileRef `json:"file_ref"`
+ Name string `json:"name"`
+ FileRef *ConfigFileRef `json:"file_ref"`
}
var files []filePushItem
@@ -296,7 +301,7 @@ func RunConfigFilesPush(env *Environment, paths []string) error {
}
name := filepath.Base(absPath)
- fileRef, err := uploadAndPrepareConfigItem(client, absPath)
+ fileRef, err := uploadConfigFile(client, absPath)
if err != nil {
return fmt.Errorf("upload config file %q: %w", name, err)
}
@@ -320,7 +325,7 @@ func RunConfigFilesPush(env *Environment, paths []string) error {
return nil
}
-func uploadAndPrepareConfigItem(client StubClient, filePath string) (*DriveFileRef, error) {
+func uploadConfigFile(client StubClient, filePath string) (*ConfigFileRef, error) {
filename := filepath.Base(filePath)
mimetype := guessMIMEType(filename)
uploadURL, err := client.CreateToolFileUploadURL(context.Background(), filename, mimetype)
@@ -331,16 +336,16 @@ func uploadAndPrepareConfigItem(client StubClient, filePath string) (*DriveFileR
if err != nil {
return nil, fmt.Errorf("upload data: %w", err)
}
-
- var uploadResult map[string]any
+ var uploadResult struct {
+ ID string `json:"id"`
+ }
if err := json.Unmarshal(uploadBody, &uploadResult); err != nil {
return nil, fmt.Errorf("parse upload result: %w", err)
}
- toolFileID, _ := uploadResult["id"].(string)
- if toolFileID == "" {
+ if uploadResult.ID == "" {
return nil, fmt.Errorf("upload response is missing id")
}
- return &DriveFileRef{Kind: "tool_file", ID: toolFileID}, nil
+ return &ConfigFileRef{Kind: "tool_file", ID: uploadResult.ID}, nil
}
// RunConfigSkillsDelete executes the `config skills delete` command.
diff --git a/dify-agent-runtime/internal/agentcli/config_test.go b/dify-agent-runtime/internal/agentcli/config_test.go
index 833932ff545..b38d565e287 100644
--- a/dify-agent-runtime/internal/agentcli/config_test.go
+++ b/dify-agent-runtime/internal/agentcli/config_test.go
@@ -13,6 +13,117 @@ import (
"testing"
)
+type configPushCapture struct {
+ payload map[string]any
+ upload []byte
+}
+
+func newConfigPushServer(t *testing.T) (*httptest.Server, *configPushCapture) {
+ t.Helper()
+ capture := &configPushCapture{}
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/agent-stub/files/upload-request":
+ _ = json.NewEncoder(w).Encode(map[string]string{"upload_url": server.URL + "/uploads/config-asset"})
+ case "/uploads/config-asset":
+ file, _, err := r.FormFile("file")
+ if err != nil {
+ http.Error(w, "missing upload", http.StatusBadRequest)
+ return
+ }
+ defer func() { _ = file.Close() }()
+ capture.upload, err = io.ReadAll(file)
+ if err != nil {
+ http.Error(w, "bad upload", http.StatusBadRequest)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]string{"id": "tool-file-1"})
+ case "/agent-stub/config/push":
+ if err := json.NewDecoder(r.Body).Decode(&capture.payload); err != nil {
+ http.Error(w, "bad config", http.StatusBadRequest)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]string{"result": "success"})
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ return server, capture
+}
+
+func assertConfigPushItem(t *testing.T, payload map[string]any, key string, name string) {
+ t.Helper()
+ items, ok := payload[key].([]any)
+ if !ok || len(items) != 1 {
+ t.Fatalf("%s = %#v, want one item", key, payload[key])
+ }
+ item, ok := items[0].(map[string]any)
+ if !ok || item["name"] != name {
+ t.Fatalf("%s item = %#v", key, items[0])
+ }
+ fileRef, ok := item["file_ref"].(map[string]any)
+ if !ok || fileRef["kind"] != "tool_file" || fileRef["id"] != "tool-file-1" {
+ t.Fatalf("file_ref = %#v", item["file_ref"])
+ }
+}
+
+func TestConfigFilesPushUploadsFileAndPushesToolFileRef(t *testing.T) {
+ server, capture := newConfigPushServer(t)
+ defer server.Close()
+
+ filePath := filepath.Join(t.TempDir(), "guide.txt")
+ if err := os.WriteFile(filePath, []byte("guide"), 0o644); err != nil {
+ t.Fatalf("write config file: %v", err)
+ }
+ if err := RunConfigFilesPush(
+ &Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
+ []string{filePath},
+ ); err != nil {
+ t.Fatalf("push config file: %v", err)
+ }
+
+ if string(capture.upload) != "guide" {
+ t.Fatalf("uploaded file = %q", capture.upload)
+ }
+ assertConfigPushItem(t, capture.payload, "files", "guide.txt")
+}
+
+func TestConfigSkillsPushUploadsArchiveAndPushesToolFileRef(t *testing.T) {
+ server, capture := newConfigPushServer(t)
+ defer server.Close()
+
+ skillDir := filepath.Join(t.TempDir(), "alpha")
+ if err := os.Mkdir(skillDir, 0o755); err != nil {
+ t.Fatalf("create skill directory: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Alpha\n"), 0o644); err != nil {
+ t.Fatalf("write SKILL.md: %v", err)
+ }
+ if err := RunConfigSkillsPush(
+ &Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
+ []string{skillDir},
+ ); err != nil {
+ t.Fatalf("push config skill: %v", err)
+ }
+
+ archive, err := zip.NewReader(bytes.NewReader(capture.upload), int64(len(capture.upload)))
+ if err != nil {
+ t.Fatalf("open uploaded skill archive: %v", err)
+ }
+ foundSkillMD := false
+ for _, file := range archive.File {
+ if file.Name == "SKILL.md" {
+ foundSkillMD = true
+ break
+ }
+ }
+ if !foundSkillMD {
+ t.Fatalf("uploaded skill archive does not contain SKILL.md")
+ }
+ assertConfigPushItem(t, capture.payload, "skills", "alpha")
+}
+
func TestConfigPullRequestsURLThenDownloadsFromDataPlane(t *testing.T) {
skillArchive := zipFixture(t, map[string]string{"SKILL.md": "# Alpha\n", "reference.md": "guide"})
tests := []struct {
diff --git a/dify-agent-runtime/internal/agentcli/drive.go b/dify-agent-runtime/internal/agentcli/drive.go
deleted file mode 100644
index 8d5e19f265a..00000000000
--- a/dify-agent-runtime/internal/agentcli/drive.go
+++ /dev/null
@@ -1,353 +0,0 @@
-package agentcli
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "strings"
-)
-
-// DriveItem represents one item in a drive manifest.
-type DriveItem struct {
- Key string `json:"key"`
- Size *int64 `json:"size,omitempty"`
- MimeType string `json:"mime_type,omitempty"`
- Hash string `json:"hash,omitempty"`
- DownloadURL *string `json:"download_url,omitempty"`
-}
-
-// DriveManifestResponse is the drive manifest from the Agent Stub.
-type DriveManifestResponse struct {
- Items []DriveItem `json:"items"`
-}
-
-// DrivePullResultItem represents one pulled drive file.
-type DrivePullResultItem struct {
- Key string `json:"key"`
- LocalPath string `json:"local_path"`
-}
-
-// DrivePullResult is the JSON output for `dify-agent drive pull --json`.
-type DrivePullResult struct {
- Items []DrivePullResultItem `json:"items"`
-}
-
-// DriveCommitItem represents one file to commit into the drive.
-type DriveCommitItem struct {
- Key string `json:"key"`
- FileRef DriveFileRef `json:"file_ref"`
-}
-
-// DriveFileRef is the reference to an uploaded file.
-type DriveFileRef struct {
- Kind string `json:"kind"`
- ID string `json:"id"`
-}
-
-// DriveCommitResponse is the response from a drive commit.
-type DriveCommitResponse struct {
- Items []DriveItem `json:"items"`
-}
-
-// RunDriveList executes the `drive list` command.
-func RunDriveList(env *Environment, pathPrefix string, jsonOutput bool) error {
- client, err := NewStubClient(env)
- if err != nil {
- return err
- }
- defer func() { _ = client.Close() }()
-
- manifest, err := client.GetDriveManifest(context.Background(), pathPrefix, false)
- if err != nil {
- return err
- }
-
- if jsonOutput {
- out, _ := json.Marshal(manifest)
- fmt.Println(string(out))
- return nil
- }
-
- for _, item := range manifest.Items {
- size := "-"
- if item.Size != nil {
- size = fmt.Sprintf("%d", *item.Size)
- }
- mimeType := item.MimeType
- if mimeType == "" {
- mimeType = "-"
- }
- hash := item.Hash
- if hash == "" {
- hash = "-"
- }
- fmt.Printf("%s\t%s\t%s\t%s\n", size, mimeType, hash, item.Key)
- }
- return nil
-}
-
-// RunDrivePull executes the `drive pull` command.
-func RunDrivePull(env *Environment, targets []string, localBase string, jsonOutput bool) error {
- client, err := NewStubClient(env)
- if err != nil {
- return err
- }
- defer func() { _ = client.Close() }()
-
- if localBase == "" {
- localBase = ReadDriveBase()
- }
- resolvedBase, err := filepath.Abs(localBase)
- if err != nil {
- return fmt.Errorf("resolve drive base: %w", err)
- }
-
- if len(targets) == 0 {
- targets = []string{""}
- }
-
- ctx := context.Background()
- resultItems := []DrivePullResultItem{}
-
- for _, target := range targets {
- manifest, err := client.GetDriveManifest(ctx, target, true)
- if err != nil {
- return err
- }
-
- if len(manifest.Items) == 0 {
- continue
- }
-
- localPath := resolveDriveDestination(resolvedBase, target)
- resultItems = append(resultItems, DrivePullResultItem{Key: target, LocalPath: localPath})
-
- for _, item := range manifest.Items {
- if item.DownloadURL == nil || *item.DownloadURL == "" {
- return fmt.Errorf("drive manifest item is missing download_url: %s", item.Key)
- }
-
- destPath := resolveDriveDestination(resolvedBase, item.Key)
- destDir := filepath.Dir(destPath)
- if err := os.MkdirAll(destDir, 0o755); err != nil {
- return fmt.Errorf("create directory: %w", err)
- }
-
- data, err := client.DownloadFromURL(*item.DownloadURL)
- if err != nil {
- return fmt.Errorf("download %s: %w", item.Key, err)
- }
-
- if err := os.WriteFile(destPath, data, 0o644); err != nil {
- return fmt.Errorf("write %s: %w", destPath, err)
- }
- }
- }
-
- if jsonOutput {
- out, _ := json.Marshal(DrivePullResult{Items: resultItems})
- fmt.Println(string(out))
- return nil
- }
-
- for _, item := range resultItems {
- fmt.Println(item.LocalPath)
- }
- return nil
-}
-
-// RunDrivePush executes the `drive push` command.
-func RunDrivePush(env *Environment, localPath string, drivePath string, kind string) error {
- absPath, err := filepath.Abs(localPath)
- if err != nil {
- return fmt.Errorf("resolve path: %w", err)
- }
-
- info, err := os.Stat(absPath)
- if err != nil {
- return fmt.Errorf("local path not found: %s", absPath)
- }
-
- client, err := NewStubClient(env)
- if err != nil {
- return err
- }
- defer func() { _ = client.Close() }()
-
- if info.IsDir() {
- if kind == "" {
- return fmt.Errorf("directory drive push requires --kind skill or --kind dir")
- }
- if kind == "file" {
- return fmt.Errorf("--kind file requires a file")
- }
- if kind == "dir" {
- return pushDirectory(client, absPath, drivePath)
- }
- return pushSkillDirectory(client, absPath, drivePath)
- }
-
- // Single file push
- if kind == "skill" {
- return fmt.Errorf("--kind skill requires a directory containing SKILL.md")
- }
- if kind == "dir" {
- return fmt.Errorf("--kind dir requires a directory")
- }
-
- commitItem, err := uploadAndPrepareCommitItem(client, absPath, drivePath)
- if err != nil {
- return err
- }
-
- return commitDriveItems(client, []DriveCommitItem{*commitItem})
-}
-
-func pushDirectory(client StubClient, dirPath string, drivePath string) error {
- var items []DriveCommitItem
-
- err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
- if err != nil {
- return err
- }
- if info.IsDir() {
- if shouldSkipDir(info.Name()) {
- return filepath.SkipDir
- }
- return nil
- }
- if info.Mode()&os.ModeSymlink != 0 {
- return fmt.Errorf("drive push does not support symlinked files: %s", path)
- }
-
- relPath, _ := filepath.Rel(dirPath, path)
- driveKey := joinDriveKey(drivePath, filepath.ToSlash(relPath))
- commitItem, err := uploadAndPrepareCommitItem(client, path, driveKey)
- if err != nil {
- return err
- }
- items = append(items, *commitItem)
- return nil
- })
- if err != nil {
- return err
- }
-
- if len(items) == 0 {
- return fmt.Errorf("directory has no regular files: %s", dirPath)
- }
-
- return commitDriveItems(client, items)
-}
-
-func pushSkillDirectory(client StubClient, dirPath string, drivePath string) error {
- skillMDPath := filepath.Join(dirPath, "SKILL.md")
- if _, err := os.Stat(skillMDPath); os.IsNotExist(err) {
- return fmt.Errorf("--kind skill requires a directory containing SKILL.md")
- }
-
- // Upload SKILL.md
- skillMDItem, err := uploadAndPrepareCommitItem(client, skillMDPath, joinDriveKey(drivePath, "SKILL.md"))
- if err != nil {
- return err
- }
-
- // Build and upload archive
- archivePath, err := buildSkillArchive(dirPath)
- if err != nil {
- return err
- }
- defer func() { _ = os.Remove(archivePath) }()
-
- archiveItem, err := uploadAndPrepareCommitItem(client, archivePath, joinDriveKey(drivePath, ".DIFY-SKILL-FULL.zip"))
- if err != nil {
- return err
- }
-
- return commitDriveItems(client, []DriveCommitItem{*skillMDItem, *archiveItem})
-}
-
-func uploadAndPrepareCommitItem(client StubClient, filePath string, driveKey string) (*DriveCommitItem, error) {
- filename := filepath.Base(filePath)
- mimetype := guessMIMEType(filename)
- ctx := context.Background()
-
- // Request upload URL
- uploadURL, err := client.CreateFileUploadURL(ctx, filename, mimetype)
- if err != nil {
- return nil, err
- }
-
- // Upload
- uploadBody, err := client.UploadFileToURL(uploadURL, filePath, filename, mimetype)
- if err != nil {
- return nil, err
- }
-
- var uploadResult map[string]any
- if err := json.Unmarshal(uploadBody, &uploadResult); err != nil {
- return nil, fmt.Errorf("parse upload result: %w", err)
- }
-
- toolFileID, _ := uploadResult["id"].(string)
- if toolFileID == "" {
- return nil, fmt.Errorf("upload response is missing id")
- }
-
- return &DriveCommitItem{
- Key: driveKey,
- FileRef: DriveFileRef{Kind: "tool_file", ID: toolFileID},
- }, nil
-}
-
-func commitDriveItems(client StubClient, items []DriveCommitItem) error {
- body, err := client.CommitDrive(context.Background(), items)
- if err != nil {
- return err
- }
- fmt.Println(string(body))
- return nil
-}
-
-func resolveDriveDestination(basePath string, key string) string {
- if key == "" {
- return basePath
- }
- return filepath.Join(basePath, filepath.FromSlash(key))
-}
-
-func joinDriveKey(base string, child string) string {
- stripped := strings.TrimRight(base, "/")
- child = strings.TrimLeft(child, "/")
- if stripped == "" {
- return child
- }
- return stripped + "/" + child
-}
-
-func shouldSkipDir(name string) bool {
- skip := map[string]bool{
- ".git": true, "__pycache__": true, ".pytest_cache": true,
- ".mypy_cache": true, ".ruff_cache": true, ".venv": true, "node_modules": true,
- }
- return skip[name]
-}
-
-// buildSkillArchive creates a zip archive of the skill directory.
-func buildSkillArchive(dirPath string) (string, error) {
- // Create temp file for archive
- tmpFile, err := os.CreateTemp("", "skill-archive-*.zip")
- if err != nil {
- return "", fmt.Errorf("create temp archive: %w", err)
- }
- archivePath := tmpFile.Name()
- _ = tmpFile.Close()
-
- if err := createZipArchive(archivePath, dirPath); err != nil {
- _ = os.Remove(archivePath)
- return "", err
- }
- return archivePath, nil
-}
diff --git a/dify-agent-runtime/internal/agentcli/env.go b/dify-agent-runtime/internal/agentcli/env.go
index 04d6f5cf16d..f81a3963315 100644
--- a/dify-agent-runtime/internal/agentcli/env.go
+++ b/dify-agent-runtime/internal/agentcli/env.go
@@ -15,9 +15,6 @@ import (
const (
EnvAPIBaseURL = envvar.EnvAgentStubAPIBaseURL
EnvAuthJWE = envvar.EnvAgentStubAuthJWE
- EnvDriveBase = envvar.EnvAgentStubDriveBase
-
- DefaultDriveBase = envvar.DefaultDriveBase
)
// Environment holds validated Agent Stub connection parameters.
@@ -65,14 +62,6 @@ func HasEnvironment() bool {
return os.Getenv(EnvAPIBaseURL) != "" && os.Getenv(EnvAuthJWE) != ""
}
-// ReadDriveBase returns the configured drive base or the default.
-func ReadDriveBase() string {
- if v := strings.TrimSpace(os.Getenv(EnvDriveBase)); v != "" {
- return v
- }
- return DefaultDriveBase
-}
-
// ParseEndpoint parses an Agent Stub URL and normalizes it.
func ParseEndpoint(rawURL string) (*Endpoint, error) {
stripped := strings.TrimSpace(rawURL)
diff --git a/dify-agent-runtime/internal/agentcli/env_test.go b/dify-agent-runtime/internal/agentcli/env_test.go
index d5b333c82e3..4347fcaf199 100644
--- a/dify-agent-runtime/internal/agentcli/env_test.go
+++ b/dify-agent-runtime/internal/agentcli/env_test.go
@@ -140,17 +140,3 @@ func TestReadEnvironment_Valid(t *testing.T) {
t.Errorf("AuthJWE = %q, want %q", env.AuthJWE, "test-token")
}
}
-
-func TestReadDriveBase_Default(t *testing.T) {
- t.Setenv(EnvDriveBase, "")
- if got := ReadDriveBase(); got != DefaultDriveBase {
- t.Errorf("ReadDriveBase() = %q, want %q", got, DefaultDriveBase)
- }
-}
-
-func TestReadDriveBase_Custom(t *testing.T) {
- t.Setenv(EnvDriveBase, "/custom/drive")
- if got := ReadDriveBase(); got != "/custom/drive" {
- t.Errorf("ReadDriveBase() = %q, want %q", got, "/custom/drive")
- }
-}
diff --git a/dify-agent-runtime/internal/envvar/envvar.go b/dify-agent-runtime/internal/envvar/envvar.go
index 40f9f9a97ad..6c14eb05666 100644
--- a/dify-agent-runtime/internal/envvar/envvar.go
+++ b/dify-agent-runtime/internal/envvar/envvar.go
@@ -28,13 +28,6 @@ const (
// EnvAgentStubAuthJWE is the per-request JWE token for Agent Stub auth.
EnvAgentStubAuthJWE = "DIFY_AGENT_STUB_AUTH_JWE"
-
- // EnvAgentStubDriveBase is the sandbox-local drive directory for the agent.
- EnvAgentStubDriveBase = "DIFY_AGENT_STUB_DRIVE_BASE"
-
- // DefaultDriveBase is the default Agent Stub drive mount point.
- // currently unused.
- DefaultDriveBase = "/mnt/drive"
)
// PathIsolationEnabled returns whether Landlock filesystem isolation is active.
diff --git a/dify-agent-runtime/internal/landlock/config.go b/dify-agent-runtime/internal/landlock/config.go
index 46e10e55474..234174840e2 100644
--- a/dify-agent-runtime/internal/landlock/config.go
+++ b/dify-agent-runtime/internal/landlock/config.go
@@ -19,7 +19,7 @@ type Config struct {
var (
// DefaultRWPaths are directories granted read-write access besides HOME.
- // Agent-specific paths (e.g. drive base) are added dynamically by the runner.
+ // Agent-specific paths are added dynamically by the runner.
DefaultRWPaths = []string{}
// DefaultROPaths are directories granted read-only + execute access.
diff --git a/dify-agent/.example.env b/dify-agent/.example.env
index 44c20ad0432..d89310a9e41 100644
--- a/dify-agent/.example.env
+++ b/dify-agent/.example.env
@@ -21,7 +21,7 @@ DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi
# Dify API inner endpoints
-# Base URL for Dify API inner endpoints used by Agent Stub config/file/drive requests.
+# Base URL for Dify API inner endpoints used by Agent Stub config and file requests.
DIFY_AGENT_INNER_API_URL=http://localhost:5001
# Must match API/worker INNER_API_KEY_FOR_PLUGIN, not the generic INNER_API_KEY.
DIFY_AGENT_INNER_API_KEY=
diff --git a/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md b/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md
index 9520b6e29cd..63f76ecce1d 100644
--- a/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md
+++ b/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md
@@ -28,7 +28,6 @@ RunLayerSpec(
| Config field | Meaning |
| --- | --- |
-| `agent_stub_drive_ref` | Optional Drive ref used by shell-visible Agent Stub commands. |
| `cli_tools` | CLI bootstrap declarations with install commands and scoped environment metadata. |
| `env` | Normal environment variables exported to Shell commands. |
| `secret_refs` | Names of secret environment variables supplied by the backend environment. |
diff --git a/dify-agent/src/dify_agent/agent_stub/_constants.py b/dify-agent/src/dify_agent/agent_stub/_constants.py
deleted file mode 100644
index d21e073f5ef..00000000000
--- a/dify-agent/src/dify_agent/agent_stub/_constants.py
+++ /dev/null
@@ -1,15 +0,0 @@
-"""Zero-side-effect Agent Stub constants shared across client-safe modules."""
-
-from __future__ import annotations
-
-from typing import Final
-
-
-AGENT_STUB_DRIVE_BASE_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_DRIVE_BASE"
-DEFAULT_AGENT_STUB_DRIVE_BASE: Final[str] = "/mnt/drive"
-
-
-__all__ = [
- "AGENT_STUB_DRIVE_BASE_ENV_VAR",
- "DEFAULT_AGENT_STUB_DRIVE_BASE",
-]
diff --git a/dify-agent/src/dify_agent/agent_stub/_drive_materialization.py b/dify-agent/src/dify_agent/agent_stub/_drive_materialization.py
deleted file mode 100644
index b59395496d1..00000000000
--- a/dify-agent/src/dify_agent/agent_stub/_drive_materialization.py
+++ /dev/null
@@ -1,176 +0,0 @@
-"""Shared drive download materialization helpers.
-
-This module centralizes the safety-critical filesystem logic used by both the
-sandbox-visible CLI and the runtime drive layer. It owns path resolution under
-one local drive base, overwrite-via-temp-file semantics, payload size checks,
-and safe extraction of downloaded skill archives so those invariants cannot
-drift between the two call sites.
-"""
-
-from __future__ import annotations
-
-import stat
-from dataclasses import dataclass
-from pathlib import Path, PurePosixPath
-from tempfile import TemporaryDirectory
-from typing import Final
-from uuid import uuid4
-from zipfile import BadZipFile, ZipFile, ZipInfo
-
-
-SKILL_ARCHIVE_FILENAME: Final[str] = ".DIFY-SKILL-FULL.zip"
-
-
-@dataclass(frozen=True, slots=True)
-class DriveDownloadPayload:
- """One downloaded drive payload ready to materialize under a local base."""
-
- key: str
- payload: bytes
- size: int | None = None
-
-
-class DriveMaterializationValidationError(ValueError):
- """Raised when one drive key or archive entry is structurally unsafe."""
-
-
-class DriveMaterializationTransferError(RuntimeError):
- """Raised when one downloaded payload cannot be safely materialized."""
-
-
-def materialize_drive_downloads(
- *,
- base_path: Path,
- downloads: list[DriveDownloadPayload],
-) -> list[Path]:
- """Write downloaded drive payloads under one local base and extract skills.
-
- The helper preserves caller-provided order in the returned list of paths.
- Skill archives are extracted and deleted only after every payload has been
- written successfully so partial extraction cannot outlive a later failure in
- the same batch. The returned path for an archive is the path where it was
- downloaded before successful extraction.
- """
-
- resolved_base_path = base_path.expanduser().resolve()
- try:
- _ = resolved_base_path.mkdir(parents=True, exist_ok=True)
- except OSError as exc:
- raise DriveMaterializationTransferError(f"failed to prepare drive base {resolved_base_path}") from exc
-
- written_paths: list[Path] = []
- archive_paths: list[Path] = []
- for download in downloads:
- if download.size is not None and len(download.payload) != download.size:
- raise DriveMaterializationTransferError(f"downloaded drive file size mismatch for {download.key}")
- destination = resolve_drive_destination(resolved_base_path, download.key)
- try:
- destination.parent.mkdir(parents=True, exist_ok=True)
- temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}")
- _ = temp_path.write_bytes(download.payload)
- _ = temp_path.replace(destination)
- except OSError as exc:
- raise DriveMaterializationTransferError(f"failed to materialize drive file {download.key}") from exc
- written_paths.append(destination)
- if destination.name == SKILL_ARCHIVE_FILENAME:
- archive_paths.append(destination)
-
- for archive_path in sorted(archive_paths):
- extract_skill_archive(archive_path)
- _delete_extracted_archive(archive_path)
- return written_paths
-
-
-def resolve_drive_destination(base_path: Path, drive_key: str) -> Path:
- """Resolve one drive key under a local base and reject path traversal."""
-
- destination = (base_path / Path(drive_key)).resolve()
- try:
- destination.relative_to(base_path)
- except ValueError as exc:
- raise DriveMaterializationValidationError(f"drive key resolves outside the drive base: {drive_key}") from exc
- return destination
-
-
-def extract_archive_to_directory(archive_path: Path, *, target_dir: Path) -> None:
- """Safely extract one downloaded archive into one resolved target directory."""
-
- resolved_target_dir = target_dir.resolve()
- try:
- with TemporaryDirectory(dir=resolved_target_dir, prefix=".dify-skill-extract-") as staging_dir_name:
- staging_dir = Path(staging_dir_name).resolve()
- with ZipFile(archive_path) as archive:
- for zip_info in archive.infolist():
- destination = _resolve_zip_entry_destination(staging_dir, zip_info.filename)
- if _is_zip_symlink(zip_info):
- raise DriveMaterializationValidationError(
- f"skill archive contains unsupported symlink entry: {zip_info.filename}"
- )
- if zip_info.is_dir():
- destination.mkdir(parents=True, exist_ok=True)
- continue
- destination.parent.mkdir(parents=True, exist_ok=True)
- with archive.open(zip_info) as source_file:
- temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}")
- _ = temp_path.write_bytes(source_file.read())
- _ = temp_path.replace(destination)
- for staged_path in sorted(staging_dir.rglob("*")):
- if staged_path.is_dir():
- continue
- relative_path = staged_path.relative_to(staging_dir)
- destination = (resolved_target_dir / relative_path).resolve()
- destination.parent.mkdir(parents=True, exist_ok=True)
- _ = staged_path.replace(destination)
- except DriveMaterializationValidationError:
- raise
- except (BadZipFile, OSError) as exc:
- raise DriveMaterializationTransferError(f"downloaded skill archive is invalid: {archive_path.name}") from exc
-
-
-def extract_skill_archive(archive_path: Path) -> None:
- """Safely extract one downloaded skill archive into its containing directory."""
-
- extract_archive_to_directory(archive_path, target_dir=archive_path.parent.resolve())
-
-
-def _resolve_zip_entry_destination(target_dir: Path, entry_name: str) -> Path:
- normalized_name = entry_name.replace("\\", "/")
- pure_path = PurePosixPath(normalized_name)
- if not normalized_name or normalized_name.startswith("/") or pure_path.is_absolute():
- raise DriveMaterializationValidationError(f"skill archive contains unsafe absolute path: {entry_name}")
- if any(part in {"", ".", ".."} for part in pure_path.parts):
- raise DriveMaterializationValidationError(f"skill archive contains unsafe path traversal entry: {entry_name}")
- destination = (target_dir / Path(*pure_path.parts)).resolve()
- try:
- destination.relative_to(target_dir)
- except ValueError as exc:
- raise DriveMaterializationValidationError(
- f"skill archive entry resolves outside the skill directory: {entry_name}"
- ) from exc
- return destination
-
-
-def _is_zip_symlink(zip_info: ZipInfo) -> bool:
- file_mode = zip_info.external_attr >> 16
- return stat.S_ISLNK(file_mode)
-
-
-def _delete_extracted_archive(archive_path: Path) -> None:
- try:
- archive_path.unlink(missing_ok=True)
- except OSError as exc:
- raise DriveMaterializationTransferError(
- f"failed to delete extracted skill archive: {archive_path.name}"
- ) from exc
-
-
-__all__ = [
- "DriveDownloadPayload",
- "DriveMaterializationTransferError",
- "DriveMaterializationValidationError",
- "SKILL_ARCHIVE_FILENAME",
- "extract_archive_to_directory",
- "extract_skill_archive",
- "materialize_drive_downloads",
- "resolve_drive_destination",
-]
diff --git a/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py b/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py
index 9bd7d1f994a..d776f92ec83 100644
--- a/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py
+++ b/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py
@@ -1,7 +1,5 @@
"""Client-safe protocol exports for the Dify Agent Stub package."""
-from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE
-
from .agent_stub import (
AGENT_STUB_AUTH_JWE_ENV_VAR,
AGENT_STUB_PROTOCOL_VERSION,
@@ -20,12 +18,6 @@ from .agent_stub import (
AgentStubConfigPushSkillItem,
AgentStubConfigSkillItem,
AgentStubConfigVersionInfo,
- AgentStubDriveCommitItem,
- AgentStubDriveCommitRequest,
- AgentStubDriveCommitResponse,
- AgentStubDriveFileRef,
- AgentStubDriveItem,
- AgentStubDriveManifestResponse,
AgentStubEndpoint,
AgentStubFileDownloadRequest,
AgentStubFileDownloadResponse,
@@ -39,9 +31,6 @@ from .agent_stub import (
agent_stub_config_push_url,
agent_stub_config_skill_inspect_url,
agent_stub_connections_url,
- agent_stub_drive_base_for_ref,
- agent_stub_drive_commit_url,
- agent_stub_drive_manifest_url,
agent_stub_file_download_request_url,
agent_stub_file_upload_request_url,
is_canonical_dify_file_reference,
@@ -51,10 +40,8 @@ from .agent_stub import (
__all__ = [
"AGENT_STUB_AUTH_JWE_ENV_VAR",
- "AGENT_STUB_DRIVE_BASE_ENV_VAR",
"AGENT_STUB_PROTOCOL_VERSION",
"AGENT_STUB_API_BASE_URL_ENV_VAR",
- "DEFAULT_AGENT_STUB_DRIVE_BASE",
"AgentStubConnectRequest",
"AgentStubConnectResponse",
"AgentStubConfigDownloadSource",
@@ -69,12 +56,6 @@ __all__ = [
"AgentStubConfigPushSkillItem",
"AgentStubConfigSkillItem",
"AgentStubConfigVersionInfo",
- "AgentStubDriveCommitItem",
- "AgentStubDriveCommitRequest",
- "AgentStubDriveCommitResponse",
- "AgentStubDriveFileRef",
- "AgentStubDriveItem",
- "AgentStubDriveManifestResponse",
"AgentStubEndpoint",
"AgentStubFileDownloadRequest",
"AgentStubFileDownloadResponse",
@@ -88,9 +69,6 @@ __all__ = [
"agent_stub_config_push_url",
"agent_stub_config_skill_inspect_url",
"agent_stub_connections_url",
- "agent_stub_drive_base_for_ref",
- "agent_stub_drive_commit_url",
- "agent_stub_drive_manifest_url",
"agent_stub_file_download_request_url",
"agent_stub_file_upload_request_url",
"is_canonical_dify_file_reference",
diff --git a/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py b/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py
index 3b0c8c419bb..8a65c19d94d 100644
--- a/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py
+++ b/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py
@@ -18,9 +18,6 @@ from urllib.parse import urlsplit, urlunsplit
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, JsonValue, model_validator
-from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE
-
-
AGENT_STUB_PROTOCOL_VERSION: Final[int] = 1
AGENT_STUB_API_BASE_URL_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_API_BASE_URL"
AGENT_STUB_AUTH_JWE_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_AUTH_JWE"
@@ -39,17 +36,6 @@ class AgentStubEndpoint:
path: str
-def agent_stub_drive_base_for_ref(drive_ref: str | None) -> str:
- """Return the fixed sandbox-local Agent Stub drive base for one drive ref."""
- normalized_ref = (drive_ref or "").strip()
- if not normalized_ref:
- return DEFAULT_AGENT_STUB_DRIVE_BASE
- drive_ref_parts = normalized_ref.split("/")
- if normalized_ref.startswith("/") or any(part in {"", ".", ".."} for part in drive_ref_parts):
- raise ValueError("Agent Stub drive_ref must be a safe relative path")
- return f"{DEFAULT_AGENT_STUB_DRIVE_BASE.rstrip('/')}/{'/'.join(drive_ref_parts)}"
-
-
def parse_agent_stub_endpoint(url: str) -> AgentStubEndpoint:
"""Parse an HTTP(S) Agent Stub endpoint and normalize its API root."""
stripped = url.strip()
@@ -103,16 +89,6 @@ def agent_stub_file_download_request_url(base_url: str) -> str:
return f"{_require_http_base_url(base_url)}/files/download-request"
-def agent_stub_drive_manifest_url(base_url: str) -> str:
- """Return the stable HTTP drive-manifest endpoint URL for one base URL."""
- return f"{_require_http_base_url(base_url)}/drive/manifest"
-
-
-def agent_stub_drive_commit_url(base_url: str) -> str:
- """Return the stable HTTP drive-commit endpoint URL for one base URL."""
- return f"{_require_http_base_url(base_url)}/drive/commit"
-
-
def agent_stub_config_manifest_url(base_url: str) -> str:
"""Return the stable HTTP config-manifest endpoint URL for one base URL."""
return f"{_require_http_base_url(base_url)}/config/manifest"
@@ -270,70 +246,6 @@ class AgentStubFileDownloadResponse(BaseModel):
download_url: str
-class AgentStubDriveFileRef(BaseModel):
- """Trusted file reference used by Agent Stub drive commit requests."""
-
- kind: Literal["upload_file", "tool_file"]
- id: str
-
- model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
-
-
-class AgentStubDriveCommitItem(BaseModel):
- """One drive key to file binding committed through the Agent Stub."""
-
- key: str
- file_ref: AgentStubDriveFileRef | None = None
- value_owned_by_drive: bool = True
- is_skill: bool = False
- skill_metadata: dict[str, str] | None = None
-
- model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
-
-
-class AgentStubDriveCommitRequest(BaseModel):
- """Request body for one Agent Stub drive commit batch."""
-
- items: list[AgentStubDriveCommitItem]
-
- model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
-
-
-class AgentStubDriveItem(BaseModel):
- """One manifest or commit item returned by the Agent Stub drive API.
-
- Known stable fields stay typed, while extra response metadata from the Dify
- API is preserved for forward compatibility.
- """
-
- key: str
- size: int | None = None
- hash: str | None = None
- mime_type: str | None = None
- file_kind: Literal["upload_file", "tool_file"] | None = None
- file_id: str | None = None
- created_at: int | None = None
- download_url: str | None = None
- value_owned_by_drive: bool | None = None
- removed: bool | None = None
- is_skill: bool | None = None
- skill_metadata: str | None = None
-
- model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow")
-
-
-class AgentStubDriveManifestResponse(BaseModel):
- """Response body for one Agent Stub drive manifest request."""
-
- items: list[AgentStubDriveItem]
-
-
-class AgentStubDriveCommitResponse(BaseModel):
- """Response body for one Agent Stub drive commit request."""
-
- items: list[AgentStubDriveItem]
-
-
class AgentStubConfigVersionInfo(BaseModel):
id: str
kind: Literal["snapshot", "draft", "build_draft"]
@@ -424,10 +336,8 @@ def _require_http_base_url(base_url: str) -> str:
__all__ = [
"AGENT_STUB_AUTH_JWE_ENV_VAR",
- "AGENT_STUB_DRIVE_BASE_ENV_VAR",
"AGENT_STUB_PROTOCOL_VERSION",
"AGENT_STUB_API_BASE_URL_ENV_VAR",
- "DEFAULT_AGENT_STUB_DRIVE_BASE",
"AgentStubConnectRequest",
"AgentStubConnectResponse",
"AgentStubEndpoint",
@@ -445,12 +355,6 @@ __all__ = [
"AgentStubConfigSkillItem",
"AgentStubConfigSkillItemsResponse",
"AgentStubConfigVersionInfo",
- "AgentStubDriveCommitItem",
- "AgentStubDriveCommitRequest",
- "AgentStubDriveCommitResponse",
- "AgentStubDriveFileRef",
- "AgentStubDriveItem",
- "AgentStubDriveManifestResponse",
"AgentStubFileDownloadRequest",
"AgentStubFileDownloadResponse",
"AgentStubFileMapping",
@@ -463,9 +367,6 @@ __all__ = [
"agent_stub_config_push_url",
"agent_stub_config_skill_inspect_url",
"agent_stub_connections_url",
- "agent_stub_drive_base_for_ref",
- "agent_stub_drive_commit_url",
- "agent_stub_drive_manifest_url",
"agent_stub_file_download_request_url",
"agent_stub_file_upload_request_url",
"is_canonical_dify_file_reference",
diff --git a/dify-agent/src/dify_agent/agent_stub/server/agent_stub_drive.py b/dify-agent/src/dify_agent/agent_stub/server/agent_stub_drive.py
deleted file mode 100644
index 8c86e5bc806..00000000000
--- a/dify-agent/src/dify_agent/agent_stub/server/agent_stub_drive.py
+++ /dev/null
@@ -1,190 +0,0 @@
-"""Server-side Dify API client for Agent Stub drive endpoints.
-
-The Agent Stub drive API is an HTTP-only control plane over the existing Dify
-agent drive inner APIs. Sandbox callers never send trusted tenant, agent, or
-user ids directly; this module receives an authenticated ``AgentStubPrincipal``,
-derives ``agent-`` from execution context, injects trusted identity
-fields into the Dify inner request, and normalizes transport, HTTP, JSON, and
-schema failures into ``AgentStubDriveRequestError`` for the route layer.
-"""
-
-from __future__ import annotations
-
-from collections.abc import Mapping
-from dataclasses import dataclass
-from typing import Any, Protocol
-
-import httpx
-from pydantic import ValidationError
-
-from dify_agent.agent_stub.protocol.agent_stub import (
- AgentStubDriveCommitRequest,
- AgentStubDriveCommitResponse,
- AgentStubDriveManifestResponse,
-)
-from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubPrincipal
-from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
-
-
-class AgentStubDriveRequestHandler(Protocol):
- """Trusted control-plane bridge from sandbox drive calls to Dify inner APIs."""
-
- async def get_manifest(
- self,
- *,
- principal: AgentStubPrincipal,
- prefix: str,
- include_download_url: bool,
- ) -> AgentStubDriveManifestResponse: ...
-
- async def commit(
- self,
- *,
- principal: AgentStubPrincipal,
- request: AgentStubDriveCommitRequest,
- ) -> AgentStubDriveCommitResponse: ...
-
-
-class AgentStubDriveRequestError(RuntimeError):
- """Raised when the Agent Stub cannot complete one drive control-plane call."""
-
- status_code: int
- detail: object
-
- def __init__(self, status_code: int, detail: object) -> None:
- self.status_code = status_code
- self.detail = detail
- super().__init__(str(detail))
-
-
-@dataclass(slots=True)
-class DifyApiAgentStubDriveRequestHandler:
- """Call Dify API inner drive endpoints on behalf of authenticated sandboxes.
-
- Manifest requests require ``tenant_id`` and ``agent_id`` from execution
- context and forward query parameters to
- ``/inner/api/drive/agent-/manifest``. Commit requests additionally
- require ``user_id`` and post a raw JSON payload to
- ``/inner/api/drive/agent-/commit``. Dify drive endpoints return
- raw ``{"items": [...]}`` payloads instead of plugin-style ``data`` envelopes,
- so this module validates the raw success payload directly.
- """
-
- inner_api_url: str
- inner_api_key: str
- timeout: httpx.Timeout | float = 30.0
-
- async def get_manifest(
- self,
- *,
- principal: AgentStubPrincipal,
- prefix: str,
- include_download_url: bool,
- ) -> AgentStubDriveManifestResponse:
- """Request one drive manifest from Dify's inner drive manifest endpoint."""
- execution_context = self._require_agent_context(principal.execution_context)
- payload = await self._get_inner_api(
- f"/inner/api/drive/{self._drive_ref(execution_context)}/manifest",
- {
- "tenant_id": execution_context.tenant_id,
- "prefix": prefix,
- "include_download_url": str(include_download_url).lower(),
- },
- )
- try:
- return AgentStubDriveManifestResponse.model_validate(payload)
- except ValidationError as exc:
- raise AgentStubDriveRequestError(502, "Dify API drive manifest response is invalid") from exc
-
- async def commit(
- self,
- *,
- principal: AgentStubPrincipal,
- request: AgentStubDriveCommitRequest,
- ) -> AgentStubDriveCommitResponse:
- """Commit one drive batch through Dify's inner drive commit endpoint."""
- execution_context = self._require_user_context(self._require_agent_context(principal.execution_context))
- payload = await self._post_inner_api(
- f"/inner/api/drive/{self._drive_ref(execution_context)}/commit",
- {
- "tenant_id": execution_context.tenant_id,
- "user_id": execution_context.user_id,
- "items": [item.model_dump(mode="json", exclude_none=True) for item in request.items],
- },
- )
- try:
- return AgentStubDriveCommitResponse.model_validate(payload)
- except ValidationError as exc:
- raise AgentStubDriveRequestError(502, "Dify API drive commit response is invalid") from exc
-
- def _require_agent_context(
- self, execution_context: DifyExecutionContextLayerConfig
- ) -> DifyExecutionContextLayerConfig:
- if execution_context.agent_id is None:
- raise AgentStubDriveRequestError(400, "execution context agent_id is required for drive operations")
- return execution_context
-
- def _require_user_context(
- self, execution_context: DifyExecutionContextLayerConfig
- ) -> DifyExecutionContextLayerConfig:
- if execution_context.user_id is None:
- raise AgentStubDriveRequestError(400, "execution context user_id is required for drive commit")
- return execution_context
-
- @staticmethod
- def _drive_ref(execution_context: DifyExecutionContextLayerConfig) -> str:
- agent_id = execution_context.agent_id
- if agent_id is None:
- raise AgentStubDriveRequestError(400, "execution context agent_id is required for drive operations")
- return f"agent-{agent_id}"
-
- async def _get_inner_api(self, path: str, params: Mapping[str, str]) -> object:
- url = f"{self.inner_api_url.rstrip('/')}{path}"
- async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) as client:
- try:
- response = await client.get(
- url,
- params=dict(params),
- headers={"X-Inner-Api-Key": self.inner_api_key},
- )
- except httpx.TimeoutException as exc:
- raise AgentStubDriveRequestError(504, "Dify API drive request timed out") from exc
- except httpx.RequestError as exc:
- raise AgentStubDriveRequestError(502, f"Dify API drive request failed: {exc}") from exc
- return self._normalize_payload(response)
-
- async def _post_inner_api(self, path: str, payload: Mapping[str, Any]) -> object:
- url = f"{self.inner_api_url.rstrip('/')}{path}"
- async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) as client:
- try:
- response = await client.post(
- url,
- json=dict(payload),
- headers={"X-Inner-Api-Key": self.inner_api_key},
- )
- except httpx.TimeoutException as exc:
- raise AgentStubDriveRequestError(504, "Dify API drive request timed out") from exc
- except httpx.RequestError as exc:
- raise AgentStubDriveRequestError(502, f"Dify API drive request failed: {exc}") from exc
- return self._normalize_payload(response)
-
- def _normalize_payload(self, response: httpx.Response) -> object:
- raw_payload = self._parse_json(response)
- if response.is_error:
- detail = raw_payload.get("detail", raw_payload) if isinstance(raw_payload, dict) else raw_payload
- raise AgentStubDriveRequestError(response.status_code, detail)
- return raw_payload
-
- @staticmethod
- def _parse_json(response: httpx.Response) -> object:
- try:
- return response.json()
- except ValueError as exc:
- raise AgentStubDriveRequestError(502, "Dify API drive request returned invalid JSON") from exc
-
-
-__all__ = [
- "AgentStubDriveRequestError",
- "AgentStubDriveRequestHandler",
- "DifyApiAgentStubDriveRequestHandler",
-]
diff --git a/dify-agent/src/dify_agent/agent_stub/server/app.py b/dify-agent/src/dify_agent/agent_stub/server/app.py
index ba16abf18c7..2e417ffa116 100644
--- a/dify-agent/src/dify_agent/agent_stub/server/app.py
+++ b/dify-agent/src/dify_agent/agent_stub/server/app.py
@@ -2,7 +2,7 @@
The standalone stub server is only a convenience wrapper around the shared
router. It reuses the main ``ServerSettings`` model and derives the Agent Stub
-token codec plus optional file and drive request bridges from the same helper
+token codec plus optional file and config request bridges from the same helper
methods that the standard run server uses before mounting
``create_agent_stub_router(...)``.
"""
@@ -24,7 +24,6 @@ def create_agent_stub_app(settings: ServerSettings | None = None) -> FastAPI:
token_codec=resolved_settings.create_agent_stub_token_codec(),
file_request_handler=resolved_settings.create_agent_stub_file_request_handler(),
config_request_handler=resolved_settings.create_agent_stub_config_request_handler(),
- drive_request_handler=resolved_settings.create_agent_stub_drive_request_handler(),
)
)
return app
diff --git a/dify-agent/src/dify_agent/agent_stub/server/control_plane.py b/dify-agent/src/dify_agent/agent_stub/server/control_plane.py
index badf83dc41c..23cc1ee7d72 100644
--- a/dify-agent/src/dify_agent/agent_stub/server/control_plane.py
+++ b/dify-agent/src/dify_agent/agent_stub/server/control_plane.py
@@ -1,6 +1,6 @@
"""Shared Agent Stub HTTP control-plane service.
-This layer owns authenticated delegation for file, config, and drive operations.
+This layer owns authenticated delegation for file and config operations.
The HTTP adapter validates transport DTOs before calling into this service.
"""
@@ -15,16 +15,12 @@ from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubConfigManifestResponse,
AgentStubConfigPushRequest,
AgentStubConfigPushResponse,
- AgentStubDriveCommitRequest,
- AgentStubDriveCommitResponse,
- AgentStubDriveManifestResponse,
AgentStubFileDownloadRequest,
AgentStubFileDownloadResponse,
AgentStubFileUploadRequest,
AgentStubFileUploadResponse,
)
from dify_agent.agent_stub.server.agent_stub_config import AgentStubConfigRequestError, AgentStubConfigRequestHandler
-from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestError, AgentStubDriveRequestHandler
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestError, AgentStubFileRequestHandler
from dify_agent.agent_stub.server.tokens.agent_stub import (
AgentStubPrincipal,
@@ -71,7 +67,6 @@ class AgentStubControlPlaneService:
token_codec: AgentStubTokenCodec | None
file_request_handler: AgentStubFileRequestHandler | None = None
config_request_handler: AgentStubConfigRequestHandler | None = None
- drive_request_handler: AgentStubDriveRequestHandler | None = None
connection_id_factory: Callable[[], str] = field(default=lambda: str(uuid4()))
async def connect(self, *, authorization: str | None) -> AgentStubConnectResponse:
@@ -115,25 +110,6 @@ class AgentStubControlPlaneService:
except AgentStubFileRequestError as exc:
raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
- async def get_drive_manifest(
- self,
- *,
- prefix: str,
- include_download_url: bool,
- authorization: str | None,
- ) -> AgentStubDriveManifestResponse:
- """Authenticate and delegate one drive manifest request."""
- principal = self._authenticate(authorization)
- handler = self._require_drive_request_handler()
- try:
- return await handler.get_manifest(
- principal=principal,
- prefix=prefix,
- include_download_url=include_download_url,
- )
- except AgentStubDriveRequestError as exc:
- raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
-
async def get_config_manifest(
self,
*,
@@ -198,20 +174,6 @@ class AgentStubControlPlaneService:
except AgentStubConfigRequestError as exc:
raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
- async def commit_drive(
- self,
- *,
- request: AgentStubDriveCommitRequest,
- authorization: str | None,
- ) -> AgentStubDriveCommitResponse:
- """Authenticate and delegate one drive commit request."""
- principal = self._authenticate(authorization)
- handler = self._require_drive_request_handler()
- try:
- return await handler.commit(principal=principal, request=request)
- except AgentStubDriveRequestError as exc:
- raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
-
def _authenticate(self, authorization: str | None, *, expose_expiration: bool = False) -> AgentStubPrincipal:
token_codec = self.token_codec
if token_codec is None:
@@ -238,11 +200,6 @@ class AgentStubControlPlaneService:
raise AgentStubConfigurationError(503, "Agent Stub config API is not configured")
return self.config_request_handler
- def _require_drive_request_handler(self) -> AgentStubDriveRequestHandler:
- if self.drive_request_handler is None:
- raise AgentStubConfigurationError(503, "Agent Stub drive API is not configured")
- return self.drive_request_handler
-
__all__ = [
"AgentStubAuthenticationError",
diff --git a/dify-agent/src/dify_agent/agent_stub/server/router.py b/dify-agent/src/dify_agent/agent_stub/server/router.py
index 5c77202093d..24726083604 100644
--- a/dify-agent/src/dify_agent/agent_stub/server/router.py
+++ b/dify-agent/src/dify_agent/agent_stub/server/router.py
@@ -1,7 +1,7 @@
"""Embeddable router factory for Dify Agent stub endpoints.
Both the standalone stub server and the standard run server mount the same
-router so the Agent Stub protocol, token validation, and file/drive
+router so the Agent Stub protocol, token validation, and file/config
control-plane behavior stay identical regardless of hosting mode. The factory is
intentionally settings-agnostic: callers must pass already constructed
token-codec and request-handler dependencies rather than having this module read
@@ -13,7 +13,6 @@ from __future__ import annotations
from fastapi import APIRouter
from dify_agent.agent_stub.server.agent_stub_config import AgentStubConfigRequestHandler
-from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestHandler
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestHandler
from dify_agent.agent_stub.server.routes.agent_stub import create_agent_stub_http_router
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
@@ -23,14 +22,12 @@ def create_agent_stub_router(
*,
token_codec: AgentStubTokenCodec | None,
file_request_handler: AgentStubFileRequestHandler | None = None,
- drive_request_handler: AgentStubDriveRequestHandler | None = None,
config_request_handler: AgentStubConfigRequestHandler | None = None,
) -> APIRouter:
"""Build the embeddable stub router from pre-built server dependencies."""
return create_agent_stub_http_router(
token_codec,
file_request_handler,
- drive_request_handler,
config_request_handler,
)
diff --git a/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py b/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py
index 5bcaa978b6a..6778a590556 100644
--- a/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py
+++ b/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py
@@ -2,7 +2,7 @@
The router is a thin HTTP adapter around ``AgentStubControlPlaneService``. It
keeps FastAPI-specific request parsing and HTTPException translation here while
-the service owns auth and file/config/drive delegation.
+the service owns auth and file/config delegation.
"""
from __future__ import annotations
@@ -17,16 +17,12 @@ from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubConfigNoteUpdateRequest,
AgentStubConfigPushRequest,
AgentStubConfigPushResponse,
- AgentStubDriveCommitRequest,
- AgentStubDriveCommitResponse,
- AgentStubDriveManifestResponse,
AgentStubFileDownloadRequest,
AgentStubFileDownloadResponse,
AgentStubFileUploadRequest,
AgentStubFileUploadResponse,
)
from dify_agent.agent_stub.server.agent_stub_config import AgentStubConfigRequestHandler
-from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestHandler
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestHandler
from dify_agent.agent_stub.server.control_plane import AgentStubControlPlaneError, AgentStubControlPlaneService
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
@@ -35,7 +31,6 @@ from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
def create_agent_stub_http_router(
token_codec: AgentStubTokenCodec | None,
file_request_handler: AgentStubFileRequestHandler | None = None,
- drive_request_handler: AgentStubDriveRequestHandler | None = None,
config_request_handler: AgentStubConfigRequestHandler | None = None,
) -> APIRouter:
"""Create HTTP routes bound to the application's Agent Stub dependencies."""
@@ -44,7 +39,6 @@ def create_agent_stub_http_router(
token_codec=token_codec,
file_request_handler=file_request_handler,
config_request_handler=config_request_handler,
- drive_request_handler=drive_request_handler,
)
@router.post("/connections", response_model=AgentStubConnectResponse)
@@ -132,31 +126,6 @@ def create_agent_stub_http_router(
except AgentStubControlPlaneError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
- @router.get("/drive/manifest", response_model=AgentStubDriveManifestResponse)
- async def get_drive_manifest(
- prefix: str = "",
- include_download_url: bool = False,
- authorization: str | None = Header(default=None, alias="Authorization"),
- ) -> AgentStubDriveManifestResponse:
- try:
- return await service.get_drive_manifest(
- prefix=prefix,
- include_download_url=include_download_url,
- authorization=authorization,
- )
- except AgentStubControlPlaneError as exc:
- raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
-
- @router.post("/drive/commit", response_model=AgentStubDriveCommitResponse)
- async def commit_drive(
- request: AgentStubDriveCommitRequest,
- authorization: str | None = Header(default=None, alias="Authorization"),
- ) -> AgentStubDriveCommitResponse:
- try:
- return await service.commit_drive(request=request, authorization=authorization)
- except AgentStubControlPlaneError as exc:
- raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
-
return router
diff --git a/dify-agent/src/dify_agent/agent_stub/shell_env.py b/dify-agent/src/dify_agent/agent_stub/shell_env.py
index dd81ee4e75a..ce1f6174515 100644
--- a/dify-agent/src/dify_agent/agent_stub/shell_env.py
+++ b/dify-agent/src/dify_agent/agent_stub/shell_env.py
@@ -1,9 +1,8 @@
"""Client-safe shell environment helpers for Agent Stub forwarding.
Only user-visible ``shell.run`` commands receive these variables. Internal
-lifecycle commands remain free of Agent Stub credentials and drive-base
-defaults so workspace setup and cleanup cannot accidentally inherit
-user-facing forwarding state. The module stays server-extra-free because the
+lifecycle commands remain free of Agent Stub credentials so workspace setup
+and cleanup cannot accidentally inherit user-facing forwarding state. The module stays server-extra-free because the
shell runtime and provider factory use it in sandbox-visible paths.
"""
@@ -11,11 +10,9 @@ from __future__ import annotations
from typing import Protocol
-from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR
from dify_agent.agent_stub.protocol.agent_stub import (
AGENT_STUB_API_BASE_URL_ENV_VAR,
AGENT_STUB_AUTH_JWE_ENV_VAR,
- agent_stub_drive_base_for_ref,
normalize_agent_stub_api_base_url,
)
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
@@ -30,29 +27,21 @@ class ShellAgentStubTokenFactory(Protocol):
def build_shell_agent_stub_env(
*,
agent_stub_api_base_url: str | None,
- agent_stub_drive_ref: str | None = None,
execution_context: DifyExecutionContextLayerConfig | None,
token_factory: ShellAgentStubTokenFactory | None,
session_id: str | None,
) -> dict[str, str] | None:
- """Build the shell-visible Agent Stub environment for one user command.
-
- ``agent_stub_drive_ref`` is the storage reference from the bound
- ``dify.drive`` layer. The sandbox-local base is fixed by the Agent Stub
- contract and derived here at shell-run injection time.
- """
+ """Build the shell-visible Agent Stub environment for one user command."""
if agent_stub_api_base_url is None or execution_context is None or token_factory is None:
return None
return {
AGENT_STUB_API_BASE_URL_ENV_VAR: normalize_agent_stub_api_base_url(agent_stub_api_base_url),
AGENT_STUB_AUTH_JWE_ENV_VAR: token_factory(execution_context, session_id=session_id),
- AGENT_STUB_DRIVE_BASE_ENV_VAR: agent_stub_drive_base_for_ref(agent_stub_drive_ref),
}
__all__ = [
"AGENT_STUB_AUTH_JWE_ENV_VAR",
- "AGENT_STUB_DRIVE_BASE_ENV_VAR",
"AGENT_STUB_API_BASE_URL_ENV_VAR",
"ShellAgentStubTokenFactory",
"build_shell_agent_stub_env",
diff --git a/dify-agent/src/dify_agent/layers/_agent_cli_help.json b/dify-agent/src/dify_agent/layers/_agent_cli_help.json
index c8942b2ae11..ebc8364be4c 100644
--- a/dify-agent/src/dify_agent/layers/_agent_cli_help.json
+++ b/dify-agent/src/dify_agent/layers/_agent_cli_help.json
@@ -15,10 +15,6 @@
"config skills pull": "Pull one or all visible config skills into ./.dify_conf/skills by default.\n\nUsage:\n dify-agent config skills pull [NAME]... [flags]\n\nFlags:\n -h, --help help for pull\n --json Emit the pull result as JSON.\n --to string Local directory for pulled config skills.",
"config skills push": "Upload one or more local skill directories into the current config manifest.\n\nUsage:\n dify-agent config skills push PATH... [flags]\n\nFlags:\n -h, --help help for push",
"connect": "Establish one Agent Stub connection using the current environment.\n\nUsage:\n dify-agent connect [ARGV]... [flags]\n\nFlags:\n -h, --help help for connect\n --json Emit the connection response as JSON.",
- "drive": "List, pull, or push agent drive files through the Agent Stub.\n\nUsage:\n dify-agent drive [command]\n\nAvailable Commands:\n list List drive files visible to the current sandbox execution.\n pull Pull one or more drive keys/prefixes into one local directory tree.\n push Upload one local file or directory into the agent drive.\n\nFlags:\n -h, --help help for drive\n\nUse \"dify-agent drive [command] --help\" for more information about a command.",
- "drive list": "List drive files visible to the current sandbox execution.\n\nUsage:\n dify-agent drive list [REMOTE_PREFIX] [flags]\n\nFlags:\n -h, --help help for list\n --json Emit the drive manifest as JSON.",
- "drive pull": "Pull one or more drive keys/prefixes into one local directory tree.\n\nUsage:\n dify-agent drive pull [REMOTE]... [flags]\n\nFlags:\n -h, --help help for pull\n --json Emit the pull result as JSON.\n --to string Local base directory for pulled drive files.",
- "drive push": "Upload one local file or directory into the agent drive.\n\nUsage:\n dify-agent drive push LOCAL_PATH REMOTE_PATH [flags]\n\nFlags:\n -h, --help help for push\n --json Accepted for consistency; drive push output is already emitted as JSON.\n --kind string Directory upload kind: skill or dir.",
"file": "Upload or download workflow files through the Agent Stub.\n\nUsage:\n dify-agent file [command]\n\nAvailable Commands:\n download Download one workflow file mapping into the local sandbox directory.\n public-url Create a browser-visible download URL for an existing ToolFile reference.\n upload Upload one sandbox-local file as a ToolFile output reference.\n\nFlags:\n -h, --help help for file\n\nUse \"dify-agent file [command] --help\" for more information about a command.",
"file download": "Download one workflow file mapping into the local sandbox directory.\n\nUsage:\n dify-agent file download TRANSFER_METHOD REFERENCE_OR_URL [flags]\n\nFlags:\n -h, --help help for download\n --to string Local directory for the downloaded file.",
"file public-url": "Create a browser-visible download URL for an existing ToolFile reference.\n\nUsage:\n dify-agent file public-url REFERENCE [flags]\n\nFlags:\n -h, --help help for public-url",
diff --git a/dify-agent/src/dify_agent/layers/drive/__init__.py b/dify-agent/src/dify_agent/layers/drive/__init__.py
deleted file mode 100644
index a38f77ed65a..00000000000
--- a/dify-agent/src/dify_agent/layers/drive/__init__.py
+++ /dev/null
@@ -1,17 +0,0 @@
-"""Client-safe exports for the Dify drive runtime catalog DTOs.
-
-The layer implementation lives in the sibling ``layer`` module. Keep this
-package root import-safe for client code that only builds run requests.
-"""
-
-from dify_agent.layers.drive.configs import (
- DIFY_DRIVE_LAYER_TYPE_ID,
- DifyDriveLayerConfig,
- DifyDriveSkillConfig,
-)
-
-__all__ = [
- "DIFY_DRIVE_LAYER_TYPE_ID",
- "DifyDriveLayerConfig",
- "DifyDriveSkillConfig",
-]
diff --git a/dify-agent/src/dify_agent/layers/drive/configs.py b/dify-agent/src/dify_agent/layers/drive/configs.py
deleted file mode 100644
index 20fd514baf2..00000000000
--- a/dify-agent/src/dify_agent/layers/drive/configs.py
+++ /dev/null
@@ -1,55 +0,0 @@
-"""Client-safe DTOs for the Dify drive declaration layer.
-
-The drive layer carries the runtime drive catalog plus the prompt-mentioned
-targets that must be pulled eagerly when the layer enters. It is still config
-only: skills are declared as metadata, not content, and plain files are listed
-only when the prompt explicitly mentions their drive keys.
-
-The API backend catalogs and writes this config; the Agent backend consumes it
-by running sandbox-visible ``dify-agent drive pull`` commands through the shell
-layer so materialized files live in the same filesystem that model shell jobs
-use.
-"""
-
-from typing import Final
-
-from pydantic import BaseModel, ConfigDict, Field
-
-from agenton.layers import LayerConfig
-
-
-DIFY_DRIVE_LAYER_TYPE_ID: Final[str] = "dify.drive"
-
-
-class DifyDriveSkillConfig(BaseModel):
- """Runtime declaration of one standardized skill — metadata, not content."""
-
- model_config = ConfigDict(extra="forbid")
-
- name: str
- # The model judges from this description whether the skill is worth loading.
- description: str
- # "/SKILL.md" — the canonical entry document in the drive.
- skill_md_key: str
- # "/.DIFY-SKILL-FULL.zip" — full archive for restoring the complete skill.
- archive_key: str | None = None
- path: str
-
-
-class DifyDriveLayerConfig(LayerConfig):
- """Drive runtime catalog plus eager-pull instructions for mentioned targets."""
-
- # "agent-" — storage addressing, deliberately explicit instead of
- # derived from execution context so a shared (non-agent-bound) drive stays
- # possible later.
- drive_ref: str
- skills: list[DifyDriveSkillConfig] = Field(default_factory=list)
- mentioned_skill_keys: list[str] = Field(default_factory=list)
- mentioned_file_keys: list[str] = Field(default_factory=list)
-
-
-__all__ = [
- "DIFY_DRIVE_LAYER_TYPE_ID",
- "DifyDriveLayerConfig",
- "DifyDriveSkillConfig",
-]
diff --git a/dify-agent/src/dify_agent/layers/drive/layer.py b/dify-agent/src/dify_agent/layers/drive/layer.py
deleted file mode 100644
index 8ac4b91c189..00000000000
--- a/dify-agent/src/dify_agent/layers/drive/layer.py
+++ /dev/null
@@ -1,268 +0,0 @@
-"""Runtime Dify drive layer with shell-backed eager pulls.
-
-The API backend sends the full drive skill catalog plus the ordered drive keys
-mentioned in the prompt. When the layer enters a run context it eagerly pulls
-those mentioned skills/files through the already-active shell layer by running
-the sandbox-visible ``dify-agent drive pull`` command, then contributes a
-concise prompt block describing what was loaded. It also contributes a suffix
-prompt with the remaining skill catalog plus agent-visible ``dify-agent file``
-usage captured from the real CLI. Drive commands remain internal for now and
-are not exposed to the model.
-"""
-
-from __future__ import annotations
-
-import shlex
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import ClassVar
-
-from typing_extensions import Self, override
-
-from agenton.layers import EmptyRuntimeState, LayerDeps, PlainLayer
-from dify_agent.agent_stub.protocol import agent_stub_drive_base_for_ref
-from dify_agent.layers._agent_file_cli_help import AGENT_FILE_UPLOAD_REPLY_HINT as _AGENT_FILE_UPLOAD_REPLY_HINT
-from dify_agent.layers.drive.configs import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
-from dify_agent.layers.shell.layer import DifyShellLayer
-
-_AGENT_STUB_FILE_HELP_COMMANDS = (
- "dify-agent file --help",
- "dify-agent file upload --help",
- "dify-agent file download --help",
-)
-
-
-class DifyDriveLayerError(RuntimeError):
- """Raised when one eager-pull drive operation fails."""
-
-
-class DifyDriveDeps(LayerDeps):
- shell: DifyShellLayer # pyright: ignore[reportUninitializedInstanceVariable]
-
-
-@dataclass(slots=True)
-class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntimeState]):
- """Drive runtime layer that materializes prompt-mentioned targets via shell."""
-
- type_id: ClassVar[str | None] = DIFY_DRIVE_LAYER_TYPE_ID
-
- config: DifyDriveLayerConfig
- _loaded_skill_bodies: dict[str, str] = field(default_factory=dict)
- _pulled_file_paths: dict[str, str] = field(default_factory=dict)
- _agent_stub_cli_help: dict[str, str] = field(default_factory=dict)
-
- @classmethod
- @override
- def from_config(cls, config: DifyDriveLayerConfig) -> Self:
- return cls(config=DifyDriveLayerConfig.model_validate(config))
-
- @property
- @override
- def prefix_prompts(self) -> list[str]:
- return [self.build_prompt_context()]
-
- @property
- @override
- def suffix_prompts(self) -> list[str]:
- return [self.build_suffix_prompt()]
-
- @override
- async def on_context_create(self) -> None:
- await self._load_agent_stub_cli_help()
- await self._pull_mentioned_targets()
-
- @override
- async def on_context_resume(self) -> None:
- await self._load_agent_stub_cli_help()
- await self._pull_mentioned_targets()
-
- def build_prompt_context(self) -> str:
- sections: list[str] = []
-
- loaded_skill_sections: list[str] = []
- for skill_key in self.config.mentioned_skill_keys:
- body = self._loaded_skill_bodies.get(skill_key)
- if body is None:
- continue
- skill = next((item for item in self.config.skills if item.skill_md_key == skill_key), None)
- if skill is None:
- continue
- pulled_skill_path = self._pulled_file_paths.get(skill_key)
- if pulled_skill_path is None:
- continue
- local_path = Path(pulled_skill_path).parent
- loaded_skill_sections.append(f"Path: {skill.path}\nLocal path: {local_path}\nSKILL.md:\n{body}")
- if loaded_skill_sections:
- sections.append("Loaded mentioned skills:\n\n" + "\n\n".join(loaded_skill_sections))
-
- mentioned_files = [
- f"- {key} -> {self._pulled_file_paths[key]}"
- for key in self.config.mentioned_file_keys
- if key in self._pulled_file_paths
- ]
- if mentioned_files:
- sections.append("Mentioned files pulled to local drive:\n" + "\n".join(mentioned_files))
-
- if not sections:
- return ""
- return "\n\n".join(sections)
-
- def build_suffix_prompt(self) -> str:
- sections: list[str] = []
- mentioned_skill_keys = set(self.config.mentioned_skill_keys)
- other_skills = [
- f"- {skill.path}: {skill.name} — {skill.description}"
- for skill in self.config.skills
- if skill.skill_md_key not in mentioned_skill_keys
- ]
- if other_skills:
- sections.append("Other available skills:\n" + "\n".join(other_skills))
- if cli_help := self._format_agent_stub_cli_help():
- sections.append(cli_help)
- return "\n\n".join(sections)
-
- def _format_agent_stub_cli_help(self) -> str:
- command_sections = [
- _format_command_output(command, self._agent_stub_cli_help[command])
- for command in _AGENT_STUB_FILE_HELP_COMMANDS
- if command in self._agent_stub_cli_help
- ]
- if not command_sections:
- return ""
- return (
- "Agent Stub file CLI reference for installed `dify-agent`:\n"
- + "\n\n".join(command_sections)
- + f"\n\n{_AGENT_FILE_UPLOAD_REPLY_HINT}"
- )
-
- async def _load_agent_stub_cli_help(self) -> None:
- self._agent_stub_cli_help = {}
- for command in _AGENT_STUB_FILE_HELP_COMMANDS:
- result = await self.deps.shell.run_remote_script(command, timeout=10.0)
- if result.exit_code != 0 or not result.output_complete:
- continue
- output = result.output.strip()
- if output:
- self._agent_stub_cli_help[command] = output
-
- async def _pull_mentioned_targets(self) -> None:
- self._loaded_skill_bodies = {}
- self._pulled_file_paths = {}
- targets = self._mentioned_pull_targets()
- if not targets:
- return
-
- script = self._build_shell_pull_script(targets=targets)
- result = await self.deps.shell.run_remote_script_complete(script, inject_agent_stub_env=True)
- if result.exit_code != 0:
- raise DifyDriveLayerError(
- "drive mentioned pull failed in shell: "
- + f"{result.status} exit_code={result.exit_code} "
- + f"output_complete={result.output_complete} "
- + f"incomplete_reason={result.incomplete_reason} "
- + f"output_path={result.output_path}\n{result.output}"
- )
- try:
- written_paths, skill_bodies = self._parse_shell_pull_output(result.output)
- self._record_pulled_paths(written_paths)
- for skill_key in self.config.mentioned_skill_keys:
- body = skill_bodies.get(skill_key)
- if body is None:
- raise DifyDriveLayerError(f"missing pulled SKILL.md content for mentioned skill {skill_key}")
- self._loaded_skill_bodies[skill_key] = body
- except DifyDriveLayerError:
- if result.output_complete:
- raise
- raise DifyDriveLayerError(
- "drive mentioned pull output incomplete before required SKILL.md content was captured: "
- + f"reason={result.incomplete_reason} output_path={result.output_path}\n{result.output}"
- ) from None
-
- def _build_shell_pull_script(self, *, targets: list[tuple[str, bool]]) -> str:
- pull_targets = list(dict.fromkeys(prefix for prefix, _exact in targets))
- base_path = agent_stub_drive_base_for_ref(self.config.drive_ref)
- lines = [
- "set -eu",
- f"base={shlex.quote(base_path)}",
- "dify-agent drive pull " + " ".join(shlex.quote(target) for target in pull_targets) + ' --to "$base"',
- ]
- for skill_key in self.config.mentioned_skill_keys:
- skill_path = self._shell_local_path(skill_key)
- lines.extend(
- [
- f"test -f {shlex.quote(skill_path)}",
- f"printf '\\n__DIFY_DRIVE_MENTIONED_PATH__\\t%s\\t%s\\n' {shlex.quote(skill_key)} {shlex.quote(skill_path)}",
- f"printf '__DIFY_DRIVE_SKILL_BEGIN__\\t%s\\n' {shlex.quote(skill_key)}",
- f"cat {shlex.quote(skill_path)}",
- f"printf '\\n__DIFY_DRIVE_SKILL_END__\\t%s\\n' {shlex.quote(skill_key)}",
- ]
- )
- for file_key in self.config.mentioned_file_keys:
- file_path = self._shell_local_path(file_key)
- lines.extend(
- [
- f"test -e {shlex.quote(file_path)}",
- f"printf '\\n__DIFY_DRIVE_MENTIONED_PATH__\\t%s\\t%s\\n' {shlex.quote(file_key)} {shlex.quote(file_path)}",
- ]
- )
- return "\n".join(lines)
-
- def _parse_shell_pull_output(self, output: str) -> tuple[dict[str, str], dict[str, str]]:
- written_paths: dict[str, str] = {}
- skill_bodies: dict[str, str] = {}
- current_skill_key: str | None = None
- current_skill_body: list[str] = []
-
- for line in output.splitlines(keepends=True):
- stripped_line = line.rstrip("\n")
- if current_skill_key is not None:
- if stripped_line == f"__DIFY_DRIVE_SKILL_END__\t{current_skill_key}":
- skill_bodies[current_skill_key] = "".join(current_skill_body)
- current_skill_key = None
- current_skill_body = []
- continue
- current_skill_body.append(line)
- continue
-
- if stripped_line.startswith("__DIFY_DRIVE_MENTIONED_PATH__\t"):
- parts = stripped_line.split("\t", 2)
- if len(parts) != 3:
- raise DifyDriveLayerError("drive mentioned pull emitted an invalid path marker")
- _marker, key, path = parts
- written_paths[key] = path
- continue
- if stripped_line.startswith("__DIFY_DRIVE_SKILL_BEGIN__\t"):
- current_skill_key = stripped_line.split("\t", 1)[1]
- current_skill_body = []
-
- if current_skill_key is not None:
- raise DifyDriveLayerError(f"drive mentioned pull omitted SKILL.md end marker for {current_skill_key}")
- return written_paths, skill_bodies
-
- def _record_pulled_paths(self, written_paths: dict[str, str]) -> None:
- self._pulled_file_paths = written_paths
- for file_key in self.config.mentioned_file_keys:
- if file_key not in written_paths:
- raise DifyDriveLayerError(f"missing pulled file for mentioned drive key {file_key}")
- for skill_key in self.config.mentioned_skill_keys:
- if skill_key not in written_paths:
- raise DifyDriveLayerError(f"missing pulled SKILL.md for mentioned skill {skill_key}")
-
- def _mentioned_pull_targets(self) -> list[tuple[str, bool]]:
- return [(self._skill_prefix(skill_key), False) for skill_key in self.config.mentioned_skill_keys] + [
- (file_key, True) for file_key in self.config.mentioned_file_keys
- ]
-
- def _shell_local_path(self, drive_key: str) -> str:
- return f"{agent_stub_drive_base_for_ref(self.config.drive_ref).rstrip('/')}/{drive_key.lstrip('/')}"
-
- @staticmethod
- def _skill_prefix(skill_key: str) -> str:
- return f"{skill_key.rsplit('/', 1)[0]}/"
-
-
-def _format_command_output(command: str, output: str) -> str:
- return f"Command:\n$ {command}\nOutput:\n{output}"
-
-
-__all__ = ["DifyDriveLayer", "DifyDriveLayerError"]
diff --git a/dify-agent/src/dify_agent/layers/shell/configs.py b/dify-agent/src/dify_agent/layers/shell/configs.py
index 821e02cc89c..77d7d05a3f7 100644
--- a/dify-agent/src/dify_agent/layers/shell/configs.py
+++ b/dify-agent/src/dify_agent/layers/shell/configs.py
@@ -4,8 +4,7 @@ Server-only Agent Stub and redaction settings are injected by the runtime
provider factory. The Sandbox dependency supplies the active shellctl data
plane. Public config carries product-level Agent Soul settings that affect the
workspace itself: CLI tool bootstrap commands, normal environment variables,
-secret environment variable names, and the Agent Stub drive ref used by
-shell-visible drive commands. Sandbox selection is a deployment concern.
+secret environment variable names. Sandbox selection is a deployment concern.
"""
import re
@@ -73,8 +72,6 @@ class DifyShellLayerConfig(LayerConfig):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
- # Optional because shell can be used without a drive layer.
- agent_stub_drive_ref: str | None = Field(default=None, max_length=1024)
cli_tools: list[DifyShellCliToolConfig] = Field(default_factory=list)
env: list[DifyShellEnvVarConfig] = Field(default_factory=list)
secret_refs: list[DifyShellSecretRefConfig] = Field(default_factory=list)
diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py
index e4f4987e19c..be7c73bdd56 100644
--- a/dify-agent/src/dify_agent/layers/shell/layer.py
+++ b/dify-agent/src/dify_agent/layers/shell/layer.py
@@ -545,7 +545,6 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
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=None,
diff --git a/dify-agent/src/dify_agent/runtime/compositor_factory.py b/dify-agent/src/dify_agent/runtime/compositor_factory.py
index 771a0ecb473..5563d43a706 100644
--- a/dify-agent/src/dify_agent/runtime/compositor_factory.py
+++ b/dify-agent/src/dify_agent/runtime/compositor_factory.py
@@ -46,7 +46,6 @@ from dify_agent.layers.dify_core_tools.layer import DifyCoreToolsLayer
from dify_agent.layers.dify_plugin.configs import DifyPluginLLMLayerConfig, 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.drive.layer import DifyDriveLayer
from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig
@@ -79,7 +78,6 @@ def create_default_layer_providers(
LayerProvider.from_layer_type(DifyOutputLayer),
LayerProvider.from_layer_type(DifyAskHumanLayer),
LayerProvider.from_layer_type(DifyConfigLayer),
- LayerProvider.from_layer_type(DifyDriveLayer),
LayerProvider.from_factory(
layer_type=DifyExecutionContextLayer,
create=lambda config: DifyExecutionContextLayer.from_config_with_settings(
diff --git a/dify-agent/src/dify_agent/server/app.py b/dify-agent/src/dify_agent/server/app.py
index 586d77b33ea..0378e04baa5 100644
--- a/dify-agent/src/dify_agent/server/app.py
+++ b/dify-agent/src/dify_agent/server/app.py
@@ -60,7 +60,6 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
agent_stub_token_factory = issue_agent_stub_token
agent_stub_file_request_handler = resolved_settings.create_agent_stub_file_request_handler()
agent_stub_config_request_handler = resolved_settings.create_agent_stub_config_request_handler()
- agent_stub_drive_request_handler = resolved_settings.create_agent_stub_drive_request_handler()
runtime_backend_profile = resolved_settings.build_runtime_backend_profile()
layer_providers = create_default_layer_providers(
plugin_daemon_url=resolved_settings.plugin_daemon_url,
@@ -146,7 +145,6 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
token_codec=agent_stub_token_codec,
file_request_handler=agent_stub_file_request_handler,
config_request_handler=agent_stub_config_request_handler,
- drive_request_handler=agent_stub_drive_request_handler,
)
)
return app
diff --git a/dify-agent/src/dify_agent/server/settings.py b/dify-agent/src/dify_agent/server/settings.py
index 197592c8815..866ce832d93 100644
--- a/dify-agent/src/dify_agent/server/settings.py
+++ b/dify-agent/src/dify_agent/server/settings.py
@@ -19,7 +19,6 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
from dify_agent.agent_stub.protocol.agent_stub import normalize_agent_stub_api_base_url
from dify_agent.agent_stub.server.agent_stub_config import DifyApiAgentStubConfigRequestHandler
-from dify_agent.agent_stub.server.agent_stub_drive import DifyApiAgentStubDriveRequestHandler
from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec, decode_server_secret_key
from dify_agent.runtime.runner import DEFAULT_AGENT_RUN_TIMEOUT_SECONDS
@@ -243,20 +242,6 @@ class ServerSettings(BaseSettings):
timeout=self.create_outbound_http_timeout(),
)
- def create_agent_stub_drive_request_handler(self) -> DifyApiAgentStubDriveRequestHandler | None:
- """Return the Dify API drive bridge when both Dify API settings are configured.
-
- Drive manifest and commit requests should honor the same outbound timeout
- settings as the server's other trusted Dify API HTTP calls.
- """
- if self.inner_api_key is None:
- return None
- return DifyApiAgentStubDriveRequestHandler(
- inner_api_url=self.inner_api_url,
- inner_api_key=self.inner_api_key,
- timeout=self.create_outbound_http_timeout(),
- )
-
def create_outbound_http_timeout(self) -> httpx.Timeout:
"""Build one shared outbound HTTP timeout object from server settings."""
return httpx.Timeout(
diff --git a/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py b/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py
index d49a66e44ef..431dda45419 100644
--- a/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py
+++ b/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py
@@ -8,18 +8,11 @@ import pytest
from pydantic import ValidationError
from dify_agent.agent_stub.protocol.agent_stub import (
- AgentStubDriveCommitItem,
- AgentStubDriveCommitRequest,
- AgentStubDriveFileRef,
- AgentStubDriveManifestResponse,
AgentStubConfigDownloadSource,
AgentStubFileDownloadRequest,
AgentStubFileMapping,
AgentStubFileUploadRequest,
agent_stub_connections_url,
- agent_stub_drive_base_for_ref,
- agent_stub_drive_commit_url,
- agent_stub_drive_manifest_url,
agent_stub_file_download_request_url,
agent_stub_file_upload_request_url,
normalize_agent_stub_api_base_url,
@@ -62,34 +55,6 @@ def test_agent_stub_file_upload_request_rejects_client_max_size() -> None:
)
-def test_agent_stub_drive_request_urls_handle_trailing_slash() -> None:
- assert agent_stub_drive_manifest_url("https://agent.example.com/agent-stub/") == (
- "https://agent.example.com/agent-stub/drive/manifest"
- )
- assert agent_stub_drive_commit_url("https://agent.example.com/agent-stub") == (
- "https://agent.example.com/agent-stub/drive/commit"
- )
-
-
-def test_agent_stub_drive_base_for_ref_uses_fixed_mount_with_drive_ref() -> None:
- assert agent_stub_drive_base_for_ref("agent-1") == "/mnt/drive/agent-1"
- assert agent_stub_drive_base_for_ref("shared/drive") == "/mnt/drive/shared/drive"
-
-
-def test_agent_stub_drive_base_for_ref_uses_default_without_drive_ref() -> None:
- assert agent_stub_drive_base_for_ref(None) == "/mnt/drive"
- assert agent_stub_drive_base_for_ref(" ") == "/mnt/drive"
-
-
-@pytest.mark.parametrize(
- "drive_ref",
- ["/agent-1", "../agent-1", "agent-1/..", "agent-1/./files", "agent-1//files"],
-)
-def test_agent_stub_drive_base_for_ref_rejects_unsafe_refs(drive_ref: str) -> None:
- with pytest.raises(ValueError, match="safe relative path"):
- _ = agent_stub_drive_base_for_ref(drive_ref)
-
-
def test_normalize_agent_stub_api_base_url_rejects_query_and_fragment() -> None:
with pytest.raises(ValueError, match="query string or fragment"):
_ = normalize_agent_stub_api_base_url("https://agent.example.com/agent-stub?x=1")
@@ -198,35 +163,6 @@ def test_agent_stub_config_download_source_rejects_invalid_names_and_identity_fi
_ = AgentStubConfigDownloadSource.model_validate(source)
-def test_agent_stub_drive_commit_request_validates_file_refs() -> None:
- request = AgentStubDriveCommitRequest(
- items=[
- AgentStubDriveCommitItem(
- key="skills/example/SKILL.md",
- file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"),
- )
- ]
- )
-
- assert request.items[0].file_ref is not None
- assert request.items[0].file_ref.kind == "tool_file"
-
- with pytest.raises(ValidationError, match="tool_file"):
- _ = AgentStubDriveFileRef(kind="bad_kind", id="tool-file-1") # pyright: ignore[reportArgumentType]
-
- item_without_file_ref = AgentStubDriveCommitItem.model_validate({"key": "skills/example/SKILL.md"})
- assert item_without_file_ref.file_ref is None
-
-
-def test_agent_stub_drive_manifest_response_preserves_extra_item_fields() -> None:
- response = AgentStubDriveManifestResponse.model_validate(
- {"items": [{"key": "skills/example/SKILL.md", "name": "SKILL.md"}]}
- )
-
- assert response.items[0].model_extra == {"name": "SKILL.md"}
- assert response.items[0].model_dump(mode="json")["name"] == "SKILL.md"
-
-
@pytest.mark.parametrize("transfer_method", ["tool_file", "local_file", "datasource_file"])
def test_agent_stub_file_mapping_rejects_non_remote_with_url(
transfer_method: Literal["tool_file", "local_file", "datasource_file"],
diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_app.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_app.py
index 8206a466e87..1da2d399b52 100644
--- a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_app.py
+++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_app.py
@@ -39,8 +39,6 @@ def test_create_agent_stub_app_exposes_same_stub_routes_as_module_app() -> None:
assert "/agent-stub/connections" in created_paths
assert "/agent-stub/files/upload-request" in created_paths
assert "/agent-stub/files/download-request" in created_paths
- assert "/agent-stub/drive/manifest" in created_paths
- assert "/agent-stub/drive/commit" in created_paths
assert created_paths == module_paths
@@ -91,57 +89,3 @@ def test_create_agent_stub_app_wires_configured_file_handler_for_upload_requests
assert response.status_code == 200
assert response.json() == {"upload_url": "https://files.example.com/files/upload/for-plugin?sign=1"}
-
-
-def test_create_agent_stub_app_wires_configured_drive_handler_for_manifest_requests(monkeypatch) -> None:
- settings = ServerSettings(
- agent_stub_api_base_url="https://agent.example.com/agent-stub",
- server_secret_key=_base64url_secret(b"1" * 32),
- inner_api_url="https://api.example.com",
- inner_api_key="inner-secret",
- sandbox_files_base_url="https://files.example.com",
- )
- token_codec = settings.create_agent_stub_token_codec()
- assert token_codec is not None
- token = token_codec.encode_connection_token(
- _execution_context().model_copy(update={"agent_id": "agent-1"}), now=int(time.time()) - 1
- )
-
- original_async_client = httpx.AsyncClient
-
- def handler(request: httpx.Request) -> httpx.Response:
- assert str(request.url) == (
- "https://api.example.com/inner/api/drive/agent-agent-1/manifest"
- "?tenant_id=tenant-1&prefix=skills%2F&include_download_url=false"
- )
- assert request.headers["X-Inner-Api-Key"] == "inner-secret"
- return httpx.Response(
- 200,
- json={
- "items": [
- {
- "key": "skills/example/SKILL.md",
- "size": 12,
- "hash": "sha256:abc",
- "mime_type": "text/markdown",
- "file_kind": "tool_file",
- "file_id": "tool-file-1",
- }
- ]
- },
- )
-
- monkeypatch.setattr(
- "dify_agent.agent_stub.server.agent_stub_drive.httpx.AsyncClient",
- lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs),
- )
-
- client = TestClient(create_agent_stub_app(settings))
- response = client.get(
- "/agent-stub/drive/manifest",
- headers={"Authorization": f"Bearer {token}"},
- params={"prefix": "skills/"},
- )
-
- assert response.status_code == 200
- assert response.json()["items"][0]["key"] == "skills/example/SKILL.md"
diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_drive.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_drive.py
deleted file mode 100644
index c636fc0a0fd..00000000000
--- a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_drive.py
+++ /dev/null
@@ -1,269 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import json
-
-import httpx
-
-from dify_agent.agent_stub.protocol.agent_stub import (
- AgentStubDriveCommitItem,
- AgentStubDriveCommitRequest,
- AgentStubDriveFileRef,
-)
-from dify_agent.agent_stub.server.agent_stub_drive import (
- AgentStubDriveRequestError,
- DifyApiAgentStubDriveRequestHandler,
-)
-from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubPrincipal
-from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
-
-
-def _principal() -> AgentStubPrincipal:
- return AgentStubPrincipal(
- execution_context=DifyExecutionContextLayerConfig(
- tenant_id="tenant-1",
- user_id="user-1",
- user_from="account",
- workflow_id="workflow-1",
- agent_id="agent-1",
- agent_mode="workflow_run",
- invoke_from="service-api",
- ),
- session_id="session-1",
- scope=["agent_stub:connect"],
- token_id="token-1",
- )
-
-
-def _patch_async_client(monkeypatch, handler) -> None:
- original_async_client = httpx.AsyncClient
- monkeypatch.setattr(
- "dify_agent.agent_stub.server.agent_stub_drive.httpx.AsyncClient",
- lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs),
- )
-
-
-def test_dify_api_agent_stub_drive_handler_injects_execution_context_for_manifest(monkeypatch) -> None:
- def handler(request: httpx.Request) -> httpx.Response:
- assert request.method == "GET"
- assert str(request.url) == (
- "https://api.example.com/inner/api/drive/agent-agent-1/manifest"
- "?tenant_id=tenant-1&prefix=skills%2F&include_download_url=true"
- )
- assert request.headers["X-Inner-Api-Key"] == "inner-secret"
- return httpx.Response(
- 200,
- json={
- "items": [
- {
- "key": "skills/example/SKILL.md",
- "name": "SKILL.md",
- "size": 12,
- "hash": "sha256:abc",
- "mime_type": "text/markdown",
- "file_kind": "tool_file",
- "file_id": "tool-file-1",
- "created_at": 123,
- "download_url": "https://files.example.com/download",
- }
- ]
- },
- )
-
- _patch_async_client(monkeypatch, handler)
- drive_handler = DifyApiAgentStubDriveRequestHandler(
- inner_api_url="https://api.example.com",
- inner_api_key="inner-secret",
- )
-
- async def scenario() -> None:
- response = await drive_handler.get_manifest(
- principal=_principal(),
- prefix="skills/",
- include_download_url=True,
- )
- assert response.items[0].download_url == "https://files.example.com/download"
- assert response.items[0].model_extra == {"name": "SKILL.md"}
-
- asyncio.run(scenario())
-
-
-def test_dify_api_agent_stub_drive_handler_injects_execution_context_for_commit(monkeypatch) -> None:
- def handler(request: httpx.Request) -> httpx.Response:
- assert request.method == "POST"
- assert str(request.url) == "https://api.example.com/inner/api/drive/agent-agent-1/commit"
- assert json.loads(request.content) == {
- "tenant_id": "tenant-1",
- "user_id": "user-1",
- "items": [
- {
- "key": "skills/example/SKILL.md",
- "file_ref": {"kind": "tool_file", "id": "tool-file-1"},
- "value_owned_by_drive": True,
- "is_skill": False,
- }
- ],
- }
- return httpx.Response(
- 200,
- json={
- "items": [
- {
- "key": "skills/example/SKILL.md",
- "size": 12,
- "mime_type": "text/markdown",
- "file_kind": "tool_file",
- "file_id": "tool-file-1",
- "value_owned_by_drive": True,
- }
- ]
- },
- )
-
- _patch_async_client(monkeypatch, handler)
- drive_handler = DifyApiAgentStubDriveRequestHandler(
- inner_api_url="https://api.example.com",
- inner_api_key="inner-secret",
- )
-
- async def scenario() -> None:
- response = await drive_handler.commit(
- principal=_principal(),
- request=AgentStubDriveCommitRequest(
- items=[
- AgentStubDriveCommitItem(
- key="skills/example/SKILL.md",
- file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"),
- )
- ]
- ),
- )
- assert response.items[0].value_owned_by_drive is True
-
- asyncio.run(scenario())
-
-
-def test_dify_api_agent_stub_drive_handler_rejects_missing_agent_id() -> None:
- drive_handler = DifyApiAgentStubDriveRequestHandler(
- inner_api_url="https://api.example.com",
- inner_api_key="inner-secret",
- )
- principal = _principal()
- principal.execution_context = principal.execution_context.model_copy(update={"agent_id": None})
-
- async def scenario() -> None:
- try:
- await drive_handler.get_manifest(principal=principal, prefix="", include_download_url=False)
- except AgentStubDriveRequestError as exc:
- assert exc.status_code == 400
- assert "agent_id" in str(exc)
- else:
- raise AssertionError("expected AgentStubDriveRequestError")
-
- asyncio.run(scenario())
-
-
-def test_dify_api_agent_stub_drive_handler_rejects_missing_user_id_for_commit() -> None:
- drive_handler = DifyApiAgentStubDriveRequestHandler(
- inner_api_url="https://api.example.com",
- inner_api_key="inner-secret",
- )
- principal = _principal()
- principal.execution_context = principal.execution_context.model_copy(update={"user_id": None})
-
- async def scenario() -> None:
- try:
- await drive_handler.commit(
- principal=principal,
- request=AgentStubDriveCommitRequest(
- items=[
- AgentStubDriveCommitItem(
- key="skills/example/SKILL.md",
- file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"),
- )
- ]
- ),
- )
- except AgentStubDriveRequestError as exc:
- assert exc.status_code == 400
- assert "user_id" in str(exc)
- else:
- raise AssertionError("expected AgentStubDriveRequestError")
-
- asyncio.run(scenario())
-
-
-def test_dify_api_agent_stub_drive_handler_maps_invalid_json_response(monkeypatch) -> None:
- def handler(_request: httpx.Request) -> httpx.Response:
- return httpx.Response(200, text="not-json", headers={"Content-Type": "application/json"})
-
- _patch_async_client(monkeypatch, handler)
- drive_handler = DifyApiAgentStubDriveRequestHandler(
- inner_api_url="https://api.example.com",
- inner_api_key="inner-secret",
- )
-
- async def scenario() -> None:
- try:
- await drive_handler.get_manifest(principal=_principal(), prefix="skills/", include_download_url=False)
- except AgentStubDriveRequestError as exc:
- assert exc.status_code == 502
- assert exc.detail == "Dify API drive request returned invalid JSON"
- else:
- raise AssertionError("expected AgentStubDriveRequestError")
-
- asyncio.run(scenario())
-
-
-def test_dify_api_agent_stub_drive_handler_rejects_malformed_success_payload(monkeypatch) -> None:
- def handler(_request: httpx.Request) -> httpx.Response:
- return httpx.Response(200, json={"unexpected": []})
-
- _patch_async_client(monkeypatch, handler)
- drive_handler = DifyApiAgentStubDriveRequestHandler(
- inner_api_url="https://api.example.com",
- inner_api_key="inner-secret",
- )
-
- async def scenario() -> None:
- try:
- await drive_handler.get_manifest(principal=_principal(), prefix="skills/", include_download_url=False)
- except AgentStubDriveRequestError as exc:
- assert exc.status_code == 502
- assert exc.detail == "Dify API drive manifest response is invalid"
- else:
- raise AssertionError("expected AgentStubDriveRequestError")
-
- asyncio.run(scenario())
-
-
-def test_dify_api_agent_stub_drive_handler_preserves_non_2xx_detail(monkeypatch) -> None:
- def handler(_request: httpx.Request) -> httpx.Response:
- return httpx.Response(404, json={"code": "source_not_found", "message": "missing file"})
-
- _patch_async_client(monkeypatch, handler)
- drive_handler = DifyApiAgentStubDriveRequestHandler(
- inner_api_url="https://api.example.com",
- inner_api_key="inner-secret",
- )
-
- async def scenario() -> None:
- try:
- await drive_handler.commit(
- principal=_principal(),
- request=AgentStubDriveCommitRequest(
- items=[
- AgentStubDriveCommitItem(
- key="skills/example/SKILL.md",
- file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"),
- )
- ]
- ),
- )
- except AgentStubDriveRequestError as exc:
- assert exc.status_code == 404
- assert exc.detail == {"code": "source_not_found", "message": "missing file"}
- else:
- raise AssertionError("expected AgentStubDriveRequestError")
-
- asyncio.run(scenario())
diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py
index ef6a4bf6849..2fdda63efc3 100644
--- a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py
+++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py
@@ -8,14 +8,7 @@ from typing import cast
from fastapi import FastAPI
from fastapi.testclient import TestClient
-from dify_agent.agent_stub.protocol.agent_stub import (
- AgentStubDriveCommitResponse,
- AgentStubDriveItem,
- AgentStubDriveManifestResponse,
- AgentStubFileDownloadResponse,
- AgentStubFileUploadResponse,
-)
-from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestError, AgentStubDriveRequestHandler
+from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileDownloadResponse, AgentStubFileUploadResponse
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestError, AgentStubFileRequestHandler
from dify_agent.agent_stub.server.routes.agent_stub import create_agent_stub_http_router
from dify_agent.agent_stub.server.tokens.agent_stub import AGENT_STUB_TOKEN_TTL_SECONDS, AgentStubTokenCodec
@@ -344,137 +337,3 @@ def test_agent_stub_file_route_preserves_structured_handler_error_details() -> N
assert response.status_code == 400
assert response.json()["detail"] == {"detail": "bad request", "code": "inner_api_error"}
-
-
-def test_agent_stub_drive_manifest_route_forwards_authenticated_request() -> None:
- codec = _token_codec()
- token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)
-
- class FakeDriveHandler:
- async def get_manifest(self, *, principal, prefix, include_download_url):
- assert principal.execution_context.user_id == "user-1"
- assert prefix == "skills/"
- assert include_download_url is True
- return AgentStubDriveManifestResponse(
- items=[
- AgentStubDriveItem(
- key="skills/example/SKILL.md",
- size=12,
- hash="sha256:abc",
- mime_type="text/markdown",
- file_kind="tool_file",
- file_id="tool-file-1",
- created_at=123,
- download_url="https://files.example.com/download",
- )
- ]
- )
-
- async def commit(self, *, principal, request):
- del principal, request
- raise AssertionError("unexpected commit request")
-
- drive_handler = cast(AgentStubDriveRequestHandler, cast(object, FakeDriveHandler()))
- app = FastAPI()
- app.include_router(create_agent_stub_http_router(codec, None, drive_handler))
- client = TestClient(app)
-
- response = client.get(
- "/agent-stub/drive/manifest",
- headers={"Authorization": f"Bearer {token}"},
- params={"prefix": "skills/", "include_download_url": "true"},
- )
-
- assert response.status_code == 200
- assert response.json()["items"][0]["key"] == "skills/example/SKILL.md"
-
-
-def test_agent_stub_drive_commit_route_forwards_authenticated_request() -> None:
- codec = _token_codec()
- token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)
-
- class FakeDriveHandler:
- async def commit(self, *, principal, request):
- assert principal.execution_context.user_id == "user-1"
- assert request.items[0].file_ref.id == "tool-file-1"
- return AgentStubDriveCommitResponse(
- items=[
- AgentStubDriveItem(
- key="skills/example/SKILL.md",
- size=12,
- hash=None,
- mime_type="text/markdown",
- file_kind="tool_file",
- file_id="tool-file-1",
- value_owned_by_drive=True,
- )
- ]
- )
-
- async def get_manifest(self, *, principal, prefix, include_download_url):
- del principal, prefix, include_download_url
- raise AssertionError("unexpected manifest request")
-
- drive_handler = cast(AgentStubDriveRequestHandler, cast(object, FakeDriveHandler()))
- app = FastAPI()
- app.include_router(create_agent_stub_http_router(codec, None, drive_handler))
- client = TestClient(app)
-
- response = client.post(
- "/agent-stub/drive/commit",
- headers={"Authorization": f"Bearer {token}"},
- json={"items": [{"key": "skills/example/SKILL.md", "file_ref": {"kind": "tool_file", "id": "tool-file-1"}}]},
- )
-
- assert response.status_code == 200
- assert response.json()["items"][0]["file_id"] == "tool-file-1"
-
-
-def test_agent_stub_drive_routes_return_503_when_drive_api_is_unconfigured() -> None:
- codec = _token_codec()
- token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)
- app = FastAPI()
- app.include_router(create_agent_stub_http_router(codec, None, None))
- client = TestClient(app)
-
- manifest_response = client.get(
- "/agent-stub/drive/manifest",
- headers={"Authorization": f"Bearer {token}"},
- )
- commit_response = client.post(
- "/agent-stub/drive/commit",
- headers={"Authorization": f"Bearer {token}"},
- json={"items": [{"key": "skills/example/SKILL.md", "file_ref": {"kind": "tool_file", "id": "tool-file-1"}}]},
- )
-
- assert manifest_response.status_code == 503
- assert commit_response.status_code == 503
- assert manifest_response.json()["detail"] == "Agent Stub drive API is not configured"
- assert commit_response.json()["detail"] == "Agent Stub drive API is not configured"
-
-
-def test_agent_stub_drive_route_preserves_structured_handler_error_details() -> None:
- codec = _token_codec()
- token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)
-
- class FakeDriveHandler:
- async def get_manifest(self, *, principal, prefix, include_download_url):
- del principal, prefix, include_download_url
- raise AgentStubDriveRequestError(400, {"code": "invalid_key", "message": "bad request"})
-
- async def commit(self, *, principal, request):
- del principal, request
- raise AssertionError("unexpected commit request")
-
- drive_handler = cast(AgentStubDriveRequestHandler, cast(object, FakeDriveHandler()))
- app = FastAPI()
- app.include_router(create_agent_stub_http_router(codec, None, drive_handler))
- client = TestClient(app)
-
- response = client.get(
- "/agent-stub/drive/manifest",
- headers={"Authorization": f"Bearer {token}"},
- )
-
- assert response.status_code == 400
- assert response.json()["detail"] == {"code": "invalid_key", "message": "bad request"}
diff --git a/dify-agent/tests/local/dify_agent/layers/config/test_layer.py b/dify-agent/tests/local/dify_agent/layers/config/test_layer.py
index ce9435a4b46..c66f0c88a47 100644
--- a/dify-agent/tests/local/dify_agent/layers/config/test_layer.py
+++ b/dify-agent/tests/local/dify_agent/layers/config/test_layer.py
@@ -19,7 +19,7 @@ from dify_agent.layers.shell.layer import CompleteRemoteCommandResult, DifyShell
def _shell_layer() -> DifyShellLayer:
return DifyShellLayer.from_config_with_settings(
- DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
+ DifyShellLayerConfig(),
)
diff --git a/dify-agent/tests/local/dify_agent/layers/drive/__init__.py b/dify-agent/tests/local/dify_agent/layers/drive/__init__.py
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/dify-agent/tests/local/dify_agent/layers/drive/test_configs.py b/dify-agent/tests/local/dify_agent/layers/drive/test_configs.py
deleted file mode 100644
index 05ddad3543c..00000000000
--- a/dify-agent/tests/local/dify_agent/layers/drive/test_configs.py
+++ /dev/null
@@ -1,57 +0,0 @@
-"""Contract tests for the dify.drive declaration layer (ENG-623)."""
-
-import pytest
-from pydantic import ValidationError
-
-from dify_agent.layers.drive import (
- DIFY_DRIVE_LAYER_TYPE_ID,
- DifyDriveLayerConfig,
- DifyDriveSkillConfig,
-)
-from dify_agent.layers.drive.layer import DifyDriveLayer
-
-
-def test_type_id_is_frozen_contract() -> None:
- assert DIFY_DRIVE_LAYER_TYPE_ID == "dify.drive"
- assert DifyDriveLayer.type_id == DIFY_DRIVE_LAYER_TYPE_ID
-
-
-def test_layer_config_round_trips_manifest_entries() -> None:
- config = DifyDriveLayerConfig.model_validate(
- {
- "drive_ref": "agent-019e9112",
- "skills": [
- {
- "path": "tender-analyzer",
- "name": "Tender Analyzer",
- "description": "Parses RFP documents step by step.",
- "skill_md_key": "tender-analyzer/SKILL.md",
- "archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
- }
- ],
- "mentioned_skill_keys": ["tender-analyzer/SKILL.md"],
- "mentioned_file_keys": ["files/sample.pdf"],
- }
- )
-
- dumped = config.model_dump(mode="json")
- assert dumped["drive_ref"] == "agent-019e9112"
- assert "drive_base" not in dumped
- assert dumped["skills"][0]["skill_md_key"] == "tender-analyzer/SKILL.md"
- assert dumped["mentioned_file_keys"] == ["files/sample.pdf"]
- assert "content" not in DifyDriveSkillConfig.model_fields
-
-
-def test_layer_config_rejects_unknown_fields() -> None:
- with pytest.raises(ValidationError):
- DifyDriveLayerConfig.model_validate({"drive_ref": "agent-1", "skill_md_body": "# inline content"})
-
-
-def test_drive_layer_is_registered_and_constructible_from_config() -> None:
- layer = DifyDriveLayer.from_config(
- DifyDriveLayerConfig(drive_ref="agent-1", skills=[], mentioned_skill_keys=[], mentioned_file_keys=[]),
- )
-
- assert isinstance(layer, DifyDriveLayer)
- assert layer.config.drive_ref == "agent-1"
- assert not hasattr(layer, "local_drive_base")
diff --git a/dify-agent/tests/local/dify_agent/layers/drive/test_layer.py b/dify-agent/tests/local/dify_agent/layers/drive/test_layer.py
deleted file mode 100644
index c4cfb74346f..00000000000
--- a/dify-agent/tests/local/dify_agent/layers/drive/test_layer.py
+++ /dev/null
@@ -1,237 +0,0 @@
-"""Behavior tests for the runtime Dify drive layer."""
-
-from __future__ import annotations
-
-from typing import Literal
-
-import pytest
-
-from dify_agent.layers.drive import DifyDriveLayerConfig, DifyDriveSkillConfig
-from dify_agent.layers.drive.layer import DifyDriveLayer, DifyDriveLayerError, _AGENT_FILE_UPLOAD_REPLY_HINT
-from dify_agent.layers.shell import DifyShellLayerConfig
-from dify_agent.layers.shell.layer import CompleteRemoteCommandResult, DifyShellLayer
-
-
-def _shell_layer() -> DifyShellLayer:
- return DifyShellLayer.from_config_with_settings(
- DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
- )
-
-
-def _build_layer() -> DifyDriveLayer:
- layer = DifyDriveLayer.from_config(
- DifyDriveLayerConfig(
- drive_ref="agent-1",
- skills=[
- DifyDriveSkillConfig(
- path="tender-analyzer",
- name="Tender Analyzer",
- description="Parses RFPs.",
- skill_md_key="tender-analyzer/SKILL.md",
- archive_key="tender-analyzer/.DIFY-SKILL-FULL.zip",
- ),
- DifyDriveSkillConfig(
- path="other-skill",
- name="Other Skill",
- description="Fallback catalog entry.",
- skill_md_key="other-skill/SKILL.md",
- archive_key=None,
- ),
- ],
- mentioned_skill_keys=["tender-analyzer/SKILL.md"],
- mentioned_file_keys=["files/report.pdf"],
- )
- )
- layer.bind_deps({"shell": _shell_layer()})
- return layer
-
-
-def _remote_result(
- output: str,
- *,
- exit_code: int | None = 0,
- output_complete: bool = True,
- incomplete_reason: Literal["output_limit", "timeout"] | None = None,
-) -> CompleteRemoteCommandResult:
- return CompleteRemoteCommandResult(
- job_id="remote-drive-pull",
- status="exited",
- done=True,
- exit_code=exit_code,
- output=output,
- output_complete=output_complete,
- incomplete_reason=incomplete_reason,
- offset=len(output),
- output_path="/tmp/output.log",
- )
-
-
-def _pulled_output() -> str:
- return (
- "/mnt/drive/agent-1/tender-analyzer\n"
- "/mnt/drive/agent-1/files/report.pdf\n"
- "__DIFY_DRIVE_MENTIONED_PATH__\ttender-analyzer/SKILL.md\t/mnt/drive/agent-1/tender-analyzer/SKILL.md\n"
- "__DIFY_DRIVE_SKILL_BEGIN__\ttender-analyzer/SKILL.md\n"
- "# Tender Analyzer\n"
- "Use carefully.\n"
- "__DIFY_DRIVE_SKILL_END__\ttender-analyzer/SKILL.md\n"
- "__DIFY_DRIVE_MENTIONED_PATH__\tfiles/report.pdf\t/mnt/drive/agent-1/files/report.pdf\n"
- )
-
-
-def _file_help_output(command: str) -> str:
- return f"Usage: {command.removesuffix(' --help')} [OPTIONS]\n\nAgent Stub file command help.\n"
-
-
-def _patch_file_help(monkeypatch: pytest.MonkeyPatch) -> list[str]:
- captured_scripts: list[str] = []
-
- async def fake_run_remote_script(
- self: DifyShellLayer,
- script: str,
- *,
- timeout: float = 10.0,
- inject_agent_stub_env: bool = False,
- ) -> CompleteRemoteCommandResult:
- del self, timeout, inject_agent_stub_env
- captured_scripts.append(script)
- return _remote_result(_file_help_output(script))
-
- monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script)
- return captured_scripts
-
-
-def test_drive_layer_exposes_agent_stub_cli_usage_suffix_prompt() -> None:
- layer = _build_layer()
- layer._agent_stub_cli_help = {
- "dify-agent file --help": _file_help_output("dify-agent file --help"),
- "dify-agent file upload --help": _file_help_output("dify-agent file upload --help"),
- "dify-agent file download --help": _file_help_output("dify-agent file download --help"),
- }
-
- assert len(layer.suffix_prompts) == 1
- prompt = layer.suffix_prompts[0]
- assert "Other available skills" in prompt
- assert "other-skill: Other Skill" in prompt
- assert "Agent Stub file CLI reference for installed `dify-agent`" in prompt
- assert "$ dify-agent file upload --help" in prompt
- assert "$ dify-agent file download --help" in prompt
- assert prompt.index("$ dify-agent file upload --help") < prompt.index("$ dify-agent file download --help")
- assert _AGENT_FILE_UPLOAD_REPLY_HINT in prompt
- assert "dify-agent drive" not in prompt
-
-
-@pytest.mark.anyio
-async def test_on_context_create_pulls_mentioned_targets_through_shell(monkeypatch: pytest.MonkeyPatch) -> None:
- layer = _build_layer()
- captured: dict[str, object] = {}
- help_scripts = _patch_file_help(monkeypatch)
-
- async def fake_run_remote_script_complete(
- self: DifyShellLayer,
- script: str,
- *,
- timeout: float = 10.0,
- inject_agent_stub_env: bool = False,
- ) -> CompleteRemoteCommandResult:
- del self, timeout
- captured["script"] = script
- captured["inject_agent_stub_env"] = inject_agent_stub_env
- return _remote_result(_pulled_output())
-
- monkeypatch.setattr(DifyShellLayer, "run_remote_script_complete", fake_run_remote_script_complete)
-
- await layer.on_context_create()
-
- assert help_scripts == [
- "dify-agent file --help",
- "dify-agent file upload --help",
- "dify-agent file download --help",
- ]
- assert "dify-agent file download --help" in layer._agent_stub_cli_help
- script = captured["script"]
- assert isinstance(script, str)
- assert captured["inject_agent_stub_env"] is True
- assert 'dify-agent drive pull tender-analyzer/ files/report.pdf --to "$base"' in script
- prompt = layer.build_prompt_context()
- assert "Loaded mentioned skills" in prompt
- assert "# Tender Analyzer\nUse carefully." in prompt
-
-
-@pytest.mark.anyio
-async def test_on_context_create_raises_when_shell_pull_fails(monkeypatch: pytest.MonkeyPatch) -> None:
- layer = _build_layer()
- _patch_file_help(monkeypatch)
-
- async def fake_run_remote_script_complete(
- self: DifyShellLayer,
- script: str,
- *,
- timeout: float = 10.0,
- inject_agent_stub_env: bool = False,
- ) -> CompleteRemoteCommandResult:
- del self, script, timeout, inject_agent_stub_env
- return _remote_result("permission denied\n", exit_code=1)
-
- monkeypatch.setattr(DifyShellLayer, "run_remote_script_complete", fake_run_remote_script_complete)
-
- with pytest.raises(DifyDriveLayerError) as exc_info:
- await layer.on_context_create()
-
- message = str(exc_info.value)
- assert "drive mentioned pull failed in shell: exited exit_code=1" in message
- assert "output_complete=True" in message
- assert "output_path=/tmp/output.log" in message
-
-
-@pytest.mark.anyio
-async def test_on_context_create_raises_when_required_skill_marker_is_missing_from_complete_output(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- layer = _build_layer()
- _patch_file_help(monkeypatch)
-
- async def fake_run_remote_script_complete(
- self: DifyShellLayer,
- script: str,
- *,
- timeout: float = 10.0,
- inject_agent_stub_env: bool = False,
- ) -> CompleteRemoteCommandResult:
- del self, script, timeout, inject_agent_stub_env
- return _remote_result("__DIFY_DRIVE_MENTIONED_PATH__\tfiles/report.pdf\t/mnt/drive/agent-1/files/report.pdf\n")
-
- monkeypatch.setattr(DifyShellLayer, "run_remote_script_complete", fake_run_remote_script_complete)
-
- with pytest.raises(DifyDriveLayerError, match="missing pulled SKILL.md"):
- await layer.on_context_create()
-
-
-@pytest.mark.anyio
-async def test_on_context_create_reports_incomplete_capture_when_required_marker_is_missing(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- layer = _build_layer()
- _patch_file_help(monkeypatch)
-
- async def fake_run_remote_script_complete(
- self: DifyShellLayer,
- script: str,
- *,
- timeout: float = 10.0,
- inject_agent_stub_env: bool = False,
- ) -> CompleteRemoteCommandResult:
- del self, script, timeout, inject_agent_stub_env
- output = (
- "__DIFY_DRIVE_MENTIONED_PATH__\ttender-analyzer/SKILL.md\t/mnt/drive/agent-1/tender-analyzer/SKILL.md\n"
- )
- return _remote_result(output, output_complete=False, incomplete_reason="output_limit")
-
- monkeypatch.setattr(DifyShellLayer, "run_remote_script_complete", fake_run_remote_script_complete)
-
- with pytest.raises(DifyDriveLayerError) as exc_info:
- await layer.on_context_create()
-
- message = str(exc_info.value)
- assert "output incomplete before required SKILL.md content was captured" in message
- assert "reason=output_limit" in message
diff --git a/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py b/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py
index 30405baff66..a4dbdb1641b 100644
--- a/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py
+++ b/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py
@@ -27,7 +27,6 @@ def test_shell_layer_config_defaults_and_forbids_unknown_fields() -> None:
config = DifyShellLayerConfig()
assert config.model_dump() == {
- "agent_stub_drive_ref": None,
"cli_tools": [],
"env": [],
"secret_refs": [],
@@ -50,7 +49,6 @@ def test_shell_layer_config_accepts_agent_soul_shell_settings() -> None:
],
env=[DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo")],
secret_refs=[DifyShellSecretRefConfig(name="OPENAI_API_KEY", ref="credential-1")],
- agent_stub_drive_ref="agent-1",
)
assert config.cli_tools[0].install_commands == ["apt-get update", "apt-get install -y ripgrep"]
@@ -58,7 +56,6 @@ def test_shell_layer_config_accepts_agent_soul_shell_settings() -> None:
assert config.cli_tools[0].secret_refs[0].ref == "credential-2"
assert config.env[0].name == "PROJECT_NAME"
assert config.secret_refs[0].ref == "credential-1"
- assert config.agent_stub_drive_ref == "agent-1"
def test_shell_layer_config_rejects_invalid_env_names() -> None:
diff --git a/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py b/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py
index afff9519fc9..d79e615b73e 100644
--- a/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py
+++ b/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py
@@ -75,6 +75,8 @@ if "jsonschema" not in sys.modules:
sys.modules["jsonschema.protocols"] = jsonschema_protocols_module
sys.modules["jsonschema.validators"] = jsonschema_validators_module
+from dify_agent.layers.config import DIFY_CONFIG_LAYER_TYPE_ID, DifyConfigLayerConfig
+from dify_agent.layers.config.layer import DifyConfigLayer
from dify_agent.layers.dify_core_tools import DIFY_CORE_TOOLS_LAYER_TYPE_ID, DifyCoreToolsLayerConfig
from dify_agent.layers.dify_core_tools.layer import DifyCoreToolsLayer
from dify_agent.layers.runtime import DIFY_RUNTIME_LAYER_TYPE_ID, DifyRuntimeLayerConfig
@@ -100,6 +102,18 @@ def _runtime_backend_profile() -> RuntimeBackendProfile:
)
+def test_default_layer_providers_register_config_layer() -> None:
+ providers = create_default_layer_providers()
+
+ config_provider = next(provider for provider in providers if provider.type_id == DIFY_CONFIG_LAYER_TYPE_ID)
+ config = DifyConfigLayerConfig(agent_id="agent-1")
+ layer = config_provider.create_layer(config)
+
+ assert isinstance(layer, DifyConfigLayer)
+ assert layer.type_id == DIFY_CONFIG_LAYER_TYPE_ID
+ assert layer.config == config
+
+
def test_default_layer_providers_register_runtime_layer() -> None:
profile = _runtime_backend_profile()
diff --git a/dify-agent/tests/local/dify_agent/server/test_app.py b/dify-agent/tests/local/dify_agent/server/test_app.py
index 66983fd1cc5..14d49f9f3be 100644
--- a/dify-agent/tests/local/dify_agent/server/test_app.py
+++ b/dify-agent/tests/local/dify_agent/server/test_app.py
@@ -292,10 +292,6 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
getattr(route, "path", None) == "/agent-stub/files/download-request"
for route in create_app(settings).routes
)
- assert any(
- getattr(route, "path", None) == "/agent-stub/drive/manifest" for route in create_app(settings).routes
- )
- assert any(getattr(route, "path", None) == "/agent-stub/drive/commit" for route in create_app(settings).routes)
route_paths = create_app(settings).openapi()["paths"]
assert {
"/execution-bindings/files/list",
@@ -378,65 +374,6 @@ def test_create_app_wires_authenticated_agent_stub_file_upload_route(monkeypatch
assert fake_redis.closed is True
-def test_create_app_wires_authenticated_agent_stub_drive_manifest_route(monkeypatch: pytest.MonkeyPatch) -> None:
- fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch)
- settings = ServerSettings(
- redis_url="redis://example.invalid/0",
- agent_stub_api_base_url="https://agent.example.com/agent-stub",
- server_secret_key=_base64url_secret(b"1" * 32),
- inner_api_url="https://api.example.com",
- inner_api_key="inner-secret",
- sandbox_files_base_url="https://files.example.com",
- )
- token_codec = settings.create_agent_stub_token_codec()
- assert token_codec is not None
- token = token_codec.encode_connection_token(
- _execution_context().model_copy(update={"agent_id": "agent-1"}), now=int(time.time()) - 1
- )
-
- original_async_client = httpx.AsyncClient
-
- def handler(request: httpx.Request) -> httpx.Response:
- assert str(request.url) == (
- "https://api.example.com/inner/api/drive/agent-agent-1/manifest"
- "?tenant_id=tenant-1&prefix=skills%2F&include_download_url=false"
- )
- assert request.headers["X-Inner-Api-Key"] == "inner-secret"
- return httpx.Response(
- 200,
- json={
- "items": [
- {
- "key": "skills/example/SKILL.md",
- "size": 12,
- "hash": "sha256:abc",
- "mime_type": "text/markdown",
- "file_kind": "tool_file",
- "file_id": "tool-file-1",
- }
- ]
- },
- )
-
- monkeypatch.setattr(
- "dify_agent.agent_stub.server.agent_stub_drive.httpx.AsyncClient",
- lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs),
- )
-
- with TestClient(create_app(settings)) as client:
- response = client.get(
- "/agent-stub/drive/manifest",
- headers={"Authorization": f"Bearer {token}"},
- params={"prefix": "skills/"},
- )
-
- assert response.status_code == 200
- assert response.json()["items"][0]["key"] == "skills/example/SKILL.md"
- assert FakeRunScheduler.created[0].shutdown_called is True
- assert fake_http_client.is_closed is True
- assert fake_redis.closed is True
-
-
def test_create_plugin_daemon_http_client_uses_generic_outbound_httpx_construction_args(
monkeypatch: pytest.MonkeyPatch,
) -> None:
diff --git a/dify-agent/tests/local/dify_agent/server/test_binding_files.py b/dify-agent/tests/local/dify_agent/server/test_binding_files.py
index 21e93ea334b..4975729a9dd 100644
--- a/dify-agent/tests/local/dify_agent/server/test_binding_files.py
+++ b/dify-agent/tests/local/dify_agent/server/test_binding_files.py
@@ -420,7 +420,6 @@ async def test_download_shell_quotes_resolved_path_and_returns_only_reference_in
"HOME": "/home/agent",
"DIFY_AGENT_STUB_API_BASE_URL": "http://stub/agent-stub",
"DIFY_AGENT_STUB_AUTH_JWE": "secret-jwe",
- "DIFY_AGENT_STUB_DRIVE_BASE": "/mnt/drive",
}
assert timeout == pytest.approx(60.0, rel=0, abs=0.01)
assert issued_tokens == [(context, None)]
diff --git a/dify-agent/tests/local/dify_agent/server/test_settings.py b/dify-agent/tests/local/dify_agent/server/test_settings.py
index 29908851d85..c04d87fa764 100644
--- a/dify-agent/tests/local/dify_agent/server/test_settings.py
+++ b/dify-agent/tests/local/dify_agent/server/test_settings.py
@@ -2,13 +2,10 @@ from __future__ import annotations
from pathlib import Path
import secrets
-from typing import cast
-import httpx
import pytest
from pydantic import ValidationError
-from dify_agent.agent_stub.server.agent_stub_drive import DifyApiAgentStubDriveRequestHandler
from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
from dify_agent.server.settings import ServerSettings
@@ -271,32 +268,6 @@ def test_server_settings_create_agent_stub_file_request_handler_returns_handler_
assert handler.max_upload_size_bytes == 72 * 1024 * 1024
-def test_server_settings_create_agent_stub_drive_request_handler_returns_none_without_full_settings() -> None:
- assert ServerSettings().create_agent_stub_drive_request_handler() is None
-
-
-def test_server_settings_create_agent_stub_drive_request_handler_returns_handler_when_configured() -> None:
- settings = ServerSettings(
- inner_api_url="https://api.example.com",
- inner_api_key="inner-secret",
- outbound_http_connect_timeout=11,
- outbound_http_read_timeout=22,
- outbound_http_write_timeout=33,
- outbound_http_pool_timeout=44,
- )
-
- handler = settings.create_agent_stub_drive_request_handler()
-
- assert isinstance(handler, DifyApiAgentStubDriveRequestHandler)
- assert handler.inner_api_url == "https://api.example.com"
- assert handler.inner_api_key == "inner-secret"
- timeout = cast(httpx.Timeout, handler.timeout)
- assert timeout.connect == 11
- assert timeout.read == 22
- assert timeout.write == 33
- assert timeout.pool == 44
-
-
def test_build_runtime_backend_profile_returns_none_when_local_endpoint_is_unset(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
diff --git a/dify-agent/tests/local/dify_agent/test_client_safe_exports.py b/dify-agent/tests/local/dify_agent/test_client_safe_exports.py
index 3ece328472a..ba1493701fb 100644
--- a/dify-agent/tests/local/dify_agent/test_client_safe_exports.py
+++ b/dify-agent/tests/local/dify_agent/test_client_safe_exports.py
@@ -68,7 +68,6 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat
agent_cli_help_module = importlib.import_module("dify_agent.layers._agent_cli_help")
agent_stub_shell_env_module = importlib.import_module("dify_agent.agent_stub.shell_env")
shell_module = importlib.import_module("dify_agent.layers.shell")
- drive_module = importlib.import_module("dify_agent.layers.drive")
execution_context_module = importlib.import_module("dify_agent.layers.execution_context")
plugin_module = importlib.import_module("dify_agent.layers.dify_plugin")
ask_human_module = importlib.import_module("dify_agent.layers.ask_human")
@@ -90,7 +89,6 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat
assert "Usage:" in agent_cli_help_module.render_agent_stub_cli_help(("config",))
assert agent_stub_shell_env_module.build_shell_agent_stub_env is not None
assert shell_module.DifyShellLayerConfig is not None
- assert drive_module.DifyDriveLayerConfig is not None
assert execution_context_module.DifyExecutionContextLayerConfig is not None
assert plugin_module.DifyPluginLLMLayerConfig is not None
assert ask_human_module.DifyAskHumanLayerConfig is not None
diff --git a/dify-agent/tests/local/dify_agent/test_import_boundaries.py b/dify-agent/tests/local/dify_agent/test_import_boundaries.py
index 4407d408f41..f23233aa3a2 100644
--- a/dify-agent/tests/local/dify_agent/test_import_boundaries.py
+++ b/dify-agent/tests/local/dify_agent/test_import_boundaries.py
@@ -104,7 +104,6 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
blocked_imports=[
"anthropic",
"dify_agent.adapters.llm",
- "dify_agent.layers.drive.layer",
"dify_agent.layers.execution_context.layer",
"dify_agent.layers.ask_human.layer",
"dify_agent.layers.dify_plugin.llm_layer",
@@ -125,7 +124,6 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
],
imports=[
"dify_agent.protocol",
- "dify_agent.layers.drive",
"dify_agent.layers.execution_context",
"dify_agent.layers.ask_human",
"dify_agent.layers.dify_plugin",
@@ -135,7 +133,6 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
],
assertions=[
"assert hasattr(dify_agent_protocol, 'CreateRunRequest')",
- "assert hasattr(dify_agent_layers_drive, 'DifyDriveLayerConfig')",
"assert hasattr(dify_agent_layers_execution_context, 'DifyExecutionContextLayerConfig')",
"assert hasattr(dify_agent_layers_ask_human, 'DifyAskHumanLayerConfig')",
"assert hasattr(dify_agent_layers_dify_plugin, 'DifyPluginLLMLayerConfig')",
diff --git a/e2e/features/agent-v2/AGENTS.md b/e2e/features/agent-v2/AGENTS.md
index 1e27c084aa4..0b0eac00f59 100644
--- a/e2e/features/agent-v2/AGENTS.md
+++ b/e2e/features/agent-v2/AGENTS.md
@@ -36,7 +36,7 @@ Agent v2 state belongs under `world.agentBuilder`:
- `fixtures` stores resolved models and seeded resources.
- `accessPoint`, `configure`, `speechToText`, and `workflow` store per-scenario state.
-Do not add Agent v2 fields to the top level of `DifyWorld`. Store created Agent IDs, drive files, and tool credentials in the existing typed cleanup fields.
+Do not add Agent v2 fields to the top level of `DifyWorld`. Store created Agent IDs, config assets, and tool credentials in the existing typed cleanup fields.
## Setup boundary
diff --git a/e2e/features/agent-v2/support/agent-drive.ts b/e2e/features/agent-v2/support/config-assets.ts
similarity index 75%
rename from e2e/features/agent-v2/support/agent-drive.ts
rename to e2e/features/agent-v2/support/config-assets.ts
index c76e7b75c9b..abc4be6e6eb 100644
--- a/e2e/features/agent-v2/support/agent-drive.ts
+++ b/e2e/features/agent-v2/support/config-assets.ts
@@ -3,9 +3,6 @@ import type {
AgentConfigFileUploadResponse,
AgentConfigSkillRefConfig,
AgentConfigSkillUploadResponse,
- AgentDriveSkillItemResponse,
- AgentDriveSkillListResponse,
- AgentSkillUploadResponse,
} from '@dify/contracts/api/console/agent/types.gen'
import type { ConsoleClient } from '../../../support/api/console-client'
import { Buffer } from 'node:buffer'
@@ -31,46 +28,28 @@ const createSingleFileZip = ({ content, entryName }: { content: Buffer; entryNam
const localHeader = Buffer.alloc(30)
localHeader.writeUInt32LE(0x04034b50, 0)
localHeader.writeUInt16LE(20, 4)
- localHeader.writeUInt16LE(0, 6)
- localHeader.writeUInt16LE(0, 8)
- localHeader.writeUInt16LE(0, 10)
- localHeader.writeUInt16LE(0, 12)
localHeader.writeUInt32LE(checksum, 14)
localHeader.writeUInt32LE(content.length, 18)
localHeader.writeUInt32LE(content.length, 22)
localHeader.writeUInt16LE(entryNameBuffer.length, 26)
- localHeader.writeUInt16LE(0, 28)
const centralDirectoryOffset = localHeader.length + entryNameBuffer.length + content.length
const centralDirectoryHeader = Buffer.alloc(46)
centralDirectoryHeader.writeUInt32LE(0x02014b50, 0)
centralDirectoryHeader.writeUInt16LE(20, 4)
centralDirectoryHeader.writeUInt16LE(20, 6)
- centralDirectoryHeader.writeUInt16LE(0, 8)
- centralDirectoryHeader.writeUInt16LE(0, 10)
- centralDirectoryHeader.writeUInt16LE(0, 12)
- centralDirectoryHeader.writeUInt16LE(0, 14)
centralDirectoryHeader.writeUInt32LE(checksum, 16)
centralDirectoryHeader.writeUInt32LE(content.length, 20)
centralDirectoryHeader.writeUInt32LE(content.length, 24)
centralDirectoryHeader.writeUInt16LE(entryNameBuffer.length, 28)
- centralDirectoryHeader.writeUInt16LE(0, 30)
- centralDirectoryHeader.writeUInt16LE(0, 32)
- centralDirectoryHeader.writeUInt16LE(0, 34)
- centralDirectoryHeader.writeUInt16LE(0, 36)
- centralDirectoryHeader.writeUInt32LE(0, 38)
- centralDirectoryHeader.writeUInt32LE(0, 42)
const centralDirectorySize = centralDirectoryHeader.length + entryNameBuffer.length
const endOfCentralDirectory = Buffer.alloc(22)
endOfCentralDirectory.writeUInt32LE(0x06054b50, 0)
- endOfCentralDirectory.writeUInt16LE(0, 4)
- endOfCentralDirectory.writeUInt16LE(0, 6)
endOfCentralDirectory.writeUInt16LE(1, 8)
endOfCentralDirectory.writeUInt16LE(1, 10)
endOfCentralDirectory.writeUInt32LE(centralDirectorySize, 12)
endOfCentralDirectory.writeUInt32LE(centralDirectoryOffset, 16)
- endOfCentralDirectory.writeUInt16LE(0, 20)
return Buffer.concat([
localHeader,
@@ -113,25 +92,6 @@ const toSkillArchiveUpload = async ({
const createUploadFile = (content: Buffer, name: string, type: string) =>
new File([Uint8Array.from(content)], name, { type })
-export async function uploadAgentDriveSkill(
- client: ConsoleClient,
- {
- agentId,
- fileName,
- filePath,
- }: {
- agentId: string
- fileName: string
- filePath: string
- },
-): Promise {
- const upload = await toSkillArchiveUpload({ fileName, filePath })
- return client.agent.byAgentId.skills.upload.post({
- body: { file: createUploadFile(upload.buffer, upload.name, 'application/zip') },
- params: { agent_id: agentId },
- })
-}
-
export async function uploadAgentConfigFileToDraft(
client: ConsoleClient,
{
@@ -195,13 +155,3 @@ export async function uploadAgentConfigSkillToDraft(
size: skill.size,
}
}
-
-export async function getAgentDriveSkills(
- client: ConsoleClient,
- agentId: string,
-): Promise {
- const body: AgentDriveSkillListResponse = await client.agent.byAgentId.drive.skills.get({
- params: { agent_id: agentId },
- })
- return body.items ?? []
-}
diff --git a/e2e/features/agent-v2/support/fixtures/agents.ts b/e2e/features/agent-v2/support/fixtures/agents.ts
index e0ab6620d97..6636a2ee524 100644
--- a/e2e/features/agent-v2/support/fixtures/agents.ts
+++ b/e2e/features/agent-v2/support/fixtures/agents.ts
@@ -110,33 +110,6 @@ export async function requirePreseededWorkflow(
}
}
-export async function requirePreseededAgentDriveSkill(
- world: DifyWorld,
- client: ConsoleClient,
- agentName: string,
- skillName: string,
-): Promise {
- const agent = await requirePreseededAgent(world, client, agentName)
-
- const response = await client.agent.byAgentId.drive.skills.get({
- params: { agent_id: agent.id },
- })
- const skill = response.items?.find((item) => item.name === skillName)
-
- if (!skill) {
- return failFixturePrerequisite(
- world,
- `Preseeded Agent "${agentName}" does not include drive skill "${skillName}".`,
- )
- }
-
- return {
- id: skill.path,
- kind: 'skill',
- name: skill.name,
- }
-}
-
export async function requirePreseededFullConfigAgentCoreConfiguration(
world: DifyWorld,
client: ConsoleClient,
@@ -146,13 +119,6 @@ export async function requirePreseededFullConfigAgentCoreConfiguration(
const agent = await requirePreseededAgent(world, client, agentName)
- await requirePreseededAgentDriveSkill(
- world,
- client,
- agentName,
- agentBuilderPreseededResources.summarySkill,
- )
-
const jsonTool = await requirePreseededTool(
world,
client,
@@ -225,13 +191,6 @@ export async function requirePreseededToolStatesAgentConfiguration(
): Promise {
const agent = await requirePreseededAgent(world, client, agentName)
- await requirePreseededAgentDriveSkill(
- world,
- client,
- agentName,
- agentBuilderPreseededResources.summarySkill,
- )
-
const jsonTool = await requirePreseededTool(
world,
client,
diff --git a/e2e/features/agent-v2/support/fixtures/common.ts b/e2e/features/agent-v2/support/fixtures/common.ts
index 6e30a3e0871..809e7b8a77f 100644
--- a/e2e/features/agent-v2/support/fixtures/common.ts
+++ b/e2e/features/agent-v2/support/fixtures/common.ts
@@ -51,9 +51,7 @@ export const matchesNameOrLabel = (value: string, name: string, label?: unknown)
export const hasNamedOrKeyedEntry = (items: unknown[], expectedName: string) =>
items.some((item) => {
const record = asRecord(item)
- const values = [record.name, record.drive_key, record.reference, record.file_id, record.id].map(
- asString,
- )
+ const values = [record.name, record.reference, record.file_id, record.id].map(asString)
return values.some((value) => value === expectedName || value.endsWith(`/${expectedName}`))
})
diff --git a/e2e/features/agent-v2/support/seed.ts b/e2e/features/agent-v2/support/seed.ts
index fd0ec8f8728..9ad0fc7be85 100644
--- a/e2e/features/agent-v2/support/seed.ts
+++ b/e2e/features/agent-v2/support/seed.ts
@@ -17,17 +17,12 @@ import {
agentBuilderFixedInputs,
agentBuilderPreseededResources,
} from './agent-builder-resources'
-import {
- getAgentDriveSkills,
- uploadAgentConfigFileToDraft,
- uploadAgentConfigSkillToDraft,
- uploadAgentDriveSkill,
-} from './agent-drive'
import {
createAgentSoulConfigWithKnowledgeDataset,
createAgentSoulConfigWithModel,
normalAgentSoulConfig,
} from './agent-soul'
+import { uploadAgentConfigFileToDraft, uploadAgentConfigSkillToDraft } from './config-assets'
import { isRecord, matchesNameOrLabel } from './fixtures/common'
import { splitToolDisplayName } from './fixtures/tools'
import { agentBuilderTestMaterials, getAgentBuilderTestMaterialPath } from './test-materials'
@@ -627,17 +622,6 @@ const saveSeededAgentComposer = async (
}
}
-const ensureDriveSkill = async (client: SeedContext['consoleClient'], agentId: string) => {
- const skills = await getAgentDriveSkills(client, agentId)
- if (skills.some((skill) => skill.name === agentBuilderPreseededResources.summarySkill)) return
-
- await uploadAgentDriveSkill(client, {
- agentId,
- fileName: agentBuilderTestMaterials.summarySkill,
- filePath: getAgentBuilderTestMaterialPath('summarySkill'),
- })
-}
-
const seedFullConfigAgent = async (context: SeedContext) => {
const title = agentBuilderPreseededResources.fullConfigAgent
const model = getStableModelResource(context)
@@ -669,7 +653,6 @@ const seedFullConfigAgent = async (context: SeedContext) => {
fileName: agentBuilderTestMaterials.summarySkill,
filePath: getAgentBuilderTestMaterialPath('summarySkill'),
})
- await ensureDriveSkill(context.consoleClient, agentId)
await saveSeededAgentComposer(context.consoleClient, {
agentId,
@@ -712,7 +695,6 @@ const seedToolStatesAgent = async (context: SeedContext) => {
fileName: agentBuilderTestMaterials.summarySkill,
filePath: getAgentBuilderTestMaterialPath('summarySkill'),
})
- await ensureDriveSkill(context.consoleClient, agent.id)
await saveSeededAgentComposer(context.consoleClient, {
agentId: agent.id,
config: {
diff --git a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts
index de28838dc27..5de92e42562 100644
--- a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts
+++ b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts
@@ -13,7 +13,6 @@ import {
agentBuilderFixedInputs,
agentBuilderPreseededResources,
} from '../../agent-v2/support/agent-builder-resources'
-import { uploadAgentConfigFileToDraft } from '../../agent-v2/support/agent-drive'
import {
createAgentSoulConfigWithModel,
normalAgentPrompt,
@@ -21,6 +20,7 @@ import {
updatedAgentPrompt,
updatedAgentSoulConfig,
} from '../../agent-v2/support/agent-soul'
+import { uploadAgentConfigFileToDraft } from '../../agent-v2/support/config-assets'
import {
agentBuilderTestMaterials,
getAgentBuilderTestMaterialPath,
diff --git a/e2e/features/step-definitions/agent-v2/configure-helpers.ts b/e2e/features/step-definitions/agent-v2/configure-helpers.ts
index 5e59b9aa47b..4867eb83034 100644
--- a/e2e/features/step-definitions/agent-v2/configure-helpers.ts
+++ b/e2e/features/step-definitions/agent-v2/configure-helpers.ts
@@ -3,8 +3,8 @@ import type { AgentComposerEnvVariable } from '../../agent-v2/support/agent-soul
import type { DifyWorld } from '../../support/world'
import { zPostAgentByAgentIdConfigFilesResponse } from '@dify/contracts/api/console/agent/zod.gen'
import { expect } from '@playwright/test'
-import { uploadAgentConfigSkillToDraft } from '../../agent-v2/support/agent-drive'
import { normalAgentPrompt } from '../../agent-v2/support/agent-soul'
+import { uploadAgentConfigSkillToDraft } from '../../agent-v2/support/config-assets'
import {
agentBuilderTestMaterials,
getAgentBuilderTestMaterialPath,
diff --git a/e2e/features/step-definitions/agent-v2/configure.steps.ts b/e2e/features/step-definitions/agent-v2/configure.steps.ts
index 174fcc8820a..8fdaae41822 100644
--- a/e2e/features/step-definitions/agent-v2/configure.steps.ts
+++ b/e2e/features/step-definitions/agent-v2/configure.steps.ts
@@ -9,7 +9,6 @@ import {
getAgentConfigurePath,
saveAgentComposerDraft,
} from '../../agent-v2/support/agent'
-import { getAgentDriveSkills, uploadAgentDriveSkill } from '../../agent-v2/support/agent-drive'
import {
concurrentFirstAgentPrompt,
concurrentSecondAgentPrompt,
@@ -19,10 +18,6 @@ import {
normalAgentSoulConfig,
updatedAgentPrompt,
} from '../../agent-v2/support/agent-soul'
-import {
- agentBuilderTestMaterials,
- getAgentBuilderTestMaterialPath,
-} from '../../agent-v2/support/test-materials'
import {
expectNormalAgentPromptDraft,
getCurrentAgentId,
@@ -137,30 +132,6 @@ Given('the Agent v2 composer draft is publishable', async function (this: DifyWo
)
})
-Given(
- 'the e2e-summary-skill Skill is available to the Agent v2 test agent',
- async function (this: DifyWorld) {
- const agentId = getCurrentAgentId(this)
- const upload = await uploadAgentDriveSkill(this.getConsoleClient(), {
- agentId,
- fileName: agentBuilderTestMaterials.summarySkill,
- filePath: getAgentBuilderTestMaterialPath('summarySkill'),
- })
- this.createdAgentDriveFiles.push({ agentId, key: upload.skill.skill_md_key })
- if (upload.skill.archive_key)
- this.createdAgentDriveFiles.push({ agentId, key: upload.skill.archive_key })
- },
-)
-
-Then(
- 'the Agent v2 test agent should include drive skill {string}',
- async function (this: DifyWorld, skillName: string) {
- const skills = await getAgentDriveSkills(this.getConsoleClient(), getCurrentAgentId(this))
-
- expect(skills.map((skill) => skill.name)).toContain(skillName)
- },
-)
-
When('I open the Agent v2 configure page', async function (this: DifyWorld) {
await this.getPage().goto(getAgentConfigurePath(getCurrentAgentId(this)))
})
diff --git a/e2e/features/support/hooks.ts b/e2e/features/support/hooks.ts
index 006277d5649..9716b1af122 100644
--- a/e2e/features/support/hooks.ts
+++ b/e2e/features/support/hooks.ts
@@ -170,15 +170,6 @@ After(
})
},
})),
- ...this.createdAgentDriveFiles.toReversed().map((file) => ({
- label: `Delete Agent drive file ${file.key}`,
- run: async () => {
- await this.getConsoleClient().agent.byAgentId.files.delete({
- params: { agent_id: file.agentId },
- query: { key: file.key },
- })
- },
- })),
...this.createdAppIds.toReversed().map((id) => ({
label: `Delete app ${id}`,
run: async () => {
diff --git a/e2e/features/support/world.ts b/e2e/features/support/world.ts
index 3cbabd84542..04d73fa8dde 100644
--- a/e2e/features/support/world.ts
+++ b/e2e/features/support/world.ts
@@ -10,10 +10,6 @@ import { runCleanupTasks } from '../../support/cleanup'
import { apiURL, baseURL, defaultLocale } from '../../test-env'
export type ScenarioCleanup = () => Promise | void
-export type CreatedAgentDriveFile = {
- agentId: string
- key: string
-}
export type CreatedAgentConfigFile = {
agentId: string
name: string
@@ -95,7 +91,6 @@ export class DifyWorld extends World {
createdDatasetIds: string[] = []
createdAgentConfigFiles: CreatedAgentConfigFile[] = []
createdAgentConfigSkills: CreatedAgentConfigSkill[] = []
- createdAgentDriveFiles: CreatedAgentDriveFile[] = []
createdBuiltinToolCredentials: CreatedBuiltinToolCredential[] = []
agentBuilder: AgentBuilderWorldState = createAgentBuilderWorldState()
scenarioCleanups: ScenarioCleanup[] = []
@@ -120,7 +115,6 @@ export class DifyWorld extends World {
this.createdDatasetIds = []
this.createdAgentConfigFiles = []
this.createdAgentConfigSkills = []
- this.createdAgentDriveFiles = []
this.createdBuiltinToolCredentials = []
this.agentBuilder = createAgentBuilderWorldState()
this.scenarioCleanups = []
diff --git a/packages/contracts/generated/api/console/agent/orpc.gen.ts b/packages/contracts/generated/api/console/agent/orpc.gen.ts
index 7d10e128fa0..fe1094a123a 100644
--- a/packages/contracts/generated/api/console/agent/orpc.gen.ts
+++ b/packages/contracts/generated/api/console/agent/orpc.gen.ts
@@ -13,13 +13,8 @@ import {
zDeleteAgentByAgentIdConfigSkillsByNamePath,
zDeleteAgentByAgentIdConfigSkillsByNameQuery,
zDeleteAgentByAgentIdConfigSkillsByNameResponse,
- zDeleteAgentByAgentIdFilesPath,
- zDeleteAgentByAgentIdFilesQuery,
- zDeleteAgentByAgentIdFilesResponse,
zDeleteAgentByAgentIdPath,
zDeleteAgentByAgentIdResponse,
- zDeleteAgentByAgentIdSkillsBySlugPath,
- zDeleteAgentByAgentIdSkillsBySlugResponse,
zGetAgentByAgentIdApiAccessPath,
zGetAgentByAgentIdApiAccessResponse,
zGetAgentByAgentIdApiKeysPath,
@@ -64,19 +59,6 @@ import {
zGetAgentByAgentIdConfigSkillsPath,
zGetAgentByAgentIdConfigSkillsQuery,
zGetAgentByAgentIdConfigSkillsResponse,
- zGetAgentByAgentIdDriveFilesDownloadPath,
- zGetAgentByAgentIdDriveFilesDownloadQuery,
- zGetAgentByAgentIdDriveFilesDownloadResponse,
- zGetAgentByAgentIdDriveFilesPath,
- zGetAgentByAgentIdDriveFilesPreviewPath,
- zGetAgentByAgentIdDriveFilesPreviewQuery,
- zGetAgentByAgentIdDriveFilesPreviewResponse,
- zGetAgentByAgentIdDriveFilesQuery,
- zGetAgentByAgentIdDriveFilesResponse,
- zGetAgentByAgentIdDriveSkillsBySkillPathInspectPath,
- zGetAgentByAgentIdDriveSkillsBySkillPathInspectResponse,
- zGetAgentByAgentIdDriveSkillsPath,
- zGetAgentByAgentIdDriveSkillsResponse,
zGetAgentByAgentIdLogsByConversationIdMessagesPath,
zGetAgentByAgentIdLogsByConversationIdMessagesQuery,
zGetAgentByAgentIdLogsByConversationIdMessagesResponse,
@@ -151,20 +133,12 @@ import {
zPostAgentByAgentIdFeedbacksBody,
zPostAgentByAgentIdFeedbacksPath,
zPostAgentByAgentIdFeedbacksResponse,
- zPostAgentByAgentIdFilesBody,
- zPostAgentByAgentIdFilesPath,
- zPostAgentByAgentIdFilesResponse,
zPostAgentByAgentIdPublishBody,
zPostAgentByAgentIdPublishPath,
zPostAgentByAgentIdPublishResponse,
zPostAgentByAgentIdSandboxFilesDownloadBody,
zPostAgentByAgentIdSandboxFilesDownloadPath,
zPostAgentByAgentIdSandboxFilesDownloadResponse,
- zPostAgentByAgentIdSkillsBySlugInferToolsPath,
- zPostAgentByAgentIdSkillsBySlugInferToolsResponse,
- zPostAgentByAgentIdSkillsUploadBody,
- zPostAgentByAgentIdSkillsUploadPath,
- zPostAgentByAgentIdSkillsUploadResponse,
zPostAgentByAgentIdVersionsByVersionIdRestorePath,
zPostAgentByAgentIdVersionsByVersionIdRestoreResponse,
zPostAgentResponse,
@@ -863,128 +837,6 @@ export const debugConversation = {
refresh,
}
-/**
- * Time-limited external signed URL for one Agent App drive value
- */
-export const get19 = oc
- .route({
- description: 'Time-limited external signed URL for one Agent App drive value',
- inputStructure: 'detailed',
- method: 'GET',
- operationId: 'getAgentByAgentIdDriveFilesDownload',
- path: '/agent/{agent_id}/drive/files/download',
- tags: ['console'],
- })
- .input(
- z.object({
- params: zGetAgentByAgentIdDriveFilesDownloadPath,
- query: zGetAgentByAgentIdDriveFilesDownloadQuery,
- }),
- )
- .output(zGetAgentByAgentIdDriveFilesDownloadResponse)
-
-export const download4 = {
- get: get19,
-}
-
-/**
- * Truncated text preview of one Agent App drive value
- */
-export const get20 = oc
- .route({
- description: 'Truncated text preview of one Agent App drive value',
- inputStructure: 'detailed',
- method: 'GET',
- operationId: 'getAgentByAgentIdDriveFilesPreview',
- path: '/agent/{agent_id}/drive/files/preview',
- tags: ['console'],
- })
- .input(
- z.object({
- params: zGetAgentByAgentIdDriveFilesPreviewPath,
- query: zGetAgentByAgentIdDriveFilesPreviewQuery,
- }),
- )
- .output(zGetAgentByAgentIdDriveFilesPreviewResponse)
-
-export const preview3 = {
- get: get20,
-}
-
-/**
- * List agent drive entries for an Agent App
- */
-export const get21 = oc
- .route({
- description: 'List agent drive entries for an Agent App',
- inputStructure: 'detailed',
- method: 'GET',
- operationId: 'getAgentByAgentIdDriveFiles',
- path: '/agent/{agent_id}/drive/files',
- tags: ['console'],
- })
- .input(
- z.object({
- params: zGetAgentByAgentIdDriveFilesPath,
- query: zGetAgentByAgentIdDriveFilesQuery.optional(),
- }),
- )
- .output(zGetAgentByAgentIdDriveFilesResponse)
-
-export const files3 = {
- get: get21,
- download: download4,
- preview: preview3,
-}
-
-/**
- * Inspect one drive-backed skill for slash-menu hover/detail UI
- */
-export const get22 = oc
- .route({
- description: 'Inspect one drive-backed skill for slash-menu hover/detail UI',
- inputStructure: 'detailed',
- method: 'GET',
- operationId: 'getAgentByAgentIdDriveSkillsBySkillPathInspect',
- path: '/agent/{agent_id}/drive/skills/{skill_path}/inspect',
- tags: ['console'],
- })
- .input(z.object({ params: zGetAgentByAgentIdDriveSkillsBySkillPathInspectPath }))
- .output(zGetAgentByAgentIdDriveSkillsBySkillPathInspectResponse)
-
-export const inspect2 = {
- get: get22,
-}
-
-export const bySkillPath = {
- inspect: inspect2,
-}
-
-/**
- * List drive-backed skills for an Agent App
- */
-export const get23 = oc
- .route({
- description: 'List drive-backed skills for an Agent App',
- inputStructure: 'detailed',
- method: 'GET',
- operationId: 'getAgentByAgentIdDriveSkills',
- path: '/agent/{agent_id}/drive/skills',
- tags: ['console'],
- })
- .input(z.object({ params: zGetAgentByAgentIdDriveSkillsPath }))
- .output(zGetAgentByAgentIdDriveSkillsResponse)
-
-export const skills2 = {
- get: get23,
- bySkillPath,
-}
-
-export const drive = {
- files: files3,
- skills: skills2,
-}
-
/**
* Update an Agent App's presentation features (opener, follow-up, citations, ...)
*/
@@ -1027,45 +879,7 @@ export const feedbacks = {
post: post14,
}
-/**
- * Delete one Agent App drive file by key
- */
-export const delete5 = oc
- .route({
- description: 'Delete one Agent App drive file by key',
- inputStructure: 'detailed',
- method: 'DELETE',
- operationId: 'deleteAgentByAgentIdFiles',
- path: '/agent/{agent_id}/files',
- tags: ['console'],
- })
- .input(
- z.object({ params: zDeleteAgentByAgentIdFilesPath, query: zDeleteAgentByAgentIdFilesQuery }),
- )
- .output(zDeleteAgentByAgentIdFilesResponse)
-
-/**
- * Commit an uploaded file into the Agent App drive under files/
- */
-export const post15 = oc
- .route({
- description: 'Commit an uploaded file into the Agent App drive under files/',
- inputStructure: 'detailed',
- method: 'POST',
- operationId: 'postAgentByAgentIdFiles',
- path: '/agent/{agent_id}/files',
- successStatus: 201,
- tags: ['console'],
- })
- .input(z.object({ body: zPostAgentByAgentIdFilesBody, params: zPostAgentByAgentIdFilesPath }))
- .output(zPostAgentByAgentIdFilesResponse)
-
-export const files4 = {
- delete: delete5,
- post: post15,
-}
-
-export const get24 = oc
+export const get19 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -1077,10 +891,10 @@ export const get24 = oc
.output(zGetAgentByAgentIdLogSourcesResponse)
export const logSources = {
- get: get24,
+ get: get19,
}
-export const get25 = oc
+export const get20 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -1097,14 +911,14 @@ export const get25 = oc
.output(zGetAgentByAgentIdLogsByConversationIdMessagesResponse)
export const messages = {
- get: get25,
+ get: get20,
}
export const byConversationId = {
messages,
}
-export const get26 = oc
+export const get21 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -1118,14 +932,14 @@ export const get26 = oc
.output(zGetAgentByAgentIdLogsResponse)
export const logs = {
- get: get26,
+ get: get21,
byConversationId,
}
/**
* Get Agent App message details by ID
*/
-export const get27 = oc
+export const get22 = oc
.route({
description: 'Get Agent App message details by ID',
inputStructure: 'detailed',
@@ -1138,14 +952,14 @@ export const get27 = oc
.output(zGetAgentByAgentIdMessagesByMessageIdResponse)
export const byMessageId2 = {
- get: get27,
+ get: get22,
}
export const messages2 = {
byMessageId: byMessageId2,
}
-export const post16 = oc
+export const post15 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -1157,13 +971,13 @@ export const post16 = oc
.output(zPostAgentByAgentIdPublishResponse)
export const publish = {
- post: post16,
+ post: post15,
}
/**
* List workflow apps that reference this Agent App's bound Agent (read-only)
*/
-export const get28 = oc
+export const get23 = oc
.route({
description: "List workflow apps that reference this Agent App's bound Agent (read-only)",
inputStructure: 'detailed',
@@ -1176,13 +990,13 @@ export const get28 = oc
.output(zGetAgentByAgentIdReferencingWorkflowsResponse)
export const referencingWorkflows = {
- get: get28,
+ get: get23,
}
/**
* Create a ToolFile from one Agent App Binding file and return its download URL
*/
-export const post17 = oc
+export const post16 = oc
.route({
description: 'Create a ToolFile from one Agent App Binding file and return its download URL',
inputStructure: 'detailed',
@@ -1199,14 +1013,14 @@ export const post17 = oc
)
.output(zPostAgentByAgentIdSandboxFilesDownloadResponse)
-export const download5 = {
- post: post17,
+export const download4 = {
+ post: post16,
}
/**
* Read a text/binary preview file in an Agent App conversation sandbox
*/
-export const get29 = oc
+export const get24 = oc
.route({
description: 'Read a text/binary preview file in an Agent App conversation sandbox',
inputStructure: 'detailed',
@@ -1224,13 +1038,13 @@ export const get29 = oc
.output(zGetAgentByAgentIdSandboxFilesReadResponse)
export const read = {
- get: get29,
+ get: get24,
}
/**
* List a directory in an Agent App conversation sandbox
*/
-export const get30 = oc
+export const get25 = oc
.route({
description: 'List a directory in an Agent App conversation sandbox',
inputStructure: 'detailed',
@@ -1247,16 +1061,16 @@ export const get30 = oc
)
.output(zGetAgentByAgentIdSandboxFilesResponse)
-export const files5 = {
- get: get30,
- download: download5,
+export const files3 = {
+ get: get25,
+ download: download4,
read,
}
/**
* Get basic information for an Agent App conversation sandbox
*/
-export const get31 = oc
+export const get26 = oc
.route({
description: 'Get basic information for an Agent App conversation sandbox',
inputStructure: 'detailed',
@@ -1269,80 +1083,11 @@ export const get31 = oc
.output(zGetAgentByAgentIdSandboxResponse)
export const sandbox = {
- get: get31,
- files: files5,
+ get: get26,
+ files: files3,
}
-/**
- * Upload + standardize a Skill into an Agent App drive
- */
-export const post18 = oc
- .route({
- description: 'Upload + standardize a Skill into an Agent App drive',
- inputStructure: 'detailed',
- method: 'POST',
- operationId: 'postAgentByAgentIdSkillsUpload',
- path: '/agent/{agent_id}/skills/upload',
- successStatus: 201,
- tags: ['console'],
- })
- .input(
- z.object({
- body: zPostAgentByAgentIdSkillsUploadBody,
- params: zPostAgentByAgentIdSkillsUploadPath,
- }),
- )
- .output(zPostAgentByAgentIdSkillsUploadResponse)
-
-export const upload2 = {
- post: post18,
-}
-
-/**
- * Infer CLI tool + ENV suggestions from a standardized Agent App skill
- */
-export const post19 = oc
- .route({
- description: 'Infer CLI tool + ENV suggestions from a standardized Agent App skill',
- inputStructure: 'detailed',
- method: 'POST',
- operationId: 'postAgentByAgentIdSkillsBySlugInferTools',
- path: '/agent/{agent_id}/skills/{slug}/infer-tools',
- tags: ['console'],
- })
- .input(z.object({ params: zPostAgentByAgentIdSkillsBySlugInferToolsPath }))
- .output(zPostAgentByAgentIdSkillsBySlugInferToolsResponse)
-
-export const inferTools = {
- post: post19,
-}
-
-/**
- * Delete a standardized skill from an Agent App drive
- */
-export const delete6 = oc
- .route({
- description: 'Delete a standardized skill from an Agent App drive',
- inputStructure: 'detailed',
- method: 'DELETE',
- operationId: 'deleteAgentByAgentIdSkillsBySlug',
- path: '/agent/{agent_id}/skills/{slug}',
- tags: ['console'],
- })
- .input(z.object({ params: zDeleteAgentByAgentIdSkillsBySlugPath }))
- .output(zDeleteAgentByAgentIdSkillsBySlugResponse)
-
-export const bySlug = {
- delete: delete6,
- inferTools,
-}
-
-export const skills3 = {
- upload: upload2,
- bySlug,
-}
-
-export const get32 = oc
+export const get27 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -1359,14 +1104,14 @@ export const get32 = oc
.output(zGetAgentByAgentIdStatisticsSummaryResponse)
export const summary = {
- get: get32,
+ get: get27,
}
export const statistics = {
summary,
}
-export const post20 = oc
+export const post17 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -1378,10 +1123,10 @@ export const post20 = oc
.output(zPostAgentByAgentIdVersionsByVersionIdRestoreResponse)
export const restore = {
- post: post20,
+ post: post17,
}
-export const get33 = oc
+export const get28 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -1393,11 +1138,11 @@ export const get33 = oc
.output(zGetAgentByAgentIdVersionsByVersionIdResponse)
export const byVersionId = {
- get: get33,
+ get: get28,
restore,
}
-export const get34 = oc
+export const get29 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -1409,11 +1154,11 @@ export const get34 = oc
.output(zGetAgentByAgentIdVersionsResponse)
export const versions = {
- get: get34,
+ get: get29,
byVersionId,
}
-export const delete7 = oc
+export const delete5 = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
@@ -1425,7 +1170,7 @@ export const delete7 = oc
.input(z.object({ params: zDeleteAgentByAgentIdPath }))
.output(zDeleteAgentByAgentIdResponse)
-export const get35 = oc
+export const get30 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -1448,8 +1193,8 @@ export const put3 = oc
.output(zPutAgentByAgentIdResponse)
export const byAgentId = {
- delete: delete7,
- get: get35,
+ delete: delete5,
+ get: get30,
put: put3,
apiAccess,
apiEnable,
@@ -1462,22 +1207,19 @@ export const byAgentId = {
config,
copy,
debugConversation,
- drive,
features,
feedbacks,
- files: files4,
logSources,
logs,
messages: messages2,
publish,
referencingWorkflows,
sandbox,
- skills: skills3,
statistics,
versions,
}
-export const get36 = oc
+export const get31 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -1488,7 +1230,7 @@ export const get36 = oc
.input(z.object({ query: zGetAgentQuery.optional() }))
.output(zGetAgentResponse)
-export const post21 = oc
+export const post18 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -1501,8 +1243,8 @@ export const post21 = oc
.output(zPostAgentResponse)
export const agent = {
- get: get36,
- post: post21,
+ get: get31,
+ post: post18,
inviteOptions,
byAgentId,
}
diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts
index ddef2e3addf..3991a7dfee0 100644
--- a/packages/contracts/generated/api/console/agent/types.gen.ts
+++ b/packages/contracts/generated/api/console/agent/types.gen.ts
@@ -290,45 +290,6 @@ export type AgentDebugConversationRefreshResponse = {
debug_conversation_message_count?: number
}
-export type AgentDriveListResponse = {
- items?: Array
-}
-
-export type AgentDriveDownloadResponse = {
- url: string
-}
-
-export type AgentDrivePreviewResponse = {
- binary: boolean
- key: string
- size?: number | null
- text?: string | null
- truncated: boolean
-}
-
-export type AgentDriveSkillListResponse = {
- items?: Array
-}
-
-export type AgentDriveSkillInspectResponse = {
- archive_key?: string | null
- created_at?: number | null
- description: string
- file_tree?: Array<{
- [key: string]: unknown
- }>
- files?: Array
- hash?: string | null
- mime_type?: string | null
- name: string
- path: string
- size?: number | null
- skill_md: AgentDriveSkillMarkdownResponse
- skill_md_key: string
- source: string
- warnings?: Array
-}
-
export type AgentAppFeaturesPayload = {
opening_statement?: string | null
retriever_resource?: AgentFeatureToggleConfig | null
@@ -345,19 +306,6 @@ export type MessageFeedbackPayload = {
rating?: 'dislike' | 'like' | null
}
-export type AgentDriveDeleteResponse = {
- removed_keys?: Array
- result: string
-}
-
-export type AgentDriveFilePayload = {
- upload_file_id: string
-}
-
-export type AgentDriveFileCommitResponse = {
- file: AgentDriveFileResponse
-}
-
export type AgentLogSourceListResponse = {
data: Array
groups: Array
@@ -451,17 +399,6 @@ export type SandboxReadResponse = {
truncated: boolean
}
-export type AgentSkillUploadResponse = {
- manifest: SkillManifest
- skill: AgentUploadedSkillResponse
-}
-
-export type SkillToolInferenceResult = {
- cli_tools?: Array
- inferable: boolean
- reason?: string | null
-}
-
export type AgentStatisticSummaryEnvelopeResponse = {
charts: AgentStatisticChartsResponse
source: string
@@ -649,7 +586,6 @@ export type AgentSoulConfig = {
config_note?: string
config_skills?: Array
env?: AgentSoulEnvConfig
- files?: AgentSoulFilesConfig
human?: AgentSoulHumanConfig
knowledge?: AgentSoulKnowledgeConfig
memory?: AgentSoulMemoryConfig
@@ -820,45 +756,6 @@ export type AgentConfigSkillMarkdownResponse = {
truncated: boolean
}
-export type AgentDriveItemResponse = {
- created_at?: number | null
- file_kind: string
- hash?: string | null
- is_skill?: boolean | null
- key: string
- mime_type?: string | null
- size?: number | null
- skill_metadata?: string | null
-}
-
-export type AgentDriveSkillItemResponse = {
- archive_key?: string | null
- created_at?: number | null
- description: string
- hash?: string | null
- mime_type?: string | null
- name: string
- path: string
- size?: number | null
- skill_md_key: string
-}
-
-export type AgentDriveSkillFileResponse = {
- available_in_drive: boolean
- drive_key?: string | null
- name: string
- path: string
- type: string
-}
-
-export type AgentDriveSkillMarkdownResponse = {
- binary: boolean
- key: string
- size?: number | null
- text?: string | null
- truncated: boolean
-}
-
export type AgentFeatureToggleConfig = {
enabled?: boolean
[key: string]: unknown
@@ -886,14 +783,6 @@ export type AgentTextToSpeechFeatureConfig = {
[key: string]: unknown
}
-export type AgentDriveFileResponse = {
- drive_key: string
- file_id: string
- mime_type?: string | null
- name: string
- size?: number | null
-}
-
export type AgentLogSourceResponse = {
app_icon?: string | null
app_icon_background?: string | null
@@ -1038,32 +927,6 @@ export type SandboxFileEntryResponse = {
type: 'dir' | 'file' | 'other' | 'symlink'
}
-export type SkillManifest = {
- description: string
- entry_path: string
- files: Array
- hash: string
- name: string
- size: number
-}
-
-export type AgentUploadedSkillResponse = {
- archive_key?: string | null
- description: string
- name: string
- path: string
- skill_md_key: string
-}
-
-export type CliToolSuggestion = {
- command?: string
- description?: string
- env_suggestions?: Array
- inferred_from?: string
- install_commands?: Array
- name: string
-}
-
export type AgentStatisticChartsResponse = {
average_response_time?: Array
average_session_interactions?: Array
@@ -1183,11 +1046,6 @@ export type AgentSoulEnvConfig = {
variables?: Array
}
-export type AgentSoulFilesConfig = {
- files?: Array
- skills?: Array
-}
-
export type AgentSoulHumanConfig = {
contacts?: Array
tools?: Array
@@ -1403,12 +1261,6 @@ export type HumanInputFormSubmissionData = {
export type ExecutionContentType = 'human_input'
-export type EnvSuggestion = {
- key: string
- reason?: string
- secret_likely?: boolean
-}
-
export type AgentAverageResponseTimeStatisticResponse = {
date: string
latency: number
@@ -1518,35 +1370,6 @@ export type AgentEnvVariableConfig = {
[key: string]: unknown
}
-export type AgentFileRefConfig = {
- drive_key?: string | null
- file_id?: string | null
- id?: string | null
- name?: string | null
- reference?: string | null
- remote_url?: string | null
- tenant_id?: string | null
- transfer_method?: string | null
- type?: string | null
- upload_file_id?: string | null
- url?: string | null
- [key: string]: unknown
-}
-
-export type AgentSkillRefConfig = {
- description?: string | null
- file_id?: string | null
- full_archive_file_id?: string | null
- full_archive_key?: string | null
- id?: string | null
- manifest_files?: Array | null
- name?: string | null
- path?: string | null
- skill_md_file_id?: string | null
- skill_md_key?: string | null
- [key: string]: unknown
-}
-
export type AgentHumanToolConfig = {
description?: string | null
enabled?: boolean
@@ -1665,6 +1488,20 @@ export type DeclaredOutputFileConfig = {
mime_types?: Array
}
+export type AgentFileRefConfig = {
+ file_id?: string | null
+ id?: string | null
+ name?: string | null
+ reference?: string | null
+ remote_url?: string | null
+ tenant_id?: string | null
+ transfer_method?: string | null
+ type?: string | null
+ upload_file_id?: string | null
+ url?: string | null
+ [key: string]: unknown
+}
+
export type AgentCliToolAuthorizationStatus =
| 'allowed'
| 'authorized'
@@ -2786,93 +2623,6 @@ export type PostAgentByAgentIdDebugConversationRefreshResponses = {
export type PostAgentByAgentIdDebugConversationRefreshResponse =
PostAgentByAgentIdDebugConversationRefreshResponses[keyof PostAgentByAgentIdDebugConversationRefreshResponses]
-export type GetAgentByAgentIdDriveFilesData = {
- body?: never
- path: {
- agent_id: string
- }
- query?: {
- prefix?: string
- }
- url: '/agent/{agent_id}/drive/files'
-}
-
-export type GetAgentByAgentIdDriveFilesResponses = {
- 200: AgentDriveListResponse
-}
-
-export type GetAgentByAgentIdDriveFilesResponse =
- GetAgentByAgentIdDriveFilesResponses[keyof GetAgentByAgentIdDriveFilesResponses]
-
-export type GetAgentByAgentIdDriveFilesDownloadData = {
- body?: never
- path: {
- agent_id: string
- }
- query: {
- key: string
- }
- url: '/agent/{agent_id}/drive/files/download'
-}
-
-export type GetAgentByAgentIdDriveFilesDownloadResponses = {
- 200: AgentDriveDownloadResponse
-}
-
-export type GetAgentByAgentIdDriveFilesDownloadResponse =
- GetAgentByAgentIdDriveFilesDownloadResponses[keyof GetAgentByAgentIdDriveFilesDownloadResponses]
-
-export type GetAgentByAgentIdDriveFilesPreviewData = {
- body?: never
- path: {
- agent_id: string
- }
- query: {
- key: string
- }
- url: '/agent/{agent_id}/drive/files/preview'
-}
-
-export type GetAgentByAgentIdDriveFilesPreviewResponses = {
- 200: AgentDrivePreviewResponse
-}
-
-export type GetAgentByAgentIdDriveFilesPreviewResponse =
- GetAgentByAgentIdDriveFilesPreviewResponses[keyof GetAgentByAgentIdDriveFilesPreviewResponses]
-
-export type GetAgentByAgentIdDriveSkillsData = {
- body?: never
- path: {
- agent_id: string
- }
- query?: never
- url: '/agent/{agent_id}/drive/skills'
-}
-
-export type GetAgentByAgentIdDriveSkillsResponses = {
- 200: AgentDriveSkillListResponse
-}
-
-export type GetAgentByAgentIdDriveSkillsResponse =
- GetAgentByAgentIdDriveSkillsResponses[keyof GetAgentByAgentIdDriveSkillsResponses]
-
-export type GetAgentByAgentIdDriveSkillsBySkillPathInspectData = {
- body?: never
- path: {
- agent_id: string
- skill_path: string
- }
- query?: never
- url: '/agent/{agent_id}/drive/skills/{skill_path}/inspect'
-}
-
-export type GetAgentByAgentIdDriveSkillsBySkillPathInspectResponses = {
- 200: AgentDriveSkillInspectResponse
-}
-
-export type GetAgentByAgentIdDriveSkillsBySkillPathInspectResponse =
- GetAgentByAgentIdDriveSkillsBySkillPathInspectResponses[keyof GetAgentByAgentIdDriveSkillsBySkillPathInspectResponses]
-
export type PostAgentByAgentIdFeaturesData = {
body: AgentAppFeaturesPayload
path: {
@@ -2914,40 +2664,6 @@ export type PostAgentByAgentIdFeedbacksResponses = {
export type PostAgentByAgentIdFeedbacksResponse =
PostAgentByAgentIdFeedbacksResponses[keyof PostAgentByAgentIdFeedbacksResponses]
-export type DeleteAgentByAgentIdFilesData = {
- body?: never
- path: {
- agent_id: string
- }
- query: {
- key: string
- }
- url: '/agent/{agent_id}/files'
-}
-
-export type DeleteAgentByAgentIdFilesResponses = {
- 200: AgentDriveDeleteResponse
-}
-
-export type DeleteAgentByAgentIdFilesResponse =
- DeleteAgentByAgentIdFilesResponses[keyof DeleteAgentByAgentIdFilesResponses]
-
-export type PostAgentByAgentIdFilesData = {
- body: AgentDriveFilePayload
- path: {
- agent_id: string
- }
- query?: never
- url: '/agent/{agent_id}/files'
-}
-
-export type PostAgentByAgentIdFilesResponses = {
- 201: AgentDriveFileCommitResponse
-}
-
-export type PostAgentByAgentIdFilesResponse =
- PostAgentByAgentIdFilesResponses[keyof PostAgentByAgentIdFilesResponses]
-
export type GetAgentByAgentIdLogSourcesData = {
body?: never
path: {
@@ -3157,62 +2873,6 @@ export type GetAgentByAgentIdSandboxFilesReadResponses = {
export type GetAgentByAgentIdSandboxFilesReadResponse =
GetAgentByAgentIdSandboxFilesReadResponses[keyof GetAgentByAgentIdSandboxFilesReadResponses]
-export type PostAgentByAgentIdSkillsUploadData = {
- body: {
- file: Blob | File
- }
- path: {
- agent_id: string
- }
- query?: never
- url: '/agent/{agent_id}/skills/upload'
-}
-
-export type PostAgentByAgentIdSkillsUploadErrors = {
- 400: unknown
-}
-
-export type PostAgentByAgentIdSkillsUploadResponses = {
- 201: AgentSkillUploadResponse
-}
-
-export type PostAgentByAgentIdSkillsUploadResponse =
- PostAgentByAgentIdSkillsUploadResponses[keyof PostAgentByAgentIdSkillsUploadResponses]
-
-export type DeleteAgentByAgentIdSkillsBySlugData = {
- body?: never
- path: {
- agent_id: string
- slug: string
- }
- query?: never
- url: '/agent/{agent_id}/skills/{slug}'
-}
-
-export type DeleteAgentByAgentIdSkillsBySlugResponses = {
- 200: AgentDriveDeleteResponse
-}
-
-export type DeleteAgentByAgentIdSkillsBySlugResponse =
- DeleteAgentByAgentIdSkillsBySlugResponses[keyof DeleteAgentByAgentIdSkillsBySlugResponses]
-
-export type PostAgentByAgentIdSkillsBySlugInferToolsData = {
- body?: never
- path: {
- agent_id: string
- slug: string
- }
- query?: never
- url: '/agent/{agent_id}/skills/{slug}/infer-tools'
-}
-
-export type PostAgentByAgentIdSkillsBySlugInferToolsResponses = {
- 200: SkillToolInferenceResult
-}
-
-export type PostAgentByAgentIdSkillsBySlugInferToolsResponse =
- PostAgentByAgentIdSkillsBySlugInferToolsResponses[keyof PostAgentByAgentIdSkillsBySlugInferToolsResponses]
-
export type GetAgentByAgentIdStatisticsSummaryData = {
body?: never
path: {
diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts
index d3fc03ef81e..92aeaa3de48 100644
--- a/packages/contracts/generated/api/console/agent/zod.gen.ts
+++ b/packages/contracts/generated/api/console/agent/zod.gen.ts
@@ -144,24 +144,6 @@ export const zAgentDebugConversationRefreshResponse = z.object({
debug_conversation_message_count: z.int().optional().default(0),
})
-/**
- * AgentDriveDownloadResponse
- */
-export const zAgentDriveDownloadResponse = z.object({
- url: z.string(),
-})
-
-/**
- * AgentDrivePreviewResponse
- */
-export const zAgentDrivePreviewResponse = z.object({
- binary: z.boolean(),
- key: z.string(),
- size: z.int().nullish(),
- text: z.string().nullish(),
- truncated: z.boolean(),
-})
-
/**
* MessageFeedbackPayload
*/
@@ -171,21 +153,6 @@ export const zMessageFeedbackPayload = z.object({
rating: z.enum(['dislike', 'like']).nullish(),
})
-/**
- * AgentDriveDeleteResponse
- */
-export const zAgentDriveDeleteResponse = z.object({
- removed_keys: z.array(z.string()).optional(),
- result: z.string(),
-})
-
-/**
- * AgentDriveFilePayload
- */
-export const zAgentDriveFilePayload = z.object({
- upload_file_id: z.string(),
-})
-
/**
* AgentPublishPayload
*/
@@ -652,91 +619,6 @@ export const zAgentConfigSkillInspectResponse = z.object({
warnings: z.array(z.string()).optional(),
})
-/**
- * AgentDriveItemResponse
- */
-export const zAgentDriveItemResponse = z.object({
- created_at: z.int().nullish(),
- file_kind: z.string(),
- hash: z.string().nullish(),
- is_skill: z.boolean().nullish(),
- key: z.string(),
- mime_type: z.string().nullish(),
- size: z.int().nullish(),
- skill_metadata: z.string().nullish(),
-})
-
-/**
- * AgentDriveListResponse
- */
-export const zAgentDriveListResponse = z.object({
- items: z.array(zAgentDriveItemResponse).optional(),
-})
-
-/**
- * AgentDriveSkillItemResponse
- */
-export const zAgentDriveSkillItemResponse = z.object({
- archive_key: z.string().nullish(),
- created_at: z.int().nullish(),
- description: z.string(),
- hash: z.string().nullish(),
- mime_type: z.string().nullish(),
- name: z.string(),
- path: z.string(),
- size: z.int().nullish(),
- skill_md_key: z.string(),
-})
-
-/**
- * AgentDriveSkillListResponse
- */
-export const zAgentDriveSkillListResponse = z.object({
- items: z.array(zAgentDriveSkillItemResponse).optional(),
-})
-
-/**
- * AgentDriveSkillFileResponse
- */
-export const zAgentDriveSkillFileResponse = z.object({
- available_in_drive: z.boolean(),
- drive_key: z.string().nullish(),
- name: z.string(),
- path: z.string(),
- type: z.string(),
-})
-
-/**
- * AgentDriveSkillMarkdownResponse
- */
-export const zAgentDriveSkillMarkdownResponse = z.object({
- binary: z.boolean(),
- key: z.string(),
- size: z.int().nullish(),
- text: z.string().nullish(),
- truncated: z.boolean(),
-})
-
-/**
- * AgentDriveSkillInspectResponse
- */
-export const zAgentDriveSkillInspectResponse = z.object({
- archive_key: z.string().nullish(),
- created_at: z.int().nullish(),
- description: z.string(),
- file_tree: z.array(z.record(z.string(), z.unknown())).optional(),
- files: z.array(zAgentDriveSkillFileResponse).optional(),
- hash: z.string().nullish(),
- mime_type: z.string().nullish(),
- name: z.string(),
- path: z.string(),
- size: z.int().nullish(),
- skill_md: zAgentDriveSkillMarkdownResponse,
- skill_md_key: z.string(),
- source: z.string(),
- warnings: z.array(z.string()).optional(),
-})
-
/**
* AgentFeatureToggleConfig
*/
@@ -754,24 +636,6 @@ export const zAgentTextToSpeechFeatureConfig = z.object({
voice: z.string().nullish(),
})
-/**
- * AgentDriveFileResponse
- */
-export const zAgentDriveFileResponse = z.object({
- drive_key: z.string(),
- file_id: z.string(),
- mime_type: z.string().nullish(),
- name: z.string(),
- size: z.int().nullish(),
-})
-
-/**
- * AgentDriveFileCommitResponse
- */
-export const zAgentDriveFileCommitResponse = z.object({
- file: zAgentDriveFileResponse,
-})
-
/**
* AgentLogSourceResponse
*/
@@ -920,39 +784,6 @@ export const zSandboxListResponse = z.object({
truncated: z.boolean().optional().default(false),
})
-/**
- * SkillManifest
- *
- * Validated metadata extracted from a Skill package.
- */
-export const zSkillManifest = z.object({
- description: z.string(),
- entry_path: z.string(),
- files: z.array(z.string()),
- hash: z.string(),
- name: z.string(),
- size: z.int(),
-})
-
-/**
- * AgentUploadedSkillResponse
- */
-export const zAgentUploadedSkillResponse = z.object({
- archive_key: z.string().nullish(),
- description: z.string(),
- name: z.string(),
- path: z.string(),
- skill_md_key: z.string(),
-})
-
-/**
- * AgentSkillUploadResponse
- */
-export const zAgentSkillUploadResponse = z.object({
- manifest: zSkillManifest,
- skill: zAgentUploadedSkillResponse,
-})
-
/**
* AgentStatisticSummaryResponse
*/
@@ -1423,36 +1254,6 @@ export const zFeedback = z.object({
*/
export const zExecutionContentType = z.enum(['human_input'])
-/**
- * EnvSuggestion
- */
-export const zEnvSuggestion = z.object({
- key: z.string(),
- reason: z.string().optional().default(''),
- secret_likely: z.boolean().optional().default(false),
-})
-
-/**
- * CliToolSuggestion
- */
-export const zCliToolSuggestion = z.object({
- command: z.string().optional().default(''),
- description: z.string().optional().default(''),
- env_suggestions: z.array(zEnvSuggestion).optional(),
- inferred_from: z.string().optional().default(''),
- install_commands: z.array(z.string()).optional(),
- name: z.string(),
-})
-
-/**
- * SkillToolInferenceResult
- */
-export const zSkillToolInferenceResult = z.object({
- cli_tools: z.array(zCliToolSuggestion).optional(),
- inferable: z.boolean(),
- reason: z.string().nullish(),
-})
-
/**
* AgentAverageResponseTimeStatisticResponse
*/
@@ -1611,55 +1412,6 @@ export const zAgentEnvVariableConfig = z.object({
variable: z.string().max(255).nullish(),
})
-/**
- * AgentFileRefConfig
- */
-export const zAgentFileRefConfig = z.object({
- drive_key: z.string().max(512).nullish(),
- file_id: z.string().max(255).nullish(),
- id: z.string().max(255).nullish(),
- name: z.string().max(255).nullish(),
- reference: z.string().max(255).nullish(),
- remote_url: z.string().nullish(),
- tenant_id: z.string().max(255).nullish(),
- transfer_method: z.string().max(64).nullish(),
- type: z.string().max(64).nullish(),
- upload_file_id: z.string().max(255).nullish(),
- url: z.string().nullish(),
-})
-
-/**
- * WorkflowNodeJobMetadata
- */
-export const zWorkflowNodeJobMetadata = z.object({
- agent_soul: z.record(z.string(), z.unknown()).nullish(),
- file_refs: z.array(zAgentFileRefConfig).nullish(),
-})
-
-/**
- * AgentSkillRefConfig
- */
-export const zAgentSkillRefConfig = z.object({
- description: z.string().nullish(),
- file_id: z.string().max(255).nullish(),
- full_archive_file_id: z.string().max(255).nullish(),
- full_archive_key: z.string().max(512).nullish(),
- id: z.string().max(255).nullish(),
- manifest_files: z.array(z.string()).nullish(),
- name: z.string().max(255).nullish(),
- path: z.string().nullish(),
- skill_md_file_id: z.string().max(255).nullish(),
- skill_md_key: z.string().max(512).nullish(),
-})
-
-/**
- * AgentSoulFilesConfig
- */
-export const zAgentSoulFilesConfig = z.object({
- files: z.array(zAgentFileRefConfig).optional(),
- skills: z.array(zAgentSkillRefConfig).optional(),
-})
-
/**
* AgentHumanToolConfig
*/
@@ -1768,6 +1520,30 @@ export const zDeclaredOutputFileConfig = z.object({
mime_types: z.array(z.string()).optional(),
})
+/**
+ * AgentFileRefConfig
+ */
+export const zAgentFileRefConfig = z.object({
+ file_id: z.string().max(255).nullish(),
+ id: z.string().max(255).nullish(),
+ name: z.string().max(255).nullish(),
+ reference: z.string().max(255).nullish(),
+ remote_url: z.string().nullish(),
+ tenant_id: z.string().max(255).nullish(),
+ transfer_method: z.string().max(64).nullish(),
+ type: z.string().max(64).nullish(),
+ upload_file_id: z.string().max(255).nullish(),
+ url: z.string().nullish(),
+})
+
+/**
+ * WorkflowNodeJobMetadata
+ */
+export const zWorkflowNodeJobMetadata = z.object({
+ agent_soul: z.record(z.string(), z.unknown()).nullish(),
+ file_refs: z.array(zAgentFileRefConfig).nullish(),
+})
+
/**
* AgentCliToolAuthorizationStatus
*
@@ -2470,7 +2246,6 @@ export const zAgentSoulConfig = z.object({
config_note: z.string().optional().default(''),
config_skills: z.array(zAgentConfigSkillRefConfig).optional(),
env: zAgentSoulEnvConfig.optional(),
- files: zAgentSoulFilesConfig.optional(),
human: zAgentSoulHumanConfig.optional(),
knowledge: zAgentSoulKnowledgeConfig.optional(),
memory: zAgentSoulMemoryConfig.optional(),
@@ -3297,65 +3072,6 @@ export const zPostAgentByAgentIdDebugConversationRefreshPath = z.object({
export const zPostAgentByAgentIdDebugConversationRefreshResponse =
zAgentDebugConversationRefreshResponse
-export const zGetAgentByAgentIdDriveFilesPath = z.object({
- agent_id: z.uuid(),
-})
-
-export const zGetAgentByAgentIdDriveFilesQuery = z.object({
- prefix: z.string().optional().default(''),
-})
-
-/**
- * Drive entries
- */
-export const zGetAgentByAgentIdDriveFilesResponse = zAgentDriveListResponse
-
-export const zGetAgentByAgentIdDriveFilesDownloadPath = z.object({
- agent_id: z.uuid(),
-})
-
-export const zGetAgentByAgentIdDriveFilesDownloadQuery = z.object({
- key: z.string().min(1),
-})
-
-/**
- * Signed URL
- */
-export const zGetAgentByAgentIdDriveFilesDownloadResponse = zAgentDriveDownloadResponse
-
-export const zGetAgentByAgentIdDriveFilesPreviewPath = z.object({
- agent_id: z.uuid(),
-})
-
-export const zGetAgentByAgentIdDriveFilesPreviewQuery = z.object({
- key: z.string().min(1),
-})
-
-/**
- * Preview
- */
-export const zGetAgentByAgentIdDriveFilesPreviewResponse = zAgentDrivePreviewResponse
-
-export const zGetAgentByAgentIdDriveSkillsPath = z.object({
- agent_id: z.uuid(),
-})
-
-/**
- * Drive skills
- */
-export const zGetAgentByAgentIdDriveSkillsResponse = zAgentDriveSkillListResponse
-
-export const zGetAgentByAgentIdDriveSkillsBySkillPathInspectPath = z.object({
- agent_id: z.uuid(),
- skill_path: z.string(),
-})
-
-/**
- * Drive skill inspect view
- */
-export const zGetAgentByAgentIdDriveSkillsBySkillPathInspectResponse =
- zAgentDriveSkillInspectResponse
-
export const zPostAgentByAgentIdFeaturesBody = zAgentAppFeaturesPayload
export const zPostAgentByAgentIdFeaturesPath = z.object({
@@ -3378,30 +3094,6 @@ export const zPostAgentByAgentIdFeedbacksPath = z.object({
*/
export const zPostAgentByAgentIdFeedbacksResponse = zSimpleResultResponse
-export const zDeleteAgentByAgentIdFilesPath = z.object({
- agent_id: z.uuid(),
-})
-
-export const zDeleteAgentByAgentIdFilesQuery = z.object({
- key: z.string().min(1),
-})
-
-/**
- * File removed
- */
-export const zDeleteAgentByAgentIdFilesResponse = zAgentDriveDeleteResponse
-
-export const zPostAgentByAgentIdFilesBody = zAgentDriveFilePayload
-
-export const zPostAgentByAgentIdFilesPath = z.object({
- agent_id: z.uuid(),
-})
-
-/**
- * File committed into the agent drive
- */
-export const zPostAgentByAgentIdFilesResponse = zAgentDriveFileCommitResponse
-
export const zGetAgentByAgentIdLogSourcesPath = z.object({
agent_id: z.uuid(),
})
@@ -3543,39 +3235,6 @@ export const zGetAgentByAgentIdSandboxFilesReadQuery = z.object({
*/
export const zGetAgentByAgentIdSandboxFilesReadResponse = zSandboxReadResponse
-export const zPostAgentByAgentIdSkillsUploadBody = z.object({
- file: z.custom((value) => value instanceof Blob || value instanceof File),
-})
-
-export const zPostAgentByAgentIdSkillsUploadPath = z.object({
- agent_id: z.uuid(),
-})
-
-/**
- * Skill uploaded into drive
- */
-export const zPostAgentByAgentIdSkillsUploadResponse = zAgentSkillUploadResponse
-
-export const zDeleteAgentByAgentIdSkillsBySlugPath = z.object({
- agent_id: z.uuid(),
- slug: z.string(),
-})
-
-/**
- * Skill removed
- */
-export const zDeleteAgentByAgentIdSkillsBySlugResponse = zAgentDriveDeleteResponse
-
-export const zPostAgentByAgentIdSkillsBySlugInferToolsPath = z.object({
- agent_id: z.uuid(),
- slug: z.string(),
-})
-
-/**
- * Inference result (draft suggestions, nothing persisted)
- */
-export const zPostAgentByAgentIdSkillsBySlugInferToolsResponse = zSkillToolInferenceResult
-
export const zGetAgentByAgentIdStatisticsSummaryPath = z.object({
agent_id: z.uuid(),
})
diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts
index ede274a1f60..4c2fcd0df73 100644
--- a/packages/contracts/generated/api/console/apps/orpc.gen.ts
+++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts
@@ -9,12 +9,6 @@ import {
zDeleteAppsByAppIdAgentConfigSkillsByNamePath,
zDeleteAppsByAppIdAgentConfigSkillsByNameQuery,
zDeleteAppsByAppIdAgentConfigSkillsByNameResponse,
- zDeleteAppsByAppIdAgentFilesPath,
- zDeleteAppsByAppIdAgentFilesQuery,
- zDeleteAppsByAppIdAgentFilesResponse,
- zDeleteAppsByAppIdAgentSkillsBySlugPath,
- zDeleteAppsByAppIdAgentSkillsBySlugQuery,
- zDeleteAppsByAppIdAgentSkillsBySlugResponse,
zDeleteAppsByAppIdAnnotationsByAnnotationIdPath,
zDeleteAppsByAppIdAnnotationsByAnnotationIdResponse,
zDeleteAppsByAppIdAnnotationsPath,
@@ -79,21 +73,6 @@ import {
zGetAppsByAppIdAgentConfigSkillsPath,
zGetAppsByAppIdAgentConfigSkillsQuery,
zGetAppsByAppIdAgentConfigSkillsResponse,
- zGetAppsByAppIdAgentDriveFilesDownloadPath,
- zGetAppsByAppIdAgentDriveFilesDownloadQuery,
- zGetAppsByAppIdAgentDriveFilesDownloadResponse,
- zGetAppsByAppIdAgentDriveFilesPath,
- zGetAppsByAppIdAgentDriveFilesPreviewPath,
- zGetAppsByAppIdAgentDriveFilesPreviewQuery,
- zGetAppsByAppIdAgentDriveFilesPreviewResponse,
- zGetAppsByAppIdAgentDriveFilesQuery,
- zGetAppsByAppIdAgentDriveFilesResponse,
- zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectPath,
- zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectQuery,
- zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse,
- zGetAppsByAppIdAgentDriveSkillsPath,
- zGetAppsByAppIdAgentDriveSkillsQuery,
- zGetAppsByAppIdAgentDriveSkillsResponse,
zGetAppsByAppIdAgentLogsPath,
zGetAppsByAppIdAgentLogsQuery,
zGetAppsByAppIdAgentLogsResponse,
@@ -313,17 +292,6 @@ import {
zPostAppsByAppIdAgentConfigSkillsUploadPath,
zPostAppsByAppIdAgentConfigSkillsUploadQuery,
zPostAppsByAppIdAgentConfigSkillsUploadResponse,
- zPostAppsByAppIdAgentFilesBody,
- zPostAppsByAppIdAgentFilesPath,
- zPostAppsByAppIdAgentFilesQuery,
- zPostAppsByAppIdAgentFilesResponse,
- zPostAppsByAppIdAgentSkillsBySlugInferToolsPath,
- zPostAppsByAppIdAgentSkillsBySlugInferToolsQuery,
- zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse,
- zPostAppsByAppIdAgentSkillsUploadBody,
- zPostAppsByAppIdAgentSkillsUploadPath,
- zPostAppsByAppIdAgentSkillsUploadQuery,
- zPostAppsByAppIdAgentSkillsUploadResponse,
zPostAppsByAppIdAnnotationReplyByActionBody,
zPostAppsByAppIdAnnotationReplyByActionPath,
zPostAppsByAppIdAnnotationReplyByActionResponse,
@@ -1159,195 +1127,12 @@ export const config = {
skills,
}
-/**
- * Time-limited external signed URL for one drive value (no streaming proxy)
- */
-export const get16 = oc
- .route({
- description: 'Time-limited external signed URL for one drive value (no streaming proxy)',
- inputStructure: 'detailed',
- method: 'GET',
- operationId: 'getAppsByAppIdAgentDriveFilesDownload',
- path: '/apps/{app_id}/agent/drive/files/download',
- tags: ['console'],
- })
- .input(
- z.object({
- params: zGetAppsByAppIdAgentDriveFilesDownloadPath,
- query: zGetAppsByAppIdAgentDriveFilesDownloadQuery,
- }),
- )
- .output(zGetAppsByAppIdAgentDriveFilesDownloadResponse)
-
-export const download4 = {
- get: get16,
-}
-
-/**
- * Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)
- */
-export const get17 = oc
- .route({
- description:
- 'Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)',
- inputStructure: 'detailed',
- method: 'GET',
- operationId: 'getAppsByAppIdAgentDriveFilesPreview',
- path: '/apps/{app_id}/agent/drive/files/preview',
- tags: ['console'],
- })
- .input(
- z.object({
- params: zGetAppsByAppIdAgentDriveFilesPreviewPath,
- query: zGetAppsByAppIdAgentDriveFilesPreviewQuery,
- }),
- )
- .output(zGetAppsByAppIdAgentDriveFilesPreviewResponse)
-
-export const preview4 = {
- get: get17,
-}
-
-/**
- * List agent drive entries (read-only inspector; one endpoint for both tabs)
- */
-export const get18 = oc
- .route({
- description: 'List agent drive entries (read-only inspector; one endpoint for both tabs)',
- inputStructure: 'detailed',
- method: 'GET',
- operationId: 'getAppsByAppIdAgentDriveFiles',
- path: '/apps/{app_id}/agent/drive/files',
- tags: ['console'],
- })
- .input(
- z.object({
- params: zGetAppsByAppIdAgentDriveFilesPath,
- query: zGetAppsByAppIdAgentDriveFilesQuery.optional(),
- }),
- )
- .output(zGetAppsByAppIdAgentDriveFilesResponse)
-
-export const files3 = {
- get: get18,
- download: download4,
- preview: preview4,
-}
-
-/**
- * Inspect one drive-backed skill for slash-menu hover/detail UI
- */
-export const get19 = oc
- .route({
- description: 'Inspect one drive-backed skill for slash-menu hover/detail UI',
- inputStructure: 'detailed',
- method: 'GET',
- operationId: 'getAppsByAppIdAgentDriveSkillsBySkillPathInspect',
- path: '/apps/{app_id}/agent/drive/skills/{skill_path}/inspect',
- tags: ['console'],
- })
- .input(
- z.object({
- params: zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectPath,
- query: zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectQuery.optional(),
- }),
- )
- .output(zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse)
-
-export const inspect2 = {
- get: get19,
-}
-
-export const bySkillPath = {
- inspect: inspect2,
-}
-
-/**
- * List drive-backed skills for the bound agent
- */
-export const get20 = oc
- .route({
- description: 'List drive-backed skills for the bound agent',
- inputStructure: 'detailed',
- method: 'GET',
- operationId: 'getAppsByAppIdAgentDriveSkills',
- path: '/apps/{app_id}/agent/drive/skills',
- tags: ['console'],
- })
- .input(
- z.object({
- params: zGetAppsByAppIdAgentDriveSkillsPath,
- query: zGetAppsByAppIdAgentDriveSkillsQuery.optional(),
- }),
- )
- .output(zGetAppsByAppIdAgentDriveSkillsResponse)
-
-export const skills2 = {
- get: get20,
- bySkillPath,
-}
-
-export const drive = {
- files: files3,
- skills: skills2,
-}
-
-/**
- * Delete one drive file by key via drive commit-null semantics
- */
-export const delete3 = oc
- .route({
- description: 'Delete one drive file by key via drive commit-null semantics',
- inputStructure: 'detailed',
- method: 'DELETE',
- operationId: 'deleteAppsByAppIdAgentFiles',
- path: '/apps/{app_id}/agent/files',
- tags: ['console'],
- })
- .input(
- z.object({
- params: zDeleteAppsByAppIdAgentFilesPath,
- query: zDeleteAppsByAppIdAgentFilesQuery,
- }),
- )
- .output(zDeleteAppsByAppIdAgentFilesResponse)
-
-/**
- * ADD FILE: commit one uploaded file into the bound agent's drive
- *
- * Commit an uploaded file into the agent drive under files/ (ENG-625 D3)
- */
-export const post11 = oc
- .route({
- description: 'Commit an uploaded file into the agent drive under files/ (ENG-625 D3)',
- inputStructure: 'detailed',
- method: 'POST',
- operationId: 'postAppsByAppIdAgentFiles',
- path: '/apps/{app_id}/agent/files',
- successStatus: 201,
- summary: "ADD FILE: commit one uploaded file into the bound agent's drive",
- tags: ['console'],
- })
- .input(
- z.object({
- body: zPostAppsByAppIdAgentFilesBody,
- params: zPostAppsByAppIdAgentFilesPath,
- query: zPostAppsByAppIdAgentFilesQuery.optional(),
- }),
- )
- .output(zPostAppsByAppIdAgentFilesResponse)
-
-export const files4 = {
- delete: delete3,
- post: post11,
-}
-
/**
* Get agent logs
*
* Get agent execution logs for an application
*/
-export const get21 = oc
+export const get16 = oc
.route({
description: 'Get agent execution logs for an application',
inputStructure: 'detailed',
@@ -1361,109 +1146,18 @@ export const get21 = oc
.output(zGetAppsByAppIdAgentLogsResponse)
export const logs = {
- get: get21,
-}
-
-/**
- * Upload a Skill, validate it, and commit drive-backed skill files
- *
- * Upload + standardize a Skill into the agent drive
- */
-export const post12 = oc
- .route({
- description: 'Upload + standardize a Skill into the agent drive',
- inputStructure: 'detailed',
- method: 'POST',
- operationId: 'postAppsByAppIdAgentSkillsUpload',
- path: '/apps/{app_id}/agent/skills/upload',
- successStatus: 201,
- summary: 'Upload a Skill, validate it, and commit drive-backed skill files',
- tags: ['console'],
- })
- .input(
- z.object({
- body: zPostAppsByAppIdAgentSkillsUploadBody,
- params: zPostAppsByAppIdAgentSkillsUploadPath,
- query: zPostAppsByAppIdAgentSkillsUploadQuery.optional(),
- }),
- )
- .output(zPostAppsByAppIdAgentSkillsUploadResponse)
-
-export const upload2 = {
- post: post12,
-}
-
-/**
- * Suggest CLI tools/env for a skill
- *
- * Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)
- * Saving still goes through composer validation.
- */
-export const post13 = oc
- .route({
- description:
- "Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)\nSaving still goes through composer validation.",
- inputStructure: 'detailed',
- method: 'POST',
- operationId: 'postAppsByAppIdAgentSkillsBySlugInferTools',
- path: '/apps/{app_id}/agent/skills/{slug}/infer-tools',
- summary: 'Suggest CLI tools/env for a skill',
- tags: ['console'],
- })
- .input(
- z.object({
- params: zPostAppsByAppIdAgentSkillsBySlugInferToolsPath,
- query: zPostAppsByAppIdAgentSkillsBySlugInferToolsQuery.optional(),
- }),
- )
- .output(zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse)
-
-export const inferTools = {
- post: post13,
-}
-
-/**
- * Delete a standardized skill by removing its known drive keys via commit-null
- */
-export const delete4 = oc
- .route({
- description: 'Delete a standardized skill by removing its known drive keys via commit-null',
- inputStructure: 'detailed',
- method: 'DELETE',
- operationId: 'deleteAppsByAppIdAgentSkillsBySlug',
- path: '/apps/{app_id}/agent/skills/{slug}',
- tags: ['console'],
- })
- .input(
- z.object({
- params: zDeleteAppsByAppIdAgentSkillsBySlugPath,
- query: zDeleteAppsByAppIdAgentSkillsBySlugQuery.optional(),
- }),
- )
- .output(zDeleteAppsByAppIdAgentSkillsBySlugResponse)
-
-export const bySlug = {
- delete: delete4,
- inferTools,
-}
-
-export const skills3 = {
- upload: upload2,
- bySlug,
+ get: get16,
}
export const agent = {
config,
- drive,
- files: files4,
logs,
- skills: skills3,
}
/**
* Get status of annotation reply action job
*/
-export const get22 = oc
+export const get17 = oc
.route({
description: 'Get status of annotation reply action job',
inputStructure: 'detailed',
@@ -1476,7 +1170,7 @@ export const get22 = oc
.output(zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse)
export const byJobId = {
- get: get22,
+ get: get17,
}
export const status = {
@@ -1486,7 +1180,7 @@ export const status = {
/**
* Enable or disable annotation reply for an app
*/
-export const post14 = oc
+export const post11 = oc
.route({
description: 'Enable or disable annotation reply for an app',
inputStructure: 'detailed',
@@ -1504,7 +1198,7 @@ export const post14 = oc
.output(zPostAppsByAppIdAnnotationReplyByActionResponse)
export const byAction = {
- post: post14,
+ post: post11,
status,
}
@@ -1515,7 +1209,7 @@ export const annotationReply = {
/**
* Get annotation settings for an app
*/
-export const get23 = oc
+export const get18 = oc
.route({
description: 'Get annotation settings for an app',
inputStructure: 'detailed',
@@ -1528,13 +1222,13 @@ export const get23 = oc
.output(zGetAppsByAppIdAnnotationSettingResponse)
export const annotationSetting = {
- get: get23,
+ get: get18,
}
/**
* Update annotation settings for an app
*/
-export const post15 = oc
+export const post12 = oc
.route({
description: 'Update annotation settings for an app',
inputStructure: 'detailed',
@@ -1552,7 +1246,7 @@ export const post15 = oc
.output(zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse)
export const byAnnotationSettingId = {
- post: post15,
+ post: post12,
}
export const annotationSettings = {
@@ -1562,7 +1256,7 @@ export const annotationSettings = {
/**
* Batch import annotations from CSV file with rate limiting and security checks
*/
-export const post16 = oc
+export const post13 = oc
.route({
description: 'Batch import annotations from CSV file with rate limiting and security checks',
inputStructure: 'detailed',
@@ -1575,13 +1269,13 @@ export const post16 = oc
.output(zPostAppsByAppIdAnnotationsBatchImportResponse)
export const batchImport = {
- post: post16,
+ post: post13,
}
/**
* Get status of batch import job
*/
-export const get24 = oc
+export const get19 = oc
.route({
description: 'Get status of batch import job',
inputStructure: 'detailed',
@@ -1594,7 +1288,7 @@ export const get24 = oc
.output(zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse)
export const byJobId2 = {
- get: get24,
+ get: get19,
}
export const batchImportStatus = {
@@ -1604,7 +1298,7 @@ export const batchImportStatus = {
/**
* Get count of message annotations for the app
*/
-export const get25 = oc
+export const get20 = oc
.route({
description: 'Get count of message annotations for the app',
inputStructure: 'detailed',
@@ -1617,13 +1311,13 @@ export const get25 = oc
.output(zGetAppsByAppIdAnnotationsCountResponse)
export const count2 = {
- get: get25,
+ get: get20,
}
/**
* Export all annotations for an app with CSV injection protection
*/
-export const get26 = oc
+export const get21 = oc
.route({
description: 'Export all annotations for an app with CSV injection protection',
inputStructure: 'detailed',
@@ -1636,13 +1330,13 @@ export const get26 = oc
.output(zGetAppsByAppIdAnnotationsExportResponse)
export const export_ = {
- get: get26,
+ get: get21,
}
/**
* Get hit histories for an annotation
*/
-export const get27 = oc
+export const get22 = oc
.route({
description: 'Get hit histories for an annotation',
inputStructure: 'detailed',
@@ -1660,10 +1354,10 @@ export const get27 = oc
.output(zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse)
export const hitHistories = {
- get: get27,
+ get: get22,
}
-export const delete5 = oc
+export const delete3 = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
@@ -1678,7 +1372,7 @@ export const delete5 = oc
/**
* Update or delete an annotation
*/
-export const post17 = oc
+export const post14 = oc
.route({
description: 'Update or delete an annotation',
inputStructure: 'detailed',
@@ -1696,12 +1390,12 @@ export const post17 = oc
.output(zPostAppsByAppIdAnnotationsByAnnotationIdResponse)
export const byAnnotationId = {
- delete: delete5,
- post: post17,
+ delete: delete3,
+ post: post14,
hitHistories,
}
-export const delete6 = oc
+export const delete4 = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
@@ -1716,7 +1410,7 @@ export const delete6 = oc
/**
* Get annotations for an app with pagination
*/
-export const get28 = oc
+export const get23 = oc
.route({
description: 'Get annotations for an app with pagination',
inputStructure: 'detailed',
@@ -1736,7 +1430,7 @@ export const get28 = oc
/**
* Create a new annotation for an app
*/
-export const post18 = oc
+export const post15 = oc
.route({
description: 'Create a new annotation for an app',
inputStructure: 'detailed',
@@ -1752,9 +1446,9 @@ export const post18 = oc
.output(zPostAppsByAppIdAnnotationsResponse)
export const annotations = {
- delete: delete6,
- get: get28,
- post: post18,
+ delete: delete4,
+ get: get23,
+ post: post15,
batchImport,
batchImportStatus,
count: count2,
@@ -1765,7 +1459,7 @@ export const annotations = {
/**
* Enable or disable app API
*/
-export const post19 = oc
+export const post16 = oc
.route({
description: 'Enable or disable app API',
inputStructure: 'detailed',
@@ -1778,13 +1472,13 @@ export const post19 = oc
.output(zPostAppsByAppIdApiEnableResponse)
export const apiEnable = {
- post: post19,
+ post: post16,
}
/**
* Transcript audio to text for chat messages
*/
-export const post20 = oc
+export const post17 = oc
.route({
description: 'Transcript audio to text for chat messages',
inputStructure: 'detailed',
@@ -1799,13 +1493,13 @@ export const post20 = oc
.output(zPostAppsByAppIdAudioToTextResponse)
export const audioToText = {
- post: post20,
+ post: post17,
}
/**
* Delete a chat conversation
*/
-export const delete7 = oc
+export const delete5 = oc
.route({
description: 'Delete a chat conversation',
inputStructure: 'detailed',
@@ -1821,7 +1515,7 @@ export const delete7 = oc
/**
* Get chat conversation details
*/
-export const get29 = oc
+export const get24 = oc
.route({
description: 'Get chat conversation details',
inputStructure: 'detailed',
@@ -1834,14 +1528,14 @@ export const get29 = oc
.output(zGetAppsByAppIdChatConversationsByConversationIdResponse)
export const byConversationId = {
- delete: delete7,
- get: get29,
+ delete: delete5,
+ get: get24,
}
/**
* Get chat conversations with pagination, filtering and summary
*/
-export const get30 = oc
+export const get25 = oc
.route({
description: 'Get chat conversations with pagination, filtering and summary',
inputStructure: 'detailed',
@@ -1859,14 +1553,14 @@ export const get30 = oc
.output(zGetAppsByAppIdChatConversationsResponse)
export const chatConversations = {
- get: get30,
+ get: get25,
byConversationId,
}
/**
* Get suggested questions for a message
*/
-export const get31 = oc
+export const get26 = oc
.route({
description: 'Get suggested questions for a message',
inputStructure: 'detailed',
@@ -1879,7 +1573,7 @@ export const get31 = oc
.output(zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse)
export const suggestedQuestions = {
- get: get31,
+ get: get26,
}
export const byMessageId = {
@@ -1889,7 +1583,7 @@ export const byMessageId = {
/**
* Stop a running chat message generation
*/
-export const post21 = oc
+export const post18 = oc
.route({
description: 'Stop a running chat message generation',
inputStructure: 'detailed',
@@ -1902,7 +1596,7 @@ export const post21 = oc
.output(zPostAppsByAppIdChatMessagesByTaskIdStopResponse)
export const stop = {
- post: post21,
+ post: post18,
}
export const byTaskId = {
@@ -1912,7 +1606,7 @@ export const byTaskId = {
/**
* Get chat messages for a conversation with pagination
*/
-export const get32 = oc
+export const get27 = oc
.route({
description: 'Get chat messages for a conversation with pagination',
inputStructure: 'detailed',
@@ -1927,7 +1621,7 @@ export const get32 = oc
.output(zGetAppsByAppIdChatMessagesResponse)
export const chatMessages = {
- get: get32,
+ get: get27,
byMessageId,
byTaskId,
}
@@ -1935,7 +1629,7 @@ export const chatMessages = {
/**
* Delete a completion conversation
*/
-export const delete8 = oc
+export const delete6 = oc
.route({
description: 'Delete a completion conversation',
inputStructure: 'detailed',
@@ -1951,7 +1645,7 @@ export const delete8 = oc
/**
* Get completion conversation details with messages
*/
-export const get33 = oc
+export const get28 = oc
.route({
description: 'Get completion conversation details with messages',
inputStructure: 'detailed',
@@ -1964,14 +1658,14 @@ export const get33 = oc
.output(zGetAppsByAppIdCompletionConversationsByConversationIdResponse)
export const byConversationId2 = {
- delete: delete8,
- get: get33,
+ delete: delete6,
+ get: get28,
}
/**
* Get completion conversations with pagination and filtering
*/
-export const get34 = oc
+export const get29 = oc
.route({
description: 'Get completion conversations with pagination and filtering',
inputStructure: 'detailed',
@@ -1989,14 +1683,14 @@ export const get34 = oc
.output(zGetAppsByAppIdCompletionConversationsResponse)
export const completionConversations = {
- get: get34,
+ get: get29,
byConversationId: byConversationId2,
}
/**
* Stop a running completion message generation
*/
-export const post22 = oc
+export const post19 = oc
.route({
description: 'Stop a running completion message generation',
inputStructure: 'detailed',
@@ -2009,7 +1703,7 @@ export const post22 = oc
.output(zPostAppsByAppIdCompletionMessagesByTaskIdStopResponse)
export const stop2 = {
- post: post22,
+ post: post19,
}
export const byTaskId2 = {
@@ -2019,7 +1713,7 @@ export const byTaskId2 = {
/**
* Generate completion message for debugging
*/
-export const post23 = oc
+export const post20 = oc
.route({
description: 'Generate completion message for debugging',
inputStructure: 'detailed',
@@ -2037,14 +1731,14 @@ export const post23 = oc
.output(zPostAppsByAppIdCompletionMessagesResponse)
export const completionMessages = {
- post: post23,
+ post: post20,
byTaskId: byTaskId2,
}
/**
* Get conversation variables for an application
*/
-export const get35 = oc
+export const get30 = oc
.route({
description: 'Get conversation variables for an application',
inputStructure: 'detailed',
@@ -2062,7 +1756,7 @@ export const get35 = oc
.output(zGetAppsByAppIdConversationVariablesResponse)
export const conversationVariables = {
- get: get35,
+ get: get30,
}
/**
@@ -2072,7 +1766,7 @@ export const conversationVariables = {
* Convert expert mode of chatbot app to workflow mode
* Convert Completion App to Workflow App
*/
-export const post24 = oc
+export const post21 = oc
.route({
description:
'Convert application to workflow mode\nConvert expert mode of chatbot app to workflow mode\nConvert Completion App to Workflow App',
@@ -2092,7 +1786,7 @@ export const post24 = oc
.output(zPostAppsByAppIdConvertToWorkflowResponse)
export const convertToWorkflow = {
- post: post24,
+ post: post21,
}
/**
@@ -2100,7 +1794,7 @@ export const convertToWorkflow = {
*
* Create a copy of an existing application
*/
-export const post25 = oc
+export const post22 = oc
.route({
description: 'Create a copy of an existing application',
inputStructure: 'detailed',
@@ -2115,7 +1809,7 @@ export const post25 = oc
.output(zPostAppsByAppIdCopyResponse)
export const copy = {
- post: post25,
+ post: post22,
}
/**
@@ -2123,7 +1817,7 @@ export const copy = {
*
* Export application configuration as DSL
*/
-export const get36 = oc
+export const get31 = oc
.route({
description: 'Export application configuration as DSL',
inputStructure: 'detailed',
@@ -2139,13 +1833,13 @@ export const get36 = oc
.output(zGetAppsByAppIdExportResponse)
export const export2 = {
- get: get36,
+ get: get31,
}
/**
* Export user feedback data for Google Sheets
*/
-export const get37 = oc
+export const get32 = oc
.route({
description: 'Export user feedback data for Google Sheets',
inputStructure: 'detailed',
@@ -2163,13 +1857,13 @@ export const get37 = oc
.output(zGetAppsByAppIdFeedbacksExportResponse)
export const export3 = {
- get: get37,
+ get: get32,
}
/**
* Create or update message feedback (like/dislike)
*/
-export const post26 = oc
+export const post23 = oc
.route({
description: 'Create or update message feedback (like/dislike)',
inputStructure: 'detailed',
@@ -2182,14 +1876,14 @@ export const post26 = oc
.output(zPostAppsByAppIdFeedbacksResponse)
export const feedbacks = {
- post: post26,
+ post: post23,
export: export3,
}
/**
* Update application icon
*/
-export const post27 = oc
+export const post24 = oc
.route({
description: 'Update application icon',
inputStructure: 'detailed',
@@ -2202,13 +1896,13 @@ export const post27 = oc
.output(zPostAppsByAppIdIconResponse)
export const icon = {
- post: post27,
+ post: post24,
}
/**
* Get message details by ID
*/
-export const get38 = oc
+export const get33 = oc
.route({
description: 'Get message details by ID',
inputStructure: 'detailed',
@@ -2221,7 +1915,7 @@ export const get38 = oc
.output(zGetAppsByAppIdMessagesByMessageIdResponse)
export const byMessageId2 = {
- get: get38,
+ get: get33,
}
export const messages = {
@@ -2233,7 +1927,7 @@ export const messages = {
*
* Update application model configuration
*/
-export const post28 = oc
+export const post25 = oc
.route({
description: 'Update application model configuration',
inputStructure: 'detailed',
@@ -2249,13 +1943,13 @@ export const post28 = oc
.output(zPostAppsByAppIdModelConfigResponse)
export const modelConfig = {
- post: post28,
+ post: post25,
}
/**
* Check if app name is available
*/
-export const post29 = oc
+export const post26 = oc
.route({
description: 'Check if app name is available',
inputStructure: 'detailed',
@@ -2268,13 +1962,13 @@ export const post29 = oc
.output(zPostAppsByAppIdNameResponse)
export const name = {
- post: post29,
+ post: post26,
}
/**
* Publish app to Creators Platform
*/
-export const post30 = oc
+export const post27 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -2287,13 +1981,13 @@ export const post30 = oc
.output(zPostAppsByAppIdPublishToCreatorsPlatformResponse)
export const publishToCreatorsPlatform = {
- post: post30,
+ post: post27,
}
/**
* Refresh MCP server configuration and regenerate server code
*/
-export const post31 = oc
+export const post28 = oc
.route({
description: 'Refresh MCP server configuration and regenerate server code',
inputStructure: 'detailed',
@@ -2306,13 +2000,13 @@ export const post31 = oc
.output(zPostAppsByAppIdServerRefreshResponse)
export const refresh = {
- post: post31,
+ post: post28,
}
/**
* Get MCP server configuration for an application
*/
-export const get39 = oc
+export const get34 = oc
.route({
description: 'Get MCP server configuration for an application',
inputStructure: 'detailed',
@@ -2327,7 +2021,7 @@ export const get39 = oc
/**
* Create MCP server configuration for an application
*/
-export const post32 = oc
+export const post29 = oc
.route({
description: 'Create MCP server configuration for an application',
inputStructure: 'detailed',
@@ -2356,8 +2050,8 @@ export const put = oc
.output(zPutAppsByAppIdServerResponse)
export const server = {
- get: get39,
- post: post32,
+ get: get34,
+ post: post29,
put,
refresh,
}
@@ -2365,7 +2059,7 @@ export const server = {
/**
* Reset access token for application site
*/
-export const post33 = oc
+export const post30 = oc
.route({
description: 'Reset access token for application site',
inputStructure: 'detailed',
@@ -2378,13 +2072,13 @@ export const post33 = oc
.output(zPostAppsByAppIdSiteAccessTokenResetResponse)
export const accessTokenReset = {
- post: post33,
+ post: post30,
}
/**
* Update application site configuration
*/
-export const post34 = oc
+export const post31 = oc
.route({
description: 'Update application site configuration',
inputStructure: 'detailed',
@@ -2397,14 +2091,14 @@ export const post34 = oc
.output(zPostAppsByAppIdSiteResponse)
export const site = {
- post: post34,
+ post: post31,
accessTokenReset,
}
/**
* Enable or disable app site
*/
-export const post35 = oc
+export const post32 = oc
.route({
description: 'Enable or disable app site',
inputStructure: 'detailed',
@@ -2417,13 +2111,13 @@ export const post35 = oc
.output(zPostAppsByAppIdSiteEnableResponse)
export const siteEnable = {
- post: post35,
+ post: post32,
}
/**
* Remove the current account's star from an application
*/
-export const delete9 = oc
+export const delete7 = oc
.route({
description: "Remove the current account's star from an application",
inputStructure: 'detailed',
@@ -2438,7 +2132,7 @@ export const delete9 = oc
/**
* Star an application for the current account
*/
-export const post36 = oc
+export const post33 = oc
.route({
description: 'Star an application for the current account',
inputStructure: 'detailed',
@@ -2451,14 +2145,14 @@ export const post36 = oc
.output(zPostAppsByAppIdStarResponse)
export const star = {
- delete: delete9,
- post: post36,
+ delete: delete7,
+ post: post33,
}
/**
* Get average response time statistics for an application
*/
-export const get40 = oc
+export const get35 = oc
.route({
description: 'Get average response time statistics for an application',
inputStructure: 'detailed',
@@ -2476,13 +2170,13 @@ export const get40 = oc
.output(zGetAppsByAppIdStatisticsAverageResponseTimeResponse)
export const averageResponseTime = {
- get: get40,
+ get: get35,
}
/**
* Get average session interaction statistics for an application
*/
-export const get41 = oc
+export const get36 = oc
.route({
description: 'Get average session interaction statistics for an application',
inputStructure: 'detailed',
@@ -2500,13 +2194,13 @@ export const get41 = oc
.output(zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse)
export const averageSessionInteractions = {
- get: get41,
+ get: get36,
}
/**
* Get daily conversation statistics for an application
*/
-export const get42 = oc
+export const get37 = oc
.route({
description: 'Get daily conversation statistics for an application',
inputStructure: 'detailed',
@@ -2524,13 +2218,13 @@ export const get42 = oc
.output(zGetAppsByAppIdStatisticsDailyConversationsResponse)
export const dailyConversations = {
- get: get42,
+ get: get37,
}
/**
* Get daily terminal/end-user statistics for an application
*/
-export const get43 = oc
+export const get38 = oc
.route({
description: 'Get daily terminal/end-user statistics for an application',
inputStructure: 'detailed',
@@ -2548,13 +2242,13 @@ export const get43 = oc
.output(zGetAppsByAppIdStatisticsDailyEndUsersResponse)
export const dailyEndUsers = {
- get: get43,
+ get: get38,
}
/**
* Get daily message statistics for an application
*/
-export const get44 = oc
+export const get39 = oc
.route({
description: 'Get daily message statistics for an application',
inputStructure: 'detailed',
@@ -2572,13 +2266,13 @@ export const get44 = oc
.output(zGetAppsByAppIdStatisticsDailyMessagesResponse)
export const dailyMessages = {
- get: get44,
+ get: get39,
}
/**
* Get daily token cost statistics for an application
*/
-export const get45 = oc
+export const get40 = oc
.route({
description: 'Get daily token cost statistics for an application',
inputStructure: 'detailed',
@@ -2596,13 +2290,13 @@ export const get45 = oc
.output(zGetAppsByAppIdStatisticsTokenCostsResponse)
export const tokenCosts = {
- get: get45,
+ get: get40,
}
/**
* Get tokens per second statistics for an application
*/
-export const get46 = oc
+export const get41 = oc
.route({
description: 'Get tokens per second statistics for an application',
inputStructure: 'detailed',
@@ -2620,13 +2314,13 @@ export const get46 = oc
.output(zGetAppsByAppIdStatisticsTokensPerSecondResponse)
export const tokensPerSecond = {
- get: get46,
+ get: get41,
}
/**
* Get user satisfaction rate statistics for an application
*/
-export const get47 = oc
+export const get42 = oc
.route({
description: 'Get user satisfaction rate statistics for an application',
inputStructure: 'detailed',
@@ -2644,7 +2338,7 @@ export const get47 = oc
.output(zGetAppsByAppIdStatisticsUserSatisfactionRateResponse)
export const userSatisfactionRate = {
- get: get47,
+ get: get42,
}
export const statistics = {
@@ -2661,7 +2355,7 @@ export const statistics = {
/**
* Get available TTS voices for a specific language
*/
-export const get48 = oc
+export const get43 = oc
.route({
description: 'Get available TTS voices for a specific language',
inputStructure: 'detailed',
@@ -2679,13 +2373,13 @@ export const get48 = oc
.output(zGetAppsByAppIdTextToAudioVoicesResponse)
export const voices = {
- get: get48,
+ get: get43,
}
/**
* Convert text to speech for chat messages
*/
-export const post37 = oc
+export const post34 = oc
.route({
description: 'Convert text to speech for chat messages',
inputStructure: 'detailed',
@@ -2700,7 +2394,7 @@ export const post37 = oc
.output(zPostAppsByAppIdTextToAudioResponse)
export const textToAudio = {
- post: post37,
+ post: post34,
voices,
}
@@ -2709,7 +2403,7 @@ export const textToAudio = {
*
* Get app tracing configuration
*/
-export const get49 = oc
+export const get44 = oc
.route({
description: 'Get app tracing configuration',
inputStructure: 'detailed',
@@ -2725,7 +2419,7 @@ export const get49 = oc
/**
* Update app tracing configuration
*/
-export const post38 = oc
+export const post35 = oc
.route({
description: 'Update app tracing configuration',
inputStructure: 'detailed',
@@ -2738,8 +2432,8 @@ export const post38 = oc
.output(zPostAppsByAppIdTraceResponse)
export const trace = {
- get: get49,
- post: post38,
+ get: get44,
+ post: post35,
}
/**
@@ -2747,7 +2441,7 @@ export const trace = {
*
* Delete an existing tracing configuration for an application
*/
-export const delete10 = oc
+export const delete8 = oc
.route({
description: 'Delete an existing tracing configuration for an application',
inputStructure: 'detailed',
@@ -2769,7 +2463,7 @@ export const delete10 = oc
/**
* Get tracing configuration for an application
*/
-export const get50 = oc
+export const get45 = oc
.route({
description: 'Get tracing configuration for an application',
inputStructure: 'detailed',
@@ -2808,7 +2502,7 @@ export const patch = oc
*
* Create a new tracing configuration for an application
*/
-export const post39 = oc
+export const post36 = oc
.route({
description: 'Create a new tracing configuration for an application',
inputStructure: 'detailed',
@@ -2825,16 +2519,16 @@ export const post39 = oc
.output(zPostAppsByAppIdTraceConfigResponse)
export const traceConfig = {
- delete: delete10,
- get: get50,
+ delete: delete8,
+ get: get45,
patch,
- post: post39,
+ post: post36,
}
/**
* Update app trigger (enable/disable)
*/
-export const post40 = oc
+export const post37 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -2852,13 +2546,13 @@ export const post40 = oc
.output(zPostAppsByAppIdTriggerEnableResponse)
export const triggerEnable = {
- post: post40,
+ post: post37,
}
/**
* Get app triggers list
*/
-export const get51 = oc
+export const get46 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -2871,7 +2565,7 @@ export const get51 = oc
.output(zGetAppsByAppIdTriggersResponse)
export const triggers = {
- get: get51,
+ get: get46,
}
/**
@@ -2879,7 +2573,7 @@ export const triggers = {
*
* Get workflow application execution logs
*/
-export const get52 = oc
+export const get47 = oc
.route({
description: 'Get workflow application execution logs',
inputStructure: 'detailed',
@@ -2898,7 +2592,7 @@ export const get52 = oc
.output(zGetAppsByAppIdWorkflowAppLogsResponse)
export const workflowAppLogs = {
- get: get52,
+ get: get47,
}
/**
@@ -2906,7 +2600,7 @@ export const workflowAppLogs = {
*
* Get workflow archived execution logs
*/
-export const get53 = oc
+export const get48 = oc
.route({
description: 'Get workflow archived execution logs',
inputStructure: 'detailed',
@@ -2925,7 +2619,7 @@ export const get53 = oc
.output(zGetAppsByAppIdWorkflowArchivedLogsResponse)
export const workflowArchivedLogs = {
- get: get53,
+ get: get48,
}
/**
@@ -2933,7 +2627,7 @@ export const workflowArchivedLogs = {
*
* Get workflow runs count statistics
*/
-export const get54 = oc
+export const get49 = oc
.route({
description: 'Get workflow runs count statistics',
inputStructure: 'detailed',
@@ -2952,7 +2646,7 @@ export const get54 = oc
.output(zGetAppsByAppIdWorkflowRunsCountResponse)
export const count3 = {
- get: get54,
+ get: get49,
}
/**
@@ -2960,7 +2654,7 @@ export const count3 = {
*
* Stop running workflow task
*/
-export const post41 = oc
+export const post38 = oc
.route({
description: 'Stop running workflow task',
inputStructure: 'detailed',
@@ -2974,7 +2668,7 @@ export const post41 = oc
.output(zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse)
export const stop3 = {
- post: post41,
+ post: post38,
}
export const byTaskId3 = {
@@ -2988,7 +2682,7 @@ export const tasks = {
/**
* Generate a download URL for an archived workflow run.
*/
-export const get55 = oc
+export const get50 = oc
.route({
description: 'Generate a download URL for an archived workflow run.',
inputStructure: 'detailed',
@@ -3001,7 +2695,7 @@ export const get55 = oc
.output(zGetAppsByAppIdWorkflowRunsByRunIdExportResponse)
export const export4 = {
- get: get55,
+ get: get50,
}
/**
@@ -3009,7 +2703,7 @@ export const export4 = {
*
* Get workflow run node execution list
*/
-export const get56 = oc
+export const get51 = oc
.route({
description: 'Get workflow run node execution list',
inputStructure: 'detailed',
@@ -3023,7 +2717,7 @@ export const get56 = oc
.output(zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse)
export const nodeExecutions = {
- get: get56,
+ get: get51,
}
/**
@@ -3031,7 +2725,7 @@ export const nodeExecutions = {
*
* Get workflow run detail
*/
-export const get57 = oc
+export const get52 = oc
.route({
description: 'Get workflow run detail',
inputStructure: 'detailed',
@@ -3045,7 +2739,7 @@ export const get57 = oc
.output(zGetAppsByAppIdWorkflowRunsByRunIdResponse)
export const byRunId = {
- get: get57,
+ get: get52,
export: export4,
nodeExecutions,
}
@@ -3053,7 +2747,7 @@ export const byRunId = {
/**
* Create a ToolFile from one workflow Agent Binding file and return its download URL
*/
-export const post42 = oc
+export const post39 = oc
.route({
description:
'Create a ToolFile from one workflow Agent Binding file and return its download URL',
@@ -3071,14 +2765,14 @@ export const post42 = oc
)
.output(zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesDownloadResponse)
-export const download5 = {
- post: post42,
+export const download4 = {
+ post: post39,
}
/**
* Read a text/binary preview file in a workflow Agent node sandbox
*/
-export const get58 = oc
+export const get53 = oc
.route({
description: 'Read a text/binary preview file in a workflow Agent node sandbox',
inputStructure: 'detailed',
@@ -3096,13 +2790,13 @@ export const get58 = oc
.output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse)
export const read = {
- get: get58,
+ get: get53,
}
/**
* List a directory in a workflow Agent node sandbox
*/
-export const get59 = oc
+export const get54 = oc
.route({
description: 'List a directory in a workflow Agent node sandbox',
inputStructure: 'detailed',
@@ -3119,14 +2813,14 @@ export const get59 = oc
)
.output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse)
-export const files5 = {
- get: get59,
- download: download5,
+export const files3 = {
+ get: get54,
+ download: download4,
read,
}
export const sandbox = {
- files: files5,
+ files: files3,
}
export const byNodeId4 = {
@@ -3146,7 +2840,7 @@ export const byWorkflowRunId = {
*
* Get workflow run list
*/
-export const get60 = oc
+export const get55 = oc
.route({
description: 'Get workflow run list',
inputStructure: 'detailed',
@@ -3165,7 +2859,7 @@ export const get60 = oc
.output(zGetAppsByAppIdWorkflowRunsResponse)
export const workflowRuns2 = {
- get: get60,
+ get: get55,
count: count3,
tasks,
byRunId,
@@ -3177,7 +2871,7 @@ export const workflowRuns2 = {
*
* Get all users in current tenant for mentions
*/
-export const get61 = oc
+export const get56 = oc
.route({
description: 'Get all users in current tenant for mentions',
inputStructure: 'detailed',
@@ -3191,7 +2885,7 @@ export const get61 = oc
.output(zGetAppsByAppIdWorkflowCommentsMentionUsersResponse)
export const mentionUsers = {
- get: get61,
+ get: get56,
}
/**
@@ -3199,7 +2893,7 @@ export const mentionUsers = {
*
* Delete a comment reply
*/
-export const delete11 = oc
+export const delete9 = oc
.route({
description: 'Delete a comment reply',
inputStructure: 'detailed',
@@ -3237,7 +2931,7 @@ export const put2 = oc
.output(zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse)
export const byReplyId = {
- delete: delete11,
+ delete: delete9,
put: put2,
}
@@ -3246,7 +2940,7 @@ export const byReplyId = {
*
* Add a reply to a workflow comment
*/
-export const post43 = oc
+export const post40 = oc
.route({
description: 'Add a reply to a workflow comment',
inputStructure: 'detailed',
@@ -3266,7 +2960,7 @@ export const post43 = oc
.output(zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse)
export const replies = {
- post: post43,
+ post: post40,
byReplyId,
}
@@ -3275,7 +2969,7 @@ export const replies = {
*
* Resolve a workflow comment
*/
-export const post44 = oc
+export const post41 = oc
.route({
description: 'Resolve a workflow comment',
inputStructure: 'detailed',
@@ -3289,7 +2983,7 @@ export const post44 = oc
.output(zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse)
export const resolve = {
- post: post44,
+ post: post41,
}
/**
@@ -3297,7 +2991,7 @@ export const resolve = {
*
* Delete a workflow comment
*/
-export const delete12 = oc
+export const delete10 = oc
.route({
description: 'Delete a workflow comment',
inputStructure: 'detailed',
@@ -3316,7 +3010,7 @@ export const delete12 = oc
*
* Get a specific workflow comment
*/
-export const get62 = oc
+export const get57 = oc
.route({
description: 'Get a specific workflow comment',
inputStructure: 'detailed',
@@ -3353,8 +3047,8 @@ export const put3 = oc
.output(zPutAppsByAppIdWorkflowCommentsByCommentIdResponse)
export const byCommentId = {
- delete: delete12,
- get: get62,
+ delete: delete10,
+ get: get57,
put: put3,
replies,
resolve,
@@ -3365,7 +3059,7 @@ export const byCommentId = {
*
* Get all comments for a workflow
*/
-export const get63 = oc
+export const get58 = oc
.route({
description: 'Get all comments for a workflow',
inputStructure: 'detailed',
@@ -3383,7 +3077,7 @@ export const get63 = oc
*
* Create a new workflow comment
*/
-export const post45 = oc
+export const post42 = oc
.route({
description: 'Create a new workflow comment',
inputStructure: 'detailed',
@@ -3403,8 +3097,8 @@ export const post45 = oc
.output(zPostAppsByAppIdWorkflowCommentsResponse)
export const comments = {
- get: get63,
- post: post45,
+ get: get58,
+ post: post42,
mentionUsers,
byCommentId,
}
@@ -3412,7 +3106,7 @@ export const comments = {
/**
* Get workflow average app interaction statistics
*/
-export const get64 = oc
+export const get59 = oc
.route({
description: 'Get workflow average app interaction statistics',
inputStructure: 'detailed',
@@ -3430,13 +3124,13 @@ export const get64 = oc
.output(zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse)
export const averageAppInteractions = {
- get: get64,
+ get: get59,
}
/**
* Get workflow daily runs statistics
*/
-export const get65 = oc
+export const get60 = oc
.route({
description: 'Get workflow daily runs statistics',
inputStructure: 'detailed',
@@ -3454,13 +3148,13 @@ export const get65 = oc
.output(zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse)
export const dailyConversations2 = {
- get: get65,
+ get: get60,
}
/**
* Get workflow daily terminals statistics
*/
-export const get66 = oc
+export const get61 = oc
.route({
description: 'Get workflow daily terminals statistics',
inputStructure: 'detailed',
@@ -3478,13 +3172,13 @@ export const get66 = oc
.output(zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse)
export const dailyTerminals = {
- get: get66,
+ get: get61,
}
/**
* Get workflow daily token cost statistics
*/
-export const get67 = oc
+export const get62 = oc
.route({
description: 'Get workflow daily token cost statistics',
inputStructure: 'detailed',
@@ -3502,7 +3196,7 @@ export const get67 = oc
.output(zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse)
export const tokenCosts2 = {
- get: get67,
+ get: get62,
}
export const statistics2 = {
@@ -3522,7 +3216,7 @@ export const workflow = {
*
* Get default block configuration by type
*/
-export const get68 = oc
+export const get63 = oc
.route({
description: 'Get default block configuration by type',
inputStructure: 'detailed',
@@ -3541,7 +3235,7 @@ export const get68 = oc
.output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse)
export const byBlockType = {
- get: get68,
+ get: get63,
}
/**
@@ -3549,7 +3243,7 @@ export const byBlockType = {
*
* Get default block configurations for workflow
*/
-export const get69 = oc
+export const get64 = oc
.route({
description: 'Get default block configurations for workflow',
inputStructure: 'detailed',
@@ -3563,14 +3257,14 @@ export const get69 = oc
.output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse)
export const defaultWorkflowBlockConfigs = {
- get: get69,
+ get: get64,
byBlockType,
}
/**
* Get conversation variables for workflow
*/
-export const get70 = oc
+export const get65 = oc
.route({
description: 'Get conversation variables for workflow',
inputStructure: 'detailed',
@@ -3585,7 +3279,7 @@ export const get70 = oc
/**
* Update conversation variables for workflow draft
*/
-export const post46 = oc
+export const post43 = oc
.route({
description: 'Update conversation variables for workflow draft',
inputStructure: 'detailed',
@@ -3603,8 +3297,8 @@ export const post46 = oc
.output(zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse)
export const conversationVariables2 = {
- get: get70,
- post: post46,
+ get: get65,
+ post: post43,
}
/**
@@ -3612,7 +3306,7 @@ export const conversationVariables2 = {
*
* Get environment variables for workflow
*/
-export const get71 = oc
+export const get66 = oc
.route({
description: 'Get environment variables for workflow',
inputStructure: 'detailed',
@@ -3628,7 +3322,7 @@ export const get71 = oc
/**
* Update environment variables for workflow draft
*/
-export const post47 = oc
+export const post44 = oc
.route({
description: 'Update environment variables for workflow draft',
inputStructure: 'detailed',
@@ -3646,14 +3340,14 @@ export const post47 = oc
.output(zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse)
export const environmentVariables = {
- get: get71,
- post: post47,
+ get: get66,
+ post: post44,
}
/**
* Update draft workflow features
*/
-export const post48 = oc
+export const post45 = oc
.route({
description: 'Update draft workflow features',
inputStructure: 'detailed',
@@ -3671,7 +3365,7 @@ export const post48 = oc
.output(zPostAppsByAppIdWorkflowsDraftFeaturesResponse)
export const features = {
- post: post48,
+ post: post45,
}
/**
@@ -3679,7 +3373,7 @@ export const features = {
*
* Test human input delivery for workflow
*/
-export const post49 = oc
+export const post46 = oc
.route({
description: 'Test human input delivery for workflow',
inputStructure: 'detailed',
@@ -3698,7 +3392,7 @@ export const post49 = oc
.output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse)
export const deliveryTest = {
- post: post49,
+ post: post46,
}
/**
@@ -3706,7 +3400,7 @@ export const deliveryTest = {
*
* Get human input form preview for workflow
*/
-export const post50 = oc
+export const post47 = oc
.route({
description: 'Get human input form preview for workflow',
inputStructure: 'detailed',
@@ -3724,8 +3418,8 @@ export const post50 = oc
)
.output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse)
-export const preview5 = {
- post: post50,
+export const preview4 = {
+ post: post47,
}
/**
@@ -3733,7 +3427,7 @@ export const preview5 = {
*
* Submit human input form preview for workflow
*/
-export const post51 = oc
+export const post48 = oc
.route({
description: 'Submit human input form preview for workflow',
inputStructure: 'detailed',
@@ -3752,11 +3446,11 @@ export const post51 = oc
.output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse)
export const run5 = {
- post: post51,
+ post: post48,
}
export const form2 = {
- preview: preview5,
+ preview: preview4,
run: run5,
}
@@ -3778,7 +3472,7 @@ export const humanInput2 = {
*
* Run draft workflow iteration node
*/
-export const post52 = oc
+export const post49 = oc
.route({
description: 'Run draft workflow iteration node',
inputStructure: 'detailed',
@@ -3797,7 +3491,7 @@ export const post52 = oc
.output(zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse)
export const run6 = {
- post: post52,
+ post: post49,
}
export const byNodeId6 = {
@@ -3817,7 +3511,7 @@ export const iteration2 = {
*
* Run draft workflow loop node
*/
-export const post53 = oc
+export const post50 = oc
.route({
description: 'Run draft workflow loop node',
inputStructure: 'detailed',
@@ -3836,7 +3530,7 @@ export const post53 = oc
.output(zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse)
export const run7 = {
- post: post53,
+ post: post50,
}
export const byNodeId7 = {
@@ -3851,7 +3545,7 @@ export const loop2 = {
nodes: nodes6,
}
-export const get72 = oc
+export const get67 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3865,10 +3559,10 @@ export const get72 = oc
.output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse)
export const candidates = {
- get: get72,
+ get: get67,
}
-export const post54 = oc
+export const post51 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -3885,10 +3579,10 @@ export const post54 = oc
.output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse)
export const copyFromRoster = {
- post: post54,
+ post: post51,
}
-export const post55 = oc
+export const post52 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -3905,10 +3599,10 @@ export const post55 = oc
.output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse)
export const impact = {
- post: post55,
+ post: post52,
}
-export const post56 = oc
+export const post53 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -3925,10 +3619,10 @@ export const post56 = oc
.output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse)
export const saveToRoster = {
- post: post56,
+ post: post53,
}
-export const post57 = oc
+export const post54 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -3945,10 +3639,10 @@ export const post57 = oc
.output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse)
export const validate = {
- post: post57,
+ post: post54,
}
-export const get73 = oc
+export const get68 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3981,7 +3675,7 @@ export const put4 = oc
.output(zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse)
export const agentComposer = {
- get: get73,
+ get: get68,
put: put4,
candidates,
copyFromRoster,
@@ -3993,7 +3687,7 @@ export const agentComposer = {
/**
* Get last run result for draft workflow node
*/
-export const get74 = oc
+export const get69 = oc
.route({
description: 'Get last run result for draft workflow node',
inputStructure: 'detailed',
@@ -4006,7 +3700,7 @@ export const get74 = oc
.output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse)
export const lastRun = {
- get: get74,
+ get: get69,
}
/**
@@ -4014,7 +3708,7 @@ export const lastRun = {
*
* Run draft workflow node
*/
-export const post58 = oc
+export const post55 = oc
.route({
description: 'Run draft workflow node',
inputStructure: 'detailed',
@@ -4033,7 +3727,7 @@ export const post58 = oc
.output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse)
export const run8 = {
- post: post58,
+ post: post55,
}
/**
@@ -4041,7 +3735,7 @@ export const run8 = {
*
* Poll for trigger events and execute single node when event arrives
*/
-export const post59 = oc
+export const post56 = oc
.route({
description: 'Poll for trigger events and execute single node when event arrives',
inputStructure: 'detailed',
@@ -4055,7 +3749,7 @@ export const post59 = oc
.output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse)
export const run9 = {
- post: post59,
+ post: post56,
}
export const trigger = {
@@ -4065,7 +3759,7 @@ export const trigger = {
/**
* Delete all variables for a specific node
*/
-export const delete13 = oc
+export const delete11 = oc
.route({
description: 'Delete all variables for a specific node',
inputStructure: 'detailed',
@@ -4081,7 +3775,7 @@ export const delete13 = oc
/**
* Get variables for a specific node
*/
-export const get75 = oc
+export const get70 = oc
.route({
description: 'Get variables for a specific node',
inputStructure: 'detailed',
@@ -4094,8 +3788,8 @@ export const get75 = oc
.output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse)
export const variables = {
- delete: delete13,
- get: get75,
+ delete: delete11,
+ get: get70,
}
export const byNodeId8 = {
@@ -4115,7 +3809,7 @@ export const nodes7 = {
*
* Run draft workflow
*/
-export const post60 = oc
+export const post57 = oc
.route({
description: 'Run draft workflow',
inputStructure: 'detailed',
@@ -4134,13 +3828,13 @@ export const post60 = oc
.output(zPostAppsByAppIdWorkflowsDraftRunResponse)
export const run10 = {
- post: post60,
+ post: post57,
}
/**
* Server-Sent Events stream of inspector deltas for a draft workflow run.
*/
-export const get76 = oc
+export const get71 = oc
.route({
description: 'Server-Sent Events stream of inspector deltas for a draft workflow run.',
inputStructure: 'detailed',
@@ -4153,13 +3847,13 @@ export const get76 = oc
.output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse)
export const events = {
- get: get76,
+ get: get71,
}
/**
* Full value for one declared output, including signed download URL for files.
*/
-export const get77 = oc
+export const get72 = oc
.route({
description: 'Full value for one declared output, including signed download URL for files.',
inputStructure: 'detailed',
@@ -4175,18 +3869,18 @@ export const get77 = oc
)
.output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse)
-export const preview6 = {
- get: get77,
+export const preview5 = {
+ get: get72,
}
export const byOutputName = {
- preview: preview6,
+ preview: preview5,
}
/**
* One node's declared outputs for a draft workflow run.
*/
-export const get78 = oc
+export const get73 = oc
.route({
description: "One node's declared outputs for a draft workflow run.",
inputStructure: 'detailed',
@@ -4199,14 +3893,14 @@ export const get78 = oc
.output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponse)
export const byNodeId9 = {
- get: get78,
+ get: get73,
byOutputName,
}
/**
* Snapshot of every node's declared outputs for a draft workflow run.
*/
-export const get79 = oc
+export const get74 = oc
.route({
description: "Snapshot of every node's declared outputs for a draft workflow run.",
inputStructure: 'detailed',
@@ -4219,7 +3913,7 @@ export const get79 = oc
.output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponse)
export const nodeOutputs = {
- get: get79,
+ get: get74,
events,
byNodeId: byNodeId9,
}
@@ -4235,7 +3929,7 @@ export const runs = {
/**
* Get system variables for workflow
*/
-export const get80 = oc
+export const get75 = oc
.route({
description: 'Get system variables for workflow',
inputStructure: 'detailed',
@@ -4248,7 +3942,7 @@ export const get80 = oc
.output(zGetAppsByAppIdWorkflowsDraftSystemVariablesResponse)
export const systemVariables = {
- get: get80,
+ get: get75,
}
/**
@@ -4256,7 +3950,7 @@ export const systemVariables = {
*
* Poll for trigger events and execute full workflow when event arrives
*/
-export const post61 = oc
+export const post58 = oc
.route({
description: 'Poll for trigger events and execute full workflow when event arrives',
inputStructure: 'detailed',
@@ -4275,7 +3969,7 @@ export const post61 = oc
.output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse)
export const run11 = {
- post: post61,
+ post: post58,
}
/**
@@ -4283,7 +3977,7 @@ export const run11 = {
*
* Full workflow debug when the start node is a trigger
*/
-export const post62 = oc
+export const post59 = oc
.route({
description: 'Full workflow debug when the start node is a trigger',
inputStructure: 'detailed',
@@ -4302,7 +3996,7 @@ export const post62 = oc
.output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse)
export const runAll = {
- post: post62,
+ post: post59,
}
export const trigger2 = {
@@ -4332,7 +4026,7 @@ export const reset = {
/**
* Delete a workflow variable
*/
-export const delete14 = oc
+export const delete12 = oc
.route({
description: 'Delete a workflow variable',
inputStructure: 'detailed',
@@ -4348,7 +4042,7 @@ export const delete14 = oc
/**
* Get a specific workflow variable
*/
-export const get81 = oc
+export const get76 = oc
.route({
description: 'Get a specific workflow variable',
inputStructure: 'detailed',
@@ -4381,8 +4075,8 @@ export const patch2 = oc
.output(zPatchAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse)
export const byVariableId = {
- delete: delete14,
- get: get81,
+ delete: delete12,
+ get: get76,
patch: patch2,
reset,
}
@@ -4390,7 +4084,7 @@ export const byVariableId = {
/**
* Delete all draft workflow variables
*/
-export const delete15 = oc
+export const delete13 = oc
.route({
description: 'Delete all draft workflow variables',
inputStructure: 'detailed',
@@ -4408,7 +4102,7 @@ export const delete15 = oc
*
* Get draft workflow variables
*/
-export const get82 = oc
+export const get77 = oc
.route({
description: 'Get draft workflow variables',
inputStructure: 'detailed',
@@ -4427,8 +4121,8 @@ export const get82 = oc
.output(zGetAppsByAppIdWorkflowsDraftVariablesResponse)
export const variables2 = {
- delete: delete15,
- get: get82,
+ delete: delete13,
+ get: get77,
byVariableId,
}
@@ -4437,7 +4131,7 @@ export const variables2 = {
*
* Get draft workflow for an application
*/
-export const get83 = oc
+export const get78 = oc
.route({
description: 'Get draft workflow for an application',
inputStructure: 'detailed',
@@ -4455,7 +4149,7 @@ export const get83 = oc
*
* Sync draft workflow configuration
*/
-export const post63 = oc
+export const post60 = oc
.route({
description: 'Sync draft workflow configuration',
inputStructure: 'detailed',
@@ -4474,8 +4168,8 @@ export const post63 = oc
.output(zPostAppsByAppIdWorkflowsDraftResponse)
export const draft2 = {
- get: get83,
- post: post63,
+ get: get78,
+ post: post60,
conversationVariables: conversationVariables2,
environmentVariables,
features,
@@ -4495,7 +4189,7 @@ export const draft2 = {
*
* Get published workflow for an application
*/
-export const get84 = oc
+export const get79 = oc
.route({
description: 'Get published workflow for an application',
inputStructure: 'detailed',
@@ -4511,7 +4205,7 @@ export const get84 = oc
/**
* Publish workflow
*/
-export const post64 = oc
+export const post61 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -4529,14 +4223,14 @@ export const post64 = oc
.output(zPostAppsByAppIdWorkflowsPublishResponse)
export const publish = {
- get: get84,
- post: post64,
+ get: get79,
+ post: post61,
}
/**
* Server-Sent Events stream of inspector deltas for a published workflow run.
*/
-export const get85 = oc
+export const get80 = oc
.route({
description: 'Server-Sent Events stream of inspector deltas for a published workflow run.',
inputStructure: 'detailed',
@@ -4549,13 +4243,13 @@ export const get85 = oc
.output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse)
export const events2 = {
- get: get85,
+ get: get80,
}
/**
* Full value for one declared output of a published run.
*/
-export const get86 = oc
+export const get81 = oc
.route({
description: 'Full value for one declared output of a published run.',
inputStructure: 'detailed',
@@ -4575,18 +4269,18 @@ export const get86 = oc
zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse,
)
-export const preview7 = {
- get: get86,
+export const preview6 = {
+ get: get81,
}
export const byOutputName2 = {
- preview: preview7,
+ preview: preview6,
}
/**
* One node's declared outputs for a published workflow run.
*/
-export const get87 = oc
+export const get82 = oc
.route({
description: "One node's declared outputs for a published workflow run.",
inputStructure: 'detailed',
@@ -4599,14 +4293,14 @@ export const get87 = oc
.output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponse)
export const byNodeId10 = {
- get: get87,
+ get: get82,
byOutputName: byOutputName2,
}
/**
* Snapshot of every node's declared outputs for a published workflow run.
*/
-export const get88 = oc
+export const get83 = oc
.route({
description: "Snapshot of every node's declared outputs for a published workflow run.",
inputStructure: 'detailed',
@@ -4619,7 +4313,7 @@ export const get88 = oc
.output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse)
export const nodeOutputs2 = {
- get: get88,
+ get: get83,
events: events2,
byNodeId: byNodeId10,
}
@@ -4639,7 +4333,7 @@ export const published = {
/**
* Get webhook trigger for a node
*/
-export const get89 = oc
+export const get84 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -4657,7 +4351,7 @@ export const get89 = oc
.output(zGetAppsByAppIdWorkflowsTriggersWebhookResponse)
export const webhook = {
- get: get89,
+ get: get84,
}
export const triggers2 = {
@@ -4667,7 +4361,7 @@ export const triggers2 = {
/**
* Restore a published workflow version into the draft workflow
*/
-export const post65 = oc
+export const post62 = oc
.route({
description: 'Restore a published workflow version into the draft workflow',
inputStructure: 'detailed',
@@ -4680,13 +4374,13 @@ export const post65 = oc
.output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse)
export const restore = {
- post: post65,
+ post: post62,
}
/**
* Delete workflow
*/
-export const delete16 = oc
+export const delete14 = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
@@ -4723,7 +4417,7 @@ export const patch3 = oc
.output(zPatchAppsByAppIdWorkflowsByWorkflowIdResponse)
export const byWorkflowId = {
- delete: delete16,
+ delete: delete14,
patch: patch3,
restore,
}
@@ -4733,7 +4427,7 @@ export const byWorkflowId = {
*
* Get all published workflows for an application
*/
-export const get90 = oc
+export const get85 = oc
.route({
description: 'Get all published workflows for an application',
inputStructure: 'detailed',
@@ -4752,7 +4446,7 @@ export const get90 = oc
.output(zGetAppsByAppIdWorkflowsResponse)
export const workflows3 = {
- get: get90,
+ get: get85,
defaultWorkflowBlockConfigs,
draft: draft2,
publish,
@@ -4766,7 +4460,7 @@ export const workflows3 = {
*
* Delete application
*/
-export const delete17 = oc
+export const delete15 = oc
.route({
description: 'Delete application',
inputStructure: 'detailed',
@@ -4785,7 +4479,7 @@ export const delete17 = oc
*
* Get application details
*/
-export const get91 = oc
+export const get86 = oc
.route({
description: 'Get application details',
inputStructure: 'detailed',
@@ -4817,8 +4511,8 @@ export const put6 = oc
.output(zPutAppsByAppIdResponse)
export const byAppId2 = {
- delete: delete17,
- get: get91,
+ delete: delete15,
+ get: get86,
put: put6,
advancedChat,
agent,
@@ -4864,7 +4558,7 @@ export const byAppId2 = {
*
* Delete an API key for an app
*/
-export const delete18 = oc
+export const delete16 = oc
.route({
description: 'Delete an API key for an app',
inputStructure: 'detailed',
@@ -4879,7 +4573,7 @@ export const delete18 = oc
.output(zDeleteAppsByResourceIdApiKeysByApiKeyIdResponse)
export const byApiKeyId = {
- delete: delete18,
+ delete: delete16,
}
/**
@@ -4887,7 +4581,7 @@ export const byApiKeyId = {
*
* Get all API keys for an app
*/
-export const get92 = oc
+export const get87 = oc
.route({
description: 'Get all API keys for an app',
inputStructure: 'detailed',
@@ -4905,7 +4599,7 @@ export const get92 = oc
*
* Create a new API key for an app
*/
-export const post66 = oc
+export const post63 = oc
.route({
description: 'Create a new API key for an app',
inputStructure: 'detailed',
@@ -4920,8 +4614,8 @@ export const post66 = oc
.output(zPostAppsByResourceIdApiKeysResponse)
export const apiKeys = {
- get: get92,
- post: post66,
+ get: get87,
+ post: post63,
byApiKeyId,
}
@@ -4934,7 +4628,7 @@ export const byResourceId = {
*
* Get list of applications with pagination and filtering
*/
-export const get93 = oc
+export const get88 = oc
.route({
description: 'Get list of applications with pagination and filtering',
inputStructure: 'detailed',
@@ -4952,7 +4646,7 @@ export const get93 = oc
*
* Create a new application
*/
-export const post67 = oc
+export const post64 = oc
.route({
description: 'Create a new application',
inputStructure: 'detailed',
@@ -4967,8 +4661,8 @@ export const post67 = oc
.output(zPostAppsResponse)
export const apps = {
- get: get93,
- post: post67,
+ get: get88,
+ post: post64,
imports,
recent,
starred,
diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts
index 03d2a09d35c..0edb77f8ec8 100644
--- a/packages/contracts/generated/api/console/apps/types.gen.ts
+++ b/packages/contracts/generated/api/console/apps/types.gen.ts
@@ -258,75 +258,12 @@ export type AgentConfigSkillInspectResponse = {
warnings?: Array
}
-export type AgentDriveListResponse = {
- items?: Array
-}
-
-export type AgentDriveDownloadResponse = {
- url: string
-}
-
-export type AgentDrivePreviewResponse = {
- binary: boolean
- key: string
- size?: number | null
- text?: string | null
- truncated: boolean
-}
-
-export type AgentDriveSkillListResponse = {
- items?: Array
-}
-
-export type AgentDriveSkillInspectResponse = {
- archive_key?: string | null
- created_at?: number | null
- description: string
- file_tree?: Array<{
- [key: string]: unknown
- }>
- files?: Array
- hash?: string | null
- mime_type?: string | null
- name: string
- path: string
- size?: number | null
- skill_md: AgentDriveSkillMarkdownResponse
- skill_md_key: string
- source: string
- warnings?: Array
-}
-
-export type AgentDriveDeleteResponse = {
- removed_keys?: Array
- result: string
-}
-
-export type AgentDriveFilePayload = {
- upload_file_id: string
-}
-
-export type AgentDriveFileCommitResponse = {
- file: AgentDriveFileResponse
-}
-
export type AgentLogResponse = {
files?: Array
iterations: Array
meta: AgentLogMetaResponse
}
-export type AgentSkillUploadResponse = {
- manifest: SkillManifest
- skill: AgentUploadedSkillResponse
-}
-
-export type SkillToolInferenceResult = {
- cli_tools?: Array
- inferable: boolean
- reason?: string | null
-}
-
export type AnnotationReplyPayload = {
embedding_model_name: string
embedding_provider_name: string
@@ -1510,53 +1447,6 @@ export type AgentConfigSkillMarkdownResponse = {
truncated: boolean
}
-export type AgentDriveItemResponse = {
- created_at?: number | null
- file_kind: string
- hash?: string | null
- is_skill?: boolean | null
- key: string
- mime_type?: string | null
- size?: number | null
- skill_metadata?: string | null
-}
-
-export type AgentDriveSkillItemResponse = {
- archive_key?: string | null
- created_at?: number | null
- description: string
- hash?: string | null
- mime_type?: string | null
- name: string
- path: string
- size?: number | null
- skill_md_key: string
-}
-
-export type AgentDriveSkillFileResponse = {
- available_in_drive: boolean
- drive_key?: string | null
- name: string
- path: string
- type: string
-}
-
-export type AgentDriveSkillMarkdownResponse = {
- binary: boolean
- key: string
- size?: number | null
- text?: string | null
- truncated: boolean
-}
-
-export type AgentDriveFileResponse = {
- drive_key: string
- file_id: string
- mime_type?: string | null
- name: string
- size?: number | null
-}
-
export type AgentIterationLogResponse = {
created_at: string
files?: Array
@@ -1578,32 +1468,6 @@ export type AgentLogMetaResponse = {
total_tokens: number
}
-export type SkillManifest = {
- description: string
- entry_path: string
- files: Array
- hash: string
- name: string
- size: number
-}
-
-export type AgentUploadedSkillResponse = {
- archive_key?: string | null
- description: string
- name: string
- path: string
- skill_md_key: string
-}
-
-export type CliToolSuggestion = {
- command?: string
- description?: string
- env_suggestions?: Array
- inferred_from?: string
- install_commands?: Array
- name: string
-}
-
export type AnnotationSettingEmbeddingModelResponse = {
embedding_model_name?: string | null
embedding_provider_name?: string | null
@@ -2064,7 +1928,6 @@ export type AgentSoulConfig = {
config_note?: string
config_skills?: Array
env?: AgentSoulEnvConfig
- files?: AgentSoulFilesConfig
human?: AgentSoulHumanConfig
knowledge?: AgentSoulKnowledgeConfig
memory?: AgentSoulMemoryConfig
@@ -2292,12 +2155,6 @@ export type AgentToolCallResponse = {
}
}
-export type EnvSuggestion = {
- key: string
- reason?: string
- secret_likely?: boolean
-}
-
export type SimpleModelConfig = {
model?: JsonValue | null
pre_prompt?: string | null
@@ -2477,11 +2334,6 @@ export type AgentSoulEnvConfig = {
variables?: Array
}
-export type AgentSoulFilesConfig = {
- files?: Array
- skills?: Array
-}
-
export type AgentSoulHumanConfig = {
contacts?: Array
tools?: Array
@@ -2779,35 +2631,6 @@ export type AgentEnvVariableConfig = {
[key: string]: unknown
}
-export type AgentFileRefConfig = {
- drive_key?: string | null
- file_id?: string | null
- id?: string | null
- name?: string | null
- reference?: string | null
- remote_url?: string | null
- tenant_id?: string | null
- transfer_method?: string | null
- type?: string | null
- upload_file_id?: string | null
- url?: string | null
- [key: string]: unknown
-}
-
-export type AgentSkillRefConfig = {
- description?: string | null
- file_id?: string | null
- full_archive_file_id?: string | null
- full_archive_key?: string | null
- id?: string | null
- manifest_files?: Array | null
- name?: string | null
- path?: string | null
- skill_md_file_id?: string | null
- skill_md_key?: string | null
- [key: string]: unknown
-}
-
export type AgentHumanToolConfig = {
description?: string | null
enabled?: boolean
@@ -2883,6 +2706,20 @@ export type AgentSoulDifyToolConfig = {
tool_name?: string | null
}
+export type AgentFileRefConfig = {
+ file_id?: string | null
+ id?: string | null
+ name?: string | null
+ reference?: string | null
+ remote_url?: string | null
+ tenant_id?: string | null
+ transfer_method?: string | null
+ type?: string | null
+ upload_file_id?: string | null
+ url?: string | null
+ [key: string]: unknown
+}
+
export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop'
export type DeclaredOutputRetryConfig = {
@@ -3946,138 +3783,6 @@ export type GetAppsByAppIdAgentConfigSkillsByNameInspectResponses = {
export type GetAppsByAppIdAgentConfigSkillsByNameInspectResponse =
GetAppsByAppIdAgentConfigSkillsByNameInspectResponses[keyof GetAppsByAppIdAgentConfigSkillsByNameInspectResponses]
-export type GetAppsByAppIdAgentDriveFilesData = {
- body?: never
- path: {
- app_id: string
- }
- query?: {
- node_id?: string
- prefix?: string
- }
- url: '/apps/{app_id}/agent/drive/files'
-}
-
-export type GetAppsByAppIdAgentDriveFilesResponses = {
- 200: AgentDriveListResponse
-}
-
-export type GetAppsByAppIdAgentDriveFilesResponse =
- GetAppsByAppIdAgentDriveFilesResponses[keyof GetAppsByAppIdAgentDriveFilesResponses]
-
-export type GetAppsByAppIdAgentDriveFilesDownloadData = {
- body?: never
- path: {
- app_id: string
- }
- query: {
- key: string
- node_id?: string
- }
- url: '/apps/{app_id}/agent/drive/files/download'
-}
-
-export type GetAppsByAppIdAgentDriveFilesDownloadResponses = {
- 200: AgentDriveDownloadResponse
-}
-
-export type GetAppsByAppIdAgentDriveFilesDownloadResponse =
- GetAppsByAppIdAgentDriveFilesDownloadResponses[keyof GetAppsByAppIdAgentDriveFilesDownloadResponses]
-
-export type GetAppsByAppIdAgentDriveFilesPreviewData = {
- body?: never
- path: {
- app_id: string
- }
- query: {
- key: string
- node_id?: string
- }
- url: '/apps/{app_id}/agent/drive/files/preview'
-}
-
-export type GetAppsByAppIdAgentDriveFilesPreviewResponses = {
- 200: AgentDrivePreviewResponse
-}
-
-export type GetAppsByAppIdAgentDriveFilesPreviewResponse =
- GetAppsByAppIdAgentDriveFilesPreviewResponses[keyof GetAppsByAppIdAgentDriveFilesPreviewResponses]
-
-export type GetAppsByAppIdAgentDriveSkillsData = {
- body?: never
- path: {
- app_id: string
- }
- query?: {
- node_id?: string
- prefix?: string
- }
- url: '/apps/{app_id}/agent/drive/skills'
-}
-
-export type GetAppsByAppIdAgentDriveSkillsResponses = {
- 200: AgentDriveSkillListResponse
-}
-
-export type GetAppsByAppIdAgentDriveSkillsResponse =
- GetAppsByAppIdAgentDriveSkillsResponses[keyof GetAppsByAppIdAgentDriveSkillsResponses]
-
-export type GetAppsByAppIdAgentDriveSkillsBySkillPathInspectData = {
- body?: never
- path: {
- app_id: string
- skill_path: string
- }
- query?: {
- node_id?: string
- }
- url: '/apps/{app_id}/agent/drive/skills/{skill_path}/inspect'
-}
-
-export type GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponses = {
- 200: AgentDriveSkillInspectResponse
-}
-
-export type GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse =
- GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponses[keyof GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponses]
-
-export type DeleteAppsByAppIdAgentFilesData = {
- body?: never
- path: {
- app_id: string
- }
- query: {
- key: string
- node_id?: string
- }
- url: '/apps/{app_id}/agent/files'
-}
-
-export type DeleteAppsByAppIdAgentFilesResponses = {
- 200: AgentDriveDeleteResponse
-}
-
-export type DeleteAppsByAppIdAgentFilesResponse =
- DeleteAppsByAppIdAgentFilesResponses[keyof DeleteAppsByAppIdAgentFilesResponses]
-
-export type PostAppsByAppIdAgentFilesData = {
- body: AgentDriveFilePayload
- path: {
- app_id: string
- }
- query?: {
- node_id?: string
- }
- url: '/apps/{app_id}/agent/files'
-}
-
-export type PostAppsByAppIdAgentFilesResponses = {
- 201: AgentDriveFileCommitResponse
-}
-
-export type PostAppsByAppIdAgentFilesResponse =
- PostAppsByAppIdAgentFilesResponses[keyof PostAppsByAppIdAgentFilesResponses]
-
export type GetAppsByAppIdAgentLogsData = {
body?: never
path: {
@@ -4101,68 +3806,6 @@ export type GetAppsByAppIdAgentLogsResponses = {
export type GetAppsByAppIdAgentLogsResponse =
GetAppsByAppIdAgentLogsResponses[keyof GetAppsByAppIdAgentLogsResponses]
-export type PostAppsByAppIdAgentSkillsUploadData = {
- body: {
- file: Blob | File
- }
- path: {
- app_id: string
- }
- query?: {
- node_id?: string
- }
- url: '/apps/{app_id}/agent/skills/upload'
-}
-
-export type PostAppsByAppIdAgentSkillsUploadErrors = {
- 400: unknown
-}
-
-export type PostAppsByAppIdAgentSkillsUploadResponses = {
- 201: AgentSkillUploadResponse
-}
-
-export type PostAppsByAppIdAgentSkillsUploadResponse =
- PostAppsByAppIdAgentSkillsUploadResponses[keyof PostAppsByAppIdAgentSkillsUploadResponses]
-
-export type DeleteAppsByAppIdAgentSkillsBySlugData = {
- body?: never
- path: {
- app_id: string
- slug: string
- }
- query?: {
- node_id?: string
- }
- url: '/apps/{app_id}/agent/skills/{slug}'
-}
-
-export type DeleteAppsByAppIdAgentSkillsBySlugResponses = {
- 200: AgentDriveDeleteResponse
-}
-
-export type DeleteAppsByAppIdAgentSkillsBySlugResponse =
- DeleteAppsByAppIdAgentSkillsBySlugResponses[keyof DeleteAppsByAppIdAgentSkillsBySlugResponses]
-
-export type PostAppsByAppIdAgentSkillsBySlugInferToolsData = {
- body?: never
- path: {
- app_id: string
- slug: string
- }
- query?: {
- node_id?: string
- }
- url: '/apps/{app_id}/agent/skills/{slug}/infer-tools'
-}
-
-export type PostAppsByAppIdAgentSkillsBySlugInferToolsResponses = {
- 200: SkillToolInferenceResult
-}
-
-export type PostAppsByAppIdAgentSkillsBySlugInferToolsResponse =
- PostAppsByAppIdAgentSkillsBySlugInferToolsResponses[keyof PostAppsByAppIdAgentSkillsBySlugInferToolsResponses]
-
export type PostAppsByAppIdAnnotationReplyByActionData = {
body: AnnotationReplyPayload
path: {
diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts
index 12f765715d6..91074f82e8b 100644
--- a/packages/contracts/generated/api/console/apps/zod.gen.ts
+++ b/packages/contracts/generated/api/console/apps/zod.gen.ts
@@ -142,39 +142,6 @@ export const zAgentConfigSkillFilePreviewResponse = z.object({
truncated: z.boolean(),
})
-/**
- * AgentDriveDownloadResponse
- */
-export const zAgentDriveDownloadResponse = z.object({
- url: z.string(),
-})
-
-/**
- * AgentDrivePreviewResponse
- */
-export const zAgentDrivePreviewResponse = z.object({
- binary: z.boolean(),
- key: z.string(),
- size: z.int().nullish(),
- text: z.string().nullish(),
- truncated: z.boolean(),
-})
-
-/**
- * AgentDriveDeleteResponse
- */
-export const zAgentDriveDeleteResponse = z.object({
- removed_keys: z.array(z.string()).optional(),
- result: z.string(),
-})
-
-/**
- * AgentDriveFilePayload
- */
-export const zAgentDriveFilePayload = z.object({
- upload_file_id: z.string(),
-})
-
/**
* AnnotationReplyPayload
*/
@@ -1239,109 +1206,6 @@ export const zAgentConfigSkillInspectResponse = z.object({
warnings: z.array(z.string()).optional(),
})
-/**
- * AgentDriveItemResponse
- */
-export const zAgentDriveItemResponse = z.object({
- created_at: z.int().nullish(),
- file_kind: z.string(),
- hash: z.string().nullish(),
- is_skill: z.boolean().nullish(),
- key: z.string(),
- mime_type: z.string().nullish(),
- size: z.int().nullish(),
- skill_metadata: z.string().nullish(),
-})
-
-/**
- * AgentDriveListResponse
- */
-export const zAgentDriveListResponse = z.object({
- items: z.array(zAgentDriveItemResponse).optional(),
-})
-
-/**
- * AgentDriveSkillItemResponse
- */
-export const zAgentDriveSkillItemResponse = z.object({
- archive_key: z.string().nullish(),
- created_at: z.int().nullish(),
- description: z.string(),
- hash: z.string().nullish(),
- mime_type: z.string().nullish(),
- name: z.string(),
- path: z.string(),
- size: z.int().nullish(),
- skill_md_key: z.string(),
-})
-
-/**
- * AgentDriveSkillListResponse
- */
-export const zAgentDriveSkillListResponse = z.object({
- items: z.array(zAgentDriveSkillItemResponse).optional(),
-})
-
-/**
- * AgentDriveSkillFileResponse
- */
-export const zAgentDriveSkillFileResponse = z.object({
- available_in_drive: z.boolean(),
- drive_key: z.string().nullish(),
- name: z.string(),
- path: z.string(),
- type: z.string(),
-})
-
-/**
- * AgentDriveSkillMarkdownResponse
- */
-export const zAgentDriveSkillMarkdownResponse = z.object({
- binary: z.boolean(),
- key: z.string(),
- size: z.int().nullish(),
- text: z.string().nullish(),
- truncated: z.boolean(),
-})
-
-/**
- * AgentDriveSkillInspectResponse
- */
-export const zAgentDriveSkillInspectResponse = z.object({
- archive_key: z.string().nullish(),
- created_at: z.int().nullish(),
- description: z.string(),
- file_tree: z.array(z.record(z.string(), z.unknown())).optional(),
- files: z.array(zAgentDriveSkillFileResponse).optional(),
- hash: z.string().nullish(),
- mime_type: z.string().nullish(),
- name: z.string(),
- path: z.string(),
- size: z.int().nullish(),
- skill_md: zAgentDriveSkillMarkdownResponse,
- skill_md_key: z.string(),
- source: z.string(),
- warnings: z.array(z.string()).optional(),
-})
-
-/**
- * AgentDriveFileResponse
- */
-export const zAgentDriveFileResponse = z.object({
- drive_key: z.string(),
- file_id: z.string(),
- mime_type: z.string().nullish(),
- name: z.string(),
- size: z.int().nullish(),
-})
-
-/**
- * AgentDriveFileCommitResponse
- */
-export const zAgentDriveFileCommitResponse = z.object({
- file: zAgentDriveFileResponse,
-})
-
/**
* AgentLogMetaResponse
*/
@@ -1355,39 +1219,6 @@ export const zAgentLogMetaResponse = z.object({
total_tokens: z.int(),
})
-/**
- * SkillManifest
- *
- * Validated metadata extracted from a Skill package.
- */
-export const zSkillManifest = z.object({
- description: z.string(),
- entry_path: z.string(),
- files: z.array(z.string()),
- hash: z.string(),
- name: z.string(),
- size: z.int(),
-})
-
-/**
- * AgentUploadedSkillResponse
- */
-export const zAgentUploadedSkillResponse = z.object({
- archive_key: z.string().nullish(),
- description: z.string(),
- name: z.string(),
- path: z.string(),
- skill_md_key: z.string(),
-})
-
-/**
- * AgentSkillUploadResponse
- */
-export const zAgentSkillUploadResponse = z.object({
- manifest: zSkillManifest,
- skill: zAgentUploadedSkillResponse,
-})
-
/**
* AnnotationSettingEmbeddingModelResponse
*/
@@ -2486,36 +2317,6 @@ export const zAgentLogResponse = z.object({
meta: zAgentLogMetaResponse,
})
-/**
- * EnvSuggestion
- */
-export const zEnvSuggestion = z.object({
- key: z.string(),
- reason: z.string().optional().default(''),
- secret_likely: z.boolean().optional().default(false),
-})
-
-/**
- * CliToolSuggestion
- */
-export const zCliToolSuggestion = z.object({
- command: z.string().optional().default(''),
- description: z.string().optional().default(''),
- env_suggestions: z.array(zEnvSuggestion).optional(),
- inferred_from: z.string().optional().default(''),
- install_commands: z.array(z.string()).optional(),
- name: z.string(),
-})
-
-/**
- * SkillToolInferenceResult
- */
-export const zSkillToolInferenceResult = z.object({
- cli_tools: z.array(zCliToolSuggestion).optional(),
- inferable: z.boolean(),
- reason: z.string().nullish(),
-})
-
/**
* SimpleModelConfig
*/
@@ -3236,55 +3037,6 @@ export const zAgentEnvVariableConfig = z.object({
variable: z.string().max(255).nullish(),
})
-/**
- * AgentFileRefConfig
- */
-export const zAgentFileRefConfig = z.object({
- drive_key: z.string().max(512).nullish(),
- file_id: z.string().max(255).nullish(),
- id: z.string().max(255).nullish(),
- name: z.string().max(255).nullish(),
- reference: z.string().max(255).nullish(),
- remote_url: z.string().nullish(),
- tenant_id: z.string().max(255).nullish(),
- transfer_method: z.string().max(64).nullish(),
- type: z.string().max(64).nullish(),
- upload_file_id: z.string().max(255).nullish(),
- url: z.string().nullish(),
-})
-
-/**
- * WorkflowNodeJobMetadata
- */
-export const zWorkflowNodeJobMetadata = z.object({
- agent_soul: z.record(z.string(), z.unknown()).nullish(),
- file_refs: z.array(zAgentFileRefConfig).nullish(),
-})
-
-/**
- * AgentSkillRefConfig
- */
-export const zAgentSkillRefConfig = z.object({
- description: z.string().nullish(),
- file_id: z.string().max(255).nullish(),
- full_archive_file_id: z.string().max(255).nullish(),
- full_archive_key: z.string().max(512).nullish(),
- id: z.string().max(255).nullish(),
- manifest_files: z.array(z.string()).nullish(),
- name: z.string().max(255).nullish(),
- path: z.string().nullish(),
- skill_md_file_id: z.string().max(255).nullish(),
- skill_md_key: z.string().max(512).nullish(),
-})
-
-/**
- * AgentSoulFilesConfig
- */
-export const zAgentSoulFilesConfig = z.object({
- files: z.array(zAgentFileRefConfig).optional(),
- skills: z.array(zAgentSkillRefConfig).optional(),
-})
-
/**
* AgentHumanToolConfig
*/
@@ -3350,6 +3102,30 @@ export const zAgentSoulSandboxConfig = z.object({
provider: z.string().nullish(),
})
+/**
+ * AgentFileRefConfig
+ */
+export const zAgentFileRefConfig = z.object({
+ file_id: z.string().max(255).nullish(),
+ id: z.string().max(255).nullish(),
+ name: z.string().max(255).nullish(),
+ reference: z.string().max(255).nullish(),
+ remote_url: z.string().nullish(),
+ tenant_id: z.string().max(255).nullish(),
+ transfer_method: z.string().max(64).nullish(),
+ type: z.string().max(64).nullish(),
+ upload_file_id: z.string().max(255).nullish(),
+ url: z.string().nullish(),
+})
+
+/**
+ * WorkflowNodeJobMetadata
+ */
+export const zWorkflowNodeJobMetadata = z.object({
+ agent_soul: z.record(z.string(), z.unknown()).nullish(),
+ file_refs: z.array(zAgentFileRefConfig).nullish(),
+})
+
/**
* OutputErrorStrategy
*
@@ -4162,7 +3938,6 @@ export const zAgentSoulConfig = z.object({
config_note: z.string().optional().default(''),
config_skills: z.array(zAgentConfigSkillRefConfig).optional(),
env: zAgentSoulEnvConfig.optional(),
- files: zAgentSoulFilesConfig.optional(),
human: zAgentSoulHumanConfig.optional(),
knowledge: zAgentSoulKnowledgeConfig.optional(),
memory: zAgentSoulMemoryConfig.optional(),
@@ -4921,106 +4696,6 @@ export const zGetAppsByAppIdAgentConfigSkillsByNameInspectQuery = z.object({
export const zGetAppsByAppIdAgentConfigSkillsByNameInspectResponse =
zAgentConfigSkillInspectResponse
-export const zGetAppsByAppIdAgentDriveFilesPath = z.object({
- app_id: z.uuid(),
-})
-
-export const zGetAppsByAppIdAgentDriveFilesQuery = z.object({
- node_id: z.string().optional(),
- prefix: z.string().optional().default(''),
-})
-
-/**
- * Drive entries
- */
-export const zGetAppsByAppIdAgentDriveFilesResponse = zAgentDriveListResponse
-
-export const zGetAppsByAppIdAgentDriveFilesDownloadPath = z.object({
- app_id: z.uuid(),
-})
-
-export const zGetAppsByAppIdAgentDriveFilesDownloadQuery = z.object({
- key: z.string().min(1),
- node_id: z.string().optional(),
-})
-
-/**
- * Signed URL
- */
-export const zGetAppsByAppIdAgentDriveFilesDownloadResponse = zAgentDriveDownloadResponse
-
-export const zGetAppsByAppIdAgentDriveFilesPreviewPath = z.object({
- app_id: z.uuid(),
-})
-
-export const zGetAppsByAppIdAgentDriveFilesPreviewQuery = z.object({
- key: z.string().min(1),
- node_id: z.string().optional(),
-})
-
-/**
- * Preview
- */
-export const zGetAppsByAppIdAgentDriveFilesPreviewResponse = zAgentDrivePreviewResponse
-
-export const zGetAppsByAppIdAgentDriveSkillsPath = z.object({
- app_id: z.uuid(),
-})
-
-export const zGetAppsByAppIdAgentDriveSkillsQuery = z.object({
- node_id: z.string().optional(),
- prefix: z.string().optional().default(''),
-})
-
-/**
- * Drive skills
- */
-export const zGetAppsByAppIdAgentDriveSkillsResponse = zAgentDriveSkillListResponse
-
-export const zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectPath = z.object({
- app_id: z.uuid(),
- skill_path: z.string(),
-})
-
-export const zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectQuery = z.object({
- node_id: z.string().optional(),
-})
-
-/**
- * Drive skill inspect view
- */
-export const zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse =
- zAgentDriveSkillInspectResponse
-
-export const zDeleteAppsByAppIdAgentFilesPath = z.object({
- app_id: z.uuid(),
-})
-
-export const zDeleteAppsByAppIdAgentFilesQuery = z.object({
- key: z.string().min(1),
- node_id: z.string().optional(),
-})
-
-/**
- * File removed
- */
-export const zDeleteAppsByAppIdAgentFilesResponse = zAgentDriveDeleteResponse
-
-export const zPostAppsByAppIdAgentFilesBody = zAgentDriveFilePayload
-
-export const zPostAppsByAppIdAgentFilesPath = z.object({
- app_id: z.uuid(),
-})
-
-export const zPostAppsByAppIdAgentFilesQuery = z.object({
- node_id: z.string().optional(),
-})
-
-/**
- * File committed into the agent drive
- */
-export const zPostAppsByAppIdAgentFilesResponse = zAgentDriveFileCommitResponse
-
export const zGetAppsByAppIdAgentLogsPath = z.object({
app_id: z.uuid(),
})
@@ -5035,51 +4710,6 @@ export const zGetAppsByAppIdAgentLogsQuery = z.object({
*/
export const zGetAppsByAppIdAgentLogsResponse = zAgentLogResponse
-export const zPostAppsByAppIdAgentSkillsUploadBody = z.object({
- file: z.custom((value) => value instanceof Blob || value instanceof File),
-})
-
-export const zPostAppsByAppIdAgentSkillsUploadPath = z.object({
- app_id: z.uuid(),
-})
-
-export const zPostAppsByAppIdAgentSkillsUploadQuery = z.object({
- node_id: z.string().optional(),
-})
-
-/**
- * Skill uploaded into drive
- */
-export const zPostAppsByAppIdAgentSkillsUploadResponse = zAgentSkillUploadResponse
-
-export const zDeleteAppsByAppIdAgentSkillsBySlugPath = z.object({
- app_id: z.uuid(),
- slug: z.string(),
-})
-
-export const zDeleteAppsByAppIdAgentSkillsBySlugQuery = z.object({
- node_id: z.string().optional(),
-})
-
-/**
- * Skill removed
- */
-export const zDeleteAppsByAppIdAgentSkillsBySlugResponse = zAgentDriveDeleteResponse
-
-export const zPostAppsByAppIdAgentSkillsBySlugInferToolsPath = z.object({
- app_id: z.uuid(),
- slug: z.string(),
-})
-
-export const zPostAppsByAppIdAgentSkillsBySlugInferToolsQuery = z.object({
- node_id: z.string().optional(),
-})
-
-/**
- * Inference result (draft suggestions, nothing persisted)
- */
-export const zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse = zSkillToolInferenceResult
-
export const zPostAppsByAppIdAnnotationReplyByActionBody = zAnnotationReplyPayload
export const zPostAppsByAppIdAnnotationReplyByActionPath = z.object({
diff --git a/packages/contracts/generated/api/console/snippets/types.gen.ts b/packages/contracts/generated/api/console/snippets/types.gen.ts
index 67e5d738e3e..5dc2a624c1e 100644
--- a/packages/contracts/generated/api/console/snippets/types.gen.ts
+++ b/packages/contracts/generated/api/console/snippets/types.gen.ts
@@ -405,7 +405,6 @@ export type AgentSoulConfig = {
config_note?: string
config_skills?: Array
env?: AgentSoulEnvConfig
- files?: AgentSoulFilesConfig
human?: AgentSoulHumanConfig
knowledge?: AgentSoulKnowledgeConfig
memory?: AgentSoulMemoryConfig
@@ -600,11 +599,6 @@ export type AgentSoulEnvConfig = {
variables?: Array
}
-export type AgentSoulFilesConfig = {
- files?: Array
- skills?: Array
-}
-
export type AgentSoulHumanConfig = {
contacts?: Array
tools?: Array
@@ -857,35 +851,6 @@ export type AgentEnvVariableConfig = {
[key: string]: unknown
}
-export type AgentFileRefConfig = {
- drive_key?: string | null
- file_id?: string | null
- id?: string | null
- name?: string | null
- reference?: string | null
- remote_url?: string | null
- tenant_id?: string | null
- transfer_method?: string | null
- type?: string | null
- upload_file_id?: string | null
- url?: string | null
- [key: string]: unknown
-}
-
-export type AgentSkillRefConfig = {
- description?: string | null
- file_id?: string | null
- full_archive_file_id?: string | null
- full_archive_key?: string | null
- id?: string | null
- manifest_files?: Array | null
- name?: string | null
- path?: string | null
- skill_md_file_id?: string | null
- skill_md_key?: string | null
- [key: string]: unknown
-}
-
export type AgentHumanToolConfig = {
description?: string | null
enabled?: boolean
@@ -961,6 +926,20 @@ export type AgentSoulDifyToolConfig = {
tool_name?: string | null
}
+export type AgentFileRefConfig = {
+ file_id?: string | null
+ id?: string | null
+ name?: string | null
+ reference?: string | null
+ remote_url?: string | null
+ tenant_id?: string | null
+ transfer_method?: string | null
+ type?: string | null
+ upload_file_id?: string | null
+ url?: string | null
+ [key: string]: unknown
+}
+
export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop'
export type DeclaredOutputRetryConfig = {
diff --git a/packages/contracts/generated/api/console/snippets/zod.gen.ts b/packages/contracts/generated/api/console/snippets/zod.gen.ts
index 958018613d8..994adf7aa0f 100644
--- a/packages/contracts/generated/api/console/snippets/zod.gen.ts
+++ b/packages/contracts/generated/api/console/snippets/zod.gen.ts
@@ -789,55 +789,6 @@ export const zAgentEnvVariableConfig = z.object({
variable: z.string().max(255).nullish(),
})
-/**
- * AgentFileRefConfig
- */
-export const zAgentFileRefConfig = z.object({
- drive_key: z.string().max(512).nullish(),
- file_id: z.string().max(255).nullish(),
- id: z.string().max(255).nullish(),
- name: z.string().max(255).nullish(),
- reference: z.string().max(255).nullish(),
- remote_url: z.string().nullish(),
- tenant_id: z.string().max(255).nullish(),
- transfer_method: z.string().max(64).nullish(),
- type: z.string().max(64).nullish(),
- upload_file_id: z.string().max(255).nullish(),
- url: z.string().nullish(),
-})
-
-/**
- * WorkflowNodeJobMetadata
- */
-export const zWorkflowNodeJobMetadata = z.object({
- agent_soul: z.record(z.string(), z.unknown()).nullish(),
- file_refs: z.array(zAgentFileRefConfig).nullish(),
-})
-
-/**
- * AgentSkillRefConfig
- */
-export const zAgentSkillRefConfig = z.object({
- description: z.string().nullish(),
- file_id: z.string().max(255).nullish(),
- full_archive_file_id: z.string().max(255).nullish(),
- full_archive_key: z.string().max(512).nullish(),
- id: z.string().max(255).nullish(),
- manifest_files: z.array(z.string()).nullish(),
- name: z.string().max(255).nullish(),
- path: z.string().nullish(),
- skill_md_file_id: z.string().max(255).nullish(),
- skill_md_key: z.string().max(512).nullish(),
-})
-
-/**
- * AgentSoulFilesConfig
- */
-export const zAgentSoulFilesConfig = z.object({
- files: z.array(zAgentFileRefConfig).optional(),
- skills: z.array(zAgentSkillRefConfig).optional(),
-})
-
/**
* AgentHumanToolConfig
*/
@@ -903,6 +854,30 @@ export const zAgentSoulSandboxConfig = z.object({
provider: z.string().nullish(),
})
+/**
+ * AgentFileRefConfig
+ */
+export const zAgentFileRefConfig = z.object({
+ file_id: z.string().max(255).nullish(),
+ id: z.string().max(255).nullish(),
+ name: z.string().max(255).nullish(),
+ reference: z.string().max(255).nullish(),
+ remote_url: z.string().nullish(),
+ tenant_id: z.string().max(255).nullish(),
+ transfer_method: z.string().max(64).nullish(),
+ type: z.string().max(64).nullish(),
+ upload_file_id: z.string().max(255).nullish(),
+ url: z.string().nullish(),
+})
+
+/**
+ * WorkflowNodeJobMetadata
+ */
+export const zWorkflowNodeJobMetadata = z.object({
+ agent_soul: z.record(z.string(), z.unknown()).nullish(),
+ file_refs: z.array(zAgentFileRefConfig).nullish(),
+})
+
/**
* OutputErrorStrategy
*
@@ -1553,7 +1528,6 @@ export const zAgentSoulConfig = z.object({
config_note: z.string().optional().default(''),
config_skills: z.array(zAgentConfigSkillRefConfig).optional(),
env: zAgentSoulEnvConfig.optional(),
- files: zAgentSoulFilesConfig.optional(),
human: zAgentSoulHumanConfig.optional(),
knowledge: zAgentSoulKnowledgeConfig.optional(),
memory: zAgentSoulMemoryConfig.optional(),
From f1b48a93a925fbbc4da44b8ce936e6ff035fc728 Mon Sep 17 00:00:00 2001
From: Pranav Agarwal
Date: Wed, 19 Aug 2026 06:48:15 +0000
Subject: [PATCH 08/18] fix(api): enforce edit and RBAC permissions on model
provider credential GET endpoints (#40899) (#40900)
---
.../console/workspace/model_providers.py | 2 ++
api/controllers/console/workspace/models.py | 2 ++
...rkspace_credential_mutation_permissions.py | 24 +++++++++++++++++++
3 files changed, 28 insertions(+)
diff --git a/api/controllers/console/workspace/model_providers.py b/api/controllers/console/workspace/model_providers.py
index 5ff114aa4e0..4e756398c91 100644
--- a/api/controllers/console/workspace/model_providers.py
+++ b/api/controllers/console/workspace/model_providers.py
@@ -209,6 +209,8 @@ class ModelProviderCredentialApi(Resource):
)
@setup_required
@login_required
+ @is_admin_or_owner_required
+ @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, provider: str):
diff --git a/api/controllers/console/workspace/models.py b/api/controllers/console/workspace/models.py
index 54d021251fd..02b5a68f9c8 100644
--- a/api/controllers/console/workspace/models.py
+++ b/api/controllers/console/workspace/models.py
@@ -349,6 +349,8 @@ class ModelProviderModelCredentialApi(Resource):
)
@setup_required
@login_required
+ @is_admin_or_owner_required
+ @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@account_initialization_required
@with_current_user
@with_current_tenant_id
diff --git a/api/tests/unit_tests/controllers/console/test_workspace_credential_mutation_permissions.py b/api/tests/unit_tests/controllers/console/test_workspace_credential_mutation_permissions.py
index b7a6212cc75..321c957e79a 100644
--- a/api/tests/unit_tests/controllers/console/test_workspace_credential_mutation_permissions.py
+++ b/api/tests/unit_tests/controllers/console/test_workspace_credential_mutation_permissions.py
@@ -5,6 +5,8 @@ import pytest
from controllers.common.wraps import RBACPermission, RBACResourceScope
from controllers.console.datasets.data_source import DataSourceApi
+from controllers.console.workspace.model_providers import ModelProviderCredentialApi
+from controllers.console.workspace.models import ModelProviderModelCredentialApi
from controllers.console.workspace.tool_providers import ToolBuiltinProviderAddApi
@@ -26,3 +28,25 @@ def test_workspace_credential_mutations_require_management_permission(
assert rbac_config["resource_type"] == RBACResourceScope.WORKSPACE
assert rbac_config["scene"] == permission
assert rbac_config["resource_required"] is False
+
+
+@pytest.mark.parametrize(
+ "method",
+ [
+ ModelProviderCredentialApi.get,
+ ModelProviderModelCredentialApi.get,
+ ],
+)
+def test_model_provider_credential_get_requires_admin_and_rbac(
+ method: FunctionType,
+) -> None:
+ """GET endpoints that return provider credential details must enforce
+ the same admin + RBAC gates as their sibling POST/PUT/DELETE methods."""
+ legacy_wrapper = unwrap(method, stop=lambda wrapper: "is_admin_or_owner_required" in wrapper.__code__.co_qualname)
+ assert "is_admin_or_owner_required" in legacy_wrapper.__code__.co_qualname
+
+ rbac_wrapper = unwrap(method, stop=lambda wrapper: "rbac_permission_required" in wrapper.__code__.co_qualname)
+ rbac_config = getclosurevars(rbac_wrapper).nonlocals
+ assert rbac_config["resource_type"] == RBACResourceScope.WORKSPACE
+ assert rbac_config["scene"] == RBACPermission.CREDENTIAL_MANAGE
+ assert rbac_config["resource_required"] is False
From 414f211cde94167c88a5bd44fbd7365aaabf11b8 Mon Sep 17 00:00:00 2001
From: zyssyz123 <916125788@qq.com>
Date: Wed, 19 Aug 2026 06:57:47 +0000
Subject: [PATCH 09/18] fix(auth): harden email code login verification
(#40960)
---
api/.env.example | 4 +
api/configs/extra/turnstile_config.py | 7 +
api/configs/feature/__init__.py | 4 +
api/controllers/console/auth/error.py | 6 +
api/controllers/console/auth/login.py | 76 +++--
api/openapi/markdown/console-openapi.md | 3 +-
api/services/account_service.py | 15 +-
api/services/email_code_login_challenge.py | 268 ++++++++++++++++++
api/services/turnstile_service.py | 13 +-
.../unit_tests/configs/test_dify_config.py | 8 +
.../console/auth/test_email_verification.py | 253 ++++++++++++++---
.../console/auth/test_login_logout.py | 28 +-
.../test_email_code_login_challenge.py | 174 ++++++++++++
.../services/test_turnstile_service.py | 14 +
docker/envs/core-services/api.env.example | 1 +
docker/envs/core-services/shared.env.example | 2 +
.../api/console/email-code-login/types.gen.ts | 1 +
.../api/console/email-code-login/zod.gen.ts | 5 +-
.../signin/check-code/__tests__/page.spec.tsx | 251 ++++++++++++++--
web/app/signin/check-code/page.tsx | 68 +++--
.../components/__tests__/turnstile.spec.tsx | 9 +-
.../signin/components/mail-and-code-auth.tsx | 1 +
web/app/signin/components/turnstile.tsx | 15 +-
web/service/common.spec.ts | 30 +-
web/service/common.ts | 10 +-
25 files changed, 1141 insertions(+), 125 deletions(-)
create mode 100644 api/services/email_code_login_challenge.py
create mode 100644 api/tests/unit_tests/services/test_email_code_login_challenge.py
diff --git a/api/.env.example b/api/.env.example
index 5bc4cffeb17..65e9fa6c9a2 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -492,6 +492,8 @@ SENTRY_DSN=
TURNSTILE_SECRET_KEY=
# Comma-separated parent or exact hostnames, for example: dify.ai,staging.dify.dev
TURNSTILE_ALLOWED_HOSTNAMES=
+# Enable only after the compatible web client has been fully deployed.
+TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED=false
# DEBUG
DEBUG=false
@@ -733,6 +735,8 @@ RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5
+EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES=5
+EMAIL_CODE_LOGIN_MAX_ATTEMPTS=5
CREATE_TIDB_SERVICE_JOB_ENABLED=false
diff --git a/api/configs/extra/turnstile_config.py b/api/configs/extra/turnstile_config.py
index c4ae92924e3..05614516784 100644
--- a/api/configs/extra/turnstile_config.py
+++ b/api/configs/extra/turnstile_config.py
@@ -13,6 +13,13 @@ class TurnstileConfig(BaseSettings):
default="",
description="Comma-separated parent or exact hostnames accepted from Turnstile.",
)
+ TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED: bool = Field(
+ default=False,
+ description=(
+ "Require a separate Turnstile challenge when verifying email login codes on Dify Cloud. "
+ "Enable after the compatible web client has been deployed."
+ ),
+ )
@field_validator("TURNSTILE_SECRET_KEY", mode="before")
@classmethod
diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py
index a4cefb4ba40..06ae6c2718e 100644
--- a/api/configs/feature/__init__.py
+++ b/api/configs/feature/__init__.py
@@ -1549,6 +1549,10 @@ class LoginConfig(BaseSettings):
description="expiry time in minutes for email code login token",
default=5,
)
+ EMAIL_CODE_LOGIN_MAX_ATTEMPTS: PositiveInt = Field(
+ description="maximum number of verification attempts for an email code login challenge",
+ default=5,
+ )
ALLOW_REGISTER: bool = Field(
description="whether to enable register",
default=False,
diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py
index 514450b343d..4ff9b4d33bc 100644
--- a/api/controllers/console/auth/error.py
+++ b/api/controllers/console/auth/error.py
@@ -95,6 +95,12 @@ class EmailCodeError(BaseHTTPException):
code = 400
+class EmailCodeLoginServiceUnavailableError(BaseHTTPException):
+ error_code = "email_code_login_service_unavailable"
+ description = "Email code verification is temporarily unavailable. Please try again later."
+ code = 503
+
+
class EmailOrPasswordMismatchError(BaseHTTPException):
error_code = "email_or_password_mismatch"
description = "The email or password is mismatched."
diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py
index 15558a0c146..6629784afaf 100644
--- a/api/controllers/console/auth/login.py
+++ b/api/controllers/console/auth/login.py
@@ -1,4 +1,5 @@
import logging
+from uuid import UUID
import flask_login
from flask import make_response, request
@@ -22,6 +23,7 @@ from controllers.console import console_ns
from controllers.console.auth.error import (
AuthenticationFailedError,
EmailCodeError,
+ EmailCodeLoginServiceUnavailableError,
EmailPasswordLoginLimitError,
InvalidEmailError,
InvalidTokenError,
@@ -61,6 +63,10 @@ from libs.token import (
from models.account import Account
from services.account_service import AccountService, InvitationDetailDict, RegisterService, TenantService
from services.billing_service import BillingService
+from services.email_code_login_challenge import (
+ EmailCodeLoginChallengeStatus,
+ EmailCodeLoginChallengeUnavailableError,
+)
from services.entities.auth_entities import LoginFailureReason, LoginPayloadBase
from services.errors.account import (
AccountRegisterError,
@@ -71,6 +77,7 @@ from services.errors.account import (
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService
from services.turnstile_service import (
+ EMAIL_CODE_VERIFY_ACTION,
TurnstileChallengeRejectedError,
TurnstileService,
TurnstileUpstreamError,
@@ -92,14 +99,20 @@ class EmailPayload(BaseModel):
class EmailCodeSendPayload(EmailPayload):
turnstile_token: str | None = Field(
default=None,
+ max_length=2048,
description="Cloudflare Turnstile token. Required at runtime for Dify Cloud.",
)
class EmailCodeLoginPayload(BaseModel):
email: EmailStr = Field(...)
- code: str = Field(...)
- token: str = Field(...)
+ code: str
+ token: UUID
+ turnstile_token: str | None = Field(
+ default=None,
+ max_length=2048,
+ description="Cloudflare Turnstile token for email-code verification.",
+ )
language: str | None = Field(default=None)
timezone: str | None = Field(default=None)
@@ -310,23 +323,55 @@ class EmailCodeLoginApi(Resource):
original_email = req_data.email
user_email = original_email.lower()
language = req_data.language
+ ip_address = extract_remote_ip(request)
- token_data = AccountService.get_email_code_login_data(req_data.token)
- if token_data is None:
- _log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE_TOKEN)
- raise InvalidTokenError()
-
- token_email = token_data.get("email")
- normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
- if normalized_token_email != user_email:
- _log_console_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH)
- raise InvalidEmailError()
-
- if token_data["code"] != req_data.code:
+ # ``code`` is Base64 on the wire and is decoded by
+ # ``decrypt_code_field`` before model validation reaches this handler.
+ if len(req_data.code) != 6 or not req_data.code.isascii() or not req_data.code.isdigit():
+ _log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE)
+ raise EmailCodeError()
+
+ if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and (
+ dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED or req_data.turnstile_token
+ ):
+ try:
+ TurnstileService.verify(
+ token=req_data.turnstile_token,
+ remote_ip=ip_address,
+ expected_action=EMAIL_CODE_VERIFY_ACTION,
+ )
+ except TurnstileChallengeRejectedError as exc:
+ logger.info("Turnstile rejected an email-code verification challenge")
+ raise TurnstileVerificationFailedError() from exc
+ except TurnstileUpstreamError as exc:
+ logger.warning("Turnstile verification is unavailable", exc_info=True)
+ raise TurnstileServiceUnavailableError() from exc
+
+ try:
+ verification = AccountService.verify_email_code_login_challenge(
+ email=user_email,
+ code=req_data.code,
+ token=str(req_data.token),
+ )
+ except EmailCodeLoginChallengeUnavailableError as exc:
+ logger.warning("Email-code challenge verification is unavailable", exc_info=True)
+ raise EmailCodeLoginServiceUnavailableError() from exc
+
+ if verification.status == EmailCodeLoginChallengeStatus.INVALID_TOKEN:
+ _log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE_TOKEN)
+ raise InvalidTokenError()
+
+ if verification.status == EmailCodeLoginChallengeStatus.EMAIL_MISMATCH:
+ _log_console_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH)
+ raise InvalidEmailError()
+
+ if verification.status in {
+ EmailCodeLoginChallengeStatus.INVALID_CODE,
+ EmailCodeLoginChallengeStatus.EXHAUSTED,
+ }:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE)
raise EmailCodeError()
- AccountService.revoke_email_code_login_token(req_data.token)
try:
account = _get_account_with_case_fallback(original_email)
except Unauthorized as exc:
@@ -346,7 +391,6 @@ class EmailCodeLoginApi(Resource):
else:
TenantService.create_owner_tenant(account, session=db.session())
- ip_address = extract_remote_ip(request)
if account is None:
try:
account = AccountService.create_account_and_tenant(
diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md
index 29559ee641a..6590baf3326 100644
--- a/api/openapi/markdown/console-openapi.md
+++ b/api/openapi/markdown/console-openapi.md
@@ -17208,7 +17208,8 @@ Portable DSL reference that could not be restored in the target workspace.
| email | string | | Yes |
| language | string | | No |
| timezone | string | | No |
-| token | string | | Yes |
+| token | string (uuid) | | Yes |
+| turnstile_token | string | Cloudflare Turnstile token for email-code verification. | No |
#### EmailCodeSendPayload
diff --git a/api/services/account_service.py b/api/services/account_service.py
index 66ca44342da..eec13a8310a 100644
--- a/api/services/account_service.py
+++ b/api/services/account_service.py
@@ -48,6 +48,10 @@ from models.account import (
from models.dataset import Dataset
from models.model import App, DifySetup
from services.billing_service import BillingService
+from services.email_code_login_challenge import (
+ EmailCodeLoginChallengeResult,
+ EmailCodeLoginChallengeStore,
+)
from services.enterprise.rbac_service import ListOption, RBACService
from services.entities.auth_entities import (
ChangeEmailNewEmailToken,
@@ -1017,14 +1021,17 @@ class AccountService:
email = account.email if account else email
if email is None:
raise ValueError("Email must be provided.")
+ email = email.lower()
if cls.email_code_login_rate_limiter.is_rate_limited(email):
from controllers.console.auth.error import EmailCodeLoginRateLimitExceededError
raise EmailCodeLoginRateLimitExceededError(int(cls.email_code_login_rate_limiter.time_window / 60))
code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)])
- token = TokenManager.generate_token(
- account=account, email=email, token_type="email_code_login", additional_data={"code": code}
+ token = EmailCodeLoginChallengeStore.create(
+ account_id=str(account.id) if account else None,
+ email=email,
+ code=code,
)
send_email_code_login_mail_task.delay(
language=language,
@@ -1052,6 +1059,10 @@ class AccountService:
def get_email_code_login_data(cls, token: str) -> dict[str, Any] | None:
return TokenManager.get_token_data(token, "email_code_login")
+ @classmethod
+ def verify_email_code_login_challenge(cls, *, email: str, code: str, token: str) -> EmailCodeLoginChallengeResult:
+ return EmailCodeLoginChallengeStore.verify(email=email, code=code, token=token)
+
@classmethod
def revoke_email_code_login_token(cls, token: str):
TokenManager.revoke_token(token, "email_code_login")
diff --git a/api/services/email_code_login_challenge.py b/api/services/email_code_login_challenge.py
new file mode 100644
index 00000000000..f7e09c95514
--- /dev/null
+++ b/api/services/email_code_login_challenge.py
@@ -0,0 +1,268 @@
+from __future__ import annotations
+
+import json
+import uuid
+from dataclasses import dataclass
+from enum import IntEnum, StrEnum
+from hashlib import sha256
+
+from redis.exceptions import RedisError
+
+from configs import dify_config
+from extensions.ext_redis import redis_client
+from extensions.redis_names import serialize_redis_name
+
+_TOKEN_TYPE = "email_code_login"
+_CHALLENGE_VERSION = 2
+
+
+# The per-email v2 challenge is the sole state for tokens created by this
+# implementation. Lua result codes must stay in sync with ``_LuaResult``.
+_VERIFY_CHALLENGE_LUA = """
+local raw = redis.call('GET', KEYS[1])
+if not raw then
+ return {0, -1}
+end
+
+local decoded, data = pcall(cjson.decode, raw)
+if not decoded or type(data) ~= 'table' then
+ return {5, -1}
+end
+
+if data.token_type ~= ARGV[1] or tonumber(data.challenge_version) ~= tonumber(ARGV[5]) then
+ return {5, -1}
+end
+
+if data.state == 'consumed' or data.state == 'exhausted' then
+ return {8, -1}
+end
+
+if type(data.token) ~= 'string' or data.token ~= ARGV[2] then
+ return {1, -1}
+end
+
+if type(data.email) ~= 'string' or data.email ~= ARGV[3] then
+ return {2, -1}
+end
+
+if type(data.code) ~= 'string' then
+ return {5, -1}
+end
+
+local remaining = tonumber(data.remaining_attempts)
+if not remaining or remaining <= 0 then
+ local tombstone = {
+ token_type = data.token_type,
+ challenge_version = data.challenge_version,
+ state = 'exhausted',
+ remaining_attempts = 0
+ }
+ redis.call('SET', KEYS[1], cjson.encode(tombstone), 'KEEPTTL')
+ return {6, 0}
+end
+
+if data.code == ARGV[4] then
+ local tombstone = {
+ token_type = data.token_type,
+ challenge_version = data.challenge_version,
+ state = 'consumed',
+ remaining_attempts = 0
+ }
+ redis.call('SET', KEYS[1], cjson.encode(tombstone), 'KEEPTTL')
+ return {4, -1}
+end
+
+remaining = remaining - 1
+if remaining <= 0 then
+ local tombstone = {
+ token_type = data.token_type,
+ challenge_version = data.challenge_version,
+ state = 'exhausted',
+ remaining_attempts = 0
+ }
+ redis.call('SET', KEYS[1], cjson.encode(tombstone), 'KEEPTTL')
+ return {6, 0}
+end
+
+data.remaining_attempts = remaining
+redis.call('SET', KEYS[1], cjson.encode(data), 'KEEPTTL')
+return {3, remaining}
+"""
+
+
+# Tokens created before this deployment only have the legacy per-token key.
+# This fallback gives those in-flight tokens the same atomic attempt budget.
+# A versioned token is never accepted here, so a consumed v2 challenge cannot
+# fall back even if a stale legacy key is present unexpectedly.
+_VERIFY_LEGACY_TOKEN_LUA = """
+local raw = redis.call('GET', KEYS[1])
+if not raw then
+ return {0, -1}
+end
+
+local decoded, data = pcall(cjson.decode, raw)
+if not decoded or type(data) ~= 'table' then
+ return {5, -1}
+end
+
+if data.token_type ~= ARGV[1] or type(data.email) ~= 'string' or type(data.code) ~= 'string' then
+ return {5, -1}
+end
+
+if string.lower(data.email) ~= ARGV[2] then
+ return {2, -1}
+end
+
+if data.challenge_version ~= nil then
+ return {7, -1}
+end
+
+local remaining = tonumber(data.remaining_attempts)
+if not remaining then
+ remaining = tonumber(ARGV[4])
+end
+if not remaining or remaining <= 0 then
+ redis.call('DEL', KEYS[1])
+ return {6, 0}
+end
+
+if data.code == ARGV[3] then
+ redis.call('DEL', KEYS[1])
+ return {4, -1}
+end
+
+remaining = remaining - 1
+if remaining <= 0 then
+ redis.call('DEL', KEYS[1])
+ return {6, 0}
+end
+
+data.remaining_attempts = remaining
+redis.call('SET', KEYS[1], cjson.encode(data), 'KEEPTTL')
+return {3, remaining}
+"""
+
+
+class EmailCodeLoginChallengeStatus(StrEnum):
+ VERIFIED = "verified"
+ INVALID_TOKEN = "invalid_token"
+ EMAIL_MISMATCH = "email_mismatch"
+ INVALID_CODE = "invalid_code"
+ EXHAUSTED = "exhausted"
+
+
+@dataclass(frozen=True)
+class EmailCodeLoginChallengeResult:
+ status: EmailCodeLoginChallengeStatus
+ remaining_attempts: int | None = None
+
+
+class EmailCodeLoginChallengeUnavailableError(RuntimeError):
+ """The Redis-backed email-code challenge could not be safely evaluated."""
+
+
+class _LuaResult(IntEnum):
+ MISSING = 0
+ TOKEN_MISMATCH = 1
+ EMAIL_MISMATCH = 2
+ INVALID_CODE = 3
+ VERIFIED = 4
+ CORRUPT = 5
+ EXHAUSTED = 6
+ VERSIONED_LEGACY_TOKEN = 7
+ TERMINAL_CHALLENGE = 8
+
+
+class EmailCodeLoginChallengeStore:
+ @classmethod
+ def create(cls, *, email: str, code: str, account_id: str | None) -> str:
+ normalized_email = email.lower()
+ token = str(uuid.uuid4())
+ payload = {
+ "account_id": account_id,
+ "email": normalized_email,
+ "token_type": _TOKEN_TYPE,
+ "code": code,
+ "remaining_attempts": dify_config.EMAIL_CODE_LOGIN_MAX_ATTEMPTS,
+ "challenge_version": _CHALLENGE_VERSION,
+ "state": "active",
+ "token": token,
+ }
+ expiry_seconds = int(dify_config.EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES * 60)
+
+ try:
+ # Overwriting this one key makes a resend invalidate the previous
+ # token for the normalized email without creating extra budgets.
+ redis_client.setex(
+ cls._challenge_key(normalized_email),
+ expiry_seconds,
+ json.dumps(payload, separators=(",", ":")),
+ )
+ except RedisError as exc:
+ raise EmailCodeLoginChallengeUnavailableError("Could not create email-code challenge") from exc
+
+ return token
+
+ @classmethod
+ def verify(cls, *, email: str, code: str, token: str) -> EmailCodeLoginChallengeResult:
+ normalized_email = email.lower()
+ max_attempts = dify_config.EMAIL_CODE_LOGIN_MAX_ATTEMPTS
+
+ try:
+ challenge_result = cls._eval(
+ _VERIFY_CHALLENGE_LUA,
+ cls._challenge_key(normalized_email),
+ _TOKEN_TYPE,
+ token,
+ normalized_email,
+ code,
+ _CHALLENGE_VERSION,
+ )
+ if challenge_result[0] is not _LuaResult.MISSING:
+ return cls._to_public_result(challenge_result)
+
+ # Only a token created before this deployment can reach the
+ # legacy fallback because new tokens are never written there.
+ legacy_result = cls._eval(
+ _VERIFY_LEGACY_TOKEN_LUA,
+ cls._legacy_token_key(token),
+ _TOKEN_TYPE,
+ normalized_email,
+ code,
+ max_attempts,
+ )
+ return cls._to_public_result(legacy_result)
+ except (RedisError, TypeError, ValueError) as exc:
+ raise EmailCodeLoginChallengeUnavailableError("Could not verify email-code challenge") from exc
+
+ @staticmethod
+ def _eval(script: str, key: str, *args: str | int) -> tuple[_LuaResult, int | None]:
+ # ``eval`` is delegated to the raw Redis client, so unlike the wrapper's
+ # normal commands it needs an explicitly serialized physical key.
+ response = redis_client.eval(script, 1, serialize_redis_name(key), *args)
+ if not isinstance(response, (list, tuple)) or len(response) != 2:
+ raise ValueError("Unexpected Redis Lua response")
+
+ lua_result = _LuaResult(int(response[0]))
+ remaining = int(response[1])
+ return lua_result, remaining if remaining >= 0 else None
+
+ @staticmethod
+ def _to_public_result(result: tuple[_LuaResult, int | None]) -> EmailCodeLoginChallengeResult:
+ lua_result, remaining = result
+ status = {
+ _LuaResult.VERIFIED: EmailCodeLoginChallengeStatus.VERIFIED,
+ _LuaResult.EMAIL_MISMATCH: EmailCodeLoginChallengeStatus.EMAIL_MISMATCH,
+ _LuaResult.INVALID_CODE: EmailCodeLoginChallengeStatus.INVALID_CODE,
+ _LuaResult.EXHAUSTED: EmailCodeLoginChallengeStatus.EXHAUSTED,
+ }.get(lua_result, EmailCodeLoginChallengeStatus.INVALID_TOKEN)
+ return EmailCodeLoginChallengeResult(status=status, remaining_attempts=remaining)
+
+ @staticmethod
+ def _challenge_key(normalized_email: str) -> str:
+ email_digest = sha256(normalized_email.encode("utf-8")).hexdigest()
+ return f"email_code_login:challenge:{{{email_digest}}}"
+
+ @staticmethod
+ def _legacy_token_key(token: str) -> str:
+ return f"{_TOKEN_TYPE}:token:{token}"
diff --git a/api/services/turnstile_service.py b/api/services/turnstile_service.py
index 84634b2310a..a1da315831a 100644
--- a/api/services/turnstile_service.py
+++ b/api/services/turnstile_service.py
@@ -7,7 +7,8 @@ from configs import dify_config
from core.helper.http_client_pooling import get_pooled_http_client
_SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
-_EXPECTED_ACTION = "signin_code"
+EMAIL_CODE_SEND_ACTION = "signin_code"
+EMAIL_CODE_VERIFY_ACTION = "signin_code_verify"
_MAX_TOKEN_LENGTH = 2048
_CLIENT_ERROR_CODES = frozenset(
{
@@ -44,7 +45,13 @@ class _TurnstileResponse(BaseModel):
class TurnstileService:
@classmethod
- def verify(cls, *, token: str | None, remote_ip: str | None) -> None:
+ def verify(
+ cls,
+ *,
+ token: str | None,
+ remote_ip: str | None,
+ expected_action: str = EMAIL_CODE_SEND_ACTION,
+ ) -> None:
normalized_token = token.strip() if token else ""
if not normalized_token or len(normalized_token) > _MAX_TOKEN_LENGTH:
raise TurnstileChallengeRejectedError
@@ -74,7 +81,7 @@ class TurnstileService:
raise TurnstileChallengeRejectedError
raise TurnstileUpstreamError("Turnstile returned a server-side verification error")
- if result.action != _EXPECTED_ACTION or not cls._is_allowed_hostname(result.hostname, allowed_hostnames):
+ if result.action != expected_action or not cls._is_allowed_hostname(result.hostname, allowed_hostnames):
raise TurnstileChallengeRejectedError
@staticmethod
diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py
index f606b731e4a..85ae1054677 100644
--- a/api/tests/unit_tests/configs/test_dify_config.py
+++ b/api/tests/unit_tests/configs/test_dify_config.py
@@ -121,11 +121,19 @@ def test_turnstile_config_is_parsed() -> None:
config = _make_config(
TURNSTILE_SECRET_KEY=" test-secret ",
TURNSTILE_ALLOWED_HOSTNAMES="dify.dev, Login.Example.COM. ",
+ TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED="true",
)
assert isinstance(config.TURNSTILE_SECRET_KEY, SecretStr)
assert config.TURNSTILE_SECRET_KEY.get_secret_value() == "test-secret"
assert frozenset({"dify.dev", "login.example.com"}) == config.TURNSTILE_ALLOWED_HOSTNAME_SET
+ assert config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED is True
+
+
+def test_email_code_login_attempt_budget_is_parsed() -> None:
+ config = _make_config(EMAIL_CODE_LOGIN_MAX_ATTEMPTS="7")
+
+ assert config.EMAIL_CODE_LOGIN_MAX_ATTEMPTS == 7
def test_plugin_remote_install_port_rejects_host_port_spec() -> None:
diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py
index 0279936f635..573934239f5 100644
--- a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py
+++ b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py
@@ -17,6 +17,7 @@ from pydantic import ValidationError
from controllers.console.auth.error import (
EmailCodeError,
+ EmailCodeLoginServiceUnavailableError,
InvalidEmailError,
InvalidTokenError,
TurnstileServiceUnavailableError,
@@ -37,9 +38,16 @@ from controllers.console.error import (
WorkspacesLimitExceeded,
)
from enums import DeploymentEdition
+from services.email_code_login_challenge import (
+ EmailCodeLoginChallengeResult,
+ EmailCodeLoginChallengeStatus,
+ EmailCodeLoginChallengeUnavailableError,
+)
from services.errors.account import AccountRegisterError
from services.turnstile_service import TurnstileChallengeRejectedError, TurnstileUpstreamError
+TEST_TOKEN = "00000000-0000-4000-8000-000000000001"
+
def encode_code(code: str) -> str:
"""Helper to encode verification code as Base64 for testing."""
@@ -52,7 +60,7 @@ def test_email_code_login_payload_rejects_invalid_timezone():
{
"email": "newuser@example.com",
"code": "123456",
- "token": "token-123",
+ "token": TEST_TOKEN,
"timezone": "",
}
)
@@ -61,6 +69,18 @@ def test_email_code_login_payload_rejects_invalid_timezone():
def test_turnstile_token_is_scoped_to_email_code_send_payload():
assert "turnstile_token" in EmailCodeSendPayload.model_fields
assert "turnstile_token" not in EmailPayload.model_fields
+ assert "turnstile_token" in EmailCodeLoginPayload.model_fields
+
+
+def test_email_code_login_code_schema_does_not_describe_plaintext_format():
+ code_schema = EmailCodeLoginPayload.model_json_schema()["properties"]["code"]
+
+ assert "pattern" not in code_schema
+
+
+def test_email_code_login_payload_rejects_non_uuid_token():
+ with pytest.raises(ValidationError):
+ EmailCodeLoginPayload.model_validate({"email": "user@example.com", "code": "123456", "token": "not-a-uuid"})
class TestEmailCodeLoginSendEmailApi:
@@ -379,9 +399,146 @@ class TestEmailCodeLoginApi:
token_pair.csrf_token = "csrf_token"
return token_pair
+ @pytest.mark.parametrize("code", ["12345", "1234567", "abcdef", "١٢٣٤٥٦"])
@patch("controllers.console.wraps.db")
- @patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
- @patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token")
+ @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
+ def test_rejects_malformed_code_after_wire_decode(
+ self,
+ mock_verify_challenge,
+ mock_db,
+ app: Flask,
+ code: str,
+ ):
+ with (
+ app.test_request_context(
+ "/email-code-login/validity",
+ method="POST",
+ json={"email": "test@example.com", "code": encode_code(code), "token": TEST_TOKEN},
+ ),
+ pytest.raises(EmailCodeError),
+ ):
+ EmailCodeLoginApi().post()
+
+ mock_verify_challenge.assert_not_called()
+
+ @patch("controllers.console.wraps.db")
+ @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
+ @patch("controllers.console.auth.login.TurnstileService.verify")
+ def test_cloud_verify_uses_separate_turnstile_action_when_required(
+ self,
+ mock_turnstile_verify,
+ mock_verify_challenge,
+ mock_db,
+ app: Flask,
+ ):
+ mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
+ status=EmailCodeLoginChallengeStatus.INVALID_TOKEN
+ )
+
+ with (
+ patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
+ patch("controllers.console.auth.login.dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED", True),
+ app.test_request_context(
+ "/email-code-login/validity",
+ method="POST",
+ json={
+ "email": "test@example.com",
+ "code": encode_code("123456"),
+ "token": TEST_TOKEN,
+ "turnstile_token": "verify-challenge-token",
+ },
+ headers={"CF-Connecting-IP": "203.0.113.8"},
+ ),
+ pytest.raises(InvalidTokenError),
+ ):
+ EmailCodeLoginApi().post()
+
+ mock_turnstile_verify.assert_called_once_with(
+ token="verify-challenge-token",
+ remote_ip="203.0.113.8",
+ expected_action="signin_code_verify",
+ )
+
+ @patch("controllers.console.wraps.db")
+ @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
+ @patch(
+ "controllers.console.auth.login.TurnstileService.verify",
+ side_effect=TurnstileChallengeRejectedError,
+ )
+ def test_cloud_verify_rejects_missing_turnstile_before_consuming_code(
+ self,
+ mock_turnstile_verify,
+ mock_verify_challenge,
+ mock_db,
+ app: Flask,
+ ):
+ with (
+ patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
+ patch("controllers.console.auth.login.dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED", True),
+ app.test_request_context(
+ "/email-code-login/validity",
+ method="POST",
+ json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
+ ),
+ pytest.raises(TurnstileVerificationFailedError),
+ ):
+ EmailCodeLoginApi().post()
+
+ mock_turnstile_verify.assert_called_once()
+ mock_verify_challenge.assert_not_called()
+
+ @patch("controllers.console.wraps.db")
+ @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
+ @patch("controllers.console.auth.login.TurnstileService.verify")
+ def test_cloud_verify_flag_off_allows_legacy_client_without_turnstile(
+ self,
+ mock_turnstile_verify,
+ mock_verify_challenge,
+ mock_db,
+ app: Flask,
+ ):
+ mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
+ status=EmailCodeLoginChallengeStatus.INVALID_TOKEN
+ )
+
+ with (
+ patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
+ patch("controllers.console.auth.login.dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED", False),
+ app.test_request_context(
+ "/email-code-login/validity",
+ method="POST",
+ json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
+ ),
+ pytest.raises(InvalidTokenError),
+ ):
+ EmailCodeLoginApi().post()
+
+ mock_turnstile_verify.assert_not_called()
+ mock_verify_challenge.assert_called_once()
+
+ @patch("controllers.console.wraps.db")
+ @patch(
+ "controllers.console.auth.login.AccountService.verify_email_code_login_challenge",
+ side_effect=EmailCodeLoginChallengeUnavailableError,
+ )
+ def test_verify_maps_redis_failure_to_service_unavailable(
+ self,
+ mock_verify_challenge,
+ mock_db,
+ app: Flask,
+ ):
+ with (
+ app.test_request_context(
+ "/email-code-login/validity",
+ method="POST",
+ json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
+ ),
+ pytest.raises(EmailCodeLoginServiceUnavailableError),
+ ):
+ EmailCodeLoginApi().post()
+
+ @patch("controllers.console.wraps.db")
+ @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
@patch("controllers.console.auth.login.AccountService.get_user_through_email")
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
@patch("controllers.console.auth.login.AccountService.login")
@@ -392,8 +549,7 @@ class TestEmailCodeLoginApi:
mock_login,
mock_get_tenants,
mock_get_user,
- mock_revoke_token,
- mock_get_data,
+ mock_verify_challenge,
mock_db,
app: Flask,
mock_account,
@@ -408,7 +564,9 @@ class TestEmailCodeLoginApi:
- User is logged in with token pair
"""
# Arrange
- mock_get_data.return_value = {"email": "test@example.com", "code": "123456"}
+ mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
+ status=EmailCodeLoginChallengeStatus.VERIFIED
+ )
mock_get_user.return_value = mock_account
mock_get_tenants.return_value = [MagicMock()]
mock_login.return_value = mock_token_pair
@@ -417,19 +575,18 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
- json={"email": "test@example.com", "code": encode_code("123456"), "token": "valid_token"},
+ json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
):
api = EmailCodeLoginApi()
response = api.post()
# Assert
assert response.json["result"] == "success"
- mock_revoke_token.assert_called_once_with("valid_token")
+ mock_verify_challenge.assert_called_once_with(email="test@example.com", code="123456", token=TEST_TOKEN)
mock_login.assert_called_once()
@patch("controllers.console.wraps.db")
- @patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
- @patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token")
+ @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
@patch("controllers.console.auth.login.AccountService.get_user_through_email")
@patch("controllers.console.auth.login.AccountService.create_account_and_tenant")
@patch("controllers.console.auth.login.AccountService.login")
@@ -440,8 +597,7 @@ class TestEmailCodeLoginApi:
mock_login,
mock_create_account,
mock_get_user,
- mock_revoke_token,
- mock_get_data,
+ mock_verify_challenge,
mock_db,
app: Flask,
mock_account,
@@ -456,7 +612,9 @@ class TestEmailCodeLoginApi:
- User is logged in after account creation
"""
# Arrange
- mock_get_data.return_value = {"email": "newuser@example.com", "code": "123456"}
+ mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
+ status=EmailCodeLoginChallengeStatus.VERIFIED
+ )
mock_get_user.return_value = None
mock_create_account.return_value = mock_account
mock_login.return_value = mock_token_pair
@@ -470,7 +628,7 @@ class TestEmailCodeLoginApi:
json={
"email": "newuser@example.com",
"code": encode_code("123456"),
- "token": "valid_token",
+ "token": TEST_TOKEN,
"language": "en-US",
"timezone": "Asia/Shanghai",
},
@@ -491,8 +649,8 @@ class TestEmailCodeLoginApi:
)
@patch("controllers.console.wraps.db")
- @patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
- def test_email_code_login_invalid_token(self, mock_get_data, mock_db, app: Flask):
+ @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
+ def test_email_code_login_invalid_token(self, mock_verify_challenge, mock_db, app: Flask):
"""
Test email code login with invalid token.
@@ -500,21 +658,23 @@ class TestEmailCodeLoginApi:
- InvalidTokenError is raised for invalid/expired tokens
"""
# Arrange
- mock_get_data.return_value = None
+ mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
+ status=EmailCodeLoginChallengeStatus.INVALID_TOKEN
+ )
# Act & Assert
with app.test_request_context(
"/email-code-login/validity",
method="POST",
- json={"email": "test@example.com", "code": encode_code("123456"), "token": "invalid_token"},
+ json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
):
api = EmailCodeLoginApi()
with pytest.raises(InvalidTokenError):
api.post()
@patch("controllers.console.wraps.db")
- @patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
- def test_email_code_login_email_mismatch(self, mock_get_data, mock_db, app: Flask):
+ @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
+ def test_email_code_login_email_mismatch(self, mock_verify_challenge, mock_db, app: Flask):
"""
Test email code login with mismatched email.
@@ -522,21 +682,23 @@ class TestEmailCodeLoginApi:
- InvalidEmailError is raised when email doesn't match token
"""
# Arrange
- mock_get_data.return_value = {"email": "original@example.com", "code": "123456"}
+ mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
+ status=EmailCodeLoginChallengeStatus.EMAIL_MISMATCH
+ )
# Act & Assert
with app.test_request_context(
"/email-code-login/validity",
method="POST",
- json={"email": "different@example.com", "code": encode_code("123456"), "token": "token"},
+ json={"email": "different@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
):
api = EmailCodeLoginApi()
with pytest.raises(InvalidEmailError):
api.post()
@patch("controllers.console.wraps.db")
- @patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
- def test_email_code_login_wrong_code(self, mock_get_data, mock_db, app: Flask):
+ @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
+ def test_email_code_login_wrong_code(self, mock_verify_challenge, mock_db, app: Flask):
"""
Test email code login with incorrect code.
@@ -544,21 +706,23 @@ class TestEmailCodeLoginApi:
- EmailCodeError is raised for wrong verification code
"""
# Arrange
- mock_get_data.return_value = {"email": "test@example.com", "code": "123456"}
+ mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
+ status=EmailCodeLoginChallengeStatus.INVALID_CODE,
+ remaining_attempts=4,
+ )
# Act & Assert
with app.test_request_context(
"/email-code-login/validity",
method="POST",
- json={"email": "test@example.com", "code": encode_code("wrong_code"), "token": "token"},
+ json={"email": "test@example.com", "code": encode_code("654321"), "token": TEST_TOKEN},
):
api = EmailCodeLoginApi()
with pytest.raises(EmailCodeError):
api.post()
@patch("controllers.console.wraps.db")
- @patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
- @patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token")
+ @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
@patch("controllers.console.auth.login.AccountService.get_user_through_email")
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
@patch("controllers.console.auth.login.FeatureService.is_workspace_creation_allowed")
@@ -567,8 +731,7 @@ class TestEmailCodeLoginApi:
mock_is_workspace_creation_allowed,
mock_get_tenants,
mock_get_user,
- mock_revoke_token,
- mock_get_data,
+ mock_verify_challenge,
mock_db,
app: Flask,
mock_account,
@@ -581,7 +744,9 @@ class TestEmailCodeLoginApi:
- User is added as owner of new workspace
"""
# Arrange
- mock_get_data.return_value = {"email": "test@example.com", "code": "123456"}
+ mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
+ status=EmailCodeLoginChallengeStatus.VERIFIED
+ )
mock_get_user.return_value = mock_account
mock_get_tenants.return_value = []
mock_is_workspace_creation_allowed.return_value = True
@@ -590,15 +755,14 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
- json={"email": "test@example.com", "code": "123456", "token": "token"},
+ json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
):
api = EmailCodeLoginApi()
# This would complete the flow, but we're testing workspace creation logic
# In real implementation, TenantService.create_tenant would be called
@patch("controllers.console.wraps.db")
- @patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
- @patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token")
+ @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
@patch("controllers.console.auth.login.AccountService.get_user_through_email")
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
@patch("controllers.console.auth.login.FeatureService.get_license")
@@ -609,8 +773,7 @@ class TestEmailCodeLoginApi:
mock_get_license,
mock_get_tenants,
mock_get_user,
- mock_revoke_token,
- mock_get_data,
+ mock_verify_challenge,
mock_db,
app: Flask,
mock_account,
@@ -622,7 +785,9 @@ class TestEmailCodeLoginApi:
- WorkspacesLimitExceeded is raised when limit reached
"""
# Arrange
- mock_get_data.return_value = {"email": "test@example.com", "code": "123456"}
+ mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
+ status=EmailCodeLoginChallengeStatus.VERIFIED
+ )
mock_get_user.return_value = mock_account
mock_get_tenants.return_value = []
mock_get_license.return_value.workspaces.is_available.return_value = False
@@ -632,15 +797,14 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
- json={"email": "test@example.com", "code": encode_code("123456"), "token": "token"},
+ json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
):
api = EmailCodeLoginApi()
with pytest.raises(WorkspacesLimitExceeded):
api.post()
@patch("controllers.console.wraps.db")
- @patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
- @patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token")
+ @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
@patch("controllers.console.auth.login.AccountService.get_user_through_email")
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
@patch("controllers.console.auth.login.FeatureService.is_workspace_creation_allowed")
@@ -649,8 +813,7 @@ class TestEmailCodeLoginApi:
mock_is_workspace_creation_allowed,
mock_get_tenants,
mock_get_user,
- mock_revoke_token,
- mock_get_data,
+ mock_verify_challenge,
mock_db,
app: Flask,
mock_account,
@@ -662,7 +825,9 @@ class TestEmailCodeLoginApi:
- NotAllowedCreateWorkspace is raised when creation disabled
"""
# Arrange
- mock_get_data.return_value = {"email": "test@example.com", "code": "123456"}
+ mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
+ status=EmailCodeLoginChallengeStatus.VERIFIED
+ )
mock_get_user.return_value = mock_account
mock_get_tenants.return_value = []
mock_is_workspace_creation_allowed.return_value = False
@@ -671,7 +836,7 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
- json={"email": "test@example.com", "code": encode_code("123456"), "token": "token"},
+ json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
):
api = EmailCodeLoginApi()
with pytest.raises(NotAllowedCreateWorkspace):
diff --git a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py
index 970acd52cfa..a4da84d1c77 100644
--- a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py
+++ b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py
@@ -30,9 +30,12 @@ from controllers.console.error import (
WorkspacesLimitExceeded,
)
from enums import DeploymentEdition
+from services.email_code_login_challenge import EmailCodeLoginChallengeResult, EmailCodeLoginChallengeStatus
from services.entities.auth_entities import LoginFailureReason
from services.errors.account import AccountLoginError, AccountPasswordError, SeatsLimitExceededError
+TEST_TOKEN = "00000000-0000-4000-8000-000000000001"
+
def encode_password(password: str) -> str:
"""Helper to encode password as Base64 for testing."""
@@ -458,30 +461,29 @@ class TestLoginApi:
mock_reset_rate_limit.assert_called_once_with("upper@example.com")
@patch("controllers.console.wraps.db")
- @patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
- @patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token")
+ @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
@patch("controllers.console.auth.login._get_account_with_case_fallback")
def test_email_code_login_logs_banned_account(
self,
mock_get_account: MagicMock,
- mock_revoke_token: MagicMock,
- mock_get_token_data: MagicMock,
+ mock_verify_challenge: MagicMock,
mock_db: MagicMock,
app: Flask,
caplog: pytest.LogCaptureFixture,
):
- mock_get_token_data.return_value = {"email": "User@Example.com", "code": "123456"}
+ mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
+ status=EmailCodeLoginChallengeStatus.VERIFIED
+ )
mock_get_account.side_effect = Unauthorized("Account is banned.")
with app.test_request_context(
"/email-code-login/validity",
method="POST",
- json={"email": "User@Example.com", "code": encode_code("123456"), "token": "token-123"},
+ json={"email": "User@Example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
):
with pytest.raises(AccountBannedError):
EmailCodeLoginApi().post()
- mock_revoke_token.assert_called_once_with("token-123")
warn_records = [
r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING
]
@@ -492,14 +494,12 @@ class TestLoginApi:
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.db")
@patch("controllers.console.auth.login.AccountService.create_account_and_tenant")
- @patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
- @patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token")
+ @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
@patch("controllers.console.auth.login._get_account_with_case_fallback")
def test_email_code_login_fails_when_seats_limit_exceeded(
self,
mock_get_account: MagicMock,
- mock_revoke_token: MagicMock,
- mock_get_token_data: MagicMock,
+ mock_verify_challenge: MagicMock,
mock_create_account: MagicMock,
mock_login_db: MagicMock,
mock_db: MagicMock,
@@ -513,7 +513,9 @@ class TestLoginApi:
- the service-layer SeatsLimitExceededError is translated to the SeatsLimitExceeded HTTP error
"""
# Arrange: valid token, no existing account -> account-creation path
- mock_get_token_data.return_value = {"email": "User@Example.com", "code": "123456"}
+ mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
+ status=EmailCodeLoginChallengeStatus.VERIFIED
+ )
mock_get_account.return_value = None
mock_create_account.side_effect = SeatsLimitExceededError("licensed seats limit exceeded")
@@ -521,7 +523,7 @@ class TestLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
- json={"email": "User@Example.com", "code": encode_code("123456"), "token": "token-123"},
+ json={"email": "User@Example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
):
with pytest.raises(SeatsLimitExceeded):
EmailCodeLoginApi().post()
diff --git a/api/tests/unit_tests/services/test_email_code_login_challenge.py b/api/tests/unit_tests/services/test_email_code_login_challenge.py
new file mode 100644
index 00000000000..761c1bffe66
--- /dev/null
+++ b/api/tests/unit_tests/services/test_email_code_login_challenge.py
@@ -0,0 +1,174 @@
+import json
+from collections.abc import Iterator
+from unittest.mock import MagicMock, patch
+
+import pytest
+from redis.exceptions import ConnectionError
+
+from services.email_code_login_challenge import (
+ EmailCodeLoginChallengeStatus,
+ EmailCodeLoginChallengeStore,
+ EmailCodeLoginChallengeUnavailableError,
+)
+
+TOKEN = "00000000-0000-4000-8000-000000000001"
+
+
+@pytest.fixture
+def challenge_redis() -> Iterator[MagicMock]:
+ with patch("services.email_code_login_challenge.redis_client") as mock_redis:
+ yield mock_redis
+
+
+def test_create_stores_only_one_per_email_v2_challenge(
+ challenge_redis: MagicMock, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr("services.email_code_login_challenge.dify_config.EMAIL_CODE_LOGIN_MAX_ATTEMPTS", 5)
+ monkeypatch.setattr("services.email_code_login_challenge.dify_config.EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES", 5)
+
+ with patch("services.email_code_login_challenge.uuid.uuid4", return_value=TOKEN):
+ token = EmailCodeLoginChallengeStore.create(
+ email="User@Example.com",
+ code="123456",
+ account_id="account-id",
+ )
+
+ assert token == TOKEN
+ challenge_key, ttl, serialized_payload = challenge_redis.setex.call_args.args
+ assert challenge_key == EmailCodeLoginChallengeStore._challenge_key("user@example.com")
+ assert ttl == 300
+ assert json.loads(serialized_payload) == {
+ "account_id": "account-id",
+ "email": "user@example.com",
+ "token_type": "email_code_login",
+ "code": "123456",
+ "remaining_attempts": 5,
+ "challenge_version": 2,
+ "state": "active",
+ "token": TOKEN,
+ }
+ assert challenge_key != f"email_code_login:token:{TOKEN}"
+ challenge_redis.set.assert_not_called()
+ challenge_redis.delete.assert_not_called()
+
+
+def test_verify_current_challenge_decrements_budget_without_refreshing_ttl(challenge_redis: MagicMock) -> None:
+ challenge_redis.eval.return_value = [3, 4]
+
+ result = EmailCodeLoginChallengeStore.verify(
+ email="User@Example.com",
+ code="654321",
+ token=TOKEN,
+ )
+
+ assert result.status is EmailCodeLoginChallengeStatus.INVALID_CODE
+ assert result.remaining_attempts == 4
+ eval_args = challenge_redis.eval.call_args.args
+ assert eval_args[1] == 1
+ assert eval_args[2] == EmailCodeLoginChallengeStore._challenge_key("user@example.com")
+ assert eval_args[-5:] == ("email_code_login", TOKEN, "user@example.com", "654321", 2)
+ challenge_redis.set.assert_not_called()
+ challenge_redis.expire.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ ("lua_response", "expected_status"),
+ [
+ ([1, -1], EmailCodeLoginChallengeStatus.INVALID_TOKEN),
+ ([2, -1], EmailCodeLoginChallengeStatus.EMAIL_MISMATCH),
+ ([4, -1], EmailCodeLoginChallengeStatus.VERIFIED),
+ ([6, 0], EmailCodeLoginChallengeStatus.EXHAUSTED),
+ ([8, -1], EmailCodeLoginChallengeStatus.INVALID_TOKEN),
+ ],
+)
+def test_verify_maps_v2_lua_result(
+ challenge_redis: MagicMock,
+ lua_response: list[int],
+ expected_status: EmailCodeLoginChallengeStatus,
+) -> None:
+ challenge_redis.eval.return_value = lua_response
+
+ result = EmailCodeLoginChallengeStore.verify(
+ email="user@example.com",
+ code="123456",
+ token=TOKEN,
+ )
+
+ assert result.status is expected_status
+ challenge_redis.eval.assert_called_once()
+
+
+def test_terminal_v2_challenge_blocks_pre_rollout_legacy_token_fallback(challenge_redis: MagicMock) -> None:
+ legacy_token = "00000000-0000-4000-8000-000000000002"
+ challenge_redis.eval.return_value = [8, -1]
+
+ result = EmailCodeLoginChallengeStore.verify(
+ email="user@example.com",
+ code="111111",
+ token=legacy_token,
+ )
+
+ assert result.status is EmailCodeLoginChallengeStatus.INVALID_TOKEN
+ challenge_redis.eval.assert_called_once()
+ assert f"email_code_login:token:{legacy_token}" not in challenge_redis.eval.call_args.args
+
+
+def test_verify_supports_unversioned_token_created_before_rollout(challenge_redis: MagicMock) -> None:
+ challenge_redis.eval.side_effect = [[0, -1], [4, -1]]
+
+ result = EmailCodeLoginChallengeStore.verify(
+ email="user@example.com",
+ code="123456",
+ token=TOKEN,
+ )
+
+ assert result.status is EmailCodeLoginChallengeStatus.VERIFIED
+ assert challenge_redis.eval.call_count == 2
+ legacy_args = challenge_redis.eval.call_args_list[1].args
+ assert legacy_args[2] == f"email_code_login:token:{TOKEN}"
+ assert legacy_args[-4:] == ("email_code_login", "user@example.com", "123456", 5)
+
+
+def test_verify_rejects_versioned_payload_in_legacy_fallback(challenge_redis: MagicMock) -> None:
+ challenge_redis.eval.side_effect = [[0, -1], [7, -1]]
+
+ result = EmailCodeLoginChallengeStore.verify(
+ email="user@example.com",
+ code="123456",
+ token=TOKEN,
+ )
+
+ assert result.status is EmailCodeLoginChallengeStatus.INVALID_TOKEN
+
+
+def test_create_fails_closed_on_redis_error(challenge_redis: MagicMock) -> None:
+ challenge_redis.setex.side_effect = ConnectionError("redis unavailable")
+
+ with pytest.raises(EmailCodeLoginChallengeUnavailableError):
+ EmailCodeLoginChallengeStore.create(
+ email="user@example.com",
+ code="123456",
+ account_id=None,
+ )
+
+
+def test_verify_fails_closed_on_redis_error(challenge_redis: MagicMock) -> None:
+ challenge_redis.eval.side_effect = ConnectionError("redis unavailable")
+
+ with pytest.raises(EmailCodeLoginChallengeUnavailableError):
+ EmailCodeLoginChallengeStore.verify(
+ email="user@example.com",
+ code="123456",
+ token=TOKEN,
+ )
+
+
+def test_verify_fails_closed_on_unexpected_lua_response(challenge_redis: MagicMock) -> None:
+ challenge_redis.eval.return_value = None
+
+ with pytest.raises(EmailCodeLoginChallengeUnavailableError):
+ EmailCodeLoginChallengeStore.verify(
+ email="user@example.com",
+ code="123456",
+ token=TOKEN,
+ )
diff --git a/api/tests/unit_tests/services/test_turnstile_service.py b/api/tests/unit_tests/services/test_turnstile_service.py
index 795b914034f..5c2173b5eb8 100644
--- a/api/tests/unit_tests/services/test_turnstile_service.py
+++ b/api/tests/unit_tests/services/test_turnstile_service.py
@@ -5,6 +5,7 @@ import pytest
from pydantic import SecretStr
from services.turnstile_service import (
+ EMAIL_CODE_VERIFY_ACTION,
TurnstileChallengeRejectedError,
TurnstileService,
TurnstileUpstreamError,
@@ -46,6 +47,19 @@ def test_verify_accepts_subdomain_and_forwards_remote_ip(monkeypatch: pytest.Mon
)
+def test_verify_accepts_caller_scoped_action(monkeypatch: pytest.MonkeyPatch) -> None:
+ mock_response(
+ monkeypatch,
+ payload={"success": True, "action": EMAIL_CODE_VERIFY_ACTION, "hostname": "agent.dify.dev"},
+ )
+
+ TurnstileService.verify(
+ token="verified-token",
+ remote_ip=None,
+ expected_action=EMAIL_CODE_VERIFY_ACTION,
+ )
+
+
@pytest.mark.parametrize("token", [None, "", " ", "x" * 2049])
def test_verify_rejects_missing_or_oversized_token(monkeypatch: pytest.MonkeyPatch, token: str | None) -> None:
post = MagicMock()
diff --git a/docker/envs/core-services/api.env.example b/docker/envs/core-services/api.env.example
index 82962444c52..9344d5a1a6b 100644
--- a/docker/envs/core-services/api.env.example
+++ b/docker/envs/core-services/api.env.example
@@ -20,3 +20,4 @@ KNOWLEDGE_FS_TIMEOUT_SECONDS=10
# Cloudflare Turnstile server-side verification for Dify Cloud sign-in
TURNSTILE_SECRET_KEY=
TURNSTILE_ALLOWED_HOSTNAMES=
+TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED=false
diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example
index f4c1211e6b4..3cd595d9ea2 100644
--- a/docker/envs/core-services/shared.env.example
+++ b/docker/envs/core-services/shared.env.example
@@ -19,6 +19,8 @@ FILES_ACCESS_TIMEOUT=300
# System Features
MARKETPLACE_ENABLED=true
ENABLE_EMAIL_CODE_LOGIN=false
+EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES=5
+EMAIL_CODE_LOGIN_MAX_ATTEMPTS=5
ENABLE_EMAIL_PASSWORD_LOGIN=true
ENABLE_SOCIAL_OAUTH_LOGIN=false
# Remove `collaboration` from COMPOSE_PROFILES to stop the dedicated websocket service.
diff --git a/packages/contracts/generated/api/console/email-code-login/types.gen.ts b/packages/contracts/generated/api/console/email-code-login/types.gen.ts
index 293fe010f7b..a07b403d8d2 100644
--- a/packages/contracts/generated/api/console/email-code-login/types.gen.ts
+++ b/packages/contracts/generated/api/console/email-code-login/types.gen.ts
@@ -21,6 +21,7 @@ export type EmailCodeLoginPayload = {
language?: string | null
timezone?: string | null
token: string
+ turnstile_token?: string | null
}
export type SimpleResultResponse = {
diff --git a/packages/contracts/generated/api/console/email-code-login/zod.gen.ts b/packages/contracts/generated/api/console/email-code-login/zod.gen.ts
index 14c69a9076a..bd2af92db1b 100644
--- a/packages/contracts/generated/api/console/email-code-login/zod.gen.ts
+++ b/packages/contracts/generated/api/console/email-code-login/zod.gen.ts
@@ -8,7 +8,7 @@ import * as z from 'zod'
export const zEmailCodeSendPayload = z.object({
email: z.string(),
language: z.string().nullish(),
- turnstile_token: z.string().nullish(),
+ turnstile_token: z.string().max(2048).nullish(),
})
/**
@@ -27,7 +27,8 @@ export const zEmailCodeLoginPayload = z.object({
email: z.string(),
language: z.string().nullish(),
timezone: z.string().nullish(),
- token: z.string(),
+ token: z.uuid(),
+ turnstile_token: z.string().max(2048).nullish(),
})
/**
diff --git a/web/app/signin/check-code/__tests__/page.spec.tsx b/web/app/signin/check-code/__tests__/page.spec.tsx
index 97a7d1bf6d8..2700fd73629 100644
--- a/web/app/signin/check-code/__tests__/page.spec.tsx
+++ b/web/app/signin/check-code/__tests__/page.spec.tsx
@@ -1,7 +1,7 @@
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { act, render, screen, waitFor } from '@testing-library/react'
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common'
@@ -26,6 +26,7 @@ type ScriptProps = {
}
type TurnstileOptions = {
+ action: string
callback: (token: string) => void
}
@@ -36,6 +37,7 @@ const turnstileMocks = vi.hoisted(() => ({
scriptProps: undefined as ScriptProps | undefined,
siteKey: '',
}))
+const turnstileWidgets = new Map()
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
@@ -100,6 +102,14 @@ function createQueryClient() {
return queryClient
}
+function createDeferred() {
+ let resolve: (value: T) => void = () => {}
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise
+ })
+ return { promise, resolve }
+}
+
function installTurnstileApi() {
Object.defineProperty(window, 'turnstile', {
configurable: true,
@@ -128,6 +138,9 @@ const accountProfile: GetAccountProfileResponse = {
describe('CheckCode', () => {
beforeEach(() => {
vi.clearAllMocks()
+ vi.mocked(emailLoginWithCode).mockReset().mockResolvedValue({ result: 'success' })
+ vi.mocked(sendEMailLoginCode).mockReset()
+ turnstileWidgets.clear()
navigationMocks.searchParams = new URLSearchParams({
email: 'user@example.com',
redirect_url: '/apps',
@@ -142,17 +155,20 @@ describe('CheckCode', () => {
const verifyButton = document.createElement('button')
verifyButton.type = 'button'
verifyButton.dataset.widgetId = widgetId
- verifyButton.textContent = 'verify-turnstile'
- verifyButton.addEventListener('click', () => options.callback('fresh-turnstile-token'))
+ verifyButton.textContent = `verify-turnstile-${options.action}`
+ verifyButton.addEventListener('click', () =>
+ options.callback(`${options.action}-token-${turnstileMocks.render.mock.calls.length}`),
+ )
container.appendChild(verifyButton)
+ turnstileWidgets.set(widgetId, verifyButton)
return widgetId
},
)
turnstileMocks.remove.mockImplementation((widgetId: string) => {
- document.querySelector(`[data-widget-id="${widgetId}"]`)?.remove()
+ turnstileWidgets.get(widgetId)?.remove()
+ turnstileWidgets.delete(widgetId)
})
installTurnstileApi()
- vi.mocked(emailLoginWithCode).mockResolvedValue({ result: 'success' })
})
afterEach(() => {
@@ -191,7 +207,192 @@ describe('CheckCode', () => {
expect(navigationMocks.back).toHaveBeenCalledOnce()
})
- it('uses a fresh Turnstile token for each Cloud resend', async () => {
+ it('rejects verification codes that are not exactly six digits', async () => {
+ const user = userEvent.setup()
+ const queryClient = createQueryClient()
+ render(
+
+
+ ,
+ )
+
+ fireEvent.change(screen.getByLabelText('login.checkCode.verificationCode'), {
+ target: { value: '1234567' },
+ })
+ await user.click(screen.getByRole('button', { name: 'login.checkCode.verify' }))
+
+ expect(emailLoginWithCode).not.toHaveBeenCalled()
+ })
+
+ it('keeps Community verification independent of Turnstile', async () => {
+ const user = userEvent.setup()
+ const queryClient = createQueryClient()
+ vi.mocked(emailLoginWithCode).mockResolvedValue({
+ code: 'invalid_code',
+ data: '',
+ message: 'Invalid code',
+ result: 'fail',
+ })
+ render(
+
+
+ ,
+ )
+
+ await user.type(screen.getByLabelText('login.checkCode.verificationCode'), '123456')
+ await user.click(screen.getByRole('button', { name: 'login.checkCode.verify' }))
+
+ expect(emailLoginWithCode).toHaveBeenCalledWith({
+ code: '123456',
+ email: 'user@example.com',
+ language: expect.any(String),
+ timezone: 'Asia/Singapore',
+ token: 'email-login-token',
+ })
+ expect(turnstileMocks.render).not.toHaveBeenCalled()
+ })
+
+ it('does not resend while verification is in progress', async () => {
+ const user = userEvent.setup()
+ const queryClient = createQueryClient()
+ const verificationRequest = createDeferred>>()
+ vi.mocked(emailLoginWithCode).mockReturnValue(verificationRequest.promise)
+ render(
+
+
+ ,
+ )
+
+ await user.type(screen.getByLabelText('login.checkCode.verificationCode'), '123456')
+ await user.click(screen.getByRole('button', { name: 'login.checkCode.verify' }))
+ await waitFor(() => {
+ expect(emailLoginWithCode).toHaveBeenCalledOnce()
+ })
+
+ const resendButton = screen.getByRole('button', { name: 'resend-code' })
+ expect(resendButton).toBeDisabled()
+ resendButton.removeAttribute('disabled')
+ fireEvent.click(resendButton)
+ expect(sendEMailLoginCode).not.toHaveBeenCalled()
+
+ act(() => {
+ verificationRequest.resolve({
+ code: 'invalid_code',
+ data: '',
+ message: 'Invalid code',
+ result: 'fail',
+ })
+ })
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'login.checkCode.verify' })).toBeEnabled()
+ })
+ })
+
+ it('does not verify while resend is in progress', async () => {
+ const user = userEvent.setup()
+ const queryClient = createQueryClient()
+ const resendRequest = createDeferred>>()
+ vi.mocked(sendEMailLoginCode).mockReturnValue(resendRequest.promise)
+ render(
+
+
+ ,
+ )
+
+ await user.type(screen.getByLabelText('login.checkCode.verificationCode'), '123456')
+ await user.click(screen.getByRole('button', { name: 'resend-code' }))
+ await waitFor(() => {
+ expect(sendEMailLoginCode).toHaveBeenCalledOnce()
+ })
+
+ const verifyButton = screen.getByRole('button', { name: 'login.checkCode.verify' })
+ expect(verifyButton).toBeDisabled()
+ const form = verifyButton.closest('form')
+ if (!form) throw new Error('Verification form is missing')
+ fireEvent.submit(form)
+ expect(emailLoginWithCode).not.toHaveBeenCalled()
+
+ act(() => {
+ resendRequest.resolve({ data: '', result: 'fail' })
+ })
+ await waitFor(() => {
+ expect(verifyButton).toBeEnabled()
+ })
+ })
+
+ it('requires a fresh verify-action Turnstile token after every Cloud login attempt', async () => {
+ const user = userEvent.setup()
+ turnstileMocks.deploymentEdition = 'CLOUD'
+ turnstileMocks.siteKey = 'cloud-site-key'
+ const queryClient = createQueryClient()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.mocked(emailLoginWithCode)
+ .mockRejectedValueOnce(new Error('invalid verification code'))
+ .mockResolvedValueOnce({
+ code: 'invalid_code',
+ data: '',
+ message: 'Invalid code',
+ result: 'fail',
+ })
+
+ render(
+
+
+ ,
+ )
+
+ const codeInput = screen.getByLabelText('login.checkCode.verificationCode')
+ const verifyButton = screen.getByRole('button', { name: 'login.checkCode.verify' })
+ expect(verifyButton).toBeDisabled()
+
+ act(() => {
+ turnstileMocks.scriptProps?.onReady?.()
+ })
+ expect(turnstileMocks.render).toHaveBeenLastCalledWith(
+ expect.any(HTMLElement),
+ expect.objectContaining({ action: 'signin_code_verify' }),
+ )
+ await user.click(
+ await screen.findByRole('button', { name: 'verify-turnstile-signin_code_verify' }),
+ )
+ expect(verifyButton).toBeEnabled()
+
+ await user.type(codeInput, '123456')
+ await user.click(verifyButton)
+
+ await waitFor(() => {
+ expect(emailLoginWithCode).toHaveBeenCalledWith(
+ expect.objectContaining({
+ turnstile_token: 'signin_code_verify-token-1',
+ }),
+ )
+ })
+ expect(codeInput).toHaveValue('123456')
+ await waitFor(() => {
+ expect(verifyButton).toBeDisabled()
+ expect(turnstileMocks.remove).toHaveBeenCalledWith('widget-1')
+ expect(turnstileMocks.render).toHaveBeenCalledTimes(2)
+ })
+ expect(turnstileMocks.render).toHaveBeenLastCalledWith(
+ expect.any(HTMLElement),
+ expect.objectContaining({ action: 'signin_code_verify' }),
+ )
+ await user.click(
+ await screen.findByRole('button', { name: 'verify-turnstile-signin_code_verify' }),
+ )
+ await user.click(verifyButton)
+
+ await waitFor(() => {
+ expect(emailLoginWithCode).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ turnstile_token: 'signin_code_verify-token-2',
+ }),
+ )
+ })
+ expect(codeInput).toHaveValue('123456')
+ })
+
+ it('keeps the Cloud resend challenge separate from the verify challenge', async () => {
const user = userEvent.setup()
turnstileMocks.deploymentEdition = 'CLOUD'
turnstileMocks.siteKey = 'cloud-site-key'
@@ -206,26 +407,44 @@ describe('CheckCode', () => {
const resendButton = screen.getByRole('button', { name: 'resend-code' })
expect(resendButton).toBeEnabled()
- expect(screen.queryByRole('button', { name: 'verify-turnstile' })).not.toBeInTheDocument()
+ act(() => {
+ turnstileMocks.scriptProps?.onReady?.()
+ })
+ await user.click(
+ await screen.findByRole('button', { name: 'verify-turnstile-signin_code_verify' }),
+ )
+ expect(screen.getByRole('button', { name: 'login.checkCode.verify' })).toBeEnabled()
await user.click(resendButton)
expect(resendButton).toBeDisabled()
- act(() => {
- turnstileMocks.scriptProps?.onReady?.()
- })
- await user.click(await screen.findByRole('button', { name: 'verify-turnstile' }))
+ expect(screen.getByRole('button', { name: 'login.checkCode.verify' })).toBeDisabled()
+ expect(turnstileMocks.remove).toHaveBeenCalledWith('widget-1')
+ expect(
+ screen.queryByRole('button', { name: 'verify-turnstile-signin_code_verify' }),
+ ).not.toBeInTheDocument()
+ expect(turnstileMocks.render).toHaveBeenLastCalledWith(
+ expect.any(HTMLElement),
+ expect.objectContaining({ action: 'signin_code' }),
+ )
+ await user.click(await screen.findByRole('button', { name: 'verify-turnstile-signin_code' }))
await waitFor(() => {
expect(sendEMailLoginCode).toHaveBeenCalledWith(
'user@example.com',
expect.any(String),
- 'fresh-turnstile-token',
+ 'signin_code-token-2',
)
})
await waitFor(() => {
- expect(screen.queryByRole('button', { name: 'verify-turnstile' })).not.toBeInTheDocument()
+ expect(
+ screen.queryByRole('button', { name: 'verify-turnstile-signin_code' }),
+ ).not.toBeInTheDocument()
})
+ expect(turnstileMocks.render).toHaveBeenLastCalledWith(
+ expect.any(HTMLElement),
+ expect.objectContaining({ action: 'signin_code_verify' }),
+ )
})
it('keeps Turnstile script-error recovery available during a Cloud resend', async () => {
@@ -265,13 +484,13 @@ describe('CheckCode', () => {
act(() => {
turnstileMocks.scriptProps?.onReady?.()
})
- await user.click(await screen.findByRole('button', { name: 'verify-turnstile' }))
+ await user.click(await screen.findByRole('button', { name: 'verify-turnstile-signin_code' }))
await waitFor(() => {
expect(sendEMailLoginCode).toHaveBeenCalledWith(
'user@example.com',
expect.any(String),
- 'fresh-turnstile-token',
+ 'signin_code-token-1',
)
})
})
@@ -288,7 +507,7 @@ describe('CheckCode', () => {
,
)
- expect(screen.queryByRole('button', { name: 'verify-turnstile' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /verify-turnstile-/ })).not.toBeInTheDocument()
const resendButton = screen.getByRole('button', { name: 'resend-code' })
expect(resendButton).toBeEnabled()
await user.click(resendButton)
diff --git a/web/app/signin/check-code/page.tsx b/web/app/signin/check-code/page.tsx
index a0e9cceee04..56e82f35fb1 100644
--- a/web/app/signin/check-code/page.tsx
+++ b/web/app/signin/check-code/page.tsx
@@ -37,6 +37,8 @@ export default function CheckCode() {
const [code, setVerifyCode] = useState('')
const [loading, setIsLoading] = useState(false)
const [isResending, setIsResending] = useState(false)
+ const [verifyTurnstileToken, setVerifyTurnstileToken] = useState('')
+ const [verifyTurnstileGeneration, setVerifyTurnstileGeneration] = useState(0)
const [showResendTurnstile, setShowResendTurnstile] = useState(false)
const [countdownGeneration, setCountdownGeneration] = useState(0)
const locale = useLocale()
@@ -44,28 +46,34 @@ export default function CheckCode() {
const codeInputRef = useRef(null)
const turnstileSiteKey = TURNSTILE_SITE_KEY.trim()
const isTurnstileRequired = systemFeatures.deployment_edition === 'CLOUD'
- const shouldRenderResendTurnstile =
- isTurnstileRequired && Boolean(turnstileSiteKey) && showResendTurnstile
+ const shouldRenderTurnstile = isTurnstileRequired && Boolean(turnstileSiteKey)
const pageTitle = t(($) => $['checkCode.checkYourEmail'], { ns: 'login' })
useDocumentTitle(pageTitle)
const verify = async () => {
+ if (loading || isResending || showResendTurnstile) return
+
+ let shouldResetTurnstile = false
try {
if (!code.trim()) {
toast.error(t(($) => $['checkCode.emptyCode'], { ns: 'login' }))
return
}
- if (!/\d{6}/.test(code)) {
+ if (!/^\d{6}$/.test(code)) {
toast.error(t(($) => $['checkCode.invalidCode'], { ns: 'login' }))
return
}
+ if (isTurnstileRequired && !verifyTurnstileToken) return
+
setIsLoading(true)
+ shouldResetTurnstile = isTurnstileRequired
const ret = await emailLoginWithCode({
email,
code: encryptVerificationCode(code),
token,
language,
timezone: getBrowserTimezone(),
+ ...(isTurnstileRequired ? { turnstile_token: verifyTurnstileToken } : {}),
})
if (ret.result === 'success') {
// Track login success event
@@ -87,6 +95,10 @@ export default function CheckCode() {
console.error(error)
} finally {
setIsLoading(false)
+ if (shouldResetTurnstile) {
+ setVerifyTurnstileToken('')
+ setVerifyTurnstileGeneration((value) => value + 1)
+ }
}
}
@@ -123,7 +135,10 @@ export default function CheckCode() {
}
const handleResend = () => {
+ if (loading || isResending) return
+
if (isTurnstileRequired) {
+ setVerifyTurnstileToken('')
setShowResendTurnstile(true)
return
}
@@ -165,31 +180,52 @@ export default function CheckCode() {
t(($) => $['checkCode.verificationCodePlaceholder'], { ns: 'login' }) as string
}
/>
+ {shouldRenderTurnstile && (
+ {
+ if (showResendTurnstile) {
+ void resendCode(turnstileToken)
+ return
+ }
+ setVerifyTurnstileToken(turnstileToken)
+ }}
+ onInvalidate={() => {
+ if (showResendTurnstile) {
+ setShowResendTurnstile(false)
+ return
+ }
+ setVerifyTurnstileToken('')
+ }}
+ onError={() => {
+ setVerifyTurnstileToken('')
+ }}
+ />
+ )}
- {shouldRenderResendTurnstile && (
- {
- void resendCode(turnstileToken)
- }}
- onInvalidate={() => {
- setShowResendTurnstile(false)
- }}
- />
- )}
diff --git a/web/app/signin/components/__tests__/turnstile.spec.tsx b/web/app/signin/components/__tests__/turnstile.spec.tsx
index 9f12706e729..efe5cff0d77 100644
--- a/web/app/signin/components/__tests__/turnstile.spec.tsx
+++ b/web/app/signin/components/__tests__/turnstile.spec.tsx
@@ -88,7 +88,12 @@ describe('Turnstile', () => {
render(
-
+
,
)
@@ -101,6 +106,7 @@ describe('Turnstile', () => {
const onError = vi.fn()
render(
{
})
render(
{
diff --git a/web/app/signin/components/turnstile.tsx b/web/app/signin/components/turnstile.tsx
index 618bf0d0bf2..b0d999078a2 100644
--- a/web/app/signin/components/turnstile.tsx
+++ b/web/app/signin/components/turnstile.tsx
@@ -26,13 +26,22 @@ type TurnstileApi = {
const getTurnstileApi = () => (window as Window & { turnstile?: TurnstileApi }).turnstile
type TurnstileProps = {
+ action: 'signin_code' | 'signin_code_verify'
+ resetKey?: number
siteKey: string
onVerify: (token: string) => void
onInvalidate: () => void
onError?: () => void
}
-export default function Turnstile({ siteKey, onVerify, onInvalidate, onError }: TurnstileProps) {
+export default function Turnstile({
+ action,
+ resetKey = 0,
+ siteKey,
+ onVerify,
+ onInvalidate,
+ onError,
+}: TurnstileProps) {
const { t } = useTranslation()
const containerRef = useRef(null)
const onVerifyRef = useRef(onVerify)
@@ -68,7 +77,7 @@ export default function Turnstile({ siteKey, onVerify, onInvalidate, onError }:
try {
widgetId = turnstile.render(container, {
sitekey: siteKey,
- action: 'signin_code',
+ action,
appearance: 'always',
size: 'flexible',
theme: 'auto',
@@ -91,7 +100,7 @@ export default function Turnstile({ siteKey, onVerify, onInvalidate, onError }:
if (!widgetId) return
turnstile.remove(widgetId)
}
- }, [handleChallengeError, hasError, invalidate, isScriptReady, siteKey])
+ }, [action, handleChallengeError, hasError, invalidate, isScriptReady, resetKey, siteKey])
const handleScriptReady = () => {
if (getTurnstileApi()) {
diff --git a/web/service/common.spec.ts b/web/service/common.spec.ts
index 4c8f6027141..ef678cb69f9 100644
--- a/web/service/common.spec.ts
+++ b/web/service/common.spec.ts
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
-import { sendEMailLoginCode } from './common'
+import { emailLoginWithCode, sendEMailLoginCode } from './common'
const mocks = vi.hoisted(() => ({
post: vi.fn(),
@@ -40,3 +40,31 @@ describe('sendEMailLoginCode', () => {
})
})
})
+
+describe('emailLoginWithCode', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('includes the verification-specific Turnstile token when provided', async () => {
+ await emailLoginWithCode({
+ code: 'encrypted-code',
+ email: 'user@example.com',
+ language: 'en-US',
+ timezone: 'Asia/Singapore',
+ token: 'email-login-token',
+ turnstile_token: 'verify-turnstile-token',
+ })
+
+ expect(mocks.post).toHaveBeenCalledWith('/email-code-login/validity', {
+ body: {
+ code: 'encrypted-code',
+ email: 'user@example.com',
+ language: 'en-US',
+ timezone: 'Asia/Singapore',
+ token: 'email-login-token',
+ turnstile_token: 'verify-turnstile-token',
+ },
+ })
+ })
+})
diff --git a/web/service/common.ts b/web/service/common.ts
index 2191ca8a605..ba4cec521ae 100644
--- a/web/service/common.ts
+++ b/web/service/common.ts
@@ -1,3 +1,4 @@
+import type { EmailCodeLoginPayload } from '@dify/contracts/api/console/email-code-login/types.gen'
import type {
PostWorkspacesInfoData,
PostWorkspacesInfoResponse,
@@ -233,13 +234,8 @@ export const sendEMailLoginCode = (
},
})
-export const emailLoginWithCode = (data: {
- email: string
- code: string
- token: string
- language: string
- timezone?: string
-}): Promise => post('/email-code-login/validity', { body: data })
+export const emailLoginWithCode = (data: EmailCodeLoginPayload): Promise =>
+ post('/email-code-login/validity', { body: data })
export const sendResetPasswordCode = (
email: string,
From 2f6e782215767fd6e011d4a4e58fe58a99b48449 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?=
Date: Wed, 19 Aug 2026 07:23:47 +0000
Subject: [PATCH 10/18] refactor(api): reuse app definition queries for service
API site (#40795)
---
api/controllers/service_api/app/site.py | 22 ++--
.../app_definition_query_repository.py | 27 ++++-
api/services/app_definition_query_service.py | 25 +++++
.../controllers/service_api/test_site.py | 106 ------------------
.../pyrefly.toml | 1 -
.../controllers/service_api/app/test_app.py | 58 ++++++++++
.../test_app_definition_query_repository.py | 62 +++++++++-
.../test_app_definition_query_service.py | 8 ++
8 files changed, 185 insertions(+), 124 deletions(-)
delete mode 100644 api/tests/test_containers_integration_tests/controllers/service_api/test_site.py
diff --git a/api/controllers/service_api/app/site.py b/api/controllers/service_api/app/site.py
index 35098ca1367..c6bc0313f8c 100644
--- a/api/controllers/service_api/app/site.py
+++ b/api/controllers/service_api/app/site.py
@@ -1,14 +1,14 @@
from flask_restx import Resource
-from sqlalchemy import select
from werkzeug.exceptions import Forbidden
from controllers.common.fields import Site as SiteResponse
from controllers.common.schema import register_response_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.wraps import validate_app_token
-from extensions.ext_database import db
-from models.account import TenantStatus
-from models.model import App, Site
+from extensions.ext_application_services import application_services
+from libs.helper import dump_response
+from models.model import App
+from services.app_definition_query_service import AppDefinitionUnavailableError
register_response_schema_models(service_api_ns, SiteResponse)
@@ -49,13 +49,9 @@ class AppSiteApi(Resource):
Returns the site configuration for the application including theme, icons, and text.
"""
- site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
+ try:
+ configuration = application_services().app_definitions.get_site_configuration(app_model.id)
+ except AppDefinitionUnavailableError:
+ raise Forbidden() from None
- if not site:
- raise Forbidden()
-
- assert app_model.tenant
- if app_model.tenant.status == TenantStatus.ARCHIVE:
- raise Forbidden()
-
- return SiteResponse.model_validate(site).model_dump(mode="json")
+ return dump_response(SiteResponse, configuration)
diff --git a/api/repositories/app_definition_query_repository.py b/api/repositories/app_definition_query_repository.py
index f05db4b654b..45566550600 100644
--- a/api/repositories/app_definition_query_repository.py
+++ b/api/repositories/app_definition_query_repository.py
@@ -11,13 +11,14 @@ from core.app.apps.agent_app.app_variable_projection import agent_app_variables_
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from models.agent import AgentConfigSnapshot
from models.agent_config_entities import AgentSoulConfig
-from models.model import App, AppMode, AppModelConfig, load_annotation_reply_config
+from models.model import App, AppMode, AppModelConfig, Site, load_annotation_reply_config
from models.tools import ApiToolProvider
from models.workflow import Workflow
from services.app_definition_query_service import (
AppDefinitionQuery,
AppDefinitionSummary,
AppParameterConfig,
+ AppSiteConfiguration,
AppToolIconSource,
)
@@ -145,6 +146,30 @@ class AppDefinitionQueryRepository(AppDefinitionQuery):
author_name=app.author_name_with_session(session=session),
)
+ @override
+ def get_site_configuration(self, app_id: str) -> AppSiteConfiguration | None:
+ with self._session_factory() as session:
+ site = session.scalar(select(Site).where(Site.app_id == app_id).limit(1))
+ if site is None:
+ return None
+
+ return AppSiteConfiguration(
+ title=site.title,
+ chat_color_theme=site.chat_color_theme,
+ chat_color_theme_inverted=site.chat_color_theme_inverted,
+ icon_type=site.icon_type.value if site.icon_type is not None else None,
+ icon=site.icon,
+ icon_background=site.icon_background,
+ description=site.description,
+ copyright=site.copyright,
+ privacy_policy=site.privacy_policy,
+ input_placeholder=site.input_placeholder,
+ custom_disclaimer=site.custom_disclaimer,
+ default_language=site.default_language,
+ show_workflow_steps=site.show_workflow_steps,
+ use_icon_as_answer_icon=site.use_icon_as_answer_icon,
+ )
+
@staticmethod
def _get_tools(session: Session, app: App) -> list[dict[str, Any]]:
if app.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
diff --git a/api/services/app_definition_query_service.py b/api/services/app_definition_query_service.py
index 823ea646f6b..6ebd367a961 100644
--- a/api/services/app_definition_query_service.py
+++ b/api/services/app_definition_query_service.py
@@ -28,6 +28,23 @@ class AppDefinitionSummary(NamedTuple):
author_name: str | None
+class AppSiteConfiguration(NamedTuple):
+ title: str
+ chat_color_theme: str | None
+ chat_color_theme_inverted: bool
+ icon_type: str | None
+ icon: str | None
+ icon_background: str | None
+ description: str | None
+ copyright: str | None
+ privacy_policy: str | None
+ input_placeholder: str | None
+ custom_disclaimer: str | None
+ default_language: str
+ show_workflow_steps: bool
+ use_icon_as_answer_icon: bool
+
+
class AppDefinitionQuery(Protocol):
def get_published_parameter_config(
self,
@@ -40,6 +57,8 @@ class AppDefinitionQuery(Protocol):
def get_summary(self, app_id: str) -> AppDefinitionSummary | None: ...
+ def get_site_configuration(self, app_id: str) -> AppSiteConfiguration | None: ...
+
class AppDefinitionUnavailableError(ValueError):
"""Raised when an app definition is unavailable."""
@@ -111,3 +130,9 @@ class AppDefinitionQueryService:
if summary is None:
raise AppDefinitionUnavailableError("App not found")
return summary
+
+ def get_site_configuration(self, app_id: str) -> AppSiteConfiguration:
+ configuration = self._definitions.get_site_configuration(app_id)
+ if configuration is None:
+ raise AppDefinitionUnavailableError("Site not found")
+ return configuration
diff --git a/api/tests/test_containers_integration_tests/controllers/service_api/test_site.py b/api/tests/test_containers_integration_tests/controllers/service_api/test_site.py
deleted file mode 100644
index c1b20cd02ba..00000000000
--- a/api/tests/test_containers_integration_tests/controllers/service_api/test_site.py
+++ /dev/null
@@ -1,106 +0,0 @@
-"""
-Testcontainers integration tests for Service API Site controller.
-"""
-
-from __future__ import annotations
-
-import pytest
-from flask import Flask
-from sqlalchemy.orm import Session
-from werkzeug.exceptions import Forbidden
-
-from controllers.service_api.app.site import AppSiteApi
-from models.account import Tenant, TenantStatus
-from models.model import App, AppMode, Site
-
-
-@pytest.fixture
-def app(flask_app_with_containers) -> Flask:
- return flask_app_with_containers
-
-
-from inspect import unwrap
-
-
-def _create_tenant(db_session: Session, *, status: TenantStatus = TenantStatus.NORMAL) -> Tenant:
- tenant = Tenant(name="service-api-site-tenant", status=status)
- db_session.add(tenant)
- db_session.commit()
- return tenant
-
-
-def _create_app(db_session: Session, tenant_id: str) -> App:
- app_model = App(
- tenant_id=tenant_id,
- mode=AppMode.CHAT,
- name="service-api-site-app",
- enable_site=True,
- enable_api=True,
- status="normal",
- )
- db_session.add(app_model)
- db_session.commit()
- return app_model
-
-
-def _create_site(db_session: Session, app_id: str) -> Site:
- site = Site(
- app_id=app_id,
- title="Service API Site",
- icon_type="emoji",
- icon="robot",
- icon_background="#ffffff",
- description="Service API test site",
- default_language="en-US",
- prompt_public=True,
- show_workflow_steps=True,
- customize_token_strategy="not_allow",
- use_icon_as_answer_icon=False,
- chat_color_theme="light",
- chat_color_theme_inverted=False,
- )
- db_session.add(site)
- db_session.commit()
- return site
-
-
-class TestAppSiteApi:
- def test_get_site_success(self, app: Flask, db_session_with_containers: Session) -> None:
- tenant = _create_tenant(db_session_with_containers)
- app_model = _create_app(db_session_with_containers, tenant.id)
- _create_site(db_session_with_containers, app_model.id)
-
- with app.test_request_context("/site", method="GET", headers={"Authorization": "Bearer test-token"}):
- api = AppSiteApi()
- response = unwrap(api.get)(api, app_model=app_model)
-
- assert response["title"] == "Service API Site"
- assert response["icon"] == "robot"
- assert response["description"] == "Service API test site"
-
- def test_get_site_not_found(self, app: Flask, db_session_with_containers: Session) -> None:
- tenant = _create_tenant(db_session_with_containers)
- app_model = _create_app(db_session_with_containers, tenant.id)
-
- with app.test_request_context("/site", method="GET", headers={"Authorization": "Bearer test-token"}):
- api = AppSiteApi()
- with pytest.raises(Forbidden):
- unwrap(api.get)(api, app_model=app_model)
-
- def test_get_site_tenant_archived(self, app: Flask, db_session_with_containers: Session) -> None:
- tenant = _create_tenant(db_session_with_containers)
- app_model = _create_app(db_session_with_containers, tenant.id)
- _create_site(db_session_with_containers, app_model.id)
-
- archived_tenant = db_session_with_containers.get(Tenant, tenant.id)
- assert archived_tenant is not None
- archived_tenant.status = TenantStatus.ARCHIVE
- db_session_with_containers.commit()
-
- app_model = db_session_with_containers.get(App, app_model.id)
- assert app_model is not None
-
- with app.test_request_context("/site", method="GET", headers={"Authorization": "Bearer test-token"}):
- api = AppSiteApi()
- with pytest.raises(Forbidden):
- unwrap(api.get)(api, app_model=app_model)
diff --git a/api/tests/test_containers_integration_tests/pyrefly.toml b/api/tests/test_containers_integration_tests/pyrefly.toml
index 6bdd09b3057..cf707c4f947 100644
--- a/api/tests/test_containers_integration_tests/pyrefly.toml
+++ b/api/tests/test_containers_integration_tests/pyrefly.toml
@@ -16,7 +16,6 @@ project-excludes = [
"controllers/console/test_apikey.py",
"controllers/console/workspace/test_workspace_wraps.py",
"controllers/service_api/dataset/test_dataset.py",
- "controllers/service_api/test_site.py",
"controllers/web/test_conversation.py",
"controllers/web/test_site.py",
"controllers/web/test_wraps.py",
diff --git a/api/tests/unit_tests/controllers/service_api/app/test_app.py b/api/tests/unit_tests/controllers/service_api/app/test_app.py
index 69169e9174c..a6ff026f6d5 100644
--- a/api/tests/unit_tests/controllers/service_api/app/test_app.py
+++ b/api/tests/unit_tests/controllers/service_api/app/test_app.py
@@ -17,8 +17,10 @@ from sqlalchemy.orm import Session, scoped_session, sessionmaker
from werkzeug.exceptions import Forbidden, Unauthorized
from controllers.service_api.app import app as app_controller
+from controllers.service_api.app import site as site_controller
from controllers.service_api.app.app import AppInfoApi, AppMetaApi, AppParameterApi
from controllers.service_api.app.error import AgentNotPublishedError, AppUnavailableError
+from controllers.service_api.app.site import AppSiteApi
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus
from models.base import TypeBase
@@ -27,6 +29,7 @@ from services.app_definition_query_service import (
AppDefinitionNotPublishedError,
AppDefinitionSummary,
AppDefinitionUnavailableError,
+ AppSiteConfiguration,
)
@@ -290,6 +293,61 @@ def test_get_info_maps_unavailable_app(
AppInfoApi().get()
+def test_get_site_configuration_queries_authenticated_app(
+ flask_app: Flask,
+ authenticated_controller: AppDatabase,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ app_definitions = Mock()
+ app_definitions.get_site_configuration.return_value = AppSiteConfiguration(
+ title="Test Site",
+ chat_color_theme="light",
+ chat_color_theme_inverted=False,
+ icon_type="emoji",
+ icon="robot",
+ icon_background="#ffffff",
+ description="A test site",
+ copyright=None,
+ privacy_policy=None,
+ input_placeholder="Ask anything",
+ custom_disclaimer=None,
+ default_language="en-US",
+ show_workflow_steps=True,
+ use_icon_as_answer_icon=False,
+ )
+ monkeypatch.setattr(
+ site_controller,
+ "application_services",
+ Mock(return_value=SimpleNamespace(app_definitions=app_definitions)),
+ )
+
+ with flask_app.test_request_context("/site", headers={"Authorization": "Bearer token"}):
+ response = AppSiteApi().get()
+
+ app_definitions.get_site_configuration.assert_called_once_with(authenticated_controller.app_id)
+ assert response["title"] == "Test Site"
+ assert response["icon"] == "robot"
+ assert response["icon_url"] is None
+
+
+@pytest.mark.usefixtures("authenticated_controller")
+def test_get_site_configuration_maps_missing_site_to_forbidden(
+ flask_app: Flask,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ app_definitions = Mock()
+ app_definitions.get_site_configuration.side_effect = AppDefinitionUnavailableError("Site not found")
+ monkeypatch.setattr(
+ site_controller,
+ "application_services",
+ Mock(return_value=SimpleNamespace(app_definitions=app_definitions)),
+ )
+
+ with flask_app.test_request_context("/site", headers={"Authorization": "Bearer token"}):
+ with pytest.raises(Forbidden):
+ AppSiteApi().get()
+
+
@pytest.mark.parametrize("state", ["missing", "disabled", "archived", "ownerless"])
def test_authentication_rejects_empty_or_invisible_database_state(
flask_app: Flask,
diff --git a/api/tests/unit_tests/repositories/test_app_definition_query_repository.py b/api/tests/unit_tests/repositories/test_app_definition_query_repository.py
index b6b7c529a06..c47943c80df 100644
--- a/api/tests/unit_tests/repositories/test_app_definition_query_repository.py
+++ b/api/tests/unit_tests/repositories/test_app_definition_query_repository.py
@@ -5,12 +5,17 @@ from sqlalchemy.orm import Session, sessionmaker
from core.tools.entities.tool_entities import ApiProviderSchemaType
from models.account import Account
-from models.enums import TagType
-from models.model import App, AppMode, AppModelConfig, Tag, TagBinding
+from models.enums import CustomizeTokenStrategy, TagType
+from models.model import App, AppMode, AppModelConfig, IconType, Site, Tag, TagBinding
from models.tools import ApiToolProvider
from models.workflow import Workflow, WorkflowKind, WorkflowType
from repositories.app_definition_query_repository import AppDefinitionQueryRepository
-from services.app_definition_query_service import AppDefinitionSummary, AppParameterConfig, AppToolIconSource
+from services.app_definition_query_service import (
+ AppDefinitionSummary,
+ AppParameterConfig,
+ AppSiteConfiguration,
+ AppToolIconSource,
+)
_APP_ID = "11111111-1111-1111-1111-111111111111"
_TENANT_ID = "22222222-2222-2222-2222-222222222222"
@@ -290,6 +295,57 @@ def test_get_summary_returns_only_tenant_scoped_app_tags(
assert result.author_name is None
+def test_get_site_configuration_returns_none_for_missing_site(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ repository = AppDefinitionQueryRepository(session_factory=sqlite_session_factory)
+
+ assert repository.get_site_configuration(_APP_ID) is None
+
+
+def test_get_site_configuration_maps_site_fields(sqlite_session_factory: sessionmaker[Session]) -> None:
+ with sqlite_session_factory.begin() as session:
+ site = Site(
+ app_id=_APP_ID,
+ title="Test Site",
+ icon_type=IconType.IMAGE,
+ icon="11111111-1111-4111-8111-111111111111",
+ icon_background="#ffffff",
+ description="A test site",
+ default_language="en-US",
+ chat_color_theme="light",
+ chat_color_theme_inverted=True,
+ copyright="Copyright",
+ privacy_policy="Privacy",
+ input_placeholder="Ask anything",
+ show_workflow_steps=False,
+ use_icon_as_answer_icon=True,
+ customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW,
+ prompt_public=True,
+ )
+ site.custom_disclaimer = "Disclaimer"
+ session.add(site)
+
+ result = AppDefinitionQueryRepository(session_factory=sqlite_session_factory).get_site_configuration(_APP_ID)
+
+ assert result == AppSiteConfiguration(
+ title="Test Site",
+ chat_color_theme="light",
+ chat_color_theme_inverted=True,
+ icon_type=IconType.IMAGE.value,
+ icon="11111111-1111-4111-8111-111111111111",
+ icon_background="#ffffff",
+ description="A test site",
+ copyright="Copyright",
+ privacy_policy="Privacy",
+ input_placeholder="Ask anything",
+ custom_disclaimer="Disclaimer",
+ default_language="en-US",
+ show_workflow_steps=False,
+ use_icon_as_answer_icon=True,
+ )
+
+
def _tool(provider_type: str, provider_id: str, tool_name: str) -> dict[str, object]:
return {
"provider_type": provider_type,
diff --git a/api/tests/unit_tests/services/test_app_definition_query_service.py b/api/tests/unit_tests/services/test_app_definition_query_service.py
index de0ac4c1c65..4a5d69bb15b 100644
--- a/api/tests/unit_tests/services/test_app_definition_query_service.py
+++ b/api/tests/unit_tests/services/test_app_definition_query_service.py
@@ -145,3 +145,11 @@ def test_get_summary_rejects_missing_app() -> None:
with pytest.raises(AppDefinitionUnavailableError, match="App not found"):
service.get_summary("missing")
+
+
+def test_get_site_configuration_rejects_missing_site() -> None:
+ service, definitions = _service()
+ definitions.get_site_configuration.return_value = None
+
+ with pytest.raises(AppDefinitionUnavailableError, match="Site not found"):
+ service.get_site_configuration("app-1")
From d0149c3f94fa62cd732fee89c297210fb9926d7c Mon Sep 17 00:00:00 2001
From: zyssyz123 <916125788@qq.com>
Date: Wed, 19 Aug 2026 07:54:38 +0000
Subject: [PATCH 11/18] fix(agent): resolve suggested questions from runtime
config (#40963)
---
api/services/agent/runtime_config_service.py | 125 ++++++++
api/services/message_service.py | 44 +++
.../test_agent_runtime_config_service.py | 147 ++++++++++
.../services/test_message_service.py | 267 +++++++++++++++++-
4 files changed, 582 insertions(+), 1 deletion(-)
create mode 100644 api/services/agent/runtime_config_service.py
create mode 100644 api/tests/unit_tests/services/agent/test_agent_runtime_config_service.py
diff --git a/api/services/agent/runtime_config_service.py b/api/services/agent/runtime_config_service.py
new file mode 100644
index 00000000000..ee8f10f2ffd
--- /dev/null
+++ b/api/services/agent/runtime_config_service.py
@@ -0,0 +1,125 @@
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from models.agent import (
+ AgentConfigDraft,
+ AgentConfigDraftType,
+ AgentConfigSnapshot,
+ AgentConfigVersionKind,
+ AgentDebugConversation,
+ AgentWorkspaceBinding,
+)
+from models.agent_config_entities import AgentSoulConfig
+from models.model import App, Conversation
+
+
+class AgentRuntimeConfigService:
+ """Resolve the Agent Soul generation that produced one conversation."""
+
+ def __init__(self, session: Session):
+ self._session = session
+
+ def resolve_conversation_soul(
+ self,
+ *,
+ app_model: App,
+ conversation: Conversation,
+ account_id: str | None,
+ use_debug_draft: bool,
+ ) -> AgentSoulConfig | None:
+ if use_debug_draft and account_id is not None:
+ draft_soul = self._resolve_debug_draft_soul(
+ app_model=app_model,
+ conversation=conversation,
+ account_id=account_id,
+ )
+ if draft_soul is not None:
+ return draft_soul
+
+ binding_soul = self._resolve_binding_soul(app_model=app_model, conversation=conversation)
+ if binding_soul is not None:
+ return binding_soul
+
+ from services.agent.roster_service import AgentRosterService
+
+ return AgentRosterService(self._session).get_published_agent_soul_for_app(
+ tenant_id=app_model.tenant_id,
+ app_id=app_model.id,
+ )
+
+ def _resolve_debug_draft_soul(
+ self,
+ *,
+ app_model: App,
+ conversation: Conversation,
+ account_id: str,
+ ) -> AgentSoulConfig | None:
+ debug_conversation = self._session.scalar(
+ select(AgentDebugConversation)
+ .where(
+ AgentDebugConversation.tenant_id == app_model.tenant_id,
+ AgentDebugConversation.app_id == app_model.id,
+ AgentDebugConversation.account_id == account_id,
+ AgentDebugConversation.conversation_id == conversation.id,
+ )
+ .limit(1)
+ )
+ if debug_conversation is None:
+ return None
+
+ draft_stmt = select(AgentConfigDraft).where(
+ AgentConfigDraft.tenant_id == app_model.tenant_id,
+ AgentConfigDraft.agent_id == debug_conversation.agent_id,
+ AgentConfigDraft.draft_type == debug_conversation.draft_type,
+ )
+ if debug_conversation.draft_type == AgentConfigDraftType.DEBUG_BUILD:
+ draft_stmt = draft_stmt.where(AgentConfigDraft.account_id == account_id)
+ draft = self._session.scalar(draft_stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1))
+ if draft is None:
+ return None
+ return AgentSoulConfig.model_validate(draft.config_snapshot_dict)
+
+ def _resolve_binding_soul(self, *, app_model: App, conversation: Conversation) -> AgentSoulConfig | None:
+ if not conversation.agent_workspace_binding_id:
+ return None
+ binding = self._session.scalar(
+ select(AgentWorkspaceBinding)
+ .where(
+ AgentWorkspaceBinding.id == conversation.agent_workspace_binding_id,
+ AgentWorkspaceBinding.tenant_id == app_model.tenant_id,
+ AgentWorkspaceBinding.app_id == app_model.id,
+ )
+ .limit(1)
+ )
+ if binding is None:
+ return None
+
+ if binding.agent_config_version_kind == AgentConfigVersionKind.SNAPSHOT:
+ snapshot = self._session.scalar(
+ select(AgentConfigSnapshot)
+ .where(
+ AgentConfigSnapshot.id == binding.agent_config_version_id,
+ AgentConfigSnapshot.tenant_id == app_model.tenant_id,
+ AgentConfigSnapshot.agent_id == binding.agent_id,
+ )
+ .limit(1)
+ )
+ if snapshot is None:
+ return None
+ return AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
+
+ draft = self._session.scalar(
+ select(AgentConfigDraft)
+ .where(
+ AgentConfigDraft.id == binding.agent_config_version_id,
+ AgentConfigDraft.tenant_id == app_model.tenant_id,
+ AgentConfigDraft.agent_id == binding.agent_id,
+ )
+ .limit(1)
+ )
+ if draft is None:
+ return None
+ return AgentSoulConfig.model_validate(draft.config_snapshot_dict)
+
+
+__all__ = ["AgentRuntimeConfigService"]
diff --git a/api/services/message_service.py b/api/services/message_service.py
index a9658244be2..aaf0e053155 100644
--- a/api/services/message_service.py
+++ b/api/services/message_service.py
@@ -6,6 +6,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
+from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
from core.app.entities.app_invoke_entities import InvokeFrom
from core.llm_generator.llm_generator import LLMGenerator
from core.memory.token_buffer_memory import TokenBufferMemory
@@ -17,15 +18,18 @@ from extensions.ext_database import db
from graphon.model_runtime.entities.model_entities import ModelType
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from models import Account
+from models.agent_config_entities import AgentSoulConfig
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import (
App,
AppMode,
AppModelConfig,
+ Conversation,
EndUser,
Message,
MessageFeedback,
SuggestedQuestionsAfterAnswerConfig,
+ load_annotation_reply_config,
)
from repositories.execution_extra_content_repository import ExecutionExtraContentRepository
from repositories.sqlalchemy_execution_extra_content_repository import (
@@ -61,6 +65,38 @@ def attach_message_extra_contents(messages: Sequence[Message]) -> None:
class MessageService:
+ @classmethod
+ def _get_agent_suggested_questions_config(
+ cls,
+ *,
+ app_model: App,
+ user: Account | EndUser,
+ conversation: Conversation,
+ invoke_from: InvokeFrom,
+ session: Session,
+ ) -> SuggestedQuestionsAfterAnswerConfig:
+ from services.agent.runtime_config_service import AgentRuntimeConfigService
+
+ agent_soul = AgentRuntimeConfigService(session).resolve_conversation_soul(
+ app_model=app_model,
+ conversation=conversation,
+ account_id=user.id if isinstance(user, Account) else None,
+ use_debug_draft=invoke_from == InvokeFrom.DEBUGGER,
+ )
+ app_model_config = (
+ session.get(AppModelConfig, app_model.app_model_config_id) if app_model.app_model_config_id else None
+ )
+ annotation_reply = load_annotation_reply_config(session, app_model.id) if app_model_config else None
+ features = merge_agent_app_features(
+ agent_soul=agent_soul or AgentSoulConfig(),
+ app_model_config=app_model_config,
+ annotation_reply=annotation_reply,
+ )
+ suggested_questions = features.get("suggested_questions_after_answer")
+ if not isinstance(suggested_questions, dict) or not suggested_questions.get("enabled", False):
+ raise SuggestedQuestionsAfterAnswerDisabledError()
+ return cast(SuggestedQuestionsAfterAnswerConfig, suggested_questions)
+
@classmethod
def pagination_by_first_id(
cls,
@@ -301,6 +337,14 @@ class MessageService:
suggested_questions_after_answer_config = cast(
SuggestedQuestionsAfterAnswerConfig, suggested_questions_after_answer
)
+ elif app_model.mode == AppMode.AGENT:
+ suggested_questions_after_answer_config = cls._get_agent_suggested_questions_config(
+ app_model=app_model,
+ user=user,
+ conversation=conversation,
+ invoke_from=invoke_from,
+ session=session,
+ )
else:
if not conversation.override_model_configs:
app_model_config = session.scalar(
diff --git a/api/tests/unit_tests/services/agent/test_agent_runtime_config_service.py b/api/tests/unit_tests/services/agent/test_agent_runtime_config_service.py
new file mode 100644
index 00000000000..2b2e22e8959
--- /dev/null
+++ b/api/tests/unit_tests/services/agent/test_agent_runtime_config_service.py
@@ -0,0 +1,147 @@
+from unittest.mock import MagicMock
+
+import pytest
+from sqlalchemy.orm import Session
+
+from models.agent import (
+ AgentConfigDraft,
+ AgentConfigDraftType,
+ AgentConfigVersionKind,
+ AgentDebugConversation,
+ AgentWorkspaceBinding,
+)
+from models.agent_config_entities import AgentSoulConfig
+from models.model import App, Conversation
+from services.agent.runtime_config_service import AgentRuntimeConfigService
+
+
+def _app() -> MagicMock:
+ app = MagicMock(spec=App)
+ app.id = "app-1"
+ app.tenant_id = "tenant-1"
+ return app
+
+
+def _conversation(*, binding_id: str | None = None) -> MagicMock:
+ conversation = MagicMock(spec=Conversation)
+ conversation.id = "conversation-1"
+ conversation.agent_workspace_binding_id = binding_id
+ return conversation
+
+
+def _soul(prompt: str) -> AgentSoulConfig:
+ return AgentSoulConfig.model_validate(
+ {
+ "prompt": {"system_prompt": prompt},
+ "app_features": {"suggested_questions_after_answer": {"enabled": True}},
+ }
+ )
+
+
+def _patch_published_soul(monkeypatch: pytest.MonkeyPatch, soul: AgentSoulConfig) -> MagicMock:
+ roster_service = MagicMock()
+ roster_service.return_value.get_published_agent_soul_for_app.return_value = soul
+ monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
+ return roster_service
+
+
+def test_debug_without_mapping_falls_back_to_published_soul(monkeypatch: pytest.MonkeyPatch) -> None:
+ session = MagicMock(spec=Session)
+ session.scalar.return_value = None
+ published = _soul("published")
+ roster_service = _patch_published_soul(monkeypatch, published)
+
+ result = AgentRuntimeConfigService(session).resolve_conversation_soul(
+ app_model=_app(),
+ conversation=_conversation(),
+ account_id="account-1",
+ use_debug_draft=True,
+ )
+
+ assert result == published
+ roster_service.return_value.get_published_agent_soul_for_app.assert_called_once_with(
+ tenant_id="tenant-1",
+ app_id="app-1",
+ )
+
+
+def test_debug_without_draft_falls_back_to_published_soul(monkeypatch: pytest.MonkeyPatch) -> None:
+ session = MagicMock(spec=Session)
+ debug_conversation = MagicMock(spec=AgentDebugConversation)
+ debug_conversation.agent_id = "agent-1"
+ debug_conversation.draft_type = AgentConfigDraftType.DRAFT
+ session.scalar.side_effect = [debug_conversation, None]
+ published = _soul("published")
+ _patch_published_soul(monkeypatch, published)
+
+ result = AgentRuntimeConfigService(session).resolve_conversation_soul(
+ app_model=_app(),
+ conversation=_conversation(),
+ account_id="account-1",
+ use_debug_draft=True,
+ )
+
+ assert result == published
+
+
+def test_missing_binding_falls_back_to_published_soul(monkeypatch: pytest.MonkeyPatch) -> None:
+ session = MagicMock(spec=Session)
+ session.scalar.return_value = None
+ published = _soul("published")
+ _patch_published_soul(monkeypatch, published)
+
+ result = AgentRuntimeConfigService(session).resolve_conversation_soul(
+ app_model=_app(),
+ conversation=_conversation(binding_id="binding-1"),
+ account_id=None,
+ use_debug_draft=False,
+ )
+
+ assert result == published
+
+
+@pytest.mark.parametrize("version_kind", [AgentConfigVersionKind.SNAPSHOT, AgentConfigVersionKind.DRAFT])
+def test_missing_bound_version_falls_back_to_published_soul(
+ version_kind: AgentConfigVersionKind,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ session = MagicMock(spec=Session)
+ binding = MagicMock(spec=AgentWorkspaceBinding)
+ binding.agent_id = "agent-1"
+ binding.agent_config_version_id = "version-1"
+ binding.agent_config_version_kind = version_kind
+ session.scalar.side_effect = [binding, None]
+ published = _soul("published")
+ _patch_published_soul(monkeypatch, published)
+
+ result = AgentRuntimeConfigService(session).resolve_conversation_soul(
+ app_model=_app(),
+ conversation=_conversation(binding_id="binding-1"),
+ account_id=None,
+ use_debug_draft=False,
+ )
+
+ assert result == published
+
+
+def test_bound_draft_returns_its_soul(monkeypatch: pytest.MonkeyPatch) -> None:
+ session = MagicMock(spec=Session)
+ binding = MagicMock(spec=AgentWorkspaceBinding)
+ binding.agent_id = "agent-1"
+ binding.agent_config_version_id = "draft-1"
+ binding.agent_config_version_kind = AgentConfigVersionKind.DRAFT
+ draft = MagicMock(spec=AgentConfigDraft)
+ bound = _soul("bound draft")
+ draft.config_snapshot_dict = bound.model_dump(mode="json")
+ session.scalar.side_effect = [binding, draft]
+ roster_service = _patch_published_soul(monkeypatch, _soul("published"))
+
+ result = AgentRuntimeConfigService(session).resolve_conversation_soul(
+ app_model=_app(),
+ conversation=_conversation(binding_id="binding-1"),
+ account_id=None,
+ use_debug_draft=False,
+ )
+
+ assert result == bound
+ roster_service.assert_not_called()
diff --git a/api/tests/unit_tests/services/test_message_service.py b/api/tests/unit_tests/services/test_message_service.py
index afc49c8865c..e0a8402b84e 100644
--- a/api/tests/unit_tests/services/test_message_service.py
+++ b/api/tests/unit_tests/services/test_message_service.py
@@ -13,6 +13,15 @@ import services.message_service as service_module
from core.app.entities.app_invoke_entities import InvokeFrom
from graphon.model_runtime.entities.model_entities import ModelType
from models.account import Account, AccountStatus
+from models.agent import (
+ AgentConfigDraft,
+ AgentConfigDraftType,
+ AgentConfigSnapshot,
+ AgentConfigVersionKind,
+ AgentDebugConversation,
+ AgentWorkspaceBinding,
+)
+from models.agent_config_entities import AgentSoulConfig
from models.enums import (
ConversationFromSource,
EndUserType,
@@ -38,7 +47,17 @@ from services.errors.message import (
)
from services.message_service import MessageService, attach_message_extra_contents
-SQLITE_MODELS = (Conversation, Message, MessageFeedback, AppModelConfig, AppAnnotationSetting)
+SQLITE_MODELS = (
+ Conversation,
+ Message,
+ MessageFeedback,
+ AppModelConfig,
+ AppAnnotationSetting,
+ AgentConfigDraft,
+ AgentConfigSnapshot,
+ AgentDebugConversation,
+ AgentWorkspaceBinding,
+)
pytestmark = [
pytest.mark.usefixtures("sqlite_session"),
pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True),
@@ -672,6 +691,22 @@ class TestMessageServiceFeedback:
class TestMessageServiceSuggestedQuestions:
+ @staticmethod
+ def _agent_soul(
+ *,
+ enabled: bool = True,
+ prompt: str | None = None,
+ model: dict[str, object] | None = None,
+ ) -> AgentSoulConfig:
+ suggested_questions: dict[str, object] = {"enabled": enabled}
+ if prompt is not None:
+ suggested_questions["prompt"] = prompt
+ if model is not None:
+ suggested_questions["model"] = model
+ return AgentSoulConfig.model_validate(
+ {"app_features": {"suggested_questions_after_answer": suggested_questions}}
+ )
+
@staticmethod
def _chat_boundaries(
monkeypatch: pytest.MonkeyPatch,
@@ -732,6 +767,236 @@ class TestMessageServiceSuggestedQuestions:
assert result == ["Q1?"]
llm_generator.generate_suggested_questions_after_answer.assert_called_once()
+ @pytest.mark.parametrize("draft_type", [AgentConfigDraftType.DRAFT, AgentConfigDraftType.DEBUG_BUILD])
+ def test_agent_debug_uses_matching_draft(
+ self,
+ draft_type: AgentConfigDraftType,
+ monkeypatch: pytest.MonkeyPatch,
+ factory: MessageServiceTestDataFactory,
+ sqlite_session: Session,
+ ) -> None:
+ conversation = factory.create_conversation()
+ conversation.mode = AppMode.AGENT
+ _, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
+ account = factory.create_account()
+ draft = AgentConfigDraft(
+ tenant_id="tenant-123",
+ agent_id="agent-123",
+ draft_type=draft_type,
+ account_id=account.id if draft_type == AgentConfigDraftType.DEBUG_BUILD else None,
+ draft_owner_key=account.id if draft_type == AgentConfigDraftType.DEBUG_BUILD else "",
+ base_snapshot_id=None,
+ home_snapshot_id=None,
+ agent_workspace_binding_id=None,
+ config_snapshot=self._agent_soul(
+ prompt=f"{draft_type.value} prompt",
+ model={
+ "provider": "openai",
+ "name": "gpt-4o-mini",
+ "mode": "chat",
+ "completion_params": {"temperature": 0.1},
+ },
+ ),
+ created_by=account.id,
+ updated_by=account.id,
+ )
+ draft.id = "draft-123"
+ mapping = AgentDebugConversation(
+ tenant_id="tenant-123",
+ agent_id="agent-123",
+ app_id="app-123",
+ account_id=account.id,
+ draft_type=draft_type,
+ conversation_id=conversation.id,
+ )
+ mapping.id = "mapping-123"
+ app_model_config = AppModelConfig(
+ app_id="app-123",
+ suggested_questions_after_answer=json.dumps({"enabled": False}),
+ )
+ app_model_config.id = "config-123"
+ _persist(sqlite_session, draft, mapping, app_model_config)
+ roster_service = MagicMock()
+ monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
+ app = factory.create_app(mode=AppMode.AGENT)
+ app.app_model_config_id = app_model_config.id
+
+ result = MessageService.get_suggested_questions_after_answer(
+ app_model=app,
+ user=account,
+ message_id="msg-123",
+ invoke_from=InvokeFrom.DEBUGGER,
+ session=sqlite_session,
+ )
+
+ assert result == ["Q1?"]
+ roster_service.assert_not_called()
+ llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
+ tenant_id="tenant-123",
+ histories="histories",
+ instruction_prompt=f"{draft_type.value} prompt",
+ model_config={
+ "provider": "openai",
+ "name": "gpt-4o-mini",
+ "mode": "chat",
+ "completion_params": {"temperature": 0.1},
+ },
+ )
+
+ def test_agent_published_conversation_uses_bound_snapshot(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ factory: MessageServiceTestDataFactory,
+ sqlite_session: Session,
+ ) -> None:
+ conversation = factory.create_conversation()
+ conversation.mode = AppMode.AGENT
+ conversation.agent_workspace_binding_id = "binding-123"
+ _, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
+ snapshot = AgentConfigSnapshot(
+ tenant_id="tenant-123",
+ agent_id="agent-123",
+ version=1,
+ config_snapshot=self._agent_soul(prompt="bound snapshot prompt"),
+ home_snapshot_id=None,
+ summary=None,
+ version_note=None,
+ created_by="account-123",
+ )
+ snapshot.id = "snapshot-123"
+ binding = AgentWorkspaceBinding(
+ tenant_id="tenant-123",
+ app_id="app-123",
+ workspace_id="workspace-123",
+ agent_id="agent-123",
+ base_home_snapshot_id=None,
+ agent_config_version_id=snapshot.id,
+ agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
+ backend_binding_ref="backend-binding-123",
+ session_snapshot=None,
+ retired_at=None,
+ pending_form_id=None,
+ pending_tool_call_id=None,
+ )
+ binding.id = "binding-123"
+ _persist(sqlite_session, snapshot, binding)
+ roster_service = MagicMock()
+ roster_service.return_value.get_published_agent_soul_for_app.return_value = self._agent_soul(
+ prompt="current published prompt"
+ )
+ monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
+
+ result = MessageService.get_suggested_questions_after_answer(
+ app_model=factory.create_app(mode=AppMode.AGENT),
+ user=factory.create_end_user(),
+ message_id="msg-123",
+ invoke_from=InvokeFrom.SERVICE_API,
+ session=sqlite_session,
+ )
+
+ assert result == ["Q1?"]
+ roster_service.assert_not_called()
+ llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
+ tenant_id="tenant-123",
+ histories="histories",
+ instruction_prompt="bound snapshot prompt",
+ model_config=None,
+ )
+
+ def test_agent_without_binding_uses_published_soul(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ factory: MessageServiceTestDataFactory,
+ sqlite_session: Session,
+ ) -> None:
+ conversation = factory.create_conversation()
+ conversation.mode = AppMode.AGENT
+ _, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
+ roster_service = MagicMock()
+ roster_service.return_value.get_published_agent_soul_for_app.return_value = self._agent_soul(
+ prompt="published prompt"
+ )
+ monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
+
+ result = MessageService.get_suggested_questions_after_answer(
+ app_model=factory.create_app(mode=AppMode.AGENT),
+ user=factory.create_end_user(),
+ message_id="msg-123",
+ invoke_from=InvokeFrom.SERVICE_API,
+ session=sqlite_session,
+ )
+
+ assert result == ["Q1?"]
+ roster_service.return_value.get_published_agent_soul_for_app.assert_called_once_with(
+ tenant_id="tenant-123",
+ app_id="app-123",
+ )
+ llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
+ tenant_id="tenant-123",
+ histories="histories",
+ instruction_prompt="published prompt",
+ model_config=None,
+ )
+
+ def test_historical_agent_without_soul_uses_current_app_model_config(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ factory: MessageServiceTestDataFactory,
+ sqlite_session: Session,
+ ) -> None:
+ conversation = factory.create_conversation()
+ conversation.mode = AppMode.AGENT
+ _, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
+ app_model_config = AppModelConfig(
+ app_id="app-123",
+ suggested_questions_after_answer=json.dumps({"enabled": True, "prompt": "legacy prompt"}),
+ )
+ app_model_config.id = "config-123"
+ _persist(sqlite_session, app_model_config)
+ app = factory.create_app(mode=AppMode.AGENT)
+ app.app_model_config_id = app_model_config.id
+ roster_service = MagicMock()
+ roster_service.return_value.get_published_agent_soul_for_app.return_value = None
+ monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
+
+ result = MessageService.get_suggested_questions_after_answer(
+ app_model=app,
+ user=factory.create_end_user(),
+ message_id="msg-123",
+ invoke_from=InvokeFrom.SERVICE_API,
+ session=sqlite_session,
+ )
+
+ assert result == ["Q1?"]
+ llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
+ tenant_id="tenant-123",
+ histories="histories",
+ instruction_prompt="legacy prompt",
+ model_config=None,
+ )
+
+ def test_agent_disabled_raises_disabled_error(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ factory: MessageServiceTestDataFactory,
+ sqlite_session: Session,
+ ) -> None:
+ conversation = factory.create_conversation()
+ conversation.mode = AppMode.AGENT
+ self._chat_boundaries(monkeypatch, conversation)
+ roster_service = MagicMock()
+ roster_service.return_value.get_published_agent_soul_for_app.return_value = self._agent_soul(enabled=False)
+ monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
+
+ with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError):
+ MessageService.get_suggested_questions_after_answer(
+ app_model=factory.create_app(mode=AppMode.AGENT),
+ user=factory.create_end_user(),
+ message_id="msg-123",
+ invoke_from=InvokeFrom.SERVICE_API,
+ session=sqlite_session,
+ )
+
@pytest.mark.parametrize(
("config", "expected_prompt", "expected_model"),
[
From 6fa9c14490e374e1025599f4919caa3e4c0bfd3c Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Wed, 19 Aug 2026 10:13:43 +0000
Subject: [PATCH 12/18] fix(ui): stabilize toast stack interactions (#40970)
---
.../src/toast/__tests__/index.spec.tsx | 98 +++++++++++++++++++
packages/dify-ui/src/toast/index.stories.tsx | 4 +-
packages/dify-ui/src/toast/index.tsx | 10 +-
3 files changed, 105 insertions(+), 7 deletions(-)
diff --git a/packages/dify-ui/src/toast/__tests__/index.spec.tsx b/packages/dify-ui/src/toast/__tests__/index.spec.tsx
index 5e10696b9d0..a53a9731a3b 100644
--- a/packages/dify-ui/src/toast/__tests__/index.spec.tsx
+++ b/packages/dify-ui/src/toast/__tests__/index.spec.tsx
@@ -1,4 +1,5 @@
import { render } from 'vitest-browser-react'
+import { userEvent } from 'vitest/browser'
import { createToast, createToastManager, toast, ToastHost } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
@@ -46,6 +47,103 @@ describe('@langgenius/dify-ui/toast', () => {
expect(document.body.querySelectorAll('[role="dialog"]')).toHaveLength(3)
})
+ it('should not intercept pointer events below a collapsed top-anchored stack', async () => {
+ const screen = await render(
+ <>
+
+
+
+ >,
+ )
+
+ toast('Older notification')
+ toast('Newest notification')
+
+ const newestToast = screen.getByRole('dialog', { name: 'Newest notification' })
+ await expect.element(newestToast).toBeInTheDocument()
+
+ const toastDialogs = Array.from(document.body.querySelectorAll('[role="dialog"]'))
+ const newestBounds = newestToast.element().getBoundingClientRect()
+ const stackBottom = Math.max(
+ ...toastDialogs.map((dialog) => dialog.getBoundingClientRect().bottom),
+ )
+ const elementBelowStack = document.elementFromPoint(
+ newestBounds.left + newestBounds.width / 2,
+ stackBottom + 4,
+ )
+
+ expect(elementBelowStack).toBe(
+ screen.getByRole('button', { name: 'Underlying action' }).element(),
+ )
+ })
+
+ it('should dismiss an expanded background toast from its current row when swiped right', async () => {
+ const baseUIAnimationGlobal = globalThis as BaseUIAnimationGlobal
+ const animationState = baseUIAnimationGlobal.BASE_UI_ANIMATIONS_DISABLED
+ baseUIAnimationGlobal.BASE_UI_ANIMATIONS_DISABLED = false
+
+ try {
+ const screen = await render(
+ <>
+
+
+
+ >,
+ )
+
+ toast('Background notification')
+ toast('Front notification')
+
+ const backgroundToast = screen.getByRole('dialog', { name: 'Background notification' })
+ await expect.element(backgroundToast).toBeInTheDocument()
+ await backgroundToast.hover()
+ await expect.element(backgroundToast).toHaveAttribute('data-expanded')
+
+ const toastElement = backgroundToast.element()
+ const bounds = toastElement.getBoundingClientRect()
+
+ await userEvent.dragAndDrop(toastElement, screen.getByLabelText('Swipe destination'), {
+ steps: 10,
+ })
+
+ await vi.waitFor(() => {
+ expect(toastElement).toHaveAttribute('data-ending-style')
+ })
+ expect(toastElement.getBoundingClientRect().top).toBeCloseTo(bounds.top, 0)
+ } finally {
+ await userEvent.unhover(document.body)
+ baseUIAnimationGlobal.BASE_UI_ANIMATIONS_DISABLED = animationState
+ }
+ })
+
it('should render a neutral toast when called directly', async () => {
const screen = await render()
diff --git a/packages/dify-ui/src/toast/index.stories.tsx b/packages/dify-ui/src/toast/index.stories.tsx
index 8997874324f..2a5e480e0cb 100644
--- a/packages/dify-ui/src/toast/index.stories.tsx
+++ b/packages/dify-ui/src/toast/index.stories.tsx
@@ -127,9 +127,9 @@ const StackExamples = () => {
}
const createVaryingHeightStack = () => {
- toast.info('Long background toast', {
+ toast.error('Failed to publish the workflow', {
description:
- 'This longer toast intentionally spans multiple lines so the collapsed stack can be checked against the shorter frontmost toast height without panel overflow.',
+ 'The workflow could not be published because several dependent resources are unavailable. Check the model provider credentials, reconnect the knowledge base, and try publishing again after every dependency is ready.',
})
toast.success('Short front toast', {
description: 'Short message.',
diff --git a/packages/dify-ui/src/toast/index.tsx b/packages/dify-ui/src/toast/index.tsx
index 6bf06d9fdce..76315e559fd 100644
--- a/packages/dify-ui/src/toast/index.tsx
+++ b/packages/dify-ui/src/toast/index.tsx
@@ -183,16 +183,16 @@ function ToastCard({ toast: toastItem }: { toast: ToastObject }) {
toast={toastItem}
className={cn(
'pointer-events-auto absolute top-0 right-0 w-full origin-top cursor-default rounded-xl select-none focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
- '[--toast-current-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:8px] [--toast-peek:5px] [--toast-scale:calc(1-(var(--toast-index)*0.0225))] [--toast-shrink:calc(1-var(--toast-scale))]',
+ '[--toast-current-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-expanded-offset-y:calc(var(--toast-offset-y)+var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-gap)))] [--toast-gap:8px] [--toast-peek:5px] [--toast-scale:calc(1-(var(--toast-index)*0.0225))] [--toast-shrink:calc(1-var(--toast-scale))]',
'z-[calc(100-var(--toast-index))] h-(--toast-current-height)',
'[transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms] motion-reduce:transition-none',
'transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-current-height))))_scale(var(--toast-scale))]',
- 'data-expanded:h-(--toast-height) data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-offset-y)+var(--toast-swipe-movement-y)+(var(--toast-index)*8px)))_scale(1)]',
+ 'data-expanded:h-(--toast-height) data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-expanded-offset-y))_scale(1)]',
'data-ending-style:pointer-events-none data-ending-style:transform-[translateY(-150%)] data-ending-style:opacity-0 data-ending-style:after:pointer-events-none',
'data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+150%))]',
- 'data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+150%))]',
+ 'data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--toast-expanded-offset-y))]',
'data-limited:pointer-events-none data-limited:opacity-0 data-starting-style:transform-[translateY(-150%)] data-starting-style:opacity-0',
- "after:pointer-events-auto after:absolute after:top-full after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full after:content-['']",
+ "after:pointer-events-auto after:absolute after:bottom-full after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full after:content-['']",
)}
>
@@ -203,7 +203,7 @@ function ToastCard({ toast: toastItem }: { toast: ToastObject
}) {
getToneGradientClasses(toastType),
)}
/>
-
+
From 532489babd068d83d5e386a7c61d4db71d46894d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?=
Date: Wed, 19 Aug 2026 10:34:36 +0000
Subject: [PATCH 13/18] refactor(api): extract web app access queries (#40482)
---
api/.importlinter | 17 +++
api/controllers/web/app.py | 54 ++++----
api/controllers/web/error.py | 12 ++
api/enums/__init__.py | 7 ++
api/extensions/ext_application_services.py | 29 ++++-
api/openapi/markdown/web-openapi.md | 2 +
.../webapp_access_query_repository.py | 24 ++++
api/services/enterprise/enterprise_service.py | 12 +-
api/services/feature_service.py | 6 +-
api/services/webapp_access_query_service.py | 49 ++++++++
.../unit_tests/controllers/web/test_app.py | 105 +++++++++-------
.../unit_tests/controllers/web/test_error.py | 4 +
.../test_ext_application_services.py | 116 +++++++++++++++++-
.../test_webapp_access_query_repository.py | 60 +++++++++
.../enterprise/test_enterprise_service.py | 13 +-
...test_feature_service_deployment_edition.py | 3 +
.../test_webapp_access_query_service.py | 101 +++++++++++++++
.../contracts/generated/api/web/types.gen.ts | 2 +
18 files changed, 538 insertions(+), 78 deletions(-)
create mode 100644 api/repositories/webapp_access_query_repository.py
create mode 100644 api/services/webapp_access_query_service.py
create mode 100644 api/tests/unit_tests/repositories/test_webapp_access_query_repository.py
create mode 100644 api/tests/unit_tests/services/test_webapp_access_query_service.py
diff --git a/api/.importlinter b/api/.importlinter
index 1cbafd9b8ec..d7492912e7f 100644
--- a/api/.importlinter
+++ b/api/.importlinter
@@ -107,6 +107,23 @@ forbidden_modules =
sqlalchemy
werkzeug
+[importlinter:contract:webapp-access-query-service-boundary]
+name = Web app access query application service is framework and persistence neutral
+type = forbidden
+source_modules =
+ services.webapp_access_query_service
+forbidden_modules =
+ configs
+ controllers
+ extensions
+ flask
+ models
+ repositories
+ services.enterprise
+ services.feature_service
+ sqlalchemy
+ werkzeug
+
[importlinter:contract:feature-query-service-boundary]
name = Feature query application service is framework and persistence neutral
type = forbidden
diff --git a/api/controllers/web/app.py b/api/controllers/web/app.py
index a6d8dc47109..135765a902c 100644
--- a/api/controllers/web/app.py
+++ b/api/controllers/web/app.py
@@ -8,8 +8,17 @@ from werkzeug.exceptions import Unauthorized
from constants import HEADER_NAME_APP_CODE
from controllers.common import fields
-from controllers.common.fields import Parameters
+from controllers.common.errors import InvalidArgumentError
+from controllers.common.fields import AccessModeResponse, Parameters
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
+from controllers.web import web_ns
+from controllers.web.error import (
+ AgentNotPublishedError,
+ AppUnavailableError,
+ WebAppAccessServiceUnavailableError,
+ WebAppNotFoundError,
+)
+from controllers.web.wraps import WebApiResource
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from libs.helper import dump_response
@@ -17,15 +26,15 @@ from libs.passport import PassportService
from libs.token import extract_webapp_passport
from models.model import App, EndUser
from services.app_definition_query_service import AppDefinitionNotPublishedError, AppDefinitionUnavailableError
-from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
+from services.webapp_access_query_service import (
+ WebAppAccessAppNotFoundError,
+ WebAppAccessReferenceRequiredError,
+ WebAppAccessUnavailableError,
+)
from services.webapp_auth_service import WebAppAuthService
-from . import web_ns
-from .error import AgentNotPublishedError, AppUnavailableError
-from .wraps import WebApiResource
-
logger = logging.getLogger(__name__)
@@ -54,7 +63,7 @@ register_response_schema_models(
web_ns,
Parameters,
AppMetaResponse,
- fields.AccessModeResponse,
+ AccessModeResponse,
fields.BooleanResultResponse,
)
@@ -122,28 +131,27 @@ class AppAccessMode(Resource):
responses={
200: "Success",
400: "Bad Request",
+ 404: "App Not Found",
500: "Internal Server Error",
+ 503: "Web App Access Service Unavailable",
}
)
- @web_ns.response(200, "Success", web_ns.models[fields.AccessModeResponse.__name__])
+ @web_ns.response(200, "Success", web_ns.models[AccessModeResponse.__name__])
def get(self):
raw_args = request.args.to_dict()
args = AppAccessModeQuery.model_validate(raw_args)
-
- features = FeatureService.get_system_features()
- if not features.webapp_auth.enabled:
- return {"accessMode": "public"}
-
- app_id = args.app_id
- if args.app_code:
- app_id = AppService.get_app_id_by_code(args.app_code, session=db.session())
-
- if not app_id:
- raise ValueError("appId or appCode must be provided")
-
- res = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id)
-
- return {"accessMode": res.access_mode}
+ try:
+ access_mode = application_services().webapp_access.get_access_mode(
+ app_id=args.app_id,
+ app_code=args.app_code,
+ )
+ except WebAppAccessReferenceRequiredError as e:
+ raise InvalidArgumentError(description=str(e)) from None
+ except WebAppAccessAppNotFoundError:
+ raise WebAppNotFoundError() from None
+ except WebAppAccessUnavailableError:
+ raise WebAppAccessServiceUnavailableError() from None
+ return dump_response(AccessModeResponse, {"access_mode": access_mode})
@web_ns.route("/webapp/permission")
diff --git a/api/controllers/web/error.py b/api/controllers/web/error.py
index c6ea2aea192..b0ab2f0334c 100644
--- a/api/controllers/web/error.py
+++ b/api/controllers/web/error.py
@@ -121,6 +121,18 @@ class WebAppAuthAccessDeniedError(BaseHTTPException):
code = 401
+class WebAppNotFoundError(BaseHTTPException):
+ error_code = "app_not_found"
+ description = "App not found."
+ code = 404
+
+
+class WebAppAccessServiceUnavailableError(BaseHTTPException):
+ error_code = "web_app_access_unavailable"
+ description = "Web app access service is unavailable."
+ code = 503
+
+
class InvokeRateLimitError(BaseHTTPException):
"""Raised when the Invoke returns rate limit error."""
diff --git a/api/enums/__init__.py b/api/enums/__init__.py
index dd5d029f56f..a56af59be25 100644
--- a/api/enums/__init__.py
+++ b/api/enums/__init__.py
@@ -23,6 +23,13 @@ class DeploymentEdition(StrEnum):
CLOUD = "CLOUD"
+class WebAppAccessMode(StrEnum):
+ PUBLIC = "public"
+ PRIVATE = "private"
+ PRIVATE_ALL = "private_all"
+ SSO_VERIFIED = "sso_verified"
+
+
class HostedTrialProvider(StrEnum):
"""Enum representing hosted model provider names for trial access."""
diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py
index 9d9b37c88c0..af732a18044 100644
--- a/api/extensions/ext_application_services.py
+++ b/api/extensions/ext_application_services.py
@@ -1,22 +1,26 @@
"""Composition root for application services used by transport adapters."""
+import json
from dataclasses import dataclass
from typing import cast
+import httpx
from flask import Flask, current_app
+from pydantic import ValidationError
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from constants.dsl_version import CURRENT_APP_DSL_VERSION
from core.db.session_factory import get_session_maker
from core.schemas.schema_manager import SchemaManager
-from enums import DeploymentEdition
+from enums import DeploymentEdition, WebAppAccessMode
from extensions.ext_redis import RedisClientWrapper, redis_client
from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository
from repositories.app_definition_query_repository import AppDefinitionQueryRepository
from repositories.data_source_api_key_auth_repository import SQLAlchemyDataSourceApiKeyAuthBindingRepository
from repositories.explore_banner_query_repository import ExploreBannerQueryRepository
from repositories.installation_state_repository import InstallationStateRepository
+from repositories.webapp_access_query_repository import WebAppAccessQueryRepository
from repositories.workspace_member_query_repository import WorkspaceMemberQueryRepository
from repositories.workspace_query_repository import WorkspaceQueryRepository
from services.account_activation_adapters import (
@@ -32,6 +36,8 @@ from services.auth.data_source_api_key_auth_gateways import (
TenantApiKeyAuthCredentialEncryptor,
)
from services.auth.data_source_api_key_auth_service import DataSourceApiKeyAuthService
+from services.enterprise.enterprise_service import EnterpriseService
+from services.errors.enterprise import EnterpriseServiceError
from services.explore_banner_query_service import ExploreBannerQueryService
from services.feature_query_service import FeatureQueryService
from services.feature_service import FeatureService
@@ -40,6 +46,10 @@ from services.init_validation_service import InitValidationService
from services.schema_definition_service import SchemaDefinitionService
from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner
from services.setup_service import SetupService
+from services.webapp_access_query_service import (
+ WebAppAccessQueryService,
+ WebAppAccessUnavailableError,
+)
from services.workspace_member_query_service import WorkspaceMemberQueryService
from services.workspace_member_role_resolver import DeploymentWorkspaceMemberRoleResolver
from services.workspace_plan_gateway import DeploymentWorkspacePlanGateway
@@ -48,11 +58,23 @@ from services.workspace_query_service import WorkspaceQueryService
_EXTENSION_KEY = "application_services"
+def _get_enterprise_webapp_access_mode(app_id: str) -> WebAppAccessMode:
+ try:
+ settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id)
+ except (EnterpriseServiceError, httpx.RequestError, json.JSONDecodeError, UnicodeDecodeError, ValidationError) as e:
+ raise WebAppAccessUnavailableError from e
+ try:
+ return WebAppAccessMode(settings.access_mode)
+ except ValueError as e:
+ raise WebAppAccessUnavailableError from e
+
+
@dataclass(frozen=True, slots=True)
class ApplicationServices:
account_activation: AccountActivationService
app_definitions: AppDefinitionQueryService
data_source_api_key_auth: DataSourceApiKeyAuthService
+ webapp_access: WebAppAccessQueryService
explore_banner_queries: ExploreBannerQueryService
schema_definitions: SchemaDefinitionService
setup: SetupService
@@ -94,6 +116,11 @@ def build_application_services(
validator=ProviderApiKeyAuthCredentialValidator(),
encryptor=TenantApiKeyAuthCredentialEncryptor(),
),
+ webapp_access=WebAppAccessQueryService(
+ access=WebAppAccessQueryRepository(session_factory=database_client),
+ webapp_auth_enabled=FeatureService.is_webapp_auth_enabled(),
+ access_mode_for_app=_get_enterprise_webapp_access_mode,
+ ),
explore_banner_queries=ExploreBannerQueryService(
banners=ExploreBannerQueryRepository(client=database_client),
is_enabled=FeatureService.is_explore_banner_enabled,
diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md
index 36f2f7a084a..0fa5dfe91ca 100644
--- a/api/openapi/markdown/web-openapi.md
+++ b/api/openapi/markdown/web-openapi.md
@@ -826,7 +826,9 @@ Retrieve the access mode for a web application (public or restricted).
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AccessModeResponse](#accessmoderesponse)
|
| 400 | Bad Request | |
+| 404 | App Not Found | |
| 500 | Internal Server Error | |
+| 503 | Web App Access Service Unavailable | |
### [GET] /webapp/permission
Check if user has permission to access a web application.
diff --git a/api/repositories/webapp_access_query_repository.py b/api/repositories/webapp_access_query_repository.py
new file mode 100644
index 00000000000..94e2a31b6a9
--- /dev/null
+++ b/api/repositories/webapp_access_query_repository.py
@@ -0,0 +1,24 @@
+"""Database repository for web-app access queries."""
+
+from typing import override
+
+from sqlalchemy import select
+from sqlalchemy.exc import DBAPIError, TimeoutError
+from sqlalchemy.orm import Session, sessionmaker
+
+from models.model import Site
+from services.webapp_access_query_service import WebAppAccessQuery, WebAppAccessUnavailableError
+
+
+class WebAppAccessQueryRepository(WebAppAccessQuery):
+ def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
+ self._session_factory = session_factory
+
+ @override
+ def find_app_id_by_code(self, app_code: str) -> str | None:
+ try:
+ with self._session_factory() as session:
+ app_id = session.scalar(select(Site.app_id).where(Site.code == app_code).limit(1))
+ return str(app_id) if app_id is not None else None
+ except (DBAPIError, TimeoutError) as e:
+ raise WebAppAccessUnavailableError from e
diff --git a/api/services/enterprise/enterprise_service.py b/api/services/enterprise/enterprise_service.py
index e8c768f20be..b64bfc914e2 100644
--- a/api/services/enterprise/enterprise_service.py
+++ b/api/services/enterprise/enterprise_service.py
@@ -1,6 +1,5 @@
from __future__ import annotations
-import enum
import logging
import uuid
from datetime import datetime
@@ -9,7 +8,7 @@ from cachetools.func import ttl_cache
from pydantic import BaseModel, ConfigDict, Field, model_validator
from configs import dify_config
-from enums import DeploymentEdition
+from enums import DeploymentEdition, WebAppAccessMode
from extensions.ext_redis import redis_client
from services.enterprise.base import (
EnterpriseRequest,
@@ -31,13 +30,6 @@ VALID_LICENSE_CACHE_TTL = 600 # 10 minutes — valid licenses are stable
INVALID_LICENSE_CACHE_TTL = 30 # 30 seconds — short so admin fixes are picked up quickly
-class WebAppAccessMode(enum.StrEnum):
- PUBLIC = "public"
- PRIVATE = "private"
- PRIVATE_ALL = "private_all"
- SSO_VERIFIED = "sso_verified"
-
-
PERMISSION_CHECK_MODES: frozenset[WebAppAccessMode] = frozenset(
{WebAppAccessMode.PRIVATE, WebAppAccessMode.PRIVATE_ALL}
)
@@ -293,7 +285,7 @@ class EnterpriseService:
params = {"appId": app_id}
data = EnterpriseRequest.send_request("GET", "/webapp/access-mode/id", params=params)
if not data:
- raise ValueError("No data found.")
+ raise EnterpriseServiceError("No data found.")
return WebAppSettings.model_validate(data)
@classmethod
diff --git a/api/services/feature_service.py b/api/services/feature_service.py
index b32387ffea0..d1e80a65fd4 100644
--- a/api/services/feature_service.py
+++ b/api/services/feature_service.py
@@ -104,10 +104,10 @@ class FeatureService:
system_features.rbac_enabled = dify_config.RBAC_ENABLED
cls._fulfill_system_params_from_env(system_features)
+ system_features.webapp_auth.enabled = cls.is_webapp_auth_enabled()
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
system_features.branding.enabled = True
- system_features.webapp_auth.enabled = True
system_features.enable_change_email = False
cls._fulfill_params_from_enterprise(system_features)
@@ -159,6 +159,10 @@ class FeatureService:
def is_explore_banner_enabled() -> bool:
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_EXPLORE_BANNER
+ @staticmethod
+ def is_webapp_auth_enabled() -> bool:
+ return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE
+
@classmethod
def _fulfill_system_params_from_env(cls, system_features: feature_entities.SystemFeatureModel):
system_features.enable_email_code_login = dify_config.ENABLE_EMAIL_CODE_LOGIN
diff --git a/api/services/webapp_access_query_service.py b/api/services/webapp_access_query_service.py
new file mode 100644
index 00000000000..10524041f9c
--- /dev/null
+++ b/api/services/webapp_access_query_service.py
@@ -0,0 +1,49 @@
+"""Application service for resolving web-app access."""
+
+from collections.abc import Callable
+from typing import Protocol
+
+from enums import WebAppAccessMode
+
+
+class WebAppAccessQuery(Protocol):
+ def find_app_id_by_code(self, app_code: str) -> str | None: ...
+
+
+class WebAppAccessReferenceRequiredError(ValueError):
+ """Raised when neither an app ID nor an app code was provided."""
+
+
+class WebAppAccessAppNotFoundError(LookupError):
+ """Raised when an app code does not resolve to an app."""
+
+
+class WebAppAccessUnavailableError(RuntimeError):
+ """Raised when an access dependency cannot answer the query."""
+
+
+class WebAppAccessQueryService:
+ def __init__(
+ self,
+ *,
+ access: WebAppAccessQuery,
+ webapp_auth_enabled: bool,
+ access_mode_for_app: Callable[[str], WebAppAccessMode],
+ ) -> None:
+ self._access = access
+ self._webapp_auth_enabled = webapp_auth_enabled
+ self._access_mode_for_app = access_mode_for_app
+
+ def get_access_mode(self, *, app_id: str | None, app_code: str | None) -> WebAppAccessMode:
+ if not self._webapp_auth_enabled:
+ return WebAppAccessMode.PUBLIC
+
+ if app_code:
+ app_id = self._access.find_app_id_by_code(app_code)
+ if app_id is None:
+ raise WebAppAccessAppNotFoundError
+
+ if not app_id:
+ raise WebAppAccessReferenceRequiredError("appId or appCode must be provided")
+
+ return self._access_mode_for_app(app_id)
diff --git a/api/tests/unit_tests/controllers/web/test_app.py b/api/tests/unit_tests/controllers/web/test_app.py
index b6245da2f01..3c5daf9224c 100644
--- a/api/tests/unit_tests/controllers/web/test_app.py
+++ b/api/tests/unit_tests/controllers/web/test_app.py
@@ -3,17 +3,29 @@
from __future__ import annotations
from types import SimpleNamespace
-from unittest.mock import ANY, MagicMock, patch
+from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
+from controllers.common.errors import InvalidArgumentError
from controllers.web.app import AppAccessMode, AppMeta, AppParameterApi, AppWebAuthPermission
-from controllers.web.error import AgentNotPublishedError, AppUnavailableError
+from controllers.web.error import (
+ AgentNotPublishedError,
+ AppUnavailableError,
+ WebAppAccessServiceUnavailableError,
+ WebAppNotFoundError,
+)
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
+from enums import WebAppAccessMode
from models.enums import EndUserType
from models.model import App, AppMode, EndUser
from services.app_definition_query_service import AppDefinitionNotPublishedError, AppDefinitionUnavailableError
+from services.webapp_access_query_service import (
+ WebAppAccessAppNotFoundError,
+ WebAppAccessReferenceRequiredError,
+ WebAppAccessUnavailableError,
+)
def _make_app() -> App:
@@ -119,53 +131,64 @@ class TestAppMeta:
# AppAccessMode
# ---------------------------------------------------------------------------
class TestAppAccessMode:
- @patch("controllers.web.app.FeatureService.get_system_features")
- def test_returns_public_when_webapp_auth_disabled(self, mock_features: MagicMock, app: Flask) -> None:
- mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
+ @patch("controllers.web.app.application_services")
+ def test_delegates_validated_app_references(self, application_services: MagicMock, app: Flask) -> None:
+ webapp_access = MagicMock()
+ webapp_access.get_access_mode.return_value = WebAppAccessMode.SSO_VERIFIED
+ application_services.return_value = SimpleNamespace(webapp_access=webapp_access)
- with app.test_request_context("/webapp/access-mode?appId=app-1"):
+ with app.test_request_context("/webapp/access-mode?appId=app-1&appCode=code-1"):
result = AppAccessMode().get()
- assert result == {"accessMode": "public"}
+ assert result == {"accessMode": "sso_verified"}
+ webapp_access.get_access_mode.assert_called_once_with(app_id="app-1", app_code="code-1")
- @patch("controllers.web.app.EnterpriseService.WebAppAuth.get_app_access_mode_by_id")
- @patch("controllers.web.app.FeatureService.get_system_features")
- def test_returns_access_mode_with_app_id(
- self, mock_features: MagicMock, mock_access: MagicMock, app: Flask
+ @pytest.mark.parametrize(
+ ("service_error", "http_error", "expected_data"),
+ [
+ pytest.param(
+ WebAppAccessReferenceRequiredError("appId or appCode must be provided"),
+ InvalidArgumentError,
+ {"code": "invalid_param", "message": "appId or appCode must be provided", "status": 400},
+ id="missing-reference",
+ ),
+ pytest.param(
+ WebAppAccessAppNotFoundError(),
+ WebAppNotFoundError,
+ {"code": "app_not_found", "message": "App not found.", "status": 404},
+ id="app-not-found",
+ ),
+ pytest.param(
+ WebAppAccessUnavailableError(),
+ WebAppAccessServiceUnavailableError,
+ {
+ "code": "web_app_access_unavailable",
+ "message": "Web app access service is unavailable.",
+ "status": 503,
+ },
+ id="access-unavailable",
+ ),
+ ],
+ )
+ @patch("controllers.web.app.application_services")
+ def test_maps_query_errors(
+ self,
+ application_services: MagicMock,
+ service_error: Exception,
+ http_error: type[Exception],
+ expected_data: dict[str, object],
+ app: Flask,
) -> None:
- mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True))
- mock_access.return_value = SimpleNamespace(access_mode="internal")
+ webapp_access = MagicMock()
+ webapp_access.get_access_mode.side_effect = service_error
+ application_services.return_value = SimpleNamespace(webapp_access=webapp_access)
- with app.test_request_context("/webapp/access-mode?appId=app-1"):
- result = AppAccessMode().get()
-
- assert result == {"accessMode": "internal"}
- mock_access.assert_called_once_with("app-1")
-
- @patch("controllers.web.app.AppService.get_app_id_by_code", return_value="resolved-id")
- @patch("controllers.web.app.EnterpriseService.WebAppAuth.get_app_access_mode_by_id")
- @patch("controllers.web.app.FeatureService.get_system_features")
- def test_resolves_app_code_to_id(
- self, mock_features: MagicMock, mock_access: MagicMock, mock_resolve: MagicMock, app: Flask
- ) -> None:
- mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True))
- mock_access.return_value = SimpleNamespace(access_mode="external")
-
- with app.test_request_context("/webapp/access-mode?appCode=code1"):
- result = AppAccessMode().get()
-
- mock_resolve.assert_called_once_with("code1", session=ANY)
- mock_access.assert_called_once_with("resolved-id")
- assert result == {"accessMode": "external"}
-
- @patch("controllers.web.app.FeatureService.get_system_features")
- def test_raises_when_no_app_id_or_code(self, mock_features: MagicMock, app: Flask) -> None:
- mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True))
-
- with app.test_request_context("/webapp/access-mode"):
- with pytest.raises(ValueError, match="appId or appCode"):
+ with app.test_request_context("/webapp/access-mode?appCode=code-1"):
+ with pytest.raises(http_error) as raised:
AppAccessMode().get()
+ assert raised.value.data == expected_data
+
# ---------------------------------------------------------------------------
# AppWebAuthPermission
diff --git a/api/tests/unit_tests/controllers/web/test_error.py b/api/tests/unit_tests/controllers/web/test_error.py
index 78a72fc624f..578f2807225 100644
--- a/api/tests/unit_tests/controllers/web/test_error.py
+++ b/api/tests/unit_tests/controllers/web/test_error.py
@@ -24,8 +24,10 @@ from controllers.web.error import (
ProviderQuotaExceededError,
SpeechToTextDisabledError,
UnsupportedAudioTypeError,
+ WebAppAccessServiceUnavailableError,
WebAppAuthAccessDeniedError,
WebAppAuthRequiredError,
+ WebAppNotFoundError,
WebFormRateLimitExceededError,
)
@@ -49,6 +51,8 @@ _ERROR_SPECS: list[tuple[type, str, int]] = [
(SpeechToTextDisabledError, "speech_to_text_disabled", 400),
(WebAppAuthRequiredError, "web_sso_auth_required", 401),
(WebAppAuthAccessDeniedError, "web_app_access_denied", 401),
+ (WebAppNotFoundError, "app_not_found", 404),
+ (WebAppAccessServiceUnavailableError, "web_app_access_unavailable", 503),
(InvokeRateLimitError, "rate_limit_error", 429),
(WebFormRateLimitExceededError, "web_form_rate_limit_exceeded", 429),
(NotFoundError, "not_found", 404),
diff --git a/api/tests/unit_tests/extensions/test_ext_application_services.py b/api/tests/unit_tests/extensions/test_ext_application_services.py
index 5701827304c..5ddaeb99412 100644
--- a/api/tests/unit_tests/extensions/test_ext_application_services.py
+++ b/api/tests/unit_tests/extensions/test_ext_application_services.py
@@ -1,12 +1,16 @@
"""Tests for application-service dependency wiring."""
+import json
+from types import SimpleNamespace
from unittest.mock import MagicMock, patch
+import httpx
import pytest
from flask import Flask
+from pydantic import ValidationError
from sqlalchemy.orm import Session, sessionmaker
-from enums import DeploymentEdition
+from enums import DeploymentEdition, WebAppAccessMode
from extensions import ext_application_services
from extensions.ext_redis import RedisClientWrapper
from models.model import DifySetup
@@ -18,7 +22,10 @@ from services.account_activation_adapters import (
RegisterServiceInvitationTokenStore,
)
from services.auth.data_source_api_key_auth_service import DataSourceApiKeyAuthService
+from services.enterprise.enterprise_service import WebAppSettings
+from services.errors.enterprise import EnterpriseAPIError, EnterpriseAPINotFoundError
from services.init_validation_service import InvalidInitializationPasswordError
+from services.webapp_access_query_service import WebAppAccessUnavailableError
@pytest.mark.parametrize(
@@ -186,3 +193,110 @@ def test_build_application_services_wires_data_source_api_key_auth(
)
assert isinstance(services.data_source_api_key_auth, DataSourceApiKeyAuthService)
+
+
+def test_build_application_services_adapts_enterprise_webapp_access_mode(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ with (
+ patch("extensions.ext_application_services.FeatureService.is_webapp_auth_enabled", return_value=True),
+ patch(
+ "extensions.ext_application_services.EnterpriseService.WebAppAuth.get_app_access_mode_by_id",
+ return_value=SimpleNamespace(access_mode="private_all"),
+ ) as get_access_mode,
+ ):
+ services = ext_application_services.build_application_services(
+ database_client=sqlite_session_factory,
+ deployment_edition=DeploymentEdition.COMMUNITY,
+ initialization_password="",
+ redis=MagicMock(spec=RedisClientWrapper),
+ )
+ result = services.webapp_access.get_access_mode(app_id="app-1", app_code=None)
+
+ assert result is WebAppAccessMode.PRIVATE_ALL
+ get_access_mode.assert_called_once_with("app-1")
+
+
+@pytest.mark.parametrize(
+ "enterprise_error",
+ [
+ pytest.param(EnterpriseAPINotFoundError(), id="not-found"),
+ pytest.param(EnterpriseAPIError(), id="api-error"),
+ pytest.param(httpx.ConnectError("connection failed"), id="transport"),
+ pytest.param(json.JSONDecodeError("invalid", "", 0), id="invalid-json"),
+ pytest.param(UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid"), id="invalid-encoding"),
+ pytest.param(
+ ValidationError.from_exception_data(WebAppSettings.__name__, []),
+ id="invalid-response",
+ ),
+ ],
+)
+def test_build_application_services_maps_known_enterprise_errors(
+ sqlite_session_factory: sessionmaker[Session],
+ enterprise_error: Exception,
+) -> None:
+ with (
+ patch("extensions.ext_application_services.FeatureService.is_webapp_auth_enabled", return_value=True),
+ patch(
+ "extensions.ext_application_services.EnterpriseService.WebAppAuth.get_app_access_mode_by_id",
+ side_effect=enterprise_error,
+ ),
+ ):
+ services = ext_application_services.build_application_services(
+ database_client=sqlite_session_factory,
+ deployment_edition=DeploymentEdition.COMMUNITY,
+ initialization_password="",
+ redis=MagicMock(spec=RedisClientWrapper),
+ )
+
+ with pytest.raises(WebAppAccessUnavailableError) as raised:
+ services.webapp_access.get_access_mode(app_id="app-1", app_code=None)
+
+ assert raised.value.__cause__ is enterprise_error
+
+
+def test_build_application_services_maps_invalid_access_mode_to_unavailable(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ with (
+ patch("extensions.ext_application_services.FeatureService.is_webapp_auth_enabled", return_value=True),
+ patch(
+ "extensions.ext_application_services.EnterpriseService.WebAppAuth.get_app_access_mode_by_id",
+ return_value=SimpleNamespace(access_mode="invalid"),
+ ),
+ ):
+ services = ext_application_services.build_application_services(
+ database_client=sqlite_session_factory,
+ deployment_edition=DeploymentEdition.COMMUNITY,
+ initialization_password="",
+ redis=MagicMock(spec=RedisClientWrapper),
+ )
+
+ with pytest.raises(WebAppAccessUnavailableError) as raised:
+ services.webapp_access.get_access_mode(app_id="app-1", app_code=None)
+
+ assert isinstance(raised.value.__cause__, ValueError)
+
+
+def test_build_application_services_does_not_hide_unknown_enterprise_errors(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ failure = TypeError("adapter bug")
+ with (
+ patch("extensions.ext_application_services.FeatureService.is_webapp_auth_enabled", return_value=True),
+ patch(
+ "extensions.ext_application_services.EnterpriseService.WebAppAuth.get_app_access_mode_by_id",
+ side_effect=failure,
+ ),
+ ):
+ services = ext_application_services.build_application_services(
+ database_client=sqlite_session_factory,
+ deployment_edition=DeploymentEdition.COMMUNITY,
+ initialization_password="",
+ redis=MagicMock(spec=RedisClientWrapper),
+ )
+
+ with pytest.raises(TypeError) as raised:
+ services.webapp_access.get_access_mode(app_id="app-1", app_code=None)
+
+ assert raised.value is failure
diff --git a/api/tests/unit_tests/repositories/test_webapp_access_query_repository.py b/api/tests/unit_tests/repositories/test_webapp_access_query_repository.py
new file mode 100644
index 00000000000..478898cdc5e
--- /dev/null
+++ b/api/tests/unit_tests/repositories/test_webapp_access_query_repository.py
@@ -0,0 +1,60 @@
+from unittest.mock import MagicMock
+
+import pytest
+from sqlalchemy.exc import OperationalError
+from sqlalchemy.orm import Session, sessionmaker
+
+from models.model import Site
+from repositories.webapp_access_query_repository import WebAppAccessQueryRepository
+from services.webapp_access_query_service import WebAppAccessUnavailableError
+
+_APP_ID = "11111111-1111-1111-1111-111111111111"
+
+
+def test_find_app_id_by_code_returns_matching_site_app(sqlite_session_factory: sessionmaker[Session]) -> None:
+ with sqlite_session_factory.begin() as session:
+ session.add(
+ Site(
+ app_id=_APP_ID,
+ code="site-code",
+ title="Test Site",
+ default_language="en-US",
+ customize_token_strategy="uuid",
+ )
+ )
+
+ repository = WebAppAccessQueryRepository(session_factory=sqlite_session_factory)
+
+ assert repository.find_app_id_by_code("site-code") == _APP_ID
+
+
+def test_find_app_id_by_code_returns_none_for_missing_code(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ repository = WebAppAccessQueryRepository(session_factory=sqlite_session_factory)
+
+ assert repository.find_app_id_by_code("missing-code") is None
+
+
+def test_find_app_id_by_code_maps_database_failures_to_unavailable() -> None:
+ database_error = OperationalError("select", {}, RuntimeError("connection failed"))
+ session = MagicMock()
+ session.__enter__.return_value.scalar.side_effect = database_error
+ repository = WebAppAccessQueryRepository(session_factory=MagicMock(return_value=session))
+
+ with pytest.raises(WebAppAccessUnavailableError) as raised:
+ repository.find_app_id_by_code("site-code")
+
+ assert raised.value.__cause__ is database_error
+
+
+def test_find_app_id_by_code_does_not_hide_unknown_errors() -> None:
+ failure = TypeError("repository bug")
+ session = MagicMock()
+ session.__enter__.return_value.scalar.side_effect = failure
+ repository = WebAppAccessQueryRepository(session_factory=MagicMock(return_value=session))
+
+ with pytest.raises(TypeError) as raised:
+ repository.find_app_id_by_code("site-code")
+
+ assert raised.value is failure
diff --git a/api/tests/unit_tests/services/enterprise/test_enterprise_service.py b/api/tests/unit_tests/services/enterprise/test_enterprise_service.py
index d50f276bc75..e37ffc8d425 100644
--- a/api/tests/unit_tests/services/enterprise/test_enterprise_service.py
+++ b/api/tests/unit_tests/services/enterprise/test_enterprise_service.py
@@ -23,7 +23,12 @@ from services.enterprise.enterprise_service import (
try_join_default_workspace,
)
from services.entities.feature_entities import LicenseStatus
-from services.errors.enterprise import EnterpriseAPIError, EnterpriseAPIForbiddenError, EnterpriseAPIUnauthorizedError
+from services.errors.enterprise import (
+ EnterpriseAPIError,
+ EnterpriseAPIForbiddenError,
+ EnterpriseAPIUnauthorizedError,
+ EnterpriseServiceError,
+)
MODULE = "services.enterprise.enterprise_service"
@@ -147,6 +152,12 @@ class TestWebAppAuth:
assert isinstance(result, WebAppSettings)
assert result.access_mode == "public"
+ def test_get_app_access_mode_raises_service_error_on_empty_response(self):
+ with patch(f"{MODULE}.EnterpriseRequest") as req:
+ req.send_request.return_value = None
+ with pytest.raises(EnterpriseServiceError, match="No data found"):
+ EnterpriseService.WebAppAuth.get_app_access_mode_by_id("a1")
+
def test_batch_get_returns_empty_for_no_apps(self):
assert EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id([]) == {}
diff --git a/api/tests/unit_tests/services/test_feature_service_deployment_edition.py b/api/tests/unit_tests/services/test_feature_service_deployment_edition.py
index 8336b923ffb..63ebb75c4ba 100644
--- a/api/tests/unit_tests/services/test_feature_service_deployment_edition.py
+++ b/api/tests/unit_tests/services/test_feature_service_deployment_edition.py
@@ -36,6 +36,9 @@ def test_get_system_features_uses_configured_deployment_edition(
assert result.deployment_edition is edition
assert result.model_dump(mode="json")["deployment_edition"] == edition.value
+ webapp_auth_enabled = edition is DeploymentEdition.ENTERPRISE
+ assert FeatureService.is_webapp_auth_enabled() is webapp_auth_enabled
+ assert result.webapp_auth.enabled is webapp_auth_enabled
if edition is DeploymentEdition.ENTERPRISE:
fulfill_from_enterprise.assert_called_once_with(result)
else:
diff --git a/api/tests/unit_tests/services/test_webapp_access_query_service.py b/api/tests/unit_tests/services/test_webapp_access_query_service.py
new file mode 100644
index 00000000000..8c0702a9018
--- /dev/null
+++ b/api/tests/unit_tests/services/test_webapp_access_query_service.py
@@ -0,0 +1,101 @@
+from unittest.mock import MagicMock, create_autospec
+
+import pytest
+
+from enums import WebAppAccessMode
+from services.webapp_access_query_service import (
+ WebAppAccessAppNotFoundError,
+ WebAppAccessQuery,
+ WebAppAccessQueryService,
+ WebAppAccessReferenceRequiredError,
+)
+
+
+def _service(
+ *,
+ access: MagicMock,
+ enabled: bool = True,
+ access_mode: WebAppAccessMode = WebAppAccessMode.PRIVATE,
+) -> tuple[WebAppAccessQueryService, MagicMock]:
+ access_mode_for_app = MagicMock(return_value=access_mode)
+ return (
+ WebAppAccessQueryService(
+ access=access,
+ webapp_auth_enabled=enabled,
+ access_mode_for_app=access_mode_for_app,
+ ),
+ access_mode_for_app,
+ )
+
+
+def test_disabled_auth_returns_public_before_resolving_app() -> None:
+ access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
+ service, access_mode_for_app = _service(access=access, enabled=False)
+
+ assert service.get_access_mode(app_id=None, app_code=None) is WebAppAccessMode.PUBLIC
+ access.find_app_id_by_code.assert_not_called()
+ access_mode_for_app.assert_not_called()
+
+
+def test_enabled_auth_reads_access_mode_by_app_id() -> None:
+ access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
+ service, access_mode_for_app = _service(access=access)
+
+ assert service.get_access_mode(app_id="app-1", app_code=None) is WebAppAccessMode.PRIVATE
+ access.find_app_id_by_code.assert_not_called()
+ access_mode_for_app.assert_called_once_with("app-1")
+
+
+def test_app_code_takes_precedence_over_app_id() -> None:
+ access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
+ access.find_app_id_by_code.return_value = "resolved-id"
+ service, access_mode_for_app = _service(access=access, access_mode=WebAppAccessMode.SSO_VERIFIED)
+
+ assert service.get_access_mode(app_id="ignored-id", app_code="code-1") is WebAppAccessMode.SSO_VERIFIED
+ access.find_app_id_by_code.assert_called_once_with("code-1")
+ access_mode_for_app.assert_called_once_with("resolved-id")
+
+
+def test_missing_app_code_raises_not_found() -> None:
+ access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
+ access.find_app_id_by_code.return_value = None
+ service, access_mode_for_app = _service(access=access)
+
+ with pytest.raises(WebAppAccessAppNotFoundError):
+ service.get_access_mode(app_id="must-not-fallback", app_code="missing-code")
+
+ access_mode_for_app.assert_not_called()
+
+
+def test_enabled_auth_requires_app_id_or_code() -> None:
+ access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
+ service, access_mode_for_app = _service(access=access)
+
+ with pytest.raises(WebAppAccessReferenceRequiredError, match="^appId or appCode must be provided$"):
+ service.get_access_mode(app_id=None, app_code=None)
+
+ access_mode_for_app.assert_not_called()
+
+
+def test_repository_failure_is_not_hidden() -> None:
+ access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
+ failure = TypeError("repository bug")
+ access.find_app_id_by_code.side_effect = failure
+ service, _ = _service(access=access)
+
+ with pytest.raises(TypeError) as raised:
+ service.get_access_mode(app_id=None, app_code="code-1")
+
+ assert raised.value is failure
+
+
+def test_access_mode_failure_is_not_hidden() -> None:
+ access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
+ service, access_mode_for_app = _service(access=access)
+ failure = TypeError("adapter bug")
+ access_mode_for_app.side_effect = failure
+
+ with pytest.raises(TypeError) as raised:
+ service.get_access_mode(app_id="app-1", app_code=None)
+
+ assert raised.value is failure
diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts
index f95ed066bb5..5c9b6a578e8 100644
--- a/packages/contracts/generated/api/web/types.gen.ts
+++ b/packages/contracts/generated/api/web/types.gen.ts
@@ -1543,7 +1543,9 @@ export type GetWebappAccessModeData = {
export type GetWebappAccessModeErrors = {
400: unknown
+ 404: unknown
500: unknown
+ 503: unknown
}
export type GetWebappAccessModeResponses = {
From 356e3a8ab68f7e9f7d4b3dccd78c0bcacdbd2ad7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?=
Date: Wed, 19 Aug 2026 10:34:37 +0000
Subject: [PATCH 14/18] refactor(api): snapshot explore banner feature flag
(#40555)
---
api/extensions/ext_application_services.py | 2 +-
api/services/explore_banner_query_service.py | 8 ++++----
.../controllers/console/explore/test_banner.py | 10 +++++-----
3 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py
index af732a18044..b91bf554897 100644
--- a/api/extensions/ext_application_services.py
+++ b/api/extensions/ext_application_services.py
@@ -123,7 +123,7 @@ def build_application_services(
),
explore_banner_queries=ExploreBannerQueryService(
banners=ExploreBannerQueryRepository(client=database_client),
- is_enabled=FeatureService.is_explore_banner_enabled,
+ enabled=FeatureService.is_explore_banner_enabled(),
),
schema_definitions=SchemaDefinitionService(source_factory=SchemaManager),
setup=SetupService(
diff --git a/api/services/explore_banner_query_service.py b/api/services/explore_banner_query_service.py
index 5a64245bb94..8ef4f3827f4 100644
--- a/api/services/explore_banner_query_service.py
+++ b/api/services/explore_banner_query_service.py
@@ -3,7 +3,7 @@
ExploreBanner is the legacy contract name shared by the API, feature flag, and database model.
"""
-from collections.abc import Callable, Sequence
+from collections.abc import Sequence
from datetime import datetime
from typing import Any, NamedTuple, Protocol
@@ -28,13 +28,13 @@ class ExploreBannerQueryService:
self,
*,
banners: ExploreBannerQuery,
- is_enabled: Callable[[], bool],
+ enabled: bool,
) -> None:
self._banners = banners
- self._is_enabled = is_enabled
+ self._enabled = enabled
def list_for_language(self, language: str) -> tuple[ExploreBannerRecord, ...]:
- if not self._is_enabled():
+ if not self._enabled:
return ()
banners = tuple(self._banners.list_enabled(language))
diff --git a/api/tests/unit_tests/controllers/console/explore/test_banner.py b/api/tests/unit_tests/controllers/console/explore/test_banner.py
index 0260cffde57..5c44ea4b8f5 100644
--- a/api/tests/unit_tests/controllers/console/explore/test_banner.py
+++ b/api/tests/unit_tests/controllers/console/explore/test_banner.py
@@ -85,7 +85,7 @@ def _use_sqlite_banner_service(
) -> None:
service = ExploreBannerQueryService(
banners=ExploreBannerQueryRepository(sqlite_session_factory),
- is_enabled=lambda: True,
+ enabled=True,
)
monkeypatch.setattr(
banner_module,
@@ -97,7 +97,7 @@ def _use_sqlite_banner_service(
class TestExploreBannerQueryService:
def test_returns_empty_without_querying_when_disabled(self) -> None:
banners = FakeExploreBannerQuery()
- service = ExploreBannerQueryService(banners=banners, is_enabled=lambda: False)
+ service = ExploreBannerQueryService(banners=banners, enabled=False)
assert service.list_for_language("fr-FR") == ()
assert banners.requested_languages == []
@@ -105,7 +105,7 @@ class TestExploreBannerQueryService:
def test_returns_requested_language(self) -> None:
record = _record()
banners = FakeExploreBannerQuery({"fr-FR": (record,)})
- service = ExploreBannerQueryService(banners=banners, is_enabled=lambda: True)
+ service = ExploreBannerQueryService(banners=banners, enabled=True)
assert service.list_for_language("fr-FR") == (record,)
assert banners.requested_languages == ["fr-FR"]
@@ -113,14 +113,14 @@ class TestExploreBannerQueryService:
def test_falls_back_to_en_us(self) -> None:
record = _record(title="fallback")
banners = FakeExploreBannerQuery({"en-US": (record,)})
- service = ExploreBannerQueryService(banners=banners, is_enabled=lambda: True)
+ service = ExploreBannerQueryService(banners=banners, enabled=True)
assert service.list_for_language("es-ES") == (record,)
assert banners.requested_languages == ["es-ES", "en-US"]
def test_does_not_repeat_default_language_query(self) -> None:
banners = FakeExploreBannerQuery()
- service = ExploreBannerQueryService(banners=banners, is_enabled=lambda: True)
+ service = ExploreBannerQueryService(banners=banners, enabled=True)
assert service.list_for_language("en-US") == ()
assert banners.requested_languages == ["en-US"]
From 930f4e6c26523fadf3f290f3cf909f4db2062aef Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=9B=90=E7=B2=92=20Yanli?=
Date: Wed, 19 Aug 2026 10:35:13 +0000
Subject: [PATCH 15/18] fix(dify-agent): persist interrupted run history
(#40972)
---
dify-agent/docs/dify-agent/guide/index.md | 11 +-
.../user-manual/history-layer/index.md | 33 ++-
.../user-manual/plugin-llm-layer/index.md | 9 +-
dify-agent/src/dify_agent/runtime/history.py | 12 +-
dify-agent/src/dify_agent/runtime/runner.py | 39 +--
.../local/dify_agent/runtime/test_runner.py | 265 +++++++++++-------
.../runtime/test_runtime_history.py | 6 +-
7 files changed, 235 insertions(+), 140 deletions(-)
diff --git a/dify-agent/docs/dify-agent/guide/index.md b/dify-agent/docs/dify-agent/guide/index.md
index 68a69772984..3090dd6e0cb 100644
--- a/dify-agent/docs/dify-agent/guide/index.md
+++ b/dify-agent/docs/dify-agent/guide/index.md
@@ -341,11 +341,12 @@ whose Agenton layers provide user input. With the MVP provider set, use
effective prompts are rejected during create-run validation before the run is
persisted or scheduled.
-There is no Pydantic AI history layer. To resume Agenton layer state, pass the
-`session_snapshot` from a previous terminal event together with a composition
-that has the same layer names and order. Success always contains a snapshot.
-Failure and cancellation contain one only when compositor entry succeeded and
-layer exit completed; otherwise callers should retain their previous snapshot.
+The optional Pydantic AI history layer uses the reserved name `history` and
+persists captured messages in session snapshots for later resume. Resume from a
+terminal event's `session_snapshot` using the same layer composition, names, and
+order. Success always contains a snapshot. Failure and cancellation contain one
+only when compositor entry succeeded and layer exit completed; otherwise callers
+should retain their previous snapshot.
## Observing runs
diff --git a/dify-agent/docs/dify-agent/user-manual/history-layer/index.md b/dify-agent/docs/dify-agent/user-manual/history-layer/index.md
index 9bda3401d58..4a8550c8636 100644
--- a/dify-agent/docs/dify-agent/user-manual/history-layer/index.md
+++ b/dify-agent/docs/dify-agent/user-manual/history-layer/index.md
@@ -47,15 +47,22 @@ tool-call/result pairs and their inputs. If the history is still over target, th
same current model incrementally summarizes older messages while retaining the
latest twenty messages and the first user message.
-With a history layer, a successful run replaces its stored messages with the
-rewritten complete history in the returned session snapshot. Without this layer,
-compaction affects only the current run. Failed runs do not write a resumable
-success snapshot, so their history rewrites do not persist across runs.
+With a history layer, once pydantic-ai binds and builds messages in the run
+capture, the captured, possibly rewritten history replaces the stored messages
+in the terminal session snapshot. This applies to successful, failed, and
+cancelled runs. A failure or cancellation before the capture contains any
+messages preserves the previously restored history. An interrupted capture can
+include a partial response or tool-return request marked `state="interrupted"`;
+pydantic-ai repairs that state when the snapshot is used by a later independent
+run. Without this layer, compaction and interrupted messages affect only the
+current run.
## Resume a conversation
Successful runs return a terminal event with both final output and a resumable
-session snapshot:
+session snapshot. Failed and cancelled terminal events can also carry a session
+snapshot that checkpoints current history, but they do not change the interrupted
+run's terminal status into success.
```python {test="skip" lint="skip"}
accepted = await client.create_run(request)
@@ -87,10 +94,16 @@ Dify Agent handles memory conservatively:
2. Stored history is sent to the model before the current user prompt.
3. When the LLM layer includes `context_window_tokens`, Harness may rewrite
over-target history immediately before a model request as described above.
-4. After a successful run, the complete possibly compacted history is written
- back to the layer.
-5. Run-level system instructions are removed before history is persisted.
-6. Failed runs emit `run_failed` and do not return a success snapshot to resume.
+4. Once pydantic-ai binds and builds messages in the run capture, the complete
+ captured and possibly compacted history is written back to the layer on
+ success, failure, timeout, or cancellation.
+5. If failure or cancellation occurs before the capture contains any messages,
+ the previously restored history remains unchanged.
+6. Run-level system instructions are removed before history is persisted.
+7. Interrupted partial messages retain pydantic-ai's `state="interrupted"` marker
+ so a later independent run can repair and continue from the checkpoint.
+8. Failed and cancelled runs keep their terminal status; their snapshot is a
+ checkpoint, not a successful continuation of the interrupted run.
## Persist snapshots outside the client process
@@ -118,5 +131,5 @@ Always restore snapshots with the same layer names and order that produced them.
| --- | --- |
| `must use reserved layer name 'history'` | Rename the layer to `history`. |
| `does not support dependencies` | Remove `deps` from the history layer. |
-| Resume fails with snapshot lifecycle errors | Use the success snapshot from `run_succeeded` and keep layer names/order unchanged. |
+| Resume fails with snapshot lifecycle errors | Use a terminal snapshot whose layers were suspended, and keep layer names/order unchanged. |
| System prompts appear missing from saved memory | This is expected; current system prompts are temporary and are not persisted. |
diff --git a/dify-agent/docs/dify-agent/user-manual/plugin-llm-layer/index.md b/dify-agent/docs/dify-agent/user-manual/plugin-llm-layer/index.md
index 4fe7f4c0849..8b8c970e9e8 100644
--- a/dify-agent/docs/dify-agent/user-manual/plugin-llm-layer/index.md
+++ b/dify-agent/docs/dify-agent/user-manual/plugin-llm-layer/index.md
@@ -72,8 +72,13 @@ model incrementally summarizes older history while retaining the latest twenty
messages and the first user message.
Compaction affects later runs only when the composition has a
-[history layer](../history-layer/index.md) and a successful run writes the
-rewritten history into its session snapshot.
+[history layer](../history-layer/index.md). Once pydantic-ai binds and builds
+messages in the run capture, successful, failed, timed-out, and cancelled runs
+write the captured rewritten history into their terminal session snapshot. A
+failure or cancellation before the capture contains any messages preserves the
+previously restored history. Interrupted partial messages may be included and
+repaired when that checkpoint is used by a later independent run; the interrupted
+run's terminal status remains unchanged.
## Complete minimal model composition
diff --git a/dify-agent/src/dify_agent/runtime/history.py b/dify-agent/src/dify_agent/runtime/history.py
index 8b2d4d11e73..872f76a348f 100644
--- a/dify-agent/src/dify_agent/runtime/history.py
+++ b/dify-agent/src/dify_agent/runtime/history.py
@@ -2,8 +2,10 @@
Dify Agent keeps pydantic-ai conversation history as an optional Agenton layer
named ``history``. Current system instructions belong to each run and are never
-stored; successful runs replace the layer with Pydantic AI's complete, possibly
-compacted history.
+stored. Once Pydantic AI binds and builds messages in the run capture, its
+complete captured history replaces the layer for every terminal outcome,
+including interrupted runs. A failure or cancellation before the capture
+contains messages preserves the previously restored history.
"""
from __future__ import annotations
@@ -63,11 +65,11 @@ def get_history_layer(run: SupportsHistoryLayerLookup) -> PydanticAIHistoryLayer
return None
-def replace_successful_run_history(
+def replace_run_history(
history_layer: PydanticAIHistoryLayer | None,
messages: Sequence[ModelMessage],
) -> None:
- """Persist a successful run's complete history without transient instructions."""
+ """Persist a run's captured history without transient instructions."""
if history_layer is None:
return
persistent_messages = [
@@ -79,6 +81,6 @@ def replace_successful_run_history(
__all__ = [
"SupportsHistoryLayerLookup",
"get_history_layer",
- "replace_successful_run_history",
+ "replace_run_history",
"validate_history_layer_composition",
]
diff --git a/dify-agent/src/dify_agent/runtime/runner.py b/dify-agent/src/dify_agent/runtime/runner.py
index c295c6607ff..b7dcd127df1 100644
--- a/dify-agent/src/dify_agent/runtime/runner.py
+++ b/dify-agent/src/dify_agent/runtime/runner.py
@@ -11,10 +11,12 @@ policy is validated:
request-level ``on_exit`` signals, and publish a terminal success or failure event;
The Pydantic AI model is resolved from the active Agenton layer named by
``DIFY_AGENT_MODEL_LAYER_ID``. An optional history layer contributes stored
-message history only through session state; successful model runs replace that
-state with ``result.all_messages()`` after transient instructions are cleared so
-compaction rewrites persist without saving current system prompts. An optional
-structured output layer named by
+message history only through session state. Once pydantic-ai binds and builds
+messages in the run capture, every terminal outcome replaces that state with the
+captured messages after transient instructions are cleared; a failure or
+cancellation before the capture contains messages preserves the restored state.
+This preserves compaction rewrites and interrupted partial messages without
+saving current system prompts. An optional structured output layer named by
``DIFY_AGENT_OUTPUT_LAYER_ID`` is read after entry and resolved into an output
contract whose type both exposes the output schema to the model and performs
runtime JSON Schema validation through custom Pydantic hooks. When the ask-human
@@ -37,6 +39,7 @@ from typing import Any, Literal, Protocol, cast, runtime_checkable
import httpx
from graphon.model_runtime.entities.llm_entities import LLMUsage
from pydantic import JsonValue, TypeAdapter
+from pydantic_ai import capture_run_messages
from pydantic_ai.exceptions import ModelHTTPError, UsageLimitExceeded
from pydantic_ai.messages import AgentStreamEvent, PartDeltaEvent, PartStartEvent, TextPart, TextPartDelta
from pydantic_ai.output import OutputSpec
@@ -73,7 +76,7 @@ from dify_agent.runtime.event_sink import (
)
from dify_agent.runtime.history import (
get_history_layer,
- replace_successful_run_history,
+ replace_run_history,
validate_history_layer_composition,
)
from dify_agent.runtime.layer_exit_signals import apply_layer_exit_signals, validate_layer_exit_signals
@@ -362,16 +365,21 @@ class AgentRunRunner:
)
run_timeout = asyncio.timeout(self.run_timeout_seconds)
try:
- async with run_timeout:
- result = await agent.run(
- None if deferred_tool_results is not None else normalize_user_input(user_prompts),
- message_history=message_history,
- deferred_tool_results=deferred_tool_results,
- event_stream_handler=handle_events,
- instructions=run.prompts or None,
- capabilities=[compaction] if compaction is not None else None,
- usage_limits=UsageLimits(request_limit=_MAX_AGENT_STEPS_PER_RUN),
- )
+ with capture_run_messages() as captured_messages:
+ try:
+ async with run_timeout:
+ result = await agent.run(
+ None if deferred_tool_results is not None else normalize_user_input(user_prompts),
+ message_history=message_history,
+ deferred_tool_results=deferred_tool_results,
+ event_stream_handler=handle_events,
+ instructions=run.prompts or None,
+ capabilities=[compaction] if compaction is not None else None,
+ usage_limits=UsageLimits(request_limit=_MAX_AGENT_STEPS_PER_RUN),
+ )
+ finally:
+ if captured_messages:
+ replace_run_history(history_layer, captured_messages)
except TimeoutError as exc:
if not run_timeout.expired():
raise
@@ -381,7 +389,6 @@ class AgentRunRunner:
complete_usage = model.accumulated_usage if isinstance(model, _HasAccumulatedUsage) else None
usage = _serialize_agent_usage(complete_usage if complete_usage is not None else _result_usage(result))
self._terminal_usage = usage
- replace_successful_run_history(history_layer, result.all_messages())
if isinstance(result.output, DeferredToolRequests):
if ask_human_layer is None:
raise AgentRunValidationError(
diff --git a/dify-agent/tests/local/dify_agent/runtime/test_runner.py b/dify-agent/tests/local/dify_agent/runtime/test_runner.py
index edd5adcc74a..86f44a9b648 100644
--- a/dify-agent/tests/local/dify_agent/runtime/test_runner.py
+++ b/dify-agent/tests/local/dify_agent/runtime/test_runner.py
@@ -1,5 +1,6 @@
import asyncio
-from collections.abc import Iterable, Mapping
+from collections.abc import AsyncIterator, Generator, Iterable, Mapping
+from contextlib import contextmanager
from decimal import Decimal
from typing import Any, ClassVar, cast
@@ -20,6 +21,7 @@ from pydantic_ai.messages import (
UserPromptPart,
)
from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.models.function import FunctionModel
from pydantic_ai.models.test import TestModel
from pydantic_ai.tools import DeferredToolRequests, DeferredToolResults
from pydantic_ai.usage import UsageLimits
@@ -488,16 +490,45 @@ def _flatten_message_parts(messages: list[ModelMessage]) -> list[object]:
return [part for message in messages for part in message.parts]
+def _assert_interrupted_history(
+ snapshot: CompositorSessionSnapshot,
+ stored_history: list[ModelMessage],
+) -> None:
+ saved_history = _history_messages_from_snapshot(snapshot)
+
+ assert saved_history[: len(stored_history)] == stored_history
+ assert len(saved_history) == len(stored_history) + 2
+ current_request = saved_history[-2]
+ assert isinstance(current_request, ModelRequest)
+ assert current_request.instructions is None
+ assert len(current_request.parts) == 1
+ assert isinstance(current_request.parts[0], UserPromptPart)
+ assert current_request.parts[0].content == "current user"
+ partial_response = saved_history[-1]
+ assert isinstance(partial_response, ModelResponse)
+ assert partial_response.state == "interrupted"
+ assert len(partial_response.parts) == 1
+ assert isinstance(partial_response.parts[0], TextPart)
+ assert partial_response.parts[0].content == "partial"
+
+
+def _install_fake_message_capture(monkeypatch: pytest.MonkeyPatch) -> list[ModelMessage]:
+ captured_messages: list[ModelMessage] = []
+
+ @contextmanager
+ def fake_capture_run_messages() -> Generator[list[ModelMessage]]:
+ captured_messages.clear()
+ yield captured_messages
+
+ monkeypatch.setattr("dify_agent.runtime.runner.capture_run_messages", fake_capture_run_messages)
+ return captured_messages
+
+
class FakeAgentRunResult:
output: object
- _all_messages: list[ModelMessage]
- def __init__(self, output: object, all_messages: list[ModelMessage]) -> None:
+ def __init__(self, output: object) -> None:
self.output = output
- self._all_messages = all_messages
-
- def all_messages(self) -> list[ModelMessage]:
- return list(self._all_messages)
def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -616,7 +647,7 @@ def test_runner_preserves_explicit_json_null_output(monkeypatch: pytest.MonkeyPa
class FakeAgent:
async def run(self, *_args: object, **_kwargs: object) -> FakeAgentRunResult:
- return FakeAgentRunResult(None, [])
+ return FakeAgentRunResult(None)
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
monkeypatch.setattr("dify_agent.runtime.runner.create_agent", lambda *_args, **_kwargs: FakeAgent())
@@ -651,7 +682,7 @@ def test_runner_passes_explicit_step_limit_to_agent(monkeypatch: pytest.MonkeyPa
async def run(self, *_args: object, **kwargs: object) -> FakeAgentRunResult:
usage_limits = cast(UsageLimits, kwargs["usage_limits"])
assert usage_limits.request_limit == 500
- return FakeAgentRunResult("done", [])
+ return FakeAgentRunResult("done")
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
monkeypatch.setattr("dify_agent.runtime.runner.create_agent", lambda *_args, **_kwargs: FakeAgent())
@@ -684,7 +715,7 @@ def test_runner_passes_context_compaction(monkeypatch: pytest.MonkeyPatch) -> No
capability = capabilities[0]
assert isinstance(capability, TieredCompaction)
assert capability.target_tokens == 7_000
- return FakeAgentRunResult("done", [])
+ return FakeAgentRunResult("done")
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
monkeypatch.setattr("dify_agent.runtime.runner.create_agent", lambda *_args, **_kwargs: FakeAgent())
@@ -724,7 +755,7 @@ def test_runner_rejects_compaction_budget_before_model_resolution_or_invocation(
async def run(self, *_args: object, **_kwargs: object) -> FakeAgentRunResult:
nonlocal model_invocation_called
model_invocation_called = True
- return FakeAgentRunResult("unused", [])
+ return FakeAgentRunResult("unused")
def fake_create_agent(*_args: object, **_kwargs: object) -> FakeAgent:
nonlocal agent_creation_called
@@ -787,7 +818,7 @@ def test_runner_timeout_excludes_tool_preparation_and_runtime_cleanup(monkeypatc
class ImmediateAgent:
async def run(self, *_args: object, **_kwargs: object) -> FakeAgentRunResult:
- return FakeAgentRunResult("done", [])
+ return FakeAgentRunResult("done")
async def slow_resolve_run_tools(
_run: object,
@@ -935,32 +966,37 @@ def test_runner_does_not_classify_nested_timeout_as_agent_limit(monkeypatch: pyt
assert sink.statuses["run-provider-timeout"] == "failed"
-def test_runner_captures_post_exit_snapshot_when_task_is_cancelled(monkeypatch: pytest.MonkeyPatch) -> None:
- started = asyncio.Event()
+def test_runner_captures_interrupted_history_when_task_is_cancelled(monkeypatch: pytest.MonkeyPatch) -> None:
+ partial_streamed = asyncio.Event()
+ stored_history = [
+ ModelRequest(parts=[UserPromptPart(content="old user")]),
+ ModelResponse(parts=[TextPart(content="old assistant")]),
+ ]
+
+ async def stream_response(_messages: list[ModelMessage], _info: object) -> AsyncIterator[str]:
+ yield "partial"
+ _ = partial_streamed.set()
+ await asyncio.Event().wait()
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient, agent_run_id: str):
- return TestModel(custom_output_text="unused") # pyright: ignore[reportReturnType]
-
- class FakeAgent:
- async def run(self, *_args: object, **_kwargs: object) -> None:
- started.set()
- await asyncio.Event().wait()
+ return FunctionModel(stream_function=stream_response)
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
- monkeypatch.setattr("dify_agent.runtime.runner.create_agent", lambda *_args, **_kwargs: FakeAgent())
+ request = _request("current user", include_history=True)
+ request.session_snapshot = _history_session_snapshot(stored_history)
sink = InMemoryRunEventSink()
async def scenario() -> AgentRunRunner:
async with httpx.AsyncClient() as client:
runner = AgentRunRunner(
sink=sink,
- request=_request(),
- run_id="run-cancel-snapshot",
+ request=request,
+ run_id="run-cancel-history",
plugin_daemon_http_client=client,
dify_api_http_client=client,
)
task = asyncio.create_task(runner.run())
- await asyncio.wait_for(started.wait(), timeout=1)
+ await asyncio.wait_for(partial_streamed.wait(), timeout=1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
@@ -970,12 +1006,13 @@ def test_runner_captures_post_exit_snapshot_when_task_is_cancelled(monkeypatch:
assert runner.terminal_session_snapshot is not None
assert all(layer.lifecycle_state is LifecycleState.SUSPENDED for layer in runner.terminal_session_snapshot.layers)
- assert [event.type for event in sink.events["run-cancel-snapshot"]] == ["run_started"]
+ _assert_interrupted_history(runner.terminal_session_snapshot, stored_history)
def test_runner_emits_deferred_tool_call_and_persists_pending_history(monkeypatch: pytest.MonkeyPatch) -> None:
captured_output_types: list[object] = []
captured_user_prompts: list[object] = []
+ captured_messages = _install_fake_message_capture(monkeypatch)
pending_tool_call = ToolCallPart(
tool_name="ask_human",
args={
@@ -993,13 +1030,12 @@ def test_runner_emits_deferred_tool_call_and_persists_pending_history(monkeypatc
async def run(self, user_prompt: object, **kwargs: object) -> FakeAgentRunResult:
captured_user_prompts.append(user_prompt)
assert kwargs["deferred_tool_results"] is None
- return FakeAgentRunResult(
- DeferredToolRequests(calls=[pending_tool_call]),
- [
- ModelRequest(parts=[UserPromptPart(content="current user")]),
- ModelResponse(parts=[pending_tool_call]),
- ],
- )
+ messages: list[ModelMessage] = [
+ ModelRequest(parts=[UserPromptPart(content="current user")]),
+ ModelResponse(parts=[pending_tool_call]),
+ ]
+ captured_messages.extend(messages)
+ return FakeAgentRunResult(DeferredToolRequests(calls=[pending_tool_call]))
def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> FakeAgent:
del model, tools
@@ -1056,6 +1092,7 @@ def test_runner_emits_deferred_tool_call_and_persists_pending_history(monkeypatc
def test_runner_resumes_with_deferred_tool_results_and_no_user_prompt(monkeypatch: pytest.MonkeyPatch) -> None:
seen_user_prompts: list[object] = []
seen_deferred_results: list[object] = []
+ captured_messages = _install_fake_message_capture(monkeypatch)
pending_tool_call = ToolCallPart(
tool_name="ask_human",
args={"question": "Need approval"},
@@ -1071,35 +1108,33 @@ def test_runner_resumes_with_deferred_tool_results_and_no_user_prompt(monkeypatc
seen_user_prompts.append(user_prompt)
seen_deferred_results.append(kwargs.get("deferred_tool_results"))
if kwargs.get("deferred_tool_results") is None:
- return FakeAgentRunResult(
- DeferredToolRequests(calls=[pending_tool_call]),
- [
- ModelRequest(parts=[UserPromptPart(content="current user")]),
- ModelResponse(parts=[pending_tool_call]),
- ],
- )
+ messages: list[ModelMessage] = [
+ ModelRequest(parts=[UserPromptPart(content="current user")]),
+ ModelResponse(parts=[pending_tool_call]),
+ ]
+ captured_messages.extend(messages)
+ return FakeAgentRunResult(DeferredToolRequests(calls=[pending_tool_call]))
deferred_tool_results = cast(DeferredToolResults, kwargs["deferred_tool_results"])
assert deferred_tool_results is not None
submitted_result = cast(dict[str, object], deferred_tool_results.calls["tool-call-1"])
assert submitted_result["status"] == "submitted"
message_history = cast(list[ModelMessage], kwargs["message_history"])
- return FakeAgentRunResult(
- "done after human",
- [
- *message_history,
- ModelRequest(
- parts=[
- ToolReturnPart(
- tool_name="ask_human",
- content={"status": "submitted", "values": {"comment": "Ship it"}},
- tool_call_id="tool-call-1",
- )
- ]
- ),
- ModelResponse(parts=[TextPart(content="done after human")]),
- ],
- )
+ messages = [
+ *message_history,
+ ModelRequest(
+ parts=[
+ ToolReturnPart(
+ tool_name="ask_human",
+ content={"status": "submitted", "values": {"comment": "Ship it"}},
+ tool_call_id="tool-call-1",
+ )
+ ]
+ ),
+ ModelResponse(parts=[TextPart(content="done after human")]),
+ ]
+ captured_messages.extend(messages)
+ return FakeAgentRunResult("done after human")
def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> FakeAgent:
del model, tools, output_type
@@ -1158,6 +1193,7 @@ def test_runner_resumes_with_deferred_tool_results_and_no_user_prompt(monkeypatc
def test_runner_can_emit_second_deferred_tool_call_after_resume(monkeypatch: pytest.MonkeyPatch) -> None:
seen_user_prompts: list[object] = []
+ captured_messages = _install_fake_message_capture(monkeypatch)
first_pending_tool_call = ToolCallPart(
tool_name="ask_human",
args={"question": "Need deployment owner"},
@@ -1178,31 +1214,29 @@ def test_runner_can_emit_second_deferred_tool_call_after_resume(monkeypatch: pyt
seen_user_prompts.append(user_prompt)
deferred_tool_results = kwargs.get("deferred_tool_results")
if deferred_tool_results is None:
- return FakeAgentRunResult(
- DeferredToolRequests(calls=[first_pending_tool_call]),
- [
- ModelRequest(parts=[UserPromptPart(content="current user")]),
- ModelResponse(parts=[first_pending_tool_call]),
- ],
- )
+ messages: list[ModelMessage] = [
+ ModelRequest(parts=[UserPromptPart(content="current user")]),
+ ModelResponse(parts=[first_pending_tool_call]),
+ ]
+ captured_messages.extend(messages)
+ return FakeAgentRunResult(DeferredToolRequests(calls=[first_pending_tool_call]))
message_history = cast(list[ModelMessage], kwargs["message_history"])
- return FakeAgentRunResult(
- DeferredToolRequests(calls=[second_pending_tool_call]),
- [
- *message_history,
- ModelRequest(
- parts=[
- ToolReturnPart(
- tool_name="ask_human",
- content={"status": "submitted", "values": {"owner": "ops"}},
- tool_call_id="tool-call-1",
- )
- ]
- ),
- ModelResponse(parts=[second_pending_tool_call]),
- ],
- )
+ messages = [
+ *message_history,
+ ModelRequest(
+ parts=[
+ ToolReturnPart(
+ tool_name="ask_human",
+ content={"status": "submitted", "values": {"owner": "ops"}},
+ tool_call_id="tool-call-1",
+ )
+ ]
+ ),
+ ModelResponse(parts=[second_pending_tool_call]),
+ ]
+ captured_messages.extend(messages)
+ return FakeAgentRunResult(DeferredToolRequests(calls=[second_pending_tool_call]))
def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> FakeAgent:
del model, tools, output_type
@@ -1281,8 +1315,7 @@ def test_runner_rejects_deferred_tool_call_without_history_layer(monkeypatch: py
calls=[
ToolCallPart(tool_name="ask_human", args={"question": "Need owner"}, tool_call_id="tool-call-1")
]
- ),
- [],
+ )
)
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
@@ -1322,7 +1355,7 @@ def test_runner_rejects_resume_with_deferred_tool_results_without_history_layer(
async def run(self, *_args: object, **_kwargs: object) -> FakeAgentRunResult:
nonlocal agent_run_called
agent_run_called = True
- return FakeAgentRunResult("unexpected", [])
+ return FakeAgentRunResult("unexpected")
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
monkeypatch.setattr("dify_agent.runtime.runner.create_agent", lambda *args, **kwargs: FakeAgent())
@@ -1373,8 +1406,7 @@ def test_runner_rejects_multiple_deferred_tool_calls(monkeypatch: pytest.MonkeyP
ToolCallPart(tool_name="ask_human", args={"question": "One"}, tool_call_id="tool-call-1"),
ToolCallPart(tool_name="ask_human", args={"question": "Two"}, tool_call_id="tool-call-2"),
]
- ),
- [],
+ )
)
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
@@ -1412,8 +1444,7 @@ def test_runner_rejects_deferred_approval_requests(monkeypatch: pytest.MonkeyPat
tool_name="ask_human", args={"question": "Need approval"}, tool_call_id="tool-call-1"
)
]
- ),
- [],
+ )
)
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
@@ -1461,9 +1492,6 @@ def test_runner_passes_dynamic_dify_plugin_tools_to_agent(monkeypatch: pytest.Mo
class FakeResult:
output: str = "done"
- def all_messages(self) -> list[ModelMessage]:
- return []
-
class FakeAgent:
async def run(self, *_args: object, **_kwargs: object) -> FakeResult:
return FakeResult()
@@ -1563,9 +1591,6 @@ def test_runner_passes_dynamic_dify_knowledge_tools_to_agent(monkeypatch: pytest
class FakeResult:
output: str = "done"
- def all_messages(self) -> list[ModelMessage]:
- return []
-
class FakeAgent:
async def run(self, *_args: object, **_kwargs: object) -> FakeResult:
return FakeResult()
@@ -1669,9 +1694,6 @@ def test_runner_passes_dynamic_dify_core_tools_to_agent(monkeypatch: pytest.Monk
class FakeResult:
output: str = "done"
- def all_messages(self) -> list[ModelMessage]:
- return []
-
class FakeAgent:
async def run(self, *_args: object, **_kwargs: object) -> FakeResult:
return FakeResult()
@@ -2255,18 +2277,21 @@ def test_runner_with_empty_history_layer_uses_instructions_and_saves_full_histor
assert all(not isinstance(message, ModelRequest) or message.instructions is None for message in saved_history)
-def test_runner_failure_with_history_layer_emits_post_exit_snapshot_without_new_history(
+def test_runner_failure_with_history_layer_captures_interrupted_history(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- model = RecordingTestModel(failure=RuntimeError("boom"))
stored_history = [
ModelRequest(parts=[UserPromptPart(content="old user")]),
ModelResponse(parts=[TextPart(content="old assistant")]),
]
+ async def stream_response(_messages: list[ModelMessage], _info: object) -> AsyncIterator[str]:
+ yield "partial"
+ raise RuntimeError("boom")
+
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient, agent_run_id: str):
assert http_client.is_closed is False
- return model # pyright: ignore[reportReturnType]
+ return FunctionModel(stream_function=stream_response)
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
request = _request("current user", include_history=True)
@@ -2286,16 +2311,58 @@ def test_runner_failure_with_history_layer_emits_post_exit_snapshot_without_new_
asyncio.run(scenario())
- assert [event.type for event in sink.events["run-history-failure"]] == ["run_started", "run_failed"]
+ event_types = [event.type for event in sink.events["run-history-failure"]]
+ assert event_types[0] == "run_started"
assert sink.statuses["run-history-failure"] == "failed"
terminal = sink.events["run-history-failure"][-1]
assert isinstance(terminal, RunFailedEvent)
assert terminal.data.session_snapshot is not None
- assert _history_messages_from_snapshot(terminal.data.session_snapshot) == stored_history
+ _assert_interrupted_history(terminal.data.session_snapshot, stored_history)
assert request.session_snapshot is not None
assert _history_messages_from_snapshot(request.session_snapshot) == stored_history
+def test_runner_preserves_history_when_agent_fails_before_capture_is_bound(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ stored_history = [
+ ModelRequest(parts=[UserPromptPart(content="old user")]),
+ ModelResponse(parts=[TextPart(content="old assistant")]),
+ ]
+
+ def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient, agent_run_id: str):
+ assert http_client.is_closed is False
+ return TestModel(custom_output_text="unused") # pyright: ignore[reportReturnType]
+
+ class FakeAgent:
+ async def run(self, *_args: object, **_kwargs: object) -> None:
+ raise RuntimeError("boom before capture")
+
+ monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
+ monkeypatch.setattr("dify_agent.runtime.runner.create_agent", lambda *_args, **_kwargs: FakeAgent())
+ request = _request("current user", include_history=True)
+ request.session_snapshot = _history_session_snapshot(stored_history)
+ sink = InMemoryRunEventSink()
+
+ async def scenario() -> None:
+ async with httpx.AsyncClient() as client:
+ with pytest.raises(RuntimeError, match="boom before capture"):
+ await AgentRunRunner(
+ sink=sink,
+ request=request,
+ run_id="run-history-empty-capture",
+ plugin_daemon_http_client=client,
+ dify_api_http_client=client,
+ ).run()
+
+ asyncio.run(scenario())
+
+ terminal = sink.events["run-history-empty-capture"][-1]
+ assert isinstance(terminal, RunFailedEvent)
+ assert terminal.data.session_snapshot is not None
+ assert _history_messages_from_snapshot(terminal.data.session_snapshot) == stored_history
+
+
def test_runner_persists_usage_limit_failure_type_in_event_and_status(
monkeypatch: pytest.MonkeyPatch,
) -> None:
diff --git a/dify-agent/tests/local/dify_agent/runtime/test_runtime_history.py b/dify-agent/tests/local/dify_agent/runtime/test_runtime_history.py
index 3072a0c6a1f..be77124bfee 100644
--- a/dify-agent/tests/local/dify_agent/runtime/test_runtime_history.py
+++ b/dify-agent/tests/local/dify_agent/runtime/test_runtime_history.py
@@ -13,7 +13,7 @@ from dify_agent.protocol.schemas import RunComposition, RunLayerSpec
from dify_agent.runtime.compositor_factory import create_default_layer_providers
from dify_agent.runtime.history import (
get_history_layer,
- replace_successful_run_history,
+ replace_run_history,
validate_history_layer_composition,
)
@@ -88,7 +88,7 @@ def test_get_history_layer_returns_optional_active_history_layer() -> None:
asyncio.run(scenario())
-def test_replace_successful_run_history_persists_full_history_without_instructions() -> None:
+def test_replace_run_history_persists_full_history_without_instructions() -> None:
history_layer = PydanticAIHistoryLayer()
history_layer.replace_messages([ModelRequest(parts=[UserPromptPart(content="stale")])])
messages = [
@@ -100,7 +100,7 @@ def test_replace_successful_run_history_persists_full_history_without_instructio
ModelResponse(parts=[TextPart(content="new assistant")]),
]
- replace_successful_run_history(history_layer, messages)
+ replace_run_history(history_layer, messages)
persisted = history_layer.message_history
assert len(persisted) == 3
From ea3fd4c093923fd3b05fe1aad8baa779d4ff08c9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=9B=90=E7=B2=92=20Yanli?=
Date: Wed, 19 Aug 2026 10:35:24 +0000
Subject: [PATCH 16/18] perf(dify-agent): parallelize Agent Stub config pulls
(#40973)
---
.../internal/agentcli/config.go | 50 ++-
.../internal/agentcli/config_test.go | 385 +++++++++++++++++-
2 files changed, 409 insertions(+), 26 deletions(-)
diff --git a/dify-agent-runtime/internal/agentcli/config.go b/dify-agent-runtime/internal/agentcli/config.go
index 17b4b27b731..ef34676792a 100644
--- a/dify-agent-runtime/internal/agentcli/config.go
+++ b/dify-agent-runtime/internal/agentcli/config.go
@@ -3,18 +3,40 @@ package agentcli
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"os"
"path/filepath"
+ "sync"
)
-const defaultConfigBase = ".dify_conf"
+const (
+ defaultConfigBase = ".dify_conf"
+ configPullConcurrencyLimit = 4
+)
type ConfigFileRef struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
+func runConfigPulls(count int, task func(index int) error) error {
+ errs := make([]error, count)
+ semaphore := make(chan struct{}, configPullConcurrencyLimit)
+
+ var waitGroup sync.WaitGroup
+ for index := 0; index < count; index++ {
+ semaphore <- struct{}{}
+ waitGroup.Go(func() {
+ defer func() { <-semaphore }()
+ errs[index] = task(index)
+ })
+ }
+ waitGroup.Wait()
+
+ return errors.Join(errs...)
+}
+
// RunConfigManifest executes the `config manifest` command.
func RunConfigManifest(env *Environment) error {
client, err := NewStubClient(env)
@@ -77,8 +99,12 @@ func RunConfigSkillsPull(env *Environment, names []string, localDir string, json
SkillMD string `json:"skill_md"`
}
var items []pullItem
+ if len(names) > 0 {
+ items = make([]pullItem, len(names))
+ }
- for _, name := range names {
+ err = runConfigPulls(len(names), func(index int) error {
+ name := names[index]
download, err := client.CreateConfigDownloadURL(ctx, "skill", name)
if err != nil {
return fmt.Errorf("request config skill %q download URL: %w", name, err)
@@ -108,12 +134,16 @@ func RunConfigSkillsPull(env *Environment, names []string, localDir string, json
skillMD = string(data)
}
- items = append(items, pullItem{
+ items[index] = pullItem{
Name: name,
ArchivePath: archivePath,
DirectoryPath: skillDir,
SkillMD: skillMD,
- })
+ }
+ return nil
+ })
+ if err != nil {
+ return err
}
if jsonOutput {
@@ -175,8 +205,12 @@ func RunConfigFilesPull(env *Environment, names []string, localDir string, jsonO
Path string `json:"path"`
}
var items []fileItem
+ if len(names) > 0 {
+ items = make([]fileItem, len(names))
+ }
- for _, name := range names {
+ err = runConfigPulls(len(names), func(index int) error {
+ name := names[index]
download, err := client.CreateConfigDownloadURL(ctx, "file", name)
if err != nil {
return fmt.Errorf("request config file %q download URL: %w", name, err)
@@ -193,7 +227,11 @@ func RunConfigFilesPull(env *Environment, names []string, localDir string, jsonO
if err := os.WriteFile(targetPath, payload, 0o644); err != nil {
return fmt.Errorf("write file: %w", err)
}
- items = append(items, fileItem{Name: name, Path: targetPath})
+ items[index] = fileItem{Name: name, Path: targetPath}
+ return nil
+ })
+ if err != nil {
+ return err
}
if jsonOutput {
diff --git a/dify-agent-runtime/internal/agentcli/config_test.go b/dify-agent-runtime/internal/agentcli/config_test.go
index b38d565e287..939745c050a 100644
--- a/dify-agent-runtime/internal/agentcli/config_test.go
+++ b/dify-agent-runtime/internal/agentcli/config_test.go
@@ -7,10 +7,14 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "net/url"
"os"
"path/filepath"
"strings"
+ "sync"
+ "sync/atomic"
"testing"
+ "time"
)
type configPushCapture struct {
@@ -223,6 +227,330 @@ func TestConfigPullRequestsURLThenDownloadsFromDataPlane(t *testing.T) {
}
}
+func TestConfigPullsDownloadConcurrentlyAndPreserveOutputOrder(t *testing.T) {
+ tests := []struct {
+ name string
+ kind string
+ names []string
+ payloads map[string][]byte
+ run func(*Environment, []string, string) error
+ assertOutput func(*testing.T, string, string)
+ }{
+ {
+ name: "files JSON output",
+ kind: "file",
+ names: []string{"first.txt", "second.txt"},
+ payloads: map[string][]byte{
+ "first.txt": []byte("first file"),
+ "second.txt": []byte("second file"),
+ },
+ run: func(env *Environment, names []string, targetDir string) error {
+ return RunConfigFilesPull(env, names, targetDir, true)
+ },
+ assertOutput: func(t *testing.T, targetDir string, output string) {
+ t.Helper()
+ var result struct {
+ Items []struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ } `json:"items"`
+ }
+ if err := json.Unmarshal([]byte(output), &result); err != nil {
+ t.Fatalf("parse JSON output %q: %v", output, err)
+ }
+ if len(result.Items) != 2 {
+ t.Fatalf("output items = %#v, want two items", result.Items)
+ }
+ for index, name := range []string{"first.txt", "second.txt"} {
+ if result.Items[index].Name != name {
+ t.Errorf("item %d name = %q, want %q", index, result.Items[index].Name, name)
+ }
+ wantPath := filepath.Join(targetDir, name)
+ if result.Items[index].Path != wantPath {
+ t.Errorf("item %d path = %q, want %q", index, result.Items[index].Path, wantPath)
+ }
+ }
+ },
+ },
+ {
+ name: "skills text output",
+ kind: "skill",
+ names: []string{"alpha", "beta"},
+ payloads: map[string][]byte{
+ "alpha": zipFixture(t, map[string]string{"SKILL.md": "# Alpha\n"}),
+ "beta": zipFixture(t, map[string]string{"SKILL.md": "# Beta\n"}),
+ },
+ run: func(env *Environment, names []string, targetDir string) error {
+ return RunConfigSkillsPull(env, names, targetDir, false)
+ },
+ assertOutput: func(t *testing.T, targetDir string, output string) {
+ t.Helper()
+ want := filepath.Join(targetDir, "alpha") + "\n# Alpha\n\n" +
+ filepath.Join(targetDir, "beta") + "\n# Beta\n"
+ if output != want {
+ t.Errorf("text output = %q, want %q", output, want)
+ }
+ },
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ var server *httptest.Server
+ bothDownloadsStarted := make(chan struct{})
+ secondResponseWritten := make(chan struct{})
+ var startMu sync.Mutex
+ started := 0
+
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/agent-stub/files/download-request":
+ var request struct {
+ Config struct {
+ Kind string `json:"kind"`
+ Name string `json:"name"`
+ } `json:"config"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if request.Config.Kind != test.kind {
+ t.Errorf("config kind = %q, want %q", request.Config.Kind, test.kind)
+ }
+ if _, ok := test.payloads[request.Config.Name]; !ok {
+ http.Error(w, "unknown target", http.StatusBadRequest)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "filename": request.Config.Name,
+ "size": len(test.payloads[request.Config.Name]),
+ "download_url": server.URL + "/files/config-asset?name=" + url.QueryEscape(request.Config.Name),
+ })
+ case "/files/config-asset":
+ name := r.URL.Query().Get("name")
+ payload, ok := test.payloads[name]
+ if !ok {
+ http.Error(w, "unknown target", http.StatusNotFound)
+ return
+ }
+
+ startMu.Lock()
+ started++
+ if started == len(test.names) {
+ close(bothDownloadsStarted)
+ }
+ startMu.Unlock()
+
+ select {
+ case <-bothDownloadsStarted:
+ case <-time.After(2 * time.Second):
+ http.Error(w, "downloads did not overlap", http.StatusGatewayTimeout)
+ return
+ }
+ if name == test.names[0] {
+ select {
+ case <-secondResponseWritten:
+ case <-time.After(2 * time.Second):
+ http.Error(w, "second response did not finish first", http.StatusGatewayTimeout)
+ return
+ }
+ }
+
+ _, _ = w.Write(payload)
+ if name == test.names[1] {
+ close(secondResponseWritten)
+ }
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ targetDir := t.TempDir()
+ output, err := captureConfigStdout(t, func() error {
+ return test.run(
+ &Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
+ test.names,
+ targetDir,
+ )
+ })
+ if err != nil {
+ t.Fatalf("pull config %s: %v", test.kind, err)
+ }
+ test.assertOutput(t, targetDir, output)
+ })
+ }
+}
+
+func TestConfigFilesPullLimitsActiveRequestsAndRunsQueuedTarget(t *testing.T) {
+ names := []string{"one.txt", "two.txt", "three.txt", "four.txt", "five.txt"}
+ entered := make(chan string, len(names))
+ release := make(chan struct{})
+ var releaseOnce sync.Once
+ unblock := func() { releaseOnce.Do(func() { close(release) }) }
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/agent-stub/files/download-request":
+ var request struct {
+ Config struct {
+ Name string `json:"name"`
+ } `json:"config"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ entered <- request.Config.Name
+ select {
+ case <-release:
+ case <-time.After(2 * time.Second):
+ http.Error(w, "request was not released", http.StatusGatewayTimeout)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "filename": request.Config.Name,
+ "download_url": server.URL + "/files/config-asset?name=" + url.QueryEscape(request.Config.Name),
+ })
+ case "/files/config-asset":
+ _, _ = w.Write([]byte(r.URL.Query().Get("name")))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer func() {
+ unblock()
+ server.Close()
+ }()
+
+ done := make(chan error, 1)
+ targetDir := t.TempDir()
+ go func() {
+ done <- RunConfigFilesPull(
+ &Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
+ names,
+ targetDir,
+ true,
+ )
+ }()
+
+ for range 4 {
+ select {
+ case <-entered:
+ case <-time.After(2 * time.Second):
+ t.Fatal("four config pull requests did not enter concurrently")
+ }
+ }
+ select {
+ case <-entered:
+ t.Fatal("a fifth request entered above the four-request limit")
+ case <-time.After(100 * time.Millisecond):
+ }
+
+ unblock()
+ select {
+ case <-entered:
+ case <-time.After(2 * time.Second):
+ t.Fatal("queued config pull request did not proceed after release")
+ }
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("pull config files: %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("config files pull did not finish")
+ }
+}
+
+func TestConfigFilesPullReturnsAllFailuresInInputOrderAfterLaterFailureCompletesFirst(t *testing.T) {
+ names := []string{"earlier-failure.txt", "later-failure.txt"}
+ laterFailureEmitted := make(chan struct{})
+
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/agent-stub/files/download-request":
+ var request struct {
+ Config struct {
+ Name string `json:"name"`
+ } `json:"config"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+
+ switch request.Config.Name {
+ case names[0]:
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "filename": request.Config.Name,
+ "download_url": server.URL + "/files/config-asset",
+ })
+ case names[1]:
+ http.Error(w, "later input failed first", http.StatusServiceUnavailable)
+ close(laterFailureEmitted)
+ return
+ default:
+ http.Error(w, "unknown target", http.StatusBadRequest)
+ }
+ case "/files/config-asset":
+ select {
+ case <-laterFailureEmitted:
+ case <-time.After(2 * time.Second):
+ http.Error(w, "later failure was not emitted", http.StatusGatewayTimeout)
+ return
+ }
+ http.Error(w, "earlier input failed later", http.StatusBadGateway)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ done := make(chan error, 1)
+ targetDir := t.TempDir()
+ go func() {
+ done <- RunConfigFilesPull(
+ &Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
+ names,
+ targetDir,
+ true,
+ )
+ }()
+
+ select {
+ case err := <-done:
+ if err == nil {
+ t.Fatal("error = nil, want both config pull failures")
+ }
+ errorText := err.Error()
+ earlierWrapper := `download config file "earlier-failure.txt"`
+ earlierMessage := "earlier input failed later"
+ laterWrapper := `request config file "later-failure.txt" download URL`
+ laterMessage := "later input failed first"
+ earlierWrapperIndex := strings.Index(errorText, earlierWrapper)
+ earlierMessageIndex := strings.Index(errorText, earlierMessage)
+ laterWrapperIndex := strings.Index(errorText, laterWrapper)
+ laterMessageIndex := strings.Index(errorText, laterMessage)
+ if earlierWrapperIndex == -1 || earlierMessageIndex == -1 || laterWrapperIndex == -1 || laterMessageIndex == -1 {
+ t.Fatalf(
+ "error = %q, want both wrapped failures with messages %q and %q",
+ errorText,
+ earlierMessage,
+ laterMessage,
+ )
+ }
+ if earlierWrapperIndex >= earlierMessageIndex || earlierMessageIndex >= laterWrapperIndex || laterWrapperIndex >= laterMessageIndex {
+ t.Fatalf("error = %q, want failures in input order", errorText)
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("config files pull did not finish")
+ }
+}
+
func TestConfigPullMultiItemFailuresIdentifyItemAndStage(t *testing.T) {
skillArchive := zipFixture(t, map[string]string{"SKILL.md": "# Alpha\n"})
tests := []struct {
@@ -269,13 +597,13 @@ func TestConfigPullMultiItemFailuresIdentifyItemAndStage(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
- controlCalls := 0
- dataPlaneCalls := 0
+ var controlCalls atomic.Int32
+ var dataPlaneCalls atomic.Int32
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/agent-stub/files/download-request":
- controlCalls++
+ controlCalls.Add(1)
var request struct {
Config struct {
Kind string `json:"kind"`
@@ -287,29 +615,23 @@ func TestConfigPullMultiItemFailuresIdentifyItemAndStage(t *testing.T) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
- if controlCalls > len(test.names) {
- t.Errorf("unexpected control-plane call %d", controlCalls)
- http.Error(w, "unexpected request", http.StatusInternalServerError)
- return
+ if request.Config.Kind != test.kind {
+ t.Errorf("config kind = %q, want %q", request.Config.Kind, test.kind)
}
- wantName := test.names[controlCalls-1]
- if request.Config.Kind != test.kind || request.Config.Name != wantName {
- t.Errorf("config request = (%q, %q), want (%q, %q)", request.Config.Kind, request.Config.Name, test.kind, wantName)
- }
- if test.failureStage == "control" && controlCalls == 2 {
+ if test.failureStage == "control" && request.Config.Name == test.names[1] {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"detail":{"code":"agent_stub_authorization_expired","message":"expired"}}`))
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
- "filename": wantName,
+ "filename": request.Config.Name,
"size": len(test.payload),
- "download_url": server.URL + "/files/config-asset",
+ "download_url": server.URL + "/files/config-asset?name=" + url.QueryEscape(request.Config.Name),
})
case "/files/config-asset":
- dataPlaneCalls++
- if test.failureStage == "data" && dataPlaneCalls == 2 {
+ dataPlaneCalls.Add(1)
+ if test.failureStage == "data" && r.URL.Query().Get("name") == test.names[1] {
http.Error(w, "data plane unavailable", http.StatusBadGateway)
return
}
@@ -334,8 +656,8 @@ func TestConfigPullMultiItemFailuresIdentifyItemAndStage(t *testing.T) {
if !strings.Contains(err.Error(), test.wantStage) {
t.Errorf("error = %q, want stage %q", err, test.wantStage)
}
- if controlCalls != 2 {
- t.Errorf("control-plane calls = %d, want 2", controlCalls)
+ if controlCalls.Load() != 2 {
+ t.Errorf("control-plane calls = %d, want 2", controlCalls.Load())
}
wantDataPlaneCalls := 2
if test.failureStage == "control" {
@@ -351,8 +673,8 @@ func TestConfigPullMultiItemFailuresIdentifyItemAndStage(t *testing.T) {
}
}
}
- if dataPlaneCalls != wantDataPlaneCalls {
- t.Errorf("data-plane calls = %d, want %d", dataPlaneCalls, wantDataPlaneCalls)
+ if int(dataPlaneCalls.Load()) != wantDataPlaneCalls {
+ t.Errorf("data-plane calls = %d, want %d", dataPlaneCalls.Load(), wantDataPlaneCalls)
}
if test.kind == "file" {
@@ -579,3 +901,26 @@ func zipFixture(t *testing.T, files map[string]string) []byte {
}
return buffer.Bytes()
}
+
+func captureConfigStdout(t *testing.T, run func() error) (string, error) {
+ t.Helper()
+ outputFile, err := os.CreateTemp(t.TempDir(), "config-stdout-*")
+ if err != nil {
+ t.Fatalf("create stdout capture: %v", err)
+ }
+ originalStdout := os.Stdout
+ var runErr error
+ func() {
+ defer func() { os.Stdout = originalStdout }()
+ os.Stdout = outputFile
+ runErr = run()
+ }()
+ if err := outputFile.Close(); err != nil {
+ t.Fatalf("close stdout capture: %v", err)
+ }
+ output, err := os.ReadFile(outputFile.Name())
+ if err != nil {
+ t.Fatalf("read stdout capture: %v", err)
+ }
+ return string(output), runErr
+}
From 8e1c259a66cdf5010e3c61f5156e90437af3d39e Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:24:40 +0000
Subject: [PATCH 17/18] fix(web): prevent workflow errors after app deletion
(#40977)
---
.../__tests__/use-app-info-actions.spec.ts | 15 +++
.../app-info/use-app-info-actions.ts | 8 ++
.../__tests__/use-nodes-sync-draft.spec.ts | 64 +++++++++
.../hooks/use-nodes-sync-draft.ts | 22 +++-
web/service/app-deletion.ts | 49 +++++++
web/service/fetch.spec.ts | 121 ++++++++++++++++++
web/service/fetch.ts | 9 +-
7 files changed, 284 insertions(+), 4 deletions(-)
create mode 100644 web/service/app-deletion.ts
diff --git a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts
index 31c9bfbc679..bef3bcc1e7d 100644
--- a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts
+++ b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts
@@ -27,6 +27,9 @@ const mockExportWorkflowAppDsl = vi.fn()
const mockWorkflowExportState = { isExporting: false }
const mockDeleteApp = vi.fn()
const mockFetchAppDetail = vi.fn()
+const mockMarkAppDeletionStarted = vi.fn()
+const mockMarkAppDeletionSucceeded = vi.fn()
+const mockMarkAppDeletionFailed = vi.fn()
const mockGetSocket = vi.fn()
const mockOnAppMetaUpdate = vi.fn()
const mockSetQueryData = vi.fn()
@@ -97,6 +100,12 @@ vi.mock('@/service/apps', () => ({
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
}))
+vi.mock('@/service/app-deletion', () => ({
+ markAppDeletionStarted: (...args: unknown[]) => mockMarkAppDeletionStarted(...args),
+ markAppDeletionSucceeded: (...args: unknown[]) => mockMarkAppDeletionSucceeded(...args),
+ markAppDeletionFailed: (...args: unknown[]) => mockMarkAppDeletionFailed(...args),
+}))
+
vi.mock('@/utils/app-redirection', () => ({
getRedirection: vi.fn(),
}))
@@ -499,6 +508,9 @@ describe('useAppInfoActions', () => {
})
expect(mockDeleteApp).toHaveBeenCalledWith('app-1')
+ expect(mockMarkAppDeletionStarted).toHaveBeenCalledWith('app-1')
+ expect(mockMarkAppDeletionSucceeded).toHaveBeenCalledWith('app-1')
+ expect(mockMarkAppDeletionFailed).not.toHaveBeenCalled()
expect(toastMocks.call).toHaveBeenCalledWith({ type: 'success', message: 'app.appDeleted' })
expect(mockInvalidateQueries).toHaveBeenCalledTimes(3)
expect(mockReplace).toHaveBeenCalledWith('/apps')
@@ -526,6 +538,9 @@ describe('useAppInfoActions', () => {
await result.current.onConfirmDelete()
})
+ expect(mockMarkAppDeletionStarted).toHaveBeenCalledWith('app-1')
+ expect(mockMarkAppDeletionFailed).toHaveBeenCalledWith('app-1')
+ expect(mockMarkAppDeletionSucceeded).not.toHaveBeenCalled()
expect(toastMocks.call).toHaveBeenCalledWith({
type: 'error',
message: expect.stringContaining('app.appDeleteFailed'),
diff --git a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts
index b1c0ee9f9e9..f0dabb42820 100644
--- a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts
+++ b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts
@@ -15,6 +15,11 @@ import { useExportAppDsl, useExportWorkflowAppDsl } from '@/app/components/app/u
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useRouter } from '@/next/navigation'
+import {
+ markAppDeletionFailed,
+ markAppDeletionStarted,
+ markAppDeletionSucceeded,
+} from '@/service/app-deletion'
import { copyApp, deleteApp, fetchAppDetail, updateAppInfo } from '@/service/apps'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
@@ -306,8 +311,10 @@ export function useAppInfoActions({ resetKey }: UseAppInfoActionsParams) {
const onConfirmDelete = useCallback(async () => {
if (!appDetail) return
+ markAppDeletionStarted(appDetail.id)
try {
await deleteApp(appDetail.id)
+ markAppDeletionSucceeded(appDetail.id)
toast(
t(($) => $.appDeleted, { ns: 'app' }),
{ type: 'success' },
@@ -319,6 +326,7 @@ export function useAppInfoActions({ resetKey }: UseAppInfoActionsParams) {
setAppDetail()
replace('/apps')
} catch (e: unknown) {
+ markAppDeletionFailed(appDetail.id)
toast(
`${t(($) => $.appDeleteFailed, { ns: 'app' })}${e instanceof Error && e.message ? `: ${e.message}` : ''}`,
{ type: 'error' },
diff --git a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts
index 1b60fa9b742..91cc23437bc 100644
--- a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts
+++ b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts
@@ -2,6 +2,7 @@ import type { EnvironmentVariablePatch } from '@/service/workflow'
import { act } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { BlockEnum } from '@/app/components/workflow/types'
+import { markAppDeletionFailed, markAppDeletionStarted } from '@/service/app-deletion'
import { renderHookWithConsoleQuery } from '@/test/console/query-data'
import { useNodesSyncDraft } from '../use-nodes-sync-draft'
@@ -544,6 +545,69 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
)
})
+ it('should skip draft persistence without reporting an error while the app is being deleted', async () => {
+ const callbacks = {
+ onError: vi.fn(),
+ onSettled: vi.fn(),
+ }
+ markAppDeletionStarted('app-1')
+
+ try {
+ const { result } = renderUseNodesSyncDraft()
+
+ await act(async () => {
+ await result.current.doSyncWorkflowDraft(false, callbacks)
+ result.current.syncWorkflowDraftWhenPageClose()
+ })
+
+ expect(mockSyncWorkflowDraft).not.toHaveBeenCalled()
+ expect(mockPostWithKeepalive).not.toHaveBeenCalled()
+ expect(callbacks.onError).not.toHaveBeenCalled()
+ expect(callbacks.onSettled).toHaveBeenCalledOnce()
+ } finally {
+ markAppDeletionFailed('app-1')
+ }
+ })
+
+ it('should not report an in-flight draft failure after app deletion starts', async () => {
+ let rejectSync!: (reason?: unknown) => void
+ let resolveStarted!: () => void
+ const started = new Promise((resolve) => {
+ resolveStarted = resolve
+ })
+ mockSyncWorkflowDraft.mockImplementationOnce(
+ () =>
+ new Promise((_, reject) => {
+ rejectSync = reject
+ resolveStarted()
+ }),
+ )
+ const callbacks = {
+ onError: vi.fn(),
+ onSettled: vi.fn(),
+ }
+ const { result } = renderUseNodesSyncDraft()
+ let syncPromise!: ReturnType
+
+ act(() => {
+ syncPromise = result.current.doSyncWorkflowDraft(false, callbacks)
+ })
+ await started
+ markAppDeletionStarted('app-1')
+
+ try {
+ await act(async () => {
+ rejectSync(new Error('App not found'))
+ await syncPromise
+ })
+
+ expect(callbacks.onError).not.toHaveBeenCalled()
+ expect(callbacks.onSettled).toHaveBeenCalledOnce()
+ } finally {
+ markAppDeletionFailed('app-1')
+ }
+ })
+
it('should not post the local start placeholder when the page closes', () => {
reactFlowState = {
...reactFlowState,
diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts
index b36a969481a..1a1f2953050 100644
--- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts
+++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts
@@ -23,10 +23,14 @@ import { useWorkflowStore } from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { API_PREFIX } from '@/config'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
+import { isAppDeletingOrDeleted } from '@/service/app-deletion'
import { postWithKeepalive } from '@/service/fetch'
import { syncWorkflowDraft } from '@/service/workflow'
import { useWorkflowRefreshDraft } from './use-workflow-refresh-draft'
+const shouldSkipDraftSync = (appId: string | undefined, isWorkflowDataLoaded: boolean) =>
+ !appId || !isWorkflowDataLoaded || isAppDeletingOrDeleted(appId)
+
const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => {
const store = useStoreApi()
const workflowStore = useWorkflowStore()
@@ -62,7 +66,7 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => {
const { appId, conversationVariables, syncWorkflowDraftHash, isWorkflowDataLoaded } =
workflowStore.getState()
- if (!appId || !isWorkflowDataLoaded) return null
+ if (shouldSkipDraftSync(appId, isWorkflowDataLoaded)) return null
const features = featuresStore!.getState().features
const producedNodes = produce(nodes, (draft) => {
@@ -142,6 +146,11 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => {
options?: SyncDraftOptions,
): Promise => {
if (getNodesReadOnly()) return null
+ const { appId, isWorkflowDataLoaded } = workflowStore.getState()
+ if (shouldSkipDraftSync(appId, isWorkflowDataLoaded)) {
+ callback?.onSettled?.()
+ return null
+ }
if (isCollaborationEnabled && !collaborationManager.canPersistLocalGraph()) {
callback?.onSettled?.()
@@ -176,6 +185,9 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => {
callback?.onSuccess?.()
return { hash: res.hash, updatedAt: res.updated_at }
} catch (error: unknown) {
+ const { appId, isWorkflowDataLoaded } = workflowStore.getState()
+ if (shouldSkipDraftSync(appId, isWorkflowDataLoaded)) return null
+
const responseError = error as {
bodyUsed?: boolean
json?: () => Promise<{ code?: string }>
@@ -206,6 +218,11 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => {
options?: SyncDraftOptions,
): Promise => {
if (getNodesReadOnly()) return null
+ const { appId, isWorkflowDataLoaded } = workflowStore.getState()
+ if (shouldSkipDraftSync(appId, isWorkflowDataLoaded)) {
+ callback?.onSettled?.()
+ return null
+ }
const shouldRequestLeader =
isCollaborationEnabled &&
@@ -232,7 +249,8 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => {
callback?.onSuccess?.()
return result
} catch {
- callback?.onError?.()
+ const { appId, isWorkflowDataLoaded } = workflowStore.getState()
+ if (!shouldSkipDraftSync(appId, isWorkflowDataLoaded)) callback?.onError?.()
return null
} finally {
callback?.onSettled?.()
diff --git a/web/service/app-deletion.ts b/web/service/app-deletion.ts
new file mode 100644
index 00000000000..0549a9bc446
--- /dev/null
+++ b/web/service/app-deletion.ts
@@ -0,0 +1,49 @@
+type AppDeletionState = {
+ pendingCount: number
+ deleted: boolean
+}
+
+// Successful IDs stay as session tombstones for requests that settle after navigation.
+const appDeletionStates = new Map()
+
+export const markAppDeletionStarted = (appId: string) => {
+ const state = appDeletionStates.get(appId)
+ appDeletionStates.set(appId, {
+ pendingCount: (state?.pendingCount ?? 0) + 1,
+ deleted: state?.deleted ?? false,
+ })
+}
+
+export const markAppDeletionSucceeded = (appId: string) => {
+ const state = appDeletionStates.get(appId)
+ appDeletionStates.set(appId, {
+ pendingCount: Math.max((state?.pendingCount ?? 1) - 1, 0),
+ deleted: true,
+ })
+}
+
+export const markAppDeletionFailed = (appId: string) => {
+ const state = appDeletionStates.get(appId)
+ if (!state) return
+
+ const pendingCount = Math.max(state.pendingCount - 1, 0)
+ if (!pendingCount && !state.deleted) {
+ appDeletionStates.delete(appId)
+ return
+ }
+
+ appDeletionStates.set(appId, { ...state, pendingCount })
+}
+
+export const isAppDeletingOrDeleted = (appId: string) => appDeletionStates.has(appId)
+
+export const shouldSuppressAppDeletionErrorToast = (requestUrl: string, status: number) => {
+ if (status !== 404) return false
+
+ const match = new URL(requestUrl, globalThis.location?.origin).pathname.match(
+ /\/apps\/([^/]+)\/workflows(?:\/|$)/,
+ )
+ if (!match?.[1]) return false
+
+ return isAppDeletingOrDeleted(decodeURIComponent(match[1]))
+}
diff --git a/web/service/fetch.spec.ts b/web/service/fetch.spec.ts
index 57d5040ec0e..9e9d1c832c3 100644
--- a/web/service/fetch.spec.ts
+++ b/web/service/fetch.spec.ts
@@ -1,5 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { PUBLIC_API_PREFIX } from '@/config'
+import {
+ markAppDeletionFailed,
+ markAppDeletionStarted,
+ markAppDeletionSucceeded,
+} from './app-deletion'
// oxlint-disable-next-line no-restricted-imports
import { base } from './fetch'
@@ -167,5 +172,121 @@ describe('base', () => {
expect(toast.error).not.toHaveBeenCalled()
})
+
+ it('should suppress a late workflow 404 for the app being deleted', async () => {
+ const appId = 'deleting-app'
+ markAppDeletionStarted(appId)
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ code: 'not_found',
+ message: 'App not found',
+ status: 404,
+ }),
+ {
+ status: 404,
+ headers: { 'Content-Type': 'application/json' },
+ },
+ ),
+ )
+
+ try {
+ await expect(
+ base(`/apps/${appId}/workflows/draft/system-variables`),
+ ).rejects.toBeInstanceOf(Response)
+ expect(toast.error).not.toHaveBeenCalled()
+ } finally {
+ markAppDeletionFailed(appId)
+ }
+ })
+
+ it('should suppress a workflow 404 that settles after app deletion succeeds', async () => {
+ const appId = 'deleted-app'
+ markAppDeletionStarted(appId)
+ markAppDeletionSucceeded(appId)
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ code: 'not_found',
+ message: 'App not found',
+ status: 404,
+ }),
+ {
+ status: 404,
+ headers: { 'Content-Type': 'application/json' },
+ },
+ ),
+ )
+
+ await expect(
+ base(`/apps/${appId}/workflows/draft/conversation-variables`),
+ ).rejects.toBeInstanceOf(Response)
+ expect(toast.error).not.toHaveBeenCalled()
+ })
+
+ it('should restore workflow 404 notifications when app deletion fails', async () => {
+ const appId = 'failed-deletion-app'
+ markAppDeletionStarted(appId)
+ markAppDeletionFailed(appId)
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ code: 'not_found',
+ message: 'Visible error',
+ status: 404,
+ }),
+ {
+ status: 404,
+ headers: { 'Content-Type': 'application/json' },
+ },
+ ),
+ )
+
+ await expect(base(`/apps/${appId}/workflows/draft/variables`)).rejects.toBeInstanceOf(
+ Response,
+ )
+ expect(toast.error).toHaveBeenCalledWith('Visible error')
+ })
+
+ it.each([
+ {
+ title: 'another app workflow 404',
+ path: '/apps/another-app/workflows/draft/system-variables',
+ status: 404,
+ },
+ {
+ title: 'a non-workflow 404',
+ path: '/apps/deleting-app',
+ status: 404,
+ },
+ {
+ title: 'a workflow 500',
+ path: '/apps/deleting-app/workflows/draft/system-variables',
+ status: 500,
+ },
+ ])('should still display $title while an app is being deleted', async ({ path, status }) => {
+ const appId = 'deleting-app'
+ markAppDeletionStarted(appId)
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ code: 'request_failed',
+ message: 'Visible error',
+ status,
+ }),
+ {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ },
+ ),
+ )
+
+ try {
+ await expect(base(path)).rejects.toBeInstanceOf(Response)
+ expect(toast.error).toHaveBeenCalledWith('Visible error')
+ } finally {
+ markAppDeletionFailed(appId)
+ }
+ })
})
})
diff --git a/web/service/fetch.ts b/web/service/fetch.ts
index 958431408d7..d84a3064fab 100644
--- a/web/service/fetch.ts
+++ b/web/service/fetch.ts
@@ -14,6 +14,7 @@ import {
PUBLIC_API_PREFIX,
WEB_APP_SHARE_CODE_HEADER_NAME,
} from '@/config'
+import { shouldSuppressAppDeletionErrorToast } from './app-deletion'
import { getWebAppPublicApiPath, resolveWebAppAddress } from './webapp-address'
import { getWebAppAccessToken, getWebAppPassport } from './webapp-auth'
@@ -68,14 +69,18 @@ const createResponseFromHTTPError = (error: HTTPError): Response => {
}
const afterResponseErrorCode = (otherOptions: IOtherOptions): AfterResponseHook => {
- return async ({ response }) => {
+ return async ({ request, response }) => {
if (!/^[23]\d{2}$/.test(String(response.status))) {
let errorData: ResponseError | null = null
try {
const data: unknown = await response.clone().json()
errorData = data as ResponseError
} catch {}
- const shouldNotifyError = response.status !== 401 && errorData && !otherOptions.silent
+ const shouldNotifyError =
+ response.status !== 401 &&
+ errorData &&
+ !otherOptions.silent &&
+ !shouldSuppressAppDeletionErrorToast(request.url, response.status)
const errorMessage = errorData?.message || errorData?.error
if (shouldNotifyError && errorMessage) toast.error(errorMessage)
From 98c2ffeec72a13baffbcf95ee59a2fc8a1db88cc Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:16:12 +0000
Subject: [PATCH 18/18] fix(ui): align toast swipe direction with viewport
(#40980)
---
.../src/toast/__tests__/index.spec.tsx | 75 +++++++++++++++++++
packages/dify-ui/src/toast/index.tsx | 3 +-
2 files changed, 77 insertions(+), 1 deletion(-)
diff --git a/packages/dify-ui/src/toast/__tests__/index.spec.tsx b/packages/dify-ui/src/toast/__tests__/index.spec.tsx
index a53a9731a3b..36fc4d14f3b 100644
--- a/packages/dify-ui/src/toast/__tests__/index.spec.tsx
+++ b/packages/dify-ui/src/toast/__tests__/index.spec.tsx
@@ -88,6 +88,81 @@ describe('@langgenius/dify-ui/toast', () => {
)
})
+ it('should reject a downward swipe and dismiss upward from the top-right viewport', async () => {
+ const baseUIAnimationGlobal = globalThis as BaseUIAnimationGlobal
+ const animationState = baseUIAnimationGlobal.BASE_UI_ANIMATIONS_DISABLED
+ baseUIAnimationGlobal.BASE_UI_ANIMATIONS_DISABLED = false
+
+ try {
+ const screen = await render(
+ <>
+
+
+
+
+ >,
+ )
+
+ toast('Directional notification')
+
+ const toastDialog = screen.getByRole('dialog', { name: 'Directional notification' })
+ await expect.element(toastDialog).toBeInTheDocument()
+ const toastElement = toastDialog.element()
+ const initialBounds = toastElement.getBoundingClientRect()
+
+ await userEvent.dragAndDrop(toastElement, screen.getByLabelText('Swipe down destination'), {
+ steps: 10,
+ })
+
+ await expect.element(toastDialog).toBeInTheDocument()
+ expect(toastElement).not.toHaveAttribute('data-ending-style')
+ expect(toastElement.getBoundingClientRect().top).toBeCloseTo(initialBounds.top, 0)
+
+ await userEvent.dragAndDrop(toastElement, screen.getByLabelText('Swipe up destination'), {
+ steps: 10,
+ })
+
+ await vi.waitFor(() => {
+ expect(toastElement).toHaveAttribute('data-ending-style')
+ expect(toastElement).toHaveAttribute('data-swipe-direction', 'up')
+ })
+ expect(toastElement.getBoundingClientRect().bottom).toBeLessThan(initialBounds.top)
+ } finally {
+ baseUIAnimationGlobal.BASE_UI_ANIMATIONS_DISABLED = animationState
+ }
+ })
+
it('should dismiss an expanded background toast from its current row when swiped right', async () => {
const baseUIAnimationGlobal = globalThis as BaseUIAnimationGlobal
const animationState = baseUIAnimationGlobal.BASE_UI_ANIMATIONS_DISABLED
diff --git a/packages/dify-ui/src/toast/index.tsx b/packages/dify-ui/src/toast/index.tsx
index 76315e559fd..bcdbdd4ea0f 100644
--- a/packages/dify-ui/src/toast/index.tsx
+++ b/packages/dify-ui/src/toast/index.tsx
@@ -181,6 +181,7 @@ function ToastCard({ toast: toastItem }: { toast: ToastObject }) {
return (
}) {
'transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-current-height))))_scale(var(--toast-scale))]',
'data-expanded:h-(--toast-height) data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-expanded-offset-y))_scale(1)]',
'data-ending-style:pointer-events-none data-ending-style:transform-[translateY(-150%)] data-ending-style:opacity-0 data-ending-style:after:pointer-events-none',
- 'data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+150%))]',
+ 'data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-150%))]',
'data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--toast-expanded-offset-y))]',
'data-limited:pointer-events-none data-limited:opacity-0 data-starting-style:transform-[translateY(-150%)] data-starting-style:opacity-0',
"after:pointer-events-auto after:absolute after:bottom-full after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full after:content-['']",