Files
Eduard van Valkenburg 6e95517659 Python: Split type checkers by target (pyright source, 5 checkers on tests/samples) (#6443)
* Python: Split type checkers by target (pyright source, 5 checkers on tests/samples)

Rework the typing setup along the lines of the 'too many type checkers'
approach:

- Pyright (strict) is now the sole source-code type checker; mypy is
  removed from source and its [tool.mypy] block becomes a relaxed profile
  used only for tests/samples.
- Tests are checked by all five checkers (pyright relaxed, mypy, pyrefly,
  ty, zuban); samples by pyright, pyrefly, and ty. All run in a relaxed/
  basic profile so authors aren't forced into over-annotation.
- Add pyrightconfig.tests.json and bump sample pyright configs to basic.
- Unify test/sample typing onto the same parallel fan-out used by source
  pyright via run_command_items in task_runner.py.
- Make version-conditional imports symmetric: keep or drop the
  '# type: ignore' on both branches so results match across interpreter
  versions (local vs CI).
- Update SKILL.md, DEV_SETUP.md, and CODING_STANDARD.md for the five
  gating checkers and pyright on source+tests+samples.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python: Fix merge regressions from main (typing + runtime)

Merging main into the type-checker split branch surfaced regressions that
the new five-checker test suite and unit tests caught:

Runtime fixes:
- anthropic: restore the dropped `cache_read_input_token_count` mapping in
  _parse_usage_from_anthropic (lost during merge conflict resolution).
- gemini: _get_function_calling_mode test helper returned str(enum)
  ('FunctionCallingConfigMode.AUTO') instead of the enum value ('AUTO').
- openai: _response_id_from_token test helper was an infinite self-recursion;
  return token['response_id'].
- orchestrations: reset output_events per approval iteration so the terminal
  output assertion counts only the final run.
- core: drop a stale duplicate harness test whose message ('non-negative')
  contradicted the source ('positive').
- purview: import PolicyLocation/PolicyScope/ProtectionScopeActivities/
  ExecutionMode used by the processor tests.

Type-checker fixes (tests, relaxed profile):
- core: pyright/mypy/pyrefly/ty/zuban green-ups across the harness, MCP,
  observability and types tests.
- anthropic/openai: route provider-namespaced UsageDetails keys through a
  dict cast (extra_items TypedDict unsupported by mypy/ty).
- purview: typed model constructors and cache-mock casts.
- ag-ui: annotate WorkflowContext[Any, Any] so yield_output accepts test
  payloads, guard Optional forwarded_props, and ty-ignore intentional bad args.

Source pyright (sole source checker) flagged unnecessary ignores newly
introduced by merged code in core _tools.py and declarative _declarative_base.py.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python: Isolate per-package mypy cache in test-typing fan-out

The parallel test-typing fan-out runs many mypy processes concurrently,
all defaulting to a single shared ./.mypy_cache. Concurrent writes corrupt
the cache and mypy aborts with INTERNAL ERROR (intermittently, depending on
worker timing) -- which is why CI's Test Typing job failed on a shifting set
of packages while a single-package run was fine.

Give each mypy invocation an isolated cache dir keyed by its target paths so
incremental caching still works per package without races. Other checkers
(zuban/pyrefly/ty/pyright) maintain their own caches and are unaffected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python: Make lab pyright-only on source (drop source mypy)

Lab was the last package still running mypy on its source code, requiring
mypy-only `# type: ignore` comments that pyright (the sole source checker
everywhere else) flags as unnecessary. Align lab with the rest of the
monorepo:

- Remove the lab source mypy poe tasks (mypy-gaia/lightning/tau2) and the
  now-dead strict [tool.mypy] config block.
- Drop the 'Run lab mypy' CI step; lab source is type-checked by pyright only.

Lab tests remain covered by the workspace test-typing fan-out (mypy, pyrefly,
ty, zuban, pyright over tests using the relaxed root config).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python: Fix test-typing regressions from latest main merge

A fresh merge from main brought in new test code never run under the
five-checker test-typing suite. Green up across the affected packages:

- core: narrow Optional span.attributes with 'and' guards in span filters
  and assert+cast the json.loads(...attributes[...]) reads (test_observability);
  match the existing as_agent ignore on the protocol-typed fixture (test_clients).
- openai: align new streaming tests with the established chat_options dict
  pattern (ChatOptions TypedDict isn't assignable to dict), route Optional
  .annotations[0] access through a small _first_annotation helper (mirrors the
  file's assert-not-None convention), and annotate a mapped ResponseStream.
- foundry_hosting: annotate error: dict[str, Any] = body.get(...) or {}
  (zuban needs the annotation).
- foundry: narrow ignores for the live AIProjectClient credential arg (pyrefly)
  and connections.get_default (zuban) SDK type gaps.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* updated pyright version

* pyright fix

* Python: Fix source typing for pyright 1.1.410

Pyright 1.1.410 tightened several checks. Apply the same source fixes as
upstream PR #6275:

- anthropic: import AsyncAnthropicBedrock from anthropic.lib.bedrock and
  AsyncAnthropicVertex from anthropic.lib.vertex (no longer re-exported from
  the anthropic top-level package -> reportPrivateImportUsage).
- core _types.py: cast the transform-hook result to UpdateT (reportAssignmentType).
- core _workflows/_events.py: annotate the @contextmanager helper as
  Generator[None] instead of Iterator[None] (reportDeprecated).
- redis: build the combined filter expression with an explicit loop instead of
  reduce(and_, ...), which pyright could no longer fully type (drops the now
  unused functools.reduce / operator.and_ imports).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python: Accept plain-text body in Azure Functions workflow/run endpoint

The workflow_orchestrator already accepts plain strings as well as JSON
objects via context.get_input(), but the start_workflow_orchestration HTTP
handler only accepted JSON and returned 400 for any non-JSON body. This made
the functions integration tests that POST text/plain to /api/workflow/run
(e.g. test_09_workflow_shared_state) fail consistently with 400 != 202.

Fall back to the raw request body (decoded as UTF-8) when the body is not
JSON, rejecting only a truly empty body. The JSON path is unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-18 15:06:20 +00:00

359 lines
15 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import asyncio
from collections.abc import AsyncIterable, Sequence
from agent_framework import ChatResponse, ChatResponseUpdate, Content, Message, ResponseStream
"""ResponseStream: A Deep Dive
This sample explores the ResponseStream class - a powerful abstraction for working with
streaming responses in the Agent Framework.
=== Why ResponseStream Exists ===
When working with AI models, responses can be delivered in two ways:
1. **Non-streaming**: Wait for the complete response, then return it all at once
2. **Streaming**: Receive incremental updates as they're generated
Streaming provides a better user experience (faster time-to-first-token, progressive rendering)
but introduces complexity:
- How do you process updates as they arrive?
- How do you also get a final, complete response?
- How do you ensure the underlying stream is only consumed once?
- How do you add custom logic (hooks) at different stages?
ResponseStream solves all these problems by wrapping an async iterable and providing:
- Multiple consumption patterns (iteration OR direct finalization)
- Hook points for transformation, cleanup, finalization, and result processing
- The `wrap()` API to layer behavior without double-consuming the stream
=== The Four Hook Types ===
ResponseStream provides four ways to inject custom logic. All can be passed via constructor
or added later via fluent methods:
1. **Transform Hooks** (`transform_hooks=[]` or `.with_transform_hook()`)
- Called for EACH update as it's yielded during iteration
- Can transform updates before they're returned to the consumer
- Multiple hooks are called in order, each receiving the previous hook's output
- Only triggered during iteration (not when calling get_final_response directly)
2. **Cleanup Hooks** (`cleanup_hooks=[]` or `.with_cleanup_hook()`)
- Called ONCE when iteration completes (stream fully consumed), BEFORE finalizer
- Used for cleanup: closing connections, releasing resources, logging
- Cannot modify the stream or response
- Triggered regardless of how the stream ends (normal completion or exception)
3. **Finalizer** (`finalizer=` constructor parameter)
- Called ONCE when `get_final_response()` is invoked
- Receives the list of collected updates and converts to the final type
- There is only ONE finalizer per stream (set at construction)
4. **Result Hooks** (`result_hooks=[]` or `.with_result_hook()`)
- Called ONCE after the finalizer produces its result
- Transform the final response before returning
- Multiple result hooks are called in order, each receiving the previous result
- Can return None to keep the previous value unchanged
=== Two Consumption Patterns ===
**Pattern 1: Async Iteration**
```python
async for update in response_stream:
print(update.text) # Process each update
# Stream is now consumed; updates are stored internally
```
- Transform hooks are called for each yielded item
- Cleanup hooks are called after the last item
- The stream collects all updates internally for later finalization
- Does not run the finalizer automatically
**Pattern 2: Direct Finalization**
```python
final = await response_stream.get_final_response()
```
- If the stream hasn't been iterated, it auto-iterates (consuming all updates)
- The finalizer converts collected updates to a final response
- Result hooks transform the response
- You get the complete response without ever seeing individual updates
** Pattern 3: Combined Usage **
When you first iterate the stream and then call `get_final_response()`, the following occurs:
- Iteration yields updates with transform hooks applied
- Cleanup hooks run after iteration completes
- Calling `get_final_response()` uses the already collected updates to produce the final response
- Note that it does not re-iterate the stream since it's already been consumed
```python
async for update in response_stream:
print(update.text) # See each update
final = await response_stream.get_final_response() # Get the aggregated result
```
=== Chaining with .map() and .with_finalizer() ===
When building a Agent on top of a ChatClient, we face a challenge:
- The ChatClient returns a ResponseStream[ChatResponseUpdate, ChatResponse]
- The Agent needs to return a ResponseStream[AgentResponseUpdate, AgentResponse]
- We can't iterate the ChatClient's stream twice!
The `.map()` and `.with_finalizer()` methods solve this by creating new ResponseStreams that:
- Delegate iteration to the inner stream (only consuming it once)
- Maintain their OWN separate transform hooks, result hooks, and cleanup hooks
- Allow type-safe transformation of updates and final responses
**`.map(transform)`**: Creates a new stream that transforms each update.
- Returns a new ResponseStream with the transformed update type
- Falls back to the inner stream's finalizer if no new finalizer is set
**`.with_finalizer(finalizer)`**: Creates a new stream with a different finalizer.
- Returns a new ResponseStream with the new final type
- The inner stream's finalizer and result_hooks ARE still called (see below)
**IMPORTANT**: When chaining these methods via `get_final_response()`:
1. The inner stream's finalizer runs first (on the original updates)
2. The inner stream's result_hooks run (on the inner final result)
3. The outer stream's finalizer runs (on the transformed updates)
4. The outer stream's result_hooks run (on the outer final result)
This ensures that post-processing hooks registered on the inner stream (e.g., context
provider notifications, telemetry, thread updates) are still executed even when the
stream is wrapped/mapped.
```python
# Agent does something like this internally:
chat_stream = client.get_response(messages, stream=True)
agent_stream = (
chat_stream
.map(_to_agent_update, _to_agent_response)
.with_result_hook(_notify_thread) # Outer hook runs AFTER inner hooks
)
```
This ensures:
- The underlying ChatClient stream is only consumed once
- The agent can add its own transform hooks, result hooks, and cleanup logic
- Each layer (ChatClient, Agent, middleware) can add independent behavior
- Inner stream post-processing (like context provider notification) still runs
- Types flow naturally through the chain
"""
async def main() -> None:
"""Demonstrate the various ResponseStream patterns and capabilities."""
# =========================================================================
# Example 1: Basic ResponseStream with iteration
# =========================================================================
print("=== Example 1: Basic Iteration ===\n")
async def generate_updates() -> AsyncIterable[ChatResponseUpdate]:
"""Simulate a streaming response from an AI model."""
words = ["Hello", " ", "from", " ", "the", " ", "streaming", " ", "response", "!"]
for word in words:
await asyncio.sleep(0.05) # Simulate network delay
yield ChatResponseUpdate(contents=[Content.from_text(word)], role="assistant")
def combine_updates(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
"""Finalizer that combines all updates into a single response."""
return ChatResponse.from_updates(updates)
stream = ResponseStream(generate_updates(), finalizer=combine_updates)
print("Iterating through updates:")
async for update in stream:
print(f" Update: '{update.text}'")
# After iteration, we can still get the final response
final = await stream.get_final_response()
print(f"\nFinal response: '{final.text}'")
# =========================================================================
# Example 2: Using get_final_response() without iteration
# =========================================================================
print("\n=== Example 2: Direct Finalization (No Iteration) ===\n")
# Create a fresh stream (streams can only be consumed once)
stream2 = ResponseStream(generate_updates(), finalizer=combine_updates)
# Skip iteration entirely - get_final_response() auto-consumes the stream
final2 = await stream2.get_final_response()
print(f"Got final response directly: '{final2.text}'")
print(f"Number of updates collected internally: {len(stream2.updates)}")
# =========================================================================
# Example 3: Transform hooks - transform updates during iteration
# =========================================================================
print("\n=== Example 3: Transform Hooks ===\n")
update_count = {"value": 0}
def counting_hook(update: ChatResponseUpdate) -> ChatResponseUpdate:
"""Hook that counts and annotates each update."""
update_count["value"] += 1
# Return the update (or a modified version)
return update
def uppercase_hook(update: ChatResponseUpdate) -> ChatResponseUpdate:
"""Hook that converts text to uppercase."""
if update.text:
return ChatResponseUpdate(
contents=[Content.from_text(update.text.upper())], role=None, response_id=update.response_id
)
return update
# Pass transform_hooks directly to constructor
stream3: ResponseStream[ChatResponseUpdate, ChatResponse] = ResponseStream(
generate_updates(),
finalizer=combine_updates,
transform_hooks=[counting_hook, uppercase_hook], # First counts, then uppercases
)
print("Iterating with hooks applied:")
async for update in stream3:
print(f" Received: '{update.text}'") # Will be uppercase
print(f"\nTotal updates processed: {update_count['value']}")
# =========================================================================
# Example 4: Cleanup hooks - cleanup after stream consumption
# =========================================================================
print("\n=== Example 4: Cleanup Hooks ===\n")
cleanup_performed = {"value": False}
async def cleanup_hook() -> None:
"""Cleanup hook for releasing resources after stream consumption."""
print(" [Cleanup] Cleaning up resources...")
cleanup_performed["value"] = True
# Pass cleanup_hooks directly to constructor
stream4 = ResponseStream(
generate_updates(),
finalizer=combine_updates,
cleanup_hooks=[cleanup_hook],
)
print("Starting iteration (cleanup happens after):")
async for _update in stream4:
pass # Just consume the stream
print(f"Cleanup was performed: {cleanup_performed['value']}")
# =========================================================================
# Example 5: Result hooks - transform the final response
# =========================================================================
print("\n=== Example 5: Result Hooks ===\n")
def add_metadata_hook(response: ChatResponse) -> ChatResponse:
"""Result hook that adds metadata to the response."""
response.additional_properties["processed"] = True
response.additional_properties["word_count"] = len((response.text or "").split())
return response
def wrap_in_quotes_hook(response: ChatResponse) -> ChatResponse:
"""Result hook that wraps the response text in quotes."""
if response.text:
return ChatResponse(
messages=[Message(contents=[f'"{response.text}"'], role="assistant")],
additional_properties=response.additional_properties,
)
return response
# Finalizer converts updates to response, then result hooks transform it
stream5: ResponseStream[ChatResponseUpdate, ChatResponse] = ResponseStream(
generate_updates(),
finalizer=combine_updates,
result_hooks=[add_metadata_hook, wrap_in_quotes_hook], # First adds metadata, then wraps in quotes
)
final5 = await stream5.get_final_response()
print(f"Final text: {final5.text}")
print(f"Metadata: {final5.additional_properties}")
# =========================================================================
# Example 6: The wrap() API - layering without double-consumption
# =========================================================================
print("\n=== Example 6: wrap() API for Layering ===\n")
# Simulate what ChatClient returns
inner_stream = ResponseStream(generate_updates(), finalizer=combine_updates)
# Simulate what Agent does: wrap the inner stream
def to_agent_format(update: ChatResponseUpdate) -> ChatResponseUpdate:
"""Map ChatResponseUpdate to agent format (simulated transformation)."""
# In real code, this would convert to AgentResponseUpdate
return ChatResponseUpdate(
contents=[Content.from_text(f"[AGENT] {update.text}")], role=None, response_id=update.response_id
)
def to_agent_response(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
"""Finalizer that converts updates to agent response (simulated)."""
# In real code, this would create an AgentResponse
text = "".join(u.text or "" for u in updates)
return ChatResponse(
messages=[Message(contents=[f"[AGENT FINAL] {text}"], role="assistant")],
additional_properties={"layer": "agent"},
)
# .map() creates a new stream that:
# 1. Delegates iteration to inner_stream (only consuming it once)
# 2. Transforms each update via the transform function
# 3. Uses the provided finalizer (required since update type may change)
outer_stream = inner_stream.map(to_agent_format, to_agent_response)
print("Iterating the mapped stream:")
async for update in outer_stream:
print(f" {update.text}")
final_outer = await outer_stream.get_final_response()
print(f"\nMapped final: {final_outer.text}")
print(f"Mapped metadata: {final_outer.additional_properties}")
# Important: the inner stream was only consumed once!
print(f"Inner stream consumed: {inner_stream._consumed}")
# =========================================================================
# Example 7: Combining all patterns
# =========================================================================
print("\n=== Example 7: Full Integration ===\n")
stats = {"updates": 0, "characters": 0}
def track_stats(update: ChatResponseUpdate) -> ChatResponseUpdate:
"""Track statistics as updates flow through."""
stats["updates"] += 1
stats["characters"] += len(update.text or "")
return update
def log_cleanup() -> None:
"""Log when stream consumption completes."""
print(f" [Cleanup] Stream complete: {stats['updates']} updates, {stats['characters']} chars")
def add_stats_to_response(response: ChatResponse) -> ChatResponse:
"""Result hook to include the statistics in the final response."""
response.additional_properties["stats"] = stats.copy()
return response
# All hooks can be passed via constructor
full_stream: ResponseStream[ChatResponseUpdate, ChatResponse] = ResponseStream(
generate_updates(),
finalizer=combine_updates,
transform_hooks=[track_stats],
result_hooks=[add_stats_to_response],
cleanup_hooks=[log_cleanup],
)
print("Processing with all hooks active:")
async for update in full_stream:
print(f" -> '{update.text}'")
final_full = await full_stream.get_final_response()
print(f"\nFinal: '{final_full.text}'")
print(f"Stats: {final_full.additional_properties['stats']}")
if __name__ == "__main__":
asyncio.run(main())