mirror of
https://github.com/langgenius/dify.git
synced 2026-08-30 17:11:50 +08:00
feat(dify-agent): add context-aware history compaction (#40872)
This commit is contained in:
@@ -150,6 +150,7 @@ class AgentBackendModelConfig(BaseModel):
|
||||
model_provider: str
|
||||
model: str
|
||||
model_settings: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
context_window_tokens: int | None = Field(default=None, gt=0)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -413,6 +414,7 @@ class AgentBackendRunRequestBuilder:
|
||||
model_provider=run_input.model.model_provider,
|
||||
model=run_input.model.model,
|
||||
model_settings=_agent_model_settings(run_input.model.model_settings),
|
||||
context_window_tokens=run_input.model.context_window_tokens,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -605,6 +607,7 @@ class AgentBackendRunRequestBuilder:
|
||||
model_provider=run_input.model.model_provider,
|
||||
model=run_input.model.model,
|
||||
model_settings=_agent_model_settings(run_input.model.model_settings),
|
||||
context_window_tokens=run_input.model.context_window_tokens,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -29,6 +29,7 @@ from clients.agent_backend import (
|
||||
)
|
||||
from configs import dify_config
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
|
||||
from core.app.llm.model_access import resolve_model_context_window
|
||||
from core.plugin.provider_identity import normalize_plugin_daemon_provider_identity
|
||||
from core.workflow.nodes.agent_v2.dify_tools_builder import (
|
||||
WorkflowAgentDifyToolLayersBuilder,
|
||||
@@ -129,6 +130,11 @@ class AgentAppRuntimeRequestBuilder:
|
||||
append_runtime_warnings(metadata, config_warnings)
|
||||
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
|
||||
knowledge_config = build_knowledge_layer_config(agent_soul)
|
||||
context_window_tokens = resolve_model_context_window(
|
||||
run_context=context.dify_context,
|
||||
provider_name=agent_soul.model.model_provider,
|
||||
model_name=agent_soul.model.model,
|
||||
)
|
||||
model_plugin_id, model_provider = normalize_plugin_daemon_provider_identity(
|
||||
ModelProviderID(agent_soul.model.model_provider),
|
||||
agent_soul.model.plugin_id,
|
||||
@@ -141,6 +147,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
model_provider=model_provider,
|
||||
model=agent_soul.model.model,
|
||||
model_settings=agent_soul.model.model_settings.model_dump(mode="json", exclude_none=True),
|
||||
context_window_tokens=context_window_tokens,
|
||||
),
|
||||
execution_context=DifyExecutionContextLayerConfig(
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
|
||||
@@ -9,7 +9,7 @@ from core.errors.error import ProviderTokenNotInitError
|
||||
from core.model_manager import ModelInstance, ModelManager
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
|
||||
from core.provider_manager import ProviderManager
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.entities.model_entities import ModelPropertyKey, ModelType
|
||||
from graphon.nodes.llm.entities import ModelConfig
|
||||
from graphon.nodes.llm.exc import LLMModeRequiredError, ModelNotExistError
|
||||
from graphon.nodes.llm.protocols import CredentialsProvider
|
||||
@@ -128,6 +128,27 @@ def build_dify_model_access(run_context: DifyRunContext) -> tuple[CredentialsPro
|
||||
)
|
||||
|
||||
|
||||
def resolve_model_context_window(
|
||||
*,
|
||||
run_context: DifyRunContext,
|
||||
provider_name: str,
|
||||
model_name: str,
|
||||
) -> int | None:
|
||||
"""Return the selected model's credential-bound context-window capability.
|
||||
|
||||
The ``ModelInstance`` and its schema are resolved with the current
|
||||
tenant/user ``DifyRunContext``. A positive, non-boolean plugin-declared
|
||||
``CONTEXT_SIZE`` is returned; a missing or invalid value returns ``None``.
|
||||
Model lookup and schema errors propagate. This function does not infer a
|
||||
window from the model name or fall back to a model registry or cache.
|
||||
"""
|
||||
model_instance = DifyModelFactory(run_context=run_context).init_model_instance(provider_name, model_name)
|
||||
context_window = model_instance.get_model_schema().model_properties.get(ModelPropertyKey.CONTEXT_SIZE)
|
||||
if isinstance(context_window, bool) or not isinstance(context_window, int) or context_window <= 0:
|
||||
return None
|
||||
return context_window
|
||||
|
||||
|
||||
def _normalize_completion_params(completion_params: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
||||
"""
|
||||
Split node-level completion params into provider parameters and stop sequences.
|
||||
|
||||
@@ -47,6 +47,7 @@ from clients.agent_backend import (
|
||||
)
|
||||
from configs import dify_config
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
|
||||
from core.app.llm.model_access import resolve_model_context_window
|
||||
from core.plugin.provider_identity import normalize_plugin_daemon_provider_identity
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text, get_system_value
|
||||
from graphon.file import File, FileTransferMethod
|
||||
@@ -211,6 +212,11 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
|
||||
soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
|
||||
knowledge_config = build_knowledge_layer_config(agent_soul)
|
||||
context_window_tokens = resolve_model_context_window(
|
||||
run_context=context.dify_context,
|
||||
provider_name=agent_soul.model.model_provider,
|
||||
model_name=agent_soul.model.model,
|
||||
)
|
||||
model_plugin_id, model_provider = normalize_plugin_daemon_provider_identity(
|
||||
ModelProviderID(agent_soul.model.model_provider),
|
||||
agent_soul.model.plugin_id,
|
||||
@@ -223,6 +229,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
model_provider=model_provider,
|
||||
model=agent_soul.model.model,
|
||||
model_settings=agent_soul.model.model_settings.model_dump(mode="json", exclude_none=True),
|
||||
context_window_tokens=context_window_tokens,
|
||||
),
|
||||
# The execution-context layer is now the only public protocol
|
||||
# carrier for Dify tenant/user/run identifiers. ``user_id`` and
|
||||
|
||||
@@ -67,9 +67,13 @@ from models.model import MessageAgentThought
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def bind_agent_db(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
|
||||
"""Bind the runner's ORM writes to the shared SQLite session."""
|
||||
def bind_agent_dependencies(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
|
||||
"""Bind local runner dependencies without reaching external services."""
|
||||
monkeypatch.setattr(app_runner_module.db, "session", sqlite_session)
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.resolve_model_context_window",
|
||||
lambda **_kwargs: None,
|
||||
)
|
||||
|
||||
|
||||
def _thought_rows(session: Session) -> list[MessageAgentThought]:
|
||||
|
||||
@@ -28,6 +28,21 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def model_context_window_calls(monkeypatch: pytest.MonkeyPatch) -> list[tuple[object, str, str]]:
|
||||
calls: list[tuple[object, str, str]] = []
|
||||
|
||||
def resolve(*, run_context: object, provider_name: str, model_name: str) -> int:
|
||||
calls.append((run_context, provider_name, model_name))
|
||||
return 32_768
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.resolve_model_context_window",
|
||||
resolve,
|
||||
)
|
||||
return calls
|
||||
|
||||
|
||||
def _exec_ctx() -> DifyExecutionContextLayerConfig:
|
||||
return DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
@@ -175,11 +190,12 @@ def _soul_with_model() -> AgentSoulConfig:
|
||||
|
||||
|
||||
class TestAgentAppRuntimeRequestBuilder:
|
||||
def test_build_maps_soul_to_run_request(self):
|
||||
def test_build_maps_soul_to_run_request(self, model_context_window_calls: list[tuple[object, str, str]]):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
result = builder.build(_ctx(_soul_with_model()))
|
||||
context = _ctx(_soul_with_model())
|
||||
result = builder.build(context)
|
||||
|
||||
req = result.request
|
||||
names = [layer.name for layer in req.composition.layers]
|
||||
@@ -197,6 +213,8 @@ class TestAgentAppRuntimeRequestBuilder:
|
||||
llm = next(layer for layer in req.composition.layers if layer.name == "llm")
|
||||
assert llm.config.plugin_id == "langgenius/openai"
|
||||
assert llm.config.model_provider == "openai"
|
||||
assert llm.config.context_window_tokens == 32_768
|
||||
assert model_context_window_calls == [(context.dify_context, "langgenius/openai/openai", "gpt-4o-mini")]
|
||||
# execution context carries conversation + agent_app invoke source.
|
||||
exec_ctx = next(layer for layer in req.composition.layers if layer.name == "execution_context")
|
||||
assert exec_ctx.config.conversation_id == "conv-1"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext
|
||||
from core.app.llm import model_access
|
||||
from graphon.model_runtime.entities.model_entities import ModelPropertyKey
|
||||
|
||||
|
||||
def _stub_model_factory(monkeypatch: pytest.MonkeyPatch, context_window: object) -> dict[str, object]:
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
class FakeModelFactory:
|
||||
def __init__(self, *, run_context: DifyRunContext) -> None:
|
||||
calls["run_context"] = run_context
|
||||
|
||||
def init_model_instance(self, provider_name: str, model_name: str) -> object:
|
||||
calls["provider_name"] = provider_name
|
||||
calls["model_name"] = model_name
|
||||
schema = SimpleNamespace(model_properties={ModelPropertyKey.CONTEXT_SIZE: context_window})
|
||||
return SimpleNamespace(get_model_schema=lambda: schema)
|
||||
|
||||
monkeypatch.setattr(model_access, "DifyModelFactory", FakeModelFactory)
|
||||
return calls
|
||||
|
||||
|
||||
def test_resolve_model_context_window_reads_selected_model_schema(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = _stub_model_factory(monkeypatch, 128_000)
|
||||
run_context = cast(DifyRunContext, object())
|
||||
|
||||
context_window = model_access.resolve_model_context_window(
|
||||
run_context=run_context,
|
||||
provider_name="langgenius/openai/openai",
|
||||
model_name="gpt-4o",
|
||||
)
|
||||
|
||||
assert context_window == 128_000
|
||||
assert calls == {
|
||||
"run_context": run_context,
|
||||
"provider_name": "langgenius/openai/openai",
|
||||
"model_name": "gpt-4o",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("context_window", [None, 0, -1, True, False, "128000", 128_000.0])
|
||||
def test_resolve_model_context_window_ignores_invalid_schema_values(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
context_window: object,
|
||||
) -> None:
|
||||
_ = _stub_model_factory(monkeypatch, context_window)
|
||||
|
||||
assert (
|
||||
model_access.resolve_model_context_window(
|
||||
run_context=cast(DifyRunContext, object()),
|
||||
provider_name="openai",
|
||||
model_name="gpt-4o",
|
||||
)
|
||||
is None
|
||||
)
|
||||
@@ -62,6 +62,14 @@ from models.agent_config_entities import (
|
||||
from services.agent.workspace_service import AgentWorkspaceNotFoundError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_model_context_window(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.resolve_model_context_window",
|
||||
lambda **_kwargs: None,
|
||||
)
|
||||
|
||||
|
||||
def _restored_file(*, transfer_method: FileTransferMethod, reference: str) -> File:
|
||||
return File(
|
||||
type=FileType.DOCUMENT,
|
||||
|
||||
@@ -40,6 +40,21 @@ from models.agent_config_entities import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def model_context_window_calls(monkeypatch: pytest.MonkeyPatch) -> list[tuple[object, str, str]]:
|
||||
calls: list[tuple[object, str, str]] = []
|
||||
|
||||
def resolve(*, run_context: object, provider_name: str, model_name: str) -> int:
|
||||
calls.append((run_context, provider_name, model_name))
|
||||
return 32_768
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.resolve_model_context_window",
|
||||
resolve,
|
||||
)
|
||||
return calls
|
||||
|
||||
|
||||
def test_agent_soul_round_trip_preserves_existing_app_feature_fields():
|
||||
config = AgentSoulConfig.model_validate(
|
||||
{
|
||||
@@ -147,7 +162,7 @@ def _context() -> WorkflowAgentRuntimeBuildContext:
|
||||
prompt={"system_prompt": "You are careful."},
|
||||
model=AgentSoulModelConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model_provider="langgenius/openai/openai",
|
||||
model="gpt-test",
|
||||
model_settings={"temperature": 0},
|
||||
),
|
||||
@@ -220,8 +235,11 @@ def _uploaded_workflow_files_prompt_payload(result) -> object:
|
||||
raise AssertionError("missing prompt payload for sys.files")
|
||||
|
||||
|
||||
def test_builds_create_run_request_from_agent_soul_and_node_job():
|
||||
result = WorkflowAgentRuntimeRequestBuilder().build(_context())
|
||||
def test_builds_create_run_request_from_agent_soul_and_node_job(
|
||||
model_context_window_calls: list[tuple[object, str, str]],
|
||||
):
|
||||
context = _context()
|
||||
result = WorkflowAgentRuntimeRequestBuilder().build(context)
|
||||
|
||||
dumped = result.request.model_dump(mode="json")
|
||||
layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]}
|
||||
@@ -238,6 +256,9 @@ def test_builds_create_run_request_from_agent_soul_and_node_job():
|
||||
assert "Previous node outputs:" not in dumped["composition"]["layers"][2]["config"]["user"]
|
||||
assert dumped["composition"]["layers"][-1]["config"]["json_schema"]["properties"]["summary"]["type"] == "string"
|
||||
assert DIFY_AGENT_HISTORY_LAYER_ID in layers
|
||||
assert layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["model_provider"] == "openai"
|
||||
assert layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["context_window_tokens"] == 32_768
|
||||
assert model_context_window_calls == [(context.dify_context, "langgenius/openai/openai", "gpt-test")]
|
||||
redacted_layers = {layer["name"]: layer for layer in result.redacted_request["composition"]["layers"]}
|
||||
assert "credentials" not in redacted_layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]
|
||||
|
||||
|
||||
Generated
+30
-12
@@ -1302,6 +1302,7 @@ dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx2" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-ai-harness" },
|
||||
{ name = "pydantic-ai-slim" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
@@ -1317,8 +1318,9 @@ requires-dist = [
|
||||
{ name = "jwcrypto", marker = "extra == 'server'", specifier = ">=1.5.6,<2" },
|
||||
{ name = "logfire", extras = ["fastapi", "httpx", "redis"], marker = "extra == 'server'", specifier = ">=4.37.0,<5.0.0" },
|
||||
{ name = "pydantic", specifier = ">=2.12.5,<2.13" },
|
||||
{ name = "pydantic-ai-slim", specifier = ">=1.106.0,<2.0.0" },
|
||||
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" },
|
||||
{ name = "pydantic-ai-harness", specifier = ">=0.20.0,<0.21.0" },
|
||||
{ name = "pydantic-ai-slim", specifier = ">=2.30.0,<3.0.0" },
|
||||
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=2.30.0,<3.0.0" },
|
||||
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" },
|
||||
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
|
||||
@@ -2674,15 +2676,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "genai-prices"
|
||||
version = "0.0.67"
|
||||
version = "0.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx2" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/17/9e/f96ad08d62f7bd33a5b24e65d4eb220569714b9a2a8813ada2e1fa47b4dd/genai_prices-0.0.67.tar.gz", hash = "sha256:54e07eb6541fda377187a471c5dba21a81b439c57f8dc44d89db3103c29ca343", size = 80015, upload-time = "2026-06-24T20:16:23.661Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/14/a188df294f013ec9cd97fc6b145f5427f89067bfb2c260fc3fb5c8d1fb34/genai_prices-0.1.3.tar.gz", hash = "sha256:62c30cddd6c2d2199d878d1a70521c3e37347cd9394446d107dc774a78ed3780", size = 92638, upload-time = "2026-08-15T00:10:31.771Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/05/d1ca6b960a3305f86d1c5f4274f2ddf8c94611ec7edc436a01cd38a01742/genai_prices-0.0.67-py3-none-any.whl", hash = "sha256:08977f1e83b4132abcfc60dabf21ff13c2d25958afb9199e59c4407bf5c9ed3f", size = 82495, upload-time = "2026-06-24T20:16:22.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/cd/d94b47c26d6367e0b949edfe2da5a47fb74037e799a0edd5b825e049f2b9/genai_prices-0.1.3-py3-none-any.whl", hash = "sha256:a2603841429c843da91c987d9ef598c73bd940caf44e844ab046d551791c04bb", size = 96892, upload-time = "2026-08-15T00:10:30.595Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5204,10 +5206,25 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-ai-slim"
|
||||
version = "1.107.0"
|
||||
name = "pydantic-ai-harness"
|
||||
version = "0.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "genai-prices" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic-ai-slim" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/6c/a066644a3a3bff22bfdddd745fd245e2b4e3148fc895be0020f03bd7470d/pydantic_ai_harness-0.20.0.tar.gz", hash = "sha256:18ec7d6f90873a8038d094280e50af5e877320b975f334268b700a266d35f522", size = 1846014, upload-time = "2026-08-14T03:36:31.045Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/85/32cab39557e338abbd4ecb844028a31110c03096e6678bf5a1266ed201d1/pydantic_ai_harness-0.20.0-py3-none-any.whl", hash = "sha256:e1164ae4d653bd2ae257e3816ee1776b27dbe8f7eaeb29b36d86a49d6fe98168", size = 623606, upload-time = "2026-08-14T03:36:29.094Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-ai-slim"
|
||||
version = "2.31.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "genai-prices" },
|
||||
{ name = "griffelib" },
|
||||
{ name = "httpx" },
|
||||
@@ -5216,9 +5233,9 @@ dependencies = [
|
||||
{ name = "pydantic-graph" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/26/ced63dfaabbc77f3beb86d59689cdea748e7ccffb6b419dbaf4780f211e8/pydantic_ai_slim-1.107.0.tar.gz", hash = "sha256:4616f689a92fcfecfecf2a7af27aca22f139a873cf6d7a8929eaeee9c0eedbb4", size = 779902, upload-time = "2026-06-10T14:53:10.574Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/4e/2165d9b90edcd5dfc8e9465b3bdc0aa66a67760843ddb0ac99ee396898f0/pydantic_ai_slim-2.31.0.tar.gz", hash = "sha256:a9310d2464154b028096f1d680f17837f16e5c6cd209b4542e4f60ca5d344789", size = 1214087, upload-time = "2026-08-15T03:17:28.353Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/57/71044e17f931b08cc3930bc0fe5a1e1fd37fa474ae826be004729ef1cb4a/pydantic_ai_slim-1.107.0-py3-none-any.whl", hash = "sha256:1af49bbae06a6c598f72c54d4734ba377100cac493c9a05fa8e089bebeae0da6", size = 964046, upload-time = "2026-06-10T14:53:03.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/09/233e529fadbece38580c3a390783f55fa196afad875ce535ee9a57a5ad71/pydantic_ai_slim-2.31.0-py3-none-any.whl", hash = "sha256:cb809ad949ca68be6bb9a0e0b994fc73a95f4cd405e8609a71034f2e2080e2a1", size = 1432053, upload-time = "2026-08-15T03:17:21.208Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5265,17 +5282,18 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-graph"
|
||||
version = "1.107.0"
|
||||
version = "2.31.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "logfire-api" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dd/c3/6e8c2d13b8701041f1b3eac5deb41f25d4dbfa479a190d5c6becc23f2a49/pydantic_graph-1.107.0.tar.gz", hash = "sha256:278dd89b3e33f3a2963ac949f27a53aef705c5d883a8ce5d06d23e6e3cfbd972", size = 62564, upload-time = "2026-06-10T14:53:13.366Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/2c/3817ae318ecc729a258fc85aaad84fc110b9467a292b98bb10e65ae71183/pydantic_graph-2.31.0.tar.gz", hash = "sha256:a19919408dfaa5a1b8713618bcce7a5135d83664321862b0d9be1f4216c432ef", size = 45180, upload-time = "2026-08-15T03:17:30.398Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/72/621556e3f5068400d43a0375d38e5963de30256eaa5a702aba12e82ed0ff/pydantic_graph-1.107.0-py3-none-any.whl", hash = "sha256:71add94fe7e14c703977a895117c475aae6c0b02a774a036c4d00d9a63c78b00", size = 80106, upload-time = "2026-06-10T14:53:06.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/ef/a3217caed3189cfcc6857316e06cb900990e4d5cb59e42ec517cfdda6a7f/pydantic_graph-2.31.0-py3-none-any.whl", hash = "sha256:062555c89b1d5699ddaaa6f09ae62fc1d0f5cc189d0867e2fafca68c24797ba5", size = 52662, upload-time = "2026-08-15T03:17:24.42Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -34,6 +34,24 @@ history_layer = RunLayerSpec(
|
||||
Include this layer in the same composition as your prompt, plugin, and LLM
|
||||
layers.
|
||||
|
||||
## Compaction and persistence
|
||||
|
||||
When the LLM layer supplies `context_window_tokens`, Dify Agent sets the Harness
|
||||
target to `min(floor(window * 0.8), window - max_tokens)` for a positive
|
||||
`model_settings.max_tokens`; otherwise it uses `floor(window * 0.8)`. A target
|
||||
that is not positive rejects the run before model invocation.
|
||||
|
||||
Harness estimates and, when needed, rewrites history immediately before model
|
||||
requests. It clears older tool results first, retaining the latest three
|
||||
tool-call/result pairs and their inputs. If the history is still over target, the
|
||||
same current model incrementally summarizes older messages while retaining the
|
||||
latest twenty messages and the first user message.
|
||||
|
||||
With a history layer, a successful run replaces its stored messages with the
|
||||
rewritten complete history in the returned session snapshot. Without this layer,
|
||||
compaction affects only the current run. Failed runs do not write a resumable
|
||||
success snapshot, so their history rewrites do not persist across runs.
|
||||
|
||||
## Resume a conversation
|
||||
|
||||
Successful runs return a terminal event with both final output and a resumable
|
||||
@@ -65,12 +83,13 @@ terminal snapshot resumable. Keep that default for normal memory flows.
|
||||
|
||||
Dify Agent handles memory conservatively:
|
||||
|
||||
1. Current system prompts are rendered into temporary `message_history` before
|
||||
stored history.
|
||||
2. Stored history is then sent to the model.
|
||||
3. Current user prompts are sent after the stored history.
|
||||
4. Only newly produced pydantic-ai messages are appended after a successful run.
|
||||
5. Current system prompts are not persisted into the history layer.
|
||||
1. Current system prompts are passed as run-level pydantic-ai instructions.
|
||||
2. Stored history is sent to the model before the current user prompt.
|
||||
3. When the LLM layer includes `context_window_tokens`, Harness may rewrite
|
||||
over-target history immediately before a model request as described above.
|
||||
4. After a successful run, the complete possibly compacted history is written
|
||||
back to the layer.
|
||||
5. Run-level system instructions are removed before history is persisted.
|
||||
6. Failed runs emit `run_failed` and do not return a success snapshot to resume.
|
||||
|
||||
## Persist snapshots outside the client process
|
||||
|
||||
@@ -15,6 +15,7 @@ because that layer supplies the caller identity required by the API gateway.
|
||||
| `model_provider` | `str` | Provider name inside `plugin_id`. Use the value of `DIFY_AGENT_PROVIDER` from `dify-agent/.env`. |
|
||||
| `model` | `str` | Model name. Use the value of `DIFY_AGENT_MODEL_NAME` from `dify-agent/.env`. |
|
||||
| `model_settings` | `ModelSettings \| None` | Optional pydantic-ai model settings. |
|
||||
| `context_window_tokens` | `int \| None` | Positive effective context-window capability metadata. Enables window-based compaction when present; omission disables it. |
|
||||
|
||||
The plugin LLM layer type id is `dify.plugin.llm`.
|
||||
|
||||
@@ -48,6 +49,32 @@ dependency field named `execution_context` to the composition layer named
|
||||
Set `MODEL_PROVIDER` and `MODEL_NAME` to the same values as
|
||||
`DIFY_AGENT_PROVIDER` and `DIFY_AGENT_MODEL_NAME` in `dify-agent/.env`.
|
||||
|
||||
## Context compaction
|
||||
|
||||
Dify product request builders resolve `context_window_tokens` from the selected
|
||||
model plugin schema using the current tenant and user credentials. A client that
|
||||
constructs `DifyPluginLLMLayerConfig` directly is responsible for supplying an
|
||||
accurate positive value. The field is model capability metadata: Dify Agent does
|
||||
not forward it as a Provider parameter or merge it into `model_settings`.
|
||||
|
||||
For a known window, Dify Agent computes the Harness compaction target as:
|
||||
|
||||
```text
|
||||
min(floor(context_window_tokens * 0.8), context_window_tokens - max_tokens)
|
||||
```
|
||||
|
||||
The second term applies only when `model_settings.max_tokens` is positive. A
|
||||
non-positive target rejects the run before model invocation. Immediately before
|
||||
model requests, Harness estimates the message history and rewrites it when it is
|
||||
over target: it first clears old tool results while retaining the three most
|
||||
recent tool-call/result pairs and their inputs; if still over target, the current
|
||||
model incrementally summarizes older history while retaining the latest twenty
|
||||
messages and the first user message.
|
||||
|
||||
Compaction affects later runs only when the composition has a
|
||||
[history layer](../history-layer/index.md) and a successful run writes the
|
||||
rewritten history into its session snapshot.
|
||||
|
||||
## Complete minimal model composition
|
||||
|
||||
Most runs include a prompt, execution-context layer, and LLM layer:
|
||||
@@ -106,3 +133,5 @@ composition = RunComposition(
|
||||
calls. The shared execution-context layer carries the Dify caller context.
|
||||
- Model credentials are never accepted from the Agent request. Dify API resolves
|
||||
the tenant's current provider configuration and owns quota accounting.
|
||||
- Omitting `context_window_tokens` disables window-based compaction. It does not
|
||||
limit or otherwise change the Provider's own context-window enforcement.
|
||||
|
||||
@@ -68,5 +68,4 @@ prompt_layer = RunLayerSpec(
|
||||
- Prompt layer names are not reserved by the runtime, but `prompt` is the
|
||||
recommended conventional name.
|
||||
- When a [history layer](../history-layer/index.md) is present, current system
|
||||
prompts are sent as a temporary prefix before stored history and are not saved
|
||||
into memory.
|
||||
prompts are passed as run-level instructions and are not saved into memory.
|
||||
|
||||
@@ -10,12 +10,10 @@ the repository root with:
|
||||
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
|
||||
|
||||
@@ -113,20 +111,8 @@ async def main() -> None:
|
||||
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
|
||||
for message in result.all_messages():
|
||||
print(message)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -78,7 +78,7 @@ async def main() -> None:
|
||||
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())
|
||||
print(run.usage)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -8,7 +8,8 @@ dependencies = [
|
||||
"httpx==0.28.1",
|
||||
"httpx2>=2.5.0,<3.0.0",
|
||||
"pydantic>=2.12.5,<2.13",
|
||||
"pydantic-ai-slim>=1.106.0,<2.0.0",
|
||||
"pydantic-ai-harness>=0.20.0,<0.21.0",
|
||||
"pydantic-ai-slim>=2.30.0,<3.0.0",
|
||||
"typing-extensions>=4.12.2,<5.0.0",
|
||||
]
|
||||
|
||||
@@ -24,7 +25,7 @@ server = [
|
||||
"jsonschema>=4.23.0,<5.0.0",
|
||||
"jwcrypto>=1.5.6,<2",
|
||||
"logfire[fastapi,httpx,redis]>=4.37.0,<5.0.0",
|
||||
"pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0",
|
||||
"pydantic-ai-slim[anthropic,google,openai]>=2.30.0,<3.0.0",
|
||||
"pydantic-settings>=2.12.0,<3.0.0",
|
||||
"redis>=7.4.0,<8.0.0",
|
||||
"uvicorn[standard]==0.46.0",
|
||||
|
||||
@@ -51,11 +51,13 @@ from pydantic_ai.messages import (
|
||||
ModelResponseStreamEvent,
|
||||
MultiModalContent,
|
||||
RetryPromptPart,
|
||||
SpeechPart,
|
||||
SystemPromptPart,
|
||||
TextContent,
|
||||
TextPart,
|
||||
ThinkingPart,
|
||||
ToolCallPart,
|
||||
ToolAvailabilityDeltaPart,
|
||||
ToolReturnPart,
|
||||
UploadedFile,
|
||||
UserContent,
|
||||
@@ -333,6 +335,8 @@ def _map_model_request_to_prompt_messages(message: ModelRequest) -> list[PromptM
|
||||
name=part.tool_name,
|
||||
)
|
||||
)
|
||||
elif isinstance(part, SpeechPart | ToolAvailabilityDeltaPart):
|
||||
raise UnexpectedModelBehavior(f"Unsupported request part for daemon adapter: {type(part).__name__}")
|
||||
else:
|
||||
assert_never(part)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import ClassVar
|
||||
from typing import ClassVar, cast
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError
|
||||
@@ -111,9 +111,9 @@ class DifyCoreToolsClient:
|
||||
request_payload = _DifyCoreToolsInvokeRequest(
|
||||
caller=_DifyCoreToolsCaller(
|
||||
tenant_id=execution_context.tenant_id,
|
||||
user_id=execution_context.user_id,
|
||||
user_from=execution_context.user_from,
|
||||
app_id=execution_context.app_id,
|
||||
user_id=cast(str, execution_context.user_id),
|
||||
user_from=cast(str, execution_context.user_from),
|
||||
app_id=cast(str, execution_context.app_id),
|
||||
invoke_from=execution_context.invoke_from,
|
||||
conversation_id=execution_context.conversation_id,
|
||||
workflow_id=execution_context.workflow_id,
|
||||
|
||||
@@ -11,9 +11,10 @@ from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar
|
||||
from typing import ClassVar, cast
|
||||
|
||||
import httpx
|
||||
from pydantic import JsonValue
|
||||
from pydantic_ai import RunContext, Tool
|
||||
from pydantic_ai.tools import ToolDefinition
|
||||
from typing_extensions import Self, override
|
||||
@@ -109,7 +110,7 @@ class DifyCoreToolsLayer(PlainLayer[DifyCoreToolsDeps, DifyCoreToolsLayerConfig]
|
||||
response = await client.invoke(
|
||||
execution_context=execution_context,
|
||||
tool_config=tool_config,
|
||||
tool_parameters=tool_arguments,
|
||||
tool_parameters=cast(dict[str, JsonValue], tool_arguments),
|
||||
)
|
||||
return response.observation
|
||||
except DifyCoreToolsClientConfigurationError:
|
||||
|
||||
@@ -109,6 +109,7 @@ class DifyPluginLLMLayerConfig(LayerConfig):
|
||||
model_provider: str
|
||||
model: str
|
||||
model_settings: ModelSettings | None = None
|
||||
context_window_tokens: int | None = Field(default=None, gt=0)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ 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. Tool values
|
||||
arriving here are already transformed by Agenton's
|
||||
``PYDANTIC_AI_TRANSFORMERS`` preset, while Dify system prompts are rendered into
|
||||
temporary ``message_history`` before the call reaches this helper. The caller
|
||||
also passes the already resolved ``output_type`` so legacy text output and the
|
||||
``PYDANTIC_AI_TRANSFORMERS`` preset. The runner passes Dify system prompts as
|
||||
run-level instructions and the context compaction capability directly to
|
||||
``Agent.run``. The caller also passes the already resolved ``output_type`` so legacy text output and the
|
||||
optional JSON Schema output layer share the same ``Agent`` construction path.
|
||||
"""
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Build the standard Dify Agent context-compaction capability.
|
||||
|
||||
``TieredCompaction`` owns the target-budget check and invokes child compactors
|
||||
without evaluating their individual triggers. Each child constructor requires
|
||||
at least one configured trigger (``max_messages``, ``max_tokens``, or
|
||||
``max_fraction``) and validates the selected trigger. Dify uses the otherwise
|
||||
unused ``max_tokens=1`` values below solely to satisfy that validation; they are
|
||||
not one-token Dify policy thresholds.
|
||||
"""
|
||||
|
||||
from pydantic_ai.settings import ModelSettings
|
||||
from pydantic_ai_harness.compaction import ClearToolResults, SummarizingCompaction, TieredCompaction
|
||||
|
||||
|
||||
def build_compaction_capability(
|
||||
*,
|
||||
context_window_tokens: int | None,
|
||||
model_settings: ModelSettings | None,
|
||||
) -> TieredCompaction[None] | None:
|
||||
"""Build compaction for the effective model window, or disable it when unknown."""
|
||||
if context_window_tokens is None:
|
||||
return None
|
||||
|
||||
input_budget = context_window_tokens * 4 // 5
|
||||
max_tokens = model_settings.get("max_tokens") if model_settings is not None else None
|
||||
if max_tokens is not None and max_tokens > 0:
|
||||
input_budget = min(input_budget, context_window_tokens - max_tokens)
|
||||
if input_budget <= 0:
|
||||
raise ValueError("Model max_tokens must leave a positive input context budget.")
|
||||
|
||||
return TieredCompaction(
|
||||
tiers=[
|
||||
ClearToolResults(max_tokens=1, keep_pairs=3, clear_tool_inputs=False),
|
||||
SummarizingCompaction(
|
||||
max_tokens=1,
|
||||
keep_messages=20,
|
||||
preserve_first_user_message=True,
|
||||
incremental=True,
|
||||
),
|
||||
],
|
||||
target_tokens=input_budget,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["build_compaction_capability"]
|
||||
@@ -1,24 +1,19 @@
|
||||
"""Helpers for optional Dify Agent history-layer integration.
|
||||
|
||||
Dify Agent keeps pydantic-ai conversation history as an optional Agenton layer
|
||||
named ``history``. The runner always injects the current Dify system prompt via
|
||||
temporary ``message_history`` instead of ``Agent.system_prompt(...)`` so the
|
||||
model sees ``current system prompt -> stored history -> current user prompt``
|
||||
even when persisted history is present. Only zero-argument system prompt
|
||||
callables are supported here because the prompts are rendered outside
|
||||
pydantic-ai's normal run context; this matches Dify's current plain-prompt
|
||||
compositions and fails fast for unsupported context-dependent prompt shapes.
|
||||
named ``history``. Current system instructions belong to each run and are never
|
||||
stored; successful runs replace the layer with Pydantic AI's complete, possibly
|
||||
compacted history.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import Protocol, cast
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import replace
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic_ai.messages import ModelMessage, ModelRequest, SystemPromptPart
|
||||
from pydantic_ai.messages import ModelMessage, ModelRequest
|
||||
|
||||
from agenton.layers.types import PydanticAIPrompt
|
||||
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, PydanticAIHistoryLayer
|
||||
from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID
|
||||
from dify_agent.protocol.schemas import RunComposition
|
||||
@@ -68,66 +63,22 @@ def get_history_layer(run: SupportsHistoryLayerLookup) -> PydanticAIHistoryLayer
|
||||
return None
|
||||
|
||||
|
||||
async def build_run_message_history(
|
||||
*,
|
||||
system_prompts: Sequence[PydanticAIPrompt[object]],
|
||||
stored_history: Sequence[ModelMessage],
|
||||
) -> list[ModelMessage] | None:
|
||||
"""Build temporary pydantic-ai history for one Dify Agent loop.
|
||||
|
||||
Current system prompts are rendered first into one transient
|
||||
``ModelRequest`` prefix, followed by any already stored history messages.
|
||||
When both inputs are empty, the helper returns ``None`` so callers can omit
|
||||
the ``message_history`` argument entirely and preserve pydantic-ai's empty
|
||||
history behavior.
|
||||
"""
|
||||
rendered_system_parts: list[SystemPromptPart] = []
|
||||
for prompt in system_prompts:
|
||||
prompt_text = await _render_system_prompt(prompt)
|
||||
if prompt_text is None:
|
||||
continue
|
||||
rendered_system_parts.append(SystemPromptPart(content=prompt_text))
|
||||
|
||||
message_history: list[ModelMessage] = []
|
||||
if rendered_system_parts:
|
||||
message_history.append(ModelRequest(parts=rendered_system_parts))
|
||||
message_history.extend(stored_history)
|
||||
return message_history or None
|
||||
|
||||
|
||||
def append_successful_run_history(
|
||||
def replace_successful_run_history(
|
||||
history_layer: PydanticAIHistoryLayer | None,
|
||||
new_messages: Sequence[ModelMessage],
|
||||
messages: Sequence[ModelMessage],
|
||||
) -> None:
|
||||
"""Append only newly produced pydantic-ai messages after successful runs."""
|
||||
if history_layer is None or not new_messages:
|
||||
"""Persist a successful run's complete history without transient instructions."""
|
||||
if history_layer is None:
|
||||
return
|
||||
history_layer.append_messages(new_messages)
|
||||
|
||||
|
||||
async def _render_system_prompt(prompt: PydanticAIPrompt[object]) -> str | None:
|
||||
signature = inspect.signature(prompt)
|
||||
if signature.parameters:
|
||||
raise ValueError(
|
||||
"Dify Agent runtime currently supports only zero-argument system prompts when rendering temporary "
|
||||
"message history."
|
||||
)
|
||||
|
||||
prompt_without_context = cast(Callable[[], str | None | Awaitable[str | None]], prompt)
|
||||
prompt_value = prompt_without_context()
|
||||
if inspect.isawaitable(prompt_value):
|
||||
prompt_value = await prompt_value
|
||||
if prompt_value is None:
|
||||
return None
|
||||
if not isinstance(prompt_value, str):
|
||||
raise TypeError(f"System prompt callables must return str | None, got '{type(prompt_value).__name__}'.")
|
||||
return prompt_value
|
||||
persistent_messages = [
|
||||
replace(message, instructions=None) if isinstance(message, ModelRequest) else message for message in messages
|
||||
]
|
||||
history_layer.replace_messages(persistent_messages)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SupportsHistoryLayerLookup",
|
||||
"append_successful_run_history",
|
||||
"build_run_message_history",
|
||||
"get_history_layer",
|
||||
"replace_successful_run_history",
|
||||
"validate_history_layer_composition",
|
||||
]
|
||||
|
||||
@@ -5,15 +5,16 @@ Agenton's graph/config split and executes one model run after the ``on_exit``
|
||||
policy is validated:
|
||||
|
||||
- model runs: enter a fresh ``CompositorRun`` (or resume one from a snapshot),
|
||||
render the current Dify system prompts into temporary ``message_history``, run
|
||||
pass the current Dify system prompts as run-level instructions, run
|
||||
pydantic-ai with either the current ``run.user_prompts`` or deferred external
|
||||
tool results, emit raw stream events with agent-message delta annotations, apply
|
||||
request-level ``on_exit`` signals, and publish a terminal success or failure event;
|
||||
The Pydantic AI model is resolved from the active Agenton layer named by
|
||||
``DIFY_AGENT_MODEL_LAYER_ID``. An optional history layer contributes stored
|
||||
message history only through session state; successful model runs append only
|
||||
``result.new_messages()`` back into that layer so current system prompts are not
|
||||
persisted. An optional structured output layer named by
|
||||
message history only through session state; successful model runs replace that
|
||||
state with ``result.all_messages()`` after transient instructions are cleared so
|
||||
compaction rewrites persist without saving current system prompts. An optional
|
||||
structured output layer named by
|
||||
``DIFY_AGENT_OUTPUT_LAYER_ID`` is read after entry and resolved into an output
|
||||
contract whose type both exposes the output schema to the model and performs
|
||||
runtime JSON Schema validation through custom Pydantic hooks. When the ask-human
|
||||
@@ -61,6 +62,7 @@ from dify_agent.protocol.schemas import (
|
||||
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.compaction import build_compaction_capability
|
||||
from dify_agent.runtime_backend import BindingLostError
|
||||
from dify_agent.runtime.event_sink import (
|
||||
RunEventSink,
|
||||
@@ -70,9 +72,8 @@ from dify_agent.runtime.event_sink import (
|
||||
emit_run_succeeded,
|
||||
)
|
||||
from dify_agent.runtime.history import (
|
||||
append_successful_run_history,
|
||||
build_run_message_history,
|
||||
get_history_layer,
|
||||
replace_successful_run_history,
|
||||
validate_history_layer_composition,
|
||||
)
|
||||
from dify_agent.runtime.layer_exit_signals import apply_layer_exit_signals, validate_layer_exit_signals
|
||||
@@ -310,12 +311,13 @@ class AgentRunRunner:
|
||||
try:
|
||||
output_contract = resolve_run_output_contract(run)
|
||||
history_layer = get_history_layer(run)
|
||||
message_history = await build_run_message_history(
|
||||
system_prompts=run.prompts,
|
||||
stored_history=history_layer.message_history if history_layer is not None else (),
|
||||
)
|
||||
message_history = history_layer.message_history if history_layer is not None else None
|
||||
ask_human_layer = get_ask_human_layer(run)
|
||||
llm_layer = run.get_layer(DIFY_AGENT_MODEL_LAYER_ID, DifyPluginLLMLayer)
|
||||
compaction = build_compaction_capability(
|
||||
context_window_tokens=llm_layer.config.context_window_tokens,
|
||||
model_settings=llm_layer.config.model_settings,
|
||||
)
|
||||
model = llm_layer.get_model(
|
||||
http_client=self.dify_api_http_client,
|
||||
agent_run_id=self.run_id,
|
||||
@@ -346,6 +348,8 @@ class AgentRunRunner:
|
||||
message_history=message_history,
|
||||
deferred_tool_results=deferred_tool_results,
|
||||
event_stream_handler=handle_events,
|
||||
instructions=run.prompts or None,
|
||||
capabilities=[compaction] if compaction is not None else None,
|
||||
usage_limits=UsageLimits(request_limit=_MAX_AGENT_STEPS_PER_RUN),
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
@@ -356,7 +360,7 @@ class AgentRunRunner:
|
||||
) from exc
|
||||
complete_usage = model.accumulated_usage if isinstance(model, _HasAccumulatedUsage) else None
|
||||
usage = _serialize_agent_usage(complete_usage if complete_usage is not None else _result_usage(result))
|
||||
append_successful_run_history(history_layer, result.new_messages())
|
||||
replace_successful_run_history(history_layer, result.all_messages())
|
||||
if isinstance(result.output, DeferredToolRequests):
|
||||
if ask_human_layer is None:
|
||||
raise AgentRunValidationError(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import unittest
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -6,16 +7,19 @@ from typing import cast
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from graphon.model_runtime.entities.message_entities import TextPromptMessageContent
|
||||
from pydantic_ai.exceptions import ModelHTTPError, UserError
|
||||
from pydantic_ai.exceptions import ModelHTTPError, UnexpectedModelBehavior, UserError
|
||||
from pydantic_ai.messages import (
|
||||
InstructionPart,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
RetryPromptPart,
|
||||
SpeechPart,
|
||||
SystemPromptPart,
|
||||
TextPart,
|
||||
ThinkingPart,
|
||||
ToolAvailabilityDeltaPart,
|
||||
ToolCallPart,
|
||||
ToolReturnPart,
|
||||
UserPromptPart,
|
||||
@@ -617,7 +621,7 @@ class DifyLLMAdapterModelTests(unittest.IsolatedAsyncioTestCase):
|
||||
content="",
|
||||
tool_calls=[
|
||||
AssistantPromptMessage.ToolCall(
|
||||
id=None,
|
||||
id=None, # pyright: ignore[reportArgumentType]
|
||||
type="function",
|
||||
function=AssistantPromptMessage.ToolCall.ToolCallFunction(
|
||||
name="shell_run",
|
||||
@@ -636,7 +640,7 @@ class DifyLLMAdapterModelTests(unittest.IsolatedAsyncioTestCase):
|
||||
content="",
|
||||
tool_calls=[
|
||||
AssistantPromptMessage.ToolCall(
|
||||
id=None,
|
||||
id=None, # pyright: ignore[reportArgumentType]
|
||||
type="function",
|
||||
function=AssistantPromptMessage.ToolCall.ToolCallFunction(
|
||||
name="shell_run",
|
||||
@@ -762,3 +766,42 @@ class DifyLLMAdapterModelTests(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(str(context.exception), "missing endpoint config")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"part",
|
||||
[
|
||||
pytest.param(SpeechPart(speaker="user", transcript="hello"), id="speech"),
|
||||
pytest.param(ToolAvailabilityDeltaPart(tools_added=["lookup"]), id="tool-availability-delta"),
|
||||
],
|
||||
)
|
||||
def test_request_rejects_unsupported_pydantic_ai_request_parts(
|
||||
part: SpeechPart | ToolAvailabilityDeltaPart,
|
||||
) -> None:
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient(trust_env=False) as http_client:
|
||||
provider = DifyApiLLMProvider(
|
||||
plugin_id="langgenius/openai",
|
||||
inner_api_url="http://dify-api",
|
||||
inner_api_key="inner-secret",
|
||||
execution_context=DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-123",
|
||||
user_from="account",
|
||||
app_id="app-1",
|
||||
agent_mode="single_step",
|
||||
invoke_from="debugger",
|
||||
),
|
||||
agent_run_id="run-1",
|
||||
http_client=http_client,
|
||||
)
|
||||
adapter = DifyLLMAdapterModel("demo-model", provider, model_provider="openai")
|
||||
|
||||
with pytest.raises(UnexpectedModelBehavior, match=type(part).__name__):
|
||||
_ = await adapter.request(
|
||||
[ModelRequest(parts=[part])],
|
||||
model_settings=None,
|
||||
model_request_parameters=ModelRequestParameters(),
|
||||
)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@@ -155,7 +155,7 @@ def test_sse_decoder_accepts_function_tool_result_part_alias(monkeypatch: pytest
|
||||
assert event is not None
|
||||
assert event.type == "pydantic_ai_event"
|
||||
assert event.data.event_kind == "function_tool_result"
|
||||
assert event.data.result.tool_name == "shell_run"
|
||||
assert event.data.part.tool_name == "shell_run"
|
||||
|
||||
|
||||
def test_function_tool_result_payload_normalization_supports_old_part_schema(
|
||||
|
||||
@@ -18,8 +18,8 @@ def _install_graphon_stubs() -> None:
|
||||
llm_entities_module = types.ModuleType("graphon.model_runtime.entities.llm_entities")
|
||||
message_entities_module = types.ModuleType("graphon.model_runtime.entities.message_entities")
|
||||
|
||||
llm_entities_module.LLMResultChunk = type("LLMResultChunk", (), {})
|
||||
llm_entities_module.LLMUsage = type("LLMUsage", (), {})
|
||||
setattr(llm_entities_module, "LLMResultChunk", type("LLMResultChunk", (), {}))
|
||||
setattr(llm_entities_module, "LLMUsage", type("LLMUsage", (), {}))
|
||||
|
||||
for name in (
|
||||
"AssistantPromptMessage",
|
||||
@@ -43,10 +43,10 @@ def _install_graphon_stubs() -> None:
|
||||
sys.modules["graphon.model_runtime.entities.llm_entities"] = llm_entities_module
|
||||
sys.modules["graphon.model_runtime.entities.message_entities"] = message_entities_module
|
||||
|
||||
graphon_module.model_runtime = model_runtime_module
|
||||
model_runtime_module.entities = entities_module
|
||||
entities_module.llm_entities = llm_entities_module
|
||||
entities_module.message_entities = message_entities_module
|
||||
setattr(graphon_module, "model_runtime", model_runtime_module)
|
||||
setattr(model_runtime_module, "entities", entities_module)
|
||||
setattr(entities_module, "llm_entities", llm_entities_module)
|
||||
setattr(entities_module, "message_entities", message_entities_module)
|
||||
|
||||
|
||||
_install_graphon_stubs()
|
||||
|
||||
@@ -44,6 +44,7 @@ def test_dify_plugin_llm_config_discards_legacy_credentials() -> None:
|
||||
"model": "gpt-4o-mini",
|
||||
"credentials": {"api_key": "secret", "nested": {"legacy": True}},
|
||||
"model_settings": {"temperature": 0.2, "max_tokens": 64},
|
||||
"context_window_tokens": 128_000,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -52,6 +53,7 @@ def test_dify_plugin_llm_config_discards_legacy_credentials() -> None:
|
||||
assert not hasattr(config, "credentials")
|
||||
assert "credentials" not in config.model_dump(mode="json")
|
||||
assert config.model_settings == {"temperature": 0.2, "max_tokens": 64}
|
||||
assert config.context_window_tokens == 128_000
|
||||
|
||||
|
||||
def test_dify_plugin_llm_config_rejects_old_provider_field() -> None:
|
||||
@@ -65,6 +67,16 @@ def test_dify_plugin_llm_config_rejects_old_provider_field() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_dify_plugin_llm_config_rejects_non_positive_context_window() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_ = DifyPluginLLMLayerConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="gpt-4o-mini",
|
||||
context_window_tokens=0,
|
||||
)
|
||||
|
||||
|
||||
def test_dify_plugin_tools_layer_config_accepts_prepared_parameters_and_schema() -> None:
|
||||
runtime_value: DifyPluginToolValue = {"locale": "en-US", "max_results": 5}
|
||||
credential_type: DifyPluginToolCredentialType = "api-key"
|
||||
|
||||
@@ -848,9 +848,9 @@ def test_plugin_tool_file_context_uploads_sandbox_path_and_resolves_signed_url()
|
||||
async def scenario() -> None:
|
||||
shell = FakeShell()
|
||||
context = _PluginToolFileContext(
|
||||
file_client=FakeFileClient(), # type: ignore[arg-type]
|
||||
file_client=FakeFileClient(), # pyright: ignore[reportArgumentType]
|
||||
execution_context=_execution_context_config(),
|
||||
shell=shell, # type: ignore[arg-type]
|
||||
shell=shell, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
result = await context.to_plugin_file_parameter("outputs/report.pdf")
|
||||
|
||||
|
||||
@@ -99,8 +99,10 @@ def test_knowledge_layer_exposes_one_set_scoped_tool_definition() -> None:
|
||||
tool_def = await tool.prepare_tool_def(None) # pyright: ignore[reportArgumentType]
|
||||
assert isinstance(tool, Tool)
|
||||
assert tool.name == "knowledge_base_search"
|
||||
assert tool.description is not None
|
||||
assert "Pick one configured set_name" in tool.description
|
||||
assert tool_def is not None
|
||||
assert tool_def.description is not None
|
||||
assert "Pick one configured set_name" in tool_def.description
|
||||
assert tool_def.parameters_json_schema == {
|
||||
"type": "object",
|
||||
@@ -140,7 +142,8 @@ def test_knowledge_layer_rejects_blank_query_locally() -> None:
|
||||
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
|
||||
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
|
||||
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
|
||||
{"set_name": "Support KB", "query": " "}, None
|
||||
{"set_name": "Support KB", "query": " "},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
assert result == BLANK_QUERY_OBSERVATION
|
||||
|
||||
@@ -313,7 +316,8 @@ def test_knowledge_layer_formats_results_and_truncates_observation() -> None:
|
||||
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
|
||||
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
|
||||
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
|
||||
{"set_name": "Support KB", "query": "reset"}, None
|
||||
{"set_name": "Support KB", "query": "reset"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
assert result.startswith("Knowledge base search results:\n1. Title: Guide")
|
||||
assert "Dataset: Docs" in result
|
||||
@@ -345,7 +349,8 @@ def test_knowledge_layer_returns_no_results_observation() -> None:
|
||||
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
|
||||
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
|
||||
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
|
||||
{"set_name": "Support KB", "query": "reset"}, None
|
||||
{"set_name": "Support KB", "query": "reset"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
assert result == NO_RESULTS_OBSERVATION
|
||||
|
||||
@@ -374,7 +379,8 @@ def test_knowledge_layer_converts_retryable_failures_into_observation() -> None:
|
||||
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
|
||||
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
|
||||
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
|
||||
{"set_name": "Support KB", "query": "reset"}, None
|
||||
{"set_name": "Support KB", "query": "reset"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
assert result == TEMPORARY_UNAVAILABLE_OBSERVATION
|
||||
|
||||
@@ -409,7 +415,8 @@ def test_knowledge_layer_converts_retryable_transport_failures_into_observation(
|
||||
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
|
||||
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
|
||||
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
|
||||
{"set_name": "Support KB", "query": "reset"}, None
|
||||
{"set_name": "Support KB", "query": "reset"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
assert result == TEMPORARY_UNAVAILABLE_OBSERVATION
|
||||
|
||||
@@ -439,7 +446,8 @@ def test_knowledge_layer_raises_non_retryable_client_errors() -> None:
|
||||
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
|
||||
with pytest.raises(DifyKnowledgeBaseClientError) as exc_info:
|
||||
await tool.function_schema.call( # pyright: ignore[reportArgumentType]
|
||||
{"set_name": "Support KB", "query": "reset"}, None
|
||||
{"set_name": "Support KB", "query": "reset"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
@@ -467,7 +475,8 @@ def test_knowledge_layer_raises_for_malformed_success_responses() -> None:
|
||||
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
|
||||
with pytest.raises(DifyKnowledgeBaseClientError) as exc_info:
|
||||
await tool.function_schema.call( # pyright: ignore[reportArgumentType]
|
||||
{"set_name": "Support KB", "query": "reset"}, None
|
||||
{"set_name": "Support KB", "query": "reset"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
assert exc_info.value.error_code == "invalid_response"
|
||||
assert exc_info.value.retryable is False
|
||||
@@ -537,7 +546,8 @@ def test_knowledge_layer_sends_execution_context_and_static_config_to_inner_api(
|
||||
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
|
||||
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
|
||||
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
|
||||
{"set_name": "Support KB", "query": "reset"}, None
|
||||
{"set_name": "Support KB", "query": "reset"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
assert result == NO_RESULTS_OBSERVATION
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import pytest
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.messages import (
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
SystemPromptPart,
|
||||
TextPart,
|
||||
ToolCallPart,
|
||||
ToolReturnPart,
|
||||
UserPromptPart,
|
||||
)
|
||||
from pydantic_ai.models.test import TestModel
|
||||
from pydantic_ai_harness.compaction import ClearToolResults, SummarizingCompaction, TieredCompaction
|
||||
|
||||
from dify_agent.runtime.compaction import build_compaction_capability
|
||||
|
||||
|
||||
def test_build_compaction_capability_uses_effective_input_budget_and_standard_tiers() -> None:
|
||||
capability = build_compaction_capability(
|
||||
context_window_tokens=10_000,
|
||||
model_settings={"max_tokens": 3_000},
|
||||
)
|
||||
|
||||
assert isinstance(capability, TieredCompaction)
|
||||
assert capability.target_tokens == 7_000
|
||||
assert len(capability.tiers) == 2
|
||||
assert isinstance(capability.tiers[0], ClearToolResults)
|
||||
assert capability.tiers[0].keep_pairs == 3
|
||||
assert capability.tiers[0].clear_tool_inputs is False
|
||||
assert isinstance(capability.tiers[1], SummarizingCompaction)
|
||||
assert capability.tiers[1].model is None
|
||||
assert capability.tiers[1].keep_messages == 20
|
||||
assert capability.tiers[1].preserve_first_user_message is True
|
||||
assert capability.tiers[1].incremental is True
|
||||
|
||||
|
||||
def test_build_compaction_capability_uses_default_budget_and_handles_unknown_window() -> None:
|
||||
capability = build_compaction_capability(context_window_tokens=10_001, model_settings=None)
|
||||
|
||||
assert isinstance(capability, TieredCompaction)
|
||||
assert capability.target_tokens == 8_000
|
||||
assert build_compaction_capability(context_window_tokens=None, model_settings=None) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"max_tokens",
|
||||
[
|
||||
pytest.param(1_000, id="default-budget-wins"),
|
||||
pytest.param(0, id="zero-is-ignored"),
|
||||
pytest.param(-1, id="negative-is-ignored"),
|
||||
],
|
||||
)
|
||||
def test_build_compaction_capability_uses_default_budget_when_output_reservation_is_smaller(
|
||||
max_tokens: int,
|
||||
) -> None:
|
||||
capability = build_compaction_capability(
|
||||
context_window_tokens=10_000,
|
||||
model_settings={"max_tokens": max_tokens},
|
||||
)
|
||||
|
||||
assert isinstance(capability, TieredCompaction)
|
||||
assert capability.target_tokens == 8_000
|
||||
|
||||
|
||||
def test_build_compaction_capability_rejects_output_budget_that_consumes_window() -> None:
|
||||
with pytest.raises(ValueError, match="Model max_tokens must leave a positive input context budget"):
|
||||
_ = build_compaction_capability(
|
||||
context_window_tokens=1_000,
|
||||
model_settings={"max_tokens": 1_000},
|
||||
)
|
||||
|
||||
|
||||
def test_compaction_clears_only_tool_results_older_than_the_last_three_pairs() -> None:
|
||||
history: list[ModelRequest | ModelResponse] = []
|
||||
for index in range(4):
|
||||
tool_call_id = f"call-{index}"
|
||||
history.extend(
|
||||
[
|
||||
ModelResponse(parts=[ToolCallPart("lookup", {"query": index}, tool_call_id)]),
|
||||
ModelRequest(parts=[ToolReturnPart("lookup", "x" * 4_000, tool_call_id)]),
|
||||
]
|
||||
)
|
||||
|
||||
capability = build_compaction_capability(context_window_tokens=4_100, model_settings=None)
|
||||
assert capability is not None
|
||||
agent = Agent[None, str](TestModel(call_tools=[]), deps_type=type(None))
|
||||
result = agent.run_sync("next", message_history=history, capabilities=[capability])
|
||||
|
||||
tool_returns = [
|
||||
part
|
||||
for message in result.all_messages()
|
||||
if isinstance(message, ModelRequest)
|
||||
for part in message.parts
|
||||
if isinstance(part, ToolReturnPart)
|
||||
]
|
||||
assert [part.content for part in tool_returns] == ["[tool result cleared]", *("x" * 4_000 for _ in range(3))]
|
||||
|
||||
|
||||
def test_compaction_summary_is_present_in_full_run_history() -> None:
|
||||
history: list[ModelRequest | ModelResponse] = []
|
||||
for index in range(30):
|
||||
history.extend(
|
||||
[
|
||||
ModelRequest(parts=[UserPromptPart(f"user-{index}-" + "u" * 120)]),
|
||||
ModelResponse(parts=[TextPart(f"assistant-{index}-" + "a" * 120)], model_name="test"),
|
||||
]
|
||||
)
|
||||
|
||||
capability = build_compaction_capability(context_window_tokens=1_000, model_settings=None)
|
||||
assert capability is not None
|
||||
agent = Agent[None, str](
|
||||
TestModel(call_tools=[], custom_output_text="summary body"),
|
||||
deps_type=type(None),
|
||||
)
|
||||
result = agent.run_sync(
|
||||
"next",
|
||||
message_history=history,
|
||||
capabilities=[capability],
|
||||
)
|
||||
|
||||
messages = result.all_messages()
|
||||
assert len(messages) < len(history)
|
||||
assert isinstance(messages[0], ModelRequest)
|
||||
assert len(messages[0].parts) == 1
|
||||
assert isinstance(messages[0].parts[0], SystemPromptPart)
|
||||
assert messages[0].parts[0].content == "Summary of previous conversation:\n\nsummary body"
|
||||
assert any(
|
||||
isinstance(part, UserPromptPart) and str(part.content).startswith("user-0-")
|
||||
for message in messages
|
||||
if isinstance(message, ModelRequest)
|
||||
for part in message.parts
|
||||
)
|
||||
@@ -9,8 +9,8 @@ if "graphon.model_runtime.entities.llm_entities" not in sys.modules:
|
||||
llm_entities_module = types.ModuleType("graphon.model_runtime.entities.llm_entities")
|
||||
message_entities_module = types.ModuleType("graphon.model_runtime.entities.message_entities")
|
||||
|
||||
llm_entities_module.LLMResultChunk = type("LLMResultChunk", (), {})
|
||||
llm_entities_module.LLMUsage = type("LLMUsage", (), {})
|
||||
setattr(llm_entities_module, "LLMResultChunk", type("LLMResultChunk", (), {}))
|
||||
setattr(llm_entities_module, "LLMUsage", type("LLMUsage", (), {}))
|
||||
|
||||
for name in (
|
||||
"AssistantPromptMessage",
|
||||
@@ -34,10 +34,10 @@ if "graphon.model_runtime.entities.llm_entities" not in sys.modules:
|
||||
sys.modules["graphon.model_runtime.entities.llm_entities"] = llm_entities_module
|
||||
sys.modules["graphon.model_runtime.entities.message_entities"] = message_entities_module
|
||||
|
||||
graphon_module.model_runtime = model_runtime_module
|
||||
model_runtime_module.entities = entities_module
|
||||
entities_module.llm_entities = llm_entities_module
|
||||
entities_module.message_entities = message_entities_module
|
||||
setattr(graphon_module, "model_runtime", model_runtime_module)
|
||||
setattr(model_runtime_module, "entities", entities_module)
|
||||
setattr(entities_module, "llm_entities", llm_entities_module)
|
||||
setattr(entities_module, "message_entities", message_entities_module)
|
||||
|
||||
if "jsonschema" not in sys.modules:
|
||||
jsonschema_module = types.ModuleType("jsonschema")
|
||||
@@ -65,10 +65,10 @@ if "jsonschema" not in sys.modules:
|
||||
def _validator_for(schema):
|
||||
return _Validator
|
||||
|
||||
jsonschema_module.SchemaError = _SchemaError
|
||||
jsonschema_exceptions_module.ValidationError = _ValidationError
|
||||
jsonschema_protocols_module.Validator = _Validator
|
||||
jsonschema_validators_module.validator_for = _validator_for
|
||||
setattr(jsonschema_module, "SchemaError", _SchemaError)
|
||||
setattr(jsonschema_exceptions_module, "ValidationError", _ValidationError)
|
||||
setattr(jsonschema_protocols_module, "Validator", _Validator)
|
||||
setattr(jsonschema_validators_module, "validator_for", _validator_for)
|
||||
|
||||
sys.modules["jsonschema"] = jsonschema_module
|
||||
sys.modules["jsonschema.exceptions"] = jsonschema_exceptions_module
|
||||
@@ -95,8 +95,8 @@ class FakeProvider:
|
||||
|
||||
def _runtime_backend_profile() -> RuntimeBackendProfile:
|
||||
return RuntimeBackendProfile(
|
||||
home_snapshots=cast(HomeSnapshotBackend, FakeProvider()),
|
||||
execution_bindings=cast(ExecutionBindingBackend, FakeProvider()),
|
||||
home_snapshots=cast(HomeSnapshotBackend, cast(object, FakeProvider())),
|
||||
execution_bindings=cast(ExecutionBindingBackend, cast(object, FakeProvider())),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from pydantic_ai.models.test import TestModel
|
||||
from pydantic_ai.tools import DeferredToolRequests, DeferredToolResults
|
||||
from pydantic_ai.usage import UsageLimits
|
||||
from pydantic_ai.settings import ModelSettings
|
||||
from pydantic_ai_harness.compaction import TieredCompaction
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot, LayerProvider, LayerSessionSnapshot
|
||||
from agenton.layers import ExitIntent, LifecycleState
|
||||
@@ -218,7 +219,7 @@ def test_run_failed_error_payload_classifies_usage_limit() -> None:
|
||||
|
||||
message, error_type, reason = _run_failed_error_payload(exc)
|
||||
|
||||
assert message == "The next request would exceed the request_limit of 500"
|
||||
assert message.startswith("The next request would exceed the request_limit of 500")
|
||||
assert error_type is RunFailureType.AGENT_RUN_LIMIT_EXCEEDED
|
||||
assert reason is None
|
||||
|
||||
@@ -270,6 +271,8 @@ def _request(
|
||||
execution_context_layer_name: str = "execution_context",
|
||||
on_exit: LayerExitSignals | None = None,
|
||||
output_config: Mapping[str, object] | DifyOutputLayerConfig | None = None,
|
||||
model_settings: ModelSettings | None = None,
|
||||
context_window_tokens: int | None = None,
|
||||
) -> CreateRunRequest:
|
||||
layers = [
|
||||
RunLayerSpec(
|
||||
@@ -311,6 +314,8 @@ def _request(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="demo-model",
|
||||
model_settings=model_settings,
|
||||
context_window_tokens=context_window_tokens,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -427,11 +432,13 @@ class SequenceOutputTestModel(TestModel):
|
||||
|
||||
class RecordingTestModel(TestModel):
|
||||
seen_requests: list[list[ModelMessage]]
|
||||
seen_instructions: list[list[str]]
|
||||
failure: Exception | None
|
||||
|
||||
def __init__(self, *, custom_output_text: str = "done", failure: Exception | None = None) -> None:
|
||||
super().__init__(call_tools=[], custom_output_text=custom_output_text)
|
||||
self.seen_requests = []
|
||||
self.seen_instructions = []
|
||||
self.failure = failure
|
||||
|
||||
def _request(
|
||||
@@ -441,6 +448,7 @@ class RecordingTestModel(TestModel):
|
||||
model_request_parameters: ModelRequestParameters,
|
||||
) -> ModelResponse:
|
||||
self.seen_requests.append(list(messages))
|
||||
self.seen_instructions.append([part.content for part in model_request_parameters.instruction_parts or []])
|
||||
if self.failure is not None:
|
||||
raise self.failure
|
||||
return super()._request(messages, model_settings, model_request_parameters)
|
||||
@@ -489,14 +497,14 @@ def _flatten_message_parts(messages: list[ModelMessage]) -> list[object]:
|
||||
|
||||
class FakeAgentRunResult:
|
||||
output: object
|
||||
_new_messages: list[ModelMessage]
|
||||
_all_messages: list[ModelMessage]
|
||||
|
||||
def __init__(self, output: object, new_messages: list[ModelMessage]) -> None:
|
||||
def __init__(self, output: object, all_messages: list[ModelMessage]) -> None:
|
||||
self.output = output
|
||||
self._new_messages = new_messages
|
||||
self._all_messages = all_messages
|
||||
|
||||
def new_messages(self) -> list[ModelMessage]:
|
||||
return list(self._new_messages)
|
||||
def all_messages(self) -> list[ModelMessage]:
|
||||
return list(self._all_messages)
|
||||
|
||||
|
||||
def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -671,6 +679,95 @@ def test_runner_passes_explicit_step_limit_to_agent(monkeypatch: pytest.MonkeyPa
|
||||
assert sink.statuses["run-explicit-step-limit"] == "succeeded"
|
||||
|
||||
|
||||
def test_runner_passes_context_compaction(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient, agent_run_id: str):
|
||||
assert http_client.is_closed is False
|
||||
return TestModel(custom_output_text="unused") # pyright: ignore[reportReturnType]
|
||||
|
||||
class FakeAgent:
|
||||
async def run(self, *_args: object, **kwargs: object) -> FakeAgentRunResult:
|
||||
capabilities = cast(list[object], kwargs["capabilities"])
|
||||
assert len(capabilities) == 1
|
||||
capability = capabilities[0]
|
||||
assert isinstance(capability, TieredCompaction)
|
||||
assert capability.target_tokens == 7_000
|
||||
return FakeAgentRunResult("done", [])
|
||||
|
||||
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
|
||||
monkeypatch.setattr("dify_agent.runtime.runner.create_agent", lambda *_args, **_kwargs: FakeAgent())
|
||||
sink = InMemoryRunEventSink()
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
await AgentRunRunner(
|
||||
sink=sink,
|
||||
request=_request(
|
||||
model_settings={"max_tokens": 3_000},
|
||||
context_window_tokens=10_000,
|
||||
),
|
||||
run_id="run-compaction",
|
||||
plugin_daemon_http_client=client,
|
||||
dify_api_http_client=client,
|
||||
).run()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert sink.statuses["run-compaction"] == "succeeded"
|
||||
|
||||
|
||||
def test_runner_rejects_compaction_budget_before_model_resolution_or_invocation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
model_resolution_called = False
|
||||
agent_creation_called = False
|
||||
model_invocation_called = False
|
||||
|
||||
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient, agent_run_id: str):
|
||||
nonlocal model_resolution_called
|
||||
model_resolution_called = True
|
||||
return TestModel(custom_output_text="unused") # pyright: ignore[reportReturnType]
|
||||
|
||||
class FakeAgent:
|
||||
async def run(self, *_args: object, **_kwargs: object) -> FakeAgentRunResult:
|
||||
nonlocal model_invocation_called
|
||||
model_invocation_called = True
|
||||
return FakeAgentRunResult("unused", [])
|
||||
|
||||
def fake_create_agent(*_args: object, **_kwargs: object) -> FakeAgent:
|
||||
nonlocal agent_creation_called
|
||||
agent_creation_called = True
|
||||
return FakeAgent()
|
||||
|
||||
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
|
||||
monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent)
|
||||
sink = InMemoryRunEventSink()
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with pytest.raises(
|
||||
AgentRunValidationError,
|
||||
match="Model max_tokens must leave a positive input context budget",
|
||||
):
|
||||
await AgentRunRunner(
|
||||
sink=sink,
|
||||
request=_request(
|
||||
model_settings={"max_tokens": 1_000},
|
||||
context_window_tokens=1_000,
|
||||
),
|
||||
run_id="run-invalid-compaction-budget",
|
||||
plugin_daemon_http_client=client,
|
||||
dify_api_http_client=client,
|
||||
).run()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert model_resolution_called is False
|
||||
assert agent_creation_called is False
|
||||
assert model_invocation_called is False
|
||||
assert [event.type for event in sink.events["run-invalid-compaction-budget"]] == ["run_started", "run_failed"]
|
||||
assert sink.statuses["run-invalid-compaction-budget"] == "failed"
|
||||
|
||||
|
||||
def test_runner_timeout_excludes_tool_preparation_and_runtime_cleanup(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
shell_client = FakeRunnerShellctlClient()
|
||||
tools_prepared = False
|
||||
@@ -937,9 +1034,11 @@ def test_runner_resumes_with_deferred_tool_results_and_no_user_prompt(monkeypatc
|
||||
assert deferred_tool_results is not None
|
||||
submitted_result = cast(dict[str, object], deferred_tool_results.calls["tool-call-1"])
|
||||
assert submitted_result["status"] == "submitted"
|
||||
message_history = cast(list[ModelMessage], kwargs["message_history"])
|
||||
return FakeAgentRunResult(
|
||||
"done after human",
|
||||
[
|
||||
*message_history,
|
||||
ModelRequest(
|
||||
parts=[
|
||||
ToolReturnPart(
|
||||
@@ -1038,9 +1137,11 @@ def test_runner_can_emit_second_deferred_tool_call_after_resume(monkeypatch: pyt
|
||||
],
|
||||
)
|
||||
|
||||
message_history = cast(list[ModelMessage], kwargs["message_history"])
|
||||
return FakeAgentRunResult(
|
||||
DeferredToolRequests(calls=[second_pending_tool_call]),
|
||||
[
|
||||
*message_history,
|
||||
ModelRequest(
|
||||
parts=[
|
||||
ToolReturnPart(
|
||||
@@ -1311,7 +1412,7 @@ def test_runner_passes_dynamic_dify_plugin_tools_to_agent(monkeypatch: pytest.Mo
|
||||
class FakeResult:
|
||||
output: str = "done"
|
||||
|
||||
def new_messages(self) -> list[ModelMessage]:
|
||||
def all_messages(self) -> list[ModelMessage]:
|
||||
return []
|
||||
|
||||
class FakeAgent:
|
||||
@@ -1413,7 +1514,7 @@ def test_runner_passes_dynamic_dify_knowledge_tools_to_agent(monkeypatch: pytest
|
||||
class FakeResult:
|
||||
output: str = "done"
|
||||
|
||||
def new_messages(self) -> list[ModelMessage]:
|
||||
def all_messages(self) -> list[ModelMessage]:
|
||||
return []
|
||||
|
||||
class FakeAgent:
|
||||
@@ -1519,7 +1620,7 @@ def test_runner_passes_dynamic_dify_core_tools_to_agent(monkeypatch: pytest.Monk
|
||||
class FakeResult:
|
||||
output: str = "done"
|
||||
|
||||
def new_messages(self) -> list[ModelMessage]:
|
||||
def all_messages(self) -> list[ModelMessage]:
|
||||
return []
|
||||
|
||||
class FakeAgent:
|
||||
@@ -1965,7 +2066,9 @@ def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers(
|
||||
assert sink.statuses["run-shell-duplicate-tools"] == "failed"
|
||||
|
||||
|
||||
def test_runner_passes_temporary_system_prompt_prefix_without_history_layer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_runner_passes_system_prompt_as_run_instructions_without_history_layer(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
model = RecordingTestModel(custom_output_text="done")
|
||||
|
||||
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient, agent_run_id: str):
|
||||
@@ -1987,11 +2090,11 @@ def test_runner_passes_temporary_system_prompt_prefix_without_history_layer(monk
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert model.seen_instructions[0] == ["system"]
|
||||
request_parts = _flatten_message_parts(model.seen_requests[0])
|
||||
assert isinstance(request_parts[0], SystemPromptPart)
|
||||
assert request_parts[0].content == "system"
|
||||
assert isinstance(request_parts[1], UserPromptPart)
|
||||
assert request_parts[1].content == "current user"
|
||||
assert len(request_parts) == 1
|
||||
assert isinstance(request_parts[0], UserPromptPart)
|
||||
assert request_parts[0].content == "current user"
|
||||
terminal = sink.events["run-no-history"][-1]
|
||||
assert isinstance(terminal, RunSucceededEvent)
|
||||
assert [layer.name for layer in terminal.data.session_snapshot.layers] == [
|
||||
@@ -2001,7 +2104,7 @@ def test_runner_passes_temporary_system_prompt_prefix_without_history_layer(monk
|
||||
]
|
||||
|
||||
|
||||
def test_runner_prepends_current_system_prompt_to_stored_history_and_appends_only_new_messages(
|
||||
def test_runner_passes_stored_history_with_current_instructions_and_replaces_full_history(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
model = RecordingTestModel(custom_output_text="done")
|
||||
@@ -2031,15 +2134,14 @@ def test_runner_prepends_current_system_prompt_to_stored_history_and_appends_onl
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert model.seen_instructions[0] == ["system"]
|
||||
request_parts = _flatten_message_parts(model.seen_requests[0])
|
||||
assert isinstance(request_parts[0], SystemPromptPart)
|
||||
assert request_parts[0].content == "system"
|
||||
assert isinstance(request_parts[1], UserPromptPart)
|
||||
assert request_parts[1].content == "old user"
|
||||
assert isinstance(request_parts[2], TextPart)
|
||||
assert request_parts[2].content == "old assistant"
|
||||
assert isinstance(request_parts[3], UserPromptPart)
|
||||
assert request_parts[3].content == "current user"
|
||||
assert isinstance(request_parts[0], UserPromptPart)
|
||||
assert request_parts[0].content == "old user"
|
||||
assert isinstance(request_parts[1], TextPart)
|
||||
assert request_parts[1].content == "old assistant"
|
||||
assert isinstance(request_parts[2], UserPromptPart)
|
||||
assert request_parts[2].content == "current user"
|
||||
|
||||
terminal = sink.events["run-history"][-1]
|
||||
assert isinstance(terminal, RunSucceededEvent)
|
||||
@@ -2054,9 +2156,10 @@ def test_runner_prepends_current_system_prompt_to_stored_history_and_appends_onl
|
||||
assert isinstance(saved_history[3].parts[0], TextPart)
|
||||
assert saved_history[3].parts[0].content == "done"
|
||||
assert all(not any(isinstance(part, SystemPromptPart) for part in message.parts) for message in saved_history)
|
||||
assert all(not isinstance(message, ModelRequest) or message.instructions is None for message in saved_history)
|
||||
|
||||
|
||||
def test_runner_with_empty_history_layer_still_sends_system_prompt_and_saves_only_new_messages(
|
||||
def test_runner_with_empty_history_layer_uses_instructions_and_saves_full_history(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
model = RecordingTestModel(custom_output_text="done")
|
||||
@@ -2082,11 +2185,11 @@ def test_runner_with_empty_history_layer_still_sends_system_prompt_and_saves_onl
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert model.seen_instructions[0] == ["system"]
|
||||
request_parts = _flatten_message_parts(model.seen_requests[0])
|
||||
assert isinstance(request_parts[0], SystemPromptPart)
|
||||
assert request_parts[0].content == "system"
|
||||
assert isinstance(request_parts[1], UserPromptPart)
|
||||
assert request_parts[1].content == "current user"
|
||||
assert len(request_parts) == 1
|
||||
assert isinstance(request_parts[0], UserPromptPart)
|
||||
assert request_parts[0].content == "current user"
|
||||
|
||||
terminal = sink.events["run-empty-history"][-1]
|
||||
assert isinstance(terminal, RunSucceededEvent)
|
||||
@@ -2100,6 +2203,7 @@ def test_runner_with_empty_history_layer_still_sends_system_prompt_and_saves_onl
|
||||
assert isinstance(saved_history[1].parts[0], TextPart)
|
||||
assert saved_history[1].parts[0].content == "done"
|
||||
assert all(not any(isinstance(part, SystemPromptPart) for part in message.parts) for message in saved_history)
|
||||
assert all(not isinstance(message, ModelRequest) or message.instructions is None for message in saved_history)
|
||||
|
||||
|
||||
def test_runner_failure_with_history_layer_emits_failed_terminal_event_without_success_snapshot(
|
||||
|
||||
@@ -12,9 +12,8 @@ from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID
|
||||
from dify_agent.protocol.schemas import RunComposition, RunLayerSpec
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
from dify_agent.runtime.history import (
|
||||
append_successful_run_history,
|
||||
build_run_message_history,
|
||||
get_history_layer,
|
||||
replace_successful_run_history,
|
||||
validate_history_layer_composition,
|
||||
)
|
||||
|
||||
@@ -89,63 +88,27 @@ def test_get_history_layer_returns_optional_active_history_layer() -> None:
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_build_run_message_history_renders_current_system_prompts_before_stored_history() -> None:
|
||||
stored_history = [
|
||||
ModelRequest(parts=[UserPromptPart(content="old user")]),
|
||||
ModelResponse(parts=[TextPart(content="old assistant")]),
|
||||
def test_replace_successful_run_history_persists_full_history_without_instructions() -> None:
|
||||
history_layer = PydanticAIHistoryLayer()
|
||||
history_layer.replace_messages([ModelRequest(parts=[UserPromptPart(content="stale")])])
|
||||
messages = [
|
||||
ModelRequest(
|
||||
parts=[SystemPromptPart(content="Summary of previous conversation:\n\nsummary")],
|
||||
instructions="current instructions",
|
||||
),
|
||||
ModelRequest(parts=[UserPromptPart(content="new user")]),
|
||||
ModelResponse(parts=[TextPart(content="new assistant")]),
|
||||
]
|
||||
|
||||
async def scenario() -> None:
|
||||
message_history = await build_run_message_history(
|
||||
system_prompts=[lambda: "current system", lambda: "current suffix"],
|
||||
stored_history=stored_history,
|
||||
)
|
||||
replace_successful_run_history(history_layer, messages)
|
||||
|
||||
assert message_history is not None
|
||||
assert isinstance(message_history[0], ModelRequest)
|
||||
assert [part.content for part in message_history[0].parts] == ["current system", "current suffix"]
|
||||
assert message_history[1:] == stored_history
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_build_run_message_history_returns_none_without_system_prompt_or_history() -> None:
|
||||
async def scenario() -> None:
|
||||
assert await build_run_message_history(system_prompts=[], stored_history=[]) is None
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_build_run_message_history_renders_system_prompt_without_history_layer() -> None:
|
||||
async def scenario() -> None:
|
||||
message_history = await build_run_message_history(system_prompts=[lambda: "current system"], stored_history=[])
|
||||
|
||||
assert message_history is not None
|
||||
assert len(message_history) == 1
|
||||
assert isinstance(message_history[0], ModelRequest)
|
||||
assert isinstance(message_history[0].parts[0], SystemPromptPart)
|
||||
assert message_history[0].parts[0].content == "current system"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_build_run_message_history_rejects_context_dependent_prompt_functions() -> None:
|
||||
def unsupported_prompt(_ctx: object) -> str:
|
||||
return "current system"
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(ValueError, match="zero-argument system prompts"):
|
||||
await build_run_message_history(system_prompts=[unsupported_prompt], stored_history=[])
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_append_successful_run_history_preserves_existing_message_order() -> None:
|
||||
history_layer = PydanticAIHistoryLayer()
|
||||
stored_history = [ModelRequest(parts=[UserPromptPart(content="old user")])]
|
||||
new_messages = [ModelResponse(parts=[TextPart(content="new assistant")])]
|
||||
|
||||
history_layer.replace_messages(stored_history)
|
||||
append_successful_run_history(history_layer, new_messages)
|
||||
|
||||
assert history_layer.message_history == [*stored_history, *new_messages]
|
||||
persisted = history_layer.message_history
|
||||
assert len(persisted) == 3
|
||||
persisted_request = persisted[0]
|
||||
assert isinstance(persisted_request, ModelRequest)
|
||||
assert persisted_request.instructions is None
|
||||
assert persisted_request.parts == messages[0].parts
|
||||
assert persisted[1:] == messages[1:]
|
||||
source_request = messages[0]
|
||||
assert isinstance(source_request, ModelRequest)
|
||||
assert source_request.instructions == "current instructions"
|
||||
|
||||
@@ -310,7 +310,7 @@ async def test_e2b_checkpoint_uses_exact_source_runtime() -> None:
|
||||
control = _ControlPlane()
|
||||
source_sandbox = _Sandbox(sandbox_id="source")
|
||||
source = E2BRuntimeLease(
|
||||
sandbox=source_sandbox,
|
||||
sandbox=source_sandbox, # pyright: ignore[reportArgumentType]
|
||||
data_plane=cast(ShellctlRuntimeLease, object()),
|
||||
)
|
||||
backend = E2BHomeSnapshotBackend(
|
||||
|
||||
@@ -44,11 +44,11 @@ def test_agenton_pydantic_ai_example_smoke() -> None:
|
||||
result = _run_example("agenton_examples.pydantic_ai_bridge")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "SystemPromptPart: Prefer concrete details." in result.stdout
|
||||
assert "UserPromptPart: [\"Use the tools for 'layer composition'.\"]" in result.stdout
|
||||
assert "ToolCallPart: count_words(" in result.stdout
|
||||
assert "ToolCallPart: write_tagline(" in result.stdout
|
||||
assert "TextPart:" in result.stdout
|
||||
assert "SystemPromptPart(content='Prefer concrete details.'," in result.stdout
|
||||
assert "UserPromptPart(content=[\"Use the tools for 'layer composition'.\"]," in result.stdout
|
||||
assert "ToolCallPart(tool_name='count_words'" in result.stdout
|
||||
assert "ToolCallPart(tool_name='write_tagline'" in result.stdout
|
||||
assert "TextPart(content=" in result.stdout
|
||||
|
||||
|
||||
def test_agenton_session_snapshot_example_smoke() -> None:
|
||||
|
||||
@@ -10,7 +10,8 @@ CLIENT_SHARED_DTO_DEPENDENCIES = {
|
||||
"httpx==0.28.1",
|
||||
"httpx2>=2.5.0,<3.0.0",
|
||||
"pydantic>=2.12.5,<2.13",
|
||||
"pydantic-ai-slim>=1.102.0,<2.0.0",
|
||||
"pydantic-ai-harness>=0.20.0,<0.21.0",
|
||||
"pydantic-ai-slim>=2.30.0,<3.0.0",
|
||||
"typing-extensions>=4.12.2,<5.0.0",
|
||||
}
|
||||
|
||||
@@ -21,7 +22,7 @@ SERVER_RUNTIME_DEPENDENCIES = {
|
||||
"jsonschema>=4.23.0,<5.0.0",
|
||||
"jwcrypto>=1.5.6,<2",
|
||||
"logfire[fastapi,httpx,redis]>=4.37.0,<5.0.0",
|
||||
"pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0",
|
||||
"pydantic-ai-slim[anthropic,google,openai]>=2.30.0,<3.0.0",
|
||||
"pydantic-settings>=2.12.0,<3.0.0",
|
||||
"redis>=7.4.0,<8.0.0",
|
||||
"uvicorn[standard]==0.46.0",
|
||||
|
||||
Generated
+34
-16
@@ -593,6 +593,7 @@ dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx2" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-ai-harness" },
|
||||
{ name = "pydantic-ai-slim" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
@@ -638,8 +639,9 @@ requires-dist = [
|
||||
{ name = "jwcrypto", marker = "extra == 'server'", specifier = ">=1.5.6,<2" },
|
||||
{ name = "logfire", extras = ["fastapi", "httpx", "redis"], marker = "extra == 'server'", specifier = ">=4.37.0,<5.0.0" },
|
||||
{ name = "pydantic", specifier = ">=2.12.5,<2.13" },
|
||||
{ name = "pydantic-ai-slim", specifier = ">=1.106.0,<2.0.0" },
|
||||
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" },
|
||||
{ name = "pydantic-ai-harness", specifier = ">=0.20.0,<0.21.0" },
|
||||
{ name = "pydantic-ai-slim", specifier = ">=2.30.0,<3.0.0" },
|
||||
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=2.30.0,<3.0.0" },
|
||||
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" },
|
||||
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
|
||||
@@ -784,15 +786,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "genai-prices"
|
||||
version = "0.0.57"
|
||||
version = "0.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx2" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/30/11f3d683cf3b1d9612475ad8bfffe3423ce9f50fc617733109033e73a038/genai_prices-0.0.57.tar.gz", hash = "sha256:6e101e9c53975557ceffa237b0995787d81fe75aac12410f2898504188bcad89", size = 66555, upload-time = "2026-04-21T13:42:52.554Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/14/a188df294f013ec9cd97fc6b145f5427f89067bfb2c260fc3fb5c8d1fb34/genai_prices-0.1.3.tar.gz", hash = "sha256:62c30cddd6c2d2199d878d1a70521c3e37347cd9394446d107dc774a78ed3780", size = 92638, upload-time = "2026-08-15T00:10:31.771Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/fe/d0095040c120d97cb63d055224ecd4e913dc5655315c203c8e83bf13aa86/genai_prices-0.0.57-py3-none-any.whl", hash = "sha256:14e50fb69cdc5a06ddb2a6df5a7fe06741b9e44304ce3f1728f56abdf1856cca", size = 69654, upload-time = "2026-04-21T13:42:51.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/cd/d94b47c26d6367e0b949edfe2da5a47fb74037e799a0edd5b825e049f2b9/genai_prices-0.1.3-py3-none-any.whl", hash = "sha256:a2603841429c843da91c987d9ef598c73bd940caf44e844ab046d551791c04bb", size = 96892, upload-time = "2026-08-15T00:10:30.595Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1819,7 +1821,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.32.0"
|
||||
version = "2.54.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -1831,9 +1833,9 @@ dependencies = [
|
||||
{ name = "tqdm" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/59/bdcc6b759b8c42dd73afaf5bf8f902c04b37987a5514dbc1c64dba390fef/openai-2.32.0.tar.gz", hash = "sha256:c54b27a9e4cb8d51f0dd94972ffd1a04437efeb259a9e60d8922b8bd26fe55e0", size = 693286, upload-time = "2026-04-15T22:28:19.434Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/9a/8c75e8c8a5b407a0586faeb2afac91674ff955c191ecc1d6d3b6669f6788/openai-2.54.0.tar.gz", hash = "sha256:e3e6f8bc1ba30ddf381ace1a14340eed381cb984a1a59bd0f34b5be3b5d49cfa", size = 1100285, upload-time = "2026-08-11T18:46:59.035Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/c1/d6e64ccd0536bf616556f0cad2b6d94a8125f508d25cfd814b1d2db4e2f1/openai-2.32.0-py3-none-any.whl", hash = "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", size = 1162570, upload-time = "2026-04-15T22:28:17.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/a8/bb76c7356de8ad57f59d5ff993d434df0607f07f08bcc9c9a5c275e399c0/openai-2.54.0-py3-none-any.whl", hash = "sha256:89089789197ccdb87f173a03145ed1598d00795220c93e96cf712b1cbf5e5f2b", size = 1660351, upload-time = "2026-08-11T18:46:56.684Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2381,10 +2383,25 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-ai-slim"
|
||||
version = "1.106.0"
|
||||
name = "pydantic-ai-harness"
|
||||
version = "0.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "genai-prices" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic-ai-slim" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/6c/a066644a3a3bff22bfdddd745fd245e2b4e3148fc895be0020f03bd7470d/pydantic_ai_harness-0.20.0.tar.gz", hash = "sha256:18ec7d6f90873a8038d094280e50af5e877320b975f334268b700a266d35f522", size = 1846014, upload-time = "2026-08-14T03:36:31.045Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/85/32cab39557e338abbd4ecb844028a31110c03096e6678bf5a1266ed201d1/pydantic_ai_harness-0.20.0-py3-none-any.whl", hash = "sha256:e1164ae4d653bd2ae257e3816ee1776b27dbe8f7eaeb29b36d86a49d6fe98168", size = 623606, upload-time = "2026-08-14T03:36:29.094Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-ai-slim"
|
||||
version = "2.31.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "genai-prices" },
|
||||
{ name = "griffelib" },
|
||||
{ name = "httpx" },
|
||||
@@ -2393,9 +2410,9 @@ dependencies = [
|
||||
{ name = "pydantic-graph" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/45/2afc9100a7c370d8ac37bdfccfb54f46fc99da3bdce63f07c32c37807ebc/pydantic_ai_slim-1.106.0.tar.gz", hash = "sha256:e265598c8ee0e903ebb02d0494bb232be4cc8aa463ba1a55aa743cf34135dacf", size = 773504, upload-time = "2026-06-05T01:29:09.129Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/4e/2165d9b90edcd5dfc8e9465b3bdc0aa66a67760843ddb0ac99ee396898f0/pydantic_ai_slim-2.31.0.tar.gz", hash = "sha256:a9310d2464154b028096f1d680f17837f16e5c6cd209b4542e4f60ca5d344789", size = 1214087, upload-time = "2026-08-15T03:17:28.353Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/d9/a2785c576e3519a72a5bbc0e12027c542b265ef6eea1aa72b9c440ac2531/pydantic_ai_slim-1.106.0-py3-none-any.whl", hash = "sha256:0dd7a99ea3fa89b490098406c2240ba7d75c327eea094c3fd057dd7aa9f3d163", size = 957617, upload-time = "2026-06-05T01:28:59.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/09/233e529fadbece38580c3a390783f55fa196afad875ce535ee9a57a5ad71/pydantic_ai_slim-2.31.0-py3-none-any.whl", hash = "sha256:cb809ad949ca68be6bb9a0e0b994fc73a95f4cd405e8609a71034f2e2080e2a1", size = 1432053, upload-time = "2026-08-15T03:17:21.208Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -2496,17 +2513,18 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-graph"
|
||||
version = "1.106.0"
|
||||
version = "2.31.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "logfire-api" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/9b/dd6826cf21eedd96a7482302be51ba6087095acbe828362135de2a505092/pydantic_graph-1.106.0.tar.gz", hash = "sha256:55afa33df4f699ed5c1185f81b6a06e2161958f1aa0c20742b2dae5745e84cce", size = 62567, upload-time = "2026-06-05T01:29:11.833Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/2c/3817ae318ecc729a258fc85aaad84fc110b9467a292b98bb10e65ae71183/pydantic_graph-2.31.0.tar.gz", hash = "sha256:a19919408dfaa5a1b8713618bcce7a5135d83664321862b0d9be1f4216c432ef", size = 45180, upload-time = "2026-08-15T03:17:30.398Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/e9/0058f0b98f5992e715a0a50128f6c3cc7946cc242d471f6e850efdf03f0c/pydantic_graph-1.106.0-py3-none-any.whl", hash = "sha256:e6bb61aef0fdb49185a81142d311f94fc3315329345471d12cab85ab5845221f", size = 80099, upload-time = "2026-06-05T01:29:04.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/ef/a3217caed3189cfcc6857316e06cb900990e4d5cb59e42ec517cfdda6a7f/pydantic_graph-2.31.0-py3-none-any.whl", hash = "sha256:062555c89b1d5699ddaaa6f09ae62fc1d0f5cc189d0867e2fafca68c24797ba5", size = 52662, upload-time = "2026-08-15T03:17:24.42Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user