From 23ebfbc9374cfcd20e1c82623b699f552c1abbef Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Wed, 11 Mar 2026 18:28:30 +0000
Subject: [PATCH 1/4] Python: Support skill scripts execution (#4558)
* support skill scripts execution
* fix mixed line endings
* address comments and fix syntax issues
* use few try/except instead of one
* change samples
* validate either script path or script resource is set not both
* fix: separate LLM args from runtime kwargs in skill script execution
* address pr review comments
* address PR review comments
* Update python/packages/core/agent_framework/_skills.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update python/packages/core/agent_framework/_skills.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update python/packages/core/agent_framework/_skills.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* 1. Fixing the caching bug where parameters_schema would re-inspect on every call when the result was None
2. Updating the arguments tool description to be more generic (not CLI-specific)
* fix failing tests
* address pr review comments
* address pr review comments
* allow resource function returning any instead of sting
* address PR review comments
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
python/packages/core/AGENTS.md | 9 +
.../packages/core/agent_framework/__init__.py | 10 +-
.../packages/core/agent_framework/_skills.py | 566 ++++++-
.../packages/core/tests/core/test_skills.py | 1414 ++++++++++++++++-
python/samples/02-agents/skills/README.md | 55 +
.../02-agents/skills/basic_skill/README.md | 68 -
.../skills/basic_skill/basic_skill.py | 88 -
.../skills/expense-report/SKILL.md | 40 -
.../assets/expense-report-template.md | 5 -
.../expense-report/references/POLICY_FAQ.md | 55 -
.../skills/code_defined_skill/README.md | 49 +
.../code_defined_skill/code_defined_skill.py | 173 ++
.../02-agents/skills/code_skill/README.md | 57 -
.../02-agents/skills/code_skill/code_skill.py | 161 --
.../skills/file_based_skill/README.md | 69 +
.../file_based_skill/file_based_skill.py | 94 ++
.../skills/unit-converter/SKILL.md | 11 +
.../references/CONVERSION_TABLES.md | 10 +
.../skills/unit-converter/scripts/convert.py | 29 +
.../02-agents/skills/mixed_skills/README.md | 100 ++
.../skills/mixed_skills/mixed_skills.py | 160 ++
.../skills/unit-converter/SKILL.md | 11 +
.../references/CONVERSION_TABLES.md | 10 +
.../skills/unit-converter/scripts/convert.py | 29 +
.../skills/script_approval/README.md | 50 +
.../skills/script_approval/script_approval.py | 124 ++
.../skills/subprocess_script_runner.py | 75 +
27 files changed, 2994 insertions(+), 528 deletions(-)
create mode 100644 python/samples/02-agents/skills/README.md
delete mode 100644 python/samples/02-agents/skills/basic_skill/README.md
delete mode 100644 python/samples/02-agents/skills/basic_skill/basic_skill.py
delete mode 100644 python/samples/02-agents/skills/basic_skill/skills/expense-report/SKILL.md
delete mode 100644 python/samples/02-agents/skills/basic_skill/skills/expense-report/assets/expense-report-template.md
delete mode 100644 python/samples/02-agents/skills/basic_skill/skills/expense-report/references/POLICY_FAQ.md
create mode 100644 python/samples/02-agents/skills/code_defined_skill/README.md
create mode 100644 python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py
delete mode 100644 python/samples/02-agents/skills/code_skill/README.md
delete mode 100644 python/samples/02-agents/skills/code_skill/code_skill.py
create mode 100644 python/samples/02-agents/skills/file_based_skill/README.md
create mode 100644 python/samples/02-agents/skills/file_based_skill/file_based_skill.py
create mode 100644 python/samples/02-agents/skills/file_based_skill/skills/unit-converter/SKILL.md
create mode 100644 python/samples/02-agents/skills/file_based_skill/skills/unit-converter/references/CONVERSION_TABLES.md
create mode 100644 python/samples/02-agents/skills/file_based_skill/skills/unit-converter/scripts/convert.py
create mode 100644 python/samples/02-agents/skills/mixed_skills/README.md
create mode 100644 python/samples/02-agents/skills/mixed_skills/mixed_skills.py
create mode 100644 python/samples/02-agents/skills/mixed_skills/skills/unit-converter/SKILL.md
create mode 100644 python/samples/02-agents/skills/mixed_skills/skills/unit-converter/references/CONVERSION_TABLES.md
create mode 100644 python/samples/02-agents/skills/mixed_skills/skills/unit-converter/scripts/convert.py
create mode 100644 python/samples/02-agents/skills/script_approval/README.md
create mode 100644 python/samples/02-agents/skills/script_approval/script_approval.py
create mode 100644 python/samples/02-agents/skills/subprocess_script_runner.py
diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md
index a270bc1686..859858f0ef 100644
--- a/python/packages/core/AGENTS.md
+++ b/python/packages/core/AGENTS.md
@@ -13,6 +13,7 @@ agent_framework/
├── _tools.py # Tool definitions and function invocation
├── _middleware.py # Middleware system for request/response interception
├── _sessions.py # AgentSession and context provider abstractions
+├── _skills.py # Agent Skills system (models, executors, provider)
├── _mcp.py # Model Context Protocol support
├── _workflows/ # Workflow orchestration (sequential, concurrent, handoff, etc.)
├── openai/ # Built-in OpenAI client
@@ -63,6 +64,14 @@ agent_framework/
- **`BaseContextProvider`** - Base class for context providers (RAG, memory systems)
- **`BaseHistoryProvider`** - Base class for conversation history storage
+### Skills (`_skills.py`)
+
+- **`Skill`** - A skill definition bundling instructions (`content`) with metadata, resources, and scripts. Supports `@skill.resource` and `@skill.script` decorators for adding components.
+- **`SkillResource`** - Named supplementary content attached to a skill; holds either static `content` or a dynamic `function` (sync or async). Exactly one must be provided.
+- **`SkillScript`** - An executable script attached to a skill; holds either an inline `function` (code-defined, runs in-process) or a `path` to a file on disk (file-based, delegated to a runner). Exactly one must be provided.
+- **`SkillScriptRunner`** - Protocol for file-based script execution. Any callable matching `(skill, script, args) -> Any` satisfies it. Code-defined scripts do not use a runner.
+- **`SkillsProvider`** - Context provider (extends `BaseContextProvider`) that discovers file-based skills from `SKILL.md` files and/or accepts code-defined `Skill` instances. Follows progressive disclosure: advertise → load → read resources / run scripts.
+
### Workflows (`_workflows/`)
- **`Workflow`** - Graph-based workflow definition
diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py
index ef03652898..d7bc38220a 100644
--- a/python/packages/core/agent_framework/__init__.py
+++ b/python/packages/core/agent_framework/__init__.py
@@ -59,7 +59,13 @@ from ._sessions import (
register_state_type,
)
from ._settings import SecretString, load_settings
-from ._skills import Skill, SkillResource, SkillsProvider
+from ._skills import (
+ Skill,
+ SkillResource,
+ SkillScript,
+ SkillScriptRunner,
+ SkillsProvider,
+)
from ._telemetry import (
AGENT_FRAMEWORK_USER_AGENT,
APP_INFO,
@@ -271,6 +277,8 @@ __all__ = [
"SingleEdgeGroup",
"Skill",
"SkillResource",
+ "SkillScript",
+ "SkillScriptRunner",
"SkillsProvider",
"SubWorkflowRequestMessage",
"SubWorkflowResponseMessage",
diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py
index fc71329a5f..b7b91919e8 100644
--- a/python/packages/core/agent_framework/_skills.py
+++ b/python/packages/core/agent_framework/_skills.py
@@ -26,13 +26,14 @@ Only use skills from trusted sources.
from __future__ import annotations
import inspect
+import json
import logging
import os
import re
from collections.abc import Callable, Sequence
from html import escape as xml_escape
from pathlib import Path, PurePosixPath
-from typing import TYPE_CHECKING, Any, ClassVar, Final
+from typing import TYPE_CHECKING, Any, ClassVar, Final, Protocol, runtime_checkable
from ._sessions import BaseContextProvider
from ._tools import FunctionTool
@@ -93,6 +94,7 @@ class SkillResource:
description: Optional human-readable summary shown when advertising the resource.
content: Static content string. Mutually exclusive with *function*.
function: Callable (sync or async) that returns content on demand.
+ May return any type; the value is passed through as-is.
Mutually exclusive with *content*.
"""
if not name or not name.strip():
@@ -115,6 +117,110 @@ class SkillResource:
self._accepts_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
+class SkillScript:
+ """An executable script attached to a skill.
+
+ .. warning:: Experimental
+
+ This API is experimental and subject to change or removal
+ in future versions without notice.
+
+ A script represents executable code that an agent can run. It holds
+ either an inline ``function`` callable (code-defined scripts) or
+ a ``path`` to a script file on disk (file-based scripts).
+ Exactly one must be provided.
+
+ When ``function`` is set the script is treated as **code-based**
+ and the function is invoked directly in-process. When ``path`` is
+ set the script is treated as **file-based** and delegated to the
+ configured :class:`SkillScriptRunner`.
+
+ Attributes:
+ name: Script identifier.
+ description: Optional human-readable summary, or ``None``.
+ function: Callable that implements the script, or ``None``.
+ path: Relative path to the script file from the skill directory, or
+ ``None`` for code-defined scripts.
+
+ Examples:
+ Code-defined script:
+
+ .. code-block:: python
+
+ SkillScript(name="analyze", function=analyze_data, description="Run analysis")
+
+ File-based script (discovered from disk):
+
+ .. code-block:: python
+
+ SkillScript(name="process.py", path="scripts/process.py")
+ """
+
+ def __init__(
+ self,
+ *,
+ name: str,
+ description: str | None = None,
+ function: Callable[..., Any] | None = None,
+ path: str | None = None,
+ ) -> None:
+ """Initialize a SkillScript.
+
+ Args:
+ name: Identifier for this script (e.g. ``"analyze"``, ``"process.py"``).
+ description: Optional human-readable summary.
+ function: Callable (sync or async) that implements the script.
+ Set for code-defined scripts; ``None`` for file-based scripts.
+ Mutually exclusive with *path*.
+ path: Relative path to the script file from the skill directory.
+ Set automatically for file-based scripts discovered from disk;
+ ``None`` for code-defined scripts.
+ Mutually exclusive with *function*.
+ """
+ if not name or not name.strip():
+ raise ValueError("Script name cannot be empty.")
+ if function is None and path is None:
+ raise ValueError(f"Script '{name}' must have either function or path.")
+ if function is not None and path is not None:
+ raise ValueError(f"Script '{name}' must have either function or path, not both.")
+
+ self.name = name
+ self.description = description
+ self.function = function
+ self.path = path
+ self._parameters_schema: dict[str, Any] | None = None
+ self._parameters_schema_resolved: bool = False
+
+ # Precompute whether the function accepts **kwargs to avoid
+ # repeated inspect.signature() calls on every invocation.
+ self._accepts_kwargs: bool = False
+ if function is not None:
+ sig = inspect.signature(function)
+ self._accepts_kwargs = any(
+ p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
+ )
+
+ @property
+ def parameters_schema(self) -> dict[str, Any] | None:
+ """JSON Schema describing the script's parameters.
+
+ .. warning:: Experimental
+
+ This API is experimental and subject to change or removal
+ in future versions without notice.
+
+ Lazily generated from the callable's signature on first access.
+ Returns ``None`` for file-based scripts or functions with no
+ introspectable parameters.
+ """
+ if not self._parameters_schema_resolved and self.function is not None:
+ tool = FunctionTool(name=self.function.__name__, func=self.function)
+ schema = tool.parameters()
+ self._parameters_schema = schema if schema and schema.get("properties") else None
+ self._parameters_schema_resolved = True
+ return self._parameters_schema
+
+
class Skill:
"""A skill definition with optional resources.
@@ -124,15 +230,16 @@ class Skill:
in future versions without notice.
A skill bundles a set of instructions (``content``) with metadata and
- zero or more :class:`SkillResource` instances. Resources can be
- supplied at construction time or added later via the :meth:`resource`
- decorator.
+ zero or more :class:`SkillResource` and :class:`SkillScript` instances.
+ Resources and scripts can be supplied at construction time or added later
+ via the :meth:`resource` and :meth:`script` decorators.
Attributes:
name: Skill name (lowercase letters, numbers, hyphens only).
description: Human-readable description of the skill.
content: The skill instructions body.
resources: Mutable list of :class:`SkillResource` instances.
+ scripts: Mutable list of :class:`SkillScript` instances.
path: Absolute path to the skill directory on disk, or ``None``
for code-defined skills.
@@ -171,6 +278,7 @@ class Skill:
description: str,
content: str,
resources: list[SkillResource] | None = None,
+ scripts: list[SkillScript] | None = None,
path: str | None = None,
) -> None:
"""Initialize a Skill.
@@ -180,6 +288,7 @@ class Skill:
description: Human-readable description of the skill (≤1024 chars).
content: The skill instructions body.
resources: Pre-built resources to attach to this skill.
+ scripts: Pre-built scripts to attach to this skill.
path: Absolute path to the skill directory on disk. Set automatically
for file-based skills; leave as ``None`` for code-defined skills.
"""
@@ -192,6 +301,7 @@ class Skill:
self.description = description
self.content = content
self.resources: list[SkillResource] = resources if resources is not None else []
+ self.scripts: list[SkillScript] = scripts if scripts is not None else []
self.path = path
def resource(
@@ -227,7 +337,7 @@ class Skill:
.. code-block:: python
@skill.resource
- def get_schema() -> str:
+ def get_schema() -> Any:
return "schema..."
With arguments:
@@ -235,7 +345,7 @@ class Skill:
.. code-block:: python
@skill.resource(name="custom-name", description="Custom desc")
- async def get_data() -> str:
+ async def get_data() -> Any:
return "data..."
"""
@@ -255,10 +365,118 @@ class Skill:
return decorator
return decorator(func)
+ def script(
+ self,
+ func: Callable[..., Any] | None = None,
+ *,
+ name: str | None = None,
+ description: str | None = None,
+ ) -> Any:
+ """Decorator that registers a callable as a script on this skill.
+
+ Supports bare usage (``@skill.script``) and parameterized usage
+ (``@skill.script(name="custom", description="...")``). The
+ decorated function is returned unchanged; a new
+ :class:`SkillScript` is appended to :attr:`scripts`.
+
+ Args:
+ func: The function being decorated. Populated automatically when
+ the decorator is applied without parentheses.
+
+ Keyword Args:
+ name: Script name override. Defaults to ``func.__name__``.
+ description: Script description override. Defaults to the
+ function's docstring (via :func:`inspect.getdoc`).
+
+ Returns:
+ The original function unchanged, or a secondary decorator when
+ called with keyword arguments.
+
+ Examples:
+ Bare decorator:
+
+ .. code-block:: python
+
+ @skill.script
+ def analyze_data(query: str) -> str:
+ \"\"\"Run data analysis.\"\"\"
+ return run_analysis(query)
+
+ With arguments:
+
+ .. code-block:: python
+
+ @skill.script(name="fetch", description="Fetch remote data")
+ async def fetch_data(url: str) -> str:
+ return await http_get(url)
+ """
+
+ def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
+ script_name = name or f.__name__
+ script_description = description or (inspect.getdoc(f) or None)
+ self.scripts.append(
+ SkillScript(
+ name=script_name,
+ description=script_description,
+ function=f,
+ )
+ )
+ return f
+
+ if func is None:
+ return decorator
+ return decorator(func)
+
# endregion
-# region Constants
+# region Script Runners
+
+
+@runtime_checkable
+class SkillScriptRunner(Protocol):
+ """Protocol for skill script runners.
+
+ .. warning:: Experimental
+
+ This API is experimental and subject to change or removal
+ in future versions without notice.
+
+ A script runner determines how **file-based** skill scripts are
+ run. Implementations decide the execution strategy
+ (e.g., local subprocess, hosted code execution environment,
+ user-provided callable).
+
+ Code-defined scripts (registered via the ``@skill.script`` decorator)
+ are always executed **in-process** and do not use a script runner.
+
+ Any callable (sync or async) matching the ``__call__`` signature
+ satisfies this protocol.
+ """
+
+ def __call__(
+ self, skill: Skill, script: SkillScript, args: dict[str, Any] | None = None
+ ) -> Any:
+ """Run a skill script.
+
+ The :class:`SkillsProvider` resolves skill and script names
+ before calling this method, so implementations receive fully
+ resolved objects.
+
+ Args:
+ skill: The skill that owns the script.
+ script: The script to run.
+ args: Optional keyword arguments for the script.
+
+ Returns:
+ The result. May be any type; the framework
+ serialises it automatically via
+ :meth:`~FunctionTool.parse_result`.
+ """
+ ...
+
+
+# endregion
SKILL_FILE_NAME: Final[str] = "SKILL.md"
MAX_SEARCH_DEPTH: Final[int] = 2
@@ -273,8 +491,7 @@ DEFAULT_RESOURCE_EXTENSIONS: Final[tuple[str, ...]] = (
".xml",
".txt",
)
-
-# endregion
+DEFAULT_SCRIPT_EXTENSIONS: Final[tuple[str, ...]] = (".py",)
# region Patterns and prompt template
@@ -307,13 +524,19 @@ Each skill provides specialized instructions, reference documents, and assets fo
When a task aligns with a skill's domain, follow these steps in exact order:
-1. Use `load_skill` to retrieve the skill's instructions.
-2. Follow the provided guidance.
-3. Use `read_skill_resource` to read any referenced resources, using the name exactly as listed
+- Use `load_skill` to retrieve the skill's instructions.
+- Follow the provided guidance.
+- Use `read_skill_resource` to read any referenced resources, using the name exactly as listed
(e.g. `"style-guide"` not `"style-guide.md"`, `"references/FAQ.md"` not `"FAQ.md"`).
-
+{runner_instructions}
Only load what is needed, when it is needed."""
+SCRIPT_RUNNER_INSTRUCTIONS: Final[str] = (
+ "\n- Use `run_skill_script` to run referenced scripts, using the name exactly as listed."
+ "\n- Pass script arguments inside `args` as a JSON object"
+ ' (e.g. `args: {"length": 24}`), not as top-level tool parameters.\n'
+)
+
# endregion
# region SkillsProvider
@@ -381,8 +604,11 @@ class SkillsProvider(BaseContextProvider):
skill_paths: str | Path | Sequence[str | Path] | None = None,
*,
skills: Sequence[Skill] | None = None,
+ script_runner: SkillScriptRunner | None = None,
instruction_template: str | None = None,
resource_extensions: tuple[str, ...] | None = None,
+ script_extensions: tuple[str, ...] | None = None,
+ require_script_approval: bool = False,
source_id: str | None = None,
) -> None:
"""Initialize a SkillsProvider.
@@ -395,21 +621,69 @@ class SkillsProvider(BaseContextProvider):
Keyword Args:
skills: Code-defined :class:`Skill` instances to register.
+ script_runner: Strategy for running **file-based** skill
+ scripts. The provider resolves skill and script names, then
+ calls the runner directly. This parameter only
+ affects scripts discovered from disk (via *skill_paths*);
+ code-defined scripts (registered with ``@skill.script``) are
+ always executed in-process and ignore this setting.
+ When ``None``, file-based scripts are not executable.
instruction_template: Custom system-prompt template for
advertising skills. Must contain a ``{skills}`` placeholder for the
generated skills list. Uses a built-in template when ``None``.
resource_extensions: File extensions recognized as discoverable
resources. Defaults to ``DEFAULT_RESOURCE_EXTENSIONS``
(``(".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt")``).
+ script_extensions: File extensions recognized as discoverable
+ scripts. Defaults to ``DEFAULT_SCRIPT_EXTENSIONS``
+ (``(".py",)``).
+ require_script_approval: When ``True``, skill script execution
+ requires explicit user approval before running. Instead of
+ executing immediately, the agent pauses and returns a
+ ``function_approval_request`` via ``result.user_input_requests``.
+ The application should present the request to the user, then
+ call ``request.to_function_approval_response(approved=True)``
+ (or ``False`` to reject) and pass the response back with
+ ``agent.run(approval_response, session=session)``.
+ Rejected scripts are not executed and the agent is informed
+ the user declined. Defaults to ``False``. See
+ ``samples/02-agents/skills/script_approval/script_approval.py``
+ for the full approval loop pattern.
source_id: Unique identifier for this provider instance.
"""
super().__init__(source_id or self.DEFAULT_SOURCE_ID)
- self._skills = _load_skills(skill_paths, skills, resource_extensions or DEFAULT_RESOURCE_EXTENSIONS)
+ self._skills = _load_skills(
+ skill_paths,
+ skills,
+ resource_extensions or DEFAULT_RESOURCE_EXTENSIONS,
+ script_extensions or DEFAULT_SCRIPT_EXTENSIONS,
+ )
- self._instructions = _create_instructions(instruction_template, self._skills)
+ # File-based skills (skill.path set) have scripts discovered from disk
+ has_file_scripts = any(s.scripts for s in self._skills.values() if s.path is not None)
- self._tools = self._create_tools()
+ # Code-defined skills (skill.path is None) have scripts with callable functions
+ has_code_scripts = any(s.scripts for s in self._skills.values() if s.path is None)
+
+ if has_file_scripts and script_runner is None:
+ raise ValueError(
+ "File-based skills with scripts were provided but no 'script_runner' was provided. "
+ "Pass a SkillScriptRunner callable to SkillsProvider."
+ )
+
+ self._script_runner = script_runner
+
+ self._instructions = _create_instructions(
+ prompt_template=instruction_template,
+ skills=self._skills,
+ include_script_runner_instructions=has_file_scripts or has_code_scripts
+ )
+
+ self._tools = self._create_tools(
+ include_script_runner_tool=has_file_scripts or has_code_scripts,
+ require_script_approval=require_script_approval,
+ )
async def before_run(
self,
@@ -425,6 +699,11 @@ class SkillsProvider(BaseContextProvider):
skill is registered, appends the skill-list system prompt and the
``load_skill`` / ``read_skill_resource`` tools to *context*.
+ When any registered skill defines one or more scripts (file-based or
+ code-based), the system prompt also includes script-runner
+ instructions (embedded via the ``{runner_instructions}`` placeholder),
+ and the ``run_skill_script`` tool is included alongside the base tools.
+
Args:
agent: The agent instance about to run.
session: The current agent session.
@@ -434,17 +713,30 @@ class SkillsProvider(BaseContextProvider):
if not self._skills:
return
- if self._instructions:
- context.extend_instructions(self.source_id, self._instructions)
+ context.extend_instructions(self.source_id, self._instructions) # type: ignore[arg-type]
context.extend_tools(self.source_id, self._tools)
- def _create_tools(self) -> list[FunctionTool]:
+ def _create_tools(
+ self,
+ include_script_runner_tool: bool,
+ require_script_approval: bool = False,
+ ) -> list[FunctionTool]:
"""Create the ``load_skill`` and ``read_skill_resource`` tool definitions.
+ When *include_script_runner_tool* is ``True``, also creates
+ ``run_skill_script``.
+
+ Args:
+ include_script_runner_tool: Whether to include the
+ ``run_skill_script`` tool in the returned list.
+ require_script_approval: When ``True``, the
+ ``run_skill_script`` tool pauses for user approval
+ before each invocation.
+
Returns:
- A two-element list of :class:`FunctionTool` instances.
+ A list of :class:`FunctionTool` instances.
"""
- return [
+ tools = [
FunctionTool(
name="load_skill",
description="Loads the full instructions for a specific skill.",
@@ -475,6 +767,45 @@ class SkillsProvider(BaseContextProvider):
),
]
+ if include_script_runner_tool:
+ tools.append(
+ FunctionTool(
+ name="run_skill_script",
+ description="Runs a script associated with a skill.",
+ func=self._run_skill_script,
+ approval_mode="always_require" if require_script_approval else "never_require",
+ input_model={
+ "type": "object",
+ "properties": {
+ "skill_name": {"type": "string", "description": "The name of the skill."},
+ "script_name": {
+ "type": "string",
+ "description": (
+ "The name of the script to run as listed in the skill, "
+ "preserving any directory prefix exactly as shown. "
+ "Do not add or remove path prefixes."
+ ),
+ },
+ "args": {
+ "type": ["object", "null"],
+ "additionalProperties": True,
+ "default": None,
+ "description": (
+ "Arguments to pass to the script as key-value pairs. "
+ "Use parameter names as keys without leading dashes "
+ '(e.g. {"length": 24, "uppercase": true}). '
+ "How these values are mapped to the underlying script "
+ "is determined by the script implementation or configured runner."
+ ),
+ },
+ },
+ "required": ["skill_name", "script_name"],
+ },
+ )
+ )
+
+ return tools
+
def _load_skill(self, skill_name: str) -> str:
"""Return the full instructions for the named skill.
@@ -516,9 +847,79 @@ class SkillsProvider(BaseContextProvider):
resource_lines = "\n".join(_create_resource_element(r) for r in skill.resources)
content += f"\n\n\n{resource_lines}\n"
+ if skill.scripts:
+ script_lines = "\n".join(_create_script_element(s) for s in skill.scripts)
+ content += f"\n\n\n{script_lines}\n"
+
return content
- async def _read_skill_resource(self, skill_name: str, resource_name: str, **kwargs: Any) -> str:
+ async def _run_skill_script(
+ self, skill_name: str, script_name: str, args: dict[str, Any] | None = None, **kwargs: Any
+ ) -> Any:
+ """Run a named script from a skill.
+
+ For code-defined scripts (those with a ``function`` and no ``path``),
+ the function is invoked directly in-process. For file-based scripts
+ the configured :class:`SkillScriptRunner` is used.
+
+ Args:
+ skill_name: The name of the owning skill.
+ script_name: The script name to look up (case-insensitive).
+ args: Optional keyword arguments for the script, provided by the
+ agent/LLM. These are mapped to the function's declared
+ parameters.
+ **kwargs: Runtime keyword arguments forwarded only to script
+ functions that accept ``**kwargs`` (e.g. arguments passed via
+ ``agent.run(user_id="123")``).
+
+ Returns:
+ The result, or a user-facing error message on
+ failure.
+ """
+ if not skill_name or not skill_name.strip():
+ return "Error: Skill name cannot be empty."
+
+ if not script_name or not script_name.strip():
+ return "Error: Script name cannot be empty."
+
+ skill = self._skills.get(skill_name)
+ if not skill:
+ return f"Error: Skill '{skill_name}' not found."
+
+ script = next((s for s in skill.scripts if s.name.lower() == script_name.lower()), None)
+ if not script:
+ return f"Error: Script '{script_name}' not found in skill '{skill_name}'."
+
+ # Code-defined scripts: run the function directly
+ if script.function is not None:
+ try:
+ if script._accepts_kwargs: # pyright: ignore[reportPrivateUsage]
+ result = script.function(**(args or {}), **kwargs)
+ else:
+ result = script.function(**(args or {}))
+ if inspect.isawaitable(result):
+ result = await result
+ return result
+ except Exception:
+ logger.exception("Error running code-defined script '%s' in skill '%s'", script_name, skill_name)
+ return f"Error: Failed to run script '{script_name}' in skill '{skill_name}'."
+
+ # File-based scripts: delegate to the runner
+ if self._script_runner is None:
+ return (
+ f"Error: Script '{script_name}' in skill '{skill_name}' requires a runner. "
+ "Provide a script_runner for file-based scripts."
+ )
+ try:
+ result = self._script_runner(skill, script, args)
+ if inspect.isawaitable(result):
+ result = await result
+ return result
+ except Exception:
+ logger.exception("Error running file-based script '%s' in skill '%s'", script_name, skill_name)
+ return f"Error: Failed to run script '{script_name}' in skill '{skill_name}'."
+
+ async def _read_skill_resource(self, skill_name: str, resource_name: str, **kwargs: Any) -> Any:
"""Read a named resource from a skill.
Resolves the resource by case-insensitive name lookup. Static
@@ -533,7 +934,7 @@ class SkillsProvider(BaseContextProvider):
``agent.run(user_id="123")``).
Returns:
- The resource content string, or a user-facing error message on
+ The resource content (any type), or a user-facing error message on
failure.
"""
if not skill_name or not skill_name.strip():
@@ -565,13 +966,10 @@ class SkillsProvider(BaseContextProvider):
)
else:
result = resource.function(**kwargs) if resource._accepts_kwargs else resource.function() # pyright: ignore[reportPrivateUsage]
- return str(result)
- except Exception as exc:
+ return result
+ except Exception:
logger.exception("Failed to read resource '%s' from skill '%s'", resource_name, skill_name)
- return (
- f"Error ({type(exc).__name__}): Failed to read resource"
- f" '{resource_name}' from skill '{skill_name}'."
- )
+ return f"Error: Failed to read resource '{resource_name}' from skill '{skill_name}'."
return f"Error: Resource '{resource.name}' has no content or function."
@@ -707,6 +1105,60 @@ def _discover_resource_files(
return resources
+def _discover_script_files(
+ skill_dir_path: str,
+ extensions: tuple[str, ...] = DEFAULT_SCRIPT_EXTENSIONS,
+) -> list[str]:
+ """Scan a skill directory for script files matching *extensions*.
+
+ Recursively walks *skill_dir_path* and collects files whose extension
+ is in *extensions*. Each candidate is validated against path-traversal
+ and symlink-escape checks; unsafe files are skipped with a warning.
+
+ Args:
+ skill_dir_path: Absolute path to the skill directory to scan.
+ extensions: Tuple of allowed script extensions (e.g. ``(".py",)``).
+
+ Returns:
+ Relative script paths (forward-slash-separated) for every
+ discovered file that passes security checks.
+ """
+ skill_dir = Path(skill_dir_path).absolute()
+ root_directory_path = str(skill_dir)
+ scripts: list[str] = []
+ normalized_extensions = {e.lower() for e in extensions}
+
+ for script_file in skill_dir.rglob("*"):
+ if not script_file.is_file():
+ continue
+
+ if script_file.suffix.lower() not in normalized_extensions:
+ continue
+
+ script_full_path = str(Path(os.path.normpath(script_file)).absolute())
+
+ if not _is_path_within_directory(script_full_path, root_directory_path):
+ logger.warning(
+ "Skipping script '%s': resolves outside skill directory '%s'",
+ script_file,
+ skill_dir_path,
+ )
+ continue
+
+ if _has_symlink_in_path(script_full_path, root_directory_path):
+ logger.warning(
+ "Skipping script '%s': symlink detected in path under skill directory '%s'",
+ script_file,
+ skill_dir_path,
+ )
+ continue
+
+ rel_path = script_file.relative_to(skill_dir)
+ scripts.append(_normalize_resource_path(str(rel_path)))
+
+ return scripts
+
+
def _validate_skill_metadata(
name: str | None,
description: str | None,
@@ -902,6 +1354,7 @@ def _read_file_skill_resource(skill: Skill, resource_name: str) -> str:
def _discover_file_skills(
skill_paths: str | Path | Sequence[str | Path] | None,
resource_extensions: tuple[str, ...] = DEFAULT_RESOURCE_EXTENSIONS,
+ script_extensions: tuple[str, ...] = DEFAULT_SCRIPT_EXTENSIONS,
) -> dict[str, Skill]:
"""Discover, parse, and load all file-based skills from the given paths.
@@ -912,6 +1365,7 @@ def _discover_file_skills(
Args:
skill_paths: Directory path(s) to scan, or ``None`` to skip.
resource_extensions: File extensions recognized as resources.
+ script_extensions: File extensions recognized as scripts.
Returns:
A dict mapping skill name → :class:`Skill`.
@@ -955,6 +1409,10 @@ def _discover_file_skills(
reader = (lambda s, r: lambda: _read_file_skill_resource(s, r))(file_skill, rn)
file_skill.resources.append(SkillResource(name=rn, function=reader))
+ # Discover and attach file-based scripts as SkillScript instances
+ for sn in _discover_script_files(skill_path, script_extensions):
+ file_skill.scripts.append(SkillScript(name=sn, path=sn))
+
skills[file_skill.name] = file_skill
logger.info("Loaded skill: %s", file_skill.name)
@@ -966,6 +1424,7 @@ def _load_skills(
skill_paths: str | Path | Sequence[str | Path] | None,
skills: Sequence[Skill] | None,
resource_extensions: tuple[str, ...],
+ script_extensions: tuple[str, ...],
) -> dict[str, Skill]:
"""Discover and merge skills from file paths and code-defined skills.
@@ -977,11 +1436,12 @@ def _load_skills(
skill_paths: Directory path(s) to scan for ``SKILL.md`` files, or ``None``.
skills: Code-defined :class:`Skill` instances, or ``None``.
resource_extensions: File extensions recognized as discoverable resources.
+ script_extensions: File extensions recognized as discoverable scripts.
Returns:
A dict mapping skill name → :class:`Skill`.
"""
- result = _discover_file_skills(skill_paths, resource_extensions)
+ result = _discover_file_skills(skill_paths, resource_extensions, script_extensions)
if skills:
for code_skill in skills:
@@ -1017,19 +1477,50 @@ def _create_resource_element(resource: SkillResource) -> str:
return f" "
+def _create_script_element(script: SkillScript) -> str:
+ """Create an XML ``"
+ return f" "
+
+
def _create_instructions(
prompt_template: str | None,
skills: dict[str, Skill],
+ include_script_runner_instructions: bool = False,
) -> str | None:
"""Create the system-prompt text that advertises available skills.
Generates an XML list of ```` elements (sorted by name) and
inserts it into *prompt_template* at the ``{skills}`` placeholder.
+ When *include_script_runner_instructions* is ``True``, executor-provided
+ instructions are inserted at the ``{runner_instructions}`` placeholder.
Args:
- prompt_template: Custom template string with a ``{skills}`` placeholder,
+ prompt_template: Custom template string with ``{skills}`` and
+ optional ``{runner_instructions}`` placeholders,
or ``None`` to use the built-in default.
skills: Registered skills keyed by name.
+ include_script_runner_instructions: When ``True``, include
+ script-runner instructions in the generated prompt.
+ Defaults to ``False``.
Returns:
The formatted instruction string, or ``None`` when *skills* is empty.
@@ -1038,12 +1529,13 @@ def _create_instructions(
ValueError: If *prompt_template* is not a valid format string
(e.g. missing ``{skills}`` placeholder).
"""
+ runner_instructions = SCRIPT_RUNNER_INSTRUCTIONS if include_script_runner_instructions else None
template = DEFAULT_SKILLS_INSTRUCTION_PROMPT
if prompt_template is not None:
# Validate that the custom template contains a valid {skills} placeholder
try:
- result = prompt_template.format(skills="__PROBE__")
+ result = prompt_template.format(skills="__PROBE__", runner_instructions="__EXEC_PROBE__")
except (KeyError, IndexError, ValueError) as exc:
raise ValueError(
"The provided instruction_template is not a valid format string. "
@@ -1055,6 +1547,11 @@ def _create_instructions(
raise ValueError(
"The provided instruction_template must contain a '{skills}' placeholder." # noqa: RUF027
)
+ if runner_instructions and "__EXEC_PROBE__" not in result:
+ raise ValueError(
+ "The provided instruction_template must contain an '{runner_instructions}' placeholder " # noqa: RUF027
+ "when a script runner is configured."
+ )
template = prompt_template
if not skills:
@@ -1068,7 +1565,10 @@ def _create_instructions(
lines.append(f" {xml_escape(skill.description)}")
lines.append(" ")
- return template.format(skills="\n".join(lines))
+ return template.format(
+ skills="\n".join(lines),
+ runner_instructions=runner_instructions or "",
+ )
# endregion
diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py
index cb829b7b9f..8fe941b208 100644
--- a/python/packages/core/tests/core/test_skills.py
+++ b/python/packages/core/tests/core/test_skills.py
@@ -14,14 +14,18 @@ import pytest
from agent_framework import SessionContext, Skill, SkillResource, SkillsProvider
from agent_framework._skills import (
DEFAULT_RESOURCE_EXTENSIONS,
+ DEFAULT_SCRIPT_EXTENSIONS,
_create_instructions,
_create_resource_element,
+ _create_script_element,
_discover_file_skills,
_discover_resource_files,
+ _discover_script_files,
_discover_skill_directories,
_extract_frontmatter,
_has_symlink_in_path,
_is_path_within_directory,
+ _load_skills,
_normalize_resource_path,
_read_and_parse_skill_file,
_read_file_skill_resource,
@@ -29,6 +33,11 @@ from agent_framework._skills import (
)
+async def _noop_script_runner(skill: Any, script: Any, args: Any = None) -> None:
+ """No-op script runner for tests that need a SkillScriptRunner."""
+ return None
+
+
def _symlinks_supported(tmp: Path) -> bool:
"""Return True if the current platform/environment supports symlinks."""
test_target = tmp / "_symlink_test_target"
@@ -742,6 +751,27 @@ class TestSymlinkDetection:
with pytest.raises(ValueError, match="symlink"):
_read_file_skill_resource(skill, "refs/leak.md")
+ def test_discover_skips_symlinked_script(self, tmp_path: Path) -> None:
+ """_discover_script_files should skip scripts with symlinks in their path."""
+ if not _symlinks_supported(tmp_path):
+ pytest.skip("Symlinks not supported on this platform/environment")
+
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+
+ outside_script = tmp_path / "evil.py"
+ outside_script.write_text("print('evil')", encoding="utf-8")
+
+ scripts_dir = skill_dir / "scripts"
+ scripts_dir.mkdir()
+ (scripts_dir / "safe.py").write_text("print('safe')", encoding="utf-8")
+ (scripts_dir / "leak.py").symlink_to(outside_script)
+
+ discovered = _discover_script_files(str(skill_dir))
+ discovered_names = [p for p in discovered]
+ assert "scripts/safe.py" in discovered_names
+ assert "scripts/leak.py" not in discovered_names
+
# ---------------------------------------------------------------------------
# Tests: SkillResource
@@ -778,6 +808,20 @@ class TestSkillResource:
with pytest.raises(ValueError, match="must have either content or function, not both"):
SkillResource(name="both", content="static", function=lambda: "dynamic")
+ def test_accepts_kwargs_true_for_kwargs_function(self) -> None:
+ def func_with_kwargs(**kwargs: Any) -> str:
+ return "dynamic"
+
+ resource = SkillResource(name="res", function=func_with_kwargs)
+ assert resource._accepts_kwargs is True
+
+ def test_accepts_kwargs_false_for_regular_function(self) -> None:
+ def func_no_kwargs() -> str:
+ return "dynamic"
+
+ resource = SkillResource(name="res", function=func_no_kwargs)
+ assert resource._accepts_kwargs is False
+
# ---------------------------------------------------------------------------
# Tests: Skill
@@ -838,7 +882,7 @@ class TestSkill:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource
- def get_schema() -> str:
+ def get_schema() -> Any:
"""Get the database schema."""
return "CREATE TABLE users (id INT)"
@@ -851,7 +895,7 @@ class TestSkill:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource(name="custom-name", description="Custom description")
- def my_resource() -> str:
+ def my_resource() -> Any:
return "data"
assert len(skill.resources) == 1
@@ -863,7 +907,7 @@ class TestSkill:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource
- def get_data() -> str:
+ def get_data() -> Any:
return "data"
assert callable(get_data)
@@ -873,11 +917,11 @@ class TestSkill:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource
- def resource_a() -> str:
+ def resource_a() -> Any:
return "A"
@skill.resource
- def resource_b() -> str:
+ def resource_b() -> Any:
return "B"
assert len(skill.resources) == 2
@@ -889,7 +933,7 @@ class TestSkill:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource
- async def get_async_data() -> str:
+ async def get_async_data() -> Any:
return "async data"
assert len(skill.resources) == 1
@@ -959,7 +1003,7 @@ class TestSkillsProviderCodeSkill:
skill = Skill(name="prog-skill", description="A skill.", content="Body")
@skill.resource
- def get_schema() -> str:
+ def get_schema() -> Any:
return "CREATE TABLE users"
provider = SkillsProvider(skills=[skill])
@@ -970,7 +1014,7 @@ class TestSkillsProviderCodeSkill:
skill = Skill(name="prog-skill", description="A skill.", content="Body")
@skill.resource
- async def get_data() -> str:
+ async def get_data() -> Any:
return "async data"
provider = SkillsProvider(skills=[skill])
@@ -998,7 +1042,7 @@ class TestSkillsProviderCodeSkill:
skill = Skill(name="prog-skill", description="A skill.", content="Body")
@skill.resource
- def get_user_config(**kwargs: Any) -> str:
+ def get_user_config(**kwargs: Any) -> Any:
user_id = kwargs.get("user_id", "unknown")
return f"config for {user_id}"
@@ -1010,7 +1054,7 @@ class TestSkillsProviderCodeSkill:
skill = Skill(name="prog-skill", description="A skill.", content="Body")
@skill.resource
- async def get_user_data(**kwargs: Any) -> str:
+ async def get_user_data(**kwargs: Any) -> Any:
token = kwargs.get("auth_token", "none")
return f"data with token={token}"
@@ -1023,13 +1067,49 @@ class TestSkillsProviderCodeSkill:
skill = Skill(name="prog-skill", description="A skill.", content="Body")
@skill.resource
- def static_resource() -> str:
+ def static_resource() -> Any:
return "static content"
provider = SkillsProvider(skills=[skill])
result = await provider._read_skill_resource("prog-skill", "static_resource", user_id="ignored")
assert result == "static content"
+ async def test_read_callable_resource_returns_dict(self) -> None:
+ """Resource functions may return non-string types, passed through as-is."""
+ skill = Skill(name="prog-skill", description="A skill.", content="Body")
+
+ @skill.resource
+ def get_config() -> Any:
+ return {"max_retries": 3, "timeout": 30}
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("prog-skill", "get_config")
+ assert result == {"max_retries": 3, "timeout": 30}
+
+ async def test_read_callable_resource_returns_list(self) -> None:
+ """Resource functions may return lists, passed through as-is."""
+ skill = Skill(name="prog-skill", description="A skill.", content="Body")
+
+ @skill.resource
+ def get_items() -> Any:
+ return [1, 2, 3]
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("prog-skill", "get_items")
+ assert result == [1, 2, 3]
+
+ async def test_read_callable_resource_returns_none(self) -> None:
+ """Resource functions may return None."""
+ skill = Skill(name="prog-skill", description="A skill.", content="Body")
+
+ @skill.resource
+ def get_nothing() -> Any:
+ return None
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("prog-skill", "get_nothing")
+ assert result is None
+
async def test_before_run_injects_code_skills(self) -> None:
skill = Skill(name="prog-skill", description="A code-defined skill.", content="Body")
provider = SkillsProvider(skills=[skill])
@@ -1570,6 +1650,24 @@ class TestCreateInstructionsEdgeCases:
charlie_pos = result.index("charlie")
assert alpha_pos < bravo_pos < charlie_pos
+ def test_custom_template_missing_runner_instructions_raises(self) -> None:
+ """Custom template without {runner_instructions} raises when scripts are enabled."""
+ skills = {
+ "my-skill": Skill(name="my-skill", description="Skill.", content="Body"),
+ }
+ template = "Skills: {skills}"
+ with pytest.raises(ValueError, match="runner_instructions"):
+ _create_instructions(template, skills, include_script_runner_instructions=True)
+
+ def test_custom_template_with_unknown_placeholder_raises(self) -> None:
+ """Template with an unknown placeholder raises ValueError."""
+ skills = {
+ "my-skill": Skill(name="my-skill", description="Skill.", content="Body"),
+ }
+ template = "Skills: {skills} {unknown_key}"
+ with pytest.raises(ValueError, match="valid format string"):
+ _create_instructions(template, skills)
+
# ---------------------------------------------------------------------------
# Tests: SkillsProvider edge cases
@@ -1609,24 +1707,24 @@ class TestSkillsProviderEdgeCases:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource
- def exploding_resource() -> str:
+ def exploding_resource() -> Any:
raise RuntimeError("boom")
provider = SkillsProvider(skills=[skill])
result = await provider._read_skill_resource("my-skill", "exploding_resource")
- assert result.startswith("Error (RuntimeError):")
+ assert result.startswith("Error:")
assert "Failed to read resource" in result
async def test_read_async_callable_resource_exception_returns_error(self) -> None:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource
- async def async_exploding() -> str:
+ async def async_exploding() -> Any:
raise ValueError("async boom")
provider = SkillsProvider(skills=[skill])
result = await provider._read_skill_resource("my-skill", "async_exploding")
- assert result.startswith("Error (ValueError):")
+ assert result.startswith("Error:")
def test_load_code_skill_xml_escapes_metadata(self) -> None:
skill = Skill(name="my-skill", description='Uses & "quotes"', content="Body")
@@ -1689,7 +1787,7 @@ class TestSkillResourceDecoratorEdgeCases:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource
- def no_docs() -> str:
+ def no_docs() -> Any:
return "data"
assert skill.resources[0].description is None
@@ -1698,7 +1796,7 @@ class TestSkillResourceDecoratorEdgeCases:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource(name="custom-name")
- def get_data() -> str:
+ def get_data() -> Any:
"""Some docs."""
return "data"
@@ -1710,7 +1808,7 @@ class TestSkillResourceDecoratorEdgeCases:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource(description="Custom desc")
- def get_data() -> str:
+ def get_data() -> Any:
return "data"
assert skill.resources[0].name == "get_data"
@@ -1720,13 +1818,1289 @@ class TestSkillResourceDecoratorEdgeCases:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource
- def original() -> str:
+ def original() -> Any:
return "original"
@skill.resource(name="aliased")
- def aliased() -> str:
+ def aliased() -> Any:
return "aliased"
# Both decorated functions should still be callable
assert original() == "original"
assert aliased() == "aliased"
+
+
+# ---------------------------------------------------------------------------
+# SkillScript tests
+# ---------------------------------------------------------------------------
+
+
+class TestSkillScript:
+ """Tests for the SkillScript data model."""
+
+ def test_empty_name_raises(self) -> None:
+ from agent_framework import SkillScript
+
+ with pytest.raises(ValueError, match="Script name cannot be empty"):
+ SkillScript(name="")
+
+ def test_whitespace_name_raises(self) -> None:
+ from agent_framework import SkillScript
+
+ with pytest.raises(ValueError, match="Script name cannot be empty"):
+ SkillScript(name=" ")
+
+ def test_path_default_none(self) -> None:
+ from agent_framework import SkillScript
+
+ script = SkillScript(name="test", function=lambda: None)
+ assert script.path is None
+
+ def test_path_set_explicitly(self) -> None:
+ from agent_framework import SkillScript
+
+ script = SkillScript(name="gen.py", path="/skills/my-skill/scripts/gen.py")
+ assert script.path == "/skills/my-skill/scripts/gen.py"
+
+ def test_create_with_function(self) -> None:
+ from agent_framework import SkillScript
+
+ script = SkillScript(name="analyze", description="Run analysis", function=lambda: "result")
+ assert script.name == "analyze"
+ assert script.description == "Run analysis"
+ assert script.function is not None
+
+ def test_accepts_kwargs_true_for_kwargs_function(self) -> None:
+ from agent_framework import SkillScript
+
+ def func_with_kwargs(**kwargs: Any) -> str:
+ return "result"
+
+ script = SkillScript(name="s1", function=func_with_kwargs)
+ assert script._accepts_kwargs is True
+
+ def test_accepts_kwargs_false_for_regular_function(self) -> None:
+ from agent_framework import SkillScript
+
+ def func_no_kwargs(x: int = 0) -> str:
+ return "result"
+
+ script = SkillScript(name="s1", function=func_no_kwargs)
+ assert script._accepts_kwargs is False
+
+
+# ---------------------------------------------------------------------------
+# @skill.script decorator tests
+# ---------------------------------------------------------------------------
+
+
+class TestSkillScriptDecorator:
+ """Tests for the @skill.script decorator."""
+
+ def test_bare_decorator(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script
+ def analyze(query: str) -> str:
+ """Run analysis."""
+ return "result"
+
+ assert len(skill.scripts) == 1
+ assert skill.scripts[0].name == "analyze"
+ assert skill.scripts[0].description == "Run analysis."
+ assert skill.scripts[0].function is analyze
+
+ def test_parameterized_decorator(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script(name="custom-name", description="Custom desc")
+ def my_func() -> str:
+ return "data"
+
+ assert len(skill.scripts) == 1
+ assert skill.scripts[0].name == "custom-name"
+ assert skill.scripts[0].description == "Custom desc"
+ assert skill.scripts[0].function is my_func
+
+ def test_multiple_scripts(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script
+ def script_a() -> str:
+ return "a"
+
+ @skill.script
+ def script_b() -> str:
+ return "b"
+
+ assert len(skill.scripts) == 2
+ assert skill.scripts[0].name == "script_a"
+ assert skill.scripts[1].name == "script_b"
+
+ def test_async_script(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script
+ async def fetch_data() -> str:
+ """Fetch remote data."""
+ return "data"
+
+ assert len(skill.scripts) == 1
+ assert skill.scripts[0].name == "fetch_data"
+ assert skill.scripts[0].function is fetch_data
+
+ def test_decorator_returns_original_function(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script
+ def original() -> str:
+ return "original"
+
+ @skill.script(name="aliased")
+ def aliased() -> str:
+ return "aliased"
+
+ assert original() == "original"
+ assert aliased() == "aliased"
+
+
+# ---------------------------------------------------------------------------
+# Skill with scripts attribute tests
+# ---------------------------------------------------------------------------
+
+
+class TestSkillWithScripts:
+ """Tests for the Skill class with scripts attribute."""
+
+ def test_default_empty_scripts(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+ assert skill.scripts == []
+
+ def test_scripts_at_construction(self) -> None:
+ from agent_framework import SkillScript
+
+ scripts = [SkillScript(name="s1", function=lambda: None)]
+ skill = Skill(name="my-skill", description="test", content="body", scripts=scripts)
+ assert len(skill.scripts) == 1
+ assert skill.scripts[0].name == "s1"
+
+
+# ---------------------------------------------------------------------------
+# Runner tests
+# ---------------------------------------------------------------------------
+
+
+class TestSkillScriptRunnerProtocol:
+ """Tests for the SkillScriptRunner protocol."""
+
+ async def test_async_callable_satisfies_protocol(self) -> None:
+ from agent_framework import SkillScriptRunner, SkillScript
+
+ results: list[tuple] = []
+
+ async def my_runner(skill, script, args=None):
+ results.append((skill.name, script.name, args))
+ return "executed"
+
+ assert isinstance(my_runner, SkillScriptRunner)
+
+ skill = Skill(name="test-skill", description="test", content="body")
+ script = SkillScript(name="my-script", path="scripts/run.py")
+ skill.scripts.append(script)
+
+ result = await my_runner(skill, script, args={"key": "val"})
+
+ assert result == "executed"
+ assert len(results) == 1
+ assert results[0] == ("test-skill", "my-script", {"key": "val"})
+
+ async def test_callable_class_satisfies_protocol(self) -> None:
+ from agent_framework import SkillScriptRunner, SkillScript
+
+ class _CustomRunner:
+ async def __call__(self, skill, script, args=None):
+ return "custom result"
+
+ runner = _CustomRunner()
+ assert isinstance(runner, SkillScriptRunner)
+
+ skill = Skill(name="test-skill", description="test", content="body")
+ script = SkillScript(name="my-script", function=lambda: None)
+ skill.scripts.append(script)
+
+ result = await runner(skill, script, args={"key": "val"})
+ assert result == "custom result"
+
+ async def test_runner_returns_none(self) -> None:
+ from agent_framework import SkillScript
+
+ async def noop_runner(skill, script, args=None):
+ return None
+
+ skill = Skill(name="test-skill", description="test", content="body")
+ script = SkillScript(name="s1", function=lambda: None)
+
+ result = await noop_runner(skill, script)
+ assert result is None
+
+ async def test_runner_returns_object(self) -> None:
+ from agent_framework import SkillScript
+
+ async def dict_runner(skill, script, args=None):
+ return {"exit_code": 0, "output": "ok"}
+
+ skill = Skill(name="test-skill", description="test", content="body")
+ script = SkillScript(name="s1", path="scripts/run.py")
+
+ result = await dict_runner(skill, script)
+ assert result == {"exit_code": 0, "output": "ok"}
+
+ def test_sync_callable_satisfies_protocol(self) -> None:
+ from agent_framework import SkillScriptRunner, SkillScript
+
+ results: list[tuple] = []
+
+ def my_runner(skill, script, args=None):
+ results.append((skill.name, script.name, args))
+ return "executed"
+
+ assert isinstance(my_runner, SkillScriptRunner)
+
+ skill = Skill(name="test-skill", description="test", content="body")
+ script = SkillScript(name="my-script", path="scripts/run.py")
+ skill.scripts.append(script)
+
+ result = my_runner(skill, script, args={"key": "val"})
+
+ assert result == "executed"
+ assert len(results) == 1
+ assert results[0] == ("test-skill", "my-script", {"key": "val"})
+
+ def test_sync_callable_class_satisfies_protocol(self) -> None:
+ from agent_framework import SkillScriptRunner, SkillScript
+
+ class _SyncRunner:
+ def __call__(self, skill, script, args=None):
+ return "sync result"
+
+ runner = _SyncRunner()
+ assert isinstance(runner, SkillScriptRunner)
+
+ skill = Skill(name="test-skill", description="test", content="body")
+ script = SkillScript(name="my-script", function=lambda: None)
+ skill.scripts.append(script)
+
+ result = runner(skill, script, args={"key": "val"})
+ assert result == "sync result"
+
+ def test_sync_runner_returns_none(self) -> None:
+ from agent_framework import SkillScript
+
+ def noop_runner(skill, script, args=None):
+ return None
+
+ skill = Skill(name="test-skill", description="test", content="body")
+ script = SkillScript(name="s1", function=lambda: None)
+
+ result = noop_runner(skill, script)
+ assert result is None
+
+ def test_sync_runner_returns_object(self) -> None:
+ from agent_framework import SkillScript
+
+ def dict_runner(skill, script, args=None):
+ return {"exit_code": 0, "output": "ok"}
+
+ skill = Skill(name="test-skill", description="test", content="body")
+ script = SkillScript(name="s1", path="scripts/run.py")
+
+ result = dict_runner(skill, script)
+ assert result == {"exit_code": 0, "output": "ok"}
+
+# ---------------------------------------------------------------------------
+# SkillsProvider static factory tests
+# ---------------------------------------------------------------------------
+
+
+class TestSkillsProviderFactories:
+ """Tests for the SkillsProvider constructor auto-wiring behavior."""
+
+ def test_code_skills_with_scripts_creates_provider(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ assert len(provider._skills) == 1
+ # Default runner auto-wired: base tools + run_skill_script
+ assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
+
+ def test_code_skills_no_scripts(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+ provider = SkillsProvider(skills=[skill])
+ # No scripts with functions, no runner — only base tools
+ assert len(provider._tools) == 2
+ assert not any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
+
+ async def test_code_script_runs_directly(self) -> None:
+ from agent_framework import SkillScript
+
+ def my_function(key: str = "") -> str:
+ return f"executed: {key}"
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=my_function))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ result = await run_tool.func(skill_name="my-skill", script_name="s1", args={"key": "hello"})
+
+ assert result == "executed: hello"
+
+ def test_no_scripts_no_tool(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+ # No scripts at all — no run_skill_script tool
+ provider = SkillsProvider(skills=[skill])
+ assert not any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
+
+ def test_file_skills_with_custom_runner(self, tmp_path: Path) -> None:
+ from agent_framework import SkillScriptRunner
+
+ class _CustomRunner:
+ async def __call__(self, skill, script, args=None):
+ return "custom result"
+
+ assert isinstance(_CustomRunner(), SkillScriptRunner)
+
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
+
+ provider = SkillsProvider(
+ skill_paths=str(tmp_path),
+ script_runner=_CustomRunner(),
+ )
+ assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
+
+ def test_file_skills_with_sync_runner(self, tmp_path: Path) -> None:
+ from agent_framework import SkillScriptRunner
+
+ def sync_runner(skill, script, args=None):
+ return "sync result"
+
+ assert isinstance(sync_runner, SkillScriptRunner)
+
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
+
+ provider = SkillsProvider(
+ skill_paths=str(tmp_path),
+ script_runner=sync_runner,
+ )
+ assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
+
+ async def test_file_script_with_sync_runner_executes(self, tmp_path: Path) -> None:
+ """A sync script_runner is awaitable through the provider's run_skill_script."""
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
+
+ def sync_runner(skill, script, args=None):
+ return f"sync: {script.name} args={args}"
+
+ provider = SkillsProvider(
+ skill_paths=str(tmp_path),
+ script_runner=sync_runner,
+ )
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ result = await run_tool.func(skill_name="my-skill", script_name="run.py", args={"key": "val"})
+ assert result == "sync: run.py args={'key': 'val'}"
+
+ def test_file_skills_with_callback_runner(self, tmp_path: Path) -> None:
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
+
+ provider = SkillsProvider(
+ skill_paths=str(tmp_path),
+ script_runner=_noop_script_runner,
+ )
+ assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
+
+ def test_combined_skills(self, tmp_path: Path) -> None:
+ from agent_framework import SkillScript
+
+ skill_dir = tmp_path / "file-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: file-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+
+ code_skill = Skill(name="code-skill", description="test", content="body")
+ code_skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(
+ skill_paths=str(tmp_path),
+ skills=[code_skill],
+ script_runner=_noop_script_runner,
+ )
+ assert "file-skill" in provider._skills
+ assert "code-skill" in provider._skills
+
+ def test_file_scripts_without_runner_raises(self, tmp_path: Path) -> None:
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
+
+ with pytest.raises(ValueError, match="script_runner"):
+ SkillsProvider(skill_paths=str(tmp_path))
+
+ async def test_file_script_error_without_runner(self) -> None:
+ from agent_framework import SkillScript
+
+ # A skill with both a code script and a file-based script
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="code-s", function=lambda: "ok"))
+ skill.scripts.append(SkillScript(name="file-s", path="scripts/s1.py"))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+
+ # Code script works
+ result = await run_tool.func(skill_name="my-skill", script_name="code-s")
+ assert result == "ok"
+
+ # File script without runner returns error
+ result = await run_tool.func(skill_name="my-skill", script_name="file-s")
+ assert "Error" in result
+ assert "script_runner" in result
+
+ async def test_async_code_script_runs_directly(self) -> None:
+ from agent_framework import SkillScript
+
+ async def async_func(x: int = 0) -> str:
+ return f"async: {x}"
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=async_func))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ result = await run_tool.func(skill_name="my-skill", script_name="s1", args={"x": 42})
+ assert result == "async: 42"
+
+ async def test_code_script_returns_object(self) -> None:
+ """Code-defined scripts can return non-string objects."""
+ from agent_framework import SkillScript
+
+ def returns_dict() -> dict:
+ return {"status": "ok", "value": 42}
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=returns_dict))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ result = await run_tool.func(skill_name="my-skill", script_name="s1")
+ assert result == {"status": "ok", "value": 42}
+
+ async def test_code_script_returns_none(self) -> None:
+ """Code-defined scripts returning None pass through as None."""
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ result = await run_tool.func(skill_name="my-skill", script_name="s1")
+ assert result is None
+
+ async def test_script_with_path_and_function_raises_error(self) -> None:
+ """A script cannot have both a path and a function."""
+ from agent_framework import SkillScript
+
+ with pytest.raises(ValueError, match="must have either function or path, not both"):
+ SkillScript(name="s1", function=lambda: "direct", path="scripts/s1.py")
+
+ async def test_script_with_path_errors_without_runner(self) -> None:
+ """A file-based script without a runner should return an error."""
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="code-s", function=lambda: "ok"))
+ skill.scripts.append(SkillScript(name="path-s", path="scripts/s1.py"))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+
+ # Code-only script still works
+ result = await run_tool.func(skill_name="my-skill", script_name="code-s")
+ assert result == "ok"
+
+ # Path+function script without runner returns error
+ result = await run_tool.func(skill_name="my-skill", script_name="path-s")
+ assert "Error" in result
+ assert "script_runner" in result
+
+ async def test_run_skill_script_error_on_missing_skill(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ result = await run_tool.func(skill_name="nonexistent", script_name="s1")
+ assert "Error" in result
+ assert "nonexistent" in result
+
+ async def test_run_skill_script_sync_with_kwargs(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script
+ def greet(name: str, **kwargs: Any) -> str:
+ user_id = kwargs.get("user_id", "unknown")
+ return f"Hello {name} (user={user_id})"
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._run_skill_script("my-skill", "greet", args={"name": "Alice"}, user_id="u42")
+ assert result == "Hello Alice (user=u42)"
+
+ async def test_run_skill_script_async_with_kwargs(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script
+ async def fetch(url: str, **kwargs: Any) -> str:
+ token = kwargs.get("auth_token", "none")
+ return f"fetched {url} with token={token}"
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._run_skill_script("my-skill", "fetch", args={"url": "http://x"}, auth_token="abc")
+ assert result == "fetched http://x with token=abc"
+
+ async def test_run_skill_script_without_kwargs_ignores_extra_args(self) -> None:
+ """Script functions without **kwargs should still work when runtime kwargs are passed."""
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script
+ def simple(query: str) -> str:
+ return f"result: {query}"
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._run_skill_script("my-skill", "simple", args={"query": "test"}, user_id="ignored")
+ assert result == "result: test"
+
+ async def test_run_skill_script_conflicting_args_and_kwargs_raises(self) -> None:
+ """Conflicting keys in args and kwargs should raise TypeError."""
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script
+ def process(**kwargs: Any) -> str:
+ return f"mode={kwargs.get('mode', 'default')}"
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._run_skill_script(
+ "my-skill", "process", args={"mode": "llm-value"}, mode="runtime-value"
+ )
+ assert "Error" in result
+
+ async def test_run_skill_script_error_on_missing_script(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ result = await run_tool.func(skill_name="my-skill", script_name="nonexistent")
+ assert "Error" in result
+ assert "nonexistent" in result
+
+ async def test_run_skill_script_error_on_empty_names(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+
+ result = await run_tool.func(skill_name="", script_name="s1")
+ assert "Error" in result
+
+ result = await run_tool.func(skill_name="my-skill", script_name="")
+ assert "Error" in result
+
+ def test_instructions_include_script_runner_hints(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ assert "run_skill_script" in provider._instructions
+ assert "not as top-level tool parameters" in provider._instructions
+
+ def test_no_scripts_no_runner_no_script_instructions(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+ provider = SkillsProvider(skills=[skill])
+ # No scripts and no runner — instructions should not mention run_skill_script
+ assert "run_skill_script" not in (provider._instructions or "")
+
+ def test_tool_schema_args_description_mentions_key_format(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ args_desc = run_tool.parameters()["properties"]["args"]["description"]
+ assert "without leading dashes" in args_desc
+ assert "script implementation or configured runner" in args_desc
+
+ def test_require_script_approval_sets_approval_mode(self) -> None:
+ """When require_script_approval=True, the run_skill_script tool has approval_mode='always_require'."""
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill], require_script_approval=True)
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ assert run_tool.approval_mode == "always_require"
+
+ def test_require_script_approval_false_by_default(self) -> None:
+ """By default, the run_skill_script tool has approval_mode='never_require'."""
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ assert run_tool.approval_mode == "never_require"
+
+ def test_require_script_approval_does_not_affect_other_tools(self) -> None:
+ """The load_skill and read_skill_resource tools should never require approval."""
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill], require_script_approval=True)
+ other_tools = [t for t in provider._tools if hasattr(t, "name") and t.name != "run_skill_script"]
+ assert len(other_tools) == 2
+ for t in other_tools:
+ assert t.approval_mode == "never_require"
+
+ async def test_code_script_exception_returns_error(self) -> None:
+ """A code script function that raises should return an error string."""
+ from agent_framework import SkillScript
+
+ def failing_script() -> str:
+ raise RuntimeError("Something went wrong")
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="boom", function=failing_script))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ result = await run_tool.func(skill_name="my-skill", script_name="boom")
+ assert "Error" in result
+ assert "boom" in result
+ assert "Something went wrong" not in result
+
+ def test_custom_template_without_runner_placeholder_raises(self) -> None:
+ """Provider with code scripts and custom template missing {runner_instructions} raises."""
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ with pytest.raises(ValueError, match="runner_instructions"):
+ SkillsProvider(
+ skills=[skill],
+ instruction_template="Skills: {skills}",
+ )
+
+
+# ---------------------------------------------------------------------------
+# File script discovery tests
+# ---------------------------------------------------------------------------
+
+
+class TestFileScriptDiscovery:
+ """Tests for automatic .py script discovery in skill directories."""
+
+ def test_discovers_py_files(self, tmp_path: Path) -> None:
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "analyze.py").write_text("print('hi')", encoding="utf-8")
+
+ skills = _discover_file_skills(str(tmp_path))
+ assert "my-skill" in skills
+ assert len(skills["my-skill"].scripts) == 1
+ assert skills["my-skill"].scripts[0].name == "analyze.py"
+
+ def test_discovered_script_has_relative_path(self, tmp_path: Path) -> None:
+ skill_dir = tmp_path / "my-skill"
+ scripts_dir = skill_dir / "scripts"
+ scripts_dir.mkdir(parents=True)
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (scripts_dir / "generate.py").write_text("print('gen')", encoding="utf-8")
+
+ skills = _discover_file_skills(str(tmp_path))
+ script = skills["my-skill"].scripts[0]
+ assert script.path is not None
+ assert not os.path.isabs(script.path)
+ assert script.path == "scripts/generate.py"
+
+ def test_discovers_nested_scripts(self, tmp_path: Path) -> None:
+ skill_dir = tmp_path / "my-skill"
+ scripts_dir = skill_dir / "scripts"
+ scripts_dir.mkdir(parents=True)
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (scripts_dir / "generate.py").write_text("print('gen')", encoding="utf-8")
+
+ skills = _discover_file_skills(str(tmp_path))
+ assert len(skills["my-skill"].scripts) == 1
+ assert skills["my-skill"].scripts[0].name == "scripts/generate.py"
+
+ def test_no_scripts_when_no_py_files(self, tmp_path: Path) -> None:
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "readme.md").write_text("# Docs", encoding="utf-8")
+
+ skills = _discover_file_skills(str(tmp_path))
+ assert len(skills["my-skill"].scripts) == 0
+
+
+class TestCustomScriptExtensions:
+ """Tests for the script_extensions parameter (parity with resource_extensions)."""
+
+ def test_custom_script_extensions_via_discover_file_skills(self, tmp_path: Path) -> None:
+ """_discover_file_skills forwards script_extensions to _discover_script_files."""
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "analyze.py").write_text("print('hi')", encoding="utf-8")
+ (skill_dir / "run.sh").write_text("#!/bin/bash", encoding="utf-8")
+
+ # Default: only .py discovered
+ skills_default = _discover_file_skills(str(tmp_path))
+ script_names_default = [s.name for s in skills_default["my-skill"].scripts]
+ assert "analyze.py" in script_names_default
+ assert "run.sh" not in script_names_default
+
+ # Custom: only .sh discovered
+ skills_custom = _discover_file_skills(str(tmp_path), script_extensions=(".sh",))
+ script_names_custom = [s.name for s in skills_custom["my-skill"].scripts]
+ assert "run.sh" in script_names_custom
+ assert "analyze.py" not in script_names_custom
+
+ def test_custom_script_extensions_via_provider(self, tmp_path: Path) -> None:
+ """SkillsProvider accepts custom script_extensions."""
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "analyze.py").write_text("print('hi')", encoding="utf-8")
+ (skill_dir / "run.sh").write_text("#!/bin/bash", encoding="utf-8")
+
+ # Only discover .sh scripts
+ provider = SkillsProvider(
+ str(tmp_path),
+ script_extensions=(".sh",),
+ script_runner=_noop_script_runner,
+ )
+ skill = provider._skills["my-skill"]
+ script_names = [s.name for s in skill.scripts]
+ assert "run.sh" in script_names
+ assert "analyze.py" not in script_names
+
+ def test_multiple_script_extensions(self, tmp_path: Path) -> None:
+ """Multiple script extensions can be specified."""
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "analyze.py").write_text("print('hi')", encoding="utf-8")
+ (skill_dir / "run.sh").write_text("#!/bin/bash", encoding="utf-8")
+ (skill_dir / "notes.txt").write_text("notes", encoding="utf-8")
+
+ provider = SkillsProvider(
+ str(tmp_path),
+ script_extensions=(".py", ".sh"),
+ script_runner=_noop_script_runner,
+ )
+ skill = provider._skills["my-skill"]
+ script_names = [s.name for s in skill.scripts]
+ assert "analyze.py" in script_names
+ assert "run.sh" in script_names
+ assert "notes.txt" not in script_names
+
+ def test_default_script_extensions_unchanged(self) -> None:
+ """DEFAULT_SCRIPT_EXTENSIONS contains only .py."""
+ assert DEFAULT_SCRIPT_EXTENSIONS == (".py",)
+
+
+# ---------------------------------------------------------------------------
+# _create_instructions with scripts tests
+# ---------------------------------------------------------------------------
+
+
+class TestCreateInstructionsWithScripts:
+ """Tests for script metadata in skill advertisement."""
+
+ def test_excludes_script_count(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ result = _create_instructions(None, {"my-skill": skill})
+ assert result is not None
+ assert "" not in result
+
+ def test_no_scripts_element_when_empty(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ result = _create_instructions(None, {"my-skill": skill})
+ assert result is not None
+ assert "" not in result
+
+
+# ---------------------------------------------------------------------------
+# _load_skill with scripts tests
+# ---------------------------------------------------------------------------
+
+
+class TestLoadSkillWithScripts:
+ """Tests for script metadata in load_skill output."""
+
+ def test_code_skill_includes_scripts_element(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="analyze", description="Run analysis", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ result = provider._load_skill("my-skill")
+
+ assert "" in result
+ assert 'name="analyze"' in result
+ assert 'description="Run analysis"' in result
+
+ def test_code_skill_no_scripts_element(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+ provider = SkillsProvider(skills=[skill])
+ result = provider._load_skill("my-skill")
+ assert "" not in result
+
+ def test_code_skill_scripts_element_contains_parameters(self) -> None:
+ """Scripts XML includes parameters schema when the function has typed parameters."""
+ from agent_framework import SkillScript
+
+ def analyze(query: str, limit: int = 10) -> str:
+ return "result"
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="analyze", description="Run analysis", function=analyze))
+
+ provider = SkillsProvider(skills=[skill])
+ result = provider._load_skill("my-skill")
+
+ assert "" in result
+ assert 'name="analyze"' in result
+ assert "" in result
+ assert '"query"' in result
+
+
+class TestReadSkillResourceWithScripts:
+ """Tests for _read_skill_resource falling back to scripts."""
+
+ async def test_reads_script_with_static_content(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="generate.py", function=lambda: "print('hello')"))
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("my-skill", "generate.py")
+ # Scripts are not returned via _read_skill_resource
+ assert "not found" in result
+
+ async def test_script_not_accessible_via_read_resource(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="run.py", function=lambda: "script output"))
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("my-skill", "run.py")
+ # Scripts are separate from resources
+ assert "not found" in result
+
+ async def test_async_script_not_accessible_via_read_resource(self) -> None:
+ from agent_framework import SkillScript
+
+ async def async_script() -> str:
+ return "async output"
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="run.py", function=async_script))
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("my-skill", "run.py")
+ assert "not found" in result
+
+ async def test_script_case_insensitive_not_in_resources(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="Generate.py", function=lambda: "code"))
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("my-skill", "generate.py")
+ assert "not found" in result
+
+ async def test_resource_takes_priority_over_script(self) -> None:
+ from agent_framework import SkillResource, SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.resources.append(SkillResource(name="data.py", content="resource content"))
+ skill.scripts.append(SkillScript(name="data.py", function=lambda: "script content"))
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("my-skill", "data.py")
+ assert result == "resource content"
+
+ async def test_script_function_error_not_exposed_via_resources(self) -> None:
+ from agent_framework import SkillScript
+
+ def failing_script() -> str:
+ raise RuntimeError("boom")
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="bad.py", function=failing_script))
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("my-skill", "bad.py")
+ assert "not found" in result
+
+
+# ---------------------------------------------------------------------------
+# Tests: _generate_function_schema
+# ---------------------------------------------------------------------------
+
+
+class TestGenerateFunctionSchema:
+ """Tests for SkillScript.parameters_schema lazy generation."""
+
+ def test_simple_function(self) -> None:
+ from agent_framework import SkillScript
+
+ def analyze(query: str, limit: int) -> str:
+ return ""
+
+ script = SkillScript(name="analyze", function=analyze)
+ schema = script.parameters_schema
+ assert schema is not None
+ assert schema["type"] == "object"
+ assert "query" in schema["properties"]
+ assert "limit" in schema["properties"]
+ assert "query" in schema["required"]
+ assert "limit" in schema["required"]
+
+ def test_optional_parameter(self) -> None:
+ from agent_framework import SkillScript
+
+ def fetch(url: str, timeout: int = 30) -> str:
+ return ""
+
+ script = SkillScript(name="fetch", function=fetch)
+ schema = script.parameters_schema
+ assert schema is not None
+ assert "url" in schema["properties"]
+ assert "timeout" in schema["properties"]
+ assert "url" in schema["required"]
+ # timeout has a default, so it should NOT be in required
+ assert "timeout" not in schema.get("required", [])
+
+ def test_no_parameters_returns_none(self) -> None:
+ from agent_framework import SkillScript
+
+ def noop() -> None:
+ pass
+
+ script = SkillScript(name="noop", function=noop)
+ assert script.parameters_schema is None
+
+ def test_skips_self_and_cls(self) -> None:
+ from agent_framework import SkillScript
+
+ def method(self, query: str) -> str: # noqa: ANN001
+ return ""
+
+ script = SkillScript(name="method", function=method)
+ schema = script.parameters_schema
+ assert schema is not None
+ assert "self" not in schema["properties"]
+ assert "query" in schema["properties"]
+
+ def test_skips_var_keyword(self) -> None:
+ from agent_framework import SkillScript
+
+ def func(name: str, **kwargs: Any) -> str:
+ return ""
+
+ script = SkillScript(name="func", function=func)
+ schema = script.parameters_schema
+ assert schema is not None
+ assert "kwargs" not in schema["properties"]
+ assert "name" in schema["properties"]
+
+ def test_async_function(self) -> None:
+ from agent_framework import SkillScript
+
+ async def fetch_data(url: str) -> str:
+ return ""
+
+ script = SkillScript(name="fetch_data", function=fetch_data)
+ schema = script.parameters_schema
+ assert schema is not None
+ assert "url" in schema["properties"]
+
+ def test_bool_and_float_types(self) -> None:
+ from agent_framework import SkillScript
+
+ def process(verbose: bool, threshold: float) -> None:
+ pass
+
+ script = SkillScript(name="process", function=process)
+ schema = script.parameters_schema
+ assert schema is not None
+ assert "verbose" in schema["properties"]
+ assert "threshold" in schema["properties"]
+
+ def test_lazy_generation_is_cached(self) -> None:
+ from agent_framework import SkillScript
+
+ def analyze(query: str) -> str:
+ return ""
+
+ script = SkillScript(name="analyze", function=analyze)
+ first = script.parameters_schema
+ second = script.parameters_schema
+ assert first is second
+
+
+# ---------------------------------------------------------------------------
+# Tests: _create_script_element
+# ---------------------------------------------------------------------------
+
+
+class TestCreateScriptElement:
+ """Tests for _create_script_element."""
+
+ def test_name_only(self) -> None:
+ from agent_framework import SkillScript
+
+ s = SkillScript(name="run.py", path="scripts/run.py")
+ elem = _create_script_element(s)
+ assert elem == ' '
+
+ def test_with_description(self) -> None:
+ from agent_framework import SkillScript
+
+ s = SkillScript(name="run.py", description="Execute script.", path="scripts/run.py")
+ elem = _create_script_element(s)
+ assert elem == ' '
+
+ def test_xml_escapes_name(self) -> None:
+ from agent_framework import SkillScript
+
+ s = SkillScript(name='script"special', path="scripts/s.py")
+ elem = _create_script_element(s)
+ assert """ in elem
+
+ def test_xml_escapes_description(self) -> None:
+ from agent_framework import SkillScript
+
+ s = SkillScript(name="run.py", description='Uses & "quotes"', path="scripts/run.py")
+ elem = _create_script_element(s)
+ assert "<tags>" in elem
+ assert "&" in elem
+ assert """ in elem
+
+ def test_includes_parameters_for_code_script(self) -> None:
+ from agent_framework import SkillScript
+
+ def analyze(query: str, limit: int = 10) -> str:
+ return ""
+
+ s = SkillScript(name="analyze", description="Run analysis", function=analyze)
+ elem = _create_script_element(s)
+ assert "" in elem
+ assert "" in elem
+ assert "query" in elem
+ assert """ not in elem
+
+ def test_no_parameters_for_file_script(self) -> None:
+ from agent_framework import SkillScript
+
+ s = SkillScript(name="run.py", path="scripts/run.py")
+ elem = _create_script_element(s)
+ assert "" not in elem
+
+
+# ---------------------------------------------------------------------------
+# Tests: SkillScript.parameters_schema
+# ---------------------------------------------------------------------------
+
+
+class TestSkillScriptParametersSchema:
+ """Tests for parameters_schema auto-generation on SkillScript."""
+
+ def test_auto_generated_from_function(self) -> None:
+ from agent_framework import SkillScript
+
+ def analyze(query: str) -> str:
+ return ""
+
+ script = SkillScript(name="analyze", function=analyze)
+ assert script.parameters_schema is not None
+ assert "query" in script.parameters_schema["properties"]
+
+ def test_none_for_file_based_script(self) -> None:
+ from agent_framework import SkillScript
+
+ script = SkillScript(name="run.py", path="scripts/run.py")
+ assert script.parameters_schema is None
+
+ def test_no_params_function_returns_none(self) -> None:
+ from agent_framework import SkillScript
+
+ def noop() -> None:
+ pass
+
+ script = SkillScript(name="noop", function=noop)
+ assert script.parameters_schema is None
+
+ def test_kwargs_only_function_returns_none(self) -> None:
+ from agent_framework import SkillScript
+
+ def func(**kwargs: Any) -> str:
+ return ""
+
+ script = SkillScript(name="func", function=func)
+ assert script.parameters_schema is None
+
+ def test_no_params_caching_does_not_reinspect(self) -> None:
+ """parameters_schema caches the None result and does not re-inspect."""
+ from unittest.mock import patch
+
+ from agent_framework import SkillScript
+
+ def noop() -> None:
+ pass
+
+ script = SkillScript(name="noop", function=noop)
+ first = script.parameters_schema
+ assert first is None
+ # Second access should not create a new FunctionTool
+ with patch("agent_framework._skills.FunctionTool", side_effect=RuntimeError("should not be called")):
+ second = script.parameters_schema
+ assert second is None
+
+
+# ---------------------------------------------------------------------------
+# Tests: _load_skills merging behavior
+# ---------------------------------------------------------------------------
+
+
+class TestLoadSkillsMerging:
+ """Tests for _load_skills merging file-based and code-defined skills."""
+
+ def test_code_skill_with_invalid_name_is_skipped(self) -> None:
+ """Code skills with invalid metadata (e.g. uppercase name) are skipped without raising."""
+ invalid_skill = Skill(name="my-skill", description="valid", content="body")
+ # Bypass Skill.__init__ validation by setting the name after construction
+ invalid_skill.name = "INVALID_NAME"
+
+ valid_skill = Skill(name="good-skill", description="valid", content="body")
+
+ result = _load_skills(
+ skill_paths=None,
+ skills=[invalid_skill, valid_skill],
+ resource_extensions=DEFAULT_RESOURCE_EXTENSIONS,
+ script_extensions=DEFAULT_SCRIPT_EXTENSIONS,
+ )
+ assert "good-skill" in result
+ assert "INVALID_NAME" not in result
+
+ def test_file_skill_takes_precedence_over_code_skill(self, tmp_path: Path) -> None:
+ """When file-based and code-defined skills share a name, file-based wins."""
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: File skill.\n---\nFile body.",
+ encoding="utf-8",
+ )
+
+ code_skill = Skill(name="my-skill", description="Code skill.", content="Code body.")
+
+ result = _load_skills(
+ skill_paths=str(tmp_path),
+ skills=[code_skill],
+ resource_extensions=DEFAULT_RESOURCE_EXTENSIONS,
+ script_extensions=DEFAULT_SCRIPT_EXTENSIONS,
+ )
+ assert "my-skill" in result
+ assert result["my-skill"].path is not None # file-based skill has path set
diff --git a/python/samples/02-agents/skills/README.md b/python/samples/02-agents/skills/README.md
new file mode 100644
index 0000000000..29f6a85e31
--- /dev/null
+++ b/python/samples/02-agents/skills/README.md
@@ -0,0 +1,55 @@
+# Agent Skills Samples
+
+These samples demonstrate how to use **Agent Skills** — modular packages of instructions, resources, and scripts that extend an agent's capabilities. Skills follow the [Agent Skills specification](https://agentskills.io/) and use progressive disclosure to optimize token usage.
+
+## Learning Path
+
+Start with file-based or code-defined skills, then explore combining them and adding approval workflows.
+
+| Sample | Description |
+|--------|-------------|
+| [**file_based_skill**](file_based_skill/) | Define skills as `SKILL.md` files on disk with reference documents and executable scripts. Uses the unit-converter skill. |
+| [**code_defined_skill**](code_defined_skill/) | Define skills entirely in Python code using `Skill`, `@skill.resource`, and `@skill.script` decorators. Uses a code-defined unit-converter skill. |
+| [**mixed_skills**](mixed_skills/) | Combine code-defined and file-based skills in a single agent. Uses a code-defined volume-converter and a file-based unit-converter. |
+| [**script_approval**](script_approval/) | Require human-in-the-loop approval before executing skill scripts |
+
+## Key Concepts
+
+### Progressive Disclosure
+
+Skills use a three-step interaction model to minimize token usage:
+
+1. **Advertise** — Skill names and descriptions (~100 tokens each) are injected into the system prompt
+2. **Load** — Full instructions are loaded on-demand via the `load_skill` tool
+3. **Access** — Resources are read via `read_skill_resource`; scripts are executed via `run_skill_script`
+
+### File-Based vs Code-Defined Skills
+
+| Aspect | File-Based | Code-Defined |
+|--------|-----------|--------------|
+| Definition | `SKILL.md` files on disk | `Skill` instances in Python |
+| Resources | Static files in `references/` and `assets/` directories | Callable functions via `@skill.resource` decorator |
+| Scripts | Python files in `scripts/` directory (executed via subprocess) | Callable functions via `@skill.script` decorator (executed in-process) |
+| Discovery | Automatic via `skill_paths` parameter | Explicit via `skills` parameter |
+| Dynamic content | No (static files only) | Yes (functions can generate content at runtime) |
+
+Both types can be combined in a single `SkillsProvider` — see the [mixed_skills](mixed_skills/) sample.
+
+### Script Execution
+
+Skills can include executable scripts. How a script runs depends on how it was defined:
+
+| | Code-Defined Scripts | File-Based Scripts |
+|---|---|---|
+| **Defined via** | `@skill.script` decorator | `.py` files in `scripts/` directory |
+| **Execution** | In-process (direct function call) | Delegated to a `script_runner` |
+| **`script_runner` needed?** | No — runs in-process automatically | **Yes** — required |
+
+The `script_runner` parameter on `SkillsProvider` is only applicable to **file-based** scripts. Code-defined scripts are always executed in-process regardless of this setting. See [file_based_skill](file_based_skill/) for an example using a `SkillScriptRunner` callable with a subprocess runner, and [code_defined_skill](code_defined_skill/) for in-process scripts that need no runner.
+
+## Prerequisites
+
+All samples require:
+- An [Azure AI Foundry](https://ai.azure.com/) project with a deployed model (e.g. `gpt-4o-mini`)
+- Azure CLI authentication (`az login`)
+- Environment variables set in a `.env` file (see `python/.env.example`)
diff --git a/python/samples/02-agents/skills/basic_skill/README.md b/python/samples/02-agents/skills/basic_skill/README.md
deleted file mode 100644
index 1e8e4870e9..0000000000
--- a/python/samples/02-agents/skills/basic_skill/README.md
+++ /dev/null
@@ -1,68 +0,0 @@
-# Agent Skills Sample
-
-This sample demonstrates how to use **Agent Skills** with a `SkillsProvider` in the Microsoft Agent Framework.
-
-## What are Agent Skills?
-
-Agent Skills are modular packages of instructions and resources that enable AI agents to perform specialized tasks. They follow the [Agent Skills specification](https://agentskills.io/) and implement the progressive disclosure pattern:
-
-1. **Advertise**: Skills are advertised with name + description (~100 tokens per skill)
-2. **Load**: Full instructions are loaded on-demand via `load_skill` tool
-3. **Resources**: References and other files loaded via `read_skill_resource` tool
-
-## Skills Included
-
-### expense-report
-Policy-based expense filing with spending limits, receipt requirements, and approval workflows.
-- `references/POLICY_FAQ.md` — Detailed expense policy Q&A
-- `assets/expense-report-template.md` — Submission template
-
-## Project Structure
-
-```
-basic_skill/
-├── basic_skill.py
-├── README.md
-└── skills/
- └── expense-report/
- ├── SKILL.md
- ├── references/
- │ └── POLICY_FAQ.md
- └── assets/
- └── expense-report-template.md
-```
-
-## Running the Sample
-
-### Prerequisites
-- An [Azure AI Foundry](https://ai.azure.com/) project with a deployed model (e.g. `gpt-4o-mini`)
-
-### Environment Variables
-
-Set the required environment variables in a `.env` file (see `python/.env.example`):
-
-- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint
-- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`)
-
-### Authentication
-
-This sample uses `AzureCliCredential` for authentication. Run `az login` in your terminal before running the sample.
-
-### Run
-
-```bash
-cd python
-uv run samples/02-agents/skills/basic_skill/basic_skill.py
-```
-
-### Examples
-
-The sample runs two examples:
-
-1. **Expense policy FAQ** — Asks about tip reimbursement; the agent loads the expense-report skill and reads the FAQ resource
-2. **Filing an expense report** — Multi-turn conversation to draft an expense report using the template asset
-
-## Learn More
-
-- [Agent Skills Specification](https://agentskills.io/)
-- [Microsoft Agent Framework Documentation](../../../../../docs/)
diff --git a/python/samples/02-agents/skills/basic_skill/basic_skill.py b/python/samples/02-agents/skills/basic_skill/basic_skill.py
deleted file mode 100644
index c2f18f73f8..0000000000
--- a/python/samples/02-agents/skills/basic_skill/basic_skill.py
+++ /dev/null
@@ -1,88 +0,0 @@
-# Copyright (c) Microsoft. All rights reserved.
-
-import asyncio
-import os
-from pathlib import Path
-
-from agent_framework import Agent, SkillsProvider
-from agent_framework.azure import AzureOpenAIResponsesClient
-from azure.identity import AzureCliCredential
-from dotenv import load_dotenv
-
-"""
-Agent Skills Sample
-
-This sample demonstrates how to use file-based Agent Skills with a SkillsProvider.
-Agent Skills are modular packages of instructions and resources that extend an agent's
-capabilities. They follow the progressive disclosure pattern:
-
-1. Advertise — skill names and descriptions are injected into the system prompt
-2. Load — full instructions are loaded on-demand via the load_skill tool
-3. Read resources — supplementary files are read via the read_skill_resource tool
-
-This sample includes the expense-report skill:
- - Policy-based expense filing with references and assets
-"""
-
-# Load environment variables from .env file
-load_dotenv()
-
-
-async def main() -> None:
- """Run the Agent Skills demo."""
- # --- Configuration ---
- endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
- deployment = os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "gpt-4o-mini")
-
- # --- 1. Create the chat client ---
- client = AzureOpenAIResponsesClient(
- project_endpoint=endpoint,
- deployment_name=deployment,
- credential=AzureCliCredential(),
- )
-
- # --- 2. Create the skills provider ---
- # Discovers skills from the 'skills' directory and makes them available to the agent
- skills_dir = Path(__file__).parent / "skills"
- skills_provider = SkillsProvider(skill_paths=str(skills_dir))
-
- # --- 3. Create the agent with skills ---
- async with Agent(
- client=client,
- instructions="You are a helpful assistant.",
- context_providers=[skills_provider],
- ) as agent:
- # --- Example 1: Expense policy question (loads FAQ resource) ---
- print("Example 1: Checking expense policy FAQ")
- print("---------------------------------------")
- response1 = await agent.run(
- "Are tips reimbursable? I left a 25% tip on a taxi ride and want to know if that's covered."
- )
- print(f"Agent: {response1}\n")
-
- # --- Example 2: Filing an expense report (uses template asset) ---
- print("Example 2: Filing an expense report")
- print("---------------------------------------")
- session = agent.create_session()
- response2 = await agent.run(
- "I had 3 client dinners and a $1,200 flight last week. "
- "Return a draft expense report and ask about any missing details.",
- session=session,
- )
- print(f"Agent: {response2}\n")
-
-
-if __name__ == "__main__":
- asyncio.run(main())
-
-"""
-Sample output:
-Example 1: Checking expense policy FAQ
----------------------------------------
-Agent: Tips up to 20% are reimbursable for meals, taxi/ride-share, and hotel housekeeping.
-Since you left a 25% tip, the portion above 20% would require written justification...
-
-Example 2: Filing an expense report
----------------------------------------
-Agent: Here's a draft expense report based on what you've told me. I'll need a few more details...
-"""
diff --git a/python/samples/02-agents/skills/basic_skill/skills/expense-report/SKILL.md b/python/samples/02-agents/skills/basic_skill/skills/expense-report/SKILL.md
deleted file mode 100644
index fc6c83cf30..0000000000
--- a/python/samples/02-agents/skills/basic_skill/skills/expense-report/SKILL.md
+++ /dev/null
@@ -1,40 +0,0 @@
----
-name: expense-report
-description: File and validate employee expense reports according to Contoso company policy. Use when asked about expense submissions, reimbursement rules, receipt requirements, spending limits, or expense categories.
-metadata:
- author: contoso-finance
- version: "2.1"
----
-
-# Expense Report
-
-## Categories and Limits
-
-| Category | Limit | Receipt | Approval |
-|---|---|---|---|
-| Meals — solo | $50/day | >$25 | No |
-| Meals — team/client | $75/person | Always | Manager if >$200 total |
-| Lodging | $250/night | Always | Manager if >3 nights |
-| Ground transport | $100/day | >$15 | No |
-| Airfare | Economy | Always | Manager; VP if >$1,500 |
-| Conference/training | $2,000/event | Always | Manager + L&D |
-| Office supplies | $100 | Yes | No |
-| Software/subscriptions | $50/month | Yes | Manager if >$200/year |
-
-## Filing Process
-
-1. Collect receipts — must show vendor, date, amount, payment method.
-2. Categorize per table above.
-3. Use template: [assets/expense-report-template.md](assets/expense-report-template.md).
-4. For client/team meals: list attendee names and business purpose.
-5. Submit — auto-approved if <$500; manager if $500–$2,000; VP if >$2,000.
-6. Reimbursement: 10 business days via direct deposit.
-
-## Policy Rules
-
-- Submit within 30 days of transaction.
-- Alcohol is never reimbursable.
-- Foreign currency: convert to USD at transaction-date rate; note original currency and amount.
-- Mixed personal/business travel: only business portion reimbursable; provide comparison quotes.
-- Lost receipts (>$25): file Lost Receipt Affidavit from Finance. Max 2 per quarter.
-- For policy questions not covered above, consult the FAQ: [references/POLICY_FAQ.md](references/POLICY_FAQ.md). Answers should be based on what this document and the FAQ state.
diff --git a/python/samples/02-agents/skills/basic_skill/skills/expense-report/assets/expense-report-template.md b/python/samples/02-agents/skills/basic_skill/skills/expense-report/assets/expense-report-template.md
deleted file mode 100644
index 3f7c7dc36c..0000000000
--- a/python/samples/02-agents/skills/basic_skill/skills/expense-report/assets/expense-report-template.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Expense Report Template
-
-| Date | Category | Vendor | Description | Amount (USD) | Original Currency | Original Amount | Attendees | Business Purpose | Receipt Attached |
-|------|----------|--------|-------------|--------------|-------------------|-----------------|-----------|------------------|------------------|
-| | | | | | | | | | Yes or No |
diff --git a/python/samples/02-agents/skills/basic_skill/skills/expense-report/references/POLICY_FAQ.md b/python/samples/02-agents/skills/basic_skill/skills/expense-report/references/POLICY_FAQ.md
deleted file mode 100644
index 8e971192f8..0000000000
--- a/python/samples/02-agents/skills/basic_skill/skills/expense-report/references/POLICY_FAQ.md
+++ /dev/null
@@ -1,55 +0,0 @@
-# Expense Policy — Frequently Asked Questions
-
-## Meals
-
-**Q: Can I expense coffee or snacks during the workday?**
-A: Daily coffee/snacks under $10 are not reimbursable (considered personal). Coffee purchased during a client meeting or team working session is reimbursable as a team meal.
-
-**Q: What if a team dinner exceeds the per-person limit?**
-A: The $75/person limit applies as a guideline. Overages up to 20% are accepted with a written justification (e.g., "client dinner at venue chosen by client"). Overages beyond 20% require pre-approval from your VP.
-
-**Q: Do I need to list every attendee?**
-A: Yes. For client meals, list the client's name and company. For team meals, list all employee names. For groups over 10, you may attach a separate attendee list.
-
-## Travel
-
-**Q: Can I book a premium economy or business class flight?**
-A: Economy class is the standard. Premium economy is allowed for flights over 6 hours. Business class requires VP pre-approval and is generally reserved for flights over 10 hours or medical accommodation.
-
-**Q: What about ride-sharing (Uber/Lyft) vs. rental cars?**
-A: Use ride-sharing for trips under 30 miles round-trip. Rent a car for multi-day travel or when ride-sharing would exceed $100/day. Always choose the compact/standard category unless traveling with 3+ people.
-
-**Q: Are tips reimbursable?**
-A: Tips up to 20% are reimbursable for meals, taxi/ride-share, and hotel housekeeping. Tips above 20% require justification.
-
-## Lodging
-
-**Q: What if the $250/night limit isn't enough for the city I'm visiting?**
-A: For high-cost cities (New York, San Francisco, London, Tokyo, Sydney), the limit is automatically increased to $350/night. No additional approval is needed. For other locations where rates are unusually high (e.g., during a major conference), request a per-trip exception from your manager before booking.
-
-**Q: Can I stay with friends/family instead and get a per-diem?**
-A: No. Contoso reimburses actual lodging costs only, not per-diems.
-
-## Subscriptions and Software
-
-**Q: Can I expense a personal productivity tool?**
-A: Software must be directly related to your job function. Tools like IDE licenses, design software, or project management apps are reimbursable. General productivity apps (note-taking, personal calendar) are not, unless your manager confirms a business need in writing.
-
-**Q: What about annual subscriptions?**
-A: Annual subscriptions over $200 require manager approval before purchase. Submit the approval email with your expense report.
-
-## Receipts and Documentation
-
-**Q: My receipt is faded/damaged. What do I do?**
-A: Try to obtain a duplicate from the vendor. If not possible, submit a Lost Receipt Affidavit (available from the Finance SharePoint site). You're limited to 2 affidavits per quarter.
-
-**Q: Do I need a receipt for parking meters or tolls?**
-A: For amounts under $15, no receipt is required — just note the date, location, and amount. For $15 and above, a receipt or bank/credit card statement excerpt is required.
-
-## Approval and Reimbursement
-
-**Q: My manager is on leave. Who approves my report?**
-A: Expense reports can be approved by your skip-level manager or any manager designated as an alternate approver in the expense system.
-
-**Q: Can I submit expenses from a previous quarter?**
-A: The standard 30-day window applies. Expenses older than 30 days require a written explanation and VP approval. Expenses older than 90 days are not reimbursable except in extraordinary circumstances (extended leave, medical emergency) with CFO approval.
diff --git a/python/samples/02-agents/skills/code_defined_skill/README.md b/python/samples/02-agents/skills/code_defined_skill/README.md
new file mode 100644
index 0000000000..ae70268ca4
--- /dev/null
+++ b/python/samples/02-agents/skills/code_defined_skill/README.md
@@ -0,0 +1,49 @@
+# Code-Defined Agent Skills
+
+This sample demonstrates how to create **Agent Skills** in Python code, without needing `SKILL.md` files on disk. A unit-converter skill shows three approaches:
+
+## What's Demonstrated
+
+1. **Static Resources** — Pass inline content via the `resources` parameter when constructing a `Skill`
+2. **Dynamic Resources** — Attach callable functions via the `@skill.resource` decorator that return content computed at runtime
+3. **Dynamic Scripts** — Attach callable scripts via the `@skill.script` decorator (unit conversion via a single factor parameter)
+
+All three can be combined with file-based skills in a single `SkillsProvider`.
+
+## Project Structure
+
+```
+code_defined_skill/
+├── code_defined_skill.py
+└── README.md
+```
+
+## Running the Sample
+
+### Prerequisites
+- An [Azure AI Foundry](https://ai.azure.com/) project with a deployed model (e.g. `gpt-4o-mini`)
+
+### Environment Variables
+
+Set the required environment variables in a `.env` file (see `python/.env.example`):
+
+- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint
+- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`)
+
+### Authentication
+
+This sample uses `AzureCliCredential` for authentication. Run `az login` in your terminal before running the sample.
+
+### Run
+
+```bash
+cd python
+uv run samples/02-agents/skills/code_defined_skill/code_defined_skill.py
+```
+
+## Learn More
+
+- [Agent Skills Specification](https://agentskills.io/)
+- [File-Based Skills Sample](../file_based_skill/)
+- [Mixed Skills Sample](../mixed_skills/)
+- [Microsoft Agent Framework Documentation](../../../../../docs/)
diff --git a/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py b/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py
new file mode 100644
index 0000000000..e9b4757bb6
--- /dev/null
+++ b/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py
@@ -0,0 +1,173 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+import json
+import os
+from textwrap import dedent
+from typing import Any
+
+from agent_framework import Agent, Skill, SkillResource, SkillsProvider
+from agent_framework.azure import AzureOpenAIResponsesClient
+from azure.identity import AzureCliCredential
+from dotenv import load_dotenv
+
+"""
+Code-Defined Agent Skills — Define skills in Python code
+
+This sample demonstrates how to create Agent Skills in code,
+without needing SKILL.md files on disk. Three approaches are shown
+using a unit-converter skill:
+
+1. Static Resources
+ Pass inline content directly via the ``resources`` parameter when
+ constructing the Skill.
+
+2. Dynamic Resources
+ Attach a callable resource via the @skill.resource decorator. The
+ function is invoked on demand, so it can return data computed at
+ runtime.
+
+3. Dynamic Scripts
+ Attach a callable script via the @skill.script decorator. Scripts are
+ executable functions the agent can invoke directly in-process.
+
+Code-defined skills can be combined with file-based skills in a single
+SkillsProvider — see the mixed_skills sample.
+"""
+
+# Load environment variables from .env file
+load_dotenv()
+
+# ---------------------------------------------------------------------------
+# 1. Static Resources — inline content passed at construction time
+# ---------------------------------------------------------------------------
+unit_converter_skill = Skill(
+ name="unit-converter",
+ description="Convert between common units using a conversion factor",
+ content=dedent("""\
+ Use this skill when the user asks to convert between units.
+
+ 1. Review the conversion-tables resource to find the factor for the
+ requested conversion.
+ 2. Check the conversion-policy resource for rounding and formatting rules.
+ 3. Use the convert script, passing the value and factor from the table.
+ """),
+ resources=[
+ SkillResource(
+ name="conversion-tables",
+ content=dedent("""\
+ # Conversion Tables
+
+ Formula: **result = value × factor**
+
+ | From | To | Factor |
+ |-------------|-------------|----------|
+ | miles | kilometers | 1.60934 |
+ | kilometers | miles | 0.621371 |
+ | pounds | kilograms | 0.453592 |
+ | kilograms | pounds | 2.20462 |
+ """),
+ ),
+ ],
+)
+
+
+# ---------------------------------------------------------------------------
+# 2. Dynamic Resources — callable function via @skill.resource
+# ---------------------------------------------------------------------------
+@unit_converter_skill.resource(name="conversion-policy", description="Current conversion formatting and rounding policy")
+def conversion_policy(**kwargs: Any) -> Any:
+ """Return the current conversion policy.
+
+ Dynamic resources are evaluated at runtime, so they can include
+ live data such as dates, configuration values, or database lookups.
+
+ When the resource function accepts ``**kwargs``, runtime keyword
+ arguments passed to ``agent.run()`` are forwarded automatically.
+
+ Args:
+ **kwargs: Runtime keyword arguments from ``agent.run()``.
+ For example, ``agent.run(..., precision=2)``
+ makes ``kwargs["precision"]`` available here.
+ """
+ precision = kwargs.get("precision", 4)
+ return dedent(f"""\
+ # Conversion Policy
+
+ **Decimal places:** {precision}
+ **Format:** Always show both the original and converted values with units
+ """)
+
+
+# ---------------------------------------------------------------------------
+# 3. Dynamic Scripts — in-process callable function
+# ---------------------------------------------------------------------------
+@unit_converter_skill.script(name="convert", description="Convert a value: result = value × factor")
+def convert_units(value: float, factor: float, **kwargs: Any) -> str:
+ """Convert a value using a multiplication factor: result = value × factor.
+
+ The caller looks up the correct factor from the conversion-tables
+ resource and passes it here.
+
+ Args:
+ value: The numeric value to convert.
+ factor: Conversion factor from the conversion table.
+ **kwargs: Runtime keyword arguments from ``agent.run()``.
+ The ``precision`` kwarg controls how many decimal places
+ the result is rounded to (default 4).
+
+ Returns:
+ JSON string with the inputs and converted result.
+ """
+ precision = kwargs.get("precision", 4)
+ result = round(value * factor, precision)
+ return json.dumps({"value": value, "factor": factor, "result": result})
+
+
+async def main() -> None:
+ """Run the code-defined skills demo."""
+ endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
+ deployment = os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "gpt-4o-mini")
+
+ client = AzureOpenAIResponsesClient(
+ project_endpoint=endpoint,
+ deployment_name=deployment,
+ credential=AzureCliCredential(),
+ )
+
+ # Create the skills provider with the code-defined skill
+ skills_provider = SkillsProvider(
+ skills=[unit_converter_skill],
+ )
+
+ async with Agent(
+ client=client,
+ instructions="You are a helpful assistant that can convert units.",
+ context_providers=[skills_provider],
+ ) as agent:
+ print("Converting units")
+ print("-" * 60)
+ response = await agent.run(
+ "How many kilometers is a marathon (26.2 miles)? "
+ "And how many pounds is 75 kilograms?",
+ precision=2,
+ )
+ print(f"Agent: {response}\n")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+
+Converting units
+------------------------------------------------------------
+Agent: Here are your conversions:
+
+1. **26.2 miles → 42.16 km** (a marathon distance)
+2. **75 kg → 165.35 lbs**
+
+I used the conversion factors from the reference table:
+miles × 1.60934 and kilograms × 2.20462.
+"""
diff --git a/python/samples/02-agents/skills/code_skill/README.md b/python/samples/02-agents/skills/code_skill/README.md
deleted file mode 100644
index 4900d00eb5..0000000000
--- a/python/samples/02-agents/skills/code_skill/README.md
+++ /dev/null
@@ -1,57 +0,0 @@
-# Code-Defined Agent Skills Sample
-
-This sample demonstrates how to create **Agent Skills** in Python code, without needing `SKILL.md` files on disk.
-
-## What are Code-Defined Skills?
-
-While file-based skills use `SKILL.md` files discovered on disk, code-defined skills let you define skills entirely in Python using `Skill` and `SkillResource` classes. Three patterns are shown:
-
-1. **Basic Code Skill** — Create a `Skill` directly with static resources (inline content)
-2. **Dynamic Resources** — Attach callable resources via the `@skill.resource` decorator that generate content at invocation time
-3. **Dynamic Resources with kwargs** — Attach a callable resource that accepts `**kwargs` to receive runtime arguments passed via `agent.run()`, useful for injecting request-scoped context (user tokens, session data)
-
-All patterns can be combined with file-based skills in a single `SkillsProvider`.
-
-## Project Structure
-
-```
-code_skill/
-├── code_skill.py
-└── README.md
-```
-
-## Running the Sample
-
-### Prerequisites
-- An [Azure AI Foundry](https://ai.azure.com/) project with a deployed model (e.g. `gpt-4o-mini`)
-
-### Environment Variables
-
-Set the required environment variables in a `.env` file (see `python/.env.example`):
-
-- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint
-- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`)
-
-### Authentication
-
-This sample uses `AzureCliCredential` for authentication. Run `az login` in your terminal before running the sample.
-
-### Run
-
-```bash
-cd python
-uv run samples/02-agents/skills/code_skill/code_skill.py
-```
-
-### Examples
-
-The sample runs two examples:
-
-1. **Code style question** — Uses Pattern 1 (static resources): the agent loads the `code-style` skill and reads the `style-guide` resource to answer naming convention questions
-2. **Project info question** — Uses Patterns 2 & 3 (dynamic resources with kwargs): the agent reads the dynamically generated `team-roster` resource and the `environment` resource which receives `app_version` via runtime kwargs
-
-## Learn More
-
-- [Agent Skills Specification](https://agentskills.io/)
-- [File-based Skills Sample](../basic_skill/)
-- [Microsoft Agent Framework Documentation](../../../../../docs/)
diff --git a/python/samples/02-agents/skills/code_skill/code_skill.py b/python/samples/02-agents/skills/code_skill/code_skill.py
deleted file mode 100644
index e111567244..0000000000
--- a/python/samples/02-agents/skills/code_skill/code_skill.py
+++ /dev/null
@@ -1,161 +0,0 @@
-# Copyright (c) Microsoft. All rights reserved.
-
-import asyncio
-import os
-import sys
-from textwrap import dedent
-from typing import Any
-
-from agent_framework import Agent, Skill, SkillResource, SkillsProvider
-from agent_framework.azure import AzureOpenAIResponsesClient
-from azure.identity import AzureCliCredential
-from dotenv import load_dotenv
-
-"""
-Code-Defined Agent Skills — Define skills in Python code
-
-This sample demonstrates how to create Agent Skills in code,
-without needing SKILL.md files on disk. Three patterns are shown:
-
-Pattern 1: Basic Code Skill
- Create a Skill instance directly with static resources (inline content).
-
-Pattern 2: Dynamic Resources
- Create a Skill and attach callable resources via the @skill.resource
- decorator. Resources can be sync or async functions that generate content at
- invocation time.
-
-Pattern 3: Dynamic Resources with kwargs
- Attach a callable resource that accepts **kwargs to receive runtime
- arguments passed via agent.run(). This is useful for injecting
- request-scoped context (user tokens, session data) into skill resources.
-
-Both patterns can be combined with file-based skills in a single SkillsProvider.
-"""
-
-# Load environment variables from .env file
-load_dotenv()
-
-# Pattern 1: Basic Code Skill — direct construction with static resources
-code_style_skill = Skill(
- name="code-style",
- description="Coding style guidelines and conventions for the team",
- content=dedent("""\
- Use this skill when answering questions about coding style, conventions,
- or best practices for the team.
- """),
- resources=[
- SkillResource(
- name="style-guide",
- content=dedent("""\
- # Team Coding Style Guide
-
- ## General Rules
- - Use 4-space indentation (no tabs)
- - Maximum line length: 120 characters
- - Use type annotations on all public functions
- - Use Google-style docstrings
-
- ## Naming Conventions
- - Classes: PascalCase (e.g., UserAccount)
- - Functions/methods: snake_case (e.g., get_user_name)
- - Constants: UPPER_SNAKE_CASE (e.g., MAX_RETRIES)
- - Private members: prefix with underscore (e.g., _internal_state)
- """),
- ),
- ],
-)
-
-# Pattern 2: Dynamic Resources — @skill.resource decorator
-project_info_skill = Skill(
- name="project-info",
- description="Project status and configuration information",
- content=dedent("""\
- Use this skill for questions about the current project status,
- environment configuration, or team structure.
- """),
-)
-
-
-@project_info_skill.resource
-def environment(**kwargs: Any) -> str:
- """Get current environment configuration."""
- # Access runtime kwargs passed via agent.run(app_version="...")
- app_version = kwargs.get("app_version", "unknown")
- env = os.environ.get("APP_ENV", "development")
- region = os.environ.get("APP_REGION", "us-east-1")
- return f"""\
- # Environment Configuration
- - App Version: {app_version}
- - Environment: {env}
- - Region: {region}
- - Python: {sys.version}
- """
-
-
-@project_info_skill.resource(name="team-roster", description="Current team members and roles")
-def get_team_roster() -> str:
- """Return the team roster."""
- return """\
- # Team Roster
- | Name | Role |
- |--------------|-------------------|
- | Alice Chen | Tech Lead |
- | Bob Smith | Backend Engineer |
- | Carol Davis | Frontend Engineer |
- """
-
-
-async def main() -> None:
- """Run the code-defined skills demo."""
- endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
- deployment = os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "gpt-4o-mini")
-
- client = AzureOpenAIResponsesClient(
- project_endpoint=endpoint,
- deployment_name=deployment,
- credential=AzureCliCredential(),
- )
-
- # Create the skills provider with both code-defined skills
- skills_provider = SkillsProvider(
- skills=[code_style_skill, project_info_skill],
- )
-
- async with Agent(
- client=client,
- instructions="You are a helpful assistant for our development team.",
- context_providers=[skills_provider],
- ) as agent:
- # Example 1: Code style question (Pattern 1 — static resources)
- print("Example 1: Code style question")
- print("-------------------------------")
- response = await agent.run("What naming convention should I use for class attributes?")
- print(f"Agent: {response}\n")
-
- # Example 2: Project info question (Pattern 2 & 3 — dynamic resources with kwargs)
- print("Example 2: Project info question")
- print("---------------------------------")
- # Pass app_version as a runtime kwarg; it flows to the environment() resource via **kwargs
- response = await agent.run("What environment are we running in and who is on the team?", app_version="2.4.1")
- print(f"Agent: {response}\n")
-
- """
- Expected output:
-
- Example 1: Code style question
- -------------------------------
- Agent: Based on our team's coding style guide, class attributes should follow
- snake_case naming. Private attributes use an underscore prefix (_internal_state).
- Constants use UPPER_SNAKE_CASE (MAX_RETRIES).
-
- Example 2: Project info question
- ---------------------------------
- Agent: We're running app version 2.4.1 in the development environment
- in us-east-1. The team consists of Alice Chen (Tech Lead), Bob Smith
- (Backend Engineer), and Carol Davis (Frontend Engineer).
- """
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/python/samples/02-agents/skills/file_based_skill/README.md b/python/samples/02-agents/skills/file_based_skill/README.md
new file mode 100644
index 0000000000..ebc686941f
--- /dev/null
+++ b/python/samples/02-agents/skills/file_based_skill/README.md
@@ -0,0 +1,69 @@
+# File-Based Agent Skills
+
+This sample demonstrates how to use **file-based Agent Skills** with a `SkillsProvider` in the Microsoft Agent Framework. File-based skills are discovered from `SKILL.md` files on disk and can include reference documents and executable scripts.
+
+## What are Agent Skills?
+
+Agent Skills are modular packages of instructions and resources that enable AI agents to perform specialized tasks. They follow the [Agent Skills specification](https://agentskills.io/) and implement progressive disclosure:
+
+1. **Advertise**: Skills are advertised with name + description (~100 tokens per skill)
+2. **Load**: Full instructions are loaded on-demand via `load_skill` tool
+3. **Resources**: References and other files loaded via `read_skill_resource` tool
+4. **Scripts**: Executable scripts run via `run_skill_script` tool
+
+## Skills Included
+
+### unit-converter
+Converts between common units (miles↔km, pounds↔kg) using a multiplication factor following [agentskills.io guidelines](https://agentskills.io/skill-creation/using-scripts).
+- `references/CONVERSION_TABLES.md` — Supported conversions and their factors
+- `scripts/convert.py` — Executable script with `--value` and `--factor` flags, JSON output, and `--help` support
+
+## Key Components
+
+- **`SkillsProvider`** — Discovers skills from `SKILL.md` files in a directory and registers tools for the agent
+- **`subprocess_script_runner`** — A `SkillScriptRunner` callback that runs scripts as local Python subprocesses, enabling the `run_skill_script` tool. Converts argument dicts to CLI flags (e.g. `{"value": 26.2, "factor": 1.60934}` → `--value 26.2 --factor 1.60934`). Shared across samples in [`../subprocess_script_runner.py`](../subprocess_script_runner.py).
+
+## Project Structure
+
+```
+file_based_skill/
+├── file_based_skill.py
+├── README.md
+└── skills/
+ └── unit-converter/
+ ├── SKILL.md
+ ├── references/
+ │ └── CONVERSION_TABLES.md
+ └── scripts/
+ └── convert.py
+```
+
+## Running the Sample
+
+### Prerequisites
+- An [Azure AI Foundry](https://ai.azure.com/) project with a deployed model (e.g. `gpt-4o-mini`)
+
+### Environment Variables
+
+Set the required environment variables in a `.env` file (see `python/.env.example`):
+
+- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint
+- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`)
+
+### Authentication
+
+This sample uses `AzureCliCredential` for authentication. Run `az login` in your terminal before running the sample.
+
+### Run
+
+```bash
+cd python
+uv run samples/02-agents/skills/file_based_skill/file_based_skill.py
+```
+
+## Learn More
+
+- [Agent Skills Specification](https://agentskills.io/)
+- [Code-Defined Skills Sample](../code_defined_skill/)
+- [Mixed Skills Sample](../mixed_skills/)
+- [Microsoft Agent Framework Documentation](../../../../../docs/)
diff --git a/python/samples/02-agents/skills/file_based_skill/file_based_skill.py b/python/samples/02-agents/skills/file_based_skill/file_based_skill.py
new file mode 100644
index 0000000000..044514e7b7
--- /dev/null
+++ b/python/samples/02-agents/skills/file_based_skill/file_based_skill.py
@@ -0,0 +1,94 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+import os
+import sys
+from pathlib import Path
+
+from agent_framework import Agent, SkillsProvider
+from agent_framework.azure import AzureOpenAIResponsesClient
+from azure.identity import AzureCliCredential
+from dotenv import load_dotenv
+
+# Add the skills folder root to sys.path so the shared subprocess_script_runner can be imported
+_SKILLS_ROOT = str(Path(__file__).resolve().parent.parent)
+if _SKILLS_ROOT not in sys.path:
+ sys.path.insert(0, _SKILLS_ROOT)
+
+from subprocess_script_runner import subprocess_script_runner # noqa: E402
+
+"""
+File-Based Agent Skills
+
+This sample demonstrates how to use file-based Agent Skills with a SkillsProvider.
+Agent Skills are modular packages of instructions and resources that extend an agent's
+capabilities. They follow progressive disclosure:
+
+1. Advertise — skill names and descriptions are injected into the system prompt
+2. Load — full instructions are loaded on-demand via the load_skill tool
+3. Read resources — supplementary files are read via the read_skill_resource tool
+4. Run scripts — skill scripts are run via the run_skill_script tool
+
+This sample includes the unit-converter skill which demonstrates all three
+file-based capabilities: instructions (SKILL.md), resources (CONVERSION_TABLES.md),
+and scripts (convert.py).
+"""
+
+# Load environment variables from .env file
+load_dotenv()
+
+
+async def main() -> None:
+ """Run the file-based skills demo."""
+ endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
+ deployment = os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "gpt-4o-mini")
+
+ # Create the chat client
+ client = AzureOpenAIResponsesClient(
+ project_endpoint=endpoint,
+ deployment_name=deployment,
+ credential=AzureCliCredential(),
+ )
+
+ # Create the skills provider
+ # Discovers skills from the 'skills' directory and configures the
+ # subprocess_script_runner to run file-based scripts.
+ skills_dir = Path(__file__).parent / "skills"
+ skills_provider = SkillsProvider(
+ skill_paths=str(skills_dir),
+ script_runner=subprocess_script_runner,
+ )
+
+ # Create the agent with skills
+ async with Agent(
+ client=client,
+ instructions="You are a helpful assistant.",
+ context_providers=[skills_provider],
+ ) as agent:
+ # The agent will: load the unit-converter skill, read the conversion
+ # tables resource, then execute the convert.py script.
+ print("Converting units")
+ print("-" * 60)
+ response = await agent.run(
+ "How many kilometers is a marathon (26.2 miles)? "
+ "And how many pounds is 75 kilograms?"
+ )
+ print(f"Agent: {response}\n")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+
+Converting units
+------------------------------------------------------------
+Agent: Here are your conversions:
+
+1. **26.2 miles → 42.16 km** (a marathon distance)
+2. **75 kg → 165.35 lbs**
+
+I used the conversion factors from the reference table:
+miles × 1.60934 and kilograms × 2.20462.
+"""
diff --git a/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/SKILL.md b/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/SKILL.md
new file mode 100644
index 0000000000..b6e6bef1a3
--- /dev/null
+++ b/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/SKILL.md
@@ -0,0 +1,11 @@
+---
+name: unit-converter
+description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.
+---
+
+## Usage
+
+When the user requests a unit conversion:
+1. First, review `references/CONVERSION_TABLES.md` to find the correct factor
+2. Run the `scripts/convert.py` script with `--value --factor ` (e.g. `--value 26.2 --factor 1.60934`)
+3. Present the converted value clearly with both units
diff --git a/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/references/CONVERSION_TABLES.md b/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/references/CONVERSION_TABLES.md
new file mode 100644
index 0000000000..7a0160b854
--- /dev/null
+++ b/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/references/CONVERSION_TABLES.md
@@ -0,0 +1,10 @@
+# Conversion Tables
+
+Formula: **result = value × factor**
+
+| From | To | Factor |
+|-------------|-------------|----------|
+| miles | kilometers | 1.60934 |
+| kilometers | miles | 0.621371 |
+| pounds | kilograms | 0.453592 |
+| kilograms | pounds | 2.20462 |
diff --git a/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/scripts/convert.py b/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/scripts/convert.py
new file mode 100644
index 0000000000..228c8809ff
--- /dev/null
+++ b/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/scripts/convert.py
@@ -0,0 +1,29 @@
+# Unit conversion script
+# Converts a value using a multiplication factor: result = value × factor
+#
+# Usage:
+# python scripts/convert.py --value 26.2 --factor 1.60934
+# python scripts/convert.py --value 75 --factor 2.20462
+
+import argparse
+import json
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="Convert a value using a multiplication factor.",
+ epilog="Examples:\n"
+ " python scripts/convert.py --value 26.2 --factor 1.60934\n"
+ " python scripts/convert.py --value 75 --factor 2.20462",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument("--value", type=float, required=True, help="The numeric value to convert.")
+ parser.add_argument("--factor", type=float, required=True, help="The conversion factor from the table.")
+ args = parser.parse_args()
+
+ result = round(args.value * args.factor, 4)
+ print(json.dumps({"value": args.value, "factor": args.factor, "result": result}))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/python/samples/02-agents/skills/mixed_skills/README.md b/python/samples/02-agents/skills/mixed_skills/README.md
new file mode 100644
index 0000000000..33b6760719
--- /dev/null
+++ b/python/samples/02-agents/skills/mixed_skills/README.md
@@ -0,0 +1,100 @@
+# Mixed Skills — Code Skills and File Skills
+
+This sample demonstrates how to combine **code-defined skills** and
+**file-based skills** in a single agent using a `SkillScriptRunner` callable
+and `SkillsProvider`.
+
+## Concepts
+
+| Concept | Description |
+|---------|-------------|
+| **Code skill** | A `Skill` created in Python with `@skill.script` decorators for in-process callable functions and `@skill.resource` for dynamic content |
+| **File skill** | A skill discovered from a `SKILL.md` file on disk, with reference documents and executable script files |
+| **`script_runner`** | A callable (sync or async) satisfying the `SkillScriptRunner` protocol — required when file skills have scripts |
+| **`SkillsProvider`** | Registers both code-defined and file-based skills in a single provider |
+
+## Skills in This Sample
+
+### volume-converter (code skill)
+
+Defined entirely in Python code using decorators:
+
+- **`@skill.resource`** — `conversion-table`: gallons↔liters conversion factors
+- **`@skill.script`** — `convert`: converts a value using a multiplication factor
+
+Code scripts run **in-process** — no subprocess or external runner needed.
+
+### unit-converter (file skill)
+
+Discovered from `skills/unit-converter/SKILL.md`:
+
+- **Reference**: `references/CONVERSION_TABLES.md` — supported unit conversions and their factors
+- **Script**: `scripts/convert.py` — converts a value using a multiplication factor (e.g. miles to kilometers)
+
+File scripts are executed as **local Python subprocesses** via the
+`script_runner` callback.
+
+## How It Works
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ SkillsProvider( │
+│ skill_paths="./skills", # file skills │
+│ skills=[volume_converter_skill], # code skills │
+│ script_runner=runner, │
+│ ) │
+└─────────────┬───────────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────┐
+│ script_runner(skill, script, args) │
+│ │
+│ • Code scripts (@skill.script) → in-process call │
+│ • File scripts (scripts/*.py) → subprocess via │
+│ the callback function │
+└─────────────────────────────────────────────────────────────┘
+```
+
+## Prerequisites
+
+Set environment variables (or create a `.env` file):
+
+```
+AZURE_AI_PROJECT_ENDPOINT=https://your-project.openai.azure.com/
+AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4o-mini
+```
+
+Authenticate with Azure CLI:
+
+```bash
+az login
+```
+
+## Running the Sample
+
+```bash
+cd python
+uv run samples/02-agents/skills/mixed_skills/mixed_skills.py
+```
+
+## Directory Structure
+
+```
+mixed_skills/
+├── mixed_skills.py # Main sample — wires code + file skills together
+├── README.md
+└── skills/
+ └── unit-converter/ # File-based skill (discovered from SKILL.md)
+ ├── SKILL.md
+ ├── references/
+ │ └── CONVERSION_TABLES.md
+ └── scripts/
+ └── convert.py
+```
+
+## Learn More
+
+- [File-Based Skills Sample](../file_based_skill/)
+- [Code-Defined Skills Sample](../code_defined_skill/)
+- [Script Approval Sample](../script_approval/)
+- [Agent Skills Specification](https://agentskills.io/)
diff --git a/python/samples/02-agents/skills/mixed_skills/mixed_skills.py b/python/samples/02-agents/skills/mixed_skills/mixed_skills.py
new file mode 100644
index 0000000000..4e0d9173b7
--- /dev/null
+++ b/python/samples/02-agents/skills/mixed_skills/mixed_skills.py
@@ -0,0 +1,160 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+import json
+import os
+import sys
+from pathlib import Path
+from textwrap import dedent
+from typing import Any
+
+from agent_framework import (
+ Agent,
+ Skill,
+ SkillsProvider,
+)
+from agent_framework.azure import AzureOpenAIResponsesClient
+from azure.identity import AzureCliCredential
+from dotenv import load_dotenv
+
+# Add the skills folder root to sys.path so the shared subprocess_script_runner can be imported
+_SKILLS_ROOT = str(Path(__file__).resolve().parent.parent)
+if _SKILLS_ROOT not in sys.path:
+ sys.path.insert(0, _SKILLS_ROOT)
+
+from subprocess_script_runner import subprocess_script_runner # noqa: E402
+
+"""
+Mixed Skills — Code skills and file skills in a single agent
+
+This sample demonstrates how to combine **code-defined skills** (with
+``@skill.script`` and ``@skill.resource`` decorators) and **file-based skills**
+(discovered from ``SKILL.md`` files on disk) in a single agent using
+``SkillsProvider`` and a ``SkillScriptRunner`` callable.
+
+Key concepts shown:
+- Code skills with ``@skill.script``: executable Python functions the agent
+ can invoke directly in-process.
+- Code skills with ``@skill.resource``: dynamic content the agent can read
+ on demand.
+- File skills from disk: ``SKILL.md`` files with reference documents and
+ executable script files.
+- ``script_runner``: routes **file-based** script execution
+ through a callback, enabling custom handling (e.g. subprocess calls).
+ Code-defined scripts (``@skill.script``) run in-process automatically.
+
+The sample registers two skills:
+1. **volume-converter** (code skill) — converts between gallons and liters using
+ ``@skill.script`` for conversion and ``@skill.resource`` for the factor table.
+2. **unit-converter** (file skill) — converts between common units (miles↔km,
+ pounds↔kg) via a subprocess-executed Python script discovered from
+ ``skills/unit-converter/SKILL.md``.
+"""
+
+# Load environment variables from .env file
+load_dotenv()
+
+# ---------------------------------------------------------------------------
+# 1. Define a code skill with @skill.script and @skill.resource decorators
+# ---------------------------------------------------------------------------
+
+volume_converter_skill = Skill(
+ name="volume-converter",
+ description="Convert between gallons and liters using a conversion factor",
+ content=dedent("""\
+ Use this skill when the user asks to convert between gallons and liters.
+
+ 1. Review the conversion-table resource to find the correct factor.
+ 2. Use the convert script, passing the value and factor.
+ """),
+)
+
+
+@volume_converter_skill.resource(name="conversion-table", description="Volume conversion factors")
+def volume_table() -> Any:
+ """Return the volume conversion factor table."""
+ return dedent("""\
+ # Volume Conversion Table
+
+ Formula: **result = value × factor**
+
+ | From | To | Factor |
+ |---------|--------|---------|
+ | gallons | liters | 3.78541 |
+ | liters | gallons| 0.264172|
+ """)
+
+
+@volume_converter_skill.script(name="convert", description="Convert a value: result = value × factor")
+def convert_volume(value: float, factor: float) -> str:
+ """Convert a value using a multiplication factor.
+
+ Args:
+ value: The numeric value to convert.
+ factor: Conversion factor from the table.
+
+ Returns:
+ JSON string with the conversion result.
+ """
+ result = round(value * factor, 4)
+ return json.dumps({"value": value, "factor": factor, "result": result})
+
+
+# ---------------------------------------------------------------------------
+# 2. Wire everything together and run the agent
+# ---------------------------------------------------------------------------
+
+
+async def main() -> None:
+ """Run the combined skills demo."""
+ endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
+ deployment = os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "gpt-4o-mini")
+
+ # Create the chat client
+ client = AzureOpenAIResponsesClient(
+ project_endpoint=endpoint,
+ deployment_name=deployment,
+ credential=AzureCliCredential(),
+ )
+
+ # Create the SkillsProvider with both code and file skills.
+ # The script_runner handles file-based scripts; code-defined scripts
+ # (@skill.script) run in-process automatically.
+ skills_dir = Path(__file__).parent / "skills"
+ skills_provider = SkillsProvider(
+ skill_paths=str(skills_dir),
+ skills=[volume_converter_skill],
+ script_runner=subprocess_script_runner,
+ )
+
+ # Run the agent
+ async with Agent(
+ client=client,
+ instructions="You are a helpful assistant that can convert units.",
+ context_providers=[skills_provider],
+ ) as agent:
+ # Ask the agent to use both skills
+ print("Converting units")
+ print("-" * 60)
+ response = await agent.run(
+ "How many kilometers is a marathon (26.2 miles)? "
+ "And how many liters is a 5-gallon bucket?"
+ )
+ print(f"Agent: {response}\n")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+
+Converting units
+------------------------------------------------------------
+Agent: Here are your conversions:
+
+1. **26.2 miles → 42.16 km** (a marathon distance)
+2. **5 gallons → 18.93 liters**
+
+I used the conversion factors from each skill's reference table.
+"""
diff --git a/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/SKILL.md b/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/SKILL.md
new file mode 100644
index 0000000000..b6e6bef1a3
--- /dev/null
+++ b/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/SKILL.md
@@ -0,0 +1,11 @@
+---
+name: unit-converter
+description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.
+---
+
+## Usage
+
+When the user requests a unit conversion:
+1. First, review `references/CONVERSION_TABLES.md` to find the correct factor
+2. Run the `scripts/convert.py` script with `--value --factor ` (e.g. `--value 26.2 --factor 1.60934`)
+3. Present the converted value clearly with both units
diff --git a/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/references/CONVERSION_TABLES.md b/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/references/CONVERSION_TABLES.md
new file mode 100644
index 0000000000..7a0160b854
--- /dev/null
+++ b/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/references/CONVERSION_TABLES.md
@@ -0,0 +1,10 @@
+# Conversion Tables
+
+Formula: **result = value × factor**
+
+| From | To | Factor |
+|-------------|-------------|----------|
+| miles | kilometers | 1.60934 |
+| kilometers | miles | 0.621371 |
+| pounds | kilograms | 0.453592 |
+| kilograms | pounds | 2.20462 |
diff --git a/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/scripts/convert.py b/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/scripts/convert.py
new file mode 100644
index 0000000000..228c8809ff
--- /dev/null
+++ b/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/scripts/convert.py
@@ -0,0 +1,29 @@
+# Unit conversion script
+# Converts a value using a multiplication factor: result = value × factor
+#
+# Usage:
+# python scripts/convert.py --value 26.2 --factor 1.60934
+# python scripts/convert.py --value 75 --factor 2.20462
+
+import argparse
+import json
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="Convert a value using a multiplication factor.",
+ epilog="Examples:\n"
+ " python scripts/convert.py --value 26.2 --factor 1.60934\n"
+ " python scripts/convert.py --value 75 --factor 2.20462",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument("--value", type=float, required=True, help="The numeric value to convert.")
+ parser.add_argument("--factor", type=float, required=True, help="The conversion factor from the table.")
+ args = parser.parse_args()
+
+ result = round(args.value * args.factor, 4)
+ print(json.dumps({"value": args.value, "factor": args.factor, "result": result}))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/python/samples/02-agents/skills/script_approval/README.md b/python/samples/02-agents/skills/script_approval/README.md
new file mode 100644
index 0000000000..5392e3f2ae
--- /dev/null
+++ b/python/samples/02-agents/skills/script_approval/README.md
@@ -0,0 +1,50 @@
+# Script Approval — Human-in-the-Loop for Skill Scripts
+
+This sample demonstrates how to require **human approval** before executing skill scripts using the `require_script_approval=True` option on `SkillsProvider`.
+
+## How It Works
+
+When `require_script_approval=True` is set, the agent pauses before executing any skill script and returns approval requests instead:
+
+1. The agent tries to call `run_skill_script` — execution is paused
+2. `result.user_input_requests` contains approval request(s) with function name and arguments
+3. The application inspects each request and decides to approve or reject
+4. `request.to_function_approval_response(approved=True|False)` creates the response
+5. The response is sent back via `agent.run(approval_response, session=session)`
+6. If approved, the script executes; if rejected, the agent receives an error
+
+## Key Components
+
+- **`require_script_approval=True`** — Gates all script execution on human approval
+- **`result.user_input_requests`** — Contains pending approval requests after `agent.run()`
+- **`request.to_function_approval_response()`** — Creates an approval or rejection response
+
+## Running the Sample
+
+### Prerequisites
+- An [Azure AI Foundry](https://ai.azure.com/) project with a deployed model (e.g. `gpt-4o-mini`)
+
+### Environment Variables
+
+Set the required environment variables in a `.env` file (see `python/.env.example`):
+
+- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint
+- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`)
+
+### Authentication
+
+This sample uses `AzureCliCredential` for authentication. Run `az login` in your terminal before running the sample.
+
+### Run
+
+```bash
+cd python
+uv run samples/02-agents/skills/script_approval/script_approval.py
+```
+
+## Learn More
+
+- [File-Based Skills Sample](../file_based_skill/)
+- [Code-Defined Skills Sample](../code_defined_skill/)
+- [Mixed Skills Sample](../mixed_skills/)
+- [Agent Skills Specification](https://agentskills.io/)
diff --git a/python/samples/02-agents/skills/script_approval/script_approval.py b/python/samples/02-agents/skills/script_approval/script_approval.py
new file mode 100644
index 0000000000..701d88de06
--- /dev/null
+++ b/python/samples/02-agents/skills/script_approval/script_approval.py
@@ -0,0 +1,124 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+import os
+from textwrap import dedent
+
+from agent_framework import Agent, Skill, SkillsProvider
+from agent_framework.azure import AzureOpenAIResponsesClient
+from azure.identity import AzureCliCredential
+from dotenv import load_dotenv
+
+"""
+Skill Script Approval — Require human approval before executing skill scripts
+
+This sample demonstrates how to use ``require_script_approval=True`` on
+:class:`SkillsProvider` so that every call to ``run_skill_script`` is
+gated by a human-in-the-loop approval step.
+
+How it works:
+1. A code-defined skill with a script is registered via SkillsProvider.
+2. ``require_script_approval=True`` causes the agent to pause and return
+ approval requests in ``result.user_input_requests`` instead of executing
+ scripts immediately.
+3. The application inspects each request and calls
+ ``request.to_function_approval_response(approved=True|False)`` to approve
+ or reject.
+4. The approval response is sent back via ``agent.run(approval_response, session=session)``
+ and the agent continues — executing the script if approved, or receiving
+ an error if rejected.
+
+Prerequisites:
+- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
+- AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME (defaults to "gpt-4o-mini").
+"""
+
+# Load environment variables from .env file
+load_dotenv()
+
+# Define a code skill with a script that performs a sensitive operation
+deployment_skill = Skill(
+ name="deployment",
+ description="Tools for deploying application versions to production",
+ content=dedent("""\
+ Use this skill when the user asks to deploy an application.
+
+ 1. Run the deploy script with the version and environment parameters.
+ """),
+)
+
+
+@deployment_skill.script
+def deploy(version: str, environment: str = "staging") -> str:
+ """Deploy the application to the specified environment."""
+ return f"Deployed version {version} to {environment}"
+
+
+async def main() -> None:
+ """Run the skill script approval demo."""
+ endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
+ deployment = os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "gpt-4o-mini")
+
+ client = AzureOpenAIResponsesClient(
+ project_endpoint=endpoint,
+ deployment_name=deployment,
+ credential=AzureCliCredential(),
+ )
+
+ # Create the skills provider with script approval enabled
+ skills_provider = SkillsProvider(
+ skills=[deployment_skill],
+ require_script_approval=True,
+ )
+
+ async with Agent(
+ client=client,
+ instructions="You are a deployment assistant. Use the deployment skill to deploy applications.",
+ context_providers=[skills_provider],
+ ) as agent:
+ session = agent.create_session()
+
+ print("Starting agent with skill script approval enabled...")
+ print("-" * 60)
+
+ # Step 1: Send the user request — the agent will try to call the script
+ query = "Deploy the latest application version 2.5.0 to the production environment"
+ print(f"User: {query}")
+ result = await agent.run(query, session=session)
+
+ # Step 2: Handle approval requests (with sessions, context is
+ # maintained automatically — just send the approval response)
+ while result.user_input_requests:
+ for request in result.user_input_requests:
+ print(f"\nApproval needed:")
+ print(f" Function: {request.function_call.name}") # type: ignore[union-attr]
+ print(f" Arguments: {request.function_call.arguments}") # type: ignore[union-attr]
+
+ # In a real application, prompt the user here
+ approved = True # Change to False to see rejection
+ print(f" Decision: {'Approved' if approved else 'Rejected'}")
+
+ # Send the approval response — session preserves conversation history
+ approval_response = request.to_function_approval_response(approved=approved)
+ result = await agent.run(approval_response, session=session)
+
+ print(f"\nAgent: {result}")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+
+Starting agent with skill script approval enabled...
+------------------------------------------------------------
+User: Deploy version 2.5.0 to production
+
+Approval needed:
+ Function: run_skill_script
+ Arguments: {"skill_name": "deployment", "script_name": "deploy", ...}
+ Decision: Approved
+
+Agent: Successfully deployed version 2.5.0 to production.
+"""
diff --git a/python/samples/02-agents/skills/subprocess_script_runner.py b/python/samples/02-agents/skills/subprocess_script_runner.py
new file mode 100644
index 0000000000..1d38bae754
--- /dev/null
+++ b/python/samples/02-agents/skills/subprocess_script_runner.py
@@ -0,0 +1,75 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Sample subprocess-based skill script runner.
+
+Executes file-based skill scripts as local Python subprocesses.
+This is provided for demonstration purposes only.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+from typing import Any
+
+from agent_framework import Skill, SkillScript
+
+
+def subprocess_script_runner(skill: Skill, script: SkillScript, args: dict[str, Any] | None = None) -> str:
+ """Run a skill script as a local Python subprocess.
+
+ Resolves the script's absolute path from the skill directory, converts
+ the ``args`` dict to CLI flags, and returns captured output.
+
+ Args:
+ skill: The skill that owns the script.
+ script: The script to run.
+ args: Optional arguments forwarded as CLI flags.
+
+ Returns:
+ The combined stdout/stderr output, or an error message.
+ """
+ if not skill.path:
+ return f"Error: Skill '{skill.name}' has no directory path."
+
+ if not script.path:
+ return f"Error: Script '{script.name}' has no file path. Only file-based scripts can be executed locally."
+
+ script_path = Path(skill.path) / script.path
+ if not script_path.is_file():
+ return f"Error: Script file not found: {script_path}"
+
+ cmd = [sys.executable, str(script_path)]
+
+ # Convert args dict to CLI flags
+ if args:
+ for key, value in args.items():
+ if isinstance(value, bool):
+ if value:
+ cmd.append(f"--{key}")
+ elif value is not None:
+ cmd.append(f"--{key}")
+ cmd.append(str(value))
+
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ cwd=str(script_path.parent),
+ )
+
+ output = result.stdout
+ if result.stderr:
+ output += f"\nStderr:\n{result.stderr}"
+ if result.returncode != 0:
+ output += f"\nScript exited with code {result.returncode}"
+
+ return output.strip() or "(no output)"
+
+ except subprocess.TimeoutExpired:
+ return f"Error: Script '{script.name}' timed out after 30 seconds."
+ except OSError as e:
+ return f"Error: Failed to execute script '{script.name}': {e}"
From 53b0753dfb0a98a821f1e8bcad123beb724b7e60 Mon Sep 17 00:00:00 2001
From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
Date: Wed, 11 Mar 2026 11:53:38 -0700
Subject: [PATCH 2/4] Prepare RC4 release (#4631)
---
python/CHANGELOG.md | 26 +++++++-
python/packages/a2a/pyproject.toml | 4 +-
python/packages/ag-ui/pyproject.toml | 4 +-
python/packages/anthropic/pyproject.toml | 4 +-
.../packages/azure-ai-search/pyproject.toml | 4 +-
python/packages/azure-ai/pyproject.toml | 4 +-
python/packages/azure-cosmos/pyproject.toml | 4 +-
python/packages/azurefunctions/pyproject.toml | 4 +-
python/packages/bedrock/pyproject.toml | 4 +-
python/packages/chatkit/pyproject.toml | 4 +-
python/packages/claude/pyproject.toml | 4 +-
python/packages/copilotstudio/pyproject.toml | 4 +-
python/packages/core/pyproject.toml | 2 +-
python/packages/declarative/pyproject.toml | 4 +-
python/packages/devui/pyproject.toml | 4 +-
python/packages/durabletask/pyproject.toml | 4 +-
python/packages/foundry_local/pyproject.toml | 4 +-
python/packages/github_copilot/pyproject.toml | 4 +-
python/packages/lab/pyproject.toml | 4 +-
python/packages/mem0/pyproject.toml | 4 +-
python/packages/ollama/pyproject.toml | 4 +-
python/packages/orchestrations/pyproject.toml | 4 +-
python/packages/purview/pyproject.toml | 4 +-
python/packages/redis/pyproject.toml | 4 +-
python/pyproject.toml | 4 +-
python/uv.lock | 61 ++++++++++---------
26 files changed, 105 insertions(+), 76 deletions(-)
diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md
index 7ecab1b442..9bdb542519 100644
--- a/python/CHANGELOG.md
+++ b/python/CHANGELOG.md
@@ -7,9 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.0.0rc4] - 2026-03-11
+
+### Added
+
+- **agent-framework-core**: Add `propagate_session` to `as_tool()` for session sharing in agent-as-tool scenarios ([#4439](https://github.com/microsoft/agent-framework/pull/4439))
+- **agent-framework-core**: Forward runtime kwargs to skill resource functions ([#4417](https://github.com/microsoft/agent-framework/pull/4417))
+- **samples**: Add A2A server sample ([#4528](https://github.com/microsoft/agent-framework/pull/4528))
+
+### Changed
+
+- **agent-framework-github-copilot**: [BREAKING] Update integration to use `ToolInvocation` and `ToolResult` types ([#4551](https://github.com/microsoft/agent-framework/pull/4551))
+- **agent-framework-azure-ai**: [BREAKING] Upgrade to `azure-ai-projects` 2.0+ ([#4536](https://github.com/microsoft/agent-framework/pull/4536))
+
### Fixed
+- **agent-framework-core**: Propagate MCP `isError` flag through the function middleware pipeline ([#4511](https://github.com/microsoft/agent-framework/pull/4511))
+- **agent-framework-core**: Fix `as_agent()` not defaulting name/description from client properties ([#4484](https://github.com/microsoft/agent-framework/pull/4484))
+- **agent-framework-core**: Exclude `conversation_id` from chat completions API options ([#4517](https://github.com/microsoft/agent-framework/pull/4517))
+- **agent-framework-core**: Fix conversation ID propagation when `chat_options` is a dict ([#4340](https://github.com/microsoft/agent-framework/pull/4340))
+- **agent-framework-core**: Auto-finalize `ResponseStream` on iteration completion ([#4478](https://github.com/microsoft/agent-framework/pull/4478))
+- **agent-framework-core**: Prevent pickle deserialization of untrusted HITL HTTP input ([#4566](https://github.com/microsoft/agent-framework/pull/4566))
+- **agent-framework-core**: Fix `executor_completed` event handling for non-copyable `raw_representation` in mixed workflows ([#4493](https://github.com/microsoft/agent-framework/pull/4493))
+- **agent-framework-core**: Fix `store=False` not overriding client default ([#4569](https://github.com/microsoft/agent-framework/pull/4569))
- **agent-framework-redis**: Fix `RedisContextProvider` compatibility with redisvl 0.14.0 by using `AggregateHybridQuery` ([#3954](https://github.com/microsoft/agent-framework/pull/3954))
+- **samples**: Fix `chat_response_cancellation` sample to use `Message` objects ([#4532](https://github.com/microsoft/agent-framework/pull/4532))
+- **agent-framework-purview**: Fix broken link in Purview README (Microsoft 365 Dev Program URL) ([#4610](https://github.com/microsoft/agent-framework/pull/4610))
## [1.0.0rc3] - 2026-03-04
@@ -745,7 +768,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
-[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc3...HEAD
+[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc4...HEAD
+[1.0.0rc4]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc3...python-1.0.0rc4
[1.0.0rc3]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc2...python-1.0.0rc3
[1.0.0rc2]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc1...python-1.0.0rc2
[1.0.0rc1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260212...python-1.0.0rc1
diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml
index b7bfdb9275..4d015305c7 100644
--- a/python/packages/a2a/pyproject.toml
+++ b/python/packages/a2a/pyproject.toml
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"a2a-sdk>=0.3.5",
]
diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml
index e41176e4c0..355405142e 100644
--- a/python/packages/ag-ui/pyproject.toml
+++ b/python/packages/ag-ui/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"ag-ui-protocol>=0.1.9",
"fastapi>=0.115.0",
"uvicorn>=0.30.0"
diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml
index 51631bdd30..95be433e5a 100644
--- a/python/packages/anthropic/pyproject.toml
+++ b/python/packages/anthropic/pyproject.toml
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"anthropic>=0.70.0,<1",
]
diff --git a/python/packages/azure-ai-search/pyproject.toml b/python/packages/azure-ai-search/pyproject.toml
index 0827c2d816..d391de0d93 100644
--- a/python/packages/azure-ai-search/pyproject.toml
+++ b/python/packages/azure-ai-search/pyproject.toml
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"azure-search-documents==11.7.0b2",
]
diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml
index 2bd51729c2..0df9533a0b 100644
--- a/python/packages/azure-ai/pyproject.toml
+++ b/python/packages/azure-ai/pyproject.toml
@@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0rc3"
+version = "1.0.0rc4"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"azure-ai-agents == 1.2.0b5",
"azure-ai-inference>=1.0.0b9",
"aiohttp",
diff --git a/python/packages/azure-cosmos/pyproject.toml b/python/packages/azure-cosmos/pyproject.toml
index cae3b3168c..24ffbf8886 100644
--- a/python/packages/azure-cosmos/pyproject.toml
+++ b/python/packages/azure-cosmos/pyproject.toml
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"azure-cosmos>=4.9.0",
]
diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml
index 0bb2ec9612..c9e7890ede 100644
--- a/python/packages/azurefunctions/pyproject.toml
+++ b/python/packages/azurefunctions/pyproject.toml
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"agent-framework-durabletask",
"azure-functions",
"azure-functions-durable",
diff --git a/python/packages/bedrock/pyproject.toml b/python/packages/bedrock/pyproject.toml
index b99ecb91ff..4f1db9f4f3 100644
--- a/python/packages/bedrock/pyproject.toml
+++ b/python/packages/bedrock/pyproject.toml
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"boto3>=1.35.0,<2.0.0",
"botocore>=1.35.0,<2.0.0",
]
diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml
index 74d7216da6..d6fa2bb382 100644
--- a/python/packages/chatkit/pyproject.toml
+++ b/python/packages/chatkit/pyproject.toml
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"openai-chatkit>=1.4.0,<2.0.0",
]
diff --git a/python/packages/claude/pyproject.toml b/python/packages/claude/pyproject.toml
index f1891586f8..2f67d8d947 100644
--- a/python/packages/claude/pyproject.toml
+++ b/python/packages/claude/pyproject.toml
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"claude-agent-sdk>=0.1.25",
]
diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml
index c37fa71ecf..c6d382b923 100644
--- a/python/packages/copilotstudio/pyproject.toml
+++ b/python/packages/copilotstudio/pyproject.toml
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"microsoft-agents-copilotstudio-client>=0.3.1",
]
diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml
index b51fb6321d..7b63f69d1e 100644
--- a/python/packages/core/pyproject.toml
+++ b/python/packages/core/pyproject.toml
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0rc3"
+version = "1.0.0rc4"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml
index 2534339ad7..c16df02fea 100644
--- a/python/packages/declarative/pyproject.toml
+++ b/python/packages/declarative/pyproject.toml
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"powerfx>=0.0.31; python_version < '3.14'",
"pyyaml>=6.0,<7.0",
]
diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml
index a56cf1ab4f..d00ad90aba 100644
--- a/python/packages/devui/pyproject.toml
+++ b/python/packages/devui/pyproject.toml
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"fastapi>=0.104.0",
"uvicorn[standard]>=0.24.0",
"python-dotenv>=1.0.0",
diff --git a/python/packages/durabletask/pyproject.toml b/python/packages/durabletask/pyproject.toml
index 56493f3126..5d773bac60 100644
--- a/python/packages/durabletask/pyproject.toml
+++ b/python/packages/durabletask/pyproject.toml
@@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"durabletask>=1.3.0",
"durabletask-azuremanaged>=1.3.0",
"python-dateutil>=2.8.0",
diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml
index 97dd99f1ca..444ffc1278 100644
--- a/python/packages/foundry_local/pyproject.toml
+++ b/python/packages/foundry_local/pyproject.toml
@@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"foundry-local-sdk>=0.5.1,<1",
]
diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml
index ded7cca079..d6348ec446 100644
--- a/python/packages/github_copilot/pyproject.toml
+++ b/python/packages/github_copilot/pyproject.toml
@@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.11"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"github-copilot-sdk>=0.1.32",
]
diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml
index 17650293ac..d474a3bfcb 100644
--- a/python/packages/lab/pyproject.toml
+++ b/python/packages/lab/pyproject.toml
@@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework"
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Programming Language :: Python :: 3.14",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
]
[project.optional-dependencies]
diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml
index 506c4d75b1..21ed3a8222 100644
--- a/python/packages/mem0/pyproject.toml
+++ b/python/packages/mem0/pyproject.toml
@@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"mem0ai>=1.0.0",
]
diff --git a/python/packages/ollama/pyproject.toml b/python/packages/ollama/pyproject.toml
index dd9ecaf46b..f20b25039a 100644
--- a/python/packages/ollama/pyproject.toml
+++ b/python/packages/ollama/pyproject.toml
@@ -4,7 +4,7 @@ description = "Ollama integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://learn.microsoft.com/en-us/agent-framework/"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"ollama >= 0.5.3",
]
diff --git a/python/packages/orchestrations/pyproject.toml b/python/packages/orchestrations/pyproject.toml
index e15e02f3e3..b10872f2a8 100644
--- a/python/packages/orchestrations/pyproject.toml
+++ b/python/packages/orchestrations/pyproject.toml
@@ -4,7 +4,7 @@ description = "Orchestration patterns for Microsoft Agent Framework. Includes Se
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
]
[tool.uv]
diff --git a/python/packages/purview/pyproject.toml b/python/packages/purview/pyproject.toml
index f30b749435..43da365ba8 100644
--- a/python/packages/purview/pyproject.toml
+++ b/python/packages/purview/pyproject.toml
@@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -24,7 +24,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"azure-core>=1.30.0",
"httpx>=0.27.0",
]
diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml
index 21aaf47865..f35567ca6c 100644
--- a/python/packages/redis/pyproject.toml
+++ b/python/packages/redis/pyproject.toml
@@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core>=1.0.0rc3",
+ "agent-framework-core>=1.0.0rc4",
"redis>=6.4.0",
"redisvl>=0.8.2",
"numpy>=2.2.6"
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 9f4ca3c08c..e916373a06 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
-version = "1.0.0rc3"
+version = "1.0.0rc4"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "agent-framework-core[all]==1.0.0rc3",
+ "agent-framework-core[all]==1.0.0rc4",
]
[dependency-groups]
diff --git a/python/uv.lock b/python/uv.lock
index 4842003720..c261d8903b 100644
--- a/python/uv.lock
+++ b/python/uv.lock
@@ -94,7 +94,7 @@ wheels = [
[[package]]
name = "agent-framework"
-version = "1.0.0rc3"
+version = "1.0.0rc4"
source = { virtual = "." }
dependencies = [
{ name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -143,7 +143,7 @@ dev = [
[[package]]
name = "agent-framework-a2a"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/a2a" }
dependencies = [
{ name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -158,7 +158,7 @@ requires-dist = [
[[package]]
name = "agent-framework-ag-ui"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/ag-ui" }
dependencies = [
{ name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -186,7 +186,7 @@ provides-extras = ["dev"]
[[package]]
name = "agent-framework-anthropic"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/anthropic" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -201,7 +201,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azure-ai"
-version = "1.0.0rc3"
+version = "1.0.0rc4"
source = { editable = "packages/azure-ai" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -220,7 +220,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azure-ai-search"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/azure-ai-search" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -235,7 +235,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azure-cosmos"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/azure-cosmos" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -250,7 +250,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azurefunctions"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/azurefunctions" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -272,7 +272,7 @@ dev = []
[[package]]
name = "agent-framework-bedrock"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/bedrock" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -289,7 +289,7 @@ requires-dist = [
[[package]]
name = "agent-framework-chatkit"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/chatkit" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -304,7 +304,7 @@ requires-dist = [
[[package]]
name = "agent-framework-claude"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/claude" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -319,7 +319,7 @@ requires-dist = [
[[package]]
name = "agent-framework-copilotstudio"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/copilotstudio" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -334,7 +334,7 @@ requires-dist = [
[[package]]
name = "agent-framework-core"
-version = "1.0.0rc3"
+version = "1.0.0rc4"
source = { editable = "packages/core" }
dependencies = [
{ name = "azure-ai-projects", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -414,7 +414,7 @@ provides-extras = ["all"]
[[package]]
name = "agent-framework-declarative"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/declarative" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -439,7 +439,7 @@ dev = [{ name = "types-pyyaml" }]
[[package]]
name = "agent-framework-devui"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/devui" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -475,7 +475,7 @@ provides-extras = ["dev", "all"]
[[package]]
name = "agent-framework-durabletask"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/durabletask" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -502,7 +502,7 @@ dev = [{ name = "types-python-dateutil", specifier = ">=2.9.0" }]
[[package]]
name = "agent-framework-foundry-local"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/foundry_local" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -517,7 +517,7 @@ requires-dist = [
[[package]]
name = "agent-framework-github-copilot"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/github_copilot" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -532,7 +532,7 @@ requires-dist = [
[[package]]
name = "agent-framework-lab"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/lab" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -610,7 +610,7 @@ dev = [
[[package]]
name = "agent-framework-mem0"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/mem0" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -625,7 +625,7 @@ requires-dist = [
[[package]]
name = "agent-framework-ollama"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/ollama" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -640,7 +640,7 @@ requires-dist = [
[[package]]
name = "agent-framework-orchestrations"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/orchestrations" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -651,7 +651,7 @@ requires-dist = [{ name = "agent-framework-core", editable = "packages/core" }]
[[package]]
name = "agent-framework-purview"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/purview" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -668,7 +668,7 @@ requires-dist = [
[[package]]
name = "agent-framework-redis"
-version = "1.0.0b260304"
+version = "1.0.0b260311"
source = { editable = "packages/redis" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -1346,7 +1346,7 @@ name = "clr-loader"
version = "0.2.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
+ { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/24/c12faf3f61614b3131b5c98d3bf0d376b49c7feaa73edca559aeb2aee080/clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446", size = 83605, upload-time = "2026-01-03T23:13:06.984Z" }
wheels = [
@@ -2142,6 +2142,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" },
{ url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" },
{ url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" },
{ url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" },
{ url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" },
{ url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" },
@@ -2150,6 +2151,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
{ url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
{ url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" },
{ url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
{ url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
{ url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
@@ -2158,6 +2160,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
+ { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
@@ -2166,6 +2169,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
@@ -2174,6 +2178,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
@@ -4111,8 +4116,8 @@ name = "powerfx"
version = "0.0.34"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
- { name = "pythonnet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
+ { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
+ { name = "pythonnet", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/fb/6c4bf87e0c74ca1c563921ce89ca1c5785b7576bca932f7255cdf81082a7/powerfx-0.0.34.tar.gz", hash = "sha256:956992e7afd272657ed16d80f4cad24ec95d9e4a79fb9dfa4a068a09e136af32", size = 3237555, upload-time = "2025-12-22T15:50:59.682Z" }
wheels = [
@@ -4729,7 +4734,7 @@ name = "pythonnet"
version = "3.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "clr-loader", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
+ { name = "clr-loader", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" }
wheels = [
From 565c0b1623d3e8f5ae0a31f1b459f150d64cfdf9 Mon Sep 17 00:00:00 2001
From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
Date: Wed, 11 Mar 2026 12:05:27 -0700
Subject: [PATCH 3/4] Updated package versions (#4632)
---
dotnet/nuget/nuget-package.props | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props
index ee3b144b06..7b241e9d56 100644
--- a/dotnet/nuget/nuget-package.props
+++ b/dotnet/nuget/nuget-package.props
@@ -2,11 +2,11 @@
1.0.0
- 3
+ 4
$(VersionPrefix)-rc$(RCNumber)
- $(VersionPrefix)-$(VersionSuffix).260304.1
- $(VersionPrefix)-preview.260304.1
- 1.0.0-rc3
+ $(VersionPrefix)-$(VersionSuffix).260311.1
+ $(VersionPrefix)-preview.260311.1
+ 1.0.0-rc4
Debug;Release;Publish
true
From 3e03a305f638862b7e98d3f612eca4851fda2d2a Mon Sep 17 00:00:00 2001
From: Eduard van Valkenburg
Date: Wed, 11 Mar 2026 20:23:00 +0100
Subject: [PATCH 4/4] Python: Implement annotation-based context compaction
(#4469)
* Implement annotation-based context compaction
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Handle missing compaction attributes in BaseChatClient
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Fix CI typing and bandit issues
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Optimize incremental compaction annotation pass
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* refinement
* Python: add ToolResultCompactionStrategy and CompactionProvider
Add ToolResultCompactionStrategy that collapses older tool-call groups
into short summary messages (e.g. [Tool calls: get_weather]) while
keeping the most recent groups verbatim. This mirrors the .NET
ToolResultCompactionStrategy from PR #4533.
Add CompactionProvider as a context-provider that auto-applies compaction
before each agent turn and stores compacted history in session state
after each turn.
Includes tests and samples for both features.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* refinement and alignment with dotnet PR
* updated tool result compaction
* updated tool result compaction
* Python: add ToolResultCompactionStrategy, CompactionProvider, and skip_excluded
- ToolResultCompactionStrategy collapses older tool-call groups into
[Tool results: func_name: result] summaries with bidirectional tracing
(same pattern as SummarizationStrategy).
- CompactionProvider as BaseContextProvider with separate before_strategy
and after_strategy parameters. before_strategy compacts loaded context;
after_strategy compacts stored history via history_source_id.
- InMemoryHistoryProvider gains skip_excluded flag to filter out messages
marked as excluded by compaction strategies.
- Tests, samples, and exports updated.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fixed checks
* fix mypy
* Fix: ensure summary messages from both strategies get full compaction annotations
SummarizationStrategy was not calling annotate_message_groups after
inserting its summary message, so the summary lacked core group
annotations (id, kind, index, has_reasoning, _excluded). Added the
missing call. ToolResultCompactionStrategy already had it.
Added tests verifying both strategies produce fully annotated summaries.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* updated propagation
* fix mypy
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
...0019-python-context-compaction-strategy.md | 7 +
.../tests/test_aisearch_context_provider.py | 7 +
.../packages/core/agent_framework/__init__.py | 54 +
.../packages/core/agent_framework/_agents.py | 48 +-
.../packages/core/agent_framework/_clients.py | 136 +-
.../core/agent_framework/_compaction.py | 1310 +++++++++++++++++
.../core/agent_framework/_middleware.py | 44 +-
.../core/agent_framework/_sessions.py | 12 +-
.../packages/core/agent_framework/_skills.py | 10 +-
.../packages/core/agent_framework/_tools.py | 13 +
.../packages/core/agent_framework/_types.py | 45 +-
.../core/agent_framework/observability.py | 41 +-
.../openai/_responses_client.py | 1 -
.../packages/core/tests/core/test_agents.py | 132 ++
.../packages/core/tests/core/test_clients.py | 197 +++
.../core/tests/core/test_compaction.py | 954 ++++++++++++
.../core/test_function_invocation_logic.py | 139 ++
.../packages/core/tests/core/test_skills.py | 11 +-
python/packages/core/tests/core/test_types.py | 78 +
.../openai/test_openai_responses_client.py | 52 +
python/pyproject.toml | 2 +-
python/samples/02-agents/compaction/README.md | 23 +
.../samples/02-agents/compaction/advanced.py | 115 ++
.../compaction/agent_client_overrides.py | 144 ++
python/samples/02-agents/compaction/basics.py | 241 +++
.../compaction/compaction_provider.py | 249 ++++
python/samples/02-agents/compaction/custom.py | 89 ++
.../compaction/tiktoken_tokenizer.py | 124 ++
python/uv.lock | 324 ++--
29 files changed, 4397 insertions(+), 205 deletions(-)
create mode 100644 python/packages/core/agent_framework/_compaction.py
create mode 100644 python/packages/core/tests/core/test_compaction.py
create mode 100644 python/samples/02-agents/compaction/README.md
create mode 100644 python/samples/02-agents/compaction/advanced.py
create mode 100644 python/samples/02-agents/compaction/agent_client_overrides.py
create mode 100644 python/samples/02-agents/compaction/basics.py
create mode 100644 python/samples/02-agents/compaction/compaction_provider.py
create mode 100644 python/samples/02-agents/compaction/custom.py
create mode 100644 python/samples/02-agents/compaction/tiktoken_tokenizer.py
diff --git a/docs/decisions/0019-python-context-compaction-strategy.md b/docs/decisions/0019-python-context-compaction-strategy.md
index 11e1c091e5..8fffb185d1 100644
--- a/docs/decisions/0019-python-context-compaction-strategy.md
+++ b/docs/decisions/0019-python-context-compaction-strategy.md
@@ -1240,3 +1240,10 @@ class AttributionAwareStrategy(CompactionStrategy):
- [ADR-0016: Unifying Context Management with ContextPlugin](0016-python-context-middleware.md) — Parent ADR that established `ContextProvider`, `HistoryProvider`, and `AgentSession` architecture.
- [Context Compaction Limitations Analysis](https://gist.github.com/victordibia/ec3f3baf97345f7e47da025cf55b999f) — Detailed analysis of why current architecture cannot support in-run compaction, with attempted solutions and their failure modes. Option 4 in this ADR corresponds to "Option A: Middleware Access to Mutable Message Source" from that analysis; Options 1-3 correspond to "Option B: Tool Loop Hook", adapted here to a `BaseChatClient` hook instead of `FunctionInvocationConfiguration`.
+
+### Implementation Rollout Note
+
+Implementation is split into two phases:
+
+1. **Phase 1 (PR 1):** runtime compaction foundation in `agent_framework/_compaction.py`, in-run integration, and extensive core tests, plus in-run compaction samples (`basics`, `advanced`, `custom`).
+2. **Phase 2 (PR 2):** history/storage compaction (`upsert`-based full replacement), provider support, storage tests, and storage-focused sample (`storage`).
diff --git a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py
index 3c4fb68fe8..4c065174ea 100644
--- a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py
+++ b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py
@@ -16,6 +16,13 @@ from agent_framework_azure_ai_search._context_provider import AzureAISearchConte
# -- Helpers -------------------------------------------------------------------
+@pytest.fixture(autouse=True)
+def clear_azure_search_environment(monkeypatch: pytest.MonkeyPatch) -> None:
+ for key in tuple(os.environ):
+ if key.startswith("AZURE_SEARCH_"):
+ monkeypatch.delenv(key, raising=False)
+
+
class MockSearchResults:
"""Async-iterable mock for Azure SearchClient.search() results."""
diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py
index d7bc38220a..95d9b97d64 100644
--- a/python/packages/core/agent_framework/__init__.py
+++ b/python/packages/core/agent_framework/__init__.py
@@ -29,6 +29,34 @@ from ._clients import (
SupportsMCPTool,
SupportsWebSearchTool,
)
+from ._compaction import (
+ COMPACTION_STATE_KEY,
+ EXCLUDE_REASON_KEY,
+ EXCLUDED_KEY,
+ GROUP_ANNOTATION_KEY,
+ GROUP_HAS_REASONING_KEY,
+ GROUP_ID_KEY,
+ GROUP_INDEX_KEY,
+ GROUP_KIND_KEY,
+ GROUP_TOKEN_COUNT_KEY,
+ SUMMARIZED_BY_SUMMARY_ID_KEY,
+ SUMMARY_OF_GROUP_IDS_KEY,
+ SUMMARY_OF_MESSAGE_IDS_KEY,
+ CharacterEstimatorTokenizer,
+ CompactionProvider,
+ CompactionStrategy,
+ SelectiveToolCallCompactionStrategy,
+ SlidingWindowStrategy,
+ SummarizationStrategy,
+ TokenBudgetComposedStrategy,
+ TokenizerProtocol,
+ ToolResultCompactionStrategy,
+ TruncationStrategy,
+ annotate_message_groups,
+ apply_compaction,
+ included_messages,
+ included_token_count,
+)
from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPWebsocketTool
from ._middleware import (
AgentContext,
@@ -196,7 +224,19 @@ from .exceptions import (
__all__ = [
"AGENT_FRAMEWORK_USER_AGENT",
"APP_INFO",
+ "COMPACTION_STATE_KEY",
"DEFAULT_MAX_ITERATIONS",
+ "EXCLUDED_KEY",
+ "EXCLUDE_REASON_KEY",
+ "GROUP_ANNOTATION_KEY",
+ "GROUP_HAS_REASONING_KEY",
+ "GROUP_ID_KEY",
+ "GROUP_INDEX_KEY",
+ "GROUP_KIND_KEY",
+ "GROUP_TOKEN_COUNT_KEY",
+ "SUMMARIZED_BY_SUMMARY_ID_KEY",
+ "SUMMARY_OF_GROUP_IDS_KEY",
+ "SUMMARY_OF_MESSAGE_IDS_KEY",
"USER_AGENT_KEY",
"USER_AGENT_TELEMETRY_DISABLED_ENV_VAR",
"Agent",
@@ -218,6 +258,7 @@ __all__ = [
"BaseEmbeddingClient",
"BaseHistoryProvider",
"Case",
+ "CharacterEstimatorTokenizer",
"ChatAndFunctionMiddlewareTypes",
"ChatContext",
"ChatMiddleware",
@@ -227,6 +268,8 @@ __all__ = [
"ChatResponse",
"ChatResponseUpdate",
"CheckpointStorage",
+ "CompactionProvider",
+ "CompactionStrategy",
"Content",
"ContinuationToken",
"Default",
@@ -273,6 +316,7 @@ __all__ = [
"Runner",
"RunnerContext",
"SecretString",
+ "SelectiveToolCallCompactionStrategy",
"SessionContext",
"SingleEdgeGroup",
"Skill",
@@ -280,8 +324,10 @@ __all__ = [
"SkillScript",
"SkillScriptRunner",
"SkillsProvider",
+ "SlidingWindowStrategy",
"SubWorkflowRequestMessage",
"SubWorkflowResponseMessage",
+ "SummarizationStrategy",
"SupportsAgentRun",
"SupportsChatGetResponse",
"SupportsCodeInterpreterTool",
@@ -294,8 +340,12 @@ __all__ = [
"SwitchCaseEdgeGroupCase",
"SwitchCaseEdgeGroupDefault",
"TextSpanRegion",
+ "TokenBudgetComposedStrategy",
+ "TokenizerProtocol",
"ToolMode",
+ "ToolResultCompactionStrategy",
"ToolTypes",
+ "TruncationStrategy",
"TypeCompatibilityError",
"UpdateT",
"UsageDetails",
@@ -322,12 +372,16 @@ __all__ = [
"__version__",
"add_usage_details",
"agent_middleware",
+ "annotate_message_groups",
+ "apply_compaction",
"chat_middleware",
"create_edge_runner",
"detect_media_type_from_base64",
"executor",
"function_middleware",
"handler",
+ "included_messages",
+ "included_token_count",
"load_settings",
"map_chat_to_agent_update",
"merge_chat_options",
diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py
index 5cf7ff78a2..2b35b96e58 100644
--- a/python/packages/core/agent_framework/_agents.py
+++ b/python/packages/core/agent_framework/_agents.py
@@ -74,6 +74,7 @@ else:
from typing_extensions import Self, TypedDict # pragma: no cover
if TYPE_CHECKING:
+ from ._compaction import CompactionStrategy, TokenizerProtocol
from ._types import ChatOptions
logger = logging.getLogger("agent_framework")
@@ -177,6 +178,8 @@ class _RunContext(TypedDict):
session_messages: Sequence[Message]
agent_name: str
chat_options: MutableMapping[str, Any]
+ compaction_strategy: CompactionStrategy | None
+ tokenizer: TokenizerProtocol | None
filtered_kwargs: Mapping[str, Any]
finalize_kwargs: Mapping[str, Any]
@@ -665,6 +668,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
default_options: OptionsCoT | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> None:
"""Initialize a Agent instance.
@@ -688,6 +693,10 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
Note: response_format typing does not flow into run outputs when set via default_options.
These can be overridden at runtime via the ``options`` parameter of ``run()``.
tools: The tools to use for the request.
+ compaction_strategy: Optional agent-level in-run compaction.
+ If both this and a compaction_strategy on the underlying client are set, this one is used.
+ tokenizer: Optional agent-level tokenizer.
+ If both this and a tokenizer on the underlying client are set, this one is used.
kwargs: Any additional keyword arguments. Will be stored as ``additional_properties``.
"""
opts = dict(default_options) if default_options else {}
@@ -705,6 +714,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
**kwargs,
)
self.client = client
+ self.compaction_strategy = compaction_strategy
+ self.tokenizer = tokenizer
# Get tools from options or named parameter (named param takes precedence)
tools_ = tools if tools is not None else opts.pop("tools", None)
@@ -799,6 +810,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
session: AgentSession | None = None,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
options: ChatOptions[ResponseModelBoundT],
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[ResponseModelBoundT]]: ...
@@ -811,6 +824,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
session: AgentSession | None = None,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
options: OptionsCoT | ChatOptions[None] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@@ -823,6 +838,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
session: AgentSession | None = None,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
options: OptionsCoT | ChatOptions[Any] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
@@ -834,6 +851,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
session: AgentSession | None = None,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
options: OptionsCoT | ChatOptions[Any] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
"""Run the agent with the given messages and options.
@@ -857,8 +876,14 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
``Agent[OpenAIChatOptions]``, this enables IDE autocomplete for
provider-specific options including temperature, max_tokens, model_id,
tool_choice, and provider-specific options like reasoning_effort.
- kwargs: Additional keyword arguments for the agent.
- Will only be passed to functions that are called.
+ compaction_strategy: Optional per-run compaction override passed to
+ ``client.get_response()``. When omitted, the agent-level override
+ is used, falling back to the client default.
+ tokenizer: Optional per-run tokenizer override passed to
+ ``client.get_response()``. When omitted, the agent-level override
+ is used, falling back to the client default.
+ kwargs: Additional keyword arguments for the agent. These are only
+ passed to functions that are called.
Returns:
When stream=False: An Awaitable[AgentResponse] containing the agent's response.
@@ -873,6 +898,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
session=session,
tools=tools,
options=options,
+ compaction_strategy=compaction_strategy,
+ tokenizer=tokenizer,
kwargs=kwargs,
)
response = cast(
@@ -881,6 +908,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
messages=ctx["session_messages"],
stream=False,
options=ctx["chat_options"], # type: ignore[reportArgumentType]
+ compaction_strategy=ctx["compaction_strategy"],
+ tokenizer=ctx["tokenizer"],
**ctx["filtered_kwargs"],
),
)
@@ -954,6 +983,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
session=session,
tools=tools,
options=options,
+ compaction_strategy=compaction_strategy,
+ tokenizer=tokenizer,
kwargs=kwargs,
)
ctx: _RunContext = ctx_holder["ctx"] # type: ignore[assignment] # Safe: we just assigned it
@@ -961,6 +992,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
messages=ctx["session_messages"],
stream=True,
options=ctx["chat_options"], # type: ignore[reportArgumentType]
+ compaction_strategy=ctx["compaction_strategy"],
+ tokenizer=ctx["tokenizer"],
**ctx["filtered_kwargs"],
)
@@ -1047,6 +1080,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
session: AgentSession | None,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
options: Mapping[str, Any] | None,
+ compaction_strategy: CompactionStrategy | None,
+ tokenizer: TokenizerProtocol | None,
kwargs: dict[str, Any],
) -> _RunContext:
opts = dict(options) if options else {}
@@ -1081,9 +1116,10 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
options=opts,
)
+ agent_name = self._get_agent_name()
+
# Normalize tools
normalized_tools = normalize_tools(tools_)
- agent_name = self._get_agent_name()
# Resolve final tool list (runtime provided tools + local MCP server tools)
final_tools: list[FunctionTool | Callable[..., Any] | dict[str, Any] | Any] = []
@@ -1153,6 +1189,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
"session_messages": session_messages,
"agent_name": agent_name,
"chat_options": co,
+ "compaction_strategy": compaction_strategy or self.compaction_strategy,
+ "tokenizer": tokenizer or self.tokenizer,
"filtered_kwargs": filtered_kwargs,
"finalize_kwargs": finalize_kwargs,
}
@@ -1408,6 +1446,8 @@ class Agent(
default_options: OptionsCoT | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> None:
"""Initialize a Agent instance."""
@@ -1421,5 +1461,7 @@ class Agent(
default_options=default_options,
context_providers=context_providers,
middleware=middleware,
+ compaction_strategy=compaction_strategy,
+ tokenizer=tokenizer,
**kwargs,
)
diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py
index 5dd049ecd3..5f9c1bb08f 100644
--- a/python/packages/core/agent_framework/_clients.py
+++ b/python/packages/core/agent_framework/_clients.py
@@ -52,6 +52,7 @@ else:
if TYPE_CHECKING:
from ._agents import Agent
+ from ._compaction import CompactionStrategy, TokenizerProtocol
from ._middleware import (
MiddlewareTypes,
)
@@ -134,6 +135,8 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]):
*,
stream: Literal[False] = ...,
options: ChatOptions[ResponseModelBoundT],
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ...
@@ -144,6 +147,8 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]):
*,
stream: Literal[False] = ...,
options: OptionsContraT | ChatOptions[None] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[Any]]: ...
@@ -154,6 +159,8 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]):
*,
stream: Literal[True],
options: OptionsContraT | ChatOptions[Any] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ...
@@ -163,6 +170,8 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]):
*,
stream: bool = False,
options: OptionsContraT | ChatOptions[Any] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]:
"""Send input and return the response.
@@ -171,6 +180,8 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]):
messages: The sequence of input messages to send.
stream: Whether to stream the response. Defaults to False.
options: Chat options as a TypedDict.
+ compaction_strategy: Optional per-call compaction override.
+ tokenizer: Optional per-call tokenizer override.
**kwargs: Additional chat options.
Returns:
@@ -252,7 +263,13 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
"""
OTEL_PROVIDER_NAME: ClassVar[str] = "unknown"
- DEFAULT_EXCLUDE: ClassVar[set[str]] = {"additional_properties"}
+ compaction_strategy: CompactionStrategy | None = None
+ tokenizer: TokenizerProtocol | None = None
+ DEFAULT_EXCLUDE: ClassVar[set[str]] = {
+ "additional_properties",
+ "compaction_strategy",
+ "tokenizer",
+ }
STORES_BY_DEFAULT: ClassVar[bool] = False
"""Whether this client stores conversation history server-side by default.
@@ -267,15 +284,21 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
self,
*,
additional_properties: dict[str, Any] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> None:
"""Initialize a BaseChatClient instance.
Keyword Args:
additional_properties: Additional properties for the client.
+ compaction_strategy: Optional compaction strategy to apply before model calls.
+ tokenizer: Optional tokenizer used by token-aware compaction strategies.
kwargs: Additional keyword arguments (merged into additional_properties).
"""
self.additional_properties = additional_properties or {}
+ self.compaction_strategy = compaction_strategy
+ self.tokenizer = tokenizer
super().__init__(**kwargs)
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
@@ -337,6 +360,46 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
finalizer=lambda updates: self._finalize_response_updates(updates, response_format=response_format),
)
+ async def _prepare_messages_for_model_call(
+ self,
+ messages: Sequence[Message],
+ *,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
+ ) -> list[Message]:
+ prepared_messages = list(messages)
+ if compaction_strategy is None:
+ if tokenizer is None:
+ return prepared_messages
+ from ._compaction import annotate_message_groups
+
+ annotate_message_groups(prepared_messages, tokenizer=tokenizer)
+ return prepared_messages
+ from ._compaction import apply_compaction
+
+ return await apply_compaction(
+ prepared_messages,
+ strategy=compaction_strategy,
+ tokenizer=tokenizer,
+ )
+
+ def _resolve_compaction_overrides(
+ self,
+ *,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
+ ) -> dict[str, Any]:
+ current_compaction_strategy = getattr(self, "compaction_strategy", None)
+ current_tokenizer = getattr(self, "tokenizer", None)
+ ret: dict[str, Any] = {}
+ if current_compaction_strategy is not None or compaction_strategy is not None:
+ ret["compaction_strategy"] = (
+ current_compaction_strategy if compaction_strategy is None else compaction_strategy
+ )
+ if current_tokenizer is not None or tokenizer is not None:
+ ret["tokenizer"] = current_tokenizer if tokenizer is None else tokenizer
+ return ret
+
# region Internal method to be implemented by derived classes
@abstractmethod
@@ -374,6 +437,8 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
*,
stream: Literal[False] = ...,
options: ChatOptions[ResponseModelBoundT],
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ...
@@ -384,6 +449,8 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
*,
stream: Literal[False] = ...,
options: OptionsCoT | ChatOptions[None] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[Any]]: ...
@@ -394,6 +461,8 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
*,
stream: Literal[True],
options: OptionsCoT | ChatOptions[Any] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ...
@@ -403,6 +472,8 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
*,
stream: bool = False,
options: OptionsCoT | ChatOptions[Any] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]:
"""Get a response from a chat client.
@@ -411,17 +482,62 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
messages: The message or messages to send to the model.
stream: Whether to stream the response. Defaults to False.
options: Chat options as a TypedDict.
+ compaction_strategy: Optional per-call override for in-run compaction.
+ When omitted, the client-level default is used.
+ tokenizer: Optional per-call tokenizer override. When omitted, the
+ client-level default is used.
**kwargs: Other keyword arguments, can be used to pass function specific parameters.
Returns:
When streaming a response stream of ChatResponseUpdates, otherwise an Awaitable ChatResponse.
"""
- return self._inner_get_response(
- messages=messages,
- stream=stream,
- options=options or {}, # type: ignore[arg-type]
- **kwargs,
+ compaction_overrides = self._resolve_compaction_overrides(
+ compaction_strategy=compaction_strategy,
+ tokenizer=tokenizer,
)
+ if not compaction_overrides:
+ return self._inner_get_response(
+ messages=messages,
+ stream=stream,
+ options=options or {},
+ **kwargs,
+ )
+
+ if stream:
+
+ async def _get_stream() -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]:
+ prepared_messages = await self._prepare_messages_for_model_call(
+ messages,
+ **compaction_overrides,
+ )
+ stream_response = self._inner_get_response(
+ messages=prepared_messages,
+ stream=True,
+ options=options or {},
+ **kwargs,
+ )
+ if isinstance(stream_response, ResponseStream):
+ return stream_response # type: ignore[reportUnknownVariableType]
+ awaited_stream_response = await stream_response
+ if isinstance(awaited_stream_response, ResponseStream):
+ return awaited_stream_response
+ raise ValueError("Streaming responses must return a ResponseStream.")
+
+ return ResponseStream.from_awaitable(_get_stream()) # type: ignore[reportUnknownVariableType]
+
+ async def _get_response() -> ChatResponse[Any]:
+ prepared_messages = await self._prepare_messages_for_model_call(
+ messages,
+ **compaction_overrides,
+ )
+ return await self._inner_get_response(
+ messages=prepared_messages,
+ stream=False,
+ options=options or {},
+ **kwargs,
+ )
+
+ return _get_response()
def service_url(self) -> str:
"""Get the URL of the service.
@@ -446,6 +562,8 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
context_providers: Sequence[Any] | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Agent[OptionsCoT]:
"""Create a Agent with this client.
@@ -468,6 +586,10 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
context_providers: Context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
function_invocation_configuration: Optional function invocation configuration override.
+ compaction_strategy: Optional agent-level compaction override. When omitted,
+ client-level compaction defaults remain in effect for each call.
+ tokenizer: Optional agent-level tokenizer override. When omitted,
+ client-level tokenizer defaults remain in effect for each call.
kwargs: Any additional keyword arguments. Will be stored as ``additional_properties``.
Returns:
@@ -504,6 +626,8 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
context_providers=context_providers,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
+ compaction_strategy=compaction_strategy,
+ tokenizer=tokenizer,
**kwargs,
)
diff --git a/python/packages/core/agent_framework/_compaction.py b/python/packages/core/agent_framework/_compaction.py
new file mode 100644
index 0000000000..07d18da695
--- /dev/null
+++ b/python/packages/core/agent_framework/_compaction.py
@@ -0,0 +1,1310 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+from __future__ import annotations
+
+import json
+import logging
+from collections.abc import Mapping, Sequence
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Final,
+ Literal,
+ Protocol,
+ TypeAlias,
+ runtime_checkable,
+)
+
+from ._sessions import BaseContextProvider
+from ._types import ChatResponse, Content, Message
+
+if TYPE_CHECKING:
+ from ._clients import SupportsChatGetResponse
+
+GroupKind: TypeAlias = Literal["system", "user", "assistant_text", "tool_call"]
+GROUP_ANNOTATION_KEY = "_group"
+GROUP_ID_KEY = "id"
+GROUP_KIND_KEY = "kind"
+GROUP_INDEX_KEY = "index"
+GROUP_HAS_REASONING_KEY = "has_reasoning"
+GROUP_TOKEN_COUNT_KEY = "token_count" # noqa: S105 # nosec B105 - compaction metadata key, not a credential
+EXCLUDED_KEY = "_excluded"
+EXCLUDE_REASON_KEY = "_exclude_reason"
+SUMMARY_OF_MESSAGE_IDS_KEY = "_summary_of_message_ids"
+SUMMARY_OF_GROUP_IDS_KEY = "_summary_of_group_ids"
+SUMMARIZED_BY_SUMMARY_ID_KEY = "_summarized_by_summary_id"
+
+
+logger = logging.getLogger("agent_framework")
+
+
+@runtime_checkable
+class TokenizerProtocol(Protocol):
+ """Protocol for token counters used by token-aware compaction strategies."""
+
+ def count_tokens(self, text: str) -> int:
+ """Count tokens for a serialized message payload."""
+ ...
+
+
+@runtime_checkable
+class CompactionStrategy(Protocol):
+ """Protocol for in-place message compaction strategies."""
+
+ async def __call__(self, messages: list[Message]) -> bool:
+ """Mutate message annotations and/or list contents in place.
+
+ Assumes caller has already applied grouping annotations (and token
+ annotations when required by the strategy).
+
+ Returns:
+ True if compaction changed message inclusion or content; otherwise False.
+ """
+ ...
+
+
+class CharacterEstimatorTokenizer:
+ """Fast heuristic tokenizer using a 4-char/token estimate."""
+
+ def count_tokens(self, text: str) -> int:
+ return max(1, len(text) // 4)
+
+
+def _has_content_type(message: Message, content_type: str) -> bool:
+ return any(content.type == content_type for content in message.contents)
+
+
+def _has_function_call(message: Message) -> bool:
+ return _has_content_type(message, "function_call")
+
+
+def _has_reasoning(message: Message) -> bool:
+ return _has_content_type(message, "text_reasoning")
+
+
+def _is_tool_call_assistant(message: Message) -> bool:
+ return message.role == "assistant" and _has_function_call(message)
+
+
+def _is_reasoning_only_assistant(message: Message) -> bool:
+ if message.role != "assistant" or not message.contents:
+ return False
+ return all(content.type == "text_reasoning" for content in message.contents)
+
+
+def _ensure_message_ids(messages: list[Message]) -> None:
+ for index, message in enumerate(messages):
+ if not message.message_id:
+ message.message_id = f"msg_{index}"
+
+
+def _group_id_for(message: Message, group_index: int) -> str:
+ if message.message_id:
+ return f"group_{message.message_id}"
+ return f"group_index_{group_index}"
+
+
+def group_messages(messages: list[Message]) -> list[dict[str, Any]]:
+ """Compute group spans and metadata for annotation.
+
+ Returns:
+ Ordered list of lightweight span dicts with keys:
+ ``group_id``, ``kind``, ``start_index``, ``end_index``, ``has_reasoning``.
+ """
+ _ensure_message_ids(messages)
+ spans: list[dict[str, Any]] = []
+ i = 0
+ group_index = 0
+
+ while i < len(messages):
+ current = messages[i]
+
+ if current.role == "system":
+ spans.append({
+ "group_id": _group_id_for(current, group_index),
+ "kind": "system",
+ "start_index": i,
+ "end_index": i,
+ "has_reasoning": _has_reasoning(current),
+ })
+ i += 1
+ group_index += 1
+ continue
+
+ if current.role == "user":
+ spans.append({
+ "group_id": _group_id_for(current, group_index),
+ "kind": "user",
+ "start_index": i,
+ "end_index": i,
+ "has_reasoning": _has_reasoning(current),
+ })
+ i += 1
+ group_index += 1
+ continue
+
+ # Reasoning prefix before an assistant function_call joins the same tool_call group.
+ # This includes the OpenAI Responses shape where reasoning and function_call
+ # contents are co-located in the same assistant message.
+ if _is_reasoning_only_assistant(current):
+ prefix_start = i
+ j = i
+ while j < len(messages) and _is_reasoning_only_assistant(messages[j]):
+ j += 1
+ if j < len(messages) and _is_tool_call_assistant(messages[j]):
+ k = j + 1
+ has_reasoning = True
+ while k < len(messages) and _is_reasoning_only_assistant(messages[k]):
+ has_reasoning = True
+ k += 1
+ while k < len(messages) and messages[k].role == "tool":
+ k += 1
+ spans.append({
+ "group_id": _group_id_for(messages[prefix_start], group_index),
+ "kind": "tool_call",
+ "start_index": prefix_start,
+ "end_index": k - 1,
+ "has_reasoning": has_reasoning or _has_reasoning(messages[j]),
+ })
+ i = k
+ group_index += 1
+ continue
+
+ if _is_tool_call_assistant(current):
+ has_reasoning = _has_reasoning(current)
+ k = i + 1
+ while k < len(messages) and _is_reasoning_only_assistant(messages[k]):
+ has_reasoning = True
+ k += 1
+ while k < len(messages) and messages[k].role == "tool":
+ k += 1
+ spans.append({
+ "group_id": _group_id_for(current, group_index),
+ "kind": "tool_call",
+ "start_index": i,
+ "end_index": k - 1,
+ "has_reasoning": has_reasoning,
+ })
+ i = k
+ group_index += 1
+ continue
+
+ if current.role == "tool":
+ k = i + 1
+ while k < len(messages) and messages[k].role == "tool":
+ k += 1
+ spans.append({
+ "group_id": _group_id_for(current, group_index),
+ "kind": "tool_call",
+ "start_index": i,
+ "end_index": k - 1,
+ "has_reasoning": False,
+ })
+ i = k
+ group_index += 1
+ continue
+
+ spans.append({
+ "group_id": _group_id_for(current, group_index),
+ "kind": "assistant_text",
+ "start_index": i,
+ "end_index": i,
+ "has_reasoning": _has_reasoning(current),
+ })
+ i += 1
+ group_index += 1
+
+ return spans
+
+
+def _coerce_group_kind(value: object) -> GroupKind | None:
+ if value == "system":
+ return "system"
+ if value == "user":
+ return "user"
+ if value == "assistant_text":
+ return "assistant_text"
+ if value == "tool_call":
+ return "tool_call"
+ return None
+
+
+def _read_group_annotation(message: Message) -> dict[str, Any] | None:
+ raw_annotation = _read_group_annotation_raw(message)
+ if raw_annotation is None:
+ return None
+
+ group_id = raw_annotation.get(GROUP_ID_KEY)
+ group_kind = _coerce_group_kind(raw_annotation.get(GROUP_KIND_KEY))
+ group_index = raw_annotation.get(GROUP_INDEX_KEY)
+ has_reasoning = raw_annotation.get(GROUP_HAS_REASONING_KEY)
+ token_count = raw_annotation.get(GROUP_TOKEN_COUNT_KEY)
+ if token_count is not None and not isinstance(token_count, int):
+ return None
+ if (
+ not isinstance(group_id, str)
+ or group_kind is None
+ or not isinstance(group_index, int)
+ or not isinstance(has_reasoning, bool)
+ ):
+ return None
+
+ return raw_annotation
+
+
+def _read_group_annotation_raw(message: Message) -> dict[str, Any] | None:
+ annotation = message.additional_properties.get(GROUP_ANNOTATION_KEY)
+ if isinstance(annotation, Mapping):
+ return annotation # type: ignore[reportUnknownVariableType, return-value]
+ return None
+
+
+def _set_group_summarized_by_summary_id(message: Message, summary_id: str) -> None:
+ annotation = _read_group_annotation_raw(message)
+ if annotation is None:
+ annotation = {}
+ message.additional_properties[GROUP_ANNOTATION_KEY] = annotation
+ annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] = summary_id
+
+
+def _write_group_annotation(
+ message: Message,
+ *,
+ group_id: str,
+ kind: GroupKind,
+ index: int,
+ has_reasoning: bool,
+) -> None:
+ existing_raw_annotation = _read_group_annotation_raw(message)
+ unknown_fields: dict[str, Any] = {}
+ token_count: int | None = None
+ if existing_raw_annotation is not None:
+ raw_token_count = existing_raw_annotation.get(GROUP_TOKEN_COUNT_KEY)
+ if isinstance(raw_token_count, int) or raw_token_count is None:
+ token_count = raw_token_count
+ unknown_fields = {
+ key: value
+ for key, value in existing_raw_annotation.items()
+ if key
+ not in {
+ GROUP_ID_KEY,
+ GROUP_KIND_KEY,
+ GROUP_INDEX_KEY,
+ GROUP_HAS_REASONING_KEY,
+ GROUP_TOKEN_COUNT_KEY,
+ }
+ }
+
+ annotation = {
+ GROUP_ID_KEY: group_id,
+ GROUP_KIND_KEY: kind,
+ GROUP_INDEX_KEY: index,
+ GROUP_HAS_REASONING_KEY: has_reasoning,
+ GROUP_TOKEN_COUNT_KEY: token_count,
+ }
+ annotation.update(unknown_fields)
+ message.additional_properties[GROUP_ANNOTATION_KEY] = annotation
+
+
+def _group_id(message: Message) -> str | None:
+ annotation = _read_group_annotation(message)
+ if annotation is None:
+ return None
+ group_id = annotation.get(GROUP_ID_KEY)
+ return group_id if isinstance(group_id, str) else None
+
+
+def _group_kind(message: Message) -> GroupKind | None:
+ annotation = _read_group_annotation(message)
+ if annotation is None:
+ return None
+ return _coerce_group_kind(annotation.get(GROUP_KIND_KEY))
+
+
+def _group_index(message: Message) -> int | None:
+ annotation = _read_group_annotation(message)
+ if annotation is None:
+ return None
+ group_index = annotation.get(GROUP_INDEX_KEY)
+ return group_index if isinstance(group_index, int) else None
+
+
+def _token_count(message: Message) -> int | None:
+ annotation = _read_group_annotation(message)
+ if annotation is None:
+ return None
+ token_count = annotation.get(GROUP_TOKEN_COUNT_KEY)
+ return token_count if isinstance(token_count, int) else None
+
+
+def _write_token_count(message: Message, token_count: int) -> None:
+ annotation = _read_group_annotation_raw(message)
+ if annotation is None:
+ return
+ annotation[GROUP_TOKEN_COUNT_KEY] = token_count
+ message.additional_properties[GROUP_ANNOTATION_KEY] = annotation
+
+
+def _ordered_group_ids_from_annotations(messages: Sequence[Message]) -> list[str]:
+ ordered_group_ids: list[str] = []
+ seen: set[str] = set()
+ for message in messages:
+ group_id = _group_id(message)
+ if group_id is not None and group_id not in seen:
+ seen.add(group_id)
+ ordered_group_ids.append(group_id)
+ return ordered_group_ids
+
+
+def _first_untokenized_index(messages: Sequence[Message]) -> int | None:
+ for index, message in enumerate(messages):
+ if _token_count(message) is None:
+ return index
+ return None
+
+
+def _first_annotation_gaps(
+ messages: Sequence[Message],
+ *,
+ include_tokens: bool,
+) -> tuple[int | None, int | None]:
+ first_unannotated: int | None = None
+ first_untokenized: int | None = None
+ for index, message in enumerate(messages):
+ missing_group_annotation = first_unannotated is None and _group_id(message) is None
+ missing_token_annotation = include_tokens and first_untokenized is None and _token_count(message) is None
+
+ if missing_group_annotation:
+ first_unannotated = index
+ if missing_token_annotation:
+ first_untokenized = index
+
+ if missing_group_annotation or missing_token_annotation:
+ break
+ return first_unannotated, first_untokenized
+
+
+def _reannotation_start(messages: Sequence[Message], index: int) -> int:
+ if index <= 0:
+ return 0
+ previous_index = index - 1
+ previous_group_id = _group_id(messages[previous_index])
+ if previous_group_id is None:
+ return previous_index
+ while previous_index > 0:
+ prior_group_id = _group_id(messages[previous_index - 1])
+ if prior_group_id != previous_group_id:
+ break
+ previous_index -= 1
+ return previous_index
+
+
+def annotate_message_groups(
+ messages: list[Message],
+ *,
+ from_index: int | None = None,
+ force_reannotate: bool = False,
+ tokenizer: TokenizerProtocol | None = None,
+) -> list[str]:
+ """Annotate message groups while reusing existing annotations when possible.
+
+ By default, the function re-annotates only the suffix that contains new
+ messages and keeps previously annotated prefixes untouched. When a
+ ``tokenizer`` is provided, token-count annotations are also populated
+ incrementally.
+ """
+ if not messages:
+ return []
+
+ if force_reannotate:
+ start_index = 0
+ elif from_index is not None:
+ start_index = max(0, min(from_index, len(messages) - 1))
+ else:
+ first_unannotated_index, first_untokenized_index = _first_annotation_gaps(
+ messages,
+ include_tokens=tokenizer is not None,
+ )
+ candidate_starts = [index for index in (first_unannotated_index, first_untokenized_index) if index is not None]
+ if not candidate_starts:
+ return _ordered_group_ids_from_annotations(messages)
+ start_index = min(candidate_starts)
+
+ start_index = _reannotation_start(messages, start_index)
+
+ # Continue group indices from the preserved prefix when only re-annotating a suffix.
+ group_index_offset = 0
+ if start_index > 0:
+ previous_group_index = _group_index(messages[start_index - 1])
+ if previous_group_index is not None:
+ group_index_offset = previous_group_index + 1
+
+ spans = group_messages(messages[start_index:])
+ for span_index, span in enumerate(spans):
+ group_id = str(span["group_id"])
+ kind = _coerce_group_kind(span["kind"])
+ if kind is None:
+ raise ValueError(f"Unexpected group kind in span: {span['kind']}")
+ local_start_index = int(span["start_index"])
+ local_end_index = int(span["end_index"])
+ has_reasoning = bool(span["has_reasoning"])
+ for idx in range(start_index + local_start_index, start_index + local_end_index + 1):
+ message = messages[idx]
+ _write_group_annotation(
+ message,
+ group_id=group_id,
+ kind=kind,
+ index=group_index_offset + span_index,
+ has_reasoning=has_reasoning,
+ )
+ message.additional_properties.setdefault(EXCLUDED_KEY, False)
+ if tokenizer is not None and _token_count(message) is None:
+ _write_token_count(message, tokenizer.count_tokens(_serialize_message(message)))
+ return _ordered_group_ids_from_annotations(messages)
+
+
+def _serialize_content(content: Content) -> dict[str, Any]:
+ payload = content.to_dict(exclude_none=True)
+ payload.pop("raw_representation", None)
+ return payload
+
+
+def _serialize_message(message: Message) -> str:
+ serialized_contents = [_serialize_content(content) for content in message.contents]
+ payload = {
+ "role": message.role,
+ "message_id": message.message_id,
+ "contents": serialized_contents,
+ }
+ return json.dumps(payload, ensure_ascii=True, sort_keys=True, default=str)
+
+
+def annotate_token_counts(
+ messages: list[Message],
+ *,
+ tokenizer: TokenizerProtocol,
+ from_index: int | None = None,
+ force_retokenize: bool = False,
+) -> None:
+ """Annotate token-count metadata, incrementally by default."""
+ if not messages:
+ return
+
+ # Token counts are stored inside group annotations.
+ annotate_message_groups(messages, from_index=from_index)
+
+ if force_retokenize:
+ start_index = 0
+ elif from_index is not None:
+ start_index = max(0, min(from_index, len(messages) - 1))
+ else:
+ first_untokenized_index = _first_untokenized_index(messages)
+ if first_untokenized_index is None:
+ return
+ start_index = first_untokenized_index
+
+ for message in messages[start_index:]:
+ _write_token_count(message, tokenizer.count_tokens(_serialize_message(message)))
+
+
+def extend_compaction_messages(
+ messages: list[Message],
+ new_messages: Sequence[Message],
+ *,
+ tokenizer: TokenizerProtocol | None = None,
+) -> None:
+ """Append a batch of messages and annotate only the appended tail."""
+ if not new_messages:
+ return
+
+ start_index = len(messages)
+ messages.extend(new_messages)
+ annotate_message_groups(
+ messages,
+ from_index=start_index,
+ tokenizer=tokenizer,
+ )
+
+
+def append_compaction_message(
+ messages: list[Message],
+ message: Message,
+ *,
+ tokenizer: TokenizerProtocol | None = None,
+) -> None:
+ """Append a single message and incrementally annotate metadata."""
+ extend_compaction_messages(messages, [message], tokenizer=tokenizer)
+
+
+def included_messages(messages: list[Message]) -> list[Message]:
+ return [message for message in messages if not message.additional_properties.get(EXCLUDED_KEY, False)]
+
+
+def included_token_count(messages: list[Message]) -> int:
+ total = 0
+ for message in included_messages(messages):
+ token_count = _token_count(message)
+ if token_count is not None:
+ total += token_count
+ return total
+
+
+def set_excluded(message: Message, *, excluded: bool, reason: str | None = None) -> bool:
+ changed = bool(message.additional_properties.get(EXCLUDED_KEY, False)) != excluded
+ if changed:
+ message.additional_properties[EXCLUDED_KEY] = excluded
+ if reason is not None:
+ message.additional_properties[EXCLUDE_REASON_KEY] = reason
+ return changed
+
+
+def exclude_group_ids(messages: list[Message], group_ids: set[str], *, reason: str) -> bool:
+ changed = False
+ for message in messages:
+ group_id = _group_id(message)
+ if group_id is not None and group_id in group_ids:
+ changed = set_excluded(message, excluded=True, reason=reason) or changed
+ return changed
+
+
+def project_included_messages(messages: list[Message]) -> list[Message]:
+ return included_messages(messages)
+
+
+def _group_messages_by_id(messages: list[Message]) -> dict[str, list[Message]]:
+ grouped: dict[str, list[Message]] = {}
+ for message in messages:
+ group_id = _group_id(message)
+ if group_id is None:
+ continue
+ grouped.setdefault(group_id, []).append(message)
+ return grouped
+
+
+def _group_kind_map(messages: list[Message]) -> dict[str, GroupKind]:
+ kinds: dict[str, GroupKind] = {}
+ for message in messages:
+ group_id = _group_id(message)
+ group_kind = _group_kind(message)
+ if group_id is not None and group_kind is not None and group_id not in kinds:
+ kinds[group_id] = group_kind
+ return kinds
+
+
+def _group_start_indices(messages: list[Message]) -> dict[str, int]:
+ starts: dict[str, int] = {}
+ for idx, message in enumerate(messages):
+ group_id = _group_id(message)
+ if group_id is not None and group_id not in starts:
+ starts[group_id] = idx
+ return starts
+
+
+def _included_group_ids(messages: list[Message], ordered_group_ids: list[str]) -> list[str]:
+ grouped = _group_messages_by_id(messages)
+ included_ids: list[str] = []
+ for group_id in ordered_group_ids:
+ if any(not m.additional_properties.get(EXCLUDED_KEY, False) for m in grouped.get(group_id, [])):
+ included_ids.append(group_id)
+ return included_ids
+
+
+def _count_included_messages(messages: list[Message]) -> int:
+ return len(included_messages(messages))
+
+
+def _count_included_tokens(messages: list[Message]) -> int:
+ return included_token_count(messages)
+
+
+class TruncationStrategy:
+ """Oldest-first compaction using a single metric threshold.
+
+ This strategy runs after group annotations are computed and excludes whole
+ groups (never partial tool-call groups). The metric is:
+ - token count when ``tokenizer`` is provided
+ - included message count when ``tokenizer`` is not provided
+ Compaction triggers when the metric exceeds ``max_n`` and trims to
+ ``compact_to``.
+ """
+
+ def __init__(
+ self,
+ *,
+ max_n: int,
+ compact_to: int,
+ tokenizer: TokenizerProtocol | None = None,
+ preserve_system: bool = True,
+ ) -> None:
+ """Create a truncation strategy.
+
+ Keyword Args:
+ max_n: Trigger threshold measured in tokens when ``tokenizer`` is
+ provided, otherwise measured in included messages.
+ compact_to: Target value for the same metric used by ``max_n``.
+ This argument is required and must be explicitly set.
+ tokenizer: Optional tokenizer used for token-based truncation.
+ preserve_system: When True, system groups remain included and only
+ non-system groups are eligible for exclusion.
+ """
+ if max_n <= 0:
+ raise ValueError("max_n must be greater than 0.")
+ if compact_to <= 0:
+ raise ValueError("compact_to must be greater than 0.")
+ if compact_to > max_n:
+ raise ValueError("compact_to must be less than or equal to max_n.")
+ self.max_n = max_n
+ self.compact_to = compact_to
+ self.tokenizer = tokenizer
+ self.preserve_system = preserve_system
+
+ async def __call__(self, messages: list[Message]) -> bool:
+ ordered_group_ids = _ordered_group_ids_from_annotations(messages)
+ if self.tokenizer is not None:
+ over_limit = _count_included_tokens(messages) > self.max_n
+ else:
+ over_limit = _count_included_messages(messages) > self.max_n
+ if not over_limit:
+ return False
+
+ grouped = _group_messages_by_id(messages)
+ kinds = _group_kind_map(messages)
+ protected_ids: set[str] = set()
+ if self.preserve_system:
+ protected_ids = {group_id for group_id in ordered_group_ids if kinds.get(group_id) == "system"}
+
+ changed = False
+ for group_id in ordered_group_ids:
+ if self.tokenizer is not None:
+ target_met = _count_included_tokens(messages) <= self.compact_to
+ else:
+ target_met = _count_included_messages(messages) <= self.compact_to
+ if target_met:
+ break
+ if group_id in protected_ids:
+ continue
+ for message in grouped.get(group_id, []):
+ changed = set_excluded(message, excluded=True, reason="truncation") or changed
+ return changed
+
+
+class SlidingWindowStrategy:
+ """Windowed compaction that keeps the most recent non-system groups.
+
+ The strategy preserves recency by retaining only the last
+ ``keep_last_groups`` included non-system groups. System groups can be kept
+ as stable anchors when ``preserve_system`` is enabled.
+
+ This can remove older user and assistant groups while keeping system
+ instructions, which is useful when directives must persist but conversation
+ history grows. Use ``SelectiveToolCallCompactionStrategy`` when only tool
+ groups should be reduced.
+ """
+
+ def __init__(self, *, keep_last_groups: int, preserve_system: bool = True) -> None:
+ """Create a sliding-window strategy.
+
+ Args:
+ keep_last_groups: Number of most-recent non-system groups to keep.
+ preserve_system: Whether system groups should always remain included.
+ """
+ if keep_last_groups <= 0:
+ raise ValueError(f"keep_last_groups must be more than 0, got {keep_last_groups}")
+ self.keep_last_groups = keep_last_groups
+ self.preserve_system = preserve_system
+
+ async def __call__(self, messages: list[Message]) -> bool:
+ ordered_group_ids = _ordered_group_ids_from_annotations(messages)
+ grouped = _group_messages_by_id(messages)
+ kinds = _group_kind_map(messages)
+
+ included_group_ids = _included_group_ids(messages, ordered_group_ids)
+ non_system_group_ids = [group_id for group_id in included_group_ids if kinds.get(group_id) != "system"]
+ keep_non_system_ids = set(non_system_group_ids[-self.keep_last_groups :])
+ keep_ids = set(keep_non_system_ids)
+ if self.preserve_system:
+ keep_ids.update(group_id for group_id in ordered_group_ids if kinds.get(group_id) == "system")
+
+ changed = False
+ for group_id in included_group_ids:
+ if group_id in keep_ids:
+ continue
+ for message in grouped.get(group_id, []):
+ changed = set_excluded(message, excluded=True, reason="sliding_window") or changed
+ return changed
+
+
+class SelectiveToolCallCompactionStrategy:
+ """Compaction focused on reducing tool-call history growth.
+
+ This strategy only targets groups annotated as ``tool_call`` and keeps the
+ latest ``keep_last_tool_call_groups`` included tool-call groups. It is
+ useful when tool chatter dominates token usage.
+
+ It does not change non-tool-call groups, so it can be combined with other
+ strategies that target different aspects of the message history.
+ """
+
+ def __init__(self, *, keep_last_tool_call_groups: int = 1) -> None:
+ """Create a tool-call-focused compaction strategy.
+
+ Args:
+ keep_last_tool_call_groups: Number of newest included tool-call
+ groups to retain. Set to 0 to remove all included tool-call
+ groups.
+
+ Raises:
+ ValueError: If ``keep_last_tool_call_groups`` is negative.
+ """
+ if keep_last_tool_call_groups < 0:
+ raise ValueError("keep_last_tool_call_groups must be greater than or equal to 0.")
+ self.keep_last_tool_call_groups = keep_last_tool_call_groups
+
+ async def __call__(self, messages: list[Message]) -> bool:
+ ordered_group_ids = _ordered_group_ids_from_annotations(messages)
+ grouped = _group_messages_by_id(messages)
+ kinds = _group_kind_map(messages)
+
+ included_tool_group_ids = [
+ group_id
+ for group_id in _included_group_ids(messages, ordered_group_ids)
+ if kinds.get(group_id) == "tool_call"
+ ]
+ if len(included_tool_group_ids) <= self.keep_last_tool_call_groups:
+ return False
+
+ keep_ids: set[str] = (
+ set(included_tool_group_ids[-self.keep_last_tool_call_groups :])
+ if self.keep_last_tool_call_groups > 0
+ else set()
+ )
+ changed = False
+ for group_id in included_tool_group_ids:
+ if group_id in keep_ids:
+ continue
+ for message in grouped.get(group_id, []):
+ changed = set_excluded(message, excluded=True, reason="tool_call_compaction") or changed
+ return changed
+
+
+class ToolResultCompactionStrategy:
+ """Collapse older tool-call groups into short summary messages.
+
+ Unlike ``SelectiveToolCallCompactionStrategy`` which fully excludes old
+ tool-call groups, this strategy *replaces* them with a compact summary
+ message containing the tool results (e.g.
+ ``[Tool results: get_weather: sunny, 18°C]``). This preserves a readable
+ trace of what tools returned while reclaiming the token overhead of the
+ full function-call/result message structure.
+
+ The most recent ``keep_last_tool_call_groups`` tool-call groups are left
+ untouched; older ones are collapsed.
+ """
+
+ def __init__(self, *, keep_last_tool_call_groups: int = 1) -> None:
+ """Create a tool-result compaction strategy.
+
+ Keyword Args:
+ keep_last_tool_call_groups: Number of newest included tool-call
+ groups to retain verbatim. Older tool-call groups are collapsed
+ into summary messages. Set to 0 to collapse all.
+
+ Raises:
+ ValueError: If ``keep_last_tool_call_groups`` is negative.
+ """
+ if keep_last_tool_call_groups < 0:
+ raise ValueError("keep_last_tool_call_groups must be greater than or equal to 0.")
+ self.keep_last_tool_call_groups = keep_last_tool_call_groups
+
+ async def __call__(self, messages: list[Message]) -> bool:
+ ordered_group_ids = _ordered_group_ids_from_annotations(messages)
+ grouped = _group_messages_by_id(messages)
+ kinds = _group_kind_map(messages)
+
+ included_tool_group_ids = [
+ group_id
+ for group_id in _included_group_ids(messages, ordered_group_ids)
+ if kinds.get(group_id) == "tool_call"
+ ]
+ if len(included_tool_group_ids) <= self.keep_last_tool_call_groups:
+ return False
+
+ keep_ids: set[str] = (
+ set(included_tool_group_ids[-self.keep_last_tool_call_groups :])
+ if self.keep_last_tool_call_groups > 0
+ else set()
+ )
+ starts = _group_start_indices(messages)
+ changed = False
+ for group_id in included_tool_group_ids:
+ if group_id in keep_ids:
+ continue
+ group_msgs = grouped.get(group_id, [])
+ # Build a call_id → function_name map from function_call contents.
+ call_id_to_name: dict[str, str] = {}
+ for msg in group_msgs:
+ for content in msg.contents:
+ if content.type == "function_call" and content.call_id and content.name:
+ call_id_to_name[content.call_id] = content.name
+ # Collect tool results with the function name for context.
+ tool_results: list[str] = []
+ for msg in group_msgs:
+ for content in msg.contents:
+ if content.type == "function_result":
+ result_text = content.result if isinstance(content.result, str) else str(content.result)
+ func_name = call_id_to_name.get(content.call_id or "", "")
+ label = f"{func_name}: {result_text}" if func_name else result_text
+ tool_results.append(label.strip())
+ summary_label = "; ".join(tool_results) if tool_results else "no results"
+ summary_text = f"[Tool results: {summary_label}]"
+
+ summary_id = f"tool_summary_{group_id}"
+ original_message_ids = [msg.message_id for msg in group_msgs if msg.message_id]
+
+ # Mark originals as excluded with back-link to the summary.
+ for msg in group_msgs:
+ _set_group_summarized_by_summary_id(msg, summary_id)
+ changed = set_excluded(msg, excluded=True, reason="tool_result_compaction") or changed
+
+ # Insert summary with forward links to the originals.
+ summary_annotation = {
+ SUMMARY_OF_MESSAGE_IDS_KEY: original_message_ids,
+ SUMMARY_OF_GROUP_IDS_KEY: [group_id],
+ }
+ insertion_index = starts.get(group_id, 0)
+ summary_message = Message(
+ role="assistant",
+ text=summary_text,
+ message_id=summary_id,
+ additional_properties={
+ GROUP_ANNOTATION_KEY: summary_annotation,
+ },
+ )
+ messages.insert(insertion_index, summary_message)
+ annotate_message_groups(messages, from_index=insertion_index, force_reannotate=False)
+ starts = _group_start_indices(messages)
+ grouped = _group_messages_by_id(messages)
+
+ return changed
+
+
+def _format_messages_for_summary(messages: list[Message]) -> str:
+ lines: list[str] = []
+ for index, message in enumerate(messages, start=1):
+ content_text = message.text
+ if not content_text:
+ content_text = ", ".join(content.type for content in message.contents)
+ lines.append(f"{index}. [{message.role}] {content_text}")
+ return "\n".join(lines)
+
+
+DEFAULT_SUMMARIZATION_PROMPT: Final[
+ str
+] = """**Generate a clear and complete summary of the entire conversation in no more than five sentences.**
+
+The summary must always:
+- Reflect contributions from both the user and the assistant
+- Preserve context to support ongoing dialogue
+- Incorporate any previously provided summary
+- Emphasize the most relevant and meaningful points
+
+The summary must never:
+- Offer critique, correction, interpretation, or speculation
+- Highlight errors, misunderstandings, or judgments of accuracy
+- Comment on events or ideas not present in the conversation
+- Omit any details included in an earlier summary
+"""
+
+
+class SummarizationStrategy:
+ """Summarize older included groups and replace them with linked summary text.
+
+ The strategy monitors included non-system message count and triggers when
+ that count grows beyond ``target_count + threshold``. When triggered, it
+ summarizes the oldest groups and retains the newest content near
+ ``target_count`` (subject to atomic group boundaries). It writes trace
+ metadata in both directions: summary -> original message/group IDs and
+ original -> summary ID.
+ """
+
+ def __init__(
+ self,
+ *,
+ client: SupportsChatGetResponse[Any],
+ target_count: int = 4,
+ threshold: int | None = 2,
+ prompt: str | None = None,
+ ) -> None:
+ """Create a summarization strategy.
+
+ Keyword Args:
+ client: A chat client compatible with ``SupportsChatGetResponse``
+ used to generate summary text.
+ target_count: Target number of included non-system messages to
+ retain after summarization. Must be greater than 0.
+ threshold: Extra included non-system messages allowed above
+ ``target_count`` before summarization triggers. Must be greater
+ than or equal to 0 when provided.
+ prompt: Optional summarization instruction. If omitted, a default
+ prompt that preserves goals, decisions, and unresolved items is
+ used.
+
+ Raises:
+ ValueError: If ``target_count`` is less than 1.
+ ValueError: If ``threshold`` is provided and is negative.
+ """
+ if target_count <= 0:
+ raise ValueError("target_count must be greater than 0.")
+ if threshold is not None and threshold < 0:
+ raise ValueError("threshold must be greater than or equal to 0.")
+ self.client = client
+ self.target_count = target_count
+ self.threshold = threshold if threshold is not None else 0
+ self.prompt = prompt or DEFAULT_SUMMARIZATION_PROMPT
+
+ async def __call__(self, messages: list[Message]) -> bool:
+ ordered_group_ids = _ordered_group_ids_from_annotations(messages)
+ grouped = _group_messages_by_id(messages)
+ kinds = _group_kind_map(messages)
+ starts = _group_start_indices(messages)
+
+ included_non_system_groups: list[tuple[str, list[Message]]] = []
+ included_non_system_message_count = 0
+ for group_id in _included_group_ids(messages, ordered_group_ids):
+ if kinds.get(group_id) == "system":
+ continue
+ group_messages = [
+ message
+ for message in grouped.get(group_id, [])
+ if not message.additional_properties.get(EXCLUDED_KEY, False)
+ ]
+ if not group_messages:
+ continue
+ included_non_system_groups.append((group_id, group_messages))
+ included_non_system_message_count += len(group_messages)
+
+ if included_non_system_message_count <= self.target_count + self.threshold:
+ return False
+
+ keep_group_ids: list[str] = []
+ retained_message_count = 0
+ for group_id, group_messages in reversed(included_non_system_groups):
+ if retained_message_count >= self.target_count and keep_group_ids:
+ break
+ keep_group_ids.append(group_id)
+ retained_message_count += len(group_messages)
+ keep_group_id_set = set(keep_group_ids)
+
+ group_ids_to_summarize = [
+ group_id for group_id, _ in included_non_system_groups if group_id not in keep_group_id_set
+ ]
+ if not group_ids_to_summarize:
+ return False
+
+ messages_to_summarize: list[Message] = []
+ for group_id, group_messages in included_non_system_groups:
+ if group_id in keep_group_id_set:
+ continue
+ messages_to_summarize.extend(group_messages)
+ if not messages_to_summarize:
+ return False
+
+ try:
+ summary_response: ChatResponse[None] = await self.client.get_response(
+ [
+ Message(role="system", text=self.prompt),
+ Message(
+ role="user",
+ text=_format_messages_for_summary(messages_to_summarize),
+ ),
+ ],
+ stream=False,
+ )
+ except Exception as exc:
+ logger.warning(
+ "Skipping summarization compaction: summary generation failed (%s).",
+ exc,
+ )
+ return False
+
+ summary_text = summary_response.text.strip() if summary_response.text else ""
+ if not summary_text:
+ logger.warning("Skipping summarization compaction: summarizer returned no text.")
+ return False
+ summary_id = f"summary_{len(messages)}"
+ original_message_ids = [message.message_id for message in messages_to_summarize if message.message_id]
+ summary_of_group_ids = list(group_ids_to_summarize)
+ summary_annotation = {
+ SUMMARY_OF_MESSAGE_IDS_KEY: original_message_ids,
+ SUMMARY_OF_GROUP_IDS_KEY: summary_of_group_ids,
+ }
+
+ summary_message = Message(
+ role="assistant",
+ text=summary_text,
+ message_id=summary_id,
+ additional_properties={
+ GROUP_ANNOTATION_KEY: summary_annotation,
+ },
+ )
+
+ for message in messages_to_summarize:
+ _set_group_summarized_by_summary_id(message, summary_id)
+ set_excluded(message, excluded=True, reason="summarized")
+
+ insertion_index = min(starts[group_id] for group_id in group_ids_to_summarize if group_id in starts)
+ messages.insert(insertion_index, summary_message)
+ annotate_message_groups(messages, from_index=insertion_index, force_reannotate=False)
+ return True
+
+
+class TokenBudgetComposedStrategy:
+ """Compose multiple strategies until an included-token budget is satisfied.
+
+ Strategies run in the provided order over shared message annotations. After
+ each step, token counts are refreshed. If no strategy reaches budget, a
+ deterministic fallback excludes oldest groups (and finally anchors when
+ necessary) to enforce the limit.
+ """
+
+ def __init__(
+ self,
+ *,
+ token_budget: int,
+ tokenizer: TokenizerProtocol,
+ strategies: Sequence[CompactionStrategy],
+ early_stop: bool = True,
+ ) -> None:
+ """Create a composed token-budget strategy.
+
+ Args:
+ token_budget: Maximum included token count allowed after compaction.
+ tokenizer: Tokenizer implementation used for per-message token
+ annotation.
+ strategies: Ordered strategy sequence to execute before fallback.
+ early_stop: When True, stop as soon as budget is satisfied.
+ """
+ self.token_budget = token_budget
+ self.tokenizer = tokenizer
+ self.strategies = list(strategies)
+ self.early_stop = early_stop
+
+ async def __call__(self, messages: list[Message]) -> bool:
+ annotate_message_groups(messages)
+ annotate_token_counts(messages, tokenizer=self.tokenizer)
+ if included_token_count(messages) <= self.token_budget:
+ return False
+
+ changed = False
+ for strategy in self.strategies:
+ changed = (await strategy(messages)) or changed
+ annotate_message_groups(messages)
+ annotate_token_counts(messages, tokenizer=self.tokenizer)
+ if self.early_stop and included_token_count(messages) <= self.token_budget:
+ return changed
+
+ if included_token_count(messages) <= self.token_budget:
+ return changed
+
+ ordered_group_ids = annotate_message_groups(messages)
+ grouped = _group_messages_by_id(messages)
+ kinds = _group_kind_map(messages)
+ for group_id in ordered_group_ids:
+ if kinds.get(group_id) == "system":
+ continue
+ for message in grouped.get(group_id, []):
+ changed = set_excluded(message, excluded=True, reason="token_budget_fallback") or changed
+ if included_token_count(messages) <= self.token_budget:
+ break
+ if included_token_count(messages) <= self.token_budget:
+ return changed
+
+ # Strict budget enforcement fallback: if anchors alone exceed budget, exclude remaining groups.
+ for group_id in ordered_group_ids:
+ if kinds.get(group_id) != "system":
+ continue
+ for message in grouped.get(group_id, []):
+ changed = set_excluded(message, excluded=True, reason="token_budget_fallback_strict") or changed
+ if included_token_count(messages) <= self.token_budget:
+ break
+ return changed
+
+
+async def apply_compaction(
+ messages: list[Message],
+ *,
+ strategy: CompactionStrategy | None,
+ tokenizer: TokenizerProtocol | None = None,
+) -> list[Message]:
+ """Apply configured compaction and return projected model-input messages."""
+ if strategy is None:
+ return messages
+ annotate_message_groups(messages)
+ if tokenizer is not None:
+ annotate_token_counts(messages, tokenizer=tokenizer)
+ await strategy(messages)
+ return project_included_messages(messages)
+
+
+COMPACTION_STATE_KEY: Final[str] = "_compaction_messages"
+
+
+class CompactionProvider(BaseContextProvider):
+ """Context provider that compacts messages before and after agent runs.
+
+ This provider accepts two separate strategies:
+
+ - ``before_strategy``: Runs in ``before_run`` on messages already in the
+ context (loaded by earlier providers such as a history provider).
+ Compacts the loaded history before it reaches the model.
+ - ``after_strategy``: Runs in ``after_run`` on the accumulated messages
+ stored by a history provider in session state. This compacts the
+ persisted history so the next turn starts with a smaller context.
+
+ Either strategy may be ``None`` to skip that phase.
+
+ Examples:
+ .. code-block:: python
+
+ from agent_framework import Agent, CompactionProvider, InMemoryHistoryProvider
+ from agent_framework._compaction import (
+ SlidingWindowStrategy,
+ ToolResultCompactionStrategy,
+ )
+
+ history = InMemoryHistoryProvider()
+ compaction = CompactionProvider(
+ before_strategy=SlidingWindowStrategy(keep_last_groups=20),
+ after_strategy=ToolResultCompactionStrategy(keep_last_tool_call_groups=1),
+ history_source_id=history.source_id,
+ )
+ agent = Agent(
+ client=client,
+ name="assistant",
+ context_providers=[history, compaction],
+ )
+ session = agent.create_session()
+ await agent.run("Hello", session=session)
+ """
+
+ def __init__(
+ self,
+ *,
+ before_strategy: CompactionStrategy | None = None,
+ after_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
+ source_id: str = "compaction",
+ history_source_id: str = "in_memory",
+ ) -> None:
+ """Create a compaction provider.
+
+ Keyword Args:
+ before_strategy: Strategy applied to loaded context messages before
+ the model runs. ``None`` to skip pre-run compaction.
+ after_strategy: Strategy applied to stored history messages after
+ the model runs. Requires ``history_source_id`` to locate the
+ messages in session state. ``None`` to skip post-run compaction.
+ tokenizer: Optional tokenizer for token-aware strategies.
+ source_id: Provider source id (default ``"compaction"``).
+ history_source_id: The ``source_id`` of the history provider whose
+ stored messages the ``after_strategy`` should compact
+ (default ``"in_memory"``).
+ """
+ super().__init__(source_id)
+ self.before_strategy = before_strategy
+ self.after_strategy = after_strategy
+ self.tokenizer = tokenizer
+ self.history_source_id = history_source_id
+
+ async def before_run(
+ self,
+ *,
+ agent: Any,
+ session: Any,
+ context: Any,
+ state: dict[str, Any],
+ ) -> None:
+ """Compact messages already present in the context from earlier providers."""
+ if self.before_strategy is None:
+ return
+
+ all_messages: list[Message] = context.get_messages()
+ if not all_messages:
+ return
+
+ annotate_message_groups(all_messages)
+ if self.tokenizer is not None:
+ annotate_token_counts(all_messages, tokenizer=self.tokenizer)
+ await self.before_strategy(all_messages)
+
+ projected = project_included_messages(all_messages)
+ projected_set = {id(m) for m in projected}
+ for sid in list(context.context_messages):
+ context.context_messages[sid] = [m for m in context.context_messages[sid] if id(m) in projected_set]
+
+ async def after_run(
+ self,
+ *,
+ agent: Any,
+ session: Any,
+ context: Any,
+ state: dict[str, Any],
+ ) -> None:
+ """Compact stored history messages after the model runs."""
+ if self.after_strategy is None:
+ return
+
+ # Access the history provider's stored messages from session state.
+ history_state_raw = session.state.get(self.history_source_id) if session else None
+ if not isinstance(history_state_raw, dict):
+ return
+ history_state: dict[str, Any] = history_state_raw # type: ignore[assignment]
+ raw_messages = history_state.get("messages")
+ if not isinstance(raw_messages, list) or not raw_messages:
+ return
+ stored_messages: list[Message] = raw_messages # type: ignore[assignment]
+
+ annotate_message_groups(stored_messages)
+ if self.tokenizer is not None:
+ annotate_token_counts(stored_messages, tokenizer=self.tokenizer)
+ await self.after_strategy(stored_messages)
+
+ # Keep all messages (including excluded) in storage so annotations are
+ # preserved. The history provider's ``skip_excluded`` flag controls
+ # whether excluded messages are loaded on the next turn.
+
+
+__all__ = [
+ "COMPACTION_STATE_KEY",
+ "EXCLUDED_KEY",
+ "EXCLUDE_REASON_KEY",
+ "GROUP_ANNOTATION_KEY",
+ "GROUP_HAS_REASONING_KEY",
+ "GROUP_ID_KEY",
+ "GROUP_INDEX_KEY",
+ "GROUP_KIND_KEY",
+ "GROUP_TOKEN_COUNT_KEY",
+ "SUMMARIZED_BY_SUMMARY_ID_KEY",
+ "SUMMARY_OF_GROUP_IDS_KEY",
+ "SUMMARY_OF_MESSAGE_IDS_KEY",
+ "CharacterEstimatorTokenizer",
+ "CompactionProvider",
+ "CompactionStrategy",
+ "GroupKind",
+ "SelectiveToolCallCompactionStrategy",
+ "SlidingWindowStrategy",
+ "SummarizationStrategy",
+ "TokenBudgetComposedStrategy",
+ "TokenizerProtocol",
+ "ToolResultCompactionStrategy",
+ "TruncationStrategy",
+ "annotate_message_groups",
+ "annotate_token_counts",
+ "append_compaction_message",
+ "apply_compaction",
+ "extend_compaction_messages",
+ "group_messages",
+ "included_messages",
+ "included_token_count",
+ "project_included_messages",
+]
diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py
index 7f3f3da13d..ba11355adc 100644
--- a/python/packages/core/agent_framework/_middleware.py
+++ b/python/packages/core/agent_framework/_middleware.py
@@ -37,6 +37,7 @@ if TYPE_CHECKING:
from ._agents import SupportsAgentRun
from ._clients import SupportsChatGetResponse
+ from ._compaction import CompactionStrategy, TokenizerProtocol
from ._sessions import AgentSession
from ._tools import FunctionTool
from ._types import ChatOptions, ChatResponse, ChatResponseUpdate
@@ -101,6 +102,8 @@ class AgentContext:
session: The agent session for this invocation, if any.
options: The options for the agent invocation as a dict.
stream: Whether this is a streaming invocation.
+ compaction_strategy: Optional per-run compaction override.
+ tokenizer: Optional per-run tokenizer override.
metadata: Metadata dictionary for sharing data between agent middleware.
result: Agent execution result. Can be observed after calling ``call_next()``
to see the actual execution result or can be set to override the execution result.
@@ -139,6 +142,8 @@ class AgentContext:
session: AgentSession | None = None,
options: Mapping[str, Any] | None = None,
stream: bool = False,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
metadata: Mapping[str, Any] | None = None,
result: AgentResponse | ResponseStream[AgentResponseUpdate, AgentResponse] | None = None,
kwargs: Mapping[str, Any] | None = None,
@@ -158,6 +163,8 @@ class AgentContext:
session: The agent session for this invocation, if any.
options: The options for the agent invocation as a dict.
stream: Whether this is a streaming invocation.
+ compaction_strategy: Optional per-run compaction override.
+ tokenizer: Optional per-run tokenizer override.
metadata: Metadata dictionary for sharing data between agent middleware.
result: Agent execution result.
kwargs: Additional keyword arguments passed to the agent run method.
@@ -170,6 +177,8 @@ class AgentContext:
self.session = session
self.options = options
self.stream = stream
+ self.compaction_strategy = compaction_strategy
+ self.tokenizer = tokenizer
self.metadata: dict[str, Any] = dict(metadata) if metadata is not None else {}
self.result = result
self.kwargs: dict[str, Any] = dict(kwargs) if kwargs is not None else {}
@@ -969,6 +978,8 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]):
*,
stream: Literal[False] = ...,
options: ChatOptions[ResponseModelBoundT],
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ...
@@ -979,6 +990,8 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]):
*,
stream: Literal[False] = ...,
options: OptionsCoT | ChatOptions[None] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[Any]]: ...
@@ -989,6 +1002,8 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]):
*,
stream: Literal[True],
options: OptionsCoT | ChatOptions[Any] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ...
@@ -998,11 +1013,18 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]):
*,
stream: bool = False,
options: OptionsCoT | ChatOptions[Any] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]:
"""Execute the chat pipeline if middleware is configured."""
super_get_response = super().get_response # type: ignore[misc]
+ if compaction_strategy is not None:
+ kwargs["compaction_strategy"] = compaction_strategy
+ if tokenizer is not None:
+ kwargs["tokenizer"] = tokenizer
+
call_middleware = kwargs.pop("middleware", [])
middleware = categorize_middleware(call_middleware)
kwargs["function_middleware"] = middleware["function"]
@@ -1091,6 +1113,8 @@ class AgentMiddlewareLayer:
session: AgentSession | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
options: ChatOptions[ResponseModelBoundT],
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[ResponseModelBoundT]]: ...
@@ -1103,6 +1127,8 @@ class AgentMiddlewareLayer:
session: AgentSession | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
options: ChatOptions[None] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@@ -1115,6 +1141,8 @@ class AgentMiddlewareLayer:
session: AgentSession | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
options: ChatOptions[Any] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
@@ -1126,6 +1154,8 @@ class AgentMiddlewareLayer:
session: AgentSession | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
options: ChatOptions[Any] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
"""MiddlewareTypes-enabled unified run method."""
@@ -1150,7 +1180,15 @@ class AgentMiddlewareLayer:
# Execute with middleware if available
if not pipeline.has_middlewares:
- return super().run(messages, stream=stream, session=session, options=options, **combined_kwargs) # type: ignore[misc, no-any-return]
+ return super().run( # type: ignore[misc, no-any-return]
+ messages,
+ stream=stream,
+ session=session,
+ options=options,
+ compaction_strategy=compaction_strategy,
+ tokenizer=tokenizer,
+ **combined_kwargs,
+ )
context = AgentContext(
agent=self, # type: ignore[arg-type]
@@ -1158,6 +1196,8 @@ class AgentMiddlewareLayer:
session=session,
options=options,
stream=stream,
+ compaction_strategy=compaction_strategy,
+ tokenizer=tokenizer,
kwargs=combined_kwargs,
)
@@ -1195,6 +1235,8 @@ class AgentMiddlewareLayer:
stream=context.stream,
session=context.session,
options=context.options,
+ compaction_strategy=context.compaction_strategy,
+ tokenizer=context.tokenizer,
**context.kwargs,
)
diff --git a/python/packages/core/agent_framework/_sessions.py b/python/packages/core/agent_framework/_sessions.py
index 8c3457da26..434a8d1fd4 100644
--- a/python/packages/core/agent_framework/_sessions.py
+++ b/python/packages/core/agent_framework/_sessions.py
@@ -547,6 +547,7 @@ class InMemoryHistoryProvider(BaseHistoryProvider):
store_context_messages: bool = False,
store_context_from: set[str] | None = None,
store_outputs: bool = True,
+ skip_excluded: bool = False,
) -> None:
"""Initialize the in-memory history provider.
@@ -558,6 +559,11 @@ class InMemoryHistoryProvider(BaseHistoryProvider):
store_context_messages: Whether to store context from other providers.
store_context_from: If set, only store context from these source_ids.
store_outputs: Whether to store response messages.
+ skip_excluded: When True, ``get_messages`` omits messages whose
+ ``additional_properties["_excluded"]`` is truthy. This is
+ useful when a ``CompactionProvider`` marks messages as excluded
+ in stored history and you want the loaded context to reflect
+ those exclusions. Defaults to False (load all messages).
"""
super().__init__(
source_id=source_id or self.DEFAULT_SOURCE_ID,
@@ -567,6 +573,7 @@ class InMemoryHistoryProvider(BaseHistoryProvider):
store_context_from=store_context_from,
store_outputs=store_outputs,
)
+ self.skip_excluded = skip_excluded
async def get_messages(
self, session_id: str | None, *, state: dict[str, Any] | None = None, **kwargs: Any
@@ -574,7 +581,10 @@ class InMemoryHistoryProvider(BaseHistoryProvider):
"""Retrieve messages from session state."""
if state is None:
return []
- return list(state.get("messages", []))
+ messages = list(state.get("messages", []))
+ if self.skip_excluded:
+ messages = [m for m in messages if not m.additional_properties.get("_excluded", False)]
+ return messages
async def save_messages(
self,
diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py
index b7b91919e8..c95fc46aa2 100644
--- a/python/packages/core/agent_framework/_skills.py
+++ b/python/packages/core/agent_framework/_skills.py
@@ -196,9 +196,7 @@ class SkillScript:
self._accepts_kwargs: bool = False
if function is not None:
sig = inspect.signature(function)
- self._accepts_kwargs = any(
- p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
- )
+ self._accepts_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
@property
def parameters_schema(self) -> dict[str, Any] | None:
@@ -454,9 +452,7 @@ class SkillScriptRunner(Protocol):
satisfies this protocol.
"""
- def __call__(
- self, skill: Skill, script: SkillScript, args: dict[str, Any] | None = None
- ) -> Any:
+ def __call__(self, skill: Skill, script: SkillScript, args: dict[str, Any] | None = None) -> Any:
"""Run a skill script.
The :class:`SkillsProvider` resolves skill and script names
@@ -677,7 +673,7 @@ class SkillsProvider(BaseContextProvider):
self._instructions = _create_instructions(
prompt_template=instruction_template,
skills=self._skills,
- include_script_runner_instructions=has_file_scripts or has_code_scripts
+ include_script_runner_instructions=has_file_scripts or has_code_scripts,
)
self._tools = self._create_tools(
diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py
index 105738e717..e920800f9e 100644
--- a/python/packages/core/agent_framework/_tools.py
+++ b/python/packages/core/agent_framework/_tools.py
@@ -59,6 +59,7 @@ else:
if TYPE_CHECKING:
from ._clients import SupportsChatGetResponse
+ from ._compaction import CompactionStrategy, TokenizerProtocol
from ._mcp import MCPTool
from ._middleware import FunctionMiddlewarePipeline, FunctionMiddlewareTypes
from ._types import (
@@ -1811,6 +1812,8 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
*,
stream: Literal[False] = ...,
options: ChatOptions[ResponseModelBoundT],
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ...
@@ -1821,6 +1824,8 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
*,
stream: Literal[False] = ...,
options: OptionsCoT | ChatOptions[None] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[Any]]: ...
@@ -1831,6 +1836,8 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
*,
stream: Literal[True],
options: OptionsCoT | ChatOptions[Any] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ...
@@ -1841,6 +1848,8 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
stream: bool = False,
options: OptionsCoT | ChatOptions[Any] | None = None,
function_middleware: Sequence[FunctionMiddlewareTypes] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]:
from ._middleware import FunctionMiddlewarePipeline
@@ -1869,6 +1878,10 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
middleware_pipeline=function_middleware_pipeline,
)
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "session"}
+ if compaction_strategy is not None:
+ filtered_kwargs["compaction_strategy"] = compaction_strategy
+ if tokenizer is not None:
+ filtered_kwargs["tokenizer"] = tokenizer
# Make options mutable so we can update conversation_id during function invocation loop
mutable_options: dict[str, Any] = dict(options) if options else {}
diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py
index b8d5f5c29a..a44baac2dd 100644
--- a/python/packages/core/agent_framework/_types.py
+++ b/python/packages/core/agent_framework/_types.py
@@ -277,6 +277,17 @@ def _serialize_value(value: Any, exclude_none: bool) -> Any:
return value
+def _restore_compaction_annotation_in_additional_properties(
+ additional_properties: MutableMapping[str, Any] | None,
+ *,
+ allow_none: bool = False,
+) -> dict[str, Any] | None:
+ if additional_properties is None:
+ return None if allow_none else {}
+
+ return dict(additional_properties)
+
+
# endregion
# region Constants and types
@@ -509,7 +520,9 @@ class Content:
"""
self.type = type
self.annotations = annotations
- self.additional_properties: dict[str, Any] = additional_properties or {} # type: ignore[assignment]
+ self.additional_properties: dict[str, Any] = (
+ _restore_compaction_annotation_in_additional_properties(additional_properties) or {}
+ )
self.raw_representation = raw_representation
# Set all content-specific attributes
@@ -1638,7 +1651,9 @@ class Message(SerializationMixin):
self.contents = parsed_contents
self.author_name = author_name
self.message_id = message_id
- self.additional_properties = additional_properties or {}
+ self.additional_properties = (
+ _restore_compaction_annotation_in_additional_properties(additional_properties) or {}
+ )
self.raw_representation = raw_representation
@property
@@ -1989,7 +2004,9 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]):
self._value: ResponseModelT | None = value
self._response_format: type[BaseModel] | None = response_format
self._value_parsed: bool = value is not None
- self.additional_properties = additional_properties or {}
+ self.additional_properties = (
+ _restore_compaction_annotation_in_additional_properties(additional_properties) or {}
+ )
self.continuation_token = continuation_token
self.raw_representation: Any | list[Any] | None = raw_representation
@@ -2239,7 +2256,10 @@ class ChatResponseUpdate(SerializationMixin):
self.created_at = created_at
self.finish_reason = finish_reason
self.continuation_token = continuation_token
- self.additional_properties = additional_properties
+ self.additional_properties = _restore_compaction_annotation_in_additional_properties(
+ additional_properties,
+ allow_none=True,
+ )
self.raw_representation = raw_representation
@property
@@ -2352,7 +2372,9 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]):
self._value: ResponseModelT | None = value
self._response_format: type[BaseModel] | None = response_format
self._value_parsed: bool = value is not None
- self.additional_properties = additional_properties or {}
+ self.additional_properties = (
+ _restore_compaction_annotation_in_additional_properties(additional_properties) or {}
+ )
self.continuation_token = continuation_token
self.raw_representation = raw_representation
@@ -2582,7 +2604,10 @@ class AgentResponseUpdate(SerializationMixin):
self.message_id = message_id
self.created_at = created_at
self.continuation_token = continuation_token
- self.additional_properties = additional_properties
+ self.additional_properties = _restore_compaction_annotation_in_additional_properties(
+ additional_properties,
+ allow_none=True,
+ )
self.raw_representation: Any | list[Any] | None = raw_representation
@property
@@ -3381,7 +3406,9 @@ class Embedding(Generic[EmbeddingT]):
self._dimensions = dimensions
self.model_id = model_id
self.created_at = created_at
- self.additional_properties = additional_properties or {}
+ self.additional_properties = (
+ _restore_compaction_annotation_in_additional_properties(additional_properties) or {}
+ )
@property
def dimensions(self) -> int | None:
@@ -3439,7 +3466,9 @@ class GeneratedEmbeddings(list[Embedding[EmbeddingT]], Generic[EmbeddingT, Embed
super().__init__(embeddings or [])
self.options = options
self.usage = usage
- self.additional_properties = additional_properties or {}
+ self.additional_properties = (
+ _restore_compaction_annotation_in_additional_properties(additional_properties) or {}
+ )
# endregion
diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py
index a595582b33..2407074efc 100644
--- a/python/packages/core/agent_framework/observability.py
+++ b/python/packages/core/agent_framework/observability.py
@@ -49,6 +49,7 @@ if TYPE_CHECKING: # pragma: no cover
from ._agents import SupportsAgentRun
from ._clients import SupportsChatGetResponse
+ from ._compaction import CompactionStrategy, TokenizerProtocol
from ._sessions import AgentSession
from ._tools import FunctionTool
from ._types import (
@@ -1122,6 +1123,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
*,
stream: Literal[False] = ...,
options: ChatOptions[ResponseModelBoundT],
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ...
@@ -1132,6 +1135,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
*,
stream: Literal[False] = ...,
options: OptionsCoT | ChatOptions[None] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[Any]]: ...
@@ -1142,6 +1147,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
*,
stream: Literal[True],
options: OptionsCoT | ChatOptions[Any] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ...
@@ -1151,6 +1158,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
*,
stream: bool = False,
options: OptionsCoT | ChatOptions[Any] | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]:
"""Trace chat responses with OpenTelemetry spans and metrics."""
@@ -1160,7 +1169,14 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
super_get_response = super().get_response # type: ignore[misc]
if not OBSERVABILITY_SETTINGS.ENABLED:
- return super_get_response(messages=messages, stream=stream, options=options, **kwargs) # type: ignore[no-any-return]
+ return super_get_response( # type: ignore[no-any-return]
+ messages=messages,
+ stream=stream,
+ options=options,
+ compaction_strategy=compaction_strategy,
+ tokenizer=tokenizer,
+ **kwargs,
+ )
opts: dict[str, Any] = options or {} # type: ignore[assignment]
provider_name = str(getattr(self, "otel_provider_name", "unknown"))
@@ -1178,7 +1194,14 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
if stream:
result_stream = cast(
ResponseStream[ChatResponseUpdate, ChatResponse[Any]],
- super_get_response(messages=messages, stream=True, options=opts, **kwargs),
+ super_get_response(
+ messages=messages,
+ stream=True,
+ options=opts,
+ compaction_strategy=compaction_strategy,
+ tokenizer=tokenizer,
+ **kwargs,
+ ),
)
# Create span directly without trace.use_span() context attachment.
@@ -1266,6 +1289,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
messages=messages,
stream=False,
options=opts,
+ compaction_strategy=compaction_strategy,
+ tokenizer=tokenizer,
**kwargs,
),
)
@@ -1393,6 +1418,8 @@ class AgentTelemetryLayer:
*,
stream: Literal[False] = ...,
session: AgentSession | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@@ -1403,6 +1430,8 @@ class AgentTelemetryLayer:
*,
stream: Literal[True],
session: AgentSession | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
@@ -1412,6 +1441,8 @@ class AgentTelemetryLayer:
*,
stream: bool = False,
session: AgentSession | None = None,
+ compaction_strategy: CompactionStrategy | None = None,
+ tokenizer: TokenizerProtocol | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
"""Trace agent runs with OpenTelemetry spans and metrics."""
@@ -1430,6 +1461,8 @@ class AgentTelemetryLayer:
messages=messages,
stream=stream,
session=session,
+ compaction_strategy=compaction_strategy,
+ tokenizer=tokenizer,
**kwargs,
)
@@ -1452,6 +1485,8 @@ class AgentTelemetryLayer:
messages=messages,
stream=True,
session=session,
+ compaction_strategy=compaction_strategy,
+ tokenizer=tokenizer,
**kwargs,
)
if isinstance(run_result, ResponseStream):
@@ -1541,6 +1576,8 @@ class AgentTelemetryLayer:
messages=messages,
stream=False,
session=session,
+ compaction_strategy=compaction_strategy,
+ tokenizer=tokenizer,
**kwargs,
)
except Exception as exception:
diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py
index 726616adbb..44639909c7 100644
--- a/python/packages/core/agent_framework/openai/_responses_client.py
+++ b/python/packages/core/agent_framework/openai/_responses_client.py
@@ -1164,7 +1164,6 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
"type": "function_call",
"name": content.name,
"arguments": content.arguments,
- "status": None,
}
case "function_result":
shell_output_type = (
diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py
index a60e924387..d804d07c55 100644
--- a/python/packages/core/tests/core/test_agents.py
+++ b/python/packages/core/tests/core/test_agents.py
@@ -10,6 +10,8 @@ import pytest
from pytest import raises
from agent_framework import (
+ GROUP_ANNOTATION_KEY,
+ GROUP_TOKEN_COUNT_KEY,
Agent,
AgentResponse,
AgentResponseUpdate,
@@ -21,14 +23,24 @@ from agent_framework import (
Content,
FunctionTool,
Message,
+ SlidingWindowStrategy,
SupportsAgentRun,
SupportsChatGetResponse,
+ TruncationStrategy,
tool,
)
from agent_framework._agents import _get_tool_name, _merge_options, _sanitize_agent_name
from agent_framework._mcp import MCPTool
+class _FixedTokenizer:
+ def __init__(self, token_count: int) -> None:
+ self.token_count = token_count
+
+ def count_tokens(self, text: str) -> int:
+ return self.token_count
+
+
def test_agent_session_type(agent_session: AgentSession) -> None:
assert isinstance(agent_session, AgentSession)
@@ -217,6 +229,30 @@ async def test_prepare_session_does_not_mutate_agent_chat_options(
assert len(agent.default_options["tools"]) == 1
+async def test_prepare_run_context_keeps_compaction_overrides_out_of_kwargs(
+ chat_client_base: SupportsChatGetResponse,
+) -> None:
+ strategy = SlidingWindowStrategy(keep_last_groups=2)
+ tokenizer = _FixedTokenizer(13)
+ agent = Agent(client=chat_client_base)
+
+ ctx = await agent._prepare_run_context( # type: ignore[reportPrivateUsage]
+ messages=[Message(role="user", text="Hello")],
+ session=None,
+ tools=None,
+ options=None,
+ compaction_strategy=strategy,
+ tokenizer=tokenizer,
+ kwargs={"custom_flag": True},
+ )
+
+ assert ctx["compaction_strategy"] is strategy
+ assert ctx["tokenizer"] is tokenizer
+ assert ctx["filtered_kwargs"].get("custom_flag") is True
+ assert "compaction_strategy" not in ctx["filtered_kwargs"]
+ assert "tokenizer" not in ctx["filtered_kwargs"]
+
+
async def test_chat_client_agent_run_with_session(
chat_client_base: SupportsChatGetResponse,
) -> None:
@@ -1128,6 +1164,102 @@ async def test_chat_agent_tool_choice_none_at_run_preserves_agent_level(chat_cli
assert captured_options[0]["tool_choice"] == "auto"
+async def test_chat_agent_compaction_overrides_client_defaults(chat_client_base: Any) -> None:
+ captured_roles: list[list[str]] = []
+ captured_token_counts: list[list[int | None]] = []
+ original_inner = chat_client_base._inner_get_response
+
+ async def capturing_inner(
+ *, messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any
+ ) -> ChatResponse:
+ captured_roles.append([message.role for message in messages])
+ captured_token_counts.append([
+ group.get(GROUP_TOKEN_COUNT_KEY) if isinstance(group, dict) else None
+ for group in (message.additional_properties.get(GROUP_ANNOTATION_KEY) for message in messages)
+ ])
+ return await original_inner(messages=messages, options=options, **kwargs)
+
+ chat_client_base._inner_get_response = capturing_inner
+ chat_client_base.function_invocation_configuration["enabled"] = False
+ chat_client_base.compaction_strategy = TruncationStrategy(max_n=1, compact_to=1)
+ chat_client_base.tokenizer = _FixedTokenizer(5)
+
+ agent = Agent(
+ client=chat_client_base,
+ compaction_strategy=SlidingWindowStrategy(keep_last_groups=2),
+ tokenizer=_FixedTokenizer(9),
+ )
+
+ await agent.run([
+ Message(role="user", text="Hello"),
+ Message(role="assistant", text="Previous response"),
+ ])
+
+ assert captured_roles == [["user", "assistant"]]
+ assert captured_token_counts == [[9, 9]]
+
+
+async def test_chat_agent_uses_client_compaction_defaults_when_agent_unset(chat_client_base: Any) -> None:
+ captured_roles: list[list[str]] = []
+ original_inner = chat_client_base._inner_get_response
+
+ async def capturing_inner(
+ *, messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any
+ ) -> ChatResponse:
+ captured_roles.append([message.role for message in messages])
+ return await original_inner(messages=messages, options=options, **kwargs)
+
+ chat_client_base._inner_get_response = capturing_inner
+ chat_client_base.function_invocation_configuration["enabled"] = False
+ chat_client_base.compaction_strategy = TruncationStrategy(max_n=1, compact_to=1)
+
+ agent = Agent(client=chat_client_base)
+
+ await agent.run([
+ Message(role="user", text="Hello"),
+ Message(role="assistant", text="Previous response"),
+ ])
+
+ assert captured_roles == [["assistant"]]
+
+
+async def test_chat_agent_run_level_compaction_and_tokenizer_override_agent_defaults(chat_client_base: Any) -> None:
+ captured_roles: list[list[str]] = []
+ captured_token_counts: list[list[int | None]] = []
+ original_inner = chat_client_base._inner_get_response
+
+ async def capturing_inner(
+ *, messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any
+ ) -> ChatResponse:
+ captured_roles.append([message.role for message in messages])
+ captured_token_counts.append([
+ group.get(GROUP_TOKEN_COUNT_KEY) if isinstance(group, dict) else None
+ for group in (message.additional_properties.get(GROUP_ANNOTATION_KEY) for message in messages)
+ ])
+ return await original_inner(messages=messages, options=options, **kwargs)
+
+ chat_client_base._inner_get_response = capturing_inner
+ chat_client_base.function_invocation_configuration["enabled"] = False
+
+ agent = Agent(
+ client=chat_client_base,
+ compaction_strategy=SlidingWindowStrategy(keep_last_groups=2),
+ tokenizer=_FixedTokenizer(9),
+ )
+
+ await agent.run(
+ [
+ Message(role="user", text="Hello"),
+ Message(role="assistant", text="Previous response"),
+ ],
+ compaction_strategy=TruncationStrategy(max_n=1, compact_to=1),
+ tokenizer=_FixedTokenizer(23),
+ )
+
+ assert captured_roles == [["assistant"]]
+ assert captured_token_counts == [[23]]
+
+
# region Test _merge_options
diff --git a/python/packages/core/tests/core/test_clients.py b/python/packages/core/tests/core/test_clients.py
index a23b1d2a5f..b060b183fb 100644
--- a/python/packages/core/tests/core/test_clients.py
+++ b/python/packages/core/tests/core/test_clients.py
@@ -1,21 +1,34 @@
# Copyright (c) Microsoft. All rights reserved.
+from typing import Any
from unittest.mock import patch
from agent_framework import (
+ GROUP_ANNOTATION_KEY,
+ GROUP_TOKEN_COUNT_KEY,
BaseChatClient,
ChatResponse,
Message,
+ SlidingWindowStrategy,
SupportsChatGetResponse,
SupportsCodeInterpreterTool,
SupportsFileSearchTool,
SupportsImageGenerationTool,
SupportsMCPTool,
SupportsWebSearchTool,
+ TruncationStrategy,
)
+class _FixedTokenizer:
+ def __init__(self, token_count: int) -> None:
+ self.token_count = token_count
+
+ def count_tokens(self, text: str) -> int:
+ return self.token_count
+
+
def test_chat_client_type(client: SupportsChatGetResponse):
assert isinstance(client, SupportsChatGetResponse)
@@ -48,6 +61,190 @@ async def test_base_client_get_response_streaming(chat_client_base: SupportsChat
assert update.text == "update - Hello" or update.text == "another update"
+async def test_base_client_applies_compaction_before_non_streaming_inner_call(
+ chat_client_base: SupportsChatGetResponse,
+):
+ chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined]
+ chat_client_base.compaction_strategy = TruncationStrategy(max_n=1, compact_to=1) # type: ignore[attr-defined]
+ captured_roles: list[list[str]] = []
+ original = chat_client_base._get_non_streaming_response # type: ignore[attr-defined]
+
+ async def _capture(
+ *,
+ messages: list[Message],
+ options: dict[str, Any],
+ **kwargs: Any,
+ ) -> ChatResponse:
+ captured_roles.append([message.role for message in messages])
+ return await original(messages=messages, options=options, **kwargs)
+
+ chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign]
+ await chat_client_base.get_response([
+ Message(role="user", text="Hello"),
+ Message(role="assistant", text="Previous response"),
+ ])
+ assert captured_roles == [["assistant"]]
+
+
+async def test_base_client_applies_compaction_before_streaming_inner_call(
+ chat_client_base: SupportsChatGetResponse,
+):
+ chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined]
+ chat_client_base.compaction_strategy = TruncationStrategy(max_n=1, compact_to=1) # type: ignore[attr-defined]
+ captured_roles: list[list[str]] = []
+ original = chat_client_base._get_streaming_response # type: ignore[attr-defined]
+
+ def _capture(
+ *,
+ messages: list[Message],
+ options: dict[str, Any],
+ **kwargs: Any,
+ ):
+ captured_roles.append([message.role for message in messages])
+ return original(messages=messages, options=options, **kwargs)
+
+ chat_client_base._get_streaming_response = _capture # type: ignore[attr-defined,method-assign]
+ async for _ in chat_client_base.get_response(
+ [
+ Message(role="user", text="Hello"),
+ Message(role="assistant", text="Previous response"),
+ ],
+ stream=True,
+ ):
+ pass
+ assert captured_roles == [["assistant"]]
+
+
+async def test_base_client_per_call_compaction_override_applies_before_inner_call(
+ chat_client_base: SupportsChatGetResponse,
+) -> None:
+ chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined]
+ captured_roles: list[list[str]] = []
+ original = chat_client_base._get_non_streaming_response # type: ignore[attr-defined]
+
+ async def _capture(
+ *,
+ messages: list[Message],
+ options: dict[str, Any],
+ **kwargs: Any,
+ ) -> ChatResponse:
+ captured_roles.append([message.role for message in messages])
+ return await original(messages=messages, options=options, **kwargs)
+
+ chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign]
+ await chat_client_base.get_response(
+ [
+ Message(role="user", text="Hello"),
+ Message(role="assistant", text="Previous response"),
+ ],
+ compaction_strategy=TruncationStrategy(max_n=1, compact_to=1),
+ )
+ assert captured_roles == [["assistant"]]
+
+
+async def test_base_client_per_call_tokenizer_override_annotates_messages(
+ chat_client_base: SupportsChatGetResponse,
+) -> None:
+ chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined]
+ captured_token_counts: list[list[int | None]] = []
+ original = chat_client_base._get_non_streaming_response # type: ignore[attr-defined]
+
+ async def _capture(
+ *,
+ messages: list[Message],
+ options: dict[str, Any],
+ **kwargs: Any,
+ ) -> ChatResponse:
+ captured_token_counts.append([
+ group.get(GROUP_TOKEN_COUNT_KEY) if isinstance(group, dict) else None
+ for group in (message.additional_properties.get(GROUP_ANNOTATION_KEY) for message in messages)
+ ])
+ return await original(messages=messages, options=options, **kwargs)
+
+ chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign]
+ await chat_client_base.get_response(
+ [
+ Message(role="user", text="Hello"),
+ Message(role="assistant", text="Previous response"),
+ ],
+ compaction_strategy=SlidingWindowStrategy(keep_last_groups=2),
+ tokenizer=_FixedTokenizer(17),
+ )
+ assert captured_token_counts == [[17, 17]]
+
+
+async def test_base_client_per_call_tokenizer_override_without_strategy_annotates_messages(
+ chat_client_base: SupportsChatGetResponse,
+) -> None:
+ chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined]
+ captured_token_counts: list[list[int | None]] = []
+ original = chat_client_base._get_non_streaming_response # type: ignore[attr-defined]
+
+ async def _capture(
+ *,
+ messages: list[Message],
+ options: dict[str, Any],
+ **kwargs: Any,
+ ) -> ChatResponse:
+ captured_token_counts.append([
+ group.get(GROUP_TOKEN_COUNT_KEY) if isinstance(group, dict) else None
+ for group in (message.additional_properties.get(GROUP_ANNOTATION_KEY) for message in messages)
+ ])
+ return await original(messages=messages, options=options, **kwargs)
+
+ chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign]
+ await chat_client_base.get_response(
+ [
+ Message(role="user", text="Hello"),
+ Message(role="assistant", text="Previous response"),
+ ],
+ tokenizer=_FixedTokenizer(17),
+ )
+ assert captured_token_counts == [[17, 17]]
+
+
+async def test_base_client_default_tokenizer_without_strategy_annotates_messages(
+ chat_client_base: SupportsChatGetResponse,
+) -> None:
+ chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined]
+ chat_client_base.tokenizer = _FixedTokenizer(19) # type: ignore[attr-defined]
+ captured_token_counts: list[list[int | None]] = []
+ original = chat_client_base._get_non_streaming_response # type: ignore[attr-defined]
+
+ async def _capture(
+ *,
+ messages: list[Message],
+ options: dict[str, Any],
+ **kwargs: Any,
+ ) -> ChatResponse:
+ captured_token_counts.append([
+ group.get(GROUP_TOKEN_COUNT_KEY) if isinstance(group, dict) else None
+ for group in (message.additional_properties.get(GROUP_ANNOTATION_KEY) for message in messages)
+ ])
+ return await original(messages=messages, options=options, **kwargs)
+
+ chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign]
+ await chat_client_base.get_response([
+ Message(role="user", text="Hello"),
+ Message(role="assistant", text="Previous response"),
+ ])
+ assert captured_token_counts == [[19, 19]]
+
+
+def test_base_client_as_agent_does_not_copy_client_compaction_defaults(
+ chat_client_base: SupportsChatGetResponse,
+) -> None:
+ strategy = TruncationStrategy(max_n=1, compact_to=1)
+ tokenizer = _FixedTokenizer(11)
+ chat_client_base.compaction_strategy = strategy # type: ignore[attr-defined]
+ chat_client_base.tokenizer = tokenizer # type: ignore[attr-defined]
+
+ agent = chat_client_base.as_agent(name="shared-client-agent")
+
+ assert agent.compaction_strategy is None # type: ignore[attr-defined]
+ assert agent.tokenizer is None # type: ignore[attr-defined]
+
+
async def test_chat_client_instructions_handling(chat_client_base: SupportsChatGetResponse):
instructions = "You are a helpful assistant."
diff --git a/python/packages/core/tests/core/test_compaction.py b/python/packages/core/tests/core/test_compaction.py
new file mode 100644
index 0000000000..0352529ec5
--- /dev/null
+++ b/python/packages/core/tests/core/test_compaction.py
@@ -0,0 +1,954 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from agent_framework import (
+ EXCLUDED_KEY,
+ GROUP_ANNOTATION_KEY,
+ GROUP_HAS_REASONING_KEY,
+ GROUP_ID_KEY,
+ GROUP_KIND_KEY,
+ GROUP_TOKEN_COUNT_KEY,
+ SUMMARIZED_BY_SUMMARY_ID_KEY,
+ SUMMARY_OF_GROUP_IDS_KEY,
+ SUMMARY_OF_MESSAGE_IDS_KEY,
+ CharacterEstimatorTokenizer,
+ ChatResponse,
+ CompactionProvider,
+ Content,
+ Message,
+ SelectiveToolCallCompactionStrategy,
+ SlidingWindowStrategy,
+ SummarizationStrategy,
+ TokenBudgetComposedStrategy,
+ ToolResultCompactionStrategy,
+ TruncationStrategy,
+ annotate_message_groups,
+ apply_compaction,
+ included_messages,
+ included_token_count,
+)
+from agent_framework._compaction import (
+ append_compaction_message,
+ extend_compaction_messages,
+)
+
+
+def _assistant_function_call(call_id: str) -> Message:
+ return Message(
+ role="assistant",
+ contents=[Content.from_function_call(call_id=call_id, name="tool", arguments='{"value":"x"}')],
+ )
+
+
+def _assistant_reasoning_and_function_calls(*call_ids: str) -> Message:
+ contents: list[Content] = [Content.from_text_reasoning(text="thinking")]
+ for call_id in call_ids:
+ contents.append(
+ Content.from_function_call(
+ call_id=call_id,
+ name="tool",
+ arguments='{"value":"x"}',
+ )
+ )
+ return Message(role="assistant", contents=contents)
+
+
+def _tool_result(call_id: str, result: str) -> Message:
+ return Message(
+ role="tool",
+ contents=[Content.from_function_result(call_id=call_id, result=result)],
+ )
+
+
+def _group_id(message: Message) -> str | None:
+ annotation = message.additional_properties.get(GROUP_ANNOTATION_KEY)
+ if not isinstance(annotation, dict):
+ return None
+ value = annotation.get(GROUP_ID_KEY)
+ return value if isinstance(value, str) else None
+
+
+def _group_kind(message: Message) -> str | None:
+ annotation = message.additional_properties.get(GROUP_ANNOTATION_KEY)
+ if not isinstance(annotation, dict):
+ return None
+ value = annotation.get(GROUP_KIND_KEY)
+ return value if isinstance(value, str) else None
+
+
+def _group_has_reasoning(message: Message) -> bool | None:
+ annotation = message.additional_properties.get(GROUP_ANNOTATION_KEY)
+ if not isinstance(annotation, dict):
+ return None
+ value = annotation.get(GROUP_HAS_REASONING_KEY)
+ return value if isinstance(value, bool) else None
+
+
+def _token_count(message: Message) -> int | None:
+ annotation = message.additional_properties.get(GROUP_ANNOTATION_KEY)
+ if not isinstance(annotation, dict):
+ return None
+ value = annotation.get(GROUP_TOKEN_COUNT_KEY)
+ return value if isinstance(value, int) else None
+
+
+def _group_unknown_value(message: Message, key: str) -> Any:
+ annotation = message.additional_properties.get(GROUP_ANNOTATION_KEY)
+ if not isinstance(annotation, dict):
+ return None
+ return annotation.get(key)
+
+
+def test_group_annotations_keep_tool_call_and_tool_result_atomic() -> None:
+ messages = [
+ Message(role="user", text="hello"),
+ _assistant_function_call("c1"),
+ _tool_result("c1", "ok"),
+ Message(role="assistant", text="final"),
+ ]
+
+ annotate_message_groups(messages)
+
+ call_group = _group_id(messages[1])
+ assert call_group is not None
+ assert call_group == _group_id(messages[2])
+ assert _group_id(messages[1]) != _group_id(messages[0])
+
+
+def test_group_annotations_include_reasoning_in_tool_call_group() -> None:
+ messages = [
+ _assistant_reasoning_and_function_calls("c2"),
+ _tool_result("c2", "ok"),
+ ]
+
+ annotate_message_groups(messages)
+
+ first_group = _group_id(messages[0])
+ assert first_group is not None
+ assert _group_id(messages[1]) == first_group
+ assert _group_has_reasoning(messages[0]) is True
+ assert _group_kind(messages[0]) == "tool_call"
+
+
+def test_group_annotations_handle_same_message_reasoning_and_function_calls() -> None:
+ messages = [
+ Message(role="user", text="hello"),
+ _assistant_reasoning_and_function_calls("c1", "c2"),
+ _tool_result("c1", "ok1"),
+ _tool_result("c2", "ok2"),
+ Message(role="assistant", text="final"),
+ ]
+
+ annotate_message_groups(messages)
+
+ call_group = _group_id(messages[1])
+ assert call_group is not None
+ assert _group_id(messages[2]) == call_group
+ assert _group_id(messages[3]) == call_group
+ assert _group_kind(messages[1]) == "tool_call"
+ assert _group_has_reasoning(messages[1]) is True
+
+
+def test_annotate_message_groups_with_tokenizer_adds_token_counts() -> None:
+ messages = [
+ Message(role="user", text="hello"),
+ Message(role="assistant", text="world"),
+ ]
+
+ annotate_message_groups(
+ messages,
+ tokenizer=CharacterEstimatorTokenizer(),
+ )
+
+ assert isinstance(_token_count(messages[0]), int)
+ assert isinstance(_token_count(messages[1]), int)
+
+
+def test_extend_compaction_messages_preserves_existing_annotations_and_tokens() -> None:
+ tokenizer = CharacterEstimatorTokenizer()
+ messages = [_assistant_function_call("c3")]
+ annotate_message_groups(messages)
+ old_group_id = _group_id(messages[0])
+ assert old_group_id is not None
+ old_token_count = tokenizer.count_tokens("precomputed")
+ annotation = messages[0].additional_properties.get(GROUP_ANNOTATION_KEY)
+ if isinstance(annotation, dict):
+ annotation[GROUP_TOKEN_COUNT_KEY] = old_token_count
+
+ extend_compaction_messages(messages, [_tool_result("c3", "ok")], tokenizer=tokenizer)
+
+ assert _group_id(messages[1]) == old_group_id
+ assert _token_count(messages[0]) == old_token_count
+ assert isinstance(_token_count(messages[1]), int)
+
+
+def test_append_compaction_message_annotates_new_message() -> None:
+ messages = [Message(role="user", text="hello")]
+ annotate_message_groups(messages)
+ append_compaction_message(messages, Message(role="assistant", text="world"))
+
+ assert len(messages) == 2
+ assert isinstance(_group_id(messages[1]), str)
+
+
+async def test_truncation_strategy_keeps_system_anchor() -> None:
+ messages = [
+ Message(role="system", text="you are helpful"),
+ Message(role="user", text="u1"),
+ Message(role="assistant", text="a1"),
+ Message(role="user", text="u2"),
+ Message(role="assistant", text="a2"),
+ ]
+ strategy = TruncationStrategy(max_n=3, compact_to=3, preserve_system=True)
+ annotate_message_groups(messages)
+
+ changed = await strategy(messages)
+
+ assert changed is True
+ projected = included_messages(messages)
+ assert projected[0].role == "system"
+ assert len(projected) <= 3
+
+
+async def test_truncation_strategy_compacts_when_token_limit_exceeded() -> None:
+ tokenizer = CharacterEstimatorTokenizer()
+ messages = [
+ Message(role="system", text="you are helpful"),
+ Message(role="user", text="u1 " * 200),
+ Message(role="assistant", text="a1 " * 200),
+ ]
+ strategy = TruncationStrategy(
+ max_n=80,
+ compact_to=40,
+ tokenizer=tokenizer,
+ preserve_system=True,
+ )
+ annotate_message_groups(messages, tokenizer=tokenizer)
+
+ changed = await strategy(messages)
+
+ assert changed is True
+ projected = included_messages(messages)
+ assert projected[0].role == "system"
+ assert included_token_count(messages) <= 40
+
+
+def test_truncation_strategy_validates_token_targets() -> None:
+ try:
+ TruncationStrategy(max_n=3, compact_to=4)
+ except ValueError as exc:
+ assert "compact_to must be less than or equal to max_n" in str(exc)
+ else:
+ raise AssertionError("Expected ValueError when compact_to is greater than max_n.")
+
+
+async def test_selective_tool_call_strategy_excludes_older_tool_groups() -> None:
+ messages = [
+ Message(role="user", text="u"),
+ _assistant_function_call("call-1"),
+ _tool_result("call-1", "r1"),
+ _assistant_function_call("call-2"),
+ _tool_result("call-2", "r2"),
+ Message(role="assistant", text="done"),
+ ]
+ strategy = SelectiveToolCallCompactionStrategy(keep_last_tool_call_groups=1)
+ annotate_message_groups(messages)
+
+ changed = await strategy(messages)
+
+ assert changed is True
+ assert messages[1].additional_properties.get(EXCLUDED_KEY) is True
+ assert messages[2].additional_properties.get(EXCLUDED_KEY) is True
+ assert messages[3].additional_properties.get(EXCLUDED_KEY) is not True
+ assert messages[4].additional_properties.get(EXCLUDED_KEY) is not True
+
+
+async def test_selective_tool_call_strategy_with_zero_removes_assistant_tool_pair() -> None:
+ messages = [
+ Message(role="user", text="u"),
+ _assistant_function_call("call-1"),
+ _tool_result("call-1", "r1"),
+ Message(role="assistant", text="done"),
+ ]
+ strategy = SelectiveToolCallCompactionStrategy(keep_last_tool_call_groups=0)
+ annotate_message_groups(messages)
+
+ changed = await strategy(messages)
+
+ assert changed is True
+ assert messages[1].additional_properties.get(EXCLUDED_KEY) is True
+ assert messages[2].additional_properties.get(EXCLUDED_KEY) is True
+ assert messages[0].additional_properties.get(EXCLUDED_KEY) is not True
+ assert messages[3].additional_properties.get(EXCLUDED_KEY) is not True
+
+
+def test_selective_tool_call_strategy_rejects_negative_keep_count() -> None:
+ try:
+ SelectiveToolCallCompactionStrategy(keep_last_tool_call_groups=-1)
+ except ValueError as exc:
+ assert "must be greater than or equal to 0" in str(exc)
+ else:
+ raise AssertionError("Expected ValueError for negative keep_last_tool_call_groups.")
+
+
+class _FakeSummarizer:
+ async def get_response(
+ self,
+ messages: list[Message],
+ *,
+ stream: bool = False,
+ options: dict[str, Any] | None = None,
+ **kwargs: Any,
+ ) -> ChatResponse:
+ return ChatResponse(messages=[Message(role="assistant", text="summarized context")])
+
+
+class _FailingSummarizer:
+ async def get_response(
+ self,
+ messages: list[Message],
+ *,
+ stream: bool = False,
+ options: dict[str, Any] | None = None,
+ **kwargs: Any,
+ ) -> ChatResponse:
+ raise RuntimeError("summary failed")
+
+
+class _EmptySummarizer:
+ async def get_response(
+ self,
+ messages: list[Message],
+ *,
+ stream: bool = False,
+ options: dict[str, Any] | None = None,
+ **kwargs: Any,
+ ) -> ChatResponse:
+ return ChatResponse(messages=[Message(role="assistant", text=" ")])
+
+
+async def test_summarization_strategy_adds_bidirectional_trace_links() -> None:
+ messages = [
+ Message(role="user", text="u1"),
+ Message(role="assistant", text="a1"),
+ Message(role="user", text="u2"),
+ Message(role="assistant", text="a2"),
+ Message(role="user", text="u3"),
+ Message(role="assistant", text="a3"),
+ ]
+ strategy = SummarizationStrategy(client=_FakeSummarizer(), target_count=2, threshold=0)
+ annotate_message_groups(messages)
+
+ changed = await strategy(messages)
+
+ assert changed is True
+ summary_messages = [
+ message for message in messages if _group_unknown_value(message, SUMMARY_OF_MESSAGE_IDS_KEY) is not None
+ ]
+ assert len(summary_messages) == 1
+ summary = summary_messages[0]
+ summary_id = summary.message_id
+ assert summary_id is not None
+ assert _group_unknown_value(summary, SUMMARY_OF_GROUP_IDS_KEY)
+ summarized_message_ids = _group_unknown_value(summary, SUMMARY_OF_MESSAGE_IDS_KEY)
+ assert isinstance(summarized_message_ids, list)
+ for message in messages:
+ if message.message_id in summarized_message_ids:
+ assert _group_unknown_value(message, SUMMARIZED_BY_SUMMARY_ID_KEY) == summary_id
+ assert message.additional_properties.get(EXCLUDED_KEY) is True
+
+
+async def test_summarization_strategy_returns_false_when_summary_generation_fails(
+ caplog: Any,
+) -> None:
+ messages = [
+ Message(role="user", text="u1"),
+ Message(role="assistant", text="a1"),
+ Message(role="user", text="u2"),
+ Message(role="assistant", text="a2"),
+ Message(role="user", text="u3"),
+ Message(role="assistant", text="a3"),
+ ]
+ strategy = SummarizationStrategy(client=_FailingSummarizer(), target_count=2, threshold=0)
+ annotate_message_groups(messages)
+
+ with caplog.at_level(logging.WARNING, logger="agent_framework"):
+ changed = await strategy(messages)
+
+ assert changed is False
+ assert any("summary generation failed" in record.message for record in caplog.records)
+ assert all(message.additional_properties.get(EXCLUDED_KEY) is not True for message in messages)
+
+
+async def test_summarization_strategy_returns_false_when_summary_is_empty(
+ caplog: Any,
+) -> None:
+ messages = [
+ Message(role="user", text="u1"),
+ Message(role="assistant", text="a1"),
+ Message(role="user", text="u2"),
+ Message(role="assistant", text="a2"),
+ Message(role="user", text="u3"),
+ Message(role="assistant", text="a3"),
+ ]
+ strategy = SummarizationStrategy(client=_EmptySummarizer(), target_count=2, threshold=0)
+ annotate_message_groups(messages)
+
+ with caplog.at_level(logging.WARNING, logger="agent_framework"):
+ changed = await strategy(messages)
+
+ assert changed is False
+ assert any("returned no text" in record.message for record in caplog.records)
+ assert all(message.additional_properties.get(EXCLUDED_KEY) is not True for message in messages)
+
+
+async def test_token_budget_composed_strategy_meets_budget_or_falls_back() -> None:
+ messages = [
+ Message(role="system", text="system"),
+ Message(role="user", text="user " * 200),
+ Message(role="assistant", text="assistant " * 200),
+ ]
+ strategy = TokenBudgetComposedStrategy(
+ token_budget=20,
+ tokenizer=CharacterEstimatorTokenizer(),
+ strategies=[SlidingWindowStrategy(keep_last_groups=1)],
+ )
+
+ changed = await strategy(messages)
+
+ assert changed is True
+ assert included_token_count(messages) <= 20
+
+
+class _ExcludeOldestNonSystem:
+ async def __call__(self, messages: list[Message]) -> bool:
+ group_ids = annotate_message_groups(messages)
+ kinds: dict[str, str] = {}
+ for message in messages:
+ group_id = _group_id(message)
+ kind = _group_kind(message)
+ if group_id is not None and kind is not None and group_id not in kinds:
+ kinds[group_id] = kind
+ for group_id in group_ids:
+ if kinds.get(group_id) == "system":
+ continue
+ for message in messages:
+ if _group_id(message) == group_id:
+ message.additional_properties[EXCLUDED_KEY] = True
+ return True
+ return False
+
+
+async def test_apply_compaction_projects_included_messages_only() -> None:
+ messages = [
+ Message(role="system", text="sys"),
+ Message(role="user", text="hello"),
+ Message(role="assistant", text="world"),
+ ]
+
+ projected = await apply_compaction(messages, strategy=_ExcludeOldestNonSystem())
+
+ assert len(projected) < len(messages)
+ assert projected[0].role == "system"
+
+
+# --- ToolResultCompactionStrategy tests ---
+
+
+async def test_tool_result_compaction_collapses_old_groups_into_summary() -> None:
+ """Old tool-call groups are collapsed into summary messages, newest kept."""
+ messages = [
+ Message(role="user", text="u"),
+ _assistant_function_call("call-1"),
+ _tool_result("call-1", "r1"),
+ _assistant_function_call("call-2"),
+ _tool_result("call-2", "r2"),
+ Message(role="assistant", text="done"),
+ ]
+ strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1)
+ annotate_message_groups(messages)
+
+ changed = await strategy(messages)
+
+ assert changed is True
+ projected = included_messages(messages)
+ texts = [m.text or "" for m in projected]
+ summary_msgs = [t for t in texts if t.startswith("[Tool results:")]
+ assert len(summary_msgs) == 1
+ assert "r1" in summary_msgs[0]
+ assert any(m.role == "tool" for m in projected)
+
+
+async def test_tool_result_compaction_zero_collapses_all() -> None:
+ """With keep=0, all tool-call groups are collapsed into summaries."""
+ messages = [
+ Message(role="user", text="u"),
+ _assistant_function_call("call-1"),
+ _tool_result("call-1", "r1"),
+ _assistant_function_call("call-2"),
+ _tool_result("call-2", "r2"),
+ Message(role="assistant", text="done"),
+ ]
+ strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=0)
+ annotate_message_groups(messages)
+
+ changed = await strategy(messages)
+
+ assert changed is True
+ projected = included_messages(messages)
+ summary_msgs = [m for m in projected if (m.text or "").startswith("[Tool results:")]
+ assert len(summary_msgs) == 2
+ assert not any(m.role == "tool" for m in projected)
+
+
+async def test_tool_result_compaction_no_change_when_within_limit() -> None:
+ """No compaction when tool groups count does not exceed keep limit."""
+ messages = [
+ Message(role="user", text="u"),
+ _assistant_function_call("call-1"),
+ _tool_result("call-1", "r1"),
+ ]
+ strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1)
+ annotate_message_groups(messages)
+
+ changed = await strategy(messages)
+
+ assert changed is False
+
+
+def test_tool_result_compaction_rejects_negative() -> None:
+ try:
+ ToolResultCompactionStrategy(keep_last_tool_call_groups=-1)
+ except ValueError as exc:
+ assert "must be greater than or equal to 0" in str(exc)
+ else:
+ raise AssertionError("Expected ValueError for negative keep_last_tool_call_groups.")
+
+
+async def test_tool_result_compaction_preserves_tool_results_in_summary() -> None:
+ """Summary text should include the tool results from the collapsed group."""
+ messages = [
+ Message(role="user", text="u"),
+ Message(
+ role="assistant",
+ contents=[
+ Content.from_function_call(call_id="c1", name="get_weather", arguments="{}"),
+ Content.from_function_call(call_id="c2", name="search_docs", arguments="{}"),
+ ],
+ ),
+ _tool_result("c1", "sunny"),
+ _tool_result("c2", "found 3 docs"),
+ Message(role="assistant", text="done"),
+ ]
+ strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=0)
+ annotate_message_groups(messages)
+
+ await strategy(messages)
+
+ projected = included_messages(messages)
+ summary_msgs = [m for m in projected if (m.text or "").startswith("[Tool results:")]
+ assert len(summary_msgs) == 1
+ assert "sunny" in summary_msgs[0].text # type: ignore[operator]
+ assert "found 3 docs" in summary_msgs[0].text # type: ignore[operator]
+
+
+async def test_tool_result_compaction_bidirectional_tracing() -> None:
+ """Summary and originals should link to each other like SummarizationStrategy does."""
+ messages = [
+ Message(role="user", text="u"),
+ _assistant_function_call("call-1"),
+ _tool_result("call-1", "r1"),
+ Message(role="assistant", text="done"),
+ ]
+ strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=0)
+ annotate_message_groups(messages)
+
+ await strategy(messages)
+
+ # Find the summary message.
+ summary_msgs = [m for m in messages if _group_unknown_value(m, SUMMARY_OF_MESSAGE_IDS_KEY) is not None]
+ assert len(summary_msgs) == 1
+ summary = summary_msgs[0]
+ summary_id = summary.message_id
+ assert summary_id is not None
+
+ # Forward link: summary knows which messages/groups it replaces.
+ assert isinstance(_group_unknown_value(summary, SUMMARY_OF_MESSAGE_IDS_KEY), list)
+ assert isinstance(_group_unknown_value(summary, SUMMARY_OF_GROUP_IDS_KEY), list)
+
+ # Back link: excluded originals know which summary replaced them.
+ for m in messages:
+ if m.additional_properties.get(EXCLUDED_KEY):
+ assert _group_unknown_value(m, SUMMARIZED_BY_SUMMARY_ID_KEY) == summary_id
+
+ # Core compaction annotations must be present on the summary message.
+ assert _group_id(summary) is not None
+ assert _group_kind(summary) is not None
+ assert summary.additional_properties.get(EXCLUDED_KEY) is False
+
+
+async def test_tool_result_compaction_summary_has_full_annotations() -> None:
+ """Summary messages inserted by ToolResultCompactionStrategy must have all compaction annotations."""
+ messages = [
+ Message(role="user", text="u"),
+ _assistant_function_call("c1"),
+ _tool_result("c1", "r1"),
+ Message(role="assistant", text="done"),
+ ]
+ strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=0)
+ annotate_message_groups(messages)
+
+ await strategy(messages)
+
+ summary = next(m for m in messages if (m.text or "").startswith("[Tool results:"))
+ annotation = summary.additional_properties.get(GROUP_ANNOTATION_KEY)
+ assert isinstance(annotation, dict)
+ assert GROUP_ID_KEY in annotation
+ assert GROUP_KIND_KEY in annotation
+ assert GROUP_HAS_REASONING_KEY in annotation
+ assert SUMMARY_OF_MESSAGE_IDS_KEY in annotation
+ assert summary.additional_properties.get(EXCLUDED_KEY) is False
+
+
+async def test_summarization_strategy_summary_has_full_annotations() -> None:
+ """Summary messages inserted by SummarizationStrategy must have all compaction annotations."""
+ messages = [
+ Message(role="user", text="u1"),
+ Message(role="assistant", text="a1"),
+ Message(role="user", text="u2"),
+ Message(role="assistant", text="a2"),
+ Message(role="user", text="u3"),
+ Message(role="assistant", text="a3"),
+ ]
+ strategy = SummarizationStrategy(client=_FakeSummarizer(), target_count=2, threshold=0)
+ annotate_message_groups(messages)
+
+ changed = await strategy(messages)
+
+ assert changed is True
+ summary = next(m for m in messages if _group_unknown_value(m, SUMMARY_OF_MESSAGE_IDS_KEY) is not None)
+ annotation = summary.additional_properties.get(GROUP_ANNOTATION_KEY)
+ assert isinstance(annotation, dict)
+ assert GROUP_ID_KEY in annotation
+ assert GROUP_KIND_KEY in annotation
+ assert GROUP_HAS_REASONING_KEY in annotation
+ assert SUMMARY_OF_MESSAGE_IDS_KEY in annotation
+ assert summary.additional_properties.get(EXCLUDED_KEY) is False
+
+
+async def test_tool_result_compaction_multiple_groups_combined() -> None:
+ """Multiple tool-call groups collapsed independently, each with its own summary.
+
+ Scenario: 3 tool-call groups, keep_last=1 → groups 1 and 2 each get a
+ separate summary, group 3 stays verbatim.
+ """
+ messages = [
+ Message(role="user", text="Compare weather in London, Paris, and Tokyo"),
+ # Group 1: get_weather for London
+ Message(
+ role="assistant",
+ contents=[Content.from_function_call(call_id="c1", name="get_weather", arguments='{"city":"London"}')],
+ ),
+ _tool_result("c1", '{"temp":12,"condition":"cloudy","wind":"NW 15km/h"}'),
+ Message(role="assistant", text="London is cloudy at 12°C."),
+ # Group 2: get_weather for Paris + search_hotels
+ Message(
+ role="assistant",
+ contents=[
+ Content.from_function_call(call_id="c2", name="get_weather", arguments='{"city":"Paris"}'),
+ Content.from_function_call(call_id="c3", name="search_hotels", arguments='{"city":"Paris"}'),
+ ],
+ ),
+ _tool_result("c2", '{"temp":18,"condition":"sunny"}'),
+ _tool_result("c3", "Grand Hotel (€120), Le Petit (€85)"),
+ Message(role="assistant", text="Paris is sunny at 18°C. Found 2 hotels."),
+ # Group 3: get_weather for Tokyo (most recent — should be kept)
+ Message(
+ role="assistant",
+ contents=[Content.from_function_call(call_id="c4", name="get_weather", arguments='{"city":"Tokyo"}')],
+ ),
+ _tool_result("c4", '{"temp":22,"condition":"rainy"}'),
+ Message(role="assistant", text="Tokyo is rainy at 22°C."),
+ ]
+ strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1)
+ annotate_message_groups(messages)
+
+ changed = await strategy(messages)
+
+ assert changed is True
+ projected = included_messages(messages)
+ summary_msgs = [m for m in projected if (m.text or "").startswith("[Tool results:")]
+
+ # Two summaries: one for group 1, one for group 2.
+ assert len(summary_msgs) == 2
+
+ # Group 1 summary: London weather result.
+ g1_text = summary_msgs[0].text or ""
+ assert "12" in g1_text
+ assert "cloudy" in g1_text
+
+ # Group 2 summary: Paris weather + hotel results combined.
+ g2_text = summary_msgs[1].text or ""
+ assert "18" in g2_text
+ assert "Grand Hotel" in g2_text
+
+ # Group 3 (Tokyo) stays verbatim — tool role messages still present.
+ verbatim_tool_msgs = [m for m in projected if m.role == "tool"]
+ assert len(verbatim_tool_msgs) == 1
+ assert "rainy" in (verbatim_tool_msgs[0].contents[0].result or "")
+
+ # All text assistant messages should still be present.
+ text_msgs = [m for m in projected if m.role == "assistant" and m.text and not m.text.startswith("[Tool results:")]
+ texts = [m.text for m in text_msgs]
+ assert "London is cloudy at 12°C." in texts
+ assert "Paris is sunny at 18°C. Found 2 hotels." in texts
+ assert "Tokyo is rainy at 22°C." in texts
+
+ # Final projected shape: 8 messages in order.
+ assert len(projected) == 8
+ assert projected[0].role == "user" # original user message
+ assert projected[1].text == '[Tool results: get_weather: {"temp":12,"condition":"cloudy","wind":"NW 15km/h"}]'
+ assert projected[2].text == "London is cloudy at 12°C."
+ expected_g2 = (
+ '[Tool results: get_weather: {"temp":18,"condition":"sunny"};'
+ " search_hotels: Grand Hotel (€120), Le Petit (€85)]"
+ )
+ assert projected[3].text == expected_g2
+ assert projected[4].text == "Paris is sunny at 18°C. Found 2 hotels." # group 2 assistant text
+ assert projected[5].role == "assistant" # group 3 function_call (verbatim)
+ assert projected[6].role == "tool" # group 3 tool result (verbatim)
+ assert projected[7].text == "Tokyo is rainy at 22°C." # group 3 assistant text
+
+
+# --- CompactionProvider tests ---
+
+
+class _MockSessionContext:
+ """Minimal mock for SessionContext used in CompactionProvider tests."""
+
+ def __init__(self) -> None:
+ self.context_messages: dict[str, list[Message]] = {}
+ self.input_messages: list[Message] = []
+ self._response: Any = None
+
+ @property
+ def response(self) -> Any:
+ return self._response
+
+ def extend_messages(self, provider: Any, messages: list[Message]) -> None:
+ source_id = getattr(provider, "source_id", "unknown")
+ self.context_messages.setdefault(source_id, []).extend(messages)
+
+ def get_messages(self) -> list[Message]:
+ result: list[Message] = []
+ for msgs in self.context_messages.values():
+ result.extend(msgs)
+ return result
+
+
+async def test_compaction_provider_compacts_existing_context_messages() -> None:
+ """CompactionProvider.before_run compacts messages already in context from earlier providers."""
+ provider = CompactionProvider(
+ before_strategy=SlidingWindowStrategy(keep_last_groups=2, preserve_system=True),
+ )
+
+ context = _MockSessionContext()
+ context.context_messages["history"] = [
+ Message(role="system", text="sys"),
+ Message(role="user", text="u1"),
+ Message(role="assistant", text="a1"),
+ Message(role="user", text="u2"),
+ Message(role="assistant", text="a2"),
+ Message(role="user", text="u3"),
+ Message(role="assistant", text="a3"),
+ ]
+
+ await provider.before_run(agent=None, session=None, context=context, state={})
+
+ remaining = context.context_messages["history"]
+ assert len(remaining) == 3
+ assert remaining[0].role == "system"
+ assert remaining[1].text == "u3"
+ assert remaining[2].text == "a3"
+
+
+async def test_compaction_provider_noop_when_no_context_messages() -> None:
+ """before_run with no context messages does nothing."""
+ provider = CompactionProvider(
+ before_strategy=SlidingWindowStrategy(keep_last_groups=2),
+ )
+
+ context = _MockSessionContext()
+ await provider.before_run(agent=None, session=None, context=context, state={})
+
+ assert context.context_messages == {}
+
+
+async def test_compaction_provider_preserves_messages_from_multiple_sources() -> None:
+ """CompactionProvider correctly filters across multiple provider sources."""
+ provider = CompactionProvider(
+ before_strategy=SlidingWindowStrategy(keep_last_groups=2, preserve_system=True),
+ )
+
+ context = _MockSessionContext()
+ context.context_messages["history"] = [
+ Message(role="system", text="sys"),
+ Message(role="user", text="old_user"),
+ Message(role="assistant", text="old_assistant"),
+ ]
+ context.context_messages["rag"] = [
+ Message(role="user", text="recent_rag_context"),
+ Message(role="assistant", text="recent_rag_answer"),
+ ]
+
+ await provider.before_run(agent=None, session=None, context=context, state={})
+
+ all_remaining = context.get_messages()
+ assert any(m.role == "system" for m in all_remaining)
+ assert len(all_remaining) < 5
+
+
+class _MockSession:
+ """Minimal mock for AgentSession used in CompactionProvider after_run tests."""
+
+ def __init__(self) -> None:
+ self.state: dict[str, Any] = {}
+
+
+async def test_compaction_provider_after_run_compacts_stored_history() -> None:
+ """after_run annotates exclusions on stored messages without removing them."""
+ provider = CompactionProvider(
+ after_strategy=SelectiveToolCallCompactionStrategy(keep_last_tool_call_groups=0),
+ history_source_id="in_memory_history",
+ )
+
+ session = _MockSession()
+ session.state["in_memory_history"] = {
+ "messages": [
+ Message(role="user", text="old question"),
+ Message(role="assistant", text="old answer"),
+ _assistant_function_call("c1"),
+ _tool_result("c1", "result"),
+ Message(role="assistant", text="final answer"),
+ ]
+ }
+
+ context = _MockSessionContext()
+ await provider.after_run(agent=None, session=session, context=context, state={})
+
+ stored = session.state["in_memory_history"]["messages"]
+ # All messages are kept; tool-call group is excluded via annotation.
+ assert len(stored) == 5
+ excluded = [m for m in stored if m.additional_properties.get("_excluded", False)]
+ assert len(excluded) == 2 # assistant function_call + tool result
+ assert any(m.text == "final answer" for m in stored if not m.additional_properties.get("_excluded", False))
+
+
+async def test_compaction_provider_after_run_noop_without_history() -> None:
+ """after_run does nothing when there is no history state."""
+ provider = CompactionProvider(
+ after_strategy=SlidingWindowStrategy(keep_last_groups=2),
+ history_source_id="in_memory_history",
+ )
+
+ session = _MockSession()
+ context = _MockSessionContext()
+ await provider.after_run(agent=None, session=session, context=context, state={})
+
+ assert "in_memory_history" not in session.state
+
+
+async def test_compaction_provider_both_strategies() -> None:
+ """Both before_strategy and after_strategy work independently."""
+ provider = CompactionProvider(
+ before_strategy=SlidingWindowStrategy(keep_last_groups=2, preserve_system=True),
+ after_strategy=SelectiveToolCallCompactionStrategy(keep_last_tool_call_groups=0),
+ history_source_id="history",
+ )
+
+ # before_run: compact loaded context
+ context = _MockSessionContext()
+ context.context_messages["history"] = [
+ Message(role="system", text="sys"),
+ Message(role="user", text="u1"),
+ Message(role="assistant", text="a1"),
+ Message(role="user", text="u2"),
+ Message(role="assistant", text="a2"),
+ ]
+ await provider.before_run(agent=None, session=None, context=context, state={})
+ assert len(context.get_messages()) == 3
+
+ # after_run: compact stored history
+ session = _MockSession()
+ session.state["history"] = {
+ "messages": [
+ Message(role="user", text="q"),
+ _assistant_function_call("c1"),
+ _tool_result("c1", "ok"),
+ Message(role="assistant", text="done"),
+ ]
+ }
+ await provider.after_run(agent=None, session=session, context=_MockSessionContext(), state={})
+ stored = session.state["history"]["messages"]
+ excluded = [m for m in stored if m.additional_properties.get("_excluded", False)]
+ assert len(excluded) == 2 # tool-call group excluded
+
+
+async def test_compaction_provider_none_strategies_are_noop() -> None:
+ """When both strategies are None, before_run and after_run are no-ops."""
+ provider = CompactionProvider()
+
+ context = _MockSessionContext()
+ context.context_messages["history"] = [
+ Message(role="user", text="hello"),
+ Message(role="assistant", text="hi"),
+ ]
+
+ await provider.before_run(agent=None, session=None, context=context, state={})
+ assert len(context.get_messages()) == 2
+
+ session = _MockSession()
+ await provider.after_run(agent=None, session=session, context=context, state={})
+ assert "in_memory_history" not in session.state
+
+
+async def test_in_memory_history_provider_skip_excluded() -> None:
+ """InMemoryHistoryProvider with skip_excluded=True omits excluded messages."""
+ from agent_framework._compaction import EXCLUDED_KEY
+ from agent_framework._sessions import InMemoryHistoryProvider as _InMemoryHistoryProvider
+
+ provider = _InMemoryHistoryProvider(skip_excluded=True)
+ state: dict[str, Any] = {
+ "messages": [
+ Message(role="user", text="u1"),
+ Message(role="assistant", text="a1", additional_properties={EXCLUDED_KEY: True}),
+ Message(role="user", text="u2"),
+ Message(role="assistant", text="a2"),
+ ]
+ }
+
+ loaded = await provider.get_messages(session_id="test", state=state)
+ assert len(loaded) == 3
+ assert all(m.text != "a1" for m in loaded)
+
+
+async def test_in_memory_history_provider_default_loads_all() -> None:
+ """InMemoryHistoryProvider with default settings loads all messages including excluded."""
+ from agent_framework._compaction import EXCLUDED_KEY
+ from agent_framework._sessions import InMemoryHistoryProvider as _InMemoryHistoryProvider
+
+ provider = _InMemoryHistoryProvider()
+ state: dict[str, Any] = {
+ "messages": [
+ Message(role="user", text="u1"),
+ Message(role="assistant", text="a1", additional_properties={EXCLUDED_KEY: True}),
+ Message(role="user", text="u2"),
+ ]
+ }
+
+ loaded = await provider.get_messages(session_id="test", state=state)
+ assert len(loaded) == 3
diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py
index 7f0eda62fc..59c932f946 100644
--- a/python/packages/core/tests/core/test_function_invocation_logic.py
+++ b/python/packages/core/tests/core/test_function_invocation_logic.py
@@ -15,9 +15,27 @@ from agent_framework import (
SupportsChatGetResponse,
tool,
)
+from agent_framework._compaction import (
+ EXCLUDED_KEY,
+ GROUP_ANNOTATION_KEY,
+ GROUP_ID_KEY,
+ CharacterEstimatorTokenizer,
+ SlidingWindowStrategy,
+ TokenBudgetComposedStrategy,
+ annotate_message_groups,
+ included_token_count,
+)
from agent_framework._middleware import FunctionInvocationContext, FunctionMiddleware, MiddlewareTermination
+def _group_id(message: Message) -> str | None:
+ annotation = message.additional_properties.get(GROUP_ANNOTATION_KEY)
+ if not isinstance(annotation, dict):
+ return None
+ value = annotation.get(GROUP_ID_KEY)
+ return value if isinstance(value, str) else None
+
+
async def test_base_client_with_function_calling(chat_client_base: SupportsChatGetResponse):
exec_counter = 0
@@ -131,6 +149,127 @@ async def test_base_client_with_function_calling_resets(chat_client_base: Suppor
assert response.messages[3].contents[0].type == "function_result"
+async def test_function_loop_applies_compaction_projection_each_model_call(chat_client_base: SupportsChatGetResponse):
+ @tool(name="test_function", approval_mode="never_require")
+ def ai_func(arg1: str) -> str:
+ return f"Processed {arg1}"
+
+ class _ExcludeOldestGroupAfterFirstTurn:
+ async def __call__(self, messages: list[Message]) -> bool:
+ groups = annotate_message_groups(messages)
+ if len(groups) <= 1:
+ return False
+ oldest_group_id = groups[0]
+ changed = False
+ for message in messages:
+ if _group_id(message) == oldest_group_id:
+ if message.additional_properties.get(EXCLUDED_KEY) is not True:
+ changed = True
+ message.additional_properties[EXCLUDED_KEY] = True
+ return changed
+
+ captured_roles: list[list[str]] = []
+ original = chat_client_base._get_non_streaming_response # type: ignore[attr-defined]
+
+ async def _capture(
+ *,
+ messages: list[Message],
+ options: dict[str, Any],
+ **kwargs: Any,
+ ) -> ChatResponse:
+ captured_roles.append([message.role for message in messages])
+ return await original(messages=messages, options=options, **kwargs)
+
+ chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign]
+ chat_client_base.compaction_strategy = _ExcludeOldestGroupAfterFirstTurn() # type: ignore[attr-defined]
+
+ chat_client_base.run_responses = [
+ ChatResponse(
+ messages=Message(
+ role="assistant",
+ contents=[
+ Content.from_function_call(call_id="1", name="test_function", arguments='{"arg1": "value1"}')
+ ],
+ )
+ ),
+ ChatResponse(messages=Message(role="assistant", text="done")),
+ ]
+
+ await chat_client_base.get_response(
+ [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [ai_func]}
+ )
+
+ assert len(captured_roles) >= 2
+ assert "user" in captured_roles[0]
+ assert "user" not in captured_roles[1]
+
+
+async def test_function_loop_token_budget_strategy_caps_tokens_each_iteration(
+ chat_client_base: SupportsChatGetResponse,
+):
+ exec_counter = 0
+ token_budget = 500
+ tokenizer = CharacterEstimatorTokenizer()
+
+ @tool(name="test_function", approval_mode="never_require")
+ def ai_func(arg1: str) -> str:
+ nonlocal exec_counter
+ exec_counter += 1
+ return f"Processed {arg1}. " + ("result " * 120)
+
+ captured_token_counts: list[int] = []
+ original = chat_client_base._get_non_streaming_response # type: ignore[attr-defined]
+
+ async def _capture(
+ *,
+ messages: list[Message],
+ options: dict[str, Any],
+ **kwargs: Any,
+ ) -> ChatResponse:
+ annotate_message_groups(messages, force_reannotate=True, tokenizer=tokenizer)
+ captured_token_counts.append(included_token_count(messages))
+ return await original(messages=messages, options=options, **kwargs)
+
+ chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign]
+ chat_client_base.tokenizer = tokenizer # type: ignore[attr-defined]
+ chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
+ chat_client_base.compaction_strategy = TokenBudgetComposedStrategy( # type: ignore[attr-defined]
+ token_budget=token_budget,
+ tokenizer=tokenizer,
+ strategies=[SlidingWindowStrategy(keep_last_groups=2)],
+ )
+ chat_client_base.run_responses = [
+ ChatResponse(
+ messages=Message(
+ role="assistant",
+ contents=[
+ Content.from_function_call(call_id="1", name="test_function", arguments='{"arg1": "value1"}')
+ ],
+ )
+ ),
+ ChatResponse(
+ messages=Message(
+ role="assistant",
+ contents=[
+ Content.from_function_call(call_id="2", name="test_function", arguments='{"arg1": "value2"}')
+ ],
+ )
+ ),
+ ChatResponse(messages=Message(role="assistant", text="done")),
+ ]
+
+ response = await chat_client_base.get_response(
+ [Message(role="user", text="hello " * 160)],
+ options={"tool_choice": "auto", "tools": [ai_func]},
+ )
+
+ assert response.messages[-1].text == "done"
+ assert exec_counter == 2
+ assert len(captured_token_counts) >= 3
+ assert all(token_count > 0 for token_count in captured_token_counts)
+ assert all(token_count <= token_budget for token_count in captured_token_counts)
+
+
async def test_base_client_with_streaming_function_calling(chat_client_base: SupportsChatGetResponse):
exec_counter = 0
diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py
index 8fe941b208..134c4219cd 100644
--- a/python/packages/core/tests/core/test_skills.py
+++ b/python/packages/core/tests/core/test_skills.py
@@ -35,7 +35,7 @@ from agent_framework._skills import (
async def _noop_script_runner(skill: Any, script: Any, args: Any = None) -> None:
"""No-op script runner for tests that need a SkillScriptRunner."""
- return None
+ return
def _symlinks_supported(tmp: Path) -> bool:
@@ -1994,7 +1994,7 @@ class TestSkillScriptRunnerProtocol:
"""Tests for the SkillScriptRunner protocol."""
async def test_async_callable_satisfies_protocol(self) -> None:
- from agent_framework import SkillScriptRunner, SkillScript
+ from agent_framework import SkillScript, SkillScriptRunner
results: list[tuple] = []
@@ -2015,7 +2015,7 @@ class TestSkillScriptRunnerProtocol:
assert results[0] == ("test-skill", "my-script", {"key": "val"})
async def test_callable_class_satisfies_protocol(self) -> None:
- from agent_framework import SkillScriptRunner, SkillScript
+ from agent_framework import SkillScript, SkillScriptRunner
class _CustomRunner:
async def __call__(self, skill, script, args=None):
@@ -2056,7 +2056,7 @@ class TestSkillScriptRunnerProtocol:
assert result == {"exit_code": 0, "output": "ok"}
def test_sync_callable_satisfies_protocol(self) -> None:
- from agent_framework import SkillScriptRunner, SkillScript
+ from agent_framework import SkillScript, SkillScriptRunner
results: list[tuple] = []
@@ -2077,7 +2077,7 @@ class TestSkillScriptRunnerProtocol:
assert results[0] == ("test-skill", "my-script", {"key": "val"})
def test_sync_callable_class_satisfies_protocol(self) -> None:
- from agent_framework import SkillScriptRunner, SkillScript
+ from agent_framework import SkillScript, SkillScriptRunner
class _SyncRunner:
def __call__(self, skill, script, args=None):
@@ -2117,6 +2117,7 @@ class TestSkillScriptRunnerProtocol:
result = dict_runner(skill, script)
assert result == {"exit_code": 0, "output": "ok"}
+
# ---------------------------------------------------------------------------
# SkillsProvider static factory tests
# ---------------------------------------------------------------------------
diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py
index b932516196..2609cb29bd 100644
--- a/python/packages/core/tests/core/test_types.py
+++ b/python/packages/core/tests/core/test_types.py
@@ -28,6 +28,12 @@ from agent_framework import (
merge_chat_options,
tool,
)
+from agent_framework._compaction import (
+ GROUP_ANNOTATION_KEY,
+ GROUP_HAS_REASONING_KEY,
+ GROUP_ID_KEY,
+ GROUP_TOKEN_COUNT_KEY,
+)
from agent_framework._types import (
_get_data_bytes,
_get_data_bytes_as_str,
@@ -1654,6 +1660,78 @@ def test_chat_message_complex_content_serialization():
assert reconstructed.contents[2].type == "function_result"
+def test_message_roundtrip_preserves_compaction_annotation_dict() -> None:
+ message = Message(
+ role="assistant",
+ contents=[Content.from_text("Hello")],
+ additional_properties={
+ GROUP_ANNOTATION_KEY: {
+ "id": "group_1",
+ "kind": "assistant_text",
+ "index": 1,
+ "has_reasoning": False,
+ "token_count": 42,
+ }
+ },
+ )
+
+ restored = Message.from_dict(message.to_dict())
+ annotation = restored.additional_properties.get(GROUP_ANNOTATION_KEY)
+
+ assert isinstance(annotation, dict)
+ assert annotation[GROUP_ID_KEY] == "group_1"
+ assert annotation[GROUP_TOKEN_COUNT_KEY] == 42
+
+
+def test_content_roundtrip_preserves_compaction_annotation_dict() -> None:
+ content = Content.from_text(
+ text="Hello",
+ additional_properties={
+ GROUP_ANNOTATION_KEY: {
+ "id": "group_2",
+ "kind": "assistant_text",
+ "index": 2,
+ "has_reasoning": False,
+ "token_count": None,
+ }
+ },
+ )
+
+ restored = Content.from_dict(content.to_dict())
+ annotation = restored.additional_properties.get(GROUP_ANNOTATION_KEY)
+
+ assert isinstance(annotation, dict)
+ assert annotation[GROUP_ID_KEY] == "group_2"
+ assert annotation[GROUP_TOKEN_COUNT_KEY] is None
+
+
+def test_chat_response_roundtrip_preserves_compaction_annotation_dict() -> None:
+ response = ChatResponse(
+ messages=[
+ Message(
+ role="assistant",
+ contents=[Content.from_text("Hello")],
+ additional_properties={
+ GROUP_ANNOTATION_KEY: {
+ "id": "group_3",
+ "kind": "assistant_text",
+ "index": 3,
+ "has_reasoning": True,
+ "token_count": 15,
+ }
+ },
+ )
+ ]
+ )
+
+ restored = ChatResponse.from_dict(response.to_dict())
+ annotation = restored.messages[0].additional_properties.get(GROUP_ANNOTATION_KEY)
+
+ assert isinstance(annotation, dict)
+ assert annotation[GROUP_ID_KEY] == "group_3"
+ assert annotation[GROUP_HAS_REASONING_KEY] is True
+
+
def test_usage_content_serialization_with_details():
"""Test UsageContent from_dict and to_dict with UsageDetails conversion."""
diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py
index e049dbd16e..d5a9903b93 100644
--- a/python/packages/core/tests/openai/test_openai_responses_client.py
+++ b/python/packages/core/tests/openai/test_openai_responses_client.py
@@ -524,6 +524,58 @@ def test_response_content_creation_with_reasoning() -> None:
assert response.messages[0].contents[0].text == "Reasoning step"
+def test_response_content_keeps_reasoning_and_function_calls_in_one_message() -> None:
+ """Reasoning + function calls should parse into one assistant message."""
+ client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
+
+ mock_response = MagicMock()
+ mock_response.output_parsed = None
+ mock_response.metadata = {}
+ mock_response.usage = None
+ mock_response.id = "test-id"
+ mock_response.model = "test-model"
+ mock_response.created_at = 1000000000
+
+ mock_reasoning_content = MagicMock()
+ mock_reasoning_content.text = "Reasoning step"
+
+ mock_reasoning_item = MagicMock()
+ mock_reasoning_item.type = "reasoning"
+ mock_reasoning_item.id = "rs_123"
+ mock_reasoning_item.content = [mock_reasoning_content]
+ mock_reasoning_item.summary = []
+
+ mock_function_call_item_1 = MagicMock()
+ mock_function_call_item_1.type = "function_call"
+ mock_function_call_item_1.id = "fc_1"
+ mock_function_call_item_1.call_id = "call_1"
+ mock_function_call_item_1.name = "tool_1"
+ mock_function_call_item_1.arguments = '{"x": 1}'
+
+ mock_function_call_item_2 = MagicMock()
+ mock_function_call_item_2.type = "function_call"
+ mock_function_call_item_2.id = "fc_2"
+ mock_function_call_item_2.call_id = "call_2"
+ mock_function_call_item_2.name = "tool_2"
+ mock_function_call_item_2.arguments = '{"y": 2}'
+
+ mock_response.output = [
+ mock_reasoning_item,
+ mock_function_call_item_1,
+ mock_function_call_item_2,
+ ]
+
+ response = client._parse_response_from_openai(mock_response, options={}) # type: ignore
+
+ assert len(response.messages) == 1
+ assert response.messages[0].role == "assistant"
+ assert [content.type for content in response.messages[0].contents] == [
+ "text_reasoning",
+ "function_call",
+ "function_call",
+ ]
+
+
def test_response_content_creation_with_code_interpreter() -> None:
"""Test _parse_response_from_openai with code interpreter outputs."""
diff --git a/python/pyproject.toml b/python/pyproject.toml
index e916373a06..82e113c811 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -222,7 +222,7 @@ samples-lint = "ruff check samples --fix --exclude samples/autogen-migration,sam
pyright = "python scripts/run_tasks_in_packages_if_exists.py pyright"
mypy = "python scripts/run_tasks_in_packages_if_exists.py mypy"
samples-syntax = "pyright -p pyrightconfig.samples.json --warnings"
-typing = ["pyright", "mypy"]
+typing = "python scripts/run_tasks_in_packages_if_exists.py mypy pyright"
# cleaning
clean-dist-packages = "python scripts/run_tasks_in_packages_if_exists.py clean-dist"
clean-dist-meta = "rm -rf dist"
diff --git a/python/samples/02-agents/compaction/README.md b/python/samples/02-agents/compaction/README.md
new file mode 100644
index 0000000000..ed5c3dab12
--- /dev/null
+++ b/python/samples/02-agents/compaction/README.md
@@ -0,0 +1,23 @@
+# Context Compaction Samples
+
+This folder demonstrates context compaction patterns introduced by ADR-0019.
+
+## Files
+
+- `basics.py` — builds a local message list and applies each built-in strategy one at a time.
+- `advanced.py` — composes multiple strategies with `TokenBudgetComposedStrategy`.
+- `agent_client_overrides.py` — shows client defaults, agent-level overrides, and per-run compaction overrides.
+- `custom.py` — defines a custom strategy implementing the `CompactionStrategy` protocol.
+- `tiktoken_tokenizer.py` — shows a `TokenizerProtocol` implementation backed by `tiktoken`.
+- `compaction_provider.py` — uses `CompactionProvider` with an agent and `InMemoryHistoryProvider`.
+
+Run samples with:
+
+```bash
+uv run samples/02-agents/compaction/basics.py
+uv run samples/02-agents/compaction/advanced.py
+uv run samples/02-agents/compaction/agent_client_overrides.py
+uv run samples/02-agents/compaction/custom.py
+uv run samples/02-agents/compaction/tiktoken_tokenizer.py
+uv run samples/02-agents/compaction/compaction_provider.py # requires OPENAI_API_KEY
+```
diff --git a/python/samples/02-agents/compaction/advanced.py b/python/samples/02-agents/compaction/advanced.py
new file mode 100644
index 0000000000..7cf1fc7f39
--- /dev/null
+++ b/python/samples/02-agents/compaction/advanced.py
@@ -0,0 +1,115 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+from typing import Any
+
+from agent_framework import (
+ CharacterEstimatorTokenizer,
+ ChatResponse,
+ Message,
+ SelectiveToolCallCompactionStrategy,
+ SlidingWindowStrategy,
+ SummarizationStrategy,
+ TokenBudgetComposedStrategy,
+ annotate_message_groups,
+ apply_compaction,
+ included_token_count,
+)
+
+"""This sample demonstrates composed in-run compaction with a token budget.
+
+Key components:
+- TokenBudgetComposedStrategy
+- Sequential strategy composition
+- Summarization with a SupportsChatGetResponse-compatible summarizer client
+"""
+
+
+class BudgetSummaryClient:
+ async def get_response(
+ self,
+ messages: list[Message],
+ *,
+ stream: bool = False,
+ options: dict[str, Any] | None = None,
+ **kwargs: Any,
+ ) -> ChatResponse:
+ summary_text = f"Budget summary generated from {len(messages)} prompt messages."
+ return ChatResponse(messages=[Message(role="assistant", text=summary_text)])
+
+
+def _build_long_history() -> list[Message]:
+ history = [Message(role="system", text="You are a migration copilot.")]
+ for i in range(1, 8):
+ history.append(
+ Message(
+ role="user",
+ text=f"Iteration {i}: capture migration requirements and edge cases.",
+ )
+ )
+ history.append(
+ Message(
+ role="assistant",
+ text=(
+ f"Iteration {i}: detailed plan with dependencies, rollback guidance, and testing details. "
+ "This sentence is intentionally long to create token pressure."
+ ),
+ )
+ )
+ return history
+
+
+async def main() -> None:
+ # 1. Build synthetic history representing long-running in-run growth.
+ messages = _build_long_history()
+
+ # 2. Configure tokenizer and measure token count before compaction.
+ tokenizer = CharacterEstimatorTokenizer()
+ annotate_message_groups(messages, tokenizer=tokenizer)
+ budget_before = included_token_count(messages)
+
+ # 3. Configure composed strategy stack.
+ composed = TokenBudgetComposedStrategy(
+ token_budget=200,
+ tokenizer=tokenizer,
+ strategies=[
+ SelectiveToolCallCompactionStrategy(keep_last_tool_call_groups=0),
+ SummarizationStrategy(
+ client=BudgetSummaryClient(),
+ target_count=3,
+ threshold=3,
+ ),
+ SlidingWindowStrategy(keep_last_groups=4),
+ ],
+ )
+
+ # 4. Apply compaction and inspect the budget result.
+ projected = await apply_compaction(messages, strategy=composed, tokenizer=tokenizer)
+ budget_after = included_token_count(messages)
+
+ print(f"Projected messages after compaction: {len(projected)}")
+ print(f"Included token count before compaction: {budget_before}")
+ print(f"Included token count after compaction: {budget_after}")
+ print("Projected roles:", [m.role for m in projected])
+ print("Projected messages with token counts:")
+ for msg in projected:
+ group = msg.additional_properties.get("_group")
+ token_count = group.get("token_count") if isinstance(group, dict) else None
+ text_preview = msg.text[:80] if msg.text else ""
+ print(f"- [{msg.role}] {text_preview} ({token_count} tokens)")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+Projected messages after compaction: 3
+Included token count before compaction: 793
+Included token count after compaction: 144
+Projected roles: ['system', 'user', 'assistant']
+Projected messages with token counts:
+- [system] You are a migration copilot. (35 tokens)
+- [user] Iteration 7: capture migration requirements and edge cases. (43 tokens)
+- [assistant] Iteration 7: detailed plan with dependencies, rollback guidance, and testing det (66 tokens)
+"""
diff --git a/python/samples/02-agents/compaction/agent_client_overrides.py b/python/samples/02-agents/compaction/agent_client_overrides.py
new file mode 100644
index 0000000000..bed7baa2a1
--- /dev/null
+++ b/python/samples/02-agents/compaction/agent_client_overrides.py
@@ -0,0 +1,144 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Awaitable, Mapping, Sequence
+from typing import Any
+
+from agent_framework import (
+ GROUP_ANNOTATION_KEY,
+ GROUP_TOKEN_COUNT_KEY,
+ Agent,
+ BaseChatClient,
+ ChatResponse,
+ Message,
+ SlidingWindowStrategy,
+ TruncationStrategy,
+)
+
+"""This sample demonstrates client defaults, agent overrides, and run-level overrides for in-run compaction.
+
+Key components:
+- A shared client with default `compaction_strategy` and `tokenizer`
+- An agent-level override that takes precedence over the shared client defaults
+- A run-level override passed through `agent.run(...)`
+"""
+
+
+class FixedTokenizer:
+ """Simple tokenizer used to make token annotations easy to inspect."""
+
+ def __init__(self, token_count: int) -> None:
+ self._token_count = token_count
+
+ def count_tokens(self, text: str) -> int:
+ return self._token_count
+
+
+class InspectingChatClient(BaseChatClient[Any]):
+ """Chat client that records the messages it receives after compaction."""
+
+ def __init__(self, **kwargs: Any) -> None:
+ super().__init__(**kwargs)
+ self.last_messages: list[Message] = []
+
+ def _inner_get_response(
+ self,
+ *,
+ messages: Sequence[Message],
+ stream: bool,
+ options: Mapping[str, Any],
+ **kwargs: Any,
+ ) -> Awaitable[ChatResponse]:
+ if stream:
+ raise ValueError("This sample only demonstrates non-streaming responses.")
+
+ self.last_messages = list(messages)
+
+ async def _get_response() -> ChatResponse:
+ return ChatResponse(messages=[Message(role="assistant", text="done")])
+
+ return _get_response()
+
+
+def _build_messages() -> list[Message]:
+ return [
+ Message(role="user", text="Collect the deployment requirements."),
+ Message(role="assistant", text="I will gather the constraints first."),
+ Message(role="user", text="Summarize the rollout risks."),
+ Message(role="assistant", text="The main risks are drift, downtime, and rollback gaps."),
+ ]
+
+
+def _token_count(message: Message) -> int | None:
+ group_annotation = message.additional_properties.get(GROUP_ANNOTATION_KEY)
+ if not isinstance(group_annotation, dict):
+ return None
+ value = group_annotation.get(GROUP_TOKEN_COUNT_KEY)
+ return value if isinstance(value, int) else None
+
+
+def _print_model_input(title: str, client: InspectingChatClient) -> None:
+ print(f"\n{title}")
+ print(f"Model receives {len(client.last_messages)} message(s):")
+ for message in client.last_messages:
+ print(f"- [{message.role}] {message.text} ({_token_count(message)} tokens)")
+
+
+async def main() -> None:
+ # 1. Create one shared client with default compaction settings.
+ shared_client = InspectingChatClient(
+ compaction_strategy=TruncationStrategy(max_n=3, compact_to=2),
+ tokenizer=FixedTokenizer(7),
+ )
+
+ # 2. Create one agent that relies on the client defaults.
+ client_default_agent = Agent(client=shared_client, name="ClientDefaultAgent")
+
+ # 3. Create another agent that overrides the shared client's defaults.
+ agent_override = Agent(
+ client=shared_client,
+ name="AgentOverrideAgent",
+ compaction_strategy=SlidingWindowStrategy(keep_last_groups=3),
+ tokenizer=FixedTokenizer(11),
+ )
+
+ # 4. Run the first agent; the client defaults are applied.
+ await client_default_agent.run(_build_messages())
+ _print_model_input("1. Client default compaction", shared_client)
+
+ # 5. Run the second agent; the agent-level override wins over the client defaults.
+ await agent_override.run(_build_messages())
+ _print_model_input("2. Agent-level override", shared_client)
+
+ # 6. Override both settings for a single run; the per-run values win over both.
+ await agent_override.run(
+ _build_messages(),
+ compaction_strategy=TruncationStrategy(max_n=2, compact_to=1),
+ tokenizer=FixedTokenizer(23),
+ )
+ _print_model_input("3. Per-run override", shared_client)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+
+1. Client default compaction
+Model receives 2 message(s):
+- [user] Summarize the rollout risks. (7 tokens)
+- [assistant] The main risks are drift, downtime, and rollback gaps. (7 tokens)
+
+2. Agent-level override
+Model receives 3 message(s):
+- [assistant] I will gather the constraints first. (11 tokens)
+- [user] Summarize the rollout risks. (11 tokens)
+- [assistant] The main risks are drift, downtime, and rollback gaps. (11 tokens)
+
+3. Per-run override
+Model receives 1 message(s):
+- [assistant] The main risks are drift, downtime, and rollback gaps. (23 tokens)
+"""
diff --git a/python/samples/02-agents/compaction/basics.py b/python/samples/02-agents/compaction/basics.py
new file mode 100644
index 0000000000..b75f9b5f47
--- /dev/null
+++ b/python/samples/02-agents/compaction/basics.py
@@ -0,0 +1,241 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+from typing import Any
+
+from agent_framework import (
+ CharacterEstimatorTokenizer,
+ ChatResponse,
+ Content,
+ Message,
+ SelectiveToolCallCompactionStrategy,
+ SlidingWindowStrategy,
+ SummarizationStrategy,
+ TokenBudgetComposedStrategy,
+ ToolResultCompactionStrategy,
+ TruncationStrategy,
+ apply_compaction,
+)
+
+"""This sample demonstrates selecting one compaction strategy at a time.
+
+How to use this sample:
+- Keep one ``selected_strategy`` block active in ``main``.
+- Comment the active block and uncomment one of the alternatives to switch strategies.
+- Run again to compare behavior against the same "before" message list shown once.
+"""
+
+SUMMARY_OF_MESSAGE_IDS_KEY = "_summary_of_message_ids"
+SUMMARIZED_BY_SUMMARY_ID_KEY = "_summarized_by_summary_id"
+
+# Keep optional strategy classes imported for quick uncomment/switch in main().
+AVAILABLE_STRATEGY_TYPES = (
+ TruncationStrategy,
+ CharacterEstimatorTokenizer,
+ SlidingWindowStrategy,
+ SelectiveToolCallCompactionStrategy,
+ ToolResultCompactionStrategy,
+ SummarizationStrategy,
+ TokenBudgetComposedStrategy,
+)
+
+
+class LocalSummaryClient:
+ """Simple local summarizer compatible with SupportsChatGetResponse."""
+
+ async def get_response(
+ self,
+ messages: list[Message],
+ *,
+ stream: bool = False,
+ options: dict[str, Any] | None = None,
+ **kwargs: Any,
+ ) -> ChatResponse:
+ return ChatResponse(messages=[Message(role="assistant", text=f"Summary for {len(messages)} messages.")])
+
+
+async def main() -> None:
+ # 1. Build one baseline history and print it once.
+ messages = [
+ Message(role="system", text="You are a helpful assistant."),
+ Message(role="user", text="Plan a data migration."),
+ Message(role="assistant", text="I will gather requirements."),
+ Message(
+ role="assistant",
+ contents=[
+ Content.from_function_call(
+ call_id="call_1",
+ name="list_tables",
+ arguments='{"db":"legacy"}',
+ )
+ ],
+ ),
+ Message(
+ role="tool",
+ contents=[
+ Content.from_function_result(
+ call_id="call_1",
+ result="users, orders, events",
+ )
+ ],
+ ),
+ Message(role="assistant", text="I found three core tables."),
+ Message(role="user", text="Estimate effort and risks."),
+ Message(role="assistant", text="Primary risk is schema drift."),
+ ]
+ print("\n--- Before compaction ---")
+ print(f"Message count: {len(messages)}")
+ for index, message in enumerate(messages, start=1):
+ message_text = message.text or ", ".join(content.type for content in message.contents)
+ print(f"{index:02d}. [{message.role}] {message_text}")
+
+ # 2. Select exactly one strategy (default shown below).
+ # Truncate when included history exceeds 5 messages, then keep 4.
+ # System remains anchored, so the oldest non-system messages are removed first.
+ # selected_strategy_name = "TruncationStrategy"
+ # selected_strategy = TruncationStrategy(max_n=5, compact_to=4, preserve_system=True)
+
+ # Keep the most recent 4 non-system groups and preserve the system anchor.
+ # A group represents a user turn (and related assistant/tool follow-up).
+ # selected_strategy_name = "SlidingWindowStrategy"
+ # selected_strategy = SlidingWindowStrategy(keep_last_groups=4, preserve_system=True)
+
+ # This means all tool-call groups are removed (assistant function_call message
+ # plus matching tool result messages). In this example, setting to 0 removes
+ # the single assistant+tool pair.
+ selected_strategy_name = "SelectiveToolCallCompactionStrategy"
+ selected_strategy = SelectiveToolCallCompactionStrategy(keep_last_tool_call_groups=0)
+
+ # Collapse older tool-call groups into short "[Tool results: tool_name]" summaries
+ # while keeping the most recent group verbatim. Unlike SelectiveToolCallCompactionStrategy
+ # which fully excludes groups, this preserves a readable trace of tool usage.
+ # selected_strategy_name = "ToolResultCompactionStrategy"
+ # selected_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=0)
+
+ # Summarize older messages so only recent context remains, and attach summary
+ # trace metadata linking summary -> originals and originals -> summary.
+ # summary_client = LocalSummaryClient()
+ # selected_strategy_name = "SummarizationStrategy"
+ # selected_strategy = SummarizationStrategy(
+ # client=summary_client, target_count=3, threshold=2
+ # )
+
+ # tokenizer = CharacterEstimatorTokenizer()
+ # selected_strategy_name = "TokenBudgetComposedStrategy"
+ # selected_strategy = TokenBudgetComposedStrategy(
+ # token_budget=150,
+ # tokenizer=tokenizer,
+ # strategies=[
+ # SelectiveToolCallCompactionStrategy(keep_last_tool_call_groups=0),
+ # SlidingWindowStrategy(keep_last_groups=2),
+ # ],
+ # )
+
+ # 3. Apply the selected strategy and print projected output.
+ projected = await apply_compaction(messages, strategy=selected_strategy)
+ print(f"\n--- After compaction ({selected_strategy_name}) ---")
+ print(f"Message count: {len(projected)}")
+ for index, message in enumerate(projected, start=1):
+ message_text = message.text or ", ".join(content.type for content in message.contents)
+ print(f"{index:02d}. [{message.role}] {message_text}")
+
+ summaries = []
+ summarized = []
+ for message in messages:
+ group_annotation = message.additional_properties.get("_group")
+ if not isinstance(group_annotation, dict):
+ continue
+ if group_annotation.get(SUMMARY_OF_MESSAGE_IDS_KEY):
+ summaries.append(message)
+ if group_annotation.get(SUMMARIZED_BY_SUMMARY_ID_KEY):
+ summarized.append(message)
+ if summaries or summarized:
+ print("Summary trace metadata present:")
+ for message in summaries:
+ group_annotation = message.additional_properties.get("_group")
+ summarized_ids = (
+ group_annotation.get(SUMMARY_OF_MESSAGE_IDS_KEY) if isinstance(group_annotation, dict) else None
+ )
+ print(f" summary_id={message.message_id} summarizes={summarized_ids}")
+ for message in summarized:
+ group_annotation = message.additional_properties.get("_group")
+ summarized_by = (
+ group_annotation.get(SUMMARIZED_BY_SUMMARY_ID_KEY) if isinstance(group_annotation, dict) else None
+ )
+ print(f" original_id={message.message_id} summarized_by={summarized_by}")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output (always present):
+--- Before compaction ---
+Message count: 8
+01. [system] You are a helpful assistant.
+02. [user] Plan a data migration.
+03. [assistant] I will gather requirements.
+04. [assistant] function_call
+05. [tool] function_result
+06. [assistant] I found three core tables.
+07. [user] Estimate effort and risks.
+08. [assistant] Primary risk is schema drift.
+"""
+
+"""
+Sample output (varies based on selected strategy):
+--- After compaction (TruncationStrategy) ---
+Message count: 4
+01. [system] You are a helpful assistant.
+02. [assistant] I found three core tables.
+03. [user] Estimate effort and risks.
+04. [assistant] Primary risk is schema drift.
+
+--- After compaction (SlidingWindowStrategy) ---
+Message count: 6
+01. [system] You are a helpful assistant.
+02. [assistant] function_call
+03. [tool] function_result
+04. [assistant] I found three core tables.
+05. [user] Estimate effort and risks.
+06. [assistant] Primary risk is schema drift.
+
+--- After compaction (SelectiveToolCallCompactionStrategy) ---
+Message count: 6
+01. [system] You are a helpful assistant.
+02. [user] Plan a data migration.
+03. [assistant] I will gather requirements.
+04. [assistant] I found three core tables.
+05. [user] Estimate effort and risks.
+06. [assistant] Primary risk is schema drift.
+
+--- After compaction (ToolResultCompactionStrategy) ---
+Message count: 7
+01. [system] You are a helpful assistant.
+02. [assistant] [Tool results: list_tables]
+03. [user] Plan a data migration.
+04. [assistant] I will gather requirements.
+05. [assistant] I found three core tables.
+06. [user] Estimate effort and risks.
+07. [assistant] Primary risk is schema drift.
+
+--- After compaction (SummarizationStrategy) ---
+Message count: 5
+01. [system] You are a helpful assistant.
+02. [assistant] Summary for 2 messages.
+03. [assistant] I found three core tables.
+04. [user] Estimate effort and risks.
+05. [assistant] Primary risk is schema drift.
+Summary trace metadata present:
+ summary_id=summary_8 summarizes=['msg_1', 'msg_2', 'msg_3', 'msg_4']
+ original_id=msg_1 summarized_by=summary_8
+ original_id=msg_2 summarized_by=summary_8
+ original_id=msg_3 summarized_by=summary_8
+ original_id=msg_4 summarized_by=summary_8
+
+--- After compaction (TokenBudgetComposedStrategy) ---
+Message count: 3
+01. [system] You are a helpful assistant.
+02. [user] Estimate effort and risks.
+03. [assistant] Primary risk is schema drift.
+"""
diff --git a/python/samples/02-agents/compaction/compaction_provider.py b/python/samples/02-agents/compaction/compaction_provider.py
new file mode 100644
index 0000000000..d91fa42d7c
--- /dev/null
+++ b/python/samples/02-agents/compaction/compaction_provider.py
@@ -0,0 +1,249 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+from collections.abc import Sequence
+from typing import Any
+
+from agent_framework import (
+ Agent,
+ ChatContext,
+ CompactionProvider,
+ InMemoryHistoryProvider,
+ Message,
+ SlidingWindowStrategy,
+ ToolResultCompactionStrategy,
+ chat_middleware,
+ tool,
+)
+from agent_framework.openai import OpenAIChatClient
+from dotenv import load_dotenv
+
+load_dotenv()
+
+"""
+CompactionProvider with Agent Example
+
+Demonstrates ``CompactionProvider`` as part of a real agent's context-provider
+pipeline alongside ``InMemoryHistoryProvider``.
+
+The compaction provider uses two separate strategies:
+
+- ``before_strategy``: Applied to the loaded history before the model sees it.
+ Here a ``SlidingWindowStrategy`` keeps only the last 3 message groups, so
+ older turns get dropped as the conversation grows.
+- ``after_strategy``: Applied to the stored history after each turn.
+ Here a ``ToolResultCompactionStrategy`` collapses all but the most recent
+ tool-call group into short ``[Tool results: ...]`` summaries.
+
+A chat middleware logs the messages the model actually receives (after context
+providers and compaction have run) so you can see the effect of compaction.
+
+This sample intentionally is too aggressive in excluding content, because you can see
+that the last turn actually does not have the full context any longer and is therefore
+only comparing the results from Paris and Tokyo and not from London.
+
+Run with:
+ uv run samples/02-agents/compaction/compaction_provider.py
+"""
+
+
+@tool(approval_mode="never_require")
+def get_weather(city: str) -> str:
+ """Get the current weather for a city."""
+ weather_data = {
+ "London": "cloudy, 12°C",
+ "Paris": "sunny, 18°C",
+ "Tokyo": "rainy, 22°C",
+ }
+ return weather_data.get(city, f"No data for {city}")
+
+
+@chat_middleware
+async def log_model_input(context: ChatContext, call_next: Any) -> None:
+ """Chat middleware that logs the messages sent to the model (after compaction)."""
+ msgs: Sequence[Message] = context.messages
+ print(f"\n Model receives {len(msgs)} messages:")
+ for i, m in enumerate(msgs, 1):
+ text = m.text or ", ".join(c.type for c in m.contents)
+ print(f" {i:02d}. [{m.role}] {text[:70]}")
+ await call_next()
+
+
+async def main() -> None:
+ client = OpenAIChatClient(model_id="gpt-4o-mini")
+
+ # History provider loads/stores conversation messages in session.state.
+ # skip_excluded=True means get_messages() will omit messages that were
+ # marked as excluded by the CompactionProvider's after_strategy.
+ history = InMemoryHistoryProvider(skip_excluded=True)
+
+ compaction = CompactionProvider(
+ # BEFORE each turn: SlidingWindow drops older message groups from
+ # the loaded context so the model's input stays bounded. With
+ # keep_last_groups=3, only the 3 most recent non-system groups are
+ # sent to the model — older turns are not shown to the model.
+ before_strategy=SlidingWindowStrategy(keep_last_groups=3, preserve_system=True),
+ # AFTER each turn: ToolResultCompaction marks older tool-call groups
+ # (assistant function_call + tool result messages) as excluded and
+ # inserts a short "[Tool results: ...]" summary. The original messages
+ # stay in storage with _excluded=True; skip_excluded on the history
+ # provider ensures they won't be loaded on the next turn.
+ after_strategy=ToolResultCompactionStrategy(keep_last_tool_call_groups=1),
+ history_source_id=history.source_id,
+ )
+
+ # Provider order matters:
+ # before_run: history loads → compaction trims (forward order)
+ # after_run: compaction marks exclusions → history stores (reverse order)
+ agent = Agent(
+ client=client,
+ name="WeatherAssistant",
+ instructions="You are a helpful weather assistant. Use the get_weather tool when asked about weather.",
+ tools=[get_weather],
+ context_providers=[history, compaction],
+ middleware=[log_model_input],
+ )
+
+ session = agent.create_session()
+
+ queries = [
+ "What is the weather in London?",
+ "How about Paris?",
+ "And Tokyo?",
+ "Which city is the warmest?",
+ ]
+
+ for turn, query in enumerate(queries, 1):
+ print(f"\n{'=' * 60}")
+ print(f"Turn {turn} — User: {query}")
+
+ # ── What is in the persistent store right now? ──
+ # This shows ALL messages the history provider has accumulated,
+ # including any that were marked as excluded by the after_strategy
+ # on the previous turn. Messages marked ✗ are excluded and won't
+ # be loaded because skip_excluded=True on the history provider.
+ stored = session.state.get(history.source_id, {}).get("messages", [])
+ if stored:
+ excluded_count = sum(1 for m in stored if m.additional_properties.get("_excluded", False))
+ print(f"\n Stored history: {len(stored)} messages ({excluded_count} excluded)")
+ for i, m in enumerate(stored, 1):
+ text = m.text or ", ".join(c.type for c in m.contents)
+ excluded = m.additional_properties.get("_excluded", False)
+ reason = m.additional_properties.get("_exclude_reason", "")
+ if excluded:
+ marker = f" ✗ ({reason})"
+ elif (m.text or "").startswith("[Tool results:"):
+ marker = " ← summary"
+ else:
+ marker = ""
+ print(f" {i:02d}. [{m.role}]{marker} {text[:65]}")
+
+ # ── What the model actually sees ──
+ # The chat middleware fires AFTER the full context pipeline:
+ # 1. InMemoryHistoryProvider loads non-excluded stored messages
+ # 2. CompactionProvider.before_strategy (SlidingWindow) drops
+ # older groups so only the last 3 non-system groups survive
+ # 3. The agent prepends instructions and appends the new user input
+ # So this list is shorter than what's in storage.
+ result = await agent.run(query, session=session)
+
+ # ── What happens after the turn ──
+ # The agent's after_run pipeline runs in reverse provider order:
+ # 1. CompactionProvider.after_strategy (ToolResultCompaction) marks
+ # older tool-call groups as excluded in the stored messages —
+ # their assistant+tool messages get ✗ and a summary is inserted
+ # 2. InMemoryHistoryProvider appends the new input + response
+ # On the NEXT turn, skip_excluded=True means the ✗ messages won't load.
+ print(f"\n Agent: {result.text}")
+
+ print(f"\n{'=' * 60}")
+ print("Done.")
+
+
+"""
+Example output:
+============================================================
+Turn 1 — User: What is the weather in London?
+
+ Model receives 1 messages:
+ 01. [user] What is the weather in London?
+
+ Agent: The weather in London is cloudy with a temperature of 12°C.
+
+============================================================
+Turn 2 — User: How about Paris?
+
+ Stored history: 4 messages (0 excluded)
+ 01. [user] What is the weather in London?
+ 02. [assistant] function_call
+ 03. [tool] function_result
+ 04. [assistant] The weather in London is cloudy with a temperature of 12°C.
+
+ Model receives 5 messages:
+ 01. [user] What is the weather in London?
+ 02. [assistant] function_call
+ 03. [tool] function_result
+ 04. [assistant] The weather in London is cloudy with a temperature of 12°C.
+ 05. [user] How about Paris?
+
+ Agent: The weather in Paris is sunny with a temperature of 18°C.
+
+============================================================
+Turn 3 — User: And Tokyo?
+
+ Stored history: 8 messages (0 excluded)
+ 01. [user] What is the weather in London?
+ 02. [assistant] function_call
+ 03. [tool] function_result
+ 04. [assistant] The weather in London is cloudy with a temperature of 12°C.
+ 05. [user] How about Paris?
+ 06. [assistant] function_call
+ 07. [tool] function_result
+ 08. [assistant] The weather in Paris is sunny with a temperature of 18°C.
+
+ Model receives 5 messages:
+ 01. [assistant] The weather in London is cloudy with a temperature of 12°C.
+ 02. [assistant] function_call
+ 03. [tool] function_result
+ 04. [assistant] The weather in Paris is sunny with a temperature of 18°C.
+ 05. [user] And Tokyo?
+
+ Agent: The weather in Tokyo is rainy with a temperature of 22°C.
+
+============================================================
+Turn 4 — User: Which city is the warmest?
+
+ Stored history: 13 messages (3 excluded)
+ 01. [user] What is the weather in London?
+ 02. [assistant] ← summary [Tool results: get_weather: cloudy, 12°C]
+ 03. [assistant] ✗ (tool_result_compaction) function_call
+ 04. [tool] ✗ (tool_result_compaction) function_result
+ 05. [assistant] The weather in London is cloudy with a temperature of 12°C.
+ 06. [user] ✗ (tool_result_compaction) How about Paris?
+ 07. [assistant] function_call
+ 08. [tool] function_result
+ 09. [assistant] The weather in Paris is sunny with a temperature of 18°C.
+ 10. [user] And Tokyo?
+ 11. [assistant] function_call
+ 12. [tool] function_result
+ 13. [assistant] The weather in Tokyo is rainy with a temperature of 22°C.
+
+ Model receives 8 messages:
+ 01. [assistant] function_call
+ 02. [tool] function_result
+ 03. [assistant] The weather in Paris is sunny with a temperature of 18°C.
+ 04. [user] And Tokyo?
+ 05. [assistant] function_call
+ 06. [tool] function_result
+ 07. [assistant] The weather in Tokyo is rainy with a temperature of 22°C.
+ 08. [user] Which city is the warmest?
+
+ Agent: Tokyo is the warmest city with a temperature of 22°C, compared to Paris, which is at 18°C.
+
+============================================================
+Done.
+"""
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/python/samples/02-agents/compaction/custom.py b/python/samples/02-agents/compaction/custom.py
new file mode 100644
index 0000000000..ea9647b9ae
--- /dev/null
+++ b/python/samples/02-agents/compaction/custom.py
@@ -0,0 +1,89 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+
+from agent_framework import (
+ Message,
+ annotate_message_groups,
+ apply_compaction,
+ included_messages,
+)
+
+"""This sample demonstrates authoring a custom compaction strategy.
+
+The custom strategy keeps system messages and the most recent user turn while
+excluding older non-system groups.
+"""
+
+EXCLUDED_KEY = "_excluded"
+GROUP_ANNOTATION_KEY = "_group"
+
+
+class KeepLastUserTurnStrategy:
+ async def __call__(self, messages: list[Message]) -> bool:
+ group_ids = annotate_message_groups(messages)
+ group_kinds: dict[str, str] = {}
+ for message in messages:
+ group_annotation = message.additional_properties.get(GROUP_ANNOTATION_KEY)
+ group_id = group_annotation.get("id") if isinstance(group_annotation, dict) else None
+ kind = group_annotation.get("kind") if isinstance(group_annotation, dict) else None
+ if (
+ isinstance(group_id, str)
+ and isinstance(kind, str)
+ and group_id not in group_kinds
+ ):
+ group_kinds[group_id] = kind
+ user_group_ids = [
+ group_id for group_id in group_ids if group_kinds.get(group_id) == "user"
+ ]
+ if not user_group_ids:
+ return False
+ keep_user_group_id = user_group_ids[-1]
+
+ changed = False
+ for message in messages:
+ group_annotation = message.additional_properties.get(GROUP_ANNOTATION_KEY)
+ group_id = group_annotation.get("id") if isinstance(group_annotation, dict) else None
+ if message.role == "system":
+ continue
+ if group_id == keep_user_group_id:
+ continue
+ if message.additional_properties.get(EXCLUDED_KEY) is not True:
+ changed = True
+ message.additional_properties[EXCLUDED_KEY] = True
+ return changed
+
+
+def _messages() -> list[Message]:
+ return [
+ Message(role="system", text="You are concise."),
+ Message(role="user", text="first request"),
+ Message(role="assistant", text="first response"),
+ Message(role="user", text="second request"),
+ Message(role="assistant", text="second response"),
+ ]
+
+
+async def main() -> None:
+ # 1. Build a short conversation.
+ messages = _messages()
+ print(f"Number of messages before compaction: {len(messages)}")
+ # 2. Apply custom strategy.
+ await apply_compaction(messages, strategy=KeepLastUserTurnStrategy())
+ # 3. Print projected messages.
+ projected = included_messages(messages)
+ print(f"Number of messages after compaction: {len(projected)}")
+ for msg in projected:
+ print(f"[{msg.role}] {msg.text}")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+Number of messages before compaction: 5
+Number of messages after compaction: 2
+[system] You are concise.
+[user] second request
+"""
diff --git a/python/samples/02-agents/compaction/tiktoken_tokenizer.py b/python/samples/02-agents/compaction/tiktoken_tokenizer.py
new file mode 100644
index 0000000000..ac282db338
--- /dev/null
+++ b/python/samples/02-agents/compaction/tiktoken_tokenizer.py
@@ -0,0 +1,124 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "tiktoken",
+# ]
+# ///
+# Run with: uv run samples/02-agents/compaction/tiktoken_tokenizer.py
+
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+from typing import Any
+
+import tiktoken
+from agent_framework import (
+ Message,
+ TokenizerProtocol,
+ TruncationStrategy,
+ annotate_message_groups,
+ apply_compaction,
+ included_token_count,
+)
+
+"""This sample demonstrates a custom TokenizerProtocol implementation with tiktoken.
+
+Key components:
+- `TiktokenTokenizer` backed by `tiktoken`
+- Token-based `TruncationStrategy` (`max_n` / `compact_to`)
+- Inspecting projected roles and remaining included token count
+"""
+
+
+class TiktokenTokenizer(TokenizerProtocol):
+ """TokenizerProtocol implementation backed by tiktoken's o200k_base (gpt-4.1 and up default) encoding."""
+
+ def __init__(
+ self, *, encoding_name: str = "o200k_base", model_name: str | None = None
+ ) -> None:
+ if model_name is not None:
+ self._encoding = tiktoken.encoding_for_model(model_name)
+ else:
+ self._encoding: Any = tiktoken.get_encoding(encoding_name)
+
+ def count_tokens(self, text: str) -> int:
+ return len(self._encoding.encode(text))
+
+
+def _build_messages() -> list[Message]:
+ return [
+ Message(role="system", text="You are a migration assistant."),
+ Message(
+ role="user",
+ text="List all migration risks and include detailed mitigations for each risk category.",
+ ),
+ Message(
+ role="assistant",
+ text=(
+ "Primary risks include schema drift, missing foreign key constraints, "
+ "and data quality regressions. Mitigations include staged validation, "
+ "shadow writes, and replay-based verification."
+ ),
+ ),
+ Message(
+ role="user",
+ text=(
+ "Now provide a detailed checklist with owners, rollback "
+ "gates, and validation criteria."
+ ),
+ ),
+ Message(
+ role="assistant",
+ text=(
+ "Checklist: baseline snapshots, migration dry-run, production "
+ "canary, progressive deployment, automated integrity checks, and "
+ "post-migration reconciliation."
+ ),
+ ),
+ ]
+
+
+async def main() -> None:
+ # 1. Create a tokenizer implementation that uses tiktoken.
+ tokenizer = TiktokenTokenizer()
+
+ # 2. Configure token-based truncation.
+ strategy = TruncationStrategy(
+ max_n=250,
+ compact_to=150,
+ tokenizer=tokenizer,
+ preserve_system=True,
+ )
+
+ # 3. Build conversation and measure token count before compaction.
+ messages = _build_messages()
+ annotate_message_groups(messages, tokenizer=tokenizer)
+ token_count_before = included_token_count(messages)
+
+ # 4. Apply compaction and measure token count after compaction.
+ projected = await apply_compaction(messages, strategy=strategy, tokenizer=tokenizer)
+ token_count_after = included_token_count(messages)
+
+ # 5. Print before/after token counts and projected conversation.
+ print(f"Projected messages: {len(projected)}")
+ print(f"Included token count before compaction: {token_count_before}")
+ print(f"Included token count after compaction: {token_count_after}")
+ print("Projected roles:", [message.role for message in projected])
+ for message in projected:
+ token_count = message.additional_properties.get("_group", {}).get("token_count")
+ print(f"- [{message.role}] {message.text} ({token_count} tokens)")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Projected messages: 3
+Included token count before compaction: 263
+Included token count after compaction: 149
+Projected roles: ['system', 'user', 'assistant']
+- [system] You are a migration assistant. (40 tokens)
+- [user] Now provide a detailed checklist with owners, rollback gates, and validation criteria. (49 tokens)
+- [assistant] Checklist: baseline snapshots, migration dry-run, production canary,
+ progressive deployment, automated integrity checks, and post-migration reconciliation. (60 tokens)
+"""
diff --git a/python/uv.lock b/python/uv.lock
index c261d8903b..448346caa6 100644
--- a/python/uv.lock
+++ b/python/uv.lock
@@ -1813,11 +1813,11 @@ wheels = [
[[package]]
name = "filelock"
-version = "3.25.1"
+version = "3.25.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b3/8b/4c32ecde6bea6486a2a5d05340e695174351ff6b06cf651a74c005f9df00/filelock-3.25.1.tar.gz", hash = "sha256:b9a2e977f794ef94d77cdf7d27129ac648a61f585bff3ca24630c1629f701aa9", size = 40319, upload-time = "2026-03-09T19:38:47.309Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a9/b8/2f664b56a3b4b32d28d3d106c71783073f712ba43ff6d34b9ea0ce36dc7b/filelock-3.25.1-py3-none-any.whl", hash = "sha256:18972df45473c4aa2c7921b609ee9ca4925910cc3a0fb226c96b92fc224ef7bf", size = 26720, upload-time = "2026-03-09T19:38:45.718Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" },
]
[[package]]
@@ -1864,51 +1864,51 @@ wheels = [
[[package]]
name = "fonttools"
-version = "4.62.0"
+version = "4.61.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5a/96/686339e0fda8142b7ebed39af53f4a5694602a729662f42a6209e3be91d0/fonttools-4.62.0.tar.gz", hash = "sha256:0dc477c12b8076b4eb9af2e440421b0433ffa9e1dcb39e0640a6c94665ed1098", size = 3579521, upload-time = "2026-03-09T16:50:06.217Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e4/33/63d79ca41020dd460b51f1e0f58ad1ff0a36b7bcbdf8f3971d52836581e9/fonttools-4.62.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:196cafef9aeec5258425bd31a4e9a414b2ee0d1557bca184d7923d3d3bcd90f9", size = 2870816, upload-time = "2026-03-09T16:48:32.39Z" },
- { url = "https://files.pythonhosted.org/packages/c0/7a/9aeec114bc9fc00d757a41f092f7107863d372e684a5b5724c043654477c/fonttools-4.62.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:153afc3012ff8761b1733e8fbe5d98623409774c44ffd88fbcb780e240c11d13", size = 2416127, upload-time = "2026-03-09T16:48:34.627Z" },
- { url = "https://files.pythonhosted.org/packages/5a/71/12cfd8ae0478b7158ffa8850786781f67e73c00fd897ef9d053415c5f88b/fonttools-4.62.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13b663fb197334de84db790353d59da2a7288fd14e9be329f5debc63ec0500a5", size = 5100678, upload-time = "2026-03-09T16:48:36.454Z" },
- { url = "https://files.pythonhosted.org/packages/8a/d7/8e4845993ee233c2023d11babe9b3dae7d30333da1d792eeccebcb77baab/fonttools-4.62.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:591220d5333264b1df0d3285adbdfe2af4f6a45bbf9ca2b485f97c9f577c49ff", size = 5070859, upload-time = "2026-03-09T16:48:38.786Z" },
- { url = "https://files.pythonhosted.org/packages/ae/a0/287ae04cd883a52e7bb1d92dfc4997dcffb54173761c751106845fa9e316/fonttools-4.62.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:579f35c121528a50c96bf6fcb6a393e81e7f896d4326bf40e379f1c971603db9", size = 5076689, upload-time = "2026-03-09T16:48:41.886Z" },
- { url = "https://files.pythonhosted.org/packages/6d/4e/a2377ad26c36fcd3e671a1c316ea5ed83107de1588e2d897a98349363bc7/fonttools-4.62.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:44956b003151d5a289eba6c71fe590d63509267c37e26de1766ba15d9c589582", size = 5202053, upload-time = "2026-03-09T16:48:43.867Z" },
- { url = "https://files.pythonhosted.org/packages/44/2e/ad0472e69b02f83dc88983a9910d122178461606404be5b4838af6d1744a/fonttools-4.62.0-cp311-cp311-win32.whl", hash = "sha256:42c7848fa8836ab92c23b1617c407a905642521ff2d7897fe2bf8381530172f1", size = 2292852, upload-time = "2026-03-09T16:48:46.962Z" },
- { url = "https://files.pythonhosted.org/packages/77/ce/f5a4c42c117f8113ce04048053c128d17426751a508f26398110c993a074/fonttools-4.62.0-cp311-cp311-win_amd64.whl", hash = "sha256:4da779e8f342a32856075ddb193b2a024ad900bc04ecb744014c32409ae871ed", size = 2344367, upload-time = "2026-03-09T16:48:48.818Z" },
- { url = "https://files.pythonhosted.org/packages/ab/9d/7ad1ffc080619f67d0b1e0fa6a0578f0be077404f13fd8e448d1616a94a3/fonttools-4.62.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:22bde4dc12a9e09b5ced77f3b5053d96cf10c4976c6ac0dee293418ef289d221", size = 2870004, upload-time = "2026-03-09T16:48:50.837Z" },
- { url = "https://files.pythonhosted.org/packages/4d/8b/ba59069a490f61b737e064c3129453dbd28ee38e81d56af0d04d7e6b4de4/fonttools-4.62.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7199c73b326bad892f1cb53ffdd002128bfd58a89b8f662204fbf1daf8d62e85", size = 2414662, upload-time = "2026-03-09T16:48:53.295Z" },
- { url = "https://files.pythonhosted.org/packages/8c/8c/c52a4310de58deeac7e9ea800892aec09b00bb3eb0c53265b31ec02be115/fonttools-4.62.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d732938633681d6e2324e601b79e93f7f72395ec8681f9cdae5a8c08bc167e72", size = 5032975, upload-time = "2026-03-09T16:48:55.718Z" },
- { url = "https://files.pythonhosted.org/packages/0b/a1/d16318232964d786907b9b3613b8409f74cf0be2da400854509d3a864e43/fonttools-4.62.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31a804c16d76038cc4e3826e07678efb0a02dc4f15396ea8e07088adbfb2578e", size = 4988544, upload-time = "2026-03-09T16:48:57.715Z" },
- { url = "https://files.pythonhosted.org/packages/b2/8d/7e745ca3e65852adc5e52a83dc213fe1b07d61cb5b394970fcd4b1199d1e/fonttools-4.62.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:090e74ac86e68c20150e665ef8e7e0c20cb9f8b395302c9419fa2e4d332c3b51", size = 4971296, upload-time = "2026-03-09T16:48:59.678Z" },
- { url = "https://files.pythonhosted.org/packages/e6/d4/b717a4874175146029ca1517e85474b1af80c9d9a306fc3161e71485eea5/fonttools-4.62.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8f086120e8be9e99ca1288aa5ce519833f93fe0ec6ebad2380c1dee18781f0b5", size = 5122503, upload-time = "2026-03-09T16:49:02.464Z" },
- { url = "https://files.pythonhosted.org/packages/cb/4b/92cfcba4bf8373f51c49c5ae4b512ead6fbda7d61a0e8c35a369d0db40a0/fonttools-4.62.0-cp312-cp312-win32.whl", hash = "sha256:37a73e5e38fd05c637daede6ffed5f3496096be7df6e4a3198d32af038f87527", size = 2281060, upload-time = "2026-03-09T16:49:04.385Z" },
- { url = "https://files.pythonhosted.org/packages/cd/06/cc96468781a4dc8ae2f14f16f32b32f69bde18cb9384aad27ccc7adf76f7/fonttools-4.62.0-cp312-cp312-win_amd64.whl", hash = "sha256:658ab837c878c4d2a652fcbb319547ea41693890e6434cf619e66f79387af3b8", size = 2331193, upload-time = "2026-03-09T16:49:06.598Z" },
- { url = "https://files.pythonhosted.org/packages/82/c7/985c1670aa6d82ef270f04cde11394c168f2002700353bd2bde405e59b8f/fonttools-4.62.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:274c8b8a87e439faf565d3bcd3f9f9e31bca7740755776a4a90a4bfeaa722efa", size = 2864929, upload-time = "2026-03-09T16:49:09.331Z" },
- { url = "https://files.pythonhosted.org/packages/c1/dc/c409c8ceec0d3119e9ab0b7b1a2e3c76d1f4d66e4a9db5c59e6b7652e7df/fonttools-4.62.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93e27131a5a0ae82aaadcffe309b1bae195f6711689722af026862bede05c07c", size = 2412586, upload-time = "2026-03-09T16:49:11.378Z" },
- { url = "https://files.pythonhosted.org/packages/5f/ac/8e300dbf7b4d135287c261ffd92ede02d9f48f0d2db14665fbc8b059588a/fonttools-4.62.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83c6524c5b93bad9c2939d88e619fedc62e913c19e673f25d5ab74e7a5d074e5", size = 5013708, upload-time = "2026-03-09T16:49:14.063Z" },
- { url = "https://files.pythonhosted.org/packages/fb/bc/60d93477b653eeb1ddf5f9ec34be689b79234d82dbdded269ac0252715b8/fonttools-4.62.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:106aec9226f9498fc5345125ff7200842c01eda273ae038f5049b0916907acee", size = 4964355, upload-time = "2026-03-09T16:49:16.515Z" },
- { url = "https://files.pythonhosted.org/packages/cb/eb/6dc62bcc3c3598c28a3ecb77e69018869c3e109bd83031d4973c059d318b/fonttools-4.62.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15d86b96c79013320f13bc1b15f94789edb376c0a2d22fb6088f33637e8dfcbc", size = 4953472, upload-time = "2026-03-09T16:49:18.494Z" },
- { url = "https://files.pythonhosted.org/packages/82/b3/3af7592d9b254b7b7fec018135f8776bfa0d1ad335476c2791b1334dc5e4/fonttools-4.62.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f16c07e5250d5d71d0f990a59460bc5620c3cc456121f2cfb5b60475699905f", size = 5094701, upload-time = "2026-03-09T16:49:21.67Z" },
- { url = "https://files.pythonhosted.org/packages/31/3d/976645583ab567d3ee75ff87b33aa1330fa2baeeeae5fc46210b4274dd45/fonttools-4.62.0-cp313-cp313-win32.whl", hash = "sha256:d31558890f3fa00d4f937d12708f90c7c142c803c23eaeb395a71f987a77ebe3", size = 2279710, upload-time = "2026-03-09T16:49:23.812Z" },
- { url = "https://files.pythonhosted.org/packages/f5/7a/e25245a30457595740041dba9d0ea8ec1b2517f2f1a6a741f15eba1a4edc/fonttools-4.62.0-cp313-cp313-win_amd64.whl", hash = "sha256:6826a5aa53fb6def8a66bf423939745f415546c4e92478a7c531b8b6282b6c3b", size = 2330291, upload-time = "2026-03-09T16:49:26.237Z" },
- { url = "https://files.pythonhosted.org/packages/1a/64/61f69298aa6e7c363dcf00dd6371a654676900abe27d1effd1a74b43e5d0/fonttools-4.62.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4fa5a9c716e2f75ef34b5a5c2ca0ee4848d795daa7e6792bf30fd4abf8993449", size = 2864222, upload-time = "2026-03-09T16:49:28.285Z" },
- { url = "https://files.pythonhosted.org/packages/c6/57/6b08756fe4455336b1fe160ab3c11fccc90768ccb6ee03fb0b45851aace4/fonttools-4.62.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:625f5cbeb0b8f4e42343eaeb4bc2786718ddd84760a2f5e55fdd3db049047c00", size = 2410674, upload-time = "2026-03-09T16:49:30.504Z" },
- { url = "https://files.pythonhosted.org/packages/6f/86/db65b63bb1b824b63e602e9be21b18741ddc99bcf5a7850f9181159ae107/fonttools-4.62.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6247e58b96b982709cd569a91a2ba935d406dccf17b6aa615afaed37ac3856aa", size = 4999387, upload-time = "2026-03-09T16:49:32.593Z" },
- { url = "https://files.pythonhosted.org/packages/86/c8/c6669e42d2f4efd60d38a3252cebbb28851f968890efb2b9b15f9d1092b0/fonttools-4.62.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:840632ea9c1eab7b7f01c369e408c0721c287dfd7500ab937398430689852fd1", size = 4912506, upload-time = "2026-03-09T16:49:34.927Z" },
- { url = "https://files.pythonhosted.org/packages/2e/49/0ae552aa098edd0ec548413fbf818f52ceb70535016215094a5ce9bf8f70/fonttools-4.62.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:28a9ea2a7467a816d1bec22658b0cce4443ac60abac3e293bdee78beb74588f3", size = 4951202, upload-time = "2026-03-09T16:49:37.1Z" },
- { url = "https://files.pythonhosted.org/packages/71/65/ae38fc8a4cea6f162d74cf11f58e9aeef1baa7d0e3d1376dabd336c129e5/fonttools-4.62.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ae611294f768d413949fd12693a8cba0e6332fbc1e07aba60121be35eac68d0", size = 5060758, upload-time = "2026-03-09T16:49:39.464Z" },
- { url = "https://files.pythonhosted.org/packages/db/3d/bb797496f35c60544cd5af71ffa5aad62df14ef7286908d204cb5c5096fe/fonttools-4.62.0-cp314-cp314-win32.whl", hash = "sha256:273acb61f316d07570a80ed5ff0a14a23700eedbec0ad968b949abaa4d3f6bb5", size = 2283496, upload-time = "2026-03-09T16:49:42.448Z" },
- { url = "https://files.pythonhosted.org/packages/2e/9f/91081ffe5881253177c175749cce5841f5ec6e931f5d52f4a817207b7429/fonttools-4.62.0-cp314-cp314-win_amd64.whl", hash = "sha256:a5f974006d14f735c6c878fc4b117ad031dc93638ddcc450ca69f8fd64d5e104", size = 2335426, upload-time = "2026-03-09T16:49:44.228Z" },
- { url = "https://files.pythonhosted.org/packages/f8/65/f47f9b3db1ec156a1f222f1089ba076b2cc9ee1d024a8b0a60c54258517e/fonttools-4.62.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0361a7d41d86937f1f752717c19f719d0fde064d3011038f9f19bdf5fc2f5c95", size = 2947079, upload-time = "2026-03-09T16:49:46.471Z" },
- { url = "https://files.pythonhosted.org/packages/52/73/bc62e5058a0c22cf02b1e0169ef0c3ca6c3247216d719f95bead3c05a991/fonttools-4.62.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4108c12773b3c97aa592311557c405d5b4fc03db2b969ed928fcf68e7b3c887", size = 2448802, upload-time = "2026-03-09T16:49:48.328Z" },
- { url = "https://files.pythonhosted.org/packages/2b/df/bfaa0e845884935355670e6e68f137185ab87295f8bc838db575e4a66064/fonttools-4.62.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b448075f32708e8fb377fe7687f769a5f51a027172c591ba9a58693631b077a8", size = 5137378, upload-time = "2026-03-09T16:49:50.223Z" },
- { url = "https://files.pythonhosted.org/packages/32/32/04f616979a18b48b52e634988b93d847b6346260faf85ecccaf7e2e9057f/fonttools-4.62.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5f1fa8cc9f1a56a3e33ee6b954d6d9235e6b9d11eb7a6c9dfe2c2f829dc24db", size = 4920714, upload-time = "2026-03-09T16:49:53.172Z" },
- { url = "https://files.pythonhosted.org/packages/3b/2e/274e16689c1dfee5c68302cd7c444213cfddd23cf4620374419625037ec6/fonttools-4.62.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f8c8ea812f82db1e884b9cdb663080453e28f0f9a1f5027a5adb59c4cc8d38d1", size = 5016012, upload-time = "2026-03-09T16:49:55.762Z" },
- { url = "https://files.pythonhosted.org/packages/7f/0c/b08117270626e7117ac2f89d732fdd4386ec37d2ab3a944462d29e6f89a1/fonttools-4.62.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:03c6068adfdc67c565d217e92386b1cdd951abd4240d65180cec62fa74ba31b2", size = 5042766, upload-time = "2026-03-09T16:49:57.726Z" },
- { url = "https://files.pythonhosted.org/packages/11/83/a48b73e54efa272ee65315a6331b30a9b3a98733310bc11402606809c50e/fonttools-4.62.0-cp314-cp314t-win32.whl", hash = "sha256:d28d5baacb0017d384df14722a63abe6e0230d8ce642b1615a27d78ffe3bc983", size = 2347785, upload-time = "2026-03-09T16:49:59.698Z" },
- { url = "https://files.pythonhosted.org/packages/f8/27/c67eab6dc3525bdc39586511b1b3d7161e972dacc0f17476dbaf932e708b/fonttools-4.62.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3f9e20c4618f1e04190c802acae6dc337cb6db9fa61e492fd97cd5c5a9ff6d07", size = 2413914, upload-time = "2026-03-09T16:50:02.251Z" },
- { url = "https://files.pythonhosted.org/packages/9c/57/c2487c281dde03abb2dec244fd67059b8d118bd30a653cbf69e94084cb23/fonttools-4.62.0-py3-none-any.whl", hash = "sha256:75064f19a10c50c74b336aa5ebe7b1f89fd0fb5255807bfd4b0c6317098f4af3", size = 1152427, upload-time = "2026-03-09T16:50:04.074Z" },
+ { url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213, upload-time = "2025-12-12T17:29:46.675Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689, upload-time = "2025-12-12T17:29:48.769Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809, upload-time = "2025-12-12T17:29:51.701Z" },
+ { url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039, upload-time = "2025-12-12T17:29:53.659Z" },
+ { url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714, upload-time = "2025-12-12T17:29:55.592Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648, upload-time = "2025-12-12T17:29:57.861Z" },
+ { url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681, upload-time = "2025-12-12T17:29:59.943Z" },
+ { url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951, upload-time = "2025-12-12T17:30:02.254Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" },
+ { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" },
+ { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" },
+ { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454, upload-time = "2025-12-12T17:30:24.938Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191, upload-time = "2025-12-12T17:30:27.343Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410, upload-time = "2025-12-12T17:30:29.771Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" },
+ { url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" },
+ { url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094, upload-time = "2025-12-12T17:30:43.511Z" },
+ { url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589, upload-time = "2025-12-12T17:30:45.681Z" },
+ { url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892, upload-time = "2025-12-12T17:30:47.709Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884, upload-time = "2025-12-12T17:30:49.656Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405, upload-time = "2025-12-12T17:30:51.735Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553, upload-time = "2025-12-12T17:30:54.823Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915, upload-time = "2025-12-12T17:30:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487, upload-time = "2025-12-12T17:30:59.804Z" },
+ { url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571, upload-time = "2025-12-12T17:31:02.574Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317, upload-time = "2025-12-12T17:31:04.974Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124, upload-time = "2025-12-12T17:31:07.456Z" },
+ { url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391, upload-time = "2025-12-12T17:31:09.732Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800, upload-time = "2025-12-12T17:31:11.681Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426, upload-time = "2025-12-12T17:31:13.764Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377, upload-time = "2025-12-12T17:31:16.49Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613, upload-time = "2025-12-12T17:31:18.769Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" },
]
[[package]]
@@ -2655,108 +2655,92 @@ wheels = [
[[package]]
name = "kiwisolver"
-version = "1.5.0"
+version = "1.4.9"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" },
- { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" },
- { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" },
- { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" },
- { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" },
- { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" },
- { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" },
- { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" },
- { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" },
- { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" },
- { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" },
- { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" },
- { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" },
- { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" },
- { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" },
- { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" },
- { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" },
- { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" },
- { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" },
- { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" },
- { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" },
- { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" },
- { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" },
- { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" },
- { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" },
- { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" },
- { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" },
- { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" },
- { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" },
- { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" },
- { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" },
- { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" },
- { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" },
- { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" },
- { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" },
- { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" },
- { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" },
- { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" },
- { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" },
- { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" },
- { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" },
- { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" },
- { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" },
- { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" },
- { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" },
- { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" },
- { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" },
- { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" },
- { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" },
- { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" },
- { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" },
- { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" },
- { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" },
- { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" },
- { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" },
- { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" },
- { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" },
- { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" },
- { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" },
- { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" },
- { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" },
- { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" },
- { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" },
- { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" },
- { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" },
- { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" },
- { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" },
- { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" },
- { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" },
- { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" },
- { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" },
- { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" },
- { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" },
- { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" },
- { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" },
- { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" },
- { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" },
- { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" },
- { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" },
- { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" },
- { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" },
- { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" },
- { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" },
- { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" },
- { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" },
- { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" },
- { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" },
- { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" },
- { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" },
- { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" },
- { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" },
- { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" },
- { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" },
- { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" },
- { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" },
- { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" },
- { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" },
- { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167, upload-time = "2025-08-10T21:25:53.403Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579, upload-time = "2025-08-10T21:25:54.79Z" },
+ { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309, upload-time = "2025-08-10T21:25:55.76Z" },
+ { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596, upload-time = "2025-08-10T21:25:56.861Z" },
+ { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548, upload-time = "2025-08-10T21:25:58.246Z" },
+ { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618, upload-time = "2025-08-10T21:25:59.857Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437, upload-time = "2025-08-10T21:26:01.105Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742, upload-time = "2025-08-10T21:26:02.675Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810, upload-time = "2025-08-10T21:26:04.009Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579, upload-time = "2025-08-10T21:26:05.317Z" },
+ { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071, upload-time = "2025-08-10T21:26:06.686Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840, upload-time = "2025-08-10T21:26:07.94Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159, upload-time = "2025-08-10T21:26:09.048Z" },
+ { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" },
+ { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" },
+ { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" },
+ { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" },
+ { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" },
+ { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" },
+ { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" },
+ { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" },
+ { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" },
+ { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" },
+ { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" },
+ { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" },
+ { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" },
+ { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" },
+ { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" },
+ { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" },
+ { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" },
+ { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" },
+ { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" },
+ { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" },
+ { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" },
+ { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" },
+ { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" },
+ { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" },
+ { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" },
+ { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" },
+ { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" },
+ { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" },
+ { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104, upload-time = "2025-08-10T21:27:43.287Z" },
+ { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592, upload-time = "2025-08-10T21:27:44.314Z" },
+ { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281, upload-time = "2025-08-10T21:27:45.369Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009, upload-time = "2025-08-10T21:27:46.376Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" },
]
[[package]]
@@ -4096,7 +4080,7 @@ wheels = [
[[package]]
name = "posthog"
-version = "7.9.8"
+version = "7.9.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -4106,9 +4090,9 @@ dependencies = [
{ name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/63/f5/490fbe0cd357bf5efaa026200d2a29aaa5e39cd8272cfe0e2d449f46f2db/posthog-7.9.8.tar.gz", hash = "sha256:52b1fa5f3d3faf2ee2fb7f5eb375332905887f7c1e386ef45103448413bd3e57", size = 176688, upload-time = "2026-03-09T14:34:07.822Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/16/08/e5064ae25749367f38f6d204ce876a045ecf4fd01ed0e66477364925416c/posthog-7.9.7.tar.gz", hash = "sha256:35dcaf4acc37b386b5ebcd6037cc80821e88d359627c0f61537c667c52359483", size = 175634, upload-time = "2026-03-05T22:09:51.979Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0f/aa/8b3de1650e0c39223c7f9b7c0f4961f7d39bfa690fa800a9521565381ecb/posthog-7.9.8-py3-none-any.whl", hash = "sha256:2735bcc3232e22c88034454e820c1739f4b29e606d55f31e56b52202650e4330", size = 202361, upload-time = "2026-03-09T14:34:06.031Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/8a/3e4dd145d7d5aaad856d522c61475c51ee80b512b6446bfb3966b2dedf66/posthog-7.9.7-py3-none-any.whl", hash = "sha256:204e47c27dcc230d0bc9b323709c36f98f86e79fa8190caea3b1fbc3c999b1a0", size = 201316, upload-time = "2026-03-05T22:09:50.18Z" },
]
[[package]]
@@ -4126,26 +4110,26 @@ wheels = [
[[package]]
name = "prek"
-version = "0.3.5"
+version = "0.3.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/46/d6/277e002e56eeab3a9d48f1ca4cc067d249d6326fc1783b770d70ad5ae2be/prek-0.3.5.tar.gz", hash = "sha256:ca40b6685a4192256bc807f32237af94bf9b8799c0d708b98735738250685642", size = 374806, upload-time = "2026-03-09T10:35:18.842Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c6/51/2324eaad93a4b144853ca1c56da76f357d3a70c7b4fd6659e972d7bb8660/prek-0.3.4.tar.gz", hash = "sha256:56a74d02d8b7dfe3c774ecfcd8c1b4e5f1e1b84369043a8003e8e3a779fce72d", size = 356633, upload-time = "2026-02-28T03:47:13.452Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8f/a9/16dd8d3a50362ebccffe58518af1f1f571c96f0695d7fcd8bbd386585f58/prek-0.3.5-py3-none-linux_armv6l.whl", hash = "sha256:44b3e12791805804f286d103682b42a84e0f98a2687faa37045e9d3375d3d73d", size = 5105604, upload-time = "2026-03-09T10:35:00.332Z" },
- { url = "https://files.pythonhosted.org/packages/e4/74/bc6036f5bf03860cda66ab040b32737e54802b71a81ec381839deb25df9e/prek-0.3.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3cb451cc51ac068974557491beb4c7d2d41dfde29ed559c1694c8ce23bf53e8", size = 5506155, upload-time = "2026-03-09T10:35:17.64Z" },
- { url = "https://files.pythonhosted.org/packages/02/d9/a3745c2a10509c63b6a118ada766614dd705efefd08f275804d5c807aa4a/prek-0.3.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ad8f5f0d8da53dc94d00b76979af312b3dacccc9dcbc6417756c5dca3633c052", size = 5100383, upload-time = "2026-03-09T10:35:13.302Z" },
- { url = "https://files.pythonhosted.org/packages/43/8e/de965fc515d39309a332789cd3778161f7bc80cde15070bedf17f9f8cb93/prek-0.3.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:4511e15d34072851ac88e4b2006868fbe13655059ad941d7a0ff9ee17138fd9f", size = 5334913, upload-time = "2026-03-09T10:35:14.813Z" },
- { url = "https://files.pythonhosted.org/packages/3f/8c/44f07e8940256059cfd82520e3cbe0764ab06ddb4aa43148465db00b39ad/prek-0.3.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc0b63b8337e2046f51267facaac63ba755bc14aad53991840a5eccba3e5c28", size = 5033825, upload-time = "2026-03-09T10:35:06.976Z" },
- { url = "https://files.pythonhosted.org/packages/94/85/3ff0f96881ff2360c212d310ff23c3cf5a15b223d34fcfa8cdcef203be69/prek-0.3.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5fc0d78c3896a674aeb8247a83bbda7efec85274dbdfbc978ceff8d37e4ed20", size = 5438586, upload-time = "2026-03-09T10:34:58.779Z" },
- { url = "https://files.pythonhosted.org/packages/79/a5/c6d08d31293400fcb5d427f8e7e6bacfc959988e868ad3a9d97b4d87c4b7/prek-0.3.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64cad21cb9072d985179495b77b312f6b81e7b45357d0c68dc1de66e0408eabc", size = 6359714, upload-time = "2026-03-09T10:34:57.454Z" },
- { url = "https://files.pythonhosted.org/packages/ba/18/321dcff9ece8065d42c8c1c7a53a23b45d2b4330aa70993be75dc5f2822f/prek-0.3.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45ee84199bb48e013bdfde0c84352c17a44cc42d5792681b86d94e9474aab6f8", size = 5717632, upload-time = "2026-03-09T10:35:08.634Z" },
- { url = "https://files.pythonhosted.org/packages/a3/7f/1288226aa381d0cea403157f4e6b64b356e1a745f2441c31dd9d8a1d63da/prek-0.3.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f43275e5d564e18e52133129ebeb5cb071af7ce4a547766c7f025aa0955dfbb6", size = 5339040, upload-time = "2026-03-09T10:35:03.665Z" },
- { url = "https://files.pythonhosted.org/packages/22/94/cfec83df9c2b8e7ed1608087bcf9538a6a77b4c2e7365123e9e0a3162cd1/prek-0.3.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:abcee520d31522bcbad9311f21326b447694cd5edba33618c25fd023fc9865ec", size = 5162586, upload-time = "2026-03-09T10:35:11.564Z" },
- { url = "https://files.pythonhosted.org/packages/13/b7/741d62132f37a5f7cc0fad1168bd31f20dea9628f482f077f569547e0436/prek-0.3.5-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:499c56a94a155790c75a973d351a33f8065579d9094c93f6d451ada5d1e469be", size = 5002933, upload-time = "2026-03-09T10:35:16.347Z" },
- { url = "https://files.pythonhosted.org/packages/6f/83/630a5671df6550fcfa67c54955e8a8174eb9b4d97ac38fb05a362029245b/prek-0.3.5-py3-none-musllinux_1_1_i686.whl", hash = "sha256:de1065b59f194624adc9dea269d4ff6b50e98a1b5bb662374a9adaa496b3c1eb", size = 5304934, upload-time = "2026-03-09T10:35:09.975Z" },
- { url = "https://files.pythonhosted.org/packages/de/79/67a7afd0c0b6c436630b7dba6e586a42d21d5d6e5778fbd9eba7bbd3dd26/prek-0.3.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a1c4869e45ee341735d07179da3a79fa2afb5959cef8b3c8a71906eb52dc6933", size = 5829914, upload-time = "2026-03-09T10:35:05.39Z" },
- { url = "https://files.pythonhosted.org/packages/37/47/e2fe13b33e7b5fdd9dd1a312f5440208bfe1be6183e54c5c99c10f27d848/prek-0.3.5-py3-none-win32.whl", hash = "sha256:70b2152ecedc58f3f4f69adc884617b0cf44259f7414c44d6268ea6f107672eb", size = 4836910, upload-time = "2026-03-09T10:35:01.884Z" },
- { url = "https://files.pythonhosted.org/packages/6b/ab/dc2a139fd4896d11f39631479ed385e86307af7f54059ebe9414bb0d00c6/prek-0.3.5-py3-none-win_amd64.whl", hash = "sha256:01d031b684f7e1546225393af1268d9b4451a44ef6cb9be4101c85c7862e08db", size = 5234234, upload-time = "2026-03-09T10:35:20.193Z" },
- { url = "https://files.pythonhosted.org/packages/ed/38/f7256b4b7581444f658e909c3b566f51bfabe56c03e80d107a6932d62040/prek-0.3.5-py3-none-win_arm64.whl", hash = "sha256:aa774168e3d868039ff79422bdef2df8d5a016ed804a9914607dcdd3d41da053", size = 5083330, upload-time = "2026-03-09T10:34:55.469Z" },
+ { url = "https://files.pythonhosted.org/packages/09/20/1a964cb72582307c2f1dc7f583caab90f42810ad41551e5220592406a4c3/prek-0.3.4-py3-none-linux_armv6l.whl", hash = "sha256:c35192d6e23fe7406bd2f333d1c7dab1a4b34ab9289789f453170f33550aa74d", size = 4641915, upload-time = "2026-02-28T03:47:03.772Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/cb/4a21f37102bac37e415b61818344aa85de8d29a581253afa7db8c08d5a33/prek-0.3.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f784d78de72a8bbe58a5fe7bde787c364ae88f0aff5222c5c5c7287876c510a", size = 4649166, upload-time = "2026-02-28T03:47:06.164Z" },
+ { url = "https://files.pythonhosted.org/packages/85/9c/a7c0d117a098d57931428bdb60fcb796e0ebc0478c59288017a2e22eca96/prek-0.3.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:50a43f522625e8c968e8c9992accf9e29017abad6c782d6d176b73145ad680b7", size = 4274422, upload-time = "2026-02-28T03:46:59.356Z" },
+ { url = "https://files.pythonhosted.org/packages/59/84/81d06df1724d09266df97599a02543d82fde7dfaefd192f09d9b2ccb092f/prek-0.3.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:4bbb1d3912a88935f35c6ba4466b4242732e3e3a8c608623c708e83cea85de00", size = 4629873, upload-time = "2026-02-28T03:46:56.419Z" },
+ { url = "https://files.pythonhosted.org/packages/09/cd/bb0aefa25cfacd8dbced75b9a9d9945707707867fa5635fb69ae1bbc2d88/prek-0.3.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca4d4134db8f6e8de3c418317becdf428957e3cab271807f475318105fd46d04", size = 4552507, upload-time = "2026-02-28T03:47:05.004Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/c0/578a7af4861afb64ec81c03bfdcc1bb3341bb61f2fff8a094ecf13987a56/prek-0.3.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fb6395f6eb76133bb1e11fc718db8144522466cdc2e541d05e7813d1bbcae7d", size = 4865929, upload-time = "2026-02-28T03:47:09.231Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/48/f169406590028f7698ef2e1ff5bffd92ca05e017636c1163a2f5ef0f8275/prek-0.3.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aae17813239ddcb4ae7b38418de4d49afff740f48f8e0556029c96f58e350412", size = 5390286, upload-time = "2026-02-28T03:47:10.796Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c5/98a73fec052059c3ae06ce105bef67caca42334c56d84e9ef75df72ba152/prek-0.3.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10a621a690d9c127afc3d21c275030d364d1fbef3296c095068d3ae80a59546e", size = 4891028, upload-time = "2026-02-28T03:47:07.916Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/b4/029966e35e59b59c142be7e1d2208ad261709ac1a66aa4a3ce33c5b9f91f/prek-0.3.4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d978c31bc3b1f0b3d58895b7c6ac26f077e0ea846da54f46aeee4c7088b1b105", size = 4633986, upload-time = "2026-02-28T03:47:14.351Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/27/d122802555745b6940c99fcb41496001c192ddcdf56ec947ec10a0298e05/prek-0.3.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8e089a030f0a023c22a4bb2ec4ff3fcc153585d701cff67acbfca2f37e173ae", size = 4680722, upload-time = "2026-02-28T03:47:12.224Z" },
+ { url = "https://files.pythonhosted.org/packages/34/40/92318c96b3a67b4e62ed82741016ede34d97ea9579d3cc1332b167632222/prek-0.3.4-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:8060c72b764f0b88112616763da9dd3a7c293e010f8520b74079893096160a2f", size = 4535623, upload-time = "2026-02-28T03:46:52.221Z" },
+ { url = "https://files.pythonhosted.org/packages/df/f5/6b383d94e722637da4926b4f609d36fe432827bb6f035ad46ee02bde66b6/prek-0.3.4-py3-none-musllinux_1_1_i686.whl", hash = "sha256:65b23268456b5a763278d4e1ec532f2df33918f13ded85869a1ddff761eb9697", size = 4729879, upload-time = "2026-02-28T03:46:57.886Z" },
+ { url = "https://files.pythonhosted.org/packages/79/f8/fdc705b807d813fd713ffa4f67f96741542ed1dafbb221206078c06f3df4/prek-0.3.4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3975c61139c7b3200e38dc3955e050b0f2615701d3deb9715696a902e850509e", size = 5001569, upload-time = "2026-02-28T03:47:00.892Z" },
+ { url = "https://files.pythonhosted.org/packages/84/92/b007a41f58e8192a1e611a21b396ad870d51d7873b7af12068ebae7fc15f/prek-0.3.4-py3-none-win32.whl", hash = "sha256:37449ae82f4dc08b72e542401e3d7318f05d1163e87c31ab260a40f425d6516e", size = 4297057, upload-time = "2026-02-28T03:47:02.219Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dc/bcb02de9b11461e8e0c7d3c8fdf8cfa15ac6efe73472a4375549ba5defd2/prek-0.3.4-py3-none-win_amd64.whl", hash = "sha256:60e9aa86ca65de963510ae28c5d94b9d7a97bcbaa6e4cdb5bf5083ed4c45dc71", size = 4655174, upload-time = "2026-02-28T03:46:53.749Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/86/98f5598569f4cd3de7161e266fab6a8981e65555f79d4704810c1502ad0a/prek-0.3.4-py3-none-win_arm64.whl", hash = "sha256:486bdae8f4512d3b4f6eb61b83e5b7595da2adca385af4b2b7823c0ab38d1827", size = 4367817, upload-time = "2026-02-28T03:46:55.264Z" },
]
[[package]]
@@ -5399,11 +5383,11 @@ wheels = [
[[package]]
name = "setuptools"
-version = "82.0.1"
+version = "82.0.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" },
]
[[package]]