mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feature/python-foundry-hosted-agent-vnext
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
---
|
||||
name: python-feature-lifecycle
|
||||
description: >
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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}"
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+163
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
+4
-4
@@ -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
|
||||
|
||||
|
||||
Generated
+87
-87
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user