mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 13:47:26 +08:00
Compare commits
46 Commits
1.14.1
...
feat/dify-
| Author | SHA1 | Date | |
|---|---|---|---|
| 04eab90825 | |||
| d24a16a9a5 | |||
| edf45fe7c1 | |||
| 8afdf7eb14 | |||
| eb1be3b7e2 | |||
| e3e245881e | |||
| b70763d751 | |||
| ae4a3f75f4 | |||
| b4ce54a7ea | |||
| 6facd9360c | |||
| a18d7f51eb | |||
| 680ef077ae | |||
| c26be9d3f4 | |||
| e8c16fb08b | |||
| ab0b4c45cb | |||
| 208012e268 | |||
| 8d96f6cfbd | |||
| 15f5c7064e | |||
| e470e9d4c5 | |||
| 499cad2f15 | |||
| 92e1d5aa6b | |||
| 85cb6558f9 | |||
| f12ec0d53a | |||
| 80312f9745 | |||
| 658caa2ae7 | |||
| 3c95ff4782 | |||
| 2a5601e868 | |||
| a523d071ca | |||
| 5a425f1525 | |||
| 8c6e9c3b95 | |||
| f316d19be6 | |||
| 31a1de4828 | |||
| 3a5477b39a | |||
| 4d580f9e8d | |||
| 24349db9b2 | |||
| 2f603183f0 | |||
| 66e245aa8d | |||
| 2ea227f3c0 | |||
| 6fd27519e9 | |||
| 468d9bca09 | |||
| 5dfd318907 | |||
| 5a7eb7fdb6 | |||
| 264533324b | |||
| 9dbf3199a3 | |||
| 6ca07c4a4e | |||
| 70bd5439d0 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -250,5 +250,5 @@ scripts/stress-test/reports/
|
||||
|
||||
# Code Agent Folder
|
||||
.qoder/*
|
||||
|
||||
.context/*
|
||||
.eslintcache
|
||||
|
||||
@ -9,6 +9,7 @@ The codebase is split into:
|
||||
- **Backend API** (`/api`): Python Flask application organized with Domain-Driven Design
|
||||
- **Frontend Web** (`/web`): Next.js application using TypeScript and React
|
||||
- **Docker deployment** (`/docker`): Containerized deployment configurations
|
||||
- **Dify Agent Backend** (`/dify-agent`): Backend services for managing and executing agent
|
||||
|
||||
## Backend Workflow
|
||||
|
||||
|
||||
@ -70,6 +70,12 @@ register_schema_models(
|
||||
)
|
||||
|
||||
|
||||
def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool:
|
||||
if role != TenantAccountRole.DATASET_OPERATOR:
|
||||
return True
|
||||
return FeatureService.get_features(tenant_id=tenant_id).dataset_operator_enabled
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/members")
|
||||
class MemberListApi(Resource):
|
||||
"""List all members of current tenant."""
|
||||
@ -110,6 +116,8 @@ class MemberInviteEmailApi(Resource):
|
||||
inviter = current_user
|
||||
if not inviter.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if not _is_role_enabled(invitee_role, inviter.current_tenant.id):
|
||||
return {"code": "invalid-role", "message": "Invalid role"}, 400
|
||||
|
||||
# Check workspace permission for member invitations
|
||||
from libs.workspace_permission import check_workspace_member_invite_permission
|
||||
@ -208,6 +216,8 @@ class MemberUpdateRoleApi(Resource):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if not _is_role_enabled(new_role, current_user.current_tenant.id):
|
||||
return {"code": "invalid-role", "message": "Invalid role"}, 400
|
||||
member = db.session.get(Account, str(member_id))
|
||||
if not member:
|
||||
abort(404)
|
||||
@ -215,11 +225,17 @@ class MemberUpdateRoleApi(Resource):
|
||||
try:
|
||||
assert member is not None, "Member not found"
|
||||
TenantService.update_member_role(current_user.current_tenant, member, new_role, current_user)
|
||||
except services.errors.account.CannotOperateSelfError as e:
|
||||
return {"code": "cannot-operate-self", "message": str(e)}, 400
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
return {"code": "forbidden", "message": str(e)}, 403
|
||||
except services.errors.account.MemberNotInTenantError as e:
|
||||
return {"code": "member-not-found", "message": str(e)}, 404
|
||||
except services.errors.account.RoleAlreadyAssignedError as e:
|
||||
return {"code": "role-already-assigned", "message": str(e)}, 400
|
||||
except Exception as e:
|
||||
raise ValueError(str(e))
|
||||
|
||||
# todo: 403
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
|
||||
@ -161,35 +161,39 @@ class AdvancedPromptTransform(PromptTransform):
|
||||
prompt_messages: list[PromptMessage] = []
|
||||
for prompt_item in prompt_template:
|
||||
raw_prompt = prompt_item.text
|
||||
|
||||
if prompt_item.edition_type == "basic" or not prompt_item.edition_type:
|
||||
if self.with_variable_tmpl:
|
||||
vp = VariablePool.empty()
|
||||
for k, v in inputs.items():
|
||||
if k.startswith("#"):
|
||||
vp.add(k[1:-1].split("."), v)
|
||||
raw_prompt = raw_prompt.replace("{{#context#}}", context or "")
|
||||
prompt = vp.convert_template(raw_prompt).text
|
||||
else:
|
||||
parser = PromptTemplateParser(template=raw_prompt, with_variable_tmpl=self.with_variable_tmpl)
|
||||
prompt_inputs: Mapping[str, str] = {k: inputs[k] for k in parser.variable_keys if k in inputs}
|
||||
prompt_inputs = self._set_context_variable(
|
||||
context=context, parser=parser, prompt_inputs=prompt_inputs
|
||||
)
|
||||
prompt = parser.format(prompt_inputs)
|
||||
elif prompt_item.edition_type == "jinja2":
|
||||
prompt = raw_prompt
|
||||
prompt_inputs = inputs
|
||||
prompt = Jinja2Formatter.format(template=prompt, inputs=prompt_inputs)
|
||||
else:
|
||||
raise ValueError(f"Invalid edition type: {prompt_item.edition_type}")
|
||||
|
||||
if prompt_item.role == PromptMessageRole.USER:
|
||||
prompt_messages.append(UserPromptMessage(content=prompt))
|
||||
elif prompt_item.role == PromptMessageRole.SYSTEM and prompt:
|
||||
prompt_messages.append(SystemPromptMessage(content=prompt))
|
||||
elif prompt_item.role == PromptMessageRole.ASSISTANT:
|
||||
prompt_messages.append(AssistantPromptMessage(content=prompt))
|
||||
edition_type = prompt_item.edition_type or "basic"
|
||||
match edition_type:
|
||||
case "basic":
|
||||
if self.with_variable_tmpl:
|
||||
vp = VariablePool.empty()
|
||||
for k, v in inputs.items():
|
||||
if k.startswith("#"):
|
||||
vp.add(k[1:-1].split("."), v)
|
||||
raw_prompt = raw_prompt.replace("{{#context#}}", context or "")
|
||||
prompt = vp.convert_template(raw_prompt).text
|
||||
else:
|
||||
parser = PromptTemplateParser(template=raw_prompt, with_variable_tmpl=self.with_variable_tmpl)
|
||||
prompt_inputs: Mapping[str, str] = {k: inputs[k] for k in parser.variable_keys if k in inputs}
|
||||
prompt_inputs = self._set_context_variable(
|
||||
context=context, parser=parser, prompt_inputs=prompt_inputs
|
||||
)
|
||||
prompt = parser.format(prompt_inputs)
|
||||
case "jinja2":
|
||||
prompt = raw_prompt
|
||||
prompt_inputs = inputs
|
||||
prompt = Jinja2Formatter.format(template=prompt, inputs=prompt_inputs)
|
||||
case _:
|
||||
raise ValueError(f"Invalid edition type: {prompt_item.edition_type}")
|
||||
match prompt_item.role:
|
||||
case PromptMessageRole.USER:
|
||||
prompt_messages.append(UserPromptMessage(content=prompt))
|
||||
case PromptMessageRole.SYSTEM:
|
||||
if prompt:
|
||||
prompt_messages.append(SystemPromptMessage(content=prompt))
|
||||
case PromptMessageRole.ASSISTANT:
|
||||
prompt_messages.append(AssistantPromptMessage(content=prompt))
|
||||
case PromptMessageRole.TOOL:
|
||||
pass
|
||||
|
||||
if query and memory_config and memory_config.query_prompt_template:
|
||||
parser = PromptTemplateParser(
|
||||
|
||||
@ -183,34 +183,35 @@ class ExtractProcessor:
|
||||
return extractor.extract()
|
||||
elif extract_setting.datasource_type == DatasourceType.WEBSITE:
|
||||
assert extract_setting.website_info is not None, "website_info is required"
|
||||
if extract_setting.website_info.provider == "firecrawl":
|
||||
extractor = FirecrawlWebExtractor(
|
||||
url=extract_setting.website_info.url,
|
||||
job_id=extract_setting.website_info.job_id,
|
||||
tenant_id=extract_setting.website_info.tenant_id,
|
||||
mode=extract_setting.website_info.mode,
|
||||
only_main_content=extract_setting.website_info.only_main_content,
|
||||
)
|
||||
return extractor.extract()
|
||||
elif extract_setting.website_info.provider == "watercrawl":
|
||||
extractor = WaterCrawlWebExtractor(
|
||||
url=extract_setting.website_info.url,
|
||||
job_id=extract_setting.website_info.job_id,
|
||||
tenant_id=extract_setting.website_info.tenant_id,
|
||||
mode=extract_setting.website_info.mode,
|
||||
only_main_content=extract_setting.website_info.only_main_content,
|
||||
)
|
||||
return extractor.extract()
|
||||
elif extract_setting.website_info.provider == "jinareader":
|
||||
extractor = JinaReaderWebExtractor(
|
||||
url=extract_setting.website_info.url,
|
||||
job_id=extract_setting.website_info.job_id,
|
||||
tenant_id=extract_setting.website_info.tenant_id,
|
||||
mode=extract_setting.website_info.mode,
|
||||
only_main_content=extract_setting.website_info.only_main_content,
|
||||
)
|
||||
return extractor.extract()
|
||||
else:
|
||||
raise ValueError(f"Unsupported website provider: {extract_setting.website_info.provider}")
|
||||
match extract_setting.website_info.provider:
|
||||
case "firecrawl":
|
||||
extractor = FirecrawlWebExtractor(
|
||||
url=extract_setting.website_info.url,
|
||||
job_id=extract_setting.website_info.job_id,
|
||||
tenant_id=extract_setting.website_info.tenant_id,
|
||||
mode=extract_setting.website_info.mode,
|
||||
only_main_content=extract_setting.website_info.only_main_content,
|
||||
)
|
||||
return extractor.extract()
|
||||
case "watercrawl":
|
||||
extractor = WaterCrawlWebExtractor(
|
||||
url=extract_setting.website_info.url,
|
||||
job_id=extract_setting.website_info.job_id,
|
||||
tenant_id=extract_setting.website_info.tenant_id,
|
||||
mode=extract_setting.website_info.mode,
|
||||
only_main_content=extract_setting.website_info.only_main_content,
|
||||
)
|
||||
return extractor.extract()
|
||||
case "jinareader":
|
||||
extractor = JinaReaderWebExtractor(
|
||||
url=extract_setting.website_info.url,
|
||||
job_id=extract_setting.website_info.job_id,
|
||||
tenant_id=extract_setting.website_info.tenant_id,
|
||||
mode=extract_setting.website_info.mode,
|
||||
only_main_content=extract_setting.website_info.only_main_content,
|
||||
)
|
||||
return extractor.extract()
|
||||
case _:
|
||||
raise ValueError(f"Unsupported website provider: {extract_setting.website_info.provider}")
|
||||
else:
|
||||
raise ValueError(f"Unsupported datasource type: {extract_setting.datasource_type}")
|
||||
|
||||
@ -22,7 +22,6 @@ dependencies = [
|
||||
"redis[hiredis]>=7.4.0",
|
||||
"sendgrid>=6.12.5",
|
||||
"sseclient-py>=1.8.0",
|
||||
|
||||
# Stable: production-proven, cap below the next major
|
||||
"aliyun-log-python-sdk>=0.9.44,<1.0.0",
|
||||
"azure-identity>=1.25.3,<2.0.0",
|
||||
@ -42,8 +41,8 @@ dependencies = [
|
||||
"opentelemetry-propagator-b3>=1.41.1,<2.0.0",
|
||||
"readabilipy>=0.3.0,<1.0.0",
|
||||
"resend>=2.27.0,<3.0.0",
|
||||
|
||||
# Emerging: newer and fast-moving, use compatible pins
|
||||
"dify-agent",
|
||||
"fastopenapi[flask]~=0.7.0",
|
||||
"graphon~=0.3.1",
|
||||
"httpx-sse~=0.4.0",
|
||||
@ -60,6 +59,7 @@ members = ["providers/vdb/*", "providers/trace/*"]
|
||||
exclude = ["providers/vdb/__pycache__", "providers/trace/__pycache__"]
|
||||
|
||||
[tool.uv.sources]
|
||||
dify-agent = { path = "../dify-agent" }
|
||||
dify-vdb-alibabacloud-mysql = { workspace = true }
|
||||
dify-vdb-analyticdb = { workspace = true }
|
||||
dify-vdb-baidu = { workspace = true }
|
||||
|
||||
@ -1280,8 +1280,8 @@ class TenantService:
|
||||
"""Check member permission"""
|
||||
perms = {
|
||||
"add": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN],
|
||||
"remove": [TenantAccountRole.OWNER],
|
||||
"update": [TenantAccountRole.OWNER],
|
||||
"remove": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN],
|
||||
"update": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN],
|
||||
}
|
||||
if action not in {"add", "remove", "update"}:
|
||||
raise InvalidActionError("Invalid action.")
|
||||
@ -1299,6 +1299,15 @@ class TenantService:
|
||||
if not ta_operator or ta_operator.role not in perms[action]:
|
||||
raise NoPermissionError(f"No permission to {action} member.")
|
||||
|
||||
if action == "remove" and ta_operator.role == TenantAccountRole.ADMIN and member:
|
||||
ta_member = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == member.id)
|
||||
.limit(1)
|
||||
)
|
||||
if ta_member and ta_member.role == TenantAccountRole.OWNER:
|
||||
raise NoPermissionError(f"No permission to {action} member.")
|
||||
|
||||
@staticmethod
|
||||
def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account):
|
||||
"""Remove member from tenant.
|
||||
@ -1370,6 +1379,7 @@ class TenantService:
|
||||
def update_member_role(tenant: Tenant, member: Account, new_role: str, operator: Account):
|
||||
"""Update member role"""
|
||||
TenantService.check_member_permission(tenant, operator, member, "update")
|
||||
new_tenant_role = TenantAccountRole(new_role)
|
||||
|
||||
target_member_join = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
@ -1380,6 +1390,11 @@ class TenantService:
|
||||
if not target_member_join:
|
||||
raise MemberNotInTenantError("Member not in tenant.")
|
||||
|
||||
operator_role = TenantService.get_user_role(operator, tenant)
|
||||
target_role = TenantAccountRole(target_member_join.role)
|
||||
if operator_role == TenantAccountRole.ADMIN and (TenantAccountRole.OWNER in {target_role, new_tenant_role}):
|
||||
raise NoPermissionError("No permission to update member.")
|
||||
|
||||
if target_member_join.role == new_role:
|
||||
raise RoleAlreadyAssignedError("The provided role is already assigned to the member.")
|
||||
|
||||
@ -1394,7 +1409,7 @@ class TenantService:
|
||||
current_owner_join.role = TenantAccountRole.ADMIN
|
||||
|
||||
# Update the role of the target member
|
||||
target_member_join.role = TenantAccountRole(new_role)
|
||||
target_member_join.role = new_tenant_role
|
||||
db.session.commit()
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -13,6 +13,7 @@ from services.errors.account import (
|
||||
AccountPasswordError,
|
||||
AccountRegisterError,
|
||||
CurrentPasswordIncorrectError,
|
||||
NoPermissionError,
|
||||
)
|
||||
|
||||
|
||||
@ -817,8 +818,8 @@ class TestTenantService:
|
||||
|
||||
# Mock the database queries in update_member_role method
|
||||
with patch("services.account_service.db") as mock_db:
|
||||
# scalar calls: permission check, target member lookup
|
||||
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join]
|
||||
# scalar calls: permission check, target member lookup, operator role lookup
|
||||
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join]
|
||||
|
||||
# Execute test
|
||||
TenantService.update_member_role(mock_tenant, mock_member, "admin", mock_operator)
|
||||
@ -827,6 +828,65 @@ class TestTenantService:
|
||||
assert mock_target_join.role == "admin"
|
||||
self._assert_database_operations_called(mock_db)
|
||||
|
||||
def test_admin_can_update_admin_member_role(self):
|
||||
"""Test admin can update another non-owner member, including an admin."""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
|
||||
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
|
||||
mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="member-789", role="admin"
|
||||
)
|
||||
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="operator-123", role="admin"
|
||||
)
|
||||
|
||||
with patch("services.account_service.db") as mock_db:
|
||||
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join]
|
||||
|
||||
TenantService.update_member_role(mock_tenant, mock_member, "editor", mock_operator)
|
||||
|
||||
assert mock_target_join.role == "editor"
|
||||
self._assert_database_operations_called(mock_db)
|
||||
|
||||
def test_admin_cannot_update_owner_member_role(self):
|
||||
"""Test admin cannot update an owner member."""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
|
||||
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
|
||||
mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="member-789", role="owner"
|
||||
)
|
||||
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="operator-123", role="admin"
|
||||
)
|
||||
|
||||
with patch("services.account_service.db") as mock_db:
|
||||
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join]
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
TenantService.update_member_role(mock_tenant, mock_member, "editor", mock_operator)
|
||||
|
||||
def test_admin_cannot_promote_member_to_owner(self):
|
||||
"""Test admin cannot promote a non-owner member to owner."""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
|
||||
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
|
||||
mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="member-789", role="admin"
|
||||
)
|
||||
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="operator-123", role="admin"
|
||||
)
|
||||
|
||||
with patch("services.account_service.db") as mock_db:
|
||||
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join]
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
TenantService.update_member_role(mock_tenant, mock_member, "owner", mock_operator)
|
||||
|
||||
# ==================== Permission Check Tests ====================
|
||||
|
||||
def test_check_member_permission_success(self, mock_db_dependencies):
|
||||
@ -864,6 +924,39 @@ class TestTenantService:
|
||||
"add",
|
||||
)
|
||||
|
||||
def test_admin_can_remove_non_owner_member(self, mock_db_dependencies):
|
||||
"""Test admin can remove a non-owner member."""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
|
||||
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
|
||||
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="operator-123", role="admin"
|
||||
)
|
||||
mock_member_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="member-789", role="admin"
|
||||
)
|
||||
mock_db_dependencies["db"].session.scalar.side_effect = [mock_operator_join, mock_member_join]
|
||||
|
||||
TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove")
|
||||
|
||||
def test_admin_cannot_remove_owner_member(self, mock_db_dependencies):
|
||||
"""Test admin cannot remove an owner member."""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
|
||||
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
|
||||
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="operator-123", role="admin"
|
||||
)
|
||||
mock_member_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="member-789", role="owner"
|
||||
)
|
||||
mock_db_dependencies["db"].session.scalar.side_effect = [mock_operator_join, mock_member_join]
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove")
|
||||
|
||||
|
||||
class TestRegisterService:
|
||||
"""
|
||||
|
||||
155
api/uv.lock
generated
155
api/uv.lock
generated
@ -1290,6 +1290,48 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dify-agent"
|
||||
version = "0.1.0"
|
||||
source = { directory = "../dify-agent" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-ai-slim" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.136.0" },
|
||||
{ name = "graphon", marker = "extra == 'server'", specifier = "~=0.2.2" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "pydantic", specifier = ">=2.13.3" },
|
||||
{ name = "pydantic-ai-slim", specifier = ">=1.85.1" },
|
||||
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1" },
|
||||
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0" },
|
||||
{ name = "redis", marker = "extra == 'server'", specifier = ">=5" },
|
||||
{ name = "typing-extensions", specifier = ">=4.12.2" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.38.0" },
|
||||
]
|
||||
provides-extras = ["server"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "basedpyright", specifier = ">=1.39.3" },
|
||||
{ name = "coverage", extras = ["toml"], specifier = ">=7.10.7" },
|
||||
{ name = "pytest", specifier = ">=9.0.3" },
|
||||
{ name = "pytest-examples", specifier = ">=0.0.18" },
|
||||
{ name = "pytest-mock", specifier = ">=3.14.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.11" },
|
||||
]
|
||||
docs = [
|
||||
{ name = "mkdocs", specifier = ">=1.6.1,<2" },
|
||||
{ name = "mkdocs-glightbox", specifier = ">=0.4.0" },
|
||||
{ name = "mkdocs-material", specifier = ">=9.7.0" },
|
||||
{ name = "mkdocstrings-python", specifier = ">=2.0.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.14.1"
|
||||
@ -1301,6 +1343,7 @@ dependencies = [
|
||||
{ name = "boto3" },
|
||||
{ name = "celery" },
|
||||
{ name = "croniter" },
|
||||
{ name = "dify-agent" },
|
||||
{ name = "fastopenapi", extra = ["flask"] },
|
||||
{ name = "flask" },
|
||||
{ name = "flask-compress" },
|
||||
@ -1584,6 +1627,7 @@ requires-dist = [
|
||||
{ name = "boto3", specifier = ">=1.43.6" },
|
||||
{ name = "celery", specifier = ">=5.6.3" },
|
||||
{ name = "croniter", specifier = ">=6.2.2" },
|
||||
{ name = "dify-agent", directory = "../dify-agent" },
|
||||
{ name = "fastopenapi", extras = ["flask"], specifier = "~=0.7.0" },
|
||||
{ name = "flask", specifier = ">=3.1.3,<4.0.0" },
|
||||
{ name = "flask-compress", specifier = ">=1.24,<2.0.0" },
|
||||
@ -2612,6 +2656,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "genai-prices"
|
||||
version = "0.0.59"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/c8/b61a028b8d8ee286ffab3f9b9f1c9229087184e7d543cea4e349e11375b0/genai_prices-0.0.59.tar.gz", hash = "sha256:3e1c7dcd9b38163589c8cf4a9bcfd286c52ea57a3becdc062a2cbaa8295b08c4", size = 67406, upload-time = "2026-05-07T12:08:40.475Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/11/f9/4693c127f9fab0a8d39c47c198e378ecafcb043463e6dd73df205eacbc13/genai_prices-0.0.59-py3-none-any.whl", hash = "sha256:88fd8818e6807374e5a5c03f293b574ade5f18a3060622080cdd94a03cf43115", size = 70509, upload-time = "2026-05-07T12:08:39.075Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gevent"
|
||||
version = "26.4.0"
|
||||
@ -3003,6 +3060,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "griffelib"
|
||||
version = "2.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grimp"
|
||||
version = "3.14"
|
||||
@ -3690,6 +3756,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logfire-api"
|
||||
version = "4.32.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/1b/0c74ad85f977743ba4c589e46e0cb138d6a6e69487830f4e86ebbdb145a3/logfire_api-4.32.1.tar.gz", hash = "sha256:5e8714b2bb5fb5d1f4a4a833941e4ca711b75d2c1f98e76c5ad680fe6991af6a", size = 78788, upload-time = "2026-04-15T14:11:58.788Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/ab/d5adeab6253c7ecd5904fc5ef3265859f218610caf4e1e55efe9aff6ac49/logfire_api-4.32.1-py3-none-any.whl", hash = "sha256:4b4c27cf6e27e8e26ef4b22a77f2a2988dd1d07e2d24ee70673ef34b234fb8a5", size = 124394, upload-time = "2026-04-15T14:11:56.157Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.1.0"
|
||||
@ -5086,7 +5161,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
@ -5094,38 +5169,57 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-ai-slim"
|
||||
version = "1.94.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "genai-prices" },
|
||||
{ name = "griffelib" },
|
||||
{ name = "httpx" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-graph" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/0b/ce4992e0e29ba81ba48d5bba955c53b72e2cda3636f9b6417386ae7e45f7/pydantic_ai_slim-1.94.0.tar.gz", hash = "sha256:7d7b1d6aec4d0fd31533a4ef5848863e8513ec75e82910296247a08b737aa828", size = 640338, upload-time = "2026-05-12T07:03:55.486Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/dd/b104641c2af7044a788b1071159679aa755e5c9281f110fdc54b4729117b/pydantic_ai_slim-1.94.0-py3-none-any.whl", hash = "sha256:f47cf89c61ef45a48dd575a8b32707edfec2b33ef7af80aa069bde1ce3fb6795", size = 805546, upload-time = "2026-05-12T07:03:46.535Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
version = "2.46.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5141,6 +5235,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-graph"
|
||||
version = "1.94.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "logfire-api" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/80/c41aa5ccf7104eba172ff45e617967b0075b3fa34ab76cd3795d7d62334a/pydantic_graph-1.94.0.tar.gz", hash = "sha256:8f991c05d412c9d12d6560c1e131de48bfde12ebd27a0b196440620210f2d52c", size = 59252, upload-time = "2026-05-12T07:03:58.26Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/7e/edcc6177d89174024bca1fb85044bec4855b96f4b724a310638283dfc8c5/pydantic_graph-1.94.0-py3-none-any.whl", hash = "sha256:c6e285abbc8a55d1b65162c238006913edd1ef05e63a29401a580e51f798503e", size = 73063, upload-time = "2026-05-12T07:03:50.651Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.13.1"
|
||||
|
||||
37
dify-agent/.example.env
Normal file
37
dify-agent/.example.env
Normal file
@ -0,0 +1,37 @@
|
||||
# Dify Agent run server settings template.
|
||||
# Mirrors dify_agent.server.settings.ServerSettings with the DIFY_AGENT_ prefix.
|
||||
# This template intentionally covers the current run-server settings only.
|
||||
|
||||
# Redis
|
||||
# Redis connection URL for run records and per-run event streams.
|
||||
DIFY_AGENT_REDIS_URL=redis://localhost:6379/0
|
||||
# Prefix for Redis run-record and event-stream keys.
|
||||
DIFY_AGENT_REDIS_PREFIX=dify-agent
|
||||
|
||||
# Shutdown and retention
|
||||
# Seconds to wait for active local runs during graceful shutdown before cancellation.
|
||||
DIFY_AGENT_SHUTDOWN_GRACE_SECONDS=30
|
||||
# Seconds to retain Redis run records and per-run event streams (default: 3 days).
|
||||
DIFY_AGENT_RUN_RETENTION_SECONDS=259200
|
||||
|
||||
# Plugin daemon
|
||||
# Base URL for the Dify plugin daemon used by local runs.
|
||||
DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002
|
||||
# API key sent to the Dify plugin daemon.
|
||||
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=
|
||||
|
||||
# Shared plugin-daemon HTTP client timeouts and limits.
|
||||
# Plugin-daemon HTTP connect timeout in seconds.
|
||||
DIFY_AGENT_PLUGIN_DAEMON_CONNECT_TIMEOUT=10
|
||||
# Plugin-daemon HTTP read timeout in seconds.
|
||||
DIFY_AGENT_PLUGIN_DAEMON_READ_TIMEOUT=600
|
||||
# Plugin-daemon HTTP write timeout in seconds.
|
||||
DIFY_AGENT_PLUGIN_DAEMON_WRITE_TIMEOUT=30
|
||||
# Plugin-daemon HTTP connection-pool wait timeout in seconds.
|
||||
DIFY_AGENT_PLUGIN_DAEMON_POOL_TIMEOUT=10
|
||||
# Maximum total plugin-daemon HTTP connections.
|
||||
DIFY_AGENT_PLUGIN_DAEMON_MAX_CONNECTIONS=100
|
||||
# Maximum idle keep-alive plugin-daemon HTTP connections.
|
||||
DIFY_AGENT_PLUGIN_DAEMON_MAX_KEEPALIVE_CONNECTIONS=20
|
||||
# Keep-alive expiry in seconds for idle plugin-daemon HTTP connections.
|
||||
DIFY_AGENT_PLUGIN_DAEMON_KEEPALIVE_EXPIRY=30
|
||||
1
dify-agent/.gitignore
vendored
Normal file
1
dify-agent/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
dify-aio
|
||||
184
dify-agent/AGENTS.md
Normal file
184
dify-agent/AGENTS.md
Normal file
@ -0,0 +1,184 @@
|
||||
# Agent Guide
|
||||
|
||||
## Notes for Agent (must-check)
|
||||
|
||||
Before changing any source code under this folder, you MUST read the surrounding docstrings and comments. These notes contain required context (invariants, edge cases, trade-offs) and are treated as part of the spec.
|
||||
|
||||
Look for:
|
||||
|
||||
- The module (file) docstring at the top of a source code file
|
||||
- Docstrings on classes and functions/methods
|
||||
- Paragraph/block comments for non-obvious logic
|
||||
|
||||
### What to write where
|
||||
|
||||
- Keep notes scoped: module notes cover module-wide context, class notes cover class-wide context, function/method notes cover behavioural contracts, and paragraph/block comments cover local “why”. Avoid duplicating the same content across scopes unless repetition prevents misuse.
|
||||
- **Module (file) docstring**: purpose, boundaries, key invariants, and “gotchas” that a new reader must know before editing.
|
||||
- Include cross-links to the key collaborators (modules/services) when discovery is otherwise hard.
|
||||
- Prefer stable facts (invariants, contracts) over ephemeral “today we…” notes.
|
||||
- **Class docstring**: responsibility, lifecycle, invariants, and how it should be used (or not used).
|
||||
- If the class is intentionally stateful, note what state exists and what methods mutate it.
|
||||
- If concurrency/async assumptions matter, state them explicitly.
|
||||
- **Function/method docstring**: behavioural contract.
|
||||
- Document arguments, return shape, side effects (DB writes, external I/O, task dispatch), and raised domain exceptions.
|
||||
- Add examples only when they prevent misuse.
|
||||
- **Paragraph/block comments**: explain *why* (trade-offs, historical constraints, surprising edge cases), not what the code already states.
|
||||
- Keep comments adjacent to the logic they justify; delete or rewrite comments that no longer match reality.
|
||||
|
||||
### Rules (must follow)
|
||||
|
||||
In this section, “notes” means module/class/function docstrings plus any relevant paragraph/block comments.
|
||||
|
||||
- **Before working**
|
||||
- Read the notes in the area you’ll touch; treat them as part of the spec.
|
||||
- If a docstring or comment conflicts with the current code, treat the **code as the single source of truth** and update the docstring or comment to match reality.
|
||||
- If important intent/invariants/edge cases are missing, add them in the closest docstring or comment (module for overall scope, function for behaviour).
|
||||
- **During working**
|
||||
- Keep the notes in sync as you discover constraints, make decisions, or change approach.
|
||||
- If you move/rename responsibilities across modules/classes, update the affected docstrings and comments so readers can still find the “why” and the invariants.
|
||||
- Record non-obvious edge cases, trade-offs, and the test/verification plan in the nearest docstring or comment that will stay correct.
|
||||
- Keep the notes **coherent**: integrate new findings into the relevant docstrings and comments; avoid append-only “recent fix” / changelog-style additions.
|
||||
- **When finishing**
|
||||
- Update the notes to reflect what changed, why, and any new edge cases/tests.
|
||||
- Remove or rewrite any comments that could be mistaken as current guidance but no longer apply.
|
||||
- Keep docstrings and comments concise and accurate; they are meant to prevent repeated rediscovery.
|
||||
|
||||
## Coding Style
|
||||
|
||||
This is the default standard for backend code in this repo. Follow it for new code and use it as the checklist when reviewing changes.
|
||||
|
||||
### Linting & Formatting
|
||||
|
||||
- Use Ruff for formatting and linting (follow `.ruff.toml`).
|
||||
- Keep each line under 120 characters (including spaces).
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- Use `snake_case` for variables and functions.
|
||||
- Use `PascalCase` for classes.
|
||||
- Use `UPPER_CASE` for constants.
|
||||
|
||||
### Typing & Class Layout
|
||||
|
||||
- Code should usually include type annotations that match the repo’s current Python version (avoid untyped public APIs and “mystery” values).
|
||||
- Prefer modern typing forms (e.g. `list[str]`, `dict[str, int]`) and avoid `Any` unless there’s a strong reason.
|
||||
- For dictionary-like data with known keys and value types, prefer `TypedDict` over `dict[...]` or `Mapping[...]`.
|
||||
- For optional keys in typed payloads, use `NotRequired[...]` (or `total=False` when most fields are optional).
|
||||
- Keep `dict[...]` / `Mapping[...]` for truly dynamic key spaces where the key set is unknown.
|
||||
|
||||
```python
|
||||
from datetime import datetime
|
||||
from typing import NotRequired, TypedDict
|
||||
|
||||
|
||||
class UserProfile(TypedDict):
|
||||
user_id: str
|
||||
email: str
|
||||
created_at: datetime
|
||||
nickname: NotRequired[str]
|
||||
```
|
||||
|
||||
- For classes, declare all member variables explicitly with types at the top of the class body (before `__init__`), even when the class is not a dataclass or Pydantic model, so the class shape is obvious at a glance:
|
||||
|
||||
```python
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Example:
|
||||
user_id: str
|
||||
created_at: datetime
|
||||
|
||||
def __init__(self, user_id: str, created_at: datetime) -> None:
|
||||
self.user_id = user_id
|
||||
self.created_at = created_at
|
||||
```
|
||||
|
||||
- For dataclasses, prefer `field(default_factory=...)` over `field(init=False)` when a default can be provided declaratively.
|
||||
- Prefer dataclasses with `slots=True` when defining lightweight data containers:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Example:
|
||||
user_id: str
|
||||
created_at: datetime
|
||||
```
|
||||
|
||||
### General Rules
|
||||
|
||||
- Use Pydantic v2 conventions.
|
||||
- Use `uv` for Python package management in this repo (usually with `--project dify-agent`).
|
||||
- Use `make typecheck` to run `basedpyright` against `dify-agent/src` and `dify-agent/tests`.
|
||||
- Keep type checking passing after every edit you make.
|
||||
- Use `pytest` for all tests in this package.
|
||||
- When integrating with, implementing, or mocking a dependency, inspect the dependency's source code to confirm its API shape and runtime behavior instead of guessing from names alone.
|
||||
- Prefer simple functions over small “utility classes” for lightweight helpers.
|
||||
- Avoid implementing dunder methods unless it’s clearly needed and matches existing patterns.
|
||||
- Keep code readable and explicit—avoid clever hacks.
|
||||
|
||||
### Testing
|
||||
|
||||
- Work in TDD style: write or update a failing test first when changing behavior, then make the implementation pass, then refactor while keeping tests and typecheck green.
|
||||
- Use `make test` to run the agent pytest suite.
|
||||
- Keep local tests under `dify-agent/tests/local/`.
|
||||
- Mirror the `dify-agent/src/` package structure inside `dify-agent/tests/local/` so test locations stay predictable.
|
||||
|
||||
#### Local Tests
|
||||
|
||||
- Write local tests for stable, externally observable behavior that can run quickly without real external services.
|
||||
- In this repo, code, comments, docs, and tests are expected to change together. Because of that, a local test is only useful if it would still be correct after an internal refactor that does not change the intended contract.
|
||||
- Local tests should verify:
|
||||
- what callers and downstream code can observe and rely on
|
||||
- how the unit is expected to use its dependencies at the boundary
|
||||
- how the unit handles dependency success, failure, empty responses, malformed responses, and documented error cases
|
||||
- documented invariants, error mapping, and output/input shape guarantees
|
||||
- When asserting dependency interactions, assert only the parts of the request or response that are part of the real boundary contract. Do not over-specify incidental details that callers or dependencies do not rely on.
|
||||
- It is acceptable to mock dependencies in local tests, but only when the mock represents a real contract, schema, documented behavior, or known regression.
|
||||
- Tests may use line-scoped type-ignore comments when intentionally exercising runtime validation paths that static typing would normally reject. Keep the ignore on the exact invalid call.
|
||||
- Do not use local tests to prove real integration, network wiring, serialization, framework configuration, or third-party runtime behavior; cover those in higher-level tests.
|
||||
- Meaningless local tests include:
|
||||
- tests that only mirror the current implementation or must be updated whenever internal code changes even though the contract did not change
|
||||
- tests of private helpers, local variables, temporary state, internal branching, or exact internal call order unless those details are part of the published contract
|
||||
- tests with mocked dependency behavior that is invented only to make the current implementation pass
|
||||
- tests that add no value beyond static type checking or linting
|
||||
|
||||
### Logging & Errors
|
||||
|
||||
- Never use `print`; use a module-level logger:
|
||||
- `logger = logging.getLogger(__name__)`
|
||||
- Include tenant/app/workflow identifiers in log context when relevant.
|
||||
- Raise domain-specific exceptions and translate them into HTTP responses in controllers.
|
||||
- Log retryable events at `warning`, terminal failures at `error`.
|
||||
|
||||
### Pydantic Usage
|
||||
|
||||
- Define DTOs with Pydantic v2 models and forbid extras by default.
|
||||
- Use `@field_validator` / `@model_validator` for domain rules.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, ConfigDict, HttpUrl, field_validator
|
||||
|
||||
|
||||
class TriggerConfig(BaseModel):
|
||||
endpoint: HttpUrl
|
||||
secret: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@field_validator("secret")
|
||||
def ensure_secret_prefix(cls, value: str) -> str:
|
||||
if not value.startswith("dify_"):
|
||||
raise ValueError("secret must start with dify_")
|
||||
return value
|
||||
```
|
||||
|
||||
### Generics & Protocols
|
||||
|
||||
- Use `typing.Protocol` to define behavioural contracts (e.g., cache interfaces).
|
||||
- Apply generics (`TypeVar`, `Generic`) for reusable utilities like caches or providers.
|
||||
- Validate dynamic inputs at runtime when generics cannot enforce safety alone.
|
||||
37
dify-agent/Makefile
Normal file
37
dify-agent/Makefile
Normal file
@ -0,0 +1,37 @@
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
PROJECT_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
|
||||
|
||||
.PHONY: help check fix typecheck test update-examples docs docs-serve
|
||||
|
||||
help:
|
||||
@echo "Dify agent targets:"
|
||||
@echo " make check - Run Ruff for dify-agent"
|
||||
@echo " make fix - Format and fix Ruff issues"
|
||||
@echo " make typecheck - Run basedpyright for src, examples, and tests"
|
||||
@echo " make test - Run local tests and docs/example tests"
|
||||
@echo " make update-examples - Rewrite docs example outputs when needed"
|
||||
@echo " make docs - Build MkDocs documentation"
|
||||
@echo " make docs-serve - Serve MkDocs documentation locally"
|
||||
|
||||
check:
|
||||
@uv --directory "$(PROJECT_DIR)" run --project . python -m ruff check .
|
||||
|
||||
fix:
|
||||
@uv --directory "$(PROJECT_DIR)" run --project . python -m ruff format .
|
||||
@uv --directory "$(PROJECT_DIR)" run --project . python -m ruff check --fix .
|
||||
|
||||
typecheck:
|
||||
@uv --directory "$(PROJECT_DIR)" run --project . basedpyright --level error src examples tests
|
||||
|
||||
test:
|
||||
@uv --directory "$(PROJECT_DIR)" run --project . python -m pytest tests
|
||||
|
||||
update-examples:
|
||||
@uv --directory "$(PROJECT_DIR)" run --project . python -m pytest --update-examples tests/docs/test_examples.py
|
||||
|
||||
docs:
|
||||
@uv --directory "$(PROJECT_DIR)" run --project . --group docs python -m mkdocs build --no-strict
|
||||
|
||||
docs-serve:
|
||||
@uv --directory "$(PROJECT_DIR)" run --project . --group docs python -m mkdocs serve --no-strict
|
||||
7
dify-agent/README.md
Normal file
7
dify-agent/README.md
Normal file
@ -0,0 +1,7 @@
|
||||
# Dify Agent
|
||||
|
||||
Agenton documentation lives in [`docs/agenton/guide/index.md`](docs/agenton/guide/index.md) and
|
||||
[`docs/agenton/api/index.md`](docs/agenton/api/index.md).
|
||||
|
||||
Dify Agent runtime documentation lives in [`docs/dify-agent/index.md`](docs/dify-agent/index.md).
|
||||
Build all docs with `make docs` from this directory.
|
||||
16
dify-agent/docs/.hooks/main.py
Normal file
16
dify-agent/docs/.hooks/main.py
Normal file
@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.structure.files import Files
|
||||
from mkdocs.structure.pages import Page
|
||||
from snippets import inject_snippets
|
||||
|
||||
DOCS_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def on_page_markdown(markdown: str, page: Page, config: MkDocsConfig, files: Files) -> str:
|
||||
"""Inject repository snippets before MkDocs renders Markdown."""
|
||||
relative_path = DOCS_ROOT / page.file.src_uri
|
||||
return inject_snippets(markdown, relative_path.parent)
|
||||
228
dify-agent/docs/.hooks/snippets.py
Normal file
228
dify-agent/docs/.hooks/snippets.py
Normal file
@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SNIPPET_DIRECTIVE_PATTERN = re.compile(r"^```snippet\s+\{[^}]+\}\s*(?:```|\n```)$", re.MULTILINE)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SnippetDirective:
|
||||
path: str
|
||||
title: str | None = None
|
||||
fragment: str | None = None
|
||||
highlight: str | None = None
|
||||
extra_attrs: dict[str, str] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LineRange:
|
||||
start_line: int
|
||||
end_line: int
|
||||
|
||||
def intersection(self, ranges: list[LineRange]) -> list[LineRange]:
|
||||
intersections: list[LineRange] = []
|
||||
for line_range in ranges:
|
||||
start_line = max(self.start_line, line_range.start_line)
|
||||
end_line = min(self.end_line, line_range.end_line)
|
||||
if start_line < end_line:
|
||||
intersections.append(LineRange(start_line, end_line))
|
||||
return intersections
|
||||
|
||||
@staticmethod
|
||||
def merge(ranges: list[LineRange]) -> list[LineRange]:
|
||||
if not ranges:
|
||||
return []
|
||||
|
||||
merged: list[LineRange] = []
|
||||
for line_range in sorted(ranges, key=lambda item: item.start_line):
|
||||
if not merged or merged[-1].end_line < line_range.start_line:
|
||||
merged.append(line_range)
|
||||
else:
|
||||
previous = merged[-1]
|
||||
merged[-1] = LineRange(previous.start_line, max(previous.end_line, line_range.end_line))
|
||||
return merged
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RenderedSnippet:
|
||||
content: str
|
||||
highlights: list[LineRange]
|
||||
original_range: LineRange
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ParsedFile:
|
||||
lines: list[str]
|
||||
sections: dict[str, list[LineRange]]
|
||||
lines_mapping: dict[int, int]
|
||||
|
||||
def render(self, fragment_sections: list[str], highlight_sections: list[str]) -> RenderedSnippet:
|
||||
fragment_ranges: list[LineRange] = []
|
||||
if fragment_sections:
|
||||
for section_name in fragment_sections:
|
||||
fragment_ranges.extend(_section_ranges(self.sections, section_name))
|
||||
fragment_ranges = LineRange.merge(fragment_ranges)
|
||||
else:
|
||||
fragment_ranges = [LineRange(0, len(self.lines))]
|
||||
|
||||
highlight_ranges: list[LineRange] = []
|
||||
for section_name in highlight_sections:
|
||||
highlight_ranges.extend(_section_ranges(self.sections, section_name))
|
||||
highlight_ranges = LineRange.merge(highlight_ranges)
|
||||
|
||||
rendered_highlights: list[LineRange] = []
|
||||
rendered_lines: list[str] = []
|
||||
last_end_line = 0
|
||||
current_line = 0
|
||||
for fragment_range in fragment_ranges:
|
||||
if fragment_range.start_line > last_end_line:
|
||||
rendered_lines.append("..." if current_line == 0 else "\n...")
|
||||
current_line += 1
|
||||
|
||||
for highlight_range in fragment_range.intersection(highlight_ranges):
|
||||
rendered_highlights.append(
|
||||
LineRange(
|
||||
highlight_range.start_line - fragment_range.start_line + current_line,
|
||||
highlight_range.end_line - fragment_range.start_line + current_line,
|
||||
)
|
||||
)
|
||||
|
||||
for line_number in range(fragment_range.start_line, fragment_range.end_line):
|
||||
rendered_lines.append(self.lines[line_number])
|
||||
current_line += 1
|
||||
last_end_line = fragment_range.end_line
|
||||
|
||||
if last_end_line < len(self.lines):
|
||||
rendered_lines.append("\n...")
|
||||
|
||||
return RenderedSnippet(
|
||||
content="\n".join(rendered_lines),
|
||||
highlights=LineRange.merge(rendered_highlights),
|
||||
original_range=LineRange(
|
||||
self.lines_mapping[fragment_ranges[0].start_line],
|
||||
self.lines_mapping[fragment_ranges[-1].end_line - 1] + 1,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def parse_snippet_directive(line: str) -> SnippetDirective | None:
|
||||
match = re.fullmatch(r"```snippet\s+\{([^}]+)\}\s*(?:```|\n```)", line.strip())
|
||||
if not match:
|
||||
return None
|
||||
|
||||
attrs = {key: value for key, value in re.findall(r'(\w+)="([^"]*)"', match.group(1))}
|
||||
if "path" not in attrs:
|
||||
raise ValueError('Missing required key "path" in snippet directive')
|
||||
|
||||
extra_attrs = {key: value for key, value in attrs.items() if key not in {"path", "title", "fragment", "highlight"}}
|
||||
return SnippetDirective(
|
||||
path=attrs["path"],
|
||||
title=attrs.get("title"),
|
||||
fragment=attrs.get("fragment"),
|
||||
highlight=attrs.get("highlight"),
|
||||
extra_attrs=extra_attrs or None,
|
||||
)
|
||||
|
||||
|
||||
def parse_file_sections(file_path: Path) -> ParsedFile:
|
||||
input_lines = file_path.read_text(encoding="utf-8").splitlines()
|
||||
output_lines: list[str] = []
|
||||
lines_mapping: dict[int, int] = {}
|
||||
sections: dict[str, list[LineRange]] = {}
|
||||
section_starts: dict[str, int] = {}
|
||||
|
||||
output_line_number = 0
|
||||
for source_line_number, line in enumerate(input_lines):
|
||||
section_match = re.search(r"\s*(?:###|///)\s*\[([^]]+)]\s*$", line)
|
||||
if section_match is None:
|
||||
output_lines.append(line)
|
||||
lines_mapping[output_line_number] = source_line_number
|
||||
output_line_number += 1
|
||||
continue
|
||||
|
||||
line_before_marker = line[: section_match.start()]
|
||||
for section_name in section_match.group(1).split(","):
|
||||
section_name = section_name.strip()
|
||||
if section_name.startswith("/"):
|
||||
start_line = section_starts.pop(section_name[1:], None)
|
||||
if start_line is None:
|
||||
raise ValueError(f"Cannot end unstarted section {section_name!r} at {file_path}")
|
||||
end_line = output_line_number + 1 if line_before_marker else output_line_number
|
||||
sections.setdefault(section_name[1:], []).append(LineRange(start_line, end_line))
|
||||
else:
|
||||
if section_name in section_starts:
|
||||
raise ValueError(f"Cannot nest section {section_name!r} at {file_path}")
|
||||
section_starts[section_name] = output_line_number
|
||||
|
||||
if line_before_marker:
|
||||
output_lines.append(line_before_marker)
|
||||
lines_mapping[output_line_number] = source_line_number
|
||||
output_line_number += 1
|
||||
|
||||
if section_starts:
|
||||
raise ValueError(f"Some sections were not finished in {file_path}: {list(section_starts)}")
|
||||
|
||||
return ParsedFile(lines=output_lines, sections=sections, lines_mapping=lines_mapping)
|
||||
|
||||
|
||||
def format_highlight_lines(highlight_ranges: list[LineRange]) -> str:
|
||||
parts: list[str] = []
|
||||
for highlight_range in highlight_ranges:
|
||||
start_line = highlight_range.start_line + 1
|
||||
end_line = highlight_range.end_line
|
||||
parts.append(str(start_line) if start_line == end_line else f"{start_line}-{end_line}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def inject_snippets(markdown: str, relative_path_root: Path) -> str:
|
||||
def replace_snippet(match: re.Match[str]) -> str:
|
||||
directive = parse_snippet_directive(match.group(0))
|
||||
if directive is None:
|
||||
return match.group(0)
|
||||
|
||||
file_path = _resolve_snippet_path(directive.path, relative_path_root)
|
||||
parsed_file = parse_file_sections(file_path)
|
||||
rendered = parsed_file.render(
|
||||
directive.fragment.split() if directive.fragment else [],
|
||||
directive.highlight.split() if directive.highlight else [],
|
||||
)
|
||||
|
||||
attrs: list[str] = []
|
||||
title = directive.title or _default_title(file_path, rendered.original_range, bool(directive.fragment))
|
||||
if title:
|
||||
attrs.append(f'title="{title}"')
|
||||
if rendered.highlights:
|
||||
attrs.append(f'hl_lines="{format_highlight_lines(rendered.highlights)}"')
|
||||
if directive.extra_attrs:
|
||||
attrs.extend(f'{key}="{value}"' for key, value in directive.extra_attrs.items())
|
||||
|
||||
attrs_text = f" {{{' '.join(attrs)}}}" if attrs else ""
|
||||
file_extension = file_path.suffix.lstrip(".") or "text"
|
||||
return f"```{file_extension}{attrs_text}\n{rendered.content}\n```"
|
||||
|
||||
return SNIPPET_DIRECTIVE_PATTERN.sub(replace_snippet, markdown)
|
||||
|
||||
|
||||
def _section_ranges(sections: dict[str, list[LineRange]], section_name: str) -> list[LineRange]:
|
||||
if section_name not in sections:
|
||||
raise ValueError(f"Unrecognized snippet section {section_name!r}; expected one of {list(sections)}")
|
||||
return sections[section_name]
|
||||
|
||||
|
||||
def _resolve_snippet_path(path: str, relative_path_root: Path) -> Path:
|
||||
file_path = (REPO_ROOT / path[1:]).resolve() if path.startswith("/") else (relative_path_root / path).resolve()
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"Snippet file {file_path} not found")
|
||||
if not file_path.is_relative_to(REPO_ROOT):
|
||||
raise ValueError(f"Snippet file {file_path} must be inside {REPO_ROOT}")
|
||||
return file_path
|
||||
|
||||
|
||||
def _default_title(file_path: Path, original_range: LineRange, has_fragment: bool) -> str:
|
||||
relative_path = file_path.relative_to(REPO_ROOT)
|
||||
if not has_fragment:
|
||||
return str(relative_path)
|
||||
return f"{relative_path} (L{original_range.start_line + 1}-L{original_range.end_line})"
|
||||
19
dify-agent/docs/agenton/examples/index.md
Normal file
19
dify-agent/docs/agenton/examples/index.md
Normal file
@ -0,0 +1,19 @@
|
||||
# Agenton examples
|
||||
|
||||
The Agenton examples live under `examples/agenton/agenton_examples` and are kept
|
||||
importable as a package so documentation can reference real source files.
|
||||
|
||||
## Basics
|
||||
|
||||
```snippet {path="/examples/agenton/agenton_examples/basics.py"}
|
||||
```
|
||||
|
||||
## Pydantic AI bridge
|
||||
|
||||
```snippet {path="/examples/agenton/agenton_examples/pydantic_ai_bridge.py"}
|
||||
```
|
||||
|
||||
## Session snapshots
|
||||
|
||||
```snippet {path="/examples/agenton/agenton_examples/session_snapshot.py"}
|
||||
```
|
||||
187
dify-agent/docs/agenton/guide/index.md
Normal file
187
dify-agent/docs/agenton/guide/index.md
Normal file
@ -0,0 +1,187 @@
|
||||
# Agenton user guide
|
||||
|
||||
Agenton composes reusable graph plans from `LayerNode`s and `LayerProvider`s.
|
||||
The core is state-only: a `Compositor` stores no live layer instances, clients,
|
||||
cleanup stacks, or run state. Each `Compositor.enter(...)` call creates a fresh
|
||||
`CompositorRun` with new layer instances, direct dependency bindings, lifecycle
|
||||
state, and an optional hydrated session snapshot.
|
||||
|
||||
## Config and runtime state
|
||||
|
||||
- **Graph config** is serializable topology: node `name`, provider `type`,
|
||||
dependency mappings, and metadata. `LayerNodeConfig` deliberately contains no
|
||||
layer config.
|
||||
- **Per-run layer config** is passed to `Compositor.enter(configs=...)` as a
|
||||
mapping keyed by node name. Providers validate each value with the layer's
|
||||
`config_type` before any factory runs.
|
||||
- **Runtime state** is serializable per-layer invocation state on
|
||||
`layer.runtime_state`. Session snapshots persist only lifecycle state and this
|
||||
model's JSON-safe data.
|
||||
- **Live Python resources** such as clients, files, sockets, or process handles
|
||||
stay outside Agenton core. Own them in application code or integration-specific
|
||||
context managers that wrap compositor entry.
|
||||
|
||||
## Define a config-backed layer
|
||||
|
||||
Use a `LayerConfig` model for per-run config and inherit from a typed layer family
|
||||
so `Layer.__init_subclass__` can infer schemas:
|
||||
|
||||
```python {test="skip" lint="skip"}
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pydantic import ConfigDict
|
||||
from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers import LayerConfig, NoLayerDeps, PlainLayer
|
||||
|
||||
|
||||
class GreetingConfig(LayerConfig):
|
||||
prefix: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GreetingLayer(PlainLayer[NoLayerDeps, GreetingConfig]):
|
||||
type_id = "example.greeting"
|
||||
|
||||
prefix: str
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def from_config(cls, config: GreetingConfig) -> Self:
|
||||
return cls(prefix=config.prefix)
|
||||
|
||||
@property
|
||||
@override
|
||||
def prefix_prompts(self) -> list[str]:
|
||||
return [self.prefix]
|
||||
```
|
||||
|
||||
Omitted schema slots default to `EmptyLayerConfig` and `EmptyRuntimeState`.
|
||||
Lifecycle hooks are no-argument methods on the layer instance; use `self.deps`
|
||||
for dependencies and `self.runtime_state` for serializable mutable state.
|
||||
|
||||
## Live resources
|
||||
|
||||
Agenton does not own resource cleanup. Keep live resources in the surrounding
|
||||
application and pass them to capability methods explicitly:
|
||||
|
||||
```python {test="skip" lint="skip"}
|
||||
@dataclass(slots=True)
|
||||
class ClientUserLayer(PlainLayer[NoLayerDeps]):
|
||||
def make_client_user(self, *, http_client: httpx.AsyncClient) -> ClientUser:
|
||||
return ClientUser(http_client)
|
||||
|
||||
|
||||
compositor = Compositor([LayerNode("client_user", ClientUserLayer)])
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with compositor.enter() as run:
|
||||
layer = run.get_layer("client_user", ClientUserLayer)
|
||||
user = layer.make_client_user(http_client=http_client)
|
||||
```
|
||||
|
||||
This keeps deterministic cleanup at the integration boundary and leaves Agenton
|
||||
snapshots limited to serializable runtime state.
|
||||
|
||||
## Build a compositor
|
||||
|
||||
Use providers for config-backed layers and pass per-run config at entry time:
|
||||
|
||||
```python {test="skip" lint="skip"}
|
||||
from agenton.compositor import Compositor, CompositorConfig, LayerNodeConfig, LayerProvider
|
||||
from agenton_collections.layers.plain import PromptLayer, PromptLayerConfig
|
||||
|
||||
|
||||
providers = (
|
||||
LayerProvider.from_layer_type(PromptLayer),
|
||||
LayerProvider.from_layer_type(GreetingLayer),
|
||||
)
|
||||
compositor = Compositor.from_config(
|
||||
CompositorConfig(
|
||||
layers=[
|
||||
LayerNodeConfig(name="prompt", type="plain.prompt"),
|
||||
LayerNodeConfig(name="greeting", type="example.greeting"),
|
||||
]
|
||||
),
|
||||
providers=providers,
|
||||
)
|
||||
|
||||
async with compositor.enter(
|
||||
configs={
|
||||
"prompt": PromptLayerConfig(user="Answer with examples."),
|
||||
"greeting": GreetingConfig(prefix="Hi"),
|
||||
}
|
||||
) as run:
|
||||
prompts = run.prompts
|
||||
```
|
||||
|
||||
Use `LayerProvider.from_factory(...)` when construction needs Python objects or
|
||||
callables. Provider factories receive only validated config and must return a
|
||||
fresh layer instance for every invocation. For node-specific construction with
|
||||
`Compositor.from_config`, pass a `node_providers={"node_name": provider}` mapping
|
||||
to override the provider selected by type id for that node.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Layer dependencies bind direct layer instances onto `self.deps` for one run.
|
||||
Dependency mappings use dependency field names as keys and compositor node names
|
||||
as values:
|
||||
|
||||
```python {test="skip" lint="skip"}
|
||||
class ModelDeps(LayerDeps):
|
||||
plugin: PluginLayer
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ModelLayer(PlainLayer[ModelDeps]):
|
||||
def make_model(self) -> Model:
|
||||
return self.deps.plugin.make_provider()
|
||||
```
|
||||
|
||||
Optional dependencies are assigned `None` when absent. Missing required
|
||||
dependencies, unknown dependency keys, and dependency targets with the wrong layer
|
||||
type fail before lifecycle hooks run.
|
||||
|
||||
## System prompts, user prompts, and tools
|
||||
|
||||
Layers expose four authoring surfaces:
|
||||
|
||||
- `prefix_prompts`: system prompt fragments collected in layer order.
|
||||
- `suffix_prompts`: system prompt fragments collected in reverse layer order.
|
||||
- `user_prompts`: user-message fragments collected in layer order.
|
||||
- `tools`: tool entries collected in layer order.
|
||||
|
||||
`PromptLayer` accepts `prefix`, `user`, and `suffix` config fields. Aggregation is
|
||||
available on the active `CompositorRun` as `run.prompts`, `run.user_prompts`, and
|
||||
`run.tools`. For pydantic-ai, import
|
||||
`agenton_collections.transformers.pydantic_ai.PYDANTIC_AI_TRANSFORMERS` and pass
|
||||
it to `Compositor(...)` or `Compositor.from_config(...)` so tagged layer items are
|
||||
converted to Pydantic AI prompt, user prompt, and tool values.
|
||||
|
||||
## Session snapshot and restore
|
||||
|
||||
Core Agenton run slots default to delete-on-exit. Call `run.suspend_on_exit()` or
|
||||
`run.suspend_layer_on_exit(name)` inside the active context when the next snapshot
|
||||
should be resumable:
|
||||
|
||||
```python {test="skip" lint="skip"}
|
||||
async with compositor.enter(configs=configs) as run:
|
||||
run.suspend_on_exit()
|
||||
|
||||
snapshot = run.session_snapshot
|
||||
async with compositor.enter(configs=configs, session_snapshot=snapshot) as restored_run:
|
||||
restored_layer = restored_run.get_layer("stateful", StatefulLayer)
|
||||
```
|
||||
|
||||
`run.session_snapshot` is populated after context exit. Snapshots include ordered
|
||||
layer names, non-active lifecycle states, and JSON-safe runtime state only. Active
|
||||
state is rejected at the DTO boundary, and closed layers cannot be entered again.
|
||||
To resume, pass the snapshot to a later `Compositor.enter(...)` call with the same
|
||||
layer names and order.
|
||||
|
||||
See also:
|
||||
|
||||
- `examples/agenton/agenton_examples/basics.py`
|
||||
- `examples/agenton/agenton_examples/pydantic_ai_bridge.py`
|
||||
- `examples/agenton/agenton_examples/session_snapshot.py`
|
||||
6
dify-agent/docs/agenton/index.md
Normal file
6
dify-agent/docs/agenton/index.md
Normal file
@ -0,0 +1,6 @@
|
||||
# Agenton documentation
|
||||
|
||||
- [User guide](guide/index.md) explains how to compose layers, register config-backed
|
||||
plugins, use system/user prompts, and snapshot sessions.
|
||||
- [API reference](api/index.md) lists the public Agenton classes, methods, and extension
|
||||
points.
|
||||
25
dify-agent/docs/dify-agent/examples/index.md
Normal file
25
dify-agent/docs/dify-agent/examples/index.md
Normal file
@ -0,0 +1,25 @@
|
||||
# Dify Agent examples
|
||||
|
||||
These examples live under `examples/dify_agent/dify_agent_examples`. They are
|
||||
separated from Agenton examples because they depend on Dify Agent runtime services
|
||||
such as the FastAPI server, Redis, or the plugin daemon.
|
||||
|
||||
## Run a Dify plugin-daemon backed model
|
||||
|
||||
```snippet {path="/examples/dify_agent/dify_agent_examples/run_pydantic_ai_agent.py"}
|
||||
```
|
||||
|
||||
## Poll run events
|
||||
|
||||
```snippet {path="/examples/dify_agent/dify_agent_examples/run_server_consumer.py"}
|
||||
```
|
||||
|
||||
## Use the synchronous client
|
||||
|
||||
```snippet {path="/examples/dify_agent/dify_agent_examples/run_server_sync_client.py"}
|
||||
```
|
||||
|
||||
## Stream run events with SSE
|
||||
|
||||
```snippet {path="/examples/dify_agent/dify_agent_examples/run_server_sse_consumer.py"}
|
||||
```
|
||||
140
dify-agent/docs/dify-agent/guide/index.md
Normal file
140
dify-agent/docs/dify-agent/guide/index.md
Normal file
@ -0,0 +1,140 @@
|
||||
# Operating the Dify Agent Run Server
|
||||
|
||||
This guide describes how to run the MVP Dify Agent API server. The server is
|
||||
implemented in `dify-agent/src/dify_agent/server/app.py` and uses Redis for run
|
||||
records and per-run event streams only.
|
||||
|
||||
## Default local startup
|
||||
|
||||
Start Redis, then run one FastAPI/uvicorn process:
|
||||
|
||||
```bash
|
||||
uv run --project dify-agent uvicorn dify_agent.server.app:app --reload
|
||||
```
|
||||
|
||||
By default, the FastAPI lifespan creates:
|
||||
|
||||
- one Redis-backed run store used by HTTP routes
|
||||
- one shared plugin-daemon `httpx.AsyncClient` used by local run tasks
|
||||
- one process-local scheduler that starts background `asyncio` run tasks
|
||||
|
||||
This means local development needs one uvicorn process plus Redis, and
|
||||
plugin-backed runs also need a reachable Dify plugin daemon. Run execution still
|
||||
happens outside request handlers, so client disconnects do not cancel the agent
|
||||
run.
|
||||
|
||||
## Configuration
|
||||
|
||||
`ServerSettings` loads environment variables with the `DIFY_AGENT_` prefix. It
|
||||
also reads `.env` and `dify-agent/.env` when present.
|
||||
|
||||
| Environment variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `DIFY_AGENT_REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL. |
|
||||
| `DIFY_AGENT_REDIS_PREFIX` | `dify-agent` | Prefix for Redis record and event keys. |
|
||||
| `DIFY_AGENT_SHUTDOWN_GRACE_SECONDS` | `30` | Seconds to wait for active local runs during graceful shutdown before cancellation. |
|
||||
| `DIFY_AGENT_RUN_RETENTION_SECONDS` | `259200` | Seconds to retain Redis run records and per-run event streams; defaults to 3 days. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_URL` | `http://localhost:5002` | Base URL for the Dify plugin daemon. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_API_KEY` | empty | API key sent to the Dify plugin daemon. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_CONNECT_TIMEOUT` | `10` | Plugin-daemon HTTP connect timeout in seconds. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_READ_TIMEOUT` | `600` | Plugin-daemon HTTP read timeout in seconds. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_WRITE_TIMEOUT` | `30` | Plugin-daemon HTTP write timeout in seconds. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_POOL_TIMEOUT` | `10` | Plugin-daemon HTTP connection-pool wait timeout in seconds. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_MAX_CONNECTIONS` | `100` | Maximum total plugin-daemon HTTP connections. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_MAX_KEEPALIVE_CONNECTIONS` | `20` | Maximum idle keep-alive plugin-daemon HTTP connections. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_KEEPALIVE_EXPIRY` | `30` | Keep-alive expiry in seconds for idle plugin-daemon HTTP connections. |
|
||||
|
||||
Example `.env`:
|
||||
|
||||
```env
|
||||
DIFY_AGENT_REDIS_URL=redis://localhost:6379/0
|
||||
DIFY_AGENT_REDIS_PREFIX=dify-agent-dev
|
||||
DIFY_AGENT_SHUTDOWN_GRACE_SECONDS=30
|
||||
DIFY_AGENT_RUN_RETENTION_SECONDS=259200
|
||||
DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002
|
||||
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=replace-with-daemon-key
|
||||
```
|
||||
|
||||
Run records and event streams use the same retention. Status writes refresh the
|
||||
record TTL, and event writes refresh both the stream TTL and the corresponding
|
||||
record TTL so active runs that keep producing events remain observable.
|
||||
|
||||
## Scheduling and shutdown semantics
|
||||
|
||||
`POST /runs` validates the composition, persists a `running` run record, and starts
|
||||
an `asyncio` task in the same process. There is no Redis job stream, consumer
|
||||
group, pending reclaim, or automatic retry layer.
|
||||
|
||||
During FastAPI shutdown the scheduler rejects new runs, waits up to
|
||||
`DIFY_AGENT_SHUTDOWN_GRACE_SECONDS` for active tasks, then cancels remaining tasks
|
||||
and best-effort appends a `run_failed` event plus failed status. A hard process
|
||||
crash can still leave active runs stuck as `running`; there is no in-service
|
||||
recovery or worker handoff.
|
||||
|
||||
Horizontal scaling is possible by running multiple API processes against the same
|
||||
Redis prefix, but each process executes only the runs it accepted. Redis provides
|
||||
shared status/event visibility, not load balancing or queued-job recovery.
|
||||
|
||||
## Run inputs and session snapshots
|
||||
|
||||
The API does not accept a top-level `user_prompt`. Submit a `RunComposition`
|
||||
whose Agenton layers provide user input. With the MVP provider set, use
|
||||
`plain.prompt` and its `config.user` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"composition": {
|
||||
"schema_version": 1,
|
||||
"layers": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "plain.prompt",
|
||||
"config": {
|
||||
"prefix": "You are concise.",
|
||||
"user": "Summarize the current state."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`config.user` can be a string or a list of strings. Empty or whitespace-only
|
||||
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 `run_succeeded.data` payload together with a
|
||||
composition that has the same layer names and order.
|
||||
|
||||
## Observing runs
|
||||
|
||||
Use the HTTP status endpoint for coarse state and the event endpoints for detailed
|
||||
progress:
|
||||
|
||||
- `POST /runs` creates a running run and schedules it locally.
|
||||
- `GET /runs/{run_id}` returns `running`, `succeeded`, or `failed`.
|
||||
- `GET /runs/{run_id}/events` polls the Redis Stream event log with `after` and
|
||||
`next_cursor` cursors.
|
||||
- `GET /runs/{run_id}/events/sse` replays and streams events over SSE. The SSE
|
||||
`id` is the event Redis Stream ID. `after` query cursors take precedence over
|
||||
`Last-Event-ID` headers.
|
||||
|
||||
Successful runs emit `run_started`, zero or more `pydantic_ai_event`, and
|
||||
`run_succeeded`. Failed runs end with `run_failed`. Event envelopes retain `id`,
|
||||
`run_id`, `type`, `data`, and `created_at`; `data` is typed per event type,
|
||||
including Pydantic AI's `AgentStreamEvent` payload for `pydantic_ai_event` and a
|
||||
terminal `run_succeeded.data` object containing JSON-safe `output` plus a
|
||||
`CompositorSessionSnapshot` for resumption.
|
||||
|
||||
## Examples
|
||||
|
||||
The repository includes simple consumers that print observed output/events:
|
||||
|
||||
- `dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py`
|
||||
creates a run and polls events.
|
||||
- `dify-agent/examples/dify_agent/dify_agent_examples/run_server_sse_consumer.py`
|
||||
consumes raw SSE frames for an existing run id.
|
||||
|
||||
The create-run examples submit Dify plugin model layers, so they require Redis,
|
||||
the API server, plugin-daemon settings, and provider credentials.
|
||||
8
dify-agent/docs/dify-agent/index.md
Normal file
8
dify-agent/docs/dify-agent/index.md
Normal file
@ -0,0 +1,8 @@
|
||||
# Dify Agent runtime
|
||||
|
||||
Dify Agent hosts Agenton-composed Pydantic AI runs behind a FastAPI API. Its
|
||||
source code stays under `src/dify_agent`, while framework-neutral Agenton code
|
||||
stays under `src/agenton` and `src/agenton_collections`.
|
||||
|
||||
See the [operations guide](guide/index.md) for local server behavior and the
|
||||
[run API](api/index.md) for request and event schemas.
|
||||
11
dify-agent/docs/index.md
Normal file
11
dify-agent/docs/index.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Dify Agent
|
||||
|
||||
This documentation is split by ownership boundary:
|
||||
|
||||
- [Agenton](agenton/index.md) covers the framework-neutral layer compositor and reusable
|
||||
collection layers.
|
||||
- [Dify Agent](dify-agent/index.md) covers the Dify runtime, HTTP API, Redis-backed run
|
||||
storage, and server examples.
|
||||
|
||||
The split mirrors the source tree so Agenton documentation and examples can be
|
||||
moved together if Agenton is published separately later.
|
||||
1
dify-agent/examples/agenton/agenton_examples/__init__.py
Normal file
1
dify-agent/examples/agenton/agenton_examples/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Runnable Agenton examples kept separate from Dify Agent runtime examples."""
|
||||
50
dify-agent/examples/agenton/agenton_examples/__main__.py
Normal file
50
dify-agent/examples/agenton/agenton_examples/__main__.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""Small CLI for listing or copying Agenton examples."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
EXAMPLE_MODULES = (
|
||||
"basics",
|
||||
"pydantic_ai_bridge",
|
||||
"session_snapshot",
|
||||
)
|
||||
|
||||
|
||||
def cli() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="agenton_examples",
|
||||
description="List or copy Agenton examples.",
|
||||
)
|
||||
parser.add_argument("--copy-to", metavar="DEST", help="Copy example files to a new directory")
|
||||
args = parser.parse_args()
|
||||
|
||||
examples_dir = Path(__file__).parent
|
||||
if args.copy_to:
|
||||
copy_to(examples_dir, Path(args.copy_to))
|
||||
return
|
||||
|
||||
for module_name in EXAMPLE_MODULES:
|
||||
print(f"python -m agenton_examples.{module_name}")
|
||||
|
||||
|
||||
def copy_to(examples_dir: Path, destination: Path) -> None:
|
||||
if destination.exists():
|
||||
print(f'Error: destination path "{destination}" already exists', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
destination.mkdir(parents=True)
|
||||
copied = 0
|
||||
for source in examples_dir.glob("*.py"):
|
||||
if source.name == "__init__.py":
|
||||
continue
|
||||
shutil.copy2(source, destination / source.name)
|
||||
copied += 1
|
||||
print(f'Copied {copied} Agenton example files to "{destination}"')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
142
dify-agent/examples/agenton/agenton_examples/basics.py
Normal file
142
dify-agent/examples/agenton/agenton_examples/basics.py
Normal file
@ -0,0 +1,142 @@
|
||||
"""Run with: uv run --project dify-agent python -m agenton_examples.basics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from inspect import signature
|
||||
from typing import cast
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from agenton.compositor import Compositor, LayerNode, LayerProvider
|
||||
from agenton.layers import LayerDeps, NoLayerDeps, PlainLayer, PlainToolType
|
||||
from agenton_collections.layers.plain import DynamicToolsLayer, ObjectLayer, PromptLayer, ToolsLayer, with_object
|
||||
from agenton_collections.layers.plain.basic import PromptLayerConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentProfile:
|
||||
name: str
|
||||
audience: str
|
||||
tone: str
|
||||
|
||||
|
||||
class ProfilePromptDeps(LayerDeps):
|
||||
profile: ObjectLayer[AgentProfile] # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ProfilePromptLayer(PlainLayer[ProfilePromptDeps]):
|
||||
@property
|
||||
@override
|
||||
def prefix_prompts(self) -> list[str]:
|
||||
profile = self.deps.profile.value
|
||||
return [
|
||||
f"You are {profile.name}, writing for {profile.audience}.",
|
||||
f"Keep the tone {profile.tone}.",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TraceLayer(PlainLayer[NoLayerDeps]):
|
||||
events: list[str] = field(default_factory=list)
|
||||
|
||||
@override
|
||||
async def on_context_create(self) -> None:
|
||||
self.events.append("create")
|
||||
|
||||
@override
|
||||
async def on_context_suspend(self) -> None:
|
||||
self.events.append("suspend")
|
||||
|
||||
@override
|
||||
async def on_context_resume(self) -> None:
|
||||
self.events.append("resume")
|
||||
|
||||
@override
|
||||
async def on_context_delete(self) -> None:
|
||||
self.events.append("delete")
|
||||
|
||||
|
||||
def count_words(text: str) -> int:
|
||||
return len(text.split())
|
||||
|
||||
|
||||
@with_object(AgentProfile)
|
||||
def write_tagline(profile: AgentProfile, topic: str) -> str:
|
||||
return f"{profile.name}: {topic} for {profile.audience}, in a {profile.tone} voice."
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
profile = AgentProfile(
|
||||
name="Agenton Assistant",
|
||||
audience="engineers composing agent capabilities",
|
||||
tone="precise and friendly",
|
||||
)
|
||||
trace_events: list[str] = []
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("base_prompt", PromptLayer),
|
||||
LayerNode("extra_prompt", PromptLayer),
|
||||
LayerNode(
|
||||
"profile",
|
||||
LayerProvider.from_factory(
|
||||
layer_type=ObjectLayer,
|
||||
create=lambda _config: ObjectLayer[AgentProfile](profile),
|
||||
),
|
||||
),
|
||||
LayerNode("profile_prompt", ProfilePromptLayer, deps={"profile": "profile"}),
|
||||
LayerNode(
|
||||
"tools",
|
||||
LayerProvider.from_factory(
|
||||
layer_type=ToolsLayer,
|
||||
create=lambda _config: ToolsLayer(tool_entries=(count_words,)),
|
||||
),
|
||||
),
|
||||
LayerNode(
|
||||
"dynamic_tools",
|
||||
LayerProvider.from_factory(
|
||||
layer_type=DynamicToolsLayer,
|
||||
create=lambda _config: DynamicToolsLayer[AgentProfile](tool_entries=(write_tagline,)),
|
||||
),
|
||||
deps={"object_layer": "profile"},
|
||||
),
|
||||
LayerNode(
|
||||
"trace",
|
||||
LayerProvider.from_factory(layer_type=TraceLayer, create=lambda _config: TraceLayer(trace_events)),
|
||||
),
|
||||
]
|
||||
)
|
||||
configs = {
|
||||
"base_prompt": PromptLayerConfig(
|
||||
prefix="Use config dicts for serializable layers.",
|
||||
user="Explain how the composed agent should use its layers.",
|
||||
suffix="Before finalizing, make the result easy to scan.",
|
||||
),
|
||||
"extra_prompt": PromptLayerConfig(prefix="Use constructed instances for objects, local code, and callables."),
|
||||
}
|
||||
|
||||
async with compositor.enter(configs=configs) as run:
|
||||
print("Prompts:")
|
||||
for prompt in run.prompts:
|
||||
print(f"- {prompt.value}")
|
||||
|
||||
print("\nUser prompts:")
|
||||
for prompt in run.user_prompts:
|
||||
print(f"- {prompt.value}")
|
||||
|
||||
print("\nTools:")
|
||||
plain_tools = [cast(PlainToolType, tool) for tool in run.tools]
|
||||
for tool in plain_tools:
|
||||
print(f"- {tool.value.__name__}{signature(tool.value)}")
|
||||
print([tool.value("layer composition") for tool in plain_tools])
|
||||
run.suspend_on_exit()
|
||||
|
||||
async with compositor.enter(configs=configs, session_snapshot=run.session_snapshot):
|
||||
pass
|
||||
print("\nLifecycle:", trace_events)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@ -0,0 +1,133 @@
|
||||
"""Pydantic AI bridge example for use from a source checkout.
|
||||
|
||||
`agenton_examples` is not part of the published package, so run this module from
|
||||
the repository root with:
|
||||
|
||||
PYTHONPATH=dify-agent/src:dify-agent/examples/agenton \
|
||||
uv run --project dify-agent python -m agenton_examples.pydantic_ai_bridge
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pydantic_ai import Agent, RunContext
|
||||
from pydantic_ai.messages import BuiltinToolCallPart, ModelMessage, ToolCallPart
|
||||
from pydantic_ai.models.openai import OpenAIChatModel # pyright: ignore[reportDeprecated]
|
||||
from pydantic_ai.models.test import TestModel
|
||||
|
||||
from agenton.compositor import Compositor, LayerNode, LayerProvider
|
||||
from agenton_collections.layers.plain import ObjectLayer, PromptLayer, PromptLayerConfig, ToolsLayer
|
||||
from agenton_collections.layers.pydantic_ai import PydanticAIBridgeLayer
|
||||
from agenton_collections.transformers.pydantic_ai import PYDANTIC_AI_TRANSFORMERS
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentProfile:
|
||||
name: str
|
||||
audience: str
|
||||
tone: str
|
||||
|
||||
|
||||
def count_words(text: str) -> int:
|
||||
return len(text.split())
|
||||
|
||||
|
||||
def profile_prompt(ctx: RunContext[AgentProfile]) -> str:
|
||||
profile = ctx.deps
|
||||
return f"You are {profile.name}, helping {profile.audience}."
|
||||
|
||||
|
||||
def tone_prompt(ctx: RunContext[AgentProfile]) -> str:
|
||||
return f"Keep responses {ctx.deps.tone}."
|
||||
|
||||
|
||||
def write_tagline(ctx: RunContext[AgentProfile], topic: str) -> str:
|
||||
profile = ctx.deps
|
||||
return f"{profile.name}: {topic} for {profile.audience}, in a {profile.tone} voice."
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
profile = AgentProfile(
|
||||
name="Agenton Assistant",
|
||||
audience="engineers composing agent capabilities",
|
||||
tone="precise and friendly",
|
||||
)
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("base_prompt", PromptLayer),
|
||||
LayerNode(
|
||||
"profile",
|
||||
LayerProvider.from_factory(
|
||||
layer_type=ObjectLayer,
|
||||
create=lambda _config: ObjectLayer[AgentProfile](profile),
|
||||
),
|
||||
),
|
||||
LayerNode(
|
||||
"plain_tools",
|
||||
LayerProvider.from_factory(
|
||||
layer_type=ToolsLayer,
|
||||
create=lambda _config: ToolsLayer(tool_entries=(count_words,)),
|
||||
),
|
||||
),
|
||||
LayerNode(
|
||||
"pydantic_ai_bridge",
|
||||
LayerProvider.from_factory(
|
||||
layer_type=PydanticAIBridgeLayer,
|
||||
create=lambda _config: PydanticAIBridgeLayer[AgentProfile](
|
||||
prefix=("Prefer concrete details.", profile_prompt, tone_prompt),
|
||||
user="Use the tools for 'layer composition'.",
|
||||
tool_entries=(write_tagline,),
|
||||
),
|
||||
),
|
||||
deps={"object_layer": "profile"},
|
||||
),
|
||||
],
|
||||
**PYDANTIC_AI_TRANSFORMERS,
|
||||
)
|
||||
|
||||
async with compositor.enter(
|
||||
configs={
|
||||
"base_prompt": PromptLayerConfig(
|
||||
prefix="Use the available tools before answering.",
|
||||
suffix="Return concise, inspectable output.",
|
||||
)
|
||||
}
|
||||
) as run:
|
||||
model = (
|
||||
OpenAIChatModel("gpt-5.5") # pyright: ignore[reportDeprecated]
|
||||
if os.getenv("OPENAI_API_KEY")
|
||||
else TestModel()
|
||||
)
|
||||
agent = Agent[AgentProfile](
|
||||
model=model,
|
||||
deps_type=AgentProfile,
|
||||
tools=run.tools,
|
||||
)
|
||||
for prompt in run.prompts:
|
||||
_ = agent.system_prompt(prompt)
|
||||
|
||||
bridge_layer = run.get_layer("pydantic_ai_bridge", PydanticAIBridgeLayer)
|
||||
result = await agent.run(run.user_prompts, deps=bridge_layer.run_deps)
|
||||
|
||||
for line in _format_messages(result.all_messages()):
|
||||
print(line)
|
||||
|
||||
|
||||
def _format_messages(messages: list[ModelMessage]) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for message in messages:
|
||||
for part in message.parts:
|
||||
if isinstance(part, ToolCallPart | BuiltinToolCallPart):
|
||||
args = json.dumps(part.args, ensure_ascii=False)
|
||||
lines.append(f"{type(part).__name__}: {part.tool_name}({args})")
|
||||
else:
|
||||
lines.append(f"{type(part).__name__}: {part.content}")
|
||||
return lines
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@ -0,0 +1,50 @@
|
||||
"""Run with: uv run --project dify-agent python -m agenton_examples.session_snapshot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from agenton.compositor import Compositor, LayerNode
|
||||
from agenton.layers import EmptyLayerConfig, NoLayerDeps, PlainLayer
|
||||
|
||||
|
||||
class ConnectionState(BaseModel):
|
||||
connection_id: str = "demo-connection"
|
||||
|
||||
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
||||
|
||||
|
||||
class ConnectionHandle:
|
||||
def __init__(self, connection_id: str) -> None:
|
||||
self.connection_id = connection_id
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ConnectionLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, ConnectionState]):
|
||||
runtime_state_type: ClassVar[type[BaseModel]] = ConnectionState
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
compositor = Compositor([LayerNode("connection", ConnectionLayer)])
|
||||
async with compositor.enter() as run:
|
||||
layer = run.get_layer("connection", ConnectionLayer)
|
||||
connection = ConnectionHandle(layer.runtime_state.connection_id)
|
||||
print("Active external handle:", connection.connection_id)
|
||||
run.suspend_on_exit()
|
||||
|
||||
snapshot = run.session_snapshot
|
||||
assert snapshot is not None
|
||||
print("Snapshot:", snapshot.model_dump(mode="json"))
|
||||
|
||||
async with compositor.enter(session_snapshot=snapshot) as restored_run:
|
||||
layer = restored_run.get_layer("connection", ConnectionLayer)
|
||||
restored_connection = ConnectionHandle(f"restored:{layer.runtime_state.connection_id}")
|
||||
print("Rehydrated external handle:", restored_connection.connection_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@ -0,0 +1 @@
|
||||
"""Runnable Dify Agent runtime examples kept separate from Agenton examples."""
|
||||
@ -0,0 +1,51 @@
|
||||
"""Small CLI for listing or copying Dify Agent examples."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
EXAMPLE_MODULES = (
|
||||
"run_pydantic_ai_agent",
|
||||
"run_server_consumer",
|
||||
"run_server_sse_consumer",
|
||||
"run_server_sync_client",
|
||||
)
|
||||
|
||||
|
||||
def cli() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="dify_agent_examples",
|
||||
description="List or copy Dify Agent runtime examples.",
|
||||
)
|
||||
parser.add_argument("--copy-to", metavar="DEST", help="Copy example files to a new directory")
|
||||
args = parser.parse_args()
|
||||
|
||||
examples_dir = Path(__file__).parent
|
||||
if args.copy_to:
|
||||
copy_to(examples_dir, Path(args.copy_to))
|
||||
return
|
||||
|
||||
for module_name in EXAMPLE_MODULES:
|
||||
print(f"python -m dify_agent_examples.{module_name}")
|
||||
|
||||
|
||||
def copy_to(examples_dir: Path, destination: Path) -> None:
|
||||
if destination.exists():
|
||||
print(f'Error: destination path "{destination}" already exists', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
destination.mkdir(parents=True)
|
||||
copied = 0
|
||||
for source in examples_dir.glob("*.py"):
|
||||
if source.name == "__init__.py":
|
||||
continue
|
||||
shutil.copy2(source, destination / source.name)
|
||||
copied += 1
|
||||
print(f'Copied {copied} Dify Agent example files to "{destination}"')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@ -0,0 +1,83 @@
|
||||
"""Run a Pydantic AI agent through the Dify plugin-daemon adapter.
|
||||
|
||||
Prerequisites:
|
||||
- Sync the server runtime dependencies first: `uv sync --project dify-agent --extra server`.
|
||||
- Start the plugin daemon from `dify-aio/dify/docker/docker-compose.middleware.yaml`.
|
||||
- Run the Dify API with `dify-aio/dify/api/.env` so the daemon can resolve tenants/plugins.
|
||||
- Fill `dify-agent/.env` with a real tenant, plugin, provider, model, and provider credentials.
|
||||
|
||||
This example is meant to be run from a source checkout because
|
||||
`dify_agent_examples` is not part of the published package.
|
||||
|
||||
Example from the repository root:
|
||||
PYTHONPATH=dify-agent/src:dify-agent/examples/dify_agent \
|
||||
uv run --project dify-agent python -m dify_agent_examples.run_pydantic_ai_agent
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic_ai import Agent
|
||||
|
||||
from dify_agent.adapters.llm import DifyLLMAdapterModel, DifyPluginDaemonProvider
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def load_env_file(path: Path) -> None:
|
||||
"""Load simple KEY=VALUE lines without adding a dotenv dependency."""
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
||||
|
||||
|
||||
def required_env(name: str) -> str:
|
||||
value = os.environ.get(name)
|
||||
if value:
|
||||
return value
|
||||
raise RuntimeError(f"Missing required environment variable: {name}")
|
||||
|
||||
|
||||
def load_credentials() -> dict[str, Any]:
|
||||
raw_credentials = required_env("DIFY_AGENT_MODEL_CREDENTIALS_JSON")
|
||||
credentials = json.loads(raw_credentials)
|
||||
if not isinstance(credentials, dict):
|
||||
raise RuntimeError("DIFY_AGENT_MODEL_CREDENTIALS_JSON must be a JSON object")
|
||||
return credentials
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
load_env_file(PROJECT_ROOT / ".env")
|
||||
|
||||
model = DifyLLMAdapterModel(
|
||||
required_env("DIFY_AGENT_MODEL_NAME"),
|
||||
DifyPluginDaemonProvider(
|
||||
tenant_id=required_env("DIFY_AGENT_TENANT_ID"),
|
||||
plugin_id=required_env("DIFY_AGENT_PLUGIN_ID"),
|
||||
plugin_daemon_url=required_env("PLUGIN_DAEMON_URL"),
|
||||
plugin_daemon_api_key=required_env("PLUGIN_DAEMON_KEY"),
|
||||
),
|
||||
model_provider=required_env("DIFY_AGENT_PROVIDER"),
|
||||
credentials=load_credentials(),
|
||||
)
|
||||
agent = Agent(model=model)
|
||||
async with agent.run_stream("Explain the theory of relativity") as run:
|
||||
async for piece in run.stream_output():
|
||||
print(piece, end="", flush=True)
|
||||
print(run.usage())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@ -0,0 +1,85 @@
|
||||
"""Async Python client example for the Dify Agent run server.
|
||||
|
||||
Requires Redis and a running API server. Before starting the server, sync the
|
||||
server runtime dependencies with `uv sync --project dify-agent --extra server`
|
||||
or install `dify-agent[server]`. The server schedules runs in-process, for
|
||||
example:
|
||||
|
||||
uv run --project dify-agent uvicorn dify_agent.server.app:app --reload
|
||||
|
||||
The request carries Dify plugin model configuration in Agenton layers. This
|
||||
script prints the created run and every event observed through cursor polling.
|
||||
``Client.create_run`` performs one POST attempt only; use polling or SSE replay to
|
||||
recover after client-side uncertainty.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
|
||||
from dify_agent.client import Client
|
||||
from dify_agent.layers.dify_plugin import (
|
||||
DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
DifyPluginCredentialValue,
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginLayerConfig,
|
||||
)
|
||||
from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, RunComposition, RunLayerSpec
|
||||
|
||||
|
||||
API_BASE_URL = "http://localhost:8000"
|
||||
TENANT_ID = "replace-with-tenant-id"
|
||||
PLUGIN_ID = "langgenius/openai"
|
||||
PLUGIN_PROVIDER = "openai"
|
||||
MODEL_NAME = "gpt-4o-mini"
|
||||
MODEL_CREDENTIALS: dict[str, DifyPluginCredentialValue] = {"api_key": "replace-with-provider-key"}
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with Client(base_url=API_BASE_URL) as client:
|
||||
run = await client.create_run(
|
||||
CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(
|
||||
name="prompt",
|
||||
type=PLAIN_PROMPT_LAYER_TYPE_ID,
|
||||
config=PromptLayerConfig(
|
||||
prefix="You are a concise assistant.",
|
||||
user="Say hello from the Dify Agent API server example.",
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="plugin",
|
||||
type=DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
config=DifyPluginLayerConfig(tenant_id=TENANT_ID, plugin_id=PLUGIN_ID),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
deps={"plugin": "plugin"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
model_provider=PLUGIN_PROVIDER,
|
||||
model=MODEL_NAME,
|
||||
credentials=MODEL_CREDENTIALS,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
)
|
||||
print("created run", run)
|
||||
|
||||
cursor = "0-0"
|
||||
while True:
|
||||
page = await client.get_events(run.run_id, after=cursor)
|
||||
cursor = page.next_cursor or cursor
|
||||
for event in page.events:
|
||||
print("event", event)
|
||||
if event.type in {"run_succeeded", "run_failed"}:
|
||||
return
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@ -0,0 +1,25 @@
|
||||
"""Async SSE client example for the Dify Agent run server.
|
||||
|
||||
Create a run with ``run_server_consumer.py`` or any HTTP client, then set RUN_ID
|
||||
below and run this script while the server is available. The Python client parses
|
||||
SSE frames into typed protocol events and reconnects with the latest event id by
|
||||
default. Malformed frames and HTTP 4xx responses fail without reconnecting.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from dify_agent.client import Client
|
||||
|
||||
|
||||
API_BASE_URL = "http://localhost:8000"
|
||||
RUN_ID = "replace-with-run-id"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with Client(base_url=API_BASE_URL, stream_timeout=None) as client:
|
||||
async for event in client.stream_events(RUN_ID):
|
||||
print(event)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@ -0,0 +1,70 @@
|
||||
"""Synchronous Python client example for the Dify Agent run server.
|
||||
|
||||
Requires the same running FastAPI server as the async examples. Before starting
|
||||
that server, sync the server runtime dependencies with
|
||||
`uv sync --project dify-agent --extra server` or install
|
||||
`dify-agent[server]`. ``create_run_sync`` does not retry ``POST /runs``; if a
|
||||
timeout occurs, inspect server state or create a new run explicitly rather than
|
||||
assuming the original request was not accepted.
|
||||
"""
|
||||
|
||||
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
|
||||
from dify_agent.client import Client
|
||||
from dify_agent.layers.dify_plugin import (
|
||||
DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
DifyPluginCredentialValue,
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginLayerConfig,
|
||||
)
|
||||
from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, RunComposition, RunLayerSpec
|
||||
|
||||
|
||||
API_BASE_URL = "http://localhost:8000"
|
||||
TENANT_ID = "replace-with-tenant-id"
|
||||
PLUGIN_ID = "langgenius/openai"
|
||||
PLUGIN_PROVIDER = "openai"
|
||||
MODEL_NAME = "gpt-4o-mini"
|
||||
MODEL_CREDENTIALS: dict[str, DifyPluginCredentialValue] = {"api_key": "replace-with-provider-key"}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with Client(base_url=API_BASE_URL) as client:
|
||||
run = client.create_run_sync(
|
||||
CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(
|
||||
name="prompt",
|
||||
type=PLAIN_PROMPT_LAYER_TYPE_ID,
|
||||
config=PromptLayerConfig(
|
||||
prefix="You are a concise assistant.",
|
||||
user="Say hello from the synchronous Dify Agent client example.",
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="plugin",
|
||||
type=DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
config=DifyPluginLayerConfig(tenant_id=TENANT_ID, plugin_id=PLUGIN_ID),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
deps={"plugin": "plugin"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
model_provider=PLUGIN_PROVIDER,
|
||||
model=MODEL_NAME,
|
||||
credentials=MODEL_CREDENTIALS,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
)
|
||||
print("created run", run)
|
||||
terminal = client.wait_run_sync(run.run_id, poll_interval_seconds=0.5)
|
||||
print("terminal status", terminal)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
64
dify-agent/mkdocs.yml
Normal file
64
dify-agent/mkdocs.yml
Normal file
@ -0,0 +1,64 @@
|
||||
site_name: Dify Agent
|
||||
site_description: Agent runtime and Agenton composition framework documentation
|
||||
strict: true
|
||||
|
||||
repo_name: langgenius/dify
|
||||
repo_url: https://github.com/langgenius/dify
|
||||
edit_uri: edit/main/dify-agent/docs/
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Agenton:
|
||||
- Overview: agenton/index.md
|
||||
- Guide: agenton/guide/index.md
|
||||
- API Reference: agenton/api/index.md
|
||||
- Examples: agenton/examples/index.md
|
||||
- Dify Agent:
|
||||
- Overview: dify-agent/index.md
|
||||
- Operations Guide: dify-agent/guide/index.md
|
||||
- Run API: dify-agent/api/index.md
|
||||
- Examples: dify-agent/examples/index.md
|
||||
|
||||
theme:
|
||||
name: material
|
||||
features:
|
||||
- content.code.copy
|
||||
- content.tabs.link
|
||||
- navigation.indexes
|
||||
- navigation.sections
|
||||
- navigation.tracking
|
||||
- toc.follow
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- attr_list
|
||||
- md_in_html
|
||||
- pymdownx.details
|
||||
- pymdownx.highlight:
|
||||
pygments_lang_class: true
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.superfences
|
||||
- pymdownx.tabbed:
|
||||
alternate_style: true
|
||||
- tables
|
||||
|
||||
plugins:
|
||||
- search
|
||||
- mkdocstrings:
|
||||
handlers:
|
||||
python:
|
||||
paths:
|
||||
- src
|
||||
options:
|
||||
docstring_style: google
|
||||
members_order: source
|
||||
separate_signature: true
|
||||
show_signature_annotations: true
|
||||
signature_crossrefs: true
|
||||
|
||||
watch:
|
||||
- src
|
||||
- examples
|
||||
|
||||
hooks:
|
||||
- docs/.hooks/main.py
|
||||
75
dify-agent/pyproject.toml
Normal file
75
dify-agent/pyproject.toml
Normal file
@ -0,0 +1,75 @@
|
||||
[project]
|
||||
name = "dify-agent"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"httpx>=0.28.1",
|
||||
"pydantic>=2.13.3",
|
||||
"pydantic-ai-slim>=1.85.1",
|
||||
"typing-extensions>=4.12.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
server = [
|
||||
"fastapi>=0.136.0",
|
||||
"graphon~=0.2.2",
|
||||
"pydantic-ai-slim[anthropic,google,openai]>=1.85.1",
|
||||
"pydantic-settings>=2.12.0",
|
||||
"redis>=5",
|
||||
"uvicorn[standard]>=0.38.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
include = [
|
||||
"agenton*",
|
||||
"agenton_collections*",
|
||||
"dify_agent*",
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["src", "examples", "tests"]
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
pythonVersion = "3.12"
|
||||
extraPaths = [
|
||||
"src",
|
||||
"examples/agenton",
|
||||
"examples/dify_agent",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py", "*_test.py"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py312"
|
||||
include = [
|
||||
"src/**/*.py",
|
||||
"examples/**/*.py",
|
||||
"tests/**/*.py",
|
||||
"docs/**/*.py",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"basedpyright>=1.39.3",
|
||||
"coverage[toml]>=7.10.7",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-examples>=0.0.18",
|
||||
"pytest-mock>=3.14.0",
|
||||
"ruff>=0.15.11",
|
||||
]
|
||||
docs = [
|
||||
"mkdocs>=1.6.1,<2",
|
||||
"mkdocs-glightbox>=0.4.0",
|
||||
"mkdocs-material>=9.7.0",
|
||||
"mkdocstrings-python>=2.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
18
dify-agent/src/agenton/__init__.py
Normal file
18
dify-agent/src/agenton/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
"""Agenton state-only core.
|
||||
|
||||
Agenton core composes reusable stateless layer graph plans, creates a fresh
|
||||
``CompositorRun`` for each invocation, hydrates and advances serializable layer
|
||||
``runtime_state`` through run slots, and emits session snapshots. It intentionally
|
||||
does not own resources, handles, clients, cleanup callbacks, or any other
|
||||
non-serializable runtime object.
|
||||
|
||||
Each ``Compositor`` stores only graph nodes and layer providers. Every enter call
|
||||
creates new layer instances, binds direct dependencies for that run, and writes
|
||||
the next cross-call state to ``run.session_snapshot`` after exit. To resume a
|
||||
suspended call, reuse the same compositor plan and pass the prior snapshot to a
|
||||
new enter call.
|
||||
|
||||
``LifecycleState.ACTIVE`` is internal-only while an entry is running. External
|
||||
session snapshots and hydrated input must contain only non-active lifecycle
|
||||
states; ``runtime_state`` is the only mutable layer data captured by snapshots.
|
||||
"""
|
||||
55
dify-agent/src/agenton/compositor/__init__.py
Normal file
55
dify-agent/src/agenton/compositor/__init__.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""Stateless layer graph composition facade for the Agenton core.
|
||||
|
||||
``agenton.compositor`` remains the stable import surface for reusable graph
|
||||
composition. ``core.py`` defines the state-only ``Compositor`` graph plan and
|
||||
``LayerNode`` orchestration, ``providers.py`` owns reusable construction plans
|
||||
and fresh-instance validation, ``run.py`` owns active invocation lifecycle plus
|
||||
prompt/tool aggregation, ``schemas.py`` owns serializable graph/snapshot DTOs
|
||||
and boundary revalidation, and ``types.py`` holds shared generic transformer
|
||||
types.
|
||||
|
||||
``Compositor`` itself stores no live layer instances, run lifecycle state,
|
||||
session state, resources, or handles. Each ``enter(...)`` call creates a fresh
|
||||
``CompositorRun`` with new layer instances, direct dependency binding, optional
|
||||
snapshot hydration, and the next ``session_snapshot`` after exit.
|
||||
``LifecycleState.ACTIVE`` remains internal-only and session snapshots contain
|
||||
only ordered layer lifecycle state plus serializable ``runtime_state``.
|
||||
|
||||
The facade also keeps true module-level aliases for ``LayerConfigInput`` and
|
||||
``LayerProviderInput`` so existing typed direct imports remain supported
|
||||
without expanding ``__all__``.
|
||||
"""
|
||||
|
||||
from .core import Compositor, LayerNode
|
||||
from .providers import LayerConfigInput as _LayerConfigInput
|
||||
from .providers import LayerFactory, LayerProvider, LayerProviderInput as _LayerProviderInput
|
||||
from .run import CompositorRun, LayerRunSlot
|
||||
from .schemas import (
|
||||
CompositorConfig,
|
||||
CompositorConfigValue,
|
||||
CompositorSessionSnapshot,
|
||||
CompositorSessionSnapshotValue,
|
||||
LayerNodeConfig,
|
||||
LayerSessionSnapshot,
|
||||
)
|
||||
from .types import CompositorTransformer, CompositorTransformerKwargs
|
||||
|
||||
type LayerConfigInput = _LayerConfigInput
|
||||
type LayerProviderInput = _LayerProviderInput
|
||||
|
||||
__all__ = [
|
||||
"Compositor",
|
||||
"CompositorConfig",
|
||||
"CompositorConfigValue",
|
||||
"CompositorRun",
|
||||
"CompositorSessionSnapshot",
|
||||
"CompositorSessionSnapshotValue",
|
||||
"CompositorTransformer",
|
||||
"CompositorTransformerKwargs",
|
||||
"LayerFactory",
|
||||
"LayerNode",
|
||||
"LayerNodeConfig",
|
||||
"LayerProvider",
|
||||
"LayerRunSlot",
|
||||
"LayerSessionSnapshot",
|
||||
]
|
||||
308
dify-agent/src/agenton/compositor/core.py
Normal file
308
dify-agent/src/agenton/compositor/core.py
Normal file
@ -0,0 +1,308 @@
|
||||
"""Stateless compositor graph plans and run construction.
|
||||
|
||||
``Compositor`` stores only reusable graph nodes and optional aggregation
|
||||
transformers. Each ``enter(...)`` call validates node-name keyed configs before
|
||||
any provider factory runs, optionally validates and hydrates a session snapshot,
|
||||
creates fresh layer instances, binds direct dependencies, and returns a new
|
||||
``CompositorRun`` for that invocation only.
|
||||
|
||||
``Compositor.from_config(...)`` resolves serializable provider type ids rather
|
||||
than import paths. Named ``node_providers`` override type-id providers for the
|
||||
same graph node without passing graph node data into factories. Session
|
||||
snapshots must list layer names in compositor order so runtime state can be
|
||||
hydrated deterministically.
|
||||
"""
|
||||
|
||||
from collections import OrderedDict
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic, cast
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
from agenton.layers.base import Layer, LayerConfig, LifecycleState
|
||||
|
||||
from .providers import LayerConfigInput, LayerProvider, LayerProviderInput, _as_layer_provider
|
||||
from .run import CompositorRun, LayerRunSlot
|
||||
from .schemas import (
|
||||
CompositorConfigValue,
|
||||
CompositorSessionSnapshot,
|
||||
CompositorSessionSnapshotValue,
|
||||
_validate_compositor_config_input,
|
||||
_validate_config_model_input,
|
||||
)
|
||||
from .types import (
|
||||
CompositorTransformer,
|
||||
LayerPromptT,
|
||||
LayerToolT,
|
||||
LayerUserPromptT,
|
||||
PromptT,
|
||||
ToolT,
|
||||
UserPromptT,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, init=False)
|
||||
class LayerNode:
|
||||
"""Stateless graph node plan for one named layer provider.
|
||||
|
||||
``implementation`` may be a layer class or an explicit ``LayerProvider``.
|
||||
``deps`` maps dependency field names on this node's layer class to other
|
||||
compositor node names. ``metadata`` is graph description data only; it is
|
||||
not passed to provider factories and is never included in session snapshots.
|
||||
"""
|
||||
|
||||
name: str
|
||||
provider: LayerProvider[Any]
|
||||
deps: Mapping[str, str]
|
||||
metadata: Mapping[str, JsonValue]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
implementation: LayerProviderInput,
|
||||
*,
|
||||
deps: Mapping[str, str] | None = None,
|
||||
metadata: Mapping[str, JsonValue] | None = None,
|
||||
) -> None:
|
||||
if not name:
|
||||
raise ValueError("Layer node name must not be empty.")
|
||||
object.__setattr__(self, "name", name)
|
||||
object.__setattr__(self, "provider", _as_layer_provider(implementation))
|
||||
object.__setattr__(self, "deps", dict(deps or {}))
|
||||
object.__setattr__(self, "metadata", dict(metadata or {}))
|
||||
|
||||
|
||||
class Compositor(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT, LayerUserPromptT]):
|
||||
"""Reusable, framework-neutral ordered layer graph plan.
|
||||
|
||||
A compositor stores only immutable graph nodes and provider construction
|
||||
plans. It is safe to enter repeatedly or concurrently because every entry
|
||||
creates a separate ``CompositorRun`` with fresh layer instances, run slots,
|
||||
dependency bindings, and optional hydrated runtime state. Session
|
||||
continuity is explicit: pass the previous ``CompositorSessionSnapshot`` to
|
||||
the next ``enter`` call and read the next one from ``run.session_snapshot``
|
||||
after exit.
|
||||
|
||||
``prompt_transformer``, ``user_prompt_transformer``, and
|
||||
``tool_transformer`` are post-aggregation hooks on each run. Use two type
|
||||
arguments for identity aggregation, four when prompt/tool layer item types
|
||||
differ from exposed item types, or all six when user prompt item types also
|
||||
differ.
|
||||
"""
|
||||
|
||||
__slots__ = ("_nodes", "prompt_transformer", "tool_transformer", "user_prompt_transformer")
|
||||
|
||||
_nodes: tuple[LayerNode, ...]
|
||||
prompt_transformer: CompositorTransformer[LayerPromptT, PromptT] | None
|
||||
user_prompt_transformer: CompositorTransformer[LayerUserPromptT, UserPromptT] | None
|
||||
tool_transformer: CompositorTransformer[LayerToolT, ToolT] | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nodes: Sequence[LayerNode],
|
||||
*,
|
||||
prompt_transformer: CompositorTransformer[LayerPromptT, PromptT] | None = None,
|
||||
user_prompt_transformer: CompositorTransformer[LayerUserPromptT, UserPromptT] | None = None,
|
||||
tool_transformer: CompositorTransformer[LayerToolT, ToolT] | None = None,
|
||||
) -> None:
|
||||
self._nodes = tuple(nodes)
|
||||
self.prompt_transformer = prompt_transformer
|
||||
self.user_prompt_transformer = user_prompt_transformer
|
||||
self.tool_transformer = tool_transformer
|
||||
self._validate_nodes()
|
||||
|
||||
@property
|
||||
def nodes(self) -> tuple[LayerNode, ...]:
|
||||
"""Return the stateless graph plan nodes in compositor order."""
|
||||
return self._nodes
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls,
|
||||
conf: CompositorConfigValue,
|
||||
*,
|
||||
providers: Sequence[LayerProviderInput],
|
||||
node_providers: Mapping[str, LayerProviderInput] | None = None,
|
||||
prompt_transformer: CompositorTransformer[LayerPromptT, PromptT] | None = None,
|
||||
user_prompt_transformer: CompositorTransformer[LayerUserPromptT, UserPromptT] | None = None,
|
||||
tool_transformer: CompositorTransformer[LayerToolT, ToolT] | None = None,
|
||||
) -> "Compositor[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT, LayerUserPromptT]":
|
||||
"""Create a reusable compositor plan from serializable graph config.
|
||||
|
||||
``providers`` resolve graph node ``type`` ids. ``node_providers`` are
|
||||
keyed by graph node name and take precedence over the type-id provider,
|
||||
allowing node-specific construction without passing node data to factory
|
||||
callables.
|
||||
"""
|
||||
graph_config = _validate_compositor_config_input(conf)
|
||||
if graph_config.schema_version != 1:
|
||||
raise ValueError(f"Unsupported compositor config schema_version: {graph_config.schema_version}.")
|
||||
|
||||
provider_by_type = _build_provider_type_map(providers)
|
||||
provider_by_node = {name: _as_layer_provider(provider) for name, provider in (node_providers or {}).items()}
|
||||
graph_node_names = {node.name for node in graph_config.layers}
|
||||
unknown_node_providers = provider_by_node.keys() - graph_node_names
|
||||
if unknown_node_providers:
|
||||
names = ", ".join(sorted(unknown_node_providers))
|
||||
raise ValueError(f"node_providers contains unknown layer node names: {names}.")
|
||||
|
||||
nodes: list[LayerNode] = []
|
||||
for node_config in graph_config.layers:
|
||||
provider = provider_by_node.get(node_config.name)
|
||||
if provider is None:
|
||||
try:
|
||||
provider = provider_by_type[node_config.type]
|
||||
except KeyError as e:
|
||||
raise KeyError(f"Layer type id '{node_config.type}' is not registered.") from e
|
||||
nodes.append(
|
||||
LayerNode(
|
||||
node_config.name,
|
||||
provider,
|
||||
deps=node_config.deps,
|
||||
metadata=node_config.metadata,
|
||||
)
|
||||
)
|
||||
|
||||
return cls(
|
||||
nodes,
|
||||
prompt_transformer=prompt_transformer,
|
||||
user_prompt_transformer=user_prompt_transformer,
|
||||
tool_transformer=tool_transformer,
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def enter(
|
||||
self,
|
||||
*,
|
||||
configs: Mapping[str, LayerConfigInput] | None = None,
|
||||
session_snapshot: CompositorSessionSnapshotValue | None = None,
|
||||
) -> AsyncIterator[CompositorRun[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT, LayerUserPromptT]]:
|
||||
"""Create a fresh run, enter layers in graph order, and yield it.
|
||||
|
||||
Configs are keyed by layer node name and validated before factories run.
|
||||
The optional session snapshot is validated and hydrated before any hook
|
||||
runs. Layers exit in reverse graph order, and ``run.session_snapshot``
|
||||
is populated after exit with the next non-active lifecycle states.
|
||||
"""
|
||||
run = self._create_run(configs=configs, session_snapshot=session_snapshot)
|
||||
await run._enter_layers()
|
||||
try:
|
||||
yield run
|
||||
finally:
|
||||
await run._exit_layers()
|
||||
|
||||
def _create_run(
|
||||
self,
|
||||
*,
|
||||
configs: Mapping[str, LayerConfigInput] | None,
|
||||
session_snapshot: CompositorSessionSnapshotValue | None,
|
||||
) -> CompositorRun[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT, LayerUserPromptT]:
|
||||
config_by_name = self._validate_run_configs(configs)
|
||||
typed_config_by_name = self._validate_layer_configs(config_by_name)
|
||||
snapshot = self._validate_session_snapshot(session_snapshot) if session_snapshot is not None else None
|
||||
layer_by_name = self._create_layers(typed_config_by_name)
|
||||
|
||||
snapshot_by_name = (
|
||||
{layer_snapshot.name: layer_snapshot for layer_snapshot in snapshot.layers} if snapshot else {}
|
||||
)
|
||||
lifecycle_by_name: dict[str, LifecycleState] = {}
|
||||
for node in self._nodes:
|
||||
layer = layer_by_name[node.name]
|
||||
layer_snapshot = snapshot_by_name.get(node.name)
|
||||
if layer_snapshot is None:
|
||||
lifecycle_by_name[node.name] = LifecycleState.NEW
|
||||
continue
|
||||
layer.runtime_state = cast(Any, layer.runtime_state_type.model_validate(layer_snapshot.runtime_state))
|
||||
lifecycle_by_name[node.name] = layer_snapshot.lifecycle_state
|
||||
|
||||
self._bind_deps(layer_by_name)
|
||||
return CompositorRun(
|
||||
slots=OrderedDict(
|
||||
(node.name, LayerRunSlot(layer=layer_by_name[node.name], lifecycle_state=lifecycle_by_name[node.name]))
|
||||
for node in self._nodes
|
||||
),
|
||||
prompt_transformer=self.prompt_transformer,
|
||||
user_prompt_transformer=self.user_prompt_transformer,
|
||||
tool_transformer=self.tool_transformer,
|
||||
)
|
||||
|
||||
def _create_layers(
|
||||
self,
|
||||
config_by_name: Mapping[str, LayerConfig],
|
||||
) -> OrderedDict[str, Layer[Any, Any, Any, Any, Any, Any]]:
|
||||
return OrderedDict(
|
||||
(node.name, node.provider.create_layer_from_config(config_by_name[node.name])) for node in self._nodes
|
||||
)
|
||||
|
||||
def _validate_layer_configs(self, config_by_name: Mapping[str, LayerConfigInput]) -> dict[str, LayerConfig]:
|
||||
"""Validate every node config before any provider factory is invoked."""
|
||||
return {node.name: node.provider.validate_config(config_by_name.get(node.name)) for node in self._nodes}
|
||||
|
||||
def _bind_deps(self, layer_by_name: Mapping[str, Layer[Any, Any, Any, Any, Any, Any]]) -> None:
|
||||
"""Resolve dependency-name mappings and bind direct layer dependencies."""
|
||||
for node in self._nodes:
|
||||
layer = layer_by_name[node.name]
|
||||
resolved_deps = {dep_name: layer_by_name[target_name] for dep_name, target_name in node.deps.items()}
|
||||
layer.bind_deps(resolved_deps)
|
||||
|
||||
def _validate_nodes(self) -> None:
|
||||
layer_names: set[str] = set()
|
||||
for node in self._nodes:
|
||||
if node.name in layer_names:
|
||||
raise ValueError(f"Duplicate layer name '{node.name}'.")
|
||||
layer_names.add(node.name)
|
||||
|
||||
for node in self._nodes:
|
||||
declared_deps = node.provider.layer_type.dependency_names()
|
||||
unknown_dep_keys = set(node.deps) - declared_deps
|
||||
if unknown_dep_keys:
|
||||
names = ", ".join(sorted(unknown_dep_keys))
|
||||
raise ValueError(f"Layer '{node.name}' declares unknown dependency keys: {names}.")
|
||||
missing_targets = set(node.deps.values()) - layer_names
|
||||
if missing_targets:
|
||||
names = ", ".join(sorted(missing_targets))
|
||||
raise ValueError(f"Layer '{node.name}' depends on undefined layer names: {names}.")
|
||||
|
||||
def _validate_run_configs(self, configs: Mapping[str, LayerConfigInput] | None) -> dict[str, LayerConfigInput]:
|
||||
config_by_name = dict(configs or {})
|
||||
known_names = {node.name for node in self._nodes}
|
||||
unknown_names = config_by_name.keys() - known_names
|
||||
if unknown_names:
|
||||
names = ", ".join(sorted(unknown_names))
|
||||
raise ValueError(f"Layer configs contain unknown layer node names: {names}.")
|
||||
return config_by_name
|
||||
|
||||
def _validate_session_snapshot(
|
||||
self,
|
||||
snapshot: CompositorSessionSnapshotValue,
|
||||
) -> CompositorSessionSnapshot:
|
||||
resolved_snapshot = _validate_config_model_input(CompositorSessionSnapshot, snapshot)
|
||||
if resolved_snapshot.schema_version != 1:
|
||||
raise ValueError(
|
||||
f"Unsupported compositor session snapshot schema_version: {resolved_snapshot.schema_version}."
|
||||
)
|
||||
expected_layer_names = tuple(node.name for node in self._nodes)
|
||||
actual_layer_names = tuple(layer.name for layer in resolved_snapshot.layers)
|
||||
if actual_layer_names != expected_layer_names:
|
||||
expected = ", ".join(expected_layer_names)
|
||||
actual = ", ".join(actual_layer_names)
|
||||
raise ValueError(
|
||||
"CompositorSessionSnapshot layer names must match compositor layers in order. "
|
||||
f"Expected [{expected}], got [{actual}]."
|
||||
)
|
||||
return resolved_snapshot
|
||||
|
||||
|
||||
def _build_provider_type_map(providers: Sequence[LayerProviderInput]) -> dict[str, LayerProvider[Any]]:
|
||||
provider_by_type: dict[str, LayerProvider[Any]] = {}
|
||||
for provider_input in providers:
|
||||
provider = _as_layer_provider(provider_input)
|
||||
type_id = provider.type_id
|
||||
if type_id is None or not type_id:
|
||||
raise ValueError(f"Layer provider for '{provider.layer_type.__qualname__}' must declare a type_id.")
|
||||
if type_id in provider_by_type:
|
||||
raise ValueError(f"Layer type id '{type_id}' is already registered.")
|
||||
provider_by_type[type_id] = provider
|
||||
return provider_by_type
|
||||
138
dify-agent/src/agenton/compositor/providers.py
Normal file
138
dify-agent/src/agenton/compositor/providers.py
Normal file
@ -0,0 +1,138 @@
|
||||
"""Reusable layer providers and fresh-instance validation.
|
||||
|
||||
Providers are construction plans, not live runtime owners. They validate raw
|
||||
per-enter config into a layer's declared ``config_type`` and then construct a
|
||||
fresh layer instance for one invocation. Provider factories receive only typed
|
||||
config, never graph node data; node-specific construction belongs in named
|
||||
``node_providers`` overrides on ``Compositor.from_config(...)``.
|
||||
|
||||
Fresh-instance enforcement is global by weak reference so ``Compositor`` can
|
||||
stay stateless while still rejecting reused layer instances before dependency
|
||||
binding or lifecycle hooks run.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any, Generic, cast
|
||||
import weakref
|
||||
|
||||
from agenton.layers.base import Layer, LayerConfig, LayerConfigValue
|
||||
|
||||
from .schemas import _validate_config_model_input
|
||||
from .types import LayerT
|
||||
|
||||
type LayerConfigInput = LayerConfigValue | Mapping[str, object] | str | bytes | None
|
||||
type LayerFactory = Callable[[LayerConfig], Layer[Any, Any, Any, Any, Any, Any]]
|
||||
type LayerProviderInput = type[Layer[Any, Any, Any, Any, Any, Any]] | "LayerProvider[Any]"
|
||||
|
||||
|
||||
_USED_LAYER_INSTANCE_REFS: dict[int, weakref.ReferenceType[Layer[Any, Any, Any, Any, Any, Any]]] = {}
|
||||
|
||||
|
||||
def _claim_fresh_layer_instance(layer: Layer[Any, Any, Any, Any, Any, Any]) -> None:
|
||||
"""Reject provider factories that return a layer object used before.
|
||||
|
||||
The registry stores weak references, not live resources or run state. It is
|
||||
intentionally global to keep ``Compositor`` stateless while still enforcing
|
||||
the fresh-instance boundary before dependencies are bound or hooks run.
|
||||
"""
|
||||
layer_identity = id(layer)
|
||||
existing_ref = _USED_LAYER_INSTANCE_REFS.get(layer_identity)
|
||||
if existing_ref is not None:
|
||||
existing_layer = existing_ref()
|
||||
if existing_layer is not None:
|
||||
raise ValueError(
|
||||
"LayerProvider factories must return a fresh layer instance for each invocation; "
|
||||
f"got reused instance of '{type(layer).__name__}'."
|
||||
)
|
||||
_USED_LAYER_INSTANCE_REFS.pop(layer_identity, None)
|
||||
|
||||
def remove_ref(ref: weakref.ReferenceType[Layer[Any, Any, Any, Any, Any, Any]]) -> None:
|
||||
if _USED_LAYER_INSTANCE_REFS.get(layer_identity) is ref:
|
||||
_USED_LAYER_INSTANCE_REFS.pop(layer_identity, None)
|
||||
|
||||
_USED_LAYER_INSTANCE_REFS[layer_identity] = weakref.ref(layer, remove_ref)
|
||||
|
||||
|
||||
class LayerProvider(Generic[LayerT]):
|
||||
"""Validated layer factory for one concrete ``Layer`` class.
|
||||
|
||||
Providers are reusable construction plans. They validate per-call config
|
||||
with ``layer_type.config_type`` before invoking either
|
||||
``layer_type.from_config`` or a custom factory. The factory receives only
|
||||
typed config, never graph node data, and must return a fresh ``layer_type``
|
||||
instance; reused instances are rejected before dependencies are bound or
|
||||
hooks run.
|
||||
"""
|
||||
|
||||
__slots__ = ("_create", "layer_type")
|
||||
|
||||
layer_type: type[LayerT]
|
||||
_create: Callable[[LayerConfig], LayerT]
|
||||
|
||||
def __init__(self, *, layer_type: type[LayerT], create: Callable[[LayerConfig], LayerT]) -> None:
|
||||
self.layer_type = layer_type
|
||||
self._create = create
|
||||
|
||||
@classmethod
|
||||
def from_layer_type(cls, layer_type: type[LayerT]) -> "LayerProvider[LayerT]":
|
||||
"""Create a provider that constructs layers via ``layer_type.from_config``."""
|
||||
|
||||
def create(config: LayerConfig) -> LayerT:
|
||||
return layer_type.from_config(cast(Any, config))
|
||||
|
||||
return cls(layer_type=layer_type, create=create)
|
||||
|
||||
@classmethod
|
||||
def from_factory(
|
||||
cls,
|
||||
*,
|
||||
layer_type: type[LayerT],
|
||||
create: Callable[[Any], LayerT],
|
||||
) -> "LayerProvider[LayerT]":
|
||||
"""Create a provider from a custom typed-config factory.
|
||||
|
||||
``create`` receives the validated instance of ``layer_type.config_type``.
|
||||
It does not receive the graph node; node-specific construction should use
|
||||
a dedicated provider in ``Compositor.from_config(node_providers=...)``.
|
||||
"""
|
||||
return cls(layer_type=layer_type, create=cast(Callable[[LayerConfig], LayerT], create))
|
||||
|
||||
@property
|
||||
def type_id(self) -> str | None:
|
||||
"""Return the serializable registry type id declared by ``layer_type``."""
|
||||
return self.layer_type.type_id
|
||||
|
||||
def create_layer(self, config: LayerConfigInput = None) -> LayerT:
|
||||
"""Validate config, call the factory, and return a fresh layer instance."""
|
||||
typed_config = self.validate_config(config)
|
||||
return self.create_layer_from_config(typed_config)
|
||||
|
||||
def validate_config(self, config: LayerConfigInput = None) -> LayerConfig:
|
||||
"""Return typed config without invoking the layer factory.
|
||||
|
||||
``Compositor.enter`` calls this for every node before creating any layer
|
||||
so a later invalid node config cannot leave earlier factory side effects.
|
||||
"""
|
||||
raw_config: LayerConfigValue | Mapping[str, object] | str | bytes = {} if config is None else config
|
||||
return _validate_config_model_input(self.layer_type.config_type, raw_config)
|
||||
|
||||
def create_layer_from_config(self, config: LayerConfig) -> LayerT:
|
||||
"""Call the factory with validated config and enforce fresh instances."""
|
||||
typed_config = self.validate_config(config)
|
||||
layer = self._create(typed_config)
|
||||
if not isinstance(layer, self.layer_type):
|
||||
raise TypeError(
|
||||
f"LayerProvider for '{self.layer_type.__name__}' returned '{type(layer).__name__}', "
|
||||
f"expected '{self.layer_type.__name__}'."
|
||||
)
|
||||
_claim_fresh_layer_instance(layer)
|
||||
layer.config = cast(Any, typed_config)
|
||||
return layer
|
||||
|
||||
|
||||
def _as_layer_provider(implementation: LayerProviderInput) -> LayerProvider[Any]:
|
||||
if isinstance(implementation, LayerProvider):
|
||||
return implementation
|
||||
if isinstance(implementation, type) and issubclass(implementation, Layer):
|
||||
return LayerProvider.from_layer_type(implementation)
|
||||
raise TypeError("LayerNode implementation must be a Layer subclass or LayerProvider.")
|
||||
221
dify-agent/src/agenton/compositor/run.py
Normal file
221
dify-agent/src/agenton/compositor/run.py
Normal file
@ -0,0 +1,221 @@
|
||||
"""Active compositor run lifecycle, snapshots, and aggregation.
|
||||
|
||||
``CompositorRun`` is the only compositor object that exposes live layer
|
||||
instances. It owns invocation-local lifecycle state, per-layer exit intent, and
|
||||
the next ``session_snapshot`` after exit. Layers enter in graph order and exit
|
||||
in reverse graph order. Prompt aggregation preserves graph ordering: prefix
|
||||
prompts first-to-last, suffix prompts last-to-first, user prompts first-to-last,
|
||||
and tools in graph order.
|
||||
|
||||
Prompt, user prompt, and tool transformers run only after layer-level wrapping
|
||||
and run-level aggregation. When no transformer is installed, the wrapped items
|
||||
are returned unchanged.
|
||||
"""
|
||||
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic, cast, overload
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
from agenton.layers.base import ExitIntent, Layer, LifecycleState
|
||||
|
||||
from .schemas import CompositorSessionSnapshot, LayerSessionSnapshot
|
||||
from .types import (
|
||||
CompositorTransformer,
|
||||
LayerPromptT,
|
||||
LayerT,
|
||||
LayerToolT,
|
||||
LayerUserPromptT,
|
||||
PromptT,
|
||||
ToolT,
|
||||
UserPromptT,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LayerRunSlot:
|
||||
"""Invocation-local lifecycle and exit state for one fresh layer instance."""
|
||||
|
||||
layer: Layer[Any, Any, Any, Any, Any, Any]
|
||||
lifecycle_state: LifecycleState
|
||||
exit_intent: ExitIntent = ExitIntent.DELETE
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CompositorRun(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT, LayerUserPromptT]):
|
||||
"""Single-invocation runtime object created by ``Compositor.enter``.
|
||||
|
||||
The run owns ordered ``LayerRunSlot`` objects and the fresh layers inside
|
||||
them. It is the only object that exposes live layers, lifecycle state, exit
|
||||
intent, and prompt/user-prompt/tool aggregation for an active invocation.
|
||||
After context exit, ``session_snapshot`` contains the next cross-call state.
|
||||
"""
|
||||
|
||||
slots: OrderedDict[str, LayerRunSlot]
|
||||
prompt_transformer: CompositorTransformer[LayerPromptT, PromptT] | None = None
|
||||
user_prompt_transformer: CompositorTransformer[LayerUserPromptT, UserPromptT] | None = None
|
||||
tool_transformer: CompositorTransformer[LayerToolT, ToolT] | None = None
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
|
||||
@overload
|
||||
def get_layer(self, name: str) -> Layer[Any, Any, Any, Any, Any, Any]: ...
|
||||
|
||||
@overload
|
||||
def get_layer(self, name: str, layer_type: type[LayerT]) -> LayerT: ...
|
||||
|
||||
def get_layer(
|
||||
self,
|
||||
name: str,
|
||||
layer_type: type[LayerT] | None = None,
|
||||
) -> Layer[Any, Any, Any, Any, Any, Any] | LayerT:
|
||||
"""Return a live layer by node name and optionally validate its type."""
|
||||
try:
|
||||
layer = self.slots[name].layer
|
||||
except KeyError as e:
|
||||
raise KeyError(f"Layer '{name}' is not defined in this compositor run.") from e
|
||||
|
||||
if layer_type is not None and not isinstance(layer, layer_type):
|
||||
raise TypeError(f"Layer '{name}' must be {layer_type.__name__}, got {type(layer).__name__}.")
|
||||
return layer
|
||||
|
||||
def suspend_on_exit(self) -> None:
|
||||
"""Request suspend behavior for every active layer when the run exits."""
|
||||
for name in self.slots:
|
||||
self.suspend_layer_on_exit(name)
|
||||
|
||||
def delete_on_exit(self) -> None:
|
||||
"""Request delete behavior for every active layer when the run exits."""
|
||||
for name in self.slots:
|
||||
self.delete_layer_on_exit(name)
|
||||
|
||||
def suspend_layer_on_exit(self, name: str) -> None:
|
||||
"""Request suspend behavior for one active layer when the run exits."""
|
||||
self._set_layer_exit_intent(name, ExitIntent.SUSPEND)
|
||||
|
||||
def delete_layer_on_exit(self, name: str) -> None:
|
||||
"""Request delete behavior for one active layer when the run exits."""
|
||||
self._set_layer_exit_intent(name, ExitIntent.DELETE)
|
||||
|
||||
def snapshot_session(self) -> CompositorSessionSnapshot:
|
||||
"""Snapshot non-active layer lifecycle state and runtime state from this run."""
|
||||
active_layers = [name for name, slot in self.slots.items() if slot.lifecycle_state is LifecycleState.ACTIVE]
|
||||
if active_layers:
|
||||
names = ", ".join(active_layers)
|
||||
raise RuntimeError(f"Cannot snapshot active compositor run layers: {names}.")
|
||||
return CompositorSessionSnapshot(
|
||||
layers=[
|
||||
LayerSessionSnapshot(
|
||||
name=name,
|
||||
lifecycle_state=slot.lifecycle_state,
|
||||
runtime_state=cast(dict[str, JsonValue], slot.layer.runtime_state.model_dump(mode="json")),
|
||||
)
|
||||
for name, slot in self.slots.items()
|
||||
]
|
||||
)
|
||||
|
||||
async def _enter_layers(self) -> None:
|
||||
self._ensure_layers_can_enter()
|
||||
entered_slots: list[LayerRunSlot] = []
|
||||
try:
|
||||
for slot in self.slots.values():
|
||||
await self._enter_slot(slot)
|
||||
entered_slots.append(slot)
|
||||
except BaseException as enter_error:
|
||||
hook_error = await self._exit_slots_reversed(entered_slots)
|
||||
self.session_snapshot = self.snapshot_session()
|
||||
if hook_error is not None:
|
||||
raise hook_error from enter_error
|
||||
raise
|
||||
|
||||
async def _exit_layers(self) -> None:
|
||||
hook_error = await self._exit_slots_reversed(list(self.slots.values()))
|
||||
self.session_snapshot = self.snapshot_session()
|
||||
if hook_error is not None:
|
||||
raise hook_error
|
||||
|
||||
async def _enter_slot(self, slot: LayerRunSlot) -> None:
|
||||
if slot.lifecycle_state is LifecycleState.NEW:
|
||||
slot.exit_intent = ExitIntent.DELETE
|
||||
await slot.layer.on_context_create()
|
||||
slot.lifecycle_state = LifecycleState.ACTIVE
|
||||
return
|
||||
if slot.lifecycle_state is LifecycleState.SUSPENDED:
|
||||
slot.exit_intent = ExitIntent.DELETE
|
||||
await slot.layer.on_context_resume()
|
||||
slot.lifecycle_state = LifecycleState.ACTIVE
|
||||
return
|
||||
raise RuntimeError(f"Cannot enter layer from lifecycle state '{slot.lifecycle_state}'.")
|
||||
|
||||
async def _exit_slots_reversed(self, slots: Sequence[LayerRunSlot]) -> BaseException | None:
|
||||
hook_error: BaseException | None = None
|
||||
for slot in reversed(slots):
|
||||
if slot.lifecycle_state is not LifecycleState.ACTIVE:
|
||||
continue
|
||||
if slot.exit_intent is ExitIntent.SUSPEND:
|
||||
try:
|
||||
await slot.layer.on_context_suspend()
|
||||
except BaseException as exc:
|
||||
hook_error = hook_error or exc
|
||||
finally:
|
||||
slot.lifecycle_state = LifecycleState.SUSPENDED
|
||||
else:
|
||||
try:
|
||||
await slot.layer.on_context_delete()
|
||||
except BaseException as exc:
|
||||
hook_error = hook_error or exc
|
||||
finally:
|
||||
slot.lifecycle_state = LifecycleState.CLOSED
|
||||
|
||||
return hook_error
|
||||
|
||||
def _set_layer_exit_intent(self, name: str, intent: ExitIntent) -> None:
|
||||
try:
|
||||
slot = self.slots[name]
|
||||
except KeyError as e:
|
||||
raise KeyError(f"Layer '{name}' is not defined in this compositor run.") from e
|
||||
if slot.lifecycle_state is not LifecycleState.ACTIVE:
|
||||
raise RuntimeError("Layer exit intent can only be changed while the run slot is active.")
|
||||
slot.exit_intent = intent
|
||||
|
||||
def _ensure_layers_can_enter(self) -> None:
|
||||
"""Reject invalid external lifecycle states before any layer side effects."""
|
||||
for name, slot in self.slots.items():
|
||||
if slot.lifecycle_state is LifecycleState.ACTIVE:
|
||||
raise RuntimeError(f"Layer '{name}' is already active; ACTIVE snapshots are not allowed.")
|
||||
if slot.lifecycle_state is LifecycleState.CLOSED:
|
||||
raise RuntimeError(f"Layer '{name}' is closed; CLOSED snapshots cannot be entered.")
|
||||
|
||||
@property
|
||||
def prompts(self) -> list[PromptT]:
|
||||
result: list[LayerPromptT] = []
|
||||
for slot in self.slots.values():
|
||||
layer = slot.layer
|
||||
result.extend(cast(LayerPromptT, layer.wrap_prompt(prompt)) for prompt in layer.prefix_prompts)
|
||||
for slot in reversed(self.slots.values()):
|
||||
layer = slot.layer
|
||||
result.extend(cast(LayerPromptT, layer.wrap_prompt(prompt)) for prompt in layer.suffix_prompts)
|
||||
if self.prompt_transformer is None:
|
||||
return cast(list[PromptT], result)
|
||||
return list(self.prompt_transformer(result))
|
||||
|
||||
@property
|
||||
def user_prompts(self) -> list[UserPromptT]:
|
||||
result: list[LayerUserPromptT] = []
|
||||
for slot in self.slots.values():
|
||||
layer = slot.layer
|
||||
result.extend(cast(LayerUserPromptT, layer.wrap_user_prompt(prompt)) for prompt in layer.user_prompts)
|
||||
if self.user_prompt_transformer is None:
|
||||
return cast(list[UserPromptT], result)
|
||||
return list(self.user_prompt_transformer(result))
|
||||
|
||||
@property
|
||||
def tools(self) -> list[ToolT]:
|
||||
result: list[LayerToolT] = []
|
||||
for slot in self.slots.values():
|
||||
layer = slot.layer
|
||||
result.extend(cast(LayerToolT, layer.wrap_tool(tool)) for tool in layer.tools)
|
||||
if self.tool_transformer is None:
|
||||
return cast(list[ToolT], result)
|
||||
return list(self.tool_transformer(result))
|
||||
112
dify-agent/src/agenton/compositor/schemas.py
Normal file
112
dify-agent/src/agenton/compositor/schemas.py
Normal file
@ -0,0 +1,112 @@
|
||||
"""Serializable compositor DTOs and external boundary validation.
|
||||
|
||||
Graph config and session snapshots are separate boundaries on purpose. Graph
|
||||
config describes only reusable composition state: schema version, ordered node
|
||||
names, provider type ids, dependency mappings, and metadata. Session snapshots
|
||||
carry only ordered layer lifecycle state plus serializable ``runtime_state``.
|
||||
|
||||
External DTOs are revalidated even when callers pass an already-constructed
|
||||
Pydantic model instance. These models are mutable, so dumping and validating
|
||||
again prevents post-construction mutations from bypassing compositor entry
|
||||
validators. ``LifecycleState.ACTIVE`` remains internal-only and is rejected in
|
||||
external session snapshots.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
|
||||
|
||||
from agenton.layers.base import LifecycleState
|
||||
|
||||
type _ConfigModelValue[ModelT: BaseModel] = ModelT | JsonValue | str | bytes
|
||||
|
||||
|
||||
def _validate_config_model_input[ModelT: BaseModel](
|
||||
model_type: type[ModelT],
|
||||
value: _ConfigModelValue[ModelT] | Mapping[str, object],
|
||||
) -> ModelT:
|
||||
"""Validate an external DTO boundary, including existing model instances.
|
||||
|
||||
Pydantic models in this package are generally mutable and do not all enable
|
||||
assignment validation. Revalidating existing instances through their dumped
|
||||
data prevents post-construction mutations from bypassing config or snapshot
|
||||
validators at compositor entry boundaries.
|
||||
"""
|
||||
if isinstance(value, BaseModel):
|
||||
return model_type.model_validate(value.model_dump(mode="python", warnings=False))
|
||||
if isinstance(value, str | bytes):
|
||||
return model_type.model_validate_json(value)
|
||||
|
||||
return model_type.model_validate(value)
|
||||
|
||||
|
||||
class LayerNodeConfig(BaseModel):
|
||||
"""Serializable config for one provider-backed layer graph node.
|
||||
|
||||
Nodes intentionally contain no runtime state and no per-call layer config.
|
||||
Runtime state belongs to session snapshots; layer config belongs to
|
||||
``Compositor.enter(configs=...)`` keyed by node name.
|
||||
"""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
deps: Mapping[str, str] = Field(default_factory=dict)
|
||||
metadata: Mapping[str, JsonValue] = Field(default_factory=dict)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class CompositorConfig(BaseModel):
|
||||
"""Serializable config for constructing a reusable compositor graph plan."""
|
||||
|
||||
schema_version: int = 1
|
||||
layers: list[LayerNodeConfig]
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
type CompositorConfigValue = _ConfigModelValue[CompositorConfig] | Mapping[str, object]
|
||||
|
||||
|
||||
def _validate_compositor_config_input(value: CompositorConfigValue) -> CompositorConfig:
|
||||
"""Validate external graph config input for ``Compositor.from_config``."""
|
||||
return _validate_config_model_input(CompositorConfig, value)
|
||||
|
||||
|
||||
class LayerSessionSnapshot(BaseModel):
|
||||
"""Serializable snapshot for one layer's state-only invocation data.
|
||||
|
||||
``runtime_state`` is the only snapshotted mutable layer data. ``ACTIVE`` is
|
||||
rejected here because a running layer cannot be represented safely outside
|
||||
the active compositor entry.
|
||||
"""
|
||||
|
||||
name: str
|
||||
lifecycle_state: LifecycleState
|
||||
runtime_state: dict[str, JsonValue]
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@field_validator("lifecycle_state")
|
||||
@classmethod
|
||||
def _reject_active_lifecycle(cls, value: LifecycleState) -> LifecycleState:
|
||||
if value is LifecycleState.ACTIVE:
|
||||
raise ValueError("LifecycleState.ACTIVE is internal-only and cannot appear in session snapshots.")
|
||||
return value
|
||||
|
||||
|
||||
class CompositorSessionSnapshot(BaseModel):
|
||||
"""Serializable compositor session snapshot.
|
||||
|
||||
Snapshots include ordered layer lifecycle state and serializable runtime
|
||||
state only. Live resources, handles, dependencies, prompts, tools, and
|
||||
config are outside Agenton snapshots and are never captured here.
|
||||
"""
|
||||
|
||||
schema_version: int = 1
|
||||
layers: list[LayerSessionSnapshot]
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
type CompositorSessionSnapshotValue = _ConfigModelValue[CompositorSessionSnapshot] | Mapping[str, object]
|
||||
46
dify-agent/src/agenton/compositor/types.py
Normal file
46
dify-agent/src/agenton/compositor/types.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""Shared generic compositor types.
|
||||
|
||||
This module contains the generic prompt/tool type variables and transformer
|
||||
contracts shared by compositor runtime and orchestration modules. It depends
|
||||
only on layer base/types modules so higher-level compositor modules can import
|
||||
it without creating cycles.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from typing_extensions import TypeVar
|
||||
|
||||
from agenton.layers.base import Layer
|
||||
from agenton.layers.types import AllPromptTypes, AllToolTypes, AllUserPromptTypes
|
||||
|
||||
PromptT = TypeVar("PromptT", default=AllPromptTypes)
|
||||
ToolT = TypeVar("ToolT", default=AllToolTypes)
|
||||
LayerPromptT = TypeVar("LayerPromptT", default=AllPromptTypes)
|
||||
LayerToolT = TypeVar("LayerToolT", default=AllToolTypes)
|
||||
UserPromptT = TypeVar("UserPromptT", default=AllUserPromptTypes)
|
||||
LayerUserPromptT = TypeVar("LayerUserPromptT", default=AllUserPromptTypes)
|
||||
LayerT = TypeVar("LayerT", bound=Layer[Any, Any, Any, Any, Any, Any])
|
||||
|
||||
|
||||
type CompositorTransformer[InputT, OutputT] = Callable[[Sequence[InputT]], Sequence[OutputT]]
|
||||
|
||||
|
||||
class CompositorTransformerKwargs[
|
||||
PromptT,
|
||||
ToolT,
|
||||
LayerPromptT,
|
||||
LayerToolT,
|
||||
UserPromptT,
|
||||
LayerUserPromptT,
|
||||
](TypedDict):
|
||||
"""Keyword arguments that install prompt, user prompt, and tool transformers.
|
||||
|
||||
The required keys intentionally mirror the keyword-only transformer
|
||||
parameters exposed by ``Compositor.__init__`` and
|
||||
``Compositor.from_config(...)``.
|
||||
"""
|
||||
|
||||
prompt_transformer: CompositorTransformer[LayerPromptT, PromptT]
|
||||
user_prompt_transformer: CompositorTransformer[LayerUserPromptT, UserPromptT]
|
||||
tool_transformer: CompositorTransformer[LayerToolT, ToolT]
|
||||
66
dify-agent/src/agenton/layers/__init__.py
Normal file
66
dify-agent/src/agenton/layers/__init__.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""Layer base classes and typed layer families.
|
||||
|
||||
``agenton.layers.base`` owns the framework-neutral ``Layer`` abstraction.
|
||||
``agenton.layers.types`` binds the prompt/tool generic slots to specific layer
|
||||
families while keeping concrete reusable layers in ``agenton_collections``.
|
||||
"""
|
||||
|
||||
from agenton.layers.base import (
|
||||
EmptyLayerConfig,
|
||||
EmptyRuntimeState,
|
||||
ExitIntent,
|
||||
Layer,
|
||||
LayerConfig,
|
||||
LayerConfigValue,
|
||||
LayerDeps,
|
||||
LifecycleState,
|
||||
NoLayerDeps,
|
||||
)
|
||||
from agenton.layers.types import (
|
||||
AllPromptTypes,
|
||||
AllToolTypes,
|
||||
AllUserPromptTypes,
|
||||
PlainLayer,
|
||||
PlainPrompt,
|
||||
PlainPromptType,
|
||||
PlainTool,
|
||||
PlainToolType,
|
||||
PlainUserPrompt,
|
||||
PlainUserPromptType,
|
||||
PydanticAILayer,
|
||||
PydanticAIPrompt,
|
||||
PydanticAIPromptType,
|
||||
PydanticAITool,
|
||||
PydanticAIToolType,
|
||||
PydanticAIUserPrompt,
|
||||
PydanticAIUserPromptType,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AllPromptTypes",
|
||||
"AllToolTypes",
|
||||
"AllUserPromptTypes",
|
||||
"Layer",
|
||||
"LayerConfig",
|
||||
"LayerConfigValue",
|
||||
"LayerDeps",
|
||||
"LifecycleState",
|
||||
"ExitIntent",
|
||||
"EmptyLayerConfig",
|
||||
"EmptyRuntimeState",
|
||||
"NoLayerDeps",
|
||||
"PlainLayer",
|
||||
"PlainPrompt",
|
||||
"PlainPromptType",
|
||||
"PlainUserPrompt",
|
||||
"PlainUserPromptType",
|
||||
"PlainTool",
|
||||
"PlainToolType",
|
||||
"PydanticAILayer",
|
||||
"PydanticAIPrompt",
|
||||
"PydanticAIPromptType",
|
||||
"PydanticAIUserPrompt",
|
||||
"PydanticAIUserPromptType",
|
||||
"PydanticAITool",
|
||||
"PydanticAIToolType",
|
||||
]
|
||||
520
dify-agent/src/agenton/layers/base.py
Normal file
520
dify-agent/src/agenton/layers/base.py
Normal file
@ -0,0 +1,520 @@
|
||||
"""Invocation-scoped core layer abstractions and typed dependency binding.
|
||||
|
||||
Agenton core deliberately manages only three concerns: stateless layer graph
|
||||
composition, serializable ``runtime_state`` lifecycle, and session snapshots. It
|
||||
does not own live resources, process handles, HTTP clients, cleanup stacks, or
|
||||
any other non-serializable runtime object. Those belong to application layers or
|
||||
integration code outside the core.
|
||||
|
||||
Layers declare their dependency shape with
|
||||
``Layer[DepsT, PromptT, UserPromptT, ToolT, ConfigT, RuntimeStateT]``.
|
||||
``DepsT`` must be a ``LayerDeps`` subclass whose annotated members are concrete
|
||||
``Layer`` subclasses or modern optional dependencies such as ``SomeLayer | None``.
|
||||
Dependencies are direct layer instance relationships bound onto ``self.deps``
|
||||
for one compositor invocation; there is no dependency-control lookup API in the
|
||||
core.
|
||||
|
||||
``LayerConfig`` is the DTO base for config schemas accepted by layer providers.
|
||||
The provider validates raw node-name keyed configs with a layer's
|
||||
``config_type`` before constructing the layer and assigning ``self.config``.
|
||||
``runtime_state_type`` is the only mutable schema managed by Agenton and the only
|
||||
per-layer data included in session snapshots. The base class infers
|
||||
``deps_type``, ``config_type``, and ``runtime_state_type`` from generic bases
|
||||
when possible, while still allowing subclasses to set them explicitly for
|
||||
unusual inheritance patterns.
|
||||
|
||||
``Layer`` is an invocation-scoped business object. It owns ``config``, direct
|
||||
``deps``, and serializable ``runtime_state`` plus prompt/tool authoring surfaces,
|
||||
but it does not own lifecycle state, exit intent, graph owner tokens, entry
|
||||
stacks, resources, or cleanup callbacks. ``CompositorRun`` owns lifecycle state
|
||||
and exit intent for one entry. ``SessionSnapshot`` objects are the only supported
|
||||
cross-call state carrier.
|
||||
|
||||
Lifecycle hooks are no-argument business hooks on the layer instance:
|
||||
``on_context_create/resume/suspend/delete(self)``. They should read dependencies
|
||||
from ``self.deps`` and read or mutate serializable invocation state through
|
||||
``self.runtime_state``. Resource acquisition and deterministic cleanup should be
|
||||
handled outside Agenton core, for example by integration-specific context
|
||||
managers that wrap compositor entry.
|
||||
|
||||
``Layer`` is framework-neutral over system prompt, user prompt, and tool item
|
||||
types. The native ``prefix_prompts``, ``suffix_prompts``, ``user_prompts``, and
|
||||
``tools`` properties are the layer authoring surface. ``wrap_prompt``,
|
||||
``wrap_user_prompt``, and ``wrap_tool`` are the compositor aggregation surface;
|
||||
typed families such as ``agenton.layers.types.PlainLayer`` implement them to tag
|
||||
native values without changing layer implementations.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from types import UnionType
|
||||
from typing import (
|
||||
Any,
|
||||
ClassVar,
|
||||
Generic,
|
||||
Union,
|
||||
cast,
|
||||
get_args,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue, SerializeAsAny
|
||||
from typing_extensions import Self, TypeVar
|
||||
|
||||
|
||||
_DepsT = TypeVar("_DepsT", bound="LayerDeps")
|
||||
_PromptT = TypeVar("_PromptT")
|
||||
_UserPromptT = TypeVar("_UserPromptT")
|
||||
_ToolT = TypeVar("_ToolT")
|
||||
|
||||
|
||||
class LayerConfig(BaseModel):
|
||||
"""Base DTO for serializable layer configuration.
|
||||
|
||||
Layer providers validate raw config values with concrete ``LayerConfig``
|
||||
subclasses before constructing a layer for one invocation. Serializable
|
||||
compositor graph config references layer type ids and node metadata only;
|
||||
per-call config travels through ``Compositor.enter(configs=...)``.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
type LayerConfigValue = JsonValue | SerializeAsAny[LayerConfig]
|
||||
|
||||
|
||||
_ConfigT = TypeVar("_ConfigT", bound=LayerConfig, default="EmptyLayerConfig")
|
||||
_RuntimeStateT = TypeVar("_RuntimeStateT", bound=BaseModel, default="EmptyRuntimeState")
|
||||
|
||||
|
||||
class LayerDeps:
|
||||
"""Typed dependency container for a layer.
|
||||
|
||||
Subclasses declare dependency members with annotations. Every annotated
|
||||
member must be a Layer subclass or ``LayerSubclass | None``. Optional deps
|
||||
are always assigned as attributes; missing optional values become ``None``.
|
||||
"""
|
||||
|
||||
def __init__(self, **deps: "Layer[Any, Any, Any, Any, Any, Any] | None") -> None:
|
||||
dep_specs = _get_dep_specs(type(self))
|
||||
missing_names = {name for name, spec in dep_specs.items() if not spec.optional} - deps.keys()
|
||||
if missing_names:
|
||||
names = ", ".join(sorted(missing_names))
|
||||
raise ValueError(f"Missing layer dependencies: {names}.")
|
||||
|
||||
unknown_names = deps.keys() - dep_specs.keys()
|
||||
if unknown_names:
|
||||
names = ", ".join(sorted(unknown_names))
|
||||
raise ValueError(f"Unknown layer dependencies: {names}.")
|
||||
|
||||
for name, spec in dep_specs.items():
|
||||
value = deps.get(name)
|
||||
if value is None:
|
||||
if spec.optional:
|
||||
setattr(self, name, None)
|
||||
continue
|
||||
raise ValueError(f"Dependency '{name}' is required but not provided.")
|
||||
|
||||
if not isinstance(value, spec.layer_type):
|
||||
raise TypeError(
|
||||
f"Dependency '{name}' should be of type '{spec.layer_type.__name__}', "
|
||||
f"but got type '{type(value).__name__}'."
|
||||
)
|
||||
setattr(self, name, value)
|
||||
|
||||
|
||||
class NoLayerDeps(LayerDeps):
|
||||
"""Dependency container for layers that do not require other layers."""
|
||||
|
||||
|
||||
class EmptyLayerConfig(LayerConfig):
|
||||
"""Default serializable config schema for layers without config."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class EmptyRuntimeState(BaseModel):
|
||||
"""Default serializable invocation runtime state schema."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
||||
|
||||
|
||||
class LifecycleState(StrEnum):
|
||||
"""Lifecycle state for one run slot.
|
||||
|
||||
``ACTIVE`` is internal-only. It is used while an invocation is running and
|
||||
must never appear in external session snapshots or hydrated input.
|
||||
"""
|
||||
|
||||
NEW = "new"
|
||||
ACTIVE = "active"
|
||||
SUSPENDED = "suspended"
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
class ExitIntent(StrEnum):
|
||||
"""Run-slot exit behavior requested during active invocation."""
|
||||
|
||||
DELETE = "delete"
|
||||
SUSPEND = "suspend"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LayerDepSpec:
|
||||
"""Runtime dependency specification derived from a deps annotation."""
|
||||
|
||||
layer_type: type["Layer[Any, Any, Any, Any, Any, Any]"]
|
||||
optional: bool = False
|
||||
|
||||
|
||||
class Layer(
|
||||
ABC,
|
||||
Generic[_DepsT, _PromptT, _UserPromptT, _ToolT, _ConfigT, _RuntimeStateT],
|
||||
):
|
||||
"""Framework-neutral base class for prompt/tool layers.
|
||||
|
||||
A layer instance is invocation-scoped mutable business state, not a reusable
|
||||
cross-session definition. ``CompositorRun`` creates fresh instances through
|
||||
layer providers, assigns validated ``config``, binds direct dependency layer
|
||||
instances to ``deps``, hydrates ``runtime_state`` from an optional session
|
||||
snapshot, and then runs no-argument lifecycle hooks. The run owns lifecycle
|
||||
state and exit intent; layers never expose a public entry context manager.
|
||||
|
||||
Live resources and handles are intentionally outside this abstraction. Only
|
||||
``runtime_state`` is managed and snapshotted by Agenton core. Lifecycle hooks
|
||||
should operate on ``self`` and keep any non-serializable cleanup policy in
|
||||
integration code that wraps the compositor.
|
||||
"""
|
||||
|
||||
deps_type: type[_DepsT]
|
||||
config: _ConfigT
|
||||
deps: _DepsT
|
||||
runtime_state: _RuntimeStateT
|
||||
type_id: ClassVar[str | None] = None
|
||||
config_type: ClassVar[type[LayerConfig]] = EmptyLayerConfig
|
||||
runtime_state_type: ClassVar[type[BaseModel]] = EmptyRuntimeState
|
||||
|
||||
def __new__(cls, *args: object, **kwargs: object) -> Self:
|
||||
instance = cast(Self, super().__new__(cls))
|
||||
runtime_state_type = getattr(cls, "runtime_state_type", None)
|
||||
if isinstance(runtime_state_type, type) and issubclass(runtime_state_type, BaseModel):
|
||||
instance.runtime_state = cast(Any, runtime_state_type.model_validate({}))
|
||||
return instance
|
||||
|
||||
def __init_subclass__(cls) -> None:
|
||||
super().__init_subclass__()
|
||||
is_generic_template = _is_generic_layer_template(cls)
|
||||
deps_type = cls.__dict__.get("deps_type")
|
||||
if deps_type is None:
|
||||
deps_type = _infer_deps_type(cls) or getattr(cls, "deps_type", None)
|
||||
if deps_type is None and is_generic_template:
|
||||
return
|
||||
if deps_type is not None:
|
||||
cls.deps_type = deps_type # pyright: ignore[reportAttributeAccessIssue]
|
||||
if deps_type is None:
|
||||
raise TypeError(f"{cls.__name__} must define deps_type or inherit from Layer[DepsT].")
|
||||
if not isinstance(deps_type, type) or not issubclass(deps_type, LayerDeps):
|
||||
raise TypeError(f"{cls.__name__}.deps_type must be a LayerDeps subclass.")
|
||||
_get_dep_specs(deps_type)
|
||||
_init_config_type(cls, _infer_config_type(cls))
|
||||
_init_schema_type(
|
||||
cls,
|
||||
"runtime_state_type",
|
||||
_infer_schema_type(cls, 5, "runtime_state_type"),
|
||||
EmptyRuntimeState,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_config(cls: type[Self], config: _ConfigT) -> Self:
|
||||
"""Create a layer from schema-validated serialized config.
|
||||
|
||||
``LayerProvider.from_layer_type`` validates raw config with
|
||||
``config_type`` before calling this method. Layers without config use the
|
||||
default no-argument construction path. Layers with a concrete config
|
||||
schema should override this method and consume the typed Pydantic model.
|
||||
"""
|
||||
if cls.config_type is not EmptyLayerConfig:
|
||||
raise TypeError(f"{cls.__name__} cannot be created from config; override from_config or use a provider.")
|
||||
EmptyLayerConfig.model_validate(config)
|
||||
try:
|
||||
return cast(Self, cls())
|
||||
except TypeError as e:
|
||||
raise TypeError(f"{cls.__name__} cannot be created from empty config; use a custom provider.") from e
|
||||
|
||||
@classmethod
|
||||
def dependency_names(cls) -> frozenset[str]:
|
||||
"""Return dependency field names declared by this layer's deps schema."""
|
||||
return frozenset(_get_dep_specs(cls.deps_type))
|
||||
|
||||
def bind_deps(self, deps: Mapping[str, "Layer[Any, Any, Any, Any, Any, Any] | None"]) -> None:
|
||||
"""Bind this layer's declared dependencies from a name-to-layer mapping.
|
||||
|
||||
The mapping may include more layers than the declared dependency fields.
|
||||
Only names declared by ``deps_type`` are selected and validated. Missing
|
||||
optional deps are bound as ``None``. Bound values are direct layer
|
||||
instances for this invocation graph.
|
||||
"""
|
||||
resolved_deps: dict[str, Layer[Any, Any, Any, Any, Any, Any] | None] = {}
|
||||
for name, spec in _get_dep_specs(self.deps_type).items():
|
||||
if name not in deps:
|
||||
if spec.optional:
|
||||
resolved_deps[name] = None
|
||||
continue
|
||||
raise ValueError(f"Dependency '{name}' is required for layer '{type(self).__name__}' but not provided.")
|
||||
resolved_deps[name] = deps[name]
|
||||
self.deps = self.deps_type(**resolved_deps)
|
||||
|
||||
async def on_context_create(self) -> None:
|
||||
"""Run when the run slot enters from ``LifecycleState.NEW``."""
|
||||
|
||||
async def on_context_delete(self) -> None:
|
||||
"""Run when the run slot exits with ``ExitIntent.DELETE``."""
|
||||
|
||||
async def on_context_suspend(self) -> None:
|
||||
"""Run when the run slot exits with ``ExitIntent.SUSPEND``."""
|
||||
|
||||
async def on_context_resume(self) -> None:
|
||||
"""Run when the run slot enters from ``LifecycleState.SUSPENDED``."""
|
||||
|
||||
@property
|
||||
def prefix_prompts(self) -> Sequence[_PromptT]:
|
||||
return []
|
||||
|
||||
@property
|
||||
def suffix_prompts(self) -> Sequence[_PromptT]:
|
||||
return []
|
||||
|
||||
@property
|
||||
def user_prompts(self) -> Sequence[_UserPromptT]:
|
||||
return []
|
||||
|
||||
@property
|
||||
def tools(self) -> Sequence[_ToolT]:
|
||||
return []
|
||||
|
||||
@abstractmethod
|
||||
def wrap_prompt(self, prompt: _PromptT) -> object:
|
||||
"""Wrap a native prompt item for run-level aggregation."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def wrap_user_prompt(self, prompt: _UserPromptT) -> object:
|
||||
"""Wrap a native user prompt item for run-level aggregation."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def wrap_tool(self, tool: _ToolT) -> object:
|
||||
"""Wrap a native tool item for run-level aggregation."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def _get_dep_specs(deps_type: type[LayerDeps]) -> dict[str, LayerDepSpec]:
|
||||
dep_specs: dict[str, LayerDepSpec] = {}
|
||||
for name, annotation in get_type_hints(deps_type).items():
|
||||
spec = _as_dep_spec(annotation)
|
||||
if spec is None:
|
||||
raise TypeError(
|
||||
f"{deps_type.__name__}.{name} must be annotated with a Layer subclass or Layer subclass | None."
|
||||
)
|
||||
dep_specs[name] = spec
|
||||
return dep_specs
|
||||
|
||||
|
||||
def _as_dep_spec(annotation: object) -> LayerDepSpec | None:
|
||||
origin = get_origin(annotation)
|
||||
args = get_args(annotation)
|
||||
if origin in (UnionType, Union) and len(args) == 2 and type(None) in args:
|
||||
layer_annotation = args[0] if args[1] is type(None) else args[1]
|
||||
layer_type = _as_layer_type(layer_annotation)
|
||||
if layer_type is None:
|
||||
return None
|
||||
return LayerDepSpec(layer_type=layer_type, optional=True)
|
||||
|
||||
layer_type = _as_layer_type(annotation)
|
||||
if layer_type is None:
|
||||
return None
|
||||
return LayerDepSpec(layer_type=layer_type)
|
||||
|
||||
|
||||
def _as_layer_type(annotation: object) -> type[Layer[Any, Any, Any, Any, Any, Any]] | None:
|
||||
runtime_type = get_origin(annotation) or annotation
|
||||
if isinstance(runtime_type, type) and issubclass(runtime_type, Layer):
|
||||
return cast(type[Layer[Any, Any, Any, Any, Any, Any]], runtime_type)
|
||||
return None
|
||||
|
||||
|
||||
def _infer_deps_type(layer_type: type[Layer[Any, Any, Any, Any, Any, Any]]) -> type[LayerDeps] | None:
|
||||
inferred = _infer_layer_generic_arg(layer_type, 0, {})
|
||||
if inferred is None:
|
||||
return None
|
||||
return _as_deps_type(inferred)
|
||||
|
||||
|
||||
def _infer_schema_type(
|
||||
layer_type: type[Layer[Any, Any, Any, Any, Any, Any]],
|
||||
index: int,
|
||||
attr_name: str,
|
||||
) -> type[BaseModel] | None:
|
||||
inferred = _infer_schema_generic_arg(layer_type, attr_name, {}) or _infer_layer_generic_arg(layer_type, index, {})
|
||||
if inferred is None:
|
||||
return None
|
||||
schema_type = _as_model_type(inferred)
|
||||
if schema_type is None:
|
||||
raise TypeError(f"{layer_type.__name__}.{attr_name} must be a Pydantic BaseModel subclass.")
|
||||
return schema_type
|
||||
|
||||
|
||||
def _infer_config_type(layer_type: type[Layer[Any, Any, Any, Any, Any, Any]]) -> type[LayerConfig] | None:
|
||||
inferred = _infer_schema_generic_arg(layer_type, "config_type", {}) or _infer_layer_generic_arg(layer_type, 4, {})
|
||||
if inferred is None:
|
||||
return None
|
||||
config_type = _as_config_type(inferred)
|
||||
if config_type is None:
|
||||
raise TypeError(f"{layer_type.__name__}.config_type must be a LayerConfig subclass.")
|
||||
return config_type
|
||||
|
||||
|
||||
def _infer_schema_generic_arg(
|
||||
layer_type: type[Layer[Any, Any, Any, Any, Any, Any]],
|
||||
attr_name: str,
|
||||
substitutions: Mapping[object, object],
|
||||
) -> object | None:
|
||||
"""Infer schema type arguments exposed by typed layer family bases."""
|
||||
expected_names = {
|
||||
"config_type": {"ConfigT", "_ConfigT"},
|
||||
"runtime_state_type": {"RuntimeStateT", "_RuntimeStateT"},
|
||||
}[attr_name]
|
||||
for base in getattr(layer_type, "__orig_bases__", ()):
|
||||
origin = get_origin(base) or base
|
||||
args = tuple(_substitute_type(arg, substitutions) for arg in get_args(base))
|
||||
if not isinstance(origin, type) or not issubclass(origin, Layer):
|
||||
continue
|
||||
|
||||
params = _generic_params(origin)
|
||||
for param, arg in zip(params, args):
|
||||
if getattr(param, "__name__", None) in expected_names:
|
||||
return arg
|
||||
|
||||
next_substitutions = dict(substitutions)
|
||||
next_substitutions.update(_generic_arg_substitutions(origin, args))
|
||||
inferred = _infer_schema_generic_arg(origin, attr_name, next_substitutions)
|
||||
if inferred is not None:
|
||||
return inferred
|
||||
return None
|
||||
|
||||
|
||||
def _infer_layer_generic_arg(
|
||||
layer_type: type[Layer[Any, Any, Any, Any, Any, Any]],
|
||||
index: int,
|
||||
substitutions: Mapping[object, object],
|
||||
) -> object | None:
|
||||
"""Infer one concrete ``Layer`` generic argument through inheritance.
|
||||
|
||||
This walks through intermediate generic base classes so subclasses can omit
|
||||
explicit class attributes in common cases such as ``class X(Base[YDeps])``.
|
||||
"""
|
||||
for base in getattr(layer_type, "__orig_bases__", ()):
|
||||
origin = get_origin(base) or base
|
||||
args = tuple(_substitute_type(arg, substitutions) for arg in get_args(base))
|
||||
if origin is Layer:
|
||||
if len(args) <= index:
|
||||
continue
|
||||
return args[index]
|
||||
|
||||
if not isinstance(origin, type) or not issubclass(origin, Layer):
|
||||
continue
|
||||
|
||||
next_substitutions = dict(substitutions)
|
||||
next_substitutions.update(_generic_arg_substitutions(origin, args))
|
||||
inferred = _infer_layer_generic_arg(origin, index, next_substitutions)
|
||||
if inferred is not None:
|
||||
return inferred
|
||||
return None
|
||||
|
||||
|
||||
def _init_schema_type(
|
||||
layer_type: type[Layer[Any, Any, Any, Any, Any, Any]],
|
||||
attr_name: str,
|
||||
inferred_schema_type: type[BaseModel] | None,
|
||||
default_schema_type: type[BaseModel],
|
||||
) -> None:
|
||||
schema_type = layer_type.__dict__.get(attr_name)
|
||||
if schema_type is None:
|
||||
schema_type = inferred_schema_type or getattr(layer_type, attr_name, default_schema_type)
|
||||
setattr(layer_type, attr_name, schema_type)
|
||||
if not isinstance(schema_type, type) or not issubclass(schema_type, BaseModel):
|
||||
raise TypeError(f"{layer_type.__name__}.{attr_name} must be a Pydantic BaseModel subclass.")
|
||||
|
||||
|
||||
def _init_config_type(
|
||||
layer_type: type[Layer[Any, Any, Any, Any, Any, Any]],
|
||||
inferred_config_type: type[LayerConfig] | None,
|
||||
) -> None:
|
||||
config_type = layer_type.__dict__.get("config_type")
|
||||
if config_type is None:
|
||||
config_type = inferred_config_type or getattr(layer_type, "config_type", EmptyLayerConfig)
|
||||
setattr(layer_type, "config_type", config_type)
|
||||
if not isinstance(config_type, type) or not issubclass(config_type, LayerConfig):
|
||||
raise TypeError(f"{layer_type.__name__}.config_type must be a LayerConfig subclass.")
|
||||
|
||||
|
||||
def _substitute_type(value: object, substitutions: Mapping[object, object]) -> object:
|
||||
if value in substitutions:
|
||||
return substitutions[value]
|
||||
|
||||
origin = get_origin(value)
|
||||
if origin is None:
|
||||
return value
|
||||
|
||||
args = get_args(value)
|
||||
if not args:
|
||||
return value
|
||||
|
||||
substituted_args = tuple(_substitute_type(arg, substitutions) for arg in args)
|
||||
if substituted_args == args:
|
||||
return value
|
||||
|
||||
try:
|
||||
return origin[substituted_args]
|
||||
except TypeError:
|
||||
return value
|
||||
|
||||
|
||||
def _generic_arg_substitutions(origin: type[Any], args: Sequence[object]) -> dict[object, object]:
|
||||
params = _generic_params(origin)
|
||||
return dict(zip(params, args))
|
||||
|
||||
|
||||
def _generic_params(origin: type[Any]) -> Sequence[object]:
|
||||
params = getattr(origin, "__type_params__", ())
|
||||
if not params:
|
||||
params = getattr(origin, "__parameters__", ())
|
||||
return params
|
||||
|
||||
|
||||
def _as_deps_type(value: object) -> type[LayerDeps] | None:
|
||||
runtime_type = get_origin(value) or value
|
||||
if isinstance(runtime_type, type) and issubclass(runtime_type, LayerDeps):
|
||||
return runtime_type
|
||||
return None
|
||||
|
||||
|
||||
def _as_model_type(value: object) -> type[BaseModel] | None:
|
||||
runtime_type = get_origin(value) or value
|
||||
if isinstance(runtime_type, type) and issubclass(runtime_type, BaseModel):
|
||||
return runtime_type
|
||||
return None
|
||||
|
||||
|
||||
def _as_config_type(value: object) -> type[LayerConfig] | None:
|
||||
runtime_type = get_origin(value) or value
|
||||
if isinstance(runtime_type, type) and issubclass(runtime_type, LayerConfig):
|
||||
return runtime_type
|
||||
return None
|
||||
|
||||
|
||||
def _is_generic_layer_template(layer_type: type[Layer[Any, Any, Any, Any, Any, Any]]) -> bool:
|
||||
return bool(getattr(layer_type, "__type_params__", ())) or bool(getattr(layer_type, "__parameters__", ()))
|
||||
184
dify-agent/src/agenton/layers/types.py
Normal file
184
dify-agent/src/agenton/layers/types.py
Normal file
@ -0,0 +1,184 @@
|
||||
"""Typed layer family definitions.
|
||||
|
||||
``Layer`` itself is framework-neutral. This module defines typed layer families
|
||||
that bind its system prompt, user prompt, and tool generic slots to concrete
|
||||
contracts, such as ordinary strings with plain callable tools or pydantic-ai
|
||||
prompt/tool shapes. The families keep the trailing schema generic slots open so
|
||||
concrete layers can have ``config_type`` and ``runtime_state_type`` inferred from
|
||||
type arguments instead of repeated class attributes. Config schemas use
|
||||
``LayerConfig`` so they can also be embedded as
|
||||
typed DTOs in serializable compositor config. Agenton core is state-only:
|
||||
typed layer families do not expose runtime handle schemas or resource ownership.
|
||||
Tagged aggregate aliases cover code paths that can accept any supported
|
||||
prompt/tool family without changing the plain and pydantic-ai layer contracts.
|
||||
Pydantic-ai names are imported for static analysis only, so ``agenton`` can be
|
||||
imported without loading that optional integration at runtime.
|
||||
Concrete reusable layers live under ``agenton_collections``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Generic, Literal
|
||||
|
||||
from typing_extensions import TypeVar, final, override
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic_ai import Tool
|
||||
from pydantic_ai.messages import UserContent
|
||||
from pydantic_ai.tools import SystemPromptFunc
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agenton.layers.base import EmptyLayerConfig, EmptyRuntimeState, Layer, LayerConfig, LayerDeps
|
||||
|
||||
type PlainPrompt = str
|
||||
type PlainUserPrompt = str
|
||||
type PlainTool = Callable[..., Any]
|
||||
|
||||
|
||||
type PydanticAIPrompt[AgentDepsT] = SystemPromptFunc[AgentDepsT]
|
||||
type PydanticAIUserPrompt = UserContent
|
||||
type PydanticAITool[AgentDepsT] = Tool[AgentDepsT]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlainPromptType:
|
||||
"""Tagged plain prompt item for aggregate prompt transformations."""
|
||||
|
||||
value: PlainPrompt
|
||||
kind: Literal["plain"] = field(default="plain", init=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlainToolType:
|
||||
"""Tagged plain tool item for aggregate tool transformations."""
|
||||
|
||||
value: PlainTool
|
||||
kind: Literal["plain"] = field(default="plain", init=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlainUserPromptType:
|
||||
"""Tagged plain user prompt item for aggregate user prompt transformations."""
|
||||
|
||||
value: PlainUserPrompt
|
||||
kind: Literal["plain"] = field(default="plain", init=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PydanticAIPromptType[AgentDepsT]:
|
||||
"""Tagged pydantic-ai prompt item for aggregate prompt transformations."""
|
||||
|
||||
value: PydanticAIPrompt[AgentDepsT]
|
||||
kind: Literal["pydantic_ai"] = field(default="pydantic_ai", init=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PydanticAIUserPromptType:
|
||||
"""Tagged pydantic-ai user prompt item for aggregate user prompts."""
|
||||
|
||||
value: PydanticAIUserPrompt
|
||||
kind: Literal["pydantic_ai"] = field(default="pydantic_ai", init=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PydanticAIToolType[AgentDepsT]:
|
||||
"""Tagged pydantic-ai tool item for aggregate tool transformations."""
|
||||
|
||||
value: PydanticAITool[AgentDepsT]
|
||||
kind: Literal["pydantic_ai"] = field(default="pydantic_ai", init=False)
|
||||
|
||||
|
||||
type AllPromptTypes = PlainPromptType | PydanticAIPromptType[Any]
|
||||
type AllUserPromptTypes = PlainUserPromptType | PydanticAIUserPromptType
|
||||
type AllToolTypes = PlainToolType | PydanticAIToolType[Any]
|
||||
|
||||
|
||||
_DepsT = TypeVar("_DepsT", bound=LayerDeps)
|
||||
_ConfigT = TypeVar("_ConfigT", bound=LayerConfig, default=EmptyLayerConfig)
|
||||
_RuntimeStateT = TypeVar("_RuntimeStateT", bound=BaseModel, default=EmptyRuntimeState)
|
||||
_AgentDepsT = TypeVar("_AgentDepsT")
|
||||
|
||||
|
||||
class PlainLayer(
|
||||
Generic[_DepsT, _ConfigT, _RuntimeStateT],
|
||||
Layer[
|
||||
_DepsT,
|
||||
PlainPrompt,
|
||||
PlainUserPrompt,
|
||||
PlainTool,
|
||||
_ConfigT,
|
||||
_RuntimeStateT,
|
||||
],
|
||||
):
|
||||
"""Layer base for ordinary string prompts and plain-callable tools."""
|
||||
|
||||
@final
|
||||
@override
|
||||
def wrap_prompt(self, prompt: PlainPrompt) -> PlainPromptType:
|
||||
return PlainPromptType(prompt)
|
||||
|
||||
@final
|
||||
@override
|
||||
def wrap_user_prompt(self, prompt: PlainUserPrompt) -> PlainUserPromptType:
|
||||
return PlainUserPromptType(prompt)
|
||||
|
||||
@final
|
||||
@override
|
||||
def wrap_tool(self, tool: PlainTool) -> PlainToolType:
|
||||
return PlainToolType(tool)
|
||||
|
||||
|
||||
class PydanticAILayer(
|
||||
Generic[_DepsT, _AgentDepsT, _ConfigT, _RuntimeStateT],
|
||||
Layer[
|
||||
_DepsT,
|
||||
PydanticAIPrompt[_AgentDepsT],
|
||||
PydanticAIUserPrompt,
|
||||
PydanticAITool[_AgentDepsT],
|
||||
_ConfigT,
|
||||
_RuntimeStateT,
|
||||
],
|
||||
):
|
||||
"""Layer base for pydantic-ai prompt and tool adapters."""
|
||||
|
||||
@final
|
||||
@override
|
||||
def wrap_prompt(
|
||||
self,
|
||||
prompt: PydanticAIPrompt[_AgentDepsT],
|
||||
) -> PydanticAIPromptType[_AgentDepsT]:
|
||||
return PydanticAIPromptType(prompt)
|
||||
|
||||
@final
|
||||
@override
|
||||
def wrap_user_prompt(self, prompt: PydanticAIUserPrompt) -> PydanticAIUserPromptType:
|
||||
return PydanticAIUserPromptType(prompt)
|
||||
|
||||
@final
|
||||
@override
|
||||
def wrap_tool(self, tool: PydanticAITool[_AgentDepsT]) -> PydanticAIToolType[_AgentDepsT]:
|
||||
return PydanticAIToolType(tool)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AllPromptTypes",
|
||||
"AllUserPromptTypes",
|
||||
"AllToolTypes",
|
||||
"PlainLayer",
|
||||
"PlainPrompt",
|
||||
"PlainPromptType",
|
||||
"PlainUserPrompt",
|
||||
"PlainUserPromptType",
|
||||
"PlainTool",
|
||||
"PlainToolType",
|
||||
"PydanticAILayer",
|
||||
"PydanticAIPrompt",
|
||||
"PydanticAIPromptType",
|
||||
"PydanticAIUserPrompt",
|
||||
"PydanticAIUserPromptType",
|
||||
"PydanticAITool",
|
||||
"PydanticAIToolType",
|
||||
]
|
||||
52
dify-agent/src/agenton_collections/__init__.py
Normal file
52
dify-agent/src/agenton_collections/__init__.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""Convenience exports for reusable layer implementations.
|
||||
|
||||
Concrete collection layers live in family subpackages such as
|
||||
``agenton_collections.layers.plain`` and
|
||||
``agenton_collections.layers.pydantic_ai``. The package root keeps short
|
||||
client-safe imports for common plain layers while requiring explicit submodule
|
||||
imports for pydantic-ai bridge implementations.
|
||||
"""
|
||||
|
||||
from agenton.layers.types import (
|
||||
AllPromptTypes,
|
||||
AllToolTypes,
|
||||
PlainLayer,
|
||||
PlainPrompt,
|
||||
PlainPromptType,
|
||||
PlainTool,
|
||||
PlainToolType,
|
||||
PydanticAILayer,
|
||||
PydanticAIPrompt,
|
||||
PydanticAIPromptType,
|
||||
PydanticAITool,
|
||||
PydanticAIToolType,
|
||||
)
|
||||
from agenton_collections.layers.plain import (
|
||||
DynamicToolsLayer,
|
||||
DynamicToolsLayerDeps,
|
||||
ObjectLayer,
|
||||
PromptLayer,
|
||||
ToolsLayer,
|
||||
with_object,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AllPromptTypes",
|
||||
"AllToolTypes",
|
||||
"DynamicToolsLayer",
|
||||
"DynamicToolsLayerDeps",
|
||||
"ObjectLayer",
|
||||
"PlainLayer",
|
||||
"PlainPrompt",
|
||||
"PlainPromptType",
|
||||
"PlainTool",
|
||||
"PlainToolType",
|
||||
"PromptLayer",
|
||||
"PydanticAILayer",
|
||||
"PydanticAIPrompt",
|
||||
"PydanticAIPromptType",
|
||||
"PydanticAITool",
|
||||
"PydanticAIToolType",
|
||||
"ToolsLayer",
|
||||
"with_object",
|
||||
]
|
||||
25
dify-agent/src/agenton_collections/layers/plain/__init__.py
Normal file
25
dify-agent/src/agenton_collections/layers/plain/__init__.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""Reusable collection layers for the plain layer family."""
|
||||
|
||||
from agenton_collections.layers.plain.basic import (
|
||||
PLAIN_PROMPT_LAYER_TYPE_ID,
|
||||
ObjectLayer,
|
||||
PromptLayer,
|
||||
PromptLayerConfig,
|
||||
ToolsLayer,
|
||||
)
|
||||
from agenton_collections.layers.plain.dynamic_tools import (
|
||||
DynamicToolsLayer,
|
||||
DynamicToolsLayerDeps,
|
||||
with_object,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DynamicToolsLayer",
|
||||
"DynamicToolsLayerDeps",
|
||||
"ObjectLayer",
|
||||
"PLAIN_PROMPT_LAYER_TYPE_ID",
|
||||
"PromptLayer",
|
||||
"PromptLayerConfig",
|
||||
"ToolsLayer",
|
||||
"with_object",
|
||||
]
|
||||
103
dify-agent/src/agenton_collections/layers/plain/basic.py
Normal file
103
dify-agent/src/agenton_collections/layers/plain/basic.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""Basic ready-to-compose layers for common plain use cases.
|
||||
|
||||
These layers are small concrete implementations built on
|
||||
``agenton.layers.types``. They intentionally stay free of compositor graph
|
||||
construction so they can be reused from config, examples, and higher-level
|
||||
dynamic layers.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Final
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers.base import LayerConfig, NoLayerDeps
|
||||
from agenton.layers.types import PlainLayer
|
||||
|
||||
|
||||
PLAIN_PROMPT_LAYER_TYPE_ID: Final[str] = "plain.prompt"
|
||||
|
||||
|
||||
class PromptLayerConfig(LayerConfig):
|
||||
"""Serializable config schema for ``PromptLayer``."""
|
||||
|
||||
prefix: list[str] | str = Field(default_factory=list)
|
||||
user: list[str] | str = Field(default_factory=list)
|
||||
suffix: list[str] | str = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ObjectLayer[ObjectT](PlainLayer[NoLayerDeps]):
|
||||
"""Layer that stores one typed object for downstream dependencies.
|
||||
|
||||
Object layers are instance-only because arbitrary Python objects are not
|
||||
serializable graph config. Add them with a custom ``LayerProvider`` factory
|
||||
that creates a fresh object layer for each compositor run.
|
||||
"""
|
||||
|
||||
value: ObjectT
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptLayer(PlainLayer[NoLayerDeps, PromptLayerConfig]):
|
||||
"""Layer that contributes configured system and user prompt fragments."""
|
||||
|
||||
type_id = PLAIN_PROMPT_LAYER_TYPE_ID
|
||||
|
||||
prefix: list[str] | str = field(default_factory=list)
|
||||
user: list[str] | str = field(default_factory=list)
|
||||
suffix: list[str] | str = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def from_config(cls, config: PromptLayerConfig) -> Self:
|
||||
"""Create a prompt layer from validated prompt config."""
|
||||
validated_config = PromptLayerConfig.model_validate(config)
|
||||
return cls(prefix=validated_config.prefix, user=validated_config.user, suffix=validated_config.suffix)
|
||||
|
||||
@property
|
||||
def prefix_prompts(self) -> list[str]:
|
||||
if isinstance(self.prefix, str):
|
||||
return [self.prefix]
|
||||
return self.prefix
|
||||
|
||||
@property
|
||||
def suffix_prompts(self) -> list[str]:
|
||||
if isinstance(self.suffix, str):
|
||||
return [self.suffix]
|
||||
return self.suffix
|
||||
|
||||
@property
|
||||
def user_prompts(self) -> list[str]:
|
||||
if isinstance(self.user, str):
|
||||
return [self.user]
|
||||
return self.user
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolsLayer(PlainLayer[NoLayerDeps]):
|
||||
"""Layer that contributes configured plain-callable tools.
|
||||
|
||||
Tool layers are instance-only because Python callables are live objects. Add
|
||||
them with a custom ``LayerProvider`` factory that returns a fresh layer for
|
||||
each compositor run.
|
||||
"""
|
||||
|
||||
tool_entries: Sequence[Callable[..., Any]] = ()
|
||||
|
||||
@property
|
||||
def tools(self) -> list[Callable[..., Any]]:
|
||||
return list(self.tool_entries)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ObjectLayer",
|
||||
"PLAIN_PROMPT_LAYER_TYPE_ID",
|
||||
"PromptLayerConfig",
|
||||
"PromptLayer",
|
||||
"ToolsLayer",
|
||||
]
|
||||
232
dify-agent/src/agenton_collections/layers/plain/dynamic_tools.py
Normal file
232
dify-agent/src/agenton_collections/layers/plain/dynamic_tools.py
Normal file
@ -0,0 +1,232 @@
|
||||
"""Dynamic plain-tool layer with object-bound tool entries.
|
||||
|
||||
This module builds on ``ObjectLayer`` from ``agenton_collections.plain.basic``.
|
||||
Plain callables are exposed unchanged, while entries wrapped with
|
||||
``with_object`` bind the current object value into the first callable argument
|
||||
and expose the remaining parameters as the public tool signature.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from functools import wraps
|
||||
from inspect import Parameter, Signature, iscoroutinefunction, signature
|
||||
from types import UnionType
|
||||
from typing import (
|
||||
Annotated,
|
||||
Any,
|
||||
Concatenate,
|
||||
Union,
|
||||
get_args,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
from agenton.layers.base import LayerDeps
|
||||
from agenton.layers.types import PlainLayer
|
||||
from agenton_collections.layers.plain.basic import ObjectLayer
|
||||
|
||||
type _ObjectToolCallable[ObjectT] = Callable[Concatenate[ObjectT, ...], Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ObjectToolEntry[ObjectT]:
|
||||
"""Tool entry whose first argument should be filled from ``ObjectLayer``."""
|
||||
|
||||
tool_entry: _ObjectToolCallable[ObjectT]
|
||||
object_type: type[ObjectT] | None = None
|
||||
|
||||
|
||||
type _DynamicToolEntry[ObjectT] = Callable[..., Any] | _ObjectToolEntry[ObjectT]
|
||||
|
||||
|
||||
def with_object[ObjectT](
|
||||
object_type: type[ObjectT],
|
||||
/,
|
||||
) -> Callable[[_ObjectToolCallable[ObjectT]], _ObjectToolEntry[ObjectT]]:
|
||||
"""Mark a tool as requiring the bound object value as its first argument."""
|
||||
|
||||
def decorator(tool_entry: _ObjectToolCallable[ObjectT]) -> _ObjectToolEntry[ObjectT]:
|
||||
_validate_object_tool_annotation(tool_entry, object_type)
|
||||
return _ObjectToolEntry(tool_entry=tool_entry, object_type=object_type)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class DynamicToolsLayerDeps[ObjectT](LayerDeps):
|
||||
"""Dependencies required by ``DynamicToolsLayer``."""
|
||||
|
||||
object_layer: ObjectLayer[ObjectT] # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DynamicToolsLayer[ObjectT](PlainLayer[DynamicToolsLayerDeps[ObjectT]]):
|
||||
"""Layer that exposes plain tools and object-bound tools."""
|
||||
|
||||
tool_entries: Sequence[_DynamicToolEntry[ObjectT]] = ()
|
||||
|
||||
@property
|
||||
def tools(self) -> list[Callable[..., Any]]:
|
||||
object_value = self.deps.object_layer.value
|
||||
return [
|
||||
_bind_object_argument(tool_entry.tool_entry, object_value, tool_entry.object_type)
|
||||
if isinstance(tool_entry, _ObjectToolEntry)
|
||||
else tool_entry
|
||||
for tool_entry in self.tool_entries
|
||||
]
|
||||
|
||||
|
||||
def _bind_object_argument[ObjectT](
|
||||
tool_entry: _ObjectToolCallable[ObjectT],
|
||||
object_value: ObjectT,
|
||||
object_type: type[ObjectT] | None,
|
||||
) -> Callable[..., Any]:
|
||||
_validate_object_value(tool_entry, object_value, object_type)
|
||||
if iscoroutinefunction(tool_entry):
|
||||
wrapped = _async_object_wrapper(tool_entry, object_value)
|
||||
else:
|
||||
wrapped = _sync_object_wrapper(tool_entry, object_value)
|
||||
|
||||
public_signature = _public_tool_signature(tool_entry)
|
||||
if public_signature is not None:
|
||||
setattr(wrapped, "__signature__", public_signature)
|
||||
_set_public_annotations(wrapped, tool_entry)
|
||||
return wrapped
|
||||
|
||||
|
||||
def _validate_object_tool_annotation[ObjectT](
|
||||
tool_entry: _ObjectToolCallable[ObjectT],
|
||||
object_type: type[ObjectT],
|
||||
) -> None:
|
||||
parameter = _first_object_parameter(tool_entry)
|
||||
if parameter is None:
|
||||
return
|
||||
|
||||
annotation = _parameter_annotation(tool_entry, parameter)
|
||||
if annotation is Parameter.empty:
|
||||
return
|
||||
if _annotation_accepts_object_type(annotation, object_type):
|
||||
return
|
||||
|
||||
raise TypeError(
|
||||
f"Object-bound tool '{_tool_name(tool_entry)}' first parameter should accept '{_type_name(object_type)}'."
|
||||
)
|
||||
|
||||
|
||||
def _first_object_parameter(tool_entry: Callable[..., Any]) -> Parameter | None:
|
||||
try:
|
||||
tool_signature = signature(tool_entry)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
parameters = list(tool_signature.parameters.values())
|
||||
if not parameters:
|
||||
raise ValueError("Dynamic tools must accept the object dependency as their first parameter.")
|
||||
return parameters[0]
|
||||
|
||||
|
||||
def _parameter_annotation(tool_entry: Callable[..., Any], parameter: Parameter) -> object:
|
||||
try:
|
||||
type_hints = get_type_hints(tool_entry, include_extras=True)
|
||||
except (AttributeError, NameError, TypeError):
|
||||
return parameter.annotation
|
||||
return type_hints.get(parameter.name, parameter.annotation)
|
||||
|
||||
|
||||
def _annotation_accepts_object_type(annotation: object, object_type: type[Any]) -> bool:
|
||||
if annotation is Any or annotation is Parameter.empty:
|
||||
return True
|
||||
|
||||
origin = get_origin(annotation)
|
||||
if origin is Annotated:
|
||||
args = get_args(annotation)
|
||||
return True if not args else _annotation_accepts_object_type(args[0], object_type)
|
||||
if origin in (UnionType, Union):
|
||||
return any(
|
||||
arg is type(None) or _annotation_accepts_object_type(arg, object_type) for arg in get_args(annotation)
|
||||
)
|
||||
|
||||
runtime_type = origin or annotation
|
||||
if not isinstance(runtime_type, type):
|
||||
return True
|
||||
try:
|
||||
return issubclass(object_type, runtime_type)
|
||||
except TypeError:
|
||||
return True
|
||||
|
||||
|
||||
def _validate_object_value[ObjectT](
|
||||
tool_entry: _ObjectToolCallable[ObjectT],
|
||||
object_value: ObjectT,
|
||||
object_type: type[ObjectT] | None,
|
||||
) -> None:
|
||||
if object_type is None or isinstance(object_value, object_type):
|
||||
return
|
||||
raise TypeError(
|
||||
f"Object-bound tool '{_tool_name(tool_entry)}' expected object dependency "
|
||||
f"of type '{_type_name(object_type)}', but got '{type(object_value).__qualname__}'."
|
||||
)
|
||||
|
||||
|
||||
def _tool_name(tool_entry: Callable[..., Any]) -> str:
|
||||
return getattr(tool_entry, "__qualname__", getattr(tool_entry, "__name__", repr(tool_entry)))
|
||||
|
||||
|
||||
def _type_name(object_type: type[Any]) -> str:
|
||||
return object_type.__qualname__
|
||||
|
||||
|
||||
def _sync_object_wrapper[ObjectT](
|
||||
tool_entry: _ObjectToolCallable[ObjectT],
|
||||
object_value: ObjectT,
|
||||
) -> Callable[..., Any]:
|
||||
@wraps(tool_entry)
|
||||
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
||||
return tool_entry(object_value, *args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _async_object_wrapper[ObjectT](
|
||||
tool_entry: _ObjectToolCallable[ObjectT],
|
||||
object_value: ObjectT,
|
||||
) -> Callable[..., Any]:
|
||||
@wraps(tool_entry)
|
||||
async def wrapped(*args: Any, **kwargs: Any) -> Any:
|
||||
return await tool_entry(object_value, *args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _public_tool_signature(tool_entry: Callable[..., Any]) -> Signature | None:
|
||||
try:
|
||||
tool_signature = signature(tool_entry)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
parameters = list(tool_signature.parameters.values())
|
||||
if not parameters:
|
||||
raise ValueError("Dynamic tools must accept the object dependency as their first parameter.")
|
||||
return tool_signature.replace(parameters=parameters[1:])
|
||||
|
||||
|
||||
def _set_public_annotations(wrapper: Callable[..., Any], tool_entry: Callable[..., Any]) -> None:
|
||||
annotations = getattr(tool_entry, "__annotations__", None)
|
||||
if not isinstance(annotations, dict):
|
||||
return
|
||||
|
||||
try:
|
||||
parameters = list(signature(tool_entry).parameters)
|
||||
except (TypeError, ValueError):
|
||||
parameters = []
|
||||
|
||||
public_annotations = dict(annotations)
|
||||
if parameters:
|
||||
public_annotations.pop(parameters[0], None)
|
||||
wrapper.__annotations__ = public_annotations
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DynamicToolsLayer",
|
||||
"DynamicToolsLayerDeps",
|
||||
"with_object",
|
||||
]
|
||||
@ -0,0 +1,11 @@
|
||||
"""Reusable collection layers for the pydantic-ai layer family."""
|
||||
|
||||
from agenton_collections.layers.pydantic_ai.bridge import (
|
||||
PydanticAIBridgeLayer,
|
||||
PydanticAIBridgeLayerDeps,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PydanticAIBridgeLayer",
|
||||
"PydanticAIBridgeLayerDeps",
|
||||
]
|
||||
106
dify-agent/src/agenton_collections/layers/pydantic_ai/bridge.py
Normal file
106
dify-agent/src/agenton_collections/layers/pydantic_ai/bridge.py
Normal file
@ -0,0 +1,106 @@
|
||||
"""Pydantic AI bridge prompt and tool layer.
|
||||
|
||||
This module keeps pydantic-ai's callable shapes intact through
|
||||
``PydanticAILayer``. The bridge layer depends on ``ObjectLayer`` so callers have
|
||||
one explicit graph node that provides the object used as
|
||||
``RunContext[ObjectT].deps`` in pydantic-ai prompt and tool callables.
|
||||
Bridge construction accepts pydantic-ai's ergonomic input forms and normalizes
|
||||
them at the layer boundary: string system prompts become zero-arg system prompt
|
||||
functions, user prompts stay as pydantic-ai ``UserContent`` values, and bare
|
||||
tool functions become ``Tool`` instances.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pydantic_ai import Tool
|
||||
from pydantic_ai.messages import UserContent
|
||||
from pydantic_ai.tools import ToolFuncEither
|
||||
from typing_extensions import override
|
||||
|
||||
from agenton.layers.base import LayerDeps
|
||||
from agenton.layers.types import PydanticAILayer, PydanticAIPrompt, PydanticAITool, PydanticAIUserPrompt
|
||||
from agenton_collections.layers.plain.basic import ObjectLayer
|
||||
|
||||
|
||||
class PydanticAIBridgeLayerDeps[ObjectT](LayerDeps):
|
||||
"""Dependencies required by ``PydanticAIBridgeLayer``."""
|
||||
|
||||
object_layer: ObjectLayer[ObjectT] # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PydanticAIBridgeLayer[ObjectT](PydanticAILayer[PydanticAIBridgeLayerDeps[ObjectT], ObjectT]):
|
||||
"""Bridge layer for pydantic-ai prompts and tools using one object deps."""
|
||||
|
||||
prefix: str | PydanticAIPrompt[ObjectT] | Sequence[str | PydanticAIPrompt[ObjectT]] = ()
|
||||
user: UserContent | Sequence[UserContent] = ()
|
||||
suffix: str | PydanticAIPrompt[ObjectT] | Sequence[str | PydanticAIPrompt[ObjectT]] = ()
|
||||
tool_entries: Sequence[PydanticAITool[ObjectT] | ToolFuncEither[ObjectT, ...]] = ()
|
||||
|
||||
@property
|
||||
def run_deps(self) -> ObjectT:
|
||||
"""Object to pass as pydantic-ai run deps for this layer."""
|
||||
return self.deps.object_layer.value
|
||||
|
||||
@property
|
||||
@override
|
||||
def prefix_prompts(self) -> list[PydanticAIPrompt[ObjectT]]:
|
||||
return _normalize_prompts(self.prefix)
|
||||
|
||||
@property
|
||||
@override
|
||||
def suffix_prompts(self) -> list[PydanticAIPrompt[ObjectT]]:
|
||||
return _normalize_prompts(self.suffix)
|
||||
|
||||
@property
|
||||
@override
|
||||
def user_prompts(self) -> list[PydanticAIUserPrompt]:
|
||||
return _normalize_user_prompts(self.user)
|
||||
|
||||
@property
|
||||
@override
|
||||
def tools(self) -> list[PydanticAITool[ObjectT]]:
|
||||
return [_normalize_tool(tool_entry) for tool_entry in self.tool_entries]
|
||||
|
||||
|
||||
def _normalize_prompts[ObjectT](
|
||||
prompts: str | PydanticAIPrompt[ObjectT] | Sequence[str | PydanticAIPrompt[ObjectT]],
|
||||
) -> list[PydanticAIPrompt[ObjectT]]:
|
||||
if isinstance(prompts, str):
|
||||
return [_normalize_prompt(prompts)]
|
||||
if isinstance(prompts, Sequence):
|
||||
return [_normalize_prompt(prompt) for prompt in prompts]
|
||||
return [prompts]
|
||||
|
||||
|
||||
def _normalize_prompt[ObjectT](
|
||||
prompt: str | PydanticAIPrompt[ObjectT],
|
||||
) -> PydanticAIPrompt[ObjectT]:
|
||||
if isinstance(prompt, str):
|
||||
return (lambda value: lambda: value)(prompt)
|
||||
return prompt
|
||||
|
||||
|
||||
def _normalize_user_prompts(
|
||||
prompts: UserContent | Sequence[UserContent],
|
||||
) -> list[PydanticAIUserPrompt]:
|
||||
if isinstance(prompts, str):
|
||||
return [prompts]
|
||||
if isinstance(prompts, Sequence):
|
||||
return list(prompts)
|
||||
return [prompts]
|
||||
|
||||
|
||||
def _normalize_tool[ObjectT](
|
||||
tool_entry: PydanticAITool[ObjectT] | ToolFuncEither[ObjectT, ...],
|
||||
) -> PydanticAITool[ObjectT]:
|
||||
if isinstance(tool_entry, Tool):
|
||||
return tool_entry
|
||||
return Tool(tool_entry)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PydanticAIBridgeLayer",
|
||||
"PydanticAIBridgeLayerDeps",
|
||||
]
|
||||
@ -0,0 +1,8 @@
|
||||
"""Transformer package marker for collection integrations.
|
||||
|
||||
Import pydantic-ai transformer presets from
|
||||
``agenton_collections.transformers.pydantic_ai`` explicitly so default imports do
|
||||
not pull runtime bridge implementations into client-safe environments.
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
@ -0,0 +1,85 @@
|
||||
"""Pydantic AI compositor transformer presets.
|
||||
|
||||
This module owns the pydantic-ai runtime dependency for transforming tagged
|
||||
agenton system prompt, user prompt, and tool items into pydantic-ai-compatible
|
||||
items.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Final
|
||||
|
||||
from pydantic_ai import Tool
|
||||
|
||||
from agenton.compositor import CompositorTransformerKwargs
|
||||
from agenton.layers.types import (
|
||||
AllPromptTypes,
|
||||
AllToolTypes,
|
||||
AllUserPromptTypes,
|
||||
PydanticAIPrompt,
|
||||
PydanticAITool,
|
||||
PydanticAIUserPrompt,
|
||||
)
|
||||
|
||||
type PydanticAICompositorTransformerKwargs = CompositorTransformerKwargs[
|
||||
PydanticAIPrompt[object],
|
||||
PydanticAITool[object],
|
||||
AllPromptTypes,
|
||||
AllToolTypes,
|
||||
PydanticAIUserPrompt,
|
||||
AllUserPromptTypes,
|
||||
]
|
||||
|
||||
|
||||
def _pydantic_ai_prompt_transformer(
|
||||
prompts: Sequence[AllPromptTypes],
|
||||
) -> list[PydanticAIPrompt[object]]:
|
||||
result: list[PydanticAIPrompt[object]] = []
|
||||
for prompt in prompts:
|
||||
if prompt.kind == "plain":
|
||||
result.append((lambda value: lambda: value)(prompt.value))
|
||||
elif prompt.kind == "pydantic_ai":
|
||||
result.append(prompt.value)
|
||||
else:
|
||||
raise NotImplementedError(f"Unsupported prompt type: {type(prompt).__qualname__}.")
|
||||
return result
|
||||
|
||||
|
||||
def _pydantic_ai_user_prompt_transformer(
|
||||
prompts: Sequence[AllUserPromptTypes],
|
||||
) -> list[PydanticAIUserPrompt]:
|
||||
result: list[PydanticAIUserPrompt] = []
|
||||
for prompt in prompts:
|
||||
if prompt.kind == "plain":
|
||||
result.append(prompt.value)
|
||||
elif prompt.kind == "pydantic_ai":
|
||||
result.append(prompt.value)
|
||||
else:
|
||||
raise NotImplementedError(f"Unsupported user prompt type: {type(prompt).__qualname__}.")
|
||||
return result
|
||||
|
||||
|
||||
def _pydantic_ai_tool_transformer(
|
||||
tools: Sequence[AllToolTypes],
|
||||
) -> list[PydanticAITool[object]]:
|
||||
result: list[PydanticAITool[object]] = []
|
||||
for tool in tools:
|
||||
if tool.kind == "plain":
|
||||
result.append(Tool(tool.value))
|
||||
elif tool.kind == "pydantic_ai":
|
||||
result.append(tool.value)
|
||||
else:
|
||||
raise NotImplementedError(f"Unsupported tool type: {type(tool).__qualname__}.")
|
||||
return result
|
||||
|
||||
|
||||
PYDANTIC_AI_TRANSFORMERS: Final[PydanticAICompositorTransformerKwargs] = {
|
||||
"prompt_transformer": _pydantic_ai_prompt_transformer,
|
||||
"user_prompt_transformer": _pydantic_ai_user_prompt_transformer,
|
||||
"tool_transformer": _pydantic_ai_tool_transformer,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PYDANTIC_AI_TRANSFORMERS",
|
||||
"PydanticAICompositorTransformerKwargs",
|
||||
]
|
||||
10
dify-agent/src/dify_agent/__init__.py
Normal file
10
dify-agent/src/dify_agent/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""Client-safe top-level exports for the Dify Agent package.
|
||||
|
||||
Default installs must be able to import ``dify_agent`` without pulling in server
|
||||
runtime adapters or their optional dependencies. Server-only adapter entry points
|
||||
remain under ``dify_agent.adapters.llm``.
|
||||
"""
|
||||
|
||||
from dify_agent.client import Client
|
||||
|
||||
__all__ = ["Client"]
|
||||
1
dify-agent/src/dify_agent/adapters/__init__.py
Normal file
1
dify-agent/src/dify_agent/adapters/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Adapter integrations for Dify agent components."""
|
||||
6
dify-agent/src/dify_agent/adapters/llm/__init__.py
Normal file
6
dify-agent/src/dify_agent/adapters/llm/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""LLM adapters for Dify plugin-daemon integrations."""
|
||||
|
||||
from .model import DifyLLMAdapterModel
|
||||
from .provider import DifyPluginDaemonProvider
|
||||
|
||||
__all__ = ["DifyLLMAdapterModel", "DifyPluginDaemonProvider"]
|
||||
748
dify-agent/src/dify_agent/adapters/llm/model.py
Normal file
748
dify-agent/src/dify_agent/adapters/llm/model.py
Normal file
@ -0,0 +1,748 @@
|
||||
"""Bridge Dify plugin-daemon LLM invocations into Pydantic AI's model interface.
|
||||
|
||||
The API and agent layers are clients of the plugin daemon, not direct hosts of provider SDK
|
||||
implementations. This adapter therefore targets the plugin-daemon dispatch protocol and maps
|
||||
Pydantic AI messages into the daemon's Graphon-compatible request and stream response schema.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Mapping, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import KW_ONLY, InitVar, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import cast
|
||||
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResultChunk, LLMUsage
|
||||
from graphon.model_runtime.entities.message_entities import (
|
||||
AssistantPromptMessage,
|
||||
AudioPromptMessageContent,
|
||||
DocumentPromptMessageContent,
|
||||
ImagePromptMessageContent,
|
||||
PromptMessage,
|
||||
PromptMessageContentUnionTypes,
|
||||
PromptMessageTool,
|
||||
SystemPromptMessage,
|
||||
TextPromptMessageContent,
|
||||
ToolPromptMessage,
|
||||
UserPromptMessage,
|
||||
VideoPromptMessageContent,
|
||||
)
|
||||
from typing_extensions import assert_never, override
|
||||
|
||||
from pydantic_ai._parts_manager import ModelResponsePartsManager
|
||||
from pydantic_ai.exceptions import UnexpectedModelBehavior
|
||||
from pydantic_ai.messages import (
|
||||
AudioUrl,
|
||||
BinaryContent,
|
||||
BuiltinToolCallPart,
|
||||
BuiltinToolReturnPart,
|
||||
CachePoint,
|
||||
CompactionPart,
|
||||
DocumentUrl,
|
||||
FilePart,
|
||||
FinishReason,
|
||||
ImageUrl,
|
||||
ModelMessage,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
ModelResponsePart,
|
||||
ModelResponseStreamEvent,
|
||||
MultiModalContent,
|
||||
RetryPromptPart,
|
||||
SystemPromptPart,
|
||||
TextContent,
|
||||
TextPart,
|
||||
ThinkingPart,
|
||||
ToolCallPart,
|
||||
ToolReturnPart,
|
||||
UploadedFile,
|
||||
UserContent,
|
||||
UserPromptPart,
|
||||
VideoUrl,
|
||||
)
|
||||
from pydantic_ai.models import Model, ModelRequestParameters, StreamedResponse
|
||||
from pydantic_ai.profiles import ModelProfileSpec
|
||||
from pydantic_ai.settings import ModelSettings
|
||||
from pydantic_ai.usage import RequestUsage
|
||||
|
||||
from .provider import DifyPluginDaemonLLMClient, DifyPluginDaemonProvider
|
||||
|
||||
_THINK_START = "<think>\n"
|
||||
_THINK_END = "\n</think>"
|
||||
_THINK_OPEN_TAG = "<think>"
|
||||
_THINK_CLOSE_TAG = "</think>"
|
||||
_THINK_TAG_PATTERN = re.compile(r"<think>(.*?)</think>", re.DOTALL)
|
||||
_DETAIL_HIGH = "high"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _DifyRequestInput:
|
||||
credentials: dict[str, object]
|
||||
prompt_messages: list[PromptMessage]
|
||||
model_parameters: dict[str, object]
|
||||
tools: list[PromptMessageTool] | None
|
||||
stop_sequences: list[str] | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyLLMAdapterModel(Model[DifyPluginDaemonLLMClient]):
|
||||
"""Use a Dify plugin-daemon transport plus request-level model identity."""
|
||||
|
||||
model: str
|
||||
daemon_provider: DifyPluginDaemonProvider
|
||||
_: KW_ONLY
|
||||
model_provider: str
|
||||
credentials: dict[str, object] = field(default_factory=dict, repr=False)
|
||||
model_profile: InitVar[ModelProfileSpec | None] = None
|
||||
model_settings: InitVar[ModelSettings | None] = None
|
||||
|
||||
def __post_init__(
|
||||
self,
|
||||
model_profile: ModelProfileSpec | None,
|
||||
model_settings: ModelSettings | None,
|
||||
) -> None:
|
||||
Model.__init__(
|
||||
self,
|
||||
settings=model_settings,
|
||||
profile=model_profile or self.daemon_provider.model_profile(self.model),
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def provider(self) -> DifyPluginDaemonProvider:
|
||||
return self.daemon_provider
|
||||
|
||||
@property
|
||||
@override
|
||||
def model_name(self) -> str:
|
||||
return self.model
|
||||
|
||||
@property
|
||||
@override
|
||||
def system(self) -> str:
|
||||
return self.daemon_provider.name
|
||||
|
||||
@override
|
||||
async def request(
|
||||
self,
|
||||
messages: list[ModelMessage],
|
||||
model_settings: ModelSettings | None,
|
||||
model_request_parameters: ModelRequestParameters,
|
||||
) -> ModelResponse:
|
||||
prepared_settings, prepared_params = self.prepare_request(model_settings, model_request_parameters)
|
||||
request_input = self._build_request_input(messages, prepared_settings, prepared_params)
|
||||
|
||||
response = DifyStreamedResponse(
|
||||
model_request_parameters=prepared_params,
|
||||
chunks=self.daemon_provider.client.iter_llm_result_chunks(
|
||||
provider=self.model_provider,
|
||||
model=self.model_name,
|
||||
credentials=request_input.credentials,
|
||||
prompt_messages=request_input.prompt_messages,
|
||||
model_parameters=request_input.model_parameters,
|
||||
tools=request_input.tools,
|
||||
stop=request_input.stop_sequences,
|
||||
stream=False,
|
||||
),
|
||||
response_model_name=self.model_name,
|
||||
provider_name_value=self.system,
|
||||
)
|
||||
async for _event in response:
|
||||
pass
|
||||
return response.get()
|
||||
|
||||
@asynccontextmanager
|
||||
@override
|
||||
async def request_stream(
|
||||
self,
|
||||
messages: list[ModelMessage],
|
||||
model_settings: ModelSettings | None,
|
||||
model_request_parameters: ModelRequestParameters,
|
||||
run_context: object | None = None,
|
||||
) -> AsyncGenerator[StreamedResponse, None]:
|
||||
del run_context
|
||||
prepared_settings, prepared_params = self.prepare_request(model_settings, model_request_parameters)
|
||||
request_input = self._build_request_input(messages, prepared_settings, prepared_params)
|
||||
|
||||
yield DifyStreamedResponse(
|
||||
model_request_parameters=prepared_params,
|
||||
chunks=self.daemon_provider.client.iter_llm_result_chunks(
|
||||
provider=self.model_provider,
|
||||
model=self.model_name,
|
||||
credentials=request_input.credentials,
|
||||
prompt_messages=request_input.prompt_messages,
|
||||
model_parameters=request_input.model_parameters,
|
||||
tools=request_input.tools,
|
||||
stop=request_input.stop_sequences,
|
||||
stream=True,
|
||||
),
|
||||
response_model_name=self.model_name,
|
||||
provider_name_value=self.system,
|
||||
)
|
||||
|
||||
def _build_request_input(
|
||||
self,
|
||||
messages: Sequence[ModelMessage],
|
||||
model_settings: ModelSettings | None,
|
||||
model_request_parameters: ModelRequestParameters,
|
||||
) -> _DifyRequestInput:
|
||||
return _DifyRequestInput(
|
||||
credentials=dict(self.credentials),
|
||||
prompt_messages=_map_messages_to_prompt_messages(messages, model_request_parameters),
|
||||
model_parameters=_map_model_settings_to_parameters(model_settings),
|
||||
tools=_map_tool_definitions_to_prompt_tools(model_request_parameters),
|
||||
stop_sequences=_get_stop_sequences(model_settings),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DifyStreamedResponse(StreamedResponse):
|
||||
chunks: AsyncIterator[LLMResultChunk]
|
||||
response_model_name: str
|
||||
provider_name_value: str
|
||||
_timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
_embedded_thinking_parser: "_EmbeddedThinkingParser" = field(default_factory=lambda: _EmbeddedThinkingParser())
|
||||
|
||||
@override
|
||||
async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
|
||||
async for chunk in self.chunks:
|
||||
if chunk.delta.usage is not None:
|
||||
self._usage: RequestUsage = _map_usage(chunk.delta.usage)
|
||||
if chunk.delta.finish_reason is not None:
|
||||
self.finish_reason: FinishReason | None = _normalize_finish_reason(chunk.delta.finish_reason)
|
||||
|
||||
for event in _chunk_to_stream_events(
|
||||
self._parts_manager,
|
||||
chunk,
|
||||
self.provider_name_value,
|
||||
self._embedded_thinking_parser,
|
||||
):
|
||||
yield event
|
||||
|
||||
for event in self._embedded_thinking_parser.flush(self._parts_manager, self.provider_name_value):
|
||||
yield event
|
||||
|
||||
@property
|
||||
@override
|
||||
def model_name(self) -> str:
|
||||
return self.response_model_name
|
||||
|
||||
@property
|
||||
@override
|
||||
def provider_name(self) -> str:
|
||||
return self.provider_name_value
|
||||
|
||||
@property
|
||||
@override
|
||||
def provider_url(self) -> None:
|
||||
return None
|
||||
|
||||
@property
|
||||
@override
|
||||
def timestamp(self) -> datetime:
|
||||
return self._timestamp
|
||||
|
||||
|
||||
def _map_messages_to_prompt_messages(
|
||||
messages: Sequence[ModelMessage],
|
||||
model_request_parameters: ModelRequestParameters,
|
||||
) -> list[PromptMessage]:
|
||||
prompt_messages: list[PromptMessage] = []
|
||||
|
||||
for message in messages:
|
||||
if isinstance(message, ModelRequest):
|
||||
prompt_messages.extend(_map_model_request_to_prompt_messages(message))
|
||||
elif isinstance(message, ModelResponse):
|
||||
assistant_message = _map_model_response_to_prompt_message(message)
|
||||
if assistant_message is not None:
|
||||
prompt_messages.append(assistant_message)
|
||||
else:
|
||||
assert_never(message)
|
||||
|
||||
instruction_messages = [
|
||||
SystemPromptMessage(content=part.content)
|
||||
for part in (Model._get_instruction_parts(messages, model_request_parameters) or [])
|
||||
if part.content.strip()
|
||||
]
|
||||
if instruction_messages:
|
||||
insert_at = next(
|
||||
(index for index, message in enumerate(prompt_messages) if not isinstance(message, SystemPromptMessage)),
|
||||
len(prompt_messages),
|
||||
)
|
||||
prompt_messages[insert_at:insert_at] = instruction_messages
|
||||
|
||||
return prompt_messages
|
||||
|
||||
|
||||
def _map_model_request_to_prompt_messages(message: ModelRequest) -> list[PromptMessage]:
|
||||
prompt_messages: list[PromptMessage] = []
|
||||
|
||||
for part in message.parts:
|
||||
if isinstance(part, SystemPromptPart):
|
||||
prompt_messages.append(SystemPromptMessage(content=part.content))
|
||||
elif isinstance(part, UserPromptPart):
|
||||
prompt_messages.append(UserPromptMessage(content=_map_user_prompt_content(part.content)))
|
||||
elif isinstance(part, ToolReturnPart):
|
||||
prompt_messages.append(_map_tool_return_part_to_prompt_message(part))
|
||||
elif isinstance(part, RetryPromptPart):
|
||||
if part.tool_name is None:
|
||||
prompt_messages.append(UserPromptMessage(content=part.model_response()))
|
||||
else:
|
||||
prompt_messages.append(
|
||||
ToolPromptMessage(
|
||||
content=part.model_response(),
|
||||
tool_call_id=part.tool_call_id,
|
||||
name=part.tool_name,
|
||||
)
|
||||
)
|
||||
else:
|
||||
assert_never(part)
|
||||
|
||||
return prompt_messages
|
||||
|
||||
|
||||
def _map_tool_return_part_to_prompt_message(part: ToolReturnPart) -> ToolPromptMessage:
|
||||
items = part.content_items(mode="str")
|
||||
if len(items) == 1 and isinstance(items[0], str):
|
||||
content: str | list[PromptMessageContentUnionTypes] | None = items[0]
|
||||
else:
|
||||
content_items: list[PromptMessageContentUnionTypes] = []
|
||||
for item in items:
|
||||
if isinstance(item, str):
|
||||
content_items.append(TextPromptMessageContent(data=item))
|
||||
elif isinstance(item, CachePoint):
|
||||
continue
|
||||
elif _is_multi_modal_content(item):
|
||||
content_items.append(_map_multi_modal_user_content(item))
|
||||
else:
|
||||
raise UnexpectedModelBehavior(f"Unsupported daemon tool message content: {type(item).__name__}")
|
||||
content = content_items or None
|
||||
|
||||
return ToolPromptMessage(content=content, tool_call_id=part.tool_call_id, name=part.tool_name)
|
||||
|
||||
|
||||
def _map_model_response_to_prompt_message(
|
||||
message: ModelResponse,
|
||||
) -> AssistantPromptMessage | None:
|
||||
content_parts: list[PromptMessageContentUnionTypes] = []
|
||||
tool_calls: list[AssistantPromptMessage.ToolCall] = []
|
||||
|
||||
for part in message.parts:
|
||||
if isinstance(part, TextPart):
|
||||
if part.content:
|
||||
content_parts.append(TextPromptMessageContent(data=part.content))
|
||||
elif isinstance(part, ThinkingPart):
|
||||
if part.content:
|
||||
content_parts.append(TextPromptMessageContent(data=f"{_THINK_START}{part.content}{_THINK_END}"))
|
||||
elif isinstance(part, FilePart):
|
||||
content_parts.append(_map_binary_content_to_prompt_content(part.content))
|
||||
elif isinstance(part, ToolCallPart):
|
||||
tool_calls.append(
|
||||
AssistantPromptMessage.ToolCall(
|
||||
id=part.tool_call_id or f"tool-call-{part.tool_name}",
|
||||
type="function",
|
||||
function=AssistantPromptMessage.ToolCall.ToolCallFunction(
|
||||
name=part.tool_name,
|
||||
arguments=part.args_as_json_str(),
|
||||
),
|
||||
)
|
||||
)
|
||||
elif isinstance(part, BuiltinToolCallPart | BuiltinToolReturnPart | CompactionPart):
|
||||
raise UnexpectedModelBehavior(f"Unsupported response part for daemon adapter: {type(part).__name__}")
|
||||
else:
|
||||
assert_never(part)
|
||||
|
||||
content = _normalize_prompt_content(content_parts)
|
||||
if content is None and not tool_calls:
|
||||
return None
|
||||
|
||||
return AssistantPromptMessage(content=content, tool_calls=tool_calls)
|
||||
|
||||
|
||||
def _map_user_prompt_content(
|
||||
content: str | Sequence[UserContent],
|
||||
) -> str | list[PromptMessageContentUnionTypes] | None:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
|
||||
prompt_content: list[PromptMessageContentUnionTypes] = []
|
||||
for item in content:
|
||||
if isinstance(item, CachePoint):
|
||||
continue
|
||||
if isinstance(item, str):
|
||||
prompt_content.append(TextPromptMessageContent(data=item))
|
||||
elif isinstance(item, TextContent):
|
||||
prompt_content.append(TextPromptMessageContent(data=item.content))
|
||||
elif _is_multi_modal_content(item):
|
||||
prompt_content.append(_map_multi_modal_user_content(item))
|
||||
else:
|
||||
raise UnexpectedModelBehavior(f"Unsupported user prompt content: {type(item).__name__}")
|
||||
return _normalize_prompt_content(prompt_content)
|
||||
|
||||
|
||||
def _is_multi_modal_content(item: object) -> bool:
|
||||
return isinstance(
|
||||
item,
|
||||
ImageUrl | AudioUrl | DocumentUrl | VideoUrl | BinaryContent | UploadedFile,
|
||||
)
|
||||
|
||||
|
||||
def _map_multi_modal_user_content(
|
||||
item: MultiModalContent,
|
||||
) -> PromptMessageContentUnionTypes:
|
||||
if isinstance(item, ImageUrl):
|
||||
detail = (
|
||||
ImagePromptMessageContent.DETAIL.HIGH
|
||||
if _get_detail(item) == _DETAIL_HIGH
|
||||
else ImagePromptMessageContent.DETAIL.LOW
|
||||
)
|
||||
return ImagePromptMessageContent(
|
||||
url=item.url,
|
||||
mime_type=item.media_type,
|
||||
format=item.format,
|
||||
filename=f"{item.identifier}.{item.format}",
|
||||
detail=detail,
|
||||
)
|
||||
if isinstance(item, AudioUrl):
|
||||
return AudioPromptMessageContent(
|
||||
url=item.url,
|
||||
mime_type=item.media_type,
|
||||
format=item.format,
|
||||
filename=f"{item.identifier}.{item.format}",
|
||||
)
|
||||
if isinstance(item, VideoUrl):
|
||||
return VideoPromptMessageContent(
|
||||
url=item.url,
|
||||
mime_type=item.media_type,
|
||||
format=item.format,
|
||||
filename=f"{item.identifier}.{item.format}",
|
||||
)
|
||||
if isinstance(item, DocumentUrl):
|
||||
return DocumentPromptMessageContent(
|
||||
url=item.url,
|
||||
mime_type=item.media_type,
|
||||
format=item.format,
|
||||
filename=f"{item.identifier}.{item.format}",
|
||||
)
|
||||
if isinstance(item, BinaryContent):
|
||||
return _map_binary_content_to_prompt_content(item)
|
||||
if isinstance(item, UploadedFile):
|
||||
raise UnexpectedModelBehavior("UploadedFile content is not supported by the daemon adapter")
|
||||
assert_never(item)
|
||||
|
||||
|
||||
def _map_binary_content_to_prompt_content(
|
||||
item: BinaryContent,
|
||||
) -> PromptMessageContentUnionTypes:
|
||||
filename = f"{item.identifier}.{item.format}"
|
||||
if item.is_image:
|
||||
detail = (
|
||||
ImagePromptMessageContent.DETAIL.HIGH
|
||||
if _get_detail(item) == _DETAIL_HIGH
|
||||
else ImagePromptMessageContent.DETAIL.LOW
|
||||
)
|
||||
return ImagePromptMessageContent(
|
||||
base64_data=item.base64,
|
||||
mime_type=item.media_type,
|
||||
format=item.format,
|
||||
filename=filename,
|
||||
detail=detail,
|
||||
)
|
||||
if item.is_audio:
|
||||
return AudioPromptMessageContent(
|
||||
base64_data=item.base64,
|
||||
mime_type=item.media_type,
|
||||
format=item.format,
|
||||
filename=filename,
|
||||
)
|
||||
if item.is_video:
|
||||
return VideoPromptMessageContent(
|
||||
base64_data=item.base64,
|
||||
mime_type=item.media_type,
|
||||
format=item.format,
|
||||
filename=filename,
|
||||
)
|
||||
if item.is_document:
|
||||
return DocumentPromptMessageContent(
|
||||
base64_data=item.base64,
|
||||
mime_type=item.media_type,
|
||||
format=item.format,
|
||||
filename=filename,
|
||||
)
|
||||
raise UnexpectedModelBehavior(f"Unsupported binary media type for daemon adapter: {item.media_type}")
|
||||
|
||||
|
||||
def _normalize_prompt_content(
|
||||
content: list[PromptMessageContentUnionTypes],
|
||||
) -> str | list[PromptMessageContentUnionTypes] | None:
|
||||
if not content:
|
||||
return None
|
||||
if len(content) == 1 and isinstance(content[0], TextPromptMessageContent):
|
||||
return content[0].data
|
||||
return content
|
||||
|
||||
|
||||
def _map_tool_definitions_to_prompt_tools(
|
||||
model_request_parameters: ModelRequestParameters,
|
||||
) -> list[PromptMessageTool] | None:
|
||||
tool_definitions = [
|
||||
*model_request_parameters.function_tools,
|
||||
*model_request_parameters.output_tools,
|
||||
]
|
||||
if not tool_definitions:
|
||||
return None
|
||||
|
||||
return [
|
||||
PromptMessageTool(
|
||||
name=tool_definition.name,
|
||||
description=tool_definition.description or "",
|
||||
parameters=cast(dict[str, object], tool_definition.parameters_json_schema),
|
||||
)
|
||||
for tool_definition in tool_definitions
|
||||
]
|
||||
|
||||
|
||||
def _map_model_settings_to_parameters(model_settings: ModelSettings | None) -> dict[str, object]:
|
||||
if not model_settings:
|
||||
return {}
|
||||
|
||||
parameters: dict[str, object] = {
|
||||
key: value
|
||||
for key, value in model_settings.items()
|
||||
if value is not None and key not in {"extra_body", "stop_sequences"}
|
||||
}
|
||||
|
||||
extra_body = model_settings.get("extra_body")
|
||||
if isinstance(extra_body, Mapping):
|
||||
parameters.update(cast(Mapping[str, object], extra_body))
|
||||
|
||||
return parameters
|
||||
|
||||
|
||||
def _get_stop_sequences(model_settings: ModelSettings | None) -> list[str] | None:
|
||||
if not model_settings:
|
||||
return None
|
||||
return list(model_settings.get("stop_sequences") or []) or None
|
||||
|
||||
|
||||
def _map_usage(usage: LLMUsage) -> RequestUsage:
|
||||
return RequestUsage(input_tokens=usage.prompt_tokens, output_tokens=usage.completion_tokens)
|
||||
|
||||
|
||||
def _normalize_finish_reason(finish_reason: str) -> FinishReason:
|
||||
lowered = finish_reason.lower()
|
||||
if lowered in {"stop", "length", "content_filter", "error", "tool_call"}:
|
||||
return cast(FinishReason, lowered)
|
||||
if lowered in {"tool_calls", "function_call", "function_calls"}:
|
||||
return "tool_call"
|
||||
return "error"
|
||||
|
||||
|
||||
def _chunk_to_stream_events(
|
||||
parts_manager: ModelResponsePartsManager,
|
||||
chunk: LLMResultChunk,
|
||||
provider_name: str,
|
||||
embedded_thinking_parser: "_EmbeddedThinkingParser",
|
||||
) -> list[ModelResponseStreamEvent]:
|
||||
events: list[ModelResponseStreamEvent] = []
|
||||
message = chunk.delta.message
|
||||
|
||||
if isinstance(message.content, str):
|
||||
if message.content:
|
||||
events.extend(embedded_thinking_parser.parse(parts_manager, message.content, provider_name))
|
||||
elif isinstance(message.content, list):
|
||||
for part in _map_assistant_content_to_response_parts(message.content):
|
||||
if isinstance(part, TextPart):
|
||||
events.extend(
|
||||
parts_manager.handle_text_delta(
|
||||
vendor_part_id=None,
|
||||
content=part.content,
|
||||
provider_name=provider_name,
|
||||
)
|
||||
)
|
||||
else:
|
||||
events.append(parts_manager.handle_part(vendor_part_id=None, part=part))
|
||||
|
||||
for index, tool_call in enumerate(message.tool_calls):
|
||||
vendor_id = tool_call.id or f"chunk-{chunk.delta.index}-tool-{index}"
|
||||
events.append(
|
||||
parts_manager.handle_tool_call_part(
|
||||
vendor_part_id=vendor_id,
|
||||
tool_name=tool_call.function.name,
|
||||
args=tool_call.function.arguments,
|
||||
tool_call_id=tool_call.id,
|
||||
provider_name=provider_name,
|
||||
)
|
||||
)
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def _map_assistant_content_to_response_parts(
|
||||
content: Sequence[PromptMessageContentUnionTypes],
|
||||
) -> list[ModelResponsePart]:
|
||||
response_parts: list[ModelResponsePart] = []
|
||||
|
||||
for item in content:
|
||||
if isinstance(item, TextPromptMessageContent):
|
||||
if item.data:
|
||||
response_parts.extend(_parse_assistant_text_parts(item.data))
|
||||
elif isinstance(
|
||||
item,
|
||||
ImagePromptMessageContent
|
||||
| AudioPromptMessageContent
|
||||
| VideoPromptMessageContent
|
||||
| DocumentPromptMessageContent,
|
||||
):
|
||||
if item.url:
|
||||
raise UnexpectedModelBehavior(
|
||||
"URL-based assistant multimodal output is not supported by the daemon adapter"
|
||||
)
|
||||
if not item.base64_data:
|
||||
continue
|
||||
response_parts.append(
|
||||
FilePart(
|
||||
content=BinaryContent(
|
||||
data=base64.b64decode(item.base64_data),
|
||||
media_type=item.mime_type,
|
||||
),
|
||||
provider_name=None,
|
||||
)
|
||||
)
|
||||
else:
|
||||
assert_never(item)
|
||||
|
||||
return response_parts
|
||||
|
||||
|
||||
def _get_detail(item: ImageUrl | BinaryContent) -> str | None:
|
||||
metadata = item.vendor_metadata or {}
|
||||
detail = metadata.get("detail")
|
||||
return detail if isinstance(detail, str) else None
|
||||
|
||||
|
||||
def _parse_assistant_text_parts(content: str) -> list[ModelResponsePart]:
|
||||
response_parts: list[ModelResponsePart] = []
|
||||
cursor = 0
|
||||
|
||||
for match in _THINK_TAG_PATTERN.finditer(content):
|
||||
if match.start() > cursor:
|
||||
response_parts.append(TextPart(content=content[cursor : match.start()], provider_name=None))
|
||||
|
||||
thinking_content = match.group(1).strip("\n")
|
||||
if thinking_content:
|
||||
response_parts.append(ThinkingPart(content=thinking_content, provider_name=None))
|
||||
cursor = match.end()
|
||||
|
||||
if cursor < len(content):
|
||||
response_parts.append(TextPart(content=content[cursor:], provider_name=None))
|
||||
|
||||
if response_parts:
|
||||
return response_parts
|
||||
return [TextPart(content=content, provider_name=None)]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _EmbeddedThinkingParser:
|
||||
_pending: str = ""
|
||||
_inside_thinking: bool = False
|
||||
|
||||
def parse(
|
||||
self,
|
||||
parts_manager: ModelResponsePartsManager,
|
||||
content: str,
|
||||
provider_name: str,
|
||||
) -> list[ModelResponseStreamEvent]:
|
||||
events: list[ModelResponseStreamEvent] = []
|
||||
buffer = self._pending + content
|
||||
self._pending = ""
|
||||
|
||||
while buffer:
|
||||
if self._inside_thinking:
|
||||
end_index = buffer.find(_THINK_CLOSE_TAG)
|
||||
if end_index >= 0:
|
||||
if end_index > 0:
|
||||
events.extend(
|
||||
parts_manager.handle_thinking_delta(
|
||||
vendor_part_id=None,
|
||||
content=buffer[:end_index],
|
||||
provider_name=provider_name,
|
||||
)
|
||||
)
|
||||
buffer = buffer[end_index + len(_THINK_CLOSE_TAG) :]
|
||||
self._inside_thinking = False
|
||||
continue
|
||||
|
||||
safe_content, self._pending = _split_incomplete_tag_suffix(buffer, _THINK_CLOSE_TAG)
|
||||
if safe_content:
|
||||
events.extend(
|
||||
parts_manager.handle_thinking_delta(
|
||||
vendor_part_id=None,
|
||||
content=safe_content,
|
||||
provider_name=provider_name,
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
start_index = buffer.find(_THINK_OPEN_TAG)
|
||||
if start_index >= 0:
|
||||
if start_index > 0:
|
||||
events.extend(
|
||||
parts_manager.handle_text_delta(
|
||||
vendor_part_id=None,
|
||||
content=buffer[:start_index],
|
||||
provider_name=provider_name,
|
||||
)
|
||||
)
|
||||
buffer = buffer[start_index + len(_THINK_OPEN_TAG) :]
|
||||
self._inside_thinking = True
|
||||
continue
|
||||
|
||||
safe_content, self._pending = _split_incomplete_tag_suffix(buffer, _THINK_OPEN_TAG)
|
||||
if safe_content:
|
||||
events.extend(
|
||||
parts_manager.handle_text_delta(
|
||||
vendor_part_id=None,
|
||||
content=safe_content,
|
||||
provider_name=provider_name,
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
return events
|
||||
|
||||
def flush(
|
||||
self,
|
||||
parts_manager: ModelResponsePartsManager,
|
||||
provider_name: str,
|
||||
) -> list[ModelResponseStreamEvent]:
|
||||
if not self._pending:
|
||||
return []
|
||||
|
||||
pending = self._pending
|
||||
self._pending = ""
|
||||
if self._inside_thinking:
|
||||
return list(
|
||||
parts_manager.handle_thinking_delta(
|
||||
vendor_part_id=None,
|
||||
content=pending,
|
||||
provider_name=provider_name,
|
||||
)
|
||||
)
|
||||
return list(
|
||||
parts_manager.handle_text_delta(
|
||||
vendor_part_id=None,
|
||||
content=pending,
|
||||
provider_name=provider_name,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _split_incomplete_tag_suffix(content: str, tag: str) -> tuple[str, str]:
|
||||
for suffix_length in range(len(tag) - 1, 0, -1):
|
||||
if content.endswith(tag[:suffix_length]):
|
||||
return content[:-suffix_length], content[-suffix_length:]
|
||||
return content, ""
|
||||
272
dify-agent/src/dify_agent/adapters/llm/provider.py
Normal file
272
dify-agent/src/dify_agent/adapters/llm/provider.py
Normal file
@ -0,0 +1,272 @@
|
||||
"""Dify plugin-daemon provider for Pydantic AI LLM adapters.
|
||||
|
||||
The Pydantic AI provider represents daemon/plugin transport identity. Business
|
||||
model provider names such as ``openai`` are request-level model identity and are
|
||||
passed by ``DifyLLMAdapterModel`` for each invocation instead of being stored on
|
||||
this provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import NoReturn
|
||||
|
||||
import httpx
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResultChunk
|
||||
from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageTool
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import override
|
||||
|
||||
from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError, UnexpectedModelBehavior, UserError
|
||||
from pydantic_ai.providers import Provider
|
||||
|
||||
_DEFAULT_DAEMON_TIMEOUT: float | httpx.Timeout | None = 600.0
|
||||
|
||||
|
||||
class PluginDaemonBasicResponse(BaseModel):
|
||||
code: int
|
||||
message: str
|
||||
data: object | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyPluginDaemonLLMClient:
|
||||
"""HTTP client wrapper for plugin-daemon LLM dispatch requests."""
|
||||
|
||||
plugin_daemon_url: str
|
||||
plugin_daemon_api_key: str
|
||||
tenant_id: str
|
||||
plugin_id: str
|
||||
user_id: str | None
|
||||
http_client: httpx.AsyncClient = field(repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.plugin_daemon_url = self.plugin_daemon_url.rstrip("/")
|
||||
|
||||
async def iter_llm_result_chunks(
|
||||
self,
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
credentials: dict[str, object],
|
||||
prompt_messages: list[PromptMessage],
|
||||
model_parameters: dict[str, object],
|
||||
tools: list[PromptMessageTool] | None,
|
||||
stop: list[str] | None,
|
||||
stream: bool,
|
||||
) -> AsyncIterator[LLMResultChunk]:
|
||||
async for item in self._iter_stream_response(
|
||||
model_name=model,
|
||||
path=f"plugin/{self.tenant_id}/dispatch/llm/invoke",
|
||||
request_data={
|
||||
"provider": provider,
|
||||
"model_type": "llm",
|
||||
"model": model,
|
||||
"credentials": credentials,
|
||||
"prompt_messages": prompt_messages,
|
||||
"model_parameters": model_parameters,
|
||||
"tools": tools,
|
||||
"stop": stop,
|
||||
"stream": stream,
|
||||
},
|
||||
response_model=LLMResultChunk,
|
||||
):
|
||||
yield item
|
||||
|
||||
async def _iter_stream_response[T: BaseModel](
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
path: str,
|
||||
request_data: Mapping[str, object],
|
||||
response_model: type[T],
|
||||
) -> AsyncIterator[T]:
|
||||
payload: dict[str, object] = {"data": _to_jsonable(request_data)}
|
||||
if self.user_id is not None:
|
||||
payload["user_id"] = self.user_id
|
||||
|
||||
headers = {
|
||||
"X-Api-Key": self.plugin_daemon_api_key,
|
||||
"X-Plugin-ID": self.plugin_id,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
url = f"{self.plugin_daemon_url}/{path}"
|
||||
|
||||
async with self.http_client.stream("POST", url, headers=headers, json=payload) as response:
|
||||
if response.is_error:
|
||||
body = (await response.aread()).decode("utf-8", errors="replace")
|
||||
error = _decode_plugin_daemon_error_payload(body)
|
||||
if error is not None:
|
||||
_raise_plugin_daemon_error(
|
||||
model_name=model_name,
|
||||
error_type=error["error_type"],
|
||||
message=error["message"],
|
||||
status_code=response.status_code,
|
||||
body=error,
|
||||
)
|
||||
raise ModelHTTPError(response.status_code, model_name, body or None)
|
||||
|
||||
async for raw_line in response.aiter_lines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("data:"):
|
||||
line = line[5:].strip()
|
||||
|
||||
wrapped = PluginDaemonBasicResponse.model_validate_json(line)
|
||||
if wrapped.code != 0:
|
||||
error = _decode_plugin_daemon_error_payload(wrapped.message)
|
||||
if error is not None:
|
||||
_raise_plugin_daemon_error(
|
||||
model_name=model_name,
|
||||
error_type=error["error_type"],
|
||||
message=error["message"],
|
||||
body=error,
|
||||
)
|
||||
raise ModelAPIError(
|
||||
model_name,
|
||||
f"Plugin daemon returned error code {wrapped.code}: {wrapped.message}",
|
||||
)
|
||||
if wrapped.data is None:
|
||||
raise UnexpectedModelBehavior("Plugin daemon returned an empty stream item")
|
||||
yield response_model.model_validate(wrapped.data)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class DifyPluginDaemonProvider(Provider[DifyPluginDaemonLLMClient]):
|
||||
"""Pydantic AI provider for Dify plugin-daemon dispatch requests.
|
||||
|
||||
The provider ``name`` identifies the daemon/plugin context. The business LLM
|
||||
provider is supplied by each adapter model request so one daemon provider can
|
||||
serve different model-provider selections without mutating transport state.
|
||||
When ``http_client`` is omitted the provider owns an ``AsyncClient`` and the
|
||||
Pydantic AI provider context manager closes it. When an external client is
|
||||
supplied, ownership stays with the caller and provider exit leaves it open.
|
||||
"""
|
||||
|
||||
tenant_id: str
|
||||
plugin_id: str
|
||||
plugin_daemon_url: str
|
||||
plugin_daemon_api_key: str = field(repr=False)
|
||||
user_id: str | None = None
|
||||
timeout: float | httpx.Timeout | None = _DEFAULT_DAEMON_TIMEOUT
|
||||
http_client: httpx.AsyncClient | None = field(default=None, repr=False)
|
||||
_client: DifyPluginDaemonLLMClient = field(init=False, repr=False)
|
||||
_own_http_client: httpx.AsyncClient | None = field(init=False, default=None, repr=False)
|
||||
_http_client_factory: Callable[[], httpx.AsyncClient] | None = field(init=False, default=None, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.plugin_daemon_url = self.plugin_daemon_url.rstrip("/")
|
||||
if self.http_client is None:
|
||||
self._http_client_factory = self._make_http_client
|
||||
http_client = self._make_http_client()
|
||||
self._own_http_client = http_client
|
||||
else:
|
||||
http_client = self.http_client
|
||||
self._own_http_client = None
|
||||
self._http_client_factory = None
|
||||
self._client = DifyPluginDaemonLLMClient(
|
||||
plugin_daemon_url=self.plugin_daemon_url,
|
||||
plugin_daemon_api_key=self.plugin_daemon_api_key,
|
||||
tenant_id=self.tenant_id,
|
||||
plugin_id=self.plugin_id,
|
||||
user_id=self.user_id,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
def _make_http_client(self) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(timeout=self.timeout, trust_env=False)
|
||||
|
||||
@override
|
||||
def _set_http_client(self, http_client: httpx.AsyncClient) -> None:
|
||||
self._client.http_client = http_client
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
return f"DifyPlugin/{self.plugin_id}"
|
||||
|
||||
@property
|
||||
@override
|
||||
def base_url(self) -> str:
|
||||
return self.plugin_daemon_url
|
||||
|
||||
@property
|
||||
@override
|
||||
def client(self) -> DifyPluginDaemonLLMClient:
|
||||
return self._client
|
||||
|
||||
|
||||
def _to_jsonable(value: object) -> object:
|
||||
if isinstance(value, BaseModel):
|
||||
return value.model_dump(mode="json")
|
||||
if isinstance(value, dict):
|
||||
return {key: _to_jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, list | tuple):
|
||||
return [_to_jsonable(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _decode_plugin_daemon_error_payload(raw_message: str) -> dict[str, str] | None:
|
||||
try:
|
||||
parsed = json.loads(raw_message)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
|
||||
error_type = parsed.get("error_type")
|
||||
message = parsed.get("message")
|
||||
if not isinstance(error_type, str) or not isinstance(message, str):
|
||||
return None
|
||||
return {"error_type": error_type, "message": message}
|
||||
|
||||
|
||||
def _raise_plugin_daemon_error(
|
||||
*,
|
||||
model_name: str,
|
||||
error_type: str,
|
||||
message: str,
|
||||
status_code: int | None = None,
|
||||
body: object | None = None,
|
||||
) -> NoReturn:
|
||||
http_error_body = body or {"error_type": error_type, "message": message}
|
||||
|
||||
match error_type:
|
||||
case "PluginInvokeError":
|
||||
nested_error = _decode_plugin_daemon_error_payload(message)
|
||||
if nested_error is not None:
|
||||
_raise_plugin_daemon_error(
|
||||
model_name=model_name,
|
||||
error_type=nested_error["error_type"],
|
||||
message=nested_error["message"],
|
||||
status_code=status_code,
|
||||
body=nested_error,
|
||||
)
|
||||
raise ModelAPIError(model_name, message)
|
||||
case "PluginDaemonUnauthorizedError" | "InvokeAuthorizationError":
|
||||
raise ModelHTTPError(status_code or 401, model_name, http_error_body)
|
||||
case "PluginPermissionDeniedError":
|
||||
raise ModelHTTPError(status_code or 403, model_name, http_error_body)
|
||||
case (
|
||||
"PluginDaemonBadRequestError"
|
||||
| "InvokeBadRequestError"
|
||||
| "CredentialsValidateFailedError"
|
||||
| "PluginUniqueIdentifierError"
|
||||
):
|
||||
raise ModelHTTPError(status_code or 400, model_name, http_error_body)
|
||||
case "EndpointSetupFailedError" | "TriggerProviderCredentialValidationError":
|
||||
raise UserError(message)
|
||||
case "PluginDaemonNotFoundError" | "PluginNotFoundError":
|
||||
raise ModelHTTPError(status_code or 404, model_name, http_error_body)
|
||||
case "InvokeRateLimitError":
|
||||
raise ModelHTTPError(status_code or 429, model_name, http_error_body)
|
||||
case "PluginDaemonInternalServerError" | "PluginDaemonInnerError":
|
||||
raise ModelHTTPError(status_code or 500, model_name, http_error_body)
|
||||
case "InvokeConnectionError" | "InvokeServerUnavailableError":
|
||||
raise ModelHTTPError(status_code or 503, model_name, http_error_body)
|
||||
case _:
|
||||
raise ModelAPIError(model_name, f"{error_type}: {message}")
|
||||
21
dify-agent/src/dify_agent/client/__init__.py
Normal file
21
dify-agent/src/dify_agent/client/__init__.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""Unified sync and async Python client for the Dify Agent run API."""
|
||||
|
||||
from ._client import (
|
||||
Client,
|
||||
DifyAgentClientError,
|
||||
DifyAgentHTTPError,
|
||||
DifyAgentNotFoundError,
|
||||
DifyAgentStreamError,
|
||||
DifyAgentTimeoutError,
|
||||
DifyAgentValidationError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Client",
|
||||
"DifyAgentClientError",
|
||||
"DifyAgentHTTPError",
|
||||
"DifyAgentNotFoundError",
|
||||
"DifyAgentStreamError",
|
||||
"DifyAgentTimeoutError",
|
||||
"DifyAgentValidationError",
|
||||
]
|
||||
665
dify-agent/src/dify_agent/client/_client.py
Normal file
665
dify-agent/src/dify_agent/client/_client.py
Normal file
@ -0,0 +1,665 @@
|
||||
"""HTTPX-based client for Dify Agent runs.
|
||||
|
||||
The client uses the public DTOs from ``dify_agent.protocol.schemas`` for all
|
||||
normal request and response parsing. It intentionally does not retry
|
||||
``POST /runs`` because create-run is not idempotent, and create helpers require a
|
||||
``CreateRunRequest`` instance rather than accepting raw payload dicts. SSE
|
||||
streams are the only operation with reconnect logic: transient stream, connect,
|
||||
or read failures, stream timeouts, and HTTP 5xx stream responses reconnect with
|
||||
the latest observed event id, while HTTP 4xx responses, DTO validation failures,
|
||||
and malformed SSE frames fail immediately.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from types import TracebackType
|
||||
from typing import Self, TypeVar, cast
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from dify_agent.protocol.schemas import (
|
||||
CreateRunRequest,
|
||||
CreateRunResponse,
|
||||
RUN_EVENT_ADAPTER,
|
||||
RunEvent,
|
||||
RunEventsResponse,
|
||||
RunStatusResponse,
|
||||
)
|
||||
|
||||
_ResponseModelT = TypeVar("_ResponseModelT", bound=BaseModel)
|
||||
_TERMINAL_EVENT_TYPES = {"run_succeeded", "run_failed"}
|
||||
_TERMINAL_RUN_STATUSES = {"succeeded", "failed"}
|
||||
|
||||
|
||||
class DifyAgentClientError(RuntimeError):
|
||||
"""Base class for errors raised by the Dify Agent Python client."""
|
||||
|
||||
|
||||
class DifyAgentHTTPError(DifyAgentClientError):
|
||||
"""Raised for HTTP 4xx/5xx responses not covered by a narrower subclass."""
|
||||
|
||||
status_code: int
|
||||
detail: object
|
||||
|
||||
def __init__(self, status_code: int, detail: object) -> None:
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
super().__init__(f"Dify Agent HTTP {status_code}: {detail}")
|
||||
|
||||
|
||||
class DifyAgentNotFoundError(DifyAgentHTTPError):
|
||||
"""Raised when the server returns ``404`` for a run resource."""
|
||||
|
||||
|
||||
class DifyAgentValidationError(DifyAgentHTTPError):
|
||||
"""Raised for local input validation, invalid DTO responses, or HTTP ``422``."""
|
||||
|
||||
def __init__(self, detail: object, *, status_code: int = 422) -> None:
|
||||
super().__init__(status_code=status_code, detail=detail)
|
||||
|
||||
|
||||
class DifyAgentTimeoutError(DifyAgentClientError):
|
||||
"""Raised when an HTTPX timeout occurs outside successful SSE reconnects."""
|
||||
|
||||
|
||||
class DifyAgentStreamError(DifyAgentClientError):
|
||||
"""Raised for malformed SSE frames or exhausted SSE reconnect attempts."""
|
||||
|
||||
|
||||
class _ReconnectableStreamError(Exception):
|
||||
"""Internal wrapper for stream failures that may be retried by the caller."""
|
||||
|
||||
error: DifyAgentClientError
|
||||
|
||||
def __init__(self, error: DifyAgentClientError) -> None:
|
||||
self.error = error
|
||||
super().__init__(str(error))
|
||||
|
||||
|
||||
class _SSEDecoder:
|
||||
"""Incrementally decode SSE lines into typed run events.
|
||||
|
||||
The decoder keeps only the fields for the current frame. Comments are ignored,
|
||||
``data`` fields are joined with newlines as required by the SSE specification,
|
||||
and payload JSON is validated by ``RUN_EVENT_ADAPTER``. The frame ``id`` is
|
||||
copied into the decoded event only when the JSON payload omits ``event.id``.
|
||||
"""
|
||||
|
||||
_event_id: str | None
|
||||
_event_type: str | None
|
||||
_data_lines: list[str]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._event_id = None
|
||||
self._event_type = None
|
||||
self._data_lines = []
|
||||
|
||||
def feed_line(self, raw_line: str) -> RunEvent | None:
|
||||
"""Consume one SSE line and return an event when a frame completes.
|
||||
|
||||
Empty lines dispatch the current frame. Comment-only frames and frames
|
||||
without ``data`` are ignored so server heartbeats do not surface to users.
|
||||
Malformed event payloads raise ``DifyAgentStreamError`` and must not be
|
||||
retried because replaying would repeat the same invalid frame.
|
||||
"""
|
||||
line = raw_line.rstrip("\r")
|
||||
if line == "":
|
||||
return self._dispatch()
|
||||
if line.startswith(":"):
|
||||
return None
|
||||
|
||||
field, separator, value = line.partition(":")
|
||||
if separator and value.startswith(" "):
|
||||
value = value[1:]
|
||||
if field == "id":
|
||||
self._event_id = value
|
||||
elif field == "event":
|
||||
self._event_type = value
|
||||
elif field == "data":
|
||||
self._data_lines.append(value)
|
||||
return None
|
||||
|
||||
def _dispatch(self) -> RunEvent | None:
|
||||
"""Validate and return the current frame, then clear decoder state."""
|
||||
if not self._data_lines:
|
||||
self._reset()
|
||||
return None
|
||||
|
||||
frame_id = self._event_id
|
||||
frame_event_type = self._event_type
|
||||
data = "\n".join(self._data_lines)
|
||||
self._reset()
|
||||
|
||||
try:
|
||||
event = RUN_EVENT_ADAPTER.validate_json(data)
|
||||
except ValidationError as exc:
|
||||
raise DifyAgentStreamError("malformed SSE data frame") from exc
|
||||
if frame_event_type is not None and frame_event_type != event.type:
|
||||
raise DifyAgentStreamError(
|
||||
f"SSE event field {frame_event_type!r} does not match payload type {event.type!r}"
|
||||
)
|
||||
if frame_id is not None and event.id is None:
|
||||
return event.model_copy(update={"id": frame_id})
|
||||
return event
|
||||
|
||||
def _reset(self) -> None:
|
||||
"""Clear the current frame without changing decoder configuration."""
|
||||
self._event_id = None
|
||||
self._event_type = None
|
||||
self._data_lines = []
|
||||
|
||||
|
||||
class Client:
|
||||
"""Unified synchronous and asynchronous client for Dify Agent runs.
|
||||
|
||||
The instance is intentionally small and stateful: it stores base URL, default
|
||||
headers, timeout settings, optional external HTTPX clients, and lazy-owned
|
||||
clients for whichever sync/async side is used. External clients are never
|
||||
closed by this wrapper. Owned sync clients close via ``close_sync`` or the
|
||||
sync context manager; owned async clients close via ``aclose`` or the async
|
||||
context manager.
|
||||
"""
|
||||
|
||||
_base_url: str
|
||||
_timeout: float | httpx.Timeout
|
||||
_stream_timeout: float | httpx.Timeout | None
|
||||
_headers: dict[str, str]
|
||||
_sync_http_client: httpx.Client | None
|
||||
_async_http_client: httpx.AsyncClient | None
|
||||
_owns_sync_http_client: bool
|
||||
_owns_async_http_client: bool
|
||||
_sync_closed: bool
|
||||
_async_closed: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
timeout: float | httpx.Timeout = 30.0,
|
||||
stream_timeout: float | httpx.Timeout | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
sync_http_client: httpx.Client | None = None,
|
||||
async_http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._timeout = timeout
|
||||
self._stream_timeout = stream_timeout
|
||||
self._headers = dict(headers or {})
|
||||
self._sync_http_client = sync_http_client
|
||||
self._async_http_client = async_http_client
|
||||
self._owns_sync_http_client = sync_http_client is None
|
||||
self._owns_async_http_client = async_http_client is None
|
||||
self._sync_closed = False
|
||||
self._async_closed = False
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
"""Enter a sync context and return this client without opening the network."""
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_value: BaseException | None,
|
||||
traceback: TracebackType | None,
|
||||
) -> None:
|
||||
"""Close the owned sync HTTP client when leaving a sync context."""
|
||||
del exc_type, exc_value, traceback
|
||||
self.close_sync()
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Enter an async context and return this client without opening the network."""
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_value: BaseException | None,
|
||||
traceback: TracebackType | None,
|
||||
) -> None:
|
||||
"""Close owned async resources when leaving an async context."""
|
||||
del exc_type, exc_value, traceback
|
||||
await self.aclose()
|
||||
|
||||
def close_sync(self) -> None:
|
||||
"""Close the owned synchronous HTTPX client if it was created."""
|
||||
if self._sync_closed:
|
||||
return
|
||||
if self._owns_sync_http_client and self._sync_http_client is not None:
|
||||
self._sync_http_client.close()
|
||||
self._sync_closed = True
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close owned asynchronous resources and any owned sync client already opened."""
|
||||
if not self._async_closed:
|
||||
if self._owns_async_http_client and self._async_http_client is not None:
|
||||
await self._async_http_client.aclose()
|
||||
self._async_closed = True
|
||||
if self._owns_sync_http_client and self._sync_http_client is not None:
|
||||
self.close_sync()
|
||||
|
||||
async def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
|
||||
"""Create one run and return its accepted status response.
|
||||
|
||||
``request`` must already be a public ``CreateRunRequest`` DTO. This
|
||||
method performs exactly one ``POST /runs`` attempt and maps HTTPX
|
||||
timeouts to ``DifyAgentTimeoutError``.
|
||||
"""
|
||||
request_model = _validate_create_run_request(request)
|
||||
try:
|
||||
response = await self._get_async_http_client().post(
|
||||
self._url("/runs"),
|
||||
content=request_model.model_dump_json(),
|
||||
headers=self._merged_headers({"Content-Type": "application/json"}),
|
||||
timeout=self._timeout,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise DifyAgentTimeoutError("create_run timed out") from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise DifyAgentClientError(f"create_run request failed: {exc}") from exc
|
||||
return _parse_model_response(response, CreateRunResponse)
|
||||
|
||||
def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse:
|
||||
"""Synchronous variant of ``create_run`` with the same no-retry contract."""
|
||||
request_model = _validate_create_run_request(request)
|
||||
try:
|
||||
response = self._get_sync_http_client().post(
|
||||
self._url("/runs"),
|
||||
content=request_model.model_dump_json(),
|
||||
headers=self._merged_headers({"Content-Type": "application/json"}),
|
||||
timeout=self._timeout,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise DifyAgentTimeoutError("create_run_sync timed out") from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise DifyAgentClientError(f"create_run_sync request failed: {exc}") from exc
|
||||
return _parse_model_response(response, CreateRunResponse)
|
||||
|
||||
async def get_run(self, run_id: str) -> RunStatusResponse:
|
||||
"""Return the current status for ``run_id`` or raise a mapped client error."""
|
||||
try:
|
||||
response = await self._get_async_http_client().get(
|
||||
self._url(f"/runs/{quote(run_id, safe='')}"),
|
||||
headers=self._merged_headers(),
|
||||
timeout=self._timeout,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise DifyAgentTimeoutError("get_run timed out") from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise DifyAgentClientError(f"get_run request failed: {exc}") from exc
|
||||
return _parse_model_response(response, RunStatusResponse)
|
||||
|
||||
def get_run_sync(self, run_id: str) -> RunStatusResponse:
|
||||
"""Synchronous variant of ``get_run``."""
|
||||
try:
|
||||
response = self._get_sync_http_client().get(
|
||||
self._url(f"/runs/{quote(run_id, safe='')}"),
|
||||
headers=self._merged_headers(),
|
||||
timeout=self._timeout,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise DifyAgentTimeoutError("get_run_sync timed out") from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise DifyAgentClientError(f"get_run_sync request failed: {exc}") from exc
|
||||
return _parse_model_response(response, RunStatusResponse)
|
||||
|
||||
async def get_events(self, run_id: str, *, after: str = "0-0", limit: int = 100) -> RunEventsResponse:
|
||||
"""Return one cursor-paginated page of events for ``run_id``."""
|
||||
try:
|
||||
response = await self._get_async_http_client().get(
|
||||
self._url(f"/runs/{quote(run_id, safe='')}/events"),
|
||||
params={"after": after, "limit": str(limit)},
|
||||
headers=self._merged_headers(),
|
||||
timeout=self._timeout,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise DifyAgentTimeoutError("get_events timed out") from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise DifyAgentClientError(f"get_events request failed: {exc}") from exc
|
||||
return _parse_model_response(response, RunEventsResponse)
|
||||
|
||||
def get_events_sync(self, run_id: str, *, after: str = "0-0", limit: int = 100) -> RunEventsResponse:
|
||||
"""Synchronous variant of ``get_events``."""
|
||||
try:
|
||||
response = self._get_sync_http_client().get(
|
||||
self._url(f"/runs/{quote(run_id, safe='')}/events"),
|
||||
params={"after": after, "limit": str(limit)},
|
||||
headers=self._merged_headers(),
|
||||
timeout=self._timeout,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise DifyAgentTimeoutError("get_events_sync timed out") from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise DifyAgentClientError(f"get_events_sync request failed: {exc}") from exc
|
||||
return _parse_model_response(response, RunEventsResponse)
|
||||
|
||||
async def stream_events(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
after: str | None = None,
|
||||
reconnect: bool = True,
|
||||
max_reconnects: int | None = None,
|
||||
reconnect_delay_seconds: float = 1.0,
|
||||
until_terminal: bool = True,
|
||||
) -> AsyncIterator[RunEvent]:
|
||||
"""Yield typed events from SSE with cursor-based reconnect.
|
||||
|
||||
The initial cursor is ``after`` or ``"0-0"``. After every yielded event
|
||||
with an id, reconnects resume from that id using the ``after`` query
|
||||
parameter. HTTP 5xx stream responses are retried, but HTTP 4xx responses,
|
||||
DTO validation failures, and malformed SSE frames are not retried. By
|
||||
default iteration stops after ``run_succeeded`` or ``run_failed``.
|
||||
"""
|
||||
_validate_stream_options(max_reconnects, reconnect_delay_seconds)
|
||||
cursor = after or "0-0"
|
||||
reconnect_attempts = 0
|
||||
while True:
|
||||
try:
|
||||
async for event in self._stream_events_once(run_id, after=cursor):
|
||||
if event.id is not None:
|
||||
cursor = event.id
|
||||
yield event
|
||||
if until_terminal and event.type in _TERMINAL_EVENT_TYPES:
|
||||
return
|
||||
except _ReconnectableStreamError as exc:
|
||||
if not reconnect:
|
||||
raise exc.error from exc
|
||||
reconnect_attempts = _next_reconnect_attempt(
|
||||
reconnect_attempts,
|
||||
max_reconnects=max_reconnects,
|
||||
error=exc.error,
|
||||
)
|
||||
await _sleep_async(reconnect_delay_seconds)
|
||||
continue
|
||||
if not reconnect:
|
||||
return
|
||||
reconnect_attempts = _next_reconnect_attempt(
|
||||
reconnect_attempts,
|
||||
max_reconnects=max_reconnects,
|
||||
error=DifyAgentStreamError("SSE stream ended before a terminal event"),
|
||||
)
|
||||
await _sleep_async(reconnect_delay_seconds)
|
||||
|
||||
def stream_events_sync(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
after: str | None = None,
|
||||
reconnect: bool = True,
|
||||
max_reconnects: int | None = None,
|
||||
reconnect_delay_seconds: float = 1.0,
|
||||
until_terminal: bool = True,
|
||||
) -> Iterator[RunEvent]:
|
||||
"""Synchronous variant of ``stream_events`` with the same reconnect rules."""
|
||||
_validate_stream_options(max_reconnects, reconnect_delay_seconds)
|
||||
cursor = after or "0-0"
|
||||
reconnect_attempts = 0
|
||||
while True:
|
||||
try:
|
||||
for event in self._stream_events_once_sync(run_id, after=cursor):
|
||||
if event.id is not None:
|
||||
cursor = event.id
|
||||
yield event
|
||||
if until_terminal and event.type in _TERMINAL_EVENT_TYPES:
|
||||
return
|
||||
except _ReconnectableStreamError as exc:
|
||||
if not reconnect:
|
||||
raise exc.error from exc
|
||||
reconnect_attempts = _next_reconnect_attempt(
|
||||
reconnect_attempts,
|
||||
max_reconnects=max_reconnects,
|
||||
error=exc.error,
|
||||
)
|
||||
_sleep_sync(reconnect_delay_seconds)
|
||||
continue
|
||||
if not reconnect:
|
||||
return
|
||||
reconnect_attempts = _next_reconnect_attempt(
|
||||
reconnect_attempts,
|
||||
max_reconnects=max_reconnects,
|
||||
error=DifyAgentStreamError("SSE stream ended before a terminal event"),
|
||||
)
|
||||
_sleep_sync(reconnect_delay_seconds)
|
||||
|
||||
async def wait_run(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
poll_interval_seconds: float = 1.0,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> RunStatusResponse:
|
||||
"""Poll run status until it becomes terminal and return the final status."""
|
||||
_validate_wait_options(poll_interval_seconds, timeout_seconds)
|
||||
deadline = time.monotonic() + timeout_seconds if timeout_seconds is not None else None
|
||||
while True:
|
||||
status = await self.get_run(run_id)
|
||||
if status.status in _TERMINAL_RUN_STATUSES:
|
||||
return status
|
||||
sleep_for = _next_sleep_seconds(poll_interval_seconds, deadline)
|
||||
if sleep_for is None:
|
||||
raise DifyAgentTimeoutError(f"run {run_id!r} did not finish before timeout")
|
||||
await _sleep_async(sleep_for)
|
||||
|
||||
def wait_run_sync(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
poll_interval_seconds: float = 1.0,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> RunStatusResponse:
|
||||
"""Synchronous variant of ``wait_run``."""
|
||||
_validate_wait_options(poll_interval_seconds, timeout_seconds)
|
||||
deadline = time.monotonic() + timeout_seconds if timeout_seconds is not None else None
|
||||
while True:
|
||||
status = self.get_run_sync(run_id)
|
||||
if status.status in _TERMINAL_RUN_STATUSES:
|
||||
return status
|
||||
sleep_for = _next_sleep_seconds(poll_interval_seconds, deadline)
|
||||
if sleep_for is None:
|
||||
raise DifyAgentTimeoutError(f"run {run_id!r} did not finish before timeout")
|
||||
_sleep_sync(sleep_for)
|
||||
|
||||
async def _stream_events_once(self, run_id: str, *, after: str) -> AsyncIterator[RunEvent]:
|
||||
"""Open one SSE connection and yield events until it ends or fails."""
|
||||
try:
|
||||
async with self._get_async_http_client().stream(
|
||||
"GET",
|
||||
self._url(f"/runs/{quote(run_id, safe='')}/events/sse"),
|
||||
params={"after": after},
|
||||
headers=self._merged_headers(),
|
||||
timeout=self._stream_timeout,
|
||||
) as response:
|
||||
if response.status_code >= 400:
|
||||
_ = await response.aread()
|
||||
_raise_for_stream_status(response)
|
||||
decoder = _SSEDecoder()
|
||||
async for line in response.aiter_lines():
|
||||
event = decoder.feed_line(line)
|
||||
if event is not None:
|
||||
yield event
|
||||
except DifyAgentHTTPError:
|
||||
raise
|
||||
except DifyAgentStreamError:
|
||||
raise
|
||||
except httpx.TimeoutException as exc:
|
||||
raise _ReconnectableStreamError(DifyAgentTimeoutError("SSE stream timed out")) from exc
|
||||
except httpx.TransportError as exc:
|
||||
raise _ReconnectableStreamError(DifyAgentStreamError(f"SSE stream failed: {exc}")) from exc
|
||||
except httpx.StreamError as exc:
|
||||
raise _ReconnectableStreamError(DifyAgentStreamError(f"SSE stream failed: {exc}")) from exc
|
||||
|
||||
def _stream_events_once_sync(self, run_id: str, *, after: str) -> Iterator[RunEvent]:
|
||||
"""Open one synchronous SSE connection and yield events until it ends or fails."""
|
||||
try:
|
||||
with self._get_sync_http_client().stream(
|
||||
"GET",
|
||||
self._url(f"/runs/{quote(run_id, safe='')}/events/sse"),
|
||||
params={"after": after},
|
||||
headers=self._merged_headers(),
|
||||
timeout=self._stream_timeout,
|
||||
) as response:
|
||||
if response.status_code >= 400:
|
||||
_ = response.read()
|
||||
_raise_for_stream_status(response)
|
||||
decoder = _SSEDecoder()
|
||||
for line in response.iter_lines():
|
||||
event = decoder.feed_line(line)
|
||||
if event is not None:
|
||||
yield event
|
||||
except DifyAgentHTTPError:
|
||||
raise
|
||||
except DifyAgentStreamError:
|
||||
raise
|
||||
except httpx.TimeoutException as exc:
|
||||
raise _ReconnectableStreamError(DifyAgentTimeoutError("SSE stream timed out")) from exc
|
||||
except httpx.TransportError as exc:
|
||||
raise _ReconnectableStreamError(DifyAgentStreamError(f"SSE stream failed: {exc}")) from exc
|
||||
except httpx.StreamError as exc:
|
||||
raise _ReconnectableStreamError(DifyAgentStreamError(f"SSE stream failed: {exc}")) from exc
|
||||
|
||||
def _get_sync_http_client(self) -> httpx.Client:
|
||||
"""Return an open sync HTTPX client, creating an owned one lazily."""
|
||||
if self._sync_closed:
|
||||
raise DifyAgentClientError("sync client is closed")
|
||||
if self._sync_http_client is None:
|
||||
self._sync_http_client = httpx.Client(timeout=self._timeout, headers=self._headers)
|
||||
return self._sync_http_client
|
||||
|
||||
def _get_async_http_client(self) -> httpx.AsyncClient:
|
||||
"""Return an open async HTTPX client, creating an owned one lazily."""
|
||||
if self._async_closed:
|
||||
raise DifyAgentClientError("async client is closed")
|
||||
if self._async_http_client is None:
|
||||
self._async_http_client = httpx.AsyncClient(timeout=self._timeout, headers=self._headers)
|
||||
return self._async_http_client
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
"""Build an absolute URL from the configured base and API path."""
|
||||
return f"{self._base_url}{path}"
|
||||
|
||||
def _merged_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""Return per-request headers without mutating client defaults."""
|
||||
headers = dict(self._headers)
|
||||
if extra is not None:
|
||||
headers.update(extra)
|
||||
return headers
|
||||
|
||||
|
||||
def _validate_create_run_request(request: CreateRunRequest) -> CreateRunRequest:
|
||||
"""Reject raw payloads so create-run uses the public request DTO boundary."""
|
||||
if isinstance(request, CreateRunRequest):
|
||||
return request
|
||||
raise DifyAgentValidationError(detail="request must be a CreateRunRequest")
|
||||
|
||||
|
||||
def _parse_model_response(response: httpx.Response, model_type: type[_ResponseModelT]) -> _ResponseModelT:
|
||||
"""Map HTTP errors and parse a Pydantic response DTO."""
|
||||
_raise_for_status(response)
|
||||
try:
|
||||
return model_type.model_validate_json(response.content)
|
||||
except ValidationError as exc:
|
||||
raise DifyAgentValidationError(
|
||||
detail=exc.errors(include_url=False),
|
||||
status_code=response.status_code,
|
||||
) from exc
|
||||
|
||||
|
||||
def _raise_for_status(response: httpx.Response) -> None:
|
||||
"""Raise the configured client exception for HTTP 4xx/5xx responses."""
|
||||
if response.status_code < 400:
|
||||
return
|
||||
detail = _extract_error_detail(response)
|
||||
if response.status_code == 404:
|
||||
raise DifyAgentNotFoundError(status_code=response.status_code, detail=detail)
|
||||
if response.status_code == 422:
|
||||
raise DifyAgentValidationError(status_code=response.status_code, detail=detail)
|
||||
raise DifyAgentHTTPError(status_code=response.status_code, detail=detail)
|
||||
|
||||
|
||||
def _raise_for_stream_status(response: httpx.Response) -> None:
|
||||
"""Raise terminal 4xx errors or wrap retryable SSE 5xx responses."""
|
||||
try:
|
||||
_raise_for_status(response)
|
||||
except DifyAgentHTTPError as exc:
|
||||
if response.status_code >= 500:
|
||||
raise _ReconnectableStreamError(
|
||||
DifyAgentStreamError(f"SSE stream HTTP {response.status_code}: {exc.detail}")
|
||||
) from exc
|
||||
raise
|
||||
|
||||
|
||||
def _extract_error_detail(response: httpx.Response) -> object:
|
||||
"""Extract FastAPI's ``detail`` field when present, falling back to text."""
|
||||
try:
|
||||
payload = cast(object, response.json())
|
||||
except (ValueError, httpx.ResponseNotRead):
|
||||
return response.text or response.reason_phrase
|
||||
if isinstance(payload, dict) and "detail" in payload:
|
||||
return cast(object, payload["detail"])
|
||||
return cast(object, payload)
|
||||
|
||||
|
||||
def _next_reconnect_attempt(
|
||||
reconnect_attempts: int,
|
||||
*,
|
||||
max_reconnects: int | None,
|
||||
error: DifyAgentClientError,
|
||||
) -> int:
|
||||
"""Increment reconnect attempts or raise when the configured budget is spent."""
|
||||
if max_reconnects is not None and reconnect_attempts >= max_reconnects:
|
||||
raise DifyAgentStreamError("SSE stream reconnect attempts exhausted") from error
|
||||
return reconnect_attempts + 1
|
||||
|
||||
|
||||
def _validate_stream_options(max_reconnects: int | None, reconnect_delay_seconds: float) -> None:
|
||||
"""Reject stream options that cannot produce deterministic reconnect behavior."""
|
||||
if max_reconnects is not None and max_reconnects < 0:
|
||||
raise DifyAgentValidationError(detail="max_reconnects must be non-negative")
|
||||
if reconnect_delay_seconds < 0:
|
||||
raise DifyAgentValidationError(detail="reconnect_delay_seconds must be non-negative")
|
||||
|
||||
|
||||
def _validate_wait_options(poll_interval_seconds: float, timeout_seconds: float | None) -> None:
|
||||
"""Reject wait options that would make polling ambiguous."""
|
||||
if poll_interval_seconds < 0:
|
||||
raise DifyAgentValidationError(detail="poll_interval_seconds must be non-negative")
|
||||
if timeout_seconds is not None and timeout_seconds < 0:
|
||||
raise DifyAgentValidationError(detail="timeout_seconds must be non-negative")
|
||||
|
||||
|
||||
def _next_sleep_seconds(poll_interval_seconds: float, deadline: float | None) -> float | None:
|
||||
"""Return the next polling sleep duration, or ``None`` when timed out."""
|
||||
if deadline is None:
|
||||
return poll_interval_seconds
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
return min(poll_interval_seconds, remaining)
|
||||
|
||||
|
||||
async def _sleep_async(seconds: float) -> None:
|
||||
"""Sleep asynchronously, skipping the call for zero-second test delays."""
|
||||
if seconds > 0:
|
||||
await asyncio.sleep(seconds)
|
||||
|
||||
|
||||
def _sleep_sync(seconds: float) -> None:
|
||||
"""Sleep synchronously, skipping the call for zero-second test delays."""
|
||||
if seconds > 0:
|
||||
time.sleep(seconds)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Client",
|
||||
"DifyAgentClientError",
|
||||
"DifyAgentHTTPError",
|
||||
"DifyAgentNotFoundError",
|
||||
"DifyAgentStreamError",
|
||||
"DifyAgentTimeoutError",
|
||||
"DifyAgentValidationError",
|
||||
]
|
||||
3
dify-agent/src/dify_agent/layers/__init__.py
Normal file
3
dify-agent/src/dify_agent/layers/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""Dify-owned Agenton layer packages."""
|
||||
|
||||
__all__: list[str] = []
|
||||
21
dify-agent/src/dify_agent/layers/dify_plugin/__init__.py
Normal file
21
dify-agent/src/dify_agent/layers/dify_plugin/__init__.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""Client-safe exports for Dify plugin DTOs and public layer type ids.
|
||||
|
||||
Implementation layers live in sibling modules and require server-side runtime
|
||||
dependencies. Keep this package root import-safe for client-only installs.
|
||||
"""
|
||||
|
||||
from dify_agent.layers.dify_plugin.configs import (
|
||||
DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
DifyPluginCredentialValue,
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginLayerConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DIFY_PLUGIN_LAYER_TYPE_ID",
|
||||
"DIFY_PLUGIN_LLM_LAYER_TYPE_ID",
|
||||
"DifyPluginCredentialValue",
|
||||
"DifyPluginLLMLayerConfig",
|
||||
"DifyPluginLayerConfig",
|
||||
]
|
||||
50
dify-agent/src/dify_agent/layers/dify_plugin/configs.py
Normal file
50
dify-agent/src/dify_agent/layers/dify_plugin/configs.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""Client-safe DTOs for Dify plugin-backed Agenton layers.
|
||||
|
||||
This module intentionally contains only public config schemas and scalar type
|
||||
aliases plus stable layer type identifiers. Runtime objects such as HTTP
|
||||
clients, server settings, and adapter implementations live in sibling
|
||||
implementation modules so clients can build run requests without importing
|
||||
server-only dependencies.
|
||||
"""
|
||||
|
||||
from typing import ClassVar, Final, TypeAlias
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
from pydantic_ai.settings import ModelSettings
|
||||
|
||||
from agenton.layers import LayerConfig
|
||||
|
||||
|
||||
DifyPluginCredentialValue: TypeAlias = str | int | float | bool | None
|
||||
DIFY_PLUGIN_LAYER_TYPE_ID: Final[str] = "dify.plugin"
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID: Final[str] = "dify.plugin.llm"
|
||||
|
||||
|
||||
class DifyPluginLayerConfig(LayerConfig):
|
||||
"""Public config for the plugin daemon tenant/plugin context layer."""
|
||||
|
||||
tenant_id: str
|
||||
plugin_id: str
|
||||
user_id: str | None = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
class DifyPluginLLMLayerConfig(LayerConfig):
|
||||
"""Public config for selecting a business provider/model from a plugin."""
|
||||
|
||||
model_provider: str
|
||||
model: str
|
||||
credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict)
|
||||
model_settings: ModelSettings | None = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DIFY_PLUGIN_LAYER_TYPE_ID",
|
||||
"DIFY_PLUGIN_LLM_LAYER_TYPE_ID",
|
||||
"DifyPluginCredentialValue",
|
||||
"DifyPluginLLMLayerConfig",
|
||||
"DifyPluginLayerConfig",
|
||||
]
|
||||
55
dify-agent/src/dify_agent/layers/dify_plugin/llm_layer.py
Normal file
55
dify-agent/src/dify_agent/layers/dify_plugin/llm_layer.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""Dify plugin LLM model layer.
|
||||
|
||||
This layer owns model capability resolution for Dify plugin-backed LLMs. It
|
||||
depends on ``DifyPluginLayer`` for daemon identity through Agenton's direct
|
||||
dependency binding and returns a Pydantic AI model adapter configured from the
|
||||
public LLM layer DTO. Runtime code supplies the FastAPI lifespan-owned shared
|
||||
HTTP client to ``get_model``; the layer does not own or discover live resources.
|
||||
The daemon provider carries plugin transport identity, while the DTO's
|
||||
``model_provider`` is passed to the adapter as request-level model identity.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers import LayerDeps, PlainLayer
|
||||
from dify_agent.adapters.llm import DifyLLMAdapterModel
|
||||
from dify_agent.layers.dify_plugin.configs import DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DifyPluginLLMLayerConfig
|
||||
from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer
|
||||
|
||||
|
||||
class DifyPluginLLMDeps(LayerDeps):
|
||||
"""Dependencies required by ``DifyPluginLLMLayer``."""
|
||||
|
||||
plugin: DifyPluginLayer # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyPluginLLMLayer(PlainLayer[DifyPluginLLMDeps, DifyPluginLLMLayerConfig]):
|
||||
"""Layer that creates the Dify plugin-daemon Pydantic AI model."""
|
||||
|
||||
type_id = DIFY_PLUGIN_LLM_LAYER_TYPE_ID
|
||||
|
||||
config: DifyPluginLLMLayerConfig
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def from_config(cls, config: DifyPluginLLMLayerConfig) -> Self:
|
||||
"""Create the LLM layer from validated public config."""
|
||||
return cls(config=config)
|
||||
|
||||
def get_model(self, *, http_client: httpx.AsyncClient) -> DifyLLMAdapterModel:
|
||||
"""Return the configured model using the directly bound plugin dependency."""
|
||||
provider = self.deps.plugin.create_daemon_provider(http_client=http_client)
|
||||
return DifyLLMAdapterModel(
|
||||
model=self.config.model,
|
||||
daemon_provider=provider,
|
||||
model_provider=self.config.model_provider,
|
||||
credentials=dict(self.config.credentials),
|
||||
model_settings=self.config.model_settings,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["DifyPluginLLMDeps", "DifyPluginLLMLayer"]
|
||||
69
dify-agent/src/dify_agent/layers/dify_plugin/plugin_layer.py
Normal file
69
dify-agent/src/dify_agent/layers/dify_plugin/plugin_layer.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""Runtime Dify plugin context layer.
|
||||
|
||||
The public config identifies tenant/plugin/user context only. Plugin daemon URL
|
||||
and API key are server-side settings injected by the provider factory. The layer
|
||||
is intentionally config/settings-only under Agenton's state-only core: it does
|
||||
not open, cache, close, or snapshot HTTP clients, and its lifecycle hooks remain
|
||||
the inherited no-op hooks. Runtime code passes the FastAPI lifespan-owned shared
|
||||
``httpx.AsyncClient`` into ``create_daemon_provider`` for each model adapter.
|
||||
Business model-provider names belong to the LLM layer/model request, not this
|
||||
daemon context layer.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers import EmptyRuntimeState, NoLayerDeps, PlainLayer
|
||||
from dify_agent.adapters.llm import DifyPluginDaemonProvider
|
||||
from dify_agent.layers.dify_plugin.configs import DIFY_PLUGIN_LAYER_TYPE_ID, DifyPluginLayerConfig
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyPluginLayer(PlainLayer[NoLayerDeps, DifyPluginLayerConfig, EmptyRuntimeState]):
|
||||
"""Layer that carries plugin daemon identity without owning live resources."""
|
||||
|
||||
type_id = DIFY_PLUGIN_LAYER_TYPE_ID
|
||||
|
||||
config: DifyPluginLayerConfig
|
||||
daemon_url: str
|
||||
daemon_api_key: str
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def from_config(cls, config: DifyPluginLayerConfig) -> Self:
|
||||
"""Reject construction without server-injected daemon settings."""
|
||||
del config
|
||||
raise TypeError("DifyPluginLayer requires server-side daemon settings and must use a provider factory.")
|
||||
|
||||
@classmethod
|
||||
def from_config_with_settings(
|
||||
cls,
|
||||
config: DifyPluginLayerConfig,
|
||||
*,
|
||||
daemon_url: str,
|
||||
daemon_api_key: str,
|
||||
) -> Self:
|
||||
"""Create a plugin layer from public config plus server-only daemon settings."""
|
||||
return cls(config=config, daemon_url=daemon_url, daemon_api_key=daemon_api_key)
|
||||
|
||||
def create_daemon_provider(self, *, http_client: httpx.AsyncClient) -> DifyPluginDaemonProvider:
|
||||
"""Return a daemon provider backed by the shared plugin daemon client.
|
||||
|
||||
Raises:
|
||||
RuntimeError: if ``http_client`` has already been closed.
|
||||
"""
|
||||
if http_client.is_closed:
|
||||
raise RuntimeError("DifyPluginLayer.create_daemon_provider() requires an open shared HTTP client.")
|
||||
return DifyPluginDaemonProvider(
|
||||
tenant_id=self.config.tenant_id,
|
||||
plugin_id=self.config.plugin_id,
|
||||
plugin_daemon_url=self.daemon_url,
|
||||
plugin_daemon_api_key=self.daemon_api_key,
|
||||
user_id=self.config.user_id,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["DifyPluginLayer"]
|
||||
51
dify-agent/src/dify_agent/protocol/__init__.py
Normal file
51
dify-agent/src/dify_agent/protocol/__init__.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""Public protocol exports shared by the Dify Agent server and clients."""
|
||||
|
||||
from .schemas import (
|
||||
DIFY_AGENT_MODEL_LAYER_ID,
|
||||
RUN_EVENT_ADAPTER,
|
||||
BaseRunEvent,
|
||||
CreateRunRequest,
|
||||
CreateRunResponse,
|
||||
EmptyRunEventData,
|
||||
LayerExitSignals,
|
||||
PydanticAIStreamRunEvent,
|
||||
RunEvent,
|
||||
RunComposition,
|
||||
RunEventType,
|
||||
RunEventsResponse,
|
||||
RunFailedEvent,
|
||||
RunFailedEventData,
|
||||
RunLayerSpec,
|
||||
RunStartedEvent,
|
||||
RunStatus,
|
||||
RunStatusResponse,
|
||||
RunSucceededEvent,
|
||||
RunSucceededEventData,
|
||||
normalize_composition,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BaseRunEvent",
|
||||
"CreateRunRequest",
|
||||
"CreateRunResponse",
|
||||
"DIFY_AGENT_MODEL_LAYER_ID",
|
||||
"EmptyRunEventData",
|
||||
"LayerExitSignals",
|
||||
"PydanticAIStreamRunEvent",
|
||||
"RUN_EVENT_ADAPTER",
|
||||
"RunComposition",
|
||||
"RunEvent",
|
||||
"RunEventType",
|
||||
"RunEventsResponse",
|
||||
"RunFailedEvent",
|
||||
"RunFailedEventData",
|
||||
"RunLayerSpec",
|
||||
"RunStartedEvent",
|
||||
"RunStatus",
|
||||
"RunStatusResponse",
|
||||
"RunSucceededEvent",
|
||||
"RunSucceededEventData",
|
||||
"normalize_composition",
|
||||
"utc_now",
|
||||
]
|
||||
264
dify-agent/src/dify_agent/protocol/schemas.py
Normal file
264
dify-agent/src/dify_agent/protocol/schemas.py
Normal file
@ -0,0 +1,264 @@
|
||||
"""Public HTTP protocol schemas for the Dify Agent run API.
|
||||
|
||||
This module is the shared wire contract for the FastAPI server, runtime event
|
||||
producers, storage adapters, and Python client. Create-run requests expose a
|
||||
Dify-friendly ``composition.layers[].config`` shape so callers can describe one
|
||||
layer in one place; the server normalizes that public DTO into Agenton's
|
||||
state-only ``CompositorConfig`` plus node-name keyed per-run configs before
|
||||
calling ``Compositor.enter(configs=...)``. Session snapshots and ``on_exit`` stay
|
||||
top-level because they are per-run resume state and exit policy, not graph node
|
||||
definition.
|
||||
|
||||
The server still constructs layers only from explicit provider type ids, keeping
|
||||
HTTP input data-only and preventing unsafe import-path construction. Run events
|
||||
are append-only records; Redis stream ids (or in-memory equivalents in tests) are
|
||||
the public cursors used by polling and SSE replay. Event envelopes keep the
|
||||
public ``id``/``run_id``/``type``/``data``/``created_at`` shape, while each
|
||||
``type`` has a typed ``data`` model so OpenAPI, Redis replay, and clients parse
|
||||
the same payload contract. Model/provider selection is part of the submitted
|
||||
composition, not a top-level run field; the runtime reads the model layer named
|
||||
by ``DIFY_AGENT_MODEL_LAYER_ID``. Request-level ``on_exit`` signals decide
|
||||
whether each active layer is suspended or deleted when the run exits, with
|
||||
suspend as the default so successful terminal events can include resumable
|
||||
snapshots. Successful runs publish the final JSON-safe agent output and the
|
||||
resumable Agenton session snapshot together on the terminal ``run_succeeded``
|
||||
event so consumers can treat terminal events as complete run summaries.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, ClassVar, Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter
|
||||
from pydantic_ai.messages import AgentStreamEvent
|
||||
|
||||
from agenton.compositor import CompositorConfig, CompositorSessionSnapshot, LayerConfigInput, LayerNodeConfig
|
||||
from agenton.layers import ExitIntent
|
||||
|
||||
|
||||
DIFY_AGENT_MODEL_LAYER_ID: Final[str] = "llm"
|
||||
RunStatus = Literal["running", "succeeded", "failed"]
|
||||
RunEventType = Literal[
|
||||
"run_started",
|
||||
"pydantic_ai_event",
|
||||
"run_succeeded",
|
||||
"run_failed",
|
||||
]
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
"""Return the timezone-aware timestamp format used by public schemas."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class LayerExitSignals(BaseModel):
|
||||
"""Requested per-layer lifecycle behavior for the top-level ``on_exit`` field."""
|
||||
|
||||
default: ExitIntent = ExitIntent.SUSPEND
|
||||
layers: dict[str, ExitIntent] = Field(default_factory=dict)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RunLayerSpec(BaseModel):
|
||||
"""Public graph node plus per-run layer config for one Dify Agent layer.
|
||||
|
||||
``name``/``type``/``deps``/``metadata`` are normalized into Agenton's
|
||||
provider-backed graph config. ``config`` is kept separate at the Agenton
|
||||
boundary and passed to ``Compositor.enter(configs=...)`` keyed by ``name``;
|
||||
existing layer config DTO instances are preserved so client code can stay
|
||||
DTO-first without being forced into raw dictionaries.
|
||||
"""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
deps: dict[str, str] = Field(default_factory=dict)
|
||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
config: LayerConfigInput = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RunComposition(BaseModel):
|
||||
"""Public create-run composition DTO.
|
||||
|
||||
The public shape intentionally differs from Agenton's internal
|
||||
``CompositorConfig`` by carrying each layer's per-run config next to its graph
|
||||
node fields. Use ``normalize_composition`` at server/runtime boundaries before
|
||||
constructing a ``Compositor``.
|
||||
"""
|
||||
|
||||
schema_version: int = 1
|
||||
layers: list[RunLayerSpec]
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class CreateRunRequest(BaseModel):
|
||||
"""Request body for creating one async agent run.
|
||||
|
||||
Model/provider configuration must be supplied through the composition layer
|
||||
named by ``DIFY_AGENT_MODEL_LAYER_ID``. ``on_exit`` defaults every active
|
||||
layer to suspend so callers receive a resumable success snapshot unless they
|
||||
explicitly request delete for one or more layers.
|
||||
"""
|
||||
|
||||
composition: RunComposition
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
on_exit: LayerExitSignals = Field(default_factory=LayerExitSignals)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
def normalize_composition(composition: RunComposition) -> tuple[CompositorConfig, dict[str, LayerConfigInput]]:
|
||||
"""Split public Dify composition into Agenton's graph config and layer configs.
|
||||
|
||||
Returns:
|
||||
A ``CompositorConfig`` containing only graph fields and a node-name keyed
|
||||
config mapping suitable for ``Compositor.enter(configs=...)``.
|
||||
|
||||
The helper is the stable public-to-Agenton boundary: it preserves concrete
|
||||
``LayerConfig`` DTO inputs where possible, does not accept legacy
|
||||
``LayerNodeConfig(config=...)`` payloads, and keeps session snapshots plus
|
||||
exit signals out of graph normalization.
|
||||
"""
|
||||
|
||||
graph_config = CompositorConfig(
|
||||
schema_version=composition.schema_version,
|
||||
layers=[
|
||||
LayerNodeConfig(
|
||||
name=layer.name,
|
||||
type=layer.type,
|
||||
deps=dict(layer.deps),
|
||||
metadata=dict(layer.metadata),
|
||||
)
|
||||
for layer in composition.layers
|
||||
],
|
||||
)
|
||||
layer_configs = {layer.name: layer.config for layer in composition.layers}
|
||||
return graph_config, layer_configs
|
||||
|
||||
|
||||
class CreateRunResponse(BaseModel):
|
||||
"""Response returned after a run has been persisted and scheduled locally."""
|
||||
|
||||
run_id: str
|
||||
status: RunStatus
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RunStatusResponse(BaseModel):
|
||||
"""Current server-side status for one run."""
|
||||
|
||||
run_id: str
|
||||
status: RunStatus
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
error: str | None = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class EmptyRunEventData(BaseModel):
|
||||
"""Typed empty payload for lifecycle events that carry no extra data."""
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RunSucceededEventData(BaseModel):
|
||||
"""Terminal success payload for final output and resumable session state."""
|
||||
|
||||
output: JsonValue
|
||||
session_snapshot: CompositorSessionSnapshot
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RunFailedEventData(BaseModel):
|
||||
"""Terminal failure payload shown to polling and SSE consumers."""
|
||||
|
||||
error: str
|
||||
reason: str | None = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class BaseRunEvent(BaseModel):
|
||||
"""Shared append-only event envelope visible through polling and SSE."""
|
||||
|
||||
id: str | None = None
|
||||
run_id: str
|
||||
created_at: datetime = Field(default_factory=utc_now)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RunStartedEvent(BaseRunEvent):
|
||||
"""Run lifecycle event emitted before runtime execution starts."""
|
||||
|
||||
type: Literal["run_started"] = "run_started"
|
||||
data: EmptyRunEventData = Field(default_factory=EmptyRunEventData)
|
||||
|
||||
|
||||
class PydanticAIStreamRunEvent(BaseRunEvent):
|
||||
"""Pydantic AI stream event using the upstream typed event model."""
|
||||
|
||||
type: Literal["pydantic_ai_event"] = "pydantic_ai_event"
|
||||
data: AgentStreamEvent
|
||||
|
||||
|
||||
class RunSucceededEvent(BaseRunEvent):
|
||||
"""Terminal success event carrying the complete successful run result."""
|
||||
|
||||
type: Literal["run_succeeded"] = "run_succeeded"
|
||||
data: RunSucceededEventData
|
||||
|
||||
|
||||
class RunFailedEvent(BaseRunEvent):
|
||||
"""Terminal failure event emitted before the run status becomes failed."""
|
||||
|
||||
type: Literal["run_failed"] = "run_failed"
|
||||
data: RunFailedEventData
|
||||
|
||||
|
||||
RunEvent: TypeAlias = Annotated[
|
||||
RunStartedEvent | PydanticAIStreamRunEvent | RunSucceededEvent | RunFailedEvent,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
RUN_EVENT_ADAPTER: TypeAdapter[RunEvent] = TypeAdapter(RunEvent)
|
||||
|
||||
|
||||
class RunEventsResponse(BaseModel):
|
||||
"""Cursor-paginated event log response."""
|
||||
|
||||
run_id: str
|
||||
events: list[RunEvent]
|
||||
next_cursor: str | None = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BaseRunEvent",
|
||||
"CreateRunRequest",
|
||||
"CreateRunResponse",
|
||||
"DIFY_AGENT_MODEL_LAYER_ID",
|
||||
"EmptyRunEventData",
|
||||
"LayerExitSignals",
|
||||
"PydanticAIStreamRunEvent",
|
||||
"RUN_EVENT_ADAPTER",
|
||||
"RunComposition",
|
||||
"RunEvent",
|
||||
"RunEventType",
|
||||
"RunEventsResponse",
|
||||
"RunFailedEvent",
|
||||
"RunFailedEventData",
|
||||
"RunStartedEvent",
|
||||
"RunStatus",
|
||||
"RunStatusResponse",
|
||||
"RunSucceededEvent",
|
||||
"RunSucceededEventData",
|
||||
"RunLayerSpec",
|
||||
"normalize_composition",
|
||||
"utc_now",
|
||||
]
|
||||
48
dify-agent/src/dify_agent/runtime/agent_factory.py
Normal file
48
dify-agent/src/dify_agent/runtime/agent_factory.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Pydantic AI agent construction for models supplied by Agenton layers.
|
||||
|
||||
The run request carries model/provider selection in the layer graph. This helper
|
||||
keeps Agent construction details out of ``AgentRunRunner`` while accepting an
|
||||
already resolved Pydantic AI model from the configured model layer. Prompt and
|
||||
tool values arriving here are already transformed by Agenton's
|
||||
``PYDANTIC_AI_TRANSFORMERS`` preset; this module registers those pydantic-ai
|
||||
objects without reimplementing plain/pydantic-ai conversion logic.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, cast
|
||||
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.messages import UserContent
|
||||
from pydantic_ai.models import Model
|
||||
|
||||
from agenton.layers.types import PydanticAIPrompt, PydanticAITool
|
||||
|
||||
|
||||
def create_agent(
|
||||
model: Model[Any],
|
||||
*,
|
||||
system_prompts: Sequence[PydanticAIPrompt[object]],
|
||||
tools: Sequence[PydanticAITool[object]],
|
||||
) -> Agent[None, object]:
|
||||
"""Create the pydantic-ai agent for one run."""
|
||||
agent = cast(
|
||||
Agent[None, object],
|
||||
Agent[None, str](
|
||||
model,
|
||||
output_type=str,
|
||||
tools=tools,
|
||||
),
|
||||
)
|
||||
for prompt in system_prompts:
|
||||
_ = agent.system_prompt(cast(Any, prompt))
|
||||
return agent
|
||||
|
||||
|
||||
def normalize_user_input(user_prompts: Sequence[UserContent]) -> str | Sequence[UserContent]:
|
||||
"""Return the pydantic-ai run input while preserving multi-part prompts."""
|
||||
if len(user_prompts) == 1 and isinstance(user_prompts[0], str):
|
||||
return user_prompts[0]
|
||||
return list(user_prompts)
|
||||
|
||||
|
||||
__all__ = ["create_agent", "normalize_user_input"]
|
||||
28
dify-agent/src/dify_agent/runtime/agenton_validation.py
Normal file
28
dify-agent/src/dify_agent/runtime/agenton_validation.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""Shared validation helpers for Agenton-backed request boundaries.
|
||||
|
||||
Most bad Dify Agent inputs surface from Agenton as ``KeyError``, ``TypeError``,
|
||||
or ``ValueError`` while graph config, per-run layer config, and session snapshot
|
||||
DTOs are being validated. One smaller class of request-shaped failures appears a
|
||||
bit later, during ``Compositor.enter(...)`` before the body of the entered run
|
||||
executes: session snapshots may contain lifecycle states such as ``CLOSED`` that
|
||||
are serializable but not enterable. Agenton reports those as ``RuntimeError``.
|
||||
|
||||
Dify Agent intentionally translates only these known enter-time runtime errors
|
||||
into public request-validation errors. Other runtime failures still represent
|
||||
execution bugs or infrastructure problems and must not be downgraded to client
|
||||
input errors.
|
||||
"""
|
||||
|
||||
_ENTER_VALIDATION_RUNTIME_ERROR_FRAGMENTS = (
|
||||
"ACTIVE snapshots are not allowed.",
|
||||
"CLOSED snapshots cannot be entered.",
|
||||
)
|
||||
|
||||
|
||||
def is_agenton_enter_validation_runtime_error(exc: RuntimeError) -> bool:
|
||||
"""Return whether ``exc`` is a known Agenton enter-time input failure."""
|
||||
message = str(exc)
|
||||
return any(fragment in message for fragment in _ENTER_VALIDATION_RUNTIME_ERROR_FRAGMENTS)
|
||||
|
||||
|
||||
__all__ = ["is_agenton_enter_validation_runtime_error"]
|
||||
88
dify-agent/src/dify_agent/runtime/compositor_factory.py
Normal file
88
dify-agent/src/dify_agent/runtime/compositor_factory.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""Safe Agenton compositor construction for API-submitted configs.
|
||||
|
||||
Only explicitly allowed provider type ids are constructible here. The default
|
||||
provider set contains prompt layers plus Dify plugin LLM layers. Public DTOs
|
||||
provide tenant/plugin/model data, while server-only plugin daemon settings are
|
||||
injected through the provider factory for ``DifyPluginLayer``. The resulting
|
||||
``Compositor`` remains Agenton state-only: live resources such as the plugin
|
||||
daemon HTTP client are supplied later by the runtime and never enter providers,
|
||||
layers, or session snapshots.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, cast
|
||||
|
||||
from pydantic_ai.messages import UserContent
|
||||
|
||||
from agenton.compositor import Compositor, CompositorConfig, LayerProvider, LayerProviderInput
|
||||
from agenton.layers.types import AllPromptTypes, AllToolTypes, AllUserPromptTypes, PydanticAIPrompt, PydanticAITool
|
||||
from agenton_collections.layers.plain.basic import PromptLayer
|
||||
from agenton_collections.transformers.pydantic_ai import PYDANTIC_AI_TRANSFORMERS
|
||||
from dify_agent.layers.dify_plugin.configs import DifyPluginLayerConfig
|
||||
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
|
||||
from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer
|
||||
|
||||
|
||||
type DifyAgentLayerProvider = LayerProvider[Any]
|
||||
|
||||
|
||||
def create_default_layer_providers(
|
||||
*,
|
||||
plugin_daemon_url: str = "http://localhost:5002",
|
||||
plugin_daemon_api_key: str = "",
|
||||
) -> tuple[DifyAgentLayerProvider, ...]:
|
||||
"""Return the server provider set of safe config-constructible layers."""
|
||||
return (
|
||||
LayerProvider.from_layer_type(PromptLayer),
|
||||
LayerProvider.from_factory(
|
||||
layer_type=DifyPluginLayer,
|
||||
create=lambda config: DifyPluginLayer.from_config_with_settings(
|
||||
DifyPluginLayerConfig.model_validate(config),
|
||||
daemon_url=plugin_daemon_url,
|
||||
daemon_api_key=plugin_daemon_api_key,
|
||||
),
|
||||
),
|
||||
LayerProvider.from_layer_type(DifyPluginLLMLayer),
|
||||
)
|
||||
|
||||
|
||||
def build_pydantic_ai_compositor(
|
||||
config: CompositorConfig,
|
||||
*,
|
||||
providers: Sequence[LayerProviderInput],
|
||||
node_providers: Mapping[str, LayerProviderInput] | None = None,
|
||||
) -> Compositor[
|
||||
PydanticAIPrompt[object],
|
||||
PydanticAITool[object],
|
||||
AllPromptTypes,
|
||||
AllToolTypes,
|
||||
UserContent,
|
||||
AllUserPromptTypes,
|
||||
]:
|
||||
"""Build a Pydantic AI-ready compositor from a validated graph config.
|
||||
|
||||
Prompt, user prompt, and tool conversion is delegated to Agenton's shared
|
||||
pydantic-ai transformer preset so Dify Agent does not duplicate conversion
|
||||
logic for plain and pydantic-ai layer families. Callers must pass the already
|
||||
selected provider set explicitly so provider defaulting stays at outer runtime
|
||||
boundaries rather than being duplicated here.
|
||||
"""
|
||||
return cast(
|
||||
Compositor[
|
||||
PydanticAIPrompt[object],
|
||||
PydanticAITool[object],
|
||||
AllPromptTypes,
|
||||
AllToolTypes,
|
||||
UserContent,
|
||||
AllUserPromptTypes,
|
||||
],
|
||||
Compositor.from_config(
|
||||
config,
|
||||
providers=providers,
|
||||
node_providers=node_providers,
|
||||
**PYDANTIC_AI_TRANSFORMERS, # pyright: ignore[reportArgumentType]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["DifyAgentLayerProvider", "build_pydantic_ai_compositor", "create_default_layer_providers"]
|
||||
134
dify-agent/src/dify_agent/runtime/event_sink.py
Normal file
134
dify-agent/src/dify_agent/runtime/event_sink.py
Normal file
@ -0,0 +1,134 @@
|
||||
"""Event sink contracts used by the runner and storage adapters.
|
||||
|
||||
The runner only needs append-only event writes and status transitions, so tests
|
||||
can use ``InMemoryRunEventSink`` without Redis. Production storage implements the
|
||||
same protocol with Redis streams in ``dify_agent.storage.redis_run_store``. The
|
||||
terminal success helper writes the final JSON-safe output and session snapshot in
|
||||
one event so event consumers can stop at ``run_succeeded`` without correlating
|
||||
separate payload events.
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic import JsonValue
|
||||
from pydantic_ai.messages import AgentStreamEvent
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.protocol.schemas import (
|
||||
EmptyRunEventData,
|
||||
PydanticAIStreamRunEvent,
|
||||
RunEvent,
|
||||
RunFailedEvent,
|
||||
RunFailedEventData,
|
||||
RunStartedEvent,
|
||||
RunStatus,
|
||||
RunSucceededEvent,
|
||||
RunSucceededEventData,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
|
||||
class RunEventSink(Protocol):
|
||||
"""Boundary used by runtime code to publish observable run progress."""
|
||||
|
||||
async def append_event(self, event: RunEvent) -> str:
|
||||
"""Persist ``event`` and return its cursor id."""
|
||||
...
|
||||
|
||||
async def update_status(self, run_id: str, status: RunStatus, error: str | None = None) -> None:
|
||||
"""Persist the current run status."""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryRunEventSink:
|
||||
"""Small async-compatible sink for local unit tests and examples."""
|
||||
|
||||
events: dict[str, list[RunEvent]]
|
||||
statuses: dict[str, RunStatus]
|
||||
errors: dict[str, str | None]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events = defaultdict(list)
|
||||
self.statuses = {}
|
||||
self.errors = {}
|
||||
|
||||
async def append_event(self, event: RunEvent) -> str:
|
||||
"""Store an event and assign a monotonic per-run cursor."""
|
||||
event_id = str(len(self.events[event.run_id]) + 1)
|
||||
stored = event.model_copy(update={"id": event_id})
|
||||
self.events[event.run_id].append(stored)
|
||||
return event_id
|
||||
|
||||
async def update_status(self, run_id: str, status: RunStatus, error: str | None = None) -> None:
|
||||
"""Record the latest status; timestamps are owned by run stores."""
|
||||
self.statuses[run_id] = status
|
||||
self.errors[run_id] = error
|
||||
|
||||
|
||||
async def emit_run_event(
|
||||
sink: RunEventSink,
|
||||
*,
|
||||
event: RunEvent,
|
||||
) -> str:
|
||||
"""Append an already typed public run event."""
|
||||
return await sink.append_event(event)
|
||||
|
||||
|
||||
async def emit_run_started(sink: RunEventSink, *, run_id: str) -> str:
|
||||
"""Emit the first lifecycle event for one run."""
|
||||
return await emit_run_event(
|
||||
sink,
|
||||
event=RunStartedEvent(run_id=run_id, data=EmptyRunEventData(), created_at=utc_now()),
|
||||
)
|
||||
|
||||
|
||||
async def emit_pydantic_ai_event(sink: RunEventSink, *, run_id: str, data: AgentStreamEvent) -> str:
|
||||
"""Emit one typed Pydantic AI stream event."""
|
||||
return await emit_run_event(
|
||||
sink,
|
||||
event=PydanticAIStreamRunEvent(run_id=run_id, data=data, created_at=utc_now()),
|
||||
)
|
||||
|
||||
|
||||
async def emit_run_succeeded(
|
||||
sink: RunEventSink,
|
||||
*,
|
||||
run_id: str,
|
||||
output: JsonValue,
|
||||
session_snapshot: CompositorSessionSnapshot,
|
||||
) -> str:
|
||||
"""Emit the terminal success event with output and resumable state."""
|
||||
return await emit_run_event(
|
||||
sink,
|
||||
event=RunSucceededEvent(
|
||||
run_id=run_id,
|
||||
data=RunSucceededEventData(output=output, session_snapshot=session_snapshot),
|
||||
created_at=utc_now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def emit_run_failed(
|
||||
sink: RunEventSink,
|
||||
*,
|
||||
run_id: str,
|
||||
error: str,
|
||||
reason: str | None = None,
|
||||
) -> str:
|
||||
"""Emit the terminal failure lifecycle event."""
|
||||
return await emit_run_event(
|
||||
sink,
|
||||
event=RunFailedEvent(run_id=run_id, data=RunFailedEventData(error=error, reason=reason), created_at=utc_now()),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"InMemoryRunEventSink",
|
||||
"RunEventSink",
|
||||
"emit_pydantic_ai_event",
|
||||
"emit_run_event",
|
||||
"emit_run_failed",
|
||||
"emit_run_started",
|
||||
"emit_run_succeeded",
|
||||
]
|
||||
46
dify-agent/src/dify_agent/runtime/layer_exit_signals.py
Normal file
46
dify-agent/src/dify_agent/runtime/layer_exit_signals.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""Validation and application of request-level Agenton layer exit signals.
|
||||
|
||||
HTTP requests carry data-only lifecycle intent in the top-level ``on_exit``
|
||||
field. The runtime validates signal keys against the built compositor before a
|
||||
run is persisted or entered, then applies the resolved intent to the active
|
||||
``CompositorRun`` after entry because Agenton initializes each run slot with a
|
||||
delete-on-exit intent.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agenton.compositor import Compositor, CompositorRun
|
||||
from agenton.layers import ExitIntent
|
||||
from dify_agent.protocol.schemas import LayerExitSignals
|
||||
|
||||
|
||||
def validate_layer_exit_signals(
|
||||
compositor: Compositor[Any, Any, Any, Any, Any, Any],
|
||||
signals: LayerExitSignals,
|
||||
) -> None:
|
||||
"""Raise ``ValueError`` when ``signals`` mention layers absent from ``compositor``."""
|
||||
known_layer_ids = {node.name for node in compositor.nodes}
|
||||
unknown_layer_ids = set(signals.layers) - known_layer_ids
|
||||
if not unknown_layer_ids:
|
||||
return
|
||||
|
||||
names = ", ".join(sorted(unknown_layer_ids))
|
||||
raise ValueError(f"on_exit.layers references unknown layer ids: {names}.")
|
||||
|
||||
|
||||
def apply_layer_exit_signals(
|
||||
run: CompositorRun[Any, Any, Any, Any, Any, Any],
|
||||
signals: LayerExitSignals,
|
||||
) -> None:
|
||||
"""Apply ``signals`` to active run slots for the current compositor entry."""
|
||||
for layer_id in run.slots:
|
||||
intent = signals.layers.get(layer_id, signals.default)
|
||||
if intent is ExitIntent.SUSPEND:
|
||||
run.suspend_layer_on_exit(layer_id)
|
||||
elif intent is ExitIntent.DELETE:
|
||||
run.delete_layer_on_exit(layer_id)
|
||||
else:
|
||||
raise ValueError(f"Unsupported layer exit intent: {intent!r}.")
|
||||
|
||||
|
||||
__all__ = ["apply_layer_exit_signals", "validate_layer_exit_signals"]
|
||||
202
dify-agent/src/dify_agent/runtime/run_scheduler.py
Normal file
202
dify-agent/src/dify_agent/runtime/run_scheduler.py
Normal file
@ -0,0 +1,202 @@
|
||||
"""In-process scheduling for Dify Agent runs.
|
||||
|
||||
The scheduler is intentionally process-local: it persists a run record, starts an
|
||||
``asyncio.Task`` for ``AgentRunRunner.run()``, and keeps only a transient active
|
||||
task registry. Redis remains the durable source for status and event streams, but
|
||||
there is no Redis job queue or cross-process handoff. If the process crashes,
|
||||
currently active runs are lost until an external operator marks or retries them.
|
||||
Create-run validation enters a lightweight Agenton run before persistence so the
|
||||
same transformed user prompts and top-level ``on_exit`` policy used by execution
|
||||
are checked without relying on removed session/control APIs; Dify's default
|
||||
layers keep lifecycle hooks side-effect free so this validation does not open
|
||||
plugin daemon clients.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol
|
||||
|
||||
import httpx
|
||||
|
||||
from agenton.compositor import LayerProviderInput
|
||||
from dify_agent.protocol.schemas import CreateRunRequest, normalize_composition
|
||||
from dify_agent.runtime.agenton_validation import is_agenton_enter_validation_runtime_error
|
||||
from dify_agent.runtime.compositor_factory import build_pydantic_ai_compositor, create_default_layer_providers
|
||||
from dify_agent.runtime.event_sink import RunEventSink, emit_run_failed
|
||||
from dify_agent.runtime.layer_exit_signals import apply_layer_exit_signals, validate_layer_exit_signals
|
||||
from dify_agent.runtime.runner import AgentRunRunner
|
||||
from dify_agent.runtime.user_prompt_validation import EMPTY_USER_PROMPTS_ERROR, has_non_blank_user_prompt
|
||||
from dify_agent.server.schemas import RunRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SchedulerStoppingError(RuntimeError):
|
||||
"""Raised when a create-run request arrives after shutdown has started."""
|
||||
|
||||
|
||||
class RunRequestValidationError(ValueError):
|
||||
"""Raised when a create-run request cannot produce an executable Agenton run."""
|
||||
|
||||
|
||||
class RunStore(RunEventSink, Protocol):
|
||||
"""Persistence boundary needed by the scheduler."""
|
||||
|
||||
async def create_run(self) -> RunRecord:
|
||||
"""Persist a new run record and return it with status ``running``."""
|
||||
...
|
||||
|
||||
|
||||
class RunnableRun(Protocol):
|
||||
"""Executable unit for one scheduled run."""
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Run until terminal status/events have been written or cancellation occurs."""
|
||||
...
|
||||
|
||||
|
||||
type RunRunnerFactory = Callable[[RunRecord, CreateRunRequest], RunnableRun]
|
||||
|
||||
|
||||
class RunScheduler:
|
||||
"""Owns process-local run tasks and best-effort graceful shutdown.
|
||||
|
||||
``active_tasks`` is mutated only on the event loop that calls ``create_run``
|
||||
and ``shutdown``. The task registry is not durable; it exists so the lifespan
|
||||
hook can wait for in-flight work and mark cancelled runs failed before Redis is
|
||||
closed. A lock guards the stopping flag, lightweight request validation, run
|
||||
persistence, and task registration so shutdown cannot begin after a request is
|
||||
admitted and no validation runs once stopping has been set.
|
||||
"""
|
||||
|
||||
store: RunStore
|
||||
shutdown_grace_seconds: float
|
||||
active_tasks: dict[str, asyncio.Task[None]]
|
||||
stopping: bool
|
||||
runner_factory: RunRunnerFactory
|
||||
layer_providers: tuple[LayerProviderInput, ...]
|
||||
plugin_daemon_http_client: httpx.AsyncClient
|
||||
_lifecycle_lock: asyncio.Lock
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
store: RunStore,
|
||||
plugin_daemon_http_client: httpx.AsyncClient,
|
||||
shutdown_grace_seconds: float = 30,
|
||||
layer_providers: tuple[LayerProviderInput, ...] | None = None,
|
||||
runner_factory: RunRunnerFactory | None = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.shutdown_grace_seconds = shutdown_grace_seconds
|
||||
self.active_tasks = {}
|
||||
self.stopping = False
|
||||
self.plugin_daemon_http_client = plugin_daemon_http_client
|
||||
self.layer_providers = layer_providers if layer_providers is not None else create_default_layer_providers()
|
||||
self.runner_factory = runner_factory or self._default_runner_factory
|
||||
self._lifecycle_lock = asyncio.Lock()
|
||||
|
||||
async def create_run(self, request: CreateRunRequest) -> RunRecord:
|
||||
"""Validate, persist, and schedule one run in the current process.
|
||||
|
||||
The returned record is already ``running``. The background task is removed
|
||||
from ``active_tasks`` when it finishes, regardless of success or failure.
|
||||
"""
|
||||
async with self._lifecycle_lock:
|
||||
if self.stopping:
|
||||
raise SchedulerStoppingError("run scheduler is shutting down")
|
||||
await validate_run_request(request, layer_providers=self.layer_providers)
|
||||
record = await self.store.create_run()
|
||||
task = asyncio.create_task(self._run_record(record, request), name=f"dify-agent-run-{record.run_id}")
|
||||
self.active_tasks[record.run_id] = task
|
||||
task.add_done_callback(lambda _task, run_id=record.run_id: self.active_tasks.pop(run_id, None))
|
||||
return record
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Stop accepting runs, wait briefly, then cancel and fail unfinished runs."""
|
||||
async with self._lifecycle_lock:
|
||||
self.stopping = True
|
||||
if not self.active_tasks:
|
||||
return
|
||||
tasks_by_run_id = dict(self.active_tasks)
|
||||
done, pending = await asyncio.wait(tasks_by_run_id.values(), timeout=self.shutdown_grace_seconds)
|
||||
del done
|
||||
if not pending:
|
||||
return
|
||||
|
||||
pending_run_ids = [run_id for run_id, task in tasks_by_run_id.items() if task in pending]
|
||||
for task in pending:
|
||||
_ = task.cancel()
|
||||
_ = await asyncio.gather(*pending, return_exceptions=True)
|
||||
for run_id in pending_run_ids:
|
||||
await self._mark_cancelled_run_failed(run_id)
|
||||
|
||||
async def _run_record(self, record: RunRecord, request: CreateRunRequest) -> None:
|
||||
"""Execute a stored run and log failures already reflected in events."""
|
||||
try:
|
||||
await self.runner_factory(record, request).run()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("scheduled run failed", extra={"run_id": record.run_id})
|
||||
|
||||
def _default_runner_factory(self, record: RunRecord, request: CreateRunRequest) -> RunnableRun:
|
||||
"""Create the production runner for a stored run record."""
|
||||
return AgentRunRunner(
|
||||
sink=self.store,
|
||||
request=request,
|
||||
run_id=record.run_id,
|
||||
plugin_daemon_http_client=self.plugin_daemon_http_client,
|
||||
layer_providers=self.layer_providers,
|
||||
)
|
||||
|
||||
async def _mark_cancelled_run_failed(self, run_id: str) -> None:
|
||||
"""Best-effort failure event/status for shutdown-cancelled runs."""
|
||||
message = "run cancelled during server shutdown"
|
||||
try:
|
||||
_ = await emit_run_failed(self.store, run_id=run_id, error=message, reason="shutdown")
|
||||
await self.store.update_status(run_id, "failed", message)
|
||||
except Exception:
|
||||
logger.exception("failed to mark cancelled run failed", extra={"run_id": run_id})
|
||||
|
||||
|
||||
async def validate_run_request(
|
||||
request: CreateRunRequest,
|
||||
*,
|
||||
layer_providers: tuple[LayerProviderInput, ...] | None = None,
|
||||
) -> None:
|
||||
"""Validate create-run semantics that require an entered Agenton run.
|
||||
|
||||
This boundary rejects unknown ``on_exit`` layer ids, effectively empty
|
||||
transformed user prompts, and known enter-time snapshot lifecycle errors
|
||||
before the scheduler persists a run record. It also exercises provider config
|
||||
validation and snapshot hydration without touching external services because
|
||||
Dify plugin daemon clients are owned by the FastAPI lifespan, not Agenton
|
||||
lifecycle hooks.
|
||||
"""
|
||||
resolved_layer_providers = layer_providers if layer_providers is not None else create_default_layer_providers()
|
||||
entered_run = False
|
||||
try:
|
||||
graph_config, layer_configs = normalize_composition(request.composition)
|
||||
compositor = build_pydantic_ai_compositor(
|
||||
graph_config,
|
||||
providers=resolved_layer_providers,
|
||||
)
|
||||
validate_layer_exit_signals(compositor, request.on_exit)
|
||||
async with compositor.enter(configs=layer_configs, session_snapshot=request.session_snapshot) as run:
|
||||
entered_run = True
|
||||
apply_layer_exit_signals(run, request.on_exit)
|
||||
if not has_non_blank_user_prompt(run.user_prompts):
|
||||
raise RunRequestValidationError(EMPTY_USER_PROMPTS_ERROR)
|
||||
except RunRequestValidationError:
|
||||
raise
|
||||
except RuntimeError as exc:
|
||||
if not entered_run and is_agenton_enter_validation_runtime_error(exc):
|
||||
raise RunRequestValidationError(str(exc)) from exc
|
||||
raise
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise RunRequestValidationError(str(exc)) from exc
|
||||
|
||||
|
||||
__all__ = ["RunRequestValidationError", "RunScheduler", "SchedulerStoppingError", "validate_run_request"]
|
||||
145
dify-agent/src/dify_agent/runtime/runner.py
Normal file
145
dify-agent/src/dify_agent/runtime/runner.py
Normal file
@ -0,0 +1,145 @@
|
||||
"""Runtime execution for one scheduled Dify Agent run.
|
||||
|
||||
The runner is storage-agnostic: it normalizes the public Dify composition into
|
||||
Agenton's graph/config split, enters a fresh ``CompositorRun`` (or resumes one
|
||||
from a snapshot), runs pydantic-ai with ``run.user_prompts`` as the user input,
|
||||
emits stream events, applies request-level ``on_exit`` signals, and then
|
||||
publishes a terminal success or failure event. The Pydantic AI model is resolved
|
||||
from the active Agenton layer named by ``DIFY_AGENT_MODEL_LAYER_ID`` and receives
|
||||
the FastAPI lifespan-owned plugin daemon HTTP client; no run or layer owns that
|
||||
client. Successful terminal events contain both the JSON-safe final output and
|
||||
session snapshot; there are no separate output or snapshot events to correlate.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
from pydantic_ai.messages import AgentStreamEvent
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot, LayerProviderInput
|
||||
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
|
||||
from dify_agent.protocol.schemas import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, normalize_composition
|
||||
from dify_agent.runtime.agent_factory import create_agent, normalize_user_input
|
||||
from dify_agent.runtime.agenton_validation import is_agenton_enter_validation_runtime_error
|
||||
from dify_agent.runtime.compositor_factory import build_pydantic_ai_compositor, create_default_layer_providers
|
||||
from dify_agent.runtime.event_sink import (
|
||||
RunEventSink,
|
||||
emit_pydantic_ai_event,
|
||||
emit_run_failed,
|
||||
emit_run_started,
|
||||
emit_run_succeeded,
|
||||
)
|
||||
from dify_agent.runtime.layer_exit_signals import apply_layer_exit_signals, validate_layer_exit_signals
|
||||
from dify_agent.runtime.user_prompt_validation import EMPTY_USER_PROMPTS_ERROR, has_non_blank_user_prompt
|
||||
|
||||
|
||||
_AGENT_OUTPUT_ADAPTER = TypeAdapter(object)
|
||||
|
||||
|
||||
class AgentRunValidationError(ValueError):
|
||||
"""Raised when a run request is valid JSON but cannot execute."""
|
||||
|
||||
|
||||
class AgentRunRunner:
|
||||
"""Executes one run and writes only public run events to its sink."""
|
||||
|
||||
sink: RunEventSink
|
||||
|
||||
request: CreateRunRequest
|
||||
run_id: str
|
||||
layer_providers: tuple[LayerProviderInput, ...]
|
||||
plugin_daemon_http_client: httpx.AsyncClient
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sink: RunEventSink,
|
||||
request: CreateRunRequest,
|
||||
run_id: str,
|
||||
plugin_daemon_http_client: httpx.AsyncClient,
|
||||
layer_providers: tuple[LayerProviderInput, ...] | None = None,
|
||||
) -> None:
|
||||
self.sink = sink
|
||||
self.request = request
|
||||
self.run_id = run_id
|
||||
self.plugin_daemon_http_client = plugin_daemon_http_client
|
||||
self.layer_providers = layer_providers if layer_providers is not None else create_default_layer_providers()
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Execute the run and emit the documented event sequence."""
|
||||
await self.sink.update_status(self.run_id, "running")
|
||||
_ = await emit_run_started(self.sink, run_id=self.run_id)
|
||||
|
||||
try:
|
||||
output, session_snapshot = await self._run_agent()
|
||||
except Exception as exc:
|
||||
message = str(exc) or type(exc).__name__
|
||||
_ = await emit_run_failed(self.sink, run_id=self.run_id, error=message)
|
||||
await self.sink.update_status(self.run_id, "failed", message)
|
||||
raise
|
||||
|
||||
_ = await emit_run_succeeded(
|
||||
self.sink,
|
||||
run_id=self.run_id,
|
||||
output=output,
|
||||
session_snapshot=session_snapshot,
|
||||
)
|
||||
await self.sink.update_status(self.run_id, "succeeded")
|
||||
|
||||
async def _run_agent(self) -> tuple[JsonValue, CompositorSessionSnapshot]:
|
||||
"""Run pydantic-ai inside an entered Agenton run.
|
||||
|
||||
Known input-shaped Agenton enter-time runtime errors, such as trying to
|
||||
resume a ``CLOSED`` snapshot layer, are normalized to
|
||||
``AgentRunValidationError``. Later runtime failures still propagate as
|
||||
execution errors so they become terminal failed runs rather than client
|
||||
validation responses.
|
||||
"""
|
||||
try:
|
||||
graph_config, layer_configs = normalize_composition(self.request.composition)
|
||||
compositor = build_pydantic_ai_compositor(graph_config, providers=self.layer_providers)
|
||||
validate_layer_exit_signals(compositor, self.request.on_exit)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise AgentRunValidationError(str(exc)) from exc
|
||||
|
||||
entered_run = False
|
||||
try:
|
||||
async with compositor.enter(configs=layer_configs, session_snapshot=self.request.session_snapshot) as run:
|
||||
entered_run = True
|
||||
apply_layer_exit_signals(run, self.request.on_exit)
|
||||
user_prompts = run.user_prompts
|
||||
if not has_non_blank_user_prompt(user_prompts):
|
||||
raise AgentRunValidationError(EMPTY_USER_PROMPTS_ERROR)
|
||||
|
||||
async def handle_events(_ctx: object, events: AsyncIterable[AgentStreamEvent]) -> None:
|
||||
async for event in events:
|
||||
_ = await emit_pydantic_ai_event(self.sink, run_id=self.run_id, data=event)
|
||||
|
||||
try:
|
||||
llm_layer = run.get_layer(DIFY_AGENT_MODEL_LAYER_ID, DifyPluginLLMLayer)
|
||||
model = llm_layer.get_model(http_client=self.plugin_daemon_http_client)
|
||||
except (KeyError, TypeError, RuntimeError) as exc:
|
||||
raise AgentRunValidationError(str(exc)) from exc
|
||||
|
||||
agent = create_agent(model, system_prompts=run.prompts, tools=run.tools)
|
||||
result = await agent.run(normalize_user_input(user_prompts), event_stream_handler=handle_events)
|
||||
output = _serialize_agent_output(result.output)
|
||||
except RuntimeError as exc:
|
||||
if not entered_run and is_agenton_enter_validation_runtime_error(exc):
|
||||
raise AgentRunValidationError(str(exc)) from exc
|
||||
raise
|
||||
|
||||
if run.session_snapshot is None:
|
||||
raise RuntimeError("Agenton run did not produce a session snapshot after exit.")
|
||||
|
||||
return output, run.session_snapshot
|
||||
|
||||
|
||||
def _serialize_agent_output(output: object) -> JsonValue:
|
||||
"""Convert arbitrary pydantic-ai output into the public JSON-safe payload type."""
|
||||
return cast(JsonValue, _AGENT_OUTPUT_ADAPTER.dump_python(output, mode="json"))
|
||||
|
||||
|
||||
__all__ = ["AgentRunRunner", "AgentRunValidationError"]
|
||||
29
dify-agent/src/dify_agent/runtime/user_prompt_validation.py
Normal file
29
dify-agent/src/dify_agent/runtime/user_prompt_validation.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""Validation for effective user prompts produced by Agenton runs.
|
||||
|
||||
Validation happens after safe compositor construction and run entry so scheduler
|
||||
and runner paths use the same transformed prompts as the actual pydantic-ai
|
||||
input. Blank string fragments do not count as meaningful input; non-string
|
||||
``UserContent`` is treated as intentional content because rich media/message
|
||||
parts do not have a universal whitespace representation.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from pydantic_ai.messages import UserContent
|
||||
|
||||
|
||||
EMPTY_USER_PROMPTS_ERROR = "run.user_prompts must not be empty"
|
||||
|
||||
|
||||
def has_non_blank_user_prompt(user_prompts: Sequence[UserContent]) -> bool:
|
||||
"""Return whether composed user prompts contain meaningful input."""
|
||||
for prompt in user_prompts:
|
||||
if isinstance(prompt, str):
|
||||
if prompt.strip():
|
||||
return True
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["EMPTY_USER_PROMPTS_ERROR", "has_non_blank_user_prompt"]
|
||||
97
dify-agent/src/dify_agent/server/app.py
Normal file
97
dify-agent/src/dify_agent/server/app.py
Normal file
@ -0,0 +1,97 @@
|
||||
"""FastAPI application factory for the Dify Agent run server.
|
||||
|
||||
The HTTP process owns Redis clients, one shared plugin daemon ``httpx.AsyncClient``,
|
||||
route wiring, and a process-local scheduler. Run execution happens in background
|
||||
``asyncio`` tasks rather than request handlers, so client disconnects do not
|
||||
cancel the agent runtime. Redis persists run records and per-run event streams
|
||||
with configured retention only; it is not used as a job queue. Agenton layers and
|
||||
providers stay state-only: they borrow the lifespan-owned plugin daemon client
|
||||
through the runner and never create or close it themselves.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
from dify_agent.runtime.run_scheduler import RunScheduler
|
||||
from dify_agent.server.routes.runs import create_runs_router
|
||||
from dify_agent.server.settings import ServerSettings
|
||||
from dify_agent.storage.redis_run_store import RedisRunStore
|
||||
|
||||
|
||||
def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
||||
"""Build the FastAPI app with one shared Redis store and local scheduler."""
|
||||
resolved_settings = settings or ServerSettings()
|
||||
layer_providers = create_default_layer_providers(
|
||||
plugin_daemon_url=resolved_settings.plugin_daemon_url,
|
||||
plugin_daemon_api_key=resolved_settings.plugin_daemon_api_key,
|
||||
)
|
||||
state: dict[str, object] = {}
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
redis = Redis.from_url(resolved_settings.redis_url)
|
||||
plugin_daemon_http_client = create_plugin_daemon_http_client(resolved_settings)
|
||||
store = RedisRunStore(
|
||||
redis,
|
||||
prefix=resolved_settings.redis_prefix,
|
||||
run_retention_seconds=resolved_settings.run_retention_seconds,
|
||||
)
|
||||
scheduler = RunScheduler(
|
||||
store=store,
|
||||
plugin_daemon_http_client=plugin_daemon_http_client,
|
||||
shutdown_grace_seconds=resolved_settings.shutdown_grace_seconds,
|
||||
layer_providers=layer_providers,
|
||||
)
|
||||
state["store"] = store
|
||||
state["scheduler"] = scheduler
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await scheduler.shutdown()
|
||||
await plugin_daemon_http_client.aclose()
|
||||
await redis.aclose()
|
||||
|
||||
app = FastAPI(title="Dify Agent Run Server", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
def get_store() -> RedisRunStore:
|
||||
return state["store"] # pyright: ignore[reportReturnType]
|
||||
|
||||
def get_scheduler() -> RunScheduler:
|
||||
return state["scheduler"] # pyright: ignore[reportReturnType]
|
||||
|
||||
app.include_router(create_runs_router(get_store, get_scheduler))
|
||||
return app
|
||||
|
||||
|
||||
def create_plugin_daemon_http_client(settings: ServerSettings) -> httpx.AsyncClient:
|
||||
"""Create the lifespan-owned plugin daemon HTTP client with configured limits.
|
||||
|
||||
The returned client is shared by all local background runs in this FastAPI
|
||||
process and must be closed by the app lifespan after the scheduler has stopped
|
||||
using it.
|
||||
"""
|
||||
return httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(
|
||||
connect=settings.plugin_daemon_connect_timeout,
|
||||
read=settings.plugin_daemon_read_timeout,
|
||||
write=settings.plugin_daemon_write_timeout,
|
||||
pool=settings.plugin_daemon_pool_timeout,
|
||||
),
|
||||
limits=httpx.Limits(
|
||||
max_connections=settings.plugin_daemon_max_connections,
|
||||
max_keepalive_connections=settings.plugin_daemon_max_keepalive_connections,
|
||||
keepalive_expiry=settings.plugin_daemon_keepalive_expiry,
|
||||
),
|
||||
trust_env=False,
|
||||
)
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
__all__ = ["app", "create_app", "create_plugin_daemon_http_client"]
|
||||
92
dify-agent/src/dify_agent/server/routes/runs.py
Normal file
92
dify-agent/src/dify_agent/server/routes/runs.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""FastAPI routes for asynchronous agent runs.
|
||||
|
||||
Controllers translate known validation and shutdown errors into HTTP status codes.
|
||||
Unexpected scheduler or storage failures are intentionally left for FastAPI's
|
||||
server-error handling so infrastructure problems are not reported as client input
|
||||
errors. Created runs are scheduled in the current process and observed through
|
||||
status polling or SSE replay backed by Redis event streams.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from dify_agent.protocol.schemas import CreateRunRequest, CreateRunResponse, RunEventsResponse, RunStatusResponse
|
||||
from dify_agent.runtime.run_scheduler import RunRequestValidationError, RunScheduler, SchedulerStoppingError
|
||||
from dify_agent.server.sse import sse_event_stream
|
||||
from dify_agent.storage.redis_run_store import RedisRunStore, RunNotFoundError
|
||||
|
||||
|
||||
def create_runs_router(
|
||||
get_store: Callable[[], RedisRunStore],
|
||||
get_scheduler: Callable[[], RunScheduler],
|
||||
) -> APIRouter:
|
||||
"""Create routes bound to the application's store dependency provider."""
|
||||
router = APIRouter(prefix="/runs", tags=["runs"])
|
||||
|
||||
async def store_dep() -> RedisRunStore:
|
||||
return get_store()
|
||||
|
||||
async def scheduler_dep() -> RunScheduler:
|
||||
return get_scheduler()
|
||||
|
||||
@router.post("", response_model=CreateRunResponse, status_code=202)
|
||||
async def create_run(
|
||||
request: CreateRunRequest,
|
||||
scheduler: Annotated[RunScheduler, Depends(scheduler_dep)],
|
||||
) -> CreateRunResponse:
|
||||
try:
|
||||
record = await scheduler.create_run(request)
|
||||
except RunRequestValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except SchedulerStoppingError as exc:
|
||||
raise HTTPException(status_code=503, detail="run scheduler is shutting down") from exc
|
||||
return CreateRunResponse(run_id=record.run_id, status=record.status)
|
||||
|
||||
@router.get("/{run_id}", response_model=RunStatusResponse)
|
||||
async def get_run_status(run_id: str, store: Annotated[RedisRunStore, Depends(store_dep)]) -> RunStatusResponse:
|
||||
try:
|
||||
record = await store.get_run(run_id)
|
||||
except RunNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="run not found") from exc
|
||||
return RunStatusResponse(
|
||||
run_id=record.run_id,
|
||||
status=record.status,
|
||||
created_at=record.created_at,
|
||||
updated_at=record.updated_at,
|
||||
error=record.error,
|
||||
)
|
||||
|
||||
@router.get("/{run_id}/events", response_model=RunEventsResponse)
|
||||
async def get_run_events(
|
||||
run_id: str,
|
||||
store: Annotated[RedisRunStore, Depends(store_dep)],
|
||||
after: str = Query(default="0-0"),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
) -> RunEventsResponse:
|
||||
try:
|
||||
return await store.get_events(run_id, after=after, limit=limit)
|
||||
except RunNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="run not found") from exc
|
||||
|
||||
@router.get("/{run_id}/events/sse")
|
||||
async def stream_run_events(
|
||||
run_id: str,
|
||||
store: Annotated[RedisRunStore, Depends(store_dep)],
|
||||
last_event_id: Annotated[str | None, Header(alias="Last-Event-ID")] = None,
|
||||
after: str | None = Query(default=None),
|
||||
) -> StreamingResponse:
|
||||
cursor = after or last_event_id or "0-0"
|
||||
try:
|
||||
_ = await store.get_run(run_id)
|
||||
events = store.iter_events(run_id, after=cursor)
|
||||
return StreamingResponse(sse_event_stream(events), media_type="text/event-stream")
|
||||
except RunNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="run not found") from exc
|
||||
|
||||
return router
|
||||
|
||||
|
||||
__all__ = ["create_runs_router"]
|
||||
48
dify-agent/src/dify_agent/server/schemas.py
Normal file
48
dify-agent/src/dify_agent/server/schemas.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Server-only schemas and helpers for persisted run records.
|
||||
|
||||
Public HTTP DTOs and run events live in ``dify_agent.protocol.schemas`` and are
|
||||
intentionally not re-exported here. Keeping this module server-only prevents old
|
||||
imports from silently depending on implementation modules while preserving the
|
||||
internal ``RunRecord`` model used by schedulers and Redis storage.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import ClassVar
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from dify_agent.protocol import schemas as _protocol_schemas
|
||||
|
||||
|
||||
def new_run_id() -> str:
|
||||
"""Return a stable external run id for newly persisted server records."""
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
class RunRecord(BaseModel):
|
||||
"""Internal representation persisted for status reads.
|
||||
|
||||
Only status metadata is persisted. Create-run requests can contain model
|
||||
credentials in layer config and must remain in scheduler memory rather than
|
||||
being written to Redis.
|
||||
"""
|
||||
|
||||
run_id: str
|
||||
status: _protocol_schemas.RunStatus
|
||||
created_at: datetime = Field(default_factory=_protocol_schemas.utc_now)
|
||||
updated_at: datetime = Field(default_factory=_protocol_schemas.utc_now)
|
||||
error: str | None = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
@field_validator("updated_at")
|
||||
@classmethod
|
||||
def updated_at_must_be_timezone_aware(cls, value: datetime) -> datetime:
|
||||
"""Reject naive timestamps before they become JSON API values."""
|
||||
if value.tzinfo is None:
|
||||
raise ValueError("updated_at must be timezone-aware")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = ["RunRecord", "new_run_id"]
|
||||
41
dify-agent/src/dify_agent/server/settings.py
Normal file
41
dify-agent/src/dify_agent/server/settings.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""Configuration for the FastAPI run server.
|
||||
|
||||
Plugin daemon HTTP client settings describe the single FastAPI lifespan-owned
|
||||
``httpx.AsyncClient`` shared by local run tasks. Layers and Agenton providers do
|
||||
not own that client, so these settings are process resource limits rather than
|
||||
per-run lifecycle knobs.
|
||||
"""
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
DEFAULT_RUN_RETENTION_SECONDS = 3 * 24 * 60 * 60
|
||||
|
||||
|
||||
class ServerSettings(BaseSettings):
|
||||
"""Environment-backed settings for Redis, scheduling, and plugin daemon access."""
|
||||
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
redis_prefix: str = "dify-agent"
|
||||
shutdown_grace_seconds: float = 30
|
||||
run_retention_seconds: int = Field(default=DEFAULT_RUN_RETENTION_SECONDS, ge=1)
|
||||
plugin_daemon_url: str = "http://localhost:5002"
|
||||
plugin_daemon_api_key: str = ""
|
||||
plugin_daemon_connect_timeout: float = Field(default=10.0, ge=0)
|
||||
plugin_daemon_read_timeout: float = Field(default=600.0, ge=0)
|
||||
plugin_daemon_write_timeout: float = Field(default=30.0, ge=0)
|
||||
plugin_daemon_pool_timeout: float = Field(default=10.0, ge=0)
|
||||
plugin_daemon_max_connections: int = Field(default=100, ge=1)
|
||||
plugin_daemon_max_keepalive_connections: int = Field(default=20, ge=0)
|
||||
plugin_daemon_keepalive_expiry: float = Field(default=30.0, ge=0)
|
||||
|
||||
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
|
||||
env_prefix="DIFY_AGENT_",
|
||||
env_file=(".env", "dify-agent/.env"),
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_RUN_RETENTION_SECONDS", "ServerSettings"]
|
||||
29
dify-agent/src/dify_agent/server/sse.py
Normal file
29
dify-agent/src/dify_agent/server/sse.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""Server-sent event formatting for run event replay.
|
||||
|
||||
SSE frames use the run event id as ``id`` and the run event type as ``event`` so
|
||||
browsers can resume with ``Last-Event-ID`` while clients can subscribe by event
|
||||
name. Payload data is the full public ``RunEvent`` JSON object.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterable, AsyncIterator
|
||||
|
||||
from dify_agent.protocol.schemas import RUN_EVENT_ADAPTER, RunEvent
|
||||
|
||||
|
||||
def format_sse_event(event: RunEvent) -> str:
|
||||
"""Serialize one event as an SSE frame."""
|
||||
lines: list[str] = []
|
||||
if event.id is not None:
|
||||
lines.append(f"id: {event.id}")
|
||||
lines.append(f"event: {event.type}")
|
||||
lines.append(f"data: {RUN_EVENT_ADAPTER.dump_json(event).decode()}")
|
||||
return "\n".join(lines) + "\n\n"
|
||||
|
||||
|
||||
async def sse_event_stream(events: AsyncIterable[RunEvent]) -> AsyncIterator[str]:
|
||||
"""Yield formatted SSE frames from public run events."""
|
||||
async for event in events:
|
||||
yield format_sse_event(event)
|
||||
|
||||
|
||||
__all__ = ["format_sse_event", "sse_event_stream"]
|
||||
14
dify-agent/src/dify_agent/storage/redis_keys.py
Normal file
14
dify-agent/src/dify_agent/storage/redis_keys.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""Redis key helpers for run records and per-run event streams."""
|
||||
|
||||
|
||||
def run_record_key(prefix: str, run_id: str) -> str:
|
||||
"""Return the Redis string key holding one serialized run record."""
|
||||
return f"{prefix}:runs:{run_id}:record"
|
||||
|
||||
|
||||
def run_events_key(prefix: str, run_id: str) -> str:
|
||||
"""Return the Redis stream key holding one run's event log."""
|
||||
return f"{prefix}:runs:{run_id}:events"
|
||||
|
||||
|
||||
__all__ = ["run_events_key", "run_record_key"]
|
||||
137
dify-agent/src/dify_agent/storage/redis_run_store.py
Normal file
137
dify-agent/src/dify_agent/storage/redis_run_store.py
Normal file
@ -0,0 +1,137 @@
|
||||
"""Redis-backed run records and per-run event streams.
|
||||
|
||||
The store writes status-only run records as JSON strings and events as Redis
|
||||
streams. HTTP event cursors are Redis stream ids; ``0-0`` means replay from the
|
||||
beginning for polling and SSE. Records and streams share one retention window
|
||||
that is refreshed when status or event data is written. Execution is scheduled
|
||||
in-process by ``dify_agent.runtime.run_scheduler``; Redis is not a job queue, and
|
||||
create-run payloads are never persisted because layer config may include model
|
||||
credentials.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import cast
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from dify_agent.protocol.schemas import RUN_EVENT_ADAPTER, RunEvent, RunEventsResponse, RunStatus, utc_now
|
||||
from dify_agent.runtime.event_sink import RunEventSink
|
||||
from dify_agent.server.schemas import RunRecord, new_run_id
|
||||
from dify_agent.server.settings import DEFAULT_RUN_RETENTION_SECONDS
|
||||
from dify_agent.storage.redis_keys import run_events_key, run_record_key
|
||||
|
||||
|
||||
class RunNotFoundError(LookupError):
|
||||
"""Raised when a requested run record does not exist."""
|
||||
|
||||
|
||||
class RedisRunStore(RunEventSink):
|
||||
"""Async Redis implementation for run records and event logs.
|
||||
|
||||
``run_retention_seconds`` is applied to both the run record key and the
|
||||
per-run Redis stream. Event writes also refresh the record TTL so long-running
|
||||
runs that keep producing events do not lose their status record mid-run.
|
||||
"""
|
||||
|
||||
redis: Redis
|
||||
prefix: str
|
||||
run_retention_seconds: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis: Redis,
|
||||
*,
|
||||
prefix: str = "dify-agent",
|
||||
run_retention_seconds: int = DEFAULT_RUN_RETENTION_SECONDS,
|
||||
) -> None:
|
||||
if run_retention_seconds <= 0:
|
||||
raise ValueError("run_retention_seconds must be positive")
|
||||
self.redis = redis
|
||||
self.prefix = prefix
|
||||
self.run_retention_seconds = run_retention_seconds
|
||||
|
||||
async def create_run(self) -> RunRecord:
|
||||
"""Persist a running run record without storing the create request."""
|
||||
run_id = new_run_id()
|
||||
record = RunRecord(run_id=run_id, status="running")
|
||||
await self.redis.set(
|
||||
run_record_key(self.prefix, run_id),
|
||||
record.model_dump_json(),
|
||||
ex=self.run_retention_seconds,
|
||||
)
|
||||
return record
|
||||
|
||||
async def get_run(self, run_id: str) -> RunRecord:
|
||||
"""Return one run record or raise ``RunNotFoundError``."""
|
||||
value = await self.redis.get(run_record_key(self.prefix, run_id))
|
||||
if value is None:
|
||||
raise RunNotFoundError(run_id)
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode()
|
||||
return RunRecord.model_validate_json(value)
|
||||
|
||||
async def update_status(self, run_id: str, status: RunStatus, error: str | None = None) -> None:
|
||||
"""Update the status fields of an existing run record."""
|
||||
record = await self.get_run(run_id)
|
||||
updated = record.model_copy(update={"status": status, "updated_at": utc_now(), "error": error})
|
||||
await self.redis.set(
|
||||
run_record_key(self.prefix, run_id),
|
||||
updated.model_dump_json(),
|
||||
ex=self.run_retention_seconds,
|
||||
)
|
||||
|
||||
async def append_event(self, event: RunEvent) -> str:
|
||||
"""Append an event JSON payload to the run's Redis stream."""
|
||||
events_key = run_events_key(self.prefix, event.run_id)
|
||||
payload = RUN_EVENT_ADAPTER.dump_json(event, exclude={"id"}).decode()
|
||||
event_id = await self.redis.xadd(
|
||||
events_key,
|
||||
{"payload": payload},
|
||||
)
|
||||
await self.redis.expire(events_key, self.run_retention_seconds)
|
||||
await self.redis.expire(run_record_key(self.prefix, event.run_id), self.run_retention_seconds)
|
||||
return event_id.decode() if isinstance(event_id, bytes) else str(event_id)
|
||||
|
||||
async def get_events(self, run_id: str, *, after: str = "0-0", limit: int = 100) -> RunEventsResponse:
|
||||
"""Read a bounded page of events after ``after`` cursor."""
|
||||
await self.get_run(run_id)
|
||||
raw_events = await self.redis.xrange(run_events_key(self.prefix, run_id), min=f"({after}", count=limit)
|
||||
events = [self._decode_event(run_id, raw_id, fields) for raw_id, fields in raw_events]
|
||||
next_cursor = events[-1].id if events else after
|
||||
return RunEventsResponse(run_id=run_id, events=events, next_cursor=next_cursor)
|
||||
|
||||
async def iter_events(self, run_id: str, *, after: str = "0-0") -> AsyncIterator[RunEvent]:
|
||||
"""Yield replayed and future events for SSE clients."""
|
||||
await self.get_run(run_id)
|
||||
cursor = after
|
||||
while True:
|
||||
page = await self.get_events(run_id, after=cursor, limit=100)
|
||||
for event in page.events:
|
||||
if event.id is not None:
|
||||
cursor = event.id
|
||||
yield event
|
||||
if not page.events:
|
||||
break
|
||||
while True:
|
||||
response = await self.redis.xread({run_events_key(self.prefix, run_id): cursor}, block=30_000, count=100)
|
||||
if not response:
|
||||
continue
|
||||
for _stream_name, entries in response:
|
||||
for raw_id, fields in entries:
|
||||
event = self._decode_event(run_id, raw_id, fields)
|
||||
if event.id is not None:
|
||||
cursor = event.id
|
||||
yield event
|
||||
|
||||
@staticmethod
|
||||
def _decode_event(run_id: str, raw_id: object, fields: dict[object, object]) -> RunEvent:
|
||||
"""Decode one Redis stream entry into a public event."""
|
||||
payload = fields.get(b"payload") or fields.get("payload")
|
||||
if isinstance(payload, bytes):
|
||||
payload = payload.decode()
|
||||
event_id = raw_id.decode() if isinstance(raw_id, bytes) else str(raw_id)
|
||||
event = RUN_EVENT_ADAPTER.validate_json(cast(str, payload))
|
||||
return event.model_copy(update={"id": event_id, "run_id": run_id})
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_RUN_RETENTION_SECONDS", "RedisRunStore", "RunNotFoundError"]
|
||||
0
dify-agent/tests/__init__.py
Normal file
0
dify-agent/tests/__init__.py
Normal file
65
dify-agent/tests/docs/test_examples.py
Normal file
65
dify-agent/tests/docs/test_examples.py
Normal file
@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from _pytest.mark import ParameterSet
|
||||
from pytest_examples import CodeExample, EvalExample, find_examples
|
||||
from pytest_examples.config import ExamplesConfig as BaseExamplesConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExamplesConfig(BaseExamplesConfig):
|
||||
known_first_party: list[str] = field(default_factory=list[str])
|
||||
|
||||
def ruff_config(self) -> tuple[str, ...]:
|
||||
config = super().ruff_config()
|
||||
if self.known_first_party:
|
||||
config = (*config, "--config", f"lint.isort.known-first-party = {self.known_first_party}")
|
||||
return config
|
||||
|
||||
|
||||
def find_doc_examples() -> Iterable[ParameterSet]:
|
||||
root_dir = Path(__file__).resolve().parents[2]
|
||||
for example in find_examples(
|
||||
root_dir / "docs",
|
||||
root_dir / "src",
|
||||
root_dir / "examples" / "agenton",
|
||||
root_dir / "examples" / "dify_agent",
|
||||
):
|
||||
path = example.path.relative_to(root_dir)
|
||||
yield pytest.param(example, id=f"{path}:{example.start_line}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("example", find_doc_examples())
|
||||
def test_documentation_examples(example: CodeExample, eval_example: EvalExample) -> None:
|
||||
prefix_settings = example.prefix_settings()
|
||||
opt_test = prefix_settings.get("test", "")
|
||||
opt_lint = prefix_settings.get("lint", "")
|
||||
line_length = int(prefix_settings.get("line_length", "120"))
|
||||
|
||||
eval_example.config = ExamplesConfig(
|
||||
ruff_ignore=["D", "Q001"],
|
||||
target_version="py312", # pyright: ignore[reportArgumentType]
|
||||
line_length=line_length,
|
||||
isort=True,
|
||||
upgrade=True,
|
||||
quotes="double",
|
||||
known_first_party=["agenton", "agenton_collections", "dify_agent"],
|
||||
)
|
||||
|
||||
if not opt_lint.startswith("skip"):
|
||||
if eval_example.update_examples: # pragma: no cover
|
||||
eval_example.format_ruff(example)
|
||||
else:
|
||||
eval_example.lint_ruff(example)
|
||||
|
||||
if opt_test.startswith("skip"):
|
||||
pytest.skip(opt_test[4:].lstrip(" -") or "running code skipped")
|
||||
|
||||
if eval_example.update_examples: # pragma: no cover
|
||||
eval_example.run_print_update(example, module_globals={"__name__": "__main__"})
|
||||
else:
|
||||
eval_example.run_print_check(example, module_globals={"__name__": "__main__"})
|
||||
46
dify-agent/tests/docs/test_snippets.py
Normal file
46
dify-agent/tests/docs/test_snippets.py
Normal file
@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
HOOKS_DIR = PROJECT_ROOT / "docs" / ".hooks"
|
||||
sys.path.append(str(HOOKS_DIR))
|
||||
|
||||
from snippets import inject_snippets, parse_file_sections, parse_snippet_directive # pyright: ignore[reportMissingImports] # noqa: E402
|
||||
|
||||
|
||||
def test_parse_snippet_directive() -> None:
|
||||
directive = parse_snippet_directive('```snippet {path="demo.py" fragment="main" hl="1"}\n```')
|
||||
|
||||
assert directive is not None
|
||||
assert directive.path == "demo.py"
|
||||
assert directive.fragment == "main"
|
||||
assert directive.extra_attrs == {"hl": "1"}
|
||||
|
||||
|
||||
def test_parse_file_sections_and_inject_snippet(tmp_path: Path) -> None:
|
||||
source = tmp_path / "demo.py"
|
||||
source.write_text(
|
||||
"""import asyncio
|
||||
|
||||
### [main]
|
||||
async def main() -> None:
|
||||
print("hello")
|
||||
### [/main]
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
parsed = parse_file_sections(source)
|
||||
assert "main" in parsed.sections
|
||||
|
||||
markdown = '```snippet {path="/examples/agenton/agenton_examples/session_snapshot.py"}\n```'
|
||||
rendered = inject_snippets(markdown, PROJECT_ROOT / "docs")
|
||||
|
||||
assert rendered.startswith('```py {title="examples/agenton/agenton_examples/session_snapshot.py"}')
|
||||
assert "async def main() -> None:" in rendered
|
||||
assert "asyncio.run(main())" in rendered
|
||||
0
dify-agent/tests/local/__init__.py
Normal file
0
dify-agent/tests/local/__init__.py
Normal file
1
dify-agent/tests/local/agenton/__init__.py
Normal file
1
dify-agent/tests/local/agenton/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
dify-agent/tests/local/agenton/compositor/__init__.py
Normal file
1
dify-agent/tests/local/agenton/compositor/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,337 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
from typing_extensions import override
|
||||
|
||||
import agenton.compositor as compositor_module
|
||||
import agenton.layers as layers_module
|
||||
from agenton.compositor import (
|
||||
Compositor,
|
||||
LayerConfigInput,
|
||||
LayerNode,
|
||||
LayerNodeConfig,
|
||||
LayerProvider,
|
||||
LayerProviderInput,
|
||||
)
|
||||
from agenton.layers import EmptyLayerConfig, Layer, LayerDeps, NoLayerDeps, PlainLayer
|
||||
from agenton_collections.layers.plain import ObjectLayer, PromptLayer, PromptLayerConfig
|
||||
|
||||
|
||||
EXPECTED_FACADE_EXPORTS = [
|
||||
"Compositor",
|
||||
"CompositorConfig",
|
||||
"CompositorConfigValue",
|
||||
"CompositorRun",
|
||||
"CompositorSessionSnapshot",
|
||||
"CompositorSessionSnapshotValue",
|
||||
"CompositorTransformer",
|
||||
"CompositorTransformerKwargs",
|
||||
"LayerFactory",
|
||||
"LayerNode",
|
||||
"LayerNodeConfig",
|
||||
"LayerProvider",
|
||||
"LayerRunSlot",
|
||||
"LayerSessionSnapshot",
|
||||
]
|
||||
|
||||
EXPECTED_DIRECT_IMPORT_COMPAT_NAMES = ["LayerConfigInput", "LayerProviderInput"]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InstanceOnlyLayer(PlainLayer[NoLayerDeps]):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RequiredConstructorLayer(PlainLayer[NoLayerDeps]):
|
||||
value: str
|
||||
|
||||
|
||||
def test_layer_provider_from_layer_type_uses_declared_schema_and_type_id() -> None:
|
||||
provider = LayerProvider.from_layer_type(PromptLayer)
|
||||
|
||||
assert provider.type_id == "plain.prompt"
|
||||
assert provider.layer_type is PromptLayer
|
||||
|
||||
layer = provider.create_layer(PromptLayerConfig(prefix="hello", user="ask politely"))
|
||||
|
||||
assert isinstance(layer, PromptLayer)
|
||||
assert layer.config == PromptLayerConfig(prefix="hello", user="ask politely")
|
||||
assert layer.prefix_prompts == ["hello"]
|
||||
|
||||
with pytest.raises(TypeError, match="cannot be created from empty config"):
|
||||
LayerProvider.from_layer_type(RequiredConstructorLayer).create_layer()
|
||||
|
||||
|
||||
def test_compositor_from_config_uses_providers_and_enter_configs_by_node_name() -> None:
|
||||
compositor = Compositor.from_config(
|
||||
{"layers": [{"name": "prompt", "type": "plain.prompt"}]},
|
||||
providers=[PromptLayer],
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter(
|
||||
configs={"prompt": {"prefix": "hello", "user": "ask politely", "suffix": ["bye"]}}
|
||||
) as active_run:
|
||||
assert [prompt.value for prompt in active_run.prompts] == ["hello", "bye"]
|
||||
assert [prompt.value for prompt in active_run.user_prompts] == ["ask politely"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
asyncio.run(_enter_once(compositor, configs={"prompt": {"unknown": "field"}}))
|
||||
|
||||
|
||||
def test_layer_node_config_has_no_runtime_state_or_layer_config() -> None:
|
||||
node = LayerNodeConfig(
|
||||
name="prompt",
|
||||
type="plain.prompt",
|
||||
deps={"source": "other"},
|
||||
metadata={"label": "Prompt"},
|
||||
)
|
||||
|
||||
assert node.model_dump(mode="json") == {
|
||||
"name": "prompt",
|
||||
"type": "plain.prompt",
|
||||
"deps": {"source": "other"},
|
||||
"metadata": {"label": "Prompt"},
|
||||
}
|
||||
assert "runtime_state" not in LayerNodeConfig.model_fields
|
||||
assert "config" not in LayerNodeConfig.model_fields
|
||||
|
||||
|
||||
def test_node_providers_override_type_id_providers_for_serializable_graphs() -> None:
|
||||
override_provider = LayerProvider.from_factory(
|
||||
layer_type=PromptLayer,
|
||||
create=lambda config: PromptLayer(prefix="override"),
|
||||
)
|
||||
compositor = Compositor.from_config(
|
||||
{"layers": [{"name": "prompt", "type": "plain.prompt"}]},
|
||||
providers=[PromptLayer],
|
||||
node_providers={"prompt": override_provider},
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter(configs={"prompt": {"prefix": "ignored"}}) as active_run:
|
||||
assert [prompt.value for prompt in active_run.prompts] == ["override"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_from_config_rejects_missing_duplicate_and_unknown_providers() -> None:
|
||||
with pytest.raises(KeyError, match="Layer type id 'missing' is not registered"):
|
||||
Compositor.from_config({"layers": [{"name": "node", "type": "missing"}]}, providers=[])
|
||||
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
Compositor.from_config(
|
||||
{"layers": [{"name": "prompt", "type": "plain.prompt"}]},
|
||||
providers=[PromptLayer, PromptLayer],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="must declare a type_id"):
|
||||
Compositor.from_config(
|
||||
{"layers": [{"name": "node", "type": "instance.only"}]},
|
||||
providers=[InstanceOnlyLayer],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="unknown layer node names: other"):
|
||||
Compositor.from_config(
|
||||
{"layers": [{"name": "prompt", "type": "plain.prompt"}]},
|
||||
providers=[PromptLayer],
|
||||
node_providers={"other": PromptLayer},
|
||||
)
|
||||
|
||||
|
||||
def test_compositor_run_get_layer_returns_named_layer_and_validates_type() -> None:
|
||||
compositor = Compositor([LayerNode("obj", _object_provider("value"))])
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter() as active_run:
|
||||
layer = active_run.get_layer("obj", ObjectLayer)
|
||||
assert active_run.get_layer("obj") is layer
|
||||
assert layer.value == "value"
|
||||
|
||||
with pytest.raises(KeyError, match="Layer 'missing' is not defined"):
|
||||
active_run.get_layer("missing")
|
||||
|
||||
with pytest.raises(TypeError, match="Layer 'obj' must be PromptLayer, got ObjectLayer"):
|
||||
active_run.get_layer("obj", PromptLayer)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
class ObjectConsumerDeps(LayerDeps):
|
||||
obj: ObjectLayer[str] # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ObjectConsumerLayer(PlainLayer[ObjectConsumerDeps]):
|
||||
@property
|
||||
@override
|
||||
def prefix_prompts(self) -> list[str]:
|
||||
return [self.deps.obj.value]
|
||||
|
||||
|
||||
def test_python_native_construction_mixes_layer_classes_and_providers() -> None:
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("prompt", PromptLayer),
|
||||
LayerNode("obj", _object_provider("instance")),
|
||||
LayerNode("consumer", ObjectConsumerLayer, deps={"obj": "obj"}),
|
||||
]
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter(configs={"prompt": {"prefix": "cfg"}}) as active_run:
|
||||
assert [prompt.value for prompt in active_run.prompts] == ["cfg", "instance"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
class SerializableState(BaseModel):
|
||||
resource_id: str = ""
|
||||
created: bool = False
|
||||
resumed: bool = False
|
||||
|
||||
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StateLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, SerializableState]):
|
||||
created_hooks: int = 0
|
||||
resumed_hooks: int = 0
|
||||
|
||||
@override
|
||||
async def on_context_create(self) -> None:
|
||||
self.created_hooks += 1
|
||||
self.runtime_state.created = True
|
||||
|
||||
@override
|
||||
async def on_context_resume(self) -> None:
|
||||
self.resumed_hooks += 1
|
||||
self.runtime_state.resumed = True
|
||||
|
||||
|
||||
def test_snapshot_contains_runtime_state_only_not_config_deps_or_resources() -> None:
|
||||
compositor = Compositor([LayerNode("state", StateLayer)])
|
||||
|
||||
async def get_snapshot() -> dict[str, object]:
|
||||
async with compositor.enter() as active_run:
|
||||
state_layer = active_run.get_layer("state", StateLayer)
|
||||
state_layer.runtime_state.resource_id = "abc"
|
||||
assert active_run.session_snapshot is not None
|
||||
return active_run.session_snapshot.model_dump(mode="json")
|
||||
|
||||
dumped = asyncio.run(get_snapshot())
|
||||
assert dumped == {
|
||||
"schema_version": 1,
|
||||
"layers": [
|
||||
{
|
||||
"name": "state",
|
||||
"lifecycle_state": "closed",
|
||||
"runtime_state": {"resource_id": "abc", "created": True, "resumed": False},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_hydrate_validates_runtime_state_and_resume_mutates_layer_self() -> None:
|
||||
compositor = Compositor([LayerNode("state", StateLayer)])
|
||||
|
||||
bad_snapshot = {"layers": [{"name": "state", "lifecycle_state": "suspended", "runtime_state": {"wrong": "field"}}]}
|
||||
with pytest.raises(ValidationError):
|
||||
asyncio.run(_enter_once(compositor, session_snapshot=bad_snapshot))
|
||||
|
||||
good_snapshot = {
|
||||
"layers": [
|
||||
{
|
||||
"name": "state",
|
||||
"lifecycle_state": "suspended",
|
||||
"runtime_state": {"resource_id": "abc", "created": True, "resumed": False},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter(session_snapshot=good_snapshot) as active_run:
|
||||
layer = active_run.get_layer("state", StateLayer)
|
||||
assert layer.runtime_state.resource_id == "abc"
|
||||
assert layer.runtime_state.resumed is True
|
||||
assert layer.resumed_hooks == 1
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_hydrate_rejects_mismatched_snapshot_layer_names() -> None:
|
||||
compositor = Compositor([LayerNode("state", StateLayer)])
|
||||
|
||||
with pytest.raises(ValueError, match=r"Expected \[state\], got \[other\]"):
|
||||
asyncio.run(
|
||||
_enter_once(
|
||||
compositor,
|
||||
session_snapshot={"layers": [{"name": "other", "lifecycle_state": "new", "runtime_state": {}}]},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_removed_lifecycle_and_resource_apis_are_not_public_exports() -> None:
|
||||
assert not hasattr(compositor_module, "CompositorBuilder")
|
||||
assert not hasattr(compositor_module, "LayerRegistry")
|
||||
assert not hasattr(compositor_module, "LayerDescriptor")
|
||||
assert not hasattr(layers_module, "LayerControl")
|
||||
assert not hasattr(layers_module, "EmptyRuntimeHandles")
|
||||
assert not hasattr(Layer, "enter")
|
||||
assert not hasattr(Layer, "hydrate_session_state")
|
||||
assert not hasattr(Layer, "suspend_on_exit")
|
||||
assert not hasattr(Layer, "delete_on_exit")
|
||||
assert not hasattr(Layer, "runtime_handles")
|
||||
assert not hasattr(Layer, "require_control")
|
||||
assert not hasattr(Layer, "control_for")
|
||||
assert not hasattr(Layer, "enter_async_resource")
|
||||
assert not hasattr(Layer, "add_async_cleanup")
|
||||
|
||||
|
||||
def test_facade_keeps_direct_import_type_aliases_without_expanding___all__() -> None:
|
||||
assert compositor_module.LayerConfigInput is LayerConfigInput
|
||||
assert compositor_module.LayerProviderInput is LayerProviderInput
|
||||
assert LayerConfigInput.__module__ == "agenton.compositor"
|
||||
assert LayerProviderInput.__module__ == "agenton.compositor"
|
||||
assert LayerConfigInput.__name__ == "LayerConfigInput"
|
||||
assert LayerProviderInput.__name__ == "LayerProviderInput"
|
||||
assert "LayerConfigInput" not in compositor_module.__all__
|
||||
assert "LayerProviderInput" not in compositor_module.__all__
|
||||
|
||||
|
||||
def test_facade_export_surface_matches_split_contract() -> None:
|
||||
compatibility_names = [*EXPECTED_FACADE_EXPORTS, *EXPECTED_DIRECT_IMPORT_COMPAT_NAMES]
|
||||
|
||||
assert compositor_module.__all__ == EXPECTED_FACADE_EXPORTS
|
||||
|
||||
namespace: dict[str, object] = {}
|
||||
exec(
|
||||
"from agenton.compositor import " + ", ".join(compatibility_names),
|
||||
{},
|
||||
namespace,
|
||||
)
|
||||
|
||||
for name in compatibility_names:
|
||||
assert namespace[name] is getattr(compositor_module, name)
|
||||
|
||||
|
||||
def _object_provider(value: str) -> LayerProvider[ObjectLayer[str]]:
|
||||
return LayerProvider.from_factory(layer_type=ObjectLayer, create=lambda config: ObjectLayer(value))
|
||||
|
||||
|
||||
async def _enter_once(
|
||||
compositor: Compositor,
|
||||
*,
|
||||
configs: dict[str, object] | None = None,
|
||||
session_snapshot: object | None = None,
|
||||
) -> None:
|
||||
async with compositor.enter(
|
||||
configs=configs, # pyright: ignore[reportArgumentType]
|
||||
session_snapshot=session_snapshot, # pyright: ignore[reportArgumentType]
|
||||
):
|
||||
pass
|
||||
128
dify-agent/tests/local/agenton/compositor/test_direct_deps.py
Normal file
128
dify-agent/tests/local/agenton/compositor/test_direct_deps.py
Normal file
@ -0,0 +1,128 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
from typing_extensions import override
|
||||
|
||||
from agenton.compositor import Compositor, LayerNode, LayerProvider
|
||||
from agenton.layers import EmptyLayerConfig, LayerDeps, PlainLayer
|
||||
from agenton_collections.layers.plain import ObjectLayer
|
||||
|
||||
|
||||
class RenamedObjectDeps(LayerDeps):
|
||||
renamed: ObjectLayer[str] # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RenamedConsumerLayer(PlainLayer[RenamedObjectDeps]):
|
||||
@property
|
||||
@override
|
||||
def prefix_prompts(self) -> list[str]:
|
||||
return [self.deps.renamed.value]
|
||||
|
||||
|
||||
class SameNameObjectDeps(LayerDeps):
|
||||
same: ObjectLayer[str] # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SameNameConsumerLayer(PlainLayer[SameNameObjectDeps]):
|
||||
@property
|
||||
@override
|
||||
def prefix_prompts(self) -> list[str]:
|
||||
return [self.deps.same.value]
|
||||
|
||||
|
||||
class OptionalObjectDeps(LayerDeps):
|
||||
maybe: ObjectLayer[str] | None # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OptionalConsumerLayer(PlainLayer[OptionalObjectDeps]):
|
||||
pass
|
||||
|
||||
|
||||
def _object_provider(value: str) -> LayerProvider[ObjectLayer[str]]:
|
||||
return LayerProvider.from_factory(
|
||||
layer_type=ObjectLayer,
|
||||
create=lambda config: ObjectLayer(value),
|
||||
)
|
||||
|
||||
|
||||
def test_direct_deps_access_uses_explicit_dependency_rename() -> None:
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("actual", _object_provider("target")),
|
||||
LayerNode("consumer", RenamedConsumerLayer, deps={"renamed": "actual"}),
|
||||
]
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter() as active_run:
|
||||
target = active_run.get_layer("actual", ObjectLayer)
|
||||
consumer = active_run.get_layer("consumer", RenamedConsumerLayer)
|
||||
assert consumer.deps.renamed is target
|
||||
assert [prompt.value for prompt in active_run.prompts] == ["target"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_direct_deps_access_uses_explicit_same_name_dependency() -> None:
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("same", _object_provider("target")),
|
||||
LayerNode("consumer", SameNameConsumerLayer, deps={"same": "same"}),
|
||||
]
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter() as active_run:
|
||||
target = active_run.get_layer("same", ObjectLayer)
|
||||
consumer = active_run.get_layer("consumer", SameNameConsumerLayer)
|
||||
assert consumer.deps.same is target
|
||||
assert [prompt.value for prompt in active_run.prompts] == ["target"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_optional_missing_dependency_is_bound_to_none() -> None:
|
||||
compositor = Compositor([LayerNode("consumer", OptionalConsumerLayer)])
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter() as active_run:
|
||||
consumer = active_run.get_layer("consumer", OptionalConsumerLayer)
|
||||
assert consumer.deps.maybe is None
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_missing_required_dependency_is_rejected_before_hooks() -> None:
|
||||
compositor = Compositor([LayerNode("consumer", SameNameConsumerLayer)])
|
||||
|
||||
with pytest.raises(ValueError, match="Dependency 'same' is required"):
|
||||
asyncio.run(_enter_once(compositor))
|
||||
|
||||
|
||||
def test_unknown_dependency_mapping_is_rejected_for_compositor_construction() -> None:
|
||||
with pytest.raises(ValueError, match="unknown dependency keys: missing"):
|
||||
Compositor([LayerNode("consumer", RenamedConsumerLayer, deps={"missing": "target"})])
|
||||
|
||||
|
||||
def test_undefined_dependency_target_is_rejected_for_compositor_construction() -> None:
|
||||
with pytest.raises(ValueError, match="depends on undefined layer names: missing_target"):
|
||||
Compositor([LayerNode("consumer", RenamedConsumerLayer, deps={"renamed": "missing_target"})])
|
||||
|
||||
|
||||
def test_duplicate_layer_node_name_is_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="Duplicate layer name 'same'"):
|
||||
Compositor(
|
||||
[
|
||||
LayerNode("same", _object_provider("first")),
|
||||
LayerNode("same", _object_provider("second")),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def _enter_once(compositor: Compositor) -> None:
|
||||
async with compositor.enter(configs={"consumer": EmptyLayerConfig()}):
|
||||
pass
|
||||
401
dify-agent/tests/local/agenton/compositor/test_enter.py
Normal file
401
dify-agent/tests/local/agenton/compositor/test_enter.py
Normal file
@ -0,0 +1,401 @@
|
||||
import asyncio
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import count
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
from typing_extensions import override
|
||||
|
||||
from agenton.compositor import Compositor, CompositorSessionSnapshot, LayerNode, LayerProvider
|
||||
from agenton.layers import (
|
||||
EmptyLayerConfig,
|
||||
ExitIntent,
|
||||
LayerConfig,
|
||||
LifecycleState,
|
||||
NoLayerDeps,
|
||||
PlainLayer,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TraceLayer(PlainLayer[NoLayerDeps]):
|
||||
"""Layer that records no-arg lifecycle events observable to tests."""
|
||||
|
||||
events: list[str] = field(default_factory=list)
|
||||
|
||||
@override
|
||||
async def on_context_create(self) -> None:
|
||||
self.events.append("create")
|
||||
|
||||
@override
|
||||
async def on_context_suspend(self) -> None:
|
||||
self.events.append("suspend")
|
||||
|
||||
@override
|
||||
async def on_context_resume(self) -> None:
|
||||
self.events.append("resume")
|
||||
|
||||
@override
|
||||
async def on_context_delete(self) -> None:
|
||||
self.events.append("delete")
|
||||
|
||||
|
||||
def _compositor(*layer_names: str) -> Compositor:
|
||||
return Compositor([LayerNode(layer_name, TraceLayer) for layer_name in layer_names])
|
||||
|
||||
|
||||
def test_same_compositor_enters_multiple_times_with_fresh_layers_and_snapshot_resume() -> None:
|
||||
compositor = _compositor("first", "second")
|
||||
runs = []
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter() as first_run:
|
||||
assert [slot.lifecycle_state for slot in first_run.slots.values()] == [
|
||||
LifecycleState.ACTIVE,
|
||||
LifecycleState.ACTIVE,
|
||||
]
|
||||
first_run.suspend_on_exit()
|
||||
assert [slot.exit_intent for slot in first_run.slots.values()] == [
|
||||
ExitIntent.SUSPEND,
|
||||
ExitIntent.SUSPEND,
|
||||
]
|
||||
runs.append(first_run)
|
||||
|
||||
assert first_run.session_snapshot is not None
|
||||
async with compositor.enter(session_snapshot=first_run.session_snapshot) as resumed_run:
|
||||
assert resumed_run.get_layer("first", TraceLayer).events == ["resume"]
|
||||
assert resumed_run.get_layer("second", TraceLayer).events == ["resume"]
|
||||
runs.append(resumed_run)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
first_layer = runs[0].get_layer("first", TraceLayer)
|
||||
resumed_layer = runs[1].get_layer("first", TraceLayer)
|
||||
assert first_layer is not resumed_layer
|
||||
assert first_layer.events == ["create", "suspend"]
|
||||
assert resumed_layer.events == ["resume", "delete"]
|
||||
assert runs[1].session_snapshot is not None
|
||||
assert [layer.lifecycle_state for layer in runs[1].session_snapshot.layers] == [
|
||||
LifecycleState.CLOSED,
|
||||
LifecycleState.CLOSED,
|
||||
]
|
||||
|
||||
|
||||
def test_concurrent_enters_do_not_share_layer_instances() -> None:
|
||||
compositor = _compositor("trace")
|
||||
|
||||
async def enter_once() -> tuple[int, list[str]]:
|
||||
async with compositor.enter() as run:
|
||||
layer = run.get_layer("trace", TraceLayer)
|
||||
await asyncio.sleep(0)
|
||||
return id(layer), layer.events
|
||||
|
||||
async def run_concurrently() -> list[tuple[int, list[str]]]:
|
||||
return list(await asyncio.gather(enter_once(), enter_once()))
|
||||
|
||||
results = asyncio.run(run_concurrently())
|
||||
|
||||
assert results[0][0] != results[1][0]
|
||||
assert results[0][1] == ["create", "delete"]
|
||||
assert results[1][1] == ["create", "delete"]
|
||||
|
||||
|
||||
class ConfiguredLayerConfig(LayerConfig):
|
||||
value: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ConfiguredLayer(PlainLayer[NoLayerDeps, ConfiguredLayerConfig]):
|
||||
type_id = "test.configured"
|
||||
|
||||
value: str
|
||||
|
||||
hooks: list[str] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def from_config(cls, config: ConfiguredLayerConfig) -> "ConfiguredLayer":
|
||||
return cls(value=config.value)
|
||||
|
||||
@override
|
||||
async def on_context_create(self) -> None:
|
||||
self.hooks.append(f"create:{self.config.value}")
|
||||
|
||||
|
||||
def test_custom_factory_is_called_each_enter_with_typed_config() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def create_layer(config: ConfiguredLayerConfig) -> ConfiguredLayer:
|
||||
calls.append(config.value)
|
||||
return ConfiguredLayer(value=f"factory:{config.value}")
|
||||
|
||||
compositor = Compositor(
|
||||
[LayerNode("configured", LayerProvider.from_factory(layer_type=ConfiguredLayer, create=create_layer))]
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter(configs={"configured": {"value": "one"}}) as first_run:
|
||||
first_layer = first_run.get_layer("configured", ConfiguredLayer)
|
||||
assert first_layer.value == "factory:one"
|
||||
assert first_layer.config.value == "one"
|
||||
async with compositor.enter(configs={"configured": ConfiguredLayerConfig(value="two")}) as second_run:
|
||||
second_layer = second_run.get_layer("configured", ConfiguredLayer)
|
||||
assert second_layer.value == "factory:two"
|
||||
assert second_layer.config.value == "two"
|
||||
assert second_layer is not first_layer
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
assert calls == ["one", "two"]
|
||||
|
||||
|
||||
def test_provider_rejects_reused_layer_instance_before_hooks_run() -> None:
|
||||
shared_layer = TraceLayer()
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode(
|
||||
"trace",
|
||||
LayerProvider.from_factory(layer_type=TraceLayer, create=lambda config: shared_layer),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter():
|
||||
pass
|
||||
|
||||
with pytest.raises(ValueError, match="fresh layer instance"):
|
||||
async with compositor.enter():
|
||||
pass
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
assert shared_layer.events == ["create", "delete"]
|
||||
|
||||
|
||||
def test_configs_are_validated_by_node_name_before_factory_call() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def create_layer(config: ConfiguredLayerConfig) -> ConfiguredLayer:
|
||||
calls.append(config.value)
|
||||
return ConfiguredLayer(value=config.value)
|
||||
|
||||
compositor = Compositor(
|
||||
[LayerNode("configured", LayerProvider.from_factory(layer_type=ConfiguredLayer, create=create_layer))]
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="unknown layer node names: missing"):
|
||||
asyncio.run(_enter_once(compositor, configs={"missing": {}}))
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
asyncio.run(_enter_once(compositor, configs={"configured": {"unknown": "field"}}))
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_all_node_configs_are_validated_before_any_factory_runs() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def create_layer(config: ConfiguredLayerConfig) -> ConfiguredLayer:
|
||||
calls.append(config.value)
|
||||
return ConfiguredLayer(value=config.value)
|
||||
|
||||
provider = LayerProvider.from_factory(layer_type=ConfiguredLayer, create=create_layer)
|
||||
compositor = Compositor([LayerNode("first", provider), LayerNode("second", provider)])
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
asyncio.run(
|
||||
_enter_once(
|
||||
compositor,
|
||||
configs={"first": {"value": "valid"}, "second": {"unknown": "field"}},
|
||||
)
|
||||
)
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_existing_config_model_instances_are_revalidated_before_factory_runs() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def create_layer(config: ConfiguredLayerConfig) -> ConfiguredLayer:
|
||||
calls.append(config.value)
|
||||
return ConfiguredLayer(value=config.value)
|
||||
|
||||
compositor = Compositor(
|
||||
[LayerNode("configured", LayerProvider.from_factory(layer_type=ConfiguredLayer, create=create_layer))]
|
||||
)
|
||||
config = ConfiguredLayerConfig(value="valid")
|
||||
config.value = 123 # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
asyncio.run(_enter_once(compositor, configs={"configured": config}))
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_existing_snapshot_model_instances_are_revalidated_before_factory_runs() -> None:
|
||||
calls = 0
|
||||
|
||||
def create_layer(config: EmptyLayerConfig) -> TraceLayer:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return TraceLayer()
|
||||
|
||||
compositor = Compositor(
|
||||
[LayerNode("trace", LayerProvider.from_factory(layer_type=TraceLayer, create=create_layer))]
|
||||
)
|
||||
snapshot = CompositorSessionSnapshot.model_validate(
|
||||
{"layers": [{"name": "trace", "lifecycle_state": "suspended", "runtime_state": {}}]}
|
||||
)
|
||||
snapshot.layers[0].lifecycle_state = LifecycleState.ACTIVE
|
||||
|
||||
with pytest.raises(ValidationError, match="ACTIVE is internal-only"):
|
||||
asyncio.run(_enter_once(compositor, session_snapshot=snapshot))
|
||||
|
||||
assert calls == 0
|
||||
|
||||
|
||||
class RuntimeState(BaseModel):
|
||||
runtime_id: int | None = None
|
||||
resumed_runtime_id: int | None = None
|
||||
deleted_runtime_id: int | None = None
|
||||
body_value: str | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RuntimeStateLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, RuntimeState]):
|
||||
next_id: Iterator[int] = field(default_factory=lambda: count(1))
|
||||
|
||||
@override
|
||||
async def on_context_create(self) -> None:
|
||||
self.runtime_state.runtime_id = next(self.next_id)
|
||||
|
||||
@override
|
||||
async def on_context_resume(self) -> None:
|
||||
self.runtime_state.resumed_runtime_id = self.runtime_state.runtime_id
|
||||
|
||||
@override
|
||||
async def on_context_delete(self) -> None:
|
||||
self.runtime_state.deleted_runtime_id = self.runtime_state.runtime_id
|
||||
|
||||
|
||||
def test_snapshot_hydrates_runtime_state_and_exit_snapshots_from_layer_self() -> None:
|
||||
compositor = Compositor([LayerNode("state", RuntimeStateLayer)])
|
||||
|
||||
async def create_suspend_resume_delete() -> tuple[CompositorSessionSnapshot, CompositorSessionSnapshot]:
|
||||
async with compositor.enter() as first_run:
|
||||
first_run.suspend_on_exit()
|
||||
assert first_run.session_snapshot is not None
|
||||
|
||||
async with compositor.enter(session_snapshot=first_run.session_snapshot) as resumed_run:
|
||||
resumed_layer = resumed_run.get_layer("state", RuntimeStateLayer)
|
||||
assert isinstance(resumed_layer.runtime_state, RuntimeState)
|
||||
assert resumed_layer.runtime_state.runtime_id == 1
|
||||
assert resumed_layer.runtime_state.resumed_runtime_id == 1
|
||||
resumed_layer.runtime_state.body_value = "mutated on self"
|
||||
assert resumed_run.session_snapshot is not None
|
||||
return first_run.session_snapshot, resumed_run.session_snapshot
|
||||
|
||||
suspended_snapshot, closed_snapshot = asyncio.run(create_suspend_resume_delete())
|
||||
|
||||
assert suspended_snapshot.model_dump(mode="json") == {
|
||||
"schema_version": 1,
|
||||
"layers": [
|
||||
{
|
||||
"name": "state",
|
||||
"lifecycle_state": "suspended",
|
||||
"runtime_state": {
|
||||
"runtime_id": 1,
|
||||
"resumed_runtime_id": None,
|
||||
"deleted_runtime_id": None,
|
||||
"body_value": None,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
assert closed_snapshot.model_dump(mode="json") == {
|
||||
"schema_version": 1,
|
||||
"layers": [
|
||||
{
|
||||
"name": "state",
|
||||
"lifecycle_state": "closed",
|
||||
"runtime_state": {
|
||||
"runtime_id": 1,
|
||||
"resumed_runtime_id": 1,
|
||||
"deleted_runtime_id": 1,
|
||||
"body_value": "mutated on self",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_run_snapshot_rejects_active_layers() -> None:
|
||||
compositor = _compositor("trace")
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter() as active_run:
|
||||
with pytest.raises(RuntimeError, match="Cannot snapshot active compositor run layers: trace"):
|
||||
active_run.snapshot_session()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_active_snapshot_input_is_rejected_before_factories_run() -> None:
|
||||
calls = 0
|
||||
|
||||
def create_layer(config: EmptyLayerConfig) -> TraceLayer:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return TraceLayer()
|
||||
|
||||
compositor = Compositor(
|
||||
[LayerNode("trace", LayerProvider.from_factory(layer_type=TraceLayer, create=create_layer))]
|
||||
)
|
||||
active_snapshot = {"layers": [{"name": "trace", "lifecycle_state": "active", "runtime_state": {}}]}
|
||||
|
||||
with pytest.raises(ValidationError, match="ACTIVE is internal-only"):
|
||||
CompositorSessionSnapshot.model_validate(active_snapshot)
|
||||
|
||||
with pytest.raises(ValidationError, match="ACTIVE is internal-only"):
|
||||
asyncio.run(_enter_once(compositor, session_snapshot=active_snapshot))
|
||||
|
||||
assert calls == 0
|
||||
|
||||
|
||||
def test_closed_snapshot_enter_is_rejected_before_hooks_run() -> None:
|
||||
created_layers: list[TraceLayer] = []
|
||||
|
||||
def create_layer(config: EmptyLayerConfig) -> TraceLayer:
|
||||
layer = TraceLayer()
|
||||
created_layers.append(layer)
|
||||
return layer
|
||||
|
||||
compositor = Compositor(
|
||||
[LayerNode("trace", LayerProvider.from_factory(layer_type=TraceLayer, create=create_layer))]
|
||||
)
|
||||
closed_snapshot = {"layers": [{"name": "trace", "lifecycle_state": "closed", "runtime_state": {}}]}
|
||||
|
||||
with pytest.raises(RuntimeError, match="CLOSED snapshots cannot be entered"):
|
||||
asyncio.run(_enter_once(compositor, session_snapshot=closed_snapshot))
|
||||
|
||||
assert len(created_layers) == 1
|
||||
assert created_layers[0].events == []
|
||||
|
||||
|
||||
async def _enter_once(
|
||||
compositor: Compositor,
|
||||
*,
|
||||
configs: dict[str, object] | None = None,
|
||||
session_snapshot: object | None = None,
|
||||
) -> None:
|
||||
async with compositor.enter(
|
||||
configs=configs, # pyright: ignore[reportArgumentType]
|
||||
session_snapshot=session_snapshot, # pyright: ignore[reportArgumentType]
|
||||
):
|
||||
pass
|
||||
156
dify-agent/tests/local/agenton/compositor/test_transformers.py
Normal file
156
dify-agent/tests/local/agenton/compositor/test_transformers.py
Normal file
@ -0,0 +1,156 @@
|
||||
import asyncio
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from inspect import Parameter, signature
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from agenton.compositor import Compositor, CompositorTransformerKwargs, LayerNode, LayerProvider
|
||||
from agenton.layers import NoLayerDeps, PlainLayer, PlainPromptType, PlainToolType, PlainUserPromptType
|
||||
|
||||
type ToolCallable = Callable[..., object]
|
||||
type WrappedPrompt = tuple[str, str]
|
||||
type WrappedUserPrompt = tuple[str, str]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PromptAndToolLayer(PlainLayer[NoLayerDeps]):
|
||||
prefix: list[str]
|
||||
user: list[str]
|
||||
suffix: list[str]
|
||||
tool_entries: list[ToolCallable]
|
||||
|
||||
@property
|
||||
@override
|
||||
def prefix_prompts(self) -> list[str]:
|
||||
return self.prefix
|
||||
|
||||
@property
|
||||
@override
|
||||
def suffix_prompts(self) -> list[str]:
|
||||
return self.suffix
|
||||
|
||||
@property
|
||||
@override
|
||||
def user_prompts(self) -> list[str]:
|
||||
return self.user
|
||||
|
||||
@property
|
||||
@override
|
||||
def tools(self) -> list[ToolCallable]:
|
||||
return self.tool_entries
|
||||
|
||||
|
||||
def base_tool() -> str:
|
||||
return "base"
|
||||
|
||||
|
||||
def wrapped_tool() -> str:
|
||||
return "wrapped"
|
||||
|
||||
|
||||
def wrap_prompts(prompts: Sequence[PlainPromptType]) -> list[WrappedPrompt]:
|
||||
return [("wrapped", prompt.value) for prompt in prompts]
|
||||
|
||||
|
||||
def wrap_user_prompts(prompts: Sequence[PlainUserPromptType]) -> list[WrappedUserPrompt]:
|
||||
return [("wrapped-user", prompt.value) for prompt in prompts]
|
||||
|
||||
|
||||
def describe_tools(tools: Sequence[PlainToolType]) -> list[str]:
|
||||
return [tool.value.__name__ for tool in tools]
|
||||
|
||||
|
||||
def prompt_tool_provider(
|
||||
*,
|
||||
prefix: list[str] | None = None,
|
||||
user: list[str] | None = None,
|
||||
suffix: list[str] | None = None,
|
||||
tool_entries: list[ToolCallable] | None = None,
|
||||
) -> LayerProvider[PromptAndToolLayer]:
|
||||
return LayerProvider.from_factory(
|
||||
layer_type=PromptAndToolLayer,
|
||||
create=lambda config: PromptAndToolLayer(
|
||||
prefix=list(prefix or []),
|
||||
user=list(user or []),
|
||||
suffix=list(suffix or []),
|
||||
tool_entries=list(tool_entries or []),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_compositor_transformer_kwargs_keys_match_constructor_parameters() -> None:
|
||||
transformer_kwargs = set(CompositorTransformerKwargs.__required_keys__)
|
||||
parameters = signature(Compositor).parameters
|
||||
|
||||
assert CompositorTransformerKwargs.__optional_keys__ == frozenset()
|
||||
assert transformer_kwargs == {name for name in parameters if name.endswith("_transformer")}
|
||||
assert all(parameters[name].kind is Parameter.KEYWORD_ONLY for name in transformer_kwargs)
|
||||
|
||||
|
||||
def test_compositor_transformer_kwargs_keys_match_from_config_parameters() -> None:
|
||||
transformer_kwargs = set(CompositorTransformerKwargs.__required_keys__)
|
||||
parameters = signature(Compositor.from_config).parameters
|
||||
|
||||
assert transformer_kwargs == {name for name in parameters if name.endswith("_transformer")}
|
||||
assert all(parameters[name].kind is Parameter.KEYWORD_ONLY for name in transformer_kwargs)
|
||||
|
||||
|
||||
def test_compositor_transforms_prompts_to_another_type_after_layer_ordering() -> None:
|
||||
compositor: Compositor[WrappedPrompt, PlainToolType, PlainPromptType, PlainToolType] = Compositor(
|
||||
[
|
||||
LayerNode("first", prompt_tool_provider(prefix=["first-prefix"], suffix=["first-suffix"])),
|
||||
LayerNode("second", prompt_tool_provider(prefix=["second-prefix"], suffix=["second-suffix"])),
|
||||
],
|
||||
prompt_transformer=wrap_prompts,
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter() as active_run:
|
||||
assert active_run.prompts == [
|
||||
("wrapped", "first-prefix"),
|
||||
("wrapped", "second-prefix"),
|
||||
("wrapped", "second-suffix"),
|
||||
("wrapped", "first-suffix"),
|
||||
]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_compositor_transforms_tools_to_another_type_after_layer_aggregation() -> None:
|
||||
compositor: Compositor[PlainPromptType, str, PlainPromptType, PlainToolType] = Compositor(
|
||||
[LayerNode("tools", prompt_tool_provider(tool_entries=[base_tool, wrapped_tool]))],
|
||||
tool_transformer=describe_tools,
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter() as active_run:
|
||||
assert active_run.tools == ["base_tool", "wrapped_tool"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_compositor_transforms_user_prompts_after_layer_ordering() -> None:
|
||||
compositor: Compositor[
|
||||
PlainPromptType,
|
||||
PlainToolType,
|
||||
PlainPromptType,
|
||||
PlainToolType,
|
||||
WrappedUserPrompt,
|
||||
PlainUserPromptType,
|
||||
] = Compositor(
|
||||
[
|
||||
LayerNode("first", prompt_tool_provider(user=["first-user"])),
|
||||
LayerNode("second", prompt_tool_provider(user=["second-user"])),
|
||||
],
|
||||
user_prompt_transformer=wrap_user_prompts,
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter() as active_run:
|
||||
assert active_run.user_prompts == [
|
||||
("wrapped-user", "first-user"),
|
||||
("wrapped-user", "second-user"),
|
||||
]
|
||||
|
||||
asyncio.run(run())
|
||||
1
dify-agent/tests/local/agenton/layers/__init__.py
Normal file
1
dify-agent/tests/local/agenton/layers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
15
dify-agent/tests/local/agenton/layers/test_layer_deps.py
Normal file
15
dify-agent/tests/local/agenton/layers/test_layer_deps.py
Normal file
@ -0,0 +1,15 @@
|
||||
import pytest
|
||||
|
||||
from agenton.layers import LayerDeps
|
||||
from agenton_collections.layers.plain import ObjectLayer, PromptLayer
|
||||
|
||||
|
||||
class ObjectLayerDeps(LayerDeps):
|
||||
"""Deps container used to exercise runtime dependency validation."""
|
||||
|
||||
object_layer: ObjectLayer[str] # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
|
||||
def test_layer_deps_rejects_mismatched_runtime_layer_class() -> None:
|
||||
with pytest.raises(TypeError, match="should be of type 'ObjectLayer'"):
|
||||
ObjectLayerDeps(object_layer=PromptLayer())
|
||||
@ -0,0 +1,92 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from agenton.compositor import LayerProvider
|
||||
from agenton.layers import (
|
||||
EmptyLayerConfig,
|
||||
EmptyRuntimeState,
|
||||
LayerConfig,
|
||||
NoLayerDeps,
|
||||
PlainLayer,
|
||||
)
|
||||
|
||||
|
||||
class InferredConfig(LayerConfig):
|
||||
value: str = "configured"
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class InferredState(BaseModel):
|
||||
count: int = 0
|
||||
|
||||
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GenericSchemaLayer(PlainLayer[NoLayerDeps, InferredConfig, InferredState]):
|
||||
type_id = "test.generic-schema"
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: InferredConfig) -> "GenericSchemaLayer":
|
||||
return cls()
|
||||
|
||||
async def on_context_create(self) -> None:
|
||||
self.runtime_state.count += 1
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DefaultSchemaLayer(PlainLayer[NoLayerDeps]):
|
||||
type_id = "test.default-schema"
|
||||
|
||||
|
||||
def test_layer_infers_config_and_runtime_state_from_generics() -> None:
|
||||
layer = GenericSchemaLayer()
|
||||
layer.runtime_state = InferredState(count=3)
|
||||
|
||||
assert GenericSchemaLayer.config_type is InferredConfig
|
||||
assert GenericSchemaLayer.runtime_state_type is InferredState
|
||||
assert isinstance(layer.runtime_state, InferredState)
|
||||
assert layer.runtime_state.count == 3
|
||||
|
||||
|
||||
def test_layer_uses_empty_schema_defaults_when_omitted() -> None:
|
||||
layer = DefaultSchemaLayer()
|
||||
|
||||
assert DefaultSchemaLayer.config_type is EmptyLayerConfig
|
||||
assert DefaultSchemaLayer.runtime_state_type is EmptyRuntimeState
|
||||
assert isinstance(layer.runtime_state, EmptyRuntimeState)
|
||||
|
||||
|
||||
def test_invalid_declared_schema_type_is_rejected_clearly() -> None:
|
||||
try:
|
||||
|
||||
class InvalidSchemaLayer(PlainLayer[NoLayerDeps]):
|
||||
config_type = dict # pyright: ignore[reportAssignmentType]
|
||||
|
||||
except TypeError as e:
|
||||
assert str(e) == "InvalidSchemaLayer.config_type must be a LayerConfig subclass."
|
||||
else:
|
||||
raise AssertionError("Expected TypeError.")
|
||||
|
||||
try:
|
||||
|
||||
class InvalidGenericSchemaLayer(PlainLayer[NoLayerDeps, dict[str, object]]): # pyright: ignore[reportInvalidTypeArguments]
|
||||
pass
|
||||
|
||||
except TypeError as e:
|
||||
assert str(e) == "InvalidGenericSchemaLayer.config_type must be a LayerConfig subclass."
|
||||
else:
|
||||
raise AssertionError("Expected TypeError.")
|
||||
|
||||
|
||||
def test_layer_provider_uses_inferred_schema_types() -> None:
|
||||
provider = LayerProvider.from_layer_type(GenericSchemaLayer)
|
||||
|
||||
layer = provider.create_layer({"value": "configured"})
|
||||
|
||||
assert provider.type_id == "test.generic-schema"
|
||||
assert provider.layer_type.config_type is InferredConfig
|
||||
assert provider.layer_type.runtime_state_type is InferredState
|
||||
assert isinstance(layer.config, InferredConfig)
|
||||
1
dify-agent/tests/local/agenton_collections/__init__.py
Normal file
1
dify-agent/tests/local/agenton_collections/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayer
|
||||
|
||||
|
||||
def test_prompt_layer_type_id_constant_matches_implementation_class() -> None:
|
||||
assert PLAIN_PROMPT_LAYER_TYPE_ID == "plain.prompt"
|
||||
assert PromptLayer.type_id == PLAIN_PROMPT_LAYER_TYPE_ID
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user