feat(dify-agent): add context-aware history compaction (#40872)

This commit is contained in:
盐粒 Yanli
2026-08-17 09:36:21 +00:00
committed by GitHub
parent 5ef8becd25
commit 96809473fc
38 changed files with 765 additions and 275 deletions
@@ -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,
+22 -1
View File
@@ -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
View File
@@ -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__":
+3 -2
View File
@@ -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"]
+16 -65
View File
@@ -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",
]
+15 -11
View File
@@ -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:
+3 -2
View File
@@ -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",
+34 -16
View File
@@ -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]]