Compare commits

...

46 Commits

Author SHA1 Message Date
04eab90825 [autofix.ci] apply automated fixes 2026-05-12 21:47:34 +00:00
d24a16a9a5 Merge remote-tracking branch 'origin/main' into feat/dify-agent 2026-05-13 05:24:33 +08:00
edf45fe7c1 add local dify-agent dependency to api 2026-05-13 05:23:52 +08:00
8afdf7eb14 split dify-agent client server deps 2026-05-13 05:23:45 +08:00
eb1be3b7e2 add dify-agent env example 2026-05-13 04:42:48 +08:00
e3e245881e split agenton compositor modules 2026-05-13 04:29:48 +08:00
b70763d751 remove obsolete agenton and run api docs 2026-05-13 03:55:43 +08:00
ae4a3f75f4 refactor dify-agent agenton run model 2026-05-13 03:51:37 +08:00
b4ce54a7ea refactor agenton compositor lifecycle model 2026-05-12 23:32:12 +08:00
6facd9360c chore: some match case (#36080)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-12 14:45:58 +00:00
a18d7f51eb fix: fixed relative (#36078)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-05-12 11:05:57 +00:00
680ef077ae chore: admin also has the permission of changing role (#36069)
Co-authored-by: Yansong Zhang <916125788@qq.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-12 10:15:05 +00:00
c26be9d3f4 fix: redirect unauthorized dataset access to /datasets for knowledge editors (#36073)
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-05-12 09:38:49 +00:00
e8c16fb08b refine agenton layer lifecycle controls 2026-05-12 04:38:23 +08:00
ab0b4c45cb add agenton dependency control lookup 2026-05-12 03:49:38 +08:00
208012e268 add dify plugin llm layers 2026-05-12 02:42:45 +08:00
8d96f6cfbd refactor dify-agent run success protocol 2026-05-12 01:46:39 +08:00
15f5c7064e add dify-agent python client 2026-05-12 00:08:55 +08:00
e470e9d4c5 fix dify-agent make project directory 2026-05-11 21:48:57 +08:00
499cad2f15 rename dify-agent make lint targets 2026-05-11 21:18:02 +08:00
92e1d5aa6b pin mkdocs below version two 2026-05-11 21:10:14 +08:00
85cb6558f9 add dify-agent docs examples infrastructure 2026-05-11 20:57:37 +08:00
f12ec0d53a add dify-agent run retention ttl 2026-05-11 20:18:34 +08:00
80312f9745 add typed dify-agent run events 2026-05-11 19:55:23 +08:00
658caa2ae7 refactor dify-agent runs to local scheduler 2026-05-11 19:39:03 +08:00
3c95ff4782 add docs and tests for dify-agent 2026-05-08 01:37:05 +08:00
2a5601e868 minor fix the impl of dify-agent worker 2026-05-08 01:36:54 +08:00
a523d071ca refactor the docs of agenton 2026-05-08 01:34:08 +08:00
5a425f1525 impl a runable version of dify_agent server 2026-05-08 01:31:30 +08:00
8c6e9c3b95 add agenton user prompt composition 2026-05-07 23:05:43 +08:00
f316d19be6 add schema-backed agenton sessions 2026-05-07 22:42:07 +08:00
31a1de4828 update the signal of compositor 2026-05-07 18:34:11 +08:00
3a5477b39a update makefile 2026-04-29 22:06:31 +08:00
4d580f9e8d restructure examples with test 2026-04-29 22:06:13 +08:00
24349db9b2 refactor the structure of local tests 2026-04-29 22:01:57 +08:00
2f603183f0 add the transformer design for compositor
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 21:55:02 +08:00
66e245aa8d move the tests for agenton_collections 2026-04-29 20:30:47 +08:00
2ea227f3c0 move the collections folder structure 2026-04-29 20:01:03 +08:00
6fd27519e9 update the example 2026-04-29 04:19:12 +08:00
468d9bca09 Merge remote-tracking branch 'origin/main' into feat/dify-agent 2026-04-29 04:11:06 +08:00
5dfd318907 refactor the design of tmp leave control 2026-04-29 04:09:32 +08:00
5a7eb7fdb6 initialize the agenton engine 2026-04-29 03:40:05 +08:00
264533324b update AGENTS.md 2026-04-29 03:38:50 +08:00
9dbf3199a3 Merge remote-tracking branch 'origin/main' into feat/dify-agent 2026-04-24 22:50:12 +08:00
6ca07c4a4e feat(agent): add dify llm adapter 2026-04-24 22:48:07 +08:00
70bd5439d0 feat(agent): add dify agent project setup 2026-04-24 22:47:52 +08:00
141 changed files with 16069 additions and 225 deletions

0
.codex Normal file
View File

2
.gitignore vendored
View File

@ -250,5 +250,5 @@ scripts/stress-test/reports/
# Code Agent Folder
.qoder/*
.context/*
.eslintcache

View File

@ -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

View File

@ -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"}

View File

@ -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(

View File

@ -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}")

View File

@ -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 }

View File

@ -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

View File

@ -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
View File

@ -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
View 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
View File

@ -0,0 +1 @@
dify-aio

184
dify-agent/AGENTS.md Normal file
View 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 youll 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 repos 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 theres 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 its 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
View 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
View 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.

View 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)

View 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})"

View 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"}
```

View 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`

View 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.

View 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"}
```

View 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.

View 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
View 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.

View File

@ -0,0 +1 @@
"""Runnable Agenton examples kept separate from Dify Agent runtime examples."""

View 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()

View 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())

View File

@ -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())

View File

@ -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())

View File

@ -0,0 +1 @@
"""Runnable Dify Agent runtime examples kept separate from Agenton examples."""

View File

@ -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()

View File

@ -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())

View File

@ -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())

View File

@ -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())

View File

@ -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
View 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
View 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"

View 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.
"""

View 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",
]

View 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

View 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.")

View 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))

View 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]

View 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]

View 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",
]

View 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__", ()))

View 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",
]

View 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",
]

View 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",
]

View 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",
]

View 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",
]

View File

@ -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",
]

View 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",
]

View File

@ -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] = []

View File

@ -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",
]

View 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"]

View File

@ -0,0 +1 @@
"""Adapter integrations for Dify agent components."""

View File

@ -0,0 +1,6 @@
"""LLM adapters for Dify plugin-daemon integrations."""
from .model import DifyLLMAdapterModel
from .provider import DifyPluginDaemonProvider
__all__ = ["DifyLLMAdapterModel", "DifyPluginDaemonProvider"]

View 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, ""

View 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}")

View 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",
]

View 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",
]

View File

@ -0,0 +1,3 @@
"""Dify-owned Agenton layer packages."""
__all__: list[str] = []

View 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",
]

View 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",
]

View 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"]

View 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"]

View 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",
]

View 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",
]

View 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"]

View 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"]

View 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"]

View 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",
]

View 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"]

View 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"]

View 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"]

View 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"]

View 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"]

View 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"]

View 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"]

View 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"]

View 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"]

View 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"]

View 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"]

View File

View 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__"})

View 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

View File

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -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

View 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

View 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

View 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())

View File

@ -0,0 +1 @@

View 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())

View File

@ -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)

View File

@ -0,0 +1 @@

View File

@ -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