diff --git a/docs/decisions/0016-python-context-middleware.md b/docs/decisions/0016-python-context-middleware.md new file mode 100644 index 0000000000..63a4014e18 --- /dev/null +++ b/docs/decisions/0016-python-context-middleware.md @@ -0,0 +1,2575 @@ +--- +# These are optional elements. Feel free to remove any of them. +status: accepted +contact: eavanvalkenburg +date: 2026-02-09 +deciders: eavanvalkenburg, markwallace-microsoft, sphenry, alliscode, johanst, brettcannon, westey-m +consulted: taochenosu, moonbox3, dmytrostruk, giles17 +--- + +# Unifying Context Management with ContextPlugin + +## Context and Problem Statement + +The Agent Framework Python SDK currently has multiple abstractions for managing conversation context: + +| Concept | Purpose | Location | +|---------|---------|----------| +| `ContextProvider` | Injects instructions, messages, and tools before/after invocations | `_memory.py` | +| `ChatMessageStore` | Stores and retrieves conversation history | `_threads.py` | +| `AgentThread` | Manages conversation state and coordinates storage | `_threads.py` | + +This creates cognitive overhead for developers doing "Context Engineering" - the practice of dynamically managing what context (history, RAG results, instructions, tools) is sent to the model. Users must understand: +- When to use `ContextProvider` vs `ChatMessageStore` +- How `AgentThread` coordinates between them +- Different lifecycle hooks (`invoking()`, `invoked()`, `thread_created()`) + +**How can we simplify context management into a single, composable pattern that handles all context-related concerns?** + +## Decision Drivers + +- **Simplicity**: Reduce the number of concepts users must learn +- **Composability**: Enable multiple context sources to be combined flexibly +- **Consistency**: Follow existing patterns in the framework +- **Flexibility**: Support both stateless and session-specific context engineering +- **Attribution**: Enable tracking which provider added which messages/tools +- **Zero-config**: Simple use cases should work without configuration + +## Related Issues + +This ADR addresses the following issues from the parent issue [#3575](https://github.com/microsoft/agent-framework/issues/3575): + +| Issue | Title | How Addressed | +|-------|-------|---------------| +| [#3587](https://github.com/microsoft/agent-framework/issues/3587) | Rename AgentThread to AgentSession | ✅ `AgentThread` → `AgentSession` (clean break, no alias). See [§7 Renaming](#7-renaming-thread--session). | +| [#3588](https://github.com/microsoft/agent-framework/issues/3588) | Add get_new_session, get_session_by_id methods | ✅ `agent.create_session()` and `agent.get_session(service_session_id)`. See [§9 Session Management Methods](#9-session-management-methods). | +| [#3589](https://github.com/microsoft/agent-framework/issues/3589) | Move serialize method into the agent | ✅ No longer needed. `AgentSession` provides `to_dict()`/`from_dict()` for serialization. Providers write JSON-serializable values to `session.state`. See [§8 Serialization](#8-session-serializationdeserialization). | +| [#3590](https://github.com/microsoft/agent-framework/issues/3590) | Design orthogonal ChatMessageStore for service vs local | ✅ `HistoryProvider` works orthogonally: configure `load_messages=False` when service manages storage. Multiple history providers allowed. See [§3 Unified Storage](#3-unified-storage). | +| [#3601](https://github.com/microsoft/agent-framework/issues/3601) | Rename ChatMessageStore to ChatHistoryProvider | 🔒 **Closed** - Superseded by this ADR. `ChatMessageStore` removed entirely, replaced by `StorageContextMiddleware`. | + +## Current State Analysis + +### ContextProvider (Current) + +```python +class ContextProvider(ABC): + async def thread_created(self, thread_id: str | None) -> None: + """Called when a new thread is created.""" + pass + + async def invoked( + self, + request_messages: ChatMessage | Sequence[ChatMessage], + response_messages: ChatMessage | Sequence[ChatMessage] | None = None, + invoke_exception: Exception | None = None, + **kwargs: Any, + ) -> None: + """Called after the agent receives a response.""" + pass + + @abstractmethod + async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context: + """Called before model invocation. Returns Context with instructions, messages, tools.""" + pass +``` + +**Limitations:** +- No clear way to compose multiple providers +- No source attribution for debugging + +### ChatMessageStore (Current) + +```python +class ChatMessageStoreProtocol(Protocol): + async def list_messages(self) -> list[ChatMessage]: ... + async def add_messages(self, messages: Sequence[ChatMessage]) -> None: ... + async def serialize(self, **kwargs: Any) -> dict[str, Any]: ... + @classmethod + async def deserialize(cls, state: MutableMapping[str, Any], **kwargs: Any) -> "ChatMessageStoreProtocol": ... +``` + +**Limitations:** +- Only handles message storage, no context injection +- Separate concept from `ContextProvider` +- No control over what gets stored (RAG context vs user messages) +- No control over which get's executed first, the Context Provider or the ChatMessageStore (ordering ambiguity), this is controlled by the framework + +### AgentThread (Current) + +```python +class AgentThread: + def __init__( + self, + *, + service_thread_id: str | None = None, + message_store: ChatMessageStoreProtocol | None = None, + context_provider: ContextProvider | None = None, + ) -> None: ... +``` + +**Limitations:** +- Coordinates storage and context separately +- Only one `context_provider` and one `ChatMessageStore` (no composition) + +## Key Design Considerations + +The following key decisions shape the ContextProvider design: + +| # | Decision | Rationale | +|---|----------|-----------| +| 1 | **Agent vs Session Ownership** | Agent owns provider instances; Session owns state as mutable dict. Providers shared across sessions, state isolated per session. | +| 2 | **Execution Pattern** | **ContextProvider** with `before_run`/`after_run` methods (hooks pattern). Simpler mental model than wrapper/onion pattern. | +| 3 | **State Management** | Whole state dict (`dict[str, Any]`) passed to each plugin. Dict is mutable, so no return value needed. | +| 4 | **Default Storage at Runtime** | `InMemoryHistoryProvider` auto-added when no providers configured and `options.conversation_id` is set or `options.store` is True. Evaluated at runtime so users can modify pipeline first. | +| 5 | **Multiple Storage Allowed** | Warn at session creation if multiple or zero history providers have `load_messages=True` (likely misconfiguration). | +| 6 | **Single Storage Class** | One `HistoryProvider` configured for memory/audit/evaluation - no separate classes. | +| 7 | **Mandatory source_id** | Required parameter forces explicit naming for attribution in `context_messages` dict. | +| 8 | **Explicit Load Behavior** | `load_messages: bool = True` - explicit configuration with no automatic detection. For history, `before_run` is skipped entirely when `load_messages=False`. | +| 9 | **Dict-based Context** | `context_messages: dict[str, list[ChatMessage]]` keyed by source_id maintains order and enables filtering. Messages can have an `attribution` marker in `additional_properties` for external filtering scenarios. | +| 10 | **Selective Storage** | `store_context_messages` and `store_context_from` control what gets persisted from other plugins. | +| 11 | **Tool Attribution** | `extend_tools()` automatically sets `tool.metadata["context_source"] = source_id`. | +| 12 | **Clean Break** | Remove `AgentThread`, old `ContextProvider`, `ChatMessageStore` completely; replace with new `ContextProvider` (hooks pattern), `HistoryProvider`, `AgentSession`. PR1 uses temporary names (`_ContextProviderBase`, `_HistoryProviderBase`) to coexist with old types; PR2 renames to final names after old types are removed. No compatibility shims (preview). | +| 13 | **Plugin Ordering** | User-defined order; storage sees prior plugins (pre-processing) or all plugins (post-processing). | +| 14 | **Session Serialization via `to_dict`/`from_dict`** | `AgentSession` provides `to_dict()` and `from_dict()` for round-tripping. Providers must ensure values they write to `session.state` are JSON-serializable. No `serialize()`/`restore()` methods on providers. | +| 15 | **Session Management Methods** | `agent.create_session()` and `agent.get_session(service_session_id)` for clear lifecycle management. | + +## Considered Options + +### Option 1: Status Quo - Keep Separate Abstractions + +Keep `ContextProvider`, `ChatMessageStore`, and `AgentThread` as separate concepts. With updated naming and minor improvements, but no fundamental changes to the API or execution model. + +**Pros:** +- No migration required +- Familiar to existing users +- Each concept has a focused responsibility +- Existing documentation and examples remain valid + +**Cons:** +- Cognitive overhead: three concepts to learn for context management +- No composability: only one `ContextProvider` per thread +- Inconsistent with middleware pattern used elsewhere in the framework +- `invoking()`/`invoked()` split makes related pre/post logic harder to follow +- No source attribution for debugging which provider added which context +- `ChatMessageStore` and `ContextProvider` overlap conceptually but are separate APIs + +### Option 2: ContextMiddleware - Wrapper Pattern + +Create a unified `ContextMiddleware` base class that uses the onion/wrapper pattern (like existing `AgentMiddleware`, `ChatMiddleware`) to handle all context-related concerns. This includes a `StorageContextMiddleware` subclass specifically for history persistence. + +**Class hierarchy:** +- `ContextMiddleware` (base) - for general context injection (RAG, instructions, tools) +- `StorageContextMiddleware(ContextMiddleware)` - for conversation history storage (in-memory, Redis, Cosmos, etc.) + +```python +class ContextMiddleware(ABC): + def __init__(self, source_id: str, *, session_id: str | None = None): + self.source_id = source_id + self.session_id = session_id + + @abstractmethod + async def process(self, context: SessionContext, next: ContextMiddlewareNext) -> None: + """Wrap the context flow - modify before next(), process after.""" + # Pre-processing: add context, modify messages + context.add_messages(self.source_id, [...]) + + await next(context) # Call next middleware or terminal handler + + # Post-processing: log, store, react to response + await self.store(context.response_messages) +``` + +**Pros:** +- Single concept for all context engineering +- Familiar pattern from other middleware in the framework (`AgentMiddleware`, `ChatMiddleware`) +- Natural composition via pipeline with clear execution order +- Pre/post processing in one method keeps related logic together +- Source attribution built-in +- Full control over the invocation chain (can short-circuit, retry, wrap with try/catch) +- Exception handling naturally scoped to the middleware that caused it + +**Cons:** +- Forgetting `await next(context)` silently breaks the chain +- Stack depth increases with each middleware layer +- Harder to implement middleware that only needs pre OR post processing +- Streaming is more complicated + +### Option 3: ContextHooks - Pre/Post Pattern + +Create a `ContextHooks` base class with explicit `before_run()` and `after_run()` methods, diverging from the wrapper pattern used by middleware. This includes a `HistoryContextHooks` subclass specifically for history persistence. + +**Class hierarchy:** +- `ContextHooks` (base) - for general context injection (RAG, instructions, tools) +- `HistoryContextHooks(ContextHooks)` - for conversation history storage (in-memory, Redis, Cosmos, etc.) + +```python +class ContextHooks(ABC): + def __init__(self, source_id: str, *, session_id: str | None = None): + self.source_id = source_id + self.session_id = session_id + + async def before_run(self, context: SessionContext) -> None: + """Called before model invocation. Modify context here.""" + pass + + async def after_run(self, context: SessionContext) -> None: + """Called after model invocation. React to response here.""" + pass +``` + +> **Note on naming:** Both the class name (`ContextHooks`) and method names (`before_run`/`after_run`) are open for discussion. The names used throughout this ADR are placeholders pending a final decision. See alternative naming options below. + +**Alternative class naming options:** + +| Name | Rationale | +|------|-----------| +| `ContextHooks` | Emphasizes the hook-based nature, familiar from React/Git hooks | +| `ContextHandler` | Generic term for something that handles context events | +| `ContextInterceptor` | Common in Java/Spring, emphasizes interception points | +| `ContextProcessor` | Emphasizes processing at defined stages | +| `ContextPlugin` | Emphasizes extensibility, familiar from build tools | +| `SessionHooks` | Ties to `AgentSession`, emphasizes session lifecycle | +| `InvokeHooks` | Directly describes what's being hooked (the invoke call) | + +**Alternative method naming options:** + +| before / after | Rationale | +|----------------|-----------| +| `before_run` / `after_run` | Matches `agent.run()` terminology | +| `before_invoke` / `after_invoke` | Emphasizes invocation lifecycle | +| `invoking` / `invoked` | Matches current Python `ContextProvider` and .NET naming | +| `pre_invoke` / `post_invoke` | Common prefix convention | +| `on_invoking` / `on_invoked` | Event-style naming | +| `prepare` / `finalize` | Action-oriented naming | + +**Example usage:** + +```python +class RAGHooks(ContextHooks): + async def before_run(self, context: SessionContext) -> None: + docs = await self.retrieve_documents(context.input_messages[-1].text) + context.add_messages(self.source_id, [ChatMessage.system(f"Context: {docs}")]) + + async def after_run(self, context: SessionContext) -> None: + await self.store_interaction(context.input_messages, context.response_messages) + + +# Pipeline execution is linear, not nested: +# 1. hook1.before_run(context) +# 2. hook2.before_run(context) +# 3. +# 4. hook2.after_run(context) # Reverse order for symmetry +# 5. hook1.after_run(context) + +agent = ChatAgent( + chat_client=client, + context_hooks=[ + InMemoryStorageHooks("memory"), + RAGHooks("rag"), + ] +) +``` + +**Pros:** +- Simpler mental model: "before" runs before, "after" runs after - no nesting to understand +- Clearer separation between what this does vs what Agent Middleware can do. +- Impossible to forget calling `next()` - the framework handles sequencing +- Easier to implement hooks that only need one phase (just override one method) +- Lower cognitive overhead for developers new to middleware patterns +- Clearer separation of concerns: pre-processing logic separate from post-processing +- Easier to test: no need to mock `next` callable, just call methods directly +- Flatter stack traces when debugging +- More similar to the current `ContextProvider` API (`invoking`/`invoked`), easing migration +- Explicit about what happens when: no hidden control flow + +**Cons:** +- Diverges from the wrapper pattern used by `AgentMiddleware` and `ChatMiddleware` +- Less powerful: cannot short-circuit the chain or implement retry logic (to mitigate, AgentMiddleware still exists and can be used for this scenario.) +- No "around" advice: cannot wrap invocation in try/catch or timing block +- Exception in `before_run` may leave state inconsistent if no cleanup in `after_run` +- Two methods to implement instead of one (though both are optional) +- Harder to share state between before/after (need instance variables, use state) +- Cannot control whether subsequent hooks run (no early termination) + +## Detailed Design + +This section covers the design decisions that apply to both approaches. Where the approaches differ, both are shown. + +### 1. Execution Pattern + +The core difference between the two options is the execution model: + +**Option 2 - Middleware (Wrapper/Onion):** +```python +class ContextMiddleware(ABC): + @abstractmethod + async def process(self, context: SessionContext, next: ContextMiddlewareNext) -> None: + """Abstract — subclasses must implement the full pre/invoke/post flow.""" + ... + +# Subclass must implement process(): +class RAGMiddleware(ContextMiddleware): + async def process(self, context, next): + context.add_messages(self.source_id, [...]) # Pre-processing + await next(context) # Call next middleware + await self.store(context.response_messages) # Post-processing +``` + +**Option 3 - Hooks (Linear):** +```python +class ContextHooks: + async def before_run(self, context: SessionContext) -> None: + """Default no-op. Override to add pre-invocation logic.""" + pass + + async def after_run(self, context: SessionContext) -> None: + """Default no-op. Override to add post-invocation logic.""" + pass + +# Subclass overrides only the hooks it needs: +class RAGHooks(ContextHooks): + async def before_run(self, context): + context.add_messages(self.source_id, [...]) + + async def after_run(self, context): + await self.store(context.response_messages) +``` + +**Execution flow comparison:** + +``` +Middleware (Wrapper/Onion): Hooks (Linear): +┌──────────────────────────┐ ┌─────────────────────────┐ +│ middleware1.process() │ │ hook1.before_run() │ +│ ┌───────────────────┐ │ │ hook2.before_run() │ +│ │ middleware2.process│ │ │ hook3.before_run() │ +│ │ ┌─────────────┐ │ │ ├─────────────────────────┤ +│ │ │ invoke │ │ │ vs │ │ +│ │ └─────────────┘ │ │ ├─────────────────────────┤ +│ │ (post-processing) │ │ │ hook3.after_run() │ +│ └───────────────────┘ │ │ hook2.after_run() │ +│ (post-processing) │ │ hook1.after_run() │ +└──────────────────────────┘ └─────────────────────────┘ +``` + +### 2. Agent vs Session Ownership + +Where provider instances live (agent-level vs session-level) is an orthogonal decision that applies to both execution patterns. Each combination has different consequences: + +| | **Agent owns instances** | **Session owns instances** | +|--|--------------------------|---------------------------| +| **Middleware (Option 2)** | Agent holds the middleware chain; all sessions share it. Per-session state must be externalized (e.g., passed via context). Pipeline ordering is fixed across sessions. | Each session gets its own middleware chain (via factories). Middleware can hold per-session state internally. Requires factory pattern to construct per-session instances. | +| **Hooks (Option 3)** | Agent holds provider instances; all sessions share them. Per-session state lives in `session.state` dict. Simple flat iteration, no pipeline to construct. | Each session gets its own provider instances (via factories). Providers can hold per-session state internally. Adds factory complexity without the pipeline benefit. | + +**Key trade-offs:** + +- **Agent-owned + Middleware**: The nested call chain makes it awkward to share — each `process()` call captures `next` in its closure, which may carry session-specific assumptions. Externalizing state is harder when it's interleaved with the wrapping flow. +- **Session-owned + Middleware**: Natural fit — each session gets its own chain with isolated state. But requires factories and heavier sessions. +- **Agent-owned + Hooks**: Natural fit — `before_run`/`after_run` are stateless calls that receive everything they need as parameters (`session`, `context`, `state`). No pipeline to construct, lightweight sessions. +- **Session-owned + Hooks**: Works but adds factory overhead without clear benefit — hooks don't need per-instance state since `session.state` handles isolation. + +### 3. Unified Storage + +Instead of separate `ChatMessageStore`, storage is a subclass of the base context type: + +**Middleware:** +```python +class StorageContextMiddleware(ContextMiddleware): + def __init__( + self, + source_id: str, + *, + load_messages: bool = True, + store_inputs: bool = True, + store_responses: bool = True, + store_context_messages: bool = False, + store_context_from: Sequence[str] | None = None, + ): ... +``` + +**Hooks:** +```python +class StorageContextHooks(ContextHooks): + def __init__( + self, + source_id: str, + *, + load_messages: bool = True, + store_inputs: bool = True, + store_responses: bool = True, + store_context_messages: bool = False, + store_context_from: Sequence[str] | None = None, + ): ... +``` + +**Load Behavior:** +- `load_messages=True` (default): Load messages from storage in `before_run`/pre-processing +- `load_messages=False`: Skip loading; for `StorageContextHooks`, the `before_run` hook is not called at all + +**Comparison to Current:** +| Aspect | ChatMessageStore (Current) | Storage Middleware/Hooks (New) | +|--------|---------------------------|------------------------------| +| Load messages | Always via `list_messages()` | Configurable `load_messages` flag | +| Store messages | Always via `add_messages()` | Configurable `store_*` flags | +| What to store | All messages | Selective: inputs, responses, context | +| Injected context | Not supported | `store_context_messages=True/False` + `store_context_from=[source_ids]` for filtering | + +### 4. Source Attribution via `source_id` + +Both approaches require a `source_id` for attribution (identical implementation): + +```python +class SessionContext: + context_messages: dict[str, list[ChatMessage]] + + def add_messages(self, source_id: str, messages: Sequence[ChatMessage]) -> None: + if source_id not in self.context_messages: + self.context_messages[source_id] = [] + self.context_messages[source_id].extend(messages) + + def get_messages( + self, + sources: Sequence[str] | None = None, + exclude_sources: Sequence[str] | None = None, + ) -> list[ChatMessage]: + """Get messages, optionally filtered by source.""" + ... +``` + +**Benefits:** +- Debug which middleware/hooks added which messages +- Filter messages by source (e.g., exclude RAG from storage) +- Multiple instances of same type distinguishable + +**Message-level Attribution:** + +In addition to source-based filtering, individual `ChatMessage` objects should have an `attribution` marker in their `additional_properties` dict. This enables external scenarios to filter messages after the full list has been composed from input and context messages: + +```python +# Setting attribution on a message +message = ChatMessage( + role="system", + text="Relevant context from knowledge base", + additional_properties={"attribution": "knowledge_base"} +) + +# Filtering by attribution (external scenario) +all_messages = context.get_all_messages(include_input=True) +filtered = [m for m in all_messages if m.additional_properties.get("attribution") != "ephemeral"] +``` + +This is useful for scenarios where filtering by `source_id` is not sufficient, such as when messages from the same source need different treatment. + +> **Note:** The `attribution` marker is intended for runtime filtering only and should **not** be propagated to storage. Storage middleware should strip `attribution` from `additional_properties` before persisting messages. + +### 5. Default Storage Behavior + +Zero-config works out of the box (both approaches): + +```python +# No middleware/hooks configured - still gets conversation history! +agent = ChatAgent(chat_client=client, name="assistant") +session = agent.create_session() +response = await agent.run("Hello!", session=session) +response = await agent.run("What did I say?", session=session) # Remembers! +``` + +Default in-memory storage is added at runtime **only when**: +- No `service_session_id` (service not managing storage) +- `options.store` is not `True` (user not expecting service storage) +- **No pipeline configured at all** (pipeline is empty or None) + +**Important:** If the user configures *any* middleware/hooks (even non-storage ones), the framework does **not** automatically add storage. This is intentional: +- Once users start customizing the pipeline, we consider them a advanced user and they should know what they are doing, therefore they should explicitly configure storage +- Automatic insertion would create ordering ambiguity +- Explicit configuration is clearer than implicit behavior + +### 6. Instance vs Factory + +Both approaches support shared instances and per-session factories: + +**Middleware:** +```python +# Instance (shared across sessions) +agent = ChatAgent(context_middleware=[RAGContextMiddleware("rag")]) + +# Factory (new instance per session) +def create_cache(session_id: str | None) -> ContextMiddleware: + return SessionCacheMiddleware("cache", session_id=session_id) + +agent = ChatAgent(context_middleware=[create_cache]) +``` + +**Hooks:** +```python +# Instance (shared across sessions) +agent = ChatAgent(context_hooks=[RAGContextHooks("rag")]) + +# Factory (new instance per session) +def create_cache(session_id: str | None) -> ContextHooks: + return SessionCacheHooks("cache", session_id=session_id) + +agent = ChatAgent(context_hooks=[create_cache]) +``` + +### 7. Renaming: Thread → Session + +`AgentThread` becomes `AgentSession` to better reflect its purpose: +- "Thread" implies a sequence of messages +- "Session" better captures the broader scope (state, pipeline, lifecycle) +- Align with recent change in .NET SDK + +### 8. Session Serialization/Deserialization + +There are two approaches to session serialization: + +**Option A: Direct serialization on `AgentSession`** + +The session itself provides `to_dict()` and `from_dict()`. The caller controls when and where to persist: + +```python +# Serialize +data = session.to_dict() # → {"type": "session", "session_id": ..., "service_session_id": ..., "state": {...}} +json_str = json.dumps(data) # Store anywhere (database, file, cache, etc.) + +# Deserialize +data = json.loads(json_str) +session = AgentSession.from_dict(data) # Reconstructs session with all state intact +``` + +**Option B: Serialization through the agent** + +The agent provides `save_session()`/`load_session()` methods that coordinate with providers (e.g., letting providers hook into the serialization process, or validating state before persisting). This adds flexibility but also complexity — providers would need lifecycle hooks for serialization, and the agent becomes responsible for persistence concerns. + +**Provider contract (both options):** Any values a provider writes to `session.state`/through lifecycle hooks **must be JSON-serializable** (dicts, lists, strings, numbers, booleans, None). + +**Comparison to Current:** +| Aspect | Current (`AgentThread`) | New (`AgentSession`) | +|--------|------------------------|---------------------| +| Serialization | `ChatMessageStore.serialize()` + custom logic | `session.to_dict()` → plain dict | +| Deserialization | `ChatMessageStore.deserialize()` + factory | `AgentSession.from_dict(data)` | +| Provider state | Instance state, needs custom ser/deser | Plain dict values in `session.state` | + +### 9. Session Management Methods + +Both approaches use identical agent methods: + +```python +class ChatAgent: + def create_session(self, *, session_id: str | None = None) -> AgentSession: + """Create a new session.""" + ... + + def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession: + """Get a session for a service-managed session ID.""" + ... +``` + +**Usage (identical for both):** +```python +session = agent.create_session() +session = agent.create_session(session_id="custom-id") +session = agent.get_session("existing-service-session-id") +session = agent.get_session("existing-service-session-id", session_id="custom-id") +``` + +### 10. Accessing Context from Other Middleware/Hooks + +Non-storage middleware/hooks can read context added by others via `context.context_messages`. However, they should operate under the assumption that **only the current input messages are available** - there is no implicit conversation history. + +If historical context is needed (e.g., RAG using last few messages), maintain a **self-managed buffer**, which would look something like this: + +**Middleware:** +```python +class RAGWithBufferMiddleware(ContextMiddleware): + def __init__(self, source_id: str, retriever: Retriever, *, buffer_window: int = 5): + super().__init__(source_id) + self._retriever = retriever + self._buffer_window = buffer_window + self._message_buffer: list[ChatMessage] = [] + + async def process(self, context: SessionContext, next: ContextMiddlewareNext) -> None: + # Use buffer + current input for retrieval + recent = self._message_buffer[-self._buffer_window * 2:] + query = self._build_query(recent + list(context.input_messages)) + docs = await self._retriever.search(query) + context.add_messages(self.source_id, [ChatMessage.system(f"Context: {docs}")]) + + await next(context) + + # Update buffer + self._message_buffer.extend(context.input_messages) + if context.response_messages: + self._message_buffer.extend(context.response_messages) +``` + +**Hooks:** +```python +class RAGWithBufferHooks(ContextHooks): + def __init__(self, source_id: str, retriever: Retriever, *, buffer_window: int = 5): + super().__init__(source_id) + self._retriever = retriever + self._buffer_window = buffer_window + self._message_buffer: list[ChatMessage] = [] + + async def before_run(self, context: SessionContext) -> None: + recent = self._message_buffer[-self._buffer_window * 2:] + query = self._build_query(recent + list(context.input_messages)) + docs = await self._retriever.search(query) + context.add_messages(self.source_id, [ChatMessage.system(f"Context: {docs}")]) + + async def after_run(self, context: SessionContext) -> None: + self._message_buffer.extend(context.input_messages) + if context.response_messages: + self._message_buffer.extend(context.response_messages) +``` + +**Simple RAG (input only, no buffer):** + +```python +# Middleware +async def process(self, context, next): + query = " ".join(msg.text for msg in context.input_messages if msg.text) + docs = await self._retriever.search(query) + context.add_messages(self.source_id, [ChatMessage.system(f"Context: {docs}")]) + await next(context) + +# Hooks +async def before_run(self, context): + query = " ".join(msg.text for msg in context.input_messages if msg.text) + docs = await self._retriever.search(query) + context.add_messages(self.source_id, [ChatMessage.system(f"Context: {docs}")]) +``` + +### Migration Impact + +| Current | Middleware (Option 2) | Hooks (Option 3) | +|---------|----------------------|------------------| +| `ContextProvider` | `ContextMiddleware` | `ContextHooks` | +| `invoking()` | Before `await next(context)` | `before_run()` | +| `invoked()` | After `await next(context)` | `after_run()` | +| `ChatMessageStore` | `StorageContextMiddleware` | `StorageContextHooks` | +| `AgentThread` | `AgentSession` | `AgentSession` | + +### Example: Current vs New + +**Current:** +```python +class MyContextProvider(ContextProvider): + async def invoking(self, messages, **kwargs) -> Context: + docs = await self.retrieve_documents(messages[-1].text) + return Context(messages=[ChatMessage.system(f"Context: {docs}")]) + + async def invoked(self, request, response, **kwargs) -> None: + await self.store_interaction(request, response) + +thread = await agent.get_new_thread(message_store=ChatMessageStore()) +thread.context_provider = provider +response = await agent.run("Hello", thread=thread) +``` + +**New (Middleware):** +```python +class RAGMiddleware(ContextMiddleware): + async def process(self, context: SessionContext, next) -> None: + docs = await self.retrieve_documents(context.input_messages[-1].text) + context.add_messages(self.source_id, [ChatMessage.system(f"Context: {docs}")]) + await next(context) + await self.store_interaction(context.input_messages, context.response_messages) + +agent = ChatAgent( + chat_client=client, + context_middleware=[InMemoryStorageMiddleware("memory"), RAGMiddleware("rag")] +) +session = agent.create_session() +response = await agent.run("Hello", session=session) +``` + +**New (Hooks):** +```python +class RAGHooks(ContextHooks): + async def before_run(self, context: SessionContext) -> None: + docs = await self.retrieve_documents(context.input_messages[-1].text) + context.add_messages(self.source_id, [ChatMessage.system(f"Context: {docs}")]) + + async def after_run(self, context: SessionContext) -> None: + await self.store_interaction(context.input_messages, context.response_messages) + +agent = ChatAgent( + chat_client=client, + context_hooks=[InMemoryStorageHooks("memory"), RAGHooks("rag")] +) +session = agent.create_session() +response = await agent.run("Hello", session=session) +``` +### Instance Ownership Options (for reference) + +#### Option A: Instances in Session + +The `AgentSession` owns the actual middleware/hooks instances. The pipeline is created when the session is created, and instances are stored in the session. + +```python +class AgentSession: + """Session owns the middleware instances.""" + + def __init__( + self, + *, + session_id: str | None = None, + context_pipeline: ContextMiddlewarePipeline | None = None, # Owns instances + ): + self._session_id = session_id or str(uuid.uuid4()) + self._context_pipeline = context_pipeline # Actual instances live here + + +class ChatAgent: + def __init__( + self, + chat_client: ..., + *, + context_middleware: Sequence[ContextMiddlewareConfig] | None = None, + ): + self._context_middleware_config = list(context_middleware or []) + + def create_session(self, *, session_id: str | None = None) -> AgentSession: + """Create session with resolved middleware instances.""" + resolved_id = session_id or str(uuid.uuid4()) + + # Resolve factories and create actual instances + pipeline = None + if self._context_middleware_config: + pipeline = ContextMiddlewarePipeline.from_config( + self._context_middleware_config, + session_id=resolved_id, + ) + + return AgentSession( + session_id=resolved_id, + context_pipeline=pipeline, # Session owns the instances + ) + + async def run(self, input: str, *, session: AgentSession) -> AgentResponse: + # Session's pipeline executes + context = await session.run_context_pipeline(input_messages) + # ... invoke model ... +``` + +**Pros:** +- Self-contained session - all state and behavior together +- Middleware can maintain per-session instance state naturally +- Session given to another agent will work the same way + +**Cons:** +- Session becomes heavier (instances + state) +- Complicated serialization - serialization needs to deal with instances, which might include non-serializable things like clients or connections +- Harder to share stateless middleware across sessions efficiently +- Factories must be re-resolved for each session + +#### Option B: Instances in Agent, State in Session (CHOSEN) + +The agent owns and manages the middleware/hooks instances. The `AgentSession` only stores state data that middleware reads/writes. The agent's runner executes the pipeline using the session's state. + +Two variants exist for how state is stored in the session: + +##### Option B1: Simple Dict State (CHOSEN) + +The session stores state as a simple `dict[str, Any]`. Each plugin receives the **whole state dict**, and since dicts are mutable in Python, plugins can modify it in place without needing to return a value. + +```python +class AgentSession: + """Session only holds state as a simple dict.""" + + def __init__(self, *, session_id: str | None = None): + self._session_id = session_id or str(uuid.uuid4()) + self.service_session_id: str | None = None + self.state: dict[str, Any] = {} # Mutable state dict + + +class ChatAgent: + def __init__( + self, + chat_client: ..., + *, + context_providers: Sequence[ContextProvider] | None = None, + ): + # Agent owns the actual plugin instances + self._context_providers = list(context_providers or []) + + def create_session(self, *, session_id: str | None = None) -> AgentSession: + """Create lightweight session with just state.""" + return AgentSession(session_id=session_id) + + async def run(self, input: str, *, session: AgentSession) -> AgentResponse: + context = SessionContext( + session_id=session.session_id, + input_messages=[...], + ) + + # Before-run plugins + for plugin in self._context_providers: + # Skip before_run for HistoryProviders that don't load messages + if isinstance(plugin, HistoryProvider) and not plugin.load_messages: + continue + await plugin.before_run(self, session, context, session.state) + + # assemble final input messages from context + + # ... actual running, i.e. `get_response` for ChatAgent ... + + # After-run plugins (reverse order) + for plugin in reversed(self._context_providers): + await plugin.after_run(self, session, context, session.state) + + +# Plugin that maintains state - modifies dict in place +class InMemoryHistoryProvider(ContextProvider): + async def before_run( + self, + agent: "SupportsAgentRun", + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + # Read from state (use source_id as key for namespace) + my_state = state.get(self.source_id, {}) + messages = my_state.get("messages", []) + context.extend_messages(self.source_id, messages) + + async def after_run( + self, + agent: "SupportsAgentRun", + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + # Modify state dict in place - no return needed + my_state = state.setdefault(self.source_id, {}) + messages = my_state.get("messages", []) + my_state["messages"] = [ + *messages, + *context.input_messages, + *(context.response.messages or []), + ] + + +# Stateless plugin - ignores state +class TimeContextProvider(ContextProvider): + async def before_run( + self, + agent: "SupportsAgentRun", + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + context.extend_instructions(self.source_id, f"Current time: {datetime.now()}") + + async def after_run( + self, + agent: "SupportsAgentRun", + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + pass # No state, nothing to do after +``` + +##### Option B2: SessionState Object + +The session stores state in a dedicated `SessionState` object. Each hook receives its own state slice through a mutable wrapper that writes back automatically. + +```python +class HookState: + """Mutable wrapper for a single hook's state. + + Changes are written back to the session state automatically. + """ + + def __init__(self, session_state: dict[str, dict[str, Any]], source_id: str): + self._session_state = session_state + self._source_id = source_id + if source_id not in session_state: + session_state[source_id] = {} + + def get(self, key: str, default: Any = None) -> Any: + return self._session_state[self._source_id].get(key, default) + + def set(self, key: str, value: Any) -> None: + self._session_state[self._source_id][key] = value + + def update(self, values: dict[str, Any]) -> None: + self._session_state[self._source_id].update(values) + + +class SessionState: + """Structured state container for a session.""" + + def __init__(self, session_id: str): + self.session_id = session_id + self.service_session_id: str | None = None + self._hook_state: dict[str, dict[str, Any]] = {} # source_id -> state + + def get_hook_state(self, source_id: str) -> HookState: + """Get mutable state wrapper for a specific hook.""" + return HookState(self._hook_state, source_id) + + +class AgentSession: + """Session holds a SessionState object.""" + + def __init__(self, *, session_id: str | None = None): + self._session_id = session_id or str(uuid.uuid4()) + self._state = SessionState(self._session_id) + + @property + def state(self) -> SessionState: + return self._state + + +class ContextHooksRunner: + """Agent-owned runner that executes hooks with session state.""" + + def __init__(self, hooks: Sequence[ContextHooks]): + self._hooks = list(hooks) + + async def run_before( + self, + context: SessionContext, + session_state: SessionState, + ) -> None: + """Run before_run for all hooks.""" + for hook in self._hooks: + my_state = session_state.get_hook_state(hook.source_id) + await hook.before_run(context, my_state) + + async def run_after( + self, + context: SessionContext, + session_state: SessionState, + ) -> None: + """Run after_run for all hooks in reverse order.""" + for hook in reversed(self._hooks): + my_state = session_state.get_hook_state(hook.source_id) + await hook.after_run(context, my_state) + + +# Hook uses HookState wrapper - no return needed +class InMemoryStorageHooks(ContextHooks): + async def before_run( + self, + context: SessionContext, + state: HookState, # Mutable wrapper + ) -> None: + messages = state.get("messages", []) + context.add_messages(self.source_id, messages) + + async def after_run( + self, + context: SessionContext, + state: HookState, # Mutable wrapper + ) -> None: + messages = state.get("messages", []) + state.set("messages", [ + *messages, + *context.input_messages, + *(context.response_messages or []), + ]) + + +# Stateless hook - state wrapper provided but not used +class TimeContextHooks(ContextHooks): + async def before_run( + self, + context: SessionContext, + state: HookState, + ) -> None: + context.add_instructions(self.source_id, f"Current time: {datetime.now()}") + + async def after_run( + self, + context: SessionContext, + state: HookState, + ) -> None: + pass # Nothing to do +``` + +**Option B Pros (both variants):** +- Lightweight sessions - just data, serializable via `to_dict()`/`from_dict()` +- Plugin instances shared across sessions (more memory efficient) +- Clearer separation: agent = behavior, session = state + +**Option B Cons (both variants):** +- More complex execution model (agent + session coordination) +- Plugins must explicitly read/write state (no implicit instance variables) +- Session given to another agent may not work (different plugins configuration) + +**B1 vs B2:** + +| Aspect | B1: Simple Dict (CHOSEN) | B2: SessionState Object | +|--------|-----------------|-------------------------| +| Simplicity | Simpler, less abstraction | More structure, helper methods | +| State passing | Whole dict passed, mutate in place | Mutable wrapper, no return needed | +| Type safety | `dict[str, Any]` - loose | Can add type hints on methods | +| Extensibility | Add keys as needed | Can add methods/validation | +| Serialization | Direct JSON serialization | Need custom serialization | + +#### Comparison + +| Aspect | Option A: Instances in Session | Option B: Instances in Agent (CHOSEN) | +|--------|-------------------------------|------------------------------| +| Session weight | Heavier (instances + state) | Lighter (state only) | +| Plugin sharing | Per-session instances | Shared across sessions | +| Instance state | Natural (instance variables) | Explicit (state dict) | +| Serialization | Serialize session + plugins | `session.to_dict()`/`AgentSession.from_dict()` | +| Factory handling | Resolved at session creation | Not needed (state dict handles per-session needs) | +| Signature | `before_run(context)` | `before_run(agent, session, context, state)` | +| Session portability | Works with any agent | Tied to agent's plugins config | + +#### Factories Not Needed with Option B + +With Option B (instances in agent, state in session), the plugins are shared across sessions and the explicit state dict handles per-session needs. Therefore, **factory support is not needed**: + +- State is externalized to the session's `state: dict[str, Any]` +- If a plugin needs per-session initialization, it can do so in `before_run` on first call (checking if state is empty) +- All plugins are shared across sessions (more memory efficient) +- Plugins use `state.setdefault(self.source_id, {})` to namespace their state + +--- +## Decision Outcome + +### Decision 1: Execution Pattern + +**Chosen: Option 3 - Hooks (Pre/Post Pattern)** with the following naming: +- **Class name:** `ContextProvider` (emphasizes extensibility, familiar from build tools, and does not favor reading or writing) +- **Method names:** `before_run` / `after_run` (matches `agent.run()` terminology) + +Rationale: +- Simpler mental model: "before" runs before, "after" runs after - no nesting to understand +- Easier to implement plugins that only need one phase (just override one method) +- More similar to the current `ContextProvider` API (`invoking`/`invoked`), easing migration +- Clearer separation between what this does vs what Agent Middleware can do + +Both options share the same: +- Agent vs Session ownership model +- `source_id` attribution +- Natively serializable sessions (state dict is JSON-serializable) +- Session management methods (`create_session`, `get_session`) +- Renaming `AgentThread` → `AgentSession` + +### Decision 2: Instance Ownership (Orthogonal) + +**Chosen: Option B1 - Instances in Agent, State in Session (Simple Dict)** + +The agent (any `SupportsAgentRun` implementation) owns and manages the `ContextProvider` instances. The `AgentSession` only stores state as a mutable `dict[str, Any]`. Each plugin receives the **whole state dict** (not just its own slice), and since a dict is mutable, no return value is needed - plugins modify the dict in place. + +Rationale for B over A: +- Lightweight sessions - just data, serializable via `to_dict()`/`from_dict()` +- Plugin instances shared across sessions (more memory efficient) +- Clearer separation: agent = behavior, session = state +- Factories not needed - state dict handles per-session needs + +Rationale for B1 over B2: Simpler is better. The whole state dict is passed to each plugin, and since Python dicts are mutable, plugins can modify state in place without returning anything. This is the most Pythonic approach. + +> **Note on trust:** Since all `ContextProvider` instances reason over conversation messages (which may contain sensitive user data), they should be **trusted by default**. This is also why we allow all plugins to see all state - if a plugin is untrusted, it shouldn't be in the pipeline at all. The whole state dict is passed rather than isolated slices because plugins that handle messages already have access to the full conversation context. + + +## Comparison to .NET Implementation + +The .NET Agent Framework provides equivalent functionality through a different structure. Both implementations achieve the same goals using idioms natural to their respective languages. + +### Concept Mapping + +| .NET Concept | Python (Chosen) | +|--------------|-----------------| +| `AIContextProvider` (abstract base) | `ContextProvider` | +| `ChatHistoryProvider` (abstract base) | `HistoryProvider` | +| `AIContext` (return from `InvokingAsync`) | `SessionContext` (mutable, passed through) | +| `AgentSession` / `ChatClientAgentSession` | `AgentSession` | +| `InMemoryChatHistoryProvider` | `InMemoryHistoryProvider` | +| `ChatClientAgentOptions` factory delegates | Not needed - state dict handles per-session needs | + +### Feature Equivalence + +Both platforms provide the same core capabilities: + +| Capability | .NET | Python | +|------------|------|--------| +| Inject context before invocation | `AIContextProvider.InvokingAsync()` → returns `AIContext` with `Instructions`, `Messages`, `Tools` | `ContextProvider.before_run()` → mutates `SessionContext` in place | +| React after invocation | `AIContextProvider.InvokedAsync()` | `ContextProvider.after_run()` | +| Load conversation history | `ChatHistoryProvider.InvokingAsync()` → returns `IEnumerable` | `HistoryProvider.before_run()` → calls `context.extend_messages()` | +| Store conversation history | `ChatHistoryProvider.InvokedAsync()` | `HistoryProvider.after_run()` → calls `save_messages()` | +| Session serialization | `Serialize()` on providers → `JsonElement` | `session.to_dict()`/`AgentSession.from_dict()` — providers write JSON-serializable values to `session.state` | +| Factory-based creation | `Func>` delegates on `ChatClientAgentOptions` | Not needed - state dict handles per-session needs | +| Default storage | Auto-injects `InMemoryChatHistoryProvider` when no `ChatHistoryProvider` or `ConversationId` set | Auto-injects `InMemoryHistoryProvider` when no providers and `conversation_id` or `store=True` | +| Service-managed history | `ConversationId` property (mutually exclusive with `ChatHistoryProvider`) | `service_session_id` on `AgentSession` | +| Message reduction | `IChatReducer` on `InMemoryChatHistoryProvider` | Not yet designed (see Open Discussion: Context Compaction) | + +### Implementation Differences + +The implementations differ in ways idiomatic to each language: + +| Aspect | .NET Approach | Python Approach | +|--------|---------------|-----------------| +| **Context providers** | Separate `AIContextProvider` and `ChatHistoryProvider` (one of each per session) | Unified list of `ContextProvider` (multiple) | +| **Composition** | One of each provider type per session | Unlimited providers in pipeline | +| **Context passing** | `InvokingAsync()` returns `AIContext` (instructions + messages + tools) | `before_run()` mutates `SessionContext` in place | +| **Response access** | `InvokedContext` carries response messages | `SessionContext.response` carries full `AgentResponse` (messages, response_id, usage_details, etc.) | +| **Type system** | Strict abstract classes, compile-time checks | Duck typing, protocols, runtime flexibility | +| **Configuration** | Factory delegates on `ChatClientAgentOptions` | Direct instantiation, list of instances | +| **State management** | Instance state in providers, serialized via `JsonElement` | Explicit state dict in session, serialized via `session.to_dict()` | +| **Default storage** | Auto-injects `InMemoryChatHistoryProvider` when neither `ChatHistoryProvider` nor `ConversationId` is set | Auto-injects `InMemoryHistoryProvider` when no providers and `conversation_id` or `store=True` | +| **Source tracking** | Limited - `message.source_id` in observability/DevUI only | Built-in `source_id` on every provider, keyed in `context_messages` dict | +| **Service discovery** | `GetService()` on providers and sessions | Not applicable - Python uses direct references | + +### Design Trade-offs + +Each approach has trade-offs that align with language conventions: + +**.NET's separate provider types:** +- Clearer separation between context injection and history storage +- Easier to detect "missing storage" and auto-inject defaults (checks for `ChatHistoryProvider` or `ConversationId`) +- Type system enforces single provider of each type +- `AIContext` return type makes it clear what context is being added (instructions vs messages vs tools) +- `GetService()` pattern enables provider discovery without tight coupling + +**Python's unified pipeline:** +- Single abstraction for all context concerns +- Multiple instances of same type (e.g., multiple storage backends with different `source_id`s) +- More explicit - customization means owning full configuration +- `source_id` enables filtering/debugging across all sources +- Mutable `SessionContext` avoids allocating return objects +- Explicit state dict makes serialization trivial (no `JsonElement` layer) + +Neither approach is inherently better - they reflect different language philosophies while achieving equivalent functionality. The Python design embraces the "we're all consenting adults" philosophy, while .NET provides more compile-time guardrails. + +--- + +## Open Discussion: Context Compaction + +### Problem Statement + +A common need for long-running agents is **context compaction** - automatically summarizing or truncating conversation history when approaching token limits. This is particularly important for agents that make many tool calls in succession (10s or 100s), where the context can grow unboundedly. + +Currently, this is challenging because: +- `ChatMessageStore.list_messages()` is only called once at the start of `agent.run()`, not during the tool loop +- `ChatMiddleware` operates on a copy of messages, so modifications don't persist across tool loop iterations +- The function calling loop happens deep within the `ChatClient`, which is below the agent level + +### Design Question + +Should `ContextPlugin` be invoked: +1. **Only at agent invocation boundaries** (current proposal) - before/after each `agent.run()` call +2. **During the tool loop** - before/after each model call within a single `agent.run()` + +### Boundary vs In-Run Compaction + +While boundary and in-run compaction could potentially use the same mechanism, they have **different goals and behaviors**: + +**Boundary compaction** (before/after `agent.run()`): +- **Before run**: Keep context manageable - load a compacted view of history +- **After run**: Keep storage compact - summarize/truncate before persisting +- Useful for maintaining reasonable context sizes across conversation turns +- One reason to have **multiple storage plugins**: persist compacted history for use during runs, while also storing the full uncompacted history for auditing and evaluations + +**In-run compaction** (during function calling loops): +- Relevant for **function calling scenarios** where many tool calls accumulate +- Typically **in-memory only** - no need to persist intermediate compaction and only useful when the conversation/session is _not_ managed by the service +- Different strategies apply: + - Remove old function call/result pairs entirely/Keep only the most recent N tool interactions + - Replace call/result pairs with a single summary message (with a different role) + - Summarize several function call/result pairs into one larger context message + +### Service-Managed vs Local Storage + +**Important:** In-run compaction is relevant only for **non-service-managed histories**. When using service-managed storage (`service_session_id` is set): +- The service handles history management internally +- Only the new calls and results are sent to/from the service each turn +- The service is responsible for its own compaction strategy, but we do not control that + +For local storage, a full message list is sent to the model each time, making compaction the client's responsibility. + +### Options + +**Option A: Invocation-boundary only (current proposal)** +- Simpler mental model +- Consistent with `AgentMiddleware` pattern +- In-run compaction would need to happen via a separate mechanism (e.g., `ChatMiddleware` at the client level) +- Risk: Different compaction mechanisms at different layers could be confusing + +**Option B: Also during tool loops** +- Single mechanism for all context manipulation +- More powerful but more complex +- Requires coordination with `ChatClient` internals +- Risk: Performance overhead if plugins are expensive + +**Option C: Unified approach across layers** +- Define a single context compaction abstraction that works at both agent and client levels +- `ContextPlugin` could delegate to `ChatMiddleware` for mid-loop execution +- Requires deeper architectural thought + +### Potential Extension Points (for any option) + +Regardless of the chosen approach, these extension points could support compaction: +- A `CompactionStrategy` that can be shared between plugins and function calling configuration +- Hooks for `ChatClient` to notify the agent layer when context limits are approaching +- A unified `ContextManager` that coordinates compaction across layers +- **Message-level attribution**: The `attribution` marker in `ChatMessage.additional_properties` can be used during compaction to identify messages that should be preserved (e.g., `attribution: "important"`) or that are safe to remove (e.g., `attribution: "ephemeral"`). This prevents accidental filtering of critical context during aggressive compaction. + +> **Note:** The .NET SDK currently has a `ChatReducer` interface for context reduction/compaction. We should consider adopting similar naming in Python (e.g., `ChatReducer` or `ContextReducer`) for cross-platform consistency. + +**This section requires further discussion.** + +## Implementation Plan + +See **Appendix A** for class hierarchy, API signatures, and user experience examples. +See the **Workplan** at the end for PR breakdown and reference implementation. + +--- + +## Appendix A: API Overview + +### Class Hierarchy + +``` +ContextProvider (base - hooks pattern) +├── HistoryProvider (storage subclass) +│ ├── InMemoryHistoryProvider (built-in) +│ ├── RedisHistoryProvider (packages/redis) +│ └── CosmosHistoryProvider (packages/azure-ai) +├── AzureAISearchContextProvider (packages/azure-ai-search) +├── Mem0ContextProvider (packages/mem0) +└── (custom user providers) + +AgentSession (lightweight state container) + +SessionContext (per-invocation state) +``` + +### ContextProvider + +```python +class ContextProvider(ABC): + """Base class for context providers (hooks pattern). + + Context providers participate in the context engineering pipeline, + adding context before model invocation and processing responses after. + + Attributes: + source_id: Unique identifier for this provider instance (required). + Used for message/tool attribution so other providers can filter. + """ + + def __init__(self, source_id: str): + self.source_id = source_id + + async def before_run( + self, + agent: "SupportsAgentRun", + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Called before model invocation. Override to add context.""" + pass + + async def after_run( + self, + agent: "SupportsAgentRun", + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Called after model invocation. Override to process response.""" + pass +``` + +> **Serialization contract:** Any values a provider writes to `state` must be JSON-serializable. Sessions are serialized via `session.to_dict()` and restored via `AgentSession.from_dict()`. + +> **Agent-agnostic:** The `agent` parameter is typed as `SupportsAgentRun` (the base protocol), not `ChatAgent`. Context providers work with any agent implementation. + +### HistoryProvider + +```python +class HistoryProvider(ContextProvider): + """Base class for conversation history storage providers. + + Subclasses only need to implement get_messages() and save_messages(). + The default before_run/after_run handle loading and storing based on + configuration flags. Override them for custom behavior. + + A single class configured for different use cases: + - Primary memory storage (loads + stores messages) + - Audit/logging storage (stores only, doesn't load) + - Evaluation storage (stores only for later analysis) + + Loading behavior: + - `load_messages=True` (default): Load messages from storage in before_run + - `load_messages=False`: Agent skips `before_run` entirely (audit/logging mode) + + Storage behavior: + - `store_inputs`: Store input messages (default True) + - `store_responses`: Store response messages (default True) + - `store_context_messages`: Also store context from other providers (default False) + - `store_context_from`: Only store from specific source_ids (default None = all) + """ + + def __init__( + self, + source_id: str, + *, + load_messages: bool = True, + store_inputs: bool = True, + store_responses: bool = True, + store_context_messages: bool = False, + store_context_from: Sequence[str] | None = None, + ): ... + + # --- Subclasses implement these --- + + @abstractmethod + async def get_messages(self, session_id: str | None) -> list[ChatMessage]: + """Retrieve stored messages for this session.""" + ... + + @abstractmethod + async def save_messages(self, session_id: str | None, messages: Sequence[ChatMessage]) -> None: + """Persist messages for this session.""" + ... + + # --- Default implementations (override for custom behavior) --- + + async def before_run(self, agent, session, context, state) -> None: + """Load history into context. Skipped by the agent when load_messages=False.""" + history = await self.get_messages(context.session_id) + context.extend_messages(self.source_id, history) + + async def after_run(self, agent, session, context, state) -> None: + """Store messages based on store_* configuration flags.""" + messages_to_store: list[ChatMessage] = [] + # Optionally include context from other providers + if self.store_context_messages: + if self.store_context_from: + messages_to_store.extend(context.get_messages(sources=self.store_context_from)) + else: + messages_to_store.extend(context.get_messages(exclude_sources=[self.source_id])) + if self.store_inputs: + messages_to_store.extend(context.input_messages) + if self.store_responses and context.response.messages: + messages_to_store.extend(context.response.messages) + if messages_to_store: + await self.save_messages(context.session_id, messages_to_store) +``` + +### SessionContext + +```python +class SessionContext: + """Per-invocation state passed through the context provider pipeline. + + Created fresh for each agent.run() call. Providers read from and write to + the mutable fields to add context before invocation and process responses after. + + Attributes: + session_id: The ID of the current session + service_session_id: Service-managed session ID (if present) + input_messages: New messages being sent to the agent (set by caller) + context_messages: Dict mapping source_id -> messages added by that provider. + Maintains insertion order (provider execution order). + instructions: Additional instructions - providers can append here + tools: Additional tools - providers can append here + response (property): After invocation, contains the full AgentResponse (set by agent). + Includes response.messages, response.response_id, response.agent_id, + response.usage_details, etc. Read-only property - use AgentMiddleware to modify. + options: Options passed to agent.run() - READ-ONLY, for reflection only + metadata: Shared metadata dictionary for cross-provider communication + """ + + def __init__( + self, + *, + session_id: str | None = None, + service_session_id: str | None = None, + input_messages: list[ChatMessage], + context_messages: dict[str, list[ChatMessage]] | None = None, + instructions: list[str] | None = None, + tools: list[ToolProtocol] | None = None, + options: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, + ): ... + self._response: "AgentResponse | None" = None + + @property + def response(self) -> "AgentResponse | None": + """The agent's response. Set by the framework after invocation, read-only for providers.""" + ... + + def extend_messages(self, source_id: str, messages: Sequence[ChatMessage]) -> None: + """Add context messages from a specific source.""" + ... + + def extend_instructions(self, source_id: str, instructions: str | Sequence[str]) -> None: + """Add instructions to be prepended to the conversation.""" + ... + + def extend_tools(self, source_id: str, tools: Sequence[ToolProtocol]) -> None: + """Add tools with source attribution in tool.metadata.""" + ... + + def get_messages( + self, + *, + sources: Sequence[str] | None = None, + exclude_sources: Sequence[str] | None = None, + include_input: bool = False, + include_response: bool = False, + ) -> list[ChatMessage]: + """Get context messages, optionally filtered and optionally including input/response. + + Returns messages in provider execution order (dict insertion order), + with input and response appended if requested. + """ + ... +``` + +### AgentSession (Decision B1) + +```python +class AgentSession: + """A conversation session with an agent. + + Lightweight state container. Provider instances are owned by the agent, + not the session. The session only holds session IDs and a mutable state dict. + """ + + def __init__(self, *, session_id: str | None = None): + self._session_id = session_id or str(uuid.uuid4()) + self.service_session_id: str | None = None + self.state: dict[str, Any] = {} + + @property + def session_id(self) -> str: + return self._session_id + + def to_dict(self) -> dict[str, Any]: + """Serialize session to a plain dict.""" + return { + "type": "session", + "session_id": self._session_id, + "service_session_id": self.service_session_id, + "state": self.state, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "AgentSession": + """Restore session from a dict.""" + session = cls(session_id=data["session_id"]) + session.service_session_id = data.get("service_session_id") + session.state = data.get("state", {}) + return session +``` + +### ChatAgent Integration + +```python +class ChatAgent: + def __init__( + self, + chat_client: ..., + *, + context_providers: Sequence[ContextProvider] | None = None, + ): + self._context_providers = list(context_providers or []) + + def create_session(self, *, session_id: str | None = None) -> AgentSession: + """Create a new lightweight session.""" + return AgentSession(session_id=session_id) + + def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession: + """Get or create a session for a service-managed session ID.""" + session = AgentSession(session_id=session_id) + session.service_session_id = service_session_id + return session + + async def run(self, input: str, *, session: AgentSession, options: dict[str, Any] | None = None) -> AgentResponse: + options = options or {} + + # Auto-add InMemoryHistoryProvider when no providers and conversation_id/store requested + if not self._context_providers and (options.get("conversation_id") or options.get("store") is True): + self._context_providers.append(InMemoryHistoryProvider("memory")) + + context = SessionContext(session_id=session.session_id, input_messages=[...]) + + # Before-run providers (forward order, skip HistoryProviders with load_messages=False) + for provider in self._context_providers: + if isinstance(provider, HistoryProvider) and not provider.load_messages: + continue + await provider.before_run(self, session, context, session.state) + + # ... assemble messages, invoke model ... + context._response = response # Set the full AgentResponse for after_run access + + # After-run providers (reverse order) + for provider in reversed(self._context_providers): + await provider.after_run(self, session, context, session.state) +``` + +### Message/Tool Attribution + +The `SessionContext` provides explicit methods for adding context: + +```python +# Adding messages (keyed by source_id in context_messages dict) +context.extend_messages(self.source_id, messages) + +# Adding instructions (flat list, source_id for debugging) +context.extend_instructions(self.source_id, "Be concise and helpful.") +context.extend_instructions(self.source_id, ["Instruction 1", "Instruction 2"]) + +# Adding tools (source attribution added to tool.metadata automatically) +context.extend_tools(self.source_id, [my_tool, another_tool]) + +# Getting all context messages in provider execution order +all_context = context.get_messages() + +# Including input and response messages too +full_conversation = context.get_messages(include_input=True, include_response=True) + +# Filtering by source +memory_messages = context.get_messages(sources=["memory"]) +non_rag_messages = context.get_messages(exclude_sources=["rag"]) + +# Direct access to check specific sources +if "memory" in context.context_messages: + history = context.context_messages["memory"] +``` + +--- + +## User Experience Examples + +### Example 0: Zero-Config Default (Simplest Use Case) + +```python +from agent_framework import ChatAgent + +# No providers configured - but conversation history still works! +agent = ChatAgent( + chat_client=client, + name="assistant", + # No context_providers specified +) + +# Create session - automatically gets InMemoryHistoryProvider when conversation_id or store=True +session = agent.create_session() +response = await agent.run("Hello, my name is Alice!", session=session) + +# Conversation history is preserved automatically +response = await agent.run("What's my name?", session=session) +# Agent remembers: "Your name is Alice!" + +# With service-managed session - no default storage added (service handles it) +service_session = agent.create_session(service_session_id="thread_abc123") + +# With store=True in options - user expects service storage, no default added +response = await agent.run("Hello!", session=session, options={"store": True}) +``` + +### Example 1: Explicit Memory Storage + +```python +from agent_framework import ChatAgent, InMemoryHistoryProvider + +# Explicit provider configuration (same behavior as default, but explicit) +agent = ChatAgent( + chat_client=client, + name="assistant", + context_providers=[ + InMemoryHistoryProvider(source_id="memory") + ] +) + +# Create session and chat +session = agent.create_session() +response = await agent.run("Hello!", session=session) + +# Messages are automatically stored and loaded on next invocation +response = await agent.run("What did I say before?", session=session) +``` + +### Example 2: RAG + Memory + Audit (All HistoryProvider) + +```python +from agent_framework import ChatAgent +from agent_framework.azure import CosmosHistoryProvider, AzureAISearchContextProvider +from agent_framework.redis import RedisHistoryProvider + +# RAG provider that injects relevant documents +search_provider = AzureAISearchContextProvider( + source_id="rag", + endpoint="https://...", + index_name="documents", +) + +# Primary memory storage (loads + stores) +# load_messages=True (default) - loads and stores messages +memory_provider = RedisHistoryProvider( + source_id="memory", + redis_url="redis://...", +) + +# Audit storage - SAME CLASS, different configuration +# load_messages=False = never loads, just stores for audit +audit_provider = CosmosHistoryProvider( + source_id="audit", + connection_string="...", + load_messages=False, # Don't load - just store for audit +) + +agent = ChatAgent( + chat_client=client, + name="assistant", + context_providers=[ + memory_provider, # First: loads history + search_provider, # Second: adds RAG context + audit_provider, # Third: stores for audit (no load) + ] +) +``` + +### Example 3: Custom Context Providers + +```python +from agent_framework import ContextProvider, SessionContext + +class TimeContextProvider(ContextProvider): + """Adds current time to the context.""" + + async def before_run(self, agent, session, context, state) -> None: + from datetime import datetime + context.extend_instructions( + self.source_id, + f"Current date and time: {datetime.now().isoformat()}" + ) + + +class UserPreferencesProvider(ContextProvider): + """Tracks and applies user preferences from conversation.""" + + async def before_run(self, agent, session, context, state) -> None: + prefs = state.get(self.source_id, {}).get("preferences", {}) + if prefs: + context.extend_instructions( + self.source_id, + f"User preferences: {json.dumps(prefs)}" + ) + + async def after_run(self, agent, session, context, state) -> None: + # Extract preferences from response and store in session state + for msg in context.response.messages or []: + if "preference:" in msg.text.lower(): + my_state = state.setdefault(self.source_id, {}) + my_state.setdefault("preferences", {}) + # ... extract and store preference + + +# Compose providers - each with mandatory source_id +agent = ChatAgent( + chat_client=client, + context_providers=[ + InMemoryHistoryProvider(source_id="memory"), + TimeContextProvider(source_id="time"), + UserPreferencesProvider(source_id="prefs"), + ] +) +``` + +### Example 4: Filtering by Source (Using Dict-Based Context) + +```python +class SelectiveContextProvider(ContextProvider): + """Provider that only processes messages from specific sources.""" + + async def before_run(self, agent, session, context, state) -> None: + # Check what sources have added messages so far + print(f"Sources so far: {list(context.context_messages.keys())}") + + # Get messages excluding RAG context + non_rag_messages = context.get_messages(exclude_sources=["rag"]) + + # Or get only memory messages + if "memory" in context.context_messages: + memory_only = context.context_messages["memory"] + + # Do something with filtered messages... + # e.g., sentiment analysis, topic extraction + + +class RAGContextProvider(ContextProvider): + """Provider that adds RAG context.""" + + async def before_run(self, agent, session, context, state) -> None: + # Search for relevant documents based on input + relevant_docs = await self._search(context.input_messages) + + # Add RAG context using explicit method + rag_messages = [ + ChatMessage(role="system", text=f"Relevant info: {doc}") + for doc in relevant_docs + ] + context.extend_messages(self.source_id, rag_messages) +``` + +### Example 5: Explicit Storage Configuration for Service-Managed Sessions + +```python +# HistoryProvider uses explicit configuration - no automatic detection. +# load_messages=True (default): Load messages from storage +# load_messages=False: Skip loading (useful for audit-only storage) + +agent = ChatAgent( + chat_client=client, + context_providers=[ + RedisHistoryProvider( + source_id="memory", + redis_url="redis://...", + # load_messages=True is the default + ) + ] +) + +session = agent.create_session() + +# Normal run - loads and stores messages +response = await agent.run("Hello!", session=session) + +# For service-managed sessions, configure storage explicitly: +# - Use load_messages=False when service handles history +service_storage = RedisHistoryProvider( + source_id="audit", + redis_url="redis://...", + load_messages=False, # Don't load - service manages history +) + +agent_with_service = ChatAgent( + chat_client=client, + context_providers=[service_storage] +) +service_session = agent_with_service.create_session(service_session_id="thread_abc123") +response = await agent_with_service.run("Hello!", session=service_session) +# History provider stores for audit but doesn't load (service handles history) +``` + +### Example 6: Multiple Instances of Same Provider Type + +```python +# You can have multiple instances of the same provider class +# by using different source_ids + +agent = ChatAgent( + chat_client=client, + context_providers=[ + # Primary storage for conversation history + RedisHistoryProvider( + source_id="conversation_memory", + redis_url="redis://primary...", + load_messages=True, # This one loads + ), + # Secondary storage for audit (different Redis instance) + RedisHistoryProvider( + source_id="audit_log", + redis_url="redis://audit...", + load_messages=False, # This one just stores + ), + ] +) +# Warning will NOT be logged because only one has load_messages=True +``` + +### Example 7: Provider Ordering - RAG Before vs After Memory + +The order of providers determines what context each one can see. This is especially important for RAG, which may benefit from seeing conversation history. + +```python +from agent_framework import ChatAgent +from agent_framework.context import InMemoryHistoryProvider, ContextProvider, SessionContext + +class RAGContextProvider(ContextProvider): + """RAG provider that retrieves relevant documents based on available context.""" + + async def before_run(self, agent, session, context, state) -> None: + # Build query from what we can see + query_parts = [] + + # We can always see the current input + for msg in context.input_messages: + query_parts.append(msg.text) + + # Can we see history? Depends on provider order! + history = context.get_messages() # Gets context from providers that ran before us + if history: + # Include recent history for better RAG context + recent = history[-3:] # Last 3 messages + for msg in recent: + query_parts.append(msg.text) + + query = " ".join(query_parts) + documents = await self._retrieve_documents(query) + + # Add retrieved documents as context + rag_messages = [ChatMessage.system(f"Relevant context:\n{doc}") for doc in documents] + context.extend_messages(self.source_id, rag_messages) + + async def _retrieve_documents(self, query: str) -> list[str]: + # ... vector search implementation + return ["doc1", "doc2"] + + +# ============================================================================= +# SCENARIO A: RAG runs BEFORE Memory +# ============================================================================= +# RAG only sees the current input message - no conversation history +# Use when: RAG should be based purely on the current query + +agent_rag_first = ChatAgent( + chat_client=client, + context_providers=[ + RAGContextProvider("rag"), # Runs first - only sees input_messages + InMemoryHistoryProvider("memory"), # Runs second - loads/stores history + ] +) + +# Flow: +# 1. RAG.before_run(): +# - context.input_messages = ["What's the weather?"] +# - context.get_messages() = [] (empty - memory hasn't run yet) +# - RAG query based on: "What's the weather?" only +# - Adds: context_messages["rag"] = [retrieved docs] +# +# 2. Memory.before_run(): +# - Loads history: context_messages["memory"] = [previous conversation] +# +# 3. Agent invocation with: history + rag docs + input +# +# 4. Memory.after_run(): +# - Stores: input + response (not RAG docs by default) +# +# 5. RAG.after_run(): +# - (nothing to do) + + +# ============================================================================= +# SCENARIO B: RAG runs AFTER Memory +# ============================================================================= +# RAG sees conversation history - can use it for better retrieval +# Use when: RAG should consider conversation context for better results + +agent_memory_first = ChatAgent( + chat_client=client, + context_providers=[ + InMemoryHistoryProvider("memory"), # Runs first - loads history + RAGContextProvider("rag"), # Runs second - sees history + input + ] +) + +# Flow: +# 1. Memory.before_run(): +# - Loads history: context_messages["memory"] = [previous conversation] +# +# 2. RAG.before_run(): +# - context.input_messages = ["What's the weather?"] +# - context.get_messages() = [previous conversation] (sees history!) +# - RAG query based on: recent history + "What's the weather?" +# - Better retrieval because RAG understands conversation context +# - Adds: context_messages["rag"] = [more relevant docs] +# +# 3. Agent invocation with: history + rag docs + input +# +# 4. RAG.after_run(): +# - (nothing to do) +# +# 5. Memory.after_run(): +# - Stores: input + response + + +# ============================================================================= +# SCENARIO C: RAG after Memory, with selective storage +# ============================================================================= +# Memory first for better RAG, plus separate audit that stores RAG context + +agent_full_context = ChatAgent( + chat_client=client, + context_providers=[ + InMemoryHistoryProvider("memory"), # Primary history storage + RAGContextProvider("rag"), # Gets history context for better retrieval + PersonaContextProvider("persona"), # Adds persona instructions + # Audit storage - stores everything including RAG results + CosmosHistoryProvider( + "audit", + load_messages=False, # Don't load (memory handles that) + store_context_messages=True, # Store RAG + persona context too + ), + ] +) +``` + +--- + +### Workplan + +The implementation is split into 2 PRs to limit scope and simplify review. + +``` +PR1 (New Types) ──► PR2 (Agent Integration + Cleanup) +``` + +#### PR 1: New Types + +**Goal:** Create all new types. No changes to existing code yet. Because the old `ContextProvider` class (in `_memory.py`) still exists during this PR, the new base class uses the **temporary name `_ContextProviderBase`** to avoid import collisions. All new provider implementations reference `_ContextProviderBase` / `_HistoryProviderBase` in PR1. + +**Core Package - `packages/core/agent_framework/_sessions.py`:** +- [ ] `SessionContext` class with explicit add/get methods +- [ ] `_ContextProviderBase` base class with `before_run()`/`after_run()` (temporary name; renamed to `ContextProvider` in PR2) +- [ ] `_HistoryProviderBase(_ContextProviderBase)` derived class with load_messages/store flags (temporary; renamed to `HistoryProvider` in PR2) +- [ ] `AgentSession` class with `state: dict[str, Any]`, `to_dict()`, `from_dict()` +- [ ] `InMemoryHistoryProvider(_HistoryProviderBase)` + +**External Packages (new classes alongside existing ones, temporary `_` prefix):** +- [ ] `packages/azure-ai-search/` - create `_AzureAISearchContextProvider(_ContextProviderBase)` — constructor keeps existing params, adds `source_id` (see compatibility notes below) +- [ ] `packages/redis/` - create `_RedisHistoryProvider(_HistoryProviderBase)` — constructor keeps existing `RedisChatMessageStore` connection params, adds `source_id` + storage flags +- [ ] `packages/redis/` - create `_RedisContextProvider(_ContextProviderBase)` — constructor keeps existing `RedisProvider` vector/search params, adds `source_id` +- [ ] `packages/mem0/` - create `_Mem0ContextProvider(_ContextProviderBase)` — constructor keeps existing params, adds `source_id` + +**Constructor Compatibility Notes:** + +The existing provider constructors can be preserved with minimal additions: + +| Existing Class | New Class (PR1 temporary name) | Constructor Changes | +|---|---|---| +| `AzureAISearchContextProvider(ContextProvider)` | `_AzureAISearchContextProvider(_ContextProviderBase)` | Add `source_id: str` (required). All existing params (`endpoint`, `index_name`, `api_key`, `mode`, `top_k`, etc.) stay the same. `invoking()` → `before_run()`, `invoked()` → `after_run()`. | +| `Mem0Provider(ContextProvider)` | `_Mem0ContextProvider(_ContextProviderBase)` | Add `source_id: str` (required). All existing params (`mem0_client`, `api_key`, `agent_id`, `user_id`, etc.) stay the same. `scope_to_per_operation_thread_id` → maps to session_id scoping via `before_run`. | +| `RedisChatMessageStore` | `_RedisHistoryProvider(_HistoryProviderBase)` | Add `source_id: str` (required) + `load_messages`, `store_inputs`, `store_responses` flags. Keep connection params (`redis_url`, `credential_provider`, `host`, `port`, `ssl`). Drop `thread_id` (now from `context.session_id`), `messages` (state managed via `session.state`), `max_messages` (→ message reduction concern). | +| `RedisProvider(ContextProvider)` | `_RedisContextProvider(_ContextProviderBase)` | Add `source_id: str` (required). Keep vector/search params (`redis_url`, `index_name`, `redis_vectorizer`, etc.). Drop `thread_id` scoping (now from `context.session_id`). | + +**Testing:** +- [ ] Unit tests for `SessionContext` methods (extend_messages, get_messages, extend_instructions, extend_tools) +- [ ] Unit tests for `_HistoryProviderBase` load/store flags +- [ ] Unit tests for `InMemoryHistoryProvider` state persistence via session.state +- [ ] Unit tests for source attribution (mandatory source_id) + +--- + +#### PR 2: Agent Integration + Cleanup + +**Goal:** Wire up new types into `ChatAgent` and remove old types. + +**Changes to `ChatAgent`:** +- [ ] Replace `thread` parameter with `session` in `agent.run()` +- [ ] Add `context_providers` parameter to `ChatAgent.__init__()` +- [ ] Add `create_session()` method +- [ ] Verify `session.to_dict()`/`AgentSession.from_dict()` round-trip in integration tests +- [ ] Wire up provider iteration (before_run forward, after_run reverse) +- [ ] Add validation warning if multiple/zero history providers have `load_messages=True` +- [ ] Wire up default `InMemoryHistoryProvider` behavior (auto-add when no providers and `conversation_id` or `store=True`) + +**Remove Legacy Types:** +- [ ] `packages/core/agent_framework/_memory.py` - remove old `ContextProvider` class +- [ ] `packages/core/agent_framework/_threads.py` - remove `ChatMessageStore`, `ChatMessageStoreProtocol`, `AgentThread` +- [ ] Remove old provider classes from `azure-ai-search`, `redis`, `mem0` + +**Rename Temporary Types → Final Names:** +- [ ] `_ContextProviderBase` → `ContextProvider` in `_sessions.py` +- [ ] `_HistoryProviderBase` → `HistoryProvider` in `_sessions.py` +- [ ] `_AzureAISearchContextProvider` → `AzureAISearchContextProvider` in `packages/azure-ai-search/` +- [ ] `_Mem0ContextProvider` → `Mem0ContextProvider` in `packages/mem0/` +- [ ] `_RedisHistoryProvider` → `RedisHistoryProvider` in `packages/redis/` +- [ ] `_RedisContextProvider` → `RedisContextProvider` in `packages/redis/` +- [ ] Update all imports across packages and `__init__.py` exports to use final names + +**Public API (root package exports):** + +All base classes and `InMemoryHistoryProvider` are exported from the root package: +```python +from agent_framework import ( + ContextProvider, + HistoryProvider, + InMemoryHistoryProvider, + SessionContext, + AgentSession, +) +``` + +**Documentation & Samples:** +- [ ] Update all samples in `samples/` to use new API +- [ ] Write migration guide +- [ ] Update API documentation + +**Testing:** +- [ ] Unit tests for provider execution order (before_run forward, after_run reverse) +- [ ] Unit tests for validation warnings (multiple/zero loaders) +- [ ] Unit tests for session serialization (`session.to_dict()`/`AgentSession.from_dict()` round-trip) +- [ ] Integration test: agent with `context_providers` + `session` works +- [ ] Integration test: full conversation with memory persistence +- [ ] Ensure all existing tests still pass (with updated API) +- [ ] Verify no references to removed types remain + +--- + +#### CHANGELOG (single entry for release) + +- **[BREAKING]** Replaced `ContextProvider` with new `ContextProvider` (hooks pattern with `before_run`/`after_run`) +- **[BREAKING]** Replaced `ChatMessageStore` with `HistoryProvider` +- **[BREAKING]** Replaced `AgentThread` with `AgentSession` +- **[BREAKING]** Replaced `thread` parameter with `session` in `agent.run()` +- Added `SessionContext` for invocation state with source attribution +- Added `InMemoryHistoryProvider` for conversation history +- `AgentSession` provides `to_dict()`/`from_dict()` for serialization (no special serialize/restore on providers) + +--- + +#### Estimated Sizes + +| PR | New Lines | Modified Lines | Risk | +|----|-----------|----------------|------| +| PR1 | ~500 | ~0 | Low | +| PR2 | ~150 | ~400 | Medium | + +--- + +#### Implementation Detail: Decorator-based Providers + +For simple use cases, a class-based provider can be verbose. A decorator API allows registering plain functions as `before_run` or `after_run` hooks for a more Pythonic setup: + +```python +from agent_framework import ChatAgent, before_run, after_run + +agent = ChatAgent(chat_client=client) + +@before_run(agent) +async def add_system_prompt(agent, session, context, state): + """Inject a system prompt before every invocation.""" + context.extend_messages("system", [ChatMessage(role="system", content="You are helpful.")]) + +@after_run(agent) +async def log_response(agent, session, context, state): + """Log the response after every invocation.""" + print(f"Response: {context.response.text}") +``` + +Under the hood, the decorators create a `ContextProvider` instance wrapping the function and append it to `agent._context_providers`: + +```python +def before_run(agent: ChatAgent, *, source_id: str = "decorated"): + def decorator(fn): + provider = _FunctionContextProvider(source_id=source_id, before_fn=fn) + agent._context_providers.append(provider) + return fn + return decorator + +def after_run(agent: ChatAgent, *, source_id: str = "decorated"): + def decorator(fn): + provider = _FunctionContextProvider(source_id=source_id, after_fn=fn) + agent._context_providers.append(provider) + return fn + return decorator +``` + +This is a convenience layer — the class-based API remains the primary interface for providers that need configuration, state, or both hooks. + +--- + +#### Reference Implementation + +Full implementation code for the chosen design (hooks pattern, Decision B1). + +##### SessionContext + +```python +# Copyright (c) Microsoft. All rights reserved. + +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable, Sequence +from typing import Any + +from ._types import ChatMessage +from ._tools import ToolProtocol + + +class SessionContext: + """Per-invocation state passed through the context provider pipeline. + + Created fresh for each agent.run() call. Providers read from and write to + the mutable fields to add context before invocation and process responses after. + + Attributes: + session_id: The ID of the current session + service_session_id: Service-managed session ID (if present, service handles storage) + input_messages: The new messages being sent to the agent (read-only, set by caller) + context_messages: Dict mapping source_id -> messages added by that provider. + Maintains insertion order (provider execution order). Use extend_messages() + to add messages with proper source attribution. + instructions: Additional instructions - providers can append here + tools: Additional tools - providers can append here + response (property): After invocation, contains the full AgentResponse (set by agent). + Includes response.messages, response.response_id, response.agent_id, + response.usage_details, etc. + Read-only property - use AgentMiddleware to modify responses. + options: Options passed to agent.run() - READ-ONLY, for reflection only + metadata: Shared metadata dictionary for cross-provider communication + + Note: + - `options` is read-only; changes will NOT be merged back into the agent run + - `response` is a read-only property; use AgentMiddleware to modify responses + - `instructions` and `tools` are merged by the agent into the run options + - `context_messages` values are flattened in order when building the final input + """ + + def __init__( + self, + *, + session_id: str | None = None, + service_session_id: str | None = None, + input_messages: list[ChatMessage], + context_messages: dict[str, list[ChatMessage]] | None = None, + instructions: list[str] | None = None, + tools: list[ToolProtocol] | None = None, + options: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, + ): + self.session_id = session_id + self.service_session_id = service_session_id + self.input_messages = input_messages + self.context_messages: dict[str, list[ChatMessage]] = context_messages or {} + self.instructions: list[str] = instructions or [] + self.tools: list[ToolProtocol] = tools or [] + self._response: AgentResponse | None = None + self.options = options or {} # READ-ONLY - for reflection only + self.metadata = metadata or {} + + @property + def response(self) -> AgentResponse | None: + """The agent's response. Set by the framework after invocation, read-only for providers.""" + return self._response + + def extend_messages(self, source_id: str, messages: Sequence[ChatMessage]) -> None: + """Add context messages from a specific source. + + Messages are stored keyed by source_id, maintaining insertion order + based on provider execution order. + + Args: + source_id: The provider source_id adding these messages + messages: The messages to add + """ + if source_id not in self.context_messages: + self.context_messages[source_id] = [] + self.context_messages[source_id].extend(messages) + + def extend_instructions(self, source_id: str, instructions: str | Sequence[str]) -> None: + """Add instructions to be prepended to the conversation. + + Instructions are added to a flat list. The source_id is recorded + in metadata for debugging but instructions are not keyed by source. + + Args: + source_id: The provider source_id adding these instructions + instructions: A single instruction string or sequence of strings + """ + if isinstance(instructions, str): + instructions = [instructions] + self.instructions.extend(instructions) + + def extend_tools(self, source_id: str, tools: Sequence[ToolProtocol]) -> None: + """Add tools to be available for this invocation. + + Tools are added with source attribution in their metadata. + + Args: + source_id: The provider source_id adding these tools + tools: The tools to add + """ + for tool in tools: + if hasattr(tool, 'metadata') and isinstance(tool.metadata, dict): + tool.metadata["context_source"] = source_id + self.tools.extend(tools) + + def get_messages( + self, + *, + sources: Sequence[str] | None = None, + exclude_sources: Sequence[str] | None = None, + include_input: bool = False, + include_response: bool = False, + ) -> list[ChatMessage]: + """Get context messages, optionally filtered and including input/response. + + Returns messages in provider execution order (dict insertion order), + with input and response appended if requested. + + Args: + sources: If provided, only include context messages from these sources + exclude_sources: If provided, exclude context messages from these sources + include_input: If True, append input_messages after context + include_response: If True, append response.messages at the end + + Returns: + Flattened list of messages in conversation order + """ + result: list[ChatMessage] = [] + for source_id, messages in self.context_messages.items(): + if sources is not None and source_id not in sources: + continue + if exclude_sources is not None and source_id in exclude_sources: + continue + result.extend(messages) + if include_input and self.input_messages: + result.extend(self.input_messages) + if include_response and self.response: + result.extend(self.response.messages) + return result +``` + +##### ContextProvider + +```python +class ContextProvider(ABC): + """Base class for context providers (hooks pattern). + + Context providers participate in the context engineering pipeline, + adding context before model invocation and processing responses after. + + Attributes: + source_id: Unique identifier for this provider instance (required). + Used for message/tool attribution so other providers can filter. + """ + + def __init__(self, source_id: str): + """Initialize the provider. + + Args: + source_id: Unique identifier for this provider instance. + Used for message/tool attribution. + """ + self.source_id = source_id + + async def before_run( + self, + agent: "SupportsAgentRun", + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Called before model invocation. + + Override to add context (messages, instructions, tools) to the + SessionContext before the model is invoked. + + Args: + agent: The agent running this invocation + session: The current session + context: The invocation context - add messages/instructions/tools here + state: The session's mutable state dict + """ + pass + + async def after_run( + self, + agent: "SupportsAgentRun", + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Called after model invocation. + + Override to process the response (store messages, extract info, etc.). + The context.response.messages will be populated at this point. + + Args: + agent: The agent that ran this invocation + session: The current session + context: The invocation context with response populated + state: The session's mutable state dict + """ + pass +``` + +> **Serialization contract:** Any values a provider writes to `state` must be JSON-serializable. +> Sessions are serialized via `session.to_dict()` and restored via `AgentSession.from_dict()`. +``` + +##### HistoryProvider + +```python +class HistoryProvider(ContextProvider): + """Base class for conversation history storage providers. + + A single class that can be configured for different use cases: + - Primary memory storage (loads + stores messages) + - Audit/logging storage (stores only, doesn't load) + - Evaluation storage (stores only for later analysis) + + Loading behavior (when to add messages to context_messages[source_id]): + - `load_messages=True` (default): Load messages from storage + - `load_messages=False`: Agent skips `before_run` entirely (audit/logging mode) + + Storage behavior: + - `store_inputs`: Store input messages (default True) + - `store_responses`: Store response messages (default True) + - Storage always happens unless explicitly disabled, regardless of load_messages + + Warning: At session creation time, a warning is logged if: + - Multiple history providers have `load_messages=True` (likely duplicate loading) + - Zero history providers have `load_messages=True` (likely missing primary storage) + + Examples: + # Primary memory - loads and stores + memory = InMemoryHistoryProvider(source_id="memory") + + # Audit storage - stores only, doesn't add to context + audit = RedisHistoryProvider( + source_id="audit", + load_messages=False, + redis_url="redis://...", + ) + + # Full audit - stores everything including RAG context + full_audit = CosmosHistoryProvider( + source_id="full_audit", + load_messages=False, + store_context_messages=True, + ) + """ + + def __init__( + self, + source_id: str, + *, + load_messages: bool = True, + store_responses: bool = True, + store_inputs: bool = True, + store_context_messages: bool = False, + store_context_from: Sequence[str] | None = None, + ): + super().__init__(source_id) + self.load_messages = load_messages + self.store_responses = store_responses + self.store_inputs = store_inputs + self.store_context_messages = store_context_messages + self.store_context_from = list(store_context_from) if store_context_from else None + + @abstractmethod + async def get_messages(self, session_id: str | None) -> list[ChatMessage]: + """Retrieve stored messages for this session.""" + pass + + @abstractmethod + async def save_messages( + self, + session_id: str | None, + messages: Sequence[ChatMessage] + ) -> None: + """Persist messages for this session.""" + pass + + def _get_context_messages_to_store(self, context: SessionContext) -> list[ChatMessage]: + """Get context messages that should be stored based on configuration.""" + if not self.store_context_messages: + return [] + if self.store_context_from is not None: + return context.get_messages(sources=self.store_context_from) + else: + return context.get_messages(exclude_sources=[self.source_id]) + + async def before_run(self, agent, session, context, state) -> None: + """Load history into context. Skipped by the agent when load_messages=False.""" + history = await self.get_messages(context.session_id) + context.extend_messages(self.source_id, history) + + async def after_run(self, agent, session, context, state) -> None: + """Store messages based on configuration.""" + messages_to_store: list[ChatMessage] = [] + messages_to_store.extend(self._get_context_messages_to_store(context)) + if self.store_inputs: + messages_to_store.extend(context.input_messages) + if self.store_responses and context.response.messages: + messages_to_store.extend(context.response.messages) + if messages_to_store: + await self.save_messages(context.session_id, messages_to_store) +``` + +##### AgentSession + +```python +import uuid +import warnings +from collections.abc import Sequence + + +class AgentSession: + """A conversation session with an agent. + + Lightweight state container. Provider instances are owned by the agent, + not the session. The session only holds session IDs and a mutable state dict. + + Attributes: + session_id: Unique identifier for this session + service_session_id: Service-managed session ID (if using service-side storage) + state: Mutable state dict shared with all providers + """ + + def __init__( + self, + *, + session_id: str | None = None, + service_session_id: str | None = None, + ): + """Initialize the session. + + Note: Prefer using agent.create_session() instead of direct construction. + + Args: + session_id: Optional session ID (generated if not provided) + service_session_id: Optional service-managed session ID + """ + self._session_id = session_id or str(uuid.uuid4()) + self.service_session_id = service_session_id + self.state: dict[str, Any] = {} + + @property + def session_id(self) -> str: + """The unique identifier for this session.""" + return self._session_id + + def to_dict(self) -> dict[str, Any]: + """Serialize session to a plain dict for storage/transfer.""" + return { + "type": "session", + "session_id": self._session_id, + "service_session_id": self.service_session_id, + "state": self.state, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "AgentSession": + """Restore session from a previously serialized dict.""" + session = cls( + session_id=data["session_id"], + service_session_id=data.get("service_session_id"), + ) + session.state = data.get("state", {}) + return session +class ChatAgent: + def __init__( + self, + chat_client: ..., + *, + context_providers: Sequence[ContextProvider] | None = None, + ): + self._context_providers = list(context_providers or []) + + def create_session( + self, + *, + session_id: str | None = None, + ) -> AgentSession: + """Create a new lightweight session. + + Args: + session_id: Optional session ID (generated if not provided) + """ + return AgentSession(session_id=session_id) + + def get_session( + self, + service_session_id: str, + *, + session_id: str | None = None, + ) -> AgentSession: + """Get or create a session for a service-managed session ID. + + Args: + service_session_id: Service-managed session ID + session_id: Optional session ID (generated if not provided) + """ + session = AgentSession(session_id=session_id) + session.service_session_id = service_session_id + return session + + def _ensure_default_storage(self, session: AgentSession, options: dict[str, Any]) -> None: + """Add default InMemoryHistoryProvider if needed. + + Default storage is added when ALL of these are true: + - A session is provided (always the case here) + - No context_providers configured + - Either options.conversation_id is set or options.store is True + """ + if self._context_providers: + return + if options.get("conversation_id") or options.get("store") is True: + self._context_providers.append(InMemoryHistoryProvider("memory")) + + def _validate_providers(self) -> None: + """Warn if history provider configuration looks like a mistake.""" + storage_providers = [ + p for p in self._context_providers + if isinstance(p, HistoryProvider) + ] + if not storage_providers: + return + loaders = [p for p in storage_providers if p.load_messages is True] + if len(loaders) > 1: + warnings.warn( + f"Multiple history providers configured to load messages: " + f"{[p.source_id for p in loaders]}. " + f"This may cause duplicate messages in context.", + UserWarning + ) + elif len(loaders) == 0: + warnings.warn( + f"History providers configured but none have load_messages=True: " + f"{[p.source_id for p in storage_providers]}. " + f"No conversation history will be loaded.", + UserWarning + ) + + async def run(self, input: str, *, session: AgentSession, options: dict[str, Any] | None = None) -> ...: + """Run the agent with the given input.""" + options = options or {} + + # Ensure default storage on first run + self._ensure_default_storage(session, options) + self._validate_providers() + + context = SessionContext( + session_id=session.session_id, + service_session_id=session.service_session_id, + input_messages=[...], + options=options, + ) + + # Before-run providers (forward order, skip HistoryProviders with load_messages=False) + for provider in self._context_providers: + if isinstance(provider, HistoryProvider) and not provider.load_messages: + continue + await provider.before_run(self, session, context, session.state) + + # ... assemble final messages from context, invoke model ... + + # After-run providers (reverse order) + for provider in reversed(self._context_providers): + await provider.after_run(self, session, context, session.state) + + +# Session serialization is trivial — session.state is a plain dict: +# +# # Serialize +# data = { +# "session_id": session.session_id, +# "service_session_id": session.service_session_id, +# "state": session.state, +# } +# json_str = json.dumps(data) +# +# # Deserialize +# data = json.loads(json_str) +# session = AgentSession(session_id=data["session_id"], service_session_id=data.get("service_session_id")) +# session.state = data["state"] +``` diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 718c9edc07..7399e723a7 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -2,9 +2,9 @@ 1.0.0 - $(VersionPrefix)-$(VersionSuffix).260205.1 - $(VersionPrefix)-preview.260205.1 - 1.0.0-preview.260205.1 + $(VersionPrefix)-$(VersionSuffix).260209.1 + $(VersionPrefix)-preview.260209.1 + 1.0.0-preview.260209.1 Debug;Release;Publish true diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs index 0a10930b75..4f370b410e 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs @@ -6,7 +6,6 @@ using Azure.AI.Projects; using Azure.AI.Projects.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; diff --git a/dotnet/samples/GettingStarted/Workflows/Observability/ApplicationInsights/Program.cs b/dotnet/samples/GettingStarted/Workflows/Observability/ApplicationInsights/Program.cs index f7894f707a..a05a5cddf6 100644 --- a/dotnet/samples/GettingStarted/Workflows/Observability/ApplicationInsights/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Observability/ApplicationInsights/Program.cs @@ -35,8 +35,10 @@ public static class Program using var traceProvider = Sdk.CreateTracerProviderBuilder() .SetResourceBuilder(resourceBuilder) - .AddSource("Microsoft.Agents.AI.Workflows*") .AddSource(SourceName) + // The following source is only required if not specifying + // the `activitySource` in the WithOpenTelemetry call below + .AddSource("Microsoft.Agents.AI.Workflows*") .AddAzureMonitorTraceExporter(options => options.ConnectionString = applicationInsightsConnectionString) .Build(); @@ -51,6 +53,10 @@ public static class Program // Build the workflow by connecting executors sequentially var workflow = new WorkflowBuilder(uppercase) .AddEdge(uppercase, reverse) + .WithOpenTelemetry( + // Set `EnableSensitiveData` to true to include message content in traces + configure: cfg => cfg.EnableSensitiveData = true, + activitySource: s_activitySource) .Build(); // Execute the workflow with input data diff --git a/dotnet/samples/GettingStarted/Workflows/Observability/AspireDashboard/Program.cs b/dotnet/samples/GettingStarted/Workflows/Observability/AspireDashboard/Program.cs index c04a397c55..06eebfba9d 100644 --- a/dotnet/samples/GettingStarted/Workflows/Observability/AspireDashboard/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Observability/AspireDashboard/Program.cs @@ -37,8 +37,10 @@ public static class Program using var traceProvider = Sdk.CreateTracerProviderBuilder() .SetResourceBuilder(resourceBuilder) - .AddSource("Microsoft.Agents.AI.Workflows*") .AddSource(SourceName) + // The following source is only required if not specifying + // the `activitySource` in the WithOpenTelemetry call below + .AddSource("Microsoft.Agents.AI.Workflows*") .AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint)) .Build(); @@ -53,6 +55,10 @@ public static class Program // Build the workflow by connecting executors sequentially var workflow = new WorkflowBuilder(uppercase) .AddEdge(uppercase, reverse) + .WithOpenTelemetry( + // Set `EnableSensitiveData` to true to include message content in traces + configure: cfg => cfg.EnableSensitiveData = true, + activitySource: s_activitySource) .Build(); // Execute the workflow with input data diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs index 638bed1f90..ab7794f1b8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs @@ -1,5 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Diagnostics; +using Microsoft.Agents.AI.Workflows.Observability; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -41,4 +44,23 @@ public sealed class DeclarativeWorkflowOptions(WorkflowAgentProvider agentProvid /// Gets the used to create loggers for workflow components. /// public ILoggerFactory LoggerFactory { get; init; } = NullLoggerFactory.Instance; + + /// + /// Gets the callback to configure telemetry options. + /// + public Action? ConfigureTelemetry { get; init; } + + /// + /// Gets an optional for telemetry. + /// If provided, the caller retains ownership and is responsible for disposal. + /// If but is set, a shared default + /// activity source named "Microsoft.Agents.AI.Workflows" will be used. + /// + public ActivitySource? TelemetryActivitySource { get; init; } + + /// + /// Gets a value indicating whether telemetry is enabled. + /// Telemetry is enabled when either or is set. + /// + internal bool IsTelemetryEnabled => this.ConfigureTelemetry is not null || this.TelemetryActivitySource is not null; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 1837142568..a90b1bd9c9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -51,6 +51,14 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor this._workflowModel.Build(builder); + // Apply telemetry if configured + if (this._workflowOptions.IsTelemetryEnabled) + { + builder.WorkflowBuilder.WithOpenTelemetry( + this._workflowOptions.ConfigureTelemetry, + this._workflowOptions.TelemetryActivitySource); + } + // Build final workflow return builder.WorkflowBuilder.Build(validateOrphans: false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/DirectEdgeRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/DirectEdgeRunner.cs index ee303c500b..db643ab441 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/DirectEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/DirectEdgeRunner.cs @@ -14,7 +14,7 @@ internal sealed class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData protected internal override async ValueTask ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer) { - using var activity = s_activitySource.StartActivity(ActivityNames.EdgeGroupProcess); + using var activity = this.StartActivity(); activity? .SetTag(Tags.EdgeGroupType, nameof(DirectEdgeRunner)) .SetTag(Tags.MessageSourceId, this.EdgeData.SourceId) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeRunner.cs index d71fa539b3..309072c32f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeRunner.cs @@ -14,9 +14,6 @@ internal interface IStatefulEdgeRunner internal abstract class EdgeRunner { - protected static readonly string s_namespace = typeof(EdgeRunner).Namespace!; - protected static readonly ActivitySource s_activitySource = new(s_namespace); - // TODO: Can this be sync? protected internal abstract ValueTask ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer); } @@ -26,4 +23,6 @@ internal abstract class EdgeRunner( { protected IRunnerContext RunContext { get; } = Throw.IfNull(runContext); protected TEdgeData EdgeData { get; } = Throw.IfNull(edgeData); + + protected Activity? StartActivity() => this.RunContext.TelemetryContext.StartEdgeGroupProcessActivity(); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/FanInEdgeRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/FanInEdgeRunner.cs index 02c0252af3..be80ef34de 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/FanInEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/FanInEdgeRunner.cs @@ -19,7 +19,7 @@ internal sealed class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData e { Debug.Assert(!envelope.IsExternal, "FanIn edges should never be chased from external input"); - using var activity = s_activitySource.StartActivity(ActivityNames.EdgeGroupProcess); + using var activity = this.StartActivity(); activity? .SetTag(Tags.EdgeGroupType, nameof(FanInEdgeRunner)) .SetTag(Tags.MessageTargetId, this.EdgeData.SinkId); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/FanOutEdgeRunner.cs index aa6133955d..d1102d6554 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/FanOutEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/FanOutEdgeRunner.cs @@ -13,7 +13,7 @@ internal sealed class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData { protected internal override async ValueTask ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer) { - using var activity = s_activitySource.StartActivity(ActivityNames.EdgeGroupProcess); + using var activity = this.StartActivity(); activity? .SetTag(Tags.EdgeGroupType, nameof(FanOutEdgeRunner)) .SetTag(Tags.MessageSourceId, this.EdgeData.SourceId); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunnerContext.cs index e84080c6a7..1c3d167b03 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunnerContext.cs @@ -3,11 +3,14 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Observability; namespace Microsoft.Agents.AI.Workflows.Execution; internal interface IRunnerContext : IExternalRequestSink, ISuperStepJoinContext { + WorkflowTelemetryContext TelemetryContext { get; } + ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default); ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null, CancellationToken cancellationToken = default); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs index a7923a7d9b..fce4d9636a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs @@ -3,6 +3,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Observability; namespace Microsoft.Agents.AI.Workflows.Execution; @@ -12,6 +13,8 @@ internal interface ISuperStepRunner string StartExecutorId { get; } + WorkflowTelemetryContext TelemetryContext { get; } + bool HasUnservicedRequests { get; } bool HasUnprocessedMessages { get; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs index b47a692113..250a9ee612 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs @@ -13,9 +13,6 @@ namespace Microsoft.Agents.AI.Workflows.Execution; internal sealed class LockstepRunEventStream : IRunEventStream { - private static readonly string s_namespace = typeof(LockstepRunEventStream).Namespace!; - private static readonly ActivitySource s_activitySource = new(s_namespace); - private readonly CancellationTokenSource _stopCancellation = new(); private readonly InputWaiter _inputWaiter = new(); private int _isDisposed; @@ -53,7 +50,7 @@ internal sealed class LockstepRunEventStream : IRunEventStream this._stepRunner.OutgoingEvents.EventRaised += OnWorkflowEventAsync; - using Activity? activity = s_activitySource.StartActivity(ActivityNames.WorkflowRun); + using Activity? activity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity(); activity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.RunId, this._stepRunner.RunId); try diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ResponseEdgeRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ResponseEdgeRunner.cs index deab3bad52..969509e40b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ResponseEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ResponseEdgeRunner.cs @@ -25,7 +25,7 @@ internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string execu { Debug.Assert(envelope.IsExternal, "Input edges should only be chased from external input"); - using var activity = s_activitySource.StartActivity(ActivityNames.EdgeGroupProcess); + using var activity = this.StartActivity(); activity? .SetTag(Tags.EdgeGroupType, nameof(ResponseEdgeRunner)) .SetTag(Tags.MessageSourceId, envelope.SourceId) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs index ca0cc52641..4cce8df844 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs @@ -17,9 +17,6 @@ namespace Microsoft.Agents.AI.Workflows.Execution; /// internal sealed class StreamingRunEventStream : IRunEventStream { - private static readonly string s_namespace = typeof(StreamingRunEventStream).Namespace!; - private static readonly ActivitySource s_activitySource = new(s_namespace); - private readonly Channel _eventChannel; private readonly ISuperStepRunner _stepRunner; private readonly InputWaiter _inputWaiter; @@ -63,7 +60,7 @@ internal sealed class StreamingRunEventStream : IRunEventStream // Subscribe to events - they will flow directly to the channel as they're raised this._stepRunner.OutgoingEvents.EventRaised += OnEventRaisedAsync; - using Activity? activity = s_activitySource.StartActivity(ActivityNames.WorkflowRun); + using Activity? activity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity(); activity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.RunId, this._stepRunner.RunId); try diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs index ba9cbac4e1..3dba017fa7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs @@ -26,9 +26,6 @@ public abstract class Executor : IIdentified /// public string Id { get; } - private static readonly string s_namespace = typeof(Executor).Namespace!; - private static readonly ActivitySource s_activitySource = new(s_namespace); - // TODO: Add overloads for binding with a configuration/options object once the Configured hierarchy goes away. /// @@ -142,13 +139,13 @@ public abstract class Executor : IIdentified /// A ValueTask representing the asynchronous operation, wrapping the output from the executor. /// No handler found for the message type. /// An exception is generated while handling the message. - public async ValueTask ExecuteAsync(object message, TypeId messageType, IWorkflowContext context, CancellationToken cancellationToken = default) + public ValueTask ExecuteAsync(object message, TypeId messageType, IWorkflowContext context, CancellationToken cancellationToken = default) + => this.ExecuteAsync(message, messageType, context, WorkflowTelemetryContext.Disabled, cancellationToken); + + internal async ValueTask ExecuteAsync(object message, TypeId messageType, IWorkflowContext context, WorkflowTelemetryContext telemetryContext, CancellationToken cancellationToken = default) { - using var activity = s_activitySource.StartActivity(ActivityNames.ExecutorProcess, ActivityKind.Internal); - activity?.SetTag(Tags.ExecutorId, this.Id) - .SetTag(Tags.ExecutorType, this.GetType().FullName) - .SetTag(Tags.MessageType, messageType.TypeName) - .CreateSourceLinks(context.TraceContext); + using var activity = telemetryContext.StartExecutorProcessActivity(this.Id, this.GetType().FullName, messageType.TypeName, message); + activity?.CreateSourceLinks(context.TraceContext); await context.AddEventAsync(new ExecutorInvokedEvent(this.Id, message), cancellationToken).ConfigureAwait(false); @@ -183,6 +180,11 @@ public abstract class Executor : IIdentified return null; // Void result. } + // Output is not available if executor does not return anything, in which case + // messages sent in the handlers of this executor will be set in the message + // send activities. + telemetryContext.SetExecutorOutput(activity, result.Result); + // If we had a real return type, raise it as a SendMessage; TODO: Should we have a way to disable this behaviour? if (result.Result is not null && this.Options.AutoSendMessageHandlerResultObject) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index 644ab3ec82..58e1890eed 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Agents.AI.Workflows.Execution; +using Microsoft.Agents.AI.Workflows.Observability; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.InProc; @@ -70,6 +71,9 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle /// public string StartExecutorId { get; } + /// + public WorkflowTelemetryContext TelemetryContext => this.Workflow.TelemetryContext; + private readonly HashSet _knownValidInputTypes; public async ValueTask IsValidInputTypeAsync(Type messageType, CancellationToken cancellationToken = default) { @@ -201,6 +205,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle envelope.Message, envelope.MessageType, this.RunContext.BindWorkflowContext(receiverId, envelope.TraceContext), + this.TelemetryContext, cancellationToken ).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index ac0baf157f..419d46cd1b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -70,6 +70,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext this.ConcurrentRunsEnabled = enableConcurrentRuns; this.OutgoingEvents = outgoingEvents; } + public WorkflowTelemetryContext TelemetryContext => this._workflow.TelemetryContext; public IExternalRequestSink RegisterPort(string executorId, RequestPort port) { @@ -195,12 +196,10 @@ internal sealed class InProcessRunnerContext : IRunnerContext return this.OutgoingEvents.EnqueueAsync(workflowEvent); } - private static readonly string s_namespace = typeof(IWorkflowContext).Namespace!; - private static readonly ActivitySource s_activitySource = new(s_namespace); - public async ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null, CancellationToken cancellationToken = default) { - using Activity? activity = s_activitySource.StartActivity(ActivityNames.MessageSend, ActivityKind.Producer); + using Activity? activity = this._workflow.TelemetryContext.StartMessageSendActivity(sourceId, targetId, message); + // Create a carrier for trace context propagation var traceContext = activity is null ? null : new Dictionary(); if (traceContext is not null) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/ActivityNames.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/ActivityNames.cs index a845915a96..9d2912ecd3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/ActivityNames.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/ActivityNames.cs @@ -5,7 +5,7 @@ namespace Microsoft.Agents.AI.Workflows.Observability; internal static class ActivityNames { public const string WorkflowBuild = "workflow.build"; - public const string WorkflowRun = "workflow.run"; + public const string WorkflowRun = "workflow_invoke"; public const string MessageSend = "message.send"; public const string ExecutorProcess = "executor.process"; public const string EdgeGroupProcess = "edge_group.process"; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs index 9acba99eea..4b40e46f8c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs @@ -14,7 +14,10 @@ internal static class Tags public const string RunId = "run.id"; public const string ExecutorId = "executor.id"; public const string ExecutorType = "executor.type"; + public const string ExecutorInput = "executor.input"; + public const string ExecutorOutput = "executor.output"; public const string MessageType = "message.type"; + public const string MessageContent = "message.content"; public const string EdgeGroupType = "edge_group.type"; public const string MessageSourceId = "message.source_id"; public const string MessageTargetId = "message.target_id"; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs new file mode 100644 index 0000000000..e4b8d7a851 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace Microsoft.Agents.AI.Workflows.Observability; + +/// +/// Internal context for workflow telemetry, holding the enabled state and configuration options. +/// +internal sealed class WorkflowTelemetryContext +{ + private const string DefaultSourceName = "Microsoft.Agents.AI.Workflows"; + private static readonly ActivitySource s_defaultActivitySource = new(DefaultSourceName); + + /// + /// Gets a shared instance representing disabled telemetry. + /// + public static WorkflowTelemetryContext Disabled { get; } = new(); + + /// + /// Gets a value indicating whether telemetry is enabled. + /// + public bool IsEnabled { get; } + + /// + /// Gets the telemetry options. + /// + public WorkflowTelemetryOptions Options { get; } + + /// + /// Gets the activity source used for creating telemetry spans. + /// + public ActivitySource ActivitySource { get; } + + private WorkflowTelemetryContext() + { + this.IsEnabled = false; + this.Options = new WorkflowTelemetryOptions(); + this.ActivitySource = s_defaultActivitySource; + } + + /// + /// Initializes a new instance of the class with telemetry enabled. + /// + /// The telemetry options. + /// + /// An optional activity source to use. If provided, this activity source will be used directly + /// and the caller retains ownership (responsible for disposal). If , the + /// shared default activity source will be used. + /// + public WorkflowTelemetryContext(WorkflowTelemetryOptions options, ActivitySource? activitySource = null) + { + this.IsEnabled = true; + this.Options = options; + this.ActivitySource = activitySource ?? s_defaultActivitySource; + } + + /// + /// Starts an activity if telemetry is enabled, otherwise returns null. + /// + /// The activity name. + /// The activity kind. + /// An activity if telemetry is enabled and the activity is sampled, otherwise null. + public Activity? StartActivity(string name, ActivityKind kind = ActivityKind.Internal) + { + if (!this.IsEnabled) + { + return null; + } + + return this.ActivitySource.StartActivity(name, kind); + } + + /// + /// Starts a workflow build activity if enabled. + /// + /// An activity if workflow build telemetry is enabled, otherwise null. + public Activity? StartWorkflowBuildActivity() + { + if (!this.IsEnabled || this.Options.DisableWorkflowBuild) + { + return null; + } + + return this.ActivitySource.StartActivity(ActivityNames.WorkflowBuild); + } + + /// + /// Starts a workflow run activity if enabled. + /// + /// An activity if workflow run telemetry is enabled, otherwise null. + public Activity? StartWorkflowRunActivity() + { + if (!this.IsEnabled || this.Options.DisableWorkflowRun) + { + return null; + } + + return this.ActivitySource.StartActivity(ActivityNames.WorkflowRun); + } + + /// + /// Starts an executor process activity if enabled, with all standard tags set. + /// + /// The executor identifier. + /// The executor type name. + /// The message type name. + /// The input message. Logged only when is true. + /// An activity if executor process telemetry is enabled, otherwise null. + public Activity? StartExecutorProcessActivity(string executorId, string? executorType, string messageType, object? message) + { + if (!this.IsEnabled || this.Options.DisableExecutorProcess) + { + return null; + } + + Activity? activity = this.ActivitySource.StartActivity(ActivityNames.ExecutorProcess + " " + executorId); + if (activity is null) + { + return null; + } + + activity.SetTag(Tags.ExecutorId, executorId) + .SetTag(Tags.ExecutorType, executorType) + .SetTag(Tags.MessageType, messageType); + + if (this.Options.EnableSensitiveData) + { + activity.SetTag(Tags.ExecutorInput, SerializeForTelemetry(message)); + } + + return activity; + } + + /// + /// Sets the executor output tag on an activity when sensitive data logging is enabled. + /// + /// The activity to set the output on. + /// The output value to log. + public void SetExecutorOutput(Activity? activity, object? output) + { + if (activity is not null && this.Options.EnableSensitiveData) + { + activity.SetTag(Tags.ExecutorOutput, SerializeForTelemetry(output)); + } + } + + /// + /// Starts an edge group process activity if enabled. + /// + /// An activity if edge group process telemetry is enabled, otherwise null. + public Activity? StartEdgeGroupProcessActivity() + { + if (!this.IsEnabled || this.Options.DisableEdgeGroupProcess) + { + return null; + } + + return this.ActivitySource.StartActivity(ActivityNames.EdgeGroupProcess); + } + + /// + /// Starts a message send activity if enabled, with all standard tags set. + /// + /// The source executor identifier. + /// The target executor identifier, if any. + /// The message being sent. Logged only when is true. + /// An activity if message send telemetry is enabled, otherwise null. + public Activity? StartMessageSendActivity(string sourceId, string? targetId, object? message) + { + if (!this.IsEnabled || this.Options.DisableMessageSend) + { + return null; + } + + Activity? activity = this.ActivitySource.StartActivity(ActivityNames.MessageSend, ActivityKind.Producer); + if (activity is null) + { + return null; + } + + activity.SetTag(Tags.MessageSourceId, sourceId); + if (targetId is not null) + { + activity.SetTag(Tags.MessageTargetId, targetId); + } + + if (this.Options.EnableSensitiveData) + { + activity.SetTag(Tags.MessageContent, SerializeForTelemetry(message)); + } + + return activity; + } + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Telemetry serialization is optional and only used when explicitly enabled.")] + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Telemetry serialization is optional and only used when explicitly enabled.")] + private static string? SerializeForTelemetry(object? value) + { + if (value is null) + { + return null; + } + + try + { + return JsonSerializer.Serialize(value, value.GetType()); + } + catch (JsonException) + { + return $"[Unserializable: {value.GetType().FullName}]"; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryOptions.cs new file mode 100644 index 0000000000..b32f0c0f66 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryOptions.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows.Observability; + +/// +/// Configuration options for workflow telemetry. +/// +public sealed class WorkflowTelemetryOptions +{ + /// + /// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry. + /// + /// + /// if potentially sensitive information should be included in telemetry; + /// if telemetry shouldn't include raw inputs and outputs. + /// The default value is . + /// + /// + /// By default, telemetry includes metadata but not raw inputs and outputs, + /// such as message content and executor data. + /// + public bool EnableSensitiveData { get; set; } + + /// + /// Gets or sets a value indicating whether workflow build activities should be disabled. + /// + /// + /// to disable workflow.build activities; + /// to enable them. The default value is . + /// + public bool DisableWorkflowBuild { get; set; } + + /// + /// Gets or sets a value indicating whether workflow run activities should be disabled. + /// + /// + /// to disable workflow_invoke activities; + /// to enable them. The default value is . + /// + public bool DisableWorkflowRun { get; set; } + + /// + /// Gets or sets a value indicating whether executor process activities should be disabled. + /// + /// + /// to disable executor.process activities; + /// to enable them. The default value is . + /// + public bool DisableExecutorProcess { get; set; } + + /// + /// Gets or sets a value indicating whether edge group process activities should be disabled. + /// + /// + /// to disable edge_group.process activities; + /// to enable them. The default value is . + /// + public bool DisableEdgeGroupProcess { get; set; } + + /// + /// Gets or sets a value indicating whether message send activities should be disabled. + /// + /// + /// to disable message.send activities; + /// to enable them. The default value is . + /// + public bool DisableMessageSend { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/OpenTelemetryWorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/OpenTelemetryWorkflowBuilderExtensions.cs new file mode 100644 index 0000000000..ffa0f0362d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/OpenTelemetryWorkflowBuilderExtensions.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics; +using Microsoft.Agents.AI.Workflows.Observability; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Provides extension methods for adding OpenTelemetry instrumentation to instances. +/// +public static class OpenTelemetryWorkflowBuilderExtensions +{ + /// + /// Enables OpenTelemetry instrumentation for the workflow, providing comprehensive observability for workflow operations. + /// + /// The to which OpenTelemetry support will be added. + /// + /// An optional callback that provides additional configuration of the instance. + /// This allows for fine-tuning telemetry behavior such as enabling sensitive data collection. + /// + /// + /// An optional to use for telemetry. If provided, this activity source will be used + /// directly and the caller retains ownership (responsible for disposal). If , a shared + /// default activity source named "Microsoft.Agents.AI.Workflows" will be used. + /// + /// The with OpenTelemetry instrumentation enabled, enabling method chaining. + /// is . + /// + /// + /// This extension adds comprehensive telemetry capabilities to workflows, including: + /// + /// Distributed tracing of workflow execution + /// Executor invocation and processing spans + /// Edge routing and message delivery spans + /// Workflow build and validation spans + /// Error tracking and exception details + /// + /// + /// + /// By default, workflow telemetry is disabled. Call this method to enable telemetry collection. + /// + /// + /// + /// + /// var workflow = new WorkflowBuilder(startExecutor) + /// .AddEdge(executor1, executor2) + /// .WithOpenTelemetry(cfg => cfg.EnableSensitiveData = true) + /// .Build(); + /// + /// + public static WorkflowBuilder WithOpenTelemetry( + this WorkflowBuilder builder, + Action? configure = null, + ActivitySource? activitySource = null) + { + Throw.IfNull(builder); + + WorkflowTelemetryOptions options = new(); + configure?.Invoke(options); + + WorkflowTelemetryContext context = new(options, activitySource); + + builder.SetTelemetryContext(context); + + return builder; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs index 8b77da7fc2..6b26a403cf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Agents.AI.Workflows.Execution; +using Microsoft.Agents.AI.Workflows.Observability; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows; @@ -76,6 +77,11 @@ public class Workflow /// public string? Description { get; internal init; } + /// + /// Gets the telemetry context for the workflow. + /// + internal WorkflowTelemetryContext TelemetryContext { get; } + internal bool AllowConcurrent => this.ExecutorBindings.Values.All(registration => registration.SupportsConcurrentSharedExecution); internal IEnumerable NonConcurrentExecutorIds => @@ -88,11 +94,13 @@ public class Workflow /// The unique identifier of the starting executor for the workflow. Cannot be null. /// Optional human-readable name for the workflow. /// Optional description of what the workflow does. - internal Workflow(string startExecutorId, string? name = null, string? description = null) + /// Optional telemetry context for the workflow. + internal Workflow(string startExecutorId, string? name = null, string? description = null, WorkflowTelemetryContext? telemetryContext = null) { this.StartExecutorId = Throw.IfNull(startExecutorId); this.Name = name; this.Description = description; + this.TelemetryContext = telemetryContext ?? WorkflowTelemetryContext.Disabled; } private bool _needsReset; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs index 4b6980d433..36a3468e2d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs @@ -38,9 +38,7 @@ public class WorkflowBuilder private readonly string _startExecutorId; private string? _name; private string? _description; - - private static readonly string s_namespace = typeof(WorkflowBuilder).Namespace!; - private static readonly ActivitySource s_activitySource = new(s_namespace); + private WorkflowTelemetryContext _telemetryContext = WorkflowTelemetryContext.Disabled; /// /// Initializes a new instance of the WorkflowBuilder class with the specified starting executor. @@ -137,6 +135,15 @@ public class WorkflowBuilder return this; } + /// + /// Sets the telemetry context for the workflow. + /// + /// The telemetry context to use. + internal void SetTelemetryContext(WorkflowTelemetryContext context) + { + this._telemetryContext = Throw.IfNull(context); + } + /// /// Binds the specified executor (via registration) to the workflow, allowing it to participate in workflow execution. /// @@ -563,7 +570,7 @@ public class WorkflowBuilder activity?.AddEvent(new ActivityEvent(EventNames.BuildValidationCompleted)); - var workflow = new Workflow(this._startExecutorId, this._name, this._description) + var workflow = new Workflow(this._startExecutorId, this._name, this._description, this._telemetryContext) { ExecutorBindings = this._executorBindings, Edges = this._edges, @@ -601,7 +608,7 @@ public class WorkflowBuilder /// or if the start executor is not bound. public Workflow Build(bool validateOrphans = true) { - using Activity? activity = s_activitySource.StartActivity(ActivityNames.WorkflowBuild); + using Activity? activity = this._telemetryContext.StartWorkflowBuildActivity(); var workflow = this.BuildInternal(validateOrphans, activity); diff --git a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosDBCollectionFixture.cs b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosDBCollectionFixture.cs index 195c433de5..d6825ad30d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosDBCollectionFixture.cs +++ b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosDBCollectionFixture.cs @@ -1,7 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using Xunit; - namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests; /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowOptionsTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowOptionsTest.cs new file mode 100644 index 0000000000..73c4792e32 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowOptionsTest.cs @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Observability; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests; + +/// +/// Tests for telemetry configuration. +/// +[Collection("DeclarativeWorkflowOptionsTest")] +public sealed class DeclarativeWorkflowOptionsTest : IDisposable +{ + // These constants mirror Microsoft.Agents.AI.Workflows.Observability.ActivityNames + // which is internal and not accessible from this test project. + private const string WorkflowBuildActivityName = "workflow.build"; + private const string WorkflowRunActivityName = "workflow_invoke"; + + // The default activity source name used by the workflow telemetry context. + private const string DefaultTelemetrySourceName = "Microsoft.Agents.AI.Workflows"; + + private const string SimpleWorkflowYaml = """ + kind: Workflow + trigger: + kind: OnConversationStart + id: test_workflow + actions: + - kind: EndConversation + id: end_all + """; + + private readonly ActivitySource _activitySource = new("TestSource"); + private readonly ActivityListener _activityListener; + private readonly ConcurrentBag _capturedActivities = []; + + public DeclarativeWorkflowOptionsTest() + { + this._activityListener = new ActivityListener + { + ShouldListenTo = source => + source.Name == DefaultTelemetrySourceName || + source.Name == "TestSource", + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData, + ActivityStarted = activity => this._capturedActivities.Add(activity), + }; + ActivitySource.AddActivityListener(this._activityListener); + } + + public void Dispose() + { + this._activityListener.Dispose(); + this._activitySource.Dispose(); + } + + [Fact] + public void ConfigureTelemetry_DefaultIsNull() + { + // Arrange + Mock mockProvider = CreateMockProvider(); + + // Act + DeclarativeWorkflowOptions options = new(mockProvider.Object); + + // Assert + Assert.Null(options.ConfigureTelemetry); + } + + [Fact] + public void ConfigureTelemetry_CanBeSet() + { + // Arrange + Mock mockProvider = CreateMockProvider(); + bool callbackInvoked = false; + + // Act + DeclarativeWorkflowOptions options = new(mockProvider.Object) + { + ConfigureTelemetry = opt => + { + callbackInvoked = true; + opt.EnableSensitiveData = true; + } + }; + + // Assert + Assert.NotNull(options.ConfigureTelemetry); + WorkflowTelemetryOptions telemetryOptions = new(); + options.ConfigureTelemetry(telemetryOptions); + Assert.True(callbackInvoked); + Assert.True(telemetryOptions.EnableSensitiveData); + } + + [Fact] + public void TelemetryActivitySource_DefaultIsNull() + { + // Arrange + Mock mockProvider = CreateMockProvider(); + + // Act + DeclarativeWorkflowOptions options = new(mockProvider.Object); + + // Assert + Assert.Null(options.TelemetryActivitySource); + } + + [Fact] + public void TelemetryActivitySource_CanBeSet() + { + // Arrange + Mock mockProvider = CreateMockProvider(); + + // Act + DeclarativeWorkflowOptions options = new(mockProvider.Object) + { + TelemetryActivitySource = this._activitySource + }; + + // Assert + Assert.Same(this._activitySource, options.TelemetryActivitySource); + } + + [Fact] + public async Task BuildWorkflow_WithDefaultTelemetry_AppliesTelemetryAsync() + { + // Arrange + using Activity testActivity = new Activity("DefaultTelemetryTest").Start()!; + Mock mockProvider = CreateMockProvider(); + DeclarativeWorkflowOptions options = new(mockProvider.Object) + { + ConfigureTelemetry = _ => { }, + LoggerFactory = NullLoggerFactory.Instance + }; + + // Act + using StringReader reader = new(SimpleWorkflowYaml); + Workflow workflow = DeclarativeWorkflowBuilder.Build(reader, options); + + await using Run run = await InProcessExecution.RunAsync(workflow, "test input"); + + // Assert + Activity[] capturedActivities = this._capturedActivities + .Where(a => a.RootId == testActivity.RootId && a.Source.Name == DefaultTelemetrySourceName) + .ToArray(); + + Assert.NotEmpty(capturedActivities); + Assert.Contains(capturedActivities, a => a.OperationName.StartsWith(WorkflowBuildActivityName, StringComparison.Ordinal)); + Assert.Contains(capturedActivities, a => a.OperationName.StartsWith(WorkflowRunActivityName, StringComparison.Ordinal)); + } + + [Fact] + public async Task BuildWorkflow_WithTelemetryActivitySource_AppliesTelemetryAsync() + { + // Arrange + using Activity testActivity = new Activity("TelemetryActivitySourceTest").Start()!; + Mock mockProvider = CreateMockProvider(); + DeclarativeWorkflowOptions options = new(mockProvider.Object) + { + TelemetryActivitySource = this._activitySource, + LoggerFactory = NullLoggerFactory.Instance + }; + + // Act + using StringReader reader = new(SimpleWorkflowYaml); + Workflow workflow = DeclarativeWorkflowBuilder.Build(reader, options); + + await using Run run = await InProcessExecution.RunAsync(workflow, "test input"); + + // Assert + Activity[] capturedActivities = this._capturedActivities + .Where(a => a.RootId == testActivity.RootId && a.Source.Name == "TestSource") + .ToArray(); + + Assert.NotEmpty(capturedActivities); + Assert.All(capturedActivities, a => Assert.Equal("TestSource", a.Source.Name)); + } + + [Fact] + public async Task BuildWorkflow_WithConfigureTelemetry_AppliesConfigurationAsync() + { + // Arrange + using Activity testActivity = new Activity("ConfigureTelemetryTest").Start()!; + Mock mockProvider = CreateMockProvider(); + bool configureInvoked = false; + DeclarativeWorkflowOptions options = new(mockProvider.Object) + { + ConfigureTelemetry = opt => + { + configureInvoked = true; + opt.EnableSensitiveData = true; + }, + LoggerFactory = NullLoggerFactory.Instance + }; + + // Act + using StringReader reader = new(SimpleWorkflowYaml); + Workflow workflow = DeclarativeWorkflowBuilder.Build(reader, options); + + await using Run run = await InProcessExecution.RunAsync(workflow, "test input"); + + // Assert + Assert.True(configureInvoked); + + Activity[] capturedActivities = this._capturedActivities + .Where(a => a.RootId == testActivity.RootId && a.Source.Name == DefaultTelemetrySourceName) + .ToArray(); + + Assert.NotEmpty(capturedActivities); + Assert.Contains(capturedActivities, a => a.OperationName.StartsWith(WorkflowBuildActivityName, StringComparison.Ordinal)); + Assert.Contains(capturedActivities, a => a.OperationName.StartsWith(WorkflowRunActivityName, StringComparison.Ordinal)); + } + + [Fact] + public async Task BuildWorkflow_WithoutTelemetry_DoesNotCreateActivitiesAsync() + { + // Arrange + using Activity testActivity = new Activity("NoTelemetryTest").Start()!; + Mock mockProvider = CreateMockProvider(); + DeclarativeWorkflowOptions options = new(mockProvider.Object) + { + LoggerFactory = NullLoggerFactory.Instance + }; + + // Act + using StringReader reader = new(SimpleWorkflowYaml); + Workflow workflow = DeclarativeWorkflowBuilder.Build(reader, options); + + await using Run run = await InProcessExecution.RunAsync(workflow, "test input"); + + // Assert - No workflow activities should be created when telemetry is disabled + Activity[] capturedActivities = this._capturedActivities + .Where(a => a.RootId == testActivity.RootId && + (a.OperationName.StartsWith(WorkflowBuildActivityName, StringComparison.Ordinal) || + a.OperationName.StartsWith(WorkflowRunActivityName, StringComparison.Ordinal))) + .ToArray(); + + Assert.Empty(capturedActivities); + } + + private static Mock CreateMockProvider() + { + Mock mockAgentProvider = new(MockBehavior.Strict); + mockAgentProvider + .Setup(provider => provider.CreateConversationAsync(It.IsAny())) + .Returns(() => Task.FromResult(Guid.NewGuid().ToString("N"))); + mockAgentProvider + .Setup(provider => provider.CreateMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ChatMessage(ChatRole.Assistant, "Test response"))); + return mockAgentProvider; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs index 8ab6280b46..e7a99d5ca2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs @@ -67,7 +67,7 @@ public sealed class ObservabilityTests : IDisposable WorkflowBuilder builder = new(uppercase); builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse); - return builder.Build(); + return builder.WithOpenTelemetry().Build(); } private static Dictionary GetExpectedActivityNameCounts() => @@ -111,8 +111,6 @@ public sealed class ObservabilityTests : IDisposable Run run = await executionEnvironment.RunAsync(workflow, "Hello, World!"); await run.DisposeAsync(); - await Task.Delay(100); // Allow time for activities to be captured - // Assert var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); capturedActivities.Should().HaveCount(8, "Exactly 8 activities should be created."); @@ -122,12 +120,12 @@ public sealed class ObservabilityTests : IDisposable { var activityName = kvp.Key; var expectedCount = kvp.Value; - var actualCount = capturedActivities.Count(a => a.OperationName == activityName); + var actualCount = capturedActivities.Count(a => a.OperationName.StartsWith(activityName, StringComparison.Ordinal)); actualCount.Should().Be(expectedCount, $"Activity '{activityName}' should occur {expectedCount} times."); } // Verify WorkflowRun activity events include workflow lifecycle events - var workflowRunActivity = capturedActivities.First(a => a.OperationName == ActivityNames.WorkflowRun); + var workflowRunActivity = capturedActivities.First(a => a.OperationName.StartsWith(ActivityNames.WorkflowRun, StringComparison.Ordinal)); var activityEvents = workflowRunActivity.Events.ToList(); activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowStarted, "activity should have workflow started event"); activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowCompleted, "activity should have workflow completed event"); @@ -166,8 +164,6 @@ public sealed class ObservabilityTests : IDisposable // Act CreateWorkflow(); - await Task.Delay(100); // Allow time for activities to be captured - // Assert var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); capturedActivities.Should().HaveCount(1, "Exactly 1 activity should be created."); @@ -183,4 +179,325 @@ public sealed class ObservabilityTests : IDisposable tags.Should().ContainKey(Tags.WorkflowId); tags.Should().ContainKey(Tags.WorkflowDefinition); } + + [Fact] + public async Task TelemetryDisabledByDefault_CreatesNoActivitiesAsync() + { + // Arrange + // Create a test activity to correlate captured activities + using var testActivity = new Activity("ObservabilityTest").Start(); + + // Act - Build workflow WITHOUT calling WithOpenTelemetry() + Func uppercaseFunc = s => s.ToUpperInvariant(); + var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor"); + + WorkflowBuilder builder = new(uppercase); + builder.Build(); // No WithOpenTelemetry() call + // Assert - No activities should be created + var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); + capturedActivities.Should().BeEmpty("No activities should be created when telemetry is disabled (default)."); + } + + [Fact] + public async Task WithOpenTelemetry_UsesProvidedActivitySourceAsync() + { + // Arrange + using var testActivity = new Activity("ObservabilityTest").Start(); + using var userActivitySource = new ActivitySource("UserProvidedSource"); + + // Set up a separate listener for the user-provided source + ConcurrentBag userActivities = []; + using var userListener = new ActivityListener + { + ShouldListenTo = source => source.Name == "UserProvidedSource", + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData, + ActivityStarted = activity => userActivities.Add(activity), + }; + ActivitySource.AddActivityListener(userListener); + + Func uppercaseFunc = s => s.ToUpperInvariant(); + var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor"); + + // Act + WorkflowBuilder builder = new(uppercase); + var workflow = builder.WithOpenTelemetry(activitySource: userActivitySource).Build(); + + Run run = await InProcessExecution.Default.RunAsync(workflow, "Hello"); + await run.DisposeAsync(); + + // Assert + var capturedActivities = userActivities.Where(a => a.RootId == testActivity.RootId).ToList(); + capturedActivities.Should().NotBeEmpty("Activities should be created with user-provided ActivitySource."); + capturedActivities.Should().OnlyContain( + a => a.Source.Name == "UserProvidedSource", + "All activities should come from the user-provided ActivitySource."); + } + + [Fact] + public async Task DisableWorkflowBuild_PreventsWorkflowBuildActivityAsync() + { + // Arrange + using var testActivity = new Activity("ObservabilityTest").Start(); + + Func uppercaseFunc = s => s.ToUpperInvariant(); + var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor"); + + // Act + WorkflowBuilder builder = new(uppercase); + builder.WithOpenTelemetry(configure: opts => opts.DisableWorkflowBuild = true).Build(); + + // Assert + var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); + capturedActivities.Should().NotContain( + a => a.OperationName.StartsWith(ActivityNames.WorkflowBuild, StringComparison.Ordinal), + "WorkflowBuild activity should be disabled."); + } + + [Fact] + public async Task DisableWorkflowRun_PreventsWorkflowRunActivityAsync() + { + // Arrange + using var testActivity = new Activity("ObservabilityTest").Start(); + + Func uppercaseFunc = s => s.ToUpperInvariant(); + var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor"); + + // Act + WorkflowBuilder builder = new(uppercase); + builder.WithOutputFrom(uppercase); + var workflow = builder.WithOpenTelemetry(configure: opts => opts.DisableWorkflowRun = true).Build(); + + Run run = await InProcessExecution.Default.RunAsync(workflow, "Hello"); + await run.DisposeAsync(); + + // Assert + var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); + capturedActivities.Should().NotContain( + a => a.OperationName.StartsWith(ActivityNames.WorkflowRun, StringComparison.Ordinal), + "WorkflowRun activity should be disabled."); + capturedActivities.Should().Contain( + a => a.OperationName.StartsWith(ActivityNames.WorkflowBuild, StringComparison.Ordinal), + "Other activities should still be created."); + } + + [Fact] + public async Task DisableExecutorProcess_PreventsExecutorProcessActivityAsync() + { + // Arrange + using var testActivity = new Activity("ObservabilityTest").Start(); + + Func uppercaseFunc = s => s.ToUpperInvariant(); + var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor"); + + // Act + WorkflowBuilder builder = new(uppercase); + builder.WithOutputFrom(uppercase); + var workflow = builder.WithOpenTelemetry(configure: opts => opts.DisableExecutorProcess = true).Build(); + + Run run = await InProcessExecution.Default.RunAsync(workflow, "Hello"); + await run.DisposeAsync(); + + // Assert + var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); + capturedActivities.Should().NotContain( + a => a.OperationName.StartsWith(ActivityNames.ExecutorProcess, StringComparison.Ordinal), + "ExecutorProcess activity should be disabled."); + capturedActivities.Should().Contain( + a => a.OperationName.StartsWith(ActivityNames.WorkflowRun, StringComparison.Ordinal), + "Other activities should still be created."); + } + + [Fact] + public async Task DisableEdgeGroupProcess_PreventsEdgeGroupProcessActivityAsync() + { + // Arrange + using var testActivity = new Activity("ObservabilityTest").Start(); + var workflow = CreateWorkflowWithDisabledEdges(); + + // Act + Run run = await InProcessExecution.Default.RunAsync(workflow, "Hello"); + await run.DisposeAsync(); + + // Assert + var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); + capturedActivities.Should().NotContain( + a => a.OperationName.StartsWith(ActivityNames.EdgeGroupProcess, StringComparison.Ordinal), + "EdgeGroupProcess activity should be disabled."); + capturedActivities.Should().Contain( + a => a.OperationName.StartsWith(ActivityNames.ExecutorProcess, StringComparison.Ordinal), + "Other activities should still be created."); + } + + [Fact] + public async Task DisableMessageSend_PreventsMessageSendActivityAsync() + { + // Arrange + using var testActivity = new Activity("ObservabilityTest").Start(); + var workflow = CreateWorkflowWithDisabledMessages(); + + // Act + Run run = await InProcessExecution.Default.RunAsync(workflow, "Hello"); + await run.DisposeAsync(); + + // Assert + var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); + capturedActivities.Should().NotContain( + a => a.OperationName.StartsWith(ActivityNames.MessageSend, StringComparison.Ordinal), + "MessageSend activity should be disabled."); + capturedActivities.Should().Contain( + a => a.OperationName.StartsWith(ActivityNames.ExecutorProcess, StringComparison.Ordinal), + "Other activities should still be created."); + } + + private static Workflow CreateWorkflowWithDisabledEdges() + { + Func uppercaseFunc = s => s.ToUpperInvariant(); + var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor"); + + Func reverseFunc = s => new string(s.Reverse().ToArray()); + var reverse = reverseFunc.BindAsExecutor("ReverseTextExecutor"); + + WorkflowBuilder builder = new(uppercase); + builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse); + + return builder.WithOpenTelemetry(configure: opts => opts.DisableEdgeGroupProcess = true).Build(); + } + + private static Workflow CreateWorkflowWithDisabledMessages() + { + Func uppercaseFunc = s => s.ToUpperInvariant(); + var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor"); + + Func reverseFunc = s => new string(s.Reverse().ToArray()); + var reverse = reverseFunc.BindAsExecutor("ReverseTextExecutor"); + + WorkflowBuilder builder = new(uppercase); + builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse); + + return builder.WithOpenTelemetry(configure: opts => opts.DisableMessageSend = true).Build(); + } + + [Fact] + public async Task EnableSensitiveData_LogsExecutorInputAndOutputAsync() + { + // Arrange + using var testActivity = new Activity("ObservabilityTest").Start(); + + Func uppercaseFunc = s => s.ToUpperInvariant(); + var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor"); + + // Act + WorkflowBuilder builder = new(uppercase); + builder.WithOutputFrom(uppercase); + var workflow = builder.WithOpenTelemetry(configure: opts => opts.EnableSensitiveData = true).Build(); + + Run run = await InProcessExecution.Default.RunAsync(workflow, "hello"); + await run.DisposeAsync(); + + // Assert + var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); + var executorActivity = capturedActivities.FirstOrDefault( + a => a.OperationName.StartsWith(ActivityNames.ExecutorProcess, StringComparison.Ordinal)); + + executorActivity.Should().NotBeNull("ExecutorProcess activity should be created."); + + var tags = executorActivity!.Tags.ToDictionary(t => t.Key, t => t.Value); + tags.Should().ContainKey(Tags.ExecutorInput, "Input should be logged when EnableSensitiveData is true."); + tags.Should().ContainKey(Tags.ExecutorOutput, "Output should be logged when EnableSensitiveData is true."); + tags[Tags.ExecutorInput].Should().Contain("hello", "Input should contain the input value."); + tags[Tags.ExecutorOutput].Should().Contain("HELLO", "Output should contain the transformed value."); + } + + [Fact] + public async Task EnableSensitiveData_Disabled_DoesNotLogInputOutputAsync() + { + // Arrange + using var testActivity = new Activity("ObservabilityTest").Start(); + + Func uppercaseFunc = s => s.ToUpperInvariant(); + var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor"); + + // Act - EnableSensitiveData is false by default + WorkflowBuilder builder = new(uppercase); + builder.WithOutputFrom(uppercase); + var workflow = builder.WithOpenTelemetry().Build(); + + Run run = await InProcessExecution.Default.RunAsync(workflow, "hello"); + await run.DisposeAsync(); + + // Assert + var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); + var executorActivity = capturedActivities.FirstOrDefault( + a => a.OperationName.StartsWith(ActivityNames.ExecutorProcess, StringComparison.Ordinal)); + + executorActivity.Should().NotBeNull("ExecutorProcess activity should be created."); + + var tags = executorActivity!.Tags.ToDictionary(t => t.Key, t => t.Value); + tags.Should().NotContainKey(Tags.ExecutorInput, "Input should NOT be logged when EnableSensitiveData is false."); + tags.Should().NotContainKey(Tags.ExecutorOutput, "Output should NOT be logged when EnableSensitiveData is false."); + } + + [Fact] + public async Task EnableSensitiveData_LogsMessageSendContentAsync() + { + // Arrange + using var testActivity = new Activity("ObservabilityTest").Start(); + + Func uppercaseFunc = s => s.ToUpperInvariant(); + var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor"); + + Func reverseFunc = s => new string(s.Reverse().ToArray()); + var reverse = reverseFunc.BindAsExecutor("ReverseTextExecutor"); + + // Act + WorkflowBuilder builder = new(uppercase); + builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse); + var workflow = builder.WithOpenTelemetry(configure: opts => opts.EnableSensitiveData = true).Build(); + + Run run = await InProcessExecution.Default.RunAsync(workflow, "hello"); + await run.DisposeAsync(); + + // Assert + var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); + var messageSendActivity = capturedActivities.FirstOrDefault( + a => a.OperationName.StartsWith(ActivityNames.MessageSend, StringComparison.Ordinal)); + + messageSendActivity.Should().NotBeNull("MessageSend activity should be created."); + + var tags = messageSendActivity!.Tags.ToDictionary(t => t.Key, t => t.Value); + tags.Should().ContainKey(Tags.MessageContent, "Message content should be logged when EnableSensitiveData is true."); + tags.Should().ContainKey(Tags.MessageSourceId, "Source ID should be logged."); + } + + [Fact] + public async Task EnableSensitiveData_Disabled_DoesNotLogMessageContentAsync() + { + // Arrange + using var testActivity = new Activity("ObservabilityTest").Start(); + + Func uppercaseFunc = s => s.ToUpperInvariant(); + var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor"); + + Func reverseFunc = s => new string(s.Reverse().ToArray()); + var reverse = reverseFunc.BindAsExecutor("ReverseTextExecutor"); + + // Act - EnableSensitiveData is false by default + WorkflowBuilder builder = new(uppercase); + builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse); + var workflow = builder.WithOpenTelemetry().Build(); + + Run run = await InProcessExecution.Default.RunAsync(workflow, "hello"); + await run.DisposeAsync(); + + // Assert + var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); + var messageSendActivity = capturedActivities.FirstOrDefault( + a => a.OperationName.StartsWith(ActivityNames.MessageSend, StringComparison.Ordinal)); + + messageSendActivity.Should().NotBeNull("MessageSend activity should be created."); + + var tags = messageSendActivity!.Tags.ToDictionary(t => t.Key, t => t.Value); + tags.Should().NotContainKey(Tags.MessageContent, "Message content should NOT be logged when EnableSensitiveData is false."); + tags.Should().ContainKey(Tags.MessageSourceId, "Source ID should still be logged."); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs index 390f99ae05..9782e68f4f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Execution; +using Microsoft.Agents.AI.Workflows.Observability; namespace Microsoft.Agents.AI.Workflows.UnitTests; @@ -133,6 +134,8 @@ public class TestRunContext : IRunnerContext public bool WithCheckpointing => false; public bool ConcurrentRunsEnabled => false; + WorkflowTelemetryContext IRunnerContext.TelemetryContext => WorkflowTelemetryContext.Disabled; + ValueTask IRunnerContext.EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken) => new(this.Executors[executorId]); diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 57e86c3710..2a308c245d 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -117,11 +117,10 @@ agent = OpenAIChatClient().as_agent( from agent_framework import ChatAgent, AgentMiddleware, AgentContext class LoggingMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next) -> AgentResponse: + async def process(self, context: AgentContext, call_next) -> None: print(f"Input: {context.messages}") - response = await next(context) - print(f"Output: {response}") - return response + await call_next(context) + print(f"Output: {context.result}") agent = ChatAgent(..., middleware=[LoggingMiddleware()]) ``` diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index eff57cfdcb..48f762cdaa 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -122,7 +122,7 @@ class AgentContext: options: The options for the agent invocation as a dict. stream: Whether this is a streaming invocation. metadata: Metadata dictionary for sharing data between agent middleware. - result: Agent execution result. Can be observed after calling ``next()`` + result: Agent execution result. Can be observed after calling ``call_next()`` to see the actual execution result or can be set to override the execution result. For non-streaming: should be AgentResponse. For streaming: should be ResponseStream[AgentResponseUpdate, AgentResponse]. @@ -135,7 +135,7 @@ class AgentContext: class LoggingMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next): + async def process(self, context: AgentContext, call_next): print(f"Agent: {context.agent.name}") print(f"Messages: {len(context.messages)}") print(f"Thread: {context.thread}") @@ -145,7 +145,7 @@ class AgentContext: context.metadata["start_time"] = time.time() # Continue execution - await next(context) + await call_next(context) # Access result after execution print(f"Result: {context.result}") @@ -208,7 +208,7 @@ class FunctionInvocationContext: function: The function being invoked. arguments: The validated arguments for the function. metadata: Metadata dictionary for sharing data between function middleware. - result: Function execution result. Can be observed after calling ``next()`` + result: Function execution result. Can be observed after calling ``call_next()`` to see the actual execution result or can be set to override the execution result. kwargs: Additional keyword arguments passed to the chat method that invoked this function. @@ -220,7 +220,7 @@ class FunctionInvocationContext: class ValidationMiddleware(FunctionMiddleware): - async def process(self, context: FunctionInvocationContext, next): + async def process(self, context: FunctionInvocationContext, call_next): print(f"Function: {context.function.name}") print(f"Arguments: {context.arguments}") @@ -229,7 +229,7 @@ class FunctionInvocationContext: raise MiddlewareTermination("Validation failed") # Continue execution - await next(context) + await call_next(context) """ def __init__( @@ -268,7 +268,7 @@ class ChatContext: options: The options for the chat request as a dict. stream: Whether this is a streaming invocation. metadata: Metadata dictionary for sharing data between chat middleware. - result: Chat execution result. Can be observed after calling ``next()`` + result: Chat execution result. Can be observed after calling ``call_next()`` to see the actual execution result or can be set to override the execution result. For non-streaming: should be ChatResponse. For streaming: should be ResponseStream[ChatResponseUpdate, ChatResponse]. @@ -284,7 +284,7 @@ class ChatContext: class TokenCounterMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next): + async def process(self, context: ChatContext, call_next): print(f"Chat client: {context.chat_client.__class__.__name__}") print(f"Messages: {len(context.messages)}") print(f"Model: {context.options.get('model_id')}") @@ -293,7 +293,7 @@ class ChatContext: context.metadata["input_tokens"] = self.count_tokens(context.messages) # Continue execution - await next(context) + await call_next(context) # Access result and count output tokens if context.result: @@ -363,9 +363,9 @@ class AgentMiddleware(ABC): def __init__(self, max_retries: int = 3): self.max_retries = max_retries - async def process(self, context: AgentContext, next): + async def process(self, context: AgentContext, call_next): for attempt in range(self.max_retries): - await next(context) + await call_next(context) if context.result and not context.result.is_error: break print(f"Retry {attempt + 1}/{self.max_retries}") @@ -379,7 +379,7 @@ class AgentMiddleware(ABC): async def process( self, context: AgentContext, - next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[AgentContext], Awaitable[None]], ) -> None: """Process an agent invocation. @@ -387,16 +387,16 @@ class AgentMiddleware(ABC): context: Agent invocation context containing agent, messages, and metadata. Use context.stream to determine if this is a streaming call. MiddlewareTypes can set context.result to override execution, or observe - the actual execution result after calling next(). + the actual execution result after calling call_next(). For non-streaming: AgentResponse For streaming: AsyncIterable[AgentResponseUpdate] - next: Function to call the next middleware or final agent execution. + call_next: Function to call the next middleware or final agent execution. Does not return anything - all data flows through the context. Note: MiddlewareTypes should not return anything. All data manipulation should happen within the context object. Set context.result to override execution, - or observe context.result after calling next() for actual results. + or observe context.result after calling call_next() for actual results. """ ... @@ -422,7 +422,7 @@ class FunctionMiddleware(ABC): def __init__(self): self.cache = {} - async def process(self, context: FunctionInvocationContext, next): + async def process(self, context: FunctionInvocationContext, call_next): cache_key = f"{context.function.name}:{context.arguments}" # Check cache @@ -431,7 +431,7 @@ class FunctionMiddleware(ABC): raise MiddlewareTermination() # Execute function - await next(context) + await call_next(context) # Cache result if context.result: @@ -446,21 +446,21 @@ class FunctionMiddleware(ABC): async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: """Process a function invocation. Args: context: Function invocation context containing function, arguments, and metadata. MiddlewareTypes can set context.result to override execution, or observe - the actual execution result after calling next(). - next: Function to call the next middleware or final function execution. + the actual execution result after calling call_next(). + call_next: Function to call the next middleware or final function execution. Does not return anything - all data flows through the context. Note: MiddlewareTypes should not return anything. All data manipulation should happen within the context object. Set context.result to override execution, - or observe context.result after calling next() for actual results. + or observe context.result after calling call_next() for actual results. """ ... @@ -486,14 +486,14 @@ class ChatMiddleware(ABC): def __init__(self, system_prompt: str): self.system_prompt = system_prompt - async def process(self, context: ChatContext, next): + async def process(self, context: ChatContext, call_next): # Add system prompt to messages from agent_framework import ChatMessage context.messages.insert(0, ChatMessage(role="system", text=self.system_prompt)) # Continue execution - await next(context) + await call_next(context) # Use with an agent @@ -508,7 +508,7 @@ class ChatMiddleware(ABC): async def process( self, context: ChatContext, - next: Callable[[ChatContext], Awaitable[None]], + call_next: Callable[[ChatContext], Awaitable[None]], ) -> None: """Process a chat client request. @@ -516,16 +516,16 @@ class ChatMiddleware(ABC): context: Chat invocation context containing chat client, messages, options, and metadata. Use context.stream to determine if this is a streaming call. MiddlewareTypes can set context.result to override execution, or observe - the actual execution result after calling next(). + the actual execution result after calling call_next(). For non-streaming: ChatResponse For streaming: ResponseStream[ChatResponseUpdate, ChatResponse] - next: Function to call the next middleware or final chat execution. + call_next: Function to call the next middleware or final chat execution. Does not return anything - all data flows through the context. Note: MiddlewareTypes should not return anything. All data manipulation should happen within the context object. Set context.result to override execution, - or observe context.result after calling next() for actual results. + or observe context.result after calling call_next() for actual results. """ ... @@ -576,9 +576,9 @@ def agent_middleware(func: AgentMiddlewareCallable) -> AgentMiddlewareCallable: @agent_middleware - async def logging_middleware(context: AgentContext, next): + async def logging_middleware(context: AgentContext, call_next): print(f"Before: {context.agent.name}") - await next(context) + await call_next(context) print(f"After: {context.result}") @@ -609,9 +609,9 @@ def function_middleware(func: FunctionMiddlewareCallable) -> FunctionMiddlewareC @function_middleware - async def logging_middleware(context: FunctionInvocationContext, next): + async def logging_middleware(context: FunctionInvocationContext, call_next): print(f"Calling: {context.function.name}") - await next(context) + await call_next(context) print(f"Result: {context.result}") @@ -642,9 +642,9 @@ def chat_middleware(func: ChatMiddlewareCallable) -> ChatMiddlewareCallable: @chat_middleware - async def logging_middleware(context: ChatContext, next): + async def logging_middleware(context: ChatContext, call_next): print(f"Messages: {len(context.messages)}") - await next(context) + await call_next(context) print(f"Response: {context.result}") @@ -669,8 +669,8 @@ class MiddlewareWrapper(Generic[TContext]): def __init__(self, func: Callable[[TContext, Callable[[TContext], Awaitable[None]]], Awaitable[None]]) -> None: self.func = func - async def process(self, context: TContext, next: Callable[[TContext], Awaitable[None]]) -> None: - await self.func(context, next) + async def process(self, context: TContext, call_next: Callable[[TContext], Awaitable[None]]) -> None: + await self.func(context, call_next) class BaseMiddlewarePipeline(ABC): @@ -1226,7 +1226,7 @@ def _determine_middleware_type(middleware: Any) -> MiddlewareType: sig = inspect.signature(middleware) params = list(sig.parameters.values()) - # Must have at least 2 parameters (context and next) + # Must have at least 2 parameters (context and call_next) if len(params) >= 2: first_param = params[0] if hasattr(first_param.annotation, "__name__"): @@ -1240,7 +1240,7 @@ def _determine_middleware_type(middleware: Any) -> MiddlewareType: else: # Not enough parameters - can't be valid middleware raise MiddlewareException( - f"MiddlewareTypes function must have at least 2 parameters (context, next), " + f"Middleware function must have at least 2 parameters (context, call_next), " f"but {middleware.__name__} has {len(params)}" ) except Exception as e: diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 7e22b78827..5dec72d02c 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1239,6 +1239,7 @@ def tool( *, name: str | None = None, description: str | None = None, + schema: type[BaseModel] | Mapping[str, Any] | None = None, approval_mode: Literal["always_require", "never_require"] | None = None, max_invocations: int | None = None, max_invocation_exceptions: int | None = None, @@ -1252,6 +1253,7 @@ def tool( *, name: str | None = None, description: str | None = None, + schema: type[BaseModel] | Mapping[str, Any] | None = None, approval_mode: Literal["always_require", "never_require"] | None = None, max_invocations: int | None = None, max_invocation_exceptions: int | None = None, @@ -1264,6 +1266,7 @@ def tool( *, name: str | None = None, description: str | None = None, + schema: type[BaseModel] | Mapping[str, Any] | None = None, approval_mode: Literal["always_require", "never_require"] | None = None, max_invocations: int | None = None, max_invocation_exceptions: int | None = None, @@ -1279,6 +1282,9 @@ def tool( with a string description as the second argument. You can also use Pydantic's ``Field`` class for more advanced configuration. + Alternatively, you can provide an explicit schema via the ``schema`` parameter + to bypass automatic inference from the function signature. + Args: func: The function to decorate. @@ -1287,6 +1293,13 @@ def tool( attribute will be used. description: A description of the function. If not provided, the function's docstring will be used. + schema: An explicit input schema for the function. This can be a Pydantic + ``BaseModel`` subclass or a JSON schema dictionary (``Mapping[str, Any]``). + When a dictionary is provided, it must be a flat object schema with a + ``properties`` key (complex JSON Schema features such as ``oneOf``, + ``$ref``, or nested compositions are not supported). + When provided, the schema is used instead of inferring one from the + function's signature. Defaults to ``None`` (infer from signature). approval_mode: Whether or not approval is required to run this tool. Default is that approval is required. max_invocations: The maximum number of times this function can be invoked. @@ -1341,6 +1354,21 @@ def tool( # Simulate async operation return f"Weather in {location}" + + # With an explicit Pydantic model schema + from pydantic import BaseModel, Field + + + class WeatherInput(BaseModel): + location: Annotated[str, Field(description="City name")] + unit: str = "celsius" + + + @tool(schema=WeatherInput) + def get_weather(location: str, unit: str = "celsius") -> str: + '''Get weather for a location.''' + return f"Weather in {location}: 22 {unit}" + """ def decorator(func: Callable[..., ReturnT | Awaitable[ReturnT]]) -> FunctionTool[Any, ReturnT]: @@ -1356,6 +1384,7 @@ def tool( max_invocation_exceptions=max_invocation_exceptions, additional_properties=additional_properties or {}, func=f, + input_model=schema, ) return wrapper(func) @@ -1674,7 +1703,14 @@ async def _try_execute_function_calls( ) if declaration_only_flag: # return the declaration only tools to the user, since we cannot execute them. - return ([fcc for fcc in function_calls if fcc.type == "function_call"], False) + # Mark as user_input_request so AgentExecutor emits request_info events and pauses the workflow. + declaration_only_calls = [] + for fcc in function_calls: + if fcc.type == "function_call": + fcc.user_input_request = True + fcc.id = fcc.call_id + declaration_only_calls.append(fcc) + return (declaration_only_calls, False) # Run all function calls concurrently, handling MiddlewareTermination from ._middleware import MiddlewareTermination @@ -1915,10 +1951,14 @@ def _handle_function_call_results( from ._types import ChatMessage if any(fccr.type in {"function_approval_request", "function_call"} for fccr in function_call_results): - if response.messages and response.messages[0].role == "assistant": - response.messages[0].contents.extend(function_call_results) - else: - response.messages.append(ChatMessage(role="assistant", contents=function_call_results)) + # Only add items that aren't already in the message (e.g. function_approval_request wrappers). + # Declaration-only function_call items are already present from the LLM response. + new_items = [fccr for fccr in function_call_results if fccr.type != "function_call"] + if new_items: + if response.messages and response.messages[0].role == "assistant": + response.messages[0].contents.extend(new_items) + else: + response.messages.append(ChatMessage(role="assistant", contents=new_items)) return { "action": "return", "errors_in_a_row": errors_in_a_row, diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index a7e2bd79b9..4158380086 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -194,8 +194,11 @@ class AgentExecutor(Executor): self._pending_agent_requests.pop(original_request.id, None) # type: ignore[arg-type] if not self._pending_agent_requests: - # All pending requests have been resolved; resume agent execution - self._cache = normalize_messages_input(ChatMessage(role="user", contents=self._pending_responses_to_agent)) + # All pending requests have been resolved; resume agent execution. + # Use role="tool" for function_result responses (from declaration-only tools) + # so the LLM receives proper tool results instead of orphaned tool_calls. + role = "tool" if all(r.type == "function_result" for r in self._pending_responses_to_agent) else "user" + self._cache = normalize_messages_input(ChatMessage(role=role, contents=self._pending_responses_to_agent)) self._pending_responses_to_agent.clear() await self._run_agent_and_emit(ctx) diff --git a/python/packages/core/tests/core/test_as_tool_kwargs_propagation.py b/python/packages/core/tests/core/test_as_tool_kwargs_propagation.py index 8a2c4ceb5b..4672b10e77 100644 --- a/python/packages/core/tests/core/test_as_tool_kwargs_propagation.py +++ b/python/packages/core/tests/core/test_as_tool_kwargs_propagation.py @@ -19,10 +19,12 @@ class TestAsToolKwargsPropagation: captured_kwargs: dict[str, Any] = {} @agent_middleware - async def capture_middleware(context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def capture_middleware( + context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: # Capture kwargs passed to the sub-agent captured_kwargs.update(context.kwargs) - await next(context) + await call_next(context) # Setup mock response chat_client.responses = [ @@ -60,9 +62,11 @@ class TestAsToolKwargsPropagation: captured_kwargs: dict[str, Any] = {} @agent_middleware - async def capture_middleware(context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def capture_middleware( + context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: captured_kwargs.update(context.kwargs) - await next(context) + await call_next(context) # Setup mock response chat_client.responses = [ @@ -95,10 +99,12 @@ class TestAsToolKwargsPropagation: captured_kwargs_list: list[dict[str, Any]] = [] @agent_middleware - async def capture_middleware(context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def capture_middleware( + context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: # Capture kwargs at each level captured_kwargs_list.append(dict(context.kwargs)) - await next(context) + await call_next(context) # Setup mock responses to trigger nested tool invocation: B calls tool C, then completes. chat_client.responses = [ @@ -156,9 +162,11 @@ class TestAsToolKwargsPropagation: captured_kwargs: dict[str, Any] = {} @agent_middleware - async def capture_middleware(context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def capture_middleware( + context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: captured_kwargs.update(context.kwargs) - await next(context) + await call_next(context) # Setup mock streaming responses from agent_framework import ChatResponseUpdate @@ -216,9 +224,11 @@ class TestAsToolKwargsPropagation: captured_kwargs: dict[str, Any] = {} @agent_middleware - async def capture_middleware(context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def capture_middleware( + context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: captured_kwargs.update(context.kwargs) - await next(context) + await call_next(context) # Setup mock response chat_client.responses = [ @@ -256,14 +266,16 @@ class TestAsToolKwargsPropagation: call_count = 0 @agent_middleware - async def capture_middleware(context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def capture_middleware( + context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: nonlocal call_count call_count += 1 if call_count == 1: first_call_kwargs.update(context.kwargs) elif call_count == 2: second_call_kwargs.update(context.kwargs) - await next(context) + await call_next(context) # Setup mock responses for both calls chat_client.responses = [ @@ -306,9 +318,11 @@ class TestAsToolKwargsPropagation: captured_kwargs: dict[str, Any] = {} @agent_middleware - async def capture_middleware(context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def capture_middleware( + context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: captured_kwargs.update(context.kwargs) - await next(context) + await call_next(context) # Setup mock response chat_client.responses = [ diff --git a/python/packages/core/tests/core/test_middleware.py b/python/packages/core/tests/core/test_middleware.py index 7adde399ba..ae84541df4 100644 --- a/python/packages/core/tests/core/test_middleware.py +++ b/python/packages/core/tests/core/test_middleware.py @@ -135,12 +135,12 @@ class TestAgentMiddlewarePipeline: """Test cases for AgentMiddlewarePipeline.""" class PreNextTerminateMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process(self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: raise MiddlewareTermination class PostNextTerminateMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Any) -> None: - await next(context) + async def process(self, context: AgentContext, call_next: Any) -> None: + await call_next(context) raise MiddlewareTermination def test_init_empty(self) -> None: @@ -157,8 +157,8 @@ class TestAgentMiddlewarePipeline: def test_init_with_function_middleware(self) -> None: """Test AgentMiddlewarePipeline initialization with function-based middleware.""" - async def test_middleware(context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: - await next(context) + async def test_middleware(context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: + await call_next(context) pipeline = AgentMiddlewarePipeline(test_middleware) assert pipeline.has_middlewares @@ -185,9 +185,11 @@ class TestAgentMiddlewarePipeline: def __init__(self, name: str): self.name = name - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_order.append(f"{self.name}_before") - await next(context) + await call_next(context) execution_order.append(f"{self.name}_after") middleware = OrderTrackingMiddleware("test") @@ -236,9 +238,11 @@ class TestAgentMiddlewarePipeline: def __init__(self, name: str): self.name = name - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_order.append(f"{self.name}_before") - await next(context) + await call_next(context) execution_order.append(f"{self.name}_after") middleware = StreamOrderTrackingMiddleware("test") @@ -363,10 +367,12 @@ class TestAgentMiddlewarePipeline: captured_thread = None class ThreadCapturingMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: nonlocal captured_thread captured_thread = context.thread - await next(context) + await call_next(context) middleware = ThreadCapturingMiddleware() pipeline = AgentMiddlewarePipeline(middleware) @@ -388,10 +394,12 @@ class TestAgentMiddlewarePipeline: captured_thread = "not_none" # Use string to distinguish from None class ThreadCapturingMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: nonlocal captured_thread captured_thread = context.thread - await next(context) + await call_next(context) middleware = ThreadCapturingMiddleware() pipeline = AgentMiddlewarePipeline(middleware) @@ -412,12 +420,12 @@ class TestFunctionMiddlewarePipeline: """Test cases for FunctionMiddlewarePipeline.""" class PreNextTerminateFunctionMiddleware(FunctionMiddleware): - async def process(self, context: FunctionInvocationContext, next: Any) -> None: + async def process(self, context: FunctionInvocationContext, call_next: Any) -> None: raise MiddlewareTermination class PostNextTerminateFunctionMiddleware(FunctionMiddleware): - async def process(self, context: FunctionInvocationContext, next: Any) -> None: - await next(context) + async def process(self, context: FunctionInvocationContext, call_next: Any) -> None: + await call_next(context) raise MiddlewareTermination async def test_execute_with_pre_next_termination(self, mock_function: FunctionTool[Any, Any]) -> None: @@ -475,9 +483,9 @@ class TestFunctionMiddlewarePipeline: """Test FunctionMiddlewarePipeline initialization with function-based middleware.""" async def test_middleware( - context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: - await next(context) + await call_next(context) pipeline = FunctionMiddlewarePipeline(test_middleware) assert pipeline.has_middlewares @@ -507,10 +515,10 @@ class TestFunctionMiddlewarePipeline: async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: execution_order.append(f"{self.name}_before") - await next(context) + await call_next(context) execution_order.append(f"{self.name}_after") middleware = OrderTrackingFunctionMiddleware("test") @@ -533,12 +541,12 @@ class TestChatMiddlewarePipeline: """Test cases for ChatMiddlewarePipeline.""" class PreNextTerminateChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: raise MiddlewareTermination class PostNextTerminateChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: - await next(context) + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + await call_next(context) raise MiddlewareTermination def test_init_empty(self) -> None: @@ -555,8 +563,8 @@ class TestChatMiddlewarePipeline: def test_init_with_function_middleware(self) -> None: """Test ChatMiddlewarePipeline initialization with function-based middleware.""" - async def test_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: - await next(context) + async def test_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + await call_next(context) pipeline = ChatMiddlewarePipeline(test_middleware) assert pipeline.has_middlewares @@ -584,9 +592,9 @@ class TestChatMiddlewarePipeline: def __init__(self, name: str): self.name = name - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append(f"{self.name}_before") - await next(context) + await call_next(context) execution_order.append(f"{self.name}_after") middleware = OrderTrackingChatMiddleware("test") @@ -636,9 +644,9 @@ class TestChatMiddlewarePipeline: def __init__(self, name: str): self.name = name - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append(f"{self.name}_before") - await next(context) + await call_next(context) execution_order.append(f"{self.name}_after") middleware = StreamOrderTrackingChatMiddleware("test") @@ -766,10 +774,12 @@ class TestClassBasedMiddleware: metadata_updates: list[str] = [] class MetadataAgentMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: context.metadata["before"] = True metadata_updates.append("before") - await next(context) + await call_next(context) context.metadata["after"] = True metadata_updates.append("after") @@ -797,11 +807,11 @@ class TestClassBasedMiddleware: async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: context.metadata["before"] = True metadata_updates.append("before") - await next(context) + await call_next(context) context.metadata["after"] = True metadata_updates.append("after") @@ -829,10 +839,12 @@ class TestFunctionBasedMiddleware: """Test function-based agent middleware.""" execution_order: list[str] = [] - async def test_agent_middleware(context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def test_agent_middleware( + context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_order.append("function_before") context.metadata["function_middleware"] = True - await next(context) + await call_next(context) execution_order.append("function_after") pipeline = AgentMiddlewarePipeline(test_agent_middleware) @@ -854,11 +866,11 @@ class TestFunctionBasedMiddleware: execution_order: list[str] = [] async def test_function_middleware( - context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: execution_order.append("function_before") context.metadata["function_middleware"] = True - await next(context) + await call_next(context) execution_order.append("function_after") pipeline = FunctionMiddlewarePipeline(test_function_middleware) @@ -884,14 +896,18 @@ class TestMixedMiddleware: execution_order: list[str] = [] class ClassMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_order.append("class_before") - await next(context) + await call_next(context) execution_order.append("class_after") - async def function_middleware(context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def function_middleware( + context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_order.append("function_before") - await next(context) + await call_next(context) execution_order.append("function_after") pipeline = AgentMiddlewarePipeline(ClassMiddleware(), function_middleware) @@ -915,17 +931,17 @@ class TestMixedMiddleware: async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: execution_order.append("class_before") - await next(context) + await call_next(context) execution_order.append("class_after") async def function_middleware( - context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: execution_order.append("function_before") - await next(context) + await call_next(context) execution_order.append("function_after") pipeline = FunctionMiddlewarePipeline(ClassMiddleware(), function_middleware) @@ -946,16 +962,16 @@ class TestMixedMiddleware: execution_order: list[str] = [] class ClassChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append("class_before") - await next(context) + await call_next(context) execution_order.append("class_after") async def function_chat_middleware( - context: ChatContext, next: Callable[[ChatContext], Awaitable[None]] + context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] ) -> None: execution_order.append("function_before") - await next(context) + await call_next(context) execution_order.append("function_after") pipeline = ChatMiddlewarePipeline(ClassChatMiddleware(), function_chat_middleware) @@ -981,21 +997,27 @@ class TestMultipleMiddlewareOrdering: execution_order: list[str] = [] class FirstMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_order.append("first_before") - await next(context) + await call_next(context) execution_order.append("first_after") class SecondMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_order.append("second_before") - await next(context) + await call_next(context) execution_order.append("second_after") class ThirdMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_order.append("third_before") - await next(context) + await call_next(context) execution_order.append("third_after") middleware = [FirstMiddleware(), SecondMiddleware(), ThirdMiddleware()] @@ -1029,20 +1051,20 @@ class TestMultipleMiddlewareOrdering: async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: execution_order.append("first_before") - await next(context) + await call_next(context) execution_order.append("first_after") class SecondMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: execution_order.append("second_before") - await next(context) + await call_next(context) execution_order.append("second_after") middleware = [FirstMiddleware(), SecondMiddleware()] @@ -1065,21 +1087,21 @@ class TestMultipleMiddlewareOrdering: execution_order: list[str] = [] class FirstChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append("first_before") - await next(context) + await call_next(context) execution_order.append("first_after") class SecondChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append("second_before") - await next(context) + await call_next(context) execution_order.append("second_after") class ThirdChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append("third_before") - await next(context) + await call_next(context) execution_order.append("third_after") middleware = [FirstChatMiddleware(), SecondChatMiddleware(), ThirdChatMiddleware()] @@ -1114,7 +1136,9 @@ class TestContextContentValidation: """Test that agent context contains expected data.""" class ContextValidationMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: # Verify context has all expected attributes assert hasattr(context, "agent") assert hasattr(context, "messages") @@ -1132,7 +1156,7 @@ class TestContextContentValidation: # Add custom metadata context.metadata["validated"] = True - await next(context) + await call_next(context) middleware = ContextValidationMiddleware() pipeline = AgentMiddlewarePipeline(middleware) @@ -1154,7 +1178,7 @@ class TestContextContentValidation: async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: # Verify context has all expected attributes assert hasattr(context, "function") @@ -1170,7 +1194,7 @@ class TestContextContentValidation: # Add custom metadata context.metadata["validated"] = True - await next(context) + await call_next(context) middleware = ContextValidationMiddleware() pipeline = FunctionMiddlewarePipeline(middleware) @@ -1189,7 +1213,7 @@ class TestContextContentValidation: """Test that chat context contains expected data.""" class ChatContextValidationMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: # Verify context has all expected attributes assert hasattr(context, "chat_client") assert hasattr(context, "messages") @@ -1211,7 +1235,7 @@ class TestContextContentValidation: # Add custom metadata context.metadata["validated"] = True - await next(context) + await call_next(context) middleware = ChatContextValidationMiddleware() pipeline = ChatMiddlewarePipeline(middleware) @@ -1236,9 +1260,11 @@ class TestStreamingScenarios: streaming_flags: list[bool] = [] class StreamingFlagMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: streaming_flags.append(context.stream) - await next(context) + await call_next(context) middleware = StreamingFlagMiddleware() pipeline = AgentMiddlewarePipeline(middleware) @@ -1276,9 +1302,11 @@ class TestStreamingScenarios: chunks_processed: list[str] = [] class StreamProcessingMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: chunks_processed.append("before_stream") - await next(context) + await call_next(context) chunks_processed.append("after_stream") middleware = StreamProcessingMiddleware() @@ -1317,9 +1345,9 @@ class TestStreamingScenarios: streaming_flags: list[bool] = [] class ChatStreamingFlagMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: streaming_flags.append(context.stream) - await next(context) + await call_next(context) middleware = ChatStreamingFlagMiddleware() pipeline = ChatMiddlewarePipeline(middleware) @@ -1358,9 +1386,9 @@ class TestStreamingScenarios: chunks_processed: list[str] = [] class ChatStreamProcessingMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: chunks_processed.append("before_stream") - await next(context) + await call_next(context) chunks_processed.append("after_stream") middleware = ChatStreamProcessingMiddleware() @@ -1408,24 +1436,24 @@ class FunctionTestArgs(BaseModel): class TestAgentMiddleware(AgentMiddleware): """Test implementation of AgentMiddleware.""" - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: - await next(context) + async def process(self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: + await call_next(context) class TestFunctionMiddleware(FunctionMiddleware): """Test implementation of FunctionMiddleware.""" async def process( - self, context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + self, context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: - await next(context) + await call_next(context) class TestChatMiddleware(ChatMiddleware): """Test implementation of ChatMiddleware.""" - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: - await next(context) + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + await call_next(context) class MockFunctionArgs(BaseModel): @@ -1441,7 +1469,9 @@ class TestMiddlewareExecutionControl: """Test that when agent middleware doesn't call next(), no execution happens.""" class NoNextMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: # Don't call next() - this should prevent any execution pass @@ -1468,7 +1498,9 @@ class TestMiddlewareExecutionControl: """Test that when agent middleware doesn't call next(), no streaming execution happens.""" class NoNextStreamingMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: # Don't call next() - this should prevent any execution pass @@ -1505,7 +1537,7 @@ class TestMiddlewareExecutionControl: async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: # Don't call next() - this should prevent any execution pass @@ -1534,14 +1566,18 @@ class TestMiddlewareExecutionControl: execution_order: list[str] = [] class FirstMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_order.append("first") # Don't call next() - this should stop the pipeline class SecondMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_order.append("second") - await next(context) + await call_next(context) pipeline = AgentMiddlewarePipeline(FirstMiddleware(), SecondMiddleware()) messages = [ChatMessage(role="user", text="test")] @@ -1565,7 +1601,7 @@ class TestMiddlewareExecutionControl: """Test that when chat middleware doesn't call next(), no execution happens.""" class NoNextChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: # Don't call next() - this should prevent any execution pass @@ -1593,7 +1629,7 @@ class TestMiddlewareExecutionControl: """Test that when chat middleware doesn't call next(), no streaming execution happens.""" class NoNextStreamingChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: # Don't call next() - this should prevent any execution pass @@ -1634,14 +1670,14 @@ class TestMiddlewareExecutionControl: execution_order: list[str] = [] class FirstChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append("first") # Don't call next() - this should stop the pipeline class SecondChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append("second") - await next(context) + await call_next(context) pipeline = ChatMiddlewarePipeline(FirstChatMiddleware(), SecondChatMiddleware()) messages = [ChatMessage(role="user", text="test")] diff --git a/python/packages/core/tests/core/test_middleware_context_result.py b/python/packages/core/tests/core/test_middleware_context_result.py index b4fc945577..abdea790df 100644 --- a/python/packages/core/tests/core/test_middleware_context_result.py +++ b/python/packages/core/tests/core/test_middleware_context_result.py @@ -43,9 +43,11 @@ class TestResultOverrideMiddleware: override_response = AgentResponse(messages=[ChatMessage(role="assistant", text="overridden response")]) class ResponseOverrideMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: # Execute the pipeline first, then override the response - await next(context) + await call_next(context) context.result = override_response middleware = ResponseOverrideMiddleware() @@ -77,9 +79,11 @@ class TestResultOverrideMiddleware: yield AgentResponseUpdate(contents=[Content.from_text(text=" stream")]) class StreamResponseOverrideMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: # Execute the pipeline first, then override the response stream - await next(context) + await call_next(context) context.result = ResponseStream(override_stream()) middleware = StreamResponseOverrideMiddleware() @@ -111,10 +115,10 @@ class TestResultOverrideMiddleware: async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: # Execute the pipeline first, then override the result - await next(context) + await call_next(context) context.result = override_result middleware = ResultOverrideMiddleware() @@ -141,9 +145,11 @@ class TestResultOverrideMiddleware: mock_chat_client = MockChatClient() class ChatAgentResponseOverrideMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: # Always call next() first to allow execution - await next(context) + await call_next(context) # Then conditionally override based on content if any("special" in msg.text for msg in context.messages if msg.text): context.result = AgentResponse( @@ -178,13 +184,15 @@ class TestResultOverrideMiddleware: yield AgentResponseUpdate(contents=[Content.from_text(text=" response!")]) class ChatAgentStreamOverrideMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: # Check if we want to override BEFORE calling next to avoid creating unused streams if any("custom stream" in msg.text for msg in context.messages if msg.text): context.result = ResponseStream(custom_stream()) return # Don't call next() - we're overriding the entire result # Normal case - let the agent handle it - await next(context) + await call_next(context) # Create ChatAgent with override middleware middleware = ChatAgentStreamOverrideMiddleware() @@ -215,10 +223,12 @@ class TestResultOverrideMiddleware: """Test that when agent middleware conditionally doesn't call next(), no execution happens.""" class ConditionalNoNextMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: # Only call next() if message contains "execute" if any("execute" in msg.text for msg in context.messages if msg.text): - await next(context) + await call_next(context) # Otherwise, don't call next() - no execution should happen middleware = ConditionalNoNextMiddleware() @@ -259,13 +269,13 @@ class TestResultOverrideMiddleware: async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: # Only call next() if argument name contains "execute" args = context.arguments assert isinstance(args, FunctionTestArgs) if "execute" in args.name: - await next(context) + await call_next(context) # Otherwise, don't call next() - no execution should happen middleware = ConditionalNoNextFunctionMiddleware() @@ -308,12 +318,14 @@ class TestResultObservability: observed_responses: list[AgentResponse] = [] class ObservabilityMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: # Context should be empty before next() assert context.result is None # Call next to execute - await next(context) + await call_next(context) # Context should now contain the response for observability assert context.result is not None @@ -343,13 +355,13 @@ class TestResultObservability: async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: # Context should be empty before next() assert context.result is None # Call next to execute - await next(context) + await call_next(context) # Context should now contain the result for observability assert context.result is not None @@ -374,9 +386,11 @@ class TestResultObservability: """Test that middleware can override response after observing execution.""" class PostExecutionOverrideMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: # Call next to execute first - await next(context) + await call_next(context) # Now observe and conditionally override assert context.result is not None @@ -409,10 +423,10 @@ class TestResultObservability: async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: # Call next to execute first - await next(context) + await call_next(context) # Now observe and conditionally override assert context.result is not None diff --git a/python/packages/core/tests/core/test_middleware_with_agent.py b/python/packages/core/tests/core/test_middleware_with_agent.py index 9c516259ca..10cc8b3011 100644 --- a/python/packages/core/tests/core/test_middleware_with_agent.py +++ b/python/packages/core/tests/core/test_middleware_with_agent.py @@ -44,9 +44,11 @@ class TestChatAgentClassBasedMiddleware: def __init__(self, name: str): self.name = name - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_order.append(f"{self.name}_before") - await next(context) + await call_next(context) execution_order.append(f"{self.name}_after") # Create ChatAgent with middleware @@ -74,9 +76,9 @@ class TestChatAgentClassBasedMiddleware: async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: - await next(context) + await call_next(context) middleware = TrackingFunctionMiddleware() ChatAgent(chat_client=chat_client, middleware=[middleware]) @@ -94,10 +96,10 @@ class TestChatAgentClassBasedMiddleware: async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: execution_order.append(f"{self.name}_before") - await next(context) + await call_next(context) execution_order.append(f"{self.name}_after") middleware = TrackingFunctionMiddleware("function_middleware") @@ -120,11 +122,13 @@ class TestChatAgentFunctionBasedMiddleware: execution_order: list[str] = [] class PreTerminationMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_order.append("middleware_before") raise MiddlewareTermination # Code after raise is unreachable - await next(context) + await call_next(context) execution_order.append("middleware_after") # Create ChatAgent with terminating middleware @@ -149,9 +153,11 @@ class TestChatAgentFunctionBasedMiddleware: execution_order: list[str] = [] class PostTerminationMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_order.append("middleware_before") - await next(context) + await call_next(context) execution_order.append("middleware_after") context.terminate = True @@ -187,12 +193,12 @@ class TestChatAgentFunctionBasedMiddleware: async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: execution_order.append("middleware_before") context.terminate = True # We call next() but since terminate=True, subsequent middleware and handler should not execute - await next(context) + await call_next(context) execution_order.append("middleware_after") ChatAgent(chat_client=chat_client, middleware=[PreTerminationFunctionMiddleware()], tools=[]) @@ -205,10 +211,10 @@ class TestChatAgentFunctionBasedMiddleware: async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: execution_order.append("middleware_before") - await next(context) + await call_next(context) execution_order.append("middleware_after") context.terminate = True @@ -219,10 +225,10 @@ class TestChatAgentFunctionBasedMiddleware: execution_order: list[str] = [] async def tracking_agent_middleware( - context: AgentContext, next: Callable[[AgentContext], Awaitable[None]] + context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] ) -> None: execution_order.append("agent_function_before") - await next(context) + await call_next(context) execution_order.append("agent_function_after") # Create ChatAgent with function middleware @@ -246,9 +252,9 @@ class TestChatAgentFunctionBasedMiddleware: """Test function-based function middleware with ChatAgent.""" async def tracking_function_middleware( - context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: - await next(context) + await call_next(context) ChatAgent(chat_client=chat_client, middleware=[tracking_function_middleware]) @@ -259,10 +265,10 @@ class TestChatAgentFunctionBasedMiddleware: execution_order: list[str] = [] async def tracking_function_middleware( - context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: execution_order.append("function_function_before") - await next(context) + await call_next(context) execution_order.append("function_function_after") agent = ChatAgent(chat_client=chat_client_base, middleware=[tracking_function_middleware]) @@ -284,10 +290,12 @@ class TestChatAgentStreamingMiddleware: streaming_flags: list[bool] = [] class StreamingTrackingMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_order.append("middleware_before") streaming_flags.append(context.stream) - await next(context) + await call_next(context) execution_order.append("middleware_after") # Create ChatAgent with middleware @@ -326,9 +334,11 @@ class TestChatAgentStreamingMiddleware: streaming_flags: list[bool] = [] class FlagTrackingMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: streaming_flags.append(context.stream) - await next(context) + await call_next(context) # Create ChatAgent with middleware middleware = FlagTrackingMiddleware() @@ -358,9 +368,11 @@ class TestChatAgentMultipleMiddlewareOrdering: def __init__(self, name: str): self.name = name - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_order.append(f"{self.name}_before") - await next(context) + await call_next(context) execution_order.append(f"{self.name}_after") # Create multiple middleware @@ -388,33 +400,35 @@ class TestChatAgentMultipleMiddlewareOrdering: execution_order: list[str] = [] class ClassAgentMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_order.append("class_agent_before") - await next(context) + await call_next(context) execution_order.append("class_agent_after") async def function_agent_middleware( - context: AgentContext, next: Callable[[AgentContext], Awaitable[None]] + context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] ) -> None: execution_order.append("function_agent_before") - await next(context) + await call_next(context) execution_order.append("function_agent_after") class ClassFunctionMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: execution_order.append("class_function_before") - await next(context) + await call_next(context) execution_order.append("class_function_after") async def function_function_middleware( - context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: execution_order.append("function_function_before") - await next(context) + await call_next(context) execution_order.append("function_function_after") agent = ChatAgent( @@ -433,23 +447,25 @@ class TestChatAgentMultipleMiddlewareOrdering: execution_order: list[str] = [] class ClassAgentMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_order.append("class_agent_before") - await next(context) + await call_next(context) execution_order.append("class_agent_after") async def function_agent_middleware( - context: AgentContext, next: Callable[[AgentContext], Awaitable[None]] + context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] ) -> None: execution_order.append("function_agent_before") - await next(context) + await call_next(context) execution_order.append("function_agent_after") async def function_function_middleware( - context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: execution_order.append("function_function_before") - await next(context) + await call_next(context) execution_order.append("function_function_after") agent = ChatAgent( @@ -505,10 +521,10 @@ class TestChatAgentFunctionMiddlewareWithTools: async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: execution_order.append(f"{self.name}_before") - await next(context) + await call_next(context) execution_order.append(f"{self.name}_after") # Set up mock to return a function call first, then a regular response @@ -567,10 +583,10 @@ class TestChatAgentFunctionMiddlewareWithTools: execution_order: list[str] = [] async def tracking_function_middleware( - context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: execution_order.append("function_middleware_before") - await next(context) + await call_next(context) execution_order.append("function_middleware_after") # Set up mock to return a function call first, then a regular response @@ -631,20 +647,20 @@ class TestChatAgentFunctionMiddlewareWithTools: async def process( self, context: AgentContext, - next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[AgentContext], Awaitable[None]], ) -> None: execution_order.append("agent_middleware_before") - await next(context) + await call_next(context) execution_order.append("agent_middleware_after") class TrackingFunctionMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: execution_order.append("function_middleware_before") - await next(context) + await call_next(context) execution_order.append("function_middleware_after") # Set up mock to return a function call first, then a regular response @@ -712,7 +728,7 @@ class TestChatAgentFunctionMiddlewareWithTools: @function_middleware async def kwargs_middleware( - context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: nonlocal middleware_called middleware_called = True @@ -732,7 +748,7 @@ class TestChatAgentFunctionMiddlewareWithTools: modified_kwargs["new_param"] = context.kwargs.get("new_param") modified_kwargs["custom_param"] = context.kwargs.get("custom_param") - await next(context) + await call_next(context) chat_client_base.run_responses = [ ChatResponse( @@ -785,9 +801,9 @@ class TestMiddlewareDynamicRebuild: self.name = name self.execution_log = execution_log - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process(self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: self.execution_log.append(f"{self.name}_start") - await next(context) + await call_next(context) self.execution_log.append(f"{self.name}_end") async def test_middleware_dynamic_rebuild_non_streaming(self, chat_client: "MockChatClient") -> None: @@ -908,9 +924,9 @@ class TestRunLevelMiddleware: self.name = name self.execution_log = execution_log - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process(self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: self.execution_log.append(f"{self.name}_start") - await next(context) + await call_next(context) self.execution_log.append(f"{self.name}_end") async def test_run_level_middleware_isolation(self, chat_client: "MockChatClient") -> None: @@ -960,25 +976,29 @@ class TestRunLevelMiddleware: def __init__(self, name: str): self.name = name - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_log.append(f"{self.name}_start") # Set metadata to pass information to run middleware context.metadata[f"{self.name}_key"] = f"{self.name}_value" - await next(context) + await call_next(context) execution_log.append(f"{self.name}_end") class MetadataRunMiddleware(AgentMiddleware): def __init__(self, name: str): self.name = name - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_log.append(f"{self.name}_start") # Read metadata set by agent middleware for key, value in context.metadata.items(): metadata_log.append(f"{self.name}_reads_{key}:{value}") # Set run-level metadata context.metadata[f"{self.name}_key"] = f"{self.name}_value" - await next(context) + await call_next(context) execution_log.append(f"{self.name}_end") # Create agent with agent-level middleware @@ -1029,10 +1049,12 @@ class TestRunLevelMiddleware: def __init__(self, name: str): self.name = name - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_log.append(f"{self.name}_start") streaming_flags.append(context.stream) - await next(context) + await call_next(context) execution_log.append(f"{self.name}_end") # Create agent without agent-level middleware @@ -1071,44 +1093,48 @@ class TestRunLevelMiddleware: # Agent-level middleware class AgentLevelAgentMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_log.append("agent_level_agent_start") context.metadata["agent_level_agent"] = "processed" - await next(context) + await call_next(context) execution_log.append("agent_level_agent_end") class AgentLevelFunctionMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: execution_log.append("agent_level_function_start") context.metadata["agent_level_function"] = "processed" - await next(context) + await call_next(context) execution_log.append("agent_level_function_end") # Run-level middleware class RunLevelAgentMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: execution_log.append("run_level_agent_start") # Verify agent-level middleware metadata is available assert "agent_level_agent" in context.metadata context.metadata["run_level_agent"] = "processed" - await next(context) + await call_next(context) execution_log.append("run_level_agent_end") class RunLevelFunctionMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: execution_log.append("run_level_function_start") # Verify agent-level function middleware metadata is available assert "agent_level_function" in context.metadata context.metadata["run_level_function"] = "processed" - await next(context) + await call_next(context) execution_log.append("run_level_function_end") # Create tool function for testing function middleware @@ -1192,17 +1218,17 @@ class TestMiddlewareDecoratorLogic: @agent_middleware async def matching_agent_middleware( - context: AgentContext, next: Callable[[AgentContext], Awaitable[None]] + context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] ) -> None: execution_order.append("decorator_type_match_agent") - await next(context) + await call_next(context) @function_middleware async def matching_function_middleware( - context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: execution_order.append("decorator_type_match_function") - await next(context) + await call_next(context) # Create tool function for testing function middleware def custom_tool(message: str) -> str: @@ -1254,9 +1280,9 @@ class TestMiddlewareDecoratorLogic: @agent_middleware # type: ignore[arg-type] async def mismatched_middleware( context: FunctionInvocationContext, # Wrong type for @agent_middleware - next: Any, + call_next: Any, ) -> None: - await next(context) + await call_next(context) agent = ChatAgent(chat_client=chat_client, middleware=[mismatched_middleware]) await agent.run([ChatMessage(role="user", text="test")]) @@ -1266,14 +1292,14 @@ class TestMiddlewareDecoratorLogic: execution_order: list[str] = [] @agent_middleware - async def decorator_only_agent(context: Any, next: Any) -> None: # No type annotation + async def decorator_only_agent(context: Any, call_next: Any) -> None: # No type annotation execution_order.append("decorator_only_agent") - await next(context) + await call_next(context) @function_middleware - async def decorator_only_function(context: Any, next: Any) -> None: # No type annotation + async def decorator_only_function(context: Any, call_next: Any) -> None: # No type annotation execution_order.append("decorator_only_function") - await next(context) + await call_next(context) # Create tool function for testing function middleware def custom_tool(message: str) -> str: @@ -1320,16 +1346,16 @@ class TestMiddlewareDecoratorLogic: execution_order: list[str] = [] # No decorator - async def type_only_agent(context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def type_only_agent(context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: execution_order.append("type_only_agent") - await next(context) + await call_next(context) # No decorator async def type_only_function( - context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: execution_order.append("type_only_function") - await next(context) + await call_next(context) # Create tool function for testing function middleware def custom_tool(message: str) -> str: @@ -1372,8 +1398,8 @@ class TestMiddlewareDecoratorLogic: async def test_neither_decorator_nor_type(self, chat_client: Any) -> None: """Neither decorator nor parameter type specified - should throw exception.""" - async def no_info_middleware(context: Any, next: Any) -> None: # No decorator, no type - await next(context) + async def no_info_middleware(context: Any, call_next: Any) -> None: # No decorator, no type + await call_next(context) # Should raise MiddlewareException with pytest.raises(MiddlewareException, match="Cannot determine middleware type"): @@ -1398,11 +1424,11 @@ class TestMiddlewareDecoratorLogic: """Test that decorator markers are properly set on functions.""" @agent_middleware - async def test_agent_middleware(context: Any, next: Any) -> None: + async def test_agent_middleware(context: Any, call_next: Any) -> None: pass @function_middleware - async def test_function_middleware(context: Any, next: Any) -> None: + async def test_function_middleware(context: Any, call_next: Any) -> None: pass # Check that decorator markers were set @@ -1421,7 +1447,9 @@ class TestChatAgentThreadBehavior: thread_states: list[dict[str, Any]] = [] class ThreadTrackingMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process( + self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: # Capture state before next() call thread_messages = [] if context.thread and context.thread.message_store: @@ -1436,7 +1464,7 @@ class TestChatAgentThreadBehavior: } thread_states.append(before_state) - await next(context) + await call_next(context) # Capture state after next() call thread_messages_after = [] @@ -1532,9 +1560,9 @@ class TestChatAgentChatMiddleware: execution_order: list[str] = [] class TrackingChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append("chat_middleware_before") - await next(context) + await call_next(context) execution_order.append("chat_middleware_after") # Create ChatAgent with chat middleware @@ -1561,10 +1589,10 @@ class TestChatAgentChatMiddleware: execution_order: list[str] = [] async def tracking_chat_middleware( - context: ChatContext, next: Callable[[ChatContext], Awaitable[None]] + context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] ) -> None: execution_order.append("chat_middleware_before") - await next(context) + await call_next(context) execution_order.append("chat_middleware_after") # Create ChatAgent with function-based chat middleware @@ -1590,7 +1618,7 @@ class TestChatAgentChatMiddleware: @chat_middleware async def message_modifier_middleware( - context: ChatContext, next: Callable[[ChatContext], Awaitable[None]] + context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] ) -> None: # Modify the first message by adding a prefix if context.messages: @@ -1600,7 +1628,7 @@ class TestChatAgentChatMiddleware: original_text = msg.text or "" context.messages[idx] = ChatMessage(role=msg.role, text=f"MODIFIED: {original_text}") break - await next(context) + await call_next(context) # Create ChatAgent with message-modifying middleware chat_client = MockBaseChatClient() @@ -1619,7 +1647,7 @@ class TestChatAgentChatMiddleware: @chat_middleware async def response_override_middleware( - context: ChatContext, next: Callable[[ChatContext], Awaitable[None]] + context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] ) -> None: # Override the response without calling next() context.result = ChatResponse( @@ -1647,15 +1675,15 @@ class TestChatAgentChatMiddleware: execution_order: list[str] = [] @chat_middleware - async def first_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def first_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append("first_before") - await next(context) + await call_next(context) execution_order.append("first_after") @chat_middleware - async def second_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def second_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append("second_before") - await next(context) + await call_next(context) execution_order.append("second_after") # Create ChatAgent with multiple chat middleware @@ -1681,10 +1709,10 @@ class TestChatAgentChatMiddleware: streaming_flags: list[bool] = [] class StreamingTrackingChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append("streaming_chat_before") streaming_flags.append(context.stream) - await next(context) + await call_next(context) execution_order.append("streaming_chat_after") # Create ChatAgent with chat middleware @@ -1721,13 +1749,13 @@ class TestChatAgentChatMiddleware: execution_order: list[str] = [] class PreTerminationChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append("middleware_before") # Set a custom response since we're terminating context.result = ChatResponse(messages=[ChatMessage(role="assistant", text="Terminated by middleware")]) raise MiddlewareTermination # We call next() but since terminate=True, execution should stop - await next(context) + await call_next(context) execution_order.append("middleware_after") # Create ChatAgent with terminating middleware @@ -1749,9 +1777,9 @@ class TestChatAgentChatMiddleware: execution_order: list[str] = [] class PostTerminationChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append("middleware_before") - await next(context) + await call_next(context) execution_order.append("middleware_after") context.terminate = True @@ -1776,21 +1804,21 @@ class TestChatAgentChatMiddleware: """Test ChatAgent with combined middleware types.""" execution_order: list[str] = [] - async def agent_middleware(context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def agent_middleware(context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: execution_order.append("agent_middleware_before") - await next(context) + await call_next(context) execution_order.append("agent_middleware_after") - async def chat_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def chat_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append("chat_middleware_before") - await next(context) + await call_next(context) execution_order.append("chat_middleware_after") async def function_middleware( - context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: execution_order.append("function_middleware_before") - await next(context) + await call_next(context) execution_order.append("function_middleware_after") # Create ChatAgent with function middleware and tools @@ -1814,7 +1842,9 @@ class TestChatAgentChatMiddleware: modified_kwargs: dict[str, Any] = {} @agent_middleware - async def kwargs_middleware(context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def kwargs_middleware( + context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] + ) -> None: # Capture the original kwargs captured_kwargs.update(context.kwargs) @@ -1826,7 +1856,7 @@ class TestChatAgentChatMiddleware: # Store modified kwargs for verification modified_kwargs.update(context.kwargs) - await next(context) + await call_next(context) # Create ChatAgent with agent middleware chat_client = MockBaseChatClient() @@ -1865,10 +1895,10 @@ class TestChatAgentChatMiddleware: # class TrackingMiddleware(AgentMiddleware): # async def process( -# self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]] +# self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] # ) -> None: # execution_order.append("before") -# await next(context) +# await call_next(context) # execution_order.append("after") # @use_agent_middleware diff --git a/python/packages/core/tests/core/test_middleware_with_chat.py b/python/packages/core/tests/core/test_middleware_with_chat.py index 1042ef9ae2..15621f759f 100644 --- a/python/packages/core/tests/core/test_middleware_with_chat.py +++ b/python/packages/core/tests/core/test_middleware_with_chat.py @@ -32,10 +32,10 @@ class TestChatMiddleware: async def process( self, context: ChatContext, - next: Callable[[ChatContext], Awaitable[None]], + call_next: Callable[[ChatContext], Awaitable[None]], ) -> None: execution_order.append("chat_middleware_before") - await next(context) + await call_next(context) execution_order.append("chat_middleware_after") # Add middleware to chat client @@ -58,9 +58,11 @@ class TestChatMiddleware: execution_order: list[str] = [] @chat_middleware - async def logging_chat_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def logging_chat_middleware( + context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] + ) -> None: execution_order.append("function_middleware_before") - await next(context) + await call_next(context) execution_order.append("function_middleware_after") # Add middleware to chat client @@ -83,13 +85,13 @@ class TestChatMiddleware: @chat_middleware async def message_modifier_middleware( - context: ChatContext, next: Callable[[ChatContext], Awaitable[None]] + context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] ) -> None: # Modify the first message by adding a prefix if context.messages and len(context.messages) > 0: original_text = context.messages[0].text or "" context.messages[0] = ChatMessage(role=context.messages[0].role, text=f"MODIFIED: {original_text}") - await next(context) + await call_next(context) # Add middleware to chat client chat_client_base.chat_middleware = [message_modifier_middleware] @@ -109,7 +111,7 @@ class TestChatMiddleware: @chat_middleware async def response_override_middleware( - context: ChatContext, next: Callable[[ChatContext], Awaitable[None]] + context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] ) -> None: # Override the response without calling next() context.result = ChatResponse( @@ -136,15 +138,15 @@ class TestChatMiddleware: execution_order: list[str] = [] @chat_middleware - async def first_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def first_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append("first_before") - await next(context) + await call_next(context) execution_order.append("first_after") @chat_middleware - async def second_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def second_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append("second_before") - await next(context) + await call_next(context) execution_order.append("second_after") # Add middleware to chat client (order should be preserved) @@ -172,10 +174,10 @@ class TestChatMiddleware: @chat_middleware async def agent_level_chat_middleware( - context: ChatContext, next: Callable[[ChatContext], Awaitable[None]] + context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] ) -> None: execution_order.append("agent_chat_middleware_before") - await next(context) + await call_next(context) execution_order.append("agent_chat_middleware_after") chat_client = MockBaseChatClient() @@ -203,15 +205,15 @@ class TestChatMiddleware: execution_order: list[str] = [] @chat_middleware - async def first_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def first_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append("first_before") - await next(context) + await call_next(context) execution_order.append("first_after") @chat_middleware - async def second_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def second_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: execution_order.append("second_before") - await next(context) + await call_next(context) execution_order.append("second_after") # Create ChatAgent with multiple chat middleware @@ -238,7 +240,9 @@ class TestChatMiddleware: execution_order: list[str] = [] @chat_middleware - async def streaming_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def streaming_middleware( + context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] + ) -> None: execution_order.append("streaming_before") # Verify it's a streaming context assert context.stream is True @@ -250,7 +254,7 @@ class TestChatMiddleware: return update context.stream_transform_hooks.append(upper_case_update) - await next(context) + await call_next(context) execution_order.append("streaming_after") # Add middleware to chat client @@ -274,9 +278,11 @@ class TestChatMiddleware: execution_count = {"count": 0} @chat_middleware - async def counting_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def counting_middleware( + context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] + ) -> None: execution_count["count"] += 1 - await next(context) + await call_next(context) # First call with run-level middleware messages = [ChatMessage(role="user", text="first message")] @@ -304,7 +310,7 @@ class TestChatMiddleware: modified_kwargs: dict[str, Any] = {} @chat_middleware - async def kwargs_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def kwargs_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: # Capture the original kwargs captured_kwargs.update(context.kwargs) @@ -316,7 +322,7 @@ class TestChatMiddleware: # Store modified kwargs for verification modified_kwargs.update(context.kwargs) - await next(context) + await call_next(context) # Add middleware to chat client chat_client_base.chat_middleware = [kwargs_middleware] @@ -349,11 +355,11 @@ class TestChatMiddleware: @function_middleware async def test_function_middleware( - context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: nonlocal execution_order execution_order.append(f"function_middleware_before_{context.function.name}") - await next(context) + await call_next(context) execution_order.append(f"function_middleware_after_{context.function.name}") # Define a simple tool function @@ -415,10 +421,10 @@ class TestChatMiddleware: @function_middleware async def run_level_function_middleware( - context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: execution_order.append("run_level_function_middleware_before") - await next(context) + await call_next(context) execution_order.append("run_level_function_middleware_after") # Define a simple tool function diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index a1daf08d29..0a616a35fc 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -70,6 +70,102 @@ def test_tool_decorator_without_args(): assert test_tool.approval_mode == "never_require" +def test_tool_decorator_with_pydantic_schema(): + """Test that the tool decorator accepts an explicit Pydantic model schema.""" + from pydantic import Field + + class MyInput(BaseModel): + location: Annotated[str, Field(description="City name")] + unit: str = "celsius" + + @tool(name="weather", description="Get weather", schema=MyInput) + def get_weather(location: str, unit: str = "celsius") -> str: + return f"{location}: {unit}" + + assert isinstance(get_weather, FunctionTool) + assert get_weather.name == "weather" + params = get_weather.parameters() + assert "location" in params["properties"] + assert params["properties"]["location"].get("description") == "City name" + assert get_weather("Seattle") == "Seattle: celsius" + assert get_weather("Seattle", "fahrenheit") == "Seattle: fahrenheit" + + +def test_tool_decorator_with_json_schema_dict(): + """Test that the tool decorator accepts an explicit JSON schema dict.""" + + json_schema = { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "max_results": {"type": "integer", "default": 10}, + }, + "required": ["query"], + } + + @tool(name="search", description="Search tool", schema=json_schema) + def search(query: str, max_results: int = 10) -> str: + return f"Searching for: {query} (max {max_results})" + + assert isinstance(search, FunctionTool) + params = search.parameters() + assert params["properties"]["query"]["type"] == "string" + assert params["properties"]["query"]["description"] == "Search query" + assert "max_results" in params["properties"] + assert search("hello") == "Searching for: hello (max 10)" + + +def test_tool_decorator_schema_none_default(): + """Test that schema=None (default) still infers from function signature.""" + + @tool(name="adder", schema=None) + def add(x: int, y: int) -> int: + return x + y + + assert isinstance(add, FunctionTool) + params = add.parameters() + assert params == { + "properties": {"x": {"title": "X", "type": "integer"}, "y": {"title": "Y", "type": "integer"}}, + "required": ["x", "y"], + "title": "adder_input", + "type": "object", + } + assert add(1, 2) == 3 + + +async def test_tool_decorator_with_schema_invoke(): + """Test that invoke works correctly with explicit schema.""" + + class CalcInput(BaseModel): + a: int + b: int + + @tool(name="calc", description="Calculator", schema=CalcInput) + def calculate(a: int, b: int) -> int: + return a + b + + result = await calculate.invoke(arguments=CalcInput(a=3, b=7)) + assert result == 10 + + +def test_tool_decorator_with_schema_overrides_annotations(): + """Test that explicit schema completely overrides function signature inference.""" + from pydantic import Field + + class DetailedInput(BaseModel): + location: Annotated[str, Field(description="The city and state")] + unit: Annotated[str, Field(description="Temperature unit")] = "celsius" + + @tool(schema=DetailedInput) + def get_weather(location: str, unit: str = "celsius") -> str: + """Get weather for a location.""" + return f"{location}: {unit}" + + params = get_weather.parameters() + assert params["properties"]["location"].get("description") == "The city and state" + assert params["properties"]["unit"].get("description") == "Temperature unit" + + def test_tool_without_args(): """Test the tool decorator.""" diff --git a/python/packages/core/tests/workflow/test_agent_executor_tool_calls.py b/python/packages/core/tests/workflow/test_agent_executor_tool_calls.py index 051a2109e5..2d4e3ecf39 100644 --- a/python/packages/core/tests/workflow/test_agent_executor_tool_calls.py +++ b/python/packages/core/tests/workflow/test_agent_executor_tool_calls.py @@ -19,6 +19,7 @@ from agent_framework import ( ChatResponse, ChatResponseUpdate, Content, + FunctionTool, ResponseStream, WorkflowBuilder, WorkflowContext, @@ -384,3 +385,207 @@ async def test_agent_executor_parallel_tool_call_with_approval_streaming() -> No # Assert assert output is not None assert output == "Tool executed successfully." + + +# --- Declaration-only tool tests --- + +declaration_only_tool = FunctionTool( + name="client_side_tool", + func=None, + description="A client-side tool that the framework cannot execute.", + input_model={"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}, +) + + +class DeclarationOnlyMockChatClient(FunctionInvocationLayer[Any], BaseChatClient[Any]): + """Mock chat client that calls a declaration-only tool on first iteration.""" + + def __init__(self, parallel_request: bool = False) -> None: + FunctionInvocationLayer.__init__(self) + BaseChatClient.__init__(self) + self._iteration: int = 0 + self._parallel_request: bool = parallel_request + + def _inner_get_response( + self, + *, + messages: Sequence[ChatMessage], + stream: bool, + options: Mapping[str, Any], + **kwargs: Any, + ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: + if stream: + return self._build_response_stream(self._stream_response()) + + async def _get_response() -> ChatResponse: + return self._create_response() + + return _get_response() + + def _create_response(self) -> ChatResponse: + if self._iteration == 0: + if self._parallel_request: + response = ChatResponse( + messages=ChatMessage( + "assistant", + [ + Content.from_function_call( + call_id="1", name="client_side_tool", arguments='{"query": "test"}' + ), + Content.from_function_call( + call_id="2", name="client_side_tool", arguments='{"query": "test2"}' + ), + ], + ) + ) + else: + response = ChatResponse( + messages=ChatMessage( + "assistant", + [ + Content.from_function_call( + call_id="1", name="client_side_tool", arguments='{"query": "test"}' + ) + ], + ) + ) + else: + response = ChatResponse(messages=ChatMessage("assistant", ["Tool executed successfully."])) + + self._iteration += 1 + return response + + async def _stream_response(self) -> AsyncIterable[ChatResponseUpdate]: + if self._iteration == 0: + if self._parallel_request: + yield ChatResponseUpdate( + contents=[ + Content.from_function_call(call_id="1", name="client_side_tool", arguments='{"query": "test"}'), + Content.from_function_call( + call_id="2", name="client_side_tool", arguments='{"query": "test2"}' + ), + ], + role="assistant", + ) + else: + yield ChatResponseUpdate( + contents=[ + Content.from_function_call(call_id="1", name="client_side_tool", arguments='{"query": "test"}') + ], + role="assistant", + ) + else: + yield ChatResponseUpdate(contents=[Content.from_text(text="Tool executed ")], role="assistant") + yield ChatResponseUpdate(contents=[Content.from_text(text="successfully.")], role="assistant") + + self._iteration += 1 + + +async def test_agent_executor_declaration_only_tool_emits_request_info() -> None: + """Test that AgentExecutor emits request_info when agent calls a declaration-only tool.""" + agent = ChatAgent( + chat_client=DeclarationOnlyMockChatClient(), + name="DeclarationOnlyAgent", + tools=[declaration_only_tool], + ) + + workflow = ( + WorkflowBuilder(start_executor=agent, output_executors=[test_executor]).add_edge(agent, test_executor).build() + ) + + # Act + events = await workflow.run("Use the client side tool") + + # Assert - workflow should pause with a request_info event + request_info_events = events.get_request_info_events() + assert len(request_info_events) == 1 + request = request_info_events[0] + assert request.data.type == "function_call" + assert request.data.name == "client_side_tool" + assert request.data.call_id == "1" + + # Act - provide the function result to resume the workflow + events = await workflow.run( + responses={ + request.request_id: Content.from_function_result(call_id=request.data.call_id, result="client result") + } + ) + + # Assert - workflow should complete + final_response = events.get_outputs() + assert len(final_response) == 1 + assert final_response[0] == "Tool executed successfully." + + +async def test_agent_executor_declaration_only_tool_emits_request_info_streaming() -> None: + """Test that AgentExecutor emits request_info for declaration-only tools in streaming mode.""" + agent = ChatAgent( + chat_client=DeclarationOnlyMockChatClient(), + name="DeclarationOnlyAgent", + tools=[declaration_only_tool], + ) + + workflow = WorkflowBuilder(start_executor=agent).add_edge(agent, test_executor).build() + + # Act + request_info_events: list[WorkflowEvent] = [] + async for event in workflow.run("Use the client side tool", stream=True): + if event.type == "request_info": + request_info_events.append(event) + + # Assert + assert len(request_info_events) == 1 + request = request_info_events[0] + assert request.data.type == "function_call" + assert request.data.name == "client_side_tool" + assert request.data.call_id == "1" + + # Act - provide the function result + output: str | None = None + async for event in workflow.run( + stream=True, + responses={ + request.request_id: Content.from_function_result(call_id=request.data.call_id, result="client result") + }, + ): + if event.type == "output": + output = event.data + + # Assert + assert output is not None + assert output == "Tool executed successfully." + + +async def test_agent_executor_parallel_declaration_only_tool_emits_request_info() -> None: + """Test that AgentExecutor emits request_info for parallel declaration-only tool calls.""" + agent = ChatAgent( + chat_client=DeclarationOnlyMockChatClient(parallel_request=True), + name="DeclarationOnlyAgent", + tools=[declaration_only_tool], + ) + + workflow = ( + WorkflowBuilder(start_executor=agent, output_executors=[test_executor]).add_edge(agent, test_executor).build() + ) + + # Act + events = await workflow.run("Use the client side tool") + + # Assert - should get 2 request_info events + request_info_events = events.get_request_info_events() + assert len(request_info_events) == 2 + for req in request_info_events: + assert req.data.type == "function_call" + assert req.data.name == "client_side_tool" + + # Act - provide both function results + responses = { + req.request_id: Content.from_function_result(call_id=req.data.call_id, result=f"result for {req.data.call_id}") + for req in request_info_events + } + events = await workflow.run(responses=responses) + + # Assert - workflow should complete + final_response = events.get_outputs() + assert len(final_response) == 1 + assert final_response[0] == "Tool executed successfully." diff --git a/python/packages/declarative/AGENTS.md b/python/packages/declarative/AGENTS.md index ca61984db9..72ac14860c 100644 --- a/python/packages/declarative/AGENTS.md +++ b/python/packages/declarative/AGENTS.md @@ -20,8 +20,13 @@ YAML/JSON-based declarative agent and workflow definitions. ```python from agent_framework.declarative import AgentFactory, WorkflowFactory -agent = AgentFactory.create_from_file("agent.yaml") -workflow = WorkflowFactory.create_from_file("workflow.yaml") +# Create agent from YAML file +agent_factory = AgentFactory() +agent = agent_factory.create_agent_from_yaml_path("agent.yaml") + +# Create workflow from YAML file +workflow_factory = WorkflowFactory() +workflow = workflow_factory.create_workflow_from_yaml_path("workflow.yaml") ``` ## Import Path diff --git a/python/packages/durabletask/AGENTS.md b/python/packages/durabletask/AGENTS.md index 094a0eb03e..905f462212 100644 --- a/python/packages/durabletask/AGENTS.md +++ b/python/packages/durabletask/AGENTS.md @@ -29,14 +29,29 @@ Durable execution support for long-running agent workflows using Azure Durable F ## Usage ```python +from durabletask.client import TaskHubGrpcClient +from durabletask.worker import TaskHubGrpcWorker +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient from agent_framework_durabletask import DurableAIAgentClient, DurableAIAgentWorker # Client side -client = DurableAIAgentClient(endpoint="https://your-functions.azurewebsites.net") -response = await client.run("Hello") +dt_client = TaskHubGrpcClient(host_address="localhost:4001") +agent_client = DurableAIAgentClient(dt_client) +agent = agent_client.get_agent("assistant") +response = agent.run("Hello, how are you?") +print(response.text) # Worker side -worker = DurableAIAgentWorker(agent=my_agent) +dt_worker = TaskHubGrpcWorker(host_address="localhost:4001") +agent_worker = DurableAIAgentWorker(dt_worker) + +# Create a chat client for the agent +chat_client = AzureOpenAIChatClient() +my_agent = ChatAgent(chat_client=chat_client, name="assistant") +agent_worker.add_agent(my_agent) + +dt_worker.start() ``` ## Import Path diff --git a/python/packages/ollama/tests/test_ollama_chat_client.py b/python/packages/ollama/tests/test_ollama_chat_client.py index 095942fd6c..3d1f51e4c8 100644 --- a/python/packages/ollama/tests/test_ollama_chat_client.py +++ b/python/packages/ollama/tests/test_ollama_chat_client.py @@ -207,8 +207,8 @@ def test_serialize(ollama_unit_test_env: dict[str, str]) -> None: def test_chat_middleware(ollama_unit_test_env: dict[str, str]) -> None: @chat_middleware - async def sample_middleware(context, next): - await next(context) + async def sample_middleware(context, call_next): + await call_next(context) ollama_chat_client = OllamaChatClient(middleware=[sample_middleware]) assert len(ollama_chat_client.middleware) == 1 diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py index edbf28b173..a227b6955e 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py @@ -129,11 +129,11 @@ class _AutoHandoffMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: """Intercept matching handoff tool calls and inject synthetic results.""" if context.function.name not in self._handoff_functions: - await next(context) + await call_next(context) return from agent_framework._middleware import MiddlewareTermination diff --git a/python/packages/purview/agent_framework_purview/_middleware.py b/python/packages/purview/agent_framework_purview/_middleware.py index dba7a3f649..42f8b37df6 100644 --- a/python/packages/purview/agent_framework_purview/_middleware.py +++ b/python/packages/purview/agent_framework_purview/_middleware.py @@ -48,7 +48,7 @@ class PurviewPolicyMiddleware(AgentMiddleware): async def process( self, context: AgentContext, - next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[AgentContext], Awaitable[None]], ) -> None: # type: ignore[override] resolved_user_id: str | None = None try: @@ -74,7 +74,7 @@ class PurviewPolicyMiddleware(AgentMiddleware): if not self._settings.ignore_exceptions: raise - await next(context) + await call_next(context) try: # Post (response) check only if we have a normal AgentResponse @@ -140,7 +140,7 @@ class PurviewChatPolicyMiddleware(ChatMiddleware): async def process( self, context: ChatContext, - next: Callable[[ChatContext], Awaitable[None]], + call_next: Callable[[ChatContext], Awaitable[None]], ) -> None: # type: ignore[override] resolved_user_id: str | None = None try: @@ -164,7 +164,7 @@ class PurviewChatPolicyMiddleware(ChatMiddleware): if not self._settings.ignore_exceptions: raise - await next(context) + await call_next(context) try: # Post (response) evaluation only if non-streaming and we have messages result shape diff --git a/python/samples/concepts/tools/README.md b/python/samples/concepts/tools/README.md index 0652494635..6643a42126 100644 --- a/python/samples/concepts/tools/README.md +++ b/python/samples/concepts/tools/README.md @@ -37,7 +37,7 @@ sequenceDiagram AML->>AMP: execute(AgentContext) loop Agent Middleware Chain - AMP->>AMP: middleware[i].process(context, next) + AMP->>AMP: middleware[i].process(context, call_next) Note right of AMP: Can modify: messages, options, thread end @@ -60,7 +60,7 @@ sequenceDiagram CML->>CMP: execute(ChatContext) loop Chat Middleware Chain - CMP->>CMP: middleware[i].process(context, next) + CMP->>CMP: middleware[i].process(context, call_next) Note right of CMP: Can modify: messages, options end @@ -81,7 +81,7 @@ sequenceDiagram loop For each function_call FIL->>FMP: execute(FunctionInvocationContext) loop Function Middleware Chain - FMP->>FMP: middleware[i].process(context, next) + FMP->>FMP: middleware[i].process(context, call_next) Note right of FMP: Can modify: arguments end FMP->>Tool: invoke(arguments) @@ -137,7 +137,7 @@ sequenceDiagram | `options` | `Mapping[str, Any]` | Chat options dict | | `stream` | `bool` | Whether streaming is enabled | | `metadata` | `dict` | Shared data between middleware | -| `result` | `AgentResponse \| None` | Set after `next()` is called | +| `result` | `AgentResponse \| None` | Set after `call_next()` is called | | `kwargs` | `Mapping[str, Any]` | Additional run arguments | **Key Operations:** @@ -150,7 +150,7 @@ sequenceDiagram - `context.messages` - Add, remove, or modify input messages - `context.options` - Change model parameters, temperature, etc. - `context.thread` - Replace or modify the thread -- `context.result` - Override the final response (after `next()`) +- `context.result` - Override the final response (after `call_next()`) ### 2. Chat Middleware Layer (`ChatMiddlewareLayer`) @@ -165,7 +165,7 @@ sequenceDiagram | `options` | `Mapping[str, Any]` | Chat options | | `stream` | `bool` | Whether streaming | | `metadata` | `dict` | Shared data between middleware | -| `result` | `ChatResponse \| None` | Set after `next()` is called | +| `result` | `ChatResponse \| None` | Set after `call_next()` is called | | `kwargs` | `Mapping[str, Any]` | Additional arguments | **Key Operations:** @@ -176,7 +176,7 @@ sequenceDiagram **What Can Be Modified:** - `context.messages` - Inject system prompts, filter content - `context.options` - Change model, temperature, tool_choice -- `context.result` - Override the response (after `next()`) +- `context.result` - Override the response (after `call_next()`) ### 3. Function Invocation Layer (`FunctionInvocationLayer`) @@ -251,23 +251,23 @@ response = await client.get_response( | `function` | `FunctionTool` | The function being invoked | | `arguments` | `BaseModel` | Validated Pydantic arguments | | `metadata` | `dict` | Shared data between middleware | -| `result` | `Any` | Set after `next()` is called | +| `result` | `Any` | Set after `call_next()` is called | | `kwargs` | `Mapping[str, Any]` | Runtime kwargs | **What Can Be Modified:** - `context.arguments` - Modify validated arguments before execution -- `context.result` - Override the function result (after `next()`) +- `context.result` - Override the function result (after `call_next()`) - Raise `MiddlewareTermination` to skip execution and terminate the function invocation loop **Special Behavior:** When `MiddlewareTermination` is raised in function middleware, it signals that the function invocation loop should exit **without making another LLM call**. This is useful when middleware determines that no further processing is needed (e.g., a termination condition is met). ```python class TerminatingMiddleware(FunctionMiddleware): - async def process(self, context: FunctionInvocationContext, next): + async def process(self, context: FunctionInvocationContext, call_next): if self.should_terminate(context): context.result = "terminated by middleware" raise MiddlewareTermination # Exit function invocation loop - await next(context) + await call_next(context) ``` ## Arguments Added/Altered at Each Layer @@ -334,20 +334,20 @@ class TerminatingMiddleware(FunctionMiddleware): There are three ways to exit a middleware's `process()` method: -### 1. Return Normally (with or without calling `next`) +### 1. Return Normally (with or without calling `call_next`) Returns control to the upstream middleware, allowing its post-processing code to run. ```python class CachingMiddleware(FunctionMiddleware): - async def process(self, context: FunctionInvocationContext, next): - # Option A: Return early WITHOUT calling next (skip downstream) + async def process(self, context: FunctionInvocationContext, call_next): + # Option A: Return early WITHOUT calling call_next (skip downstream) if cached := self.cache.get(context.function.name): context.result = cached return # Upstream post-processing still runs - # Option B: Call next, then return normally - await next(context) + # Option B: Call call_next, then return normally + await call_next(context) self.cache[context.function.name] = context.result return # Normal completion ``` @@ -358,11 +358,11 @@ Immediately exits the entire middleware chain. Upstream middleware's post-proces ```python class BlockedFunctionMiddleware(FunctionMiddleware): - async def process(self, context: FunctionInvocationContext, next): + async def process(self, context: FunctionInvocationContext, call_next): if context.function.name in self.blocked_functions: context.result = "Function blocked by policy" raise MiddlewareTermination("Blocked") # Skips ALL post-processing - await next(context) + await call_next(context) ``` ### 3. Raise Any Other Exception @@ -371,10 +371,10 @@ Bubbles up to the caller. The middleware chain is aborted and the exception prop ```python class ValidationMiddleware(FunctionMiddleware): - async def process(self, context: FunctionInvocationContext, next): + async def process(self, context: FunctionInvocationContext, call_next): if not self.is_valid(context.arguments): raise ValueError("Invalid arguments") # Bubbles up to user - await next(context) + await call_next(context) ``` ## `return` vs `raise MiddlewareTermination` @@ -383,13 +383,13 @@ The key difference is what happens to **upstream middleware's post-processing**: ```python class MiddlewareA(AgentMiddleware): - async def process(self, context, next): + async def process(self, context, call_next): print("A: before") - await next(context) + await call_next(context) print("A: after") # Does this run? class MiddlewareB(AgentMiddleware): - async def process(self, context, next): + async def process(self, context, call_next): print("B: before") context.result = "early result" # Choose one: @@ -408,14 +408,14 @@ With middleware registered as `[MiddlewareA, MiddlewareB]`: **Use `raise MiddlewareTermination`** when you want to completely bypass all remaining processing (e.g., blocking a request, returning cached response without any modification). -## Calling `next()` or Not +## Calling `call_next()` or Not -The decision to call `next(context)` determines whether downstream middleware and the actual operation execute: +The decision to call `call_next(context)` determines whether downstream middleware and the actual operation execute: -### Without calling `next()` - Skip downstream +### Without calling `call_next()` - Skip downstream ```python -async def process(self, context, next): +async def process(self, context, call_next): context.result = "replacement result" return # Downstream middleware and actual execution are SKIPPED ``` @@ -425,12 +425,12 @@ async def process(self, context, next): - Upstream middleware post-processing: ✅ Still runs (unless `MiddlewareTermination` raised) - Result: Whatever you set in `context.result` -### With calling `next()` - Full execution +### With calling `call_next()` - Full execution ```python -async def process(self, context, next): +async def process(self, context, call_next): # Pre-processing - await next(context) # Execute downstream + actual operation + await call_next(context) # Execute downstream + actual operation # Post-processing (context.result now contains real result) return ``` @@ -442,7 +442,7 @@ async def process(self, context, next): ### Summary Table -| Exit Method | Call `next()`? | Downstream Executes? | Actual Op Executes? | Upstream Post-Processing? | +| Exit Method | Call `call_next()`? | Downstream Executes? | Actual Op Executes? | Upstream Post-Processing? | |-------------|----------------|---------------------|---------------------|--------------------------| | `return` (or implicit) | Yes | ✅ | ✅ | ✅ Yes | | `return` | No | ❌ | ❌ | ✅ Yes | @@ -450,7 +450,7 @@ async def process(self, context, next): | `raise MiddlewareTermination` | Yes | ✅ | ✅ | ❌ No | | `raise OtherException` | Either | Depends | Depends | ❌ No (exception propagates) | -> **Note:** The first row (`return` after calling `next()`) is the default behavior. Python functions implicitly return `None` at the end, so simply calling `await next(context)` without an explicit `return` statement achieves this pattern. +> **Note:** The first row (`return` after calling `call_next()`) is the default behavior. Python functions implicitly return `None` at the end, so simply calling `await call_next(context)` without an explicit `return` statement achieves this pattern. ## Streaming vs Non-Streaming diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_agent_as_tool.py b/python/samples/getting_started/agents/azure_ai/azure_ai_with_agent_as_tool.py index b336e02d9d..f03fc4beb1 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_agent_as_tool.py +++ b/python/samples/getting_started/agents/azure_ai/azure_ai_with_agent_as_tool.py @@ -20,13 +20,13 @@ multiple specialized agents, each focusing on specific tasks. async def logging_middleware( context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: """MiddlewareTypes that logs tool invocations to show the delegation flow.""" print(f"[Calling tool: {context.function.name}]") print(f"[Request: {context.arguments}]") - await next(context) + await call_next(context) print(f"[Response: {context.result}]") diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_basic.py b/python/samples/getting_started/agents/openai/openai_responses_client_basic.py index 5ed88814fb..b564f07d51 100644 --- a/python/samples/getting_started/agents/openai/openai_responses_client_basic.py +++ b/python/samples/getting_started/agents/openai/openai_responses_client_basic.py @@ -28,7 +28,7 @@ response generation, showing both streaming and non-streaming responses. @chat_middleware async def security_and_override_middleware( context: ChatContext, - next: Callable[[ChatContext], Awaitable[None]], + call_next: Callable[[ChatContext], Awaitable[None]], ) -> None: """Function-based middleware that implements security filtering and response override.""" print("[SecurityMiddleware] Processing input...") @@ -59,7 +59,7 @@ async def security_and_override_middleware( raise MiddlewareTermination # Continue to next middleware or AI execution - await next(context) + await call_next(context) print("[SecurityMiddleware] Response generated.") print(type(context.result)) diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_agent_as_tool.py b/python/samples/getting_started/agents/openai/openai_responses_client_with_agent_as_tool.py index d90202a9af..d37d5a9b4a 100644 --- a/python/samples/getting_started/agents/openai/openai_responses_client_with_agent_as_tool.py +++ b/python/samples/getting_started/agents/openai/openai_responses_client_with_agent_as_tool.py @@ -19,13 +19,13 @@ multiple specialized agents, each focusing on specific tasks. async def logging_middleware( context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: """MiddlewareTypes that logs tool invocations to show the delegation flow.""" print(f"[Calling tool: {context.function.name}]") print(f"[Request: {context.arguments}]") - await next(context) + await call_next(context) print(f"[Response: {context.result}]") diff --git a/python/samples/getting_started/devui/weather_agent_azure/agent.py b/python/samples/getting_started/devui/weather_agent_azure/agent.py index d3872e2141..0ebf985913 100644 --- a/python/samples/getting_started/devui/weather_agent_azure/agent.py +++ b/python/samples/getting_started/devui/weather_agent_azure/agent.py @@ -37,7 +37,7 @@ def cleanup_resources(): @chat_middleware async def security_filter_middleware( context: ChatContext, - next: Callable[[ChatContext], Awaitable[None]], + call_next: Callable[[ChatContext], Awaitable[None]], ) -> None: """Chat middleware that blocks requests containing sensitive information.""" blocked_terms = ["password", "secret", "api_key", "token"] @@ -76,13 +76,13 @@ async def security_filter_middleware( raise MiddlewareTermination - await next(context) + await call_next(context) @function_middleware async def atlantis_location_filter_middleware( context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: """Function middleware that blocks weather requests for Atlantis.""" # Check if location parameter is "atlantis" @@ -94,7 +94,7 @@ async def atlantis_location_filter_middleware( ) raise MiddlewareTermination - await next(context) + await call_next(context) # NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. diff --git a/python/samples/getting_started/middleware/agent_and_run_level_middleware.py b/python/samples/getting_started/middleware/agent_and_run_level_middleware.py index c90dd1936b..b76e0ac520 100644 --- a/python/samples/getting_started/middleware/agent_and_run_level_middleware.py +++ b/python/samples/getting_started/middleware/agent_and_run_level_middleware.py @@ -49,7 +49,7 @@ def get_weather( class SecurityAgentMiddleware(AgentMiddleware): """Agent-level security middleware that validates all requests.""" - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process(self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: print("[SecurityMiddleware] Checking security for all requests...") # Check for security violations in the last user message @@ -58,22 +58,22 @@ class SecurityAgentMiddleware(AgentMiddleware): query = last_message.text.lower() if any(word in query for word in ["password", "secret", "credentials"]): print("[SecurityMiddleware] Security violation detected! Blocking request.") - return # Don't call next() to prevent execution + return # Don't call call_next() to prevent execution print("[SecurityMiddleware] Security check passed.") context.metadata["security_validated"] = True - await next(context) + await call_next(context) async def performance_monitor_middleware( context: AgentContext, - next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[AgentContext], Awaitable[None]], ) -> None: """Agent-level performance monitoring for all runs.""" print("[PerformanceMonitor] Starting performance monitoring...") start_time = time.time() - await next(context) + await call_next(context) end_time = time.time() duration = end_time - start_time @@ -85,7 +85,7 @@ async def performance_monitor_middleware( class HighPriorityMiddleware(AgentMiddleware): """Run-level middleware for high priority requests.""" - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process(self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: print("[HighPriority] Processing high priority request with expedited handling...") # Read metadata set by agent-level middleware @@ -96,13 +96,13 @@ class HighPriorityMiddleware(AgentMiddleware): context.metadata["priority"] = "high" context.metadata["expedited"] = True - await next(context) + await call_next(context) print("[HighPriority] High priority processing completed") async def debugging_middleware( context: AgentContext, - next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[AgentContext], Awaitable[None]], ) -> None: """Run-level debugging middleware for troubleshooting specific runs.""" print("[Debug] Debug mode enabled for this run") @@ -115,7 +115,7 @@ async def debugging_middleware( context.metadata["debug_enabled"] = True - await next(context) + await call_next(context) print("[Debug] Debug information collected") @@ -126,7 +126,7 @@ class CachingMiddleware(AgentMiddleware): def __init__(self) -> None: self.cache: dict[str, AgentResponse] = {} - async def process(self, context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process(self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: # Create a simple cache key from the last message last_message = context.messages[-1] if context.messages else None cache_key: str = last_message.text if last_message and last_message.text else "no_message" @@ -134,12 +134,12 @@ class CachingMiddleware(AgentMiddleware): if cache_key in self.cache: print(f"[Cache] Cache HIT for: '{cache_key[:30]}...'") context.result = self.cache[cache_key] # type: ignore - return # Don't call next(), return cached result + return # Don't call call_next(), return cached result print(f"[Cache] Cache MISS for: '{cache_key[:30]}...'") context.metadata["cache_key"] = cache_key - await next(context) + await call_next(context) # Cache the result if we have one if context.result: @@ -149,14 +149,14 @@ class CachingMiddleware(AgentMiddleware): async def function_logging_middleware( context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: """Function middleware that logs all function calls.""" function_name = context.function.name args = context.arguments print(f"[FunctionLog] Calling function: {function_name} with args: {args}") - await next(context) + await call_next(context) print(f"[FunctionLog] Function {function_name} completed") diff --git a/python/samples/getting_started/middleware/chat_middleware.py b/python/samples/getting_started/middleware/chat_middleware.py index 21ae052bdb..e35ba5981f 100644 --- a/python/samples/getting_started/middleware/chat_middleware.py +++ b/python/samples/getting_started/middleware/chat_middleware.py @@ -57,7 +57,7 @@ class InputObserverMiddleware(ChatMiddleware): async def process( self, context: ChatContext, - next: Callable[[ChatContext], Awaitable[None]], + call_next: Callable[[ChatContext], Awaitable[None]], ) -> None: """Observe and modify input messages before they are sent to AI.""" print("[InputObserverMiddleware] Observing input messages:") @@ -91,7 +91,7 @@ class InputObserverMiddleware(ChatMiddleware): context.messages[:] = modified_messages # Continue to next middleware or AI execution - await next(context) + await call_next(context) # Observe that processing is complete print("[InputObserverMiddleware] Processing completed") @@ -100,7 +100,7 @@ class InputObserverMiddleware(ChatMiddleware): @chat_middleware async def security_and_override_middleware( context: ChatContext, - next: Callable[[ChatContext], Awaitable[None]], + call_next: Callable[[ChatContext], Awaitable[None]], ) -> None: """Function-based middleware that implements security filtering and response override.""" print("[SecurityMiddleware] Processing input...") @@ -131,7 +131,7 @@ async def security_and_override_middleware( raise MiddlewareTermination # Continue to next middleware or AI execution - await next(context) + await call_next(context) async def class_based_chat_middleware() -> None: diff --git a/python/samples/getting_started/middleware/class_based_middleware.py b/python/samples/getting_started/middleware/class_based_middleware.py index 727c0a2821..ab6bfd5ab4 100644 --- a/python/samples/getting_started/middleware/class_based_middleware.py +++ b/python/samples/getting_started/middleware/class_based_middleware.py @@ -50,7 +50,7 @@ class SecurityAgentMiddleware(AgentMiddleware): async def process( self, context: AgentContext, - next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[AgentContext], Awaitable[None]], ) -> None: # Check for potential security violations in the query # Look at the last user message @@ -63,11 +63,11 @@ class SecurityAgentMiddleware(AgentMiddleware): context.result = AgentResponse( messages=[ChatMessage("assistant", ["Detected sensitive information, the request is blocked."])] ) - # Simply don't call next() to prevent execution + # Simply don't call call_next() to prevent execution return print("[SecurityAgentMiddleware] Security check passed.") - await next(context) + await call_next(context) class LoggingFunctionMiddleware(FunctionMiddleware): @@ -76,14 +76,14 @@ class LoggingFunctionMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: function_name = context.function.name print(f"[LoggingFunctionMiddleware] About to call function: {function_name}.") start_time = time.time() - await next(context) + await call_next(context) end_time = time.time() duration = end_time - start_time diff --git a/python/samples/getting_started/middleware/decorator_middleware.py b/python/samples/getting_started/middleware/decorator_middleware.py index 2ea1196bc3..3f5e57e48e 100644 --- a/python/samples/getting_started/middleware/decorator_middleware.py +++ b/python/samples/getting_started/middleware/decorator_middleware.py @@ -50,18 +50,18 @@ def get_current_time() -> str: @agent_middleware # Decorator marks this as agent middleware - no type annotations needed -async def simple_agent_middleware(context, next): # type: ignore - parameters intentionally untyped to demonstrate decorator functionality +async def simple_agent_middleware(context, call_next): # type: ignore - parameters intentionally untyped to demonstrate decorator functionality """Agent middleware that runs before and after agent execution.""" print("[Agent MiddlewareTypes] Before agent execution") - await next(context) + await call_next(context) print("[Agent MiddlewareTypes] After agent execution") @function_middleware # Decorator marks this as function middleware - no type annotations needed -async def simple_function_middleware(context, next): # type: ignore - parameters intentionally untyped to demonstrate decorator functionality +async def simple_function_middleware(context, call_next): # type: ignore - parameters intentionally untyped to demonstrate decorator functionality """Function middleware that runs before and after function calls.""" print(f"[Function MiddlewareTypes] Before calling: {context.function.name}") # type: ignore - await next(context) + await call_next(context) print(f"[Function MiddlewareTypes] After calling: {context.function.name}") # type: ignore diff --git a/python/samples/getting_started/middleware/exception_handling_with_middleware.py b/python/samples/getting_started/middleware/exception_handling_with_middleware.py index bc752e3615..b929af4c94 100644 --- a/python/samples/getting_started/middleware/exception_handling_with_middleware.py +++ b/python/samples/getting_started/middleware/exception_handling_with_middleware.py @@ -35,13 +35,13 @@ def unstable_data_service( async def exception_handling_middleware( - context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: function_name = context.function.name try: print(f"[ExceptionHandlingMiddleware] Executing function: {function_name}") - await next(context) + await call_next(context) print(f"[ExceptionHandlingMiddleware] Function {function_name} completed successfully.") except TimeoutError as e: print(f"[ExceptionHandlingMiddleware] Caught TimeoutError: {e}") diff --git a/python/samples/getting_started/middleware/function_based_middleware.py b/python/samples/getting_started/middleware/function_based_middleware.py index 1616aa5fc3..d9b9062003 100644 --- a/python/samples/getting_started/middleware/function_based_middleware.py +++ b/python/samples/getting_started/middleware/function_based_middleware.py @@ -27,7 +27,7 @@ The example includes: Function-based middleware is ideal for simple, stateless operations and provides a more lightweight approach compared to class-based middleware. Both agent and function middleware -can be implemented as async functions that accept context and next parameters. +can be implemented as async functions that accept context and call_next parameters. """ @@ -43,7 +43,7 @@ def get_weather( async def security_agent_middleware( context: AgentContext, - next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[AgentContext], Awaitable[None]], ) -> None: """Agent middleware that checks for security violations.""" # Check for potential security violations in the query @@ -53,16 +53,16 @@ async def security_agent_middleware( query = last_message.text if "password" in query.lower() or "secret" in query.lower(): print("[SecurityAgentMiddleware] Security Warning: Detected sensitive information, blocking request.") - # Simply don't call next() to prevent execution + # Simply don't call call_next() to prevent execution return print("[SecurityAgentMiddleware] Security check passed.") - await next(context) + await call_next(context) async def logging_function_middleware( context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: """Function middleware that logs function calls.""" function_name = context.function.name @@ -70,7 +70,7 @@ async def logging_function_middleware( start_time = time.time() - await next(context) + await call_next(context) end_time = time.time() duration = end_time - start_time diff --git a/python/samples/getting_started/middleware/middleware_termination.py b/python/samples/getting_started/middleware/middleware_termination.py index 05fad65cf4..96c5917f58 100644 --- a/python/samples/getting_started/middleware/middleware_termination.py +++ b/python/samples/getting_started/middleware/middleware_termination.py @@ -23,7 +23,7 @@ MiddlewareTypes Termination Example This sample demonstrates how middleware can terminate execution using the `context.terminate` flag. The example includes: -- PreTerminationMiddleware: Terminates execution before calling next() to prevent agent processing +- PreTerminationMiddleware: Terminates execution before calling call_next() to prevent agent processing - PostTerminationMiddleware: Allows processing to complete but terminates further execution This is useful for implementing security checks, rate limiting, or early exit conditions. @@ -49,7 +49,7 @@ class PreTerminationMiddleware(AgentMiddleware): async def process( self, context: AgentContext, - next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[AgentContext], Awaitable[None]], ) -> None: # Check if the user message contains any blocked words last_message = context.messages[-1] if context.messages else None @@ -75,7 +75,7 @@ class PreTerminationMiddleware(AgentMiddleware): # Set terminate flag to prevent further processing raise MiddlewareTermination - await next(context) + await call_next(context) class PostTerminationMiddleware(AgentMiddleware): @@ -88,7 +88,7 @@ class PostTerminationMiddleware(AgentMiddleware): async def process( self, context: AgentContext, - next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[AgentContext], Awaitable[None]], ) -> None: print(f"[PostTerminationMiddleware] Processing request (response count: {self.response_count})") @@ -101,7 +101,7 @@ class PostTerminationMiddleware(AgentMiddleware): raise MiddlewareTermination # Allow the agent to process normally - await next(context) + await call_next(context) # Increment response count after processing self.response_count += 1 diff --git a/python/samples/getting_started/middleware/override_result_with_middleware.py b/python/samples/getting_started/middleware/override_result_with_middleware.py index 6f27c6a7da..6f83c4bee2 100644 --- a/python/samples/getting_started/middleware/override_result_with_middleware.py +++ b/python/samples/getting_started/middleware/override_result_with_middleware.py @@ -48,11 +48,11 @@ def get_weather( return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." -async def weather_override_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: +async def weather_override_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: """Chat middleware that overrides weather results for both streaming and non-streaming cases.""" # Let the original agent execution complete first - await next(context) + await call_next(context) # Check if there's a result to override (agent called weather function) if context.result is not None: @@ -83,9 +83,9 @@ async def weather_override_middleware(context: ChatContext, next: Callable[[Chat context.result = ChatResponse(messages=[ChatMessage(role="assistant", text=custom_message)]) -async def validate_weather_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None: +async def validate_weather_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: """Chat middleware that simulates result validation for both streaming and non-streaming cases.""" - await next(context) + await call_next(context) validation_note = "Validation: weather data verified." @@ -103,9 +103,9 @@ async def validate_weather_middleware(context: ChatContext, next: Callable[[Chat context.result.messages.append(ChatMessage(role="assistant", text=validation_note)) -async def agent_cleanup_middleware(context: AgentContext, next: Callable[[AgentContext], Awaitable[None]]) -> None: +async def agent_cleanup_middleware(context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: """Agent middleware that validates chat middleware effects and cleans the result.""" - await next(context) + await call_next(context) if context.result is None: return diff --git a/python/samples/getting_started/middleware/runtime_context_delegation.py b/python/samples/getting_started/middleware/runtime_context_delegation.py index d4669239a6..700b1da6f5 100644 --- a/python/samples/getting_started/middleware/runtime_context_delegation.py +++ b/python/samples/getting_started/middleware/runtime_context_delegation.py @@ -54,7 +54,7 @@ class SessionContextContainer: async def inject_context_middleware( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: """MiddlewareTypes that extracts runtime context from kwargs and stores in container. @@ -74,7 +74,7 @@ class SessionContextContainer: print(f" - Session Metadata Keys: {list(self.session_metadata.keys())}") # Continue to tool execution - await next(context) + await call_next(context) # Create a container instance that will be shared via closure @@ -278,19 +278,19 @@ async def pattern_2_hierarchical_with_kwargs_propagation() -> None: @function_middleware async def email_kwargs_tracker( - context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: email_agent_kwargs.update(context.kwargs) print(f"[EmailAgent] Received runtime context: {list(context.kwargs.keys())}") - await next(context) + await call_next(context) @function_middleware async def sms_kwargs_tracker( - context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: sms_agent_kwargs.update(context.kwargs) print(f"[SMSAgent] Received runtime context: {list(context.kwargs.keys())}") - await next(context) + await call_next(context) client = OpenAIChatClient(model_id="gpt-4o-mini") @@ -359,7 +359,7 @@ class AuthContextMiddleware: self.validated_tokens: list[str] = [] async def validate_and_track( - self, context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] + self, context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] ) -> None: """Validate API token and track usage.""" api_token = context.kwargs.get("api_token") @@ -375,7 +375,7 @@ class AuthContextMiddleware: else: print("[AuthMiddleware] No API token provided") - await next(context) + await call_next(context) @tool(approval_mode="never_require") diff --git a/python/samples/getting_started/middleware/shared_state_middleware.py b/python/samples/getting_started/middleware/shared_state_middleware.py index f48ec3807d..a377d7dfd3 100644 --- a/python/samples/getting_started/middleware/shared_state_middleware.py +++ b/python/samples/getting_started/middleware/shared_state_middleware.py @@ -57,7 +57,7 @@ class MiddlewareContainer: async def call_counter_middleware( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: """First middleware: increments call count in shared state.""" # Increment the shared call count @@ -66,18 +66,18 @@ class MiddlewareContainer: print(f"[CallCounter] This is function call #{self.call_count}") # Call the next middleware/function - await next(context) + await call_next(context) async def result_enhancer_middleware( self, context: FunctionInvocationContext, - next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: """Second middleware: uses shared call count to enhance function results.""" print(f"[ResultEnhancer] Current total calls so far: {self.call_count}") # Call the next middleware/function - await next(context) + await call_next(context) # After function execution, enhance the result using shared state if context.result: diff --git a/python/samples/getting_started/middleware/thread_behavior_middleware.py b/python/samples/getting_started/middleware/thread_behavior_middleware.py index 0665d23720..e3306eef7b 100644 --- a/python/samples/getting_started/middleware/thread_behavior_middleware.py +++ b/python/samples/getting_started/middleware/thread_behavior_middleware.py @@ -21,14 +21,14 @@ The example shows: - How AgentContext.thread property behaves across multiple runs - How middleware can access conversation history through the thread -- The timing of when thread messages are populated (before vs after next() call) +- The timing of when thread messages are populated (before vs after call_next() call) - How to track thread state changes across runs Key behaviors demonstrated: -1. First run: context.messages is populated, context.thread is initially empty (before next()) -2. After next(): thread contains input message + response from agent +1. First run: context.messages is populated, context.thread is initially empty (before call_next()) +2. After call_next(): thread contains input message + response from agent 3. Second run: context.messages contains only current input, thread contains previous history -4. After next(): thread contains full conversation history (all previous + current messages) +4. After call_next(): thread contains full conversation history (all previous + current messages) """ @@ -46,7 +46,7 @@ def get_weather( async def thread_tracking_middleware( context: AgentContext, - next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[AgentContext], Awaitable[None]], ) -> None: """MiddlewareTypes that tracks and logs thread behavior across runs.""" thread_messages = [] @@ -56,8 +56,8 @@ async def thread_tracking_middleware( print(f"[MiddlewareTypes pre-execution] Current input messages: {len(context.messages)}") print(f"[MiddlewareTypes pre-execution] Thread history messages: {len(thread_messages)}") - # Call next to execute the agent - await next(context) + # Call call_next to execute the agent + await call_next(context) # Check thread state after agent execution updated_thread_messages = [] diff --git a/python/samples/getting_started/tools/README.md b/python/samples/getting_started/tools/README.md index e732784dbb..3f5445bfb8 100644 --- a/python/samples/getting_started/tools/README.md +++ b/python/samples/getting_started/tools/README.md @@ -19,6 +19,7 @@ keep `approval_mode="always_require"` unless you are confident in the tool behav | [`function_tool_with_thread_injection.py`](function_tool_with_thread_injection.py) | Shows how to access the current `thread` object inside a local tool via `**kwargs`. | | [`function_tool_with_max_exceptions.py`](function_tool_with_max_exceptions.py) | Shows how to limit the number of times a tool can fail with exceptions using `max_invocation_exceptions`. Useful for preventing expensive tools from being called repeatedly when they keep failing. | | [`function_tool_with_max_invocations.py`](function_tool_with_max_invocations.py) | Demonstrates limiting the total number of times a tool can be invoked using `max_invocations`. Useful for rate-limiting expensive operations or ensuring tools are only called a specific number of times per conversation. | +| [`function_tool_with_explicit_schema.py`](function_tool_with_explicit_schema.py) | Demonstrates how to provide an explicit Pydantic model or JSON schema dictionary to the `@tool` decorator via the `schema` parameter, bypassing automatic inference from the function signature. | | [`tool_in_class.py`](tool_in_class.py) | Shows how to use the `tool` decorator with class methods to create stateful tools. Demonstrates how class state can control tool behavior dynamically, allowing you to adjust tool functionality at runtime by modifying class properties. | ## Key Concepts @@ -26,6 +27,7 @@ keep `approval_mode="always_require"` unless you are confident in the tool behav ### Local Tool Features - **Function Declarations**: Define tool schemas without implementations for testing or external tools +- **Explicit Schema**: Provide a Pydantic model or JSON schema dict to control the tool's parameter schema directly - **Dependency Injection**: Create tools from configurations with runtime-injected implementations - **Error Handling**: Gracefully handle and recover from tool execution failures - **Approval Workflows**: Require user approval before executing sensitive or important operations @@ -55,6 +57,23 @@ def sensitive_operation(data: Annotated[str, "Data to process"]) -> str: return f"Processed: {data}" ``` +#### Tool with Explicit Schema + +```python +from pydantic import BaseModel, Field +from agent_framework import tool +from typing import Annotated + +class WeatherInput(BaseModel): + location: Annotated[str, Field(description="City name")] + unit: str = "celsius" + +@tool(schema=WeatherInput) +def get_weather(location: str, unit: str = "celsius") -> str: + """Get the weather for a location.""" + return f"Weather in {location}: 22 {unit}" +``` + #### Tool with Invocation Limits ```python diff --git a/python/samples/getting_started/tools/function_tool_with_explicit_schema.py b/python/samples/getting_started/tools/function_tool_with_explicit_schema.py new file mode 100644 index 0000000000..6b0a812660 --- /dev/null +++ b/python/samples/getting_started/tools/function_tool_with_explicit_schema.py @@ -0,0 +1,81 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Function Tool with Explicit Schema Example + +This example demonstrates how to provide an explicit schema to the @tool decorator +using the `schema` parameter, bypassing the automatic inference from the function +signature. This is useful when you want full control over the tool's parameter +schema that the AI model sees, or when the function signature does not accurately +represent the desired schema. + +Two approaches are shown: +1. Using a Pydantic BaseModel subclass as the schema +2. Using a raw JSON schema dictionary as the schema +""" + +import asyncio +from typing import Annotated + +from agent_framework import tool +from agent_framework.openai import OpenAIResponsesClient +from pydantic import BaseModel, Field + + +# Approach 1: Pydantic model as explicit schema +class WeatherInput(BaseModel): + """Input schema for the weather tool.""" + + location: Annotated[str, Field(description="The city name to get weather for")] + unit: Annotated[str, Field(description="Temperature unit: celsius or fahrenheit")] = "celsius" + + +@tool( + name="get_weather", + description="Get the current weather for a given location.", + schema=WeatherInput, + approval_mode="never_require", +) +def get_weather(location: str, unit: str = "celsius") -> str: + """Get the current weather for a location.""" + return f"The weather in {location} is 22 degrees {unit}." + + +# Approach 2: JSON schema dictionary as explicit schema +get_current_time_schema = { + "type": "object", + "properties": { + "timezone": {"type": "string", "description": "The timezone to get the current time for", "default": "UTC"}, + }, +} + + +@tool( + name="get_current_time", + description="Get the current time in a given timezone.", + schema=get_current_time_schema, + approval_mode="never_require", +) +def get_current_time(timezone: str = "UTC") -> str: + """Get the current time.""" + from datetime import datetime + from zoneinfo import ZoneInfo + + return f"The current time in {timezone} is {datetime.now(ZoneInfo(timezone)).isoformat()}" + + +async def main(): + agent = OpenAIResponsesClient().as_agent( + name="AssistantAgent", + instructions="You are a helpful assistant. Use the available tools to answer questions.", + tools=[get_weather, get_current_time], + ) + + query = "What is the weather in Seattle and what time is it?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Result: {result.text}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/README.md b/python/samples/getting_started/workflows/README.md index 1d16f8f24b..7b368335a3 100644 --- a/python/samples/getting_started/workflows/README.md +++ b/python/samples/getting_started/workflows/README.md @@ -84,6 +84,7 @@ Once comfortable with these, explore the rest of the samples below. | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | | Human-In-The-Loop (Guessing Game) | [human-in-the-loop/guessing_game_with_human_input.py](./human-in-the-loop/guessing_game_with_human_input.py) | Interactive request/response prompts with a human via `ctx.request_info()` | | Agents with Approval Requests in Workflows | [human-in-the-loop/agents_with_approval_requests.py](./human-in-the-loop/agents_with_approval_requests.py) | Agents that create approval requests during workflow execution and wait for human approval to proceed | +| Agents with Declaration-Only Tools | [human-in-the-loop/agents_with_declaration_only_tools.py](./human-in-the-loop/agents_with_declaration_only_tools.py) | Workflow pauses when agent calls a client-side tool (`func=None`), caller supplies the result | | SequentialBuilder Request Info | [human-in-the-loop/sequential_request_info.py](./human-in-the-loop/sequential_request_info.py) | Request info for agent responses mid-workflow using `.with_request_info()` on SequentialBuilder | | ConcurrentBuilder Request Info | [human-in-the-loop/concurrent_request_info.py](./human-in-the-loop/concurrent_request_info.py) | Review concurrent agent outputs before aggregation using `.with_request_info()` on ConcurrentBuilder | | GroupChatBuilder Request Info | [human-in-the-loop/group_chat_request_info.py](./human-in-the-loop/group_chat_request_info.py) | Steer group discussions with periodic guidance using `.with_request_info()` on GroupChatBuilder | diff --git a/python/samples/getting_started/workflows/human-in-the-loop/agents_with_declaration_only_tools.py b/python/samples/getting_started/workflows/human-in-the-loop/agents_with_declaration_only_tools.py new file mode 100644 index 0000000000..b203c2d522 --- /dev/null +++ b/python/samples/getting_started/workflows/human-in-the-loop/agents_with_declaration_only_tools.py @@ -0,0 +1,95 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Sample: Declaration-only tools in a workflow (issue #3425) + +A declaration-only tool (func=None) represents a client-side tool that the +framework cannot execute — the LLM can call it, but the workflow must pause +so the caller can supply the result. + +Flow: + 1. The agent is given a declaration-only tool ("get_user_location"). + 2. When the LLM decides to call it, the workflow pauses and emits a + request_info event containing the FunctionCallContent. + 3. The caller inspects the tool name/args, runs the tool however it wants, + and feeds the result back via workflow.run(responses={...}). + 4. The workflow resumes — the agent sees the tool result and finishes. + +Prerequisites: + - Azure OpenAI endpoint configured via environment variables. + - `az login` for AzureCliCredential. +""" + +import asyncio +import json +from typing import Any + +from agent_framework import Content, FunctionTool, WorkflowBuilder +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential + +# A declaration-only tool: the schema is sent to the LLM, but the framework +# has no implementation to execute. The caller must supply the result. +get_user_location = FunctionTool( + name="get_user_location", + func=None, + description="Get the user's current city. Only the client application can resolve this.", + input_model={ + "type": "object", + "properties": { + "reason": {"type": "string", "description": "Why the location is needed"}, + }, + "required": ["reason"], + }, +) + + +async def main() -> None: + agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + name="WeatherBot", + instructions=( + "You are a helpful weather assistant. " + "When the user asks about weather, call get_user_location first, " + "then make up a plausible forecast for that city." + ), + tools=[get_user_location], + ) + + workflow = WorkflowBuilder(start_executor=agent).build() + + # --- First run: the agent should call the declaration-only tool --- + print(">>> Sending: 'What's the weather like today?'") + result = await workflow.run("What's the weather like today?") + + requests = result.get_request_info_events() + if not requests: + # The LLM chose not to call the tool — print whatever it said and exit + print(f"Agent replied without calling the tool: {result.get_outputs()}") + return + + # --- Inspect what the agent wants --- + for req in requests: + data = req.data + args = json.loads(data.arguments) if isinstance(data.arguments, str) else data.arguments + print(f"Workflow paused — agent called: {data.name}({args})") + + # --- "Execute" the tool on the client side and send results back --- + responses: dict[str, Any] = {} + for req in requests: + # In a real app this could be a GPS lookup, browser API, user prompt, etc. + client_result = "Seattle, WA" + print(f"Client provides result for {req.data.name}: {client_result!r}") + responses[req.request_id] = Content.from_function_result( + call_id=req.data.call_id, + result=client_result, + ) + + result = await workflow.run(responses=responses) + + # --- Final answer --- + for output in result.get_outputs(): + print(f"\nAgent: {output.text}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/uv.lock b/python/uv.lock index e762f40433..0a6de3f940 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1324,19 +1324,19 @@ wheels = [ [[package]] name = "claude-agent-sdk" -version = "0.1.33" +version = "0.1.34" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "mcp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or 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/57/aa/5c417ef464d3fa712d830cd56a9a79aef8dfb5bc3414aae4bae136cf4e73/claude_agent_sdk-0.1.33.tar.gz", hash = "sha256:134bf403bb7553d829dadec42c30ecef340f5d4ad1595c1bdef933a9ca3129cf", size = 61196, upload-time = "2026-02-07T19:19:53.372Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/69/faeb64e9c8f0962cbf12bee1b959acc41f87c82947ec7074a6780b417001/claude_agent_sdk-0.1.34.tar.gz", hash = "sha256:db9e4023a754d9a58a0793666fe9174ead277197cd896156d2f8784cc73c5006", size = 61196, upload-time = "2026-02-10T01:04:00.585Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/31/1ac5d536013b1e38b37d71928a0db214cbd47e7bb815c21141dbc6dd93b6/claude_agent_sdk-0.1.33-py3-none-macosx_11_0_arm64.whl", hash = "sha256:57886a2dd124e5b3c9e12ec3e4841742ab3444d1e428b45ceaec8841c96698fa", size = 54323456, upload-time = "2026-02-07T19:19:39.407Z" }, - { url = "https://files.pythonhosted.org/packages/54/36/79c3feb3f2c95591b80de39a1d3097d30bc3a9a84fcff6422f5434f1187a/claude_agent_sdk-0.1.33-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:ea0f1e4fadeec766000122723c406a6f47c6210ea11bb5cc0c88af11ef7c940c", size = 69106772, upload-time = "2026-02-07T19:19:42.998Z" }, - { url = "https://files.pythonhosted.org/packages/03/a8/64d22ae767154da4629004a80e9f59f71b5070d55fcfade4efdfb06b1f7a/claude_agent_sdk-0.1.33-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:0ecd822c577b4ea2a52e51146a24dcea73eb69ff366bdb875785dadb116d593b", size = 69688592, upload-time = "2026-02-07T19:19:46.629Z" }, - { url = "https://files.pythonhosted.org/packages/b8/aa/83677a3d42b047bcacf4dbe730bf5189a106b5b6746ee83f6920e5d9729a/claude_agent_sdk-0.1.33-py3-none-win_amd64.whl", hash = "sha256:a9fbd09d8f947005e087340ecd0706ed35639c946b4bd49429d3132db4cb3751", size = 72211078, upload-time = "2026-02-07T19:19:50.528Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7b/2ccdc12b553a61b59b0470f1cf3b0a864c79ab8a4ac8013ea22fa3e6c461/claude_agent_sdk-0.1.34-py3-none-macosx_11_0_arm64.whl", hash = "sha256:18569ab4bfb5451c4aacb51c0d44eb9802d18d8442d30c29f32b6e8a2479d210", size = 54604881, upload-time = "2026-02-10T01:03:44.575Z" }, + { url = "https://files.pythonhosted.org/packages/38/59/335b213fb3342c4405fa992cc9e45e52e18a543068f0574ae84010dc4c08/claude_agent_sdk-0.1.34-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:d7ecd7421066e405376d3feca21ccb3e9245506ba7c219858f7a7f0129877cdb", size = 69359030, upload-time = "2026-02-10T01:03:49.113Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/28c715efdad7b5c413e046f5f914d9ab888d25f2c3bb9233f1164d58d2be/claude_agent_sdk-0.1.34-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:82e9148410ec98ff4061e43e85601d8f0a2e8568d897ab82c324ccf11c297fc5", size = 69949555, upload-time = "2026-02-10T01:03:53.525Z" }, + { url = "https://files.pythonhosted.org/packages/12/3d/843159343b20d6c9b44cf4a7fe46b568d5b448276ba8ba4c49178d26ba4c/claude_agent_sdk-0.1.34-py3-none-win_amd64.whl", hash = "sha256:a64031a9bf5c70388a6a84368d350d68586d9854a1539f494b46ec6d0b6acf93", size = 72493949, upload-time = "2026-02-10T01:03:57.839Z" }, ] [[package]] @@ -1542,101 +1542,115 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.3" +version = "7.13.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/43/3e4ac666cc35f231fa70c94e9f38459299de1a152813f9d2f60fc5f3ecaf/coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac", size = 826832, upload-time = "2026-02-03T14:02:30.944Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/07/1c8099563a8a6c389a31c2d0aa1497cee86d6248bb4b9ba5e779215db9f9/coverage-7.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b4f345f7265cdbdb5ec2521ffff15fa49de6d6c39abf89fc7ad68aa9e3a55f0", size = 219143, upload-time = "2026-02-03T13:59:40.459Z" }, - { url = "https://files.pythonhosted.org/packages/69/39/a892d44af7aa092cab70e0cc5cdbba18eeccfe1d6930695dab1742eef9e9/coverage-7.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:96c3be8bae9d0333e403cc1a8eb078a7f928b5650bae94a18fb4820cc993fb9b", size = 219663, upload-time = "2026-02-03T13:59:41.951Z" }, - { url = "https://files.pythonhosted.org/packages/9a/25/9669dcf4c2bb4c3861469e6db20e52e8c11908cf53c14ec9b12e9fd4d602/coverage-7.13.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d6f4a21328ea49d38565b55599e1c02834e76583a6953e5586d65cb1efebd8f8", size = 246424, upload-time = "2026-02-03T13:59:43.418Z" }, - { url = "https://files.pythonhosted.org/packages/f3/68/d9766c4e298aca62ea5d9543e1dd1e4e1439d7284815244d8b7db1840bfb/coverage-7.13.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc970575799a9d17d5c3fafd83a0f6ccf5d5117cdc9ad6fbd791e9ead82418b0", size = 248228, upload-time = "2026-02-03T13:59:44.816Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e2/eea6cb4a4bd443741adf008d4cccec83a1f75401df59b6559aca2bdd9710/coverage-7.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ff33b652b3556b05e204ae20793d1f872161b0fa5ec8a9ac76f8430e152ed6", size = 250103, upload-time = "2026-02-03T13:59:46.271Z" }, - { url = "https://files.pythonhosted.org/packages/db/77/664280ecd666c2191610842177e2fab9e5dbdeef97178e2078fed46a3d2c/coverage-7.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7df8759ee57b9f3f7b66799b7660c282f4375bef620ade1686d6a7b03699e75f", size = 247107, upload-time = "2026-02-03T13:59:48.53Z" }, - { url = "https://files.pythonhosted.org/packages/2b/df/2a672eab99e0d0eba52d8a63e47dc92245eee26954d1b2d3c8f7d372151f/coverage-7.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f45c9bcb16bee25a798ccba8a2f6a1251b19de6a0d617bb365d7d2f386c4e20e", size = 248143, upload-time = "2026-02-03T13:59:50.027Z" }, - { url = "https://files.pythonhosted.org/packages/a5/dc/a104e7a87c13e57a358b8b9199a8955676e1703bb372d79722b54978ae45/coverage-7.13.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:318b2e4753cbf611061e01b6cc81477e1cdfeb69c36c4a14e6595e674caadb56", size = 246148, upload-time = "2026-02-03T13:59:52.025Z" }, - { url = "https://files.pythonhosted.org/packages/2b/89/e113d3a58dc20b03b7e59aed1e53ebc9ca6167f961876443e002b10e3ae9/coverage-7.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:24db3959de8ee394eeeca89ccb8ba25305c2da9a668dd44173394cbd5aa0777f", size = 246414, upload-time = "2026-02-03T13:59:53.859Z" }, - { url = "https://files.pythonhosted.org/packages/3f/60/a3fd0a6e8d89b488396019a2268b6a1f25ab56d6d18f3be50f35d77b47dc/coverage-7.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:be14d0622125edef21b3a4d8cd2d138c4872bf6e38adc90fd92385e3312f406a", size = 247023, upload-time = "2026-02-03T13:59:55.454Z" }, - { url = "https://files.pythonhosted.org/packages/19/fa/de4840bb939dbb22ba0648a6d8069fa91c9cf3b3fca8b0d1df461e885b3d/coverage-7.13.3-cp310-cp310-win32.whl", hash = "sha256:53be4aab8ddef18beb6188f3a3fdbf4d1af2277d098d4e618be3a8e6c88e74be", size = 221751, upload-time = "2026-02-03T13:59:57.383Z" }, - { url = "https://files.pythonhosted.org/packages/de/87/233ff8b7ef62fb63f58c78623b50bef69681111e0c4d43504f422d88cda4/coverage-7.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:bfeee64ad8b4aae3233abb77eb6b52b51b05fa89da9645518671b9939a78732b", size = 222686, upload-time = "2026-02-03T13:59:58.825Z" }, - { url = "https://files.pythonhosted.org/packages/ec/09/1ac74e37cf45f17eb41e11a21854f7f92a4c2d6c6098ef4a1becb0c6d8d3/coverage-7.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5907605ee20e126eeee2abe14aae137043c2c8af2fa9b38d2ab3b7a6b8137f73", size = 219276, upload-time = "2026-02-03T14:00:00.296Z" }, - { url = "https://files.pythonhosted.org/packages/2e/cb/71908b08b21beb2c437d0d5870c4ec129c570ca1b386a8427fcdb11cf89c/coverage-7.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a88705500988c8acad8b8fd86c2a933d3aa96bec1ddc4bc5cb256360db7bbd00", size = 219776, upload-time = "2026-02-03T14:00:02.414Z" }, - { url = "https://files.pythonhosted.org/packages/09/85/c4f3dd69232887666a2c0394d4be21c60ea934d404db068e6c96aa59cd87/coverage-7.13.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bbb5aa9016c4c29e3432e087aa29ebee3f8fda089cfbfb4e6d64bd292dcd1c2", size = 250196, upload-time = "2026-02-03T14:00:04.197Z" }, - { url = "https://files.pythonhosted.org/packages/9c/cc/560ad6f12010344d0778e268df5ba9aa990aacccc310d478bf82bf3d302c/coverage-7.13.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c2be202a83dde768937a61cdc5d06bf9fb204048ca199d93479488e6247656c", size = 252111, upload-time = "2026-02-03T14:00:05.639Z" }, - { url = "https://files.pythonhosted.org/packages/f0/66/3193985fb2c58e91f94cfbe9e21a6fdf941e9301fe2be9e92c072e9c8f8c/coverage-7.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f45e32ef383ce56e0ca099b2e02fcdf7950be4b1b56afaab27b4ad790befe5b", size = 254217, upload-time = "2026-02-03T14:00:07.738Z" }, - { url = "https://files.pythonhosted.org/packages/c5/78/f0f91556bf1faa416792e537c523c5ef9db9b1d32a50572c102b3d7c45b3/coverage-7.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ed2e787249b922a93cd95c671cc9f4c9797a106e81b455c83a9ddb9d34590c0", size = 250318, upload-time = "2026-02-03T14:00:09.224Z" }, - { url = "https://files.pythonhosted.org/packages/6f/aa/fc654e45e837d137b2c1f3a2cc09b4aea1e8b015acd2f774fa0f3d2ddeba/coverage-7.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:05dd25b21afffe545e808265897c35f32d3e4437663923e0d256d9ab5031fb14", size = 251909, upload-time = "2026-02-03T14:00:10.712Z" }, - { url = "https://files.pythonhosted.org/packages/73/4d/ab53063992add8a9ca0463c9d92cce5994a29e17affd1c2daa091b922a93/coverage-7.13.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46d29926349b5c4f1ea4fca95e8c892835515f3600995a383fa9a923b5739ea4", size = 249971, upload-time = "2026-02-03T14:00:12.402Z" }, - { url = "https://files.pythonhosted.org/packages/29/25/83694b81e46fcff9899694a1b6f57573429cdd82b57932f09a698f03eea5/coverage-7.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fae6a21537519c2af00245e834e5bf2884699cc7c1055738fd0f9dc37a3644ad", size = 249692, upload-time = "2026-02-03T14:00:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/d4/ef/d68fc304301f4cb4bf6aefa0045310520789ca38dabdfba9dbecd3f37919/coverage-7.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c672d4e2f0575a4ca2bf2aa0c5ced5188220ab806c1bb6d7179f70a11a017222", size = 250597, upload-time = "2026-02-03T14:00:15.461Z" }, - { url = "https://files.pythonhosted.org/packages/8d/85/240ad396f914df361d0f71e912ddcedb48130c71b88dc4193fe3c0306f00/coverage-7.13.3-cp311-cp311-win32.whl", hash = "sha256:fcda51c918c7a13ad93b5f89a58d56e3a072c9e0ba5c231b0ed81404bf2648fb", size = 221773, upload-time = "2026-02-03T14:00:17.462Z" }, - { url = "https://files.pythonhosted.org/packages/2f/71/165b3a6d3d052704a9ab52d11ea64ef3426745de517dda44d872716213a7/coverage-7.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:d1a049b5c51b3b679928dd35e47c4a2235e0b6128b479a7596d0ef5b42fa6301", size = 222711, upload-time = "2026-02-03T14:00:19.449Z" }, - { url = "https://files.pythonhosted.org/packages/51/d0/0ddc9c5934cdd52639c5df1f1eb0fdab51bb52348f3a8d1c7db9c600d93a/coverage-7.13.3-cp311-cp311-win_arm64.whl", hash = "sha256:79f2670c7e772f4917895c3d89aad59e01f3dbe68a4ed2d0373b431fad1dcfba", size = 221377, upload-time = "2026-02-03T14:00:20.968Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/330f8e83b143f6668778ed61d17ece9dc48459e9e74669177de02f45fec5/coverage-7.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ed48b4170caa2c4420e0cd27dc977caaffc7eecc317355751df8373dddcef595", size = 219441, upload-time = "2026-02-03T14:00:22.585Z" }, - { url = "https://files.pythonhosted.org/packages/08/e7/29db05693562c2e65bdf6910c0af2fd6f9325b8f43caf7a258413f369e30/coverage-7.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8f2adf4bcffbbec41f366f2e6dffb9d24e8172d16e91da5799c9b7ed6b5716e6", size = 219801, upload-time = "2026-02-03T14:00:24.186Z" }, - { url = "https://files.pythonhosted.org/packages/90/ae/7f8a78249b02b0818db46220795f8ac8312ea4abd1d37d79ea81db5cae81/coverage-7.13.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01119735c690786b6966a1e9f098da4cd7ca9174c4cfe076d04e653105488395", size = 251306, upload-time = "2026-02-03T14:00:25.798Z" }, - { url = "https://files.pythonhosted.org/packages/62/71/a18a53d1808e09b2e9ebd6b47dad5e92daf4c38b0686b4c4d1b2f3e42b7f/coverage-7.13.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8bb09e83c603f152d855f666d70a71765ca8e67332e5829e62cb9466c176af23", size = 254051, upload-time = "2026-02-03T14:00:27.474Z" }, - { url = "https://files.pythonhosted.org/packages/4a/0a/eb30f6455d04c5a3396d0696cad2df0269ae7444bb322f86ffe3376f7bf9/coverage-7.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b607a40cba795cfac6d130220d25962931ce101f2f478a29822b19755377fb34", size = 255160, upload-time = "2026-02-03T14:00:29.024Z" }, - { url = "https://files.pythonhosted.org/packages/7b/7e/a45baac86274ce3ed842dbb84f14560c673ad30535f397d89164ec56c5df/coverage-7.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f14a62f5da2e9aedf9080e01d2cda61df39197d48e323538ec037336d68da8", size = 251709, upload-time = "2026-02-03T14:00:30.641Z" }, - { url = "https://files.pythonhosted.org/packages/c0/df/dd0dc12f30da11349993f3e218901fdf82f45ee44773596050c8f5a1fb25/coverage-7.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:debf29e0b157769843dff0981cc76f79e0ed04e36bb773c6cac5f6029054bd8a", size = 253083, upload-time = "2026-02-03T14:00:32.14Z" }, - { url = "https://files.pythonhosted.org/packages/ab/32/fc764c8389a8ce95cb90eb97af4c32f392ab0ac23ec57cadeefb887188d3/coverage-7.13.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:824bb95cd71604031ae9a48edb91fd6effde669522f960375668ed21b36e3ec4", size = 251227, upload-time = "2026-02-03T14:00:34.721Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ca/d025e9da8f06f24c34d2da9873957cfc5f7e0d67802c3e34d0caa8452130/coverage-7.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8f1010029a5b52dc427c8e2a8dbddb2303ddd180b806687d1acd1bb1d06649e7", size = 250794, upload-time = "2026-02-03T14:00:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/45/c7/76bf35d5d488ec8f68682eb8e7671acc50a6d2d1c1182de1d2b6d4ffad3b/coverage-7.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cd5dee4fd7659d8306ffa79eeaaafd91fa30a302dac3af723b9b469e549247e0", size = 252671, upload-time = "2026-02-03T14:00:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/bf/10/1921f1a03a7c209e1cb374f81a6b9b68b03cdb3ecc3433c189bc90e2a3d5/coverage-7.13.3-cp312-cp312-win32.whl", hash = "sha256:f7f153d0184d45f3873b3ad3ad22694fd73aadcb8cdbc4337ab4b41ea6b4dff1", size = 221986, upload-time = "2026-02-03T14:00:40.442Z" }, - { url = "https://files.pythonhosted.org/packages/3c/7c/f5d93297f8e125a80c15545edc754d93e0ed8ba255b65e609b185296af01/coverage-7.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:03a6e5e1e50819d6d7436f5bc40c92ded7e484e400716886ac921e35c133149d", size = 222793, upload-time = "2026-02-03T14:00:42.106Z" }, - { url = "https://files.pythonhosted.org/packages/43/59/c86b84170015b4555ebabca8649bdf9f4a1f737a73168088385ed0f947c4/coverage-7.13.3-cp312-cp312-win_arm64.whl", hash = "sha256:51c4c42c0e7d09a822b08b6cf79b3c4db8333fffde7450da946719ba0d45730f", size = 221410, upload-time = "2026-02-03T14:00:43.726Z" }, - { url = "https://files.pythonhosted.org/packages/81/f3/4c333da7b373e8c8bfb62517e8174a01dcc373d7a9083698e3b39d50d59c/coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25", size = 219468, upload-time = "2026-02-03T14:00:45.829Z" }, - { url = "https://files.pythonhosted.org/packages/d6/31/0714337b7d23630c8de2f4d56acf43c65f8728a45ed529b34410683f7217/coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a", size = 219839, upload-time = "2026-02-03T14:00:47.407Z" }, - { url = "https://files.pythonhosted.org/packages/12/99/bd6f2a2738144c98945666f90cae446ed870cecf0421c767475fcf42cdbe/coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627", size = 250828, upload-time = "2026-02-03T14:00:49.029Z" }, - { url = "https://files.pythonhosted.org/packages/6f/99/97b600225fbf631e6f5bfd3ad5bcaf87fbb9e34ff87492e5a572ff01bbe2/coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8", size = 253432, upload-time = "2026-02-03T14:00:50.655Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5c/abe2b3490bda26bd4f5e3e799be0bdf00bd81edebedc2c9da8d3ef288fa8/coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1", size = 254672, upload-time = "2026-02-03T14:00:52.757Z" }, - { url = "https://files.pythonhosted.org/packages/31/ba/5d1957c76b40daff53971fe0adb84d9c2162b614280031d1d0653dd010c1/coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b", size = 251050, upload-time = "2026-02-03T14:00:54.332Z" }, - { url = "https://files.pythonhosted.org/packages/69/dc/dffdf3bfe9d32090f047d3c3085378558cb4eb6778cda7de414ad74581ed/coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc", size = 252801, upload-time = "2026-02-03T14:00:56.121Z" }, - { url = "https://files.pythonhosted.org/packages/87/51/cdf6198b0f2746e04511a30dc9185d7b8cdd895276c07bdb538e37f1cd50/coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea", size = 250763, upload-time = "2026-02-03T14:00:58.719Z" }, - { url = "https://files.pythonhosted.org/packages/d7/1a/596b7d62218c1d69f2475b69cc6b211e33c83c902f38ee6ae9766dd422da/coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67", size = 250587, upload-time = "2026-02-03T14:01:01.197Z" }, - { url = "https://files.pythonhosted.org/packages/f7/46/52330d5841ff660f22c130b75f5e1dd3e352c8e7baef5e5fef6b14e3e991/coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86", size = 252358, upload-time = "2026-02-03T14:01:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/36/8a/e69a5be51923097ba7d5cff9724466e74fe486e9232020ba97c809a8b42b/coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43", size = 222007, upload-time = "2026-02-03T14:01:04.876Z" }, - { url = "https://files.pythonhosted.org/packages/0a/09/a5a069bcee0d613bdd48ee7637fa73bc09e7ed4342b26890f2df97cc9682/coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587", size = 222812, upload-time = "2026-02-03T14:01:07.296Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4f/d62ad7dfe32f9e3d4a10c178bb6f98b10b083d6e0530ca202b399371f6c1/coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051", size = 221433, upload-time = "2026-02-03T14:01:09.156Z" }, - { url = "https://files.pythonhosted.org/packages/04/b2/4876c46d723d80b9c5b695f1a11bf5f7c3dabf540ec00d6edc076ff025e6/coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9", size = 220162, upload-time = "2026-02-03T14:01:11.409Z" }, - { url = "https://files.pythonhosted.org/packages/fc/04/9942b64a0e0bdda2c109f56bda42b2a59d9d3df4c94b85a323c1cae9fc77/coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e", size = 220510, upload-time = "2026-02-03T14:01:13.038Z" }, - { url = "https://files.pythonhosted.org/packages/5a/82/5cfe1e81eae525b74669f9795f37eb3edd4679b873d79d1e6c1c14ee6c1c/coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107", size = 261801, upload-time = "2026-02-03T14:01:14.674Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ec/a553d7f742fd2cd12e36a16a7b4b3582d5934b496ef2b5ea8abeb10903d4/coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43", size = 263882, upload-time = "2026-02-03T14:01:16.343Z" }, - { url = "https://files.pythonhosted.org/packages/e1/58/8f54a2a93e3d675635bc406de1c9ac8d551312142ff52c9d71b5e533ad45/coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3", size = 266306, upload-time = "2026-02-03T14:01:18.02Z" }, - { url = "https://files.pythonhosted.org/packages/1a/be/e593399fd6ea1f00aee79ebd7cc401021f218d34e96682a92e1bae092ff6/coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a", size = 261051, upload-time = "2026-02-03T14:01:19.757Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e5/e9e0f6138b21bcdebccac36fbfde9cf15eb1bbcea9f5b1f35cd1f465fb91/coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e", size = 263868, upload-time = "2026-02-03T14:01:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bf/de72cfebb69756f2d4a2dde35efcc33c47d85cd3ebdf844b3914aac2ef28/coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155", size = 261498, upload-time = "2026-02-03T14:01:23.097Z" }, - { url = "https://files.pythonhosted.org/packages/f2/91/4a2d313a70fc2e98ca53afd1c8ce67a89b1944cd996589a5b1fe7fbb3e5c/coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e", size = 260394, upload-time = "2026-02-03T14:01:24.949Z" }, - { url = "https://files.pythonhosted.org/packages/40/83/25113af7cf6941e779eb7ed8de2a677865b859a07ccee9146d4cc06a03e3/coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96", size = 262579, upload-time = "2026-02-03T14:01:26.703Z" }, - { url = "https://files.pythonhosted.org/packages/1e/19/a5f2b96262977e82fb9aabbe19b4d83561f5d063f18dde3e72f34ffc3b2f/coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f", size = 222679, upload-time = "2026-02-03T14:01:28.553Z" }, - { url = "https://files.pythonhosted.org/packages/81/82/ef1747b88c87a5c7d7edc3704799ebd650189a9158e680a063308b6125ef/coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c", size = 223740, upload-time = "2026-02-03T14:01:30.776Z" }, - { url = "https://files.pythonhosted.org/packages/1c/4c/a67c7bb5b560241c22736a9cb2f14c5034149ffae18630323fde787339e4/coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9", size = 221996, upload-time = "2026-02-03T14:01:32.495Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b3/677bb43427fed9298905106f39c6520ac75f746f81b8f01104526a8026e4/coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b", size = 219513, upload-time = "2026-02-03T14:01:34.29Z" }, - { url = "https://files.pythonhosted.org/packages/42/53/290046e3bbf8986cdb7366a42dab3440b9983711eaff044a51b11006c67b/coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10", size = 219850, upload-time = "2026-02-03T14:01:35.984Z" }, - { url = "https://files.pythonhosted.org/packages/ea/2b/ab41f10345ba2e49d5e299be8663be2b7db33e77ac1b85cd0af985ea6406/coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39", size = 250886, upload-time = "2026-02-03T14:01:38.287Z" }, - { url = "https://files.pythonhosted.org/packages/72/2d/b3f6913ee5a1d5cdd04106f257e5fac5d048992ffc2d9995d07b0f17739f/coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f", size = 253393, upload-time = "2026-02-03T14:01:40.118Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f6/b1f48810ffc6accf49a35b9943636560768f0812330f7456aa87dc39aff5/coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4", size = 254740, upload-time = "2026-02-03T14:01:42.413Z" }, - { url = "https://files.pythonhosted.org/packages/57/d0/e59c54f9be0b61808f6bc4c8c4346bd79f02dd6bbc3f476ef26124661f20/coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef", size = 250905, upload-time = "2026-02-03T14:01:44.163Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f7/5291bcdf498bafbee3796bb32ef6966e9915aebd4d0954123c8eae921c32/coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75", size = 252753, upload-time = "2026-02-03T14:01:45.974Z" }, - { url = "https://files.pythonhosted.org/packages/a0/a9/1dcafa918c281554dae6e10ece88c1add82db685be123e1b05c2056ff3fb/coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895", size = 250716, upload-time = "2026-02-03T14:01:48.844Z" }, - { url = "https://files.pythonhosted.org/packages/44/bb/4ea4eabcce8c4f6235df6e059fbc5db49107b24c4bdffc44aee81aeca5a8/coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c", size = 250530, upload-time = "2026-02-03T14:01:50.793Z" }, - { url = "https://files.pythonhosted.org/packages/6d/31/4a6c9e6a71367e6f923b27b528448c37f4e959b7e4029330523014691007/coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a", size = 252186, upload-time = "2026-02-03T14:01:52.607Z" }, - { url = "https://files.pythonhosted.org/packages/27/92/e1451ef6390a4f655dc42da35d9971212f7abbbcad0bdb7af4407897eb76/coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4", size = 222253, upload-time = "2026-02-03T14:01:55.071Z" }, - { url = "https://files.pythonhosted.org/packages/8a/98/78885a861a88de020c32a2693487c37d15a9873372953f0c3c159d575a43/coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0", size = 223069, upload-time = "2026-02-03T14:01:56.95Z" }, - { url = "https://files.pythonhosted.org/packages/eb/fb/3784753a48da58a5337972abf7ca58b1fb0f1bda21bc7b4fae992fd28e47/coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3", size = 221633, upload-time = "2026-02-03T14:01:58.758Z" }, - { url = "https://files.pythonhosted.org/packages/40/f9/75b732d9674d32cdbffe801ed5f770786dd1c97eecedef2125b0d25102dc/coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8", size = 220243, upload-time = "2026-02-03T14:02:01.109Z" }, - { url = "https://files.pythonhosted.org/packages/cf/7e/2868ec95de5a65703e6f0c87407ea822d1feb3619600fbc3c1c4fa986090/coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca", size = 220515, upload-time = "2026-02-03T14:02:02.862Z" }, - { url = "https://files.pythonhosted.org/packages/7d/eb/9f0d349652fced20bcaea0f67fc5777bd097c92369f267975732f3dc5f45/coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba", size = 261874, upload-time = "2026-02-03T14:02:04.727Z" }, - { url = "https://files.pythonhosted.org/packages/ee/a5/6619bc4a6c7b139b16818149a3e74ab2e21599ff9a7b6811b6afde99f8ec/coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f", size = 264004, upload-time = "2026-02-03T14:02:06.634Z" }, - { url = "https://files.pythonhosted.org/packages/29/b7/90aa3fc645a50c6f07881fca4fd0ba21e3bfb6ce3a7078424ea3a35c74c9/coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508", size = 266408, upload-time = "2026-02-03T14:02:09.037Z" }, - { url = "https://files.pythonhosted.org/packages/62/55/08bb2a1e4dcbae384e638f0effef486ba5987b06700e481691891427d879/coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba", size = 260977, upload-time = "2026-02-03T14:02:11.755Z" }, - { url = "https://files.pythonhosted.org/packages/9b/76/8bd4ae055a42d8fb5dd2230e5cf36ff2e05f85f2427e91b11a27fea52ed7/coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd", size = 263868, upload-time = "2026-02-03T14:02:13.565Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f9/ba000560f11e9e32ec03df5aa8477242c2d95b379c99ac9a7b2e7fbacb1a/coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab", size = 261474, upload-time = "2026-02-03T14:02:16.069Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/4de4de8f9ca7af4733bfcf4baa440121b7dbb3856daf8428ce91481ff63b/coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e", size = 260317, upload-time = "2026-02-03T14:02:17.996Z" }, - { url = "https://files.pythonhosted.org/packages/05/71/5cd8436e2c21410ff70be81f738c0dddea91bcc3189b1517d26e0102ccb3/coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024", size = 262635, upload-time = "2026-02-03T14:02:20.405Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f8/2834bb45bdd70b55a33ec354b8b5f6062fc90e5bb787e14385903a979503/coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3", size = 223035, upload-time = "2026-02-03T14:02:22.323Z" }, - { url = "https://files.pythonhosted.org/packages/26/75/f8290f0073c00d9ae14056d2b84ab92dff21d5370e464cb6cb06f52bf580/coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8", size = 224142, upload-time = "2026-02-03T14:02:24.143Z" }, - { url = "https://files.pythonhosted.org/packages/03/01/43ac78dfea8946c4a9161bbc034b5549115cb2b56781a4b574927f0d141a/coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3", size = 222166, upload-time = "2026-02-03T14:02:26.005Z" }, - { url = "https://files.pythonhosted.org/packages/7d/fb/70af542d2d938c778c9373ce253aa4116dbe7c0a5672f78b2b2ae0e1b94b/coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910", size = 211237, upload-time = "2026-02-03T14:02:27.986Z" }, + { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, + { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, + { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, ] [package.optional-dependencies] @@ -1839,7 +1853,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.128.5" +version = "0.128.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1848,9 +1862,9 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/d4/811e7283aaaa84f1e7bd55fb642b58f8c01895e4884a9b7628cb55e00d63/fastapi-0.128.5.tar.gz", hash = "sha256:a7173579fc162d6471e3c6fbd9a4b7610c7a3b367bcacf6c4f90d5d022cab711", size = 374636, upload-time = "2026-02-08T10:22:30.493Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/d1/195005b5e45b443e305136df47ee7df4493d782e0c039dd0d97065580324/fastapi-0.128.6.tar.gz", hash = "sha256:0cb3946557e792d731b26a42b04912f16367e3c3135ea8290f620e234f2b604f", size = 374757, upload-time = "2026-02-09T17:27:03.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/e0/511972dba23ee76c0e9d09d1ae95e916fc8ebce5322b2b8b65a481428b10/fastapi-0.128.5-py3-none-any.whl", hash = "sha256:bceec0de8aa6564599c5bcc0593b0d287703562c848271fca8546fd2c87bf4dd", size = 103677, upload-time = "2026-02-08T10:22:28.919Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/a2c4f6b240eeb148fb88cdac48f50a194aba760c1ca4988c6031c66a20ee/fastapi-0.128.6-py3-none-any.whl", hash = "sha256:bb1c1ef87d6086a7132d0ab60869d6f1ee67283b20fbf84ec0003bd335099509", size = 103674, upload-time = "2026-02-09T17:27:02.355Z" }, ] [[package]] @@ -2945,7 +2959,7 @@ wheels = [ [[package]] name = "langfuse" -version = "3.13.0" +version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2959,9 +2973,9 @@ dependencies = [ { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/d0/744e5613c728427330ac2049da0f54fc313e8bf84622f71b025bfba65496/langfuse-3.13.0.tar.gz", hash = "sha256:dacea8111ca4442e97dbfec4f8d676cf9709b35357a26e468f8887b95de0012f", size = 233420, upload-time = "2026-02-06T19:54:14.415Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/7a/ddd8df7f2c5d8c2112a8b20fa88b2513917e2c25d2ef87034c0927f87596/langfuse-3.14.1.tar.gz", hash = "sha256:404a6104cd29353d7829aa417ec46565b04917e5599afdda96c5b0865f4bc991", size = 234530, upload-time = "2026-02-09T15:37:45.994Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/63/148382e8e79948f7e5c9c137288e504bb88117574eb7e7c886b4fb470b4b/langfuse-3.13.0-py3-none-any.whl", hash = "sha256:71912ddac1cc831a65df895eae538a556f564c094ae51473e747426e9ded1a9d", size = 417626, upload-time = "2026-02-06T19:54:12.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/b9/e8ac3072469737358975da66ec4218dc1cee0051555dd4665b3e34a28420/langfuse-3.14.1-py3-none-any.whl", hash = "sha256:17bed605dbfc9947cbd1738a715f6d27c1b80b6da9f2946586171958fa5820d0", size = 420336, upload-time = "2026-02-09T15:37:44.381Z" }, ] [[package]] @@ -3864,7 +3878,7 @@ wheels = [ [[package]] name = "openai" -version = "2.17.0" +version = "2.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3876,14 +3890,14 @@ dependencies = [ { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/a2/677f22c4b487effb8a09439fb6134034b5f0a39ca27df8b95fac23a93720/openai-2.17.0.tar.gz", hash = "sha256:47224b74bd20f30c6b0a6a329505243cb2f26d5cf84d9f8d0825ff8b35e9c999", size = 631445, upload-time = "2026-02-05T16:27:40.953Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/cb/f2c9f988a06d1fcdd18ddc010f43ac384219a399eb01765493d6b34b1461/openai-2.18.0.tar.gz", hash = "sha256:5018d3bcb6651c5aac90e6d0bf9da5cde1bdd23749f67b45b37c522b6e6353af", size = 632124, upload-time = "2026-02-09T21:42:18.017Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/97/284535aa75e6e84ab388248b5a323fc296b1f70530130dee37f7f4fbe856/openai-2.17.0-py3-none-any.whl", hash = "sha256:4f393fd886ca35e113aac7ff239bcd578b81d8f104f5aedc7d3693eb2af1d338", size = 1069524, upload-time = "2026-02-05T16:27:38.941Z" }, + { url = "https://files.pythonhosted.org/packages/20/5f/8940e0641c223eaf972732b3154f2178a968290f8cb99e8c88582cde60ed/openai-2.18.0-py3-none-any.whl", hash = "sha256:538f97e1c77a00e3a99507688c878cda7e9e63031807ba425c68478854d48b30", size = 1069897, upload-time = "2026-02-09T21:42:16.4Z" }, ] [[package]] name = "openai-agents" -version = "0.8.1" +version = "0.8.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3894,9 +3908,9 @@ dependencies = [ { name = "types-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/43/ccea6b70e3c4399eea24a7e0c0cde9e05727781e5b7dd2c00e2cebe09961/openai_agents-0.8.1.tar.gz", hash = "sha256:32dc6124359397e5775e936e621892576a0b2f5c88b3fc548a084334f6918541", size = 2373798, upload-time = "2026-02-06T22:44:24.24Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/6b/f86002a00f16b387b0570860e461475660d81eb00e2817391926d3947933/openai_agents-0.8.3.tar.gz", hash = "sha256:07a6e900b0fe4b7fd8f91a06ed9ab4fec9df335ed676f1c9e1125f60cb57919b", size = 2378346, upload-time = "2026-02-10T00:11:07.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/3f/49ff704c933cf2a3467c040b13231258bb1f2fa66d995c3b62b3a13c2eb4/openai_agents-0.8.1-py3-none-any.whl", hash = "sha256:a29916690f4ca2d67c0d782abbff99350ce2a7cee0067b8dd2c2297e38a3714a", size = 376922, upload-time = "2026-02-06T22:44:21.977Z" }, + { url = "https://files.pythonhosted.org/packages/7b/38/d77602daf5308395ee067954ffa7e96cb9ecf9292ad3b5f398f1c77e0b36/openai_agents-0.8.3-py3-none-any.whl", hash = "sha256:e562ec1a70177abaa34ca6f0428241a9dbeb6b3d73f88a7f4ba3ee3d72b3b98d", size = 378042, upload-time = "2026-02-10T00:11:04.967Z" }, ] [[package]] @@ -4525,7 +4539,7 @@ wheels = [ [[package]] name = "posthog" -version = "7.8.3" +version = "7.8.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -4535,9 +4549,9 @@ dependencies = [ { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/ad/2f116cd9b83dc83ece4328a4efe0bcb80e5c2993837f89a788467d261da8/posthog-7.8.3.tar.gz", hash = "sha256:2b85e818bf818ac2768a890b772b7c12d4f909797226acd9327d66a319dbcf83", size = 167083, upload-time = "2026-02-06T13:16:22.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/10/8e74a5e997c8286f0b63c69da522e503b1ab11627217ab76a06c7b62d647/posthog-7.8.5.tar.gz", hash = "sha256:e4f3796ce18323d8e05139bf419a04d318ccc4ad77b210f4d9d7c7546aea4f35", size = 169117, upload-time = "2026-02-09T22:59:49.207Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/e5/5a4b060cbb9aa9defb8bfd55d15899b3146fece14147f4d66be80e81955a/posthog-7.8.3-py3-none-any.whl", hash = "sha256:1840796e4f7e14dd91ec5fdeb939712c3383fe9e758cfcdeb0317d8f30f7b901", size = 192528, upload-time = "2026-02-06T13:16:21.385Z" }, + { url = "https://files.pythonhosted.org/packages/33/b3/59b61d4b90e2efd138abaa34d98c7a89a4a352850cc3a079a60a46780655/posthog-7.8.5-py3-none-any.whl", hash = "sha256:979d306f07e61a8e837746e5dc432aafc49827fecac91bd6c624dcf3a1967448", size = 194647, upload-time = "2026-02-09T22:59:47.744Z" }, ] [[package]] @@ -5319,14 +5333,14 @@ wheels = [ [[package]] name = "redis" -version = "7.1.0" +version = "7.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-timeout", marker = "(python_full_version < '3.11.3' and sys_platform == 'darwin') or (python_full_version < '3.11.3' and sys_platform == 'linux') or (python_full_version < '3.11.3' and sys_platform == 'win32')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/80/2971931d27651affa88a44c0ad7b8c4a19dc29c998abb20b23868d319b59/redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43", size = 4800064, upload-time = "2026-02-09T18:39:40.292Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/1de1d812ba1481fa4b37fb03b4eec0fcb71b6a0d44c04ea3482eb017600f/redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a", size = 356057, upload-time = "2026-02-09T18:39:38.602Z" }, ] [[package]]