Merge branch 'main' into feat/durable_task

This commit is contained in:
Shyju Krishnankutty
2026-03-11 12:45:13 -07:00
committed by GitHub
78 changed files with 7488 additions and 801 deletions
@@ -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`).
+4 -4
View File
@@ -2,11 +2,11 @@
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<RCNumber>3</RCNumber>
<RCNumber>4</RCNumber>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260304.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260304.1</PackageVersion>
<GitTag>1.0.0-rc3</GitTag>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260311.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260311.1</PackageVersion>
<GitTag>1.0.0-rc4</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
+25 -1
View File
@@ -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
+2 -2
View File
@@ -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",
]
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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",
]
@@ -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",
]
@@ -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."""
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
]
@@ -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",
+2 -2
View File
@@ -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",
]
+2 -2
View File
@@ -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",
]
+2 -2
View File
@@ -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",
]
+2 -2
View File
@@ -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",
]
+9
View File
@@ -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
@@ -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,
@@ -59,7 +87,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,
@@ -190,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",
@@ -212,6 +258,7 @@ __all__ = [
"BaseEmbeddingClient",
"BaseHistoryProvider",
"Case",
"CharacterEstimatorTokenizer",
"ChatAndFunctionMiddlewareTypes",
"ChatContext",
"ChatMiddleware",
@@ -221,6 +268,8 @@ __all__ = [
"ChatResponse",
"ChatResponseUpdate",
"CheckpointStorage",
"CompactionProvider",
"CompactionStrategy",
"Content",
"ContinuationToken",
"Default",
@@ -267,13 +316,18 @@ __all__ = [
"Runner",
"RunnerContext",
"SecretString",
"SelectiveToolCallCompactionStrategy",
"SessionContext",
"SingleEdgeGroup",
"Skill",
"SkillResource",
"SkillScript",
"SkillScriptRunner",
"SkillsProvider",
"SlidingWindowStrategy",
"SubWorkflowRequestMessage",
"SubWorkflowResponseMessage",
"SummarizationStrategy",
"SupportsAgentRun",
"SupportsChatGetResponse",
"SupportsCodeInterpreterTool",
@@ -286,8 +340,12 @@ __all__ = [
"SwitchCaseEdgeGroupCase",
"SwitchCaseEdgeGroupDefault",
"TextSpanRegion",
"TokenBudgetComposedStrategy",
"TokenizerProtocol",
"ToolMode",
"ToolResultCompactionStrategy",
"ToolTypes",
"TruncationStrategy",
"TypeCompatibilityError",
"UpdateT",
"UsageDetails",
@@ -314,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",
@@ -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,
)
@@ -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,
)
File diff suppressed because it is too large Load Diff
@@ -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,
)
@@ -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,
+529 -33
View File
@@ -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,108 @@ 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 +228,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 +276,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 +286,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 +299,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 +335,7 @@ class Skill:
.. code-block:: python
@skill.resource
def get_schema() -> str:
def get_schema() -> Any:
return "schema..."
With arguments:
@@ -235,7 +343,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 +363,116 @@ 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 +487,7 @@ DEFAULT_RESOURCE_EXTENSIONS: Final[tuple[str, ...]] = (
".xml",
".txt",
)
# endregion
DEFAULT_SCRIPT_EXTENSIONS: Final[tuple[str, ...]] = (".py",)
# region Patterns and prompt template
@@ -307,13 +520,19 @@ Each skill provides specialized instructions, reference documents, and assets fo
</available_skills>
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 +600,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 +617,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 +695,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 +709,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 +763,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 +843,79 @@ class SkillsProvider(BaseContextProvider):
resource_lines = "\n".join(_create_resource_element(r) for r in skill.resources)
content += f"\n\n<resources>\n{resource_lines}\n</resources>"
if skill.scripts:
script_lines = "\n".join(_create_script_element(s) for s in skill.scripts)
content += f"\n\n<scripts>\n{script_lines}\n</scripts>"
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 +930,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 +962,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 +1101,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 +1350,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 +1361,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 +1405,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 +1420,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 +1432,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 +1473,50 @@ def _create_resource_element(resource: SkillResource) -> str:
return f" <resource {attrs}/>"
def _create_script_element(script: SkillScript) -> str:
"""Create an XML ``<script …>`` element from a :class:`SkillScript`.
When the script has a ``parameters_schema``, the element includes a
``<parameters_schema>`` child element containing the JSON schema.
Otherwise the element is self-closing.
Args:
script: The script to create the element from.
Returns:
An indented XML element string with ``name``, optional
``description`` attributes, and an optional
``<parameters_schema>`` child element.
"""
attrs = f'name="{xml_escape(script.name, quote=True)}"'
if script.description:
attrs += f' description="{xml_escape(script.description, quote=True)}"'
if script.parameters_schema:
params_json = xml_escape(json.dumps(script.parameters_schema), quote=False)
return f" <script {attrs}>\n <parameters_schema>{params_json}</parameters_schema>\n </script>"
return f" <script {attrs}/>"
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 ``<skill>`` 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 +1525,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 +1543,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 +1561,10 @@ def _create_instructions(
lines.append(f" <description>{xml_escape(skill.description)}</description>")
lines.append(" </skill>")
return template.format(skills="\n".join(lines))
return template.format(
skills="\n".join(lines),
runner_instructions=runner_instructions or "",
)
# endregion
@@ -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 {}
+37 -8
View File
@@ -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
@@ -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:
@@ -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 = (
+1 -1
View File
@@ -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"
@@ -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
@@ -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."
@@ -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
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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."""
@@ -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."""
+2 -2
View File
@@ -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",
]
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
]
@@ -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",
]
+2 -2
View File
@@ -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]
+2 -2
View File
@@ -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",
]
+2 -2
View File
@@ -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",
]
@@ -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]
+2 -2
View File
@@ -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",
]
+2 -2
View File
@@ -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"
+3 -3
View File
@@ -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]
@@ -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"
@@ -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
```
@@ -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 "<non-text>"
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)
"""
@@ -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)
"""
@@ -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.
"""
@@ -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())
@@ -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
"""
@@ -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)
"""
+55
View File
@@ -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`)
@@ -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/)
@@ -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...
"""
@@ -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.
@@ -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 |
@@ -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.
@@ -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/)
@@ -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.
"""
@@ -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/)
@@ -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())
@@ -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/)
@@ -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.
"""
@@ -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 <number> --factor <factor>` (e.g. `--value 26.2 --factor 1.60934`)
3. Present the converted value clearly with both units
@@ -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 |
@@ -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()
@@ -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/)
@@ -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 (mileskm,
poundskg) 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.
"""
@@ -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 <number> --factor <factor>` (e.g. `--value 26.2 --factor 1.60934`)
3. Present the converted value clearly with both units
@@ -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 |
@@ -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()
@@ -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/)
@@ -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.
"""
@@ -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}"
+187 -198
View File
@@ -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 = [
@@ -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]]
@@ -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" },
@@ -2650,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]]
@@ -4091,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'" },
@@ -4101,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]]
@@ -4111,8 +4100,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 = [
@@ -4121,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]]
@@ -4729,7 +4718,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 = [
@@ -5394,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]]