Merge branch 'main' into feature/python-foundry-hosted-agent-vnext

This commit is contained in:
Tao Chen
2026-04-13 16:47:46 -07:00
Unverified
122 changed files with 6187 additions and 1529 deletions
@@ -1,5 +1,3 @@
# Copyright (c) Microsoft. All rights reserved.
---
name: python-feature-lifecycle
description: >
+2 -4
View File
@@ -1,7 +1,8 @@
fail_fast: true
exclude: ^scripts/
repos:
- repo: builtin
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-toml
name: Check TOML files
@@ -34,9 +35,6 @@ repos:
- id: no-commit-to-branch
name: Protect main branch
args: [--branch, main]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-ast
name: Check Valid Python Samples
types: ["python"]
+52 -1
View File
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.1] - 2026-04-09
### Added
- **samples**: Add sample documentation for two separate Neo4j context providers for retrieval and memory ([#4010](https://github.com/microsoft/agent-framework/pull/4010))
- **agent-framework-azure-cosmos**: Add Cosmos DB NoSQL checkpoint storage for Python workflows ([#4916](https://github.com/microsoft/agent-framework/pull/4916))
### Changed
- **docs**: Remove pre-release flag from agent-framework installation instructions ([#5082](https://github.com/microsoft/agent-framework/pull/5082))
- **samples**: Revise agent examples in `README.md` ([#5067](https://github.com/microsoft/agent-framework/pull/5067))
- **repo**: Update `CHANGELOG` with v1.0.0 release ([#5069](https://github.com/microsoft/agent-framework/pull/5069))
- **agent-framework-orchestrations**: [BREAKING] Fix handoff workflow context management and improve AG-UI demo ([#5136](https://github.com/microsoft/agent-framework/pull/5136))
- **agent-framework-core**: Restrict persisted checkpoint deserialization by default ([#4941](https://github.com/microsoft/agent-framework/pull/4941))
- **samples**: Bump `vite` from 7.3.1 to 7.3.2 in `/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend` ([#5132](https://github.com/microsoft/agent-framework/pull/5132))
- **python**: Bump `cryptography` from 46.0.6 to 46.0.7 ([#5176](https://github.com/microsoft/agent-framework/pull/5176))
- **python**: Bump `mcp` from 1.26.0 to 1.27.0 ([#5117](https://github.com/microsoft/agent-framework/pull/5117))
- **python**: Bump `mcp[ws]` from 1.26.0 to 1.27.0 ([#5119](https://github.com/microsoft/agent-framework/pull/5119))
### Fixed
- **agent-framework-core**: Raise clear handler registration error for unresolved `TypeVar` annotations ([#4944](https://github.com/microsoft/agent-framework/pull/4944))
- **agent-framework-openai**: Fix `response_format` crash on background polling with empty text ([#5146](https://github.com/microsoft/agent-framework/pull/5146))
- **agent-framework-foundry**: Strip tools from `FoundryAgent` request when `agent_reference` is present ([#5101](https://github.com/microsoft/agent-framework/pull/5101))
- **agent-framework-core**: Fix test compatibility for entity key validation ([#5179](https://github.com/microsoft/agent-framework/pull/5179))
- **agent-framework-openai**: Stop emitting duplicate reasoning content from `response.reasoning_text.done` and `response.reasoning_summary_text.done` events ([#5162](https://github.com/microsoft/agent-framework/pull/5162))
## [1.0.0] - 2026-04-02
### Added
- **repo**: Add `PACKAGE_STATUS.md` to track lifecycle status of all Python packages ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
### Changed
- **agent-framework**, **agent-framework-core**, **agent-framework-openai**, **agent-framework-foundry**: [BREAKING] Promote from `1.0.0rc6` to `1.0.0` (Production/Stable) ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
- **agent-framework-core**, **agent-framework-openai**, **agent-framework-foundry**: [BREAKING] Dependency floors now require released `>=1.0.0,<2` packages, breaking compatibility with older RC installs ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
- **agent-framework-a2a**, **agent-framework-ag-ui**, **agent-framework-anthropic**, **agent-framework-azure-ai-search**, **agent-framework-azure-cosmos**, **agent-framework-azurefunctions**, **agent-framework-bedrock**, **agent-framework-chatkit**, **agent-framework-claude**, **agent-framework-copilotstudio**, **agent-framework-declarative**, **agent-framework-devui**, **agent-framework-durabletask**, **agent-framework-foundry-local**, **agent-framework-github-copilot**, **agent-framework-lab**, **agent-framework-mem0**, **agent-framework-ollama**, **agent-framework-orchestrations**, **agent-framework-purview**, **agent-framework-redis**: Bump beta versions from `1.0.0b260330` to `1.0.0b260402` ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
- **docs**: Update install instructions to drop `--pre` flag for released packages ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
### Removed
- **agent-framework-core**: [BREAKING] Remove deprecated `BaseContextProvider` and `BaseHistoryProvider` aliases ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
- **agent-framework-core**: [BREAKING] Remove deprecated `text` parameter from `Message` constructor ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
### Fixed
- **agent-framework-core**, **agent-framework-openai**, **agent-framework-foundry**, **agent-framework-azurefunctions**, **agent-framework-devui**, **agent-framework-orchestrations**, **agent-framework-azure-ai-search**: Migrate message construction from `Message(text=...)` to `Message(contents=[...])` throughout codebase ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
- **agent-framework-devui**: Accept legacy payload formats (`text`, `message`, `content`, `input`, `data`) and convert to framework-native `Message(contents=...)` ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
- **samples**: Fix Foundry samples to use env vars consistently and update install guidance ([#5062](https://github.com/microsoft/agent-framework/pull/5062))
## [1.0.0rc6] - 2026-03-30
### Added
@@ -846,7 +895,9 @@ 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.0rc6...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.1...HEAD
[1.0.1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0...python-1.0.1
[1.0.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc6...python-1.0.0
[1.0.0rc6]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc5...python-1.0.0rc6
[1.0.0rc5]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc4...python-1.0.0rc5
[1.0.0rc4]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc3...python-1.0.0rc4
+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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"a2a-sdk>=0.3.5,<0.3.24",
]
@@ -46,6 +46,7 @@ from ._orchestration._tooling import collect_server_tools, merge_tools, register
from ._run_common import (
FlowState,
_build_run_finished_event, # type: ignore
_close_reasoning_block, # type: ignore
_emit_content, # type: ignore
_extract_resume_payload, # type: ignore
_has_only_tool_calls, # type: ignore
@@ -1058,6 +1059,10 @@ async def run_agent_stream(
}
)
# Close any open reasoning block
for event in _close_reasoning_block(flow):
yield event
# Close any open message
if flow.message_id:
logger.debug(f"End of run: closing text message message_id={flow.message_id}")
@@ -128,6 +128,7 @@ class FlowState:
interrupts: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
reasoning_messages: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
accumulated_reasoning: dict[str, str] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
reasoning_message_id: str | None = None
def get_tool_name(self, call_id: str | None) -> str | None:
"""Get tool name by call ID."""
@@ -462,12 +463,39 @@ def _emit_mcp_tool_result(
return _emit_tool_result_common(content.call_id, raw_output, flow, predictive_handler)
def _close_reasoning_block(flow: FlowState) -> list[BaseEvent]:
"""Close an open reasoning block, emitting end events.
Should be called when the reasoning block is complete -- e.g. when
non-reasoning content arrives or at end of a run.
"""
if not flow.reasoning_message_id:
return []
message_id = flow.reasoning_message_id
flow.reasoning_message_id = None
return [
ReasoningMessageEndEvent(message_id=message_id),
ReasoningEndEvent(message_id=message_id),
]
def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> list[BaseEvent]:
"""Emit AG-UI reasoning events for text_reasoning content.
Uses the protocol-defined reasoning event types so that AG-UI consumers
such as CopilotKit can render reasoning natively.
When *flow* is provided the function follows the streaming pattern: it
emits ``ReasoningStartEvent`` / ``ReasoningMessageStartEvent`` only on
the first delta for a given ``message_id`` and just
``ReasoningMessageContentEvent`` for subsequent deltas. The matching
``ReasoningMessageEndEvent`` / ``ReasoningEndEvent`` are deferred until
``_close_reasoning_block`` is called (e.g. when non-reasoning content
arrives or at end-of-run).
Without *flow* (backward-compat) the full Start→Content→End sequence is
emitted for every call.
Only ``content.text`` is used for the visible reasoning message. If
``content.protected_data`` is present it is emitted as a
``ReasoningEncryptedValueEvent`` so that consumers can persist encrypted
@@ -483,26 +511,49 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis
message_id = content.id or generate_event_id()
events: list[BaseEvent] = [
ReasoningStartEvent(message_id=message_id),
ReasoningMessageStartEvent(message_id=message_id, role="assistant"),
]
events: list[BaseEvent] = []
if text:
events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text))
if flow is not None:
# Streaming mode: track open reasoning block in flow state.
if flow.reasoning_message_id != message_id:
# Close any previously open reasoning block (different message_id).
events.extend(_close_reasoning_block(flow))
# Open new reasoning block.
events.append(ReasoningStartEvent(message_id=message_id))
events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant"))
flow.reasoning_message_id = message_id
events.append(ReasoningMessageEndEvent(message_id=message_id))
if text:
events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text))
if content.protected_data is not None:
events.append(
ReasoningEncryptedValueEvent(
subtype="message",
entity_id=message_id,
encrypted_value=content.protected_data,
if content.protected_data is not None:
events.append(
ReasoningEncryptedValueEvent(
subtype="message",
entity_id=message_id,
encrypted_value=content.protected_data,
)
)
)
else:
# No flow -- backward-compatible full sequence per call.
events.append(ReasoningStartEvent(message_id=message_id))
events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant"))
events.append(ReasoningEndEvent(message_id=message_id))
if text:
events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text))
events.append(ReasoningMessageEndEvent(message_id=message_id))
if content.protected_data is not None:
events.append(
ReasoningEncryptedValueEvent(
subtype="message",
entity_id=message_id,
encrypted_value=content.protected_data,
)
)
events.append(ReasoningEndEvent(message_id=message_id))
# Persist reasoning into flow state for MESSAGES_SNAPSHOT.
# Accumulate reasoning text per message_id, similar to flow.accumulated_text,
@@ -546,23 +597,30 @@ def _emit_content(
) -> list[BaseEvent]:
"""Emit appropriate events for any content type."""
content_type = getattr(content, "type", None)
# Close open reasoning block when switching to non-reasoning content.
if content_type != "text_reasoning":
events = _close_reasoning_block(flow)
else:
events = []
if content_type == "text":
return _emit_text(content, flow, skip_text)
return events + _emit_text(content, flow, skip_text)
if content_type == "function_call":
return _emit_tool_call(content, flow, predictive_handler)
return events + _emit_tool_call(content, flow, predictive_handler)
if content_type == "function_result":
return _emit_tool_result(content, flow, predictive_handler)
return events + _emit_tool_result(content, flow, predictive_handler)
if content_type == "function_approval_request":
return _emit_approval_request(content, flow, predictive_handler, require_confirmation)
return events + _emit_approval_request(content, flow, predictive_handler, require_confirmation)
if content_type == "usage":
return _emit_usage(content)
return events + _emit_usage(content)
if content_type == "oauth_consent_request":
return _emit_oauth_consent(content)
return events + _emit_oauth_consent(content)
if content_type == "mcp_server_tool_call":
return _emit_mcp_tool_call(content, flow)
return events + _emit_mcp_tool_call(content, flow)
if content_type == "mcp_server_tool_result":
return _emit_mcp_tool_result(content, flow, predictive_handler)
return events + _emit_mcp_tool_result(content, flow, predictive_handler)
if content_type == "text_reasoning":
return _emit_text_reasoning(content, flow)
logger.debug("Skipping unsupported content type in AG-UI emitter: %s", content_type)
return []
return events
@@ -29,6 +29,7 @@ from ._message_adapters import normalize_agui_input_messages
from ._run_common import (
FlowState,
_build_run_finished_event,
_close_reasoning_block,
_emit_content,
_extract_resume_payload,
_normalize_resume_interrupts,
@@ -729,6 +730,9 @@ async def run_workflow_stream(
run_error_emitted = True
terminal_emitted = True
for reasoning_evt in _close_reasoning_block(flow):
yield reasoning_evt
for end_event in _drain_open_message():
yield end_event
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"ag-ui-protocol==0.1.13",
"fastapi>=0.115.0,<0.133.1",
"uvicorn[standard]>=0.30.0,<0.42.0"
+140 -1
View File
@@ -11,6 +11,7 @@ from ag_ui.core import (
ReasoningMessageEndEvent,
ReasoningMessageStartEvent,
ReasoningStartEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
@@ -29,6 +30,7 @@ from agent_framework_ag_ui._agent_run import (
from agent_framework_ag_ui._run_common import (
FlowState,
_build_run_finished_event,
_close_reasoning_block,
_emit_approval_request,
_emit_content,
_emit_mcp_tool_call,
@@ -1344,8 +1346,11 @@ class TestEmitContentMcpRouting:
events = _emit_content(content, flow)
assert len(events) == 5
# Streaming pattern: Start + MessageStart + Content (no End events yet)
assert len(events) == 3
assert isinstance(events[0], ReasoningStartEvent)
assert isinstance(events[1], ReasoningMessageStartEvent)
assert isinstance(events[2], ReasoningMessageContentEvent)
class TestReasoningInSnapshot:
@@ -1501,3 +1506,137 @@ class TestReasoningInSnapshot:
assert len(flow.reasoning_messages) == 1
assert flow.reasoning_messages[0]["content"] == "part1 part2"
assert flow.reasoning_messages[0]["encryptedValue"] == "encrypted-payload"
def test_reasoning_done_after_deltas_does_not_duplicate(self):
"""A done-style content arriving after deltas does not duplicate accumulated text.
The upstream client should skip done events when deltas preceded them,
but if one leaks through, the accumulator must not double-append.
This test verifies that only the delta-produced text is stored.
"""
flow = FlowState()
msg_id = "reason_dedup"
delta1 = Content.from_text_reasoning(id=msg_id, text="Hello ")
delta2 = Content.from_text_reasoning(id=msg_id, text="world")
_emit_text_reasoning(delta1, flow)
_emit_text_reasoning(delta2, flow)
# Accumulated text should equal the concatenation of deltas only
assert len(flow.reasoning_messages) == 1
assert flow.reasoning_messages[0]["content"] == "Hello world"
assert flow.reasoning_messages[0]["id"] == msg_id
def test_reasoning_deltas_emit_one_content_event_each(self):
"""Each reasoning delta emits exactly one ReasoningMessageContentEvent
within a single Start/End sequence (streaming pattern)."""
flow = FlowState()
msg_id = "reason_evt"
delta1 = Content.from_text_reasoning(id=msg_id, text="Think ")
delta2 = Content.from_text_reasoning(id=msg_id, text="hard")
events1 = _emit_text_reasoning(delta1, flow)
events2 = _emit_text_reasoning(delta2, flow)
close_events = _close_reasoning_block(flow)
all_events = events1 + events2 + close_events
content_events = [e for e in all_events if isinstance(e, ReasoningMessageContentEvent)]
assert len(content_events) == 2
assert content_events[0].delta == "Think "
assert content_events[1].delta == "hard"
# Streaming pattern: one Start/End sequence wrapping both content events
start_events = [e for e in all_events if isinstance(e, ReasoningStartEvent)]
end_events = [e for e in all_events if isinstance(e, ReasoningEndEvent)]
msg_start_events = [e for e in all_events if isinstance(e, ReasoningMessageStartEvent)]
msg_end_events = [e for e in all_events if isinstance(e, ReasoningMessageEndEvent)]
assert len(start_events) == 1
assert len(end_events) == 1
assert len(msg_start_events) == 1
assert len(msg_end_events) == 1
def test_reasoning_streaming_event_order(self):
"""Streaming reasoning emits Start once, then Content per delta, then End on close."""
flow = FlowState()
msg_id = "reason_order"
d1 = Content.from_text_reasoning(id=msg_id, text="A ")
d2 = Content.from_text_reasoning(id=msg_id, text="B ")
d3 = Content.from_text_reasoning(id=msg_id, text="C")
events = []
events.extend(_emit_text_reasoning(d1, flow))
events.extend(_emit_text_reasoning(d2, flow))
events.extend(_emit_text_reasoning(d3, flow))
events.extend(_close_reasoning_block(flow))
assert isinstance(events[0], ReasoningStartEvent)
assert isinstance(events[1], ReasoningMessageStartEvent)
assert isinstance(events[2], ReasoningMessageContentEvent)
assert events[2].delta == "A "
assert isinstance(events[3], ReasoningMessageContentEvent)
assert events[3].delta == "B "
assert isinstance(events[4], ReasoningMessageContentEvent)
assert events[4].delta == "C"
assert isinstance(events[5], ReasoningMessageEndEvent)
assert isinstance(events[6], ReasoningEndEvent)
assert len(events) == 7
def test_close_reasoning_block_noop_when_not_open(self):
"""_close_reasoning_block returns empty list when no reasoning block is open."""
flow = FlowState()
assert _close_reasoning_block(flow) == []
def test_close_reasoning_block_resets_state(self):
"""_close_reasoning_block clears reasoning_message_id."""
flow = FlowState()
_emit_text_reasoning(Content.from_text_reasoning(id="r1", text="x"), flow)
assert flow.reasoning_message_id == "r1"
_close_reasoning_block(flow)
assert flow.reasoning_message_id is None
def test_emit_content_closes_reasoning_on_text(self):
"""Switching from reasoning to text content auto-closes reasoning block."""
flow = FlowState()
reasoning = Content.from_text_reasoning(id="r1", text="thinking")
text = Content.from_text("answer")
r_events = _emit_content(reasoning, flow)
t_events = _emit_content(text, flow)
# reasoning events: Start + MsgStart + Content
assert isinstance(r_events[0], ReasoningStartEvent)
# text events should start with reasoning End events
assert isinstance(t_events[0], ReasoningMessageEndEvent)
assert isinstance(t_events[1], ReasoningEndEvent)
# then text start
assert isinstance(t_events[2], TextMessageStartEvent)
assert isinstance(t_events[3], TextMessageContentEvent)
def test_reasoning_distinct_ids_close_previous_block(self):
"""Emitting reasoning with a new message_id auto-closes the previous block."""
flow = FlowState()
c1 = Content.from_text_reasoning(id="block1", text="first")
c2 = Content.from_text_reasoning(id="block2", text="second")
events1 = _emit_text_reasoning(c1, flow)
events2 = _emit_text_reasoning(c2, flow)
close = _close_reasoning_block(flow)
# events1: Start(block1) + MsgStart(block1) + Content(block1)
assert events1[0].message_id == "block1"
# events2: MsgEnd(block1) + End(block1) + Start(block2) + MsgStart(block2) + Content(block2)
assert isinstance(events2[0], ReasoningMessageEndEvent)
assert events2[0].message_id == "block1"
assert isinstance(events2[1], ReasoningEndEvent)
assert events2[1].message_id == "block1"
assert isinstance(events2[2], ReasoningStartEvent)
assert events2[2].message_id == "block2"
# close: MsgEnd(block2) + End(block2)
assert isinstance(close[0], ReasoningMessageEndEvent)
assert close[0].message_id == "block2"
+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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"anthropic>=0.80.0,<0.80.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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"azure-search-documents>=11.7.0b2,<11.7.0b3",
]
+86 -7
View File
@@ -14,7 +14,7 @@ The Azure Cosmos DB integration provides `CosmosHistoryProvider` for persistent
```python
from azure.identity.aio import DefaultAzureCredential
from agent_framework.azure import CosmosHistoryProvider
from agent_framework_azure_cosmos import CosmosHistoryProvider
provider = CosmosHistoryProvider(
endpoint="https://<account>.documents.azure.com:443/",
@@ -35,13 +35,92 @@ Container naming behavior:
- Container name is configured on the provider (`container_name` or `AZURE_COSMOS_CONTAINER_NAME`)
- `session_id` is used as the Cosmos partition key for reads/writes
See the [conversation samples](../../samples/02-agents/conversations/) for runnable examples, including
[`cosmos_history_provider.py`](../../samples/02-agents/conversations/cosmos_history_provider.py).
See `samples/02-agents/conversations/cosmos_history_provider.py` for a runnable example.
## Import Paths
## Cosmos DB Workflow Checkpoint Storage
`CosmosCheckpointStorage` implements the `CheckpointStorage` protocol, enabling
durable workflow checkpointing backed by Azure Cosmos DB NoSQL. Workflows can be
paused and resumed across process restarts by persisting checkpoint state in Cosmos DB.
### Basic Usage
#### Managed Identity / RBAC (recommended for production)
```python
from agent_framework.azure import CosmosHistoryProvider
# or directly:
from agent_framework_azure_cosmos import CosmosHistoryProvider
from azure.identity.aio import DefaultAzureCredential
from agent_framework import WorkflowBuilder
from agent_framework_azure_cosmos import CosmosCheckpointStorage
checkpoint_storage = CosmosCheckpointStorage(
endpoint="https://<account>.documents.azure.com:443/",
credential=DefaultAzureCredential(),
database_name="agent-framework",
container_name="workflow-checkpoints",
)
```
#### Account Key
```python
from agent_framework_azure_cosmos import CosmosCheckpointStorage
checkpoint_storage = CosmosCheckpointStorage(
endpoint="https://<account>.documents.azure.com:443/",
credential="<your-account-key>",
database_name="agent-framework",
container_name="workflow-checkpoints",
)
```
#### Then use with a workflow
```python
from agent_framework import WorkflowBuilder
# Build a workflow with checkpointing enabled
workflow = WorkflowBuilder(
start_executor=start,
checkpoint_storage=checkpoint_storage,
).build()
# Run the workflow — checkpoints are automatically saved after each superstep
result = await workflow.run(message="input data")
# Resume from a checkpoint
latest = await checkpoint_storage.get_latest(workflow_name=workflow.name)
if latest:
resumed = await workflow.run(checkpoint_id=latest.checkpoint_id)
```
### Authentication Options
`CosmosCheckpointStorage` supports the same authentication modes as `CosmosHistoryProvider`:
- **Managed identity / RBAC** (recommended): Pass `DefaultAzureCredential()`,
`ManagedIdentityCredential()`, or any Azure `TokenCredential`
- **Account key**: Pass a key string via `credential` parameter
- **Environment variables**: Set `AZURE_COSMOS_ENDPOINT`, `AZURE_COSMOS_DATABASE_NAME`,
`AZURE_COSMOS_CONTAINER_NAME`, and `AZURE_COSMOS_KEY` (key not required when using
Azure credentials)
- **Pre-created client**: Pass an existing `CosmosClient` or `ContainerProxy`
### Database and Container Setup
The database and container are created automatically on first use (via
`create_database_if_not_exists` and `create_container_if_not_exists`). The container
uses `/workflow_name` as the partition key. You can also pre-create them in the Azure
portal with this partition key configuration.
### Environment Variables
| Variable | Description |
|---|---|
| `AZURE_COSMOS_ENDPOINT` | Cosmos DB account endpoint |
| `AZURE_COSMOS_DATABASE_NAME` | Database name |
| `AZURE_COSMOS_CONTAINER_NAME` | Container name |
| `AZURE_COSMOS_KEY` | Account key (optional if using Azure credentials) |
See `samples/03-workflows/checkpoint/cosmos_workflow_checkpointing.py` for a standalone example,
or `samples/03-workflows/checkpoint/cosmos_workflow_checkpointing_foundry.py` for an end-to-end
example with Azure AI Foundry agents.
@@ -2,6 +2,7 @@
import importlib.metadata
from ._checkpoint_storage import CosmosCheckpointStorage
from ._history_provider import CosmosHistoryProvider
try:
@@ -10,6 +11,7 @@ except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"CosmosCheckpointStorage",
"CosmosHistoryProvider",
"__version__",
]
@@ -0,0 +1,426 @@
# Copyright (c) Microsoft. All rights reserved.
"""Azure Cosmos DB checkpoint storage for workflow checkpointing."""
from __future__ import annotations
import logging
from typing import Any, TypedDict
from agent_framework import AGENT_FRAMEWORK_USER_AGENT
from agent_framework._settings import SecretString, load_settings
from agent_framework._workflows._checkpoint import CheckpointID, WorkflowCheckpoint
from agent_framework._workflows._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from agent_framework.exceptions import WorkflowCheckpointException
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from azure.cosmos import PartitionKey
from azure.cosmos.aio import ContainerProxy, CosmosClient
from azure.cosmos.exceptions import CosmosResourceNotFoundError
AzureCredentialTypes = TokenCredential | AsyncTokenCredential
logger = logging.getLogger(__name__)
class AzureCosmosCheckpointSettings(TypedDict, total=False):
"""Settings for CosmosCheckpointStorage resolved from args and environment."""
endpoint: str | None
database_name: str | None
container_name: str | None
key: SecretString | None
class CosmosCheckpointStorage:
"""Azure Cosmos DB-backed checkpoint storage for workflow checkpointing.
Implements the ``CheckpointStorage`` protocol using Azure Cosmos DB NoSQL
as the persistent backend. Checkpoints are stored as JSON documents with
``workflow_name`` as the partition key, enabling efficient per-workflow queries.
This storage uses the same hybrid JSON + pickle encoding as
``FileCheckpointStorage``, allowing full Python object fidelity for
complex workflow state while keeping the document structure human-readable.
SECURITY WARNING: Checkpoints use pickle for data serialization. Only load
checkpoints from trusted sources. Loading a malicious checkpoint can execute
arbitrary code.
The database and container are created automatically on first use
if they do not already exist. The container uses partition key
``/workflow_name``.
Example using managed identity / RBAC:
.. code-block:: python
from azure.identity.aio import DefaultAzureCredential
from agent_framework_azure_cosmos import CosmosCheckpointStorage
storage = CosmosCheckpointStorage(
endpoint="https://my-account.documents.azure.com:443/",
credential=DefaultAzureCredential(),
database_name="agent-db",
container_name="checkpoints",
)
Example using account key:
.. code-block:: python
storage = CosmosCheckpointStorage(
endpoint="https://my-account.documents.azure.com:443/",
credential="my-account-key",
database_name="agent-db",
container_name="checkpoints",
)
Then use with a workflow builder:
.. code-block:: python
workflow = WorkflowBuilder(
start_executor=start,
checkpoint_storage=storage,
).build()
"""
def __init__(
self,
*,
endpoint: str | None = None,
database_name: str | None = None,
container_name: str | None = None,
credential: str | AzureCredentialTypes | None = None,
cosmos_client: CosmosClient | None = None,
container_client: ContainerProxy | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize the Azure Cosmos DB checkpoint storage.
Supports multiple authentication modes:
- **Container client** (``container_client``): Use a pre-created
Cosmos async container proxy. No client lifecycle is managed.
- **Cosmos client** (``cosmos_client``): Use a pre-created Cosmos
async client. The caller is responsible for closing it.
- **Endpoint + credential**: Create a new Cosmos client. The storage
owns the client and closes it on ``close()``.
- **Environment variables**: Falls back to ``AZURE_COSMOS_ENDPOINT``,
``AZURE_COSMOS_DATABASE_NAME``, ``AZURE_COSMOS_CONTAINER_NAME``,
and ``AZURE_COSMOS_KEY``.
Args:
endpoint: Cosmos DB account endpoint.
Can be set via ``AZURE_COSMOS_ENDPOINT``.
database_name: Cosmos DB database name.
Can be set via ``AZURE_COSMOS_DATABASE_NAME``.
container_name: Cosmos DB container name.
Can be set via ``AZURE_COSMOS_CONTAINER_NAME``.
credential: Credential to authenticate with Cosmos DB.
For **managed identity / RBAC**, pass an Azure credential object
such as ``DefaultAzureCredential()`` or
``ManagedIdentityCredential()``.
For **key-based auth**, pass the account key as a string,
or set ``AZURE_COSMOS_KEY`` in the environment.
cosmos_client: Pre-created Cosmos async client.
container_client: Pre-created Cosmos container client.
env_file_path: Path to environment file for loading settings.
env_file_encoding: Encoding of the environment file.
"""
self._cosmos_client: CosmosClient | None = cosmos_client
self._container_proxy: ContainerProxy | None = container_client
self._owns_client = False
if self._container_proxy is not None:
self.database_name: str = database_name or ""
self.container_name: str = container_name or ""
return
required_fields: list[str] = ["database_name", "container_name"]
if cosmos_client is None:
required_fields.append("endpoint")
if credential is None:
required_fields.append("key")
settings = load_settings(
AzureCosmosCheckpointSettings,
env_prefix="AZURE_COSMOS_",
required_fields=required_fields,
endpoint=endpoint,
database_name=database_name,
container_name=container_name,
key=credential if isinstance(credential, str) else None,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
self.database_name = settings["database_name"] # type: ignore[assignment]
self.container_name = settings["container_name"] # type: ignore[assignment]
if self._cosmos_client is None:
self._cosmos_client = CosmosClient(
url=settings["endpoint"], # type: ignore[arg-type]
credential=credential or settings["key"].get_secret_value(), # type: ignore[arg-type,union-attr]
user_agent_suffix=AGENT_FRAMEWORK_USER_AGENT,
)
self._owns_client = True
async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID:
"""Save a checkpoint to Cosmos DB and return its ID.
The checkpoint is encoded to a JSON-compatible form (using pickle for
non-JSON-native values) and stored as a Cosmos DB document with the
``workflow_name`` as the partition key.
The document ``id`` is a composite of ``workflow_name`` and
``checkpoint_id`` to ensure global uniqueness across partitions.
Args:
checkpoint: The WorkflowCheckpoint object to save.
Returns:
The unique ID of the saved checkpoint.
"""
await self._ensure_container_proxy()
checkpoint_dict = checkpoint.to_dict()
encoded = encode_checkpoint_value(checkpoint_dict)
document: dict[str, Any] = {
"id": self._make_document_id(checkpoint.workflow_name, checkpoint.checkpoint_id),
"workflow_name": checkpoint.workflow_name,
**encoded,
}
await self._container_proxy.upsert_item(body=document) # type: ignore[union-attr]
logger.info("Saved checkpoint %s to Cosmos DB", checkpoint.checkpoint_id)
return checkpoint.checkpoint_id
async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
"""Load a checkpoint from Cosmos DB by ID.
Args:
checkpoint_id: The unique ID of the checkpoint to load.
Returns:
The WorkflowCheckpoint object corresponding to the given ID.
Raises:
WorkflowCheckpointException: If no checkpoint with the given ID exists,
or if multiple checkpoints share the same ID across workflows.
"""
await self._ensure_container_proxy()
query = "SELECT * FROM c WHERE c.checkpoint_id = @checkpoint_id"
parameters: list[dict[str, object]] = [
{"name": "@checkpoint_id", "value": checkpoint_id},
]
items = self._container_proxy.query_items( # type: ignore[union-attr]
query=query,
parameters=parameters,
)
results: list[dict[str, Any]] = []
async for item in items:
results.append(item)
if not results:
raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}")
if len(results) > 1:
workflow_names = [r.get("workflow_name", "unknown") for r in results]
raise WorkflowCheckpointException(
f"Multiple checkpoints found with ID {checkpoint_id} across workflows: "
f"{workflow_names}. Use list_checkpoints(workflow_name=...) to query "
f"by workflow instead."
)
return self._document_to_checkpoint(results[0])
async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]:
"""List checkpoint objects for a given workflow name.
Args:
workflow_name: The name of the workflow to list checkpoints for.
Returns:
A list of WorkflowCheckpoint objects for the specified workflow name.
"""
await self._ensure_container_proxy()
query = "SELECT * FROM c WHERE c.workflow_name = @workflow_name ORDER BY c.timestamp ASC"
parameters: list[dict[str, object]] = [
{"name": "@workflow_name", "value": workflow_name},
]
items = self._container_proxy.query_items( # type: ignore[union-attr]
query=query,
parameters=parameters,
partition_key=workflow_name,
)
checkpoints: list[WorkflowCheckpoint] = []
async for item in items:
try:
checkpoints.append(self._document_to_checkpoint(item))
except Exception as e:
logger.warning("Failed to decode checkpoint document: %s", e)
return checkpoints
async def delete(self, checkpoint_id: CheckpointID) -> bool:
"""Delete a checkpoint from Cosmos DB by ID.
Args:
checkpoint_id: The unique ID of the checkpoint to delete.
Returns:
True if the checkpoint was successfully deleted, False if not found.
"""
await self._ensure_container_proxy()
query = "SELECT c.id, c.workflow_name FROM c WHERE c.checkpoint_id = @checkpoint_id"
parameters: list[dict[str, object]] = [
{"name": "@checkpoint_id", "value": checkpoint_id},
]
items = self._container_proxy.query_items( # type: ignore[union-attr]
query=query,
parameters=parameters,
)
async for item in items:
try:
await self._container_proxy.delete_item( # type: ignore[union-attr]
item=item["id"],
partition_key=item["workflow_name"],
)
logger.info("Deleted checkpoint %s from Cosmos DB", checkpoint_id)
return True
except CosmosResourceNotFoundError:
return False
return False
async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None:
"""Get the latest checkpoint for a given workflow name.
Args:
workflow_name: The name of the workflow to get the latest checkpoint for.
Returns:
The latest WorkflowCheckpoint, or None if no checkpoints exist.
"""
await self._ensure_container_proxy()
query = "SELECT * FROM c WHERE c.workflow_name = @workflow_name ORDER BY c.timestamp DESC OFFSET 0 LIMIT 1"
parameters: list[dict[str, object]] = [
{"name": "@workflow_name", "value": workflow_name},
]
items = self._container_proxy.query_items( # type: ignore[union-attr]
query=query,
parameters=parameters,
partition_key=workflow_name,
)
async for item in items:
checkpoint = self._document_to_checkpoint(item)
logger.debug(
"Latest checkpoint for workflow %s is %s",
workflow_name,
checkpoint.checkpoint_id,
)
return checkpoint
return None
async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]:
"""List checkpoint IDs for a given workflow name.
Args:
workflow_name: The name of the workflow to list checkpoint IDs for.
Returns:
A list of checkpoint IDs for the specified workflow name.
"""
await self._ensure_container_proxy()
query = "SELECT c.checkpoint_id FROM c WHERE c.workflow_name = @workflow_name ORDER BY c.timestamp ASC"
parameters: list[dict[str, object]] = [
{"name": "@workflow_name", "value": workflow_name},
]
items = self._container_proxy.query_items( # type: ignore[union-attr]
query=query,
parameters=parameters,
partition_key=workflow_name,
)
checkpoint_ids: list[CheckpointID] = []
async for item in items:
cid = item.get("checkpoint_id")
if isinstance(cid, str):
checkpoint_ids.append(cid)
return checkpoint_ids
async def close(self) -> None:
"""Close the underlying Cosmos client when this storage owns it."""
if self._owns_client and self._cosmos_client is not None:
await self._cosmos_client.close()
async def __aenter__(self) -> CosmosCheckpointStorage:
"""Async context manager entry."""
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: Any,
) -> None:
"""Async context manager exit."""
try:
await self.close()
except Exception:
if exc_type is None:
raise
async def _ensure_container_proxy(self) -> None:
"""Get or create the Cosmos DB database and container for storing checkpoints."""
if self._container_proxy is not None:
return
if self._cosmos_client is None:
raise RuntimeError("Cosmos client is not initialized.")
database = await self._cosmos_client.create_database_if_not_exists(id=self.database_name)
self._container_proxy = await database.create_container_if_not_exists(
id=self.container_name,
partition_key=PartitionKey(path="/workflow_name"),
)
@staticmethod
def _document_to_checkpoint(document: dict[str, Any]) -> WorkflowCheckpoint:
"""Convert a Cosmos DB document back to a WorkflowCheckpoint.
Strips Cosmos DB system properties (``_rid``, ``_self``, ``_etag``,
``_attachments``, ``_ts``) before decoding.
"""
# Remove Cosmos DB system properties and the composite 'id' field
# (checkpoints use 'checkpoint_id', not 'id')
cosmos_keys = {"id", "_rid", "_self", "_etag", "_attachments", "_ts"}
cleaned = {k: v for k, v in document.items() if k not in cosmos_keys}
decoded = decode_checkpoint_value(cleaned)
return WorkflowCheckpoint.from_dict(decoded)
@staticmethod
def _make_document_id(workflow_name: str, checkpoint_id: str) -> str:
"""Create a composite Cosmos DB document ID.
Combines ``workflow_name`` and ``checkpoint_id`` to ensure global
uniqueness across partitions.
"""
return f"{workflow_name}_{checkpoint_id}"
+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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"azure-cosmos>=4.3.0,<5",
]
@@ -0,0 +1,597 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import os
import uuid
from collections.abc import AsyncIterator
from contextlib import suppress
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
from agent_framework._workflows._checkpoint_encoding import encode_checkpoint_value
from agent_framework.exceptions import SettingNotFoundError, WorkflowCheckpointException
from azure.cosmos.aio import CosmosClient
from azure.cosmos.exceptions import CosmosResourceNotFoundError
import agent_framework_azure_cosmos._checkpoint_storage as checkpoint_storage_module
from agent_framework_azure_cosmos._checkpoint_storage import CosmosCheckpointStorage
skip_if_cosmos_integration_tests_disabled = pytest.mark.skipif(
any(
os.getenv(name, "") == ""
for name in (
"AZURE_COSMOS_ENDPOINT",
"AZURE_COSMOS_KEY",
"AZURE_COSMOS_DATABASE_NAME",
"AZURE_COSMOS_CONTAINER_NAME",
)
),
reason=(
"AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_KEY, AZURE_COSMOS_DATABASE_NAME, and "
"AZURE_COSMOS_CONTAINER_NAME are required for Cosmos integration tests."
),
)
def _to_async_iter(items: list[Any]) -> AsyncIterator[Any]:
async def _iterator() -> AsyncIterator[Any]:
for item in items:
yield item
return _iterator()
def _make_checkpoint(
workflow_name: str = "test-workflow",
checkpoint_id: str | None = None,
previous_checkpoint_id: str | None = None,
timestamp: str | None = None,
) -> WorkflowCheckpoint:
"""Create a minimal WorkflowCheckpoint for testing."""
return WorkflowCheckpoint(
workflow_name=workflow_name,
graph_signature_hash="abc123",
checkpoint_id=checkpoint_id or str(uuid.uuid4()),
previous_checkpoint_id=previous_checkpoint_id,
timestamp=timestamp or "2025-01-01T00:00:00+00:00",
state={"counter": 42},
iteration_count=1,
)
def _checkpoint_to_cosmos_document(checkpoint: WorkflowCheckpoint) -> dict[str, Any]:
"""Simulate what a Cosmos DB document looks like after save."""
encoded = encode_checkpoint_value(checkpoint.to_dict())
doc: dict[str, Any] = {
"id": f"{checkpoint.workflow_name}_{checkpoint.checkpoint_id}",
"workflow_name": checkpoint.workflow_name,
**encoded,
# Cosmos system properties
"_rid": "abc",
"_self": "dbs/abc/colls/def/docs/ghi",
"_etag": '"00000000-0000-0000-0000-000000000000"',
"_attachments": "attachments/",
"_ts": 1700000000,
}
return doc
@pytest.fixture
def mock_container() -> MagicMock:
container = MagicMock()
container.query_items = MagicMock(return_value=_to_async_iter([]))
container.upsert_item = AsyncMock(return_value={})
container.delete_item = AsyncMock(return_value={})
return container
@pytest.fixture
def mock_cosmos_client(mock_container: MagicMock) -> MagicMock:
database_client = MagicMock()
database_client.create_container_if_not_exists = AsyncMock(return_value=mock_container)
client = MagicMock()
client.create_database_if_not_exists = AsyncMock(return_value=database_client)
client.close = AsyncMock()
return client
# --- Tests for initialization ---
async def test_init_uses_provided_container_client(mock_container: MagicMock) -> None:
storage = CosmosCheckpointStorage(container_client=mock_container)
assert storage.database_name == ""
assert storage.container_name == ""
async def test_init_uses_provided_cosmos_client(mock_cosmos_client: MagicMock) -> None:
storage = CosmosCheckpointStorage(
cosmos_client=mock_cosmos_client,
database_name="db1",
container_name="checkpoints",
)
assert storage.database_name == "db1"
assert storage.container_name == "checkpoints"
async def test_init_missing_required_settings_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("AZURE_COSMOS_ENDPOINT", raising=False)
monkeypatch.delenv("AZURE_COSMOS_DATABASE_NAME", raising=False)
monkeypatch.delenv("AZURE_COSMOS_CONTAINER_NAME", raising=False)
monkeypatch.delenv("AZURE_COSMOS_KEY", raising=False)
with pytest.raises(SettingNotFoundError, match="database_name"):
CosmosCheckpointStorage()
async def test_init_constructs_client_with_credential(
monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock
) -> None:
"""Uses key-based auth when a key string is provided, otherwise falls back to Azure credential (RBAC)."""
mock_factory = MagicMock(return_value=mock_cosmos_client)
monkeypatch.setattr(checkpoint_storage_module, "CosmosClient", mock_factory)
monkeypatch.delenv("AZURE_COSMOS_KEY", raising=False)
# Simulate real-world pattern: use key if available, else RBAC credential
cosmos_key = os.getenv("AZURE_COSMOS_KEY")
credential: Any = cosmos_key if cosmos_key else MagicMock() # MagicMock simulates DefaultAzureCredential()
CosmosCheckpointStorage(
endpoint="https://account.documents.azure.com:443/",
credential=credential,
database_name="db1",
container_name="checkpoints",
)
mock_factory.assert_called_once()
kwargs = mock_factory.call_args.kwargs
assert kwargs["url"] == "https://account.documents.azure.com:443/"
assert kwargs["credential"] is credential
async def test_init_creates_database_and_container(mock_cosmos_client: MagicMock) -> None:
storage = CosmosCheckpointStorage(
cosmos_client=mock_cosmos_client,
database_name="db1",
container_name="custom-checkpoints",
)
await storage.list_checkpoint_ids(workflow_name="wf")
mock_cosmos_client.create_database_if_not_exists.assert_awaited_once_with(id="db1")
database_client = mock_cosmos_client.create_database_if_not_exists.return_value
assert database_client.create_container_if_not_exists.await_count == 1
kwargs = database_client.create_container_if_not_exists.await_args.kwargs
assert kwargs["id"] == "custom-checkpoints"
# --- Tests for save ---
async def test_save_upserts_document(mock_container: MagicMock) -> None:
storage = CosmosCheckpointStorage(container_client=mock_container)
checkpoint = _make_checkpoint()
result = await storage.save(checkpoint)
assert result == checkpoint.checkpoint_id
mock_container.upsert_item.assert_awaited_once()
document = mock_container.upsert_item.await_args.kwargs["body"]
assert document["id"] == f"test-workflow_{checkpoint.checkpoint_id}"
assert document["workflow_name"] == "test-workflow"
assert document["graph_signature_hash"] == "abc123"
assert document["state"]["counter"] == 42
async def test_save_returns_checkpoint_id(mock_container: MagicMock) -> None:
storage = CosmosCheckpointStorage(container_client=mock_container)
checkpoint = _make_checkpoint(checkpoint_id="cp-123")
result = await storage.save(checkpoint)
assert result == "cp-123"
# --- Tests for load ---
async def test_load_returns_checkpoint(mock_container: MagicMock) -> None:
checkpoint = _make_checkpoint(checkpoint_id="cp-load")
doc = _checkpoint_to_cosmos_document(checkpoint)
mock_container.query_items.return_value = _to_async_iter([doc])
storage = CosmosCheckpointStorage(container_client=mock_container)
loaded = await storage.load("cp-load")
assert loaded.checkpoint_id == "cp-load"
assert loaded.workflow_name == "test-workflow"
assert loaded.graph_signature_hash == "abc123"
assert loaded.state["counter"] == 42
async def test_load_nonexistent_raises(mock_container: MagicMock) -> None:
mock_container.query_items.return_value = _to_async_iter([])
storage = CosmosCheckpointStorage(container_client=mock_container)
with pytest.raises(WorkflowCheckpointException, match="No checkpoint found"):
await storage.load("nonexistent-id")
async def test_load_queries_without_partition_key(mock_container: MagicMock) -> None:
mock_container.query_items.return_value = _to_async_iter([])
storage = CosmosCheckpointStorage(container_client=mock_container)
with suppress(WorkflowCheckpointException):
await storage.load("cp-id")
kwargs = mock_container.query_items.call_args.kwargs
assert "partition_key" not in kwargs
async def test_load_multiple_workflows_same_checkpoint_id_raises(mock_container: MagicMock) -> None:
cp1 = _make_checkpoint(checkpoint_id="shared-id", workflow_name="workflow-a")
cp2 = _make_checkpoint(checkpoint_id="shared-id", workflow_name="workflow-b")
mock_container.query_items.return_value = _to_async_iter([
_checkpoint_to_cosmos_document(cp1),
_checkpoint_to_cosmos_document(cp2),
])
storage = CosmosCheckpointStorage(container_client=mock_container)
with pytest.raises(WorkflowCheckpointException, match="Multiple checkpoints found"):
await storage.load("shared-id")
# --- Tests for list_checkpoints ---
async def test_list_checkpoints_returns_checkpoints_for_workflow(mock_container: MagicMock) -> None:
cp1 = _make_checkpoint(checkpoint_id="cp-1", timestamp="2025-01-01T00:00:00+00:00")
cp2 = _make_checkpoint(checkpoint_id="cp-2", timestamp="2025-01-02T00:00:00+00:00")
mock_container.query_items.return_value = _to_async_iter([
_checkpoint_to_cosmos_document(cp1),
_checkpoint_to_cosmos_document(cp2),
])
storage = CosmosCheckpointStorage(container_client=mock_container)
results = await storage.list_checkpoints(workflow_name="test-workflow")
assert len(results) == 2
assert results[0].checkpoint_id == "cp-1"
assert results[1].checkpoint_id == "cp-2"
async def test_list_checkpoints_uses_partition_key(mock_container: MagicMock) -> None:
mock_container.query_items.return_value = _to_async_iter([])
storage = CosmosCheckpointStorage(container_client=mock_container)
await storage.list_checkpoints(workflow_name="my-workflow")
kwargs = mock_container.query_items.call_args.kwargs
assert kwargs["partition_key"] == "my-workflow"
async def test_list_checkpoints_empty_returns_empty(mock_container: MagicMock) -> None:
mock_container.query_items.return_value = _to_async_iter([])
storage = CosmosCheckpointStorage(container_client=mock_container)
results = await storage.list_checkpoints(workflow_name="test-workflow")
assert results == []
async def test_list_checkpoints_skips_malformed_documents(mock_container: MagicMock) -> None:
valid_cp = _make_checkpoint(checkpoint_id="cp-valid")
mock_container.query_items.return_value = _to_async_iter([
{"id": "bad_doc", "workflow_name": "test-workflow", "not_a_checkpoint": True},
_checkpoint_to_cosmos_document(valid_cp),
])
storage = CosmosCheckpointStorage(container_client=mock_container)
results = await storage.list_checkpoints(workflow_name="test-workflow")
assert len(results) == 1
assert results[0].checkpoint_id == "cp-valid"
# --- Tests for delete ---
async def test_delete_existing_returns_true(mock_container: MagicMock) -> None:
mock_container.query_items.return_value = _to_async_iter([
{"id": "test-workflow_cp-del", "workflow_name": "test-workflow"},
])
storage = CosmosCheckpointStorage(container_client=mock_container)
result = await storage.delete("cp-del")
assert result is True
mock_container.delete_item.assert_awaited_once_with(
item="test-workflow_cp-del",
partition_key="test-workflow",
)
async def test_delete_nonexistent_returns_false(mock_container: MagicMock) -> None:
mock_container.query_items.return_value = _to_async_iter([])
storage = CosmosCheckpointStorage(container_client=mock_container)
result = await storage.delete("nonexistent")
assert result is False
mock_container.delete_item.assert_not_awaited()
async def test_delete_cosmos_not_found_returns_false(mock_container: MagicMock) -> None:
mock_container.query_items.return_value = _to_async_iter([
{"id": "test-workflow_cp-del", "workflow_name": "test-workflow"},
])
mock_container.delete_item = AsyncMock(side_effect=CosmosResourceNotFoundError)
storage = CosmosCheckpointStorage(container_client=mock_container)
result = await storage.delete("cp-del")
assert result is False
# --- Tests for get_latest ---
async def test_get_latest_returns_latest_checkpoint(mock_container: MagicMock) -> None:
cp = _make_checkpoint(checkpoint_id="cp-latest", timestamp="2025-06-01T00:00:00+00:00")
mock_container.query_items.return_value = _to_async_iter([
_checkpoint_to_cosmos_document(cp),
])
storage = CosmosCheckpointStorage(container_client=mock_container)
result = await storage.get_latest(workflow_name="test-workflow")
assert result is not None
assert result.checkpoint_id == "cp-latest"
async def test_get_latest_returns_none_when_empty(mock_container: MagicMock) -> None:
mock_container.query_items.return_value = _to_async_iter([])
storage = CosmosCheckpointStorage(container_client=mock_container)
result = await storage.get_latest(workflow_name="test-workflow")
assert result is None
async def test_get_latest_uses_order_by_desc_with_limit(mock_container: MagicMock) -> None:
mock_container.query_items.return_value = _to_async_iter([])
storage = CosmosCheckpointStorage(container_client=mock_container)
await storage.get_latest(workflow_name="test-workflow")
kwargs = mock_container.query_items.call_args.kwargs
assert "ORDER BY c.timestamp DESC" in kwargs["query"]
assert "OFFSET 0 LIMIT 1" in kwargs["query"]
# --- Tests for list_checkpoint_ids ---
async def test_list_checkpoint_ids_returns_ids(mock_container: MagicMock) -> None:
mock_container.query_items.return_value = _to_async_iter([
{"checkpoint_id": "cp-1"},
{"checkpoint_id": "cp-2"},
])
storage = CosmosCheckpointStorage(container_client=mock_container)
ids = await storage.list_checkpoint_ids(workflow_name="test-workflow")
assert ids == ["cp-1", "cp-2"]
async def test_list_checkpoint_ids_empty_returns_empty(mock_container: MagicMock) -> None:
mock_container.query_items.return_value = _to_async_iter([])
storage = CosmosCheckpointStorage(container_client=mock_container)
ids = await storage.list_checkpoint_ids(workflow_name="test-workflow")
assert ids == []
# --- Tests for close and context manager ---
async def test_close_closes_owned_client(monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock) -> None:
mock_factory = MagicMock(return_value=mock_cosmos_client)
monkeypatch.setattr(checkpoint_storage_module, "CosmosClient", mock_factory)
storage = CosmosCheckpointStorage(
endpoint="https://account.documents.azure.com:443/",
credential="key-123",
database_name="db1",
container_name="checkpoints",
)
await storage.close()
mock_cosmos_client.close.assert_awaited_once()
async def test_close_does_not_close_external_client(mock_cosmos_client: MagicMock) -> None:
storage = CosmosCheckpointStorage(
cosmos_client=mock_cosmos_client,
database_name="db1",
container_name="checkpoints",
)
await storage.close()
mock_cosmos_client.close.assert_not_awaited()
async def test_context_manager_closes_owned_client(
monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock
) -> None:
mock_factory = MagicMock(return_value=mock_cosmos_client)
monkeypatch.setattr(checkpoint_storage_module, "CosmosClient", mock_factory)
async with CosmosCheckpointStorage(
endpoint="https://account.documents.azure.com:443/",
credential="key-123",
database_name="db1",
container_name="checkpoints",
) as storage:
assert storage is not None
mock_cosmos_client.close.assert_awaited_once()
async def test_context_manager_preserves_original_exception(mock_container: MagicMock) -> None:
storage = CosmosCheckpointStorage(container_client=mock_container)
with (
patch.object(storage, "close", AsyncMock(side_effect=RuntimeError("close failed"))),
pytest.raises(ValueError, match="inner error"),
):
async with storage:
raise ValueError("inner error")
async def test_context_manager_reraises_close_error(mock_container: MagicMock) -> None:
storage = CosmosCheckpointStorage(container_client=mock_container)
with (
patch.object(storage, "close", AsyncMock(side_effect=RuntimeError("close failed"))),
pytest.raises(RuntimeError, match="close failed"),
):
async with storage:
pass # no inner exception — close error should propagate
# --- Tests for save/load round-trip ---
async def test_round_trip_preserves_data(mock_container: MagicMock) -> None:
checkpoint = _make_checkpoint(
checkpoint_id="cp-roundtrip",
previous_checkpoint_id="cp-parent",
)
checkpoint.state = {"key": "value", "nested": {"a": 1}}
checkpoint.metadata = {"superstep": 3}
checkpoint.iteration_count = 5
saved_doc: dict[str, Any] = {}
async def capture_upsert(body: dict[str, Any]) -> dict[str, Any]:
saved_doc.update(body)
return body
mock_container.upsert_item = AsyncMock(side_effect=capture_upsert)
storage = CosmosCheckpointStorage(container_client=mock_container)
await storage.save(checkpoint)
returned_doc = {
**saved_doc,
"_rid": "abc",
"_self": "dbs/abc/colls/def/docs/ghi",
"_etag": '"etag"',
"_attachments": "attachments/",
"_ts": 1700000000,
}
mock_container.query_items.return_value = _to_async_iter([returned_doc])
loaded = await storage.load("cp-roundtrip")
assert loaded.checkpoint_id == checkpoint.checkpoint_id
assert loaded.workflow_name == checkpoint.workflow_name
assert loaded.graph_signature_hash == checkpoint.graph_signature_hash
assert loaded.previous_checkpoint_id == "cp-parent"
assert loaded.state == {"key": "value", "nested": {"a": 1}}
assert loaded.metadata == {"superstep": 3}
assert loaded.iteration_count == 5
assert loaded.version == "1.0"
# --- Integration test ---
@pytest.mark.integration
@skip_if_cosmos_integration_tests_disabled
async def test_cosmos_checkpoint_storage_roundtrip_with_emulator() -> None:
endpoint = os.getenv("AZURE_COSMOS_ENDPOINT", "")
key = os.getenv("AZURE_COSMOS_KEY", "")
database_prefix = os.getenv("AZURE_COSMOS_DATABASE_NAME", "")
container_prefix = os.getenv("AZURE_COSMOS_CONTAINER_NAME", "")
unique = uuid.uuid4().hex[:8]
database_name = f"{database_prefix}-cp-{unique}"
container_name = f"{container_prefix}-cp-{unique}"
async with CosmosClient(url=endpoint, credential=key) as cosmos_client:
await cosmos_client.create_database_if_not_exists(id=database_name)
storage = CosmosCheckpointStorage(
cosmos_client=cosmos_client,
database_name=database_name,
container_name=container_name,
)
try:
# Save two checkpoints for the same workflow
cp1 = _make_checkpoint(
checkpoint_id="cp-int-1",
workflow_name="integration-wf",
timestamp="2025-01-01T00:00:00+00:00",
)
cp2 = _make_checkpoint(
checkpoint_id="cp-int-2",
workflow_name="integration-wf",
previous_checkpoint_id="cp-int-1",
timestamp="2025-01-02T00:00:00+00:00",
)
cp2.state = {"step": 2}
await storage.save(cp1)
await storage.save(cp2)
# Load by ID
loaded = await storage.load("cp-int-1")
assert loaded.checkpoint_id == "cp-int-1"
assert loaded.workflow_name == "integration-wf"
# List all checkpoints for workflow
all_cps = await storage.list_checkpoints(workflow_name="integration-wf")
assert len(all_cps) == 2
# List checkpoint IDs
ids = await storage.list_checkpoint_ids(workflow_name="integration-wf")
assert "cp-int-1" in ids
assert "cp-int-2" in ids
# Get latest
latest = await storage.get_latest(workflow_name="integration-wf")
assert latest is not None
assert latest.checkpoint_id == "cp-int-2"
assert latest.state == {"step": 2}
# Delete
assert await storage.delete("cp-int-1") is True
assert await storage.delete("cp-int-1") is False
remaining = await storage.list_checkpoint_ids(workflow_name="integration-wf")
assert remaining == ["cp-int-2"]
# Cross-workflow isolation
other_cp = _make_checkpoint(
checkpoint_id="cp-other",
workflow_name="other-wf",
)
await storage.save(other_cp)
wf_cps = await storage.list_checkpoints(workflow_name="integration-wf")
assert len(wf_cps) == 1
assert wf_cps[0].checkpoint_id == "cp-int-2"
finally:
with suppress(Exception):
await cosmos_client.delete_database(database_name)
@@ -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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"agent-framework-durabletask",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
+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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"openai-chatkit>=1.4.1,<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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"claude-agent-sdk>=0.1.36,<0.1.49",
]
+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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
]
@@ -244,14 +244,39 @@ class FileCheckpointStorage:
is serialized using pickle and embedded as base64-encoded strings within the JSON. This allows
for human-readable checkpoint files while preserving the ability to store complex Python objects.
SECURITY WARNING: Checkpoints use pickle for data serialization. Only load checkpoints
from trusted sources. Loading a malicious checkpoint file can execute arbitrary code.
By default, checkpoint deserialization is restricted to a built-in set of safe
Python types (primitives, datetime, uuid, ...) and all ``agent_framework``
internal types. To allow additional application-specific types, pass them via
the ``allowed_checkpoint_types`` parameter using ``"module:qualname"`` format.
Example::
storage = FileCheckpointStorage(
"/tmp/checkpoints",
allowed_checkpoint_types=[
"my_app.models:MyState",
],
)
"""
def __init__(self, storage_path: str | Path):
"""Initialize the file storage."""
def __init__(
self,
storage_path: str | Path,
*,
allowed_checkpoint_types: list[str] | None = None,
) -> None:
"""Initialize the file storage.
Args:
storage_path: Directory path where checkpoint files will be stored.
allowed_checkpoint_types: Additional types (beyond the built-in safe set
and framework types) that are permitted during checkpoint
deserialization. Each entry should be a ``"module:qualname"``
string (e.g., ``"my_app.models:MyState"``).
"""
self.storage_path = Path(storage_path)
self.storage_path.mkdir(parents=True, exist_ok=True)
self._allowed_types: frozenset[str] = frozenset(allowed_checkpoint_types or [])
logger.info(f"Initialized file checkpoint storage at {self.storage_path}")
def _validate_file_path(self, checkpoint_id: CheckpointID) -> Path:
@@ -327,7 +352,7 @@ class FileCheckpointStorage:
from ._checkpoint_encoding import decode_checkpoint_value
try:
decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint)
decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint, allowed_types=self._allowed_types)
except WorkflowCheckpointException:
raise
checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict)
@@ -352,7 +377,9 @@ class FileCheckpointStorage:
encoded_checkpoint = json.load(f)
from ._checkpoint_encoding import decode_checkpoint_value
decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint)
decoded_checkpoint_dict = decode_checkpoint_value(
encoded_checkpoint, allowed_types=self._allowed_types
)
checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict)
if checkpoint.workflow_name == workflow_name:
checkpoints.append(checkpoint)
@@ -1,14 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import base64
import logging
import pickle # nosec # noqa: S403
from typing import Any
from ..exceptions import WorkflowCheckpointException
"""Checkpoint encoding using JSON structure with pickle+base64 for arbitrary data.
This hybrid approach provides:
@@ -16,10 +7,23 @@ This hybrid approach provides:
- Full Python object fidelity via pickle for data values (non-JSON-native types)
- Base64 encoding to embed binary pickle data in JSON strings
SECURITY WARNING: Checkpoints use pickle for data serialization. Only load checkpoints
from trusted sources. Loading a malicious checkpoint file can execute arbitrary code.
When ``allowed_types`` is supplied to :func:`decode_checkpoint_value`, a
``RestrictedUnpickler`` is used that limits which classes may be instantiated
during deserialization. The default built-in safe set covers common Python
value types (primitives, datetime, uuid, ...) and all ``agent_framework``
internal types. Callers can extend the set by passing additional
``"module:qualname"`` strings.
"""
from __future__ import annotations
import base64
import io
import logging
import pickle # nosec # noqa: S403
from typing import Any
from ..exceptions import WorkflowCheckpointException
logger = logging.getLogger("agent_framework")
@@ -30,6 +34,82 @@ _TYPE_MARKER = "__type__"
# Types that are natively JSON-serializable and don't need pickling
_JSON_NATIVE_TYPES = (str, int, float, bool, type(None))
# Module prefix for framework-internal types that are always allowed
_FRAMEWORK_MODULE_PREFIX = "agent_framework."
# Built-in types considered safe for checkpoint deserialization.
# Each entry is a ``module:qualname`` string matching the format produced by
# :func:`_type_to_key`. These are the classes for which pickle's
# ``find_class`` will be called when unpickling common Python value types.
_BUILTIN_ALLOWED_TYPE_KEYS: frozenset[str] = frozenset({
# builtins
"builtins:object",
"builtins:complex",
"builtins:range",
"builtins:slice",
"builtins:int",
"builtins:float",
"builtins:str",
"builtins:bytes",
"builtins:bytearray",
"builtins:bool",
"builtins:set",
"builtins:frozenset",
"builtins:list",
"builtins:dict",
"builtins:tuple",
"builtins:type",
# getattr is used by pickle to reconstruct enum members
"builtins:getattr",
# copyreg helpers used by pickle for object reconstruction
"copyreg:_reconstructor",
# datetime
"datetime:datetime",
"datetime:date",
"datetime:time",
"datetime:timedelta",
"datetime:timezone",
# uuid
"uuid:UUID",
# decimal
"decimal:Decimal",
# collections
"collections:OrderedDict",
"collections:defaultdict",
"collections:deque",
})
class _RestrictedUnpickler(pickle.Unpickler): # noqa: S301
"""Unpickler that restricts which classes may be instantiated.
Only classes whose ``module:qualname`` key appears in the combined allow
set (built-in safe types + framework types + caller-specified extras) are
permitted. All other classes raise :class:`pickle.UnpicklingError`.
"""
def __init__(self, data: bytes, allowed_types: frozenset[str]) -> None:
super().__init__(io.BytesIO(data))
self._allowed_types = allowed_types
def find_class(self, module: str, name: str) -> type:
type_key = f"{module}:{name}"
if (
type_key in _BUILTIN_ALLOWED_TYPE_KEYS
or type_key in self._allowed_types
or module.startswith(_FRAMEWORK_MODULE_PREFIX)
):
return super().find_class(module, name) # type: ignore[no-any-return] # nosec
raise pickle.UnpicklingError(
f"Checkpoint deserialization blocked for type '{type_key}'. "
f"To allow this type, either include its 'module:qualname' key in the "
f"'allowed_types' set passed to 'decode_checkpoint_value', or add it to "
f"'allowed_checkpoint_types' on your checkpoint storage "
f"(for example, 'FileCheckpointStorage.allowed_checkpoint_types')."
)
def encode_checkpoint_value(value: Any) -> Any:
"""Encode a Python value for checkpoint storage.
@@ -48,29 +128,51 @@ def encode_checkpoint_value(value: Any) -> Any:
return _encode(value)
def decode_checkpoint_value(value: Any) -> Any:
def decode_checkpoint_value(value: Any, *, allowed_types: frozenset[str] | None = None) -> Any:
"""Decode a value from checkpoint storage.
Reverses the encoding performed by encode_checkpoint_value.
Pickled values (identified by _PICKLE_MARKER) are decoded and unpickled.
WARNING: Only call this with trusted data. Pickle can execute
arbitrary code during deserialization. The post-unpickle type verification
detects accidental corruption or type mismatches, but cannot prevent
arbitrary code execution from malicious pickle payloads.
Args:
value: A JSON-deserialized value from checkpoint storage.
allowed_types: If not ``None``, restrict pickle deserialization to the
built-in safe set, framework types, and the types listed here.
Each entry should use ``"module:qualname"`` format — that is, the
dotted module path followed by a colon and the class
``__qualname__``. For example, given a user-defined class::
# my_app/models.py
class MyState: ...
the corresponding entry would be ``"my_app.models:MyState"``::
decode_checkpoint_value(
data,
allowed_types=frozenset({"my_app.models:MyState"}),
)
When using :class:`FileCheckpointStorage`, pass the same strings
via ``allowed_checkpoint_types``::
storage = FileCheckpointStorage(
"/tmp/checkpoints",
allowed_checkpoint_types=["my_app.models:MyState"],
)
If ``None``, no restriction is applied (backward-compatible
behavior).
Returns:
The original Python value.
Raises:
WorkflowCheckpointException: If the unpickled object's type doesn't match
the recorded type, indicating corruption, or if the base64/pickle
data is malformed.
the recorded type, indicating corruption, if the base64/pickle
data is malformed, or if a disallowed type is encountered during
restricted deserialization.
"""
return _decode(value)
return _decode(value, allowed_types=allowed_types)
def _encode(value: Any) -> Any:
@@ -94,7 +196,7 @@ def _encode(value: Any) -> Any:
}
def _decode(value: Any) -> Any:
def _decode(value: Any, *, allowed_types: frozenset[str] | None = None) -> Any:
"""Recursively decode a value from JSON storage."""
# JSON-native types pass through
if isinstance(value, _JSON_NATIVE_TYPES):
@@ -104,16 +206,16 @@ def _decode(value: Any) -> Any:
if isinstance(value, dict):
# Pickled value: decode, unpickle, and verify type
if _PICKLE_MARKER in value and _TYPE_MARKER in value:
obj = _base64_to_unpickle(value[_PICKLE_MARKER]) # type: ignore
obj = _base64_to_unpickle(value[_PICKLE_MARKER], allowed_types=allowed_types) # type: ignore
_verify_type(obj, value.get(_TYPE_MARKER)) # type: ignore
return obj
# Regular dict: decode values recursively
return {k: _decode(v) for k, v in value.items()} # type: ignore
return {k: _decode(v, allowed_types=allowed_types) for k, v in value.items()} # type: ignore
# Handle encoded lists
if isinstance(value, list):
return [_decode(item) for item in value] # type: ignore
return [_decode(item, allowed_types=allowed_types) for item in value] # type: ignore
return value
@@ -148,15 +250,23 @@ def _pickle_to_base64(value: Any) -> str:
return base64.b64encode(pickled).decode("ascii")
def _base64_to_unpickle(encoded: str) -> Any:
def _base64_to_unpickle(encoded: str, *, allowed_types: frozenset[str] | None = None) -> Any:
"""Decode base64 string and unpickle.
Args:
encoded: Base64-encoded pickle data.
allowed_types: If not ``None``, use restricted unpickling that only
permits built-in safe types, framework types, and the specified
extra types.
Raises:
WorkflowCheckpointException: If the base64 data is corrupted or the pickle
format is incompatible.
WorkflowCheckpointException: If the base64 data is corrupted, the pickle
format is incompatible, or a disallowed type is encountered.
"""
try:
pickled = base64.b64decode(encoded.encode("ascii"))
if allowed_types is not None:
return _RestrictedUnpickler(pickled, allowed_types).load()
return pickle.loads(pickled) # nosec # noqa: S301
except Exception as exc:
raise WorkflowCheckpointException(f"Failed to decode pickled checkpoint data: {exc}") from exc
+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.0"
version = "1.0.1"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -1048,7 +1048,10 @@ async def test_file_checkpoint_storage_roundtrip_datetime():
async def test_file_checkpoint_storage_roundtrip_dataclass():
"""Test that dataclass objects roundtrip correctly via pickle encoding."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
storage = FileCheckpointStorage(
temp_dir,
allowed_checkpoint_types=["tests.workflow.test_checkpoint:_TestCustomData"],
)
custom_obj = _TestCustomData(name="test", value=42, tags=["a", "b", "c"])
@@ -1238,7 +1241,10 @@ async def test_file_checkpoint_storage_roundtrip_messages_with_complex_data():
async def test_file_checkpoint_storage_roundtrip_pending_request_info_events():
"""Test that pending_request_info_events with WorkflowEvent objects roundtrip correctly."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
storage = FileCheckpointStorage(
temp_dir,
allowed_checkpoint_types=["tests.workflow.test_checkpoint:_TestToolApprovalRequest"],
)
# Create request_info events using the proper WorkflowEvent factory
event1 = WorkflowEvent.request_info(
@@ -1300,7 +1306,13 @@ async def test_file_checkpoint_storage_roundtrip_pending_request_info_events():
async def test_file_checkpoint_storage_roundtrip_full_checkpoint():
"""Test complete WorkflowCheckpoint roundtrip with all fields populated using proper types."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
storage = FileCheckpointStorage(
temp_dir,
allowed_checkpoint_types=[
"tests.workflow.test_checkpoint:_TestApprovalRequest",
"tests.workflow.test_checkpoint:_TestExecutorState",
],
)
# Create proper WorkflowMessage objects
msg1 = WorkflowMessage(data="msg1", source_id="s", target_id="t")
@@ -0,0 +1,218 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for restricted checkpoint deserialization.
These tests verify that persisted checkpoint loading uses a restricted
unpickler by default:
- Arbitrary callables are blocked during deserialization
- __reduce__ payloads cannot execute code during deserialization
- FileCheckpointStorage accepts allowed_checkpoint_types for extension
- User-defined types are blocked unless explicitly allowed
- Built-in safe types and framework types are always allowed
"""
import base64
import os
import pickle
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
import pytest
from agent_framework import WorkflowCheckpointException
from agent_framework._workflows._checkpoint import FileCheckpointStorage
from agent_framework._workflows._checkpoint_encoding import (
_PICKLE_MARKER,
_TYPE_MARKER,
decode_checkpoint_value,
encode_checkpoint_value,
)
class MaliciousPayload:
"""A class whose __reduce__ executes code during unpickling."""
def __reduce__(self):
return (os.getpid, ())
def test_restricted_decode_blocks_arbitrary_callable():
"""Restricted decoding blocks arbitrary module-level callables."""
pickled = pickle.dumps(os.getpid, protocol=pickle.HIGHEST_PROTOCOL)
encoded_b64 = base64.b64encode(pickled).decode("ascii")
checkpoint_value = {
_PICKLE_MARKER: encoded_b64,
_TYPE_MARKER: "builtins:builtin_function_or_method",
}
with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"):
decode_checkpoint_value(checkpoint_value, allowed_types=frozenset())
def test_restricted_decode_blocks_reduce_payload():
"""__reduce__-based payloads are blocked before code can execute."""
payload = MaliciousPayload()
pickled = pickle.dumps(payload, protocol=pickle.HIGHEST_PROTOCOL)
encoded_b64 = base64.b64encode(pickled).decode("ascii")
checkpoint_value = {
_PICKLE_MARKER: encoded_b64,
_TYPE_MARKER: f"{MaliciousPayload.__module__}:{MaliciousPayload.__qualname__}",
}
with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"):
decode_checkpoint_value(checkpoint_value, allowed_types=frozenset())
def test_restricted_decode_prevents_code_execution():
"""Restricted deserialization prevents __reduce__ code from running."""
with tempfile.TemporaryDirectory() as tmpdir:
marker_file = os.path.join(tmpdir, "checkpoint_test_marker")
payload_bytes = pickle.dumps(
type(
"Exploit",
(),
{
"__reduce__": lambda self: (
eval,
(f"open({marker_file!r}, 'w').write('pwned')",),
)
},
)(),
protocol=pickle.HIGHEST_PROTOCOL,
)
encoded_b64 = base64.b64encode(payload_bytes).decode("ascii")
checkpoint_value = {
_PICKLE_MARKER: encoded_b64,
_TYPE_MARKER: "builtins:int",
}
with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"):
decode_checkpoint_value(checkpoint_value, allowed_types=frozenset())
assert not os.path.exists(marker_file), (
"Restricted unpickler should have prevented code execution, but the marker file was created."
)
def test_file_checkpoint_storage_accepts_allowed_types():
"""FileCheckpointStorage.__init__ accepts allowed_checkpoint_types."""
with tempfile.TemporaryDirectory() as tmpdir:
storage = FileCheckpointStorage(
tmpdir,
allowed_checkpoint_types=["some.module:SomeType"],
)
assert storage is not None
@dataclass
class _AllowedTestState:
"""Test dataclass that will be explicitly allowed."""
name: str
value: int
def test_restricted_decode_blocks_unlisted_user_type():
"""User-defined types are blocked when not in allowed_checkpoint_types."""
original = _AllowedTestState(name="test", value=42)
encoded = encode_checkpoint_value(original)
with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"):
decode_checkpoint_value(encoded, allowed_types=frozenset())
def test_restricted_decode_allows_listed_user_type():
"""User-defined types are allowed when listed in allowed_types."""
original = _AllowedTestState(name="test", value=42)
encoded = encode_checkpoint_value(original)
type_key = f"{_AllowedTestState.__module__}:{_AllowedTestState.__qualname__}"
decoded = decode_checkpoint_value(encoded, allowed_types=frozenset({type_key}))
assert isinstance(decoded, _AllowedTestState)
assert decoded.name == "test"
assert decoded.value == 42
def test_restricted_decode_allows_builtin_safe_types():
"""Built-in safe types (datetime, set, etc.) are always allowed."""
test_values = [
datetime(2025, 1, 1, tzinfo=timezone.utc),
{1, 2, 3},
frozenset({4, 5, 6}),
(1, "two", 3.0),
complex(1, 2),
]
for original in test_values:
encoded = encode_checkpoint_value(original)
decoded = decode_checkpoint_value(encoded, allowed_types=frozenset())
assert decoded == original
def test_unrestricted_decode_allows_arbitrary_types():
"""Without allowed_types, decode_checkpoint_value remains unrestricted."""
original = _AllowedTestState(name="test", value=42)
encoded = encode_checkpoint_value(original)
decoded = decode_checkpoint_value(encoded)
assert isinstance(decoded, _AllowedTestState)
assert decoded.name == "test"
async def test_file_storage_blocks_unlisted_user_type():
"""FileCheckpointStorage blocks user types not in allowed_checkpoint_types."""
from agent_framework import WorkflowCheckpoint
with tempfile.TemporaryDirectory() as tmpdir:
# Save with a storage that allows the type
type_key = f"{_AllowedTestState.__module__}:{_AllowedTestState.__qualname__}"
save_storage = FileCheckpointStorage(tmpdir, allowed_checkpoint_types=[type_key])
checkpoint = WorkflowCheckpoint(
workflow_name="test",
graph_signature_hash="hash",
state={"data": _AllowedTestState(name="test", value=1)},
)
await save_storage.save(checkpoint)
# Load with a storage that does NOT allow the type
load_storage = FileCheckpointStorage(tmpdir)
with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"):
await load_storage.load(checkpoint.checkpoint_id)
async def test_file_storage_allows_listed_user_type():
"""FileCheckpointStorage allows user types listed in allowed_checkpoint_types."""
from agent_framework import WorkflowCheckpoint
with tempfile.TemporaryDirectory() as tmpdir:
type_key = f"{_AllowedTestState.__module__}:{_AllowedTestState.__qualname__}"
storage = FileCheckpointStorage(tmpdir, allowed_checkpoint_types=[type_key])
checkpoint = WorkflowCheckpoint(
workflow_name="test",
graph_signature_hash="hash",
state={"data": _AllowedTestState(name="allowed", value=99)},
)
await storage.save(checkpoint)
loaded = await storage.load(checkpoint.checkpoint_id)
assert isinstance(loaded.state["data"], _AllowedTestState)
assert loaded.state["data"].name == "allowed"
assert loaded.state["data"].value == 99
def test_restricted_unpickler_raises_pickle_error():
"""_RestrictedUnpickler.find_class raises pickle.UnpicklingError, not a framework exception."""
from agent_framework._workflows._checkpoint_encoding import _RestrictedUnpickler
pickled = pickle.dumps(os.getpid, protocol=pickle.HIGHEST_PROTOCOL)
unpickler = _RestrictedUnpickler(pickled, frozenset())
with pytest.raises(pickle.UnpicklingError, match="deserialization blocked"):
unpickler.load()
@@ -130,7 +130,17 @@ async def test_checkpoint_with_pending_request_info_events():
with tempfile.TemporaryDirectory() as temp_dir:
# Use file-based storage to test full serialization
storage = FileCheckpointStorage(temp_dir)
storage = FileCheckpointStorage(
temp_dir,
allowed_checkpoint_types=[
"tests.workflow.test_request_info_and_response:UserApprovalRequest",
"tests.workflow.test_request_info_and_response:CalculationRequest",
"tests.workflow.test_request_info_event_rehydrate:MockRequest",
"tests.workflow.test_request_info_event_rehydrate:SimpleApproval",
"tests.workflow.test_request_info_event_rehydrate:SlottedApproval",
"tests.workflow.test_request_info_event_rehydrate:TimedApproval",
],
)
# Create workflow with checkpointing enabled
executor = ApprovalRequiredExecutor(id="approval_executor")
@@ -225,7 +235,17 @@ async def test_checkpoint_restore_with_responses_does_not_reemit_handled_request
with tempfile.TemporaryDirectory() as temp_dir:
# Use file-based storage to test full serialization
storage = FileCheckpointStorage(temp_dir)
storage = FileCheckpointStorage(
temp_dir,
allowed_checkpoint_types=[
"tests.workflow.test_request_info_and_response:UserApprovalRequest",
"tests.workflow.test_request_info_and_response:CalculationRequest",
"tests.workflow.test_request_info_event_rehydrate:MockRequest",
"tests.workflow.test_request_info_event_rehydrate:SimpleApproval",
"tests.workflow.test_request_info_event_rehydrate:SlottedApproval",
"tests.workflow.test_request_info_event_rehydrate:TimedApproval",
],
)
# Create workflow with checkpointing enabled
executor = ApprovalRequiredExecutor(id="approval_executor")
@@ -288,7 +308,17 @@ async def test_checkpoint_restore_with_partial_responses_reemits_unhandled_reque
import tempfile
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
storage = FileCheckpointStorage(
temp_dir,
allowed_checkpoint_types=[
"tests.workflow.test_request_info_and_response:UserApprovalRequest",
"tests.workflow.test_request_info_and_response:CalculationRequest",
"tests.workflow.test_request_info_event_rehydrate:MockRequest",
"tests.workflow.test_request_info_event_rehydrate:SimpleApproval",
"tests.workflow.test_request_info_event_rehydrate:SlottedApproval",
"tests.workflow.test_request_info_event_rehydrate:TimedApproval",
],
)
# Create workflow with multiple requests
executor = MultiRequestExecutor(id="multi_executor")
+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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
"pyyaml>=6.0,<7.0",
]
+1 -1
View File
@@ -51,7 +51,7 @@ export default tseslint.config([
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
+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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"openai>=1.99.0,<3",
"opentelemetry-sdk>=1.39.0,<2",
"fastapi>=0.115.0,<0.133.1",
+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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"durabletask>=1.3.0,<2",
"durabletask-azuremanaged>=1.3.0,<2",
"python-dateutil>=2.8.0,<3",
@@ -50,6 +50,7 @@ def mock_orchestration_context(mock_entity_task: Mock) -> Mock:
"""Provide a mock orchestration context with call_entity configured."""
context = Mock()
context.call_entity = Mock(return_value=mock_entity_task)
context.new_uuid = Mock(return_value="test-uuid-1234")
return context
+3 -3
View File
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0"
version = "1.0.1"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0,<2",
"agent-framework-openai>=1.0.0,<2",
"agent-framework-core>=1.0.1,<2",
"agent-framework-openai>=1.0.1,<2",
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
"azure-ai-projects>=2.0.0,<3.0",
]
+3 -3
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.0b260402"
version = "1.0.0b260409"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0,<2",
"agent-framework-openai>=1.0.0,<2",
"agent-framework-core>=1.0.1,<2",
"agent-framework-openai>=1.0.1,<2",
"foundry-local-sdk>=0.5.1,<0.5.2",
]
@@ -7,7 +7,7 @@ import contextlib
import logging
import sys
from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence
from typing import Any, ClassVar, Generic, Literal, TypedDict, cast, overload
from typing import Any, ClassVar, Generic, Literal, TypedDict, overload
from agent_framework import (
AgentMiddlewareTypes,
@@ -29,20 +29,11 @@ from agent_framework._types import AgentRunInputs, normalize_tools
from agent_framework.exceptions import AgentException
try:
from copilot import CopilotClient, CopilotSession
from copilot import CopilotClient, CopilotSession, SubprocessConfig
from copilot.generated.session_events import PermissionRequest, SessionEvent, SessionEventType
from copilot.types import (
CopilotClientOptions,
MCPServerConfig,
MessageOptions,
PermissionRequestResult,
ResumeSessionConfig,
SessionConfig,
SystemMessageConfig,
ToolInvocation,
ToolResult,
)
from copilot.types import Tool as CopilotTool
from copilot.session import MCPServerConfig, PermissionRequestResult, SystemMessageConfig
from copilot.tools import Tool as CopilotTool
from copilot.tools import ToolInvocation, ToolResult
except ImportError as _copilot_import_error:
raise ImportError(
"GitHubCopilotAgent requires the 'github-copilot-sdk' package, which is only available on Python 3.11+. "
@@ -64,6 +55,14 @@ PermissionHandlerType = Callable[[PermissionRequest, dict[str, str]], Permission
logger = logging.getLogger("agent_framework.github_copilot")
def _deny_all_permissions(
_request: PermissionRequest,
_invocation: dict[str, str],
) -> PermissionRequestResult:
"""Default permission handler that denies all requests."""
return PermissionRequestResult()
class GitHubCopilotSettings(TypedDict, total=False):
"""GitHub Copilot model settings.
@@ -274,16 +273,13 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
return
if self._client is None:
client_options: CopilotClientOptions = {}
cli_path = self._settings.get("cli_path")
if cli_path:
client_options["cli_path"] = cli_path
cli_path = self._settings.get("cli_path") or None
log_level = self._settings.get("log_level") or None
log_level = self._settings.get("log_level")
subprocess_kwargs: dict[str, Any] = {"cli_path": cli_path}
if log_level:
client_options["log_level"] = log_level # type: ignore[typeddict-item]
self._client = CopilotClient(client_options if client_options else None)
subprocess_kwargs["log_level"] = log_level
self._client = CopilotClient(SubprocessConfig(**subprocess_kwargs))
try:
await self._client.start()
@@ -407,10 +403,9 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
prompt = "\n".join([message.text for message in context_messages])
if session_context.instructions:
prompt = "\n".join(session_context.instructions) + "\n" + prompt
message_options = cast(MessageOptions, {"prompt": prompt})
try:
response_event = await copilot_session.send_and_wait(message_options, timeout=timeout)
response_event = await copilot_session.send_and_wait(prompt, timeout=timeout)
except Exception as ex:
raise AgentException(f"GitHub Copilot request failed: {ex}") from ex
@@ -489,7 +484,6 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
prompt = "\n".join([message.text for message in context_messages])
if session_context.instructions:
prompt = "\n".join(session_context.instructions) + "\n" + prompt
message_options = cast(MessageOptions, {"prompt": prompt})
queue: asyncio.Queue[AgentResponseUpdate | Exception | None] = asyncio.Queue()
@@ -550,7 +544,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
unsubscribe = copilot_session.on(event_handler)
try:
await copilot_session.send(message_options)
await copilot_session.send(prompt)
while (item := await queue.get()) is not None:
if isinstance(item, Exception):
@@ -730,43 +724,35 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
raise RuntimeError("GitHub Copilot client not initialized. Call start() first.")
opts = runtime_options or {}
config: SessionConfig = {"streaming": streaming}
model = opts.get("model") or self._settings.get("model") or None
system_message = opts.get("system_message") or self._default_options.get("system_message") or None
permission_handler: PermissionHandlerType = (
opts.get("on_permission_request") or self._permission_handler or _deny_all_permissions
)
mcp_servers = opts.get("mcp_servers") or self._mcp_servers or None
tools = self._prepare_tools(self._tools) if self._tools else None
model = opts.get("model") or self._settings.get("model")
if model:
config["model"] = model # type: ignore[typeddict-item]
system_message = opts.get("system_message") or self._default_options.get("system_message")
if system_message:
config["system_message"] = system_message
if self._tools:
config["tools"] = self._prepare_tools(self._tools)
permission_handler = opts.get("on_permission_request") or self._permission_handler
if permission_handler:
config["on_permission_request"] = permission_handler
mcp_servers = opts.get("mcp_servers") or self._mcp_servers
if mcp_servers:
config["mcp_servers"] = mcp_servers
return await self._client.create_session(config)
return await self._client.create_session(
on_permission_request=permission_handler,
streaming=streaming,
model=model or None,
system_message=system_message or None,
tools=tools or None,
mcp_servers=mcp_servers or None,
)
async def _resume_session(self, session_id: str, streaming: bool) -> CopilotSession:
"""Resume an existing Copilot session by ID."""
if not self._client:
raise RuntimeError("GitHub Copilot client not initialized. Call start() first.")
config: ResumeSessionConfig = {"streaming": streaming}
permission_handler: PermissionHandlerType = self._permission_handler or _deny_all_permissions
tools = self._prepare_tools(self._tools) if self._tools else None
if self._tools:
config["tools"] = self._prepare_tools(self._tools)
if self._permission_handler:
config["on_permission_request"] = self._permission_handler
if self._mcp_servers:
config["mcp_servers"] = self._mcp_servers
return await self._client.resume_session(session_id, config)
return await self._client.resume_session(
session_id,
on_permission_request=permission_handler,
streaming=streaming,
tools=tools or None,
mcp_servers=self._mcp_servers or None,
)
@@ -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.10"
version = "1.0.0b260402"
version = "1.0.0b260409"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0,<2",
"github-copilot-sdk>=0.1.31,<0.1.33; python_version >= '3.11'",
"agent-framework-core>=1.0.1,<2",
"github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'",
]
[tool.uv]
@@ -102,3 +102,4 @@ interpreter = "posix"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -23,7 +23,7 @@ from agent_framework import (
)
from agent_framework.exceptions import AgentException
from copilot.generated.session_events import Data, ErrorClass, Result, SessionEvent, SessionEventType
from copilot.types import ToolInvocation, ToolResult
from copilot.tools import ToolInvocation, ToolResult
from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions
@@ -268,8 +268,8 @@ class TestGitHubCopilotAgentLifecycle:
await agent.start()
call_args = MockClient.call_args[0][0]
assert call_args["cli_path"] == "/custom/path"
assert call_args["log_level"] == "debug"
assert call_args.cli_path == "/custom/path"
assert call_args.log_level == "debug"
class TestGitHubCopilotAgentRun:
@@ -855,7 +855,13 @@ class TestGitHubCopilotAgentSessionManagement:
await agent.run("World", session=session)
mock_client.create_session.assert_called_once()
mock_client.resume_session.assert_called_once_with(mock_session.session_id, unittest.mock.ANY)
mock_client.resume_session.assert_called_once_with(
mock_session.session_id,
on_permission_request=unittest.mock.ANY,
streaming=unittest.mock.ANY,
tools=unittest.mock.ANY,
mcp_servers=unittest.mock.ANY,
)
async def test_session_config_includes_model(
self,
@@ -871,7 +877,7 @@ class TestGitHubCopilotAgentSessionManagement:
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args[0][0]
config = call_args.kwargs
assert config["model"] == "claude-sonnet-4"
async def test_session_config_includes_instructions(
@@ -889,7 +895,7 @@ class TestGitHubCopilotAgentSessionManagement:
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args[0][0]
config = call_args.kwargs
assert config["system_message"]["mode"] == "append"
assert config["system_message"]["content"] == "You are a helpful assistant."
@@ -914,7 +920,7 @@ class TestGitHubCopilotAgentSessionManagement:
)
call_args = mock_client.create_session.call_args
config = call_args[0][0]
config = call_args.kwargs
assert config["system_message"]["mode"] == "replace"
assert config["system_message"]["content"] == "Runtime instructions"
@@ -930,7 +936,7 @@ class TestGitHubCopilotAgentSessionManagement:
await agent._get_or_create_session(AgentSession(), streaming=True) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args[0][0]
config = call_args.kwargs
assert config["streaming"] is True
async def test_resume_session_with_existing_service_session_id(
@@ -958,7 +964,8 @@ class TestGitHubCopilotAgentSessionManagement:
mock_session: MagicMock,
) -> None:
"""Test that resumed session config includes tools and permission handler."""
from copilot.types import PermissionRequest, PermissionRequestResult
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
def my_handler(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
return PermissionRequestResult(kind="approved")
@@ -981,7 +988,7 @@ class TestGitHubCopilotAgentSessionManagement:
mock_client.resume_session.assert_called_once()
call_args = mock_client.resume_session.call_args
config = call_args[0][1]
config = call_args.kwargs
assert "tools" in config
assert "on_permission_request" in config
@@ -995,7 +1002,7 @@ class TestGitHubCopilotAgentMCPServers:
mock_session: MagicMock,
) -> None:
"""Test that mcp_servers are passed through to create_session config."""
from copilot.types import MCPServerConfig
from copilot.session import MCPServerConfig
mcp_servers: dict[str, MCPServerConfig] = {
"filesystem": {
@@ -1020,7 +1027,7 @@ class TestGitHubCopilotAgentMCPServers:
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args[0][0]
config = call_args.kwargs
assert "mcp_servers" in config
assert "filesystem" in config["mcp_servers"]
assert "remote" in config["mcp_servers"]
@@ -1033,7 +1040,7 @@ class TestGitHubCopilotAgentMCPServers:
mock_session: MagicMock,
) -> None:
"""Test that mcp_servers are passed through to resume_session config."""
from copilot.types import MCPServerConfig
from copilot.session import MCPServerConfig
mcp_servers: dict[str, MCPServerConfig] = {
"test-server": {
@@ -1057,7 +1064,7 @@ class TestGitHubCopilotAgentMCPServers:
mock_client.resume_session.assert_called_once()
call_args = mock_client.resume_session.call_args
config = call_args[0][1]
config = call_args.kwargs
assert "mcp_servers" in config
assert "test-server" in config["mcp_servers"]
@@ -1073,8 +1080,8 @@ class TestGitHubCopilotAgentMCPServers:
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args[0][0]
assert "mcp_servers" not in config
config = call_args.kwargs
assert config["mcp_servers"] is None
class TestGitHubCopilotAgentToolConversion:
@@ -1097,7 +1104,7 @@ class TestGitHubCopilotAgentToolConversion:
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args[0][0]
config = call_args.kwargs
assert "tools" in config
assert len(config["tools"]) == 1
assert config["tools"][0].name == "my_tool"
@@ -1120,7 +1127,7 @@ class TestGitHubCopilotAgentToolConversion:
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args[0][0]
config = call_args.kwargs
copilot_tool = config["tools"][0]
result = await copilot_tool.handler(ToolInvocation(arguments={"arg": "test"}))
@@ -1146,7 +1153,7 @@ class TestGitHubCopilotAgentToolConversion:
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args[0][0]
config = call_args.kwargs
copilot_tool = config["tools"][0]
result = await copilot_tool.handler(ToolInvocation(arguments={"arg": "test"}))
@@ -1173,7 +1180,7 @@ class TestGitHubCopilotAgentToolConversion:
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args[0][0]
config = call_args.kwargs
copilot_tool = config["tools"][0]
with pytest.raises((TypeError, AttributeError)):
@@ -1196,7 +1203,7 @@ class TestGitHubCopilotAgentToolConversion:
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args[0][0]
config = call_args.kwargs
copilot_tool = config["tools"][0]
result = await copilot_tool.handler(ToolInvocation(arguments={}))
@@ -1210,7 +1217,7 @@ class TestGitHubCopilotAgentToolConversion:
mock_client: MagicMock,
) -> None:
"""Test that CopilotTool instances are passed through as-is."""
from copilot.types import Tool as CopilotTool
from copilot.tools import Tool as CopilotTool
async def tool_handler(invocation: Any) -> Any:
return {"text_result_for_llm": "result", "result_type": "success"}
@@ -1234,7 +1241,7 @@ class TestGitHubCopilotAgentToolConversion:
) -> None:
"""Test that mixed tool types are handled correctly."""
from agent_framework import tool
from copilot.types import Tool as CopilotTool
from copilot.tools import Tool as CopilotTool
@tool(approval_mode="never_require")
def my_function(arg: str) -> str:
@@ -1317,10 +1324,11 @@ class TestGitHubCopilotAgentPermissions:
def test_permission_handler_set_when_provided(self) -> None:
"""Test that a handler is set when on_permission_request is provided."""
from copilot.types import PermissionRequest, PermissionRequestResult
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
def approve_shell(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
if request.get("kind") == "shell":
if request.kind == "shell":
return PermissionRequestResult(kind="approved")
return PermissionRequestResult(kind="denied-interactively-by-user")
@@ -1335,10 +1343,11 @@ class TestGitHubCopilotAgentPermissions:
mock_session: MagicMock,
) -> None:
"""Test that session config includes permission handler when provided."""
from copilot.types import PermissionRequest, PermissionRequestResult
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
def approve_shell_read(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
if request.get("kind") in ("shell", "read"):
if request.kind in ("shell", "read"):
return PermissionRequestResult(kind="approved")
return PermissionRequestResult(kind="denied-interactively-by-user")
@@ -1351,24 +1360,29 @@ class TestGitHubCopilotAgentPermissions:
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args[0][0]
config = call_args.kwargs
assert "on_permission_request" in config
assert config["on_permission_request"] is not None
async def test_session_config_excludes_permission_handler_when_not_set(
async def test_session_config_uses_deny_all_when_no_permission_handler_set(
self,
mock_client: MagicMock,
mock_session: MagicMock,
) -> None:
"""Test that session config does not include permission handler when not set."""
"""Test that session config uses deny-all handler when no permission handler is set.
In SDK 0.2.x, on_permission_request is required by create_session, so the agent
always falls back to _deny_all_permissions when no handler is provided.
"""
agent = GitHubCopilotAgent(client=mock_client)
await agent.start()
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args[0][0]
assert "on_permission_request" not in config
config = call_args.kwargs
assert "on_permission_request" in config
assert config["on_permission_request"] is not None
class SpyContextProvider(ContextProvider):
@@ -1454,7 +1468,7 @@ class TestGitHubCopilotAgentContextProviders:
session = agent.create_session()
await agent.run("Hello", session=session)
sent_prompt = mock_session.send_and_wait.call_args[0][0]["prompt"]
sent_prompt = mock_session.send_and_wait.call_args[0][0]
assert "Injected by spy provider" in sent_prompt
async def test_after_run_receives_response(
@@ -1547,7 +1561,7 @@ class TestGitHubCopilotAgentContextProviders:
async for _ in agent.run("Hello", stream=True, session=session):
pass
sent_prompt = mock_session.send.call_args[0][0]["prompt"]
sent_prompt = mock_session.send.call_args[0][0]
assert "Injected by spy provider" in sent_prompt
async def test_context_preserved_across_runs(
@@ -1608,7 +1622,7 @@ class TestGitHubCopilotAgentContextProviders:
session = agent.create_session()
await agent.run("Hello", session=session)
sent_prompt = mock_session.send_and_wait.call_args[0][0]["prompt"]
sent_prompt = mock_session.send_and_wait.call_args[0][0]
assert "History message" in sent_prompt
assert "Hello" in sent_prompt
@@ -1659,7 +1673,7 @@ class TestGitHubCopilotAgentContextProviders:
async for _ in agent.run("Hello", stream=True, session=session):
pass
sent_prompt = mock_session.send.call_args[0][0]["prompt"]
sent_prompt = mock_session.send.call_args[0][0]
assert "History message" in sent_prompt
assert "Hello" in sent_prompt
+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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
]
[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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"mem0ai>=1.0.0,<2",
]
+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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"ollama>=0.5.3,<0.5.4",
]
@@ -512,6 +512,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
if stream:
function_call_ids: dict[int, tuple[str, str]] = {}
seen_reasoning_delta_item_ids: set[str] = set()
validated_options: dict[str, Any] | None = None
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
@@ -530,6 +531,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
chunk,
options=validated_options,
function_call_ids=function_call_ids,
seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids,
)
except Exception as ex:
self._handle_request_error(ex)
@@ -1930,6 +1932,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
event: OpenAIResponseStreamEvent,
options: dict[str, Any],
function_call_ids: dict[int, tuple[str, str]],
seen_reasoning_delta_item_ids: set[str] | None = None,
) -> ChatResponseUpdate:
"""Parse an OpenAI Responses API streaming event into a ChatResponseUpdate."""
metadata: dict[str, Any] = {}
@@ -2008,6 +2011,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
contents.append(Content.from_text(text=event.delta, raw_representation=event))
metadata.update(self._get_metadata_from_response(event))
case "response.reasoning_text.delta":
if seen_reasoning_delta_item_ids is not None:
seen_reasoning_delta_item_ids.add(event.item_id)
contents.append(
Content.from_text_reasoning(
id=event.item_id,
@@ -2017,15 +2022,21 @@ class RawOpenAIChatClient( # type: ignore[misc]
)
metadata.update(self._get_metadata_from_response(event))
case "response.reasoning_text.done":
contents.append(
Content.from_text_reasoning(
id=event.item_id,
text=event.text,
raw_representation=event,
# Done event carries the full accumulated text. Emit it only as a
# fallback when no delta was already received for this item_id, to
# avoid duplicating content in downstream accumulators (e.g. ag-ui).
if seen_reasoning_delta_item_ids is None or event.item_id not in seen_reasoning_delta_item_ids:
contents.append(
Content.from_text_reasoning(
id=event.item_id,
text=event.text,
raw_representation=event,
)
)
)
metadata.update(self._get_metadata_from_response(event))
case "response.reasoning_summary_text.delta":
if seen_reasoning_delta_item_ids is not None:
seen_reasoning_delta_item_ids.add(event.item_id)
contents.append(
Content.from_text_reasoning(
id=event.item_id,
@@ -2035,13 +2046,17 @@ class RawOpenAIChatClient( # type: ignore[misc]
)
metadata.update(self._get_metadata_from_response(event))
case "response.reasoning_summary_text.done":
contents.append(
Content.from_text_reasoning(
id=event.item_id,
text=event.text,
raw_representation=event,
# Done event carries the full accumulated text. Emit it only as a
# fallback when no delta was already received for this item_id, to
# avoid duplicating content in downstream accumulators (e.g. ag-ui).
if seen_reasoning_delta_item_ids is None or event.item_id not in seen_reasoning_delta_item_ids:
contents.append(
Content.from_text_reasoning(
id=event.item_id,
text=event.text,
raw_representation=event,
)
)
)
metadata.update(self._get_metadata_from_response(event))
case "response.code_interpreter_call_code.delta":
call_id = getattr(event, "call_id", None) or getattr(event, "id", None) or event.item_id
@@ -2065,6 +2080,9 @@ class RawOpenAIChatClient( # type: ignore[misc]
)
)
metadata.update(self._get_metadata_from_response(event))
# NOTE: Unlike reasoning done events, code_interpreter done events always
# emit content because downstream consumers do not accumulate
# code_interpreter deltas the same way.
case "response.code_interpreter_call_code.done":
call_id = getattr(event, "call_id", None) or getattr(event, "id", None) or event.item_id
ci_additional_properties = {
+2 -2
View File
@@ -4,7 +4,7 @@ description = "OpenAI integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0"
version = "1.0.1"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"openai>=1.99.0,<3",
]
@@ -2808,11 +2808,12 @@ def test_streaming_reasoning_text_delta_event() -> None:
mock_metadata.assert_called_once_with(event)
def test_streaming_reasoning_text_done_event() -> None:
"""Test reasoning text done event creates TextReasoningContent with complete text."""
def test_streaming_reasoning_text_done_event_skipped_after_deltas() -> None:
"""Test reasoning text done event does not emit content when deltas were already received."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
chat_options = ChatOptions()
function_call_ids: dict[int, tuple[str, str]] = {}
seen_reasoning_delta_item_ids: set[str] = {"reasoning_456"}
event = ResponseReasoningTextDoneEvent(
type="response.reasoning_text.done",
@@ -2824,12 +2825,40 @@ def test_streaming_reasoning_text_done_event() -> None:
)
with patch.object(client, "_get_metadata_from_response", return_value={"test": "data"}) as mock_metadata:
response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore
response = client._parse_chunk_from_openai(
event, chat_options, function_call_ids, seen_reasoning_delta_item_ids
) # type: ignore
assert len(response.contents) == 0
mock_metadata.assert_called_once_with(event)
assert response.additional_properties == {"test": "data"}
def test_streaming_reasoning_text_done_event_fallback_without_deltas() -> None:
"""Test reasoning text done event emits content when no deltas were received for this item_id."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
chat_options = ChatOptions()
function_call_ids: dict[int, tuple[str, str]] = {}
seen_reasoning_delta_item_ids: set[str] = set()
event = ResponseReasoningTextDoneEvent(
type="response.reasoning_text.done",
content_index=0,
item_id="reasoning_456",
output_index=0,
sequence_number=2,
text="complete reasoning",
)
with patch.object(client, "_get_metadata_from_response", return_value={"test": "data"}) as mock_metadata:
response = client._parse_chunk_from_openai(
event, chat_options, function_call_ids, seen_reasoning_delta_item_ids
) # type: ignore
assert len(response.contents) == 1
assert response.contents[0].type == "text_reasoning"
assert response.contents[0].id == "reasoning_456"
assert response.contents[0].text == "complete reasoning"
assert response.contents[0].raw_representation == event
mock_metadata.assert_called_once_with(event)
assert response.additional_properties == {"test": "data"}
@@ -2859,11 +2888,12 @@ def test_streaming_reasoning_summary_text_delta_event() -> None:
mock_metadata.assert_called_once_with(event)
def test_streaming_reasoning_summary_text_done_event() -> None:
"""Test reasoning summary text done event creates TextReasoningContent with complete text."""
def test_streaming_reasoning_summary_text_done_event_skipped_after_deltas() -> None:
"""Test reasoning summary text done event does not emit content when deltas were already received."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
chat_options = ChatOptions()
function_call_ids: dict[int, tuple[str, str]] = {}
seen_reasoning_delta_item_ids: set[str] = {"summary_012"}
event = ResponseReasoningSummaryTextDoneEvent(
type="response.reasoning_summary_text.done",
@@ -2875,16 +2905,94 @@ def test_streaming_reasoning_summary_text_done_event() -> None:
)
with patch.object(client, "_get_metadata_from_response", return_value={"custom": "meta"}) as mock_metadata:
response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore
response = client._parse_chunk_from_openai(
event, chat_options, function_call_ids, seen_reasoning_delta_item_ids
) # type: ignore
assert len(response.contents) == 0
mock_metadata.assert_called_once_with(event)
assert response.additional_properties == {"custom": "meta"}
def test_streaming_reasoning_summary_text_done_event_fallback_without_deltas() -> None:
"""Test reasoning summary text done event emits content when no deltas were received for this item_id."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
chat_options = ChatOptions()
function_call_ids: dict[int, tuple[str, str]] = {}
seen_reasoning_delta_item_ids: set[str] = set()
event = ResponseReasoningSummaryTextDoneEvent(
type="response.reasoning_summary_text.done",
item_id="summary_012",
output_index=0,
sequence_number=4,
summary_index=0,
text="complete summary",
)
with patch.object(client, "_get_metadata_from_response", return_value={"custom": "meta"}) as mock_metadata:
response = client._parse_chunk_from_openai(
event, chat_options, function_call_ids, seen_reasoning_delta_item_ids
) # type: ignore
assert len(response.contents) == 1
assert response.contents[0].type == "text_reasoning"
assert response.contents[0].id == "summary_012"
assert response.contents[0].text == "complete summary"
assert response.contents[0].raw_representation == event
mock_metadata.assert_called_once_with(event)
assert response.additional_properties == {"custom": "meta"}
def test_streaming_reasoning_deltas_then_done_no_duplication() -> None:
"""Sending delta events followed by a done event produces content only from deltas."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
chat_options = ChatOptions()
function_call_ids: dict[int, tuple[str, str]] = {}
seen_reasoning_delta_item_ids: set[str] = set()
item_id = "reasoning_seq"
delta1 = ResponseReasoningTextDeltaEvent(
type="response.reasoning_text.delta",
content_index=0,
item_id=item_id,
output_index=0,
sequence_number=1,
delta="Hello ",
)
delta2 = ResponseReasoningTextDeltaEvent(
type="response.reasoning_text.delta",
content_index=0,
item_id=item_id,
output_index=0,
sequence_number=2,
delta="world",
)
done = ResponseReasoningTextDoneEvent(
type="response.reasoning_text.done",
content_index=0,
item_id=item_id,
output_index=0,
sequence_number=3,
text="Hello world",
)
all_contents = []
with patch.object(client, "_get_metadata_from_response", return_value={}):
for event in [delta1, delta2, done]:
response = client._parse_chunk_from_openai(
event,
chat_options,
function_call_ids,
seen_reasoning_delta_item_ids, # type: ignore
)
all_contents.extend(response.contents)
assert len(all_contents) == 2
assert all_contents[0].text == "Hello "
assert all_contents[1].text == "world"
assert "".join(c.text for c in all_contents) == "Hello world"
def test_streaming_reasoning_events_preserve_metadata() -> None:
"""Test that reasoning events preserve metadata like regular text events."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
@@ -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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
]
[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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"azure-core>=1.30.0,<2",
"httpx>=0.27.0,<0.29",
]
+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.0b260402"
version = "1.0.0b260409"
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.0,<2",
"agent-framework-core>=1.0.1,<2",
"redis>=6.4.0,<7.2.1",
"redisvl>=0.11.0,<0.16",
"numpy>=2.2.6,<3"
+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.0"
version = "1.0.1"
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.0",
"agent-framework-core[all]==1.0.1",
]
[dependency-groups]
@@ -39,7 +39,7 @@ dev = [
"pytest-retry==1.7.0",
"mypy==1.20.0",
"pyright==1.1.408",
"mcp[ws]==1.26.0",
"mcp[ws]==1.27.0",
"opentelemetry-sdk==1.40.0",
#tasks
"poethepoet==0.42.1",
@@ -9,6 +9,9 @@ These samples demonstrate different approaches to managing conversation history
| [`suspend_resume_session.py`](suspend_resume_session.py) | Suspend and resume conversation sessions, comparing service-managed sessions (Azure AI Foundry) with in-memory sessions (OpenAI). |
| [`custom_history_provider.py`](custom_history_provider.py) | Implement a custom history provider by extending `HistoryProvider`, enabling conversation persistence in your preferred storage backend. |
| [`cosmos_history_provider.py`](cosmos_history_provider.py) | Use Azure Cosmos DB as a history provider for durable conversation storage with `CosmosHistoryProvider`. |
| [`cosmos_history_provider_conversation_persistence.py`](cosmos_history_provider_conversation_persistence.py) | Persist and resume conversations across application restarts using `CosmosHistoryProvider` — serialize session state, restore it, and continue with full Cosmos DB history. |
| [`cosmos_history_provider_messages.py`](cosmos_history_provider_messages.py) | Direct message history operations — retrieve stored messages as a transcript, clear session history, and verify data deletion. |
| [`cosmos_history_provider_sessions.py`](cosmos_history_provider_sessions.py) | Multi-session and multi-tenant management — per-tenant session isolation, `list_sessions()` to enumerate, switch between sessions, and resume specific conversations. |
| [`redis_history_provider.py`](redis_history_provider.py) | Use Redis as a history provider for persistent conversation history storage across sessions. |
## Prerequisites
@@ -22,7 +25,7 @@ These samples demonstrate different approaches to managing conversation history
**For `custom_history_provider.py`:**
- `OPENAI_API_KEY`: Your OpenAI API key
**For `cosmos_history_provider.py`:**
**For Cosmos DB samples (`cosmos_history_provider*.py`):**
- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint
- `FOUNDRY_MODEL`: The Foundry model deployment name
- `AZURE_COSMOS_ENDPOINT`: Your Azure Cosmos DB account endpoint
@@ -0,0 +1,163 @@
# Copyright (c) Microsoft. All rights reserved.
# ruff: noqa: T201
import asyncio
import os
from agent_framework import Agent, AgentSession
from agent_framework.foundry import FoundryChatClient
from agent_framework_azure_cosmos import CosmosHistoryProvider
from azure.identity.aio import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file.
load_dotenv()
"""
This sample demonstrates persisting and resuming conversations across application
restarts using CosmosHistoryProvider as the persistent backend.
Key components:
- Phase 1: Run a conversation and serialize the session with session.to_dict()
- Phase 2: Simulate an app restart create new provider and agent instances,
restore the session with AgentSession.from_dict(), and continue the conversation
- Cosmos DB reloads the full message history, so the agent remembers everything
Environment variables:
FOUNDRY_PROJECT_ENDPOINT
FOUNDRY_MODEL
AZURE_COSMOS_ENDPOINT
AZURE_COSMOS_DATABASE_NAME
AZURE_COSMOS_CONTAINER_NAME
Optional:
AZURE_COSMOS_KEY
"""
async def main() -> None:
"""Run the conversation persistence sample."""
project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT")
model = os.getenv("FOUNDRY_MODEL")
cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT")
cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME")
cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME")
cosmos_key = os.getenv("AZURE_COSMOS_KEY")
if (
not project_endpoint
or not model
or not cosmos_endpoint
or not cosmos_database_name
or not cosmos_container_name
):
print(
"Please set FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL, "
"AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME."
)
return
# ── Phase 1: Initial conversation ──
print("=== Phase 1: Initial conversation ===\n")
async with (
AzureCliCredential() as credential,
CosmosHistoryProvider(
endpoint=cosmos_endpoint,
database_name=cosmos_database_name,
container_name=cosmos_container_name,
credential=cosmos_key or credential,
) as history_provider,
Agent(
client=FoundryChatClient(
project_endpoint=project_endpoint,
model=model,
credential=credential,
),
name="PersistentAgent",
instructions="You are a helpful assistant that remembers prior turns.",
context_providers=[history_provider],
default_options={"store": False},
) as agent,
):
session = agent.create_session()
response1 = await agent.run("My name is Ada. I'm building a distributed database in Rust.", session=session)
print("User: My name is Ada. I'm building a distributed database in Rust.")
print(f"Assistant: {response1.text}\n")
response2 = await agent.run("The hardest part is the consensus algorithm.", session=session)
print("User: The hardest part is the consensus algorithm.")
print(f"Assistant: {response2.text}\n")
serialized_session = session.to_dict()
print(f"Session serialized. Session ID: {session.session_id}")
# ── Phase 2: Simulate app restart ──
print("\n=== Phase 2: Resuming after 'restart' ===\n")
async with (
AzureCliCredential() as credential,
CosmosHistoryProvider(
endpoint=cosmos_endpoint,
database_name=cosmos_database_name,
container_name=cosmos_container_name,
credential=cosmos_key or credential,
) as history_provider,
Agent(
client=FoundryChatClient(
project_endpoint=project_endpoint,
model=model,
credential=credential,
),
name="PersistentAgent",
instructions="You are a helpful assistant that remembers prior turns.",
context_providers=[history_provider],
default_options={"store": False},
) as agent,
):
restored_session = AgentSession.from_dict(serialized_session)
print(f"Session restored. Session ID: {restored_session.session_id}\n")
response3 = await agent.run("What was I working on and what was the challenge?", session=restored_session)
print("User: What was I working on and what was the challenge?")
print(f"Assistant: {response3.text}\n")
messages = await history_provider.get_messages(restored_session.session_id)
print(f"Messages stored in Cosmos DB: {len(messages)}")
for i, msg in enumerate(messages, 1):
print(f" {i}. [{msg.role}] {msg.text[:80]}...")
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
=== Phase 1: Initial conversation ===
User: My name is Ada. I'm building a distributed database in Rust.
Assistant: That sounds like a great project, Ada! Rust is an excellent choice for ...
User: The hardest part is the consensus algorithm.
Assistant: Consensus algorithms can be tricky! Are you looking at Raft, Paxos, or ...
Session serialized. Session ID: <session-uuid>
=== Phase 2: Resuming after 'restart' ===
Session restored. Session ID: <session-uuid>
User: What was I working on and what was the challenge?
Assistant: You told me you're building a distributed database in Rust and that the hardest
part is the consensus algorithm.
Messages stored in Cosmos DB: 6
1. [user] My name is Ada. I'm building a distributed database in Rust....
2. [assistant] That sounds like a great project, Ada! Rust is an excellent ch...
3. [user] The hardest part is the consensus algorithm....
4. [assistant] Consensus algorithms can be tricky! Are you looking at Raft, Pa...
5. [user] What was I working on and what was the challenge?...
6. [assistant] You told me you're building a distributed database in Rust and ...
"""
@@ -0,0 +1,157 @@
# Copyright (c) Microsoft. All rights reserved.
# ruff: noqa: T201
import asyncio
import os
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework_azure_cosmos import CosmosHistoryProvider
from azure.identity.aio import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file.
load_dotenv()
"""
This sample demonstrates direct message history operations using
CosmosHistoryProvider retrieving, displaying, and clearing stored messages.
Key components:
- get_messages(session_id): Retrieve all stored messages as a chat transcript
- clear(session_id): Delete all messages for a session (e.g., GDPR compliance)
- Verifying that history is empty after clearing
- Running a new conversation in the same session after clearing
Environment variables:
FOUNDRY_PROJECT_ENDPOINT
FOUNDRY_MODEL
AZURE_COSMOS_ENDPOINT
AZURE_COSMOS_DATABASE_NAME
AZURE_COSMOS_CONTAINER_NAME
Optional:
AZURE_COSMOS_KEY
"""
async def main() -> None:
"""Run the messages history sample."""
project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT")
model = os.getenv("FOUNDRY_MODEL")
cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT")
cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME")
cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME")
cosmos_key = os.getenv("AZURE_COSMOS_KEY")
if (
not project_endpoint
or not model
or not cosmos_endpoint
or not cosmos_database_name
or not cosmos_container_name
):
print(
"Please set FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL, "
"AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME."
)
return
async with (
AzureCliCredential() as credential,
CosmosHistoryProvider(
endpoint=cosmos_endpoint,
database_name=cosmos_database_name,
container_name=cosmos_container_name,
credential=cosmos_key or credential,
) as history_provider,
Agent(
client=FoundryChatClient(
project_endpoint=project_endpoint,
model=model,
credential=credential,
),
name="HistoryAgent",
instructions="You are a helpful assistant that remembers prior turns.",
context_providers=[history_provider],
default_options={"store": False},
) as agent,
):
session = agent.create_session()
session_id = session.session_id
# 1. Have a multi-turn conversation.
print("=== Building a conversation ===\n")
queries = [
"Hi! My favorite programming language is Python.",
"I also enjoy hiking in the mountains on weekends.",
"What do you know about me so far?",
]
for query in queries:
response = await agent.run(query, session=session)
print(f"User: {query}")
print(f"Assistant: {response.text}\n")
# 2. Retrieve and display the full message history as a transcript.
print("=== Chat transcript from Cosmos DB ===\n")
messages = await history_provider.get_messages(session_id)
print(f"Total messages stored: {len(messages)}\n")
for i, msg in enumerate(messages, 1):
print(f" {i}. [{msg.role}] {msg.text[:100]}")
# 3. Clear the session history.
print("\n=== Clearing session history ===\n")
await history_provider.clear(session_id)
print(f"Cleared all messages for session: {session_id}")
# 4. Verify history is empty.
remaining = await history_provider.get_messages(session_id)
print(f"Messages after clear: {len(remaining)}")
# 5. Start a fresh conversation in the same session — agent has no memory.
print("\n=== Fresh conversation (same session, no memory) ===\n")
response = await agent.run("What do you know about me?", session=session)
print("User: What do you know about me?")
print(f"Assistant: {response.text}")
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
=== Building a conversation ===
User: Hi! My favorite programming language is Python.
Assistant: That's great! Python is a wonderful language. What do you like most about it?
User: I also enjoy hiking in the mountains on weekends.
Assistant: Hiking sounds lovely! Do you have a favorite trail or mountain range?
User: What do you know about me so far?
Assistant: You love Python as your favorite programming language and enjoy hiking in the mountains on weekends.
=== Chat transcript from Cosmos DB ===
Total messages stored: 6
1. [user] Hi! My favorite programming language is Python.
2. [assistant] That's great! Python is a wonderful language. What do you like most about it?
3. [user] I also enjoy hiking in the mountains on weekends.
4. [assistant] Hiking sounds lovely! Do you have a favorite trail or mountain range?
5. [user] What do you know about me so far?
6. [assistant] You love Python as your favorite programming language and enjoy hiking ...
=== Clearing session history ===
Cleared all messages for session: <session-uuid>
Messages after clear: 0
=== Fresh conversation (same session, no memory) ===
User: What do you know about me?
Assistant: I don't have any information about you yet. Feel free to share anything you'd like!
"""
@@ -0,0 +1,193 @@
# Copyright (c) Microsoft. All rights reserved.
# ruff: noqa: T201
import asyncio
import os
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework_azure_cosmos import CosmosHistoryProvider
from azure.identity.aio import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file.
load_dotenv()
"""
This sample demonstrates multi-session and multi-tenant management using
CosmosHistoryProvider. Each tenant (user) gets isolated conversation sessions
stored in the same Cosmos DB container, partitioned by session_id.
Key components:
- Per-tenant session isolation using prefixed session IDs
- list_sessions(): Enumerate all stored sessions across tenants
- Switching between sessions for different users
- Resuming a specific user's session — verifying data isolation
Environment variables:
FOUNDRY_PROJECT_ENDPOINT
FOUNDRY_MODEL
AZURE_COSMOS_ENDPOINT
AZURE_COSMOS_DATABASE_NAME
AZURE_COSMOS_CONTAINER_NAME
Optional:
AZURE_COSMOS_KEY
"""
async def main() -> None:
"""Run the session management sample."""
project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT")
model = os.getenv("FOUNDRY_MODEL")
cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT")
cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME")
cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME")
cosmos_key = os.getenv("AZURE_COSMOS_KEY")
if (
not project_endpoint
or not model
or not cosmos_endpoint
or not cosmos_database_name
or not cosmos_container_name
):
print(
"Please set FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL, "
"AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME."
)
return
async with (
AzureCliCredential() as credential,
CosmosHistoryProvider(
endpoint=cosmos_endpoint,
database_name=cosmos_database_name,
container_name=cosmos_container_name,
credential=cosmos_key or credential,
) as history_provider,
Agent(
client=FoundryChatClient(
project_endpoint=project_endpoint,
model=model,
credential=credential,
),
name="MultiTenantAgent",
instructions="You are a helpful assistant that remembers prior turns.",
context_providers=[history_provider],
default_options={"store": False},
) as agent,
):
# 1. Tenant "alice" starts a conversation about travel.
print("=== Tenant: Alice — Travel conversation ===\n")
alice_session = agent.create_session(session_id="tenant-alice-session-1")
response = await agent.run("Hi! I'm planning a trip to Italy. I love Renaissance art.", session=alice_session)
print("Alice: I'm planning a trip to Italy. I love Renaissance art.")
print(f"Assistant: {response.text}\n")
response = await agent.run("Which museums should I visit in Florence?", session=alice_session)
print("Alice: Which museums should I visit in Florence?")
print(f"Assistant: {response.text}\n")
# 2. Tenant "bob" starts a separate conversation about cooking.
print("=== Tenant: Bob — Cooking conversation ===\n")
bob_session = agent.create_session(session_id="tenant-bob-session-1")
response = await agent.run("Hey! I'm learning to cook Thai food. I just made pad thai.", session=bob_session)
print("Bob: I'm learning to cook Thai food. I just made pad thai.")
print(f"Assistant: {response.text}\n")
response = await agent.run("What Thai dish should I try next?", session=bob_session)
print("Bob: What Thai dish should I try next?")
print(f"Assistant: {response.text}\n")
# 3. List all sessions stored in Cosmos DB.
print("=== Listing all sessions ===\n")
sessions = await history_provider.list_sessions()
print(f"Found {len(sessions)} session(s):")
for sid in sessions:
print(f" - {sid}")
# 4. Resume Alice's session — verify she gets her travel context back.
print("\n=== Resuming Alice's session ===\n")
alice_resumed = agent.create_session(session_id="tenant-alice-session-1")
response = await agent.run("What were we discussing?", session=alice_resumed)
print("Alice: What were we discussing?")
print(f"Assistant: {response.text}\n")
# 5. Resume Bob's session — verify he gets his cooking context back.
print("=== Resuming Bob's session ===\n")
bob_resumed = agent.create_session(session_id="tenant-bob-session-1")
response = await agent.run("What was the last dish I mentioned?", session=bob_resumed)
print("Bob: What was the last dish I mentioned?")
print(f"Assistant: {response.text}\n")
# 6. Show per-session message counts.
print("=== Per-session message counts ===\n")
alice_messages = await history_provider.get_messages("tenant-alice-session-1")
bob_messages = await history_provider.get_messages("tenant-bob-session-1")
print(f"Alice's session: {len(alice_messages)} messages")
print(f"Bob's session: {len(bob_messages)} messages")
# 7. Clean up: clear both sessions.
print("\n=== Cleaning up ===\n")
await history_provider.clear("tenant-alice-session-1")
await history_provider.clear("tenant-bob-session-1")
print("Cleared Alice's and Bob's sessions.")
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
=== Tenant: Alice Travel conversation ===
Alice: I'm planning a trip to Italy. I love Renaissance art.
Assistant: Italy is a dream for Renaissance art lovers! Florence, Rome, and Venice ...
Alice: Which museums should I visit in Florence?
Assistant: In Florence, the Uffizi Gallery is a must it has Botticelli's Birth of Venus ...
=== Tenant: Bob Cooking conversation ===
Bob: I'm learning to cook Thai food. I just made pad thai.
Assistant: Pad thai is a great start! How did it turn out?
Bob: What Thai dish should I try next?
Assistant: I'd suggest trying green curry or tom yum soup — both are classic Thai dishes ...
=== Listing all sessions ===
Found 2 session(s):
- tenant-alice-session-1
- tenant-bob-session-1
=== Resuming Alice's session ===
Alice: What were we discussing?
Assistant: We were discussing your trip to Italy and your love for Renaissance art ...
=== Resuming Bob's session ===
Bob: What was the last dish I mentioned?
Assistant: You mentioned pad thai it was the dish you just made!
=== Per-session message counts ===
Alice's session: 6 messages
Bob's session: 6 messages
=== Cleaning up ===
Cleared Alice's and Bob's sessions.
"""
@@ -20,7 +20,7 @@ from typing import Annotated
from agent_framework import tool
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.types import PermissionRequestResult
from copilot.session import PermissionRequestResult
from dotenv import load_dotenv
from pydantic import Field
@@ -16,7 +16,7 @@ import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.types import PermissionRequestResult
from copilot.session import PermissionRequestResult
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
@@ -16,7 +16,7 @@ import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.types import MCPServerConfig, PermissionRequestResult
from copilot.session import MCPServerConfig, PermissionRequestResult
from dotenv import load_dotenv
# Load environment variables from .env file
@@ -22,7 +22,7 @@ import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.types import PermissionRequestResult
from copilot.session import PermissionRequestResult
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
@@ -15,7 +15,7 @@ from typing import Annotated
from agent_framework import tool
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.types import PermissionRequestResult
from copilot.session import PermissionRequestResult
from pydantic import Field
@@ -15,7 +15,7 @@ import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.types import PermissionRequestResult
from copilot.session import PermissionRequestResult
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
@@ -15,7 +15,7 @@ import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.types import PermissionRequestResult
from copilot.session import PermissionRequestResult
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
+2
View File
@@ -52,6 +52,8 @@ Once comfortable with these, explore the rest of the samples below.
| Checkpointed Sub-Workflow | [checkpoint/sub_workflow_checkpoint.py](./checkpoint/sub_workflow_checkpoint.py) | Save and resume a sub-workflow that pauses for human approval |
| Handoff + Tool Approval Resume | [orchestrations/handoff_with_tool_approval_checkpoint_resume.py](./orchestrations/handoff_with_tool_approval_checkpoint_resume.py) | Handoff workflow that captures tool-call approvals in checkpoints and resumes with human decisions |
| Workflow as Agent Checkpoint | [checkpoint/workflow_as_agent_checkpoint.py](./checkpoint/workflow_as_agent_checkpoint.py) | Enable checkpointing when using workflow.as_agent() with checkpoint_storage parameter |
| Cosmos DB Checkpoint Storage | [checkpoint/cosmos_workflow_checkpointing.py](./checkpoint/cosmos_workflow_checkpointing.py) | Use `CosmosCheckpointStorage` for durable workflow checkpointing backed by Azure Cosmos DB NoSQL |
| Cosmos DB + Foundry Checkpoint | [checkpoint/cosmos_workflow_checkpointing_foundry.py](./checkpoint/cosmos_workflow_checkpointing_foundry.py) | Multi-agent workflow using `FoundryChatClient` with `CosmosCheckpointStorage` for durable pause/resume |
### composition
@@ -0,0 +1,201 @@
# Copyright (c) Microsoft. All rights reserved.
# ruff: noqa: T201
"""Sample: Workflow Checkpointing with Cosmos DB NoSQL.
Purpose:
This sample shows how to use Azure Cosmos DB NoSQL as a persistent checkpoint
storage backend for workflows, enabling durable pause-and-resume across
process restarts.
What you learn:
- How to configure CosmosCheckpointStorage for workflow checkpointing
- How to run a workflow that automatically persists checkpoints to Cosmos DB
- How to resume a workflow from a Cosmos DB checkpoint
- How to list and inspect available checkpoints
Prerequisites:
- An Azure Cosmos DB account (or local emulator)
- Environment variables set (see below)
Environment variables:
AZURE_COSMOS_ENDPOINT - Cosmos DB account endpoint
AZURE_COSMOS_DATABASE_NAME - Database name
AZURE_COSMOS_CONTAINER_NAME - Container name for checkpoints
Optional:
AZURE_COSMOS_KEY - Account key (if not using Azure credentials)
"""
import asyncio
import os
import sys
from dataclasses import dataclass
from typing import Any
from agent_framework import (
Executor,
WorkflowBuilder,
WorkflowCheckpoint,
WorkflowContext,
handler,
)
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
from agent_framework_azure_cosmos import CosmosCheckpointStorage
@dataclass
class ComputeTask:
"""Task containing the list of numbers remaining to be processed."""
remaining_numbers: list[int]
class StartExecutor(Executor):
"""Initiates the workflow by providing the upper limit."""
@handler
async def start(self, upper_limit: int, ctx: WorkflowContext[ComputeTask]) -> None:
"""Start the workflow with numbers up to the given limit."""
print(f"StartExecutor: Starting computation up to {upper_limit}")
await ctx.send_message(ComputeTask(remaining_numbers=list(range(1, upper_limit + 1))))
class WorkerExecutor(Executor):
"""Processes numbers and manages executor state for checkpointing."""
def __init__(self, id: str) -> None:
"""Initialize the worker executor."""
super().__init__(id=id)
self._results: dict[int, list[tuple[int, int]]] = {}
@handler
async def compute(
self,
task: ComputeTask,
ctx: WorkflowContext[ComputeTask, dict[int, list[tuple[int, int]]]],
) -> None:
"""Process the next number, computing its factor pairs."""
next_number = task.remaining_numbers.pop(0)
print(f"WorkerExecutor: Processing {next_number}")
pairs: list[tuple[int, int]] = []
for i in range(1, next_number):
if next_number % i == 0:
pairs.append((i, next_number // i))
self._results[next_number] = pairs
if not task.remaining_numbers:
await ctx.yield_output(self._results)
else:
await ctx.send_message(task)
@override
async def on_checkpoint_save(self) -> dict[str, Any]:
return {"results": self._results}
@override
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
self._results = state.get("results", {})
async def main() -> None:
"""Run the workflow checkpointing sample with Cosmos DB."""
cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT")
cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME")
cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME")
cosmos_key = os.getenv("AZURE_COSMOS_KEY")
if not cosmos_endpoint or not cosmos_database_name or not cosmos_container_name:
print("Please set AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME.")
return
# Authentication: supports both managed identity/RBAC and key-based auth.
# When AZURE_COSMOS_KEY is set, key-based auth is used.
# Otherwise, falls back to DefaultAzureCredential (properly closed via async with).
if cosmos_key:
async with CosmosCheckpointStorage(
endpoint=cosmos_endpoint,
credential=cosmos_key,
database_name=cosmos_database_name,
container_name=cosmos_container_name,
) as checkpoint_storage:
await _run_workflow(checkpoint_storage)
else:
from azure.identity.aio import DefaultAzureCredential
async with (
DefaultAzureCredential() as credential,
CosmosCheckpointStorage(
endpoint=cosmos_endpoint,
credential=credential,
database_name=cosmos_database_name,
container_name=cosmos_container_name,
) as checkpoint_storage,
):
await _run_workflow(checkpoint_storage)
async def _run_workflow(checkpoint_storage: CosmosCheckpointStorage) -> None:
"""Build and run the workflow with Cosmos DB checkpointing."""
start = StartExecutor(id="start")
worker = WorkerExecutor(id="worker")
workflow_builder = (
WorkflowBuilder(start_executor=start, checkpoint_storage=checkpoint_storage)
.add_edge(start, worker)
.add_edge(worker, worker)
)
# --- First run: execute the workflow ---
print("\n=== First Run ===\n")
workflow = workflow_builder.build()
output = None
async for event in workflow.run(message=8, stream=True):
if event.type == "output":
output = event.data
print(f"Factor pairs computed: {output}")
# List checkpoints saved in Cosmos DB
checkpoint_ids = await checkpoint_storage.list_checkpoint_ids(
workflow_name=workflow.name,
)
print(f"\nCheckpoints in Cosmos DB: {len(checkpoint_ids)}")
for cid in checkpoint_ids:
print(f" - {cid}")
# Get the latest checkpoint
latest: WorkflowCheckpoint | None = await checkpoint_storage.get_latest(
workflow_name=workflow.name,
)
if latest is None:
print("No checkpoint found to resume from.")
return
print(f"\nLatest checkpoint: {latest.checkpoint_id}")
print(f" iteration_count: {latest.iteration_count}")
print(f" timestamp: {latest.timestamp}")
# --- Second run: resume from the latest checkpoint ---
print("\n=== Resuming from Checkpoint ===\n")
workflow2 = workflow_builder.build()
output2 = None
async for event in workflow2.run(checkpoint_id=latest.checkpoint_id, stream=True):
if event.type == "output":
output2 = event.data
if output2:
print(f"Resumed workflow produced: {output2}")
else:
print("Resumed workflow completed (no remaining work — already finished).")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,141 @@
# Copyright (c) Microsoft. All rights reserved.
# ruff: noqa: T201
"""Sample: Workflow Checkpointing with Cosmos DB and Azure AI Foundry.
Purpose:
This sample demonstrates how to use CosmosCheckpointStorage with agents built
on Azure AI Foundry (via FoundryChatClient). It shows a multi-agent
workflow where checkpoint state is persisted to Cosmos DB, enabling durable
pause-and-resume across process restarts.
What you learn:
- How to wire CosmosCheckpointStorage with FoundryChatClient agents
- How to combine session history with workflow checkpointing
- How to resume a workflow-as-agent from a Cosmos DB checkpoint
Key concepts:
- AgentSession: Maintains conversation history across agent invocations
- CosmosCheckpointStorage: Persists workflow execution state in Cosmos DB
- These are complementary: sessions track conversation, checkpoints track workflow state
Environment variables:
FOUNDRY_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
FOUNDRY_MODEL - Model deployment name
AZURE_COSMOS_ENDPOINT - Cosmos DB account endpoint
AZURE_COSMOS_DATABASE_NAME - Database name
AZURE_COSMOS_CONTAINER_NAME - Container name for checkpoints
Optional:
AZURE_COSMOS_KEY - Account key (if not using Azure credentials)
"""
import asyncio
import os
from typing import Any
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import SequentialBuilder
from agent_framework_azure_cosmos import CosmosCheckpointStorage
from azure.identity.aio import AzureCliCredential
from dotenv import load_dotenv
load_dotenv()
async def main() -> None:
"""Run the Azure AI Foundry + Cosmos DB checkpointing sample."""
project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT")
model = os.getenv("FOUNDRY_MODEL")
cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT")
cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME")
cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME")
cosmos_key = os.getenv("AZURE_COSMOS_KEY")
if not project_endpoint or not model:
print("Please set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL.")
return
if not cosmos_endpoint or not cosmos_database_name or not cosmos_container_name:
print("Please set AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME.")
return
# Use a single AzureCliCredential for both Cosmos and Foundry,
# properly closed via async context manager.
async with AzureCliCredential() as azure_credential:
cosmos_credential: Any = cosmos_key if cosmos_key else azure_credential
async with CosmosCheckpointStorage(
endpoint=cosmos_endpoint,
credential=cosmos_credential,
database_name=cosmos_database_name,
container_name=cosmos_container_name,
) as checkpoint_storage:
# Create Azure AI Foundry agents
client = FoundryChatClient(
project_endpoint=project_endpoint,
model=model,
credential=azure_credential,
)
assistant = Agent(
name="assistant",
instructions="You are a helpful assistant. Keep responses brief.",
client=client,
)
reviewer = Agent(
name="reviewer",
instructions="You are a reviewer. Provide a one-sentence summary of the assistant's response.",
client=client,
)
# Build a sequential workflow and wrap it as an agent
workflow = SequentialBuilder(participants=[assistant, reviewer]).build()
agent = workflow.as_agent(name="FoundryCheckpointedAgent")
# --- First run: execute with Cosmos DB checkpointing ---
print("=== First Run ===\n")
session = agent.create_session()
query = "What are the benefits of renewable energy?"
print(f"User: {query}")
response = await agent.run(query, session=session, checkpoint_storage=checkpoint_storage)
for msg in response.messages:
speaker = msg.author_name or msg.role
print(f"[{speaker}]: {msg.text}")
# Show checkpoints persisted in Cosmos DB
checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
print(f"\nCheckpoints in Cosmos DB: {len(checkpoints)}")
for i, cp in enumerate(checkpoints[:5], 1):
print(f" {i}. {cp.checkpoint_id} (iteration={cp.iteration_count})")
# --- Second run: continue conversation with checkpoint history ---
print("\n=== Second Run (continuing conversation) ===\n")
query2 = "Can you elaborate on the economic benefits?"
print(f"User: {query2}")
response2 = await agent.run(query2, session=session, checkpoint_storage=checkpoint_storage)
for msg in response2.messages:
speaker = msg.author_name or msg.role
print(f"[{speaker}]: {msg.text}")
# Show total checkpoints
all_checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
print(f"\nTotal checkpoints after two runs: {len(all_checkpoints)}")
# Get latest checkpoint
latest = await checkpoint_storage.get_latest(workflow_name=workflow.name)
if latest:
print(f"Latest checkpoint: {latest.checkpoint_id}")
print(f" iteration_count: {latest.iteration_count}")
print(f" timestamp: {latest.timestamp}")
if __name__ == "__main__":
asyncio.run(main())
@@ -17,7 +17,7 @@
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^7.3.1"
"vite": "^7.3.2"
}
},
"node_modules/@babel/code-frame": {
@@ -1776,9 +1776,9 @@
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -18,6 +18,6 @@
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^7.3.1"
"vite": "^7.3.2"
}
}
@@ -15,7 +15,7 @@ from agent_framework import (
)
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.types import PermissionRequestResult
from copilot.session import PermissionRequestResult
from pydantic import BaseModel
from typing_extensions import Never
+87 -87
View File
@@ -94,7 +94,7 @@ wheels = [
[[package]]
name = "agent-framework"
version = "1.0.0"
version = "1.0.1"
source = { virtual = "." }
dependencies = [
{ name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -127,7 +127,7 @@ requires-dist = [{ name = "agent-framework-core", extras = ["all"], editable = "
[package.metadata.requires-dev]
dev = [
{ name = "flit", specifier = "==3.12.0" },
{ name = "mcp", extras = ["ws"], specifier = "==1.26.0" },
{ name = "mcp", extras = ["ws"], specifier = "==1.27.0" },
{ name = "mypy", specifier = "==1.20.0" },
{ name = "opentelemetry-sdk", specifier = "==1.40.0" },
{ name = "poethepoet", specifier = "==0.42.1" },
@@ -147,7 +147,7 @@ dev = [
[[package]]
name = "agent-framework-a2a"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/a2a" }
dependencies = [
{ name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -162,7 +162,7 @@ requires-dist = [
[[package]]
name = "agent-framework-ag-ui"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/ag-ui" }
dependencies = [
{ name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -190,7 +190,7 @@ provides-extras = ["dev"]
[[package]]
name = "agent-framework-anthropic"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/anthropic" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -205,7 +205,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azure-ai-search"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/azure-ai-search" }
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-cosmos"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/azure-cosmos" }
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-azurefunctions"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/azurefunctions" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -257,7 +257,7 @@ dev = []
[[package]]
name = "agent-framework-bedrock"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/bedrock" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -274,7 +274,7 @@ requires-dist = [
[[package]]
name = "agent-framework-chatkit"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/chatkit" }
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-claude"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/claude" }
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-copilotstudio"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/copilotstudio" }
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-core"
version = "1.0.0"
version = "1.0.1"
source = { editable = "packages/core" }
dependencies = [
{ name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -391,7 +391,7 @@ provides-extras = ["all"]
[[package]]
name = "agent-framework-declarative"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/declarative" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -416,7 +416,7 @@ dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }]
[[package]]
name = "agent-framework-devui"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/devui" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -454,7 +454,7 @@ provides-extras = ["dev", "all"]
[[package]]
name = "agent-framework-durabletask"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/durabletask" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -481,7 +481,7 @@ dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260402" }]
[[package]]
name = "agent-framework-foundry"
version = "1.0.0"
version = "1.0.1"
source = { editable = "packages/foundry" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -521,7 +521,7 @@ requires-dist = [
[[package]]
name = "agent-framework-foundry-local"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/foundry_local" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -538,7 +538,7 @@ requires-dist = [
[[package]]
name = "agent-framework-github-copilot"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/github_copilot" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -548,12 +548,12 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "agent-framework-core", editable = "packages/core" },
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.1.31,<0.1.33" },
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.2.1,<=0.2.1" },
]
[[package]]
name = "agent-framework-lab"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/lab" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -634,7 +634,7 @@ dev = [
[[package]]
name = "agent-framework-mem0"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/mem0" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -649,7 +649,7 @@ requires-dist = [
[[package]]
name = "agent-framework-ollama"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/ollama" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -664,7 +664,7 @@ requires-dist = [
[[package]]
name = "agent-framework-openai"
version = "1.0.0"
version = "1.0.1"
source = { editable = "packages/openai" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -679,7 +679,7 @@ requires-dist = [
[[package]]
name = "agent-framework-orchestrations"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/orchestrations" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -690,7 +690,7 @@ requires-dist = [{ name = "agent-framework-core", editable = "packages/core" }]
[[package]]
name = "agent-framework-purview"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/purview" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -707,7 +707,7 @@ requires-dist = [
[[package]]
name = "agent-framework-redis"
version = "1.0.0b260402"
version = "1.0.0b260409"
source = { editable = "packages/redis" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -1822,62 +1822,62 @@ wheels = [
[[package]]
name = "cryptography"
version = "46.0.6"
version = "46.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" },
{ name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" }
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" },
{ url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" },
{ url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" },
{ url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" },
{ url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" },
{ url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" },
{ url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" },
{ url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" },
{ url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" },
{ url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" },
{ url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" },
{ url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" },
{ url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" },
{ url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" },
{ url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" },
{ url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" },
{ url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" },
{ url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" },
{ url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" },
{ url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" },
{ url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" },
{ url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" },
{ url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" },
{ url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" },
{ url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" },
{ url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" },
{ url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" },
{ url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" },
{ url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" },
{ url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" },
{ url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" },
{ url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" },
{ url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" },
{ url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" },
{ url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" },
{ url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" },
{ url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" },
{ url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" },
{ url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" },
{ url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" },
{ url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" },
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
{ url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" },
{ url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" },
{ url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" },
{ url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" },
]
[[package]]
@@ -2373,19 +2373,19 @@ wheels = [
[[package]]
name = "github-copilot-sdk"
version = "0.1.32"
version = "0.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" },
{ name = "python-dateutil", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/67/ebd002c14fe7d2640d0fff47a0b29fdb21ed239b597afa2d2c6f6cfebb0b/github_copilot_sdk-0.1.32-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d97bc39fbd4b51e0aea3405299da1e643838ddbf6bff284f688a2d8c20d82ff8", size = 58576987, upload-time = "2026-03-07T15:28:24.062Z" },
{ url = "https://files.pythonhosted.org/packages/a5/50/add440f61e19f5b7e6989c89c5cefcb14c23f06627621e7c3a15a1f75e5d/github_copilot_sdk-0.1.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8098592f34e7ee7decbcbb7615c7eb924471e65a3e4d0d93bc49b0d112f8ec51", size = 55328145, upload-time = "2026-03-07T15:28:28.395Z" },
{ url = "https://files.pythonhosted.org/packages/f9/b8/c3ca0678b21d8a0dd8fe3aa8fad4b7ec5f22cbe9d5fb3a11f82df4f40578/github_copilot_sdk-0.1.32-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c20cae4bec3584ce007a65a363216a1f98a71428a3ca3b76622f9e556307eed2", size = 61456678, upload-time = "2026-03-07T15:28:32.646Z" },
{ url = "https://files.pythonhosted.org/packages/21/5c/bdfe177353f88d44da9600c3ec478e2b0df7a838901947b168e869ba5ad7/github_copilot_sdk-0.1.32-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:0941fd445e97a9b13fb713086c4a8c09c20ec8c7ab854cf009bd7cc213488999", size = 59641536, upload-time = "2026-03-07T15:28:36.977Z" },
{ url = "https://files.pythonhosted.org/packages/1e/d0/2f3a07c74ecd24587b8f7d26729738f73e63f3341bf4bdc9eb2bb73ddaaf/github_copilot_sdk-0.1.32-py3-none-win_amd64.whl", hash = "sha256:37a82ff0908e01512052b69df4aa498332fa5769999635425015ed43cd850622", size = 54077464, upload-time = "2026-03-07T15:28:41.34Z" },
{ url = "https://files.pythonhosted.org/packages/1c/76/292088d6ccf2daf8bcb8a94b22b4f16005a6772087896f1b43c4f0d5edaa/github_copilot_sdk-0.1.32-py3-none-win_arm64.whl", hash = "sha256:3199c99604e8d393b1d60905be80b84da44e70d16d30b92e2ae9b92814cdc4ae", size = 52083845, upload-time = "2026-03-07T15:28:45.092Z" },
{ url = "https://files.pythonhosted.org/packages/67/41/76a9d50d7600bf8d26c659dc113be62e4e56e00a5cbfd544e1b5b200f45c/github_copilot_sdk-0.2.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:c0823150f3b73431f04caee43d1dbafac22ae7e8bd1fc83727ee8363089ee038", size = 61076141, upload-time = "2026-04-03T20:18:22.062Z" },
{ url = "https://files.pythonhosted.org/packages/04/04/d2e8bf4587c4da270ccb9cbd5ab8a2c4b41217c2bf04a43904be8a27ae20/github_copilot_sdk-0.2.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ef7ff68eb8960515e1a2e199ac0ffb9a17cd3325266461e6edd7290e43dcf012", size = 57838464, upload-time = "2026-04-03T20:18:26.042Z" },
{ url = "https://files.pythonhosted.org/packages/78/8b/cc8ee46724bd9fdfd6afe855a043c8403ed6884c5f3a55a9737780810396/github_copilot_sdk-0.2.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:890f7124e3b147532a1ac6c8d5f66421ea37757b2b9990d7967f3f147a2f533a", size = 63940155, upload-time = "2026-04-03T20:18:30.297Z" },
{ url = "https://files.pythonhosted.org/packages/cf/ee/facf04e22e42d4bdd4fe3d356f3a51180a6ea769ae2ac306d0897f9bf9d9/github_copilot_sdk-0.2.1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6502be0b9ececacbda671835e5f61c7aaa906c6b8657ee252cad6cc8335cac8e", size = 62130538, upload-time = "2026-04-03T20:18:34.061Z" },
{ url = "https://files.pythonhosted.org/packages/3f/1c/8b105f14bf61d1d304a00ac29460cb0d4e7406ceb89907d5a7b41a72fe85/github_copilot_sdk-0.2.1-py3-none-win_amd64.whl", hash = "sha256:8275ca8e387e6b29bc5155a3c02a0eb3d035c6bc7b1896253eb0d469f2385790", size = 56547331, upload-time = "2026-04-03T20:18:37.859Z" },
{ url = "https://files.pythonhosted.org/packages/cb/c1/0ce319d2f618e9bc89f275e60b1920f4587eb0218bba6cbb84283dc7a7f3/github_copilot_sdk-0.2.1-py3-none-win_arm64.whl", hash = "sha256:1f9b59b7c41f31be416bf20818f58e25b6adc76f6d17357653fde6fbab662606", size = 54499549, upload-time = "2026-04-03T20:18:41.77Z" },
]
[[package]]
@@ -3530,7 +3530,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.26.0"
version = "1.27.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -3548,9 +3548,9 @@ dependencies = [
{ name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
{ url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" },
]
[package.optional-dependencies]