chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python Debugger: Current File",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${file}",
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
# Core Package (agent-framework-core)
|
||||
|
||||
The foundation package containing all core abstractions, types, and built-in OpenAI/Azure OpenAI support.
|
||||
|
||||
## Module Structure
|
||||
|
||||
```
|
||||
agent_framework/
|
||||
├── __init__.py # Lazy runtime public API exports
|
||||
├── __init__.pyi # Public API typing surface for lazy root exports
|
||||
├── security.py # Public security primitives, middleware, and tools
|
||||
├── _agents.py # Agent implementations
|
||||
├── _clients.py # Chat client base classes and protocols
|
||||
├── _types.py # Core types (Message, ChatResponse, Content, etc.)
|
||||
├── _tools.py # Tool definitions and function invocation
|
||||
├── _middleware.py # Middleware system for request/response interception
|
||||
├── _sessions.py # AgentSession and context provider abstractions
|
||||
├── _skills.py # Agent Skills system (models, executors, provider)
|
||||
├── _mcp.py # Model Context Protocol support
|
||||
├── _workflows/ # Workflow orchestration (sequential, concurrent, handoff, etc.)
|
||||
├── openai/ # Built-in OpenAI client
|
||||
├── azure/ # Lazy-loading entry point for Azure integrations
|
||||
└── <provider>/ # Other lazy-loading provider folders
|
||||
```
|
||||
|
||||
## Core Classes
|
||||
|
||||
### Root Public API (`__init__.py` / `__init__.pyi`)
|
||||
|
||||
- `agent_framework.__init__` uses lazy module-level `__getattr__` for most public exports to keep cold
|
||||
`import agent_framework` lightweight.
|
||||
- Keep `_LAZY_MODULE_EXPORTS`, `_LAZY_EXPORTS`, the explicit runtime `__all__`, and `__init__.pyi` synchronized
|
||||
whenever adding, removing, or moving a root public export.
|
||||
- Runtime `__all__` is still required for `from agent_framework import *`; the `.pyi` file is for type checkers
|
||||
and editors and does not replace runtime exports.
|
||||
- Public deprecation behavior for a lazy export belongs in the owning module. The root package should delegate via
|
||||
the normal lazy export map instead of carrying one-off branches.
|
||||
|
||||
### Agents (`_agents.py`)
|
||||
|
||||
- **`SupportsAgentRun`** - Protocol defining the agent interface
|
||||
- **`BaseAgent`** - Abstract base class for agents
|
||||
- **`Agent`** - Main agent class wrapping a chat client with tools, instructions, and middleware
|
||||
|
||||
### Chat Clients (`_clients.py`)
|
||||
|
||||
- **`SupportsChatGetResponse`** - Protocol for chat client implementations
|
||||
- **`BaseChatClient`** - Abstract base class with middleware support; subclasses implement `_inner_get_response()` and `_inner_get_streaming_response()`
|
||||
|
||||
### Types (`_types.py`)
|
||||
|
||||
- **`Message`** - Represents a chat message with role, content, and metadata
|
||||
- **`ChatResponse`** - Response from a chat client containing messages and usage
|
||||
- **`ChatResponseUpdate`** - Streaming response update
|
||||
- **`AgentResponse`** / **`AgentResponseUpdate`** - Agent-level response wrappers
|
||||
- **`Content`** - Base class for message content (text, function calls, images, etc.)
|
||||
- **`ChatOptions`** - TypedDict for chat request options
|
||||
|
||||
### Tools (`_tools.py`)
|
||||
|
||||
- **`ToolProtocol`** - Protocol for tool definitions
|
||||
- **`FunctionTool`** - Wraps Python functions as tools with JSON schema generation
|
||||
- **`@tool`** decorator - Converts functions to tools
|
||||
- **`use_function_invocation()`** - Decorator to add automatic function calling to chat clients
|
||||
|
||||
### Middleware (`_middleware.py`)
|
||||
|
||||
- **`AgentMiddleware`** - Intercepts agent `run()` calls
|
||||
- **`ChatMiddleware`** - Intercepts chat client `get_response()` calls
|
||||
- **`FunctionMiddleware`** - Intercepts function/tool invocations
|
||||
- **`AgentContext`** / **`ChatContext`** / **`FunctionInvocationContext`** - Context objects passed through middleware. A tool can declare a `FunctionInvocationContext` parameter to receive it; `context.tools` is the live, mutable tools list for the run, and `context.add_tools(...)` / `context.remove_tools(...)` enable progressive tool exposure (changes apply on the next function-calling iteration).
|
||||
- **`MessageInjectionMiddleware`** - Session-scoped chat middleware that lets tools or other code enqueue messages for the next model call in the current `AgentSession`; it drains queued messages into the next call and loops only when no function calls need to be handled by the function invocation layer.
|
||||
|
||||
### Sessions (`_sessions.py`)
|
||||
|
||||
- **`AgentSession`** - Manages conversation state and session metadata
|
||||
- **`ServiceSessionId`** - Mapping alias for structured service-owned continuation handles used in `AgentSession.service_session_id`
|
||||
- **`SessionContext`** - Context object for session-scoped data during agent runs
|
||||
- **`ContextProvider`** - Base class for context providers (RAG, memory systems)
|
||||
- **`HistoryProvider`** - Base class for conversation history storage
|
||||
- **`InMemoryHistoryProvider`** - Built-in session-state history provider for local runs
|
||||
- **`FileHistoryProvider`** - JSON Lines file-backed history provider storing one file per session with one message record per line
|
||||
|
||||
### Skills (`_skills.py`)
|
||||
|
||||
- **`Skill`** - Abstract base for a skill definition bundling instructions (`content`) with frontmatter metadata, resources, and scripts. Concrete subclasses (`InlineSkill`, `FileSkill`, `ClassSkill`) accept a `frontmatter=SkillFrontmatter(...)` argument carrying the spec fields. Adding new spec fields is done in one place — on `SkillFrontmatter` — keeping the subclass constructors stable.
|
||||
- **`SkillFrontmatter`** - L1 discovery metadata for a skill (`name`, `description`, `license`, `compatibility`, `allowed_tools`, `metadata`). All fields are mutable plain attributes; the constructor validates `name`, `description`, and `compatibility` against the spec but post-construction assignments are not re-validated. Spec fields are reachable on every skill via `skill.frontmatter`.
|
||||
- **`SkillResource`** - Named supplementary content attached to a skill; holds either static `content` or a dynamic `function` (sync or async). Exactly one must be provided.
|
||||
- **`SkillScript`** - An executable script attached to a skill; holds either an inline `function` (code-defined, runs in-process) or a `path` to a file on disk (file-based, delegated to a runner). Exactly one must be provided.
|
||||
- **`SkillScriptRunner`** - Protocol for file-based script execution. Any callable matching `(skill, script, args) -> Any` satisfies it. Code-defined scripts do not use a runner.
|
||||
- **`SkillScriptArgumentParser`** - Public type alias for an optional callable `(raw args: dict | list[str] | str | None) -> dict | None` that converts the raw `args` value before an `InlineSkillScript` runs (applied before the inline list-args guard). It is an opt-in customization hook (port of .NET PR #6498) that lets callers support backends sending tool-call arguments in a non-conforming shape (e.g. vLLM JSON strings). The output is constrained to a `dict` (named keyword arguments) or `None`, because inline scripts bind arguments by keyword name. Supply it via the `argument_parser=` constructor arg on `InlineSkillScript`, `InlineSkill` (default for scripts added via `@skill.script`), or `ClassSkill` (default for scripts discovered via `@ClassSkill.script`). When `None` (the default), the raw value is used unchanged. File-based scripts are unaffected (their runner owns arg handling).
|
||||
- **`SkillsProvider`** - Context provider (extends `ContextProvider`) that discovers file-based skills from `SKILL.md` files and/or accepts code-defined `Skill` instances. Follows progressive disclosure: advertise → load → read resources / run scripts. By default all three tools it exposes (`load_skill`, `read_skill_resource`, `run_skill_script`) are registered with `approval_mode="always_require"`, so every skill operation needs approval. To run unattended, pass one of the static auto-approval rules to `ToolApprovalMiddleware` (via `auto_approval_rules`): `SkillsProvider.read_only_tools_auto_approval_rule` approves only the read-only tools (`load_skill`, `read_skill_resource`) while still prompting for `run_skill_script`, and `SkillsProvider.all_tools_auto_approval_rule` approves every skill tool including script execution. Both rules reject any call carrying a `server_label` so they stay scoped to this provider's local tools and never auto-approve a same-named hosted tool. Alternatively, for trusted skills, the constructor / `from_paths` kwargs `disable_load_skill_approval`, `disable_read_skill_resource_approval`, and `disable_run_skill_script_approval` (all default `False`) opt individual tools out of approval entirely by registering them with `approval_mode="never_require"` (the auto-approval rules only apply to tools that still require approval). The tool names are also exposed as class constants (`LOAD_SKILL_TOOL_NAME`, `READ_SKILL_RESOURCE_TOOL_NAME`, `RUN_SKILL_SCRIPT_TOOL_NAME`).
|
||||
- **`SkillsSource` decorators** - Skill sources are composable: `SkillsSource` is the abstract base, with concrete sources (`InMemorySkillsSource`, `FileSkillsSource`, `MCPSkillsSource`) and decorators that wrap an inner source — `AggregatingSkillsSource` (concatenate several sources), `FilteringSkillsSource` (predicate filter), `DeduplicatingSkillsSource` (first-wins by name), and `CachingSkillsSource` (cache the inner source's skills list). `DelegatingSkillsSource` is the abstract base for decorators. **`get_skills` takes a `SkillsSourceContext`**: every source/decorator implements `async def get_skills(self, context: SkillsSourceContext) -> list[Skill]` and forwards `context` to inner sources. `SkillsSourceContext` (frozen) carries the invoking `agent` (`SupportsAgentRun`) and optional `session` (`AgentSession | None`); `SkillsProvider` builds it from `before_run`'s `agent`/`session` and passes it into the pipeline. `FilteringSkillsSource`'s predicate is context-aware: `Callable[[Skill, SkillsSourceContext], bool]` (port of .NET #6797). **Default caching is applied only to the built-in, context-independent leaf sources**: for the `Skill` / sequence-of-skills / `from_paths` constructors, `SkillsProvider` builds `DeduplicatingSkillsSource(CachingSkillsSource(<file|in-memory leaf>))` so expensive filesystem/network discovery runs once. A **caller-supplied `SkillsSource` is used as-is — never auto-wrapped in caching or deduplication** — because auto-caching a context-aware caller source in a single shared bucket would replay the first invocation's skills for later `SkillsSourceContext`s and leak skills across agents/tenants (matches .NET, whose custom-source constructor also adds no caching/dedup). Callers who want caching on a custom pipeline compose `CachingSkillsSource(inner, cache_isolation_key_selector=...)` themselves. `disable_caching=True` only affects the built-in leaf caching (it has no effect on a caller-supplied source, which is never cached). `CachingSkillsSource` shares a single in-flight fetch across concurrent callers (per cache key) and does not update its cache on a failed fetch, so the next call retries (an initial failure leaves the cache empty; a refresh failure keeps the previously cached list). By default all callers share one cache bucket; pass `cache_isolation_key_selector=Callable[[SkillsSourceContext], str | None]` to cache separately per key (e.g. per agent name) for context-aware inner sources — the key should be low-cardinality and stable, and returning `None` (or leaving the selector `None`) uses the shared bucket. By default a cached list never expires; pass `refresh_interval=timedelta(...)` (port of .NET `CachingAgentSkillsSourceOptions.RefreshInterval`) to treat a cached list as stale once it is older than the interval so the next call re-queries the inner source (useful when an inner source such as `MCPSkillsSource` changes over the process lifetime; a zero/negative interval makes every result immediately stale, and a failed refresh keeps the prior list and retries). Freshness is measured with a monotonic clock (`time.monotonic()`). `SkillsProvider.__init__` / `from_paths` expose a `cache_refresh_interval` kwarg that is threaded into the built-in `CachingSkillsSource` (it has no effect on a caller-supplied source or when `disable_caching=True`).
|
||||
|
||||
### Model Context Protocol (`_mcp.py`)
|
||||
|
||||
- **`MCPTool`** - Base wrapper that owns the MCP `ClientSession` and exposes the remote server's tools as `FunctionTool`s.
|
||||
- **`MCPStdioTool`** / **`MCPStreamableHTTPTool`** / **`MCPWebsocketTool`** - Transport-specific subclasses.
|
||||
- **Argument allowlist (`_prepare_call_kwargs`)** - Before each `tools/call`, kwargs are filtered to an **allowlist** built from the tool's declared parameters (`inputSchema.properties`) plus any user-configured extras. Framework runtime kwargs injected through the function-invocation pipeline (e.g. `thread`, `conversation_id`, `chat_options`, `options`, `response_format`) are stripped by default rather than forwarded. A tool that declares no usable `properties` (including schemas with `additionalProperties: true`) forwards only the configured extras. The `_MCP_FRAMEWORK_DENYLIST` is a safety net for framework-named params a server *declares* in its schema (those are dropped); names explicitly opted in via `additional_tool_argument_names` always win. The reserved `_meta` key is never forwarded as an argument; trusted caller/runtime `_meta` is validated as MCP request metadata, model-supplied `_meta` is discarded in generated MCP functions, and metadata precedence is caller/runtime < OpenTelemetry < tools/list metadata.
|
||||
- **`allowed_tools`** (constructor arg on all `MCPTool` subclasses) - Restricts exposed MCP tools by raw remote MCP tool identity. Prefixed local names remain accepted only when the raw remote name already matches its normalized form; normalized/local aliases do not authorize a different raw remote name. If multiple raw remote tool names map to the same local function name, tool loading raises `ToolExecutionException` instead of first-one-wins shadowing.
|
||||
- **Progressive MCP disclosure** (`use_progressive_disclosure`, `always_load`) - When enabled on any `MCPTool` subclass, the initial model-facing surface is loader tools (`list_mcp_tools` / `load_tool` / `unload_tool`, prefixed by `tool_name_prefix` when configured) plus allowed tools selected by `always_load` and tools loaded earlier on the same `MCPTool` instance. `list_mcp_tools` only reports tools that pass `allowed_tools`; filtered tools are not listed or loadable. Loader tool names are reserved in progressive mode: remote MCP tools whose local generated name collides with a loader name are omitted from the initial/listed surface, and explicit `load_tool` calls return a model-visible message pointing callers to `tool_name_prefix` or excluding the colliding tool. `load_tool` accepts one tool name or a list of tool names and uses `FunctionInvocationContext.add_tools(...)` so the selected generated MCP `FunctionTool`s become available on the next function-calling iteration while keeping existing approval mode, argument filtering, header-provider runtime kwargs, result parsing, OTel, and task behavior. `unload_tool` accepts one dynamically loaded tool name or a list of names and removes them from the live tool list and persisted progressive surface, but it does not remove tools configured in `always_load`. Invalid `always_load` entries are ignored like unmatched `allowed_tools` entries.
|
||||
- **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a normal forwarded argument name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins; `_meta` is the exception and only trusted runtime/caller metadata is used.
|
||||
- **Sampling guardrails** (`sampling_callback`) - Passing `client=` advertises `SamplingCapability` so the server can send `sampling/createMessage`. Because remote servers are untrusted (confused-deputy risk), the default `sampling_callback` is **deny-by-default** and applies, in order: a per-session rate limit (`sampling_max_requests`, default `_DEFAULT_SAMPLING_MAX_REQUESTS`), an approval gate (`sampling_approval_callback`), and a `maxTokens` cap (`sampling_max_tokens`, default `_DEFAULT_SAMPLING_MAX_TOKENS`). The approval callback (constructor arg on all subclasses; exported type alias `SamplingApprovalCallback`) receives the raw `CreateMessageRequestParams`, may be sync or async, and must return truthy to approve. When it is `None` (the default) every sampling request is denied; pass `lambda params: True` to restore legacy auto-approve as an explicit opt-in. Requests and denials are logged at WARNING (content is not logged). The per-session counter resets in `_reset_session_state`.
|
||||
- **`MCPTaskOptions`** (experimental, `MCP_LONG_RUNNING_TASKS` feature, **frozen**) - Per-tool-instance options controlling the SEP-2663 long-running task lifecycle. When the server advertises a tool with `execution.taskSupport == "required"`, `MCPTool.call_tool` transparently routes through `call_tool_as_task`, which sends an augmented `tools/call`, polls `tasks/get` until terminal, and reinterprets `tasks/result` as a normal `CallToolResult`. Instances are immutable; replace via `MCPTool.task_options = MCPTaskOptions(...)`. Fields:
|
||||
- `default_ttl: timedelta | None` — forwarded to the server as `params.task.ttl` (milliseconds). When `None`, the server's default applies.
|
||||
- `cancel_remote_task_on_local_cancellation: bool = True` — only gates the `CancelledError` path. Abandonment paths (see below) always cancel.
|
||||
- `max_task_wait: timedelta | None` — client-side deadline for the whole post-create lifecycle (poll + result fetch). When exceeded, raises `ToolExecutionException` and fires a best-effort `tasks/cancel`. `None` (default) means no client-side bound. Bounds sleeps, sends, AND reconnects via `asyncio.wait_for`.
|
||||
- **Permissive fallback**: servers that ignore the augmentation (return `CallToolResult` directly) or reject the unknown `task` field with `METHOD_NOT_FOUND` / `INVALID_PARAMS` fall back to the plain `session.call_tool(...)` path so legacy servers keep working. An unparseable success response (server accepted the augmented call but returned a payload that is neither `CreateTaskResult` nor `CallToolResult`) **does not** fall back — it raises `ToolExecutionException` to avoid double-executing a side-effecting tool.
|
||||
- **Submit-vs-track reconnect policy**: a dropped connection before a `task_id` is known raises `ToolExecutionException("connection lost; task state unknown")` without re-issuing the augmented `tools/call`, so a server that accepted the request but lost the response cannot be made to start the same operation twice; once a `task_id` exists, `tasks/get` / `tasks/result` reconnect once and retry against the same id (a shared `_send_with_one_reconnect` helper).
|
||||
- **Cancel-on-abandonment vs terminal failure**: any path where the remote task may still be running (max-wait exceeded, hard `McpError` in poll, malformed `tasks/get`, second connection loss in poll/fetch, reconnect failure) fires best-effort `tasks/cancel` before raising. Terminal failures (`failed`/`cancelled`/`input_required` server-side, `completed+isError`, malformed `tasks/result` after server completed) do **not** cancel — the server is already done. `_MCPTaskAbandoned` is the private marker distinguishing the two.
|
||||
- **Transient poll retry**: a slow `tasks/get` that surfaces as `McpError(code=408 REQUEST_TIMEOUT)` is retried (bounded by `max_task_wait`). All other non-connection `McpError`s during poll are treated as abandonment. `tasks/result` does not get transient retry — the server has already completed, so a slow payload fetch is anomalous.
|
||||
|
||||
### File Access Harness (`_harness/_file_access.py`)
|
||||
|
||||
- **`AgentFileStore`** - Abstract async store backing the file-access harness. Implementations expose `write`, `read`, `delete`, `list_children`, `file_exists`, `search`, and `create_directory` over forward-slash relative paths. `list_children` returns the direct children (files and subdirectories, subdirectories first) as `FileStoreEntry` instances; `search` accepts a keyword-only `recursive` flag (default `False`) and, when `recursive=True`, walks all descendants and returns `file_name` values relative to the search directory.
|
||||
- **`InMemoryAgentFileStore`** - Dict-backed store suitable for tests and lightweight scenarios.
|
||||
- **`FileSystemAgentFileStore`** - Disk-backed store rooted under a configurable directory. Enforces relative-path normalization, root containment, and rejects symlink/reparse-point segments to prevent escape.
|
||||
- **`FileSearchResult`** / **`FileSearchMatch`** - `SerializationMixin` DTOs returned by `search`, carrying the matching file name, a context snippet, and the matching lines with 1-based line numbers.
|
||||
- **`FileStoreEntry`** - `SerializationMixin` DTO returned by `list_children`, carrying an entry `name` and `type` (`"file"` or `"directory"`).
|
||||
- **`FileAccessProvider`** - `ContextProvider` that adds shared file-access tools (`file_access_write`, `file_access_read`, `file_access_delete`, `file_access_ls`, `file_access_grep`, `file_access_replace`, `file_access_replace_lines`) plus default usage instructions to each invocation. `file_access_ls` enumerates direct children (both files and subdirectories) as `{name, type}` entries with an optional `glob_pattern`, so the agent can walk the tree level by level; `file_access_grep` searches recursively from an optional base `directory` and returns relative `file_name` paths, scoped via an `fnmatch` `glob_pattern` (where `*` crosses `/`, e.g. `*.md`, `reports/*`). `file_access_replace` substitutes `old_string` with `new_string` (failing if not found, or if multiple matches and `replace_all` is false); `file_access_replace_lines` replaces whole 1-based lines with literal text (each `new_line` includes its own trailing newline; an empty `new_line` deletes the line, including its line break). All tools are registered with `approval_mode="always_require"` by default, so every file operation needs host approval. Pass `disable_write_tools=True` to advertise only the read-only tools. To run unattended you can disable approval at the source with `disable_readonly_tool_approval=True` (read, ls, grep) and/or `disable_write_tool_approval=True` (write, delete, replace, replace_lines), which register the affected tools with `approval_mode="never_require"`; alternatively, keep approval on and pass one of the static auto-approval rules to `ToolApprovalMiddleware` (via `auto_approval_rules`): `FileAccessProvider.read_only_tools_auto_approval_rule` approves only the read-only tools (read, ls, grep), while `FileAccessProvider.all_tools_auto_approval_rule` approves every file-access tool including the write tools. Both rules reject any call carrying a `server_label` so they stay scoped to this provider's local tools and never auto-approve a same-named hosted tool. The tool names are also exposed as class constants (`WRITE_TOOL_NAME`, `READ_TOOL_NAME`, `DELETE_TOOL_NAME`, `LS_TOOL_NAME`, `GREP_TOOL_NAME`, `REPLACE_TOOL_NAME`, `REPLACE_LINES_TOOL_NAME`). Unlike `MemoryContextProvider`, the store is intentionally shared across sessions and agents.
|
||||
|
||||
### File Memory Harness (`_harness/_file_memory.py`)
|
||||
|
||||
- **`FileMemoryProvider`** - `ContextProvider` that gives an agent a session-scoped, file-based memory backed by the same `AgentFileStore` abstraction. Adds tools (`file_memory_write`, `file_memory_read`, `file_memory_delete`, `file_memory_ls`, `file_memory_grep`, `file_memory_replace`, `file_memory_replace_lines`) plus default usage instructions. Port of the .NET `FileMemoryProvider`.
|
||||
- **Scoping** - Memories are isolated per session by default: each session writes under a working folder derived from `context.session_id`. Pass an explicit `scope` (e.g. a user id) to group memories across sessions, mirroring `FoundryMemoryProvider`'s `scope` arg.
|
||||
- **Descriptions & index** - `file_memory_write` accepts an optional `description`, stored in a companion `<stem>_description.md` sidecar. After each write/delete the provider rebuilds a capped (50-entry) `memories.md` index, and `before_run` injects that index as a `user` context message so the model knows what memories exist. Sidecars and the index are internal files hidden from `file_memory_ls`/`file_memory_grep` and rejected as write targets.
|
||||
- **`DEFAULT_FILE_MEMORY_SOURCE_ID`** / **`DEFAULT_FILE_MEMORY_INSTRUCTIONS`** - Public defaults for the provider's source id and instruction banner.
|
||||
- **Harness wiring** - `create_harness_agent` includes both `FileMemoryProvider` and `FileAccessProvider` by default. Disable via `disable_file_memory` / `disable_file_access`; override the backing store via `file_memory_store` / `file_access_store`. When no store is supplied, defaults are `FileSystemAgentFileStore` rooted at `{cwd}/agent-file-memory` (memory) and `{cwd}/working` (access), mirroring the .NET `HarnessAgent`. `create_harness_agent` also wires in `MessageInjectionMiddleware` by default (mirroring the .NET harness's `UseMessageInjection`); it is always on with no opt-out because it is a no-op when no messages are queued for the session.
|
||||
|
||||
### Tool Approval Harness (`_harness/_tool_approval.py`)
|
||||
|
||||
- **`ToolApprovalMiddleware`** - Experimental opt-in agent middleware that coordinates session-backed approval
|
||||
rules, heuristic `auto_approval_rules`, queued approval requests, collected approval responses, and
|
||||
streaming/non-streaming approval prompts. Heuristic callbacks receive the underlying `function_call` content.
|
||||
- **`ToolApprovalRule`** / **`ToolApprovalState`** - Serializable state models for standing approvals and queued
|
||||
approval flow. `ToolApprovalRule.arguments is None` means a tool-wide rule; an empty dict `{}` means an exact
|
||||
no-argument call for `create_always_approve_tool_with_arguments_response`.
|
||||
- **`create_always_approve_tool_response`** / **`create_always_approve_tool_with_arguments_response`** - Helpers
|
||||
that return normal `function_approval_response` content with `additional_properties` metadata consumed by
|
||||
`ToolApprovalMiddleware`. Standing rules for hosted tools include the `server_label` boundary, so same-named tools
|
||||
on different hosted servers do not share approvals.
|
||||
- Mixed tool-call batches use a default .NET-style bypass in the function invocation loop: when a session is
|
||||
available, approval requests for known non-approval-required tools are treated as already approved, hidden, stored
|
||||
in session state keyed to the visible approval request ids from that batch, and reinjected only when that visible
|
||||
approval flow resumes.
|
||||
### Agent Loop (`_harness/_loop.py`)
|
||||
|
||||
- **`AgentLoopMiddleware`** - `AgentMiddleware` that re-runs an agent in a loop by calling `call_next()` repeatedly (the pipeline re-reads `context.messages` each time). One configurable class covers two patterns: a required user `should_continue` predicate (sync or async, the first positional/keyword arg), and a chat-client judge built via the `.with_judge(...)` factory (a second chat client decides whether the original request was answered; loops while it is *not*, using a `JudgeVerdict` structured-output response — internally just an async `should_continue` predicate). The constructor covers the predicate pattern directly; only the judge has a convenience classmethod factory (`.with_judge(judge_client, ...)`) that forwards to `__init__`. Supports both streaming and non-streaming runs. By default a non-streaming run returns an aggregated `AgentResponse` containing every iteration's messages plus the injected `next_message` "nudge" messages (as `user` messages); set `return_final_only=True` to return only the last iteration's response. Streaming runs always yield each iteration's updates and emit the injected nudge messages as `user` updates between iterations (the `return_final_only` flag has no effect on streaming, and the final response reflects the last iteration; `MiddlewareTermination` is handled cleanly). `should_continue` is required; other constructor args are optional: `max_iterations` (safety cap; defaults to `DEFAULT_MAX_ITERATIONS`=10, explicit `None`→unbounded, positive int caps; `.with_judge` uses `DEFAULT_JUDGE_MAX_ITERATIONS`=5 as its default), `next_message` (defaults to a short "continue" nudge), `return_final_only`, and `additional_instructions` (an extra `system` message injected ahead of the input before the agent runs — becomes part of the original messages so it survives `fresh_context` resets and persists via a session). The judge is configured only through `.with_judge` (`judge_client`/`instructions`/`criteria`), not the constructor, and its `reasoning` is fed back to the agent as the next iteration's input; the judge forwards the original request messages and the agent's latest response messages verbatim so multi-modal content is preserved. `criteria` (a `list[str]`) is both injected as the agent's `additional_instructions` and rendered into the judge instructions wherever the `{{criteria}}` placeholder (`CRITERIA_PLACEHOLDER`) appears (`DEFAULT_JUDGE_INSTRUCTIONS` ends with it; custom `instructions` may include it, and it is stripped when no criteria are given). The `should_continue`/`next_message` callables are invoked with keyword args (`iteration`, `last_result`, `messages`, `original_messages`, `session`, `agent`, `progress`, `feedback`) and may be sync or async; declare only what you need plus `**kwargs`. `should_continue` may return a plain `bool` or a `(bool, str | None)` tuple whose second item is feedback surfaced to `next_message`/`record_feedback` via the `feedback` kwarg (the judge uses this to relay its `reasoning`). Stop precedence per iteration is `max_iterations` → `should_continue`, evaluated before `record_feedback` so the feedback is available to it.
|
||||
- **Feedback tracking** - `record_feedback` captures a per-iteration progress entry (called with the loop kwargs; if it returns a truthy string the entry is appended, otherwise the agent's response text is used as the fallback entry). The accumulated log is exposed to every callback via the `progress` keyword (a per-iteration copy of prior entries) and, when `inject_progress=True` (default), injected into the next iteration's input as a `user` message (the full log without a session, only the latest entry with a session to avoid duplicating history). `fresh_context=True` restarts each iteration from the original task plus the progress log; when a session is attached it is snapshotted (`to_dict()`) before the loop and restored (`from_dict` + field copy) between iterations so the local transcript and any service-side conversation id reset too (in-loop working-state is discarded, pre-loop state preserved, continuity carried only by the progress log).
|
||||
- **`todos_remaining(*, looping_modes=None)`** / **`todos_remaining_message`** - Helper factories for todo-driven loops (the Python counterpart of .NET's `TodoCompletionLoopEvaluator`), designed for `create_harness_agent` but usable with any agent that registers a `TodoProvider` via `context_providers`. They resolve the `TodoProvider`/`AgentModeProvider` from the *running agent* (`agent.context_providers`, via `_resolve_context_provider`) rather than taking the provider as an argument, so they can be wired directly into `loop_should_continue`/`loop_next_message`. `todos_remaining` returns a `should_continue` predicate that loops while any todo is open; pass `looping_modes=[...]` to gate looping to specific operating modes (case-insensitive; honors the `AgentModeProvider`'s `source_id`/`available_modes`), `looping_modes=None` (default) applies in every mode, and an empty sequence raises `ValueError`. `todos_remaining_message` is a `next_message` callable that lists the still-open todo titles and tells the agent to finish them, returning `None` when the session/agent/provider is unavailable or nothing is open (in which case the middleware's default `None` handling applies: reuse the previous iteration's messages verbatim under the default `fresh_context=False`, or `DEFAULT_NEXT_MESSAGE` only when `fresh_context=True`).
|
||||
- **`background_tasks_running()`** / **`background_tasks_running_message`** - Helper factories for background-agent-driven loops, mirroring the `todos_remaining` pair. They resolve the `BackgroundAgentsProvider` from the *running agent* (`agent.context_providers`, via `_resolve_context_provider`) rather than taking the provider as an argument, so they can be wired directly into `create_harness_agent`'s `loop_should_continue`/`loop_next_message`. `background_tasks_running` returns a `should_continue` predicate that loops while the provider's persisted state shows any task with `status == RUNNING` (pair it with `max_iterations` so the loop is bounded even if a task's persisted status is never refreshed). `background_tasks_running_message` is a `next_message` callable that lists the still-running tasks (`#<id> (<agent_name>): <description>`) and tells the agent to wait for them to finish and retrieve their results, returning `None` when the session/agent/provider is unavailable or no task is running.
|
||||
- **Approval escape hatch** - `_has_pending_approval_request(result)` checks whether an iteration's response carries a pending tool-approval request (any content with `type == "function_approval_request"`). Both the streaming and non-streaming loops stop and return that response to the caller *before* evaluating `should_continue`/`max_iterations` or injecting `next_message`, so the loop is HITL-safe even when wrapped outermost around a `ToolApprovalMiddleware` (mirrors the C# `LoopAgent`'s `HasPendingApprovalRequests`).
|
||||
- **Harness integration** - `create_harness_agent` enables the loop when a `loop_should_continue` callable is passed; it prepends `AgentLoopMiddleware(loop_should_continue, max_iterations=loop_max_iterations, next_message=loop_next_message)` ahead of `ToolApprovalMiddleware` so the loop is the outermost middleware (each iteration is a full agent run including tool approval, and the escape hatch hands pending approvals back to the caller). `loop_next_message` and `loop_max_iterations` only take effect together with `loop_should_continue` (with no `loop_should_continue` there is no loop, so they are ignored); `loop_max_iterations` defaults to the loop's default cap (`None` → unbounded).
|
||||
|
||||
### Workflows (`_workflows/`)
|
||||
|
||||
- **`Workflow`** - Graph-based workflow definition
|
||||
- **`WorkflowBuilder`** - Fluent API for building workflows, including explicit
|
||||
`output_from` / `intermediate_output_from` selection for caller-facing emissions. `output_from`
|
||||
is an allow-list for **Workflow Output**; unselected executor payloads are hidden unless
|
||||
`intermediate_output_from` selects them as **Intermediate Output**. Use `output_from="all"` for
|
||||
explicit all-output behavior and `intermediate_output_from="all_other"` for visible progress from
|
||||
every output-capable executor not selected by `output_from`.
|
||||
- **`WorkflowRunResult`** - Non-streaming workflow result with Workflow Output `get_outputs()`
|
||||
and Intermediate Output `get_intermediate_outputs()` accessors
|
||||
- **Orchestrators**: `SequentialOrchestrator`, `ConcurrentOrchestrator`, `GroupChatOrchestrator`, `MagenticOrchestrator`, `HandoffOrchestrator`
|
||||
|
||||
## Built-in Providers
|
||||
|
||||
### OpenAI (`openai/`)
|
||||
|
||||
- **`OpenAIChatClient`** - Chat client for the OpenAI Responses API
|
||||
- **`OpenAIChatCompletionClient`** - Chat client for the OpenAI Chat Completions API
|
||||
|
||||
### Foundry (`foundry/`)
|
||||
|
||||
- **`FoundryChatClient`** - Chat client for Azure AI Foundry project endpoints
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Creating an Agent
|
||||
|
||||
```python
|
||||
from agent_framework import Agent
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
agent = Agent(
|
||||
client=OpenAIChatClient(),
|
||||
instructions="You are helpful.",
|
||||
tools=[my_function],
|
||||
)
|
||||
response = await agent.run("Hello")
|
||||
```
|
||||
|
||||
### Using `as_agent()` Shorthand
|
||||
|
||||
```python
|
||||
agent = OpenAIChatClient().as_agent(
|
||||
name="Assistant",
|
||||
instructions="You are helpful.",
|
||||
)
|
||||
```
|
||||
|
||||
### Middleware Pipeline
|
||||
|
||||
```python
|
||||
from agent_framework import Agent, AgentMiddleware, AgentContext
|
||||
|
||||
|
||||
class LoggingMiddleware(AgentMiddleware):
|
||||
async def process(self, context: AgentContext, call_next) -> None:
|
||||
print(f"Input: {context.messages}")
|
||||
await call_next()
|
||||
print(f"Output: {context.result}")
|
||||
|
||||
|
||||
agent = Agent(..., middleware=[LoggingMiddleware()])
|
||||
```
|
||||
|
||||
### Custom Chat Client
|
||||
|
||||
```python
|
||||
from agent_framework import BaseChatClient, ChatResponse, Message
|
||||
|
||||
|
||||
class MyClient(BaseChatClient):
|
||||
async def _inner_get_response(self, *, messages, options, **kwargs) -> ChatResponse:
|
||||
# Call your LLM here
|
||||
return ChatResponse(messages=[Message(role="assistant", contents=["Hi!"])])
|
||||
|
||||
async def _inner_get_streaming_response(self, *, messages, options, **kwargs):
|
||||
yield ChatResponseUpdate(...)
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,225 @@
|
||||
# Get Started with Microsoft Agent Framework
|
||||
|
||||
Highlights
|
||||
|
||||
- Flexible Agent Framework: build, orchestrate, and deploy AI agents and multi-agent systems
|
||||
- Multi-Agent Orchestration: Group chat, sequential, concurrent, and handoff patterns
|
||||
- Plugin Ecosystem: Extend with native functions, OpenAPI, Model Context Protocol (MCP), and more
|
||||
- LLM Support: OpenAI, Foundry, Anthropic, and more
|
||||
- Runtime Support: In-process and distributed agent execution
|
||||
- Multimodal: Text, vision, and function calling
|
||||
- Cross-Platform: .NET and Python implementations
|
||||
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
pip install agent-framework-core
|
||||
# Optional: Add Azure AI Foundry integration
|
||||
pip install agent-framework-foundry
|
||||
# Optional: Add OpenAI integration
|
||||
pip install agent-framework-openai
|
||||
```
|
||||
|
||||
Supported Platforms:
|
||||
|
||||
- Python: 3.10+
|
||||
- OS: Windows, macOS, Linux
|
||||
|
||||
## 1. Setup API Keys
|
||||
|
||||
Depending on the client you want to use, there are various environment variables you can set to configure the chat clients. This can be done in the environment itself, or with a `.env` file in your project root, some examples of environment variables include:
|
||||
|
||||
```bash
|
||||
FOUNDRY_PROJECT_ENDPOINT=...
|
||||
FOUNDRY_MODEL=...
|
||||
...
|
||||
OPENAI_API_KEY=sk-...
|
||||
OPENAI_CHAT_COMPLETION_MODEL=...
|
||||
OPENAI_CHAT_MODEL=...
|
||||
...
|
||||
AZURE_OPENAI_API_KEY=...
|
||||
AZURE_OPENAI_ENDPOINT=...
|
||||
AZURE_OPENAI_MODEL=...
|
||||
```
|
||||
|
||||
You can also override environment variables by explicitly passing configuration parameters to the chat client constructor:
|
||||
|
||||
```python
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
client = OpenAIChatClient(
|
||||
api_key="",
|
||||
model="",
|
||||
)
|
||||
```
|
||||
|
||||
See the following [getting started samples](https://github.com/microsoft/agent-framework/tree/main/python/samples/01-get-started) for more information.
|
||||
|
||||
## 2. Create a Simple Agent
|
||||
|
||||
Create agents and invoke them directly:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from agent_framework import Agent
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
agent = Agent(
|
||||
client=OpenAIChatClient(),
|
||||
instructions="""
|
||||
1) A robot may not injure a human being...
|
||||
2) A robot must obey orders given it by human beings...
|
||||
3) A robot must protect its own existence...
|
||||
|
||||
Give me the TLDR in exactly 5 words.
|
||||
"""
|
||||
)
|
||||
|
||||
result = asyncio.run(agent.run("Summarize the Three Laws of Robotics"))
|
||||
print(result)
|
||||
# Output: Protect humans, obey, self-preserve, prioritized.
|
||||
```
|
||||
|
||||
## 3. Directly Use Chat Clients (No Agent Required)
|
||||
|
||||
You can use the chat client classes directly for advanced workflows:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework import Message, Role
|
||||
|
||||
async def main():
|
||||
client = OpenAIChatClient()
|
||||
|
||||
response = await client.get_response([
|
||||
Message("system", ["You are a helpful assistant."]),
|
||||
Message("user", ["Write a haiku about Agent Framework."])
|
||||
])
|
||||
print(response.messages[0].text)
|
||||
|
||||
"""
|
||||
Output:
|
||||
|
||||
Agents work in sync,
|
||||
Framework threads through each task—
|
||||
Code sparks collaboration.
|
||||
"""
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## 4. Build an Agent with Tools and Functions
|
||||
|
||||
Enhance your agent with custom tools and function calling:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
from random import randint
|
||||
from agent_framework import Agent
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
|
||||
def get_weather(
|
||||
location: Annotated[str, "The location to get the weather for."],
|
||||
) -> str:
|
||||
"""Get the weather for a given location."""
|
||||
conditions = ["sunny", "cloudy", "rainy", "stormy"]
|
||||
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
|
||||
|
||||
|
||||
def get_menu_specials() -> str:
|
||||
"""Get today's menu specials."""
|
||||
return """
|
||||
Special Soup: Clam Chowder
|
||||
Special Salad: Cobb Salad
|
||||
Special Drink: Chai Tea
|
||||
"""
|
||||
|
||||
|
||||
async def main():
|
||||
agent = Agent(
|
||||
client=OpenAIChatClient(),
|
||||
instructions="You are a helpful assistant that can provide weather and restaurant information.",
|
||||
tools=[get_weather, get_menu_specials]
|
||||
)
|
||||
|
||||
response = await agent.run("What's the weather in Amsterdam and what are today's specials?")
|
||||
print(response)
|
||||
|
||||
# Output:
|
||||
# The weather in Amsterdam is sunny with a high of 22°C. Today's specials include
|
||||
# Clam Chowder soup, Cobb Salad, and Chai Tea as the special drink.
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
You can explore additional agent samples [here](https://github.com/microsoft/agent-framework/tree/main/python/samples/02-agents).
|
||||
|
||||
## 5. Multi-Agent Orchestration
|
||||
|
||||
Coordinate multiple agents to collaborate on complex tasks using orchestration patterns:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from agent_framework import Agent
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
|
||||
async def main():
|
||||
# Create specialized agents
|
||||
writer = Agent(
|
||||
client=OpenAIChatClient(),
|
||||
name="Writer",
|
||||
instructions="You are a creative content writer. Generate and refine slogans based on feedback."
|
||||
)
|
||||
|
||||
reviewer = Agent(
|
||||
client=OpenAIChatClient(),
|
||||
name="Reviewer",
|
||||
instructions="You are a critical reviewer. Provide detailed feedback on proposed slogans."
|
||||
)
|
||||
|
||||
# Sequential workflow: Writer creates, Reviewer provides feedback
|
||||
task = "Create a slogan for a new electric SUV that is affordable and fun to drive."
|
||||
|
||||
# Step 1: Writer creates initial slogan
|
||||
initial_result = await writer.run(task)
|
||||
print(f"Writer: {initial_result}")
|
||||
|
||||
# Step 2: Reviewer provides feedback
|
||||
feedback_request = f"Please review this slogan: {initial_result}"
|
||||
feedback = await reviewer.run(feedback_request)
|
||||
print(f"Reviewer: {feedback}")
|
||||
|
||||
# Step 3: Writer refines based on feedback
|
||||
refinement_request = f"Please refine this slogan based on the feedback: {initial_result}\nFeedback: {feedback}"
|
||||
final_result = await writer.run(refinement_request)
|
||||
print(f"Final Slogan: {final_result}")
|
||||
|
||||
# Example Output:
|
||||
# Writer: "Charge Forward: Affordable Adventure Awaits!"
|
||||
# Reviewer: "Good energy, but 'Charge Forward' is overused in EV marketing..."
|
||||
# Final Slogan: "Power Up Your Adventure: Premium Feel, Smart Price!"
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
**Note**: Sequential, Concurrent, Group Chat, Handoff, and Magentic orchestrations are available. See examples in [orchestration samples](https://github.com/microsoft/agent-framework/tree/main/python/samples/03-workflows/orchestrations).
|
||||
|
||||
## More Examples & Samples
|
||||
|
||||
- [Getting Started with Agents](https://github.com/microsoft/agent-framework/tree/main/python/samples/02-agents): Basic agent creation and tool usage
|
||||
- [Chat Client Examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/02-agents/chat_client): Direct chat client usage patterns
|
||||
- [Foundry Integration](https://github.com/microsoft/agent-framework/tree/main/python/packages/foundry): Foundry integration
|
||||
- [Workflows Samples](https://github.com/microsoft/agent-framework/tree/main/python/samples/03-workflows): Advanced multi-agent patterns
|
||||
|
||||
## Agent Framework Documentation
|
||||
|
||||
- [Agent Framework Repository](https://github.com/microsoft/agent-framework)
|
||||
- [Python Package Documentation](https://github.com/microsoft/agent-framework/tree/main/python)
|
||||
- [.NET Package Documentation](https://github.com/microsoft/agent-framework/tree/main/dotnet)
|
||||
- [Design Documents](https://github.com/microsoft/agent-framework/tree/main/docs/design)
|
||||
- [Learn Documentation](https://learn.microsoft.com/agent-framework/)
|
||||
@@ -0,0 +1,652 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Public API surface for Agent Framework core.
|
||||
|
||||
This module exposes the primary abstractions for agents, chat clients, tools, sessions,
|
||||
middleware, observability, and workflows. Most public exports are resolved lazily to keep
|
||||
``import agent_framework`` lightweight; importing a specific symbol still loads the module
|
||||
that owns that symbol.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# pyright: reportUnsupportedDunderAll=false
|
||||
# ruff: noqa: F822
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
try:
|
||||
_version = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
_version = "0.0.0" # Fallback for development mode
|
||||
__version__: Final[str] = _version
|
||||
|
||||
from ._telemetry import (
|
||||
AGENT_FRAMEWORK_USER_AGENT,
|
||||
APP_INFO,
|
||||
USER_AGENT_KEY,
|
||||
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR,
|
||||
prepend_agent_framework_to_user_agent,
|
||||
)
|
||||
from .exceptions import (
|
||||
AgentFrameworkException,
|
||||
MiddlewareException,
|
||||
UserInputRequiredException,
|
||||
WorkflowCheckpointException,
|
||||
WorkflowConvergenceException,
|
||||
WorkflowException,
|
||||
WorkflowRunnerException,
|
||||
)
|
||||
|
||||
_LAZY_MODULE_EXPORTS: Final[Mapping[str, tuple[str, ...]]] = {
|
||||
"._agents": ("Agent", "BaseAgent", "RawAgent", "SupportsAgentRun"),
|
||||
"._clients": (
|
||||
"BaseChatClient",
|
||||
"BaseEmbeddingClient",
|
||||
"SupportsChatGetResponse",
|
||||
"SupportsCodeInterpreterTool",
|
||||
"SupportsFileSearchTool",
|
||||
"SupportsGetEmbeddings",
|
||||
"SupportsImageGenerationTool",
|
||||
"SupportsMCPTool",
|
||||
"SupportsShellTool",
|
||||
"SupportsWebSearchTool",
|
||||
),
|
||||
"._compaction": (
|
||||
"COMPACTION_STATE_KEY",
|
||||
"EXCLUDE_REASON_KEY",
|
||||
"EXCLUDED_KEY",
|
||||
"GROUP_ANNOTATION_KEY",
|
||||
"GROUP_HAS_REASONING_KEY",
|
||||
"GROUP_ID_KEY",
|
||||
"GROUP_INDEX_KEY",
|
||||
"GROUP_KIND_KEY",
|
||||
"GROUP_TOKEN_COUNT_KEY",
|
||||
"SUMMARIZED_BY_SUMMARY_ID_KEY",
|
||||
"SUMMARY_OF_GROUP_IDS_KEY",
|
||||
"SUMMARY_OF_MESSAGE_IDS_KEY",
|
||||
"CharacterEstimatorTokenizer",
|
||||
"CompactionProvider",
|
||||
"CompactionStrategy",
|
||||
"ContextWindowCompactionStrategy",
|
||||
"SelectiveToolCallCompactionStrategy",
|
||||
"SlidingWindowStrategy",
|
||||
"SummarizationStrategy",
|
||||
"TokenBudgetComposedStrategy",
|
||||
"TokenizerProtocol",
|
||||
"ToolResultCompactionStrategy",
|
||||
"TruncationStrategy",
|
||||
"annotate_message_groups",
|
||||
"apply_compaction",
|
||||
"included_messages",
|
||||
"included_token_count",
|
||||
),
|
||||
"._evaluation": (
|
||||
"AgentEvalConverter",
|
||||
"CheckResult",
|
||||
"ConversationSplit",
|
||||
"ConversationSplitter",
|
||||
"EvalItem",
|
||||
"EvalItemResult",
|
||||
"EvalNotPassedError",
|
||||
"EvalResults",
|
||||
"EvalScoreResult",
|
||||
"Evaluator",
|
||||
"ExpectedToolCall",
|
||||
"LocalEvaluator",
|
||||
"RubricScore",
|
||||
"evaluate_agent",
|
||||
"evaluate_workflow",
|
||||
"evaluator",
|
||||
"keyword_check",
|
||||
"tool_call_args_match",
|
||||
"tool_called_check",
|
||||
"tool_calls_present",
|
||||
),
|
||||
"._feature_stage": ("ExperimentalFeature", "ReleaseCandidateFeature"),
|
||||
"._harness._agent": ("DEFAULT_HARNESS_INSTRUCTIONS", "create_harness_agent"),
|
||||
"._harness._background_agents": (
|
||||
"DEFAULT_BACKGROUND_AGENTS_SOURCE_ID",
|
||||
"BackgroundAgentsProvider",
|
||||
"BackgroundTaskInfo",
|
||||
"BackgroundTaskStatus",
|
||||
),
|
||||
"._harness._file_access": (
|
||||
"DEFAULT_FILE_ACCESS_INSTRUCTIONS",
|
||||
"DEFAULT_FILE_ACCESS_SOURCE_ID",
|
||||
"AgentFileStore",
|
||||
"FileAccessProvider",
|
||||
"FileSearchMatch",
|
||||
"FileSearchResult",
|
||||
"FileStoreEntry",
|
||||
"FileSystemAgentFileStore",
|
||||
"InMemoryAgentFileStore",
|
||||
),
|
||||
"._harness._file_memory": (
|
||||
"DEFAULT_FILE_MEMORY_INSTRUCTIONS",
|
||||
"DEFAULT_FILE_MEMORY_SOURCE_ID",
|
||||
"FileMemoryProvider",
|
||||
),
|
||||
"._harness._loop": (
|
||||
"AgentLoopMiddleware",
|
||||
"JudgeVerdict",
|
||||
"background_tasks_running",
|
||||
"background_tasks_running_message",
|
||||
"todos_remaining",
|
||||
"todos_remaining_message",
|
||||
),
|
||||
"._harness._memory": (
|
||||
"DEFAULT_MEMORY_SOURCE_ID",
|
||||
"MemoryContextProvider",
|
||||
"MemoryFileStore",
|
||||
"MemoryIndexEntry",
|
||||
"MemoryStore",
|
||||
"MemoryTopicRecord",
|
||||
),
|
||||
"._harness._mode": ("DEFAULT_MODE_SOURCE_ID", "AgentModeProvider", "get_agent_mode", "set_agent_mode"),
|
||||
"._harness._todo": (
|
||||
"DEFAULT_TODO_SOURCE_ID",
|
||||
"TodoFileStore",
|
||||
"TodoInput",
|
||||
"TodoItem",
|
||||
"TodoProvider",
|
||||
"TodoSessionStore",
|
||||
"TodoStore",
|
||||
),
|
||||
"._harness._tool_approval": (
|
||||
"DEFAULT_TOOL_APPROVAL_SOURCE_ID",
|
||||
"ToolApprovalMiddleware",
|
||||
"ToolApprovalRule",
|
||||
"ToolApprovalRuleCallback",
|
||||
"ToolApprovalState",
|
||||
"create_always_approve_tool_response",
|
||||
"create_always_approve_tool_with_arguments_response",
|
||||
),
|
||||
"._mcp": (
|
||||
"MCPStdioTool",
|
||||
"MCPStreamableHTTPTool",
|
||||
"MCPTaskOptions",
|
||||
"MCPWebsocketTool",
|
||||
"SamplingApprovalCallback",
|
||||
),
|
||||
"._middleware": (
|
||||
"AgentContext",
|
||||
"AgentMiddleware",
|
||||
"AgentMiddlewareLayer",
|
||||
"AgentMiddlewareTypes",
|
||||
"ChatAndFunctionMiddlewareTypes",
|
||||
"ChatContext",
|
||||
"ChatMiddleware",
|
||||
"ChatMiddlewareLayer",
|
||||
"ChatMiddlewareTypes",
|
||||
"FunctionInvocationContext",
|
||||
"FunctionMiddleware",
|
||||
"FunctionMiddlewareTypes",
|
||||
"MiddlewareTermination",
|
||||
"MiddlewareType",
|
||||
"MiddlewareTypes",
|
||||
"agent_middleware",
|
||||
"chat_middleware",
|
||||
"function_middleware",
|
||||
),
|
||||
"._sessions": (
|
||||
"AgentSession",
|
||||
"ContextProvider",
|
||||
"FileHistoryProvider",
|
||||
"HistoryProvider",
|
||||
"InMemoryHistoryProvider",
|
||||
"MESSAGE_INJECTION_PENDING_MESSAGES_STATE_KEY",
|
||||
"MessageInjectionMiddleware",
|
||||
"ServiceSessionId",
|
||||
"SessionContext",
|
||||
"enqueue_messages",
|
||||
"register_state_type",
|
||||
),
|
||||
"._settings": ("SecretString", "load_settings"),
|
||||
"._skills": (
|
||||
"AggregatingSkillsSource",
|
||||
"CachingSkillsSource",
|
||||
"ClassSkill",
|
||||
"DeduplicatingSkillsSource",
|
||||
"DelegatingSkillsSource",
|
||||
"FileSkill",
|
||||
"FileSkillScript",
|
||||
"FileSkillsSource",
|
||||
"FilteringSkillsSource",
|
||||
"InlineSkill",
|
||||
"InlineSkillResource",
|
||||
"InlineSkillScript",
|
||||
"InMemorySkillsSource",
|
||||
"MCPSkill",
|
||||
"MCPSkillResource",
|
||||
"MCPSkillsSource",
|
||||
"Skill",
|
||||
"SkillFrontmatter",
|
||||
"SkillResource",
|
||||
"SkillScript",
|
||||
"SkillScriptArgumentParser",
|
||||
"SkillScriptRunner",
|
||||
"SkillsProvider",
|
||||
"SkillsSource",
|
||||
"SkillsSourceContext",
|
||||
),
|
||||
"._tools": (
|
||||
"SKIP_PARSING",
|
||||
"FunctionInvocationConfiguration",
|
||||
"FunctionInvocationLayer",
|
||||
"FunctionTool",
|
||||
"ToolTypes",
|
||||
"normalize_function_invocation_configuration",
|
||||
"tool",
|
||||
),
|
||||
"._types": (
|
||||
"AgentResponse",
|
||||
"AgentResponseUpdate",
|
||||
"AgentRunInputs",
|
||||
"Annotation",
|
||||
"ChatOptions",
|
||||
"ChatResponse",
|
||||
"ChatResponseUpdate",
|
||||
"Content",
|
||||
"ContinuationToken",
|
||||
"Embedding",
|
||||
"EmbeddingGenerationOptions",
|
||||
"EmbeddingInputT",
|
||||
"EmbeddingT",
|
||||
"FinalT",
|
||||
"FinishReason",
|
||||
"FinishReasonLiteral",
|
||||
"GeneratedEmbeddings",
|
||||
"Message",
|
||||
"OuterFinalT",
|
||||
"OuterUpdateT",
|
||||
"ResponseStream",
|
||||
"Role",
|
||||
"RoleLiteral",
|
||||
"TextSpanRegion",
|
||||
"ToolMode",
|
||||
"UpdateT",
|
||||
"UsageDetails",
|
||||
"add_usage_details",
|
||||
"detect_media_type_from_base64",
|
||||
"map_chat_to_agent_update",
|
||||
"merge_chat_options",
|
||||
"normalize_messages",
|
||||
"normalize_tools",
|
||||
"prepend_instructions_to_messages",
|
||||
"validate_chat_options",
|
||||
"validate_tool_mode",
|
||||
"validate_tools",
|
||||
),
|
||||
"._workflows._agent": ("WorkflowAgent",),
|
||||
"._workflows._agent_executor": ("AgentExecutor", "AgentExecutorRequest", "AgentExecutorResponse"),
|
||||
"._workflows._agent_utils": ("resolve_agent_id",),
|
||||
"._workflows._checkpoint": (
|
||||
"CheckpointID",
|
||||
"CheckpointStorage",
|
||||
"FileCheckpointStorage",
|
||||
"InMemoryCheckpointStorage",
|
||||
"WorkflowCheckpoint",
|
||||
),
|
||||
"._workflows._const": ("DEFAULT_MAX_ITERATIONS",),
|
||||
"._workflows._edge": (
|
||||
"Case",
|
||||
"Default",
|
||||
"Edge",
|
||||
"EdgeCondition",
|
||||
"FanInEdgeGroup",
|
||||
"FanOutEdgeGroup",
|
||||
"SingleEdgeGroup",
|
||||
"SwitchCaseEdgeGroup",
|
||||
"SwitchCaseEdgeGroupCase",
|
||||
"SwitchCaseEdgeGroupDefault",
|
||||
),
|
||||
"._workflows._edge_runner": ("create_edge_runner",),
|
||||
"._workflows._events": (
|
||||
"WorkflowErrorDetails",
|
||||
"WorkflowEvent",
|
||||
"WorkflowEventSource",
|
||||
"WorkflowEventType",
|
||||
"WorkflowRunState",
|
||||
),
|
||||
"._workflows._executor": ("Executor", "handler"),
|
||||
"._workflows._function_executor": ("FunctionExecutor", "executor"),
|
||||
"._workflows._functional": (
|
||||
"FunctionalWorkflow",
|
||||
"FunctionalWorkflowAgent",
|
||||
"RunContext",
|
||||
"StepWrapper",
|
||||
"get_run_context",
|
||||
"step",
|
||||
"workflow",
|
||||
),
|
||||
"._workflows._request_info_mixin": ("response_handler",),
|
||||
"._workflows._runner": ("Runner",),
|
||||
"._workflows._runner_context": ("InProcRunnerContext", "RunnerContext", "WorkflowMessage"),
|
||||
"._workflows._validation": (
|
||||
"EdgeDuplicationError",
|
||||
"GraphConnectivityError",
|
||||
"TypeCompatibilityError",
|
||||
"ValidationTypeEnum",
|
||||
"WorkflowValidationError",
|
||||
"validate_workflow_graph",
|
||||
),
|
||||
"._workflows._viz": ("WorkflowViz",),
|
||||
"._workflows._workflow": ("Workflow", "WorkflowRunResult"),
|
||||
"._workflows._workflow_builder": ("WorkflowBuilder",),
|
||||
"._workflows._workflow_context": ("WorkflowContext",),
|
||||
"._workflows._workflow_executor": (
|
||||
"SubWorkflowRequestMessage",
|
||||
"SubWorkflowResponseMessage",
|
||||
"WorkflowExecutor",
|
||||
),
|
||||
}
|
||||
_LAZY_EXPORTS: Final[dict[str, str]] = {
|
||||
name: module_name for module_name, names in _LAZY_MODULE_EXPORTS.items() for name in names
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"AGENT_FRAMEWORK_USER_AGENT",
|
||||
"APP_INFO",
|
||||
"COMPACTION_STATE_KEY",
|
||||
"DEFAULT_BACKGROUND_AGENTS_SOURCE_ID",
|
||||
"DEFAULT_FILE_ACCESS_INSTRUCTIONS",
|
||||
"DEFAULT_FILE_ACCESS_SOURCE_ID",
|
||||
"DEFAULT_FILE_MEMORY_INSTRUCTIONS",
|
||||
"DEFAULT_FILE_MEMORY_SOURCE_ID",
|
||||
"DEFAULT_HARNESS_INSTRUCTIONS",
|
||||
"DEFAULT_MAX_ITERATIONS",
|
||||
"DEFAULT_MEMORY_SOURCE_ID",
|
||||
"DEFAULT_MODE_SOURCE_ID",
|
||||
"DEFAULT_TODO_SOURCE_ID",
|
||||
"DEFAULT_TOOL_APPROVAL_SOURCE_ID",
|
||||
"EXCLUDED_KEY",
|
||||
"EXCLUDE_REASON_KEY",
|
||||
"GROUP_ANNOTATION_KEY",
|
||||
"GROUP_HAS_REASONING_KEY",
|
||||
"GROUP_ID_KEY",
|
||||
"GROUP_INDEX_KEY",
|
||||
"GROUP_KIND_KEY",
|
||||
"GROUP_TOKEN_COUNT_KEY",
|
||||
"MESSAGE_INJECTION_PENDING_MESSAGES_STATE_KEY",
|
||||
"SKIP_PARSING",
|
||||
"SUMMARIZED_BY_SUMMARY_ID_KEY",
|
||||
"SUMMARY_OF_GROUP_IDS_KEY",
|
||||
"SUMMARY_OF_MESSAGE_IDS_KEY",
|
||||
"USER_AGENT_KEY",
|
||||
"USER_AGENT_TELEMETRY_DISABLED_ENV_VAR",
|
||||
"Agent",
|
||||
"AgentContext",
|
||||
"AgentEvalConverter",
|
||||
"AgentExecutor",
|
||||
"AgentExecutorRequest",
|
||||
"AgentExecutorResponse",
|
||||
"AgentFileStore",
|
||||
"AgentFrameworkException",
|
||||
"AgentLoopMiddleware",
|
||||
"AgentMiddleware",
|
||||
"AgentMiddlewareLayer",
|
||||
"AgentMiddlewareTypes",
|
||||
"AgentModeProvider",
|
||||
"AgentResponse",
|
||||
"AgentResponseUpdate",
|
||||
"AgentRunInputs",
|
||||
"AgentSession",
|
||||
"AggregatingSkillsSource",
|
||||
"Annotation",
|
||||
"BackgroundAgentsProvider",
|
||||
"BackgroundTaskInfo",
|
||||
"BackgroundTaskStatus",
|
||||
"BaseAgent",
|
||||
"BaseChatClient",
|
||||
"BaseEmbeddingClient",
|
||||
"CachingSkillsSource",
|
||||
"Case",
|
||||
"CharacterEstimatorTokenizer",
|
||||
"ChatAndFunctionMiddlewareTypes",
|
||||
"ChatContext",
|
||||
"ChatMiddleware",
|
||||
"ChatMiddlewareLayer",
|
||||
"ChatMiddlewareTypes",
|
||||
"ChatOptions",
|
||||
"ChatResponse",
|
||||
"ChatResponseUpdate",
|
||||
"CheckResult",
|
||||
"CheckpointID",
|
||||
"CheckpointStorage",
|
||||
"ClassSkill",
|
||||
"CompactionProvider",
|
||||
"CompactionStrategy",
|
||||
"Content",
|
||||
"ContextProvider",
|
||||
"ContextWindowCompactionStrategy",
|
||||
"ContinuationToken",
|
||||
"ConversationSplit",
|
||||
"ConversationSplitter",
|
||||
"DeduplicatingSkillsSource",
|
||||
"Default",
|
||||
"DelegatingSkillsSource",
|
||||
"Edge",
|
||||
"EdgeCondition",
|
||||
"EdgeDuplicationError",
|
||||
"Embedding",
|
||||
"EmbeddingGenerationOptions",
|
||||
"EmbeddingInputT",
|
||||
"EmbeddingT",
|
||||
"EvalItem",
|
||||
"EvalItemResult",
|
||||
"EvalNotPassedError",
|
||||
"EvalResults",
|
||||
"EvalScoreResult",
|
||||
"Evaluator",
|
||||
"Executor",
|
||||
"ExpectedToolCall",
|
||||
"ExperimentalFeature",
|
||||
"FanInEdgeGroup",
|
||||
"FanOutEdgeGroup",
|
||||
"FileAccessProvider",
|
||||
"FileCheckpointStorage",
|
||||
"FileHistoryProvider",
|
||||
"FileMemoryProvider",
|
||||
"FileSearchMatch",
|
||||
"FileSearchResult",
|
||||
"FileSkill",
|
||||
"FileSkillScript",
|
||||
"FileSkillsSource",
|
||||
"FileStoreEntry",
|
||||
"FileSystemAgentFileStore",
|
||||
"FilteringSkillsSource",
|
||||
"FinalT",
|
||||
"FinishReason",
|
||||
"FinishReasonLiteral",
|
||||
"FunctionExecutor",
|
||||
"FunctionInvocationConfiguration",
|
||||
"FunctionInvocationContext",
|
||||
"FunctionInvocationLayer",
|
||||
"FunctionMiddleware",
|
||||
"FunctionMiddlewareTypes",
|
||||
"FunctionTool",
|
||||
"FunctionalWorkflow",
|
||||
"FunctionalWorkflowAgent",
|
||||
"GeneratedEmbeddings",
|
||||
"GraphConnectivityError",
|
||||
"HistoryProvider",
|
||||
"InMemoryAgentFileStore",
|
||||
"InMemoryCheckpointStorage",
|
||||
"InMemoryHistoryProvider",
|
||||
"InMemorySkillsSource",
|
||||
"InProcRunnerContext",
|
||||
"InlineSkill",
|
||||
"InlineSkillResource",
|
||||
"InlineSkillScript",
|
||||
"JudgeVerdict",
|
||||
"LocalEvaluator",
|
||||
"MCPSkill",
|
||||
"MCPSkillResource",
|
||||
"MCPSkillsSource",
|
||||
"MCPStdioTool",
|
||||
"MCPStreamableHTTPTool",
|
||||
"MCPTaskOptions",
|
||||
"MCPWebsocketTool",
|
||||
"MemoryContextProvider",
|
||||
"MemoryFileStore",
|
||||
"MemoryIndexEntry",
|
||||
"MemoryStore",
|
||||
"MemoryTopicRecord",
|
||||
"Message",
|
||||
"MessageInjectionMiddleware",
|
||||
"MiddlewareException",
|
||||
"MiddlewareTermination",
|
||||
"MiddlewareType",
|
||||
"MiddlewareTypes",
|
||||
"OuterFinalT",
|
||||
"OuterUpdateT",
|
||||
"RawAgent",
|
||||
"ReleaseCandidateFeature",
|
||||
"ResponseStream",
|
||||
"Role",
|
||||
"RoleLiteral",
|
||||
"RubricScore",
|
||||
"RunContext",
|
||||
"Runner",
|
||||
"RunnerContext",
|
||||
"SamplingApprovalCallback",
|
||||
"SecretString",
|
||||
"SelectiveToolCallCompactionStrategy",
|
||||
"ServiceSessionId",
|
||||
"SessionContext",
|
||||
"SingleEdgeGroup",
|
||||
"Skill",
|
||||
"SkillFrontmatter",
|
||||
"SkillResource",
|
||||
"SkillScript",
|
||||
"SkillScriptArgumentParser",
|
||||
"SkillScriptRunner",
|
||||
"SkillsProvider",
|
||||
"SkillsSource",
|
||||
"SkillsSourceContext",
|
||||
"SlidingWindowStrategy",
|
||||
"StepWrapper",
|
||||
"SubWorkflowRequestMessage",
|
||||
"SubWorkflowResponseMessage",
|
||||
"SummarizationStrategy",
|
||||
"SupportsAgentRun",
|
||||
"SupportsChatGetResponse",
|
||||
"SupportsCodeInterpreterTool",
|
||||
"SupportsFileSearchTool",
|
||||
"SupportsGetEmbeddings",
|
||||
"SupportsImageGenerationTool",
|
||||
"SupportsMCPTool",
|
||||
"SupportsShellTool",
|
||||
"SupportsWebSearchTool",
|
||||
"SwitchCaseEdgeGroup",
|
||||
"SwitchCaseEdgeGroupCase",
|
||||
"SwitchCaseEdgeGroupDefault",
|
||||
"TextSpanRegion",
|
||||
"TodoFileStore",
|
||||
"TodoInput",
|
||||
"TodoItem",
|
||||
"TodoProvider",
|
||||
"TodoSessionStore",
|
||||
"TodoStore",
|
||||
"TokenBudgetComposedStrategy",
|
||||
"TokenizerProtocol",
|
||||
"ToolApprovalMiddleware",
|
||||
"ToolApprovalRule",
|
||||
"ToolApprovalRuleCallback",
|
||||
"ToolApprovalState",
|
||||
"ToolMode",
|
||||
"ToolResultCompactionStrategy",
|
||||
"ToolTypes",
|
||||
"TruncationStrategy",
|
||||
"TypeCompatibilityError",
|
||||
"UpdateT",
|
||||
"UsageDetails",
|
||||
"UserInputRequiredException",
|
||||
"ValidationTypeEnum",
|
||||
"Workflow",
|
||||
"WorkflowAgent",
|
||||
"WorkflowBuilder",
|
||||
"WorkflowCheckpoint",
|
||||
"WorkflowCheckpointException",
|
||||
"WorkflowContext",
|
||||
"WorkflowConvergenceException",
|
||||
"WorkflowErrorDetails",
|
||||
"WorkflowEvent",
|
||||
"WorkflowEventSource",
|
||||
"WorkflowEventType",
|
||||
"WorkflowException",
|
||||
"WorkflowExecutor",
|
||||
"WorkflowMessage",
|
||||
"WorkflowRunResult",
|
||||
"WorkflowRunState",
|
||||
"WorkflowRunnerException",
|
||||
"WorkflowValidationError",
|
||||
"WorkflowViz",
|
||||
"__version__",
|
||||
"add_usage_details",
|
||||
"agent_middleware",
|
||||
"annotate_message_groups",
|
||||
"apply_compaction",
|
||||
"background_tasks_running",
|
||||
"background_tasks_running_message",
|
||||
"chat_middleware",
|
||||
"create_always_approve_tool_response",
|
||||
"create_always_approve_tool_with_arguments_response",
|
||||
"create_edge_runner",
|
||||
"create_harness_agent",
|
||||
"detect_media_type_from_base64",
|
||||
"enqueue_messages",
|
||||
"evaluate_agent",
|
||||
"evaluate_workflow",
|
||||
"evaluator",
|
||||
"executor",
|
||||
"function_middleware",
|
||||
"get_agent_mode",
|
||||
"get_run_context",
|
||||
"handler",
|
||||
"included_messages",
|
||||
"included_token_count",
|
||||
"keyword_check",
|
||||
"load_settings",
|
||||
"map_chat_to_agent_update",
|
||||
"merge_chat_options",
|
||||
"normalize_function_invocation_configuration",
|
||||
"normalize_messages",
|
||||
"normalize_tools",
|
||||
"prepend_agent_framework_to_user_agent",
|
||||
"prepend_instructions_to_messages",
|
||||
"register_state_type",
|
||||
"resolve_agent_id",
|
||||
"response_handler",
|
||||
"set_agent_mode",
|
||||
"step",
|
||||
"todos_remaining",
|
||||
"todos_remaining_message",
|
||||
"tool",
|
||||
"tool_call_args_match",
|
||||
"tool_called_check",
|
||||
"tool_calls_present",
|
||||
"validate_chat_options",
|
||||
"validate_tool_mode",
|
||||
"validate_tools",
|
||||
"validate_workflow_graph",
|
||||
"workflow",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Lazily resolve public names exported from ``agent_framework``."""
|
||||
if module_name := _LAZY_EXPORTS.get(name):
|
||||
value = getattr(importlib.import_module(module_name, __name__), name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
"""Return public names for interactive discovery."""
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
@@ -0,0 +1,604 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Final
|
||||
|
||||
__version__: Final[str]
|
||||
|
||||
from ._agents import Agent, BaseAgent, RawAgent, SupportsAgentRun
|
||||
from ._clients import (
|
||||
BaseChatClient,
|
||||
BaseEmbeddingClient,
|
||||
SupportsChatGetResponse,
|
||||
SupportsCodeInterpreterTool,
|
||||
SupportsFileSearchTool,
|
||||
SupportsGetEmbeddings,
|
||||
SupportsImageGenerationTool,
|
||||
SupportsMCPTool,
|
||||
SupportsShellTool,
|
||||
SupportsWebSearchTool,
|
||||
)
|
||||
from ._compaction import (
|
||||
COMPACTION_STATE_KEY,
|
||||
EXCLUDE_REASON_KEY,
|
||||
EXCLUDED_KEY,
|
||||
GROUP_ANNOTATION_KEY,
|
||||
GROUP_HAS_REASONING_KEY,
|
||||
GROUP_ID_KEY,
|
||||
GROUP_INDEX_KEY,
|
||||
GROUP_KIND_KEY,
|
||||
GROUP_TOKEN_COUNT_KEY,
|
||||
SUMMARIZED_BY_SUMMARY_ID_KEY,
|
||||
SUMMARY_OF_GROUP_IDS_KEY,
|
||||
SUMMARY_OF_MESSAGE_IDS_KEY,
|
||||
CharacterEstimatorTokenizer,
|
||||
CompactionProvider,
|
||||
CompactionStrategy,
|
||||
ContextWindowCompactionStrategy,
|
||||
SelectiveToolCallCompactionStrategy,
|
||||
SlidingWindowStrategy,
|
||||
SummarizationStrategy,
|
||||
TokenBudgetComposedStrategy,
|
||||
TokenizerProtocol,
|
||||
ToolResultCompactionStrategy,
|
||||
TruncationStrategy,
|
||||
annotate_message_groups,
|
||||
apply_compaction,
|
||||
included_messages,
|
||||
included_token_count,
|
||||
)
|
||||
from ._evaluation import (
|
||||
AgentEvalConverter,
|
||||
CheckResult,
|
||||
ConversationSplit,
|
||||
ConversationSplitter,
|
||||
EvalItem,
|
||||
EvalItemResult,
|
||||
EvalNotPassedError,
|
||||
EvalResults,
|
||||
EvalScoreResult,
|
||||
Evaluator,
|
||||
ExpectedToolCall,
|
||||
LocalEvaluator,
|
||||
RubricScore,
|
||||
evaluate_agent,
|
||||
evaluate_workflow,
|
||||
evaluator,
|
||||
keyword_check,
|
||||
tool_call_args_match,
|
||||
tool_called_check,
|
||||
tool_calls_present,
|
||||
)
|
||||
from ._feature_stage import ExperimentalFeature, ReleaseCandidateFeature
|
||||
from ._harness._agent import DEFAULT_HARNESS_INSTRUCTIONS, create_harness_agent
|
||||
from ._harness._background_agents import (
|
||||
DEFAULT_BACKGROUND_AGENTS_SOURCE_ID,
|
||||
BackgroundAgentsProvider,
|
||||
BackgroundTaskInfo,
|
||||
BackgroundTaskStatus,
|
||||
)
|
||||
from ._harness._file_access import (
|
||||
DEFAULT_FILE_ACCESS_INSTRUCTIONS,
|
||||
DEFAULT_FILE_ACCESS_SOURCE_ID,
|
||||
AgentFileStore,
|
||||
FileAccessProvider,
|
||||
FileSearchMatch,
|
||||
FileSearchResult,
|
||||
FileStoreEntry,
|
||||
FileSystemAgentFileStore,
|
||||
InMemoryAgentFileStore,
|
||||
)
|
||||
from ._harness._file_memory import DEFAULT_FILE_MEMORY_INSTRUCTIONS, DEFAULT_FILE_MEMORY_SOURCE_ID, FileMemoryProvider
|
||||
from ._harness._loop import (
|
||||
AgentLoopMiddleware,
|
||||
JudgeVerdict,
|
||||
background_tasks_running,
|
||||
background_tasks_running_message,
|
||||
todos_remaining,
|
||||
todos_remaining_message,
|
||||
)
|
||||
from ._harness._memory import (
|
||||
DEFAULT_MEMORY_SOURCE_ID,
|
||||
MemoryContextProvider,
|
||||
MemoryFileStore,
|
||||
MemoryIndexEntry,
|
||||
MemoryStore,
|
||||
MemoryTopicRecord,
|
||||
)
|
||||
from ._harness._mode import DEFAULT_MODE_SOURCE_ID, AgentModeProvider, get_agent_mode, set_agent_mode
|
||||
from ._harness._todo import (
|
||||
DEFAULT_TODO_SOURCE_ID,
|
||||
TodoFileStore,
|
||||
TodoInput,
|
||||
TodoItem,
|
||||
TodoProvider,
|
||||
TodoSessionStore,
|
||||
TodoStore,
|
||||
)
|
||||
from ._harness._tool_approval import (
|
||||
DEFAULT_TOOL_APPROVAL_SOURCE_ID,
|
||||
ToolApprovalMiddleware,
|
||||
ToolApprovalRule,
|
||||
ToolApprovalRuleCallback,
|
||||
ToolApprovalState,
|
||||
create_always_approve_tool_response,
|
||||
create_always_approve_tool_with_arguments_response,
|
||||
)
|
||||
from ._mcp import (
|
||||
MCPStdioTool,
|
||||
MCPStreamableHTTPTool,
|
||||
MCPTaskOptions,
|
||||
MCPWebsocketTool,
|
||||
SamplingApprovalCallback,
|
||||
)
|
||||
from ._middleware import (
|
||||
AgentContext,
|
||||
AgentMiddleware,
|
||||
AgentMiddlewareLayer,
|
||||
AgentMiddlewareTypes,
|
||||
ChatAndFunctionMiddlewareTypes,
|
||||
ChatContext,
|
||||
ChatMiddleware,
|
||||
ChatMiddlewareLayer,
|
||||
ChatMiddlewareTypes,
|
||||
FunctionInvocationContext,
|
||||
FunctionMiddleware,
|
||||
FunctionMiddlewareTypes,
|
||||
MiddlewareTermination,
|
||||
MiddlewareType,
|
||||
MiddlewareTypes,
|
||||
agent_middleware,
|
||||
chat_middleware,
|
||||
function_middleware,
|
||||
)
|
||||
from ._sessions import (
|
||||
MESSAGE_INJECTION_PENDING_MESSAGES_STATE_KEY,
|
||||
AgentSession,
|
||||
ContextProvider,
|
||||
FileHistoryProvider,
|
||||
HistoryProvider,
|
||||
InMemoryHistoryProvider,
|
||||
MessageInjectionMiddleware,
|
||||
ServiceSessionId,
|
||||
SessionContext,
|
||||
enqueue_messages,
|
||||
register_state_type,
|
||||
)
|
||||
from ._settings import SecretString, load_settings
|
||||
from ._skills import (
|
||||
AggregatingSkillsSource,
|
||||
CachingSkillsSource,
|
||||
ClassSkill,
|
||||
DeduplicatingSkillsSource,
|
||||
DelegatingSkillsSource,
|
||||
FileSkill,
|
||||
FileSkillScript,
|
||||
FileSkillsSource,
|
||||
FilteringSkillsSource,
|
||||
InlineSkill,
|
||||
InlineSkillResource,
|
||||
InlineSkillScript,
|
||||
InMemorySkillsSource,
|
||||
MCPSkill,
|
||||
MCPSkillResource,
|
||||
MCPSkillsSource,
|
||||
Skill,
|
||||
SkillFrontmatter,
|
||||
SkillResource,
|
||||
SkillScript,
|
||||
SkillScriptArgumentParser,
|
||||
SkillScriptRunner,
|
||||
SkillsProvider,
|
||||
SkillsSource,
|
||||
SkillsSourceContext,
|
||||
)
|
||||
from ._telemetry import (
|
||||
AGENT_FRAMEWORK_USER_AGENT,
|
||||
APP_INFO,
|
||||
USER_AGENT_KEY,
|
||||
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR,
|
||||
prepend_agent_framework_to_user_agent,
|
||||
)
|
||||
from ._tools import (
|
||||
SKIP_PARSING,
|
||||
FunctionInvocationConfiguration,
|
||||
FunctionInvocationLayer,
|
||||
FunctionTool,
|
||||
ToolTypes,
|
||||
normalize_function_invocation_configuration,
|
||||
tool,
|
||||
)
|
||||
from ._types import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentRunInputs,
|
||||
Annotation,
|
||||
ChatOptions,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
ContinuationToken,
|
||||
Embedding,
|
||||
EmbeddingGenerationOptions,
|
||||
EmbeddingInputT,
|
||||
EmbeddingT,
|
||||
FinalT,
|
||||
FinishReason,
|
||||
FinishReasonLiteral,
|
||||
GeneratedEmbeddings,
|
||||
Message,
|
||||
OuterFinalT,
|
||||
OuterUpdateT,
|
||||
ResponseStream,
|
||||
Role,
|
||||
RoleLiteral,
|
||||
TextSpanRegion,
|
||||
ToolMode,
|
||||
UpdateT,
|
||||
UsageDetails,
|
||||
add_usage_details,
|
||||
detect_media_type_from_base64,
|
||||
map_chat_to_agent_update,
|
||||
merge_chat_options,
|
||||
normalize_messages,
|
||||
normalize_tools,
|
||||
prepend_instructions_to_messages,
|
||||
validate_chat_options,
|
||||
validate_tool_mode,
|
||||
validate_tools,
|
||||
)
|
||||
from ._workflows._agent import WorkflowAgent
|
||||
from ._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse
|
||||
from ._workflows._agent_utils import resolve_agent_id
|
||||
from ._workflows._checkpoint import (
|
||||
CheckpointID,
|
||||
CheckpointStorage,
|
||||
FileCheckpointStorage,
|
||||
InMemoryCheckpointStorage,
|
||||
WorkflowCheckpoint,
|
||||
)
|
||||
from ._workflows._const import DEFAULT_MAX_ITERATIONS
|
||||
from ._workflows._edge import (
|
||||
Case,
|
||||
Default,
|
||||
Edge,
|
||||
EdgeCondition,
|
||||
FanInEdgeGroup,
|
||||
FanOutEdgeGroup,
|
||||
SingleEdgeGroup,
|
||||
SwitchCaseEdgeGroup,
|
||||
SwitchCaseEdgeGroupCase,
|
||||
SwitchCaseEdgeGroupDefault,
|
||||
)
|
||||
from ._workflows._edge_runner import create_edge_runner
|
||||
from ._workflows._events import (
|
||||
WorkflowErrorDetails,
|
||||
WorkflowEvent,
|
||||
WorkflowEventSource,
|
||||
WorkflowEventType,
|
||||
WorkflowRunState,
|
||||
)
|
||||
from ._workflows._executor import Executor, handler
|
||||
from ._workflows._function_executor import FunctionExecutor, executor
|
||||
from ._workflows._functional import (
|
||||
FunctionalWorkflow,
|
||||
FunctionalWorkflowAgent,
|
||||
RunContext,
|
||||
StepWrapper,
|
||||
get_run_context,
|
||||
step,
|
||||
workflow,
|
||||
)
|
||||
from ._workflows._request_info_mixin import response_handler
|
||||
from ._workflows._runner import Runner
|
||||
from ._workflows._runner_context import InProcRunnerContext, RunnerContext, WorkflowMessage
|
||||
from ._workflows._validation import (
|
||||
EdgeDuplicationError,
|
||||
GraphConnectivityError,
|
||||
TypeCompatibilityError,
|
||||
ValidationTypeEnum,
|
||||
WorkflowValidationError,
|
||||
validate_workflow_graph,
|
||||
)
|
||||
from ._workflows._viz import WorkflowViz
|
||||
from ._workflows._workflow import Workflow, WorkflowRunResult
|
||||
from ._workflows._workflow_builder import WorkflowBuilder
|
||||
from ._workflows._workflow_context import WorkflowContext
|
||||
from ._workflows._workflow_executor import SubWorkflowRequestMessage, SubWorkflowResponseMessage, WorkflowExecutor
|
||||
from .exceptions import (
|
||||
AgentFrameworkException,
|
||||
MiddlewareException,
|
||||
UserInputRequiredException,
|
||||
WorkflowCheckpointException,
|
||||
WorkflowConvergenceException,
|
||||
WorkflowException,
|
||||
WorkflowRunnerException,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AGENT_FRAMEWORK_USER_AGENT",
|
||||
"APP_INFO",
|
||||
"COMPACTION_STATE_KEY",
|
||||
"DEFAULT_BACKGROUND_AGENTS_SOURCE_ID",
|
||||
"DEFAULT_FILE_ACCESS_INSTRUCTIONS",
|
||||
"DEFAULT_FILE_ACCESS_SOURCE_ID",
|
||||
"DEFAULT_FILE_MEMORY_INSTRUCTIONS",
|
||||
"DEFAULT_FILE_MEMORY_SOURCE_ID",
|
||||
"DEFAULT_HARNESS_INSTRUCTIONS",
|
||||
"DEFAULT_MAX_ITERATIONS",
|
||||
"DEFAULT_MEMORY_SOURCE_ID",
|
||||
"DEFAULT_MODE_SOURCE_ID",
|
||||
"DEFAULT_TODO_SOURCE_ID",
|
||||
"DEFAULT_TOOL_APPROVAL_SOURCE_ID",
|
||||
"EXCLUDED_KEY",
|
||||
"EXCLUDE_REASON_KEY",
|
||||
"GROUP_ANNOTATION_KEY",
|
||||
"GROUP_HAS_REASONING_KEY",
|
||||
"GROUP_ID_KEY",
|
||||
"GROUP_INDEX_KEY",
|
||||
"GROUP_KIND_KEY",
|
||||
"GROUP_TOKEN_COUNT_KEY",
|
||||
"MESSAGE_INJECTION_PENDING_MESSAGES_STATE_KEY",
|
||||
"SKIP_PARSING",
|
||||
"SUMMARIZED_BY_SUMMARY_ID_KEY",
|
||||
"SUMMARY_OF_GROUP_IDS_KEY",
|
||||
"SUMMARY_OF_MESSAGE_IDS_KEY",
|
||||
"USER_AGENT_KEY",
|
||||
"USER_AGENT_TELEMETRY_DISABLED_ENV_VAR",
|
||||
"Agent",
|
||||
"AgentContext",
|
||||
"AgentEvalConverter",
|
||||
"AgentExecutor",
|
||||
"AgentExecutorRequest",
|
||||
"AgentExecutorResponse",
|
||||
"AgentFileStore",
|
||||
"AgentFrameworkException",
|
||||
"AgentLoopMiddleware",
|
||||
"AgentMiddleware",
|
||||
"AgentMiddlewareLayer",
|
||||
"AgentMiddlewareTypes",
|
||||
"AgentModeProvider",
|
||||
"AgentResponse",
|
||||
"AgentResponseUpdate",
|
||||
"AgentRunInputs",
|
||||
"AgentSession",
|
||||
"AggregatingSkillsSource",
|
||||
"Annotation",
|
||||
"BackgroundAgentsProvider",
|
||||
"BackgroundTaskInfo",
|
||||
"BackgroundTaskStatus",
|
||||
"BaseAgent",
|
||||
"BaseChatClient",
|
||||
"BaseEmbeddingClient",
|
||||
"CachingSkillsSource",
|
||||
"Case",
|
||||
"CharacterEstimatorTokenizer",
|
||||
"ChatAndFunctionMiddlewareTypes",
|
||||
"ChatContext",
|
||||
"ChatMiddleware",
|
||||
"ChatMiddlewareLayer",
|
||||
"ChatMiddlewareTypes",
|
||||
"ChatOptions",
|
||||
"ChatResponse",
|
||||
"ChatResponseUpdate",
|
||||
"CheckResult",
|
||||
"CheckpointID",
|
||||
"CheckpointStorage",
|
||||
"ClassSkill",
|
||||
"CompactionProvider",
|
||||
"CompactionStrategy",
|
||||
"Content",
|
||||
"ContextProvider",
|
||||
"ContextWindowCompactionStrategy",
|
||||
"ContinuationToken",
|
||||
"ConversationSplit",
|
||||
"ConversationSplitter",
|
||||
"DeduplicatingSkillsSource",
|
||||
"Default",
|
||||
"DelegatingSkillsSource",
|
||||
"Edge",
|
||||
"EdgeCondition",
|
||||
"EdgeDuplicationError",
|
||||
"Embedding",
|
||||
"EmbeddingGenerationOptions",
|
||||
"EmbeddingInputT",
|
||||
"EmbeddingT",
|
||||
"EvalItem",
|
||||
"EvalItemResult",
|
||||
"EvalNotPassedError",
|
||||
"EvalResults",
|
||||
"EvalScoreResult",
|
||||
"Evaluator",
|
||||
"Executor",
|
||||
"ExpectedToolCall",
|
||||
"ExperimentalFeature",
|
||||
"FanInEdgeGroup",
|
||||
"FanOutEdgeGroup",
|
||||
"FileAccessProvider",
|
||||
"FileCheckpointStorage",
|
||||
"FileHistoryProvider",
|
||||
"FileMemoryProvider",
|
||||
"FileSearchMatch",
|
||||
"FileSearchResult",
|
||||
"FileSkill",
|
||||
"FileSkillScript",
|
||||
"FileSkillsSource",
|
||||
"FileStoreEntry",
|
||||
"FileSystemAgentFileStore",
|
||||
"FilteringSkillsSource",
|
||||
"FinalT",
|
||||
"FinishReason",
|
||||
"FinishReasonLiteral",
|
||||
"FunctionExecutor",
|
||||
"FunctionInvocationConfiguration",
|
||||
"FunctionInvocationContext",
|
||||
"FunctionInvocationLayer",
|
||||
"FunctionMiddleware",
|
||||
"FunctionMiddlewareTypes",
|
||||
"FunctionTool",
|
||||
"FunctionalWorkflow",
|
||||
"FunctionalWorkflowAgent",
|
||||
"GeneratedEmbeddings",
|
||||
"GraphConnectivityError",
|
||||
"HistoryProvider",
|
||||
"InMemoryAgentFileStore",
|
||||
"InMemoryCheckpointStorage",
|
||||
"InMemoryHistoryProvider",
|
||||
"InMemorySkillsSource",
|
||||
"InProcRunnerContext",
|
||||
"InlineSkill",
|
||||
"InlineSkillResource",
|
||||
"InlineSkillScript",
|
||||
"JudgeVerdict",
|
||||
"LocalEvaluator",
|
||||
"MCPSkill",
|
||||
"MCPSkillResource",
|
||||
"MCPSkillsSource",
|
||||
"MCPStdioTool",
|
||||
"MCPStreamableHTTPTool",
|
||||
"MCPTaskOptions",
|
||||
"MCPWebsocketTool",
|
||||
"MemoryContextProvider",
|
||||
"MemoryFileStore",
|
||||
"MemoryIndexEntry",
|
||||
"MemoryStore",
|
||||
"MemoryTopicRecord",
|
||||
"Message",
|
||||
"MessageInjectionMiddleware",
|
||||
"MiddlewareException",
|
||||
"MiddlewareTermination",
|
||||
"MiddlewareType",
|
||||
"MiddlewareTypes",
|
||||
"OuterFinalT",
|
||||
"OuterUpdateT",
|
||||
"RawAgent",
|
||||
"ReleaseCandidateFeature",
|
||||
"ResponseStream",
|
||||
"Role",
|
||||
"RoleLiteral",
|
||||
"RubricScore",
|
||||
"RunContext",
|
||||
"Runner",
|
||||
"RunnerContext",
|
||||
"SamplingApprovalCallback",
|
||||
"SecretString",
|
||||
"SelectiveToolCallCompactionStrategy",
|
||||
"ServiceSessionId",
|
||||
"SessionContext",
|
||||
"SingleEdgeGroup",
|
||||
"Skill",
|
||||
"SkillFrontmatter",
|
||||
"SkillResource",
|
||||
"SkillScript",
|
||||
"SkillScriptArgumentParser",
|
||||
"SkillScriptRunner",
|
||||
"SkillsProvider",
|
||||
"SkillsSource",
|
||||
"SkillsSourceContext",
|
||||
"SlidingWindowStrategy",
|
||||
"StepWrapper",
|
||||
"SubWorkflowRequestMessage",
|
||||
"SubWorkflowResponseMessage",
|
||||
"SummarizationStrategy",
|
||||
"SupportsAgentRun",
|
||||
"SupportsChatGetResponse",
|
||||
"SupportsCodeInterpreterTool",
|
||||
"SupportsFileSearchTool",
|
||||
"SupportsGetEmbeddings",
|
||||
"SupportsImageGenerationTool",
|
||||
"SupportsMCPTool",
|
||||
"SupportsShellTool",
|
||||
"SupportsWebSearchTool",
|
||||
"SwitchCaseEdgeGroup",
|
||||
"SwitchCaseEdgeGroupCase",
|
||||
"SwitchCaseEdgeGroupDefault",
|
||||
"TextSpanRegion",
|
||||
"TodoFileStore",
|
||||
"TodoInput",
|
||||
"TodoItem",
|
||||
"TodoProvider",
|
||||
"TodoSessionStore",
|
||||
"TodoStore",
|
||||
"TokenBudgetComposedStrategy",
|
||||
"TokenizerProtocol",
|
||||
"ToolApprovalMiddleware",
|
||||
"ToolApprovalRule",
|
||||
"ToolApprovalRuleCallback",
|
||||
"ToolApprovalState",
|
||||
"ToolMode",
|
||||
"ToolResultCompactionStrategy",
|
||||
"ToolTypes",
|
||||
"TruncationStrategy",
|
||||
"TypeCompatibilityError",
|
||||
"UpdateT",
|
||||
"UsageDetails",
|
||||
"UserInputRequiredException",
|
||||
"ValidationTypeEnum",
|
||||
"Workflow",
|
||||
"WorkflowAgent",
|
||||
"WorkflowBuilder",
|
||||
"WorkflowCheckpoint",
|
||||
"WorkflowCheckpointException",
|
||||
"WorkflowContext",
|
||||
"WorkflowConvergenceException",
|
||||
"WorkflowErrorDetails",
|
||||
"WorkflowEvent",
|
||||
"WorkflowEventSource",
|
||||
"WorkflowEventType",
|
||||
"WorkflowException",
|
||||
"WorkflowExecutor",
|
||||
"WorkflowMessage",
|
||||
"WorkflowRunResult",
|
||||
"WorkflowRunState",
|
||||
"WorkflowRunnerException",
|
||||
"WorkflowValidationError",
|
||||
"WorkflowViz",
|
||||
"__version__",
|
||||
"add_usage_details",
|
||||
"agent_middleware",
|
||||
"annotate_message_groups",
|
||||
"apply_compaction",
|
||||
"background_tasks_running",
|
||||
"background_tasks_running_message",
|
||||
"chat_middleware",
|
||||
"create_always_approve_tool_response",
|
||||
"create_always_approve_tool_with_arguments_response",
|
||||
"create_edge_runner",
|
||||
"create_harness_agent",
|
||||
"detect_media_type_from_base64",
|
||||
"enqueue_messages",
|
||||
"evaluate_agent",
|
||||
"evaluate_workflow",
|
||||
"evaluator",
|
||||
"executor",
|
||||
"function_middleware",
|
||||
"get_agent_mode",
|
||||
"get_run_context",
|
||||
"handler",
|
||||
"included_messages",
|
||||
"included_token_count",
|
||||
"keyword_check",
|
||||
"load_settings",
|
||||
"map_chat_to_agent_update",
|
||||
"merge_chat_options",
|
||||
"normalize_function_invocation_configuration",
|
||||
"normalize_messages",
|
||||
"normalize_tools",
|
||||
"prepend_agent_framework_to_user_agent",
|
||||
"prepend_instructions_to_messages",
|
||||
"register_state_type",
|
||||
"resolve_agent_id",
|
||||
"response_handler",
|
||||
"set_agent_mode",
|
||||
"step",
|
||||
"todos_remaining",
|
||||
"todos_remaining_message",
|
||||
"tool",
|
||||
"tool_call_args_match",
|
||||
"tool_called_check",
|
||||
"tool_calls_present",
|
||||
"validate_chat_options",
|
||||
"validate_tool_mode",
|
||||
"validate_tools",
|
||||
"validate_workflow_graph",
|
||||
"workflow",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import textwrap
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
_GOOGLE_SECTION_HEADERS = (
|
||||
"Args:",
|
||||
"Keyword Args:",
|
||||
"Attributes:",
|
||||
"Returns:",
|
||||
"Raises:",
|
||||
"Examples:",
|
||||
"Note:",
|
||||
"Notes:",
|
||||
"Warning:",
|
||||
"Warnings:",
|
||||
)
|
||||
|
||||
|
||||
def _find_section_index(lines: list[str], header: str) -> int | None:
|
||||
for index, line in enumerate(lines):
|
||||
if line == header:
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
def _find_next_section_index(lines: list[str], start: int) -> int:
|
||||
for index in range(start, len(lines)):
|
||||
if lines[index] in _GOOGLE_SECTION_HEADERS:
|
||||
return index
|
||||
return len(lines)
|
||||
|
||||
|
||||
def _format_keyword_arg_lines(extra_keyword_args: Mapping[str, str]) -> list[str]:
|
||||
formatted_lines: list[str] = []
|
||||
for name, description in extra_keyword_args.items():
|
||||
description_lines = inspect.cleandoc(description).splitlines()
|
||||
if not description_lines:
|
||||
formatted_lines.append(f" {name}:")
|
||||
continue
|
||||
formatted_lines.append(f" {name}: {description_lines[0]}")
|
||||
formatted_lines.extend(f" {line}" for line in description_lines[1:])
|
||||
return formatted_lines
|
||||
|
||||
|
||||
def insert_docstring_block(docstring: str | None, *, block: str) -> str | None:
|
||||
"""Insert a preformatted block before the first Google-style section."""
|
||||
cleaned_block = textwrap.dedent(block).strip()
|
||||
if not cleaned_block:
|
||||
return docstring
|
||||
if not docstring:
|
||||
return cleaned_block
|
||||
|
||||
lines = inspect.cleandoc(docstring).splitlines()
|
||||
block_lines = cleaned_block.splitlines()
|
||||
insert_index = _find_next_section_index(lines, 0)
|
||||
|
||||
insertion: list[str] = []
|
||||
if insert_index > 0 and lines[insert_index - 1] != "":
|
||||
insertion.append("")
|
||||
insertion.extend(block_lines)
|
||||
if insert_index < len(lines) and insertion[-1] != "":
|
||||
insertion.append("")
|
||||
|
||||
lines[insert_index:insert_index] = insertion
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
|
||||
def build_layered_docstring(
|
||||
source: Callable[..., Any],
|
||||
*,
|
||||
extra_keyword_args: Mapping[str, str] | None = None,
|
||||
) -> str | None:
|
||||
"""Build a Google-style docstring from a lower-layer implementation."""
|
||||
docstring = inspect.getdoc(source)
|
||||
if not docstring:
|
||||
return None
|
||||
if not extra_keyword_args:
|
||||
return docstring
|
||||
|
||||
lines = docstring.splitlines()
|
||||
formatted_keyword_arg_lines = _format_keyword_arg_lines(extra_keyword_args)
|
||||
keyword_args_index = _find_section_index(lines, "Keyword Args:")
|
||||
|
||||
if keyword_args_index is None:
|
||||
args_index = _find_section_index(lines, "Args:")
|
||||
if args_index is not None:
|
||||
insert_index = _find_next_section_index(lines, args_index + 1)
|
||||
else:
|
||||
insert_index = _find_next_section_index(lines, 0)
|
||||
lines[insert_index:insert_index] = ["", "Keyword Args:", *formatted_keyword_arg_lines]
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
insert_index = _find_next_section_index(lines, keyword_args_index + 1)
|
||||
lines[insert_index:insert_index] = formatted_keyword_arg_lines
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
|
||||
def apply_layered_docstring(
|
||||
target: Callable[..., Any],
|
||||
source: Callable[..., Any],
|
||||
*,
|
||||
extra_keyword_args: Mapping[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Copy a lower-layer docstring onto a wrapper and extend it when needed."""
|
||||
target.__doc__ = build_layered_docstring(source, extra_keyword_args=extra_keyword_args)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import asyncio.coroutines
|
||||
import contextlib
|
||||
import functools
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import typing
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from types import MethodType
|
||||
from typing import Any, Literal, TypeVar, cast
|
||||
|
||||
from ._docstrings import insert_docstring_block
|
||||
|
||||
FeatureStageT = TypeVar("FeatureStageT", bound=Callable[..., Any])
|
||||
|
||||
FeatureStageName = Literal["experimental", "release_candidate"]
|
||||
|
||||
# Optional feature-stage metadata for warnings and best-effort introspection.
|
||||
_FEATURE_ID_ATTR = "__feature_id__"
|
||||
_FEATURE_STAGE_ATTR = "__feature_stage__"
|
||||
_WARNED_FEATURES: set[tuple[type[Warning], str]] = set()
|
||||
_EXPERIMENTAL_DOCSTRING = """\
|
||||
.. warning:: Experimental
|
||||
|
||||
This API is experimental and subject to change or removal
|
||||
in future versions without notice.
|
||||
"""
|
||||
_RELEASE_CANDIDATE_DOCSTRING = """\
|
||||
.. note:: Release candidate
|
||||
|
||||
This API is in release-candidate stage and may receive
|
||||
minor refinements before it is considered generally available.
|
||||
"""
|
||||
|
||||
|
||||
class ExperimentalFeature(str, Enum):
|
||||
"""Current experimental feature IDs.
|
||||
|
||||
This enum is a stage-scoped inventory, not a stable introspection surface.
|
||||
Members may move or be removed as features advance. The `__feature_id__`
|
||||
attribute is also optional stage metadata and may disappear when a feature
|
||||
is released, so consumer code should use `getattr(...)` rather than relying
|
||||
on enum membership or attribute presence over time.
|
||||
"""
|
||||
|
||||
DECLARATIVE_AGENTS = "DECLARATIVE_AGENTS"
|
||||
EVALS = "EVALS"
|
||||
FILE_HISTORY = "FILE_HISTORY"
|
||||
FIDES = "FIDES"
|
||||
FOUNDRY_TOOLS = "FOUNDRY_TOOLS"
|
||||
FOUNDRY_PREVIEW_TOOLS = "FOUNDRY_PREVIEW_TOOLS"
|
||||
FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS"
|
||||
HARNESS = "HARNESS"
|
||||
MCP_LONG_RUNNING_TASKS = "MCP_LONG_RUNNING_TASKS"
|
||||
MCP_SKILLS = "MCP_SKILLS"
|
||||
PROGRESSIVE_TOOLS = "PROGRESSIVE_TOOLS"
|
||||
TO_PROMPT_AGENT = "TO_PROMPT_AGENT"
|
||||
|
||||
|
||||
class ReleaseCandidateFeature(str, Enum):
|
||||
"""Current release-candidate feature IDs.
|
||||
|
||||
This enum is a stage-scoped inventory, not a stable introspection surface.
|
||||
Members may move or be removed as features advance. The `__feature_id__`
|
||||
attribute is also optional stage metadata and may disappear when a feature
|
||||
is released, so consumer code should use `getattr(...)` rather than relying
|
||||
on enum membership or attribute presence over time.
|
||||
"""
|
||||
|
||||
|
||||
class FeatureStageWarning(FutureWarning):
|
||||
"""Base warning category for staged APIs."""
|
||||
|
||||
|
||||
class ExperimentalWarning(FeatureStageWarning):
|
||||
"""Warning emitted when an experimental API is used."""
|
||||
|
||||
|
||||
# Sentinel attribute used to detect (and reuse) a formatter we've already
|
||||
# installed. This lets the install be idempotent across re-imports / reloads
|
||||
# and keeps a stable reference to the previous formatter for testing or
|
||||
# external restoration via ``warnings.formatwarning = original``.
|
||||
_FEATURE_STAGE_FORMATTER_MARKER = "__feature_stage_formatter__"
|
||||
|
||||
|
||||
def _install_feature_stage_formatter() -> None:
|
||||
"""Install a single-line formatter for FeatureStageWarning categories.
|
||||
|
||||
The stdlib default formatter emits two lines (header + source snippet)
|
||||
which is noisy for our warnings — the offending class/function name is
|
||||
already in the message, so a one-line ``file:lineno: Category: message``
|
||||
is enough. Other warning categories are delegated to the previous
|
||||
formatter so we never change behaviour for unrelated warnings.
|
||||
|
||||
The install is idempotent: if a formatter installed by this module is
|
||||
already in place, we leave it alone so re-imports (and any third-party
|
||||
formatter wrapped on top of ours) don't get wrapped multiple times.
|
||||
"""
|
||||
current = warnings.formatwarning
|
||||
if getattr(current, _FEATURE_STAGE_FORMATTER_MARKER, False):
|
||||
return
|
||||
|
||||
def _formatwarning(
|
||||
message: Warning | str,
|
||||
category: type[Warning],
|
||||
filename: str,
|
||||
lineno: int,
|
||||
line: str | None = None,
|
||||
) -> str:
|
||||
if issubclass(category, FeatureStageWarning):
|
||||
return f"{filename}:{lineno}: {category.__name__}: {message}\n"
|
||||
return current(message, category, filename, lineno, line)
|
||||
|
||||
setattr(_formatwarning, _FEATURE_STAGE_FORMATTER_MARKER, True)
|
||||
# Keep a reference to the wrapped formatter so callers (tests, embedders)
|
||||
# can restore the previous behaviour if they need to.
|
||||
_formatwarning.__wrapped__ = current # type: ignore[attr-defined]
|
||||
warnings.formatwarning = _formatwarning
|
||||
|
||||
|
||||
_install_feature_stage_formatter()
|
||||
|
||||
|
||||
def _normalize_feature_id(feature_id: str | Enum) -> str:
|
||||
return str(feature_id.value if isinstance(feature_id, Enum) else feature_id)
|
||||
|
||||
|
||||
def _get_object_name(obj: Any) -> str:
|
||||
return str(getattr(obj, "__qualname__", getattr(obj, "__name__", type(obj).__name__)))
|
||||
|
||||
|
||||
def _get_descriptor_callable(obj: Any) -> Callable[..., Any]:
|
||||
return cast(Callable[..., Any], obj.__func__)
|
||||
|
||||
|
||||
def _is_protocol_class(obj: Any) -> bool:
|
||||
return isinstance(obj, type) and bool(getattr(obj, "_is_protocol", False))
|
||||
|
||||
|
||||
def _build_stage_warning_message(*, stage: FeatureStageName, feature_id: str, object_name: str) -> str:
|
||||
if stage == "experimental":
|
||||
return (
|
||||
f"[{feature_id}] {object_name} is experimental and may change or be removed in future versions "
|
||||
"without notice."
|
||||
)
|
||||
|
||||
return (
|
||||
f"[{feature_id}] {object_name} is in release-candidate stage and may receive minor refinements before it is "
|
||||
"considered generally available."
|
||||
)
|
||||
|
||||
|
||||
def _set_feature_stage_metadata(obj: Any, *, stage: FeatureStageName, feature_id: str) -> None:
|
||||
setattr(obj, _FEATURE_STAGE_ATTR, stage)
|
||||
setattr(obj, _FEATURE_ID_ATTR, feature_id)
|
||||
|
||||
|
||||
_INTERNAL_FRAME_FILE = os.path.normcase(__file__)
|
||||
# Module names whose frames we never want to surface as the caller. ``abc`` is
|
||||
# the big one (its ``__new__`` shows up as ``<frozen abc>:106`` for ABC-driven
|
||||
# subclass creation on modern CPython, so we cannot rely on filename matching).
|
||||
# ``functools``/``typing``/``contextlib`` are added because they often wrap our
|
||||
# decorators or appear in the metaclass call path.
|
||||
_INTERNAL_FRAME_MODULES: frozenset[str] = frozenset({
|
||||
abc.__name__,
|
||||
functools.__name__,
|
||||
typing.__name__,
|
||||
contextlib.__name__,
|
||||
})
|
||||
|
||||
|
||||
def _is_internal_frame(frame: Any) -> bool:
|
||||
if os.path.normcase(frame.f_code.co_filename) == _INTERNAL_FRAME_FILE:
|
||||
return True
|
||||
module_name = frame.f_globals.get("__name__", "")
|
||||
if module_name in _INTERNAL_FRAME_MODULES:
|
||||
return True
|
||||
# Submodules of the skipped stdlib packages (``typing.ext``, ``functools``
|
||||
# wrappers under ``concurrent.futures._base``, etc.) are also wrappers we
|
||||
# don't want to surface.
|
||||
return any(module_name.startswith(prefix + ".") for prefix in _INTERNAL_FRAME_MODULES)
|
||||
|
||||
|
||||
def _resolve_user_frame() -> tuple[str, int, str] | None:
|
||||
"""Resolve the user frame that triggered an experimental warning.
|
||||
|
||||
Walk the stack and return ``(filename, lineno, module_name)`` for the first
|
||||
frame outside this module and the wrapping/metaclass machinery.
|
||||
|
||||
Returns ``None`` if no such frame is found; callers fall back to plain
|
||||
``warnings.warn`` with a fixed stacklevel.
|
||||
"""
|
||||
# Frame objects participate in reference cycles (``frame -> f_locals ->
|
||||
# frame``) and can delay GC if held implicitly. Capture the user frame's
|
||||
# data into plain values inside the try, and explicitly delete the frame
|
||||
# references in finally so we never leak frames across this call. This
|
||||
# follows CPython's own guidance for code that uses ``inspect.currentframe``.
|
||||
frame = inspect.currentframe()
|
||||
candidate: Any = None
|
||||
try:
|
||||
if frame is None:
|
||||
return None
|
||||
# Skip _resolve_user_frame itself + the warn helper that called it.
|
||||
candidate = frame.f_back.f_back if frame.f_back and frame.f_back.f_back else None
|
||||
while candidate is not None:
|
||||
if not _is_internal_frame(candidate):
|
||||
return (
|
||||
candidate.f_code.co_filename,
|
||||
candidate.f_lineno,
|
||||
candidate.f_globals.get("__name__", "<unknown>"),
|
||||
)
|
||||
candidate = candidate.f_back
|
||||
return None
|
||||
finally:
|
||||
del frame, candidate
|
||||
|
||||
|
||||
def _warn_on_feature_use(
|
||||
*,
|
||||
stage: FeatureStageName,
|
||||
feature_id: str | Enum,
|
||||
object_name: str,
|
||||
category: type[Warning],
|
||||
) -> None:
|
||||
normalized_feature_id = _normalize_feature_id(feature_id)
|
||||
warning_key = (category, normalized_feature_id)
|
||||
if warning_key in _WARNED_FEATURES:
|
||||
return
|
||||
|
||||
message = _build_stage_warning_message(stage=stage, feature_id=normalized_feature_id, object_name=object_name)
|
||||
user_frame = _resolve_user_frame()
|
||||
if user_frame is None:
|
||||
# Last-resort fallback: emit at the immediate caller of this helper.
|
||||
warnings.warn(message, category=category, stacklevel=2)
|
||||
else:
|
||||
filename, lineno, module = user_frame
|
||||
warnings.warn_explicit(
|
||||
message,
|
||||
category=category,
|
||||
filename=filename,
|
||||
lineno=lineno,
|
||||
module=module,
|
||||
)
|
||||
_WARNED_FEATURES.add(warning_key)
|
||||
|
||||
|
||||
def _add_runtime_warning(
|
||||
obj: FeatureStageT,
|
||||
*,
|
||||
stage: FeatureStageName,
|
||||
feature_id: str,
|
||||
category: type[Warning],
|
||||
) -> FeatureStageT:
|
||||
object_name = _get_object_name(obj)
|
||||
|
||||
if isinstance(obj, type):
|
||||
experimental_class = cast(type[Any], obj)
|
||||
original_new: Any = experimental_class.__new__
|
||||
|
||||
@functools.wraps(original_new)
|
||||
def __new__(cls: type[Any], /, *args: Any, **kwargs: Any) -> Any:
|
||||
if cls is experimental_class:
|
||||
_warn_on_feature_use(
|
||||
stage=stage,
|
||||
feature_id=feature_id,
|
||||
object_name=object_name,
|
||||
category=category,
|
||||
)
|
||||
if original_new is not object.__new__:
|
||||
return original_new(cls, *args, **kwargs)
|
||||
if cls.__init__ is object.__init__ and (args or kwargs):
|
||||
raise TypeError(f"{cls.__name__}() takes no arguments")
|
||||
return original_new(cls)
|
||||
|
||||
experimental_class.__new__ = staticmethod(__new__)
|
||||
|
||||
original_init_subclass: Any = experimental_class.__init_subclass__
|
||||
if isinstance(original_init_subclass, MethodType):
|
||||
original_init_subclass_func = original_init_subclass.__func__
|
||||
|
||||
@functools.wraps(original_init_subclass_func)
|
||||
def bound_init_subclass_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
_warn_on_feature_use(
|
||||
stage=stage,
|
||||
feature_id=feature_id,
|
||||
object_name=object_name,
|
||||
category=category,
|
||||
)
|
||||
return original_init_subclass_func(*args, **kwargs)
|
||||
|
||||
experimental_class.__init_subclass__ = classmethod(bound_init_subclass_wrapper) # type: ignore[assignment]
|
||||
else:
|
||||
|
||||
@functools.wraps(original_init_subclass)
|
||||
def init_subclass_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
_warn_on_feature_use(
|
||||
stage=stage,
|
||||
feature_id=feature_id,
|
||||
object_name=object_name,
|
||||
category=category,
|
||||
)
|
||||
return original_init_subclass(*args, **kwargs)
|
||||
|
||||
experimental_class.__init_subclass__ = init_subclass_wrapper
|
||||
|
||||
return cast(FeatureStageT, experimental_class)
|
||||
|
||||
@functools.wraps(obj)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
_warn_on_feature_use(
|
||||
stage=stage,
|
||||
feature_id=feature_id,
|
||||
object_name=object_name,
|
||||
category=category,
|
||||
)
|
||||
return obj(*args, **kwargs)
|
||||
|
||||
if inspect.iscoroutinefunction(obj):
|
||||
if sys.version_info >= (3, 12):
|
||||
wrapper = inspect.markcoroutinefunction(wrapper)
|
||||
else:
|
||||
wrapper._is_coroutine = asyncio.coroutines._is_coroutine # type: ignore[attr-defined]
|
||||
|
||||
return cast(FeatureStageT, wrapper)
|
||||
|
||||
|
||||
def _feature_stage(
|
||||
*,
|
||||
stage: FeatureStageName,
|
||||
feature_id: str | Enum,
|
||||
docstring_block: str,
|
||||
warning_category: type[Warning] | None,
|
||||
) -> Callable[[FeatureStageT], FeatureStageT]:
|
||||
normalized_feature_id = _normalize_feature_id(feature_id)
|
||||
|
||||
def decorator(obj: FeatureStageT) -> FeatureStageT:
|
||||
descriptor_wrapper: Callable[[Any], Any] | None = None
|
||||
target: Any = obj
|
||||
|
||||
if isinstance(obj, staticmethod):
|
||||
descriptor_wrapper = staticmethod
|
||||
target = _get_descriptor_callable(obj)
|
||||
elif isinstance(obj, classmethod):
|
||||
descriptor_wrapper = classmethod
|
||||
target = _get_descriptor_callable(obj)
|
||||
|
||||
if not callable(target):
|
||||
raise TypeError(f"{stage} decorator can only be applied to classes and callables, not {obj!r}.")
|
||||
|
||||
is_protocol_class = _is_protocol_class(target)
|
||||
decorated: Any = target
|
||||
if warning_category is not None and not is_protocol_class:
|
||||
decorated = _add_runtime_warning(
|
||||
target,
|
||||
stage=stage,
|
||||
feature_id=normalized_feature_id,
|
||||
category=warning_category,
|
||||
)
|
||||
|
||||
updated_docstring = insert_docstring_block(decorated.__doc__, block=docstring_block)
|
||||
if updated_docstring is not None:
|
||||
decorated.__doc__ = updated_docstring
|
||||
|
||||
# runtime_checkable Protocol classes treat added class attributes as protocol members
|
||||
# on older Python versions, which breaks isinstance/issubclass checks.
|
||||
if not is_protocol_class:
|
||||
_set_feature_stage_metadata(decorated, stage=stage, feature_id=normalized_feature_id)
|
||||
if descriptor_wrapper is not None:
|
||||
return cast(FeatureStageT, descriptor_wrapper(decorated))
|
||||
|
||||
return cast(FeatureStageT, decorated)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def experimental(*, feature_id: ExperimentalFeature) -> Callable[[FeatureStageT], FeatureStageT]:
|
||||
"""Mark a class or callable as experimental."""
|
||||
return _feature_stage(
|
||||
stage="experimental",
|
||||
feature_id=feature_id,
|
||||
docstring_block=_EXPERIMENTAL_DOCSTRING,
|
||||
warning_category=ExperimentalWarning,
|
||||
)
|
||||
|
||||
|
||||
def release_candidate(
|
||||
*,
|
||||
feature_id: ReleaseCandidateFeature,
|
||||
) -> Callable[[FeatureStageT], FeatureStageT]:
|
||||
"""Mark a class or callable as release-candidate."""
|
||||
return _feature_stage(
|
||||
stage="release_candidate",
|
||||
feature_id=feature_id,
|
||||
docstring_block=_RELEASE_CANDIDATE_DOCSTRING,
|
||||
warning_category=None,
|
||||
)
|
||||
@@ -0,0 +1,609 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Harness agent factory: a pre-configured bundled agent with batteries included.
|
||||
|
||||
This module provides :func:`create_harness_agent`, a factory function that assembles
|
||||
the full agent pipeline from a chat client, wiring up function invocation,
|
||||
per-service-call history persistence, compaction, and a rich set of default
|
||||
context providers (todo, mode, memory, skills).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
|
||||
from .._agents import Agent, SupportsAgentRun
|
||||
from .._clients import SupportsShellTool, SupportsWebSearchTool
|
||||
from .._compaction import CompactionProvider, ContextWindowCompactionStrategy, ToolResultCompactionStrategy
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._sessions import ContextProvider, HistoryProvider, InMemoryHistoryProvider, MessageInjectionMiddleware
|
||||
from .._skills import SkillsProvider
|
||||
from .._types import ChatOptions
|
||||
from ._background_agents import BackgroundAgentsProvider
|
||||
from ._file_access import AgentFileStore, FileAccessProvider, FileSystemAgentFileStore
|
||||
from ._file_memory import FileMemoryProvider
|
||||
from ._loop import DEFAULT_MAX_ITERATIONS, AgentLoopMiddleware
|
||||
from ._mode import AgentModeProvider
|
||||
from ._todo import TodoProvider
|
||||
from ._tool_approval import ToolApprovalMiddleware
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import TypeVar # pragma: no cover
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
from agent_framework_tools.shell import ShellEnvironmentProviderOptions, ShellExecutor
|
||||
|
||||
from .._clients import SupportsChatGetResponse
|
||||
from .._compaction import CompactionStrategy, TokenizerProtocol
|
||||
from .._middleware import MiddlewareTypes
|
||||
from .._tools import ToolTypes
|
||||
from ._loop import NextMessageCallable, ShouldContinueCallable
|
||||
from ._tool_approval import ToolApprovalRuleCallback
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_HARNESS_INSTRUCTIONS = """\
|
||||
You are a helpful AI assistant that uses tools to complete tasks.
|
||||
|
||||
## General guidelines
|
||||
|
||||
- Think through the task before acting. Break complex work into clear steps.
|
||||
- Use the tools available to you to gather information, perform actions, and verify results.
|
||||
- Explain your reasoning and thought process as you work through tasks.
|
||||
- Explain what you learned and what you are going to do next between tool calls, \
|
||||
so the user can follow along with your thought process.
|
||||
- Avoid making more than 4 tool calls in a row without explaining what you are doing.
|
||||
- If a tool call fails or returns unexpected results, adapt your approach rather than \
|
||||
repeating the same call.
|
||||
- When you have completed the task, present a clear and concise summary of what you did \
|
||||
and what you found.
|
||||
"""
|
||||
|
||||
|
||||
def _assemble_instructions(
|
||||
harness_instructions: str | None,
|
||||
agent_instructions: str | None,
|
||||
) -> str | None:
|
||||
"""Assemble final instructions from harness + agent instructions."""
|
||||
harness = harness_instructions if harness_instructions is not None else DEFAULT_HARNESS_INSTRUCTIONS
|
||||
|
||||
return f"{harness}\n\n{agent_instructions or ''}".strip() or None
|
||||
|
||||
|
||||
def _assemble_compaction_provider(
|
||||
*,
|
||||
disable_compaction: bool,
|
||||
max_context_window_tokens: int | None,
|
||||
max_output_tokens: int | None,
|
||||
history_source_id: str,
|
||||
before_compaction_strategy: CompactionStrategy | None,
|
||||
after_compaction_strategy: CompactionStrategy | None,
|
||||
tokenizer: TokenizerProtocol | None,
|
||||
) -> CompactionProvider | None:
|
||||
"""Build the compaction provider from parameters or defaults.
|
||||
|
||||
The token-budget defaults (``ContextWindowCompactionStrategy`` for the before phase and
|
||||
``ToolResultCompactionStrategy`` for the after phase) are only applied when the token
|
||||
params are provided. Caller-supplied strategies are always honored. Either phase may end
|
||||
up ``None``, which ``CompactionProvider`` interprets as "skip that phase".
|
||||
|
||||
Returns None when compaction is explicitly disabled, or when neither phase has a strategy
|
||||
(no custom strategies and no token budget to build the defaults).
|
||||
"""
|
||||
if disable_compaction:
|
||||
return None
|
||||
|
||||
# Resolve the before-strategy: custom strategy wins; otherwise fall back to the
|
||||
# token-budget-aware default when token params are available.
|
||||
before_strategy = before_compaction_strategy
|
||||
if before_strategy is None and max_context_window_tokens is not None and max_output_tokens is not None:
|
||||
before_strategy = ContextWindowCompactionStrategy(
|
||||
max_context_window_tokens=max_context_window_tokens,
|
||||
max_output_tokens=max_output_tokens,
|
||||
tokenizer=tokenizer,
|
||||
)
|
||||
|
||||
# Resolve the after-strategy: custom strategy wins; otherwise fall back to the default
|
||||
# when token params are available.
|
||||
after_strategy = after_compaction_strategy
|
||||
if after_strategy is None and max_context_window_tokens is not None and max_output_tokens is not None:
|
||||
after_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=2)
|
||||
|
||||
# Nothing to compact in either phase: skip the provider entirely.
|
||||
if before_strategy is None and after_strategy is None:
|
||||
return None
|
||||
|
||||
return CompactionProvider(
|
||||
before_strategy=before_strategy,
|
||||
after_strategy=after_strategy,
|
||||
tokenizer=tokenizer,
|
||||
history_source_id=history_source_id,
|
||||
)
|
||||
|
||||
|
||||
def _assemble_context_providers(
|
||||
*,
|
||||
history_provider: HistoryProvider,
|
||||
compaction_provider: CompactionProvider | None,
|
||||
disable_todo: bool,
|
||||
todo_provider: TodoProvider | None,
|
||||
disable_mode: bool,
|
||||
mode_provider: AgentModeProvider | None,
|
||||
disable_file_memory: bool,
|
||||
file_memory_store: AgentFileStore | None,
|
||||
disable_file_access: bool,
|
||||
file_access_store: AgentFileStore | None,
|
||||
file_access_disable_write_tools: bool,
|
||||
file_access_disable_readonly_tool_approval: bool,
|
||||
file_access_disable_write_tool_approval: bool,
|
||||
skills_provider: SkillsProvider | None,
|
||||
skills_paths: str | Path | Sequence[str | Path] | None,
|
||||
background_agents: Sequence[SupportsAgentRun] | None,
|
||||
background_agents_instructions: str | None,
|
||||
shell_context_provider: ContextProvider | None,
|
||||
extra_context_providers: Sequence[ContextProvider] | None,
|
||||
) -> list[ContextProvider]:
|
||||
"""Assemble the ordered list of context providers."""
|
||||
providers: list[ContextProvider] = []
|
||||
|
||||
# History first so other providers can access loaded messages.
|
||||
providers.append(history_provider)
|
||||
|
||||
# Compaction runs after history loads messages.
|
||||
if compaction_provider is not None:
|
||||
providers.append(compaction_provider)
|
||||
|
||||
if not disable_todo:
|
||||
providers.append(todo_provider or TodoProvider())
|
||||
|
||||
if not disable_mode:
|
||||
providers.append(mode_provider or AgentModeProvider())
|
||||
|
||||
# File-based session memory (on by default). Default store is rooted at
|
||||
# ``{cwd}/agent-file-memory``; the provider isolates memories per session
|
||||
# via its default ``scope=session_id``.
|
||||
if not disable_file_memory:
|
||||
memory_store = file_memory_store or FileSystemAgentFileStore(Path.cwd() / "agent-file-memory")
|
||||
providers.append(FileMemoryProvider(memory_store))
|
||||
|
||||
# Shared file access (on by default). Default store is rooted at ``{cwd}/working``.
|
||||
if not disable_file_access:
|
||||
access_store = file_access_store or FileSystemAgentFileStore(Path.cwd() / "working")
|
||||
providers.append(
|
||||
FileAccessProvider(
|
||||
access_store,
|
||||
disable_write_tools=file_access_disable_write_tools,
|
||||
disable_readonly_tool_approval=file_access_disable_readonly_tool_approval,
|
||||
disable_write_tool_approval=file_access_disable_write_tool_approval,
|
||||
)
|
||||
)
|
||||
|
||||
# Skills are opt-in: only added when skills_provider or skills_paths is provided.
|
||||
if skills_provider:
|
||||
providers.append(skills_provider)
|
||||
if skills_paths:
|
||||
providers.append(SkillsProvider.from_paths(skills_paths))
|
||||
|
||||
# Background agents are opt-in: only added when agents are provided.
|
||||
if background_agents:
|
||||
providers.append(BackgroundAgentsProvider(background_agents, instructions=background_agents_instructions))
|
||||
|
||||
# Shell environment provider is opt-in: only added when a shell tool was wired.
|
||||
if shell_context_provider is not None:
|
||||
providers.append(shell_context_provider)
|
||||
|
||||
# Append any user-supplied additional providers.
|
||||
if extra_context_providers:
|
||||
providers.extend(extra_context_providers)
|
||||
|
||||
return providers
|
||||
|
||||
|
||||
def _assemble_shell(
|
||||
client: SupportsChatGetResponse[Any],
|
||||
shell_executor: ShellExecutor | None,
|
||||
shell_environment_provider_options: ShellEnvironmentProviderOptions | None,
|
||||
) -> tuple[ToolTypes | None, ContextProvider | None]:
|
||||
"""Build the shell tool and environment provider when a shell executor is supplied.
|
||||
|
||||
Returns a ``(tool, provider)`` tuple. Both are ``None`` when no shell executor is
|
||||
provided, or when the client does not support shell tools (a warning is logged in the
|
||||
latter case, since the environment provider is not useful without an execution path).
|
||||
|
||||
Raises:
|
||||
TypeError: If ``shell_executor`` does not expose a callable ``as_function()`` method.
|
||||
"""
|
||||
if shell_executor is None:
|
||||
return None, None
|
||||
|
||||
# ShellExecutor is a protocol without ``as_function()``, so the
|
||||
# contract is validated at runtime: a shell tool such as LocalShellTool/DockerShellTool exposes it.
|
||||
as_function = getattr(shell_executor, "as_function", None)
|
||||
if not callable(as_function):
|
||||
raise TypeError(
|
||||
f"shell_executor must expose a callable 'as_function()' method "
|
||||
f"(e.g. a LocalShellTool or DockerShellTool from agent-framework-tools), "
|
||||
f"but got {type(shell_executor).__name__}."
|
||||
)
|
||||
|
||||
if not isinstance(client, SupportsShellTool):
|
||||
logger.warning(
|
||||
"Shell tool not available: client %r does not implement SupportsShellTool. "
|
||||
"Skipping the shell tool and environment provider.",
|
||||
type(client).__name__,
|
||||
)
|
||||
return None, None
|
||||
|
||||
# Imported lazily: the shell types live in the separate agent-framework-tools package,
|
||||
# which depends on core, so core cannot import them at module load time.
|
||||
from agent_framework_tools.shell import ShellEnvironmentProvider
|
||||
|
||||
shell_tool = client.get_shell_tool(func=as_function())
|
||||
shell_provider = ShellEnvironmentProvider(shell_executor, shell_environment_provider_options)
|
||||
return shell_tool, shell_provider
|
||||
|
||||
|
||||
HARNESS_AGENT_PROVIDER_NAME = "microsoft.agent_framework.harness"
|
||||
|
||||
OptionsCoT = TypeVar(
|
||||
"OptionsCoT",
|
||||
bound=TypedDict, # type: ignore[valid-type]
|
||||
default="ChatOptions[None]",
|
||||
)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
def create_harness_agent(
|
||||
client: SupportsChatGetResponse[OptionsCoT],
|
||||
*,
|
||||
id: str | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
harness_instructions: str | None = None,
|
||||
agent_instructions: str | None = None,
|
||||
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
|
||||
max_context_window_tokens: int | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
history_provider: HistoryProvider | None = None,
|
||||
disable_compaction: bool = False,
|
||||
before_compaction_strategy: CompactionStrategy | None = None,
|
||||
after_compaction_strategy: CompactionStrategy | None = None,
|
||||
tokenizer: TokenizerProtocol | None = None,
|
||||
disable_todo: bool = False,
|
||||
todo_provider: TodoProvider | None = None,
|
||||
disable_mode: bool = False,
|
||||
mode_provider: AgentModeProvider | None = None,
|
||||
disable_file_memory: bool = False,
|
||||
file_memory_store: AgentFileStore | None = None,
|
||||
disable_file_access: bool = False,
|
||||
file_access_store: AgentFileStore | None = None,
|
||||
file_access_disable_write_tools: bool = False,
|
||||
file_access_disable_readonly_tool_approval: bool = False,
|
||||
file_access_disable_write_tool_approval: bool = False,
|
||||
skills_provider: SkillsProvider | None = None,
|
||||
skills_paths: str | Path | Sequence[str | Path] | None = None,
|
||||
background_agents: Sequence[SupportsAgentRun] | None = None,
|
||||
background_agents_instructions: str | None = None,
|
||||
shell_executor: ShellExecutor | None = None,
|
||||
shell_environment_provider_options: ShellEnvironmentProviderOptions | None = None,
|
||||
disable_web_search: bool = False,
|
||||
disable_tool_auto_approval: bool = False,
|
||||
auto_approval_rules: Sequence[ToolApprovalRuleCallback] | None = None,
|
||||
loop_should_continue: ShouldContinueCallable | None = None,
|
||||
loop_next_message: NextMessageCallable | None = None,
|
||||
loop_max_iterations: int | None = DEFAULT_MAX_ITERATIONS,
|
||||
otel_provider_name: str | None = None,
|
||||
context_providers: Sequence[ContextProvider] | None = None,
|
||||
middleware: Sequence[MiddlewareTypes] | None = None,
|
||||
default_options: Mapping[str, Any] | None = None,
|
||||
) -> Agent[OptionsCoT]:
|
||||
"""Create a pre-configured agent with batteries included.
|
||||
|
||||
Assembles an :class:`~agent_framework.Agent` from a chat client, automatically wiring:
|
||||
|
||||
- **Function invocation** — automatic tool calling loop
|
||||
- **Per-service-call history persistence** — persists history after every model call
|
||||
- **Compaction** — context-window compaction before/after each run
|
||||
- **TodoProvider** — todo list management
|
||||
- **AgentModeProvider** — plan/execute mode tracking
|
||||
- **FileMemoryProvider** — file-based session memory (on by default)
|
||||
- **FileAccessProvider** — shared file read/write tools (on by default)
|
||||
- **SkillsProvider** — skill discovery and progressive loading
|
||||
- **BackgroundAgentsProvider** — delegate work to background sub-agents
|
||||
- **Tool approval** — "don't ask again" standing approval rules plus heuristic
|
||||
auto-approval callbacks
|
||||
- **Looping** — re-run the agent until a ``should_continue`` predicate is satisfied
|
||||
- **OpenTelemetry** — observability via ``AgentTelemetryLayer``
|
||||
|
||||
Each feature can be disabled or customized via keyword arguments.
|
||||
|
||||
Examples:
|
||||
Basic usage:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import create_harness_agent
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
agent = create_harness_agent(
|
||||
OpenAIChatClient(model="gpt-4o"),
|
||||
)
|
||||
session = agent.create_session()
|
||||
response = await agent.run("Plan a weekend trip to Seattle", session=session)
|
||||
|
||||
With customization:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
agent = create_harness_agent(
|
||||
client=client,
|
||||
max_context_window_tokens=200_000,
|
||||
max_output_tokens=32_000,
|
||||
name="research-agent",
|
||||
agent_instructions="Focus on academic sources.",
|
||||
disable_todo=True,
|
||||
skills_paths=["./skills", "./custom-skills"],
|
||||
)
|
||||
|
||||
Args:
|
||||
client: The chat client providing access to the underlying AI model.
|
||||
|
||||
Keyword Args:
|
||||
id: Optional agent ID (auto-generated UUID if omitted).
|
||||
name: Optional agent name.
|
||||
description: Optional agent description.
|
||||
harness_instructions: Override the default harness-level system instructions that
|
||||
govern agent behavior (how to use tools, report progress, structure responses).
|
||||
These provide general "operating guidelines" independent of any specific task.
|
||||
When None, ``DEFAULT_HARNESS_INSTRUCTIONS`` is used. Set to empty string ``""``
|
||||
to omit harness instructions entirely.
|
||||
agent_instructions: Domain or task-specific instructions appended after harness
|
||||
instructions. Use this for the agent's purpose, persona, or specialization
|
||||
(e.g., "You are a research assistant focused on academic sources.").
|
||||
tools: Additional tools to include in the agent's toolset.
|
||||
max_context_window_tokens: Maximum tokens the model's context window supports.
|
||||
Used to construct the default token-budget-aware compaction strategies. When None
|
||||
(default) and no custom ``before_compaction_strategy`` / ``after_compaction_strategy``
|
||||
is provided, compaction is automatically disabled.
|
||||
max_output_tokens: Maximum output tokens per response.
|
||||
Used to construct the default compaction strategies and sets a default max_tokens
|
||||
chat option. When None (default), no default max_tokens option is set, and unless a
|
||||
custom compaction strategy is provided, compaction is automatically disabled.
|
||||
history_provider: Custom history provider. When None, an InMemoryHistoryProvider is used.
|
||||
disable_compaction: When True, skip compaction provider setup.
|
||||
before_compaction_strategy: Custom before-run compaction strategy. When provided,
|
||||
compaction runs even if token params are omitted. Defaults to
|
||||
ContextWindowCompactionStrategy (token-budget aware) when token params are provided.
|
||||
after_compaction_strategy: Custom after-run compaction strategy. When provided,
|
||||
compaction runs even if token params are omitted. Defaults to
|
||||
ToolResultCompactionStrategy when token params are provided.
|
||||
tokenizer: Custom tokenizer for compaction strategies.
|
||||
disable_todo: When True, skip the TodoProvider.
|
||||
todo_provider: Custom TodoProvider instance. Ignored when disable_todo is True.
|
||||
disable_mode: When True, skip the AgentModeProvider.
|
||||
mode_provider: Custom AgentModeProvider instance. Ignored when disable_mode is True.
|
||||
disable_file_memory: When True, skip the FileMemoryProvider. When False (default),
|
||||
a FileMemoryProvider is added, giving the agent session-scoped, file-based memory.
|
||||
file_memory_store: Custom AgentFileStore backing the FileMemoryProvider. When None
|
||||
(and disable_file_memory is False), a FileSystemAgentFileStore rooted at
|
||||
``{cwd}/agent-file-memory`` is created. Ignored when disable_file_memory is True.
|
||||
disable_file_access: When True, skip the FileAccessProvider. When False (default),
|
||||
a FileAccessProvider is added, giving the agent shared read/write file tools.
|
||||
file_access_store: Custom AgentFileStore backing the FileAccessProvider. When None
|
||||
(and disable_file_access is False), a FileSystemAgentFileStore rooted at
|
||||
``{cwd}/working`` is created. Ignored when disable_file_access is True.
|
||||
file_access_disable_write_tools: When True, the FileAccessProvider advertises only its
|
||||
read-only tools (read, ls, grep); the write tools (write, delete, replace,
|
||||
replace_lines) are hidden. When False (default), all tools are advertised. Ignored
|
||||
when disable_file_access is True.
|
||||
file_access_disable_readonly_tool_approval: When True, the FileAccessProvider's read-only
|
||||
tools (read, ls, grep) are registered with ``approval_mode="never_require"`` so they
|
||||
run without host approval. When False (default), they require approval. Ignored when
|
||||
disable_file_access is True.
|
||||
file_access_disable_write_tool_approval: When True, the FileAccessProvider's write tools
|
||||
(write, delete, replace, replace_lines) are registered with
|
||||
``approval_mode="never_require"`` so they run without host approval. When False
|
||||
(default), they require approval. Ignored when disable_file_access is True.
|
||||
skills_provider: Custom SkillsProvider instance for code-defined skills.
|
||||
Can be combined with ``skills_paths`` to aggregate file and code-based skills.
|
||||
**Security:** if the provider is configured with an external skill source (e.g.
|
||||
:class:`~agent_framework.MCPSkillsSource`), the skill content it loads is untrusted input
|
||||
— only enable sources you trust; see :class:`~agent_framework.SkillsSource`.
|
||||
skills_paths: Paths for file-based skill discovery (looks for SKILL.md files).
|
||||
Accepts a single ``str`` or :class:`~pathlib.Path`, or a sequence of
|
||||
``str | Path``. Can be combined with ``skills_provider``. When neither
|
||||
``skills_provider`` nor ``skills_paths`` is provided, no SkillsProvider
|
||||
is added.
|
||||
background_agents: Collection of agents available for background task delegation.
|
||||
When provided, a ``BackgroundAgentsProvider`` is automatically included,
|
||||
enabling the agent to start, monitor, and retrieve results from background tasks.
|
||||
Each agent must have a non-empty, unique name (case-insensitive).
|
||||
**Security:** supplied agents receive text input from this agent and their output is fed
|
||||
back into its context, so only supply agents you have vetted and trust — see
|
||||
:class:`~agent_framework.BackgroundAgentsProvider` for the exfiltration and
|
||||
prompt-injection risks of untrusted agents.
|
||||
background_agents_instructions: Optional instruction override for the
|
||||
``BackgroundAgentsProvider``. May include ``{background_agents}`` placeholder
|
||||
which will be replaced with the agent listing.
|
||||
shell_executor: Optional shell tool that enables shell command execution. When
|
||||
provided, the shell tool and a ``ShellEnvironmentProvider`` are automatically
|
||||
added (provided the client supports shell tools; otherwise a warning is logged
|
||||
and both are skipped). The object must expose ``as_function()`` and satisfy the
|
||||
``ShellExecutor`` protocol -- e.g. a ``LocalShellTool`` or ``DockerShellTool`` from
|
||||
the ``agent-framework-tools`` package. The caller owns the executor's lifecycle.
|
||||
shell_environment_provider_options: Optional ``ShellEnvironmentProviderOptions``
|
||||
(from ``agent-framework-tools``) used to customize the ``ShellEnvironmentProvider``
|
||||
environment probing and instructions. Only used when ``shell_executor`` is provided.
|
||||
disable_web_search: When True, skip automatic web search tool inclusion.
|
||||
When False (default), the web search tool is automatically added if the
|
||||
client implements SupportsWebSearchTool. A warning is logged if the client
|
||||
does not support web search.
|
||||
disable_tool_auto_approval: When True, do not wire the tool auto-approval middleware.
|
||||
When False (default), a :class:`~agent_framework.ToolApprovalMiddleware` is added
|
||||
(outermost) to coordinate "don't ask again" standing approval rules and queued
|
||||
approval prompts; callers must pass an :class:`~agent_framework.AgentSession` to
|
||||
:meth:`~agent_framework.Agent.run` when enabled.
|
||||
auto_approval_rules: Optional heuristic callbacks that can auto-approve a function call
|
||||
that would otherwise require approval. Each callback receives the ``function_call``
|
||||
content and returns ``True`` to approve it. Rules are evaluated after standing rules
|
||||
(derived from prior user approvals) but before prompting the user. Only used when
|
||||
``disable_tool_auto_approval`` is False.
|
||||
loop_should_continue: Optional predicate that enables the looping middleware. When provided, the
|
||||
agent is re-run in a loop (via :class:`~agent_framework.AgentLoopMiddleware`, wired as
|
||||
the outermost middleware so each iteration is a full agent run including tool approval)
|
||||
for as long as the predicate returns ``True``, up to ``loop_max_iterations``. If an
|
||||
iteration returns a pending tool-approval request, the loop stops and returns it so the
|
||||
caller can approve before continuing. When None (default), no loop is added.
|
||||
loop_next_message: Optional callable controlling the input for the next loop iteration.
|
||||
Only takes effect when ``loop_should_continue`` is set (otherwise no loop is added and
|
||||
this is ignored).
|
||||
loop_max_iterations: Safety cap on the number of loop iterations. ``None`` means unbounded;
|
||||
a positive integer caps the loop (defaults to the loop middleware's default cap). Only
|
||||
takes effect when ``loop_should_continue`` is set (otherwise no loop is added and this
|
||||
is ignored).
|
||||
otel_provider_name: Custom OpenTelemetry provider/source name for telemetry.
|
||||
context_providers: Additional context providers to include after the built-in ones.
|
||||
middleware: Additional middleware to include.
|
||||
default_options: Provider-specific chat options (temperature, max_tokens, etc.).
|
||||
|
||||
Returns:
|
||||
A fully configured :class:`~agent_framework.Agent` instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If max_context_window_tokens is provided and <= 0, or
|
||||
max_output_tokens is provided and <= 0, or max_output_tokens >=
|
||||
max_context_window_tokens when both are provided.
|
||||
"""
|
||||
if max_context_window_tokens is not None and max_context_window_tokens <= 0:
|
||||
raise ValueError("max_context_window_tokens must be positive.")
|
||||
if max_output_tokens is not None and max_output_tokens <= 0:
|
||||
raise ValueError("max_output_tokens must be positive.")
|
||||
if (
|
||||
max_context_window_tokens is not None
|
||||
and max_output_tokens is not None
|
||||
and max_output_tokens >= max_context_window_tokens
|
||||
):
|
||||
raise ValueError("max_output_tokens must be less than max_context_window_tokens.")
|
||||
|
||||
# Build history provider.
|
||||
resolved_history = history_provider or InMemoryHistoryProvider()
|
||||
|
||||
# Build compaction provider.
|
||||
compaction_provider = _assemble_compaction_provider(
|
||||
disable_compaction=disable_compaction,
|
||||
max_context_window_tokens=max_context_window_tokens,
|
||||
max_output_tokens=max_output_tokens,
|
||||
history_source_id=resolved_history.source_id,
|
||||
before_compaction_strategy=before_compaction_strategy,
|
||||
after_compaction_strategy=after_compaction_strategy,
|
||||
tokenizer=tokenizer,
|
||||
)
|
||||
|
||||
# Build the shell tool and environment provider (opt-in via shell_executor).
|
||||
shell_tool, shell_provider = _assemble_shell(
|
||||
client,
|
||||
shell_executor,
|
||||
shell_environment_provider_options,
|
||||
)
|
||||
|
||||
# Build context providers.
|
||||
assembled_providers = _assemble_context_providers(
|
||||
history_provider=resolved_history,
|
||||
compaction_provider=compaction_provider,
|
||||
disable_todo=disable_todo,
|
||||
todo_provider=todo_provider,
|
||||
disable_mode=disable_mode,
|
||||
mode_provider=mode_provider,
|
||||
disable_file_memory=disable_file_memory,
|
||||
file_memory_store=file_memory_store,
|
||||
disable_file_access=disable_file_access,
|
||||
file_access_store=file_access_store,
|
||||
file_access_disable_write_tools=file_access_disable_write_tools,
|
||||
file_access_disable_readonly_tool_approval=file_access_disable_readonly_tool_approval,
|
||||
file_access_disable_write_tool_approval=file_access_disable_write_tool_approval,
|
||||
skills_provider=skills_provider,
|
||||
skills_paths=skills_paths,
|
||||
background_agents=background_agents,
|
||||
background_agents_instructions=background_agents_instructions,
|
||||
shell_context_provider=shell_provider,
|
||||
extra_context_providers=context_providers,
|
||||
)
|
||||
|
||||
# Build instructions.
|
||||
instructions = _assemble_instructions(harness_instructions, agent_instructions)
|
||||
|
||||
# Assemble tools, auto-adding web search if supported.
|
||||
assembled_tools: list[ToolTypes | Callable[..., Any]] = []
|
||||
if not disable_web_search:
|
||||
if isinstance(client, SupportsWebSearchTool):
|
||||
assembled_tools.append(client.get_web_search_tool())
|
||||
else:
|
||||
logger.warning(
|
||||
"Web search tool not available: client %r does not implement SupportsWebSearchTool. "
|
||||
"Set disable_web_search=True to suppress this warning.",
|
||||
type(client).__name__,
|
||||
)
|
||||
if shell_tool is not None:
|
||||
assembled_tools.append(shell_tool)
|
||||
if tools is not None:
|
||||
if isinstance(tools, Sequence):
|
||||
assembled_tools.extend(tools) # pyright: ignore[reportUnknownArgumentType]
|
||||
else:
|
||||
assembled_tools.append(tools)
|
||||
final_tools: list[ToolTypes | Callable[..., Any]] | None = assembled_tools or None
|
||||
|
||||
# Build default options dict.
|
||||
default_opts: dict[str, Any] = dict(default_options) if default_options else {}
|
||||
if max_output_tokens is not None:
|
||||
default_opts.setdefault("max_tokens", max_output_tokens)
|
||||
|
||||
# Assemble middleware. Tool approval is enabled by default (like the .NET harness) and is
|
||||
# placed first so it sits outermost: it intercepts inbound "always approve" responses and
|
||||
# outbound approval requests at the caller boundary, and its re-invocation loop re-runs any
|
||||
# user-supplied middleware. ToolApprovalMiddleware requires an AgentSession at run time.
|
||||
# When should_continue is supplied, the loop is prepended ahead of tool approval so it sits
|
||||
# outermost of all: each loop iteration is a full agent run (including tool approval), and the
|
||||
# loop's approval escape hatch returns any pending approval request to the caller.
|
||||
assembled_middleware: list[MiddlewareTypes] = []
|
||||
if not disable_tool_auto_approval:
|
||||
assembled_middleware.append(ToolApprovalMiddleware(auto_approval_rules=auto_approval_rules))
|
||||
if loop_should_continue is not None:
|
||||
assembled_middleware.insert(
|
||||
0,
|
||||
AgentLoopMiddleware(
|
||||
loop_should_continue,
|
||||
max_iterations=loop_max_iterations,
|
||||
next_message=loop_next_message,
|
||||
),
|
||||
)
|
||||
# Message injection is always on. It is a no-op when no messages are queued for the session,
|
||||
# so there is no opt-out.
|
||||
assembled_middleware.append(MessageInjectionMiddleware())
|
||||
if middleware:
|
||||
assembled_middleware.extend(middleware)
|
||||
|
||||
agent = Agent(
|
||||
client,
|
||||
instructions,
|
||||
id=id,
|
||||
name=name,
|
||||
description=description,
|
||||
tools=final_tools,
|
||||
default_options=default_opts, # type: ignore[arg-type]
|
||||
context_providers=assembled_providers,
|
||||
middleware=assembled_middleware or None,
|
||||
require_per_service_call_history_persistence=True,
|
||||
)
|
||||
|
||||
# Set the telemetry provider name after construction.
|
||||
agent.otel_provider_name = otel_provider_name or HARNESS_AGENT_PROVIDER_NAME
|
||||
|
||||
return agent
|
||||
@@ -0,0 +1,92 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from typing_extensions import TypedDict, TypeVar
|
||||
|
||||
from .._agents import Agent, SupportsAgentRun
|
||||
from .._clients import SupportsChatGetResponse
|
||||
from .._compaction import CompactionStrategy, TokenizerProtocol
|
||||
from .._middleware import MiddlewareTypes
|
||||
from .._sessions import ContextProvider, HistoryProvider
|
||||
from .._skills import SkillsProvider
|
||||
from .._tools import ToolTypes
|
||||
from .._types import ChatOptions
|
||||
from ._file_access import AgentFileStore
|
||||
from ._loop import DEFAULT_MAX_ITERATIONS, NextMessageCallable, ShouldContinueCallable
|
||||
from ._mode import AgentModeProvider
|
||||
from ._todo import TodoProvider
|
||||
from ._tool_approval import ToolApprovalRuleCallback
|
||||
|
||||
DEFAULT_HARNESS_INSTRUCTIONS: str
|
||||
HARNESS_AGENT_PROVIDER_NAME: str
|
||||
|
||||
OptionsCoT = TypeVar(
|
||||
"OptionsCoT",
|
||||
bound=TypedDict, # type: ignore[valid-type]
|
||||
default=ChatOptions[None],
|
||||
)
|
||||
|
||||
class _ShellExecutorLike(Protocol):
|
||||
def as_function(self, *args: Any, **kwargs: Any) -> Any: ...
|
||||
|
||||
class _ShellEnvironmentProviderOptionsLike(Protocol):
|
||||
@property
|
||||
def probe_tools(self) -> Sequence[str]: ...
|
||||
@property
|
||||
def override_family(self) -> Any | None: ...
|
||||
@property
|
||||
def probe_timeout(self) -> float: ...
|
||||
@property
|
||||
def instructions_formatter(self) -> Callable[[Any], str] | None: ...
|
||||
|
||||
def _assemble_instructions(
|
||||
harness_instructions: str | None,
|
||||
agent_instructions: str | None,
|
||||
) -> str | None: ...
|
||||
def create_harness_agent(
|
||||
client: SupportsChatGetResponse[OptionsCoT],
|
||||
*,
|
||||
id: str | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
harness_instructions: str | None = None,
|
||||
agent_instructions: str | None = None,
|
||||
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
|
||||
max_context_window_tokens: int | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
history_provider: HistoryProvider | None = None,
|
||||
disable_compaction: bool = False,
|
||||
before_compaction_strategy: CompactionStrategy | None = None,
|
||||
after_compaction_strategy: CompactionStrategy | None = None,
|
||||
tokenizer: TokenizerProtocol | None = None,
|
||||
disable_todo: bool = False,
|
||||
todo_provider: TodoProvider | None = None,
|
||||
disable_mode: bool = False,
|
||||
mode_provider: AgentModeProvider | None = None,
|
||||
disable_file_memory: bool = False,
|
||||
file_memory_store: AgentFileStore | None = None,
|
||||
disable_file_access: bool = False,
|
||||
file_access_store: AgentFileStore | None = None,
|
||||
file_access_disable_write_tools: bool = False,
|
||||
file_access_disable_readonly_tool_approval: bool = False,
|
||||
file_access_disable_write_tool_approval: bool = False,
|
||||
skills_provider: SkillsProvider | None = None,
|
||||
skills_paths: str | Path | Sequence[str | Path] | None = None,
|
||||
background_agents: Sequence[SupportsAgentRun] | None = None,
|
||||
background_agents_instructions: str | None = None,
|
||||
shell_executor: _ShellExecutorLike | None = None,
|
||||
shell_environment_provider_options: _ShellEnvironmentProviderOptionsLike | None = None,
|
||||
disable_web_search: bool = False,
|
||||
disable_tool_auto_approval: bool = False,
|
||||
auto_approval_rules: Sequence[ToolApprovalRuleCallback] | None = None,
|
||||
loop_should_continue: ShouldContinueCallable | None = None,
|
||||
loop_next_message: NextMessageCallable | None = None,
|
||||
loop_max_iterations: int | None = DEFAULT_MAX_ITERATIONS,
|
||||
otel_provider_name: str | None = None,
|
||||
context_providers: Sequence[ContextProvider] | None = None,
|
||||
middleware: Sequence[MiddlewareTypes] | None = None,
|
||||
default_options: Mapping[str, Any] | None = None,
|
||||
) -> Agent[OptionsCoT]: ...
|
||||
@@ -0,0 +1,534 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""BackgroundAgentsProvider: enables an agent to delegate work to background sub-agents asynchronously.
|
||||
|
||||
This module provides :class:`BackgroundAgentsProvider`, a context provider that allows
|
||||
a parent agent to start background tasks on child agents, wait for their completion,
|
||||
and retrieve results. Each background task runs in its own session concurrently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, MutableMapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, ClassVar, cast
|
||||
|
||||
from .._agents import SupportsAgentRun
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._serialization import SerializationMixin
|
||||
from .._sessions import AgentSession, ContextProvider, SessionContext
|
||||
from .._tools import tool
|
||||
from .._types import AgentResponse, Message
|
||||
|
||||
DEFAULT_BACKGROUND_AGENTS_SOURCE_ID = "background_agents"
|
||||
|
||||
DEFAULT_BACKGROUND_AGENTS_INSTRUCTIONS = """\
|
||||
## Background Agents
|
||||
|
||||
You have access to background agents that can perform work on your behalf.
|
||||
|
||||
- Use the `background_agents_*` tools to start tasks on background agents and check their results.
|
||||
- Creating a background task does not block, and background tasks run concurrently.
|
||||
- Important: Always wait for outstanding tasks to finish before you finish processing.
|
||||
- Important: After retrieving results from a completed task, clear it with \
|
||||
background_agents_clear_completed_task to free memory, unless you plan to continue it with \
|
||||
background_agents_continue_task.
|
||||
|
||||
{background_agents}"""
|
||||
|
||||
|
||||
class BackgroundTaskStatus(str, Enum):
|
||||
"""Status of a background task."""
|
||||
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
LOST = "lost"
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class BackgroundTaskInfo(SerializationMixin):
|
||||
"""Metadata for a single background task."""
|
||||
|
||||
DEFAULT_EXCLUDE: ClassVar[set[str]] = set()
|
||||
|
||||
id: int
|
||||
agent_name: str
|
||||
description: str
|
||||
status: BackgroundTaskStatus
|
||||
result_text: str | None
|
||||
error_text: str | None
|
||||
__slots__ = ("agent_name", "description", "error_text", "id", "result_text", "status")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: int,
|
||||
agent_name: str,
|
||||
description: str,
|
||||
status: BackgroundTaskStatus = BackgroundTaskStatus.RUNNING,
|
||||
result_text: str | None = None,
|
||||
error_text: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize a background task info entry."""
|
||||
self.id = id
|
||||
self.agent_name = agent_name
|
||||
self.description = description
|
||||
self.status = status
|
||||
self.result_text = result_text
|
||||
self.error_text = error_text
|
||||
|
||||
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
|
||||
"""Serialize for session state persistence."""
|
||||
del exclude
|
||||
data: dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"agent_name": self.agent_name,
|
||||
"description": self.description,
|
||||
"status": self.status.value,
|
||||
}
|
||||
if not exclude_none or self.result_text is not None:
|
||||
data["result_text"] = self.result_text
|
||||
if not exclude_none or self.error_text is not None:
|
||||
data["error_text"] = self.error_text
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: MutableMapping[str, Any], **kwargs: Any) -> BackgroundTaskInfo:
|
||||
"""Deserialize from session state."""
|
||||
return cls(
|
||||
id=data["id"],
|
||||
agent_name=data["agent_name"],
|
||||
description=data["description"],
|
||||
status=BackgroundTaskStatus(data["status"]),
|
||||
result_text=data.get("result_text"),
|
||||
error_text=data.get("error_text"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RuntimeState:
|
||||
"""Non-serializable per-session runtime state for background tasks."""
|
||||
|
||||
in_flight_tasks: dict[int, asyncio.Task[AgentResponse[Any]]] = field(default_factory=lambda: {})
|
||||
background_sessions: dict[int, AgentSession] = field(default_factory=lambda: {})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level helper functions (following ModeProvider pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _run_agent(awaitable: Awaitable[AgentResponse[Any]]) -> AgentResponse[Any]:
|
||||
"""Wrap an Awaitable in a proper coroutine for use with asyncio.create_task."""
|
||||
return await awaitable
|
||||
|
||||
|
||||
def _validate_and_build_agent_dict(agents: Sequence[SupportsAgentRun]) -> dict[str, SupportsAgentRun]:
|
||||
"""Validate agents and build a case-insensitive lookup dict.
|
||||
|
||||
Raises:
|
||||
ValueError: If agents is empty, an agent has no name, or names are not unique.
|
||||
"""
|
||||
if not agents:
|
||||
raise ValueError("At least one background agent must be provided.")
|
||||
|
||||
agent_dict: dict[str, SupportsAgentRun] = {}
|
||||
for agent in agents:
|
||||
name = agent.name
|
||||
if not name or not name.strip():
|
||||
raise ValueError("All background agents must have a non-empty name.")
|
||||
key = name.lower()
|
||||
if key in agent_dict:
|
||||
raise ValueError(
|
||||
f"Duplicate background agent name: '{name}'. Agent names must be unique (case-insensitive)."
|
||||
)
|
||||
agent_dict[key] = agent
|
||||
return agent_dict
|
||||
|
||||
|
||||
def _build_agent_list_text(agents: dict[str, SupportsAgentRun]) -> str:
|
||||
"""Build text listing available background agents."""
|
||||
lines = ["Available background agents:"]
|
||||
for agent in agents.values():
|
||||
line = f"- {agent.name}"
|
||||
if agent.description:
|
||||
line += f": {agent.description}"
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _get_provider_state(session: AgentSession, *, source_id: str) -> dict[str, Any]:
|
||||
"""Load or initialize serializable provider state from session."""
|
||||
state = session.state.get(source_id)
|
||||
if state is None:
|
||||
initial: dict[str, Any] = {"next_task_id": 1, "tasks": []}
|
||||
session.state[source_id] = initial
|
||||
return initial
|
||||
return cast(dict[str, Any], state)
|
||||
|
||||
|
||||
def _save_provider_state(session: AgentSession, state: dict[str, Any], *, source_id: str) -> None:
|
||||
"""Persist serializable state to session."""
|
||||
session.state[source_id] = state
|
||||
|
||||
|
||||
def _get_tasks(state: dict[str, Any]) -> list[BackgroundTaskInfo]:
|
||||
"""Parse task list from state dict."""
|
||||
return [BackgroundTaskInfo.from_dict(t) for t in state.get("tasks", [])]
|
||||
|
||||
|
||||
def _save_tasks(state: dict[str, Any], tasks: list[BackgroundTaskInfo]) -> None:
|
||||
"""Serialize task list back to state dict."""
|
||||
state["tasks"] = [t.to_dict() for t in tasks]
|
||||
|
||||
|
||||
def _finalize_task(
|
||||
task_info: BackgroundTaskInfo,
|
||||
completed_task: asyncio.Task[AgentResponse[Any]],
|
||||
runtime: _RuntimeState,
|
||||
) -> None:
|
||||
"""Extract results from a completed asyncio task and update task info."""
|
||||
if completed_task.cancelled():
|
||||
task_info.status = BackgroundTaskStatus.FAILED
|
||||
task_info.error_text = "Task was canceled."
|
||||
else:
|
||||
exception = completed_task.exception()
|
||||
if exception is not None:
|
||||
task_info.status = BackgroundTaskStatus.FAILED
|
||||
task_info.error_text = str(exception)
|
||||
else:
|
||||
task_info.status = BackgroundTaskStatus.COMPLETED
|
||||
task_info.result_text = completed_task.result().text
|
||||
runtime.in_flight_tasks.pop(task_info.id, None)
|
||||
|
||||
|
||||
def _refresh_task_state(
|
||||
session: AgentSession, state: dict[str, Any], runtime: _RuntimeState, *, source_id: str
|
||||
) -> list[BackgroundTaskInfo]:
|
||||
"""Refresh status of in-flight tasks and return updated task list."""
|
||||
tasks = _get_tasks(state)
|
||||
changed = False
|
||||
|
||||
for task_info in tasks:
|
||||
if task_info.status != BackgroundTaskStatus.RUNNING:
|
||||
continue
|
||||
|
||||
in_flight = runtime.in_flight_tasks.get(task_info.id)
|
||||
if in_flight is None:
|
||||
task_info.status = BackgroundTaskStatus.LOST
|
||||
changed = True
|
||||
continue
|
||||
|
||||
if in_flight.done():
|
||||
_finalize_task(task_info, in_flight, runtime)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
_save_tasks(state, tasks)
|
||||
_save_provider_state(session, state, source_id=source_id)
|
||||
|
||||
return tasks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class BackgroundAgentsProvider(ContextProvider):
|
||||
"""Context provider that enables an agent to delegate work to background sub-agents.
|
||||
|
||||
The ``BackgroundAgentsProvider`` allows a parent agent to start background tasks on child agents,
|
||||
wait for their completion, and retrieve results. Each background task runs in its own session and
|
||||
executes concurrently.
|
||||
|
||||
This provider exposes the following tools to the agent:
|
||||
|
||||
- ``background_agents_start_task`` — Start a background task on a named agent with text input.
|
||||
- ``background_agents_wait_for_first_completion`` — Block until the first of the specified tasks completes.
|
||||
- ``background_agents_get_task_results`` — Retrieve the text output of a completed background task.
|
||||
- ``background_agents_get_all_tasks`` — List all background tasks with their IDs, statuses, and descriptions.
|
||||
- ``background_agents_continue_task`` — Send follow-up input to a completed task's session to resume work.
|
||||
- ``background_agents_clear_completed_task`` — Remove a completed task and release its session.
|
||||
|
||||
Security considerations:
|
||||
The agents passed to the constructor are delegated arbitrary work by the parent agent — the
|
||||
parent sends them text input (which may include content derived from the parent's own
|
||||
untrusted context) and receives back whatever text they produce. A compromised or malicious
|
||||
supplied agent (for example, one with a compromised system prompt, tools, or upstream model)
|
||||
could exfiltrate that input to an external system, or return adversarial output designed to
|
||||
influence the parent agent via indirect prompt injection once its result is retrieved. Only
|
||||
supply background agents you have vetted and trust with the data the parent may pass to them.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agents: Sequence[SupportsAgentRun],
|
||||
*,
|
||||
source_id: str = DEFAULT_BACKGROUND_AGENTS_SOURCE_ID,
|
||||
instructions: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the background agents provider.
|
||||
|
||||
Args:
|
||||
agents: Collection of background agents available for delegation.
|
||||
Each agent must have a non-empty, unique name (case-insensitive).
|
||||
**Security:** each supplied agent should be vetted and trusted, since it will receive
|
||||
text input from the parent agent and its output is fed back into the parent's
|
||||
context — see the class-level security considerations for the exfiltration and
|
||||
prompt-injection risks of untrusted agents.
|
||||
|
||||
Keyword Args:
|
||||
source_id: Unique source ID for serializable task state in session.
|
||||
instructions: Optional instruction override. May include ``{background_agents}``
|
||||
placeholder which will be replaced with the agent listing.
|
||||
|
||||
Raises:
|
||||
ValueError: If agents is empty, an agent has no name, or names are not unique.
|
||||
"""
|
||||
super().__init__(source_id)
|
||||
|
||||
self._agents = _validate_and_build_agent_dict(agents)
|
||||
|
||||
# Build instructions with agent listing.
|
||||
base_instructions = instructions if instructions is not None else DEFAULT_BACKGROUND_AGENTS_INSTRUCTIONS
|
||||
agent_list_text = _build_agent_list_text(self._agents)
|
||||
self._instructions = base_instructions.replace("{background_agents}", agent_list_text)
|
||||
|
||||
# Per-session runtime state (non-serializable), keyed by session_id.
|
||||
# Note: Runtime state (in-flight asyncio.Task objects, child AgentSession handles)
|
||||
# is inherently non-serializable and cannot survive process restarts. If the provider
|
||||
# instance is lost, _refresh_task_state() marks orphaned tasks as LOST.
|
||||
self._runtime: dict[str, _RuntimeState] = {}
|
||||
|
||||
def _get_runtime(self, session: AgentSession) -> _RuntimeState:
|
||||
"""Get or create runtime state for a session."""
|
||||
session_id = session.session_id
|
||||
if session_id not in self._runtime:
|
||||
self._runtime[session_id] = _RuntimeState()
|
||||
return self._runtime[session_id]
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Inject background agent tools and instructions before the model runs."""
|
||||
del agent, state
|
||||
|
||||
provider_state = _get_provider_state(session, source_id=self.source_id)
|
||||
runtime = self._get_runtime(session)
|
||||
source_id = self.source_id
|
||||
|
||||
@tool(name="background_agents_start_task", approval_mode="never_require")
|
||||
def background_agents_start_task(agent_name: str, input: str, description: str) -> str:
|
||||
"""Start a background task on a named agent. Returns a confirmation with the task ID."""
|
||||
key = agent_name.lower()
|
||||
if key not in self._agents:
|
||||
available = ", ".join(a.name or "" for a in self._agents.values())
|
||||
return f"Error: No background agent found with name '{agent_name}'. Available agents: {available}"
|
||||
|
||||
bg_agent = self._agents[key]
|
||||
task_id = provider_state.get("next_task_id", 1)
|
||||
provider_state["next_task_id"] = task_id + 1
|
||||
|
||||
task_info = BackgroundTaskInfo(
|
||||
id=task_id,
|
||||
agent_name=agent_name,
|
||||
description=description,
|
||||
)
|
||||
tasks = _get_tasks(provider_state)
|
||||
tasks.append(task_info)
|
||||
_save_tasks(provider_state, tasks)
|
||||
|
||||
# Create a dedicated session for this background task.
|
||||
sub_session = bg_agent.create_session()
|
||||
|
||||
# Start the task concurrently.
|
||||
async_task = asyncio.create_task(_run_agent(bg_agent.run(input, session=sub_session)))
|
||||
runtime.in_flight_tasks[task_id] = async_task
|
||||
runtime.background_sessions[task_id] = sub_session
|
||||
|
||||
_save_provider_state(session, provider_state, source_id=source_id)
|
||||
return f"Background task {task_id} started on agent '{agent_name}'."
|
||||
|
||||
background_agents_start_task._invoke_sync_on_event_loop = True # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
@tool(name="background_agents_wait_for_first_completion", approval_mode="never_require")
|
||||
async def background_agents_wait_for_first_completion(task_ids: list[int]) -> str:
|
||||
"""Block until the first of the specified background tasks completes. Returns the completed task's ID."""
|
||||
if not task_ids:
|
||||
return "Error: No task IDs provided."
|
||||
|
||||
# Collect in-flight tasks matching the requested IDs.
|
||||
waitable: list[tuple[int, asyncio.Task[AgentResponse[Any]]]] = []
|
||||
for tid in task_ids:
|
||||
in_flight = runtime.in_flight_tasks.get(tid)
|
||||
if in_flight is not None:
|
||||
waitable.append((tid, in_flight))
|
||||
|
||||
if not waitable:
|
||||
# Refresh state to catch any that completed.
|
||||
tasks = _refresh_task_state(session, provider_state, runtime, source_id=source_id)
|
||||
already_complete = next(
|
||||
(t for t in tasks if t.id in task_ids and t.status != BackgroundTaskStatus.RUNNING), None
|
||||
)
|
||||
if already_complete is not None:
|
||||
return (
|
||||
f"Task {already_complete.id} is not running; current status: {already_complete.status.value}."
|
||||
)
|
||||
return "Error: None of the specified task IDs correspond to running tasks."
|
||||
|
||||
# Wait for the first one to complete.
|
||||
done, _ = await asyncio.wait(
|
||||
[t for _, t in waitable],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
# Find which ID completed.
|
||||
completed_id: int | None = None
|
||||
for tid, task in waitable:
|
||||
if task in done:
|
||||
completed_id = tid
|
||||
break
|
||||
|
||||
# Finalize the completed task.
|
||||
tasks = _get_tasks(provider_state)
|
||||
task_info = next((t for t in tasks if t.id == completed_id), None)
|
||||
if task_info is not None and completed_id is not None:
|
||||
completed_task = runtime.in_flight_tasks.get(completed_id)
|
||||
if completed_task is not None:
|
||||
_finalize_task(task_info, completed_task, runtime)
|
||||
_save_tasks(provider_state, tasks)
|
||||
_save_provider_state(session, provider_state, source_id=source_id)
|
||||
|
||||
status_str = task_info.status.value if task_info else "Unknown"
|
||||
return f"Task {completed_id} finished with status: {status_str}."
|
||||
|
||||
@tool(name="background_agents_get_task_results", approval_mode="never_require")
|
||||
def background_agents_get_task_results(task_id: int) -> str:
|
||||
"""Get the text output of a background task by its ID."""
|
||||
tasks = _refresh_task_state(session, provider_state, runtime, source_id=source_id)
|
||||
task_info = next((t for t in tasks if t.id == task_id), None)
|
||||
|
||||
if task_info is None:
|
||||
return f"Error: No task found with ID {task_id}."
|
||||
|
||||
if task_info.status == BackgroundTaskStatus.COMPLETED:
|
||||
return task_info.result_text or "(no output)"
|
||||
if task_info.status == BackgroundTaskStatus.FAILED:
|
||||
return f"Task failed: {task_info.error_text or 'Unknown error'}"
|
||||
if task_info.status == BackgroundTaskStatus.LOST:
|
||||
return "Task state was lost (reference unavailable)."
|
||||
if task_info.status == BackgroundTaskStatus.RUNNING:
|
||||
return f"Task {task_id} is still running."
|
||||
return f"Task {task_id} has status: {task_info.status.value}."
|
||||
|
||||
@tool(name="background_agents_get_all_tasks", approval_mode="never_require")
|
||||
def background_agents_get_all_tasks() -> str:
|
||||
"""List all background tasks with their IDs, statuses, agent names, and descriptions."""
|
||||
tasks = _refresh_task_state(session, provider_state, runtime, source_id=source_id)
|
||||
|
||||
if not tasks:
|
||||
return "No tasks."
|
||||
|
||||
lines = ["Tasks:"]
|
||||
for t in tasks:
|
||||
lines.append(f"- Task {t.id} [{t.status.value}] ({t.agent_name}): {t.description}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@tool(name="background_agents_continue_task", approval_mode="never_require")
|
||||
def background_agents_continue_task(task_id: int, text: str) -> str:
|
||||
"""Send follow-up input to a completed or failed task to resume its work."""
|
||||
tasks = _refresh_task_state(session, provider_state, runtime, source_id=source_id)
|
||||
task_info = next((t for t in tasks if t.id == task_id), None)
|
||||
|
||||
if task_info is None:
|
||||
return f"Error: No task found with ID {task_id}."
|
||||
|
||||
if task_info.status == BackgroundTaskStatus.LOST:
|
||||
return (
|
||||
f"Error: Task {task_id} cannot be continued because its session was lost. Start a new task instead."
|
||||
)
|
||||
|
||||
if task_info.status == BackgroundTaskStatus.RUNNING:
|
||||
return f"Error: Task {task_id} is still running. Wait for it to complete before continuing."
|
||||
|
||||
key = task_info.agent_name.lower()
|
||||
if key not in self._agents:
|
||||
return f"Error: Agent '{task_info.agent_name}' is no longer available."
|
||||
|
||||
sub_session = runtime.background_sessions.get(task_id)
|
||||
if sub_session is None:
|
||||
return f"Error: Session for task {task_id} is no longer available."
|
||||
|
||||
bg_agent = self._agents[key]
|
||||
|
||||
# Reset task state and start a new run on the existing session.
|
||||
task_info.status = BackgroundTaskStatus.RUNNING
|
||||
task_info.result_text = None
|
||||
task_info.error_text = None
|
||||
_save_tasks(provider_state, tasks)
|
||||
|
||||
async_task = asyncio.create_task(_run_agent(bg_agent.run(text, session=sub_session)))
|
||||
runtime.in_flight_tasks[task_id] = async_task
|
||||
|
||||
_save_provider_state(session, provider_state, source_id=source_id)
|
||||
return f"Task {task_id} continued with new input."
|
||||
|
||||
background_agents_continue_task._invoke_sync_on_event_loop = True # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
@tool(name="background_agents_clear_completed_task", approval_mode="never_require")
|
||||
def background_agents_clear_completed_task(task_id: int) -> str:
|
||||
"""Remove a completed or failed task and release its session to free memory."""
|
||||
tasks = _refresh_task_state(session, provider_state, runtime, source_id=source_id)
|
||||
task_info = next((t for t in tasks if t.id == task_id), None)
|
||||
|
||||
if task_info is None:
|
||||
return f"Error: No task found with ID {task_id}."
|
||||
|
||||
if task_info.status == BackgroundTaskStatus.RUNNING:
|
||||
return f"Error: Task {task_id} is still running. Wait for it to complete before clearing."
|
||||
|
||||
# Remove the task from state.
|
||||
tasks = [t for t in tasks if t.id != task_id]
|
||||
_save_tasks(provider_state, tasks)
|
||||
|
||||
# Clean up runtime references.
|
||||
runtime.in_flight_tasks.pop(task_id, None)
|
||||
runtime.background_sessions.pop(task_id, None)
|
||||
|
||||
_save_provider_state(session, provider_state, source_id=source_id)
|
||||
return f"Task {task_id} cleared."
|
||||
|
||||
# Inject instructions and current task status.
|
||||
context.extend_instructions(self.source_id, [self._instructions])
|
||||
context.extend_tools(
|
||||
self.source_id,
|
||||
[
|
||||
background_agents_start_task,
|
||||
background_agents_wait_for_first_completion,
|
||||
background_agents_get_task_results,
|
||||
background_agents_get_all_tasks,
|
||||
background_agents_continue_task,
|
||||
background_agents_clear_completed_task,
|
||||
],
|
||||
)
|
||||
|
||||
# Include current task status as context message if there are tasks.
|
||||
# Refresh first to get accurate statuses for any tasks that completed between turns.
|
||||
tasks = _refresh_task_state(session, provider_state, runtime, source_id=source_id)
|
||||
if tasks:
|
||||
status_lines = ["### Current background tasks"]
|
||||
for t in tasks:
|
||||
status_lines.append(f"- Task {t.id} [{t.status.value}] ({t.agent_name}): {t.description}")
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[Message(role="user", contents=["\n".join(status_lines)])],
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,533 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""File-based memory harness provider backed by an ``AgentFileStore``.
|
||||
|
||||
:class:`FileMemoryProvider` gives an agent a session-scoped, file-based memory
|
||||
system. Each memory is stored as an individual file with a meaningful name, and
|
||||
large files can carry a companion description file (suffixed with
|
||||
``_description.md``) that provides a short summary used for discovery. A
|
||||
``memories.md`` index file is maintained automatically and injected into the
|
||||
agent's context so the model knows what memories already exist.
|
||||
|
||||
File access is mediated through the :class:`~agent_framework.AgentFileStore`
|
||||
abstraction (shared with :class:`~agent_framework.FileAccessProvider`), so the
|
||||
same in-memory, local-disk, or remote-blob backends can be reused here.
|
||||
|
||||
Unlike :class:`~agent_framework.FileAccessProvider`, which exposes a *shared*
|
||||
store visible across sessions and agents, :class:`FileMemoryProvider` isolates
|
||||
memories per session by default: every session writes under its own working
|
||||
folder (derived from the session id). Pass an explicit ``scope`` to group
|
||||
memories differently, for example by user id.
|
||||
|
||||
The provider exposes the following tools to the agent (registered on the
|
||||
per-invocation :class:`~agent_framework.SessionContext` in
|
||||
:meth:`FileMemoryProvider.before_run`):
|
||||
|
||||
* ``file_memory_write`` — Write a memory file (with an optional description).
|
||||
* ``file_memory_read`` — Read the content of a memory file by name.
|
||||
* ``file_memory_delete`` — Delete a memory file (and its description).
|
||||
* ``file_memory_ls`` — List memory files with their descriptions.
|
||||
* ``file_memory_grep`` — Search memory file contents with a regex.
|
||||
* ``file_memory_replace`` — Replace a substring within a memory file.
|
||||
* ``file_memory_replace_lines`` — Replace whole lines within a memory file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._sessions import AgentSession, ContextProvider, SessionContext
|
||||
from .._tools import tool
|
||||
from .._types import Message
|
||||
from ._file_access import (
|
||||
AgentFileStore,
|
||||
FileStoreEntry,
|
||||
_apply_replace, # pyright: ignore[reportPrivateUsage]
|
||||
_apply_replace_lines, # pyright: ignore[reportPrivateUsage]
|
||||
_line_edits, # pyright: ignore[reportPrivateUsage]
|
||||
_matches_glob, # pyright: ignore[reportPrivateUsage]
|
||||
_normalize_relative_path, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_FILE_MEMORY_SOURCE_ID = "file_memory"
|
||||
|
||||
DEFAULT_FILE_MEMORY_INSTRUCTIONS = (
|
||||
"## File Based Memory\n"
|
||||
"You have access to a session-scoped, file-based memory system via the `file_memory_*` tools "
|
||||
"for storing and retrieving information across interactions. "
|
||||
"These files act as your working memory for the current session and are isolated from other sessions. "
|
||||
"Use these tools to store plans, memories, processing results, or downloaded data.\n\n"
|
||||
'- Use descriptive file names (e.g., "projectarchitecture.md", "userpreferences.md").\n'
|
||||
"- Include a description when writing a file to help with future discovery.\n"
|
||||
"- Before starting new tasks, use file_memory_ls and file_memory_grep to check for "
|
||||
"relevant existing memories to avoid duplicate work.\n"
|
||||
"- Keep memories up-to-date by overwriting files when information changes.\n"
|
||||
"- When you receive large amounts of data (e.g., downloaded web pages, API responses, research results), "
|
||||
"write them to files if they will be required later, so that they are not lost when older context is "
|
||||
"compacted or truncated. This ensures important data remains accessible across long-running sessions."
|
||||
)
|
||||
|
||||
_DESCRIPTION_SUFFIX = "_description.md"
|
||||
_MEMORY_INDEX_FILE_NAME = "memories.md"
|
||||
_MAX_INDEX_ENTRIES = 50
|
||||
|
||||
|
||||
def _description_file_name(file_name: str) -> str:
|
||||
"""Return the companion description file name for ``file_name``.
|
||||
|
||||
The suffix replaces the original extension when present (so ``notes.md``
|
||||
becomes ``notes_description.md``); otherwise it is appended.
|
||||
"""
|
||||
dot_index = file_name.rfind(".")
|
||||
if dot_index > 0:
|
||||
return f"{file_name[:dot_index]}{_DESCRIPTION_SUFFIX}"
|
||||
return f"{file_name}{_DESCRIPTION_SUFFIX}"
|
||||
|
||||
|
||||
def _is_internal_file(file_name: str) -> bool:
|
||||
"""Return whether ``file_name`` is an internal file hidden from the agent.
|
||||
|
||||
Internal files are the description sidecars and the ``memories.md`` index.
|
||||
"""
|
||||
lowered = file_name.lower()
|
||||
return lowered.endswith(_DESCRIPTION_SUFFIX) or lowered == _MEMORY_INDEX_FILE_NAME
|
||||
|
||||
|
||||
def _combine_paths(base_path: str, relative_path: str) -> str:
|
||||
"""Join a working-folder path with a relative path using forward slashes."""
|
||||
if not base_path:
|
||||
return relative_path
|
||||
if not relative_path:
|
||||
return base_path
|
||||
return f"{base_path.rstrip('/')}/{relative_path.lstrip('/')}"
|
||||
|
||||
|
||||
def _is_nested_path(normalized_file_name: str) -> bool:
|
||||
"""Return whether a normalized file name points into a subdirectory.
|
||||
|
||||
File memory is a flat, session-scoped space: every discovery surface
|
||||
(the ``memories.md`` index, ``file_memory_ls``, and non-recursive
|
||||
``file_memory_grep``) only enumerates direct children of the
|
||||
working folder. A nested name such as ``"notes/plan.md"`` would therefore
|
||||
be written but never surface again, so such names are rejected up front.
|
||||
``_normalize_relative_path`` already converts backslashes to forward
|
||||
slashes, so checking for ``/`` covers both separators.
|
||||
"""
|
||||
return "/" in normalized_file_name
|
||||
|
||||
|
||||
class _WriteFileInput(BaseModel):
|
||||
"""Input schema for ``file_memory_write``."""
|
||||
|
||||
file_name: Annotated[str, Field(description="Flat file name to write under; must not contain path separators.")]
|
||||
content: Annotated[str, Field(description="Full text content to write to the file.")]
|
||||
description: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Optional summary used to aid future discovery; recommended for large files.",
|
||||
),
|
||||
] = None
|
||||
|
||||
|
||||
class _ReadFileInput(BaseModel):
|
||||
"""Input schema for ``file_memory_read``."""
|
||||
|
||||
file_name: Annotated[str, Field(description="Name of the memory file to read.")]
|
||||
|
||||
|
||||
class _DeleteFileInput(BaseModel):
|
||||
"""Input schema for ``file_memory_delete``."""
|
||||
|
||||
file_name: Annotated[str, Field(description="Name of the memory file to delete.")]
|
||||
|
||||
|
||||
class _ListInput(BaseModel):
|
||||
"""Input schema for ``file_memory_ls``."""
|
||||
|
||||
glob_pattern: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description='Optional glob (e.g. "*.md") matched against file names to filter the listing.',
|
||||
),
|
||||
] = None
|
||||
|
||||
|
||||
class _ReplaceInput(BaseModel):
|
||||
"""Input schema for ``file_memory_replace``."""
|
||||
|
||||
file_name: Annotated[str, Field(description="Name of the memory file to modify.")]
|
||||
old_string: Annotated[str, Field(description="Substring to find and replace.")]
|
||||
new_string: Annotated[str, Field(description="Replacement text.")]
|
||||
replace_all: Annotated[
|
||||
bool,
|
||||
Field(
|
||||
default=False,
|
||||
description="When true, replace every occurrence; when false, fail unless exactly one occurrence exists.",
|
||||
),
|
||||
] = False
|
||||
|
||||
|
||||
class _LineEdit(BaseModel):
|
||||
"""A single literal line replacement for ``file_memory_replace_lines``."""
|
||||
|
||||
line_number: Annotated[int, Field(description="1-based line number to replace.")]
|
||||
new_line: Annotated[
|
||||
str,
|
||||
Field(
|
||||
description=(
|
||||
"Literal replacement text for the line, including any trailing newline you want to keep "
|
||||
"(the editor does not add one). Set to an empty string to delete the line entirely, "
|
||||
"including its line break."
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class _ReplaceLinesInput(BaseModel):
|
||||
"""Input schema for ``file_memory_replace_lines``."""
|
||||
|
||||
file_name: Annotated[str, Field(description="Name of the memory file to modify.")]
|
||||
edits: Annotated[
|
||||
list[_LineEdit],
|
||||
Field(description="List of 1-based line numbers and their literal replacement text."),
|
||||
]
|
||||
|
||||
|
||||
class _SearchFilesInput(BaseModel):
|
||||
"""Input schema for ``file_memory_grep``."""
|
||||
|
||||
regex_pattern: Annotated[
|
||||
str,
|
||||
Field(description="Case-insensitive regex matched against file contents; 256 characters or fewer."),
|
||||
]
|
||||
glob_pattern: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description='Optional glob to filter which files are searched (e.g. "*.md", "research*").',
|
||||
),
|
||||
] = None
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class FileMemoryProvider(ContextProvider):
|
||||
"""Context provider that gives an agent session-scoped, file-based memory.
|
||||
|
||||
The provider exposes the following tools to the agent via the per-invocation
|
||||
:class:`~agent_framework.SessionContext`:
|
||||
|
||||
- ``file_memory_write`` — Write a memory file with an optional description.
|
||||
- ``file_memory_read`` — Read the content of a memory file by name.
|
||||
- ``file_memory_delete`` — Delete a memory file and its description.
|
||||
- ``file_memory_ls`` — List memory files with their descriptions.
|
||||
- ``file_memory_grep`` — Search memory file contents with a regex.
|
||||
- ``file_memory_replace`` — Replace a substring within a memory file.
|
||||
- ``file_memory_replace_lines`` — Replace whole lines within a memory file.
|
||||
|
||||
Memories are isolated per session: each session reads and writes under a
|
||||
working folder derived from its session id. Pass an explicit ``scope`` to
|
||||
group memories differently (for example, per user id) across sessions.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: AgentFileStore,
|
||||
*,
|
||||
source_id: str = DEFAULT_FILE_MEMORY_SOURCE_ID,
|
||||
scope: str | None = None,
|
||||
instructions: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the file memory provider.
|
||||
|
||||
Args:
|
||||
store: The file store implementation used for storage operations.
|
||||
|
||||
Keyword Args:
|
||||
source_id: Unique source ID for the provider.
|
||||
scope: The namespace that logically groups and isolates memories
|
||||
(for example, a user ID). Used as the working folder within the
|
||||
store. When ``None`` (the default), the active session's
|
||||
``session_id`` is used, isolating memories per session.
|
||||
instructions: Optional instruction override. When ``None`` the
|
||||
default file-memory instructions are used.
|
||||
"""
|
||||
super().__init__(source_id)
|
||||
self.store = store
|
||||
self.scope = scope
|
||||
self.instructions = instructions or DEFAULT_FILE_MEMORY_INSTRUCTIONS
|
||||
# Serializes write/delete operations (and their index rebuilds) so the
|
||||
# ``memories.md`` index stays consistent. A single per-instance lock is
|
||||
# sufficient for v1; concurrent writes across scopes are rare in practice.
|
||||
self._write_lock = asyncio.Lock()
|
||||
|
||||
def _resolve_working_folder(self, context: SessionContext) -> str:
|
||||
"""Resolve the working folder for the current invocation.
|
||||
|
||||
Uses the configured ``scope`` when set, otherwise the session id. The
|
||||
result is normalized as a relative directory path so it cannot escape
|
||||
the store root.
|
||||
"""
|
||||
raw_scope = self.scope or context.session_id or ""
|
||||
return _normalize_relative_path(raw_scope, is_directory=True)
|
||||
|
||||
async def _rebuild_index(self, working_folder: str) -> None:
|
||||
"""Rebuild the ``memories.md`` index for ``working_folder``.
|
||||
|
||||
Lists the non-internal files, sorts them deterministically, reads any
|
||||
companion descriptions, and writes a capped markdown summary.
|
||||
"""
|
||||
entries = await self.store.list_children(working_folder)
|
||||
file_names = [entry.name for entry in entries if entry.type == FileStoreEntry.FILE]
|
||||
sorted_files = sorted((name for name in file_names if not _is_internal_file(name)), key=str.lower)
|
||||
|
||||
lines = ["# Memory Index", ""]
|
||||
for file_name in sorted_files[:_MAX_INDEX_ENTRIES]:
|
||||
description = await self.store.read(_combine_paths(working_folder, _description_file_name(file_name)))
|
||||
if description and description.strip():
|
||||
lines.append(f"- **{file_name}**: {description.strip()}")
|
||||
else:
|
||||
lines.append(f"- **{file_name}**")
|
||||
|
||||
index_path = _combine_paths(working_folder, _MEMORY_INDEX_FILE_NAME)
|
||||
await self.store.write(index_path, "\n".join(lines) + "\n")
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Inject file-memory tools, instructions, and the memory index."""
|
||||
working_folder = self._resolve_working_folder(context)
|
||||
|
||||
if working_folder:
|
||||
await self.store.create_directory(working_folder)
|
||||
|
||||
@tool(name="file_memory_write", schema=_WriteFileInput, approval_mode="never_require")
|
||||
async def file_memory_write(file_name: str, content: str, description: str | None = None) -> str:
|
||||
"""Write a memory file with the given name and content. Overwrites the file if it already exists. Include a description for large files to provide a summary that helps with future discovery.""" # noqa: E501
|
||||
try:
|
||||
normalized = _normalize_relative_path(file_name)
|
||||
except ValueError as exc:
|
||||
return f"Could not write file '{file_name}': {exc}"
|
||||
if _is_nested_path(normalized):
|
||||
return (
|
||||
f"Could not write file '{file_name}': memory files must not be written into a "
|
||||
"subdirectory. Please choose a flat file name without path separators."
|
||||
)
|
||||
if _is_internal_file(normalized):
|
||||
return (
|
||||
f"Could not write file '{file_name}': the file name is reserved for internal use. "
|
||||
"Please choose a different file name."
|
||||
)
|
||||
|
||||
path = _combine_paths(working_folder, normalized)
|
||||
desc_path = _combine_paths(working_folder, _description_file_name(normalized))
|
||||
async with self._write_lock:
|
||||
try:
|
||||
await self.store.write(path, content)
|
||||
if description and description.strip():
|
||||
await self.store.write(desc_path, description)
|
||||
else:
|
||||
await self.store.delete(desc_path)
|
||||
await self._rebuild_index(working_folder)
|
||||
except ValueError as exc:
|
||||
return f"Could not write file '{file_name}': {exc}"
|
||||
except OSError as exc:
|
||||
return f"Could not write file '{file_name}': {exc.strerror or exc}"
|
||||
if description and description.strip():
|
||||
return f"File '{file_name}' written with description."
|
||||
return f"File '{file_name}' written."
|
||||
|
||||
@tool(name="file_memory_read", schema=_ReadFileInput, approval_mode="never_require")
|
||||
async def file_memory_read(file_name: str) -> str:
|
||||
"""Read the content of a memory file by name. Returns the file content or a message indicating the file was not found.""" # noqa: E501
|
||||
try:
|
||||
normalized = _normalize_relative_path(file_name)
|
||||
except ValueError as exc:
|
||||
return f"Could not read file '{file_name}': {exc}"
|
||||
if _is_nested_path(normalized):
|
||||
return f"File '{file_name}' not found."
|
||||
try:
|
||||
content = await self.store.read(_combine_paths(working_folder, normalized))
|
||||
except ValueError as exc:
|
||||
return f"Could not read file '{file_name}': {exc}"
|
||||
except OSError as exc:
|
||||
return f"Could not read file '{file_name}': {exc.strerror or exc}"
|
||||
return content if content is not None else f"File '{file_name}' not found."
|
||||
|
||||
@tool(name="file_memory_delete", schema=_DeleteFileInput, approval_mode="never_require")
|
||||
async def file_memory_delete(file_name: str) -> str:
|
||||
"""Delete a memory file by name. Also removes its companion description file if one exists."""
|
||||
try:
|
||||
normalized = _normalize_relative_path(file_name)
|
||||
except ValueError as exc:
|
||||
return f"Could not delete file '{file_name}': {exc}"
|
||||
if _is_nested_path(normalized):
|
||||
return f"File '{file_name}' not found."
|
||||
|
||||
path = _combine_paths(working_folder, normalized)
|
||||
desc_path = _combine_paths(working_folder, _description_file_name(normalized))
|
||||
async with self._write_lock:
|
||||
try:
|
||||
deleted = await self.store.delete(path)
|
||||
await self.store.delete(desc_path)
|
||||
await self._rebuild_index(working_folder)
|
||||
except ValueError as exc:
|
||||
return f"Could not delete file '{file_name}': {exc}"
|
||||
except OSError as exc:
|
||||
return f"Could not delete file '{file_name}': {exc.strerror or exc}"
|
||||
return f"File '{file_name}' deleted." if deleted else f"File '{file_name}' not found."
|
||||
|
||||
@tool(name="file_memory_ls", schema=_ListInput, approval_mode="never_require")
|
||||
async def file_memory_ls(glob_pattern: str | None = None) -> list[dict[str, Any]] | str:
|
||||
"""List all memory files with their descriptions (if available). Optionally filter file names with a glob_pattern (e.g. "*.md"). Internal files (description sidecars and the memory index) are not shown. Each entry is {"name": <name>, "type": "file", "description": <desc-or-null>}.""" # noqa: E501
|
||||
try:
|
||||
entries = await self.store.list_children(working_folder)
|
||||
except OSError as exc:
|
||||
return f"Could not list memory files: {exc.strerror or exc}"
|
||||
|
||||
file_names = [entry.name for entry in entries if entry.type == FileStoreEntry.FILE]
|
||||
available = set(file_names)
|
||||
results: list[dict[str, Any]] = []
|
||||
for file_name in file_names:
|
||||
if _is_internal_file(file_name):
|
||||
continue
|
||||
if not _matches_glob(file_name, glob_pattern):
|
||||
continue
|
||||
description: str | None = None
|
||||
desc_file_name = _description_file_name(file_name)
|
||||
if desc_file_name in available:
|
||||
description = await self.store.read(_combine_paths(working_folder, desc_file_name))
|
||||
results.append({"name": file_name, "type": "file", "description": description})
|
||||
return results
|
||||
|
||||
@tool(name="file_memory_replace", schema=_ReplaceInput, approval_mode="never_require")
|
||||
async def file_memory_replace(
|
||||
file_name: str, old_string: str, new_string: str, replace_all: bool = False
|
||||
) -> str:
|
||||
"""Replace occurrences of old_string with new_string in a memory file. Fails if old_string is not found, or if it occurs more than once and replace_all is false. Returns the number of occurrences replaced.""" # noqa: E501
|
||||
try:
|
||||
normalized = _normalize_relative_path(file_name)
|
||||
except ValueError as exc:
|
||||
return f"Could not replace in file '{file_name}': {exc}"
|
||||
if _is_nested_path(normalized):
|
||||
return f"File '{file_name}' not found."
|
||||
if _is_internal_file(normalized):
|
||||
return (
|
||||
f"Could not replace in file '{file_name}': the file name is reserved for internal use. "
|
||||
"Please choose a different file name."
|
||||
)
|
||||
path = _combine_paths(working_folder, normalized)
|
||||
async with self._write_lock:
|
||||
try:
|
||||
content = await self.store.read(path)
|
||||
if content is None:
|
||||
return f"File '{file_name}' not found."
|
||||
new_content, count = _apply_replace(content, old_string, new_string, replace_all)
|
||||
await self.store.write(path, new_content)
|
||||
except ValueError as exc:
|
||||
return f"Could not replace in file '{file_name}': {exc}"
|
||||
except OSError as exc:
|
||||
return f"Could not replace in file '{file_name}': {exc.strerror or exc}"
|
||||
return f"Replaced {count} occurrence(s) in '{file_name}'."
|
||||
|
||||
@tool(name="file_memory_replace_lines", schema=_ReplaceLinesInput, approval_mode="never_require")
|
||||
async def file_memory_replace_lines(file_name: str, edits: list[_LineEdit]) -> str:
|
||||
"""Replace lines in a memory file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers.""" # noqa: E501
|
||||
try:
|
||||
normalized = _normalize_relative_path(file_name)
|
||||
except ValueError as exc:
|
||||
return f"Could not edit file '{file_name}': {exc}"
|
||||
if _is_nested_path(normalized):
|
||||
return f"File '{file_name}' not found."
|
||||
if _is_internal_file(normalized):
|
||||
return (
|
||||
f"Could not edit file '{file_name}': the file name is reserved for internal use. "
|
||||
"Please choose a different file name."
|
||||
)
|
||||
path = _combine_paths(working_folder, normalized)
|
||||
async with self._write_lock:
|
||||
try:
|
||||
content = await self.store.read(path)
|
||||
if content is None:
|
||||
return f"File '{file_name}' not found."
|
||||
new_content = _apply_replace_lines(content, _line_edits(edits))
|
||||
await self.store.write(path, new_content)
|
||||
except ValueError as exc:
|
||||
return f"Could not edit file '{file_name}': {exc}"
|
||||
except OSError as exc:
|
||||
return f"Could not edit file '{file_name}': {exc.strerror or exc}"
|
||||
return f"Replaced {len(edits)} line(s) in '{file_name}'."
|
||||
|
||||
@tool(name="file_memory_grep", schema=_SearchFilesInput, approval_mode="never_require")
|
||||
async def file_memory_grep(
|
||||
regex_pattern: str,
|
||||
glob_pattern: str | None = None,
|
||||
) -> list[dict[str, Any]] | str:
|
||||
"""Search memory file contents using a case-insensitive regular expression. Optionally filter which files to search using a glob pattern (e.g., "*.md", "research*"). Returns matching file names, content snippets, and matching lines with line numbers. The regex_pattern must be 256 characters or fewer.""" # noqa: E501
|
||||
glob_filter = glob_pattern if glob_pattern and glob_pattern.strip() else None
|
||||
try:
|
||||
results = await self.store.search(working_folder, regex_pattern, glob_filter, recursive=False)
|
||||
except ValueError as exc:
|
||||
return f"Could not search memory files: {exc}"
|
||||
except OSError as exc:
|
||||
return f"Could not search memory files: {exc.strerror or exc}"
|
||||
return [result.to_dict() for result in results if not _is_internal_file(result.file_name)]
|
||||
|
||||
context.extend_instructions(self.source_id, [self.instructions])
|
||||
context.extend_tools(
|
||||
self.source_id,
|
||||
[
|
||||
file_memory_write,
|
||||
file_memory_read,
|
||||
file_memory_delete,
|
||||
file_memory_ls,
|
||||
file_memory_grep,
|
||||
file_memory_replace,
|
||||
file_memory_replace_lines,
|
||||
],
|
||||
)
|
||||
|
||||
try:
|
||||
index_content = await self.store.read(_combine_paths(working_folder, _MEMORY_INDEX_FILE_NAME))
|
||||
except (OSError, ValueError) as exc:
|
||||
# A corrupt/unavailable index (e.g. non-UTF8 bytes on disk or a store
|
||||
# error) must not block the run. Skip index injection for this run; it
|
||||
# self-heals on the next successful write/delete that rebuilds the index.
|
||||
logger.warning("Could not read memory index; skipping index injection: %s", exc)
|
||||
index_content = None
|
||||
if index_content and index_content.strip():
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
(
|
||||
"The following is your memory index — a list of files you have previously written. "
|
||||
"You can read any of these files using the file_memory_read tool.\n\n"
|
||||
f"{index_content}"
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_FILE_MEMORY_INSTRUCTIONS",
|
||||
"DEFAULT_FILE_MEMORY_SOURCE_ID",
|
||||
"FileMemoryProvider",
|
||||
]
|
||||
@@ -0,0 +1,972 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""AgentLoopMiddleware: re-run an agent in a loop until a criterion is met.
|
||||
|
||||
This module provides :class:`AgentLoopMiddleware`, an :class:`~agent_framework.AgentMiddleware`
|
||||
that repeatedly re-invokes the wrapped agent while a ``should_continue`` predicate says to keep
|
||||
going. It serves two common patterns through a single configurable class:
|
||||
|
||||
1. A user-supplied ``should_continue`` predicate - for example, keep looping while a response does
|
||||
not yet contain a completion marker, while a :class:`~agent_framework.TodoProvider` still has
|
||||
open items, or while a :class:`~agent_framework.BackgroundAgentsProvider` still has running
|
||||
tasks (see the :func:`todos_remaining` and :func:`background_tasks_running` helpers, which resolve
|
||||
their provider from the running agent). The loop
|
||||
can track a **feedback log** across iterations (``record_feedback``): each pass contributes an
|
||||
entry that is exposed to every callback via the ``progress`` keyword and (by default) injected
|
||||
into the next iteration's input. Set ``fresh_context=True`` to restart each pass from the
|
||||
original task plus the progress log (with a session attached, the session is also snapshotted
|
||||
before the loop and restored between iterations so no accumulated history leaks back in).
|
||||
``max_iterations`` bounds the loop as a safety cap.
|
||||
2. A chat-client judge (via :meth:`AgentLoopMiddleware.with_judge`) - a second chat client decides
|
||||
whether the user's original request has been answered (via a :class:`JudgeVerdict` structured
|
||||
output); the loop continues while the answer is "no". This is a convenience wrapper that builds an
|
||||
async ``should_continue`` predicate, so it is a special case of (1).
|
||||
|
||||
In every case, the input for the next iteration is controlled by the ``next_message`` callable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import TYPE_CHECKING, Any, TypeAlias
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import Self
|
||||
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._middleware import AgentContext, AgentMiddleware, MiddlewareTermination
|
||||
from .._types import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentRunInputs,
|
||||
Message,
|
||||
ResponseStream,
|
||||
UsageDetails,
|
||||
add_usage_details,
|
||||
normalize_messages,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._clients import SupportsChatGetResponse
|
||||
|
||||
__all__ = [
|
||||
"AgentLoopMiddleware",
|
||||
"JudgeVerdict",
|
||||
"background_tasks_running",
|
||||
"background_tasks_running_message",
|
||||
"todos_remaining",
|
||||
"todos_remaining_message",
|
||||
]
|
||||
|
||||
DEFAULT_NEXT_MESSAGE = "Continue working on the task. If it is complete, say so."
|
||||
|
||||
# Placeholder substituted with the rendered ``criteria`` block in judge instructions (see
|
||||
# :meth:`AgentLoopMiddleware.with_judge`). User-supplied instructions may include it to control
|
||||
# where the criteria are inserted; if absent, the criteria are not added to the judge instructions.
|
||||
CRITERIA_PLACEHOLDER = "{{criteria}}"
|
||||
|
||||
# Verdict markers the judge is asked to emit for clients that do not honor structured output. They
|
||||
# are deliberately non-overlapping: neither marker is a substring of the other, nor of the JSON
|
||||
# field name ``answered``, so the text fallback in :func:`_build_judge_condition` cannot misclassify
|
||||
# a negative verdict (e.g. ``{"answered": false}``) as a positive one.
|
||||
JUDGE_VERDICT_DONE = "VERDICT: DONE"
|
||||
JUDGE_VERDICT_MORE = "VERDICT: MORE"
|
||||
|
||||
DEFAULT_JUDGE_INSTRUCTIONS = (
|
||||
"You are an evaluator. You are given a user's original request and an agent's latest response. "
|
||||
"Decide whether the agent has fully addressed the original request. "
|
||||
"Set 'answered' to true if the request has been fully addressed, or false if more work is still "
|
||||
"required, and use 'reasoning' to briefly justify your decision. "
|
||||
f"If you cannot return structured output, end your reply with a line reading exactly "
|
||||
f"'{JUDGE_VERDICT_DONE}' when the request has been fully addressed or '{JUDGE_VERDICT_MORE}' "
|
||||
f"when more work is still required."
|
||||
"{{criteria}}"
|
||||
)
|
||||
|
||||
|
||||
def _render_criteria_block(criteria: Sequence[str] | None) -> str:
|
||||
"""Render a list of criteria into a bullet block for the judge instructions (``""`` if none)."""
|
||||
if not criteria:
|
||||
return ""
|
||||
bullets = "\n".join(f"- {item}" for item in criteria)
|
||||
return f"\n\nThe response must satisfy all of the following criteria:\n{bullets}"
|
||||
|
||||
|
||||
def _criteria_agent_instruction(criteria: Sequence[str]) -> str:
|
||||
"""Render the criteria into an extra instruction injected for the agent before each run."""
|
||||
bullets = "\n".join(f"- {item}" for item in criteria)
|
||||
return f"Your response must satisfy all of the following criteria:\n{bullets}"
|
||||
|
||||
|
||||
class JudgeVerdict(BaseModel):
|
||||
"""Structured verdict returned by the judge chat client."""
|
||||
|
||||
answered: bool = Field(
|
||||
description=(
|
||||
"True if the agent has fully addressed the original request and it adheres to the other "
|
||||
"judging standards, otherwise False."
|
||||
),
|
||||
)
|
||||
reasoning: str = Field(
|
||||
default="",
|
||||
description="Brief justification for the verdict.",
|
||||
)
|
||||
|
||||
|
||||
# Default iteration cap applied when ``max_iterations`` is not provided. Loops are bounded by
|
||||
# default to guard against runaway re-invocation; pass ``max_iterations=None`` explicitly to opt
|
||||
# into an unbounded loop.
|
||||
DEFAULT_MAX_ITERATIONS = 10
|
||||
|
||||
# Default iteration cap for judge-driven loops. LLM-judged loops are costly and probabilistic, so
|
||||
# they are bounded by a smaller default. Pass ``max_iterations=None`` explicitly to opt into an
|
||||
# unbounded judge loop.
|
||||
DEFAULT_JUDGE_MAX_ITERATIONS = 5
|
||||
|
||||
|
||||
# A callable invoked between iterations. It always receives the loop keyword arguments
|
||||
# (``iteration``, ``last_result``, ``messages``, ``original_messages``, ``session``, ``agent``,
|
||||
# ``progress``, ``feedback``). Callers declare only the keywords they need plus ``**kwargs`` to
|
||||
# ignore the rest. ``should_continue`` may return a plain ``bool`` (continue/stop) or a
|
||||
# ``(bool, str | None)`` tuple whose second item is feedback surfaced to the ``next_message`` and
|
||||
# ``record_feedback`` callables via the ``feedback`` keyword argument.
|
||||
ShouldContinueResult: TypeAlias = "bool | tuple[bool, str | None]"
|
||||
ShouldContinueCallable = Callable[..., "ShouldContinueResult | Awaitable[ShouldContinueResult]"]
|
||||
NextMessageCallable = Callable[..., "AgentRunInputs | Awaitable[AgentRunInputs | None] | None"]
|
||||
|
||||
# A callable invoked once per work iteration to capture a progress-log entry from that iteration. It
|
||||
# receives the loop keyword arguments and returns a string entry (appended to the log) or ``None``
|
||||
# (record nothing for that iteration).
|
||||
FeedbackCallable = Callable[..., "str | Awaitable[str | None] | None"]
|
||||
|
||||
|
||||
async def _maybe_await(value: Any) -> Any:
|
||||
"""Await ``value`` if it is awaitable, otherwise return it as-is."""
|
||||
if inspect.isawaitable(value):
|
||||
return await value
|
||||
return value
|
||||
|
||||
|
||||
def _build_judge_condition(
|
||||
judge_client: SupportsChatGetResponse,
|
||||
instructions: str,
|
||||
) -> tuple[ShouldContinueCallable, NextMessageCallable]:
|
||||
"""Build the ``should_continue`` predicate and ``next_message`` callable for a judge loop.
|
||||
|
||||
The judge is called directly (no agent tools, session, or middleware) with fresh messages, so
|
||||
the loop's evaluation cannot recurse back through the agent pipeline. The original input messages
|
||||
are forwarded verbatim (rather than collapsed to text) so multi-modal requests are preserved. The
|
||||
judge is asked for a :class:`JudgeVerdict` structured output; if the client does not honor
|
||||
structured output the verdict falls back to the explicit, non-overlapping ``VERDICT: DONE`` /
|
||||
``VERDICT: MORE`` markers (``MORE`` wins, keeping the loop running, when the marker is ambiguous
|
||||
or absent).
|
||||
|
||||
The predicate returns a ``(continue, reasoning)`` tuple; the loop surfaces that ``reasoning`` to
|
||||
the next-message callable as the ``feedback`` keyword argument, which feeds it back to the agent
|
||||
so it knows *why* its previous answer was judged incomplete.
|
||||
"""
|
||||
|
||||
async def _judge(
|
||||
*, last_result: AgentResponse, original_messages: list[Message], **kwargs: Any
|
||||
) -> tuple[bool, str | None]:
|
||||
judge_messages = [
|
||||
Message(role="system", contents=[instructions]),
|
||||
Message(
|
||||
role="user",
|
||||
contents=["Evaluate the agent's work. The user's original request follows:"],
|
||||
),
|
||||
*original_messages,
|
||||
Message(role="user", contents=["The agent's latest response was:"]),
|
||||
*last_result.messages,
|
||||
Message(role="user", contents=["Has the original request been fully addressed?"]),
|
||||
]
|
||||
response = await judge_client.get_response(judge_messages, options={"response_format": JudgeVerdict})
|
||||
verdict = response.value
|
||||
if isinstance(verdict, JudgeVerdict):
|
||||
answered = verdict.answered
|
||||
reasoning = verdict.reasoning
|
||||
else:
|
||||
# Fallback for clients that do not honor structured output: look for the explicit,
|
||||
# non-overlapping verdict markers. ``FAIL`` (more work needed) takes precedence so an
|
||||
# ambiguous or marker-less reply keeps looping rather than stopping on an incomplete
|
||||
# answer.
|
||||
text = response.text.upper()
|
||||
# ``MORE`` (more work needed) takes precedence so an ambiguous reply keeps looping.
|
||||
answered = False if JUDGE_VERDICT_MORE in text else JUDGE_VERDICT_DONE in text
|
||||
reasoning = response.text.strip()
|
||||
# Continue looping while the request is not yet answered, surfacing the reasoning as feedback.
|
||||
return (not answered), (reasoning or None)
|
||||
|
||||
def _next_message(*, feedback: str | None = None, **kwargs: Any) -> AgentRunInputs:
|
||||
# Feed the judge's reasoning back to the agent so the next iteration addresses the gap.
|
||||
if feedback:
|
||||
return (
|
||||
"An evaluator reviewed your previous response and judged that it does not yet fully "
|
||||
f"address the original request.\n\nEvaluator feedback: {feedback}\n\n"
|
||||
"Revise and continue so the original request is fully addressed."
|
||||
)
|
||||
return DEFAULT_NEXT_MESSAGE
|
||||
|
||||
return _judge, _next_message
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class AgentLoopMiddleware(AgentMiddleware):
|
||||
"""Re-run an agent in a loop until a criterion is met (or never).
|
||||
|
||||
This middleware repeatedly invokes the wrapped agent. After each run it decides whether to run
|
||||
again based on ``should_continue`` and ``max_iterations``, and uses ``next_message`` to build
|
||||
the input for the next iteration. Use :meth:`with_judge` to drive the loop with a chat-client
|
||||
judge instead of a hand-written predicate.
|
||||
|
||||
By default a non-streaming run returns an aggregated :class:`~agent_framework.AgentResponse`
|
||||
containing every iteration's messages plus the injected ``next_message`` "nudge" messages (set
|
||||
``return_final_only=True`` to return only the last iteration's response). Streaming runs always
|
||||
yield each iteration's updates and emit the injected nudge messages as ``user`` updates between
|
||||
iterations.
|
||||
|
||||
The ``should_continue`` and ``next_message`` callables are invoked with keyword arguments, so a
|
||||
caller only needs to declare the ones it uses plus ``**kwargs``. The keywords are:
|
||||
|
||||
- ``iteration`` (int): the number of completed runs so far (1-based after the first run).
|
||||
- ``last_result`` (AgentResponse): the result of the iteration that just completed.
|
||||
- ``messages`` (list[Message]): the messages used for the iteration that just completed.
|
||||
- ``original_messages`` (list[Message]): the input used for the first iteration.
|
||||
- ``session`` (AgentSession | None): the active session, used by the provider helpers.
|
||||
- ``agent``: the agent being looped.
|
||||
- ``progress`` (list[str]): the feedback log accumulated so far (see ``record_feedback``).
|
||||
- ``feedback`` (str | None): the feedback string returned by ``should_continue`` for this
|
||||
iteration (``None`` when it returned a plain bool). ``should_continue`` may return either a
|
||||
``bool`` or a ``(bool, str | None)`` tuple; the string is surfaced here so ``next_message``
|
||||
and ``record_feedback`` can reference it.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import Agent, AgentResponse
|
||||
from agent_framework._harness._loop import AgentLoopMiddleware
|
||||
|
||||
|
||||
async def should_continue(*, iteration: int, last_result: AgentResponse, **kwargs) -> bool:
|
||||
return iteration < 3 and "DONE" not in last_result.text
|
||||
|
||||
|
||||
agent = Agent(client=client, middleware=[AgentLoopMiddleware(should_continue)])
|
||||
|
||||
Note:
|
||||
``max_iterations`` acts as a safety cap and defaults to ``DEFAULT_MAX_ITERATIONS`` (10). Pass
|
||||
an explicit ``None`` to make the loop unbounded, in which case it relies entirely on
|
||||
``should_continue`` to stop, so make sure the predicate can eventually return ``False``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
should_continue: ShouldContinueCallable,
|
||||
*,
|
||||
max_iterations: int | None = DEFAULT_MAX_ITERATIONS,
|
||||
next_message: NextMessageCallable | None = None,
|
||||
record_feedback: FeedbackCallable | None = None,
|
||||
inject_progress: bool = True,
|
||||
fresh_context: bool = False,
|
||||
return_final_only: bool = False,
|
||||
additional_instructions: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the agent loop middleware.
|
||||
|
||||
Args:
|
||||
should_continue: Predicate that decides whether to run the agent again. May be sync or
|
||||
async and is called with the loop keyword arguments (``iteration``, ``last_result``,
|
||||
``messages``, ``original_messages``, ``session``, ``agent``, ``progress``, and
|
||||
``feedback`` -- see the class docstring for what each one carries; declare only the
|
||||
ones you need plus ``**kwargs``). Return ``True``/``False`` to
|
||||
continue/stop, or a ``(bool, str | None)`` tuple to also provide feedback; the
|
||||
feedback string is surfaced to the ``next_message`` and ``record_feedback`` callables
|
||||
via the ``feedback`` keyword argument. To loop on a chat-client judge instead, build
|
||||
the middleware via :meth:`with_judge`.
|
||||
|
||||
Keyword Args:
|
||||
max_iterations: Maximum number of agent runs, used as a safety cap. Defaults to
|
||||
``DEFAULT_MAX_ITERATIONS`` (10); pass an explicit ``None`` for an unbounded loop, or
|
||||
a positive integer to set a custom cap. (The :meth:`with_judge` factory uses
|
||||
``DEFAULT_JUDGE_MAX_ITERATIONS`` (5) as its default instead.)
|
||||
next_message: Callable that produces the input for the next iteration, called with the
|
||||
loop keyword arguments. Defaults to a short "continue" nudge. Returning ``None``
|
||||
reuses the previous iteration's messages verbatim (in which case the progress log is
|
||||
*not* injected; see ``inject_progress``).
|
||||
record_feedback: Optional callable invoked once per work iteration to capture a feedback
|
||||
entry. Called as ``record_feedback(**loop_kwargs)`` and returns a
|
||||
string entry appended to the progress log, or ``None`` to record nothing for that
|
||||
iteration. When not provided, the iteration's response text (``last_result.text``) is
|
||||
recorded instead. The accumulated log is exposed to every callback via the
|
||||
``progress`` loop keyword argument. For production loops prefer a ``record_feedback``
|
||||
that returns a terse summary rather than relying on the full response text.
|
||||
inject_progress: When ``True`` (default), the accumulated progress log is injected into
|
||||
the next iteration's input as a single ``user`` message ("Progress so far: ..."). To
|
||||
avoid duplication, only the most recent entry is injected when a session is attached
|
||||
(the session already retains earlier turns); the full log is injected when there is
|
||||
no session or ``fresh_context`` is set. When ``False`` the log is only exposed via the
|
||||
``progress`` loop keyword argument and never injected automatically.
|
||||
fresh_context: When ``True``, each iteration starts from a clean context: ``context``
|
||||
messages are reset to the original input messages (plus the injected progress log)
|
||||
instead of accumulating the prior conversation. When a session is attached, the
|
||||
session is snapshotted once before the loop and restored to that pre-loop baseline
|
||||
before each subsequent iteration, so the local transcript and any service-side
|
||||
conversation id are reset too and the agent does not re-read the accumulated history.
|
||||
In-loop working-state mutations are discarded; pre-loop state is preserved; continuity
|
||||
is carried only by the progress log.
|
||||
return_final_only: Controls what a non-streaming run returns. When ``False`` (default),
|
||||
the returned :class:`~agent_framework.AgentResponse` aggregates every iteration: each
|
||||
iteration's response messages plus the injected ``next_message`` "nudge" messages
|
||||
(as ``user`` messages), so the caller sees the full back-and-forth. When ``True``,
|
||||
only the final iteration's :class:`~agent_framework.AgentResponse` is returned. This
|
||||
flag has no effect on streaming runs (the stream cannot know in advance which
|
||||
iteration is last); streaming always yields each iteration's updates and injects the
|
||||
``next_message`` messages as ``user`` updates between iterations.
|
||||
additional_instructions: Optional extra instruction injected as a ``system`` message
|
||||
ahead of the input messages before the agent runs. It becomes part of the original
|
||||
messages, so it is preserved across ``fresh_context`` resets and (with a session)
|
||||
persists server-side across iterations. Used by :meth:`with_judge` to tell the agent
|
||||
about the criteria its response must satisfy, but available to any loop.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``max_iterations`` is not ``None`` and is less than 1.
|
||||
"""
|
||||
if max_iterations is not None and max_iterations < 1:
|
||||
raise ValueError("max_iterations must be None or a positive integer (>= 1).")
|
||||
|
||||
self.max_iterations: int | None = max_iterations
|
||||
self.should_continue: ShouldContinueCallable = should_continue
|
||||
self.next_message = next_message
|
||||
self.record_feedback = record_feedback
|
||||
self.inject_progress = inject_progress
|
||||
self.fresh_context = fresh_context
|
||||
self.return_final_only = return_final_only
|
||||
self.additional_instructions = additional_instructions
|
||||
|
||||
@classmethod
|
||||
def with_judge(
|
||||
cls,
|
||||
judge_client: SupportsChatGetResponse,
|
||||
*,
|
||||
criteria: Sequence[str] | None = None,
|
||||
instructions: str | None = None,
|
||||
max_iterations: int | None = DEFAULT_JUDGE_MAX_ITERATIONS,
|
||||
next_message: NextMessageCallable | None = None,
|
||||
fresh_context: bool = False,
|
||||
) -> Self:
|
||||
"""Create a loop that continues until a judge chat client decides the request was answered.
|
||||
|
||||
Convenience factory for the judge pattern: ``judge_client`` is queried with a
|
||||
:class:`JudgeVerdict` structured-output response after each iteration and the loop continues
|
||||
while the request is *not* answered. The judge's ``reasoning`` is fed back to the agent as
|
||||
the next iteration's input (unless a custom ``next_message`` is provided), so the agent knows
|
||||
why its previous answer was judged incomplete. See :meth:`__init__` for the full meaning of
|
||||
each argument.
|
||||
|
||||
Security considerations:
|
||||
Using a judge is an explicit opt-in — the caller must supply a ``judge_client`` — and
|
||||
introduces a second external LLM boundary in addition to the agent's own model. On every
|
||||
iteration the judge is sent the original request and the agent's latest response, both of
|
||||
which may contain sensitive or untrusted content. A compromised or malicious judge
|
||||
endpoint could exfiltrate that data, or return a manipulated :class:`JudgeVerdict` / gap
|
||||
analysis that is fed back into the loop as feedback, potentially steering the agent via
|
||||
indirect prompt injection. Only configure a ``judge_client`` that points at a service you
|
||||
trust as much as the primary model.
|
||||
|
||||
Args:
|
||||
judge_client: Chat client used to judge whether the original request was answered.
|
||||
**Security:** this client is sent the original request and the agent's latest
|
||||
response on every iteration, so only point it at a service you trust as much as the
|
||||
primary model — see the security considerations above for the exfiltration and
|
||||
prompt-injection risks of an untrusted judge.
|
||||
|
||||
Keyword Args:
|
||||
criteria: Optional list of criteria the response must satisfy. When provided, they are
|
||||
(1) injected as an extra ``system`` instruction for the agent before it runs (via
|
||||
``additional_instructions``) and (2) rendered into the judge instructions wherever
|
||||
the ``{{criteria}}`` placeholder appears (``CRITERIA_PLACEHOLDER``).
|
||||
instructions: Optional system instructions for the judge. Defaults to
|
||||
``DEFAULT_JUDGE_INSTRUCTIONS``. May contain the ``{{criteria}}`` placeholder, which
|
||||
is replaced with the rendered ``criteria`` (or removed when no criteria are given).
|
||||
max_iterations: Maximum number of agent runs. Defaults to
|
||||
``DEFAULT_JUDGE_MAX_ITERATIONS`` (5); pass ``None`` for unbounded, or a positive
|
||||
integer to set a custom cap.
|
||||
next_message: Callable that produces the next iteration's input. Defaults to one that
|
||||
relays the judge's ``reasoning`` back to the agent.
|
||||
fresh_context: When ``True``, each iteration restarts from the original input messages
|
||||
(plus the injected progress log and judge feedback) instead of accumulating the prior
|
||||
conversation; an attached session is snapshotted before the loop and restored to that
|
||||
baseline between iterations. See :meth:`__init__` for the full semantics. Defaults to
|
||||
``False``.
|
||||
"""
|
||||
judge_instructions = (instructions or DEFAULT_JUDGE_INSTRUCTIONS).replace(
|
||||
CRITERIA_PLACEHOLDER, _render_criteria_block(criteria)
|
||||
)
|
||||
should_continue, judge_next_message = _build_judge_condition(judge_client, judge_instructions)
|
||||
return cls(
|
||||
should_continue=should_continue,
|
||||
max_iterations=max_iterations,
|
||||
next_message=next_message or judge_next_message,
|
||||
fresh_context=fresh_context,
|
||||
additional_instructions=_criteria_agent_instruction(criteria) if criteria else None,
|
||||
)
|
||||
|
||||
async def process(
|
||||
self,
|
||||
context: AgentContext,
|
||||
call_next: Callable[[], Awaitable[None]],
|
||||
) -> None:
|
||||
"""Run the wrapped agent in a loop."""
|
||||
if self.additional_instructions is not None:
|
||||
# Inject the extra instruction as a system message ahead of the input so it is present
|
||||
# on every iteration and preserved across fresh_context resets (which restart from
|
||||
# ``original_messages``).
|
||||
context.messages = [
|
||||
Message(role="system", contents=[self.additional_instructions]),
|
||||
*context.messages,
|
||||
]
|
||||
original_messages = list(context.messages)
|
||||
# For a truly fresh context per iteration the session must also be reset, otherwise the
|
||||
# next run reloads the local transcript or re-threads the service-side conversation and the
|
||||
# model still sees the accumulated history. Snapshot the session once here (the pre-loop
|
||||
# baseline) and restore it before each subsequent iteration so every pass starts clean.
|
||||
snapshot = context.session.to_dict() if self.fresh_context and context.session is not None else None
|
||||
if context.stream:
|
||||
self._process_streaming(context, call_next, original_messages, snapshot)
|
||||
else:
|
||||
await self._process_non_streaming(context, call_next, original_messages, snapshot)
|
||||
|
||||
@staticmethod
|
||||
def _has_pending_approval_request(result: AgentResponse | None) -> bool:
|
||||
"""Return ``True`` if ``result`` carries a pending tool-approval request.
|
||||
|
||||
When the loop sits outermost (e.g. around a tool-approval middleware), an iteration may
|
||||
return a response that asks the caller to approve a tool call rather than a completed turn.
|
||||
In that case the loop must stop and hand the response back so a human can approve, instead
|
||||
of continuing or injecting the next message. This mirrors the C# ``LoopAgent`` escape hatch
|
||||
(``HasPendingApprovalRequests``). A pending request is any content whose ``type`` is
|
||||
``"function_approval_request"``.
|
||||
"""
|
||||
if result is None:
|
||||
return False
|
||||
return any(
|
||||
getattr(content, "type", None) == "function_approval_request"
|
||||
for message in result.messages
|
||||
for content in message.contents
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _restore_session(session: Any, snapshot: dict[str, Any]) -> None:
|
||||
"""Restore a session in place to a previously captured ``to_dict()`` snapshot.
|
||||
|
||||
Re-hydrates the snapshot via :meth:`AgentSession.from_dict` and copies the mutable fields
|
||||
(``service_session_id`` and ``state``) back onto the live ``session`` instance, so any
|
||||
reference held by the agent/context observes the reset. ``session_id`` is preserved (the
|
||||
snapshot carries the same id). A fresh ``from_dict`` is built on every call so repeated
|
||||
restores from one snapshot do not alias the same state dict.
|
||||
"""
|
||||
from .._sessions import AgentSession
|
||||
|
||||
restored = AgentSession.from_dict(snapshot)
|
||||
session.service_session_id = restored.service_session_id
|
||||
session.state = restored.state
|
||||
|
||||
async def _process_non_streaming(
|
||||
self,
|
||||
context: AgentContext,
|
||||
call_next: Callable[[], Awaitable[None]],
|
||||
original_messages: list[Message],
|
||||
snapshot: dict[str, Any] | None,
|
||||
) -> None:
|
||||
iteration = 0
|
||||
work_iterations = 0
|
||||
progress: list[str] = []
|
||||
# Aggregated transcript across iterations: each iteration's response messages plus the
|
||||
# injected "nudge" messages, used to build the combined response when return_final_only=False.
|
||||
aggregated: list[Message] = []
|
||||
aggregated_usage: UsageDetails | None = None
|
||||
final_result: AgentResponse | None = None
|
||||
while True:
|
||||
await call_next()
|
||||
iteration += 1
|
||||
|
||||
result = context.result
|
||||
if not isinstance(result, AgentResponse):
|
||||
raise TypeError(
|
||||
"AgentLoopMiddleware expected an AgentResponse from a non-streaming run, "
|
||||
f"got {type(result).__name__}."
|
||||
)
|
||||
|
||||
final_result = result
|
||||
aggregated.extend(result.messages)
|
||||
if result.usage_details is not None:
|
||||
aggregated_usage = add_usage_details(aggregated_usage, result.usage_details)
|
||||
|
||||
# Escape hatch: if this iteration is asking for tool approval, stop and return the
|
||||
# response so the caller can approve, instead of continuing or injecting next_message.
|
||||
if self._has_pending_approval_request(result):
|
||||
break
|
||||
|
||||
messages_used = context.messages
|
||||
loop_kwargs = self._build_loop_kwargs(
|
||||
context=context,
|
||||
iteration=iteration,
|
||||
last_result=result,
|
||||
messages_used=messages_used,
|
||||
original_messages=original_messages,
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
work_iterations += 1
|
||||
# Decide whether to stop and capture any feedback from should_continue first, so the
|
||||
# feedback is available to both the progress and next-message callables this iteration.
|
||||
stop, feedback = await self._evaluate_stop(loop_kwargs, work_iterations)
|
||||
loop_kwargs = self._build_loop_kwargs(
|
||||
context=context,
|
||||
iteration=iteration,
|
||||
last_result=result,
|
||||
messages_used=messages_used,
|
||||
original_messages=original_messages,
|
||||
progress=progress,
|
||||
feedback=feedback,
|
||||
)
|
||||
# Capture this iteration's progress entry, then refresh loop_kwargs so the next-message
|
||||
# resolution sees the latest entry.
|
||||
if await self._record_progress(result, loop_kwargs, progress):
|
||||
loop_kwargs = self._build_loop_kwargs(
|
||||
context=context,
|
||||
iteration=iteration,
|
||||
last_result=result,
|
||||
messages_used=messages_used,
|
||||
original_messages=original_messages,
|
||||
progress=progress,
|
||||
feedback=feedback,
|
||||
)
|
||||
if stop:
|
||||
break
|
||||
if snapshot is not None and context.session is not None:
|
||||
# Reset the session to the pre-loop baseline so the next run starts fresh; only the
|
||||
# progress log (injected by _resolve_next_message) carries continuity forward.
|
||||
self._restore_session(context.session, snapshot)
|
||||
next_messages = await self._resolve_next_message(loop_kwargs, messages_used, original_messages)
|
||||
context.messages = next_messages
|
||||
aggregated.extend(next_messages)
|
||||
|
||||
if not self.return_final_only:
|
||||
context.result = self._aggregate_response(final_result, aggregated, aggregated_usage)
|
||||
|
||||
def _process_streaming(
|
||||
self,
|
||||
context: AgentContext,
|
||||
call_next: Callable[[], Awaitable[None]],
|
||||
original_messages: list[Message],
|
||||
snapshot: dict[str, Any] | None,
|
||||
) -> None:
|
||||
# Holds the last iteration's final response so the outer stream's finalizer can return it
|
||||
# rather than an aggregate of every iteration.
|
||||
holder: dict[str, AgentResponse | None] = {"final": None}
|
||||
|
||||
async def _generator() -> Any:
|
||||
iteration = 0
|
||||
work_iterations = 0
|
||||
progress: list[str] = []
|
||||
while True:
|
||||
try:
|
||||
await call_next()
|
||||
inner = context.result
|
||||
if not isinstance(inner, ResponseStream):
|
||||
raise TypeError(
|
||||
"AgentLoopMiddleware expected a ResponseStream from a streaming run, "
|
||||
f"got {type(inner).__name__}."
|
||||
)
|
||||
|
||||
async for update in inner:
|
||||
yield update
|
||||
|
||||
holder["final"] = await inner.get_final_response()
|
||||
except MiddlewareTermination:
|
||||
# The pipeline's MiddlewareTermination suppression is no longer active once
|
||||
# process() has returned (the stream is consumed lazily), so a termination
|
||||
# raised by a downstream middleware or during stream consumption surfaces here.
|
||||
# Stop cleanly and keep whatever final response we have from a prior iteration.
|
||||
return
|
||||
|
||||
iteration += 1
|
||||
|
||||
messages_used = context.messages
|
||||
final = holder["final"]
|
||||
# Escape hatch: if this iteration is asking for tool approval, stop the loop and
|
||||
# let the caller approve, instead of continuing or injecting next_message.
|
||||
if self._has_pending_approval_request(final):
|
||||
return
|
||||
loop_kwargs = self._build_loop_kwargs(
|
||||
context=context,
|
||||
iteration=iteration,
|
||||
last_result=final,
|
||||
messages_used=messages_used,
|
||||
original_messages=original_messages,
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
work_iterations += 1
|
||||
# Decide whether to stop and capture any feedback from should_continue first, so the
|
||||
# feedback is available to both the progress and next-message callables this iteration.
|
||||
stop, feedback = await self._evaluate_stop(loop_kwargs, work_iterations)
|
||||
loop_kwargs = self._build_loop_kwargs(
|
||||
context=context,
|
||||
iteration=iteration,
|
||||
last_result=final,
|
||||
messages_used=messages_used,
|
||||
original_messages=original_messages,
|
||||
progress=progress,
|
||||
feedback=feedback,
|
||||
)
|
||||
if await self._record_progress(final, loop_kwargs, progress):
|
||||
loop_kwargs = self._build_loop_kwargs(
|
||||
context=context,
|
||||
iteration=iteration,
|
||||
last_result=final,
|
||||
messages_used=messages_used,
|
||||
original_messages=original_messages,
|
||||
progress=progress,
|
||||
feedback=feedback,
|
||||
)
|
||||
if stop:
|
||||
return
|
||||
if snapshot is not None and context.session is not None:
|
||||
# Reset the session to the pre-loop baseline before the next run. The final
|
||||
# response was already awaited above, so the service-side conversation id has
|
||||
# been propagated and is safe to discard here.
|
||||
self._restore_session(context.session, snapshot)
|
||||
next_messages = await self._resolve_next_message(loop_kwargs, messages_used, original_messages)
|
||||
context.messages = next_messages
|
||||
# Surface the injected "nudge" messages in the stream so consumers see the user
|
||||
# turns that drive each subsequent iteration (the equivalent of the aggregated
|
||||
# transcript that non-streaming runs return).
|
||||
for message in next_messages:
|
||||
yield self._message_to_update(message)
|
||||
|
||||
def _finalize(updates: Sequence[AgentResponseUpdate]) -> AgentResponse:
|
||||
if holder["final"] is not None:
|
||||
return holder["final"]
|
||||
return AgentResponse.from_updates(updates)
|
||||
|
||||
context.result = ResponseStream(_generator(), finalizer=_finalize)
|
||||
|
||||
def _build_loop_kwargs(
|
||||
self,
|
||||
*,
|
||||
context: AgentContext,
|
||||
iteration: int,
|
||||
last_result: AgentResponse | None,
|
||||
messages_used: list[Message],
|
||||
original_messages: list[Message],
|
||||
progress: list[str],
|
||||
feedback: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"iteration": iteration,
|
||||
"last_result": last_result,
|
||||
"messages": messages_used,
|
||||
"original_messages": original_messages,
|
||||
"session": context.session,
|
||||
"agent": context.agent,
|
||||
# A copy so user callbacks cannot mutate the loop's internal progress log.
|
||||
"progress": list(progress),
|
||||
# Feedback returned by ``should_continue`` for this iteration (``None`` if it returned a
|
||||
# plain bool, or the stop was decided by ``max_iterations``).
|
||||
"feedback": feedback,
|
||||
}
|
||||
|
||||
async def _record_progress(
|
||||
self,
|
||||
last_result: AgentResponse | None,
|
||||
loop_kwargs: dict[str, Any],
|
||||
progress: list[str],
|
||||
) -> bool:
|
||||
"""Capture this iteration's feedback into ``progress``. Returns ``True`` if an entry was added."""
|
||||
if self.record_feedback is not None:
|
||||
entry = await _maybe_await(self.record_feedback(**loop_kwargs))
|
||||
else:
|
||||
entry = last_result.text.strip() if last_result is not None else None
|
||||
if entry:
|
||||
progress.append(entry)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _evaluate_stop(self, loop_kwargs: dict[str, Any], work_iterations: int) -> tuple[bool, str | None]:
|
||||
"""Decide whether the loop should stop, returning ``(stop, feedback)``.
|
||||
|
||||
``max_iterations`` is a safety cap that short-circuits before ``should_continue`` is
|
||||
evaluated (so an expensive predicate/judge is not called once the cap has fired). Any
|
||||
feedback returned by ``should_continue`` is propagated so the progress and next-message
|
||||
callables can reference it.
|
||||
"""
|
||||
if self.max_iterations is not None and work_iterations >= self.max_iterations:
|
||||
return True, None
|
||||
keep_going, feedback = await self._should_continue(loop_kwargs)
|
||||
return (not keep_going), feedback
|
||||
|
||||
async def _should_continue(self, loop_kwargs: dict[str, Any]) -> tuple[bool, str | None]:
|
||||
"""Evaluate the predicate, normalizing its result to ``(continue, feedback)``."""
|
||||
result = await _maybe_await(self.should_continue(**loop_kwargs))
|
||||
return (bool(result[0]), result[1]) if isinstance(result, tuple) else (bool(result), None) # type: ignore
|
||||
|
||||
@staticmethod
|
||||
def _message_to_update(message: Message) -> AgentResponseUpdate:
|
||||
"""Wrap an injected loop message as a streaming update so consumers see it inline."""
|
||||
return AgentResponseUpdate(
|
||||
contents=message.contents,
|
||||
role=message.role,
|
||||
author_name=message.author_name,
|
||||
message_id=message.message_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _aggregate_response(
|
||||
final: AgentResponse,
|
||||
messages: list[Message],
|
||||
usage: UsageDetails | None,
|
||||
) -> AgentResponse:
|
||||
"""Build a combined response carrying every iteration's messages and summed usage.
|
||||
|
||||
Metadata (``response_id``, structured ``value``, etc.) is taken from the final iteration; the
|
||||
structured value is passed through pre-parsed so it is not re-derived from the aggregated text.
|
||||
"""
|
||||
return AgentResponse(
|
||||
messages=messages,
|
||||
response_id=final.response_id,
|
||||
agent_id=final.agent_id,
|
||||
created_at=final.created_at,
|
||||
finish_reason=final.finish_reason, # pyright: ignore[reportArgumentType]
|
||||
usage_details=usage,
|
||||
value=final.value,
|
||||
additional_properties=dict(final.additional_properties) if final.additional_properties else None,
|
||||
raw_representation=final.raw_representation,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _render_progress(entries: list[str]) -> Message:
|
||||
"""Format progress-log entries into a single ``user`` message."""
|
||||
body = "\n".join(f"- {entry}" for entry in entries)
|
||||
return Message(role="user", contents=[f"Progress so far:\n{body}"])
|
||||
|
||||
async def _resolve_next_message(
|
||||
self,
|
||||
loop_kwargs: dict[str, Any],
|
||||
messages_used: list[Message],
|
||||
original_messages: list[Message],
|
||||
) -> list[Message]:
|
||||
# Compute the base next input. A ``next_message`` callable returning None requests a verbatim
|
||||
# reuse of the previous messages (no progress injection); in fresh-context mode that escape
|
||||
# hatch does not apply, so fall back to the default nudge instead.
|
||||
if self.next_message is None:
|
||||
next_msgs = normalize_messages(DEFAULT_NEXT_MESSAGE)
|
||||
else:
|
||||
next_input = await _maybe_await(self.next_message(**loop_kwargs))
|
||||
if next_input is None:
|
||||
if not self.fresh_context:
|
||||
return list(messages_used)
|
||||
next_msgs = normalize_messages(DEFAULT_NEXT_MESSAGE)
|
||||
else:
|
||||
next_msgs = normalize_messages(next_input)
|
||||
|
||||
progress: list[str] = loop_kwargs.get("progress") or []
|
||||
session = loop_kwargs.get("session")
|
||||
progress_msg: Message | None = None
|
||||
if self.inject_progress and progress:
|
||||
# With a session the earlier entries are already retained in the conversation, so only
|
||||
# the latest entry is injected to avoid duplication. Otherwise inject the full log.
|
||||
entries = progress if (session is None or self.fresh_context) else progress[-1:]
|
||||
progress_msg = self._render_progress(entries)
|
||||
|
||||
if self.fresh_context:
|
||||
result = list(original_messages)
|
||||
if progress_msg is not None:
|
||||
result.append(progress_msg)
|
||||
result.extend(next_msgs)
|
||||
return result
|
||||
|
||||
if progress_msg is not None:
|
||||
return [progress_msg, *next_msgs]
|
||||
return list(next_msgs)
|
||||
|
||||
|
||||
def _running_background_tasks(session: Any, agent: Any) -> list[Any]:
|
||||
"""Return the still-running ``BackgroundTaskInfo`` entries for the agent's provider.
|
||||
|
||||
Resolves the :class:`~agent_framework.BackgroundAgentsProvider` from the running agent
|
||||
(``agent.context_providers``) and reads its persisted task state. Returns an empty list when the
|
||||
session/agent/provider is unavailable or no task is currently running.
|
||||
"""
|
||||
from ._background_agents import BackgroundAgentsProvider, BackgroundTaskInfo, BackgroundTaskStatus
|
||||
|
||||
if session is None or agent is None:
|
||||
return []
|
||||
provider = _resolve_context_provider(agent, BackgroundAgentsProvider)
|
||||
if provider is None:
|
||||
return []
|
||||
state = session.state.get(provider.source_id)
|
||||
if not state:
|
||||
return []
|
||||
tasks = [BackgroundTaskInfo.from_dict(task) for task in state.get("tasks", [])]
|
||||
return [task for task in tasks if task.status == BackgroundTaskStatus.RUNNING]
|
||||
|
||||
|
||||
def background_tasks_running() -> ShouldContinueCallable:
|
||||
"""Build a ``should_continue`` predicate that loops while the agent's background tasks are busy.
|
||||
|
||||
This resolves the :class:`~agent_framework.BackgroundAgentsProvider` from the running agent
|
||||
(``agent.context_providers``).
|
||||
|
||||
The predicate inspects the provider's persisted task state and continues while any task is still
|
||||
marked as running. Pair it with ``max_iterations`` so the loop is guaranteed to stop even if a
|
||||
task's persisted status is never refreshed.
|
||||
|
||||
Returns:
|
||||
A predicate suitable for :class:`AgentLoopMiddleware`'s ``should_continue`` argument (and for
|
||||
``create_harness_agent``'s ``loop_should_continue``).
|
||||
"""
|
||||
|
||||
def _should_continue(*, session: Any = None, agent: Any = None, **kwargs: Any) -> bool:
|
||||
return bool(_running_background_tasks(session, agent))
|
||||
|
||||
return _should_continue
|
||||
|
||||
|
||||
def background_tasks_running_message(*, session: Any = None, agent: Any = None, **kwargs: Any) -> str | None:
|
||||
"""``next_message`` callable that reminds the agent which background tasks are still running.
|
||||
|
||||
Designed to pair with :func:`background_tasks_running` as a loop's ``next_message`` (e.g.
|
||||
``create_harness_agent``'s ``loop_next_message``): between iterations it resolves the
|
||||
:class:`~agent_framework.BackgroundAgentsProvider` from the agent, lists the still-running tasks,
|
||||
and instructs the agent to wait for them to finish (and retrieve their results) before finishing.
|
||||
|
||||
Returns ``None`` when the session/agent/provider is unavailable or no task is running. In that
|
||||
case the loop's default ``next_message`` handling applies. In normal looping a ``None`` here is
|
||||
rare, since "no running tasks" also makes :func:`background_tasks_running` stop the loop before
|
||||
the next message is consulted.
|
||||
"""
|
||||
running = _running_background_tasks(session, agent)
|
||||
if not running:
|
||||
return None
|
||||
task_lines = "\n".join(f"- #{task.id} ({task.agent_name}): {task.description}" for task in running)
|
||||
return (
|
||||
f"You still have {len(running)} background task(s) running that must finish before you can "
|
||||
f"complete the work:\n{task_lines}\n\n"
|
||||
"Wait for these tasks to complete, retrieve their results, and incorporate them. Only stop "
|
||||
"once every background task has finished."
|
||||
)
|
||||
|
||||
|
||||
def _resolve_context_provider(agent: Any, provider_type: type) -> Any:
|
||||
"""Return the first ``provider_type`` instance on ``agent.context_providers`` (or ``None``).
|
||||
|
||||
The harness exposes its built-in context providers (``TodoProvider``, ``AgentModeProvider``,
|
||||
...) on ``agent.context_providers``, so loop callbacks can reuse the same instances that
|
||||
:func:`~agent_framework.create_harness_agent` wired up instead of constructing their own.
|
||||
"""
|
||||
return next(
|
||||
(provider for provider in getattr(agent, "context_providers", []) if isinstance(provider, provider_type)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def todos_remaining(*, looping_modes: Sequence[str] | None = None) -> ShouldContinueCallable:
|
||||
"""Build a ``should_continue`` predicate that loops while the Agent's ``TodoProvider`` has open items.
|
||||
|
||||
This resolves the :class:`~agent_framework.TodoProvider` from the running agent
|
||||
(``agent.context_providers``) rather than taking it as an argument, so it can be used directly
|
||||
with :func:`~agent_framework.create_harness_agent` (whose providers are built internally) as well
|
||||
as with any agent that registers a ``TodoProvider`` via ``context_providers``. It is the Python
|
||||
counterpart of the .NET ``TodoCompletionLoopEvaluator``.
|
||||
|
||||
Args:
|
||||
looping_modes: When provided, the loop only continues while the agent's current operating
|
||||
mode (read from its :class:`~agent_framework.AgentModeProvider`) is one of these modes;
|
||||
in any other mode the predicate returns ``False`` so the agent stays interactive. Mode
|
||||
matching is case-insensitive. When ``None`` (default), the loop applies in every mode. An
|
||||
empty sequence is rejected (there would be no mode in which the loop could ever run).
|
||||
Restricting looping to certain modes is useful when, for example, the agent has a planning
|
||||
and execution mode, and you only want to loop on the execution mode until all todos are
|
||||
complete. Looping until completion in planning is usually undesirable since the agent is
|
||||
still building the list of todos to complete.
|
||||
|
||||
Returns:
|
||||
A predicate suitable for :class:`AgentLoopMiddleware`'s ``should_continue`` argument (and for
|
||||
``create_harness_agent``'s ``loop_should_continue``).
|
||||
|
||||
Raises:
|
||||
ValueError: ``looping_modes`` is an empty sequence.
|
||||
"""
|
||||
if looping_modes is not None:
|
||||
allowed_modes: set[str] | None = {mode.strip().lower() for mode in looping_modes}
|
||||
if not allowed_modes:
|
||||
raise ValueError("looping_modes must be None or a non-empty sequence of mode names.")
|
||||
else:
|
||||
allowed_modes = None
|
||||
|
||||
async def _should_continue(*, session: Any = None, agent: Any = None, **kwargs: Any) -> bool:
|
||||
from ._mode import AgentModeProvider, get_agent_mode
|
||||
from ._todo import TodoProvider
|
||||
|
||||
if session is None or agent is None:
|
||||
return False
|
||||
|
||||
if allowed_modes is not None:
|
||||
mode_provider = _resolve_context_provider(agent, AgentModeProvider)
|
||||
if mode_provider is not None:
|
||||
current_mode = get_agent_mode(
|
||||
session,
|
||||
source_id=mode_provider.source_id,
|
||||
default_mode=mode_provider.default_mode,
|
||||
available_modes=mode_provider.available_modes,
|
||||
)
|
||||
else:
|
||||
current_mode = get_agent_mode(session)
|
||||
if current_mode.strip().lower() not in allowed_modes:
|
||||
return False
|
||||
|
||||
todo_provider = _resolve_context_provider(agent, TodoProvider)
|
||||
if todo_provider is None:
|
||||
return False
|
||||
items = await todo_provider.store.load_items(session, source_id=todo_provider.source_id)
|
||||
return any(not item.is_complete for item in items)
|
||||
|
||||
return _should_continue
|
||||
|
||||
|
||||
async def todos_remaining_message(*, session: Any = None, agent: Any = None, **kwargs: Any) -> str | None:
|
||||
"""``next_message`` callable that reminds the agent which todos are still open.
|
||||
|
||||
Designed to pair with :func:`todos_remaining` as a loop's ``next_message`` (e.g.
|
||||
``create_harness_agent``'s ``loop_next_message``): between iterations it resolves the harness
|
||||
:class:`~agent_framework.TodoProvider` from the agent, lists the still-open todo items, and
|
||||
instructs the agent to complete them all before finishing.
|
||||
|
||||
Returns ``None`` when the session/agent/provider is unavailable or no todos are open. In that
|
||||
case the loop's default ``next_message`` handling applies: with ``fresh_context=False`` (the
|
||||
default, used by ``create_harness_agent``) it reuses the previous iteration's messages verbatim
|
||||
(skipping progress injection); only with ``fresh_context=True`` does it fall back to
|
||||
``DEFAULT_NEXT_MESSAGE``. In normal looping a ``None`` here is rare, since "no open todos" also
|
||||
makes :func:`todos_remaining` stop the loop before the next message is consulted.
|
||||
"""
|
||||
from ._todo import TodoProvider
|
||||
|
||||
if session is None or agent is None:
|
||||
return None
|
||||
todo_provider = _resolve_context_provider(agent, TodoProvider)
|
||||
if todo_provider is None:
|
||||
return None
|
||||
items = await todo_provider.store.load_items(session, source_id=todo_provider.source_id)
|
||||
open_items = [item for item in items if not item.is_complete]
|
||||
if not open_items:
|
||||
return None
|
||||
todo_lines = "\n".join(f"- {item.title}" for item in open_items)
|
||||
return (
|
||||
f"You still have {len(open_items)} open todo item(s) that must be addressed before you can "
|
||||
f"finish:\n{todo_lines}\n\n"
|
||||
"Continue working through them now. Mark each todo complete as you finish it, and only stop "
|
||||
"once every todo item is complete."
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,325 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, cast
|
||||
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._sessions import AgentSession, ContextProvider, SessionContext
|
||||
from .._tools import tool
|
||||
from .._types import Message
|
||||
|
||||
DEFAULT_MODE_SOURCE_ID = "agent_mode"
|
||||
DEFAULT_MODE_INSTRUCTIONS = (
|
||||
"## Agent Mode\n\n"
|
||||
"- You can operate in different modes. Depending on the mode you are in, "
|
||||
"you will be required to follow different processes.\n\n"
|
||||
"Use the mode_get tool to check your current operating mode.\n"
|
||||
"Use the mode_set tool to switch between modes as your work progresses. "
|
||||
"Only use mode_set if the user explicitly instructs/allows you to change modes.\n\n"
|
||||
"You are currently operating in the {current_mode} mode.\n\n"
|
||||
"### Mandatory Mode based Workflow\n\n"
|
||||
"For every new substantive user request, including short factual questions, "
|
||||
"your behavior is determined by the mode you are in.\n\n"
|
||||
"{available_modes}\n"
|
||||
)
|
||||
DEFAULT_MODE_CHANGE_NOTIFICATION = (
|
||||
'[Mode changed: The operating mode has been switched from "{previous_mode}" to "{current_mode}". '
|
||||
'You must now adjust your behavior to match the "{current_mode}" mode.]'
|
||||
)
|
||||
DEFAULT_MODE_DESCRIPTIONS: dict[str, str] = {
|
||||
"plan": (
|
||||
"Use this mode when analyzing requirements, breaking down tasks, and creating plans. "
|
||||
"This is the interactive mode — ask clarifying questions, discuss options, and get user approval before "
|
||||
"proceeding.\n\n"
|
||||
"Process to follow when in plan mode:\n"
|
||||
"1. Analyze the request with the purpose of building a research plan.\n"
|
||||
"2. Create a list of todo items.\n"
|
||||
"3. If needed, use the provided tools to do some exploratory checks to help build a plan and determine "
|
||||
"what clarifying questions you may need from the user.\n"
|
||||
"4. Ask for clarifications from the user where needed.\n"
|
||||
" 1. Ask each clarification one by one.\n"
|
||||
" 2. When asking for clarification and you have specific options in mind, present them to the user, "
|
||||
"so they can choose the option instead of having to retype the entire response.\n"
|
||||
" 3. Do not proceed until you have received all the needed clarifications.\n"
|
||||
" 4. Do short exploratory research if it helps with being able to ask sensible clarifications from "
|
||||
"the user.\n"
|
||||
"5. Write the plan to a memory file, so that it is retained even if compaction happens. "
|
||||
"Make sure to update the plan file if the user requests changes.\n"
|
||||
"6. Present the plan to the user and ask for approval to switch to execute mode and process the plan.\n"
|
||||
"7. When approval is granted, always switch to execute mode (using the `mode_set` tool), "
|
||||
"and follow the steps for *Execute mode*."
|
||||
),
|
||||
"execute": (
|
||||
"Determine the type of ask:\n"
|
||||
"1. Simple question that doesn't require any further work to answer.\n"
|
||||
"2. Any other work, including complex user request that requires a multi-step process to satisfy.\n\n"
|
||||
"If 1. just answer the question directly.\n"
|
||||
"If 2. Work autonomously using your best judgment — do not ask the user questions or wait for feedback "
|
||||
"and follow the following process:\n"
|
||||
"1. If you don't have a plan or tasks yet, analyze the user request and create tasks and a plan. "
|
||||
"(**Skip this step if you came from plan mode**)\n"
|
||||
"2. Work autonomously — use your best judgment to make decisions and keep progressing without asking "
|
||||
"the user questions. The goal is to have a complete, useful result ready when the user returns.\n"
|
||||
"3. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable "
|
||||
"option, note your choice, and keep going.\n"
|
||||
"4. Mark tasks as completed as you finish them.\n"
|
||||
"5. Continue working, thinking and calling tools until you have the research result for the user."
|
||||
),
|
||||
}
|
||||
|
||||
_PREVIOUS_MODE_STATE_KEY = "previous_mode_for_notification"
|
||||
|
||||
|
||||
def _get_mode_state(session: AgentSession, *, source_id: str) -> dict[str, Any]:
|
||||
"""Return the mutable session state used by the mode provider."""
|
||||
provider_state = session.state.get(source_id)
|
||||
if isinstance(provider_state, dict):
|
||||
return cast(dict[str, Any], provider_state)
|
||||
if provider_state is not None:
|
||||
raise TypeError(
|
||||
f"Session state for source_id {source_id!r} must be a dict, got {type(provider_state).__name__}."
|
||||
)
|
||||
state: dict[str, Any] = {}
|
||||
session.state[source_id] = state
|
||||
return state
|
||||
|
||||
|
||||
def _normalize_available_modes(available_modes: Sequence[str]) -> dict[str, str]:
|
||||
"""Return normalized mode names mapped to display names."""
|
||||
normalized_modes: dict[str, str] = {}
|
||||
for mode in available_modes:
|
||||
display_mode = mode.strip()
|
||||
normalized_mode = display_mode.lower()
|
||||
if normalized_mode in normalized_modes:
|
||||
raise ValueError(f"Duplicate mode configured: {mode}.")
|
||||
normalized_modes[normalized_mode] = display_mode
|
||||
return normalized_modes
|
||||
|
||||
|
||||
def _normalize_mode(mode: str, *, available_modes: Mapping[str, str]) -> str:
|
||||
"""Validate and normalize a mode string."""
|
||||
normalized = mode.strip().lower()
|
||||
if normalized not in available_modes:
|
||||
supported_modes = ", ".join(repr(item) for item in available_modes.values())
|
||||
raise ValueError(f"Invalid mode: {mode}. Supported modes are {supported_modes}.")
|
||||
return normalized
|
||||
|
||||
|
||||
def _resolve_default_mode(default_mode: str | None, *, available_modes: Mapping[str, str]) -> str:
|
||||
"""Resolve the default mode, falling back to the first configured mode when omitted."""
|
||||
if default_mode is None:
|
||||
return next(iter(available_modes))
|
||||
return _normalize_mode(default_mode, available_modes=available_modes)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
def get_agent_mode(
|
||||
session: AgentSession,
|
||||
*,
|
||||
source_id: str = DEFAULT_MODE_SOURCE_ID,
|
||||
default_mode: str | None = None,
|
||||
available_modes: Sequence[str] | None = None,
|
||||
) -> str:
|
||||
"""Get the current operating mode from session state.
|
||||
|
||||
Args:
|
||||
session: The agent session to read the mode from.
|
||||
|
||||
Keyword Args:
|
||||
source_id: Unique source ID for the provider state.
|
||||
default_mode: Initial mode used when no mode is stored yet. When omitted, the first entry of
|
||||
``available_modes`` is used.
|
||||
available_modes: Supported modes to validate against. Defaults to the built-in modes.
|
||||
|
||||
Returns:
|
||||
The current mode string.
|
||||
"""
|
||||
normalized_modes = _normalize_available_modes(tuple(available_modes or DEFAULT_MODE_DESCRIPTIONS))
|
||||
normalized_default_mode = _resolve_default_mode(default_mode, available_modes=normalized_modes)
|
||||
provider_state = _get_mode_state(session, source_id=source_id)
|
||||
current_mode = provider_state.get("current_mode")
|
||||
if isinstance(current_mode, str):
|
||||
try:
|
||||
return _normalize_mode(current_mode, available_modes=normalized_modes)
|
||||
except ValueError:
|
||||
# Stored mode is no longer in the configured set (e.g. available_modes was reconfigured).
|
||||
# Fall through and reset to the default mode.
|
||||
pass
|
||||
provider_state["current_mode"] = normalized_default_mode
|
||||
return normalized_default_mode
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
def set_agent_mode(
|
||||
session: AgentSession,
|
||||
mode: str,
|
||||
*,
|
||||
source_id: str = DEFAULT_MODE_SOURCE_ID,
|
||||
available_modes: Sequence[str] | None = None,
|
||||
) -> str:
|
||||
"""Set the current operating mode in session state.
|
||||
|
||||
External callers (e.g., a slash-command handler) should use this helper rather than mutating
|
||||
session state directly. When the mode actually changes, the prior mode is recorded so that the
|
||||
next :meth:`AgentModeProvider.before_run` invocation injects a user message announcing the
|
||||
switch — system instructions alone are insufficient to redirect a model that has already seen
|
||||
its own ``set_mode`` tool call earlier in the chat history.
|
||||
|
||||
Args:
|
||||
session: The agent session to update the mode in.
|
||||
mode: The new mode to set.
|
||||
|
||||
Keyword Args:
|
||||
source_id: Unique source ID for the provider state.
|
||||
available_modes: Supported modes to validate against. Defaults to the built-in modes.
|
||||
|
||||
Returns:
|
||||
The normalized mode string that was stored.
|
||||
|
||||
Raises:
|
||||
ValueError: The requested mode is not configured.
|
||||
"""
|
||||
normalized_modes = _normalize_available_modes(tuple(available_modes or DEFAULT_MODE_DESCRIPTIONS))
|
||||
normalized_mode = _normalize_mode(mode, available_modes=normalized_modes)
|
||||
provider_state = _get_mode_state(session, source_id=source_id)
|
||||
previous_mode = provider_state.get("current_mode")
|
||||
provider_state["current_mode"] = normalized_mode
|
||||
# When the mode is changed externally (i.e. not via the agent's own ``set_mode`` tool), record the
|
||||
# prior mode so the next ``before_run`` can inject a user message announcing the switch. Without
|
||||
# that injection, the model often anchors on the earlier ``set_mode`` tool call in the chat history
|
||||
# and keeps behaving as if it were still in that mode — system instructions alone are insufficient.
|
||||
if isinstance(previous_mode, str) and previous_mode != normalized_mode:
|
||||
provider_state[_PREVIOUS_MODE_STATE_KEY] = previous_mode
|
||||
return normalized_mode
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class AgentModeProvider(ContextProvider):
|
||||
"""Track the agent's operating mode in session state and provide mode tools.
|
||||
|
||||
The ``AgentModeProvider`` enables agents to operate in distinct modes during long-running complex tasks.
|
||||
The current mode is persisted in the ``AgentSession`` state and is included in the instructions provided to the
|
||||
agent on each invocation.
|
||||
|
||||
The set of available modes is configurable with ``mode_descriptions``. By default, two modes are provided:
|
||||
``"plan"`` (interactive planning) and ``"execute"`` (autonomous execution).
|
||||
|
||||
This provider exposes the following tools to the agent:
|
||||
- ``mode_set``: Switch the agent's operating mode.
|
||||
- ``mode_get``: Retrieve the agent's current operating mode.
|
||||
|
||||
Public helper functions ``get_agent_mode`` and ``set_agent_mode`` allow external code to programmatically read
|
||||
and change the mode.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_id: str = DEFAULT_MODE_SOURCE_ID,
|
||||
*,
|
||||
default_mode: str | None = None,
|
||||
mode_descriptions: Mapping[str, str] | None = None,
|
||||
instructions: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize a new agent mode provider.
|
||||
|
||||
Args:
|
||||
source_id: Unique source ID for the provider.
|
||||
|
||||
Keyword Args:
|
||||
default_mode: Initial mode used when no mode is stored yet. When omitted, the first entry of
|
||||
``mode_descriptions`` is used.
|
||||
mode_descriptions: Mapping of supported modes to descriptions of when and how to use each mode.
|
||||
instructions: Custom instructions for using the mode tools. The instructions can contain an
|
||||
``{available_modes}`` placeholder for the configured list of modes and a ``{current_mode}`` placeholder
|
||||
for the currently active mode. When omitted, the provider uses a default set of instructions.
|
||||
|
||||
Raises:
|
||||
ValueError: No modes are configured, or the default mode is not configured.
|
||||
"""
|
||||
super().__init__(source_id)
|
||||
mode_descriptions = dict(DEFAULT_MODE_DESCRIPTIONS if mode_descriptions is None else mode_descriptions)
|
||||
self._mode_display_names = _normalize_available_modes(tuple(mode_descriptions))
|
||||
if not self._mode_display_names:
|
||||
raise ValueError("mode_descriptions must contain at least one mode.")
|
||||
self.mode_descriptions = {mode.strip().lower(): description for mode, description in mode_descriptions.items()}
|
||||
self.available_modes = tuple(self._mode_display_names)
|
||||
self.default_mode = _resolve_default_mode(default_mode, available_modes=self._mode_display_names)
|
||||
self.instructions = instructions
|
||||
|
||||
def _build_instructions(self, current_mode: str) -> str:
|
||||
"""Build the mode guidance injected for the current session."""
|
||||
mode_lines = "".join(
|
||||
f"#### {self._mode_display_names[mode]}\n\n{description}\n\n"
|
||||
for mode, description in self.mode_descriptions.items()
|
||||
)
|
||||
instructions = self.instructions or DEFAULT_MODE_INSTRUCTIONS
|
||||
return instructions.replace("{available_modes}", mode_lines).replace("{current_mode}", current_mode)
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Inject mode tools and instructions before the model runs.
|
||||
|
||||
Args:
|
||||
agent: The agent being invoked.
|
||||
session: The agent session whose state stores the current mode.
|
||||
context: The session context to receive instructions and tools.
|
||||
state: Per-provider invocation state.
|
||||
"""
|
||||
del agent, state
|
||||
current_mode = get_agent_mode(
|
||||
session,
|
||||
source_id=self.source_id,
|
||||
default_mode=self.default_mode,
|
||||
available_modes=self.available_modes,
|
||||
)
|
||||
# Pop the external-mode-change marker (set by ``set_agent_mode``) before injecting tools so
|
||||
# the agent only sees the notification once.
|
||||
provider_state = _get_mode_state(session, source_id=self.source_id)
|
||||
previous_mode = provider_state.pop(_PREVIOUS_MODE_STATE_KEY, None)
|
||||
|
||||
@tool(name="mode_set", approval_mode="never_require")
|
||||
def mode_set(mode: str) -> str:
|
||||
"""Switch the agent's operating mode."""
|
||||
# The agent invoked the tool itself, so it knows the mode just changed — bypass
|
||||
# ``set_agent_mode`` to avoid triggering a notification message on the next turn.
|
||||
normalized_mode = _normalize_mode(mode, available_modes=self._mode_display_names)
|
||||
tool_state = _get_mode_state(session, source_id=self.source_id)
|
||||
tool_state["current_mode"] = normalized_mode
|
||||
return json.dumps({"mode": normalized_mode, "message": f"Mode changed to '{normalized_mode}'."})
|
||||
|
||||
@tool(name="mode_get", approval_mode="never_require")
|
||||
def mode_get() -> str:
|
||||
"""Get the agent's current operating mode."""
|
||||
current_mode_value = get_agent_mode(
|
||||
session,
|
||||
source_id=self.source_id,
|
||||
default_mode=self.default_mode,
|
||||
available_modes=self.available_modes,
|
||||
)
|
||||
return json.dumps({"mode": current_mode_value})
|
||||
|
||||
context.extend_instructions(
|
||||
self.source_id,
|
||||
[self._build_instructions(current_mode)],
|
||||
)
|
||||
context.extend_tools(self.source_id, [mode_set, mode_get])
|
||||
if isinstance(previous_mode, str) and previous_mode != current_mode:
|
||||
# Inject a user-role message announcing the external mode change. System instructions
|
||||
# always render first in the chat history, so the agent can otherwise stay anchored to
|
||||
# the most recent ``mode_set`` tool call rather than the new mode.
|
||||
previous_display = self._mode_display_names.get(previous_mode, previous_mode)
|
||||
current_display = self._mode_display_names.get(current_mode, current_mode)
|
||||
notification = DEFAULT_MODE_CHANGE_NOTIFICATION.format(
|
||||
previous_mode=previous_display,
|
||||
current_mode=current_display,
|
||||
)
|
||||
context.extend_messages(self, [Message(role="user", contents=[notification])])
|
||||
@@ -0,0 +1,619 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import weakref
|
||||
from abc import ABC, abstractmethod
|
||||
from base64 import urlsafe_b64encode
|
||||
from collections.abc import Mapping, MutableMapping
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar, cast
|
||||
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._serialization import SerializationMixin
|
||||
from .._sessions import AgentSession, ContextProvider, SessionContext
|
||||
from .._tools import tool
|
||||
from .._types import Message
|
||||
|
||||
DEFAULT_TODO_SOURCE_ID = "todo"
|
||||
DEFAULT_TODO_INSTRUCTIONS = (
|
||||
"## Todo Items\n\n"
|
||||
"You have access to a todo list for tracking work items.\n"
|
||||
"When a user asks you to perform a task, follow these steps to manage your work:\n"
|
||||
"1. Determine whether the ask requires multiple steps to complete (complex) or can be completed "
|
||||
"using a single step (simple).\n"
|
||||
"2. If complex, turn the task into manageable todo items and add them to the list.\n"
|
||||
"3. If simple, don't add a todo item, but rather just complete the task directly.\n\n"
|
||||
"### General TODO Guidelines\n"
|
||||
"Ask questions from the user where clarification is needed to create effective todos.\n"
|
||||
"If the user provides feedback on your plan, adjust your todos accordingly by adding new items "
|
||||
"or removing irrelevant ones.\n"
|
||||
"During execution, use the todo list to keep track of what needs to be done, "
|
||||
"mark items as complete when finished, and remove any items that are no longer needed.\n"
|
||||
"When a user changes the topic, changes their mind or switches to a new request, ensure that you update "
|
||||
"the todo list accordingly by removing irrelevant/old items, clearing the list, or adding new ones as needed.\n\n"
|
||||
"Use these tools to manage your tasks:\n"
|
||||
"- Use todos_add to break down complex work into trackable items (supports adding one or many at once).\n"
|
||||
"- Use todos_complete to mark items as done when finished (supports one or many at once). "
|
||||
"Include a reason describing how the items were completed.\n"
|
||||
"- Use todos_get_remaining to check what work is still pending.\n"
|
||||
"- Use todos_get_all to review the full list including completed items.\n"
|
||||
"- Use todos_remove to remove items that are no longer needed (supports one or many at once)."
|
||||
)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class TodoItem(SerializationMixin):
|
||||
"""Represent one todo item tracked for the current session."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
description: str | None
|
||||
is_complete: bool
|
||||
|
||||
def __init__(self, id: int, title: str, description: str | None = None, is_complete: bool = False) -> None:
|
||||
"""Initialize one todo item."""
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.is_complete = is_complete
|
||||
|
||||
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
|
||||
"""Serialize the todo item for persistence."""
|
||||
del exclude
|
||||
payload = {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"is_complete": self.is_complete,
|
||||
}
|
||||
return {key: value for key, value in payload.items() if value is not None or not exclude_none}
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls, raw_item: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None
|
||||
) -> TodoItem:
|
||||
"""Parse one todo item loaded from storage."""
|
||||
del dependencies
|
||||
item_id = raw_item.get("id")
|
||||
title = raw_item.get("title")
|
||||
description = raw_item.get("description")
|
||||
is_complete = raw_item.get("is_complete", False)
|
||||
if not isinstance(item_id, int):
|
||||
raise ValueError("Todo item id must be an integer.")
|
||||
if not isinstance(title, str) or not title.strip():
|
||||
raise ValueError("Todo item title must be a non-empty string.")
|
||||
if description is not None and not isinstance(description, str):
|
||||
raise ValueError("Todo item description must be a string or null.")
|
||||
if not isinstance(is_complete, bool):
|
||||
raise ValueError("Todo item is_complete must be a boolean.")
|
||||
return cls(id=item_id, title=title, description=description, is_complete=is_complete)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
"""Return whether two todo items have the same values."""
|
||||
return isinstance(other, TodoItem) and self.to_dict() == other.to_dict()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a helpful debug representation."""
|
||||
return (
|
||||
"TodoItem("
|
||||
f"id={self.id!r}, title={self.title!r}, description={self.description!r}, is_complete={self.is_complete!r})"
|
||||
)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class TodoInput(SerializationMixin):
|
||||
"""Describe one todo item to create."""
|
||||
|
||||
title: str
|
||||
description: str | None
|
||||
|
||||
def __init__(self, title: str, description: str | None = None) -> None:
|
||||
"""Initialize one todo input."""
|
||||
normalized_title = title.strip()
|
||||
if not normalized_title:
|
||||
raise ValueError("Todo input title must be a non-empty string.")
|
||||
if description is not None and not isinstance(description, str):
|
||||
raise ValueError("Todo input description must be a string or null.")
|
||||
self.title = normalized_title
|
||||
self.description = description
|
||||
|
||||
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
|
||||
"""Serialize the todo input."""
|
||||
del exclude
|
||||
payload = {"title": self.title, "description": self.description}
|
||||
return {key: value for key, value in payload.items() if value is not None or not exclude_none}
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls, raw_todo: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None
|
||||
) -> TodoInput:
|
||||
"""Parse one todo input loaded from tool arguments."""
|
||||
del dependencies
|
||||
title = raw_todo.get("title")
|
||||
description = raw_todo.get("description")
|
||||
if not isinstance(title, str):
|
||||
raise ValueError("Todo input title must be a string.")
|
||||
return cls(title=title, description=description)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class TodoCompleteInput(SerializationMixin):
|
||||
"""Describe one todo item to mark as complete."""
|
||||
|
||||
id: int
|
||||
reason: str
|
||||
|
||||
def __init__(self, id: int, reason: str) -> None:
|
||||
"""Initialize one todo complete input."""
|
||||
if not isinstance(id, int):
|
||||
raise ValueError("Todo complete input id must be an integer.")
|
||||
if not isinstance(reason, str) or not reason.strip():
|
||||
raise ValueError("Todo complete input reason must be a non-empty string.")
|
||||
self.id = id
|
||||
self.reason = reason.strip()
|
||||
|
||||
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
|
||||
"""Serialize the todo complete input."""
|
||||
del exclude, exclude_none
|
||||
return {"id": self.id, "reason": self.reason}
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls, raw_item: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None
|
||||
) -> TodoCompleteInput:
|
||||
"""Parse one todo complete input from tool arguments."""
|
||||
del dependencies
|
||||
item_id = raw_item.get("id")
|
||||
reason = raw_item.get("reason")
|
||||
if not isinstance(item_id, int):
|
||||
raise ValueError("Todo complete input id must be an integer.")
|
||||
if not isinstance(reason, str):
|
||||
raise ValueError("Todo complete input reason must be a string.")
|
||||
return cls(id=item_id, reason=reason)
|
||||
|
||||
|
||||
class _TodoAddItemSchema(TypedDict):
|
||||
"""Schema for a single todo item in the todos_add tool."""
|
||||
|
||||
title: str
|
||||
description: NotRequired[str]
|
||||
|
||||
|
||||
class _TodoCompleteItemSchema(TypedDict):
|
||||
"""Schema for a single item in the todos_complete tool."""
|
||||
|
||||
id: int
|
||||
reason: str
|
||||
|
||||
|
||||
def _parse_todo_items(items_payload: list[Any], *, source_description: str) -> list[TodoItem]:
|
||||
"""Parse persisted todo item payloads with clear corruption errors."""
|
||||
items: list[TodoItem] = []
|
||||
for index, item in enumerate(items_payload):
|
||||
if not isinstance(item, Mapping):
|
||||
raise ValueError(
|
||||
f"Todo item at index {index} in {source_description} must be a mapping; got {type(item).__name__}."
|
||||
)
|
||||
items.append(TodoItem.from_dict(dict(cast(Mapping[str, Any], item))))
|
||||
return items
|
||||
|
||||
|
||||
def _coerce_todo_input(todo: TodoInput | dict[str, Any] | Any) -> TodoInput:
|
||||
"""Normalize tool-provided todo input into a TodoInput model."""
|
||||
if isinstance(todo, TodoInput):
|
||||
return todo
|
||||
if isinstance(todo, MutableMapping):
|
||||
return TodoInput.from_dict(cast(MutableMapping[str, Any], todo))
|
||||
raise ValueError("Todo input must be a TodoInput instance or JSON object.")
|
||||
|
||||
|
||||
def _coerce_todo_complete_input(item: TodoCompleteInput | dict[str, Any] | Any) -> TodoCompleteInput:
|
||||
"""Normalize tool-provided complete input into a TodoCompleteInput model."""
|
||||
if isinstance(item, TodoCompleteInput):
|
||||
return item
|
||||
if isinstance(item, MutableMapping):
|
||||
return TodoCompleteInput.from_dict(cast(MutableMapping[str, Any], item))
|
||||
raise ValueError("Todo complete input must be a TodoCompleteInput instance or JSON object.")
|
||||
|
||||
|
||||
def _safe_next_id(items: list[TodoItem], next_id: int) -> int:
|
||||
"""Clamp ``next_id`` so it cannot collide with any persisted item id."""
|
||||
return max(next_id, max((item.id for item in items), default=0) + 1)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class TodoStore(ABC):
|
||||
"""Abstract backing store for session todo items."""
|
||||
|
||||
@abstractmethod
|
||||
async def load_state(self, session: AgentSession, *, source_id: str) -> tuple[list[TodoItem], int]:
|
||||
"""Load persisted todo items and the next available ID."""
|
||||
|
||||
@abstractmethod
|
||||
async def save_state(self, session: AgentSession, items: list[TodoItem], *, next_id: int, source_id: str) -> None:
|
||||
"""Persist todo items and the next available ID."""
|
||||
|
||||
async def load_items(self, session: AgentSession, *, source_id: str) -> list[TodoItem]:
|
||||
"""Load todo items for one session."""
|
||||
items, _ = await self.load_state(session, source_id=source_id)
|
||||
return items
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class TodoSessionStore(TodoStore):
|
||||
"""Store todo state inside ``AgentSession.state``."""
|
||||
|
||||
async def load_state(self, session: AgentSession, *, source_id: str) -> tuple[list[TodoItem], int]:
|
||||
"""Load todo state from session state."""
|
||||
provider_state_value = session.state.get(source_id)
|
||||
if provider_state_value is None:
|
||||
provider_state: dict[str, Any] = {}
|
||||
session.state[source_id] = provider_state
|
||||
elif isinstance(provider_state_value, dict):
|
||||
provider_state = cast(dict[str, Any], provider_state_value)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Session state for source_id {source_id!r} must be a dict; got {type(provider_state_value).__name__}."
|
||||
)
|
||||
|
||||
raw_items = provider_state.get("items", [])
|
||||
if not isinstance(raw_items, list):
|
||||
raise ValueError(
|
||||
f"Session state for source_id {source_id!r} has a non-list 'items' field; "
|
||||
f"got {type(raw_items).__name__}."
|
||||
)
|
||||
raw_next_id = provider_state.get("next_id", 1)
|
||||
if not isinstance(raw_next_id, int):
|
||||
raise ValueError(
|
||||
f"Session state for source_id {source_id!r} has a non-integer 'next_id' field; "
|
||||
f"got {type(raw_next_id).__name__}."
|
||||
)
|
||||
items_payload: list[Any] = cast(Any, raw_items)
|
||||
items = _parse_todo_items(items_payload, source_description="session todo state")
|
||||
return items, _safe_next_id(items, raw_next_id)
|
||||
|
||||
async def save_state(self, session: AgentSession, items: list[TodoItem], *, next_id: int, source_id: str) -> None:
|
||||
"""Persist todo state back into session state."""
|
||||
provider_state_value = session.state.get(source_id)
|
||||
provider_state = cast(dict[str, Any], provider_state_value) if isinstance(provider_state_value, dict) else {}
|
||||
if not isinstance(provider_state_value, dict):
|
||||
session.state[source_id] = provider_state
|
||||
provider_state["items"] = [item.to_dict(exclude_none=False) for item in items]
|
||||
provider_state["next_id"] = _safe_next_id(items, next_id)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class TodoFileStore(TodoStore):
|
||||
"""Store todo state in one JSON file per session and source ID."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_path: str | Path,
|
||||
*,
|
||||
kind: str = "todos",
|
||||
owner_prefix: str = "",
|
||||
owner_state_key: str | None = None,
|
||||
state_filename: str = "todos.json",
|
||||
) -> None:
|
||||
"""Initialize the file-backed todo store.
|
||||
|
||||
Args:
|
||||
base_path: Root storage directory.
|
||||
|
||||
Keyword Args:
|
||||
kind: Storage bucket name under each owner directory.
|
||||
owner_prefix: Optional prefix applied to the resolved owner ID.
|
||||
owner_state_key: Session-state key holding the logical owner ID.
|
||||
state_filename: File name used for the persisted todo state.
|
||||
"""
|
||||
self.base_path = Path(base_path)
|
||||
self.kind = kind
|
||||
self.owner_prefix = owner_prefix
|
||||
self.owner_state_key = owner_state_key
|
||||
self.state_filename = state_filename
|
||||
self._base_root = self.base_path.resolve()
|
||||
|
||||
_ENCODED_SEGMENT_PREFIX: ClassVar[str] = "~todo-"
|
||||
_WINDOWS_RESERVED_FILE_STEMS: ClassVar[frozenset[str]] = frozenset({
|
||||
"CON",
|
||||
"PRN",
|
||||
"AUX",
|
||||
"NUL",
|
||||
"COM1",
|
||||
"COM2",
|
||||
"COM3",
|
||||
"COM4",
|
||||
"COM5",
|
||||
"COM6",
|
||||
"COM7",
|
||||
"COM8",
|
||||
"COM9",
|
||||
"LPT1",
|
||||
"LPT2",
|
||||
"LPT3",
|
||||
"LPT4",
|
||||
"LPT5",
|
||||
"LPT6",
|
||||
"LPT7",
|
||||
"LPT8",
|
||||
"LPT9",
|
||||
})
|
||||
|
||||
def _get_state_path(self, session: AgentSession, *, source_id: str) -> Path:
|
||||
"""Return the JSON file path for one session and source ID."""
|
||||
session_directory = self.base_path
|
||||
if self.owner_state_key is not None:
|
||||
owner_value = session.state.get(self.owner_state_key)
|
||||
if owner_value is None:
|
||||
raise RuntimeError(
|
||||
f"TodoFileStore requires session.state[{self.owner_state_key!r}] to be set for file-backed storage."
|
||||
)
|
||||
owner_segment = self._path_segment(owner_value, label="owner")
|
||||
session_directory = session_directory / f"{self.owner_prefix}{owner_segment}" / self.kind
|
||||
session_directory = session_directory / self._path_segment(
|
||||
session.session_id, label="session_id", reject_path_separators=True
|
||||
)
|
||||
state_path = (session_directory / self._state_filename(source_id)).resolve()
|
||||
if not state_path.is_relative_to(self._base_root):
|
||||
raise ValueError(f"Todo file path escaped base directory for session_id {session.session_id!r}.")
|
||||
return state_path
|
||||
|
||||
@classmethod
|
||||
def _path_segment(cls, value: object, *, label: str, reject_path_separators: bool = False) -> str:
|
||||
"""Return a filesystem-safe path segment for user-controlled state values."""
|
||||
raw_value = str(value)
|
||||
if reject_path_separators and ("/" in raw_value or "\\" in raw_value):
|
||||
raise ValueError(f"TodoFileStore {label} must not contain path separators: {raw_value!r}")
|
||||
if cls._is_literal_path_segment_safe(raw_value):
|
||||
return raw_value
|
||||
encoded_value = urlsafe_b64encode(raw_value.encode("utf-8")).decode("ascii").rstrip("=")
|
||||
return f"{cls._ENCODED_SEGMENT_PREFIX}{encoded_value or label}"
|
||||
|
||||
@classmethod
|
||||
def _is_literal_path_segment_safe(cls, value: str) -> bool:
|
||||
"""Return whether a value can be used directly as one path segment."""
|
||||
if (
|
||||
not value
|
||||
or value.startswith(".")
|
||||
or value.endswith((" ", "."))
|
||||
or value.upper() in cls._WINDOWS_RESERVED_FILE_STEMS
|
||||
):
|
||||
return False
|
||||
if any(ord(character) < 32 for character in value):
|
||||
return False
|
||||
return all(character.isalnum() or character in "._-" for character in value)
|
||||
|
||||
def _state_filename(self, source_id: str) -> str:
|
||||
"""Return a source-specific JSON state filename."""
|
||||
state_path = Path(self.state_filename)
|
||||
source_segment = self._path_segment(source_id, label="source_id")
|
||||
if state_path.suffix:
|
||||
return f"{state_path.stem}.{source_segment}{state_path.suffix}"
|
||||
return f"{state_path.name}.{source_segment}.json"
|
||||
|
||||
async def load_state(self, session: AgentSession, *, source_id: str) -> tuple[list[TodoItem], int]:
|
||||
"""Load todo state from disk."""
|
||||
state_path = self._get_state_path(session, source_id=source_id)
|
||||
return await asyncio.to_thread(self._load_state_sync, state_path)
|
||||
|
||||
@staticmethod
|
||||
def _load_state_sync(state_path: Path) -> tuple[list[TodoItem], int]:
|
||||
"""Synchronous helper that performs the disk I/O for ``load_state``."""
|
||||
if not state_path.exists():
|
||||
return [], 1
|
||||
payload = cast(dict[str, Any], json.loads(state_path.read_text(encoding="utf-8")))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"Todo file {state_path} must contain a JSON object.")
|
||||
raw_items = payload.get("items", [])
|
||||
raw_next_id = payload.get("next_id", 1)
|
||||
if not isinstance(raw_items, list):
|
||||
raise ValueError(f"Todo file {state_path} has a non-list 'items' field.")
|
||||
if not isinstance(raw_next_id, int):
|
||||
raise ValueError(f"Todo file {state_path} has a non-integer 'next_id' field.")
|
||||
items_payload: list[Any] = cast(Any, raw_items)
|
||||
items = _parse_todo_items(items_payload, source_description=f"todo file {state_path}")
|
||||
return items, _safe_next_id(items, raw_next_id)
|
||||
|
||||
async def save_state(self, session: AgentSession, items: list[TodoItem], *, next_id: int, source_id: str) -> None:
|
||||
"""Persist todo state to disk."""
|
||||
state_path = self._get_state_path(session, source_id=source_id)
|
||||
payload = (
|
||||
json.dumps({
|
||||
"items": [item.to_dict(exclude_none=False) for item in items],
|
||||
"next_id": _safe_next_id(items, next_id),
|
||||
})
|
||||
+ "\n"
|
||||
)
|
||||
await asyncio.to_thread(self._save_state_sync, state_path, payload)
|
||||
|
||||
@staticmethod
|
||||
def _save_state_sync(state_path: Path, payload: str) -> None:
|
||||
"""Synchronous helper that atomically writes the JSON state file."""
|
||||
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Write to a sibling temp file then atomically replace, so a crash mid-write cannot leave
|
||||
# a truncated state file that breaks every subsequent tool call.
|
||||
temp_path = state_path.with_name(f"{state_path.name}.tmp.{os.getpid()}")
|
||||
try:
|
||||
temp_path.write_text(payload, encoding="utf-8")
|
||||
os.replace(temp_path, state_path)
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class TodoProvider(ContextProvider):
|
||||
"""Provide todo management tools and instructions to an agent.
|
||||
|
||||
The ``TodoProvider`` enables agents to create, complete, remove, and query todo items as part of their planning
|
||||
and execution workflow. Todo state is stored in the configured ``TodoStore`` and persists across agent invocations
|
||||
within the same session. By default, state is stored in ``AgentSession.state`` with ``TodoSessionStore``; callers
|
||||
can provide ``TodoFileStore`` or another store implementation for file-backed or custom persistence.
|
||||
|
||||
This provider exposes the following tools to the agent:
|
||||
- ``todos_add``: Add one or more todo items, each with a title and optional description.
|
||||
- ``todos_complete``: Mark one or more todo items as complete by their IDs and reasons.
|
||||
- ``todos_remove``: Remove one or more todo items by their IDs.
|
||||
- ``todos_get_remaining``: Retrieve only incomplete todo items.
|
||||
- ``todos_get_all``: Retrieve all todo items, complete and incomplete.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_id: str = DEFAULT_TODO_SOURCE_ID,
|
||||
*,
|
||||
instructions: str | None = None,
|
||||
store: TodoStore | None = None,
|
||||
) -> None:
|
||||
"""Initialize the todo provider.
|
||||
|
||||
Args:
|
||||
source_id: Unique source ID for the provider.
|
||||
|
||||
Keyword Args:
|
||||
instructions: Optional instruction override.
|
||||
store: Optional todo store override.
|
||||
"""
|
||||
super().__init__(source_id)
|
||||
self.instructions = instructions or DEFAULT_TODO_INSTRUCTIONS
|
||||
self.store = store or TodoSessionStore()
|
||||
# WeakKeyDictionary so per-session locks are evicted automatically when the session is GC'd
|
||||
# rather than accumulating in long-running services that create many sessions.
|
||||
self._mutation_locks: weakref.WeakKeyDictionary[AgentSession, asyncio.Lock] = weakref.WeakKeyDictionary()
|
||||
|
||||
def _mutation_lock(self, session: AgentSession) -> asyncio.Lock:
|
||||
"""Return the per-session lock for read-modify-write todo operations."""
|
||||
lock = self._mutation_locks.get(session)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._mutation_locks[session] = lock
|
||||
return lock
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Inject todo tools and instructions before the model runs."""
|
||||
del agent, state
|
||||
|
||||
@tool(name="todos_add", approval_mode="never_require")
|
||||
async def todos_add(todos: list[_TodoAddItemSchema]) -> str:
|
||||
"""Add one or more todo items for the current session."""
|
||||
if not todos:
|
||||
raise ValueError("todos must contain at least one item.")
|
||||
|
||||
async with self._mutation_lock(session):
|
||||
existing_items, next_id = await self.store.load_state(session, source_id=self.source_id)
|
||||
created_items: list[TodoItem] = []
|
||||
for raw_todo in todos:
|
||||
todo = _coerce_todo_input(raw_todo)
|
||||
created_item = TodoItem(
|
||||
id=next_id,
|
||||
title=todo.title,
|
||||
description=todo.description.strip() if todo.description is not None else None,
|
||||
)
|
||||
existing_items.append(created_item)
|
||||
created_items.append(created_item)
|
||||
next_id += 1
|
||||
|
||||
await self.store.save_state(session, existing_items, next_id=next_id, source_id=self.source_id)
|
||||
return json.dumps([item.to_dict(exclude_none=False) for item in created_items])
|
||||
|
||||
@tool(name="todos_complete", approval_mode="never_require")
|
||||
async def todos_complete(items: list[_TodoCompleteItemSchema]) -> str:
|
||||
"""Mark one or more todo items as complete.
|
||||
|
||||
Each entry has an id (int) and a reason (string) describing how/why the item was completed.
|
||||
"""
|
||||
if not items:
|
||||
raise ValueError("items must contain at least one entry.")
|
||||
|
||||
parsed = [_coerce_todo_complete_input(entry) for entry in items]
|
||||
ids = [entry.id for entry in parsed]
|
||||
|
||||
async with self._mutation_lock(session):
|
||||
existing_items, next_id = await self.store.load_state(session, source_id=self.source_id)
|
||||
id_set = set(ids)
|
||||
completed_count = 0
|
||||
updated_items: list[TodoItem] = []
|
||||
for item in existing_items:
|
||||
if not item.is_complete and item.id in id_set:
|
||||
updated_items.append(
|
||||
TodoItem(
|
||||
id=item.id,
|
||||
title=item.title,
|
||||
description=item.description,
|
||||
is_complete=True,
|
||||
)
|
||||
)
|
||||
completed_count += 1
|
||||
else:
|
||||
updated_items.append(item)
|
||||
|
||||
if completed_count:
|
||||
await self.store.save_state(session, updated_items, next_id=next_id, source_id=self.source_id)
|
||||
return json.dumps({"completed": completed_count})
|
||||
|
||||
@tool(name="todos_remove", approval_mode="never_require")
|
||||
async def todos_remove(ids: list[int]) -> str:
|
||||
"""Remove one or more todo items by ID."""
|
||||
if not ids:
|
||||
raise ValueError("ids must contain at least one todo ID.")
|
||||
|
||||
async with self._mutation_lock(session):
|
||||
items, next_id = await self.store.load_state(session, source_id=self.source_id)
|
||||
remaining_items = [item for item in items if item.id not in set(ids)]
|
||||
removed_count = len(items) - len(remaining_items)
|
||||
if removed_count:
|
||||
await self.store.save_state(session, remaining_items, next_id=next_id, source_id=self.source_id)
|
||||
return json.dumps({"removed": removed_count})
|
||||
|
||||
@tool(name="todos_get_remaining", approval_mode="never_require")
|
||||
async def todos_get_remaining() -> str:
|
||||
"""Retrieve only incomplete todo items for the current session."""
|
||||
items = [
|
||||
item for item in await self.store.load_items(session, source_id=self.source_id) if not item.is_complete
|
||||
]
|
||||
return json.dumps([item.to_dict(exclude_none=False) for item in items])
|
||||
|
||||
@tool(name="todos_get_all", approval_mode="never_require")
|
||||
async def todos_get_all() -> str:
|
||||
"""Retrieve all todo items for the current session."""
|
||||
items = await self.store.load_items(session, source_id=self.source_id)
|
||||
return json.dumps([item.to_dict(exclude_none=False) for item in items])
|
||||
|
||||
context.extend_instructions(self.source_id, [self.instructions])
|
||||
context.extend_tools(
|
||||
self.source_id,
|
||||
[todos_add, todos_complete, todos_remove, todos_get_remaining, todos_get_all],
|
||||
)
|
||||
current_items = await self.store.load_items(session, source_id=self.source_id)
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
"### Current todo list\n"
|
||||
+ (
|
||||
"\n".join(
|
||||
f"- {item.id} [{'done' if item.is_complete else 'open'}] {item.title}"
|
||||
+ (f": {item.description}" if item.description else "")
|
||||
for item in current_items
|
||||
)
|
||||
or "- none yet"
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,632 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import inspect
|
||||
import json
|
||||
from asyncio import sleep
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, Iterable, Mapping, MutableMapping, Sequence
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._middleware import AgentContext, AgentMiddleware
|
||||
from .._serialization import SerializationMixin
|
||||
from .._sessions import AgentSession
|
||||
from .._types import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
Content,
|
||||
FinishReason,
|
||||
FinishReasonLiteral,
|
||||
Message,
|
||||
ResponseStream,
|
||||
)
|
||||
|
||||
DEFAULT_TOOL_APPROVAL_SOURCE_ID = "tool_approval"
|
||||
_FUNCTION_INVOCATION_BUDGET_STATE_KEY = "_function_invocation_budget_state"
|
||||
ALWAYS_APPROVE_PROPERTY = "tool_approval"
|
||||
ALWAYS_APPROVE_SCOPE_PROPERTY = "always_approve"
|
||||
ALWAYS_APPROVE_TOOL: Literal["tool"] = "tool"
|
||||
ALWAYS_APPROVE_TOOL_WITH_ARGUMENTS: Literal["tool_with_arguments"] = "tool_with_arguments"
|
||||
|
||||
_RULES_KEY = "rules"
|
||||
_QUEUED_APPROVAL_REQUESTS_KEY = "queued_approval_requests"
|
||||
_COLLECTED_APPROVAL_RESPONSES_KEY = "collected_approval_responses"
|
||||
|
||||
ToolApprovalScope = Literal["tool", "tool_with_arguments"]
|
||||
ToolApprovalRuleCallback = Callable[[Content], bool | Awaitable[bool]]
|
||||
|
||||
|
||||
def _parse_function_arguments(function_call: Content) -> dict[str, Any]:
|
||||
arguments = function_call.parse_arguments()
|
||||
return dict(arguments or {})
|
||||
|
||||
|
||||
def _serialize_argument_value(value: Any) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
||||
|
||||
|
||||
def _serialize_arguments(function_call: Content) -> dict[str, str]:
|
||||
"""Serialize arguments for exact matching.
|
||||
|
||||
``None`` is reserved on :class:`ToolApprovalRule` for tool-wide rules.
|
||||
An argument-scoped rule for a no-argument call stores ``{}``, so it only
|
||||
matches future no-argument calls and never becomes a wildcard.
|
||||
"""
|
||||
arguments = _parse_function_arguments(function_call)
|
||||
return {key: _serialize_argument_value(value) for key, value in arguments.items()}
|
||||
|
||||
|
||||
def _server_label(function_call: Content) -> str | None:
|
||||
"""Return the hosted-tool server boundary for a function call, if present."""
|
||||
value = function_call.additional_properties.get("server_label")
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _content_from_state(value: Any) -> Content:
|
||||
if isinstance(value, Content):
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
return Content.from_dict(cast(Mapping[str, Any], value))
|
||||
raise TypeError(f"Expected Content or mapping state item, got {type(value).__name__}.")
|
||||
|
||||
|
||||
def _contents_from_state(values: Any) -> list[Content]:
|
||||
if not isinstance(values, list):
|
||||
return []
|
||||
state_items = list(cast(Iterable[Any], values))
|
||||
return [_content_from_state(value) for value in state_items]
|
||||
|
||||
|
||||
def _content_to_state(content: Content) -> dict[str, Any]:
|
||||
return content.to_dict()
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class ToolApprovalRule(SerializationMixin):
|
||||
"""A standing rule for approving future matching tool calls."""
|
||||
|
||||
tool_name: str
|
||||
arguments: dict[str, str] | None
|
||||
server_label: str | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: Mapping[str, str] | None = None,
|
||||
*,
|
||||
server_label: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize a tool approval rule.
|
||||
|
||||
Args:
|
||||
tool_name: The function tool name this rule applies to.
|
||||
arguments: Optional canonicalized argument values. When omitted, the
|
||||
rule applies to every call to the tool. Use an empty mapping to
|
||||
match only no-argument calls.
|
||||
|
||||
Keyword Args:
|
||||
server_label: Optional hosted-tool server boundary. Hosted approvals
|
||||
only match future approvals from the same server label.
|
||||
"""
|
||||
normalized_name = tool_name.strip()
|
||||
if not normalized_name:
|
||||
raise ValueError("Tool approval rule tool_name must be a non-empty string.")
|
||||
self.tool_name = normalized_name
|
||||
self.arguments = dict(arguments) if arguments is not None else None
|
||||
self.server_label = server_label
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls,
|
||||
value: MutableMapping[str, Any],
|
||||
/,
|
||||
*,
|
||||
dependencies: MutableMapping[str, Any] | None = None,
|
||||
) -> ToolApprovalRule:
|
||||
"""Create a rule from serialized state."""
|
||||
del dependencies
|
||||
tool_name = value.get("tool_name")
|
||||
if not isinstance(tool_name, str):
|
||||
raise ValueError("Tool approval rule tool_name must be a string.")
|
||||
raw_arguments = value.get("arguments")
|
||||
if raw_arguments is not None and not isinstance(raw_arguments, Mapping):
|
||||
raise ValueError("Tool approval rule arguments must be a mapping or None.")
|
||||
server_label = value.get("server_label")
|
||||
if server_label is not None and not isinstance(server_label, str):
|
||||
raise ValueError("Tool approval rule server_label must be a string or None.")
|
||||
arguments = (
|
||||
{str(key): str(argument_value) for key, argument_value in cast(Mapping[str, Any], raw_arguments).items()}
|
||||
if isinstance(raw_arguments, Mapping)
|
||||
else None
|
||||
)
|
||||
return cls(tool_name=tool_name, arguments=arguments, server_label=server_label)
|
||||
|
||||
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
|
||||
"""Serialize the rule."""
|
||||
exclude = exclude or set()
|
||||
payload: dict[str, Any] = {"tool_name": self.tool_name}
|
||||
if "type" not in exclude:
|
||||
payload["type"] = self._get_type_identifier()
|
||||
if self.arguments is not None or not exclude_none:
|
||||
payload["arguments"] = self.arguments
|
||||
if self.server_label is not None or not exclude_none:
|
||||
payload["server_label"] = self.server_label
|
||||
return payload
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class ToolApprovalState(SerializationMixin):
|
||||
"""Session-backed state used by :class:`ToolApprovalMiddleware`."""
|
||||
|
||||
rules: list[ToolApprovalRule]
|
||||
queued_approval_requests: list[Content]
|
||||
collected_approval_responses: list[Content]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
rules: Sequence[ToolApprovalRule | Mapping[str, Any]] | None = None,
|
||||
queued_approval_requests: Sequence[Content | Mapping[str, Any]] | None = None,
|
||||
collected_approval_responses: Sequence[Content | Mapping[str, Any]] | None = None,
|
||||
) -> None:
|
||||
"""Initialize approval state."""
|
||||
self.rules = [
|
||||
rule if isinstance(rule, ToolApprovalRule) else ToolApprovalRule.from_dict(dict(rule))
|
||||
for rule in (rules or [])
|
||||
]
|
||||
self.queued_approval_requests = [
|
||||
item if isinstance(item, Content) else Content.from_dict(item) for item in (queued_approval_requests or [])
|
||||
]
|
||||
self.collected_approval_responses = [
|
||||
item if isinstance(item, Content) else Content.from_dict(item)
|
||||
for item in (collected_approval_responses or [])
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls,
|
||||
value: MutableMapping[str, Any],
|
||||
/,
|
||||
*,
|
||||
dependencies: MutableMapping[str, Any] | None = None,
|
||||
) -> ToolApprovalState:
|
||||
"""Create state from serialized state."""
|
||||
del dependencies
|
||||
return cls(
|
||||
rules=cast(Sequence[Mapping[str, Any]], value.get("rules", [])),
|
||||
queued_approval_requests=cast(Sequence[Mapping[str, Any]], value.get("queued_approval_requests", [])),
|
||||
collected_approval_responses=cast(
|
||||
Sequence[Mapping[str, Any]],
|
||||
value.get("collected_approval_responses", []),
|
||||
),
|
||||
)
|
||||
|
||||
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
|
||||
"""Serialize state."""
|
||||
del exclude_none
|
||||
exclude = exclude or set()
|
||||
payload: dict[str, Any] = {
|
||||
"rules": [rule.to_dict() for rule in self.rules],
|
||||
"queued_approval_requests": [_content_to_state(item) for item in self.queued_approval_requests],
|
||||
"collected_approval_responses": [_content_to_state(item) for item in self.collected_approval_responses],
|
||||
}
|
||||
if "type" not in exclude:
|
||||
payload["type"] = self._get_type_identifier()
|
||||
return payload
|
||||
|
||||
|
||||
def create_always_approve_tool_response(request: Content, *, reason: str | None = None) -> Content:
|
||||
"""Create an approval response that records a standing rule for the whole tool.
|
||||
|
||||
Args:
|
||||
request: The ``function_approval_request`` content to approve.
|
||||
|
||||
Keyword Args:
|
||||
reason: Optional approval reason stored in ``additional_properties``.
|
||||
|
||||
Returns:
|
||||
A ``function_approval_response`` with metadata consumed by
|
||||
:class:`ToolApprovalMiddleware`.
|
||||
"""
|
||||
return _create_always_approve_response(request, ALWAYS_APPROVE_TOOL, reason=reason)
|
||||
|
||||
|
||||
def create_always_approve_tool_with_arguments_response(request: Content, *, reason: str | None = None) -> Content:
|
||||
"""Create an approval response that records a standing rule for the tool and exact arguments."""
|
||||
return _create_always_approve_response(request, ALWAYS_APPROVE_TOOL_WITH_ARGUMENTS, reason=reason)
|
||||
|
||||
|
||||
def _create_always_approve_response(request: Content, scope: ToolApprovalScope, *, reason: str | None) -> Content:
|
||||
response = request.to_function_approval_response(approved=True)
|
||||
metadata: dict[str, Any] = {ALWAYS_APPROVE_SCOPE_PROPERTY: scope}
|
||||
if reason is not None:
|
||||
metadata["reason"] = reason
|
||||
response.additional_properties[ALWAYS_APPROVE_PROPERTY] = metadata
|
||||
return response
|
||||
|
||||
|
||||
def _get_state(session: AgentSession, *, source_id: str) -> ToolApprovalState:
|
||||
raw_state = session.state.get(source_id)
|
||||
if isinstance(raw_state, ToolApprovalState):
|
||||
return raw_state
|
||||
if isinstance(raw_state, MutableMapping):
|
||||
raw_state_mapping = cast(MutableMapping[str, Any], raw_state)
|
||||
return ToolApprovalState(
|
||||
rules=cast(Sequence[Mapping[str, Any]], raw_state_mapping.get(_RULES_KEY, [])),
|
||||
queued_approval_requests=_contents_from_state(raw_state_mapping.get(_QUEUED_APPROVAL_REQUESTS_KEY, [])),
|
||||
collected_approval_responses=_contents_from_state(
|
||||
raw_state_mapping.get(_COLLECTED_APPROVAL_RESPONSES_KEY, []),
|
||||
),
|
||||
)
|
||||
if raw_state is not None:
|
||||
raise TypeError(f"Session state for {source_id!r} must be a mapping, got {type(raw_state).__name__}.")
|
||||
state = ToolApprovalState()
|
||||
session.state[source_id] = state.to_dict(exclude={"type"})
|
||||
return state
|
||||
|
||||
|
||||
def _save_state(session: AgentSession, state: ToolApprovalState, *, source_id: str) -> None:
|
||||
serialized = state.to_dict(exclude={"type"})
|
||||
existing = session.state.get(source_id)
|
||||
if isinstance(existing, MutableMapping):
|
||||
for key, value in cast(MutableMapping[str, Any], existing).items():
|
||||
if key not in serialized and key != "type":
|
||||
serialized[key] = value
|
||||
session.state[source_id] = serialized
|
||||
|
||||
|
||||
def _rule_exists(rules: Sequence[ToolApprovalRule], new_rule: ToolApprovalRule) -> bool:
|
||||
for rule in rules:
|
||||
if rule.tool_name != new_rule.tool_name:
|
||||
continue
|
||||
if rule.server_label != new_rule.server_label:
|
||||
continue
|
||||
if rule.arguments == new_rule.arguments:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _add_rule_if_missing(state: ToolApprovalState, rule: ToolApprovalRule) -> None:
|
||||
if not _rule_exists(state.rules, rule):
|
||||
state.rules.append(rule)
|
||||
|
||||
|
||||
def _function_call_from_request(request: Content) -> Content | None:
|
||||
function_call = request.function_call
|
||||
if function_call is None or function_call.type != "function_call" or function_call.name is None:
|
||||
return None
|
||||
return function_call
|
||||
|
||||
|
||||
def _arguments_match(rule_arguments: Mapping[str, str], function_call: Content) -> bool:
|
||||
call_arguments = _serialize_arguments(function_call) or {}
|
||||
if len(rule_arguments) != len(call_arguments):
|
||||
return False
|
||||
return all(call_arguments.get(key) == value for key, value in rule_arguments.items())
|
||||
|
||||
|
||||
def _matches_rule(request: Content, rules: Sequence[ToolApprovalRule]) -> bool:
|
||||
function_call = _function_call_from_request(request)
|
||||
if function_call is None:
|
||||
return False
|
||||
for rule in rules:
|
||||
if rule.tool_name != function_call.name:
|
||||
continue
|
||||
if rule.server_label != _server_label(function_call):
|
||||
continue
|
||||
if rule.arguments is None:
|
||||
return True
|
||||
if _arguments_match(rule.arguments, function_call):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_always_approve_scope(response: Content) -> ToolApprovalScope | None:
|
||||
metadata = response.additional_properties.get(ALWAYS_APPROVE_PROPERTY)
|
||||
if not isinstance(metadata, Mapping):
|
||||
return None
|
||||
metadata_mapping = cast(Mapping[str, Any], metadata)
|
||||
scope = metadata_mapping.get(ALWAYS_APPROVE_SCOPE_PROPERTY)
|
||||
if scope == ALWAYS_APPROVE_TOOL:
|
||||
return ALWAYS_APPROVE_TOOL
|
||||
if scope == ALWAYS_APPROVE_TOOL_WITH_ARGUMENTS:
|
||||
return ALWAYS_APPROVE_TOOL_WITH_ARGUMENTS
|
||||
return None
|
||||
|
||||
|
||||
def _clone_without_always_approve_metadata(response: Content) -> Content:
|
||||
cloned = copy.deepcopy(response)
|
||||
cloned.additional_properties.pop(ALWAYS_APPROVE_PROPERTY, None)
|
||||
return cloned
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class ToolApprovalMiddleware(AgentMiddleware):
|
||||
"""Coordinate standing tool approvals and queued approval prompts for an agent.
|
||||
|
||||
This middleware is opt-in and requires callers to run the agent with an
|
||||
:class:`AgentSession`, because approval rules and queued requests are stored
|
||||
in session state.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
source_id: str = DEFAULT_TOOL_APPROVAL_SOURCE_ID,
|
||||
auto_approval_rules: Sequence[ToolApprovalRuleCallback] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the middleware.
|
||||
|
||||
Keyword Args:
|
||||
source_id: Session-state key used by this middleware.
|
||||
auto_approval_rules: Optional callbacks that can auto-approve a
|
||||
``function_call``. Each callback receives the function-call
|
||||
content and returns ``True`` to approve it.
|
||||
"""
|
||||
self.source_id = source_id
|
||||
self.auto_approval_rules = tuple(auto_approval_rules or ())
|
||||
|
||||
async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None:
|
||||
"""Process one agent invocation."""
|
||||
if context.session is None:
|
||||
raise RuntimeError("ToolApprovalMiddleware requires an AgentSession.")
|
||||
|
||||
state = _get_state(context.session, source_id=self.source_id)
|
||||
context.client_kwargs.setdefault(_FUNCTION_INVOCATION_BUDGET_STATE_KEY, {})
|
||||
context.messages = self._prepare_inbound_messages(context.messages, state)
|
||||
await self._drain_auto_approvable_queue(state)
|
||||
if next_queued := self._pop_next_queued_request(state):
|
||||
_save_state(context.session, state, source_id=self.source_id)
|
||||
context.result = self._response_for_queued_request(next_queued, stream=context.stream)
|
||||
return
|
||||
if context.stream:
|
||||
context.result = self._process_stream(context, call_next, state)
|
||||
return
|
||||
|
||||
while True:
|
||||
context.messages = self._inject_collected_responses(context.messages, state)
|
||||
state_changed = bool(state.collected_approval_responses)
|
||||
state.collected_approval_responses.clear()
|
||||
if state_changed:
|
||||
_save_state(context.session, state, source_id=self.source_id)
|
||||
|
||||
await call_next()
|
||||
if isinstance(context.result, ResponseStream):
|
||||
return
|
||||
if context.result is None:
|
||||
_save_state(context.session, state, source_id=self.source_id)
|
||||
return
|
||||
|
||||
all_auto_approved = await self._process_outbound_messages(context.result.messages, state)
|
||||
_save_state(context.session, state, source_id=self.source_id)
|
||||
if not all_auto_approved:
|
||||
return
|
||||
context.messages = []
|
||||
context.result = None
|
||||
|
||||
def _response_for_queued_request(
|
||||
self,
|
||||
request: Content,
|
||||
*,
|
||||
stream: bool,
|
||||
) -> AgentResponse | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
if not stream:
|
||||
return AgentResponse(messages=[Message(role="assistant", contents=[request])])
|
||||
|
||||
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
|
||||
await sleep(0)
|
||||
yield AgentResponseUpdate(role="assistant", contents=[request])
|
||||
|
||||
return ResponseStream(_stream(), finalizer=AgentResponse.from_updates)
|
||||
|
||||
def _process_stream(
|
||||
self,
|
||||
context: AgentContext,
|
||||
call_next: Callable[[], Awaitable[None]],
|
||||
state: ToolApprovalState,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
|
||||
if context.session is None:
|
||||
raise RuntimeError("ToolApprovalMiddleware requires an AgentSession.")
|
||||
while True:
|
||||
context.messages = self._inject_collected_responses(context.messages, state)
|
||||
state_changed = bool(state.collected_approval_responses)
|
||||
state.collected_approval_responses.clear()
|
||||
if state_changed:
|
||||
_save_state(context.session, state, source_id=self.source_id)
|
||||
|
||||
await call_next()
|
||||
if not isinstance(context.result, ResponseStream):
|
||||
raise ValueError("Streaming ToolApprovalMiddleware requires a ResponseStream result.")
|
||||
|
||||
approval_requests: list[Content] = []
|
||||
async for update in context.result:
|
||||
approval_contents = [
|
||||
content for content in update.contents if content.type == "function_approval_request"
|
||||
]
|
||||
if not approval_contents:
|
||||
yield update
|
||||
continue
|
||||
approval_requests.extend(approval_contents)
|
||||
remaining_contents = [
|
||||
content for content in update.contents if content.type != "function_approval_request"
|
||||
]
|
||||
if remaining_contents:
|
||||
raw_finish_reason = update.finish_reason
|
||||
finish_reason: FinishReasonLiteral | FinishReason | None
|
||||
if isinstance(raw_finish_reason, str):
|
||||
finish_reason = FinishReason(raw_finish_reason)
|
||||
else:
|
||||
finish_reason = cast(FinishReasonLiteral | FinishReason | None, raw_finish_reason)
|
||||
yield AgentResponseUpdate(
|
||||
contents=remaining_contents,
|
||||
role=update.role,
|
||||
author_name=update.author_name,
|
||||
agent_id=update.agent_id,
|
||||
response_id=update.response_id,
|
||||
message_id=update.message_id,
|
||||
created_at=update.created_at,
|
||||
finish_reason=finish_reason,
|
||||
continuation_token=update.continuation_token,
|
||||
additional_properties=update.additional_properties,
|
||||
raw_representation=update.raw_representation,
|
||||
)
|
||||
await context.result.get_final_response()
|
||||
if not approval_requests:
|
||||
return
|
||||
|
||||
response_messages = [Message(role="assistant", contents=approval_requests)]
|
||||
all_auto_approved = await self._process_outbound_messages(response_messages, state)
|
||||
_save_state(context.session, state, source_id=self.source_id)
|
||||
if not all_auto_approved:
|
||||
for message in response_messages:
|
||||
if message.contents:
|
||||
yield AgentResponseUpdate(role=message.role, contents=message.contents)
|
||||
return
|
||||
context.messages = []
|
||||
context.result = None
|
||||
|
||||
return ResponseStream(_stream(), finalizer=AgentResponse.from_updates)
|
||||
|
||||
def _prepare_inbound_messages(self, messages: Sequence[Message], state: ToolApprovalState) -> list[Message]:
|
||||
prepared: list[Message] = []
|
||||
for message in messages:
|
||||
replacement_contents: list[Content] = []
|
||||
changed = False
|
||||
for content in message.contents:
|
||||
if content.type == "function_approval_response":
|
||||
replacement = self._handle_inbound_approval_response(content, state)
|
||||
state.collected_approval_responses.append(replacement)
|
||||
changed = True
|
||||
continue
|
||||
replacement_contents.append(content)
|
||||
|
||||
if not changed:
|
||||
prepared.append(message)
|
||||
continue
|
||||
if replacement_contents:
|
||||
cloned = copy.copy(message)
|
||||
cloned.contents = replacement_contents
|
||||
prepared.append(cloned)
|
||||
return prepared
|
||||
|
||||
def _handle_inbound_approval_response(self, response: Content, state: ToolApprovalState) -> Content:
|
||||
scope = _get_always_approve_scope(response)
|
||||
if scope is None or not response.approved:
|
||||
return response
|
||||
|
||||
function_call = response.function_call
|
||||
if function_call is not None and function_call.type == "function_call" and function_call.name is not None:
|
||||
if scope == ALWAYS_APPROVE_TOOL:
|
||||
_add_rule_if_missing(
|
||||
state,
|
||||
ToolApprovalRule(
|
||||
tool_name=function_call.name,
|
||||
server_label=_server_label(function_call),
|
||||
),
|
||||
)
|
||||
else:
|
||||
_add_rule_if_missing(
|
||||
state,
|
||||
ToolApprovalRule(
|
||||
tool_name=function_call.name,
|
||||
arguments=_serialize_arguments(function_call),
|
||||
server_label=_server_label(function_call),
|
||||
),
|
||||
)
|
||||
return _clone_without_always_approve_metadata(response)
|
||||
|
||||
def _inject_collected_responses(self, messages: Sequence[Message], state: ToolApprovalState) -> list[Message]:
|
||||
if not state.collected_approval_responses:
|
||||
return list(messages)
|
||||
return [Message(role="user", contents=list(state.collected_approval_responses)), *messages]
|
||||
|
||||
async def _drain_auto_approvable_queue(self, state: ToolApprovalState) -> None:
|
||||
remaining: list[Content] = []
|
||||
for request in state.queued_approval_requests:
|
||||
if _matches_rule(request, state.rules) or await self._matches_auto_rule(request):
|
||||
state.collected_approval_responses.append(request.to_function_approval_response(approved=True))
|
||||
continue
|
||||
remaining.append(request)
|
||||
state.queued_approval_requests = remaining
|
||||
|
||||
def _pop_next_queued_request(self, state: ToolApprovalState) -> Content | None:
|
||||
if not state.queued_approval_requests:
|
||||
return None
|
||||
return state.queued_approval_requests.pop(0)
|
||||
|
||||
async def _process_outbound_messages(self, messages: list[Message], state: ToolApprovalState) -> bool:
|
||||
approval_requests = [
|
||||
content
|
||||
for message in messages
|
||||
for content in message.contents
|
||||
if content.type == "function_approval_request"
|
||||
]
|
||||
if not approval_requests:
|
||||
return False
|
||||
|
||||
auto_approved: set[int] = set()
|
||||
unresolved: list[Content] = []
|
||||
for request in approval_requests:
|
||||
if _matches_rule(request, state.rules) or await self._matches_auto_rule(request):
|
||||
state.collected_approval_responses.append(request.to_function_approval_response(approved=True))
|
||||
auto_approved.add(id(request))
|
||||
else:
|
||||
unresolved.append(request)
|
||||
|
||||
if not auto_approved and len(unresolved) <= 1:
|
||||
return False
|
||||
|
||||
queued_ids: set[int] = set()
|
||||
for request in unresolved[1:]:
|
||||
queued_ids.add(id(request))
|
||||
state.queued_approval_requests.append(request)
|
||||
|
||||
remove_ids = auto_approved | queued_ids
|
||||
self._remove_approval_requests(messages, remove_ids)
|
||||
return not unresolved
|
||||
|
||||
@staticmethod
|
||||
def _remove_approval_requests(messages: list[Message], remove_ids: set[int]) -> None:
|
||||
for message_index in range(len(messages) - 1, -1, -1):
|
||||
message = messages[message_index]
|
||||
filtered = [
|
||||
content
|
||||
for content in message.contents
|
||||
if content.type != "function_approval_request" or id(content) not in remove_ids
|
||||
]
|
||||
if len(filtered) == len(message.contents):
|
||||
continue
|
||||
if filtered:
|
||||
message.contents = filtered
|
||||
else:
|
||||
messages.pop(message_index)
|
||||
|
||||
async def _matches_auto_rule(self, request: Content) -> bool:
|
||||
function_call = _function_call_from_request(request)
|
||||
if function_call is None:
|
||||
return False
|
||||
for rule in self.auto_approval_rules:
|
||||
result = rule(function_call)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
if result:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ALWAYS_APPROVE_PROPERTY",
|
||||
"ALWAYS_APPROVE_SCOPE_PROPERTY",
|
||||
"ALWAYS_APPROVE_TOOL",
|
||||
"ALWAYS_APPROVE_TOOL_WITH_ARGUMENTS",
|
||||
"DEFAULT_TOOL_APPROVAL_SOURCE_ID",
|
||||
"ToolApprovalMiddleware",
|
||||
"ToolApprovalRule",
|
||||
"ToolApprovalRuleCallback",
|
||||
"ToolApprovalState",
|
||||
"create_always_approve_tool_response",
|
||||
"create_always_approve_tool_with_arguments_response",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,661 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Mapping, MutableMapping
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from datetime import date, datetime
|
||||
from typing import Any, ClassVar, Protocol, TypeVar, runtime_checkable
|
||||
|
||||
logger = logging.getLogger("agent_framework")
|
||||
|
||||
ClassT = TypeVar("ClassT", bound="SerializationMixin")
|
||||
ProtocolT = TypeVar("ProtocolT", bound="SerializationProtocol")
|
||||
|
||||
# Regex pattern for converting CamelCase to snake_case
|
||||
_CAMEL_TO_SNAKE_PATTERN = re.compile(r"(?<!^)(?=[A-Z])")
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SerializationProtocol(Protocol):
|
||||
"""Protocol for objects that support serialization and deserialization.
|
||||
|
||||
This protocol defines the interface that classes must implement to be compatible
|
||||
with the agent framework's serialization system. Any class implementing both
|
||||
``to_dict()`` and ``from_dict()`` methods will automatically satisfy this protocol
|
||||
and can be used seamlessly with other serializable components.
|
||||
|
||||
The protocol enables type safety and duck typing for serializable objects,
|
||||
ensuring consistent behavior across the framework.
|
||||
|
||||
Examples:
|
||||
The framework's ``Message`` class demonstrates the protocol in action:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import Message
|
||||
from agent_framework._serialization import SerializationProtocol
|
||||
|
||||
|
||||
# Message implements SerializationProtocol via SerializationMixin
|
||||
user_msg = Message(role="user", contents=["What's the weather like today?"])
|
||||
|
||||
# Serialize to dictionary - automatic type identification and nested serialization
|
||||
msg_dict = user_msg.to_dict()
|
||||
# Result: {
|
||||
# "type": "chat_message",
|
||||
# "role": {"type": "role", "value": "user"},
|
||||
# "contents": [{"type": "text_content", "text": "What's the weather like today?"}],
|
||||
# "message_id": "...",
|
||||
# "additional_properties": {}
|
||||
# }
|
||||
|
||||
# Deserialize back to Message instance - automatic type reconstruction
|
||||
restored_msg = Message.from_dict(msg_dict)
|
||||
print(restored_msg.text) # "What's the weather like today?"
|
||||
print(restored_msg.role) # "user"
|
||||
|
||||
# Verify protocol compliance (useful for type checking and validation)
|
||||
assert isinstance(user_msg, SerializationProtocol)
|
||||
assert isinstance(restored_msg, SerializationProtocol)
|
||||
|
||||
The protocol is also implemented by simpler classes like ``UsageDetails``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import UsageDetails
|
||||
|
||||
# Create usage tracking instance
|
||||
usage = UsageDetails(input_token_count=150, output_token_count=75, total_token_count=225)
|
||||
|
||||
# Seamless serialization with type preservation
|
||||
usage_dict = usage.to_dict()
|
||||
restored_usage = UsageDetails.from_dict(usage_dict)
|
||||
|
||||
# Both satisfy the SerializationProtocol
|
||||
assert isinstance(usage, SerializationProtocol)
|
||||
assert restored_usage.total_token_count == 225
|
||||
|
||||
The protocol ensures consistent serialization behavior across all framework components,
|
||||
enabling reliable data persistence, API communication, and object reconstruction
|
||||
throughout the agent framework ecosystem.
|
||||
"""
|
||||
|
||||
def to_dict(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Convert the instance to a dictionary.
|
||||
|
||||
Keyword Args:
|
||||
kwargs: Additional keyword arguments for serialization.
|
||||
|
||||
Returns:
|
||||
Dictionary representation of the instance.
|
||||
"""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: type[ProtocolT], value: MutableMapping[str, Any], /, **kwargs: Any) -> ProtocolT:
|
||||
"""Create an instance from a dictionary.
|
||||
|
||||
Args:
|
||||
value: Dictionary containing the instance data (positional-only).
|
||||
|
||||
Keyword Args:
|
||||
kwargs: Additional keyword arguments for deserialization.
|
||||
|
||||
Returns:
|
||||
New instance of the class.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def is_serializable(value: Any) -> bool:
|
||||
"""Check if a value is JSON serializable.
|
||||
|
||||
This function tests whether a value can be directly serialized to JSON
|
||||
without custom encoding. It checks for basic Python types that have
|
||||
direct JSON equivalents.
|
||||
|
||||
Args:
|
||||
value: The value to check for JSON serializability.
|
||||
|
||||
Returns:
|
||||
True if the value is one of the basic JSON-serializable types
|
||||
(str, int, float, bool, None, list, dict), False otherwise.
|
||||
|
||||
Note:
|
||||
This function only checks for direct JSON compatibility. Complex objects
|
||||
that implement ``SerializationProtocol`` require conversion via ``to_dict()``
|
||||
before JSON serialization.
|
||||
"""
|
||||
return isinstance(value, (str, int, float, bool, type(None), list, dict))
|
||||
|
||||
|
||||
class SerializationMixin:
|
||||
"""Mixin class providing comprehensive serialization and deserialization capabilities.
|
||||
|
||||
.. note::
|
||||
SerializationMixin is in active development. The API may change in future versions
|
||||
as we continue to improve and extend its functionality.
|
||||
|
||||
This mixin enables classes to automatically handle complex serialization scenarios
|
||||
including nested objects, dependency injection, and type conversion. It provides
|
||||
robust support for converting objects to/from dictionaries and JSON strings while
|
||||
maintaining object relationships and handling external dependencies.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Automatic serialization of nested SerializationProtocol objects
|
||||
- Support for lists and dictionaries containing serializable objects
|
||||
- Dependency injection system for non-serializable external dependencies
|
||||
- Flexible exclusion of fields from serialization
|
||||
- Type-safe deserialization with automatic type conversion
|
||||
|
||||
**Constructor Pattern for Nested Objects:**
|
||||
|
||||
Classes using this mixin should handle ``MutableMapping`` inputs in their ``__init__`` method
|
||||
for any parameters that expect ``SerializationMixin`` or ``SerializationProtocol`` instances.
|
||||
This enables automatic conversion of dictionaries to proper object instances during deserialization.
|
||||
|
||||
**Dependency Injection System:**
|
||||
|
||||
The mixin supports injecting external dependencies (like database connections, API clients,
|
||||
or configuration objects) that shouldn't be serialized but are needed at runtime.
|
||||
Fields marked in ``INJECTABLE`` are excluded during serialization and can be provided
|
||||
during deserialization via the ``dependencies`` parameter.
|
||||
|
||||
Examples:
|
||||
**Nested object serialization:**
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import Message
|
||||
from agent_framework._sessions import AgentSession
|
||||
|
||||
|
||||
# AgentSession uses SerializationMixin for state serialization
|
||||
session = AgentSession(session_id="test")
|
||||
|
||||
# Serialization produces a clean dict representation
|
||||
session_dict = session.to_dict()
|
||||
|
||||
# Reconstruction from dictionaries
|
||||
restored = AgentSession.from_dict(session_dict)
|
||||
|
||||
**Framework tools with exclusion patterns:**
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework._tools import BaseTool
|
||||
|
||||
|
||||
class WeatherTool(BaseTool):
|
||||
\"\"\"Example tool that extends BaseTool with additional properties exclusion.\"\"\"
|
||||
|
||||
# Inherits DEFAULT_EXCLUDE = {"additional_properties"} from BaseTool
|
||||
|
||||
def __init__(self, name: str, api_key: str, **kwargs):
|
||||
super().__init__(name=name, description="Get weather information", **kwargs)
|
||||
self.api_key = api_key # Will be serialized
|
||||
|
||||
# Additional properties are excluded from serialization
|
||||
self.additional_properties = {"version": "1.0", "internal_config": {...}}
|
||||
|
||||
|
||||
weather_tool = WeatherTool(name="get_weather", api_key="secret-key")
|
||||
|
||||
# Serialization excludes additional_properties but includes other fields
|
||||
tool_dict = weather_tool.to_dict()
|
||||
# Result: {
|
||||
# "type": "weather_tool",
|
||||
# "name": "get_weather",
|
||||
# "description": "Get weather information",
|
||||
# "api_key": "secret-key"
|
||||
# # additional_properties excluded due to DEFAULT_EXCLUDE
|
||||
# }
|
||||
|
||||
**Agent framework with injectable dependencies:**
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import BaseAgent
|
||||
|
||||
|
||||
class CustomAgent(BaseAgent):
|
||||
\"\"\"Custom agent extending BaseAgent with additional functionality.\"\"\"
|
||||
|
||||
# Inherits DEFAULT_EXCLUDE = {"additional_properties"} from BaseAgent
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="custom-agent", description="A custom agent", **kwargs)
|
||||
|
||||
# additional_properties stores runtime configuration but isn't serialized
|
||||
self.additional_properties.update({
|
||||
"runtime_context": {...},
|
||||
"session_data": {...}
|
||||
})
|
||||
|
||||
|
||||
agent = CustomAgent(
|
||||
context_provider=[...],
|
||||
middleware=[...]
|
||||
)
|
||||
|
||||
# Serialization captures agent configuration but excludes runtime data
|
||||
agent_dict = agent.to_dict()
|
||||
# Result: {
|
||||
# "type": "custom_agent",
|
||||
# "id": "...",
|
||||
# "name": "custom-agent",
|
||||
# "description": "A custom agent",
|
||||
# "context_provider": [...],
|
||||
# "middleware": [...]
|
||||
# # additional_properties excluded
|
||||
# }
|
||||
|
||||
# Agent can be reconstructed with the same configuration
|
||||
restored_agent = CustomAgent.from_dict(agent_dict)
|
||||
|
||||
This approach enables the agent framework to maintain clean separation between
|
||||
persistent configuration and transient runtime state, allowing agents and tools
|
||||
to be serialized for storage or transmission while preserving their functionality.
|
||||
"""
|
||||
|
||||
DEFAULT_EXCLUDE: ClassVar[set[str]] = set()
|
||||
INJECTABLE: ClassVar[set[str]] = set()
|
||||
_SHALLOW_COPY_FIELDS: ClassVar[set[str]] = {"raw_representation"}
|
||||
|
||||
def __deepcopy__(self, memo: dict[int, Any]) -> SerializationMixin:
|
||||
"""Create a deep copy, preserving ``_SHALLOW_COPY_FIELDS`` by reference.
|
||||
|
||||
Fields listed in ``_SHALLOW_COPY_FIELDS`` may contain LLM SDK objects
|
||||
(e.g., proto/gRPC responses) that are not safe to deep-copy. They are
|
||||
kept as shallow references in the copy; all other attributes are
|
||||
deep-copied normally.
|
||||
"""
|
||||
cls = type(self)
|
||||
result = cls.__new__(cls)
|
||||
memo[id(self)] = result
|
||||
for k, v in self.__dict__.items():
|
||||
if k in cls._SHALLOW_COPY_FIELDS:
|
||||
object.__setattr__(result, k, v)
|
||||
else:
|
||||
object.__setattr__(result, k, copy.deepcopy(v, memo))
|
||||
return result
|
||||
|
||||
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
|
||||
"""Convert the instance and any nested objects to a dictionary.
|
||||
|
||||
This method performs deep serialization, automatically converting nested
|
||||
``SerializationProtocol`` objects, lists, and dictionaries containing
|
||||
serializable objects. Non-serializable objects are skipped with debug logging.
|
||||
|
||||
Fields marked in ``DEFAULT_EXCLUDE`` and ``INJECTABLE`` are automatically
|
||||
excluded from the output, as are any private attributes (starting with '_').
|
||||
|
||||
Keyword Args:
|
||||
exclude: Additional field names to exclude from serialization beyond
|
||||
the default exclusions (``DEFAULT_EXCLUDE`` and ``INJECTABLE``).
|
||||
exclude_none: Whether to exclude None values from the output. When True,
|
||||
None values are omitted from the dictionary. Defaults to True.
|
||||
|
||||
Returns:
|
||||
Dictionary representation of the instance including a 'type' field
|
||||
for type identification during deserialization (unless 'type' is excluded).
|
||||
"""
|
||||
# Combine exclude sets
|
||||
combined_exclude = set(self.DEFAULT_EXCLUDE)
|
||||
if exclude:
|
||||
combined_exclude.update(exclude)
|
||||
combined_exclude.update(self.INJECTABLE)
|
||||
|
||||
# Get all instance attributes
|
||||
result: dict[str, Any] = {} if "type" in combined_exclude else {"type": self._get_type_identifier()}
|
||||
for key, value in self.__dict__.items():
|
||||
if key not in combined_exclude and not key.startswith("_"):
|
||||
if exclude_none and value is None:
|
||||
continue
|
||||
# Recursively serialize SerializationProtocol objects
|
||||
if isinstance(value, SerializationProtocol):
|
||||
result[key] = value.to_dict(exclude=exclude, exclude_none=exclude_none)
|
||||
continue
|
||||
# Handle lists containing SerializationProtocol objects
|
||||
if isinstance(value, list):
|
||||
value_as_list: list[Any] = []
|
||||
for item in value: # pyright: ignore[reportUnknownVariableType]
|
||||
if isinstance(item, SerializationProtocol):
|
||||
value_as_list.append(item.to_dict(exclude=exclude, exclude_none=exclude_none))
|
||||
continue
|
||||
if is_serializable(item):
|
||||
value_as_list.append(item)
|
||||
continue
|
||||
logger.debug(
|
||||
f"Skipping non-serializable item in list attribute '{key}' of type {type(item).__name__}" # pyright: ignore[reportUnknownArgumentType]
|
||||
)
|
||||
result[key] = value_as_list
|
||||
continue
|
||||
# Handle dicts containing SerializationProtocol values
|
||||
if isinstance(value, dict):
|
||||
from datetime import date, datetime, time
|
||||
|
||||
serialized_dict: dict[str, Any] = {}
|
||||
for raw_key, v in value.items(): # pyright: ignore[reportUnknownVariableType]
|
||||
dict_key = str(raw_key) # pyright: ignore[reportUnknownArgumentType]
|
||||
if isinstance(v, SerializationProtocol):
|
||||
serialized_dict[dict_key] = v.to_dict(exclude=exclude, exclude_none=exclude_none)
|
||||
continue
|
||||
# Convert datetime objects to strings
|
||||
if isinstance(v, (datetime, date, time)):
|
||||
serialized_dict[dict_key] = str(v)
|
||||
continue
|
||||
# Check if the value is JSON serializable
|
||||
if is_serializable(v):
|
||||
serialized_dict[dict_key] = v
|
||||
continue
|
||||
logger.debug(
|
||||
f"Skipping non-serializable value for key '{dict_key}' in dict attribute '{key}' "
|
||||
f"of type {type(v).__name__}" # pyright: ignore[reportUnknownArgumentType]
|
||||
)
|
||||
result[key] = serialized_dict
|
||||
continue
|
||||
# Directly include JSON serializable values
|
||||
if is_serializable(value):
|
||||
result[key] = value
|
||||
continue
|
||||
logger.debug(f"Skipping non-serializable attribute '{key}' of type {type(value).__name__}")
|
||||
|
||||
return result
|
||||
|
||||
def to_json(self, *, exclude: set[str] | None = None, exclude_none: bool = True, **kwargs: Any) -> str:
|
||||
"""Convert the instance to a JSON string.
|
||||
|
||||
This is a convenience method that calls ``to_dict()`` and then serializes
|
||||
the result using ``json.dumps()``. All the same serialization rules apply
|
||||
as in ``to_dict()``, including automatic exclusion of injectable dependencies
|
||||
and deep serialization of nested objects.
|
||||
|
||||
Keyword Args:
|
||||
exclude: Additional field names to exclude from serialization.
|
||||
exclude_none: Whether to exclude None values from the output. Defaults to True.
|
||||
**kwargs: Additional keyword arguments passed through to ``json.dumps()``.
|
||||
Common options include ``indent`` for pretty-printing and
|
||||
``ensure_ascii`` for Unicode handling.
|
||||
|
||||
Returns:
|
||||
JSON string representation of the instance.
|
||||
"""
|
||||
return json.dumps(self.to_dict(exclude=exclude, exclude_none=exclude_none), **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls: type[ClassT], value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None
|
||||
) -> ClassT:
|
||||
"""Create an instance from a dictionary with optional dependency injection.
|
||||
|
||||
This method reconstructs an object from its dictionary representation, automatically
|
||||
handling type conversion and dependency injection. It supports three patterns of
|
||||
dependency injection to handle different scenarios where external dependencies
|
||||
need to be provided at deserialization time.
|
||||
|
||||
Args:
|
||||
value: The dictionary containing the instance data (positional-only).
|
||||
Must include a 'type' field matching the class type identifier.
|
||||
|
||||
Keyword Args:
|
||||
dependencies: A nested dictionary mapping type identifiers to their injectable dependencies.
|
||||
The structure varies based on injection pattern:
|
||||
|
||||
- **Simple injection**: ``{"<type>": {"<parameter>": value}}``
|
||||
- **Dict parameter injection**: ``{"<type>": {"<dict-parameter>": {"<key>": value}}}``
|
||||
- **Instance-specific injection**: ``{"<type>": {"<field>:<value>": {"<parameter>": value}}}``
|
||||
|
||||
Returns:
|
||||
New instance of the class with injected dependencies.
|
||||
|
||||
Raises:
|
||||
ValueError: If the 'type' field in the data doesn't match the class type identifier.
|
||||
|
||||
Examples:
|
||||
**Simple Client Injection** - OpenAI client dependency injection:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
|
||||
# OpenAI chat client requires an AsyncOpenAI client instance.
|
||||
# The client dependency is excluded from serialization.
|
||||
|
||||
# Serialized data contains only the model configuration
|
||||
client_data = {
|
||||
"type": "open_ai_chat_client",
|
||||
"model": "gpt-4o-mini",
|
||||
# client is excluded from serialization
|
||||
}
|
||||
|
||||
# Provide the OpenAI client during deserialization
|
||||
openai_client = AsyncOpenAI(api_key="your-api-key")
|
||||
dependencies = {"open_ai_chat_client": {"client": openai_client}}
|
||||
|
||||
# The chat client is reconstructed with the OpenAI client injected
|
||||
client = OpenAIChatClient.from_dict(client_data, dependencies=dependencies)
|
||||
# Now ready to make API calls with the injected client
|
||||
|
||||
**Function Injection for Tools** - FunctionTool runtime dependency:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import FunctionTool
|
||||
from typing import Annotated
|
||||
|
||||
|
||||
# Define a function to be wrapped
|
||||
async def get_current_weather(location: Annotated[str, "The city name"]) -> str:
|
||||
# In real implementation, this would call a weather API
|
||||
return f"Current weather in {location}: 72°F and sunny"
|
||||
|
||||
|
||||
# FunctionTool has INJECTABLE = {"func"}
|
||||
function_data = {
|
||||
"type": "function_tool",
|
||||
"name": "get_weather",
|
||||
"description": "Get current weather for a location",
|
||||
# func is excluded from serialization
|
||||
}
|
||||
|
||||
# Inject the actual function implementation during deserialization
|
||||
dependencies = {"function_tool": {"func": get_current_weather}}
|
||||
|
||||
# Reconstruct the FunctionTool with the callable injected
|
||||
weather_func = FunctionTool.from_dict(function_data, dependencies=dependencies)
|
||||
# The function is now callable and ready for agent use
|
||||
|
||||
**MiddlewareTypes Context Injection** - Agent execution context:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework._middleware import AgentContext
|
||||
from agent_framework import BaseAgent
|
||||
|
||||
# AgentContext has INJECTABLE = {"agent", "result"}
|
||||
context_data = {
|
||||
"type": "agent_context",
|
||||
"messages": [{"role": "user", "text": "Hello"}],
|
||||
"stream": False,
|
||||
"metadata": {"session_id": "abc123"},
|
||||
# agent and result are excluded from serialization
|
||||
}
|
||||
|
||||
# Inject agent and result during middleware processing
|
||||
my_agent = BaseAgent(name="test-agent")
|
||||
dependencies = {
|
||||
"agent_context": {
|
||||
"agent": my_agent,
|
||||
"result": None, # Will be populated during execution
|
||||
}
|
||||
}
|
||||
|
||||
# Reconstruct context with agent dependency for middleware chain
|
||||
context = AgentContext.from_dict(context_data, dependencies=dependencies)
|
||||
# MiddlewareTypes can now access context.agent and process the execution
|
||||
|
||||
This injection system allows the agent framework to maintain clean separation
|
||||
between serializable configuration and runtime dependencies like API clients,
|
||||
functions, and execution contexts that cannot or should not be persisted.
|
||||
"""
|
||||
if dependencies is None:
|
||||
dependencies = {}
|
||||
|
||||
# Get the type identifier
|
||||
type_id = cls._get_type_identifier(value)
|
||||
|
||||
if (supplied_type := value.get("type")) and supplied_type != type_id:
|
||||
raise ValueError(f"Type mismatch: expected '{type_id}', got '{supplied_type}'")
|
||||
|
||||
# Create a copy of the value dict to work with, filtering out the 'type' key
|
||||
kwargs = {k: v for k, v in value.items() if k != "type"}
|
||||
|
||||
# Process dependencies using dict-based structure
|
||||
type_deps = dependencies.get(type_id, {})
|
||||
for dep_key, dep_value in type_deps.items():
|
||||
# Check if this is an instance-specific dependency (field:name format)
|
||||
if ":" in dep_key:
|
||||
field, name = dep_key.split(":", 1)
|
||||
# Only apply if the instance matches
|
||||
if kwargs.get(field) == name and isinstance(dep_value, dict):
|
||||
# Apply instance-specific dependencies
|
||||
for raw_param_name, param_value in dep_value.items(): # pyright: ignore[reportUnknownVariableType]
|
||||
param_name = str(raw_param_name) # pyright: ignore[reportUnknownArgumentType]
|
||||
if param_name not in cls.INJECTABLE:
|
||||
logger.debug(
|
||||
f"Dependency '{param_name}' for type '{type_id}' is not in INJECTABLE set. "
|
||||
f"Available injectable parameters: {cls.INJECTABLE}"
|
||||
)
|
||||
# Handle nested dict parameters
|
||||
if (
|
||||
isinstance(param_value, dict)
|
||||
and param_name in kwargs
|
||||
and isinstance(kwargs[param_name], dict)
|
||||
):
|
||||
kwargs[param_name].update(param_value)
|
||||
else:
|
||||
kwargs[param_name] = param_value
|
||||
else:
|
||||
# Regular parameter dependency
|
||||
if dep_key not in cls.INJECTABLE:
|
||||
logger.debug(
|
||||
f"Dependency '{dep_key}' for type '{type_id}' is not in INJECTABLE set. "
|
||||
f"Available injectable parameters: {cls.INJECTABLE}"
|
||||
)
|
||||
# Handle dict parameters - merge if both are dicts
|
||||
if isinstance(dep_value, dict) and dep_key in kwargs and isinstance(kwargs[dep_key], dict):
|
||||
kwargs[dep_key].update(dep_value)
|
||||
else:
|
||||
kwargs[dep_key] = dep_value
|
||||
|
||||
return cls(**kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls: type[ClassT], value: str, /, *, dependencies: MutableMapping[str, Any] | None = None) -> ClassT:
|
||||
"""Create an instance from a JSON string.
|
||||
|
||||
This is a convenience method that parses the JSON string using ``json.loads()``
|
||||
and then calls ``from_dict()`` to reconstruct the object. All dependency injection
|
||||
capabilities are available through the ``dependencies`` parameter.
|
||||
|
||||
Args:
|
||||
value: The JSON string containing the instance data (positional-only).
|
||||
Must be valid JSON that deserializes to a dictionary with a 'type' field.
|
||||
|
||||
Keyword Args:
|
||||
dependencies: A nested dictionary mapping type identifiers to their injectable dependencies.
|
||||
See :meth:`from_dict` for detailed structure and examples of the three
|
||||
injection patterns (simple, dict parameter, and instance-specific).
|
||||
|
||||
Returns:
|
||||
New instance of the class with any specified dependencies injected.
|
||||
|
||||
Raises:
|
||||
json.JSONDecodeError: If the JSON string is malformed.
|
||||
ValueError: If the parsed data doesn't contain a valid 'type' field.
|
||||
"""
|
||||
data = json.loads(value)
|
||||
return cls.from_dict(data, dependencies=dependencies)
|
||||
|
||||
@classmethod
|
||||
def _get_type_identifier(cls, value: Mapping[str, Any] | None = None) -> str:
|
||||
"""Get the type identifier for this class.
|
||||
|
||||
The type identifier is used in serialized data to enable proper deserialization.
|
||||
It follows a priority order to determine the identifier:
|
||||
|
||||
1. If ``value`` contains a 'type' field, return that value (for ``from_dict``)
|
||||
2. If the class has a ``type`` attribute, use that value (instance-level)
|
||||
3. If the class has a ``TYPE`` attribute, use that value (class-level constant)
|
||||
4. Otherwise, convert the class name to snake_case as fallback
|
||||
|
||||
Args:
|
||||
value: Optional mapping containing serialized data that may have a 'type' field.
|
||||
|
||||
Returns:
|
||||
Type identifier string used for serialization and dependency injection mapping.
|
||||
"""
|
||||
# for from_dict
|
||||
if value and (type_ := value.get("type")) and isinstance(type_, str):
|
||||
return type_
|
||||
# for todict when defined per instance
|
||||
if (type_ := getattr(cls, "type", None)) and isinstance(type_, str):
|
||||
return type_
|
||||
# for both when defined on class.
|
||||
if (type_ := getattr(cls, "TYPE", None)) and isinstance(type_, str):
|
||||
return type_
|
||||
# Fallback and default
|
||||
# Convert class name to snake_case
|
||||
return _CAMEL_TO_SNAKE_PATTERN.sub("_", cls.__name__).lower()
|
||||
|
||||
|
||||
def make_json_safe(obj: Any) -> Any:
|
||||
"""Recursively convert an object to a JSON-serializable form.
|
||||
|
||||
Handles dataclasses, Pydantic models, objects with ``to_dict``/``dict``/``__dict__``,
|
||||
datetimes, lists, dicts, and primitives. Falls back to ``str()`` for any remaining
|
||||
non-serializable value so that ``json.dumps`` never raises a ``TypeError``.
|
||||
|
||||
Args:
|
||||
obj: Object to make JSON safe.
|
||||
|
||||
Returns:
|
||||
A JSON-serializable version of the object.
|
||||
"""
|
||||
if obj is None or isinstance(obj, (str, int, float, bool)):
|
||||
return obj
|
||||
if isinstance(obj, (datetime, date)):
|
||||
return obj.isoformat()
|
||||
if is_dataclass(obj) and not isinstance(obj, type):
|
||||
return make_json_safe(asdict(obj))
|
||||
if callable(getattr(obj, "model_dump", None)):
|
||||
try:
|
||||
return make_json_safe(obj.model_dump()) # type: ignore[no-any-return]
|
||||
except TypeError:
|
||||
pass
|
||||
if callable(getattr(obj, "to_dict", None)):
|
||||
try:
|
||||
return make_json_safe(obj.to_dict()) # type: ignore[no-any-return]
|
||||
except TypeError:
|
||||
pass
|
||||
if callable(getattr(obj, "dict", None)):
|
||||
try:
|
||||
return make_json_safe(obj.dict()) # type: ignore[no-any-return]
|
||||
except TypeError:
|
||||
pass
|
||||
if isinstance(obj, dict):
|
||||
return {str(key): make_json_safe(value) for key, value in obj.items()} # type: ignore[misc]
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [make_json_safe(item) for item in obj] # type: ignore[misc]
|
||||
if hasattr(obj, "__dict__"):
|
||||
return {key: make_json_safe(value) for key, value in vars(obj).items()}
|
||||
return str(obj)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,293 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Generic settings loader with environment variable resolution.
|
||||
|
||||
This module provides a ``load_settings()`` function that populates a ``TypedDict``
|
||||
from environment variables, ``.env`` files, and explicit overrides. It replaces
|
||||
the previous pydantic-settings-based ``AFBaseSettings`` with a lighter-weight,
|
||||
function-based approach that has no pydantic-settings dependency.
|
||||
|
||||
Usage::
|
||||
|
||||
class MySettings(TypedDict, total=False):
|
||||
api_key: str | None # optional — resolves to None if not set
|
||||
model: str | None # optional by default
|
||||
source_a: str | None
|
||||
source_b: str | None
|
||||
|
||||
|
||||
# Make model required; require exactly one of source_a / source_b:
|
||||
settings = load_settings(
|
||||
MySettings,
|
||||
env_prefix="MY_APP_",
|
||||
required_fields=["model", ("source_a", "source_b")],
|
||||
model="gpt-4",
|
||||
source_a="value",
|
||||
)
|
||||
settings["api_key"] # type-checked dict access
|
||||
settings["model"] # str | None per type, but guaranteed not None at runtime
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable, Sequence
|
||||
from contextlib import suppress
|
||||
from typing import Any, Union, get_args, get_origin, get_type_hints
|
||||
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from .exceptions import SettingNotFoundError
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import TypeVar # pragma: no cover
|
||||
|
||||
|
||||
SettingsT = TypeVar("SettingsT", default=dict[str, Any])
|
||||
|
||||
|
||||
class SecretString(str):
|
||||
"""A string subclass that masks its value in repr() to prevent accidental exposure.
|
||||
|
||||
SecretString behaves exactly like a regular string in all operations,
|
||||
but its repr() shows '**********' instead of the actual value.
|
||||
This helps prevent secrets from being accidentally logged or displayed.
|
||||
|
||||
It also provides a ``get_secret_value()`` method for backward compatibility
|
||||
with code that previously used ``pydantic.SecretStr``.
|
||||
|
||||
Example:
|
||||
```python
|
||||
api_key = SecretString("sk-secret-key")
|
||||
print(api_key) # sk-secret-key (normal string behavior)
|
||||
print(repr(api_key)) # SecretString('**********')
|
||||
print(f"Key: {api_key}") # Key: sk-secret-key
|
||||
print(api_key.get_secret_value()) # sk-secret-key
|
||||
```
|
||||
"""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a masked representation to prevent secret exposure."""
|
||||
return "SecretString('**********')"
|
||||
|
||||
def get_secret_value(self) -> str:
|
||||
"""Return the underlying string value.
|
||||
|
||||
Provided for backward compatibility with ``pydantic.SecretStr``.
|
||||
Since SecretString *is* a str, this simply returns ``str(self)``.
|
||||
"""
|
||||
return str(self)
|
||||
|
||||
|
||||
def _coerce_value(value: str, target_type: type) -> Any:
|
||||
"""Coerce a string value to the target type."""
|
||||
origin = get_origin(target_type)
|
||||
args = get_args(target_type)
|
||||
|
||||
# Handle Union types (e.g., str | None) — try each non-None arm
|
||||
if origin is type(None):
|
||||
return None
|
||||
|
||||
if args and type(None) in args:
|
||||
for arg in args:
|
||||
if arg is not type(None):
|
||||
with suppress(ValueError, TypeError):
|
||||
return _coerce_value(value, arg)
|
||||
return value
|
||||
|
||||
# Handle SecretString
|
||||
if target_type is SecretString or (isinstance(target_type, type) and issubclass(target_type, SecretString)):
|
||||
return SecretString(value)
|
||||
|
||||
# Handle basic types
|
||||
if target_type is str:
|
||||
return value
|
||||
if target_type is int:
|
||||
return int(value)
|
||||
if target_type is float:
|
||||
return float(value)
|
||||
if target_type is bool:
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _check_override_type(value: Any, field_type: type, field_name: str) -> None:
|
||||
"""Validate that *value* is compatible with *field_type*.
|
||||
|
||||
Raises ``ValueError`` when the override is clearly
|
||||
incompatible (e.g. a ``dict`` passed where ``str`` is expected).
|
||||
Callable values and ``None`` are always accepted.
|
||||
"""
|
||||
if value is None:
|
||||
return
|
||||
|
||||
# Callables are always allowed (e.g. lazy token providers)
|
||||
if callable(value) and not isinstance(value, (str, bytes)):
|
||||
return
|
||||
|
||||
# Collect the concrete types that *field_type* allows
|
||||
origin = get_origin(field_type)
|
||||
args = get_args(field_type)
|
||||
|
||||
allowed: tuple[type, ...]
|
||||
if origin is Union or origin is type(int | str):
|
||||
allowed = tuple(a for a in args if isinstance(a, type) and a is not type(None))
|
||||
# If any arm is a Callable, allow anything callable
|
||||
if any(get_origin(a) is Callable or a is Callable for a in args):
|
||||
return
|
||||
elif isinstance(field_type, type):
|
||||
allowed = (field_type,)
|
||||
else:
|
||||
return # complex / unknown annotation — skip check
|
||||
|
||||
if not allowed:
|
||||
return
|
||||
|
||||
if not isinstance(value, allowed):
|
||||
# Allow str for SecretString fields (will be coerced)
|
||||
if isinstance(value, str) and any(isinstance(a, type) and issubclass(a, str) for a in allowed):
|
||||
return
|
||||
# Allow int for float fields (standard numeric promotion)
|
||||
if isinstance(value, int) and float in allowed:
|
||||
return
|
||||
|
||||
allowed_names = ", ".join(t.__name__ for t in allowed)
|
||||
raise ValueError(
|
||||
f"Invalid type for setting '{field_name}': expected {allowed_names}, got {type(value).__name__}."
|
||||
)
|
||||
|
||||
|
||||
def load_settings(
|
||||
settings_type: type[SettingsT],
|
||||
*,
|
||||
env_prefix: str = "",
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
required_fields: Sequence[str | tuple[str, ...]] | None = None,
|
||||
**overrides: Any,
|
||||
) -> SettingsT:
|
||||
"""Load settings from explicit overrides, an optional ``.env`` file, and environment variables.
|
||||
|
||||
The *settings_type* must be a ``TypedDict`` subclass. Values are resolved in
|
||||
this order (highest priority first):
|
||||
|
||||
1. Explicit keyword *overrides* (``None`` values are filtered out).
|
||||
2. A ``.env`` file (when *env_file_path* is explicitly provided).
|
||||
3. Environment variables (``<env_prefix><FIELD_NAME>``).
|
||||
4. Default values — fields with class-level defaults on the TypedDict, or
|
||||
``None`` for optional fields.
|
||||
|
||||
Entries in *required_fields* are validated after resolution:
|
||||
|
||||
- A **string** entry means the field must resolve to a non-``None`` value.
|
||||
- A **tuple** entry means exactly one field in the group must be non-``None``
|
||||
(mutually exclusive).
|
||||
|
||||
Args:
|
||||
settings_type: A ``TypedDict`` class describing the settings schema.
|
||||
env_prefix: Prefix for environment variable lookup (e.g. ``"OPENAI_"``).
|
||||
env_file_path: Path to ``.env`` file. When provided, the file is required
|
||||
and values are resolved before process environment variables.
|
||||
env_file_encoding: Encoding for reading the ``.env`` file. Defaults to ``"utf-8"``.
|
||||
required_fields: Field names (``str``) that must resolve to a non-``None``
|
||||
value, or tuples of field names where exactly one must be set.
|
||||
**overrides: Field values. ``None`` values are ignored so that callers can
|
||||
forward optional parameters without masking env-var / default resolution.
|
||||
|
||||
Returns:
|
||||
A populated dict matching *settings_type*.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If *env_file_path* was provided but the file does not exist.
|
||||
SettingNotFoundError: If a required field could not be resolved from any
|
||||
source, or if a mutually exclusive constraint is violated.
|
||||
ValueError: If an override value has an incompatible type.
|
||||
"""
|
||||
encoding = env_file_encoding or "utf-8"
|
||||
|
||||
loaded_dotenv_values: dict[str, str] = {}
|
||||
if env_file_path is not None:
|
||||
if not os.path.exists(env_file_path):
|
||||
raise FileNotFoundError(env_file_path)
|
||||
|
||||
raw_dotenv_values = dotenv_values(dotenv_path=env_file_path, encoding=encoding)
|
||||
loaded_dotenv_values = {key: value for key, value in raw_dotenv_values.items() if value is not None}
|
||||
|
||||
# Filter out None overrides so defaults / env vars are preserved
|
||||
overrides = {k: v for k, v in overrides.items() if v is not None}
|
||||
|
||||
# Get field type hints from the TypedDict
|
||||
hints = get_type_hints(settings_type)
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
for field_name, field_type in hints.items():
|
||||
# 1. Explicit override wins
|
||||
if field_name in overrides:
|
||||
override_value = overrides[field_name]
|
||||
_check_override_type(override_value, field_type, field_name)
|
||||
# Coerce plain str → SecretString if the annotation expects it
|
||||
if isinstance(override_value, str) and not isinstance(override_value, SecretString):
|
||||
with suppress(ValueError, TypeError):
|
||||
coerced = _coerce_value(override_value, field_type)
|
||||
if isinstance(coerced, SecretString):
|
||||
override_value = coerced
|
||||
result[field_name] = override_value
|
||||
continue
|
||||
|
||||
env_var_name = f"{env_prefix}{field_name.upper()}"
|
||||
|
||||
# 2. Optional .env value (only when env_file_path is explicitly provided)
|
||||
if loaded_dotenv_values:
|
||||
dotenv_value = loaded_dotenv_values.get(env_var_name)
|
||||
if dotenv_value is not None:
|
||||
try:
|
||||
result[field_name] = _coerce_value(dotenv_value, field_type)
|
||||
except (ValueError, TypeError):
|
||||
result[field_name] = dotenv_value
|
||||
continue
|
||||
|
||||
# 3. Environment variable
|
||||
env_value = os.getenv(env_var_name)
|
||||
if env_value is not None:
|
||||
try:
|
||||
result[field_name] = _coerce_value(env_value, field_type)
|
||||
except (ValueError, TypeError):
|
||||
result[field_name] = env_value
|
||||
continue
|
||||
|
||||
# 4. Default from TypedDict class-level defaults, or None for optional fields
|
||||
if hasattr(settings_type, field_name):
|
||||
result[field_name] = getattr(settings_type, field_name)
|
||||
else:
|
||||
result[field_name] = None
|
||||
|
||||
# Validate required fields after all resolution
|
||||
if required_fields:
|
||||
for entry in required_fields:
|
||||
if isinstance(entry, str):
|
||||
# Single required field
|
||||
if result.get(entry) is None:
|
||||
env_var_name = f"{env_prefix}{entry.upper()}"
|
||||
raise SettingNotFoundError(
|
||||
f"Required setting '{entry}' was not provided. "
|
||||
f"Set it via the '{entry}' parameter or the "
|
||||
f"'{env_var_name}' environment variable."
|
||||
)
|
||||
else:
|
||||
# Mutually exclusive group — exactly one must be set
|
||||
set_fields = [f for f in entry if result.get(f) is not None]
|
||||
if len(set_fields) == 0:
|
||||
names = ", ".join(f"'{f}'" for f in entry)
|
||||
raise SettingNotFoundError(f"Exactly one of {names} must be provided, but none was set.")
|
||||
if len(set_fields) > 1:
|
||||
all_names = ", ".join(f"'{f}'" for f in entry)
|
||||
set_names = ", ".join(f"'{f}'" for f in set_fields)
|
||||
raise SettingNotFoundError(
|
||||
f"Only one of {all_names} may be provided, but multiple were set: {set_names}."
|
||||
)
|
||||
|
||||
return result # type: ignore[return-value]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Final
|
||||
|
||||
from . import __version__ as version_info
|
||||
|
||||
logger = logging.getLogger("agent_framework")
|
||||
|
||||
|
||||
# Note that if this environment variable does not exist, user agent telemetry is enabled.
|
||||
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR = "AGENT_FRAMEWORK_USER_AGENT_DISABLED"
|
||||
IS_TELEMETRY_ENABLED = os.environ.get(USER_AGENT_TELEMETRY_DISABLED_ENV_VAR, "false").lower() not in ["true", "1"]
|
||||
|
||||
APP_INFO = (
|
||||
{
|
||||
"agent-framework-version": f"python/{version_info}",
|
||||
}
|
||||
if IS_TELEMETRY_ENABLED
|
||||
else None
|
||||
)
|
||||
USER_AGENT_KEY: Final[str] = "User-Agent"
|
||||
HTTP_USER_AGENT: Final[str] = "agent-framework-python"
|
||||
AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}"
|
||||
|
||||
# This environment variable is reserved by the Foundry hosting environment to
|
||||
# indicate that the agent is running in a hosted environment.
|
||||
_FOUNDRY_HOSTING_ENV_VAR = "FOUNDRY_HOSTING_ENVIRONMENT"
|
||||
# This prefix is added to the user agent string when the agent is running in a hosted environment.
|
||||
_HOSTED_USER_AGENT_PREFIX = "foundry-hosting"
|
||||
|
||||
_user_agent_prefixes: set[str] = set()
|
||||
_hosted_env_detected: bool = False
|
||||
|
||||
|
||||
def _add_user_agent_prefix(prefix: str) -> None:
|
||||
"""Permanently add a prefix to the user agent string.
|
||||
|
||||
This is used by hosting layers to identify themselves in telemetry.
|
||||
Once added, the prefix applies to all subsequent user agent strings.
|
||||
|
||||
Args:
|
||||
prefix: The prefix to add (e.g. "foundry-hosting").
|
||||
"""
|
||||
if prefix:
|
||||
_user_agent_prefixes.add(prefix)
|
||||
|
||||
|
||||
def _detect_hosted_environment() -> None:
|
||||
"""Detect if running in a hosted environment and add the user agent prefix.
|
||||
|
||||
Checks the ``FOUNDRY_HOSTING_ENVIRONMENT`` env var first, then falls back
|
||||
to checking whether the agent server SDK is installed (via
|
||||
``importlib.util.find_spec``) before importing it, to avoid unnecessary
|
||||
import overhead for non-hosted scenarios.
|
||||
"""
|
||||
global _hosted_env_detected
|
||||
if _hosted_env_detected:
|
||||
return
|
||||
|
||||
if (env_value := os.environ.get(_FOUNDRY_HOSTING_ENV_VAR)) is not None:
|
||||
# Env var exists — trust its value and skip the fallback.
|
||||
if env_value:
|
||||
_add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX)
|
||||
_hosted_env_detected = True
|
||||
return
|
||||
|
||||
# Env var not set — fall back to AgentConfig as a second layer of defense.
|
||||
# Use find_spec to avoid the cost of a full import when the SDK is not installed.
|
||||
import importlib.util
|
||||
|
||||
try:
|
||||
if importlib.util.find_spec("azure.ai.agentserver.core") is None:
|
||||
return
|
||||
except (ImportError, ValueError):
|
||||
return
|
||||
with contextlib.suppress(ImportError, AttributeError):
|
||||
from azure.ai.agentserver.core import (
|
||||
AgentConfig,
|
||||
)
|
||||
|
||||
if AgentConfig.from_env().is_hosted:
|
||||
_add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX)
|
||||
_hosted_env_detected = True
|
||||
|
||||
|
||||
def get_user_agent() -> str:
|
||||
"""Return the full user agent string including any registered prefixes."""
|
||||
_detect_hosted_environment()
|
||||
if not _user_agent_prefixes:
|
||||
return AGENT_FRAMEWORK_USER_AGENT
|
||||
return f"{'/'.join(sorted(_user_agent_prefixes))}/{AGENT_FRAMEWORK_USER_AGENT}"
|
||||
|
||||
|
||||
def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Prepend "agent-framework" to the User-Agent in the headers.
|
||||
|
||||
When user agent telemetry is disabled through the ``AGENT_FRAMEWORK_USER_AGENT_DISABLED``
|
||||
environment variable, the User-Agent header will not include the agent-framework information.
|
||||
It will be sent back as is, or as an empty dict when None is passed.
|
||||
|
||||
Args:
|
||||
headers: The existing headers dictionary.
|
||||
|
||||
Returns:
|
||||
A new dict with "User-Agent" set to "agent-framework-python/{version}" if headers is None.
|
||||
The modified headers dictionary with "agent-framework-python/{version}" prepended to the User-Agent.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import prepend_agent_framework_to_user_agent
|
||||
|
||||
# Add agent-framework to new headers
|
||||
headers = prepend_agent_framework_to_user_agent()
|
||||
print(headers["User-Agent"]) # "agent-framework-python/0.1.0"
|
||||
|
||||
# Prepend to existing headers
|
||||
existing = {"User-Agent": "my-app/1.0"}
|
||||
headers = prepend_agent_framework_to_user_agent(existing)
|
||||
print(headers["User-Agent"]) # "agent-framework-python/0.1.0 my-app/1.0"
|
||||
"""
|
||||
if not IS_TELEMETRY_ENABLED:
|
||||
return headers or {}
|
||||
user_agent = get_user_agent()
|
||||
if not headers:
|
||||
return {USER_AGENT_KEY: user_agent}
|
||||
headers[USER_AGENT_KEY] = f"{user_agent} {headers[USER_AGENT_KEY]}" if USER_AGENT_KEY in headers else user_agent
|
||||
|
||||
return headers
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
@@ -0,0 +1,922 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast, overload
|
||||
|
||||
from .._agents import BaseAgent
|
||||
from .._sessions import (
|
||||
AgentSession,
|
||||
ContextProvider,
|
||||
HistoryProvider,
|
||||
InMemoryHistoryProvider,
|
||||
SessionContext,
|
||||
)
|
||||
from .._types import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentRunInputs,
|
||||
Content,
|
||||
Message,
|
||||
ResponseStream,
|
||||
UsageDetails,
|
||||
add_usage_details,
|
||||
)
|
||||
from ..exceptions import AgentException, AgentInvalidRequestException, AgentInvalidResponseException
|
||||
from ._checkpoint import CheckpointStorage
|
||||
from ._events import (
|
||||
AGENT_FORWARDED_EVENT_TYPES,
|
||||
WorkflowEvent,
|
||||
WorkflowRunState,
|
||||
)
|
||||
from ._message_utils import normalize_messages_input
|
||||
from ._typing_utils import is_instance_of, is_type_compatible
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import TypedDict # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import TypedDict # pragma: no cover
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._workflow import Workflow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowAgent(BaseAgent):
|
||||
"""An `Agent` subclass that wraps a workflow and exposes it as an agent."""
|
||||
|
||||
# Class variable for the request info function name
|
||||
REQUEST_INFO_FUNCTION_NAME: ClassVar[str] = "request_info"
|
||||
|
||||
@dataclass
|
||||
class RequestInfoFunctionArgs:
|
||||
request_id: str
|
||||
request_event: WorkflowEvent
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"request_id": self.request_id, "request_event": self.request_event.to_dict()}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> WorkflowAgent.RequestInfoFunctionArgs:
|
||||
if "request_id" not in payload or "request_event" not in payload:
|
||||
raise ValueError(
|
||||
"Invalid payload for RequestInfoFunctionArgs. 'request_id' and 'request_event' are required."
|
||||
)
|
||||
if not payload["request_id"]:
|
||||
raise ValueError("request_id cannot be empty.")
|
||||
|
||||
return cls(
|
||||
request_id=payload.get("request_id", ""),
|
||||
request_event=WorkflowEvent.from_dict(payload.get("request_event", {})),
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow: Workflow,
|
||||
*,
|
||||
id: str | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
context_providers: Sequence[ContextProvider] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize the WorkflowAgent.
|
||||
|
||||
Args:
|
||||
workflow: The workflow to wrap as an agent.
|
||||
|
||||
Keyword Args:
|
||||
id: Unique identifier for the agent. If None, will be generated.
|
||||
name: Optional name for the agent.
|
||||
description: Optional description of the agent.
|
||||
context_providers: Optional sequence of context providers for the agent.
|
||||
**kwargs: Additional keyword arguments passed to BaseAgent.
|
||||
|
||||
Note:
|
||||
Only output events (type='output') and request_info events (type='request_info') from
|
||||
the workflow are considered and converted to agent responses of the WorkflowAgent.
|
||||
Other workflow events are ignored. Use `output_from` in WorkflowBuilder to control
|
||||
which executors' outputs are surfaced as agent responses.
|
||||
"""
|
||||
if id is None:
|
||||
id = f"WorkflowAgent_{uuid.uuid4().hex[:8]}"
|
||||
# Initialize with standard BaseAgent parameters first
|
||||
# Validate the workflow's start executor can handle agent-facing message inputs
|
||||
try:
|
||||
start_executor = workflow.get_start_executor()
|
||||
except KeyError as exc: # Defensive: workflow lacks a configured entry point
|
||||
raise ValueError("Workflow's start executor is not defined.") from exc
|
||||
|
||||
if not any(is_type_compatible(list[Message], input_type) for input_type in start_executor.input_types):
|
||||
raise ValueError("Workflow's start executor cannot handle list[Message]")
|
||||
|
||||
super().__init__(
|
||||
id=id,
|
||||
name=name,
|
||||
description=description,
|
||||
context_providers=context_providers,
|
||||
**kwargs,
|
||||
)
|
||||
self._workflow: Workflow = workflow
|
||||
|
||||
@property
|
||||
def workflow(self) -> Workflow:
|
||||
return self._workflow
|
||||
|
||||
# region Run Methods
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
stream: Literal[True],
|
||||
session: AgentSession | None = None,
|
||||
checkpoint_id: str | None = None,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ...
|
||||
|
||||
@overload
|
||||
async def run(
|
||||
self,
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
stream: Literal[False] = ...,
|
||||
session: AgentSession | None = None,
|
||||
checkpoint_id: str | None = None,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None,
|
||||
) -> AgentResponse: ...
|
||||
|
||||
def run(
|
||||
self,
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
session: AgentSession | None = None,
|
||||
checkpoint_id: str | None = None,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse] | Awaitable[AgentResponse]:
|
||||
"""Get a response from the workflow agent.
|
||||
|
||||
Args:
|
||||
messages: The message(s) to send to the workflow. Required for new runs,
|
||||
could be None if only restoring the underlying workflow from a checkpoint.
|
||||
|
||||
Keyword Args:
|
||||
stream: If True, returns an async iterable of updates. If False (default),
|
||||
returns an awaitable AgentResponse.
|
||||
session: The agent session for conversation context.
|
||||
checkpoint_id: ID of checkpoint to restore from. If provided, the workflow
|
||||
resumes from this checkpoint instead of starting fresh.
|
||||
checkpoint_storage: Runtime checkpoint storage. When provided with checkpoint_id,
|
||||
used to load and restore the checkpoint. When provided without checkpoint_id,
|
||||
enables checkpointing for this run.
|
||||
function_invocation_kwargs: Keyword arguments forwarded to tool invocations in
|
||||
subagents. Either a mapping of agent name/executor id to kwargs, or a flat
|
||||
mapping of kwargs for all tool invocations.
|
||||
client_kwargs: Keyword arguments forwarded to chat client calls in
|
||||
subagents. Either a mapping of agent name/executor id to kwargs, or a flat
|
||||
mapping of kwargs for all chat client calls.
|
||||
|
||||
Returns:
|
||||
When stream=True: An AsyncIterable[AgentResponseUpdate] for streaming updates.
|
||||
When stream=False: An Awaitable[AgentResponse] with the complete response.
|
||||
|
||||
Output events (type='output') from the workflow will be converted to ChatMessages
|
||||
or AgentResponseUpdate objects. Request info events (type='request_info') will be
|
||||
converted to function call and approval request contents.
|
||||
"""
|
||||
if messages is None:
|
||||
messages = []
|
||||
response_id = str(uuid.uuid4())
|
||||
if stream:
|
||||
return ResponseStream(
|
||||
self._run_stream_impl(
|
||||
messages,
|
||||
response_id,
|
||||
session,
|
||||
checkpoint_id,
|
||||
checkpoint_storage,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
),
|
||||
finalizer=AgentResponse.from_updates,
|
||||
)
|
||||
return self._run_impl(
|
||||
messages,
|
||||
response_id,
|
||||
session,
|
||||
checkpoint_id,
|
||||
checkpoint_storage,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
)
|
||||
|
||||
async def _run_impl(
|
||||
self,
|
||||
messages: AgentRunInputs,
|
||||
response_id: str,
|
||||
session: AgentSession | None,
|
||||
checkpoint_id: str | None = None,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None,
|
||||
) -> AgentResponse:
|
||||
"""Internal implementation of non-streaming execution.
|
||||
|
||||
Args:
|
||||
messages: Normalized input messages to process.
|
||||
response_id: The unique response ID for this workflow execution.
|
||||
session: The agent session for conversation context.
|
||||
checkpoint_id: ID of checkpoint to restore from.
|
||||
checkpoint_storage: Runtime checkpoint storage.
|
||||
function_invocation_kwargs: Optional kwargs for tool invocations.
|
||||
client_kwargs: Optional kwargs for chat client calls.
|
||||
|
||||
Returns:
|
||||
An AgentResponse representing the workflow execution results.
|
||||
"""
|
||||
input_messages = normalize_messages_input(messages)
|
||||
|
||||
if (
|
||||
not any(
|
||||
provider.load_messages for provider in self.context_providers if isinstance(provider, HistoryProvider)
|
||||
)
|
||||
and session is not None
|
||||
):
|
||||
self.context_providers.append(InMemoryHistoryProvider())
|
||||
|
||||
provider_session = session
|
||||
if provider_session is None and self.context_providers:
|
||||
provider_session = AgentSession()
|
||||
|
||||
# run the context providers with the session
|
||||
session_context = SessionContext(
|
||||
session_id=provider_session.session_id if provider_session else None,
|
||||
service_session_id=provider_session.service_session_id if provider_session else None,
|
||||
input_messages=input_messages or [],
|
||||
options={},
|
||||
)
|
||||
for provider in self.context_providers:
|
||||
if isinstance(provider, HistoryProvider) and not provider.load_messages:
|
||||
continue
|
||||
if provider_session is None:
|
||||
raise RuntimeError("Provider session must be available when context providers are configured.")
|
||||
await provider.before_run(
|
||||
agent=self,
|
||||
session=provider_session,
|
||||
context=session_context,
|
||||
state=provider_session.state.setdefault(provider.source_id, {}),
|
||||
)
|
||||
# combine the messages
|
||||
session_messages: list[Message] = session_context.get_messages(include_input=True)
|
||||
|
||||
output_events: list[WorkflowEvent[Any]] = []
|
||||
async for event in self._run_core(
|
||||
session_messages,
|
||||
checkpoint_id,
|
||||
checkpoint_storage,
|
||||
streaming=False,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
if event.type in AGENT_FORWARDED_EVENT_TYPES:
|
||||
output_events.append(event)
|
||||
|
||||
result = self._convert_workflow_events_to_agent_response(response_id, output_events)
|
||||
|
||||
# Set the response on the context so after_run providers (e.g. InMemoryHistoryProvider)
|
||||
# can persist the response messages alongside input messages.
|
||||
session_context._response = result # type: ignore[assignment]
|
||||
|
||||
await self._run_after_providers(session=provider_session, context=session_context)
|
||||
return result
|
||||
|
||||
async def _run_stream_impl(
|
||||
self,
|
||||
messages: AgentRunInputs,
|
||||
response_id: str,
|
||||
session: AgentSession | None,
|
||||
checkpoint_id: str | None = None,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None,
|
||||
) -> AsyncIterable[AgentResponseUpdate]:
|
||||
"""Internal implementation of streaming execution.
|
||||
|
||||
Args:
|
||||
messages: Input messages to process.
|
||||
response_id: The unique response ID for this workflow execution.
|
||||
session: The agent session for conversation context.
|
||||
checkpoint_id: ID of checkpoint to restore from.
|
||||
checkpoint_storage: Runtime checkpoint storage.
|
||||
function_invocation_kwargs: Optional kwargs for tool invocations.
|
||||
client_kwargs: Optional kwargs for chat client calls.
|
||||
|
||||
Yields:
|
||||
AgentResponseUpdate objects representing the workflow execution progress.
|
||||
"""
|
||||
input_messages = normalize_messages_input(messages)
|
||||
|
||||
if (
|
||||
not any(
|
||||
provider.load_messages for provider in self.context_providers if isinstance(provider, HistoryProvider)
|
||||
)
|
||||
and session is not None
|
||||
):
|
||||
self.context_providers.append(InMemoryHistoryProvider())
|
||||
|
||||
provider_session = session
|
||||
if provider_session is None and self.context_providers:
|
||||
provider_session = AgentSession()
|
||||
|
||||
# run the context providers with the session
|
||||
session_context = SessionContext(
|
||||
session_id=provider_session.session_id if provider_session else None,
|
||||
service_session_id=provider_session.service_session_id if provider_session else None,
|
||||
input_messages=input_messages or [],
|
||||
options={},
|
||||
)
|
||||
for provider in self.context_providers:
|
||||
if isinstance(provider, HistoryProvider) and not provider.load_messages:
|
||||
continue
|
||||
if provider_session is None:
|
||||
raise RuntimeError("Provider session must be available when context providers are configured.")
|
||||
await provider.before_run(
|
||||
agent=self,
|
||||
session=provider_session,
|
||||
context=session_context,
|
||||
state=provider_session.state.setdefault(provider.source_id, {}),
|
||||
)
|
||||
# combine the messages
|
||||
|
||||
session_messages: list[Message] = session_context.get_messages(include_input=True)
|
||||
all_updates: list[AgentResponseUpdate] = []
|
||||
async for event in self._run_core(
|
||||
session_messages,
|
||||
checkpoint_id,
|
||||
checkpoint_storage,
|
||||
streaming=True,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
updates = self._convert_workflow_event_to_agent_response_updates(response_id, event)
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
|
||||
# Build the final response from collected updates so after_run providers
|
||||
# (e.g. InMemoryHistoryProvider) can persist the response messages.
|
||||
if all_updates:
|
||||
session_context._response = AgentResponse.from_updates(all_updates) # type: ignore[assignment]
|
||||
|
||||
await self._run_after_providers(session=provider_session, context=session_context)
|
||||
|
||||
async def _run_core(
|
||||
self,
|
||||
input_messages: Sequence[Message],
|
||||
checkpoint_id: str | None,
|
||||
checkpoint_storage: CheckpointStorage | None,
|
||||
streaming: bool,
|
||||
function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None,
|
||||
) -> AsyncIterable[WorkflowEvent]:
|
||||
"""Core implementation that yields workflow events for both streaming and non-streaming modes.
|
||||
|
||||
Args:
|
||||
input_messages: Normalized input messages to process.
|
||||
checkpoint_id: ID of checkpoint to restore from.
|
||||
checkpoint_storage: Runtime checkpoint storage.
|
||||
streaming: Whether to use streaming workflow methods.
|
||||
function_invocation_kwargs: Optional kwargs for tool invocations.
|
||||
client_kwargs: Optional kwargs for chat client calls.
|
||||
|
||||
Yields:
|
||||
WorkflowEvent objects from the workflow execution.
|
||||
"""
|
||||
# Restore the workflow state if a checkpoint is provided
|
||||
if checkpoint_id is not None:
|
||||
if checkpoint_storage is None:
|
||||
raise AgentInvalidRequestException("checkpoint_storage must be provided when checkpoint_id is provided")
|
||||
logger.debug(f"Restoring workflow from checkpoint {checkpoint_id}")
|
||||
# Restore the workflow from checkpoint
|
||||
if streaming:
|
||||
async for _ in self.workflow.run(
|
||||
stream=True,
|
||||
checkpoint_id=checkpoint_id,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
):
|
||||
pass
|
||||
else:
|
||||
_ = await self.workflow.run(
|
||||
checkpoint_id=checkpoint_id,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
)
|
||||
if not input_messages:
|
||||
logger.info("No input messages provided; the workflow has been restored to the checkpoint state.")
|
||||
return
|
||||
|
||||
final_state = self._workflow.status
|
||||
logger.debug(f"Workflow state: {final_state}")
|
||||
|
||||
if final_state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
|
||||
# Extract function responses from input messages, and ensure that
|
||||
# only function responses are present in messages if there is any
|
||||
# pending request.
|
||||
# NOTE: It is possible that some pending requests are not fulfilled,
|
||||
# and we will let the workflow to handle this -- the agent does not
|
||||
# have an opinion on this.
|
||||
function_responses = self._extract_function_responses(input_messages)
|
||||
if streaming:
|
||||
async for event in self.workflow.run(
|
||||
responses=function_responses,
|
||||
stream=True,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
yield event
|
||||
else:
|
||||
for event in await self.workflow.run(
|
||||
responses=function_responses,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
yield event
|
||||
elif final_state == WorkflowRunState.IDLE:
|
||||
if streaming:
|
||||
async for event in self.workflow.run(
|
||||
message=input_messages,
|
||||
stream=True,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
yield event
|
||||
else:
|
||||
for event in await self.workflow.run(
|
||||
message=input_messages,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
yield event
|
||||
else:
|
||||
raise AgentException(f"The underlying workflow is in an invalid state to restart: {final_state}.")
|
||||
|
||||
# endregion Run Methods
|
||||
|
||||
def _convert_workflow_events_to_agent_response(
|
||||
self,
|
||||
response_id: str,
|
||||
output_events: list[WorkflowEvent[Any]],
|
||||
) -> AgentResponse:
|
||||
"""Convert a list of workflow events to an AgentResponse.
|
||||
|
||||
Caller-facing workflow events are forwarded as agent messages. Terminal and
|
||||
intermediate event payloads keep their original content types.
|
||||
"""
|
||||
messages: list[Message] = []
|
||||
raw_representations: list[object] = []
|
||||
merged_usage: UsageDetails | None = None
|
||||
latest_created_at: str | None = None
|
||||
|
||||
for output_event in output_events:
|
||||
if output_event.type == "request_info":
|
||||
request_content = self._process_request_info_event(output_event)
|
||||
messages.append(
|
||||
Message(
|
||||
contents=[request_content],
|
||||
role="assistant",
|
||||
author_name=output_event.source_executor_id,
|
||||
message_id=str(uuid.uuid4()),
|
||||
raw_representation=output_event,
|
||||
)
|
||||
)
|
||||
raw_representations.append(output_event)
|
||||
else:
|
||||
data = output_event.data
|
||||
# Anything that isn't `output` is intermediate — this branch only sees
|
||||
# events that already passed the lifecycle filter and weren't request_info.
|
||||
is_intermediate = output_event.type != "output"
|
||||
|
||||
if isinstance(data, AgentResponseUpdate):
|
||||
# AgentResponseUpdate is a streaming-only payload. Accepting it
|
||||
# in non-streaming runs would make message ordering depend on
|
||||
# partial chunks for both terminal and intermediate events.
|
||||
event_label = "Intermediate" if is_intermediate else "Output"
|
||||
raise AgentInvalidRequestException(
|
||||
f"{event_label} event with AgentResponseUpdate data cannot be emitted "
|
||||
"in non-streaming mode. Please ensure executors emit AgentResponse "
|
||||
"for non-streaming workflows."
|
||||
)
|
||||
|
||||
if isinstance(data, AgentResponse):
|
||||
messages.extend(data.messages)
|
||||
raw_representations.append(data.raw_representation)
|
||||
merged_usage = add_usage_details(merged_usage, data.usage_details)
|
||||
latest_created_at = (
|
||||
data.created_at
|
||||
if not latest_created_at
|
||||
else max(latest_created_at, data.created_at)
|
||||
if data.created_at
|
||||
else latest_created_at
|
||||
)
|
||||
elif isinstance(data, Message):
|
||||
messages.append(data)
|
||||
raw_representations.append(data.raw_representation)
|
||||
elif is_instance_of(data, list[Message]):
|
||||
chat_messages = cast(list[Message], data)
|
||||
messages.extend(chat_messages)
|
||||
raw_representations.append(data)
|
||||
else:
|
||||
contents = self._extract_contents(data)
|
||||
if not contents:
|
||||
continue
|
||||
|
||||
messages.append(
|
||||
Message(
|
||||
contents=contents,
|
||||
role="assistant",
|
||||
author_name=output_event.executor_id,
|
||||
message_id=str(uuid.uuid4()),
|
||||
raw_representation=data,
|
||||
)
|
||||
)
|
||||
raw_representations.append(data)
|
||||
|
||||
return AgentResponse(
|
||||
messages=messages,
|
||||
response_id=response_id,
|
||||
created_at=latest_created_at,
|
||||
usage_details=merged_usage,
|
||||
raw_representation=raw_representations,
|
||||
)
|
||||
|
||||
def _convert_workflow_event_to_agent_response_updates(
|
||||
self,
|
||||
response_id: str,
|
||||
event: WorkflowEvent[Any],
|
||||
) -> list[AgentResponseUpdate]:
|
||||
"""Convert a workflow event to a list of AgentResponseUpdate objects.
|
||||
|
||||
Forwarding rule:
|
||||
|
||||
- ``type='output'`` — terminal user-facing emission. Forwarded as-is.
|
||||
- ``type='intermediate'`` (and the deprecated ``type='data'``) — forwarded
|
||||
as-is.
|
||||
- ``type='request_info'`` — request-info translation (unchanged).
|
||||
- Everything else (lifecycle, diagnostics, executor bookkeeping,
|
||||
orchestration-internal events like ``group_chat``/``handoff_sent``/
|
||||
``magentic_orchestrator``) is dropped.
|
||||
"""
|
||||
# TODO(evmattso): https://github.com/microsoft/agent-framework/issues/5885
|
||||
if event.type not in AGENT_FORWARDED_EVENT_TYPES:
|
||||
return []
|
||||
|
||||
if event.type != "request_info":
|
||||
data = event.data
|
||||
executor_id = event.executor_id
|
||||
|
||||
if isinstance(data, AgentResponseUpdate):
|
||||
# Construct a fresh AgentResponseUpdate so we don't mutate a payload
|
||||
# that AgentExecutor still holds a reference to in its `updates` list.
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=list(data.contents),
|
||||
role=data.role,
|
||||
author_name=data.author_name or executor_id,
|
||||
response_id=data.response_id,
|
||||
message_id=data.message_id,
|
||||
created_at=data.created_at,
|
||||
raw_representation=data.raw_representation,
|
||||
)
|
||||
]
|
||||
if isinstance(data, AgentResponse):
|
||||
# Convert each message in AgentResponse to an AgentResponseUpdate
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
for msg in data.messages:
|
||||
updates.append(
|
||||
AgentResponseUpdate(
|
||||
contents=list(msg.contents),
|
||||
role=msg.role,
|
||||
author_name=msg.author_name or executor_id,
|
||||
response_id=data.response_id or response_id,
|
||||
message_id=msg.message_id or str(uuid.uuid4()),
|
||||
created_at=data.created_at
|
||||
or datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
raw_representation=msg,
|
||||
)
|
||||
)
|
||||
return updates
|
||||
if isinstance(data, Message):
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=list(data.contents),
|
||||
role=data.role,
|
||||
author_name=data.author_name or executor_id,
|
||||
response_id=response_id,
|
||||
message_id=str(uuid.uuid4()),
|
||||
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
raw_representation=data,
|
||||
)
|
||||
]
|
||||
if is_instance_of(data, list[Message]):
|
||||
# Convert each Message to an AgentResponseUpdate
|
||||
chat_messages = cast(list[Message], data)
|
||||
updates = []
|
||||
for msg in chat_messages:
|
||||
updates.append(
|
||||
AgentResponseUpdate(
|
||||
contents=list(msg.contents),
|
||||
role=msg.role,
|
||||
author_name=msg.author_name or executor_id,
|
||||
response_id=response_id,
|
||||
message_id=msg.message_id or str(uuid.uuid4()),
|
||||
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
raw_representation=msg,
|
||||
)
|
||||
)
|
||||
return updates
|
||||
contents = self._extract_contents(data)
|
||||
if not contents:
|
||||
return []
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=contents,
|
||||
role="assistant",
|
||||
author_name=executor_id,
|
||||
response_id=response_id,
|
||||
message_id=str(uuid.uuid4()),
|
||||
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
raw_representation=data,
|
||||
)
|
||||
]
|
||||
|
||||
if event.type == "request_info":
|
||||
request_content = self._process_request_info_event(event)
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=[request_content],
|
||||
role="assistant",
|
||||
author_name=self.name,
|
||||
response_id=response_id,
|
||||
message_id=str(uuid.uuid4()),
|
||||
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
raw_representation=event,
|
||||
)
|
||||
]
|
||||
|
||||
# Ignore workflow-internal events
|
||||
return []
|
||||
|
||||
def _process_request_info_event(
|
||||
self,
|
||||
event: WorkflowEvent[Any],
|
||||
) -> Content:
|
||||
"""Convert a request_info event to FunctionApprovalRequestContent.
|
||||
|
||||
Args:
|
||||
event: A WorkflowEvent with type='request_info'.
|
||||
|
||||
Returns:
|
||||
A content object representing the request info. The content can be a `function_approval_request`
|
||||
or a `function_call` depending on the structure of the event data.
|
||||
|
||||
Note:
|
||||
If the event data is already a FunctionApprovalRequestContent, it will be returned as-is.
|
||||
"""
|
||||
if isinstance(event.data, Content) and event.data.user_input_request:
|
||||
# Return the event data as-is if it's already a properly formed FunctionApprovalRequestContent
|
||||
return event.data
|
||||
|
||||
request_id = event.request_id
|
||||
args = self.RequestInfoFunctionArgs(request_id=request_id, request_event=event).to_dict()
|
||||
|
||||
return Content.from_function_call(
|
||||
call_id=request_id,
|
||||
name=self.REQUEST_INFO_FUNCTION_NAME,
|
||||
arguments=args,
|
||||
)
|
||||
|
||||
def _extract_function_responses(self, input_messages: Sequence[Message]) -> dict[str, Any]:
|
||||
"""Extract function responses from input messages.
|
||||
|
||||
The responses are for pending requests that the workflow is waiting on, and
|
||||
will be passed to the workflow. The pending requests are processed to either
|
||||
`function_approval_request` or `function_call` content by `_process_request_info_event`.
|
||||
"""
|
||||
function_responses: dict[str, Any] = {}
|
||||
for message in input_messages:
|
||||
for content in message.contents:
|
||||
if content.type == "function_approval_response":
|
||||
request_id: str = content.id # type: ignore[assignment]
|
||||
function_responses[request_id] = content
|
||||
elif content.type == "function_result":
|
||||
response_data = content.result if hasattr(content, "result") else str(content)
|
||||
function_responses[content.call_id] = response_data # type: ignore
|
||||
else:
|
||||
raise AgentInvalidResponseException(
|
||||
"Unexpected content type while awaiting request info responses."
|
||||
)
|
||||
|
||||
return function_responses
|
||||
|
||||
def _extract_contents(self, data: Any) -> list[Content]:
|
||||
"""Recursively extract Content from workflow output data."""
|
||||
if isinstance(data, list):
|
||||
return [c for item in data for c in self._extract_contents(item)] # type: ignore
|
||||
if isinstance(data, Content):
|
||||
return [data]
|
||||
if isinstance(data, str):
|
||||
return [Content.from_text(text=data)]
|
||||
return [Content.from_text(text=str(data))]
|
||||
|
||||
class _ResponseState(TypedDict):
|
||||
"""State for grouping response updates by message_id."""
|
||||
|
||||
by_msg: dict[str, list[AgentResponseUpdate]]
|
||||
dangling: list[AgentResponseUpdate]
|
||||
|
||||
@staticmethod
|
||||
def merge_updates(updates: list[AgentResponseUpdate], response_id: str) -> AgentResponse:
|
||||
"""Merge streaming updates into a single AgentResponse.
|
||||
|
||||
Behavior:
|
||||
- Group updates by response_id; within each response_id, group by message_id and keep a dangling bucket for
|
||||
updates without message_id.
|
||||
- Convert each group (per message and dangling) into an intermediate AgentResponse via
|
||||
AgentResponse.from_updates, then sort by created_at and merge.
|
||||
- Append messages from updates without any response_id at the end (global dangling), while aggregating metadata.
|
||||
|
||||
Args:
|
||||
updates: The list of AgentResponseUpdate objects to merge.
|
||||
response_id: The response identifier to set on the returned AgentResponse.
|
||||
|
||||
Returns:
|
||||
An AgentResponse with messages in processing order and aggregated metadata.
|
||||
"""
|
||||
# PHASE 1: GROUP UPDATES BY RESPONSE_ID AND MESSAGE_ID
|
||||
# First pass: build call_id -> response_id map from FunctionCallContent updates
|
||||
call_id_to_response_id: dict[str, str] = {}
|
||||
for u in updates:
|
||||
if u.response_id:
|
||||
for content in u.contents:
|
||||
if content.type == "function_call" and content.call_id:
|
||||
call_id_to_response_id[content.call_id] = u.response_id
|
||||
|
||||
# Second pass: group updates, associating FunctionResultContent with their calls
|
||||
states: dict[str, WorkflowAgent._ResponseState] = {}
|
||||
global_dangling: list[AgentResponseUpdate] = []
|
||||
|
||||
for u in updates:
|
||||
effective_response_id = u.response_id
|
||||
# If no response_id, check if this is a FunctionResultContent that matches a call
|
||||
if not effective_response_id:
|
||||
for content in u.contents:
|
||||
if content.type == "function_result" and content.call_id:
|
||||
effective_response_id = call_id_to_response_id.get(content.call_id)
|
||||
if effective_response_id:
|
||||
break
|
||||
|
||||
if effective_response_id:
|
||||
state = states.setdefault(effective_response_id, {"by_msg": {}, "dangling": []})
|
||||
by_msg = state["by_msg"]
|
||||
dangling = state["dangling"]
|
||||
if u.message_id:
|
||||
by_msg.setdefault(u.message_id, []).append(u)
|
||||
else:
|
||||
dangling.append(u)
|
||||
else:
|
||||
global_dangling.append(u)
|
||||
|
||||
# HELPER FUNCTIONS
|
||||
def _parse_dt(value: str | None) -> tuple[int, datetime | str | None]:
|
||||
if not value:
|
||||
return (1, None)
|
||||
v = value
|
||||
if v.endswith("Z"):
|
||||
v = v[:-1] + "+00:00"
|
||||
try:
|
||||
return (0, datetime.fromisoformat(v))
|
||||
except Exception:
|
||||
return (0, v)
|
||||
|
||||
def _merge_responses(current: AgentResponse | None, incoming: AgentResponse) -> AgentResponse:
|
||||
if current is None:
|
||||
return incoming
|
||||
raw_list: list[object] = []
|
||||
|
||||
def _add_raw(value: object) -> None:
|
||||
if isinstance(value, list):
|
||||
raw_list.extend(cast(list[object], value))
|
||||
else:
|
||||
raw_list.append(value)
|
||||
|
||||
if current.raw_representation is not None:
|
||||
_add_raw(current.raw_representation)
|
||||
if incoming.raw_representation is not None:
|
||||
_add_raw(incoming.raw_representation)
|
||||
return AgentResponse(
|
||||
messages=(current.messages or []) + (incoming.messages or []),
|
||||
response_id=current.response_id or incoming.response_id,
|
||||
created_at=incoming.created_at or current.created_at,
|
||||
usage_details=add_usage_details(current.usage_details, incoming.usage_details),
|
||||
raw_representation=raw_list if raw_list else None,
|
||||
additional_properties=incoming.additional_properties or current.additional_properties,
|
||||
)
|
||||
|
||||
# PHASE 2: CONVERT GROUPED UPDATES TO RESPONSES AND MERGE
|
||||
final_messages: list[Message] = []
|
||||
merged_usage: UsageDetails | None = None
|
||||
latest_created_at: str | None = None
|
||||
merged_additional_properties: dict[str, Any] | None = None
|
||||
raw_representations: list[object] = []
|
||||
|
||||
for grouped_response_id in states:
|
||||
state = states[grouped_response_id]
|
||||
by_msg = state["by_msg"]
|
||||
dangling = state["dangling"]
|
||||
|
||||
per_message_responses: list[AgentResponse] = []
|
||||
for _, msg_updates in by_msg.items():
|
||||
if msg_updates:
|
||||
per_message_responses.append(AgentResponse.from_updates(msg_updates))
|
||||
if dangling:
|
||||
per_message_responses.append(AgentResponse.from_updates(dangling))
|
||||
|
||||
per_message_responses.sort(key=lambda r: _parse_dt(r.created_at))
|
||||
|
||||
aggregated: AgentResponse | None = None
|
||||
for resp in per_message_responses:
|
||||
if resp.response_id and grouped_response_id and resp.response_id != grouped_response_id:
|
||||
resp.response_id = grouped_response_id
|
||||
aggregated = _merge_responses(aggregated, resp)
|
||||
|
||||
if aggregated:
|
||||
final_messages.extend(aggregated.messages)
|
||||
if aggregated.usage_details:
|
||||
merged_usage = add_usage_details(merged_usage, aggregated.usage_details)
|
||||
if aggregated.created_at and (
|
||||
not latest_created_at or _parse_dt(aggregated.created_at) > _parse_dt(latest_created_at)
|
||||
):
|
||||
latest_created_at = aggregated.created_at
|
||||
if aggregated.additional_properties:
|
||||
if merged_additional_properties is None:
|
||||
merged_additional_properties = {}
|
||||
merged_additional_properties.update(aggregated.additional_properties)
|
||||
raw_value = aggregated.raw_representation
|
||||
if raw_value:
|
||||
cast_value = cast(object | list[object], raw_value)
|
||||
if isinstance(cast_value, list):
|
||||
raw_representations.extend(cast(list[object], cast_value))
|
||||
else:
|
||||
raw_representations.append(cast_value)
|
||||
|
||||
# PHASE 3: HANDLE GLOBAL DANGLING UPDATES (NO RESPONSE_ID)
|
||||
# These are updates that couldn't be associated with any response_id
|
||||
# (e.g., orphan FunctionResultContent with no matching FunctionCallContent)
|
||||
if global_dangling:
|
||||
flattened = AgentResponse.from_updates(global_dangling)
|
||||
final_messages.extend(flattened.messages)
|
||||
if flattened.usage_details:
|
||||
merged_usage = add_usage_details(merged_usage, flattened.usage_details)
|
||||
if flattened.created_at and (
|
||||
not latest_created_at or _parse_dt(flattened.created_at) > _parse_dt(latest_created_at)
|
||||
):
|
||||
latest_created_at = flattened.created_at
|
||||
if flattened.additional_properties:
|
||||
if merged_additional_properties is None:
|
||||
merged_additional_properties = {}
|
||||
merged_additional_properties.update(flattened.additional_properties)
|
||||
flat_raw = flattened.raw_representation
|
||||
if flat_raw:
|
||||
cast_flat = cast(object | list[object], flat_raw)
|
||||
if isinstance(cast_flat, list):
|
||||
raw_representations.extend(cast(list[object], cast_flat))
|
||||
else:
|
||||
raw_representations.append(cast_flat)
|
||||
|
||||
# PHASE 4: CONSTRUCT FINAL RESPONSE WITH INPUT RESPONSE_ID
|
||||
return AgentResponse(
|
||||
messages=final_messages,
|
||||
response_id=response_id,
|
||||
created_at=latest_created_at,
|
||||
usage_details=merged_usage,
|
||||
raw_representation=raw_representations if raw_representations else None,
|
||||
additional_properties=merged_additional_properties,
|
||||
)
|
||||
@@ -0,0 +1,602 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import Content
|
||||
|
||||
from .._agents import SupportsAgentRun
|
||||
from .._sessions import AgentSession
|
||||
from .._types import AgentResponse, AgentResponseUpdate, Message, ResponseStream
|
||||
from ._agent_utils import resolve_agent_id
|
||||
from ._const import GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY
|
||||
from ._executor import Executor, handler
|
||||
from ._message_utils import normalize_messages_input
|
||||
from ._request_info_mixin import response_handler
|
||||
from ._typing_utils import is_chat_agent
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # pragma: no cover
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentExecutorRequest:
|
||||
"""A request to an agent executor.
|
||||
|
||||
Attributes:
|
||||
messages: A list of chat messages to be processed by the agent.
|
||||
should_respond: A flag indicating whether the agent should respond to the messages.
|
||||
If False, the messages will be saved to the executor's cache but not sent to the agent.
|
||||
"""
|
||||
|
||||
messages: list[Message]
|
||||
should_respond: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentExecutorResponse:
|
||||
"""A response from an agent executor.
|
||||
|
||||
Attributes:
|
||||
executor_id: The ID of the executor that generated the response.
|
||||
agent_response: The underlying agent run response (unaltered from client).
|
||||
full_conversation: The full conversation context (prior inputs + all assistant/tool outputs) that
|
||||
should be used when chaining to another AgentExecutor. This prevents downstream agents losing
|
||||
user prompts.
|
||||
"""
|
||||
|
||||
executor_id: str
|
||||
agent_response: AgentResponse
|
||||
full_conversation: list[Message]
|
||||
|
||||
def with_text(self, text: str) -> "AgentExecutorResponse":
|
||||
"""Create a new AgentExecutorResponse with replaced text, preserving the conversation history.
|
||||
|
||||
Use this in custom executors that transform agent output text (e.g. upper-casing, summarising)
|
||||
when you need downstream AgentExecutors to still have access to the full prior conversation.
|
||||
|
||||
Without this helper, sending a plain ``str`` from a custom executor breaks the context chain:
|
||||
the downstream ``AgentExecutor.from_str`` handler only adds that one string to its cache and
|
||||
loses all prior messages. By using ``with_text`` the response type stays
|
||||
``AgentExecutorResponse``, so ``AgentExecutor.from_response`` is invoked instead and the full
|
||||
conversation is preserved.
|
||||
|
||||
Args:
|
||||
text: The replacement assistant message text.
|
||||
|
||||
Returns:
|
||||
A new ``AgentExecutorResponse`` whose ``agent_response`` contains a single assistant
|
||||
message with ``text``, and whose ``full_conversation`` is the prior conversation
|
||||
(everything before the original agent turn) followed by the new assistant message.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import AgentExecutorResponse, WorkflowContext, executor
|
||||
|
||||
|
||||
@executor(
|
||||
id="upper_case_executor",
|
||||
input=AgentExecutorResponse,
|
||||
output=AgentExecutorResponse,
|
||||
workflow_output=str,
|
||||
)
|
||||
async def upper_case(
|
||||
response: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[AgentExecutorResponse, str],
|
||||
) -> None:
|
||||
upper_text = response.agent_response.text.upper()
|
||||
await ctx.send_message(response.with_text(upper_text))
|
||||
await ctx.yield_output(upper_text)
|
||||
"""
|
||||
new_message = Message("assistant", [text])
|
||||
new_agent_response = AgentResponse(messages=[new_message])
|
||||
|
||||
# Strip off the original agent turn and replace with the new text.
|
||||
n_agent_messages = len(self.agent_response.messages)
|
||||
prior_messages = (
|
||||
self.full_conversation[:-n_agent_messages] if n_agent_messages else list(self.full_conversation)
|
||||
)
|
||||
new_full_conversation = [*prior_messages, new_message]
|
||||
|
||||
return AgentExecutorResponse(
|
||||
executor_id=self.executor_id,
|
||||
agent_response=new_agent_response,
|
||||
full_conversation=new_full_conversation,
|
||||
)
|
||||
|
||||
|
||||
class AgentExecutor(Executor):
|
||||
"""built-in executor that wraps an agent for handling messages.
|
||||
|
||||
AgentExecutor adapts its behavior based on the workflow execution mode:
|
||||
- run(stream=True): Emits incremental output events (type='output') as the agent produces tokens
|
||||
- run(): Emits a single output event (type='output') containing the complete response
|
||||
|
||||
Use `output_from` in WorkflowBuilder to control whether the AgentResponse
|
||||
or AgentResponseUpdate objects are yielded as workflow outputs.
|
||||
|
||||
Messages sent to downstream executors will always be the complete AgentResponse. In
|
||||
streaming mode, incremental AgentResponseUpdates will be concatenated to form the full
|
||||
response to be sent downstream.
|
||||
|
||||
The executor automatically detects the mode via WorkflowContext.is_streaming().
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: SupportsAgentRun,
|
||||
*,
|
||||
session: AgentSession | None = None,
|
||||
id: str | None = None,
|
||||
context_mode: Literal["full", "last_agent", "custom"] | None = None,
|
||||
context_filter: Callable[[list[Message]], list[Message]] | None = None,
|
||||
):
|
||||
"""Initialize the executor with a unique identifier.
|
||||
|
||||
Args:
|
||||
agent: The agent to be wrapped by this executor.
|
||||
session: The session to use for running the agent. If None, a new session will be created.
|
||||
id: A unique identifier for the executor. If None, the agent's name will be used if available.
|
||||
context_mode: Configuration for how the executor should manage conversation context upon
|
||||
receiving an AgentExecutorResponse as input. Options:
|
||||
- "full": append the full conversation (all prior messages + latest agent response) to the
|
||||
cache for the agent run. This is the default mode.
|
||||
- "last_agent": provide only the messages from the latest agent response as context for
|
||||
the agent run.
|
||||
- "custom": use the provided context_filter function to determine which messages to include
|
||||
as context for the agent run.
|
||||
context_filter: A function that takes the full conversation (list of Messages) as input and returns
|
||||
a filtered list of Messages to be used as context for the agent run. This is required
|
||||
if context_mode is set to "custom".
|
||||
"""
|
||||
# Prefer provided id; else use agent.name if present; else generate deterministic prefix
|
||||
exec_id = id or resolve_agent_id(agent)
|
||||
if not exec_id:
|
||||
raise ValueError("Agent must have a non-empty name or id or an explicit id must be provided.")
|
||||
super().__init__(exec_id)
|
||||
self._agent = agent
|
||||
self._session = session or self._agent.create_session()
|
||||
|
||||
self._pending_agent_requests: dict[str, Content] = {}
|
||||
self._pending_responses_to_agent: list[Content] = []
|
||||
|
||||
# AgentExecutor maintains an internal cache of messages in between runs
|
||||
self._cache: list[Message] = []
|
||||
# This tracks the full conversation after each run
|
||||
self._full_conversation: list[Message] = []
|
||||
|
||||
# Context mode validation
|
||||
self._context_mode = context_mode or "full"
|
||||
self._context_filter = context_filter
|
||||
if self._context_mode not in {"full", "last_agent", "custom"}:
|
||||
raise ValueError("context_mode must be one of 'full', 'last_agent', or 'custom'.")
|
||||
if self._context_mode == "custom" and not self._context_filter:
|
||||
raise ValueError("context_filter must be provided when context_mode is set to 'custom'.")
|
||||
|
||||
@property
|
||||
def agent(self) -> SupportsAgentRun:
|
||||
"""Get the underlying agent wrapped by this executor."""
|
||||
return self._agent
|
||||
|
||||
@property
|
||||
def description(self) -> str | None:
|
||||
"""Get the description of the underlying agent."""
|
||||
return self._agent.description
|
||||
|
||||
@handler
|
||||
async def run(
|
||||
self,
|
||||
request: AgentExecutorRequest,
|
||||
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate],
|
||||
) -> None:
|
||||
"""Handle an AgentExecutorRequest (canonical input).
|
||||
|
||||
This is the standard path: extend cache with provided messages; if should_respond
|
||||
run the agent and emit an AgentExecutorResponse downstream.
|
||||
"""
|
||||
self._cache.extend(request.messages)
|
||||
|
||||
if request.should_respond:
|
||||
await self._run_agent_and_emit(ctx)
|
||||
|
||||
@handler
|
||||
async def from_response(
|
||||
self,
|
||||
prior: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate],
|
||||
) -> None:
|
||||
"""Enable seamless chaining: accept a prior AgentExecutorResponse as input.
|
||||
|
||||
Strategy: treat the prior response's messages as the conversation state and
|
||||
immediately run the agent to produce a new response.
|
||||
"""
|
||||
if self._context_mode == "full":
|
||||
self._cache.extend(prior.full_conversation)
|
||||
elif self._context_mode == "last_agent":
|
||||
self._cache.extend(prior.agent_response.messages)
|
||||
else:
|
||||
if not self._context_filter:
|
||||
# This should never happen due to validation in __init__, but mypy doesn't track that well
|
||||
raise ValueError("context_filter function must be provided for 'custom' context_mode.")
|
||||
self._cache.extend(self._context_filter(prior.full_conversation))
|
||||
|
||||
await self._run_agent_and_emit(ctx)
|
||||
|
||||
@handler
|
||||
async def from_str(
|
||||
self, text: str, ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate]
|
||||
) -> None:
|
||||
"""Accept a raw user prompt string and run the agent.
|
||||
|
||||
The new string input will be added to the cache which is used as the conversation context for the agent run.
|
||||
|
||||
Warning:
|
||||
If the upstream executor received an ``AgentExecutorResponse`` but emits a plain
|
||||
``str``, this handler will be invoked instead of ``from_response``. This resets
|
||||
the conversation context because only the new string is added to the cache and
|
||||
all prior messages from the upstream agent are lost.
|
||||
|
||||
To preserve the full conversation when transforming agent output in a custom
|
||||
executor, use ``AgentExecutorResponse.with_text(...)`` so that the message type
|
||||
stays ``AgentExecutorResponse`` and ``from_response`` is called instead.
|
||||
"""
|
||||
if not self._cache and ctx.source_executor_ids != ["Workflow"]:
|
||||
logger.warning(
|
||||
"AgentExecutor '%s': from_str handler invoked with an empty cache. "
|
||||
"If you are chaining from an AgentExecutor, the upstream custom executor may be "
|
||||
"emitting a plain str instead of using AgentExecutorResponse.with_text(...), "
|
||||
"which causes the full conversation context to be lost.",
|
||||
self.id,
|
||||
)
|
||||
self._cache.extend(normalize_messages_input(text))
|
||||
await self._run_agent_and_emit(ctx)
|
||||
|
||||
@handler
|
||||
async def from_message(
|
||||
self,
|
||||
message: Message,
|
||||
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate],
|
||||
) -> None:
|
||||
"""Accept a single Message as input.
|
||||
|
||||
The new message will be added to the cache which is used as the conversation context for the agent run.
|
||||
"""
|
||||
self._cache.extend(normalize_messages_input(message))
|
||||
await self._run_agent_and_emit(ctx)
|
||||
|
||||
@handler
|
||||
async def from_messages(
|
||||
self,
|
||||
messages: list[str | Message],
|
||||
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate],
|
||||
) -> None:
|
||||
"""Accept a list of chat inputs (strings or Message) as conversation context.
|
||||
|
||||
The new messages will be added to the cache which is used as the conversation context for the agent run.
|
||||
"""
|
||||
self._cache.extend(normalize_messages_input(messages))
|
||||
await self._run_agent_and_emit(ctx)
|
||||
|
||||
@response_handler
|
||||
async def handle_user_input_response(
|
||||
self,
|
||||
original_request: Content,
|
||||
response: Content,
|
||||
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate],
|
||||
) -> None:
|
||||
"""Handle user input responses for function approvals during agent execution.
|
||||
|
||||
This will hold the executor's execution until all pending user input requests are resolved.
|
||||
|
||||
Args:
|
||||
original_request: The original function approval request sent by the agent.
|
||||
response: The user's response to the function approval request.
|
||||
ctx: The workflow context for emitting events and outputs.
|
||||
"""
|
||||
self._pending_responses_to_agent.append(response)
|
||||
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.
|
||||
# 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(Message(role=role, contents=self._pending_responses_to_agent))
|
||||
self._pending_responses_to_agent.clear()
|
||||
await self._run_agent_and_emit(ctx)
|
||||
|
||||
@override
|
||||
async def on_checkpoint_save(self) -> dict[str, Any]:
|
||||
"""Capture current executor state for checkpointing.
|
||||
|
||||
NOTE: if the session uses service-side storage, the full session state
|
||||
may not be serialized locally.
|
||||
|
||||
Returns:
|
||||
Dict containing serialized cache and session state
|
||||
"""
|
||||
serialized_session = self._session.to_dict()
|
||||
|
||||
return {
|
||||
"cache": self._cache,
|
||||
"full_conversation": self._full_conversation,
|
||||
"agent_session": serialized_session,
|
||||
"pending_agent_requests": self._pending_agent_requests,
|
||||
"pending_responses_to_agent": self._pending_responses_to_agent,
|
||||
}
|
||||
|
||||
@override
|
||||
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
|
||||
"""Restore executor state from checkpoint.
|
||||
|
||||
Args:
|
||||
state: Checkpoint data dict
|
||||
"""
|
||||
cache_payload = state.get("cache")
|
||||
self._cache = cache_payload or []
|
||||
|
||||
full_conversation_payload = state.get("full_conversation")
|
||||
self._full_conversation = full_conversation_payload or []
|
||||
|
||||
session_payload = state.get("agent_session")
|
||||
if session_payload:
|
||||
try:
|
||||
self._session = AgentSession.from_dict(session_payload)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to restore agent session: %s", exc)
|
||||
self._session = self._agent.create_session()
|
||||
else:
|
||||
self._session = self._agent.create_session()
|
||||
|
||||
pending_requests_payload = state.get("pending_agent_requests")
|
||||
self._pending_agent_requests = pending_requests_payload or {}
|
||||
|
||||
pending_responses_payload = state.get("pending_responses_to_agent")
|
||||
self._pending_responses_to_agent = pending_responses_payload or []
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the internal cache of the executor."""
|
||||
logger.debug("AgentExecutor %s: Resetting cache", self.id)
|
||||
self._cache.clear()
|
||||
|
||||
async def _run_agent_and_emit(
|
||||
self,
|
||||
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate],
|
||||
) -> None:
|
||||
"""Execute the underlying agent, emit events, and enqueue response.
|
||||
|
||||
Checks ctx.is_streaming() to determine whether to emit output events (type='output')
|
||||
containing incremental updates (streaming mode) or a single output event (type='output')
|
||||
containing the complete response (non-streaming mode).
|
||||
"""
|
||||
if ctx.is_streaming():
|
||||
# Streaming mode: emit incremental updates
|
||||
response = await self._run_agent_streaming(cast(WorkflowContext[Never, AgentResponseUpdate], ctx))
|
||||
else:
|
||||
# Non-streaming mode: use run() and emit single event
|
||||
response = await self._run_agent(cast(WorkflowContext[Never, AgentResponse], ctx))
|
||||
|
||||
# Snapshot current conversation as cache + latest agent outputs.
|
||||
# Do not append to prior snapshots: callers may provide full-history messages
|
||||
# in request.messages, and extending would duplicate prior turns.
|
||||
self._full_conversation = [*self._cache, *(list(response.messages) if response else [])]
|
||||
|
||||
if response is None:
|
||||
# Agent did not complete (e.g., waiting for user input); do not emit response
|
||||
logger.info("AgentExecutor %s: Agent did not complete, awaiting user input", self.id)
|
||||
return
|
||||
|
||||
agent_response = AgentExecutorResponse(self.id, response, full_conversation=self._full_conversation)
|
||||
await ctx.send_message(agent_response)
|
||||
self._cache.clear()
|
||||
|
||||
async def _run_agent(self, ctx: WorkflowContext[Never, AgentResponse]) -> AgentResponse | None:
|
||||
"""Execute the underlying agent in non-streaming mode.
|
||||
|
||||
Args:
|
||||
ctx: The workflow context for emitting events.
|
||||
|
||||
Returns:
|
||||
The complete AgentResponse, or None if waiting for user input.
|
||||
"""
|
||||
function_invocation_kwargs, client_kwargs = self._prepare_agent_run_args(
|
||||
ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})
|
||||
)
|
||||
|
||||
if not self._cache:
|
||||
logger.warning(
|
||||
"AgentExecutor %s: Running agent with empty message cache. "
|
||||
"This could lead to service error for some LLM providers.",
|
||||
self.id,
|
||||
)
|
||||
|
||||
run_agent = cast(Callable[..., Awaitable[AgentResponse[Any]]], self._agent.run)
|
||||
response = await run_agent(
|
||||
self._cache,
|
||||
stream=False,
|
||||
session=self._session,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
)
|
||||
|
||||
# Handle any user input requests
|
||||
if response.user_input_requests:
|
||||
user_input_request_count = len(response.user_input_requests)
|
||||
total_message_content_count = sum(len(msg.contents) for msg in response.messages)
|
||||
if user_input_request_count != total_message_content_count:
|
||||
logger.warning(
|
||||
"Response %s contains %d user input requests but total message contents are %d. "
|
||||
"This indicates the response contains both user input requests and message contents. "
|
||||
"Double check if this is the intended behavior, as non user input request contents in "
|
||||
"this response will not be emitted.",
|
||||
response.response_id,
|
||||
user_input_request_count,
|
||||
total_message_content_count,
|
||||
)
|
||||
for user_input_request in response.user_input_requests:
|
||||
self._pending_agent_requests[user_input_request.id] = user_input_request # type: ignore[index]
|
||||
await ctx.request_info(user_input_request, Content, request_id=user_input_request.id)
|
||||
return None
|
||||
|
||||
# Only yield output if the response is complete and not waiting for user input.
|
||||
# This is to avoid emitting two events of different types ('output' and 'request_info')
|
||||
# that carry the same payload.
|
||||
await ctx.yield_output(response)
|
||||
return response
|
||||
|
||||
async def _run_agent_streaming(self, ctx: WorkflowContext[Never, AgentResponseUpdate]) -> AgentResponse | None:
|
||||
"""Execute the underlying agent in streaming mode and collect the full response.
|
||||
|
||||
Args:
|
||||
ctx: The workflow context for emitting events.
|
||||
|
||||
Returns:
|
||||
The complete AgentResponse, or None if waiting for user input.
|
||||
"""
|
||||
function_invocation_kwargs, client_kwargs = self._prepare_agent_run_args(
|
||||
ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})
|
||||
)
|
||||
|
||||
if not self._cache:
|
||||
logger.warning(
|
||||
"AgentExecutor %s: Running agent with empty message cache. "
|
||||
"This could lead to service error for some LLM providers.",
|
||||
self.id,
|
||||
)
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
streamed_user_input_requests: list[Content] = []
|
||||
run_agent_stream = cast(Callable[..., ResponseStream[AgentResponseUpdate, AgentResponse[Any]]], self._agent.run)
|
||||
stream = run_agent_stream(
|
||||
self._cache,
|
||||
stream=True,
|
||||
session=self._session,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
)
|
||||
async for update in stream:
|
||||
updates.append(update)
|
||||
if update.user_input_requests:
|
||||
user_input_request_count = len(update.user_input_requests)
|
||||
total_message_content_count = len(update.contents)
|
||||
if user_input_request_count != total_message_content_count:
|
||||
logger.warning(
|
||||
"Response update %s contains %d user input requests but total message contents are %d. "
|
||||
"This indicates the response update contains both user input requests and message contents. "
|
||||
"Double check if this is the intended behavior, as non user input request contents will "
|
||||
"not be emitted.",
|
||||
update.response_id,
|
||||
user_input_request_count,
|
||||
total_message_content_count,
|
||||
)
|
||||
streamed_user_input_requests.extend(update.user_input_requests)
|
||||
else:
|
||||
# Only yield output events for updates that do not contain user input requests.
|
||||
# This is to avoid emitting two events of different types ('output' and 'request_info')
|
||||
# that carry the same payload.
|
||||
await ctx.yield_output(update)
|
||||
|
||||
# Prefer stream finalization when available so result hooks run
|
||||
# (e.g., thread conversation updates). Fall back to reconstructing from updates
|
||||
# for compatibility/custom agents that return a plain async iterable.
|
||||
# TODO(evmattso): Integrate workflow agent run handling around ResponseStream so
|
||||
# AgentExecutor does not need this conditional stream-finalization branch.
|
||||
maybe_get_final_response = getattr(stream, "get_final_response", None)
|
||||
get_final_response = maybe_get_final_response if callable(maybe_get_final_response) else None
|
||||
response: AgentResponse[Any]
|
||||
if get_final_response is not None:
|
||||
response = await cast(Callable[[], Awaitable[AgentResponse[Any]]], get_final_response)()
|
||||
elif is_chat_agent(self._agent):
|
||||
response_format = self._agent.default_options.get("response_format")
|
||||
response = AgentResponse.from_updates(
|
||||
updates,
|
||||
output_format_type=response_format,
|
||||
)
|
||||
else:
|
||||
response = AgentResponse.from_updates(updates)
|
||||
|
||||
# Handle any user input requests after the streaming completes
|
||||
user_input_requests: list[Content] = []
|
||||
seen_request_ids: set[str] = set()
|
||||
for user_input_request in [*streamed_user_input_requests, *response.user_input_requests]:
|
||||
request_id = getattr(user_input_request, "id", None)
|
||||
if isinstance(request_id, str) and request_id:
|
||||
if request_id in seen_request_ids:
|
||||
continue
|
||||
seen_request_ids.add(request_id)
|
||||
user_input_requests.append(user_input_request)
|
||||
|
||||
if user_input_requests:
|
||||
for user_input_request in user_input_requests:
|
||||
self._pending_agent_requests[user_input_request.id] = user_input_request # type: ignore[index]
|
||||
await ctx.request_info(user_input_request, Content, request_id=user_input_request.id)
|
||||
return None
|
||||
|
||||
return response
|
||||
|
||||
def _prepare_agent_run_args(
|
||||
self,
|
||||
raw_run_kwargs: dict[str, Any],
|
||||
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||
"""Prepare function_invocation_kwargs and client_kwargs for agent.run().
|
||||
|
||||
Extracts ``function_invocation_kwargs`` and ``client_kwargs`` from the
|
||||
workflow state dict, resolving per-executor entries using ``self.id``. The
|
||||
``__global__`` sentinel key (set by ``Workflow._resolve_invocation_kwargs``) denotes
|
||||
global kwargs that apply to all executors. Per-executor dicts use executor IDs as
|
||||
keys; this executor extracts only its own entry.
|
||||
|
||||
Returns:
|
||||
A 2-tuple of (function_invocation_kwargs, client_kwargs).
|
||||
"""
|
||||
fi_resolved = raw_run_kwargs.get("function_invocation_kwargs")
|
||||
ci_resolved = raw_run_kwargs.get("client_kwargs")
|
||||
|
||||
function_invocation_kwargs = self._resolve_executor_kwargs(fi_resolved)
|
||||
client_kwargs = self._resolve_executor_kwargs(ci_resolved)
|
||||
|
||||
return function_invocation_kwargs, client_kwargs
|
||||
|
||||
def _resolve_executor_kwargs(self, resolved: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""Extract this executor's kwargs from a resolved invocation kwargs dict.
|
||||
|
||||
Args:
|
||||
resolved: The resolved dict produced by ``Workflow._resolve_invocation_kwargs``,
|
||||
containing either a ``__global__`` key (global kwargs) or executor-ID keys
|
||||
(per-executor kwargs). May also be ``None``.
|
||||
|
||||
Returns:
|
||||
The kwargs for this executor, or ``None`` if not applicable.
|
||||
"""
|
||||
if not isinstance(resolved, dict):
|
||||
return None
|
||||
# Use explicit key-presence checks so that an empty per-executor dict is
|
||||
# honoured (e.g. to clear kwargs) instead of falling through to global.
|
||||
if self.id in resolved:
|
||||
executor_kwargs = resolved[self.id]
|
||||
elif GLOBAL_KWARGS_KEY in resolved:
|
||||
executor_kwargs = resolved[GLOBAL_KWARGS_KEY]
|
||||
else:
|
||||
return None
|
||||
|
||||
if not isinstance(executor_kwargs, dict):
|
||||
logger.warning(
|
||||
"Executor %s expected a dict for its kwargs, but got %s. Ignoring.",
|
||||
self.id,
|
||||
type(executor_kwargs), # type: ignore
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
return executor_kwargs # type: ignore
|
||||
@@ -0,0 +1,17 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from .._agents import SupportsAgentRun
|
||||
|
||||
|
||||
def resolve_agent_id(agent: SupportsAgentRun) -> str:
|
||||
"""Resolve the unique identifier for an agent.
|
||||
|
||||
Prefers the `.name` attribute if set; otherwise falls back to `.id`.
|
||||
|
||||
Args:
|
||||
agent: The agent whose identifier is to be resolved.
|
||||
|
||||
Returns:
|
||||
The resolved unique identifier for the agent.
|
||||
"""
|
||||
return agent.name if agent.name else agent.id
|
||||
@@ -0,0 +1,450 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field, fields
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Protocol, TypeAlias
|
||||
|
||||
from ..exceptions import WorkflowCheckpointException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._events import WorkflowEvent
|
||||
from ._runner_context import WorkflowMessage
|
||||
|
||||
# Type alias for checkpoint IDs in case we want to change the
|
||||
# underlying type in the future (e.g., to UUID or a custom class)
|
||||
CheckpointID: TypeAlias = str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WorkflowCheckpoint:
|
||||
"""Represents a complete checkpoint of workflow state.
|
||||
|
||||
Checkpoints capture the full execution state of a workflow at a specific point,
|
||||
enabling workflows to be paused and resumed.
|
||||
|
||||
Note that a checkpoint is not tied to a specific workflow instance, but rather to
|
||||
a workflow definition (identified by workflow_name and graph_signature_hash). Thus,
|
||||
the ID of the workflow instance that created the checkpoint is not included in the
|
||||
checkpoint data. This allows checkpoints to be shared and restored across different
|
||||
workflow instances of the same workflow definition.
|
||||
|
||||
Attributes:
|
||||
workflow_name: Name of the workflow this checkpoint belongs to. This acts as a
|
||||
logical grouping for checkpoints and can be used to filter checkpoints by
|
||||
workflow. Workflows with the same name are expected to have compatible graph
|
||||
structures for checkpointing.
|
||||
graph_signature_hash: Hash of the workflow graph topology to validate checkpoint
|
||||
compatibility during restore
|
||||
checkpoint_id: Unique identifier for this checkpoint
|
||||
previous_checkpoint_id: ID of the previous checkpoint in the chain, if any. This
|
||||
allows chaining checkpoints together to form a history of workflow states.
|
||||
timestamp: ISO 8601 timestamp when checkpoint was created
|
||||
messages: Messages exchanged between executors
|
||||
state: Committed workflow state including user data and executor states.
|
||||
This contains only committed state; pending state changes are not
|
||||
included in checkpoints. Executor states are stored under the
|
||||
reserved key '_executor_state'.
|
||||
pending_request_info_events: Any pending request info events that have not
|
||||
yet been processed at the time of checkpointing. This allows the workflow
|
||||
to resume with the correct pending events after a restore.
|
||||
iteration_count: Current iteration number when checkpoint was created
|
||||
metadata: Additional metadata (e.g., superstep info, graph signature)
|
||||
version: Checkpoint format version
|
||||
|
||||
Note:
|
||||
The state dict may contain reserved keys managed by the framework.
|
||||
See State class documentation for details on reserved keys.
|
||||
"""
|
||||
|
||||
workflow_name: str
|
||||
graph_signature_hash: str
|
||||
|
||||
checkpoint_id: CheckpointID = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
previous_checkpoint_id: CheckpointID | None = None
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
# Core workflow state
|
||||
messages: dict[str, list[WorkflowMessage]] = field(default_factory=dict) # type: ignore[misc]
|
||||
state: dict[str, Any] = field(default_factory=dict) # type: ignore[misc]
|
||||
pending_request_info_events: dict[str, WorkflowEvent[Any]] = field(default_factory=dict) # type: ignore[misc]
|
||||
|
||||
# Runtime state
|
||||
iteration_count: int = 0
|
||||
|
||||
# Metadata
|
||||
metadata: dict[str, Any] = field(default_factory=dict) # type: ignore[misc]
|
||||
version: str = "1.0"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert the WorkflowCheckpoint to a dictionary.
|
||||
|
||||
Notes:
|
||||
1. This method does not recursively convert nested dataclasses to dicts.
|
||||
2. This is a shallow conversion. The resulting dict will contain the same
|
||||
references to nested objects as the original dataclass.
|
||||
"""
|
||||
return {f.name: getattr(self, f.name) for f in fields(self)}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> WorkflowCheckpoint:
|
||||
"""Create a WorkflowCheckpoint from a dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing checkpoint fields.
|
||||
|
||||
Returns:
|
||||
A new WorkflowCheckpoint instance.
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException: If required fields are missing.
|
||||
"""
|
||||
try:
|
||||
return cls(**data)
|
||||
except Exception as ex:
|
||||
raise WorkflowCheckpointException(f"Failed to create WorkflowCheckpoint from dict: {ex}") from ex
|
||||
|
||||
|
||||
class CheckpointStorage(Protocol):
|
||||
"""Protocol for checkpoint storage backends."""
|
||||
|
||||
async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID:
|
||||
"""Save a checkpoint and return its ID.
|
||||
|
||||
Args:
|
||||
checkpoint: The WorkflowCheckpoint object to save.
|
||||
|
||||
Returns:
|
||||
The unique ID of the saved checkpoint.
|
||||
"""
|
||||
...
|
||||
|
||||
async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
|
||||
"""Load a checkpoint by ID.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The unique ID of the checkpoint to load.
|
||||
|
||||
Returns:
|
||||
The WorkflowCheckpoint object corresponding to the given ID.
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException: If no checkpoint with the given ID exists.
|
||||
"""
|
||||
...
|
||||
|
||||
async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]:
|
||||
"""List checkpoint objects for a given workflow name.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow to list checkpoints for.
|
||||
|
||||
Returns:
|
||||
A list of WorkflowCheckpoint objects for the specified workflow name.
|
||||
"""
|
||||
...
|
||||
|
||||
async def delete(self, checkpoint_id: CheckpointID) -> bool:
|
||||
"""Delete a checkpoint by ID.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The unique ID of the checkpoint to delete.
|
||||
|
||||
Returns:
|
||||
True if the checkpoint was successfully deleted, False if no checkpoint with the given ID exists.
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None:
|
||||
"""Get the latest checkpoint for a given workflow name.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow to get the latest checkpoint for.
|
||||
|
||||
Returns:
|
||||
The latest WorkflowCheckpoint object for the specified workflow name, or None if no checkpoints exist.
|
||||
"""
|
||||
...
|
||||
|
||||
async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]:
|
||||
"""List checkpoint IDs for a given workflow name.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow to list checkpoint IDs for.
|
||||
|
||||
Returns:
|
||||
A list of checkpoint IDs for the specified workflow name.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryCheckpointStorage:
|
||||
"""In-memory checkpoint storage for testing and development."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the memory storage."""
|
||||
self._checkpoints: dict[CheckpointID, WorkflowCheckpoint] = {}
|
||||
|
||||
async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID:
|
||||
"""Save a checkpoint and return its ID."""
|
||||
self._checkpoints[checkpoint.checkpoint_id] = copy.deepcopy(checkpoint)
|
||||
logger.debug(f"Saved checkpoint {checkpoint.checkpoint_id} to memory")
|
||||
return checkpoint.checkpoint_id
|
||||
|
||||
async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
|
||||
"""Load a checkpoint by ID."""
|
||||
checkpoint = self._checkpoints.get(checkpoint_id)
|
||||
if checkpoint:
|
||||
logger.debug(f"Loaded checkpoint {checkpoint_id} from memory")
|
||||
return checkpoint
|
||||
raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}")
|
||||
|
||||
async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]:
|
||||
"""List checkpoint objects for a given workflow name."""
|
||||
return [cp for cp in self._checkpoints.values() if cp.workflow_name == workflow_name]
|
||||
|
||||
async def delete(self, checkpoint_id: CheckpointID) -> bool:
|
||||
"""Delete a checkpoint by ID."""
|
||||
if checkpoint_id in self._checkpoints:
|
||||
del self._checkpoints[checkpoint_id]
|
||||
logger.debug(f"Deleted checkpoint {checkpoint_id} from memory")
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None:
|
||||
"""Get the latest checkpoint for a given workflow name."""
|
||||
checkpoints = [cp for cp in self._checkpoints.values() if cp.workflow_name == workflow_name]
|
||||
if not checkpoints:
|
||||
return None
|
||||
latest_checkpoint = max(checkpoints, key=lambda cp: datetime.fromisoformat(cp.timestamp))
|
||||
logger.debug(f"Latest checkpoint for workflow {workflow_name} is {latest_checkpoint.checkpoint_id}")
|
||||
return latest_checkpoint
|
||||
|
||||
async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]:
|
||||
"""List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
|
||||
return [cp.checkpoint_id for cp in self._checkpoints.values() if cp.workflow_name == workflow_name]
|
||||
|
||||
|
||||
class FileCheckpointStorage:
|
||||
"""File-based checkpoint storage for persistence.
|
||||
|
||||
This storage implements a hybrid approach where the checkpoint metadata and structure are
|
||||
stored in JSON format, while the actual state data (which may contain complex Python objects)
|
||||
is serialized using pickle and embedded as base64-encoded strings within the JSON. This allows
|
||||
for human-readable checkpoint files while preserving the ability to store complex Python objects.
|
||||
|
||||
By default, checkpoint deserialization is restricted to a built-in set of safe Python types
|
||||
(primitives, datetime, uuid, ...), all ``agent_framework`` internal types, and OpenAI SDK types
|
||||
(``openai.types``). To allow additional application-specific types, pass them via the
|
||||
``allowed_checkpoint_types`` parameter using ``"module:qualname"`` format.
|
||||
|
||||
Example::
|
||||
|
||||
storage = FileCheckpointStorage(
|
||||
"/tmp/checkpoints",
|
||||
allowed_checkpoint_types=[
|
||||
"my_app.models:MyState",
|
||||
],
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storage_path: str | Path,
|
||||
*,
|
||||
allowed_checkpoint_types: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the file storage.
|
||||
|
||||
Args:
|
||||
storage_path: Directory path where checkpoint files will be stored.
|
||||
allowed_checkpoint_types: Additional types (beyond the built-in safe set
|
||||
and framework types) that are permitted during checkpoint
|
||||
deserialization. Each entry should be a ``"module:qualname"``
|
||||
string (e.g., ``"my_app.models:MyState"``).
|
||||
"""
|
||||
self.storage_path = Path(storage_path)
|
||||
self.storage_path.mkdir(parents=True, exist_ok=True)
|
||||
self._allowed_types: frozenset[str] = frozenset(allowed_checkpoint_types or [])
|
||||
logger.info(f"Initialized file checkpoint storage at {self.storage_path}")
|
||||
|
||||
def _validate_file_path(self, checkpoint_id: CheckpointID) -> Path:
|
||||
"""Validate that a checkpoint ID resolves to a path within the storage directory.
|
||||
|
||||
This can prevent someone from crafting a checkpoint ID that points to an arbitrary
|
||||
file on the filesystem.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The checkpoint ID to validate.
|
||||
|
||||
Returns:
|
||||
The validated file path.
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException: If the checkpoint ID would resolve outside the storage directory.
|
||||
"""
|
||||
file_path = (self.storage_path / f"{checkpoint_id}.json").resolve()
|
||||
if not file_path.is_relative_to(self.storage_path.resolve()):
|
||||
raise WorkflowCheckpointException(f"Invalid checkpoint ID: {checkpoint_id}")
|
||||
return file_path
|
||||
|
||||
async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID:
|
||||
"""Save a checkpoint and return its ID.
|
||||
|
||||
Args:
|
||||
checkpoint: The WorkflowCheckpoint object to save.
|
||||
|
||||
Returns:
|
||||
The unique ID of the saved checkpoint.
|
||||
"""
|
||||
from ._checkpoint_encoding import encode_checkpoint_value
|
||||
|
||||
file_path = self._validate_file_path(checkpoint.checkpoint_id)
|
||||
checkpoint_dict = checkpoint.to_dict()
|
||||
encoded_checkpoint = encode_checkpoint_value(checkpoint_dict)
|
||||
|
||||
def _write_atomic() -> None:
|
||||
tmp_path = file_path.with_suffix(".json.tmp")
|
||||
with open(tmp_path, "w") as f:
|
||||
json.dump(encoded_checkpoint, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp_path, file_path)
|
||||
|
||||
await asyncio.to_thread(_write_atomic)
|
||||
|
||||
logger.info(f"Saved checkpoint {checkpoint.checkpoint_id} to {file_path}")
|
||||
return checkpoint.checkpoint_id
|
||||
|
||||
async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
|
||||
"""Load a checkpoint by ID.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The unique ID of the checkpoint to load.
|
||||
|
||||
Returns:
|
||||
The WorkflowCheckpoint object corresponding to the given ID.
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException: If no checkpoint with the given ID exists,
|
||||
or if checkpoint decoding fails.
|
||||
"""
|
||||
file_path = self._validate_file_path(checkpoint_id)
|
||||
|
||||
if not file_path.exists():
|
||||
raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}")
|
||||
|
||||
def _read() -> dict[str, Any]:
|
||||
with open(file_path) as f:
|
||||
return json.load(f)
|
||||
|
||||
encoded_checkpoint = await asyncio.to_thread(_read)
|
||||
|
||||
from ._checkpoint_encoding import decode_checkpoint_value
|
||||
|
||||
try:
|
||||
decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint, allowed_types=self._allowed_types)
|
||||
except WorkflowCheckpointException:
|
||||
raise
|
||||
checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict)
|
||||
logger.info(f"Loaded checkpoint {checkpoint_id} from {file_path}")
|
||||
return checkpoint
|
||||
|
||||
async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]:
|
||||
"""List checkpoint objects for a given workflow name.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow to list checkpoints for.
|
||||
|
||||
Returns:
|
||||
A list of WorkflowCheckpoint objects for the specified workflow name.
|
||||
"""
|
||||
|
||||
def _list_checkpoints() -> list[WorkflowCheckpoint]:
|
||||
checkpoints: list[WorkflowCheckpoint] = []
|
||||
for file_path in self.storage_path.glob("*.json"):
|
||||
try:
|
||||
with open(file_path) as f:
|
||||
encoded_checkpoint = json.load(f)
|
||||
from ._checkpoint_encoding import decode_checkpoint_value
|
||||
|
||||
decoded_checkpoint_dict = decode_checkpoint_value(
|
||||
encoded_checkpoint, allowed_types=self._allowed_types
|
||||
)
|
||||
checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict)
|
||||
if checkpoint.workflow_name == workflow_name:
|
||||
checkpoints.append(checkpoint)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read checkpoint file {file_path}: {e}")
|
||||
return checkpoints
|
||||
|
||||
return await asyncio.to_thread(_list_checkpoints)
|
||||
|
||||
async def delete(self, checkpoint_id: CheckpointID) -> bool:
|
||||
"""Delete a checkpoint by ID.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The unique ID of the checkpoint to delete.
|
||||
|
||||
Returns:
|
||||
True if the checkpoint was successfully deleted, False if no checkpoint with the given ID exists.
|
||||
"""
|
||||
file_path = self._validate_file_path(checkpoint_id)
|
||||
|
||||
def _delete() -> bool:
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
logger.info(f"Deleted checkpoint {checkpoint_id} from {file_path}")
|
||||
return True
|
||||
return False
|
||||
|
||||
return await asyncio.to_thread(_delete)
|
||||
|
||||
async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None:
|
||||
"""Get the latest checkpoint for a given workflow name.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow to get the latest checkpoint for.
|
||||
|
||||
Returns:
|
||||
The latest WorkflowCheckpoint object for the specified workflow name, or None if no checkpoints exist.
|
||||
"""
|
||||
checkpoints = await self.list_checkpoints(workflow_name=workflow_name)
|
||||
if not checkpoints:
|
||||
return None
|
||||
latest_checkpoint = max(checkpoints, key=lambda cp: datetime.fromisoformat(cp.timestamp))
|
||||
logger.debug(f"Latest checkpoint for workflow {workflow_name} is {latest_checkpoint.checkpoint_id}")
|
||||
return latest_checkpoint
|
||||
|
||||
async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]:
|
||||
"""List checkpoint IDs for a given workflow name.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow to list checkpoint IDs for.
|
||||
|
||||
Returns:
|
||||
A list of checkpoint IDs for the specified workflow name.
|
||||
"""
|
||||
|
||||
def _list_ids() -> list[CheckpointID]:
|
||||
checkpoint_ids: list[CheckpointID] = []
|
||||
for file_path in self.storage_path.glob("*.json"):
|
||||
try:
|
||||
with open(file_path) as f:
|
||||
data = json.load(f)
|
||||
if data.get("workflow_name") == workflow_name:
|
||||
checkpoint_ids.append(data.get("checkpoint_id", file_path.stem))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read checkpoint file {file_path}: {e}")
|
||||
return checkpoint_ids
|
||||
|
||||
return await asyncio.to_thread(_list_ids)
|
||||
@@ -0,0 +1,311 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Checkpoint encoding using JSON structure with pickle+base64 for arbitrary data.
|
||||
|
||||
This hybrid approach provides:
|
||||
- Human-readable JSON structure for debugging and inspection of primitives and collections
|
||||
- Full Python object fidelity via pickle for data values (non-JSON-native types)
|
||||
- Base64 encoding to embed binary pickle data in JSON strings
|
||||
|
||||
When ``allowed_types`` is supplied to :func:`decode_checkpoint_value`, a
|
||||
``RestrictedUnpickler`` is used that limits which classes may be instantiated
|
||||
during deserialization. The default built-in safe set covers common Python
|
||||
value types (primitives, datetime, uuid, ...), all ``agent_framework`` internal
|
||||
types, and all ``openai.types`` types. Callers can extend the set by passing
|
||||
additional ``"module:qualname"`` strings.
|
||||
|
||||
Security Model
|
||||
--------------
|
||||
Checkpoint storage is treated as a **trusted data source**. The serialization
|
||||
format uses Python's ``pickle`` module which can execute arbitrary code during
|
||||
deserialization. The ``RestrictedUnpickler`` provides a defense-in-depth
|
||||
allowlist that limits instantiable classes, but it is **not** a security
|
||||
boundary — certain allowlisted builtins (e.g. ``getattr``) are required for
|
||||
legitimate object reconstruction (enums, named tuples) and cannot be removed
|
||||
without breaking compatibility.
|
||||
|
||||
Developers **must** ensure that:
|
||||
|
||||
1. The checkpoint storage backend (file system, Cosmos DB, Azure Blob, Durable
|
||||
Functions storage) is access-controlled and not writable by untrusted
|
||||
parties.
|
||||
2. Data flowing into ``decode_checkpoint_value`` originates exclusively from
|
||||
the application's own checkpoint storage — never from user-supplied HTTP
|
||||
requests, message payloads, or other untrusted sources.
|
||||
3. The ``allowed_types`` parameter is specified whenever possible to restrict
|
||||
the set of reconstructible types to the minimum required by the application.
|
||||
4. Never pass untrusted external input to ``decode_checkpoint_value``. If you
|
||||
must accept external JSON that might contain checkpoint markers, sanitize it
|
||||
first (for example, :func:`agent_framework_durabletask._workflows.serialization.strip_pickle_markers`).
|
||||
|
||||
The allowlist is a mitigation that reduces attack surface but does not
|
||||
eliminate the inherent risks of deserializing untrusted pickle data. Treat
|
||||
your checkpoint storage with the same access controls you would apply to
|
||||
application secrets or database credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import pickle # nosec # noqa: S403
|
||||
from typing import Any
|
||||
|
||||
from ..exceptions import WorkflowCheckpointException
|
||||
|
||||
logger = logging.getLogger("agent_framework")
|
||||
|
||||
# Marker to identify pickled values in serialized JSON
|
||||
_PICKLE_MARKER = "__pickled__"
|
||||
_TYPE_MARKER = "__type__"
|
||||
|
||||
# Types that are natively JSON-serializable and don't need pickling
|
||||
_JSON_NATIVE_TYPES = (str, int, float, bool, type(None))
|
||||
|
||||
# Module prefix for framework-internal types that are always allowed
|
||||
_FRAMEWORK_MODULE_PREFIX = "agent_framework."
|
||||
|
||||
# Module prefix for OpenAI SDK types that are always allowed
|
||||
_OPENAI_MODULE_PREFIX = "openai.types."
|
||||
|
||||
# Built-in types considered safe for checkpoint deserialization.
|
||||
# Each entry is a ``module:qualname`` string matching the format produced by
|
||||
# :func:`_type_to_key`. These are the classes for which pickle's
|
||||
# ``find_class`` will be called when unpickling common Python value types.
|
||||
_BUILTIN_ALLOWED_TYPE_KEYS: frozenset[str] = frozenset({
|
||||
# builtins
|
||||
"builtins:object",
|
||||
"builtins:complex",
|
||||
"builtins:range",
|
||||
"builtins:slice",
|
||||
"builtins:int",
|
||||
"builtins:float",
|
||||
"builtins:str",
|
||||
"builtins:bytes",
|
||||
"builtins:bytearray",
|
||||
"builtins:bool",
|
||||
"builtins:set",
|
||||
"builtins:frozenset",
|
||||
"builtins:list",
|
||||
"builtins:dict",
|
||||
"builtins:tuple",
|
||||
"builtins:type",
|
||||
# getattr is used by pickle to reconstruct enum members
|
||||
"builtins:getattr",
|
||||
# copyreg helpers used by pickle for object reconstruction
|
||||
"copyreg:_reconstructor",
|
||||
# datetime
|
||||
"datetime:datetime",
|
||||
"datetime:date",
|
||||
"datetime:time",
|
||||
"datetime:timedelta",
|
||||
"datetime:timezone",
|
||||
# uuid
|
||||
"uuid:UUID",
|
||||
# decimal
|
||||
"decimal:Decimal",
|
||||
# collections
|
||||
"collections:OrderedDict",
|
||||
"collections:defaultdict",
|
||||
"collections:deque",
|
||||
})
|
||||
|
||||
|
||||
class _RestrictedUnpickler(pickle.Unpickler): # noqa: S301
|
||||
"""Unpickler that restricts which classes may be instantiated.
|
||||
|
||||
Only classes whose ``module:qualname`` key appears in the combined allow
|
||||
set (built-in safe types + framework types + OpenAI SDK types +
|
||||
caller-specified extras) are permitted. All other classes raise
|
||||
:class:`pickle.UnpicklingError`.
|
||||
"""
|
||||
|
||||
def __init__(self, data: bytes, allowed_types: frozenset[str]) -> None:
|
||||
super().__init__(io.BytesIO(data))
|
||||
self._allowed_types = allowed_types
|
||||
|
||||
def find_class(self, module: str, name: str) -> type:
|
||||
type_key = f"{module}:{name}"
|
||||
|
||||
if (
|
||||
type_key in _BUILTIN_ALLOWED_TYPE_KEYS
|
||||
or type_key in self._allowed_types
|
||||
or module.startswith(_FRAMEWORK_MODULE_PREFIX)
|
||||
or module.startswith(_OPENAI_MODULE_PREFIX)
|
||||
):
|
||||
return super().find_class(module, name)
|
||||
|
||||
raise pickle.UnpicklingError(
|
||||
f"Checkpoint deserialization blocked for type '{type_key}'. "
|
||||
f"To allow this type, either include its 'module:qualname' key in the "
|
||||
f"'allowed_types' set passed to 'decode_checkpoint_value', or add it to "
|
||||
f"'allowed_checkpoint_types' on your checkpoint storage "
|
||||
f"(for example, 'FileCheckpointStorage.allowed_checkpoint_types')."
|
||||
)
|
||||
|
||||
|
||||
def encode_checkpoint_value(value: Any) -> Any:
|
||||
"""Encode a Python value for checkpoint storage.
|
||||
|
||||
JSON-native types (str, int, float, bool, None) pass through unchanged.
|
||||
Collections (dict, list) are recursed with their values encoded.
|
||||
All other types (dataclasses, custom objects, datetime, etc.) are pickled
|
||||
and stored as base64-encoded strings.
|
||||
|
||||
Args:
|
||||
value: Any Python value to encode.
|
||||
|
||||
Returns:
|
||||
A JSON-serializable representation of the value.
|
||||
"""
|
||||
return _encode(value)
|
||||
|
||||
|
||||
def decode_checkpoint_value(value: Any, *, allowed_types: frozenset[str] | None = None) -> Any:
|
||||
"""Decode a value from checkpoint storage.
|
||||
|
||||
Reverses the encoding performed by encode_checkpoint_value.
|
||||
Pickled values (identified by _PICKLE_MARKER) are decoded and unpickled.
|
||||
|
||||
Args:
|
||||
value: A JSON-deserialized value from checkpoint storage.
|
||||
allowed_types: If not ``None``, restrict pickle deserialization to the
|
||||
built-in safe set, framework types, and the types listed here.
|
||||
Each entry should use ``"module:qualname"`` format — that is, the
|
||||
dotted module path followed by a colon and the class
|
||||
``__qualname__``. For example, given a user-defined class::
|
||||
|
||||
# my_app/models.py
|
||||
class MyState: ...
|
||||
|
||||
the corresponding entry would be ``"my_app.models:MyState"``::
|
||||
|
||||
decode_checkpoint_value(
|
||||
data,
|
||||
allowed_types=frozenset({"my_app.models:MyState"}),
|
||||
)
|
||||
|
||||
When using :class:`FileCheckpointStorage`, pass the same strings
|
||||
via ``allowed_checkpoint_types``::
|
||||
|
||||
storage = FileCheckpointStorage(
|
||||
"/tmp/checkpoints",
|
||||
allowed_checkpoint_types=["my_app.models:MyState"],
|
||||
)
|
||||
|
||||
If ``None``, no restriction is applied (backward-compatible
|
||||
behavior).
|
||||
|
||||
Returns:
|
||||
The original Python value.
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException: If the unpickled object's type doesn't match
|
||||
the recorded type, indicating corruption, if the base64/pickle
|
||||
data is malformed, or if a disallowed type is encountered during
|
||||
restricted deserialization.
|
||||
"""
|
||||
return _decode(value, allowed_types=allowed_types)
|
||||
|
||||
|
||||
def _encode(value: Any) -> Any:
|
||||
"""Recursively encode a value for JSON storage."""
|
||||
# JSON-native types pass through
|
||||
if isinstance(value, _JSON_NATIVE_TYPES):
|
||||
return value
|
||||
|
||||
# Recursively encode dict values (keys become strings)
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _encode(v) for k, v in value.items()} # type: ignore
|
||||
|
||||
# Recursively encode list items (lists are JSON-native collections)
|
||||
if isinstance(value, list):
|
||||
return [_encode(item) for item in value] # type: ignore
|
||||
|
||||
# Everything else (tuples, sets, dataclasses, custom objects, etc.): pickle and base64 encode
|
||||
return {
|
||||
_PICKLE_MARKER: _pickle_to_base64(value),
|
||||
_TYPE_MARKER: _type_to_key(type(value)), # type: ignore
|
||||
}
|
||||
|
||||
|
||||
def _decode(value: Any, *, allowed_types: frozenset[str] | None = None) -> Any:
|
||||
"""Recursively decode a value from JSON storage."""
|
||||
# JSON-native types pass through
|
||||
if isinstance(value, _JSON_NATIVE_TYPES):
|
||||
return value
|
||||
|
||||
# Handle encoded dicts
|
||||
if isinstance(value, dict):
|
||||
# Pickled value: decode, unpickle, and verify type
|
||||
if _PICKLE_MARKER in value and _TYPE_MARKER in value:
|
||||
obj = _base64_to_unpickle(value[_PICKLE_MARKER], allowed_types=allowed_types) # type: ignore
|
||||
_verify_type(obj, value.get(_TYPE_MARKER)) # type: ignore
|
||||
return obj
|
||||
|
||||
# Regular dict: decode values recursively
|
||||
return {k: _decode(v, allowed_types=allowed_types) for k, v in value.items()} # type: ignore
|
||||
|
||||
# Handle encoded lists
|
||||
if isinstance(value, list):
|
||||
return [_decode(item, allowed_types=allowed_types) for item in value] # type: ignore
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _verify_type(obj: Any, expected_type_key: str) -> None:
|
||||
"""Verify that an unpickled object matches its recorded type.
|
||||
|
||||
This is a post-deserialization integrity check that detects accidental
|
||||
corruption or type mismatches. It does not prevent arbitrary code execution
|
||||
from malicious pickle payloads, since ``pickle.loads()`` has already
|
||||
executed by the time this function is called.
|
||||
|
||||
Args:
|
||||
obj: The unpickled object.
|
||||
expected_type_key: The recorded type key (module:qualname format).
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException: If the types don't match.
|
||||
"""
|
||||
actual_type_key = _type_to_key(type(obj)) # type: ignore
|
||||
if actual_type_key != expected_type_key:
|
||||
raise WorkflowCheckpointException(
|
||||
f"Type mismatch during checkpoint decoding: "
|
||||
f"expected '{expected_type_key}', got '{actual_type_key}'. "
|
||||
f"The checkpoint may be corrupted or tampered with."
|
||||
)
|
||||
|
||||
|
||||
def _pickle_to_base64(value: Any) -> str:
|
||||
"""Pickle a value and encode as base64 string."""
|
||||
pickled = pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
return base64.b64encode(pickled).decode("ascii")
|
||||
|
||||
|
||||
def _base64_to_unpickle(encoded: str, *, allowed_types: frozenset[str] | None = None) -> Any:
|
||||
"""Decode base64 string and unpickle.
|
||||
|
||||
Args:
|
||||
encoded: Base64-encoded pickle data.
|
||||
allowed_types: If not ``None``, use restricted unpickling that only
|
||||
permits built-in safe types, framework types, and the specified
|
||||
extra types.
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException: If the base64 data is corrupted, the pickle
|
||||
format is incompatible, or a disallowed type is encountered.
|
||||
"""
|
||||
try:
|
||||
pickled = base64.b64decode(encoded.encode("ascii"))
|
||||
if allowed_types is not None:
|
||||
return _RestrictedUnpickler(pickled, allowed_types).load()
|
||||
return pickle.loads(pickled) # nosec # noqa: S301
|
||||
except Exception as exc:
|
||||
raise WorkflowCheckpointException(f"Failed to decode pickled checkpoint data: {exc}") from exc
|
||||
|
||||
|
||||
def _type_to_key(t: type) -> str:
|
||||
"""Convert a type to a module:qualname string."""
|
||||
return f"{t.__module__}:{t.__qualname__}"
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
# Default maximum iterations for workflow execution.
|
||||
DEFAULT_MAX_ITERATIONS = 100
|
||||
|
||||
# Key used to store executor state in state.
|
||||
EXECUTOR_STATE_KEY = "_executor_state"
|
||||
|
||||
# Source identifier for internal workflow messages.
|
||||
INTERNAL_SOURCE_PREFIX = "internal"
|
||||
|
||||
# State key for storing run kwargs that should be passed to agent invocations.
|
||||
# Used by all orchestration patterns (Sequential, Concurrent, GroupChat, Handoff, Magentic)
|
||||
# to pass kwargs from workflow.run() through to agent.run() and @tool functions.
|
||||
WORKFLOW_RUN_KWARGS_KEY = "_workflow_run_kwargs"
|
||||
|
||||
# Sentinel key used in resolved invocation kwargs dicts to denote global kwargs
|
||||
# that apply to all executors (as opposed to per-executor keyed entries).
|
||||
GLOBAL_KWARGS_KEY = "__global__"
|
||||
|
||||
|
||||
def INTERNAL_SOURCE_ID(executor_id: str) -> str:
|
||||
"""Generate an internal source ID for a given executor."""
|
||||
return f"{INTERNAL_SOURCE_PREFIX}:{executor_id}"
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from .._types import Message
|
||||
|
||||
"""Helpers for managing chat conversation history.
|
||||
|
||||
These utilities operate on standard `list[Message]` collections and simple
|
||||
dictionary snapshots so orchestrators can share logic without new mixins.
|
||||
"""
|
||||
|
||||
|
||||
def latest_user_message(conversation: Sequence[Message]) -> Message:
|
||||
"""Return the most recent user-authored message from `conversation`."""
|
||||
for message in reversed(conversation):
|
||||
role_value = getattr(message.role, "value", message.role)
|
||||
if str(role_value).lower() == "user":
|
||||
return message
|
||||
raise ValueError("No user message in conversation")
|
||||
|
||||
|
||||
def ensure_author(message: Message, fallback: str) -> Message:
|
||||
"""Attach `fallback` author if message is missing `author_name`."""
|
||||
message.author_name = message.author_name or fallback
|
||||
return message
|
||||
@@ -0,0 +1,943 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, ClassVar, TypeAlias, TypeVar
|
||||
|
||||
from .._agents import SupportsAgentRun
|
||||
from ._const import INTERNAL_SOURCE_ID
|
||||
from ._executor import Executor
|
||||
from ._model_utils import DictConvertible, encode_value
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Type alias for edge condition functions.
|
||||
# Conditions receive the message data and return bool (sync or async).
|
||||
EdgeCondition: TypeAlias = Callable[[Any], bool | Awaitable[bool]]
|
||||
|
||||
# TypeVar for EdgeGroup subclasses used in class methods
|
||||
EdgeGroupT = TypeVar("EdgeGroupT", bound="EdgeGroup")
|
||||
|
||||
|
||||
def _extract_function_name(func: Callable[..., Any]) -> str:
|
||||
"""Map a Python callable to a concise, human-focused identifier.
|
||||
|
||||
The workflow graph persists references to callables by recording only an
|
||||
identifier. This helper inspects standard callable metadata and picks a
|
||||
stable value so that serialized representations remain intelligible when
|
||||
they are later rendered in logs or reconstructed during deserialization.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
def threshold(value: float) -> bool:
|
||||
return value > 0.5
|
||||
|
||||
|
||||
assert _extract_function_name(threshold) == "threshold"
|
||||
"""
|
||||
if hasattr(func, "__name__"):
|
||||
name = func.__name__
|
||||
return name if name != "<lambda>" else "<lambda>"
|
||||
return "<callable>"
|
||||
|
||||
|
||||
def _missing_callable(name: str) -> Callable[..., Any]:
|
||||
"""Create a defensive placeholder for callables that cannot be restored.
|
||||
|
||||
When a workflow is deserialized in an environment that lacks the original
|
||||
Python callable, we install a proxy that fails loudly. Surfacing the error
|
||||
at invocation time preserves a clean separation between I/O concerns and
|
||||
runtime execution, while making it obvious which callable needs to be
|
||||
re-registered.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
guard = _missing_callable("transform_price")
|
||||
try:
|
||||
guard()
|
||||
except RuntimeError as exc:
|
||||
assert "transform_price" in str(exc)
|
||||
"""
|
||||
|
||||
def _raise(*_: Any, **__: Any) -> Any:
|
||||
raise RuntimeError(f"Callable '{name}' is unavailable after serialization")
|
||||
|
||||
return _raise
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Edge(DictConvertible):
|
||||
"""Model a directed, optionally-conditional hand-off between two executors.
|
||||
|
||||
Each `Edge` captures the minimal metadata required to move a message from
|
||||
one executor to another inside the workflow graph. It optionally embeds a
|
||||
boolean predicate that decides if the edge should be taken at runtime. By
|
||||
serialising the edge down to primitives we can reconstruct the topology of
|
||||
a workflow irrespective of the original Python process.
|
||||
|
||||
Edge conditions receive the message data and return a boolean (sync or async).
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
edge = Edge(source_id="ingest", target_id="score", condition=lambda data: data["ready"])
|
||||
assert await edge.should_route({"ready": True}) is True
|
||||
"""
|
||||
|
||||
ID_SEPARATOR: ClassVar[str] = "->"
|
||||
|
||||
source_id: str
|
||||
target_id: str
|
||||
condition_name: str | None
|
||||
_condition: EdgeCondition | None = field(default=None, repr=False, compare=False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
condition: EdgeCondition | None = None,
|
||||
*,
|
||||
condition_name: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize a fully-specified edge between two workflow executors.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source_id:
|
||||
Canonical identifier of the upstream executor instance.
|
||||
target_id:
|
||||
Canonical identifier of the downstream executor instance.
|
||||
condition:
|
||||
Optional predicate that receives the message data and returns
|
||||
`True` when the edge should be traversed. Can be sync or async.
|
||||
When omitted, the edge is unconditionally active.
|
||||
condition_name:
|
||||
Optional override that pins a human-friendly name for the condition
|
||||
when the callable cannot be introspected (for example after
|
||||
deserialization).
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
edge = Edge("fetch", "parse", condition=lambda data: data.is_valid)
|
||||
assert edge.source_id == "fetch"
|
||||
assert edge.target_id == "parse"
|
||||
"""
|
||||
if not source_id:
|
||||
raise ValueError("Edge source_id must be a non-empty string")
|
||||
if not target_id:
|
||||
raise ValueError("Edge target_id must be a non-empty string")
|
||||
self.source_id = source_id
|
||||
self.target_id = target_id
|
||||
self._condition = condition
|
||||
self.condition_name = (
|
||||
_extract_function_name(condition) if condition is not None and condition_name is None else condition_name
|
||||
)
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Return the stable identifier used to reference this edge.
|
||||
|
||||
The identifier combines the source and target executor identifiers with
|
||||
a deterministic separator. This allows other graph structures such as
|
||||
adjacency lists or visualisations to refer to an edge without carrying
|
||||
the full object.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
edge = Edge("reader", "writer")
|
||||
assert edge.id == "reader->writer"
|
||||
"""
|
||||
return f"{self.source_id}{self.ID_SEPARATOR}{self.target_id}"
|
||||
|
||||
@property
|
||||
def has_condition(self) -> bool:
|
||||
"""Check if this edge has a condition.
|
||||
|
||||
Returns True if the edge was configured with a condition function.
|
||||
"""
|
||||
return self._condition is not None
|
||||
|
||||
async def should_route(self, data: Any) -> bool:
|
||||
"""Evaluate the edge predicate against payload.
|
||||
|
||||
When the edge was defined without an explicit predicate the method
|
||||
returns `True`, signalling an unconditional routing rule. Otherwise the
|
||||
user-supplied callable decides whether the message should proceed along
|
||||
this edge. Any exception raised by the callable is deliberately allowed
|
||||
to surface to the caller to avoid masking logic bugs.
|
||||
|
||||
The condition receives the message data and may be sync or async.
|
||||
|
||||
Args:
|
||||
data: The message payload
|
||||
|
||||
Returns:
|
||||
True if the edge should be traversed, False otherwise.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
edge = Edge("stage1", "stage2", condition=lambda data: data["score"] > 0.8)
|
||||
assert await edge.should_route({"score": 0.9}) is True
|
||||
assert await edge.should_route({"score": 0.4}) is False
|
||||
"""
|
||||
if self._condition is None:
|
||||
return True
|
||||
result = self._condition(data)
|
||||
if inspect.isawaitable(result):
|
||||
return bool(await result)
|
||||
return bool(result)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Produce a JSON-serialisable view of the edge metadata.
|
||||
|
||||
The representation includes the source and target executor identifiers
|
||||
plus the condition name when it is known. Serialisation intentionally
|
||||
omits the live callable to keep payloads transport-friendly.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
edge = Edge("reader", "writer", condition=lambda payload: payload["ok"])
|
||||
snapshot = edge.to_dict()
|
||||
assert snapshot == {"source_id": "reader", "target_id": "writer", "condition_name": "<lambda>"}
|
||||
"""
|
||||
payload = {"source_id": self.source_id, "target_id": self.target_id}
|
||||
if self.condition_name is not None:
|
||||
payload["condition_name"] = self.condition_name
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> Edge:
|
||||
"""Reconstruct an `Edge` from its serialised dictionary form.
|
||||
|
||||
The deserialised edge will lack the executable predicate because we do
|
||||
not attempt to hydrate Python callables from storage. Instead, the
|
||||
stored `condition_name` is preserved so that downstream consumers can
|
||||
detect missing callables and re-register them where appropriate.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
payload = {"source_id": "reader", "target_id": "writer", "condition_name": "is_ready"}
|
||||
edge = Edge.from_dict(payload)
|
||||
assert edge.source_id == "reader"
|
||||
assert edge.condition_name == "is_ready"
|
||||
"""
|
||||
return cls(
|
||||
source_id=data["source_id"],
|
||||
target_id=data["target_id"],
|
||||
condition=None,
|
||||
condition_name=data.get("condition_name"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Case:
|
||||
"""Runtime wrapper combining a switch-case predicate with its target.
|
||||
|
||||
Each `Case` couples a boolean predicate with the executor that should
|
||||
handle the message when the predicate evaluates to `True`. The runtime
|
||||
keeps this lightweight container separate from the serialisable
|
||||
`SwitchCaseEdgeGroupCase` so that execution can operate with live callables
|
||||
without polluting persisted state.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
class JsonExecutor(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="json", defer_discovery=True)
|
||||
|
||||
|
||||
processor = JsonExecutor()
|
||||
case = Case(condition=lambda payload: payload["kind"] == "json", target=processor)
|
||||
assert case.target.id == "json"
|
||||
"""
|
||||
|
||||
condition: Callable[[Any], bool]
|
||||
target: Executor | SupportsAgentRun
|
||||
|
||||
|
||||
@dataclass
|
||||
class Default:
|
||||
"""Runtime representation of the default branch in a switch-case group.
|
||||
|
||||
The default branch is invoked only when no other case predicates match. In
|
||||
practice it is guaranteed to exist so that routing never produces an empty
|
||||
target.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
class DeadLetterExecutor(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="dead_letter", defer_discovery=True)
|
||||
|
||||
|
||||
fallback = Default(target=DeadLetterExecutor())
|
||||
assert fallback.target.id == "dead_letter"
|
||||
"""
|
||||
|
||||
target: Executor | SupportsAgentRun
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class EdgeGroup(DictConvertible):
|
||||
"""Bundle edges that share a common routing semantics under a single id.
|
||||
|
||||
The workflow runtime manipulates `EdgeGroup` instances rather than raw
|
||||
edges so it can reason about higher-order routing behaviours such as
|
||||
fan-out, fan-in, switch-case, and other graph patterns. The base class stores the
|
||||
identifying information and handles serialisation duties so specialised
|
||||
groups need only maintain their additional state.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
group = EdgeGroup([Edge("source", "sink")])
|
||||
assert group.source_executor_ids == ["source"]
|
||||
"""
|
||||
|
||||
id: str
|
||||
type: str
|
||||
edges: list[Edge]
|
||||
|
||||
from builtins import type as builtin_type
|
||||
|
||||
_TYPE_REGISTRY: ClassVar[dict[str, builtin_type[EdgeGroup]]] = {}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
edges: Sequence[Edge] | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
type: str | None = None,
|
||||
) -> None:
|
||||
"""Construct an edge group shell around a set of `Edge` instances.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
edges:
|
||||
Sequence of edges that participate in this group. When omitted we
|
||||
start from an empty list so subclasses can append later.
|
||||
id:
|
||||
Stable identifier for the group. Defaults to a random UUID so
|
||||
serialised graphs remain uniquely addressable.
|
||||
type:
|
||||
Logical discriminator used to recover the appropriate subclass when
|
||||
de-serialising.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
edges = [Edge("validate", "persist")]
|
||||
group = EdgeGroup(edges, id="stage", type="Custom")
|
||||
assert group.to_dict()["type"] == "Custom"
|
||||
"""
|
||||
self.id = id or f"{self.__class__.__name__}/{uuid.uuid4()}"
|
||||
self.type = type or self.__class__.__name__
|
||||
self.edges = list(edges) if edges is not None else []
|
||||
|
||||
@property
|
||||
def source_executor_ids(self) -> list[str]:
|
||||
"""Return the deduplicated list of upstream executor ids.
|
||||
|
||||
The property preserves order-of-first-appearance so the caller can rely
|
||||
on deterministic iteration when reconstructing graph topology.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
group = EdgeGroup([Edge("read", "write"), Edge("read", "archive")])
|
||||
assert group.source_executor_ids == ["read"]
|
||||
"""
|
||||
return list(dict.fromkeys(edge.source_id for edge in self.edges))
|
||||
|
||||
@property
|
||||
def target_executor_ids(self) -> list[str]:
|
||||
"""Return the ordered, deduplicated list of downstream executor ids.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
group = EdgeGroup([Edge("read", "write"), Edge("read", "archive")])
|
||||
assert group.target_executor_ids == ["write", "archive"]
|
||||
"""
|
||||
return list(dict.fromkeys(edge.target_id for edge in self.edges))
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialise the group metadata and contained edges into primitives.
|
||||
|
||||
The payload captures each edge through its own `to_dict` call, enabling
|
||||
round-tripping through formats such as JSON without leaking Python
|
||||
objects.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
group = EdgeGroup([Edge("read", "write")])
|
||||
snapshot = group.to_dict()
|
||||
assert snapshot["edges"][0]["source_id"] == "read"
|
||||
"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"type": self.type,
|
||||
"edges": [edge.to_dict() for edge in self.edges],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def register(cls, subclass: builtin_type[EdgeGroupT]) -> builtin_type[EdgeGroupT]:
|
||||
"""Register a subclass so deserialisation can recover the right type.
|
||||
|
||||
Registration is typically performed via the decorator syntax applied to
|
||||
each concrete edge group. The registry stores classes by their
|
||||
`__name__`, which must therefore remain stable across versions when
|
||||
persisted workflows are in circulation.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
@EdgeGroup.register
|
||||
class CustomGroup(EdgeGroup):
|
||||
pass
|
||||
|
||||
|
||||
assert EdgeGroup._TYPE_REGISTRY["CustomGroup"] is CustomGroup
|
||||
"""
|
||||
cls._TYPE_REGISTRY[subclass.__name__] = subclass
|
||||
return subclass
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> EdgeGroup:
|
||||
"""Hydrate the correct `EdgeGroup` subclass from serialised state.
|
||||
|
||||
The method inspects the `type` field, allocates the corresponding class
|
||||
without executing subclass `__init__`, and then manually restores any
|
||||
subtype-specific attributes. This keeps deserialisation deterministic
|
||||
even for complex group types that configure additional runtime
|
||||
callables.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
payload = {"type": "EdgeGroup", "edges": [{"source_id": "a", "target_id": "b"}]}
|
||||
group = EdgeGroup.from_dict(payload)
|
||||
assert isinstance(group, EdgeGroup)
|
||||
"""
|
||||
group_type = data.get("type", "EdgeGroup")
|
||||
target_cls = cls._TYPE_REGISTRY.get(group_type, EdgeGroup)
|
||||
edges = [Edge.from_dict(entry) for entry in data.get("edges", [])]
|
||||
|
||||
obj = target_cls.__new__(target_cls)
|
||||
EdgeGroup.__init__(obj, edges=edges, id=data.get("id"), type=group_type)
|
||||
|
||||
# Handle FanOutEdgeGroup-specific attributes
|
||||
if isinstance(obj, FanOutEdgeGroup):
|
||||
obj.selection_func_name = data.get("selection_func_name")
|
||||
obj._selection_func = ( # type: ignore[attr-defined]
|
||||
None if obj.selection_func_name is None else _missing_callable(obj.selection_func_name)
|
||||
)
|
||||
obj._target_ids = [edge.target_id for edge in obj.edges] # type: ignore[attr-defined]
|
||||
|
||||
# Handle SwitchCaseEdgeGroup-specific attributes
|
||||
if isinstance(obj, SwitchCaseEdgeGroup):
|
||||
cases_payload = data.get("cases", [])
|
||||
restored_cases: list[SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault] = []
|
||||
for case_data in cases_payload:
|
||||
case_type = case_data.get("type")
|
||||
if case_type == "Default":
|
||||
restored_cases.append(SwitchCaseEdgeGroupDefault.from_dict(case_data))
|
||||
else:
|
||||
restored_cases.append(SwitchCaseEdgeGroupCase.from_dict(case_data))
|
||||
obj.cases = restored_cases
|
||||
obj._selection_func = _missing_callable("switch_case_selection") # type: ignore[attr-defined]
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
@EdgeGroup.register
|
||||
@dataclass(init=False)
|
||||
class SingleEdgeGroup(EdgeGroup):
|
||||
"""Convenience wrapper for a solitary edge, keeping the group API uniform."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
condition: EdgeCondition | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
) -> None:
|
||||
"""Create a one-to-one edge group between two executors.
|
||||
|
||||
Args:
|
||||
source_id: The source executor ID.
|
||||
target_id: The target executor ID.
|
||||
condition: Optional condition function `(data) -> bool | Awaitable[bool]`.
|
||||
id: Optional explicit ID for the edge group.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
group = SingleEdgeGroup("ingest", "validate")
|
||||
assert group.edges[0].source_id == "ingest"
|
||||
"""
|
||||
edge = Edge(source_id=source_id, target_id=target_id, condition=condition)
|
||||
super().__init__([edge], id=id, type=self.__class__.__name__)
|
||||
|
||||
|
||||
@EdgeGroup.register
|
||||
@dataclass(init=False)
|
||||
class FanOutEdgeGroup(EdgeGroup):
|
||||
"""Represent a broadcast-style edge group with optional selection logic.
|
||||
|
||||
A fan-out forwards a message produced by a single source executor to one
|
||||
or more downstream executors. At runtime we may further narrow the targets
|
||||
by executing a `selection_func` that inspects the payload and returns the
|
||||
subset of ids that should receive the message.
|
||||
"""
|
||||
|
||||
selection_func_name: str | None
|
||||
_selection_func: Callable[[Any, list[str]], list[str]] | None
|
||||
_target_ids: list[str]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_id: str,
|
||||
target_ids: Sequence[str],
|
||||
selection_func: Callable[[Any, list[str]], list[str]] | None = None,
|
||||
*,
|
||||
selection_func_name: str | None = None,
|
||||
id: str | None = None,
|
||||
) -> None:
|
||||
"""Create a fan-out mapping from a single source to many targets.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source_id:
|
||||
Identifier of the upstream executor broadcasting the message.
|
||||
target_ids:
|
||||
Ordered set of downstream executor identifiers that may receive the
|
||||
message. At least two targets are required to preserve the fan-out
|
||||
semantics.
|
||||
selection_func:
|
||||
Optional callable that returns the subset of `target_ids` that
|
||||
should be active for a given payload. The callable receives the
|
||||
original message plus a copy of all configured target ids.
|
||||
selection_func_name:
|
||||
Static identifier used when persisting the fan-out. Needed when the
|
||||
callable cannot be introspected or is unavailable during
|
||||
deserialisation.
|
||||
id:
|
||||
Stable identifier for the group; defaults to an autogenerated UUID.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
def choose_targets(message: dict[str, Any], available: list[str]) -> list[str]:
|
||||
return [target for target in available if message.get(target)]
|
||||
|
||||
|
||||
group = FanOutEdgeGroup("sensor", ["db", "cache"], selection_func=choose_targets)
|
||||
assert group.selection_func is choose_targets
|
||||
"""
|
||||
if len(target_ids) <= 1:
|
||||
raise ValueError("FanOutEdgeGroup must contain at least two targets.")
|
||||
|
||||
edges = [Edge(source_id=source_id, target_id=target) for target in target_ids]
|
||||
super().__init__(edges, id=id, type=self.__class__.__name__)
|
||||
|
||||
self._target_ids = list(target_ids)
|
||||
self._selection_func = selection_func
|
||||
self.selection_func_name = (
|
||||
_extract_function_name(selection_func) if selection_func is not None else selection_func_name
|
||||
)
|
||||
|
||||
@property
|
||||
def target_ids(self) -> list[str]:
|
||||
"""Return a shallow copy of the configured downstream executor ids.
|
||||
|
||||
The list is defensively copied to prevent callers from mutating the
|
||||
internal state while still providing deterministic ordering.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
group = FanOutEdgeGroup("node", ["alpha", "beta"])
|
||||
assert group.target_ids == ["alpha", "beta"]
|
||||
"""
|
||||
return list(self._target_ids)
|
||||
|
||||
@property
|
||||
def selection_func(self) -> Callable[[Any, list[str]], list[str]] | None:
|
||||
"""Expose the runtime callable used to select active fan-out targets.
|
||||
|
||||
When no selection function was supplied the property returns `None`,
|
||||
signalling that all targets must receive the payload.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
group = FanOutEdgeGroup("source", ["x", "y"], selection_func=None)
|
||||
assert group.selection_func is None
|
||||
"""
|
||||
return self._selection_func
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialise the fan-out group while preserving selection metadata.
|
||||
|
||||
In addition to the base `EdgeGroup` payload we embed the human-friendly
|
||||
name of the selection function. The callable itself is not persisted.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
group = FanOutEdgeGroup("source", ["a", "b"], selection_func=lambda *_: ["a"])
|
||||
snapshot = group.to_dict()
|
||||
assert snapshot["selection_func_name"] == "<lambda>"
|
||||
"""
|
||||
payload = super().to_dict()
|
||||
payload["selection_func_name"] = self.selection_func_name
|
||||
return payload
|
||||
|
||||
|
||||
@EdgeGroup.register
|
||||
@dataclass(init=False)
|
||||
class FanInEdgeGroup(EdgeGroup):
|
||||
"""Represent a converging set of edges that feed a single downstream executor.
|
||||
|
||||
Fan-in groups are typically used when multiple upstream stages independently
|
||||
produce messages that should all arrive at the same downstream processor.
|
||||
"""
|
||||
|
||||
def __init__(self, source_ids: Sequence[str], target_id: str, *, id: str | None = None) -> None:
|
||||
"""Build a fan-in mapping that merges several sources into one target.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source_ids:
|
||||
Sequence of upstream executor identifiers contributing messages.
|
||||
target_id:
|
||||
Downstream executor that receives every message emitted by the
|
||||
sources.
|
||||
id:
|
||||
Optional explicit identifier for the edge group.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
group = FanInEdgeGroup(["parser", "enricher"], target_id="writer")
|
||||
assert group.to_dict()["edges"][0]["target_id"] == "writer"
|
||||
"""
|
||||
if len(source_ids) <= 1:
|
||||
raise ValueError("FanInEdgeGroup must contain at least two sources.")
|
||||
|
||||
edges = [Edge(source_id=source, target_id=target_id) for source in source_ids]
|
||||
super().__init__(edges, id=id, type=self.__class__.__name__)
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class SwitchCaseEdgeGroupCase(DictConvertible):
|
||||
"""Persistable description of a single conditional branch in a switch-case.
|
||||
|
||||
Unlike the runtime `Case` object this serialisable variant stores only the
|
||||
target identifier and a descriptive name for the predicate. When the
|
||||
underlying callable is unavailable during deserialisation we substitute a
|
||||
proxy placeholder that fails loudly, ensuring the missing dependency is
|
||||
immediately visible.
|
||||
"""
|
||||
|
||||
target_id: str
|
||||
condition_name: str | None
|
||||
type: str
|
||||
_condition: Callable[[Any], bool] = field(repr=False, compare=False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
condition: Callable[[Any], bool] | None,
|
||||
target_id: str,
|
||||
*,
|
||||
condition_name: str | None = None,
|
||||
) -> None:
|
||||
"""Record the routing metadata for a conditional case branch.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
condition:
|
||||
Optional live predicate. When omitted we fall back to a placeholder
|
||||
that raises at runtime to highlight missing registrations.
|
||||
target_id:
|
||||
Identifier of the executor that should handle messages when the
|
||||
predicate succeeds.
|
||||
condition_name:
|
||||
Human-friendly label for the predicate used for diagnostics and
|
||||
on-disk persistence.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
case = SwitchCaseEdgeGroupCase(lambda payload: payload["type"] == "csv", target_id="csv_handler")
|
||||
assert case.condition_name == "<lambda>"
|
||||
"""
|
||||
if not target_id:
|
||||
raise ValueError("SwitchCaseEdgeGroupCase requires a target_id")
|
||||
self.target_id = target_id
|
||||
self.type = "Case"
|
||||
if condition is not None:
|
||||
self._condition = condition
|
||||
self.condition_name = _extract_function_name(condition)
|
||||
else:
|
||||
safe_name = condition_name or "<missing_condition>"
|
||||
self._condition = _missing_callable(safe_name)
|
||||
self.condition_name = condition_name
|
||||
|
||||
@property
|
||||
def condition(self) -> Callable[[Any], bool]:
|
||||
"""Return the predicate associated with this case.
|
||||
|
||||
The placeholder installed during deserialisation raises a
|
||||
`RuntimeError` when invoked so that workflow authors are forced to
|
||||
provide the missing callable explicitly.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
case = SwitchCaseEdgeGroupCase(None, target_id="missing", condition_name="needs_registration")
|
||||
guard = case.condition
|
||||
try:
|
||||
guard({})
|
||||
except RuntimeError:
|
||||
pass
|
||||
"""
|
||||
return self._condition
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialise the case metadata without the executable predicate.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
case = SwitchCaseEdgeGroupCase(lambda _: True, target_id="handler")
|
||||
assert case.to_dict()["target_id"] == "handler"
|
||||
"""
|
||||
payload = {"target_id": self.target_id, "type": self.type}
|
||||
if self.condition_name is not None:
|
||||
payload["condition_name"] = self.condition_name
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> SwitchCaseEdgeGroupCase:
|
||||
"""Instantiate a case from its serialised dictionary payload.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
payload = {"target_id": "handler", "condition_name": "is_ready"}
|
||||
case = SwitchCaseEdgeGroupCase.from_dict(payload)
|
||||
assert case.target_id == "handler"
|
||||
"""
|
||||
return cls(
|
||||
condition=None,
|
||||
target_id=data["target_id"],
|
||||
condition_name=data.get("condition_name"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class SwitchCaseEdgeGroupDefault(DictConvertible):
|
||||
"""Persistable descriptor for the fallback branch of a switch-case group.
|
||||
|
||||
The default branch is guaranteed to exist and is invoked when every other
|
||||
case predicate fails to match the payload.
|
||||
"""
|
||||
|
||||
target_id: str
|
||||
type: str
|
||||
|
||||
def __init__(self, target_id: str) -> None:
|
||||
"""Point the default branch toward the given executor identifier.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
fallback = SwitchCaseEdgeGroupDefault(target_id="dead_letter")
|
||||
assert fallback.target_id == "dead_letter"
|
||||
"""
|
||||
if not target_id:
|
||||
raise ValueError("SwitchCaseEdgeGroupDefault requires a target_id")
|
||||
self.target_id = target_id
|
||||
self.type = "Default"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialise the default branch metadata for persistence or logging.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
fallback = SwitchCaseEdgeGroupDefault("dead_letter")
|
||||
assert fallback.to_dict()["type"] == "Default"
|
||||
"""
|
||||
return {"target_id": self.target_id, "type": self.type}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> SwitchCaseEdgeGroupDefault:
|
||||
"""Recreate the default branch from its persisted form.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
payload = {"target_id": "dead_letter", "type": "Default"}
|
||||
fallback = SwitchCaseEdgeGroupDefault.from_dict(payload)
|
||||
assert fallback.target_id == "dead_letter"
|
||||
"""
|
||||
return cls(target_id=data["target_id"])
|
||||
|
||||
|
||||
@EdgeGroup.register
|
||||
@dataclass(init=False)
|
||||
class SwitchCaseEdgeGroup(FanOutEdgeGroup):
|
||||
"""Fan-out variant that mimics a traditional switch/case control flow.
|
||||
|
||||
Each case inspects the message payload and decides whether it should handle
|
||||
the message. Exactly one case-or the default branch-returns a target at
|
||||
runtime, preserving single-dispatch semantics.
|
||||
"""
|
||||
|
||||
cases: list[SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_id: str,
|
||||
cases: Sequence[SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault],
|
||||
*,
|
||||
id: str | None = None,
|
||||
) -> None:
|
||||
"""Configure a switch/case routing structure for a single source executor.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source_id:
|
||||
Identifier of the executor producing the message to be routed.
|
||||
cases:
|
||||
Ordered sequence of case descriptors concluding with a
|
||||
`SwitchCaseEdgeGroupDefault`. Ordering matters because the runtime
|
||||
evaluates each branch sequentially until one matches.
|
||||
id:
|
||||
Optional explicit identifier for the edge group.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
cases = [
|
||||
SwitchCaseEdgeGroupCase(lambda payload: payload["kind"] == "csv", target_id="process_csv"),
|
||||
SwitchCaseEdgeGroupDefault(target_id="process_default"),
|
||||
]
|
||||
group = SwitchCaseEdgeGroup("router", cases)
|
||||
encoded = group.to_dict()
|
||||
assert encoded["cases"][0]["type"] == "Case"
|
||||
"""
|
||||
if len(cases) < 2:
|
||||
raise ValueError("SwitchCaseEdgeGroup must contain at least two cases (including the default case).")
|
||||
|
||||
default_cases = [case for case in cases if isinstance(case, SwitchCaseEdgeGroupDefault)]
|
||||
if len(default_cases) != 1:
|
||||
raise ValueError("SwitchCaseEdgeGroup must contain exactly one default case.")
|
||||
|
||||
if not isinstance(cases[-1], SwitchCaseEdgeGroupDefault):
|
||||
logger.warning(
|
||||
"Default case in the switch-case edge group is not the last case. "
|
||||
"This may result in unexpected behavior."
|
||||
)
|
||||
|
||||
def selection_func(message: Any, targets: list[str]) -> list[str]:
|
||||
for case in cases:
|
||||
if isinstance(case, SwitchCaseEdgeGroupDefault):
|
||||
return [case.target_id]
|
||||
try:
|
||||
if case.condition(message):
|
||||
return [case.target_id]
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning("Error evaluating condition for case %s: %s", case.target_id, exc)
|
||||
raise RuntimeError("No matching case found in SwitchCaseEdgeGroup")
|
||||
|
||||
target_ids = [case.target_id for case in cases]
|
||||
# Call FanOutEdgeGroup constructor directly to avoid type checking issues
|
||||
edges = [Edge(source_id=source_id, target_id=target) for target in target_ids]
|
||||
EdgeGroup.__init__(self, edges, id=id, type=self.__class__.__name__)
|
||||
|
||||
# Initialize FanOutEdgeGroup-specific attributes
|
||||
self._target_ids = list(target_ids)
|
||||
self._selection_func = selection_func
|
||||
self.selection_func_name = None
|
||||
self.cases = list(cases)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialise the switch-case group, capturing all case descriptors.
|
||||
|
||||
Each case is converted using `encode_value` to respect dataclass
|
||||
semantics as well as any nested serialisable structures.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
group = SwitchCaseEdgeGroup(
|
||||
"router",
|
||||
[
|
||||
SwitchCaseEdgeGroupCase(lambda _: True, target_id="handler"),
|
||||
SwitchCaseEdgeGroupDefault(target_id="fallback"),
|
||||
],
|
||||
)
|
||||
snapshot = group.to_dict()
|
||||
assert len(snapshot["cases"]) == 2
|
||||
"""
|
||||
payload = super().to_dict()
|
||||
payload["cases"] = [encode_value(case) for case in self.cases]
|
||||
return payload
|
||||
|
||||
|
||||
@EdgeGroup.register
|
||||
@dataclass(init=False)
|
||||
class InternalEdgeGroup(EdgeGroup):
|
||||
"""Special edge group used to route internal messages to executors.
|
||||
|
||||
This group is created automatically when a new executor is added to the workflow
|
||||
builder. It contains a single edge that routes messages from the internal source
|
||||
to the executor itself. Internal source represent messages that are generated by
|
||||
the system rather than by another executor. This includes request and response
|
||||
handling.
|
||||
|
||||
This edge group only contains one edge from the internal source to the executor.
|
||||
And it does not support any conditions or complex routing logic.
|
||||
|
||||
During workflow serialization and deserialization, the internal edge group is
|
||||
preserved and visible to systems consuming the workflow definition.
|
||||
|
||||
Messages sent along this edge will also be captured by monitoring and logging systems,
|
||||
allowing for observability into internal message flows (when tracing is enabled).
|
||||
"""
|
||||
|
||||
def __init__(self, executor_id: str) -> None:
|
||||
"""Create an internal edge group from the given edges.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
executor_id:
|
||||
Identifier of the internal executor that should receive messages.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
edge_group = InternalEdgeGroup("executor_a")
|
||||
"""
|
||||
edge = Edge(source_id=INTERNAL_SOURCE_ID(executor_id), target_id=executor_id)
|
||||
super().__init__([edge])
|
||||
@@ -0,0 +1,429 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
|
||||
from ..observability import EdgeGroupDeliveryStatus, OtelAttr, create_edge_group_processing_span
|
||||
from ._edge import (
|
||||
Edge,
|
||||
EdgeGroup,
|
||||
FanInEdgeGroup,
|
||||
FanOutEdgeGroup,
|
||||
InternalEdgeGroup,
|
||||
SingleEdgeGroup,
|
||||
SwitchCaseEdgeGroup,
|
||||
)
|
||||
from ._executor import Executor
|
||||
from ._runner_context import RunnerContext, WorkflowMessage
|
||||
from ._state import State
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EdgeRunner(ABC):
|
||||
"""Abstract base class for edge runners that handle message delivery."""
|
||||
|
||||
def __init__(self, edge_group: EdgeGroup, executors: dict[str, Executor]) -> None:
|
||||
"""Initialize the edge runner with an edge group and executor map.
|
||||
|
||||
Args:
|
||||
edge_group: The edge group to run.
|
||||
executors: Map of executor IDs to executor instances.
|
||||
"""
|
||||
self._edge_group = edge_group
|
||||
self._executors = executors
|
||||
|
||||
@abstractmethod
|
||||
async def send_message(
|
||||
self,
|
||||
message: WorkflowMessage,
|
||||
state: State,
|
||||
ctx: RunnerContext,
|
||||
) -> bool:
|
||||
"""Send a message through the edge group.
|
||||
|
||||
Args:
|
||||
message: The message to send.
|
||||
state: The workflow state.
|
||||
ctx: The context for the runner.
|
||||
|
||||
Returns:
|
||||
bool: True if the message was processed successfully,
|
||||
False if the target executor cannot handle the message.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def _can_handle(self, executor_id: str, message: WorkflowMessage) -> bool:
|
||||
"""Check if an executor can handle the given message data."""
|
||||
if executor_id not in self._executors:
|
||||
return False
|
||||
return self._executors[executor_id].can_handle(message)
|
||||
|
||||
async def _execute_on_target(
|
||||
self,
|
||||
target_id: str,
|
||||
source_ids: list[str],
|
||||
message: WorkflowMessage,
|
||||
state: State,
|
||||
ctx: RunnerContext,
|
||||
) -> None:
|
||||
"""Execute a message on a target executor with trace context."""
|
||||
if target_id not in self._executors:
|
||||
raise RuntimeError(f"Target executor {target_id} not found.")
|
||||
|
||||
target_executor = self._executors[target_id]
|
||||
|
||||
# Execute with trace context parameters
|
||||
await target_executor.execute(
|
||||
message,
|
||||
source_ids, # source_executor_ids
|
||||
state, # state
|
||||
ctx, # runner_context
|
||||
trace_contexts=message.trace_contexts, # Pass trace contexts
|
||||
source_span_ids=message.source_span_ids, # Pass source span IDs for linking
|
||||
)
|
||||
|
||||
|
||||
class SingleEdgeRunner(EdgeRunner):
|
||||
"""Runner for single edge groups."""
|
||||
|
||||
def __init__(self, edge_group: SingleEdgeGroup | InternalEdgeGroup, executors: dict[str, Executor]) -> None:
|
||||
super().__init__(edge_group, executors)
|
||||
self._edge = edge_group.edges[0]
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
message: WorkflowMessage,
|
||||
state: State,
|
||||
ctx: RunnerContext,
|
||||
) -> bool:
|
||||
"""Send a message through the single edge."""
|
||||
should_execute = False
|
||||
target_id: str | None = None
|
||||
source_id: str | None = None
|
||||
with create_edge_group_processing_span(
|
||||
self._edge_group.__class__.__name__,
|
||||
edge_group_id=self._edge_group.id,
|
||||
message_source_id=message.source_id,
|
||||
message_target_id=message.target_id,
|
||||
source_trace_contexts=message.trace_contexts,
|
||||
source_span_ids=message.source_span_ids,
|
||||
) as span:
|
||||
try:
|
||||
if message.target_id and message.target_id != self._edge.target_id:
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: False,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_TARGET_MISMATCH.value,
|
||||
})
|
||||
return False
|
||||
|
||||
if self._can_handle(self._edge.target_id, message):
|
||||
route_result = await self._edge.should_route(message.data)
|
||||
|
||||
if route_result:
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: True,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DELIVERED.value,
|
||||
})
|
||||
should_execute = True
|
||||
target_id = self._edge.target_id
|
||||
source_id = self._edge.source_id
|
||||
else:
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: False,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_CONDITION_FALSE.value,
|
||||
})
|
||||
# Return True here because message was processed, just condition failed
|
||||
return True
|
||||
else:
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: False,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_TYPE_MISMATCH.value,
|
||||
})
|
||||
return False
|
||||
except Exception as e:
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: False,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.EXCEPTION.value,
|
||||
})
|
||||
raise e
|
||||
|
||||
# Execute outside the span
|
||||
if should_execute and target_id and source_id:
|
||||
await self._execute_on_target(target_id, [source_id], message, state, ctx)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class FanOutEdgeRunner(EdgeRunner):
|
||||
"""Runner for fan-out edge groups."""
|
||||
|
||||
def __init__(self, edge_group: FanOutEdgeGroup, executors: dict[str, Executor]) -> None:
|
||||
super().__init__(edge_group, executors)
|
||||
self._edges = edge_group.edges
|
||||
self._target_ids = edge_group.target_executor_ids
|
||||
self._target_map = {edge.target_id: edge for edge in self._edges}
|
||||
self._selection_func = cast(
|
||||
Callable[[Any, list[str]], list[str]] | None, getattr(edge_group, "selection_func", None)
|
||||
)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
message: WorkflowMessage,
|
||||
state: State,
|
||||
ctx: RunnerContext,
|
||||
) -> bool:
|
||||
"""Send a message through all edges in the fan-out edge group."""
|
||||
deliverable_edges: list[Edge] = []
|
||||
single_target_edge: Edge | None = None
|
||||
# Process routing logic within span
|
||||
with create_edge_group_processing_span(
|
||||
self._edge_group.__class__.__name__,
|
||||
edge_group_id=self._edge_group.id,
|
||||
message_source_id=message.source_id,
|
||||
message_target_id=message.target_id,
|
||||
source_trace_contexts=message.trace_contexts,
|
||||
source_span_ids=message.source_span_ids,
|
||||
) as span:
|
||||
try:
|
||||
selection_results = (
|
||||
self._selection_func(message.data, self._target_ids) if self._selection_func else self._target_ids
|
||||
)
|
||||
if not self._validate_selection_result(selection_results):
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: False,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.EXCEPTION.value,
|
||||
})
|
||||
raise RuntimeError(
|
||||
f"Invalid selection result: {selection_results}. "
|
||||
f"Expected selections to be a subset of valid target executor IDs: {self._target_ids}."
|
||||
)
|
||||
|
||||
if message.target_id:
|
||||
# If the target ID is specified and the selection result contains it, send the message to that edge
|
||||
if message.target_id in selection_results:
|
||||
edge = self._target_map.get(message.target_id)
|
||||
if edge and self._can_handle(edge.target_id, message):
|
||||
route_result = await edge.should_route(message.data)
|
||||
|
||||
if route_result:
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: True,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DELIVERED.value,
|
||||
})
|
||||
single_target_edge = edge
|
||||
else:
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: False,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_CONDITION_FALSE.value, # noqa: E501
|
||||
})
|
||||
# For targeted messages with condition failure, return True (message was processed)
|
||||
return True
|
||||
else:
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: False,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_TYPE_MISMATCH.value, # noqa: E501
|
||||
})
|
||||
# For targeted messages that can't be handled, return False
|
||||
return False
|
||||
else:
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: False,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_TARGET_MISMATCH.value,
|
||||
})
|
||||
# For targeted messages not in selection, return False
|
||||
return False
|
||||
else:
|
||||
# If no target ID, send the message to the selected targets
|
||||
for target_id in selection_results:
|
||||
edge = self._target_map[target_id]
|
||||
if self._can_handle(edge.target_id, message):
|
||||
route_result = await edge.should_route(message.data)
|
||||
if route_result:
|
||||
deliverable_edges.append(edge)
|
||||
|
||||
if len(deliverable_edges) > 0:
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: True,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DELIVERED.value,
|
||||
})
|
||||
else:
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: False,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_TYPE_MISMATCH.value,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: False,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.EXCEPTION.value,
|
||||
})
|
||||
raise e
|
||||
|
||||
# Execute outside the span
|
||||
if single_target_edge:
|
||||
await self._execute_on_target(
|
||||
single_target_edge.target_id,
|
||||
[single_target_edge.source_id],
|
||||
message,
|
||||
state,
|
||||
ctx,
|
||||
)
|
||||
return True
|
||||
|
||||
if deliverable_edges:
|
||||
|
||||
async def send_to_edge(edge: Edge) -> bool:
|
||||
await self._execute_on_target(edge.target_id, [edge.source_id], message, state, ctx)
|
||||
return True
|
||||
|
||||
tasks = [send_to_edge(edge) for edge in deliverable_edges]
|
||||
results = await asyncio.gather(*tasks)
|
||||
return any(results)
|
||||
|
||||
# If we get here, it's a broadcast message with no deliverable edges
|
||||
return False
|
||||
|
||||
def _validate_selection_result(self, selection_results: list[str]) -> bool:
|
||||
"""Validate the selection results to ensure all IDs are valid target executor IDs."""
|
||||
return all(result in self._target_ids for result in selection_results)
|
||||
|
||||
|
||||
class FanInEdgeRunner(EdgeRunner):
|
||||
"""Runner for fan-in edge groups."""
|
||||
|
||||
def __init__(self, edge_group: FanInEdgeGroup, executors: dict[str, Executor]) -> None:
|
||||
super().__init__(edge_group, executors)
|
||||
self._edges = edge_group.edges
|
||||
# Buffer to hold messages before sending them to the target executor
|
||||
# Key is the source executor ID, value is a list of messages
|
||||
self._buffer: dict[str, list[WorkflowMessage]] = defaultdict(list)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
message: WorkflowMessage,
|
||||
state: State,
|
||||
ctx: RunnerContext,
|
||||
) -> bool:
|
||||
"""Send a message through all edges in the fan-in edge group."""
|
||||
execution_data: dict[str, Any] | None = None
|
||||
with create_edge_group_processing_span(
|
||||
self._edge_group.__class__.__name__,
|
||||
edge_group_id=self._edge_group.id,
|
||||
message_source_id=message.source_id,
|
||||
message_target_id=message.target_id,
|
||||
source_trace_contexts=message.trace_contexts,
|
||||
source_span_ids=message.source_span_ids,
|
||||
) as span:
|
||||
try:
|
||||
if message.target_id and message.target_id != self._edges[0].target_id:
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: False,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_TARGET_MISMATCH.value,
|
||||
})
|
||||
return False
|
||||
|
||||
# Check if target can handle list of message data (fan-in aggregates multiple messages)
|
||||
if self._can_handle(
|
||||
self._edges[0].target_id, WorkflowMessage(data=[message.data], source_id=message.source_id)
|
||||
):
|
||||
# If the edge can handle the data, buffer the message
|
||||
self._buffer[message.source_id].append(message)
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: True,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.BUFFERED.value,
|
||||
})
|
||||
else:
|
||||
# If the edge cannot handle the data, return False
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: False,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_TYPE_MISMATCH.value,
|
||||
})
|
||||
return False
|
||||
|
||||
if self._is_ready_to_send():
|
||||
# If all edges in the group have data, prepare for execution
|
||||
messages_to_send = [msg for edge in self._edges for msg in self._buffer[edge.source_id]]
|
||||
self._buffer.clear()
|
||||
# Send aggregated data to target
|
||||
aggregated_data = [msg.data for msg in messages_to_send]
|
||||
|
||||
# Collect all trace contexts and source span IDs for fan-in linking
|
||||
trace_contexts = [msg.trace_context for msg in messages_to_send if msg.trace_context]
|
||||
source_span_ids = [msg.source_span_id for msg in messages_to_send if msg.source_span_id]
|
||||
|
||||
# Create a new Message object for the aggregated data
|
||||
aggregated_message = WorkflowMessage(
|
||||
data=aggregated_data,
|
||||
source_id=self._edge_group.__class__.__name__, # This won't be used in self._execute_on_target.
|
||||
trace_contexts=trace_contexts,
|
||||
source_span_ids=source_span_ids,
|
||||
)
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: True,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DELIVERED.value,
|
||||
})
|
||||
|
||||
# Store execution data for later
|
||||
execution_data = {
|
||||
"target_id": self._edges[0].target_id,
|
||||
"source_ids": [edge.source_id for edge in self._edges],
|
||||
"message": aggregated_message,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: False,
|
||||
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.EXCEPTION.value,
|
||||
})
|
||||
raise e
|
||||
|
||||
# Execute outside the span if needed
|
||||
if execution_data:
|
||||
await self._execute_on_target(
|
||||
execution_data["target_id"],
|
||||
execution_data["source_ids"],
|
||||
execution_data["message"],
|
||||
state,
|
||||
ctx,
|
||||
)
|
||||
return True
|
||||
|
||||
return True # Return True for buffered messages (waiting for more)
|
||||
|
||||
def _is_ready_to_send(self) -> bool:
|
||||
"""Check if all edges in the group have data to send."""
|
||||
return all(self._buffer[edge.source_id] for edge in self._edges)
|
||||
|
||||
|
||||
class SwitchCaseEdgeRunner(FanOutEdgeRunner):
|
||||
"""Runner for switch-case edge groups (inherits from FanOutEdgeRunner)."""
|
||||
|
||||
def __init__(self, edge_group: SwitchCaseEdgeGroup, executors: dict[str, Executor]) -> None:
|
||||
super().__init__(edge_group, executors)
|
||||
|
||||
|
||||
def create_edge_runner(edge_group: EdgeGroup, executors: dict[str, Executor]) -> EdgeRunner:
|
||||
"""Factory function to create the appropriate edge runner for an edge group.
|
||||
|
||||
Args:
|
||||
edge_group: The edge group to create a runner for.
|
||||
executors: Map of executor IDs to executor instances.
|
||||
|
||||
Returns:
|
||||
The appropriate EdgeRunner instance.
|
||||
"""
|
||||
if isinstance(edge_group, (SingleEdgeGroup, InternalEdgeGroup)):
|
||||
return SingleEdgeRunner(edge_group, executors)
|
||||
if isinstance(edge_group, SwitchCaseEdgeGroup):
|
||||
return SwitchCaseEdgeRunner(edge_group, executors)
|
||||
if isinstance(edge_group, FanOutEdgeGroup):
|
||||
return FanOutEdgeRunner(edge_group, executors)
|
||||
if isinstance(edge_group, FanInEdgeGroup):
|
||||
return FanInEdgeRunner(edge_group, executors)
|
||||
raise ValueError(f"Unsupported edge group type: {type(edge_group)}")
|
||||
@@ -0,0 +1,448 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import sys
|
||||
import traceback as _traceback
|
||||
import warnings
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Generic, Literal, cast
|
||||
|
||||
from ._typing_utils import deserialize_type, serialize_type
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import TypeVar # pragma: no cover
|
||||
|
||||
DataT = TypeVar("DataT", default=Any)
|
||||
|
||||
|
||||
class WorkflowEventSource(str, Enum):
|
||||
"""Identifies whether a workflow event came from the framework or an executor.
|
||||
|
||||
Use `FRAMEWORK` for events emitted by built-in orchestration paths—even when the
|
||||
code that raises them lives in runner-related modules—and `EXECUTOR` for events
|
||||
surfaced by developer-provided executor implementations.
|
||||
"""
|
||||
|
||||
FRAMEWORK = "FRAMEWORK" # Framework-owned orchestration, regardless of module location
|
||||
EXECUTOR = "EXECUTOR" # User-supplied executor code and callbacks
|
||||
|
||||
|
||||
_event_origin_context: ContextVar[WorkflowEventSource] = ContextVar(
|
||||
"workflow_event_origin", default=WorkflowEventSource.EXECUTOR
|
||||
)
|
||||
|
||||
|
||||
def _current_event_origin() -> WorkflowEventSource:
|
||||
"""Return the origin to associate with newly created workflow events."""
|
||||
return _event_origin_context.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _framework_event_origin() -> Generator[None]: # pyright: ignore[reportUnusedFunction]
|
||||
"""Temporarily mark subsequently created events as originating from the framework (internal)."""
|
||||
token = _event_origin_context.set(WorkflowEventSource.FRAMEWORK)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_event_origin_context.reset(token)
|
||||
|
||||
|
||||
class WorkflowRunState(str, Enum):
|
||||
"""Run-level state of a workflow execution."""
|
||||
|
||||
STARTED = "STARTED"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
IN_PROGRESS_PENDING_REQUESTS = "IN_PROGRESS_PENDING_REQUESTS"
|
||||
IDLE = "IDLE"
|
||||
IDLE_WITH_PENDING_REQUESTS = "IDLE_WITH_PENDING_REQUESTS"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowErrorDetails:
|
||||
"""Structured error information to surface in error events/results."""
|
||||
|
||||
error_type: str
|
||||
message: str
|
||||
traceback: str | None = None
|
||||
executor_id: str | None = None
|
||||
extra: dict[str, Any] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_exception(
|
||||
cls,
|
||||
exc: BaseException,
|
||||
*,
|
||||
executor_id: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> WorkflowErrorDetails:
|
||||
tb = None
|
||||
try:
|
||||
tb = "".join(_traceback.format_exception(type(exc), exc, exc.__traceback__))
|
||||
except Exception:
|
||||
tb = None
|
||||
return cls(
|
||||
error_type=exc.__class__.__name__,
|
||||
message=str(exc),
|
||||
traceback=tb,
|
||||
executor_id=executor_id,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
# Type discriminator for workflow events.
|
||||
# Includes both framework lifecycle types and well-known orchestration types.
|
||||
WorkflowEventType = Literal[
|
||||
# Lifecycle events (workflow-level)
|
||||
"started", # Workflow run began
|
||||
"status", # Workflow state changed (use .state)
|
||||
"failed", # Workflow terminated with error (use .details)
|
||||
# Data events
|
||||
"output", # Executor yielded final terminal output (use .executor_id, .data)
|
||||
"intermediate", # Executor emitted intermediate (non-terminal) output (use .executor_id, .data)
|
||||
"data", # DEPRECATED — compatibility alias for intermediate emissions; use type='intermediate' instead.
|
||||
# Request events (human-in-the-loop)
|
||||
"request_info", # Executor requests external info (use .request_id, .source_executor_id)
|
||||
# Diagnostic events (warnings/errors from user code)
|
||||
"warning", # Warning from user code (use .data as str)
|
||||
"error", # Error from user code, non-fatal (use .data as Exception)
|
||||
# Iteration events (supersteps)
|
||||
"superstep_started", # Superstep began (use .iteration)
|
||||
"superstep_completed", # Superstep ended (use .iteration)
|
||||
# Executor lifecycle events
|
||||
"executor_invoked", # Executor handler was called (use .executor_id, .data)
|
||||
"executor_completed", # Executor handler completed (use .executor_id, .data)
|
||||
"executor_failed", # Executor handler raised error (use .executor_id, .details)
|
||||
"executor_bypassed", # Executor skipped via cache hit during replay (use .executor_id, .data)
|
||||
# Orchestration event types (use .data for typed payload)
|
||||
"group_chat", # Group chat orchestrator events (use .data as GroupChatRequestSentEvent | GroupChatResponseReceivedEvent) # noqa: E501
|
||||
"handoff_sent", # Handoff routing events (use .data as HandoffSentEvent)
|
||||
"magentic_orchestrator", # Magentic orchestrator events (use .data as MagenticOrchestratorEvent)
|
||||
]
|
||||
|
||||
|
||||
# Event types forwarded across the ``workflow.as_agent()`` boundary. Anything not
|
||||
# in this set — lifecycle events, diagnostics, executor bookkeeping, and
|
||||
# orchestration-internal events (``group_chat``, ``handoff_sent``,
|
||||
# ``magentic_orchestrator``) — stays inside the workflow and is not surfaced to
|
||||
# agent callers. Internal to the ``_workflows`` package.
|
||||
AGENT_FORWARDED_EVENT_TYPES: frozenset[str] = frozenset({
|
||||
"output",
|
||||
"intermediate",
|
||||
"data", # deprecated alias for intermediate; retained for backward compat
|
||||
"request_info",
|
||||
})
|
||||
|
||||
|
||||
class WorkflowEvent(Generic[DataT]):
|
||||
"""Unified event for all workflow emissions.
|
||||
|
||||
This single generic class handles all workflow events through a `type` discriminator,
|
||||
following the same pattern as the `Content` class.
|
||||
|
||||
Use factory methods for convenient construction of lifecycle, diagnostic, request,
|
||||
and executor bookkeeping events. Workflow ``output`` and ``intermediate`` events
|
||||
are emitted by ``ctx.yield_output(...)`` based on workflow output selection.
|
||||
|
||||
- `WorkflowEvent.started()` - workflow run began
|
||||
- `WorkflowEvent.status(state)` - workflow state changed
|
||||
- `WorkflowEvent.failed(details)` - workflow terminated with error
|
||||
- `WorkflowEvent.warning(message)` - warning from user code
|
||||
- `WorkflowEvent.error(exception)` - error from user code
|
||||
- `WorkflowEvent.request_info(...)` - executor requests external info
|
||||
- `WorkflowEvent.superstep_started(iteration)` - superstep began
|
||||
- `WorkflowEvent.superstep_completed(iteration)` - superstep ended
|
||||
- `WorkflowEvent.executor_invoked(executor_id)` - executor handler called
|
||||
- `WorkflowEvent.executor_completed(executor_id)` - executor handler completed
|
||||
- `WorkflowEvent.executor_failed(executor_id, details)` - executor handler failed
|
||||
- `WorkflowEvent.executor_bypassed(executor_id)` - executor skipped via cache hit
|
||||
|
||||
The generic parameter DataT represents the type of the event's data payload:
|
||||
- Lifecycle events: `WorkflowEvent[None]` (data is None)
|
||||
- Data events: `WorkflowEvent[DataT]` where DataT is the payload type (e.g., AgentResponse)
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
# Create lifecycle events via factory methods
|
||||
started = WorkflowEvent.started()
|
||||
status = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS)
|
||||
|
||||
# Type-safe access to event data
|
||||
event: WorkflowEvent[AgentResponse] = WorkflowEvent("data", executor_id="agent1", data=response)
|
||||
data: AgentResponse = event.data
|
||||
|
||||
# Check event type
|
||||
if event.type == "status":
|
||||
print(f"State: {event.state}")
|
||||
elif event.type == "output":
|
||||
print(f"Output from {event.executor_id}: {event.data}")
|
||||
elif event.type == "data":
|
||||
if isinstance(event.data, AgentResponse):
|
||||
print(f"Agent response: {event.data.text}")
|
||||
"""
|
||||
|
||||
type: WorkflowEventType
|
||||
data: DataT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
type: WorkflowEventType,
|
||||
data: DataT | None = None,
|
||||
*,
|
||||
# Event context fields
|
||||
origin: WorkflowEventSource | None = None,
|
||||
# STATUS event fields
|
||||
state: WorkflowRunState | None = None,
|
||||
# FAILED event fields
|
||||
details: WorkflowErrorDetails | None = None,
|
||||
# OUTPUT/DATA event fields
|
||||
executor_id: str | None = None,
|
||||
# REQUEST_INFO event fields
|
||||
request_id: str | None = None,
|
||||
source_executor_id: str | None = None,
|
||||
request_type: builtins.type[Any] | None = None,
|
||||
response_type: builtins.type[Any] | None = None,
|
||||
# SUPERSTEP event fields
|
||||
iteration: int | None = None,
|
||||
) -> None:
|
||||
"""Initialize the workflow event.
|
||||
|
||||
Prefer using factory methods like `WorkflowEvent.started()` instead of __init__ directly.
|
||||
"""
|
||||
self.type = type
|
||||
self.data = data # type: ignore[assignment]
|
||||
self.origin = origin if origin is not None else _current_event_origin()
|
||||
|
||||
# Event-specific fields
|
||||
self.state = state
|
||||
self.details = details
|
||||
self.executor_id = executor_id
|
||||
self._request_id = request_id
|
||||
self._source_executor_id = source_executor_id
|
||||
self._request_type = request_type
|
||||
self._response_type = response_type
|
||||
self.iteration = iteration
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the workflow event."""
|
||||
parts = [f"type={self.type!r}"]
|
||||
if self.state is not None:
|
||||
parts.append(f"state={self.state.value}")
|
||||
if self.executor_id is not None:
|
||||
parts.append(f"executor_id={self.executor_id!r}")
|
||||
if self.iteration is not None:
|
||||
parts.append(f"iteration={self.iteration}")
|
||||
if self._request_id is not None:
|
||||
parts.append(f"request_id={self._request_id!r}")
|
||||
if self.data is not None:
|
||||
parts.append(f"data={self.data!r}")
|
||||
return f"WorkflowEvent({', '.join(parts)})" # pragma: no cover
|
||||
|
||||
# ==========================================================================
|
||||
# Factory methods
|
||||
# ==========================================================================
|
||||
|
||||
@classmethod
|
||||
def started(cls, data: DataT | None = None) -> WorkflowEvent[DataT]:
|
||||
"""Create a 'started' event when a workflow run begins."""
|
||||
return cls("started", data=data)
|
||||
|
||||
@classmethod
|
||||
def status(cls, state: WorkflowRunState, data: DataT | None = None) -> WorkflowEvent[DataT]:
|
||||
"""Create a 'status' event for workflow state transitions."""
|
||||
return cls("status", data=data, state=state)
|
||||
|
||||
@classmethod
|
||||
def failed(cls, details: WorkflowErrorDetails, data: DataT | None = None) -> WorkflowEvent[DataT]:
|
||||
"""Create a 'failed' event when a workflow terminates with error."""
|
||||
return cls("failed", data=data, details=details)
|
||||
|
||||
@classmethod
|
||||
def warning(cls, message: str) -> WorkflowEvent[str]:
|
||||
"""Create a 'warning' event from user code."""
|
||||
return WorkflowEvent("warning", data=message)
|
||||
|
||||
@classmethod
|
||||
def error(cls, exception: Exception) -> WorkflowEvent[Exception]:
|
||||
"""Create an 'error' event from user code."""
|
||||
return WorkflowEvent("error", data=exception)
|
||||
|
||||
@classmethod
|
||||
def emit(cls, executor_id: str, data: DataT) -> WorkflowEvent[DataT]:
|
||||
"""Create a 'data' event (deprecated alias for intermediate emissions).
|
||||
|
||||
.. deprecated::
|
||||
Use ``ctx.yield_output(...)`` and configure ``intermediate_output_from`` instead.
|
||||
Will be removed in a future major release along with the ``type='data'`` event variant.
|
||||
"""
|
||||
warnings.warn(
|
||||
"WorkflowEvent.emit() / type='data' are deprecated; use ctx.yield_output() from an "
|
||||
"intermediate-designated executor. Will be removed in a future major release.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return cls("data", executor_id=executor_id, data=data)
|
||||
|
||||
@classmethod
|
||||
def request_info(
|
||||
cls,
|
||||
request_id: str,
|
||||
source_executor_id: str,
|
||||
request_data: DataT,
|
||||
response_type: builtins.type[Any],
|
||||
) -> WorkflowEvent[DataT]:
|
||||
"""Create a 'request_info' event when an executor requests external information."""
|
||||
return cls(
|
||||
"request_info",
|
||||
data=request_data,
|
||||
request_id=request_id,
|
||||
source_executor_id=source_executor_id,
|
||||
request_type=type(request_data),
|
||||
response_type=response_type,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def superstep_started(cls, iteration: int, data: DataT | None = None) -> WorkflowEvent[DataT]:
|
||||
"""Create a 'superstep_started' event when a superstep begins."""
|
||||
return cls("superstep_started", iteration=iteration, data=data)
|
||||
|
||||
@classmethod
|
||||
def superstep_completed(cls, iteration: int, data: DataT | None = None) -> WorkflowEvent[DataT]:
|
||||
"""Create a 'superstep_completed' event when a superstep ends."""
|
||||
return cls("superstep_completed", iteration=iteration, data=data)
|
||||
|
||||
@classmethod
|
||||
def executor_invoked(cls, executor_id: str, data: DataT | None = None) -> WorkflowEvent[DataT]:
|
||||
"""Create an 'executor_invoked' event when an executor handler is called."""
|
||||
return cls("executor_invoked", executor_id=executor_id, data=data)
|
||||
|
||||
@classmethod
|
||||
def executor_completed(cls, executor_id: str, data: DataT | None = None) -> WorkflowEvent[DataT]:
|
||||
"""Create an 'executor_completed' event when an executor handler completes."""
|
||||
return cls("executor_completed", executor_id=executor_id, data=data)
|
||||
|
||||
@classmethod
|
||||
def executor_failed(cls, executor_id: str, details: WorkflowErrorDetails) -> WorkflowEvent[WorkflowErrorDetails]:
|
||||
"""Create an 'executor_failed' event when an executor handler raises an error."""
|
||||
return WorkflowEvent("executor_failed", executor_id=executor_id, data=details, details=details)
|
||||
|
||||
@classmethod
|
||||
def executor_bypassed(cls, executor_id: str, data: DataT | None = None) -> WorkflowEvent[DataT]:
|
||||
"""Create an 'executor_bypassed' event when a step is skipped via cache hit during replay."""
|
||||
return cls("executor_bypassed", executor_id=executor_id, data=data)
|
||||
|
||||
# ==========================================================================
|
||||
# Property for type-safe access
|
||||
# ==========================================================================
|
||||
|
||||
@property
|
||||
def request_id(self) -> str:
|
||||
"""Get request_id for request_info events.
|
||||
|
||||
Returns:
|
||||
The request ID as a non-None string.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If called on an event that is not a request_info event,
|
||||
or if the event is malformed (request_info without request_id).
|
||||
"""
|
||||
if self.type != "request_info" or self._request_id is None:
|
||||
raise RuntimeError(f"request_id is only available for request_info events, got type={self.type!r}")
|
||||
return self._request_id
|
||||
|
||||
@property
|
||||
def source_executor_id(self) -> str:
|
||||
"""Get source_executor_id for request_info events.
|
||||
|
||||
Returns:
|
||||
The source executor ID as a non-None string.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If called on an event that is not a request_info event,
|
||||
or if the event is malformed (request_info without source_executor_id).
|
||||
"""
|
||||
if self.type != "request_info" or self._source_executor_id is None:
|
||||
raise RuntimeError(f"source_executor_id is only available for request_info events, got type={self.type!r}")
|
||||
return self._source_executor_id
|
||||
|
||||
@property
|
||||
def request_type(self) -> builtins.type[Any]:
|
||||
"""Get request_type for request_info events.
|
||||
|
||||
Returns:
|
||||
The request data type as a non-None type object.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If called on an event that is not a request_info event,
|
||||
or if the event is malformed (request_info without request_type).
|
||||
"""
|
||||
if self.type != "request_info" or self._request_type is None:
|
||||
raise RuntimeError(f"request_type is only available for request_info events, got type={self.type!r}")
|
||||
return self._request_type
|
||||
|
||||
@property
|
||||
def response_type(self) -> builtins.type[Any]:
|
||||
"""Get response_type for request_info events.
|
||||
|
||||
Returns:
|
||||
The response data type as a non-None type object.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If called on an event that is not a request_info event,
|
||||
or if the event is malformed (request_info without response_type).
|
||||
"""
|
||||
if self.type != "request_info" or self._response_type is None:
|
||||
raise RuntimeError(f"response_type is only available for request_info events, got type={self.type!r}")
|
||||
return self._response_type
|
||||
|
||||
# ==========================================================================
|
||||
# Serialization methods (primarily for REQUEST_INFO events)
|
||||
# ==========================================================================
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for serialization.
|
||||
|
||||
Currently only implemented for 'request_info' events for checkpoint storage.
|
||||
"""
|
||||
if self.type != "request_info":
|
||||
raise ValueError(f"to_dict() only supported for 'request_info' events, got '{self.type}'")
|
||||
return {
|
||||
"type": self.type,
|
||||
"data": self.data,
|
||||
"request_id": self._request_id,
|
||||
"source_executor_id": self._source_executor_id,
|
||||
"request_type": serialize_type(self._request_type) if self._request_type else None,
|
||||
"response_type": serialize_type(self._response_type) if self._response_type else None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> WorkflowEvent[Any]:
|
||||
"""Create a REQUEST_INFO event from a dictionary."""
|
||||
for prop in ["data", "request_id", "source_executor_id", "request_type", "response_type"]:
|
||||
if prop not in data:
|
||||
raise KeyError(f"Missing '{prop}' field in WorkflowEvent dictionary.")
|
||||
|
||||
request_data = data["data"]
|
||||
request_type = deserialize_type(data["request_type"])
|
||||
|
||||
if request_type is not type(request_data):
|
||||
raise TypeError(
|
||||
"Mismatch between deserialized request_data type and request_type field in WorkflowEvent dictionary."
|
||||
)
|
||||
|
||||
return cls.request_info(
|
||||
request_id=data["request_id"],
|
||||
source_executor_id=data["source_executor_id"],
|
||||
request_data=cast(Any, request_data), # type: ignore
|
||||
response_type=deserialize_type(data["response_type"]),
|
||||
)
|
||||
@@ -0,0 +1,814 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import copy
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
import types
|
||||
import typing
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, TypeVar, overload
|
||||
|
||||
from ..observability import create_processing_span
|
||||
from ._events import (
|
||||
WorkflowErrorDetails,
|
||||
WorkflowEvent,
|
||||
_framework_event_origin, # type: ignore[reportPrivateUsage]
|
||||
)
|
||||
from ._model_utils import DictConvertible
|
||||
from ._request_info_mixin import RequestInfoMixin
|
||||
from ._runner_context import MessageType, RunnerContext, WorkflowMessage
|
||||
from ._state import State
|
||||
from ._typing_utils import contains_typevar, is_instance_of, normalize_type_to_list, resolve_type_annotation
|
||||
from ._workflow_context import WorkflowContext, validate_workflow_context_annotation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# region Executor
|
||||
class Executor(RequestInfoMixin, DictConvertible):
|
||||
"""Base class for all workflow executors that process messages and perform computations.
|
||||
|
||||
## Overview
|
||||
Executors are the fundamental building blocks of workflows, representing individual processing
|
||||
units that receive messages, perform operations, and produce outputs. Each executor is uniquely
|
||||
identified and can handle specific message types through decorated handler methods.
|
||||
|
||||
## Type System
|
||||
Executors have a rich type system that defines their capabilities:
|
||||
|
||||
### Input Types
|
||||
The types of messages an executor can process, discovered from handler method signatures:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyExecutor(Executor):
|
||||
@handler
|
||||
async def handle_string(self, message: str, ctx: WorkflowContext) -> None:
|
||||
# This executor can handle 'str' input types
|
||||
Access via the `input_types` property.
|
||||
|
||||
### Output Types
|
||||
The types of messages an executor can send to other executors via `ctx.send_message()`:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyExecutor(Executor):
|
||||
@handler
|
||||
async def handle_data(self, message: str, ctx: WorkflowContext[int | bool]) -> None:
|
||||
# This executor can send 'int' or 'bool' messages
|
||||
Access via the `output_types` property.
|
||||
|
||||
### Workflow Output Types
|
||||
The types of data an executor can emit as workflow-level outputs via `ctx.yield_output()`:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyExecutor(Executor):
|
||||
@handler
|
||||
async def process(self, message: str, ctx: WorkflowContext[int, str]) -> None:
|
||||
# Can send 'int' messages AND yield 'str' workflow outputs
|
||||
Access via the `workflow_output_types` property.
|
||||
|
||||
## Handler Discovery
|
||||
Executors discover their capabilities through decorated methods:
|
||||
|
||||
### @handler Decorator
|
||||
Marks methods that process incoming messages:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyExecutor(Executor):
|
||||
@handler
|
||||
async def handle_text(self, message: str, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.send_message(message.upper())
|
||||
|
||||
### Sub-workflow Request Interception
|
||||
Use @handler methods to intercept sub-workflow requests:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class ParentExecutor(Executor):
|
||||
@handler
|
||||
async def handle_subworkflow_request(
|
||||
self,
|
||||
request: SubWorkflowRequestMessage,
|
||||
ctx: WorkflowContext[SubWorkflowResponseMessage],
|
||||
) -> None:
|
||||
if self.is_allowed(request.domain):
|
||||
response = request.create_response(data=True)
|
||||
await ctx.send_message(response, target_id=request.executor_id)
|
||||
else:
|
||||
await ctx.request_info(request.source_event, response_type=request.source_event.response_type)
|
||||
|
||||
## Context Types
|
||||
Handler methods receive different WorkflowContext variants based on their type annotations:
|
||||
|
||||
### WorkflowContext (no type parameters)
|
||||
For handlers that only perform side effects without sending messages or yielding outputs:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class LoggingExecutor(Executor):
|
||||
@handler
|
||||
async def log_message(self, msg: str, ctx: WorkflowContext) -> None:
|
||||
print(f"Received: {msg}") # Only logging, no outputs
|
||||
|
||||
### WorkflowContext[OutT]
|
||||
Enables sending messages of type OutT via `ctx.send_message()`:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class ProcessorExecutor(Executor):
|
||||
@handler
|
||||
async def handler(self, msg: str, ctx: WorkflowContext[int]) -> None:
|
||||
await ctx.send_message(42) # Can send int messages
|
||||
|
||||
### WorkflowContext[OutT, W_OutT]
|
||||
Enables both sending messages (OutT) and yielding workflow outputs (W_OutT):
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class DualOutputExecutor(Executor):
|
||||
@handler
|
||||
async def handler(self, msg: str, ctx: WorkflowContext[int, str]) -> None:
|
||||
await ctx.send_message(42) # Send int message
|
||||
await ctx.yield_output("done") # Yield str workflow output
|
||||
|
||||
## Function Executors
|
||||
Simple functions can be converted to executors using the `@executor` decorator:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@executor
|
||||
async def process_text(text: str, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.send_message(text.upper())
|
||||
|
||||
|
||||
# Or with custom ID:
|
||||
@executor(id="text_processor")
|
||||
def sync_process(text: str, ctx: WorkflowContext[str]) -> None:
|
||||
ctx.send_message(text.lower()) # Sync functions run in thread pool
|
||||
|
||||
## Sub-workflow Composition
|
||||
Executors can contain sub-workflows using WorkflowExecutor. Sub-workflows can make requests
|
||||
that parent workflows can intercept. See WorkflowExecutor documentation for details on
|
||||
workflow composition patterns and request/response handling.
|
||||
|
||||
## State Management
|
||||
Executors can contain states that persist across workflow runs and checkpoints. Override the
|
||||
`on_checkpoint_save` and `on_checkpoint_restore` methods to implement custom state
|
||||
serialization and restoration logic.
|
||||
|
||||
## Implementation Notes
|
||||
- Do not call `execute()` directly - it's invoked by the workflow engine
|
||||
- Do not override `execute()` - define handlers using decorators instead
|
||||
- Each executor must have at least one `@handler` method
|
||||
- Handler method signatures are validated at initialization time
|
||||
"""
|
||||
|
||||
# Provide a default so static analyzers (e.g., pyright) don't require passing `id`.
|
||||
# Runtime still sets a concrete value in __init__.
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
*,
|
||||
type: str | None = None,
|
||||
type_: str | None = None,
|
||||
defer_discovery: bool = False,
|
||||
**_: Any,
|
||||
) -> None:
|
||||
"""Initialize the executor with a unique identifier.
|
||||
|
||||
Args:
|
||||
id: A unique identifier for the executor.
|
||||
|
||||
Keyword Args:
|
||||
type: The executor type name. If not provided, uses class name.
|
||||
type_: Alternative parameter name for executor type.
|
||||
defer_discovery: If True, defer handler method discovery until later.
|
||||
**_: Additional keyword arguments. Unused in this implementation.
|
||||
"""
|
||||
if not id:
|
||||
raise ValueError("Executor ID must be a non-empty string.")
|
||||
|
||||
resolved_type = type or type_ or self.__class__.__name__
|
||||
self.id = id
|
||||
self.type = resolved_type
|
||||
self.type_ = resolved_type
|
||||
|
||||
# Serialize per-executor message processing. Within a superstep the runner may
|
||||
# dispatch deliveries to the same target executor from multiple sources
|
||||
# concurrently; this lock guarantees the executor processes them one at a time
|
||||
# (and, per source, in the order they were sent).
|
||||
#
|
||||
# The lock must be created lazily under the running loop (see ``_get_execution_lock``)
|
||||
# in order to support executors that are constructed outside an event loop and reused
|
||||
# across multiple async loops (e.g., successive ``asyncio.run`` calls on the same workflow).
|
||||
# Binding a single lock to one loop would raise "bound to a different event loop" on reuse.
|
||||
self._execution_lock: asyncio.Lock | None = None
|
||||
self._execution_lock_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
from builtins import type as builtin_type
|
||||
|
||||
self._handlers: dict[
|
||||
builtin_type[Any] | types.UnionType, Callable[[Any, WorkflowContext[Any, Any]], Awaitable[None]]
|
||||
] = {}
|
||||
self._handler_specs: list[dict[str, Any]] = []
|
||||
if not defer_discovery:
|
||||
self._discover_handlers()
|
||||
|
||||
if not self._handlers:
|
||||
raise ValueError(
|
||||
f"Executor {self.__class__.__name__} has no handlers defined. "
|
||||
"Please define at least one handler using the @handler decorator."
|
||||
)
|
||||
|
||||
# Initialize RequestInfoMixin to discover response handlers
|
||||
self._discover_response_handlers()
|
||||
|
||||
def _get_execution_lock(self) -> asyncio.Lock:
|
||||
"""Return this executor's serialization lock, bound to the running event loop.
|
||||
|
||||
The lock is created lazily and re-created if the running loop has changed since it
|
||||
was last used (for example, the executor is reused across successive
|
||||
``asyncio.run`` calls), avoiding ``asyncio.Lock`` "bound to a different event loop"
|
||||
errors. Must be called from within a running loop.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
if self._execution_lock is None or self._execution_lock_loop is not loop:
|
||||
self._execution_lock = asyncio.Lock()
|
||||
self._execution_lock_loop = loop
|
||||
return self._execution_lock
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
message: Any,
|
||||
source_executor_ids: list[str],
|
||||
state: State,
|
||||
runner_context: RunnerContext,
|
||||
trace_contexts: list[dict[str, str]] | None = None,
|
||||
source_span_ids: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Execute the executor with a given message and context parameters.
|
||||
|
||||
- Do not call this method directly - it is invoked by the workflow engine.
|
||||
- Do not override this method. Instead, define handlers using @handler decorator.
|
||||
|
||||
Args:
|
||||
message: The message to be processed by the executor.
|
||||
source_executor_ids: The IDs of the source executors that sent messages to this executor.
|
||||
state: The state for the workflow.
|
||||
runner_context: The runner context that provides methods to send messages and events.
|
||||
trace_contexts: Optional trace contexts from multiple sources for OpenTelemetry propagation.
|
||||
source_span_ids: Optional source span IDs from multiple sources for linking.
|
||||
|
||||
Returns:
|
||||
An awaitable that resolves to the result of the execution.
|
||||
"""
|
||||
async with self._get_execution_lock():
|
||||
# Create processing span for tracing (gracefully handles disabled tracing)
|
||||
with create_processing_span(
|
||||
self.id,
|
||||
self.__class__.__name__,
|
||||
str(MessageType.STANDARD if not isinstance(message, WorkflowMessage) else message.type),
|
||||
type(message).__name__,
|
||||
source_trace_contexts=trace_contexts,
|
||||
source_span_ids=source_span_ids,
|
||||
):
|
||||
# Find the handler and handler spec that matches the message type.
|
||||
handler = self._find_handler(message)
|
||||
|
||||
original_message = message
|
||||
if isinstance(message, WorkflowMessage):
|
||||
# Unwrap raw data for handler call
|
||||
message = message.data
|
||||
|
||||
# Create the appropriate WorkflowContext based on handler specs
|
||||
context = self._create_context_for_handler(
|
||||
source_executor_ids=source_executor_ids,
|
||||
state=state,
|
||||
runner_context=runner_context,
|
||||
trace_contexts=trace_contexts,
|
||||
source_span_ids=source_span_ids,
|
||||
request_id=original_message.original_request_info_event.request_id
|
||||
if isinstance(original_message, WorkflowMessage) and original_message.original_request_info_event
|
||||
else None,
|
||||
)
|
||||
|
||||
# Invoke the handler with the message and context
|
||||
# Use deepcopy to capture original input state before handler can mutate it
|
||||
with _framework_event_origin():
|
||||
invoke_event = WorkflowEvent.executor_invoked(self.id, copy.deepcopy(message))
|
||||
await context.add_event(invoke_event)
|
||||
try:
|
||||
await handler(message, context)
|
||||
except Exception as exc:
|
||||
# Surface structured executor failure before propagating
|
||||
with _framework_event_origin():
|
||||
failure_event = WorkflowEvent.executor_failed(self.id, WorkflowErrorDetails.from_exception(exc))
|
||||
await context.add_event(failure_event)
|
||||
raise
|
||||
with _framework_event_origin():
|
||||
# Include sent messages and yielded outputs as the completion data
|
||||
sent_messages = context.get_sent_messages()
|
||||
yielded_outputs = context.get_yielded_outputs()
|
||||
completion_data = sent_messages + yielded_outputs
|
||||
completed_event = WorkflowEvent.executor_completed(
|
||||
self.id, completion_data if completion_data else None
|
||||
)
|
||||
await context.add_event(completed_event)
|
||||
|
||||
def _create_context_for_handler(
|
||||
self,
|
||||
source_executor_ids: list[str],
|
||||
state: State,
|
||||
runner_context: RunnerContext,
|
||||
trace_contexts: list[dict[str, str]] | None = None,
|
||||
source_span_ids: list[str] | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> WorkflowContext[Any]:
|
||||
"""Create the appropriate WorkflowContext based on the handler's context annotation.
|
||||
|
||||
Args:
|
||||
source_executor_ids: The IDs of the source executors that sent messages to this executor.
|
||||
state: The state for the workflow.
|
||||
runner_context: The runner context that provides methods to send messages and events.
|
||||
trace_contexts: Optional trace contexts from multiple sources for OpenTelemetry propagation.
|
||||
source_span_ids: Optional source span IDs from multiple sources for linking.
|
||||
request_id: Optional request ID if this context is for a `handle_response` handler.
|
||||
|
||||
Returns:
|
||||
WorkflowContext[Any] based on the handler's context annotation.
|
||||
"""
|
||||
# Create WorkflowContext
|
||||
return WorkflowContext(
|
||||
executor=self,
|
||||
source_executor_ids=source_executor_ids,
|
||||
state=state,
|
||||
runner_context=runner_context,
|
||||
trace_contexts=trace_contexts,
|
||||
source_span_ids=source_span_ids,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
def _discover_handlers(self) -> None:
|
||||
"""Discover message handlers in the executor class."""
|
||||
# Use __class__.__dict__ to avoid accessing pydantic's dynamic attributes
|
||||
for attr_name in dir(self.__class__):
|
||||
try:
|
||||
attr = getattr(self.__class__, attr_name)
|
||||
except AttributeError:
|
||||
# Skip attributes that may not be accessible (e.g., dynamic descriptors)
|
||||
continue
|
||||
|
||||
# Discover @handler methods
|
||||
if callable(attr) and hasattr(attr, "_handler_spec"):
|
||||
handler_spec = attr._handler_spec # type: ignore
|
||||
message_type = handler_spec["message_type"]
|
||||
|
||||
# Keep full generic types for handler registration to avoid conflicts
|
||||
if self._handlers.get(message_type) is not None:
|
||||
raise ValueError(f"Duplicate handler for type {message_type} in {self.__class__.__name__}")
|
||||
|
||||
# Get the bound method
|
||||
bound_method = getattr(self, attr_name)
|
||||
self._handlers[message_type] = bound_method
|
||||
|
||||
# Add to unified handler specs list
|
||||
self._handler_specs.append({**handler_spec})
|
||||
|
||||
def can_handle(self, message: WorkflowMessage) -> bool:
|
||||
"""Check if the executor can handle a given message type.
|
||||
|
||||
Args:
|
||||
message: The message to check.
|
||||
|
||||
Returns:
|
||||
True if the executor can handle the message type, False otherwise.
|
||||
"""
|
||||
if message.type == MessageType.RESPONSE:
|
||||
if message.original_request_info_event is None:
|
||||
logger.warning(
|
||||
f"Executor {self.__class__.__name__} received a response message without an original request event."
|
||||
)
|
||||
return False
|
||||
|
||||
return any(
|
||||
is_instance_of(message.original_request_info_event.data, message_type[0])
|
||||
and is_instance_of(message.data, message_type[1])
|
||||
for message_type in self._response_handlers
|
||||
)
|
||||
|
||||
return any(is_instance_of(message.data, message_type) for message_type in self._handlers)
|
||||
|
||||
def _register_instance_handler(
|
||||
self,
|
||||
name: str,
|
||||
func: Callable[[Any, WorkflowContext[Any]], Awaitable[Any]],
|
||||
message_type: type | types.UnionType,
|
||||
ctx_annotation: Any,
|
||||
output_types: list[type[Any] | types.UnionType],
|
||||
workflow_output_types: list[type[Any] | types.UnionType],
|
||||
) -> None:
|
||||
"""Register a handler at instance level.
|
||||
|
||||
Args:
|
||||
name: Name of the handler function for error reporting
|
||||
func: The async handler function to register
|
||||
message_type: Type of message this handler processes
|
||||
ctx_annotation: The WorkflowContext[T] annotation from the function
|
||||
output_types: List of output types for send_message()
|
||||
workflow_output_types: List of workflow output types for yield_output()
|
||||
"""
|
||||
if message_type in self._handlers:
|
||||
raise ValueError(f"Handler for type {message_type} already registered in {self.__class__.__name__}")
|
||||
|
||||
self._handlers[message_type] = func
|
||||
self._handler_specs.append({
|
||||
"name": name,
|
||||
"message_type": message_type,
|
||||
"ctx_annotation": ctx_annotation,
|
||||
"output_types": output_types,
|
||||
"workflow_output_types": workflow_output_types,
|
||||
})
|
||||
|
||||
@property
|
||||
def input_types(self) -> list[type[Any] | types.UnionType]:
|
||||
"""Get the list of input types that this executor can handle.
|
||||
|
||||
Returns:
|
||||
A list of the message types that this executor's handlers can process.
|
||||
"""
|
||||
return list(self._handlers.keys())
|
||||
|
||||
@property
|
||||
def output_types(self) -> list[type[Any] | types.UnionType]:
|
||||
"""Get the list of output types that this executor can produce via send_message().
|
||||
|
||||
Returns:
|
||||
A list of the output types inferred from the handlers' WorkflowContext[T] annotations.
|
||||
"""
|
||||
output_types: set[type[Any] | types.UnionType] = set()
|
||||
|
||||
# Collect output types from all handlers
|
||||
for handler_spec in self._handler_specs + self._response_handler_specs:
|
||||
handler_output_types = handler_spec.get("output_types", [])
|
||||
output_types.update(handler_output_types)
|
||||
|
||||
return list(output_types)
|
||||
|
||||
@property
|
||||
def workflow_output_types(self) -> list[type[Any] | types.UnionType]:
|
||||
"""Get the list of workflow output types that this executor can produce via yield_output().
|
||||
|
||||
Returns:
|
||||
A list of the workflow output types inferred from handlers' WorkflowContext[T, U] annotations.
|
||||
"""
|
||||
output_types: set[type[Any] | types.UnionType] = set()
|
||||
|
||||
# Collect workflow output types from all handlers
|
||||
for handler_spec in self._handler_specs + self._response_handler_specs:
|
||||
handler_workflow_output_types = handler_spec.get("workflow_output_types", [])
|
||||
output_types.update(handler_workflow_output_types)
|
||||
|
||||
return list(output_types)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize executor definition for workflow topology export."""
|
||||
return {"id": self.id, "type": self.type}
|
||||
|
||||
def _find_handler(self, message: Any) -> Callable[[Any, WorkflowContext[Any, Any]], Awaitable[None]]:
|
||||
"""Find the handler for a given message.
|
||||
|
||||
Args:
|
||||
message: The message to find the handler for.
|
||||
|
||||
Returns:
|
||||
The handler function if found, None otherwise
|
||||
"""
|
||||
if isinstance(message, WorkflowMessage):
|
||||
# Case where Message wrapper is passed instead of raw data
|
||||
# Handler can be a standard handler or a response handler
|
||||
if message.type == MessageType.STANDARD:
|
||||
for message_type in self._handlers:
|
||||
if is_instance_of(message.data, message_type):
|
||||
return self._handlers[message_type]
|
||||
raise RuntimeError(
|
||||
f"Executor {self.__class__.__name__} cannot handle message of type {type(message.data)}."
|
||||
)
|
||||
# Response message case - find response handler based on original request and response types
|
||||
if message.original_request_info_event is None:
|
||||
raise RuntimeError(
|
||||
f"Executor {self.__class__.__name__} received a response message without an original request event."
|
||||
)
|
||||
handler = self._find_response_handler(message.original_request_info_event.data, message.data)
|
||||
if not handler:
|
||||
raise RuntimeError(
|
||||
f"Executor {self.__class__.__name__} cannot handle request of type "
|
||||
f"{type(message.original_request_info_event.data)} and response of type {type(message.data)}."
|
||||
)
|
||||
return handler
|
||||
|
||||
# Standard raw message data case - only standard handlers apply
|
||||
for message_type in self._handlers:
|
||||
if is_instance_of(message, message_type):
|
||||
return self._handlers[message_type]
|
||||
raise RuntimeError(f"Executor {self.__class__.__name__} cannot handle message of type {type(message)}.")
|
||||
|
||||
async def on_checkpoint_save(self) -> dict[str, Any]:
|
||||
"""Hook called when the workflow is being saved to a checkpoint.
|
||||
|
||||
Override this method in subclasses to implement custom logic that should
|
||||
return state to be saved in the checkpoint.
|
||||
|
||||
The returned state dictionary will be passed to `on_checkpoint_restore`
|
||||
when the workflow is restored from the checkpoint. The dictionary should
|
||||
only contain JSON-serializable data.
|
||||
|
||||
Returns:
|
||||
A state dictionary to be saved during checkpointing.
|
||||
"""
|
||||
return {}
|
||||
|
||||
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
|
||||
"""Hook called when the workflow is restored from a checkpoint.
|
||||
|
||||
Override this method in subclasses to implement custom logic that should
|
||||
run when the workflow is restored from a checkpoint.
|
||||
|
||||
Args:
|
||||
state: The state dictionary that was saved during checkpointing.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# endregion: Executor
|
||||
|
||||
# region Handler Decorator
|
||||
|
||||
|
||||
ExecutorT = TypeVar("ExecutorT", bound="Executor")
|
||||
ContextT = TypeVar("ContextT", bound="WorkflowContext[Any, Any]")
|
||||
|
||||
|
||||
@overload
|
||||
def handler(
|
||||
func: Callable[[ExecutorT, Any, ContextT], Awaitable[Any]],
|
||||
) -> Callable[[ExecutorT, Any, ContextT], Awaitable[Any]]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def handler(
|
||||
*,
|
||||
input: type | types.UnionType | str | None = None,
|
||||
output: type | types.UnionType | str | None = None,
|
||||
workflow_output: type | types.UnionType | str | None = None,
|
||||
) -> Callable[
|
||||
[Callable[..., Awaitable[Any]]],
|
||||
Callable[..., Awaitable[Any]],
|
||||
]: ...
|
||||
|
||||
|
||||
def handler(
|
||||
func: Callable[[ExecutorT, Any, ContextT], Awaitable[Any]] | None = None,
|
||||
*,
|
||||
input: type | types.UnionType | str | None = None,
|
||||
output: type | types.UnionType | str | None = None,
|
||||
workflow_output: type | types.UnionType | str | None = None,
|
||||
) -> (
|
||||
Callable[[ExecutorT, Any, ContextT], Awaitable[Any]]
|
||||
| Callable[
|
||||
[Callable[[ExecutorT, Any, ContextT], Awaitable[Any]]],
|
||||
Callable[[ExecutorT, Any, ContextT], Awaitable[Any]],
|
||||
]
|
||||
):
|
||||
"""Decorator to register a handler for an executor.
|
||||
|
||||
Type information can be provided in two mutually exclusive ways:
|
||||
|
||||
1. **Introspection** (default): Types are inferred from function signature annotations.
|
||||
Use type annotations on the message parameter and WorkflowContext generic parameters.
|
||||
|
||||
2. **Explicit parameters**: Types are specified via decorator parameters (input, output,
|
||||
workflow_output). When ANY explicit parameter is provided, ALL types must come from
|
||||
explicit parameters - introspection is completely disabled. The ``input`` parameter
|
||||
is required; ``output`` and ``workflow_output`` are optional (default to no outputs).
|
||||
|
||||
Args:
|
||||
func: The function to decorate. Can be None when used with parameters.
|
||||
input: Explicit input type(s) for this handler. Required when using explicit mode.
|
||||
Supports union types (e.g., ``str | int``) and string forward references.
|
||||
output: Explicit output type(s) that can be sent via ``ctx.send_message()``.
|
||||
Optional; defaults to no outputs if not specified.
|
||||
workflow_output: Explicit output type(s) that can be yielded via ``ctx.yield_output()``.
|
||||
Optional; defaults to no outputs if not specified.
|
||||
|
||||
Returns:
|
||||
The decorated function with handler metadata.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
# Mode 1: Introspection - types from annotations
|
||||
@handler
|
||||
async def handle_string(self, message: str, ctx: WorkflowContext[str]) -> None: ...
|
||||
|
||||
|
||||
# Mode 2: Explicit types - ALL types from decorator params
|
||||
# Note: No type annotations on function parameters when using explicit types
|
||||
@handler(input=str | int, output=bool)
|
||||
async def handle_data(self, message, ctx): ...
|
||||
|
||||
|
||||
# Explicit with string forward references
|
||||
@handler(input="MyCustomType | int", output="ResponseType")
|
||||
async def handle_custom(self, message, ctx): ...
|
||||
|
||||
|
||||
# Explicit with all three type parameters
|
||||
@handler(input=str, output=int, workflow_output=bool)
|
||||
async def handle_full(self, message, ctx):
|
||||
await ctx.send_message(42) # int - matches output
|
||||
await ctx.yield_output(True) # bool - matches workflow_output
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
func: Callable[[ExecutorT, Any, ContextT], Awaitable[Any]],
|
||||
) -> Callable[[ExecutorT, Any, ContextT], Awaitable[Any]]:
|
||||
# Check if ANY explicit type parameter was provided - if so, use ONLY explicit params.
|
||||
# This is "all or nothing" - no mixing of explicit params with introspection.
|
||||
use_explicit_types = input is not None or output is not None or workflow_output is not None
|
||||
|
||||
if use_explicit_types:
|
||||
# Resolve string forward references using the function's globals
|
||||
resolved_input_type = resolve_type_annotation(input, func.__globals__) if input is not None else None
|
||||
resolved_output_type = resolve_type_annotation(output, func.__globals__) if output is not None else None
|
||||
resolved_workflow_output_type = (
|
||||
resolve_type_annotation(workflow_output, func.__globals__) if workflow_output is not None else None
|
||||
)
|
||||
|
||||
# Check for unresolved TypeVars in explicit type parameters
|
||||
for param_name, param_type in [
|
||||
("input", resolved_input_type),
|
||||
("output", resolved_output_type),
|
||||
("workflow_output", resolved_workflow_output_type),
|
||||
]:
|
||||
if param_type is not None and contains_typevar(param_type):
|
||||
raise ValueError(
|
||||
f"Handler '{func.__name__}' has an unresolved TypeVar '{param_type}' "
|
||||
f"as its {param_name} type. "
|
||||
f"Use @handler(input=ConcreteType, output=ConcreteType) with concrete types "
|
||||
f"for parameterized executors."
|
||||
)
|
||||
|
||||
# Validate signature structure (correct number of params, ctx is WorkflowContext)
|
||||
# but skip type extraction since we're using explicit types
|
||||
_validate_handler_signature(func, skip_message_annotation=True)
|
||||
|
||||
# Use explicit types only - missing params default to empty
|
||||
message_type = resolved_input_type
|
||||
if message_type is None:
|
||||
raise ValueError(f"Handler {func.__name__} with explicit type parameters must specify 'input' type")
|
||||
|
||||
final_output_types = normalize_type_to_list(resolved_output_type) if resolved_output_type else []
|
||||
final_workflow_output_types = (
|
||||
normalize_type_to_list(resolved_workflow_output_type) if resolved_workflow_output_type else []
|
||||
)
|
||||
# Get ctx_annotation for consistency (even though types come from explicit params)
|
||||
ctx_annotation = (
|
||||
inspect.signature(func).parameters[list(inspect.signature(func).parameters.keys())[2]].annotation
|
||||
)
|
||||
else:
|
||||
# Use introspection for ALL types - no explicit params provided
|
||||
introspected_message_type, ctx_annotation, inferred_output_types, inferred_workflow_output_types = (
|
||||
_validate_handler_signature(func, skip_message_annotation=False)
|
||||
)
|
||||
|
||||
message_type = introspected_message_type
|
||||
if message_type is None:
|
||||
raise ValueError(
|
||||
f"Handler {func.__name__} requires either a message parameter type annotation "
|
||||
"or explicit type parameters (input, output, workflow_output)"
|
||||
)
|
||||
|
||||
# Check for unresolved TypeVar in introspected message type
|
||||
if contains_typevar(message_type):
|
||||
raise ValueError(
|
||||
f"Handler '{func.__name__}' has an unresolved TypeVar '{message_type}' "
|
||||
f"as its message type. "
|
||||
f"Use @handler(input=ConcreteType, output=ConcreteType) with concrete types "
|
||||
f"for parameterized executors."
|
||||
)
|
||||
|
||||
final_output_types = inferred_output_types
|
||||
final_workflow_output_types = inferred_workflow_output_types
|
||||
|
||||
# Get signature for preservation
|
||||
sig = inspect.signature(func)
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(self: ExecutorT, message: Any, ctx: ContextT) -> Any:
|
||||
"""Wrapper function to call the handler."""
|
||||
return await func(self, message, ctx)
|
||||
|
||||
# Preserve the original function signature for introspection during validation
|
||||
with contextlib.suppress(AttributeError, TypeError):
|
||||
wrapper.__signature__ = sig # type: ignore[attr-defined]
|
||||
|
||||
wrapper._handler_spec = { # type: ignore
|
||||
"name": func.__name__,
|
||||
"message_type": message_type,
|
||||
# Keep output_types and workflow_output_types in spec for validators
|
||||
"output_types": final_output_types,
|
||||
"workflow_output_types": final_workflow_output_types,
|
||||
"ctx_annotation": ctx_annotation,
|
||||
}
|
||||
|
||||
return wrapper
|
||||
|
||||
# Handle both @handler and @handler(...) usage patterns
|
||||
if func is not None:
|
||||
# Called as @handler without parentheses
|
||||
return decorator(func)
|
||||
# Called as @handler(...) with parentheses
|
||||
return decorator
|
||||
|
||||
|
||||
# endregion: Handler Decorator
|
||||
|
||||
# region Handler Validation
|
||||
|
||||
|
||||
def _validate_handler_signature(
|
||||
func: Callable[..., Any],
|
||||
*,
|
||||
skip_message_annotation: bool = False,
|
||||
) -> tuple[type | None, Any, list[type[Any] | types.UnionType], list[type[Any] | types.UnionType]]:
|
||||
"""Validate function signature for executor functions.
|
||||
|
||||
Args:
|
||||
func: The function to validate
|
||||
skip_message_annotation: If True, skip validation that message parameter has a type
|
||||
annotation. Used when input_type is explicitly provided to the @handler decorator.
|
||||
|
||||
Returns:
|
||||
Tuple of (message_type, ctx_annotation, output_types, workflow_output_types).
|
||||
message_type may be None if skip_message_annotation is True and no annotation exists.
|
||||
|
||||
Raises:
|
||||
ValueError: If the function signature is invalid
|
||||
"""
|
||||
signature = inspect.signature(func)
|
||||
params = list(signature.parameters.values())
|
||||
|
||||
expected_counts = 3 # self, message, ctx
|
||||
param_description = "(self, message: T, ctx: WorkflowContext[U, V])"
|
||||
if len(params) != expected_counts:
|
||||
raise ValueError(f"Handler {func.__name__} must have {param_description}. Got {len(params)} parameters.")
|
||||
|
||||
# Check message parameter has type annotation (unless skipped)
|
||||
message_param = params[1]
|
||||
if not skip_message_annotation and message_param.annotation == inspect.Parameter.empty:
|
||||
raise ValueError(f"Handler {func.__name__} must have a type annotation for the message parameter")
|
||||
|
||||
# Resolve string annotations from `from __future__ import annotations`.
|
||||
# Fall back to raw annotations if resolution fails (e.g. unresolvable forward refs,
|
||||
# AttributeError, or RecursionError), so registration failures are easier to diagnose.
|
||||
try:
|
||||
type_hints = typing.get_type_hints(func)
|
||||
except (NameError, AttributeError, RecursionError):
|
||||
type_hints = {p.name: p.annotation for p in params}
|
||||
|
||||
message_type = type_hints.get(message_param.name, message_param.annotation)
|
||||
if message_type == inspect.Parameter.empty:
|
||||
message_type = None
|
||||
|
||||
# Reject unresolved TypeVar in message annotation -- these are not supported
|
||||
# for workflow type validation and must be replaced with concrete types.
|
||||
if not skip_message_annotation and contains_typevar(message_type):
|
||||
raise ValueError(
|
||||
f"Handler {func.__name__} has an unresolved TypeVar '{message_type}' as its message type annotation. "
|
||||
"Generic TypeVar annotations are not supported for workflow type validation. "
|
||||
"Use @handler(input=<concrete_type>, output=<concrete_type>) to specify explicit types."
|
||||
)
|
||||
|
||||
# Validate ctx parameter is WorkflowContext and extract type args
|
||||
ctx_param = params[2]
|
||||
ctx_annotation = type_hints.get(ctx_param.name, ctx_param.annotation)
|
||||
if skip_message_annotation and ctx_annotation == inspect.Parameter.empty:
|
||||
# When explicit types are provided via @handler(input=..., output=...),
|
||||
# the ctx parameter doesn't need a type annotation - types come from the decorator.
|
||||
output_types: list[type[Any] | types.UnionType] = []
|
||||
workflow_output_types: list[type[Any] | types.UnionType] = []
|
||||
else:
|
||||
output_types, workflow_output_types = validate_workflow_context_annotation(
|
||||
ctx_annotation, f"parameter '{ctx_param.name}'", "Handler"
|
||||
)
|
||||
|
||||
return message_type, ctx_annotation, output_types, workflow_output_types
|
||||
|
||||
|
||||
# endregion: Handler Validation
|
||||
@@ -0,0 +1,397 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Function-based Executor and decorator utilities.
|
||||
|
||||
This module provides:
|
||||
- FunctionExecutor: an Executor subclass that wraps a standalone user-defined function
|
||||
with signature (message) or (message, ctx: WorkflowContext[T]). Both sync and async functions are supported.
|
||||
Synchronous functions are executed in a thread pool using asyncio.to_thread() to avoid blocking the event loop.
|
||||
- executor decorator: converts a standalone module-level function into a ready-to-use Executor instance
|
||||
with proper type validation and handler registration.
|
||||
|
||||
Design Pattern:
|
||||
- Use @executor for standalone module-level or local functions
|
||||
- Use Executor subclass with @handler for class-based executors with state/dependencies
|
||||
- Do NOT use @executor with @staticmethod or @classmethod
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import sys
|
||||
import types
|
||||
import typing
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from ._executor import Executor
|
||||
from ._typing_utils import contains_typevar, normalize_type_to_list, resolve_type_annotation
|
||||
from ._workflow_context import WorkflowContext, validate_workflow_context_annotation
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import overload # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import overload # pragma: no cover
|
||||
|
||||
|
||||
class FunctionExecutor(Executor):
|
||||
"""Executor that wraps a user-defined function.
|
||||
|
||||
This executor allows users to define simple functions (both sync and async) and use them
|
||||
as workflow executors without needing to create full executor classes.
|
||||
|
||||
Synchronous functions are executed in a thread pool using asyncio.to_thread() to avoid
|
||||
blocking the event loop.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
func: Callable[..., Any],
|
||||
id: str | None = None,
|
||||
*,
|
||||
input: type | types.UnionType | str | None = None,
|
||||
output: type | types.UnionType | str | None = None,
|
||||
workflow_output: type | types.UnionType | str | None = None,
|
||||
):
|
||||
"""Initialize the FunctionExecutor with a user-defined function.
|
||||
|
||||
Args:
|
||||
func: The function to wrap as an executor (can be sync or async)
|
||||
id: Optional executor ID. If None, uses the function name.
|
||||
input: Optional explicit input type(s) for this executor. Supports union types
|
||||
(e.g., ``str | int``) and string forward references (e.g., ``"MyType | int"``).
|
||||
When provided, takes precedence over introspection from the function's message
|
||||
parameter annotation.
|
||||
output: Optional explicit output type(s) that can be sent via ``ctx.send_message()``.
|
||||
Supports union types (e.g., ``str | int``) and string forward references.
|
||||
When provided, takes precedence over introspection from the ``WorkflowContext``
|
||||
first generic parameter (OutT).
|
||||
workflow_output: Optional explicit output type(s) that can be yielded via
|
||||
``ctx.yield_output()``. Supports union types (e.g., ``str | int``) and string
|
||||
forward references. When provided, takes precedence over introspection from the
|
||||
``WorkflowContext`` second generic parameter (W_OutT).
|
||||
|
||||
Raises:
|
||||
ValueError: If func is a staticmethod or classmethod (use @handler on instance methods instead)
|
||||
"""
|
||||
# Detect misuse of @executor with staticmethod/classmethod
|
||||
if isinstance(func, (staticmethod, classmethod)):
|
||||
descriptor_type = "staticmethod" if isinstance(func, staticmethod) else "classmethod"
|
||||
raise ValueError(
|
||||
f"The @executor decorator cannot be used with @{descriptor_type}. "
|
||||
f"Use the @executor decorator on standalone module-level functions, "
|
||||
f"or create an Executor subclass and use @handler on instance methods instead."
|
||||
)
|
||||
|
||||
# Resolve string forward references using the function's globals
|
||||
resolved_input_type = resolve_type_annotation(input, func.__globals__) if input is not None else None
|
||||
resolved_output_type = resolve_type_annotation(output, func.__globals__) if output is not None else None
|
||||
resolved_workflow_output_type = (
|
||||
resolve_type_annotation(workflow_output, func.__globals__) if workflow_output is not None else None
|
||||
)
|
||||
|
||||
# Validate function signature and extract types
|
||||
introspected_message_type, ctx_annotation, inferred_output_types, inferred_workflow_output_types = (
|
||||
_validate_function_signature(func, skip_message_annotation=resolved_input_type is not None)
|
||||
)
|
||||
|
||||
# Check for unresolved TypeVars in explicit type parameters
|
||||
for param_name, param_type in [
|
||||
("input", resolved_input_type),
|
||||
("output", resolved_output_type),
|
||||
("workflow_output", resolved_workflow_output_type),
|
||||
]:
|
||||
if param_type is not None and contains_typevar(param_type):
|
||||
raise ValueError(
|
||||
f"Executor '{func.__name__}' has an unresolved TypeVar '{param_type}' "
|
||||
f"as its {param_name} type. "
|
||||
f"Use @executor(input=ConcreteType, output=ConcreteType) with concrete types."
|
||||
)
|
||||
|
||||
# Use explicit types if provided, otherwise fall back to introspection
|
||||
message_type = resolved_input_type if resolved_input_type is not None else introspected_message_type
|
||||
output_types: list[type[Any] | types.UnionType] = (
|
||||
normalize_type_to_list(resolved_output_type)
|
||||
if resolved_output_type is not None
|
||||
else list(inferred_output_types)
|
||||
)
|
||||
final_workflow_output_types: list[type[Any] | types.UnionType] = (
|
||||
normalize_type_to_list(resolved_workflow_output_type)
|
||||
if resolved_workflow_output_type is not None
|
||||
else list(inferred_workflow_output_types)
|
||||
)
|
||||
|
||||
# Validate that we have a message type - provides a clear error if type information is missing
|
||||
if message_type is None:
|
||||
raise ValueError(
|
||||
f"Function {func.__name__} requires either a message parameter type annotation "
|
||||
"or an explicit input_type parameter"
|
||||
)
|
||||
|
||||
# Check for unresolved TypeVar in introspected message type
|
||||
if contains_typevar(message_type):
|
||||
raise ValueError(
|
||||
f"Executor '{func.__name__}' has an unresolved TypeVar '{message_type}' "
|
||||
f"as its message type. "
|
||||
f"Use @executor(input=ConcreteType, output=ConcreteType) with concrete types."
|
||||
)
|
||||
|
||||
# Store the original function
|
||||
self._original_func = func
|
||||
# Determine if function has WorkflowContext parameter
|
||||
self._has_context = ctx_annotation is not None
|
||||
# Determine if the function is an async function
|
||||
self._is_async = inspect.iscoroutinefunction(func)
|
||||
|
||||
# Initialize parent WITHOUT calling _discover_handlers yet
|
||||
# We'll manually set up the attributes first
|
||||
executor_id = str(id or getattr(func, "__name__", "FunctionExecutor"))
|
||||
kwargs = {"type": "FunctionExecutor"}
|
||||
|
||||
super().__init__(id=executor_id, defer_discovery=True, **kwargs)
|
||||
|
||||
# Create a wrapper function that always accepts both message and context
|
||||
if self._has_context and self._is_async:
|
||||
# Async function with context - already has the right signature
|
||||
wrapped_func: Callable[[Any, WorkflowContext[Any]], Awaitable[Any]] = func # type: ignore
|
||||
elif self._has_context and not self._is_async:
|
||||
# Sync function with context - wrap to make async using thread pool
|
||||
async def wrapped_func(message: Any, ctx: WorkflowContext[Any]) -> Any:
|
||||
# Call the sync function with both parameters in a thread
|
||||
return await asyncio.to_thread(func, message, ctx)
|
||||
|
||||
elif not self._has_context and self._is_async:
|
||||
# Async function without context - wrap to ignore context
|
||||
async def wrapped_func(message: Any, ctx: WorkflowContext[Any]) -> Any:
|
||||
# Call the async function with just the message
|
||||
return await func(message)
|
||||
|
||||
else:
|
||||
# Sync function without context - wrap to make async and ignore context using thread pool
|
||||
async def wrapped_func(message: Any, ctx: WorkflowContext[Any]) -> Any:
|
||||
# Call the sync function with just the message in a thread
|
||||
return await asyncio.to_thread(func, message)
|
||||
|
||||
# Now register our instance handler
|
||||
self._register_instance_handler(
|
||||
name=func.__name__,
|
||||
func=wrapped_func,
|
||||
message_type=message_type,
|
||||
ctx_annotation=ctx_annotation,
|
||||
output_types=output_types,
|
||||
workflow_output_types=final_workflow_output_types,
|
||||
)
|
||||
|
||||
# Now we can safely call _discover_handlers (it won't find any class-level handlers)
|
||||
self._discover_handlers()
|
||||
self._discover_response_handlers()
|
||||
|
||||
if not self._handlers:
|
||||
raise ValueError(
|
||||
f"FunctionExecutor {self.__class__.__name__} failed to register handler for {func.__name__}"
|
||||
)
|
||||
|
||||
|
||||
# region Decorator
|
||||
|
||||
|
||||
@overload
|
||||
def executor(func: Callable[..., Any]) -> FunctionExecutor: ...
|
||||
|
||||
|
||||
@overload
|
||||
def executor(
|
||||
*,
|
||||
id: str | None = None,
|
||||
input: type | types.UnionType | str | None = None,
|
||||
output: type | types.UnionType | str | None = None,
|
||||
workflow_output: type | types.UnionType | str | None = None,
|
||||
) -> Callable[[Callable[..., Any]], FunctionExecutor]: ...
|
||||
|
||||
|
||||
def executor(
|
||||
func: Callable[..., Any] | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
input: type | types.UnionType | str | None = None,
|
||||
output: type | types.UnionType | str | None = None,
|
||||
workflow_output: type | types.UnionType | str | None = None,
|
||||
) -> Callable[[Callable[..., Any]], FunctionExecutor] | FunctionExecutor:
|
||||
"""Decorator that converts a standalone function into a FunctionExecutor instance.
|
||||
|
||||
The @executor decorator is designed for **standalone module-level functions only**.
|
||||
For class-based executors, use the Executor base class with @handler on instance methods.
|
||||
|
||||
Supports both synchronous and asynchronous functions. Synchronous functions
|
||||
are executed in a thread pool to avoid blocking the event loop.
|
||||
|
||||
Important:
|
||||
- Use @executor for standalone functions (module-level or local functions)
|
||||
- Do NOT use @executor with @staticmethod or @classmethod
|
||||
- For class-based executors, subclass Executor and use @handler on instance methods
|
||||
|
||||
Usage:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Standalone async function (RECOMMENDED):
|
||||
@executor(id="upper_case")
|
||||
async def to_upper(text: str, ctx: WorkflowContext[str]):
|
||||
await ctx.send_message(text.upper())
|
||||
|
||||
|
||||
# Standalone sync function (runs in thread pool):
|
||||
@executor
|
||||
def process_data(data: str):
|
||||
return data.upper()
|
||||
|
||||
|
||||
# Using explicit types (takes precedence over introspection):
|
||||
# Note: No type annotations on function parameters when using explicit types
|
||||
@executor(id="my_executor", input=str | int, output=bool)
|
||||
async def process(message, ctx):
|
||||
await ctx.send_message(True)
|
||||
|
||||
|
||||
# Using string forward references:
|
||||
@executor(input="MyCustomType | int", output="ResponseType")
|
||||
async def process(message, ctx): ...
|
||||
|
||||
|
||||
# Specifying both output types (send_message and yield_output):
|
||||
@executor(input=str, output=int, workflow_output=bool)
|
||||
async def process(message, ctx):
|
||||
await ctx.send_message(42) # int - matches output
|
||||
await ctx.yield_output(True) # bool - matches workflow_output
|
||||
|
||||
|
||||
# For class-based executors, use @handler instead:
|
||||
class MyExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="my_executor")
|
||||
|
||||
@handler
|
||||
async def process(self, data: str, ctx: WorkflowContext[str]):
|
||||
await ctx.send_message(data.upper())
|
||||
|
||||
Args:
|
||||
func: The function to decorate (when used without parentheses)
|
||||
id: Optional custom ID for the executor. If None, uses the function name.
|
||||
input: Optional explicit input type(s) for this executor. Supports union types
|
||||
(e.g., ``str | int``) and string forward references (e.g., ``"MyType | int"``).
|
||||
When provided, takes precedence over introspection from the function's message
|
||||
parameter annotation.
|
||||
output: Optional explicit output type(s) that can be sent via ``ctx.send_message()``.
|
||||
Supports union types (e.g., ``str | int``) and string forward references.
|
||||
When provided, takes precedence over introspection from the ``WorkflowContext``
|
||||
first generic parameter (OutT).
|
||||
workflow_output: Optional explicit output type(s) that can be yielded via
|
||||
``ctx.yield_output()``. Supports union types (e.g., ``str | int``) and string
|
||||
forward references. When provided, takes precedence over introspection from the
|
||||
``WorkflowContext`` second generic parameter (W_OutT).
|
||||
|
||||
Warning:
|
||||
When placing a custom ``@executor`` **between** two ``AgentExecutor`` nodes, be
|
||||
careful about the output type. If the custom executor receives an
|
||||
``AgentExecutorResponse`` but emits a plain ``str``, the downstream
|
||||
``AgentExecutor.from_str`` handler is invoked instead of ``from_response``.
|
||||
This resets the conversation context because only the new string is added to
|
||||
the cache and all prior messages from the upstream agent are lost.
|
||||
|
||||
To preserve the full conversation, use
|
||||
``AgentExecutorResponse.with_text(new_text)`` to create a new response that
|
||||
keeps the prior history, and set ``output=AgentExecutorResponse`` on the
|
||||
decorator.
|
||||
|
||||
Returns:
|
||||
A FunctionExecutor instance that can be wired into a Workflow.
|
||||
|
||||
Raises:
|
||||
ValueError: If used with @staticmethod or @classmethod (unsupported pattern)
|
||||
"""
|
||||
|
||||
def wrapper(func: Callable[..., Any]) -> FunctionExecutor:
|
||||
return FunctionExecutor(func, id=id, input=input, output=output, workflow_output=workflow_output)
|
||||
|
||||
# If func is provided, this means @executor was used without parentheses
|
||||
if func is not None:
|
||||
return wrapper(func)
|
||||
|
||||
# Otherwise, return the wrapper for @executor() or @executor(id="...")
|
||||
return wrapper
|
||||
|
||||
|
||||
# endregion: Decorator
|
||||
|
||||
# region Function Validation
|
||||
|
||||
|
||||
def _validate_function_signature(
|
||||
func: Callable[..., Any],
|
||||
*,
|
||||
skip_message_annotation: bool = False,
|
||||
) -> tuple[type | None, Any, list[type[Any] | types.UnionType], list[type[Any] | types.UnionType]]:
|
||||
"""Validate function signature for executor functions.
|
||||
|
||||
Args:
|
||||
func: The function to validate
|
||||
skip_message_annotation: If True, skip validation that message parameter has a type
|
||||
annotation. Used when input is explicitly provided to the @executor decorator.
|
||||
|
||||
Returns:
|
||||
Tuple of (message_type, ctx_annotation, output_types, workflow_output_types).
|
||||
message_type may be None if skip_message_annotation is True and no annotation exists.
|
||||
|
||||
Raises:
|
||||
ValueError: If the function signature is invalid
|
||||
"""
|
||||
signature = inspect.signature(func)
|
||||
params = list(signature.parameters.values())
|
||||
|
||||
expected_counts = (1, 2) # Function executor: (message) or (message, ctx)
|
||||
param_description = "(message: T) or (message: T, ctx: WorkflowContext[U])"
|
||||
if len(params) not in expected_counts:
|
||||
raise ValueError(
|
||||
f"Function instance {func.__name__} must have {param_description}. Got {len(params)} parameters."
|
||||
)
|
||||
|
||||
# Check message parameter has type annotation (unless skipped)
|
||||
message_param = params[0]
|
||||
if not skip_message_annotation and message_param.annotation == inspect.Parameter.empty:
|
||||
raise ValueError(f"Function instance {func.__name__} must have a type annotation for the message parameter")
|
||||
|
||||
# Resolve string annotations from `from __future__ import annotations`.
|
||||
# Fall back to raw annotations if resolution fails (e.g. unresolvable forward refs,
|
||||
# AttributeError, or RecursionError), so registration failures are easier to diagnose.
|
||||
try:
|
||||
type_hints = typing.get_type_hints(func)
|
||||
except (NameError, AttributeError, RecursionError):
|
||||
type_hints = {p.name: p.annotation for p in params}
|
||||
message_type = type_hints.get(message_param.name, message_param.annotation)
|
||||
if message_type == inspect.Parameter.empty:
|
||||
message_type = None
|
||||
|
||||
# Reject unresolved TypeVar in message annotation -- these are not supported
|
||||
# for workflow type validation and must be replaced with concrete types.
|
||||
if not skip_message_annotation and contains_typevar(message_type):
|
||||
raise ValueError(
|
||||
f"Function instance {func.__name__} has an unresolved TypeVar '{message_type}' as its message type "
|
||||
"annotation. Generic TypeVar annotations are not supported for workflow type validation. "
|
||||
"Use @executor(input=<concrete_type>, output=<concrete_type>) to specify explicit types."
|
||||
)
|
||||
|
||||
# Check if there's a context parameter
|
||||
if len(params) == 2:
|
||||
ctx_param = params[1]
|
||||
ctx_annotation = type_hints.get(ctx_param.name, ctx_param.annotation)
|
||||
output_types, workflow_output_types = validate_workflow_context_annotation(
|
||||
ctx_annotation, f"parameter '{ctx_param.name}'", "Function instance"
|
||||
)
|
||||
else:
|
||||
# No context parameter (only valid for function executors)
|
||||
output_types, workflow_output_types = [], []
|
||||
ctx_annotation = None
|
||||
|
||||
return message_type, ctx_annotation, output_types, workflow_output_types
|
||||
|
||||
|
||||
# endregion: Function Validation
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Shared helpers for normalizing workflow message inputs."""
|
||||
|
||||
from agent_framework import Content, Message
|
||||
from agent_framework._types import AgentRunInputs
|
||||
|
||||
|
||||
def normalize_messages_input(
|
||||
messages: AgentRunInputs | None = None,
|
||||
) -> list[Message]:
|
||||
"""Normalize heterogeneous message inputs to a list of Message objects.
|
||||
|
||||
Args:
|
||||
messages: String, Content, Message, or sequence of those values. None yields empty list.
|
||||
|
||||
Returns:
|
||||
List of Message instances suitable for workflow consumption.
|
||||
"""
|
||||
if messages is None:
|
||||
return []
|
||||
|
||||
if isinstance(messages, str):
|
||||
return [Message(role="user", contents=[messages])]
|
||||
|
||||
if isinstance(messages, Content):
|
||||
return [Message(role="user", contents=[messages])]
|
||||
|
||||
if isinstance(messages, Message):
|
||||
return [messages]
|
||||
|
||||
normalized: list[Message] = []
|
||||
for item in messages:
|
||||
if isinstance(item, (str, Content)):
|
||||
normalized.append(Message(role="user", contents=[item]))
|
||||
elif isinstance(item, Message):
|
||||
normalized.append(item)
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Messages sequence must contain only str, Content, or Message instances; found {type(item).__name__}."
|
||||
)
|
||||
return normalized
|
||||
@@ -0,0 +1,51 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import copy
|
||||
import sys
|
||||
from typing import Any, TypeVar, cast
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import Self # pragma: no cover
|
||||
|
||||
ModelT = TypeVar("ModelT", bound="DictConvertible")
|
||||
|
||||
|
||||
class DictConvertible:
|
||||
"""Mixin providing conversion helpers for plain Python models."""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: type[ModelT], data: dict[str, Any]) -> ModelT:
|
||||
return cls(**data)
|
||||
|
||||
def clone(self, *, deep: bool = True) -> Self:
|
||||
return copy.deepcopy(self) if deep else copy.copy(self)
|
||||
|
||||
def to_json(self) -> str:
|
||||
import json
|
||||
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls: type[ModelT], raw: str) -> ModelT:
|
||||
import json
|
||||
|
||||
data = json.loads(raw)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("JSON payload must decode to a mapping")
|
||||
return cls.from_dict(cast(dict[str, Any], data))
|
||||
|
||||
|
||||
def encode_value(value: Any) -> Any:
|
||||
"""Recursively encode values for JSON-friendly serialization."""
|
||||
if isinstance(value, DictConvertible):
|
||||
return value.to_dict()
|
||||
if isinstance(value, dict):
|
||||
return {k: encode_value(v) for k, v in value.items()} # type: ignore[misc]
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [encode_value(v) for v in value] # type: ignore[misc]
|
||||
return value
|
||||
@@ -0,0 +1,369 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
from builtins import type as builtin_type
|
||||
from collections.abc import Awaitable, Callable
|
||||
from types import UnionType
|
||||
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
||||
|
||||
from ._typing_utils import is_instance_of, is_type_compatible, normalize_type_to_list, resolve_type_annotation
|
||||
from ._workflow_context import WorkflowContext, validate_workflow_context_annotation
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import overload # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import overload # pragma: no cover
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._executor import Executor
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RequestInfoMixin:
|
||||
"""Mixin providing common functionality for request info handling."""
|
||||
|
||||
def is_request_supported(self, request_type: builtin_type[Any], response_type: builtin_type[Any]) -> bool:
|
||||
"""Check if the executor supports request of the given type and handling a response of the given type.
|
||||
|
||||
Args:
|
||||
request_type: The type of the request message
|
||||
response_type: The type of the expected response message
|
||||
Returns:
|
||||
True if a response handler is registered for the given request and response types, False otherwise
|
||||
"""
|
||||
if not hasattr(self, "_response_handlers"):
|
||||
return False
|
||||
|
||||
for request_type_key, response_type_key in self._response_handlers:
|
||||
if is_type_compatible(request_type, request_type_key) and is_type_compatible(
|
||||
response_type, response_type_key
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _find_response_handler(self, request: Any, response: Any) -> Callable[..., Awaitable[None]] | None:
|
||||
"""Find a registered response handler for the given request and response types.
|
||||
|
||||
Args:
|
||||
request: The original request
|
||||
response: The response message
|
||||
Returns:
|
||||
The response handler function with the request bound as the first argument, or None if not found
|
||||
"""
|
||||
if not hasattr(self, "_response_handlers"):
|
||||
return None
|
||||
|
||||
for (request_type, response_type), handler in self._response_handlers.items():
|
||||
if is_instance_of(request, request_type) and is_instance_of(response, response_type):
|
||||
return functools.partial(handler, request)
|
||||
|
||||
return None
|
||||
|
||||
def _discover_response_handlers(self) -> None:
|
||||
"""Discover and register response handlers defined in the class."""
|
||||
# Initialize handler storage if not already present
|
||||
if not hasattr(self, "_response_handlers"):
|
||||
self._response_handlers: dict[
|
||||
tuple[builtin_type[Any], builtin_type[Any]], # key
|
||||
Callable[[Any, Any, WorkflowContext[Any, Any]], Awaitable[None]], # value
|
||||
] = {}
|
||||
if not hasattr(self, "_response_handler_specs"):
|
||||
self._response_handler_specs: list[dict[str, Any]] = []
|
||||
|
||||
for attr_name in dir(self.__class__):
|
||||
try:
|
||||
attr = getattr(self.__class__, attr_name)
|
||||
if callable(attr) and hasattr(attr, "_response_handler_spec"):
|
||||
handler_spec = attr._response_handler_spec # type: ignore
|
||||
|
||||
request_type = handler_spec["request_type"]
|
||||
response_type = handler_spec["response_type"]
|
||||
|
||||
if self._response_handlers.get((request_type, response_type)):
|
||||
raise ValueError(
|
||||
f"Duplicate response handler for request type {request_type} "
|
||||
f"and response type {response_type} in {self.__class__.__name__}"
|
||||
)
|
||||
|
||||
self._response_handlers[request_type, response_type] = getattr(self, attr_name)
|
||||
self._response_handler_specs.append({**handler_spec, "source": "class_method"})
|
||||
except AttributeError:
|
||||
continue # Skip non-callable attributes or those without handler spec
|
||||
|
||||
# A request sent via `request_info` must be handled by a response handler inside the same executor.
|
||||
# It is safe to assume that an executor is request-response capable if it has at least one response
|
||||
# handler, and that the executor could send a request.
|
||||
self.is_request_response_capable = bool(self._response_handlers)
|
||||
|
||||
|
||||
ExecutorT = TypeVar("ExecutorT", bound="Executor")
|
||||
ContextT = TypeVar("ContextT", bound="WorkflowContext[Any, Any]")
|
||||
|
||||
# region Handler Decorator
|
||||
|
||||
|
||||
@overload
|
||||
def response_handler(
|
||||
func: Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]],
|
||||
) -> Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def response_handler(
|
||||
func: None = None,
|
||||
*,
|
||||
request: type | types.UnionType | str | None = None,
|
||||
response: type | types.UnionType | str | None = None,
|
||||
output: type | types.UnionType | str | None = None,
|
||||
workflow_output: type | types.UnionType | str | None = None,
|
||||
) -> Callable[
|
||||
[Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]]],
|
||||
Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]],
|
||||
]: ...
|
||||
|
||||
|
||||
def response_handler(
|
||||
func: Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]] | None = None,
|
||||
*,
|
||||
request: type | types.UnionType | str | None = None,
|
||||
response: type | types.UnionType | str | None = None,
|
||||
output: type | types.UnionType | str | None = None,
|
||||
workflow_output: type | types.UnionType | str | None = None,
|
||||
) -> (
|
||||
Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]]
|
||||
| Callable[
|
||||
[Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]]],
|
||||
Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]],
|
||||
]
|
||||
):
|
||||
"""Decorator to register a handler to handle responses for a request.
|
||||
|
||||
Type information can be provided in two mutually exclusive ways:
|
||||
|
||||
1. **Introspection** (default): Types are inferred from function signature annotations.
|
||||
Use type annotations on the original_request, response parameters and WorkflowContext
|
||||
generic parameters.
|
||||
|
||||
2. **Explicit parameters**: Types are specified via decorator parameters (request, response,
|
||||
output, workflow_output). When ANY explicit parameter is provided, ALL types must come
|
||||
from explicit parameters - introspection is completely disabled. The ``request`` and
|
||||
``response`` parameters are required; ``output`` and ``workflow_output`` are optional
|
||||
(default to no outputs).
|
||||
|
||||
Args:
|
||||
func: The function to decorate. Can be None when used with parameters.
|
||||
request: Explicit request type for this handler (the original_request parameter type).
|
||||
Required when using explicit mode. Supports union types and string forward references.
|
||||
response: Explicit response type for this handler (the response parameter type).
|
||||
Required when using explicit mode. Supports union types and string forward references.
|
||||
output: Explicit output type(s) that can be sent via ``ctx.send_message()``.
|
||||
Optional; defaults to no outputs if not specified.
|
||||
workflow_output: Explicit output type(s) that can be yielded via ``ctx.yield_output()``.
|
||||
Optional; defaults to no outputs if not specified.
|
||||
|
||||
Returns:
|
||||
The decorated function with handler metadata.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
# Mode 1: Introspection - types from annotations
|
||||
@handler
|
||||
async def run(self, message: int, context: WorkflowContext[str]) -> None:
|
||||
# Example of a handler that sends a request
|
||||
...
|
||||
# Send a request with a `CustomRequest` payload and expect a `str` response.
|
||||
await context.request_info(CustomRequest(...), str)
|
||||
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self,
|
||||
original_request: CustomRequest,
|
||||
response: str,
|
||||
context: WorkflowContext[str],
|
||||
) -> None:
|
||||
# Example of a response handler for the above request
|
||||
...
|
||||
|
||||
|
||||
# Mode 2: Explicit types - ALL types from decorator params
|
||||
# Note: No type annotations on function parameters when using explicit types
|
||||
@response_handler(request=CustomRequest, response=dict, output=int)
|
||||
async def handle_response(self, original_request, response, context):
|
||||
# Example of a response handler with explicit types
|
||||
await context.send_message(42)
|
||||
|
||||
|
||||
# Explicit with string forward references
|
||||
@response_handler(request="MyRequest", response="MyResponse")
|
||||
async def handle_response(self, original_request, response, context): ...
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
func: Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]],
|
||||
) -> Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]]:
|
||||
# Check if ANY explicit type parameter was provided - if so, use ONLY explicit params.
|
||||
# This is "all or nothing" - no mixing of explicit params with introspection.
|
||||
use_explicit_types = (
|
||||
request is not None or response is not None or output is not None or workflow_output is not None
|
||||
)
|
||||
|
||||
if use_explicit_types:
|
||||
# Resolve string forward references using the function's globals
|
||||
resolved_request_type = resolve_type_annotation(request, func.__globals__) if request is not None else None
|
||||
resolved_response_type = (
|
||||
resolve_type_annotation(response, func.__globals__) if response is not None else None
|
||||
)
|
||||
resolved_output_type = resolve_type_annotation(output, func.__globals__) if output is not None else None
|
||||
resolved_workflow_output_type = (
|
||||
resolve_type_annotation(workflow_output, func.__globals__) if workflow_output is not None else None
|
||||
)
|
||||
|
||||
# Validate signature structure but skip type extraction
|
||||
_validate_response_handler_signature(func, skip_annotations=True)
|
||||
|
||||
# Validate required parameters
|
||||
if resolved_request_type is None:
|
||||
raise ValueError(
|
||||
f"Response handler {func.__name__} with explicit type parameters must specify 'request' type"
|
||||
)
|
||||
if resolved_response_type is None:
|
||||
raise ValueError(
|
||||
f"Response handler {func.__name__} with explicit type parameters must specify 'response' type"
|
||||
)
|
||||
|
||||
final_request_type = resolved_request_type
|
||||
final_response_type = resolved_response_type
|
||||
final_output_types = normalize_type_to_list(resolved_output_type) if resolved_output_type else []
|
||||
final_workflow_output_types = (
|
||||
normalize_type_to_list(resolved_workflow_output_type) if resolved_workflow_output_type else []
|
||||
)
|
||||
# Get ctx_annotation for consistency
|
||||
ctx_annotation = (
|
||||
inspect.signature(func).parameters[list(inspect.signature(func).parameters.keys())[3]].annotation
|
||||
)
|
||||
if ctx_annotation == inspect.Parameter.empty:
|
||||
ctx_annotation = None
|
||||
else:
|
||||
# Use introspection - all types from annotations
|
||||
(
|
||||
inferred_request_type,
|
||||
inferred_response_type,
|
||||
ctx_annotation,
|
||||
final_output_types,
|
||||
final_workflow_output_types,
|
||||
) = _validate_response_handler_signature(func)
|
||||
# In introspection mode, validation ensures these are not None (raises ValueError if missing)
|
||||
final_request_type = cast(type, inferred_request_type)
|
||||
final_response_type = cast(type, inferred_response_type)
|
||||
|
||||
# Get signature for preservation
|
||||
sig = inspect.signature(func)
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(self: ExecutorT, original_request: Any, response_msg: Any, ctx: ContextT) -> Any:
|
||||
"""Wrapper function to call the handler."""
|
||||
return await func(self, original_request, response_msg, ctx)
|
||||
|
||||
# Preserve the original function signature for introspection during validation
|
||||
with contextlib.suppress(AttributeError, TypeError):
|
||||
wrapper.__signature__ = sig # type: ignore[attr-defined]
|
||||
|
||||
wrapper._response_handler_spec = { # type: ignore
|
||||
"name": func.__name__,
|
||||
"request_type": final_request_type,
|
||||
"response_type": final_response_type,
|
||||
# Keep output_types and workflow_output_types in spec for validators
|
||||
"output_types": final_output_types,
|
||||
"workflow_output_types": final_workflow_output_types,
|
||||
"ctx_annotation": ctx_annotation,
|
||||
}
|
||||
|
||||
return wrapper
|
||||
|
||||
# If func is provided, this means @response_handler was used without parentheses
|
||||
if func is not None:
|
||||
return decorator(func)
|
||||
|
||||
# Otherwise, return the wrapper for @response_handler(...) with parameters
|
||||
return decorator
|
||||
|
||||
|
||||
# endregion: Handler Decorator
|
||||
|
||||
# region Response Handler Validation
|
||||
|
||||
|
||||
def _validate_response_handler_signature(
|
||||
func: Callable[..., Any],
|
||||
*,
|
||||
skip_annotations: bool = False,
|
||||
) -> tuple[type | None, type | None, Any, list[type[Any] | UnionType], list[type[Any] | UnionType]]:
|
||||
"""Validate function signature for response handler functions.
|
||||
|
||||
Args:
|
||||
func: The function to validate
|
||||
skip_annotations: If True, skip validation that request/response parameters have type
|
||||
annotations. Used when types are explicitly provided to the @response_handler decorator.
|
||||
|
||||
Returns:
|
||||
Tuple of (request_type, response_type, ctx_annotation, output_types, workflow_output_types).
|
||||
request_type and response_type may be None if skip_annotations is True and no annotations exist.
|
||||
|
||||
Raises:
|
||||
ValueError: If the function signature is invalid
|
||||
"""
|
||||
signature = inspect.signature(func)
|
||||
params = list(signature.parameters.values())
|
||||
|
||||
# Note that the original_request parameter must be the second parameter
|
||||
# such that we can wrap the handler with functools.partial to bind it
|
||||
# to the original request when registering the handler, while maintaining
|
||||
# the order of parameters as if the response handler is a normal handler.
|
||||
expected_counts = 4 # self, original_request, message, ctx
|
||||
param_description = "(self, original_request, response, ctx)"
|
||||
if len(params) != expected_counts:
|
||||
raise ValueError(
|
||||
f"Response handler {func.__name__} must have {param_description}. Got {len(params)} parameters."
|
||||
)
|
||||
|
||||
# Check original_request parameter exists and has annotation (unless skipped)
|
||||
original_request_param = params[1]
|
||||
if not skip_annotations and original_request_param.annotation == inspect.Parameter.empty:
|
||||
raise ValueError(
|
||||
f"Response handler {func.__name__} must have a type annotation for the original_request parameter"
|
||||
)
|
||||
|
||||
# Check response parameter has type annotation (unless skipped)
|
||||
response_param = params[2]
|
||||
if not skip_annotations and response_param.annotation == inspect.Parameter.empty:
|
||||
raise ValueError(f"Response handler {func.__name__} must have a type annotation for the response parameter")
|
||||
|
||||
# Validate ctx parameter is WorkflowContext and extract type args (if annotated)
|
||||
ctx_param = params[3]
|
||||
if ctx_param.annotation != inspect.Parameter.empty:
|
||||
output_types, workflow_output_types = validate_workflow_context_annotation(
|
||||
ctx_param.annotation, f"parameter '{ctx_param.name}'", "Response handler"
|
||||
)
|
||||
else:
|
||||
output_types, workflow_output_types = [], []
|
||||
|
||||
request_type = (
|
||||
original_request_param.annotation if original_request_param.annotation != inspect.Parameter.empty else None
|
||||
)
|
||||
response_type = response_param.annotation if response_param.annotation != inspect.Parameter.empty else None
|
||||
ctx_annotation = ctx_param.annotation if ctx_param.annotation != inspect.Parameter.empty else None
|
||||
|
||||
return request_type, response_type, ctx_annotation, output_types, workflow_output_types
|
||||
|
||||
|
||||
# endregion: Response Handler Validation
|
||||
@@ -0,0 +1,433 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import warnings
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncGenerator, Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..exceptions import (
|
||||
WorkflowCheckpointException,
|
||||
WorkflowConvergenceException,
|
||||
)
|
||||
from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint
|
||||
from ._const import EXECUTOR_STATE_KEY
|
||||
from ._edge import EdgeGroup
|
||||
from ._edge_runner import EdgeRunner, create_edge_runner
|
||||
from ._events import WorkflowEvent
|
||||
from ._executor import Executor
|
||||
from ._runner_context import (
|
||||
RunnerContext,
|
||||
WorkflowMessage,
|
||||
)
|
||||
from ._state import State
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def warn_runner_deprecated() -> None:
|
||||
"""Emit a deprecation warning when ``Runner`` is accessed from the public API.
|
||||
|
||||
``Runner`` remains importable from ``agent_framework`` for backward
|
||||
compatibility, but it is intended for internal use only and will be removed
|
||||
from the public API in a future version.
|
||||
"""
|
||||
warnings.warn(
|
||||
"`Runner` is deprecated and will be removed from the public API in a future version. "
|
||||
"It is intended for internal use only.",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
|
||||
|
||||
class RunnerImpl:
|
||||
"""A class to run a workflow in Pregel supersteps."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
edge_groups: Sequence[EdgeGroup],
|
||||
executors: dict[str, Executor],
|
||||
state: State,
|
||||
ctx: RunnerContext,
|
||||
workflow_name: str,
|
||||
graph_signature_hash: str,
|
||||
max_iterations: int = 100,
|
||||
) -> None:
|
||||
"""Initialize the runner with edges, state, and context.
|
||||
|
||||
Args:
|
||||
edge_groups: The edge groups of the workflow.
|
||||
executors: Map of executor IDs to executor instances.
|
||||
state: The state for the workflow.
|
||||
ctx: The runner context for the workflow.
|
||||
workflow_name: The name of the workflow, used for checkpoint labeling.
|
||||
graph_signature_hash: A hash representing the workflow graph topology for checkpoint validation.
|
||||
max_iterations: The maximum number of iterations to run.
|
||||
"""
|
||||
# Workflow instance related attributes
|
||||
self._executors = executors
|
||||
self._edge_runners = [create_edge_runner(group, executors) for group in edge_groups]
|
||||
self._edge_runner_map = self._parse_edge_runners(self._edge_runners)
|
||||
self._ctx = ctx
|
||||
self._workflow_name = workflow_name
|
||||
self._graph_signature_hash = graph_signature_hash
|
||||
|
||||
# Runner state related attributes
|
||||
self._iteration = 0
|
||||
self._max_iterations = max_iterations
|
||||
self._state = state
|
||||
|
||||
# Checkpointing related attributes
|
||||
self._resumed_from_checkpoint = False
|
||||
self._previous_checkpoint_id: CheckpointID | None = None
|
||||
|
||||
@property
|
||||
def context(self) -> RunnerContext:
|
||||
"""Get the runner context for message, event, and checkpoint handling."""
|
||||
return self._ctx
|
||||
|
||||
@property
|
||||
def state(self) -> State:
|
||||
"""Get the shared state for the workflow."""
|
||||
return self._state
|
||||
|
||||
def reset_iteration_count(self) -> None:
|
||||
"""Reset the iteration count to zero.
|
||||
|
||||
This is useful when the workflow resumes from a new set of messages.
|
||||
|
||||
Note:
|
||||
When a workflow is resumed from a response (for a request_info_event)
|
||||
or a checkpoint, the iteration count is normally NOT reset.
|
||||
"""
|
||||
self._iteration = 0
|
||||
|
||||
async def run_until_convergence(self) -> AsyncGenerator[WorkflowEvent, None]:
|
||||
"""Run the workflow until no more messages are sent."""
|
||||
try:
|
||||
# Emit any events already produced prior to entering loop
|
||||
if await self._ctx.has_events():
|
||||
logger.info("Yielding pre-loop events")
|
||||
for event in await self._ctx.drain_events():
|
||||
yield event
|
||||
|
||||
# Create a checkpoint before a run starts. Checkpoints are usually considered to be created at the
|
||||
# end of an iteration, we can think of this checkpoint as being created at the end of "superstep 0"
|
||||
# which captures the states after which the start executor has run. Note that we execute the start
|
||||
# executor outside of the main iteration loop.
|
||||
if await self._ctx.has_messages() and self._iteration == 0 and not self._resumed_from_checkpoint:
|
||||
await self.create_checkpoint_if_enabled()
|
||||
|
||||
while self._iteration < self._max_iterations:
|
||||
logger.info(f"Starting superstep {self._iteration + 1}")
|
||||
yield WorkflowEvent.superstep_started(iteration=self._iteration + 1)
|
||||
|
||||
# Run iteration concurrently with live event streaming: we poll
|
||||
# for new events while the iteration coroutine progresses.
|
||||
iteration_task = asyncio.create_task(self._run_iteration())
|
||||
try:
|
||||
while not iteration_task.done():
|
||||
try:
|
||||
# Wait briefly for any new event; timeout allows progress checks
|
||||
event = await asyncio.wait_for(self._ctx.next_event(), timeout=0.05)
|
||||
yield event
|
||||
except asyncio.TimeoutError:
|
||||
# Periodically continue to let iteration advance
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
# Propagate cancellation to the iteration task to avoid orphaned work
|
||||
iteration_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await iteration_task
|
||||
raise
|
||||
|
||||
# Propagate errors from iteration, but first surface any pending events
|
||||
try:
|
||||
await iteration_task
|
||||
except Exception:
|
||||
# Make sure failure-related events (like ExecutorFailedEvent) are surfaced
|
||||
if await self._ctx.has_events():
|
||||
for event in await self._ctx.drain_events():
|
||||
yield event
|
||||
raise
|
||||
self._iteration += 1
|
||||
|
||||
# Drain any straggler events emitted at tail end
|
||||
if await self._ctx.has_events():
|
||||
for event in await self._ctx.drain_events():
|
||||
yield event
|
||||
|
||||
logger.info(f"Completed superstep {self._iteration}")
|
||||
|
||||
# Commit pending state changes at superstep boundary
|
||||
self._state.commit()
|
||||
|
||||
# Create checkpoint after each superstep iteration
|
||||
await self.create_checkpoint_if_enabled()
|
||||
|
||||
yield WorkflowEvent.superstep_completed(iteration=self._iteration)
|
||||
|
||||
# Check for convergence: no more messages to process
|
||||
if not await self._ctx.has_messages():
|
||||
break
|
||||
|
||||
logger.info(f"Workflow completed after {self._iteration} supersteps")
|
||||
|
||||
if self._iteration >= self._max_iterations and await self._ctx.has_messages():
|
||||
raise WorkflowConvergenceException(f"Runner did not converge after {self._max_iterations} iterations.")
|
||||
finally:
|
||||
# Reset the resume flag so stale resume state never leaks into the next run on this
|
||||
# instance - even if convergence raised before completing (e.g. an executor failure
|
||||
# during a resumed run).
|
||||
self._resumed_from_checkpoint = False
|
||||
|
||||
async def _run_iteration(self) -> None:
|
||||
"""Run a single iteration of the workflow.
|
||||
|
||||
Messages are delivered through edge runners. A source executor may have multiple outgoing edge
|
||||
runners. All edge runners run concurrently, but messages sent through the same edge runner are
|
||||
delivered in the order they were sent to preserve message ordering guarantees per edge.
|
||||
|
||||
What this means in practice:
|
||||
- A message from a source to multiple target is delivered to all targets concurrently.
|
||||
- Multiple messages from a source to the same target are delivered in the order they were sent.
|
||||
- Multiple messages from different sources to the same target can be delivered to the target one
|
||||
at a time in any order, because true parallelism is not realized in Python.
|
||||
- Multiple message from different sources to different targets are delivered concurrently to all
|
||||
targets, assuming each message is targeting a unique target, or it falls back to the previous
|
||||
rules if there are multiple messages targeting the same target.
|
||||
- Special case: if using a fan-out edge runner (or derived edge runner that replicates messages
|
||||
to multiple targets such as multi-selection or switch-case) to send messages to targets from
|
||||
a source by specifying the target, the messages will be delivered to the specified targets
|
||||
in the order they were sent. This is because all messages go through the same edge runner instance
|
||||
which preserves message order.
|
||||
"""
|
||||
|
||||
async def _deliver_messages(source_executor_id: str, source_messages: list[WorkflowMessage]) -> None:
|
||||
"""Outer loop to concurrently deliver messages from all sources to their targets."""
|
||||
|
||||
async def _deliver_message_inner(edge_runner: EdgeRunner, message: WorkflowMessage) -> bool:
|
||||
"""Inner loop to deliver a single message through an edge runner."""
|
||||
return await edge_runner.send_message(message, self._state, self._ctx)
|
||||
|
||||
async def _deliver_messages_for_edge_runner(edge_runner: EdgeRunner) -> None:
|
||||
# Preserve message order per edge runner (and therefore per routed target path)
|
||||
# while still allowing parallelism across different edge runners.
|
||||
for message in source_messages:
|
||||
await _deliver_message_inner(edge_runner, message)
|
||||
|
||||
# Route all messages through normal workflow edges
|
||||
associated_edge_runners = self._edge_runner_map.get(source_executor_id, [])
|
||||
if not associated_edge_runners:
|
||||
# This is expected for terminal nodes (e.g., EndWorkflow, last action in workflow)
|
||||
logger.debug(f"No outgoing edges found for executor {source_executor_id}; dropping messages.")
|
||||
return
|
||||
|
||||
tasks = [_deliver_messages_for_edge_runner(edge_runner) for edge_runner in associated_edge_runners]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
message_batches = await self._ctx.drain_messages()
|
||||
tasks = [
|
||||
_deliver_messages(source_executor_id, source_messages)
|
||||
for source_executor_id, source_messages in message_batches.items()
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
async def _prepare_checkpoint_state(self) -> None:
|
||||
"""Persist executor snapshots into committed shared state.
|
||||
|
||||
This is used by checkpoint capture paths that need a complete, restorable
|
||||
state payload without necessarily writing to a checkpoint storage backend.
|
||||
"""
|
||||
await self._save_executor_states()
|
||||
self._state.commit()
|
||||
|
||||
async def create_checkpoint_if_enabled(self) -> None:
|
||||
"""Create a checkpoint if checkpointing is enabled and attach a label and metadata."""
|
||||
if not self._ctx.has_checkpointing():
|
||||
return
|
||||
|
||||
try:
|
||||
# Save executor states into committed state before creating the checkpoint.
|
||||
await self._prepare_checkpoint_state()
|
||||
|
||||
checkpoint_id = await self._ctx.create_checkpoint(
|
||||
self._workflow_name,
|
||||
self._graph_signature_hash,
|
||||
self._state,
|
||||
self._previous_checkpoint_id,
|
||||
self._iteration,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Created checkpoint: %s with parent checkpoint at iteration %d: %s",
|
||||
checkpoint_id,
|
||||
self._iteration,
|
||||
self._previous_checkpoint_id,
|
||||
)
|
||||
self._previous_checkpoint_id = checkpoint_id
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to create checkpoint at iteration %d: %s. "
|
||||
"Note that this does not fail the workflow run. "
|
||||
"The next successfully-created checkpoint will be parented to the last successful checkpoint: %s",
|
||||
self._iteration,
|
||||
e,
|
||||
self._previous_checkpoint_id,
|
||||
)
|
||||
|
||||
async def restore_from_checkpoint(
|
||||
self,
|
||||
checkpoint_id: CheckpointID,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
) -> None:
|
||||
"""Restore the runner from a checkpoint.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The ID of the checkpoint to restore from
|
||||
checkpoint_storage: Optional storage to load checkpoints from when the
|
||||
runner context itself is not configured with checkpointing.
|
||||
|
||||
Returns:
|
||||
None on success.
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException on failure.
|
||||
"""
|
||||
try:
|
||||
# Load the checkpoint
|
||||
checkpoint: WorkflowCheckpoint | None
|
||||
if self._ctx.has_checkpointing():
|
||||
checkpoint = await self._ctx.load_checkpoint(checkpoint_id)
|
||||
elif checkpoint_storage is not None:
|
||||
checkpoint = await checkpoint_storage.load(checkpoint_id)
|
||||
else:
|
||||
raise WorkflowCheckpointException(
|
||||
"Cannot load checkpoint: no checkpointing configured in context or external storage provided."
|
||||
)
|
||||
|
||||
if not checkpoint:
|
||||
logger.error(f"Checkpoint {checkpoint_id} not found")
|
||||
raise WorkflowCheckpointException(f"Checkpoint {checkpoint_id} not found")
|
||||
|
||||
# Validate the loaded checkpoint against the workflow
|
||||
if self._graph_signature_hash != checkpoint.graph_signature_hash:
|
||||
raise WorkflowCheckpointException(
|
||||
"Workflow graph has changed since the checkpoint was created. "
|
||||
"Please rebuild the original workflow before resuming."
|
||||
)
|
||||
|
||||
# Restore state. Clear first so import_state (which merges) does
|
||||
# not leak stale keys from a prior run on this Workflow instance.
|
||||
# This matters more now that Workflow.run() no longer wipes state
|
||||
# per call - the only reset point for shared state on a reused
|
||||
# instance is at restore time.
|
||||
self._state.clear()
|
||||
self._state.import_state(checkpoint.state)
|
||||
# Restore executor states using the restored state
|
||||
await self._restore_executor_states()
|
||||
# Apply the checkpoint to the context
|
||||
await self._ctx.apply_checkpoint(checkpoint)
|
||||
# Mark the runner as resumed
|
||||
self._mark_resumed(checkpoint)
|
||||
|
||||
logger.info(f"Successfully restored workflow from checkpoint: {checkpoint_id}")
|
||||
except WorkflowCheckpointException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restore from checkpoint {checkpoint_id}: {e}")
|
||||
raise WorkflowCheckpointException(f"Failed to restore from checkpoint {checkpoint_id}") from e
|
||||
|
||||
async def _save_executor_states(self) -> None:
|
||||
"""Populate executor state by calling checkpoint hooks on executors."""
|
||||
for exec_id, executor in self._executors.items():
|
||||
# Try the updated behavior only if backward compatibility did not yield state
|
||||
try:
|
||||
state_dict = await executor.on_checkpoint_save()
|
||||
await self._set_executor_state(exec_id, state_dict)
|
||||
except WorkflowCheckpointException:
|
||||
raise
|
||||
except Exception as ex: # pragma: no cover
|
||||
raise WorkflowCheckpointException(f"Executor {exec_id} on_checkpoint_save failed") from ex
|
||||
|
||||
async def _restore_executor_states(self) -> None:
|
||||
"""Restore executor state by calling restore hooks on executors."""
|
||||
has_executor_states = self._state.has(EXECUTOR_STATE_KEY)
|
||||
if not has_executor_states:
|
||||
return
|
||||
|
||||
executor_states = self._state.get(EXECUTOR_STATE_KEY)
|
||||
if not isinstance(executor_states, dict):
|
||||
raise WorkflowCheckpointException("Executor states in shared state is not a dictionary. Unable to restore.")
|
||||
|
||||
for executor_id, state in executor_states.items(): # pyright: ignore[reportUnknownVariableType]
|
||||
if not isinstance(executor_id, str):
|
||||
raise WorkflowCheckpointException("Executor ID in executor states is not a string. Unable to restore.")
|
||||
if not isinstance(state, dict) or not all(isinstance(k, str) for k in state): # pyright: ignore[reportUnknownVariableType]
|
||||
raise WorkflowCheckpointException(
|
||||
f"Executor state for {executor_id} is not a dict[str, Any]. Unable to restore."
|
||||
)
|
||||
|
||||
executor = self._executors.get(executor_id)
|
||||
if not executor:
|
||||
raise WorkflowCheckpointException(f"Executor {executor_id} not found during state restoration.")
|
||||
|
||||
# Try the updated behavior only if backward compatibility did not restore
|
||||
try:
|
||||
await executor.on_checkpoint_restore(state) # pyright: ignore[reportUnknownArgumentType]
|
||||
except Exception as ex: # pragma: no cover - defensive
|
||||
raise WorkflowCheckpointException(f"Executor {executor_id} on_checkpoint_restore failed") from ex
|
||||
|
||||
def _parse_edge_runners(self, edge_runners: list[EdgeRunner]) -> dict[str, list[EdgeRunner]]:
|
||||
"""Parse the edge runners of the workflow into a mapping where each source executor ID maps to its edge runners.
|
||||
|
||||
Args:
|
||||
edge_runners: A list of edge runners in the workflow.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping each source executor ID to a list of edge runners.
|
||||
"""
|
||||
parsed: defaultdict[str, list[EdgeRunner]] = defaultdict(list)
|
||||
for runner in edge_runners:
|
||||
# Accessing protected attribute (_edge_group) intentionally for internal wiring.
|
||||
for source_executor_id in runner._edge_group.source_executor_ids: # type: ignore[attr-defined]
|
||||
parsed[source_executor_id].append(runner)
|
||||
|
||||
return parsed
|
||||
|
||||
def _mark_resumed(self, checkpoint: WorkflowCheckpoint) -> None:
|
||||
"""Mark the runner as having resumed from a checkpoint.
|
||||
|
||||
Optionally set the current iteration and max iterations.
|
||||
"""
|
||||
self._resumed_from_checkpoint = True
|
||||
self._iteration = checkpoint.iteration_count
|
||||
self._previous_checkpoint_id = checkpoint.checkpoint_id
|
||||
|
||||
async def _set_executor_state(self, executor_id: str, state: dict[str, Any]) -> None:
|
||||
"""Store executor state in state under a reserved key.
|
||||
|
||||
Executors call this with a JSON-serializable dict capturing the minimal
|
||||
state needed to resume. It replaces any previously stored state.
|
||||
"""
|
||||
existing_states = self._state.get(EXECUTOR_STATE_KEY, {})
|
||||
|
||||
if not isinstance(existing_states, dict):
|
||||
raise WorkflowCheckpointException("Existing executor states in state is not a dictionary.")
|
||||
|
||||
existing_states[executor_id] = state
|
||||
self._state.set(EXECUTOR_STATE_KEY, existing_states)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
Runner = RunnerImpl
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Lazily expose deprecated module-level public names."""
|
||||
if name == "Runner":
|
||||
warn_runner_deprecated()
|
||||
return RunnerImpl
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,518 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from copy import copy
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Literal, Protocol, TypeVar, runtime_checkable
|
||||
|
||||
from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint
|
||||
from ._const import INTERNAL_SOURCE_ID
|
||||
from ._events import WorkflowEvent
|
||||
from ._state import State
|
||||
from ._typing_utils import is_instance_of
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
YieldOutputEventType = Literal["output", "intermediate"]
|
||||
YieldOutputClassifier = Callable[[str], YieldOutputEventType | None]
|
||||
|
||||
|
||||
class MessageType(Enum):
|
||||
"""Enumeration of WorkflowMessage types in the workflow."""
|
||||
|
||||
STANDARD = "standard"
|
||||
"""A standard WorkflowMessage between executors."""
|
||||
|
||||
RESPONSE = "response"
|
||||
"""A response WorkflowMessage to a pending request."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowMessage:
|
||||
"""A class representing a WorkflowMessage in the workflow."""
|
||||
|
||||
data: Any
|
||||
source_id: str
|
||||
target_id: str | None = None
|
||||
type: MessageType = MessageType.STANDARD
|
||||
|
||||
# OpenTelemetry trace context fields for WorkflowMessage propagation
|
||||
# These are plural to support fan-in scenarios where multiple messages are aggregated
|
||||
trace_contexts: list[dict[str, str]] | None = None # W3C Trace Context headers from multiple sources
|
||||
source_span_ids: list[str] | None = None # Publishing span IDs for linking from multiple sources
|
||||
|
||||
# For response messages, the original request data
|
||||
original_request_info_event: WorkflowEvent[Any] | None = None
|
||||
|
||||
# Backward compatibility properties
|
||||
@property
|
||||
def trace_context(self) -> dict[str, str] | None:
|
||||
"""Get the first trace context for backward compatibility."""
|
||||
return self.trace_contexts[0] if self.trace_contexts else None
|
||||
|
||||
@property
|
||||
def source_span_id(self) -> str | None:
|
||||
"""Get the first source span ID for backward compatibility."""
|
||||
return self.source_span_ids[0] if self.source_span_ids else None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert the WorkflowMessage to a dictionary for serialization."""
|
||||
return {
|
||||
"data": self.data,
|
||||
"source_id": self.source_id,
|
||||
"target_id": self.target_id,
|
||||
"type": self.type.value,
|
||||
"trace_contexts": self.trace_contexts,
|
||||
"source_span_ids": self.source_span_ids,
|
||||
"original_request_info_event": self.original_request_info_event,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict[str, Any]) -> WorkflowMessage:
|
||||
"""Create a WorkflowMessage from a dictionary."""
|
||||
# Validation
|
||||
if "data" not in data:
|
||||
raise KeyError("Missing 'data' field in WorkflowMessage dictionary.")
|
||||
|
||||
if "source_id" not in data:
|
||||
raise KeyError("Missing 'source_id' field in WorkflowMessage dictionary.")
|
||||
|
||||
return WorkflowMessage(
|
||||
data=data["data"],
|
||||
source_id=data["source_id"],
|
||||
target_id=data.get("target_id"),
|
||||
type=MessageType(data.get("type", "standard")),
|
||||
trace_contexts=data.get("trace_contexts"),
|
||||
source_span_ids=data.get("source_span_ids"),
|
||||
original_request_info_event=data.get("original_request_info_event"),
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RunnerContext(Protocol):
|
||||
"""Protocol for the execution context used by the runner.
|
||||
|
||||
A single context that supports messaging, events, and optional checkpointing.
|
||||
If checkpoint storage is not configured, checkpoint methods may raise.
|
||||
"""
|
||||
|
||||
async def send_message(self, message: WorkflowMessage) -> None:
|
||||
"""Send a WorkflowMessage from the executor to the context.
|
||||
|
||||
Args:
|
||||
message: The WorkflowMessage to be sent.
|
||||
"""
|
||||
...
|
||||
|
||||
async def drain_messages(self) -> dict[str, list[WorkflowMessage]]:
|
||||
"""Drain all messages from the context.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping executor IDs to lists of messages.
|
||||
"""
|
||||
...
|
||||
|
||||
async def has_messages(self) -> bool:
|
||||
"""Check if there are any messages in the context.
|
||||
|
||||
Returns:
|
||||
True if there are messages, False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
async def add_event(self, event: WorkflowEvent) -> None:
|
||||
"""Add an event to the execution context.
|
||||
|
||||
Args:
|
||||
event: The event to be added.
|
||||
"""
|
||||
...
|
||||
|
||||
async def drain_events(self) -> list[WorkflowEvent]:
|
||||
"""Drain all events from the context.
|
||||
|
||||
Returns:
|
||||
A list of events that were added to the context.
|
||||
"""
|
||||
...
|
||||
|
||||
async def has_events(self) -> bool:
|
||||
"""Check if there are any events in the context.
|
||||
|
||||
Returns:
|
||||
True if there are events, False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
async def next_event(self) -> WorkflowEvent: # pragma: no cover - interface only
|
||||
"""Wait for and return the next event emitted by the workflow run."""
|
||||
...
|
||||
|
||||
# Checkpointing capability
|
||||
def has_checkpointing(self) -> bool:
|
||||
"""Check if the context supports checkpointing.
|
||||
|
||||
Returns:
|
||||
True if checkpointing is supported, False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
def set_runtime_checkpoint_storage(self, storage: CheckpointStorage) -> None:
|
||||
"""Set runtime checkpoint storage to override build-time configuration.
|
||||
|
||||
Args:
|
||||
storage: The checkpoint storage to use for this run.
|
||||
"""
|
||||
...
|
||||
|
||||
def clear_runtime_checkpoint_storage(self) -> None:
|
||||
"""Clear runtime checkpoint storage override."""
|
||||
...
|
||||
|
||||
def reset_for_new_run(self) -> None:
|
||||
"""Reset the context for a new workflow run."""
|
||||
...
|
||||
|
||||
def set_streaming(self, streaming: bool) -> None:
|
||||
"""Set whether agents should stream incremental updates.
|
||||
|
||||
Args:
|
||||
streaming: True for streaming mode (stream=True), False for non-streaming (stream=False).
|
||||
"""
|
||||
...
|
||||
|
||||
def is_streaming(self) -> bool:
|
||||
"""Check if the workflow is in streaming mode.
|
||||
|
||||
Returns:
|
||||
True if streaming mode is enabled, False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
async def create_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
graph_signature_hash: str,
|
||||
state: State,
|
||||
previous_checkpoint_id: CheckpointID | None,
|
||||
iteration_count: int,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> CheckpointID:
|
||||
"""Create a checkpoint of the current workflow state.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow for which the checkpoint is being created.
|
||||
graph_signature_hash: Hash of the workflow graph topology to
|
||||
validate checkpoint compatibility during restore.
|
||||
state: The state to include in the checkpoint.
|
||||
This is needed to capture the full state of the workflow.
|
||||
The state is not managed by the context itself.
|
||||
previous_checkpoint_id: The ID of the previous checkpoint, if any, to form a checkpoint chain.
|
||||
iteration_count: The current iteration count of the workflow.
|
||||
metadata: Optional metadata to associate with the checkpoint.
|
||||
|
||||
Returns:
|
||||
The ID of the created checkpoint.
|
||||
"""
|
||||
...
|
||||
|
||||
async def load_checkpoint(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint | None:
|
||||
"""Load a checkpoint without mutating the current context state.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The ID of the checkpoint to load.
|
||||
|
||||
Returns:
|
||||
The loaded checkpoint, or None if it does not exist.
|
||||
"""
|
||||
...
|
||||
|
||||
async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None:
|
||||
"""Apply a checkpoint to the current context, mutating its state.
|
||||
|
||||
Args:
|
||||
checkpoint: The checkpoint whose state is to be applied.
|
||||
"""
|
||||
...
|
||||
|
||||
async def add_request_info_event(self, event: WorkflowEvent[Any]) -> None:
|
||||
"""Add a request_info event to the context and track it for correlation.
|
||||
|
||||
Args:
|
||||
event: The WorkflowEvent with type='request_info' to be added.
|
||||
"""
|
||||
...
|
||||
|
||||
async def send_request_info_response(self, request_id: str, response: Any) -> None:
|
||||
"""Send a response correlated to a pending request.
|
||||
|
||||
Args:
|
||||
request_id: The ID of the original request.
|
||||
response: The response data to be sent.
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_pending_request_info_events(self) -> dict[str, WorkflowEvent[Any]]:
|
||||
"""Get the mapping of request IDs to their corresponding request_info events.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping request IDs to their corresponding WorkflowEvent (type='request_info').
|
||||
"""
|
||||
...
|
||||
|
||||
def set_yield_output_classifier(self, classifier: YieldOutputClassifier) -> None:
|
||||
"""Set the classifier used by WorkflowContext.yield_output()."""
|
||||
...
|
||||
|
||||
def classify_yielded_output(self, executor_id: str) -> YieldOutputEventType | None:
|
||||
"""Classify an executor's yield_output payload as output, intermediate, or hidden."""
|
||||
...
|
||||
|
||||
|
||||
class InProcRunnerContext:
|
||||
"""In-process execution context for local execution and optional checkpointing."""
|
||||
|
||||
def __init__(self, checkpoint_storage: CheckpointStorage | None = None):
|
||||
"""Initialize the in-process execution context.
|
||||
|
||||
Args:
|
||||
checkpoint_storage: Optional storage to enable checkpointing.
|
||||
"""
|
||||
self._messages: dict[str, list[WorkflowMessage]] = {}
|
||||
|
||||
# The queue must be created lazily under the running loop (see ``_get_event_queue``)
|
||||
# in order to support contexts that are constructed outside an event loop and reused
|
||||
# across multiple async loops (e.g., successive ``asyncio.run`` calls on the same workflow).
|
||||
# Binding a single queue to one loop would raise "bound to a different event loop" on reuse.
|
||||
self._event_queue: asyncio.Queue[WorkflowEvent] | None = None
|
||||
self._event_queue_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
# An additional storage for pending request info events
|
||||
self._pending_request_info_events: dict[str, WorkflowEvent[Any]] = {}
|
||||
|
||||
# Checkpointing configuration/state
|
||||
self._checkpoint_storage = checkpoint_storage
|
||||
self._runtime_checkpoint_storage: CheckpointStorage | None = None
|
||||
|
||||
# Streaming flag - set by workflow's run(..., stream=True) vs run(..., stream=False)
|
||||
self._streaming: bool = False
|
||||
self._yield_output_classifier: YieldOutputClassifier = lambda _executor_id: "output"
|
||||
|
||||
# region Messaging and Events
|
||||
async def send_message(self, message: WorkflowMessage) -> None:
|
||||
self._messages.setdefault(message.source_id, [])
|
||||
self._messages[message.source_id].append(message)
|
||||
|
||||
async def drain_messages(self) -> dict[str, list[WorkflowMessage]]:
|
||||
messages = copy(self._messages)
|
||||
self._messages.clear()
|
||||
return messages
|
||||
|
||||
async def has_messages(self) -> bool:
|
||||
return bool(self._messages)
|
||||
|
||||
def _get_event_queue(self) -> asyncio.Queue[WorkflowEvent]:
|
||||
"""Return the event queue bound to the running loop, re-creating it on loop change."""
|
||||
loop = asyncio.get_running_loop()
|
||||
if self._event_queue is None or self._event_queue_loop is not loop:
|
||||
self._event_queue = asyncio.Queue()
|
||||
self._event_queue_loop = loop
|
||||
return self._event_queue
|
||||
|
||||
async def add_event(self, event: WorkflowEvent) -> None:
|
||||
"""Add an event to the context immediately.
|
||||
|
||||
Events are enqueued so runners can stream them in real time instead of
|
||||
waiting for superstep boundaries.
|
||||
"""
|
||||
await self._get_event_queue().put(event)
|
||||
|
||||
async def drain_events(self) -> list[WorkflowEvent]:
|
||||
"""Drain all currently queued events without blocking for new ones."""
|
||||
events: list[WorkflowEvent] = []
|
||||
queue = self._get_event_queue()
|
||||
while True:
|
||||
try:
|
||||
events.append(queue.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
return events
|
||||
|
||||
async def has_events(self) -> bool:
|
||||
return not self._get_event_queue().empty()
|
||||
|
||||
async def next_event(self) -> WorkflowEvent:
|
||||
"""Wait for and return the next event.
|
||||
|
||||
Used by the runner to interleave event emission with ongoing iteration work.
|
||||
"""
|
||||
return await self._get_event_queue().get()
|
||||
|
||||
# endregion Messaging and Events
|
||||
|
||||
# region Checkpointing
|
||||
|
||||
def _get_effective_checkpoint_storage(self) -> CheckpointStorage | None:
|
||||
"""Get the effective checkpoint storage (runtime override or build-time)."""
|
||||
return self._runtime_checkpoint_storage or self._checkpoint_storage
|
||||
|
||||
def set_runtime_checkpoint_storage(self, storage: CheckpointStorage) -> None:
|
||||
"""Set runtime checkpoint storage to override build-time configuration.
|
||||
|
||||
Args:
|
||||
storage: The checkpoint storage to use for this run.
|
||||
"""
|
||||
self._runtime_checkpoint_storage = storage
|
||||
|
||||
def clear_runtime_checkpoint_storage(self) -> None:
|
||||
"""Clear runtime checkpoint storage override.
|
||||
|
||||
This is called automatically by workflow execution methods after a run completes,
|
||||
ensuring runtime storage doesn't leak across runs.
|
||||
"""
|
||||
self._runtime_checkpoint_storage = None
|
||||
|
||||
def has_checkpointing(self) -> bool:
|
||||
return self._get_effective_checkpoint_storage() is not None
|
||||
|
||||
async def create_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
graph_signature_hash: str,
|
||||
state: State,
|
||||
previous_checkpoint_id: CheckpointID | None,
|
||||
iteration_count: int,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> CheckpointID:
|
||||
storage = self._get_effective_checkpoint_storage()
|
||||
if not storage:
|
||||
raise ValueError("Checkpoint storage not configured")
|
||||
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
workflow_name=workflow_name,
|
||||
graph_signature_hash=graph_signature_hash,
|
||||
previous_checkpoint_id=previous_checkpoint_id,
|
||||
messages=dict(self._messages),
|
||||
state=state.export_state(),
|
||||
pending_request_info_events=dict(self._pending_request_info_events),
|
||||
iteration_count=iteration_count,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
checkpoint_id = await storage.save(checkpoint)
|
||||
logger.debug(f"Created checkpoint {checkpoint_id}")
|
||||
return checkpoint_id
|
||||
|
||||
async def load_checkpoint(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
|
||||
storage = self._get_effective_checkpoint_storage()
|
||||
if not storage:
|
||||
raise ValueError("Checkpoint storage not configured")
|
||||
return await storage.load(checkpoint_id)
|
||||
|
||||
def reset_for_new_run(self) -> None:
|
||||
"""Reset the context for a new workflow run.
|
||||
|
||||
This clears messages, events, and resets streaming flag.
|
||||
Runtime checkpoint storage is NOT cleared here as it's managed at the workflow level.
|
||||
"""
|
||||
self._messages.clear()
|
||||
# Drop any pending events. The queue and its loop marker are cleared so the queue
|
||||
# rebinds lazily under the running loop on next use.
|
||||
self._event_queue = None
|
||||
self._event_queue_loop = None
|
||||
self._streaming = False # Reset streaming flag
|
||||
|
||||
async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None:
|
||||
"""Apply a checkpoint to the current context, mutating its state."""
|
||||
# Restore messages
|
||||
self._messages.clear()
|
||||
messages_data = checkpoint.messages
|
||||
for source_id, message_list in messages_data.items():
|
||||
self._messages[source_id] = list(message_list)
|
||||
|
||||
# Restore pending request info events
|
||||
self._pending_request_info_events.clear()
|
||||
for request_id, request_info_event in checkpoint.pending_request_info_events.items():
|
||||
self._pending_request_info_events[request_id] = request_info_event
|
||||
await self.add_event(request_info_event)
|
||||
|
||||
# endregion Checkpointing
|
||||
|
||||
def set_streaming(self, streaming: bool) -> None:
|
||||
"""Set whether agents should stream incremental updates.
|
||||
|
||||
Args:
|
||||
streaming: True for streaming mode (run(stream=True)), False for non-streaming.
|
||||
"""
|
||||
self._streaming = streaming
|
||||
|
||||
def is_streaming(self) -> bool:
|
||||
"""Check if the workflow is in streaming mode.
|
||||
|
||||
Returns:
|
||||
True if streaming mode is enabled, False otherwise.
|
||||
"""
|
||||
return self._streaming
|
||||
|
||||
async def add_request_info_event(self, event: WorkflowEvent[Any]) -> None:
|
||||
"""Add a request_info event to the context and track it for correlation.
|
||||
|
||||
Args:
|
||||
event: The WorkflowEvent with type='request_info' to be added.
|
||||
"""
|
||||
if event.type != "request_info":
|
||||
raise ValueError("Event type must be 'request_info'")
|
||||
self._pending_request_info_events[event.request_id] = event
|
||||
await self.add_event(event)
|
||||
|
||||
async def send_request_info_response(self, request_id: str, response: Any) -> None:
|
||||
"""Send a response correlated to a pending request.
|
||||
|
||||
Args:
|
||||
request_id: The ID of the original request.
|
||||
response: The response data to be sent.
|
||||
"""
|
||||
event = self._pending_request_info_events.pop(request_id, None)
|
||||
if not event:
|
||||
raise ValueError(f"No pending request found for request_id: {request_id}")
|
||||
|
||||
# Validate response type if specified
|
||||
if event.response_type and not is_instance_of(response, event.response_type):
|
||||
raise TypeError(
|
||||
f"Response type mismatch for request_id {request_id}: "
|
||||
f"expected {event.response_type.__name__}, got {type(response).__name__}"
|
||||
)
|
||||
|
||||
source_executor_id = event.source_executor_id
|
||||
|
||||
# Create ResponseMessage instance
|
||||
response_msg = WorkflowMessage(
|
||||
data=response,
|
||||
source_id=INTERNAL_SOURCE_ID(source_executor_id),
|
||||
target_id=source_executor_id,
|
||||
type=MessageType.RESPONSE,
|
||||
original_request_info_event=event,
|
||||
)
|
||||
|
||||
await self.send_message(response_msg)
|
||||
|
||||
async def get_pending_request_info_events(self) -> dict[str, WorkflowEvent[Any]]:
|
||||
"""Get the mapping of request IDs to their corresponding request_info events.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping request IDs to their corresponding WorkflowEvent (type='request_info').
|
||||
"""
|
||||
return dict(self._pending_request_info_events)
|
||||
|
||||
def set_yield_output_classifier(self, classifier: YieldOutputClassifier) -> None:
|
||||
"""Set the classifier used by WorkflowContext.yield_output()."""
|
||||
self._yield_output_classifier = classifier
|
||||
|
||||
def classify_yielded_output(self, executor_id: str) -> YieldOutputEventType | None:
|
||||
"""Classify an executor's yield_output payload as output, intermediate, or hidden."""
|
||||
return self._yield_output_classifier(executor_id)
|
||||
@@ -0,0 +1,127 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class State:
|
||||
"""Manages shared state across executors within a workflow.
|
||||
|
||||
State provides access to workflow state data that is shared across executors
|
||||
during workflow execution. It implements superstep caching semantics where
|
||||
writes are staged in a pending buffer and only committed to the actual state
|
||||
at superstep boundaries.
|
||||
|
||||
Superstep Semantics:
|
||||
- `set()` writes to a pending buffer, not directly to committed state
|
||||
- `get()` checks pending buffer first, then committed state
|
||||
- `commit()` moves all pending changes to committed state (called by Runner at superstep boundary)
|
||||
- `discard()` clears pending changes without committing
|
||||
|
||||
Reserved Keys:
|
||||
Keys starting with underscore (_) are reserved for internal framework use.
|
||||
Do not use these in user code.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the state."""
|
||||
self._committed: dict[str, Any] = {}
|
||||
self._pending: dict[str, Any] = {}
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""Set a value in the pending state buffer.
|
||||
|
||||
The value will be visible to subsequent `get()` calls but won't be
|
||||
committed to the actual state until `commit()` is called.
|
||||
|
||||
Note:
|
||||
When multiple executors run concurrently within the same superstep,
|
||||
each executor's writes go to the same pending buffer. The last write
|
||||
for a given key wins when commit() is called. This is consistent with
|
||||
the .NET behavior and the superstep execution model where all executors
|
||||
in a superstep see the same committed state at the start.
|
||||
"""
|
||||
self._pending[key] = value
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a value from state, checking pending first then committed.
|
||||
|
||||
Args:
|
||||
key: The key to retrieve.
|
||||
default: Value to return if key is not found. Defaults to None.
|
||||
|
||||
Returns:
|
||||
The value if found, otherwise the default value.
|
||||
"""
|
||||
if key in self._pending:
|
||||
value = self._pending[key]
|
||||
if value is _DeleteSentinel:
|
||||
return default
|
||||
return value
|
||||
return self._committed.get(key, default)
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
"""Check if a key exists in pending or committed state."""
|
||||
if key in self._pending:
|
||||
return self._pending[key] is not _DeleteSentinel
|
||||
return key in self._committed
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""Mark a key for deletion.
|
||||
|
||||
If the key exists in committed state, a sentinel is stored in pending
|
||||
to indicate deletion at commit time. If it only exists in pending,
|
||||
it is removed from pending.
|
||||
"""
|
||||
if key not in self._pending and key not in self._committed:
|
||||
raise KeyError(f"Key '{key}' not found in state.")
|
||||
|
||||
if key in self._committed:
|
||||
# Mark for deletion from committed state at commit time
|
||||
self._pending[key] = _DeleteSentinel
|
||||
elif key in self._pending:
|
||||
# Only exists in pending, safe to just remove
|
||||
del self._pending[key]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear both committed and pending state."""
|
||||
self._committed.clear()
|
||||
self._pending.clear()
|
||||
|
||||
def commit(self) -> None:
|
||||
"""Commit pending changes to the committed state.
|
||||
|
||||
Called by the Runner at superstep boundaries after successful execution.
|
||||
"""
|
||||
for key, value in self._pending.items():
|
||||
if value is _DeleteSentinel:
|
||||
self._committed.pop(key, None)
|
||||
else:
|
||||
self._committed[key] = value
|
||||
self._pending.clear()
|
||||
|
||||
def discard(self) -> None:
|
||||
"""Discard all pending changes without committing."""
|
||||
self._pending.clear()
|
||||
|
||||
def export_state(self) -> dict[str, Any]:
|
||||
"""Export a serialized copy of the committed state.
|
||||
|
||||
Note: Does not include pending changes.
|
||||
"""
|
||||
return dict(self._committed)
|
||||
|
||||
def import_state(self, state: dict[str, Any]) -> None:
|
||||
"""Import state from a serialized dictionary.
|
||||
|
||||
Merges into committed state. Does not affect pending changes.
|
||||
"""
|
||||
self._committed.update(state)
|
||||
|
||||
|
||||
class _DeleteSentinelType:
|
||||
"""Sentinel type to mark keys for deletion in pending state."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
_DeleteSentinel = _DeleteSentinelType()
|
||||
@@ -0,0 +1,414 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import typing
|
||||
from types import UnionType
|
||||
from typing import Any, TypeGuard, Union, cast, get_args, get_origin
|
||||
|
||||
import typing_extensions
|
||||
|
||||
from .._agents import Agent
|
||||
|
||||
# Pre-compute the TypeVar types for runtime-safe detection.
|
||||
# isinstance(x, TypeVar) can fail if TypeVar is a factory/callable
|
||||
# on some Python versions, so we compare against the actual runtime type.
|
||||
_TYPEVAR_TYPES: tuple[type, ...] = (type(typing.TypeVar("_T")), type(typing_extensions.TypeVar("_T"))) # pyright: ignore[reportUnknownVariableType]
|
||||
|
||||
|
||||
def is_typevar(x: Any) -> bool:
|
||||
"""Check if x is an unresolved TypeVar instance (from typing or typing_extensions).
|
||||
|
||||
Args:
|
||||
x: The value to check.
|
||||
|
||||
Returns:
|
||||
True if x is a TypeVar instance, False otherwise.
|
||||
"""
|
||||
return isinstance(x, _TYPEVAR_TYPES)
|
||||
|
||||
|
||||
def contains_typevar(annotation: Any) -> bool:
|
||||
"""Check if an annotation contains an unresolved TypeVar at any nesting level.
|
||||
|
||||
Args:
|
||||
annotation: The annotation to inspect.
|
||||
|
||||
Returns:
|
||||
True if the annotation or any nested type argument is a TypeVar, False otherwise.
|
||||
"""
|
||||
if is_typevar(annotation):
|
||||
return True
|
||||
|
||||
return any(contains_typevar(arg) for arg in get_args(annotation))
|
||||
|
||||
|
||||
def is_chat_agent(agent: Any) -> TypeGuard[Agent]:
|
||||
"""Check if the given agent is a Agent.
|
||||
|
||||
Args:
|
||||
agent (Any): The agent to check.
|
||||
|
||||
Returns:
|
||||
TypeGuard[Agent]: True if the agent is a Agent, False otherwise.
|
||||
"""
|
||||
return isinstance(agent, Agent)
|
||||
|
||||
|
||||
def resolve_type_annotation(
|
||||
type_annotation: type[Any] | UnionType | str | None,
|
||||
globalns: dict[str, Any] | None = None,
|
||||
localns: dict[str, Any] | None = None,
|
||||
) -> type[Any] | UnionType | None:
|
||||
"""Resolve a type annotation, including string forward references.
|
||||
|
||||
Args:
|
||||
type_annotation: A type, union type, string forward reference, or None
|
||||
globalns: Global namespace for resolving forward references (typically func.__globals__)
|
||||
localns: Local namespace for resolving forward references
|
||||
|
||||
Returns:
|
||||
The resolved type annotation. For string annotations, evaluates them in the
|
||||
provided namespace. Returns None if type_annotation is None.
|
||||
|
||||
Raises:
|
||||
NameError: If a forward reference cannot be resolved in the provided namespaces
|
||||
SyntaxError: If a string annotation contains invalid Python syntax
|
||||
|
||||
Note:
|
||||
This function uses eval() to resolve string type annotations. This is the same
|
||||
approach used by Python's typing.get_type_hints() and typing.ForwardRef internally.
|
||||
Security is managed by: (1) strings come from decorator parameters in source code,
|
||||
not runtime user input, and (2) the eval namespace is restricted to the function's
|
||||
module globals plus Union/Optional from typing.
|
||||
|
||||
Examples:
|
||||
- resolve_type_annotation(str) -> str
|
||||
- resolve_type_annotation("str | int", {"str": str, "int": int}) -> str | int
|
||||
- resolve_type_annotation("MyClass", {"MyClass": MyClass}) -> MyClass
|
||||
"""
|
||||
if type_annotation is None:
|
||||
return None
|
||||
|
||||
if isinstance(type_annotation, str):
|
||||
# Resolve string forward reference by evaluating it.
|
||||
# This uses eval() which is the same approach as Python's typing.get_type_hints()
|
||||
# and typing.ForwardRef._evaluate(). The namespace is restricted to the function's
|
||||
# globals plus typing constructs, and input comes from developer source code.
|
||||
eval_globalns = globalns.copy() if globalns else {}
|
||||
eval_globalns.setdefault("Union", Union)
|
||||
eval_globalns.setdefault("Optional", __import__("typing").Optional)
|
||||
|
||||
try:
|
||||
return cast(
|
||||
"type[Any] | UnionType",
|
||||
eval(type_annotation, eval_globalns, localns), # noqa: S307 # nosec B307
|
||||
)
|
||||
except NameError as e:
|
||||
raise NameError(
|
||||
f"Could not resolve type annotation '{type_annotation}'. "
|
||||
f"Make sure the type is defined or imported. Original error: {e}"
|
||||
) from e
|
||||
|
||||
return type_annotation
|
||||
|
||||
|
||||
def normalize_type_to_list(type_annotation: type[Any] | UnionType | None) -> list[type[Any] | UnionType]:
|
||||
"""Normalize a type annotation (possibly a union) to a list of concrete types.
|
||||
|
||||
Args:
|
||||
type_annotation: A type, union type (using | or Union[]), or None
|
||||
|
||||
Returns:
|
||||
A list of types. For union types, returns all members.
|
||||
For None, returns an empty list.
|
||||
For Optional[T] (Union[T, None]), returns [T, type(None)].
|
||||
|
||||
Examples:
|
||||
- normalize_type_to_list(str) -> [str]
|
||||
- normalize_type_to_list(str | int) -> [str, int]
|
||||
- normalize_type_to_list(Union[str, int]) -> [str, int]
|
||||
- normalize_type_to_list(None) -> []
|
||||
"""
|
||||
if type_annotation is None:
|
||||
return []
|
||||
|
||||
origin = get_origin(type_annotation)
|
||||
|
||||
# Handle Union types (str | int or Union[str, int])
|
||||
if origin is Union or origin is UnionType:
|
||||
return list(get_args(type_annotation))
|
||||
|
||||
# Single type
|
||||
return [type_annotation]
|
||||
|
||||
|
||||
def is_instance_of(data: Any, target_type: type | UnionType | Any) -> bool:
|
||||
"""Check if the data is an instance of the target type.
|
||||
|
||||
Args:
|
||||
data (Any): The data to check.
|
||||
target_type (type): The type to check against.
|
||||
|
||||
Returns:
|
||||
bool: True if data is an instance of target_type, False otherwise.
|
||||
"""
|
||||
# Case 0: target_type is Any - always return True
|
||||
if target_type is Any:
|
||||
return True
|
||||
|
||||
origin = get_origin(target_type)
|
||||
args = get_args(target_type)
|
||||
|
||||
# Case 1: origin is None, meaning target_type is not a generic type
|
||||
if origin is None:
|
||||
return isinstance(data, target_type)
|
||||
|
||||
# Case 2: target_type is Optional[T] or Union[T1, T2, ...]
|
||||
# Optional[T] is really just as Union[T, None]
|
||||
if origin is UnionType:
|
||||
return any(is_instance_of(data, arg) for arg in args)
|
||||
|
||||
# Case 2b: Handle typing.Union (legacy Union syntax)
|
||||
if origin is Union:
|
||||
return any(is_instance_of(data, arg) for arg in args)
|
||||
|
||||
# Case 3: target_type is a generic type
|
||||
if origin in [list, set]:
|
||||
return isinstance(data, origin) and (
|
||||
not args or all(any(is_instance_of(item, arg) for arg in args) for item in data) # type: ignore[misc]
|
||||
)
|
||||
|
||||
# Case 4: target_type is a tuple
|
||||
if origin is tuple:
|
||||
if len(args) == 2 and args[1] is Ellipsis: # Tuple[T, ...] case
|
||||
element_type = args[0]
|
||||
return isinstance(data, tuple) and all(is_instance_of(item, element_type) for item in data) # type: ignore[misc]
|
||||
if len(args) == 1 and args[0] is Ellipsis: # Tuple[...] case
|
||||
return isinstance(data, tuple)
|
||||
if len(args) == 0:
|
||||
return isinstance(data, tuple)
|
||||
return (
|
||||
isinstance(data, tuple)
|
||||
and len(data) == len(args) # type: ignore
|
||||
and all(is_instance_of(item, arg) for item, arg in zip(data, args, strict=False)) # type: ignore
|
||||
)
|
||||
|
||||
# Case 5: target_type is a dict
|
||||
if origin is dict:
|
||||
return isinstance(data, dict) and (
|
||||
not args
|
||||
or all(
|
||||
is_instance_of(key, args[0]) and is_instance_of(value, args[1])
|
||||
for key, value in data.items() # type: ignore
|
||||
)
|
||||
)
|
||||
|
||||
# Case 6: Other custom generic classes - check origin type only
|
||||
# For generic classes, we check if data is an instance of the origin type
|
||||
# We don't validate the generic parameters at runtime since that's handled by type system
|
||||
if origin and hasattr(origin, "__name__"):
|
||||
return isinstance(data, origin)
|
||||
|
||||
# Fallback: if we reach here, we assume data is an instance of the target_type
|
||||
return isinstance(data, target_type)
|
||||
|
||||
|
||||
def try_coerce_to_type(data: Any, target_type: type | UnionType | Any) -> Any:
|
||||
"""Try to coerce data to the target type.
|
||||
|
||||
Attempts lightweight type coercion for common cases where raw data
|
||||
(e.g., from JSON deserialization) needs to be converted to the expected type.
|
||||
|
||||
Returns the coerced value if successful, or the original value if coercion
|
||||
is not needed or not possible.
|
||||
|
||||
Args:
|
||||
data: The data to coerce.
|
||||
target_type: The type to coerce to.
|
||||
|
||||
Returns:
|
||||
The coerced value, or the original value if coercion fails.
|
||||
"""
|
||||
original_data = data
|
||||
|
||||
# If already the right type, return as-is
|
||||
if is_instance_of(data, target_type):
|
||||
return data
|
||||
|
||||
# Can't coerce to non-concrete targets (Union, generic, etc.)
|
||||
if not isinstance(target_type, type):
|
||||
return original_data
|
||||
|
||||
target_cls: type[Any] = target_type
|
||||
|
||||
# int -> float (JSON integers for float fields)
|
||||
if isinstance(data, int) and target_cls is float:
|
||||
return float(data)
|
||||
|
||||
# dict -> dataclass or pydantic model
|
||||
if isinstance(data, dict):
|
||||
from dataclasses import is_dataclass
|
||||
|
||||
if is_dataclass(target_cls):
|
||||
try:
|
||||
return target_cls(**data)
|
||||
except (TypeError, ValueError):
|
||||
return original_data
|
||||
|
||||
model_validate = getattr(target_cls, "model_validate", None)
|
||||
if callable(model_validate):
|
||||
try:
|
||||
return model_validate(data)
|
||||
except Exception:
|
||||
return original_data
|
||||
|
||||
return original_data
|
||||
|
||||
|
||||
def serialize_type(t: type) -> str:
|
||||
"""Serialize a type to a string.
|
||||
|
||||
For example,
|
||||
|
||||
serialize_type(int) => "builtins.int"
|
||||
"""
|
||||
return f"{t.__module__}.{t.__qualname__}"
|
||||
|
||||
|
||||
def deserialize_type(serialized_type_string: str) -> type:
|
||||
"""Deserialize a serialized type string.
|
||||
|
||||
For example,
|
||||
|
||||
deserialize_type("builtins.int") => int
|
||||
"""
|
||||
import importlib
|
||||
|
||||
module_name, _, type_name = serialized_type_string.rpartition(".")
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
return cast(type, getattr(module, type_name))
|
||||
|
||||
|
||||
def is_type_compatible(source_type: type | UnionType | Any, target_type: type | UnionType | Any) -> bool:
|
||||
"""Check if source_type is compatible with target_type.
|
||||
|
||||
A type is compatible if values of source_type can be assigned to variables of target_type.
|
||||
For example:
|
||||
- list[Message] is compatible with list[str | Message]
|
||||
- str is compatible with str | int
|
||||
- int is compatible with Any
|
||||
|
||||
Args:
|
||||
source_type: The type being assigned from
|
||||
target_type: The type being assigned to
|
||||
|
||||
Returns:
|
||||
bool: True if source_type is compatible with target_type, False otherwise
|
||||
"""
|
||||
# Case 0: target_type is Any - always compatible
|
||||
if target_type is Any:
|
||||
return True
|
||||
|
||||
# Case 1: exact type match
|
||||
if source_type == target_type:
|
||||
return True
|
||||
|
||||
source_origin = get_origin(source_type)
|
||||
source_args = get_args(source_type)
|
||||
target_origin = get_origin(target_type)
|
||||
target_args = get_args(target_type)
|
||||
|
||||
# Case 2: target is Union/Optional - source is compatible if it matches any target member
|
||||
if target_origin is Union or target_origin is UnionType:
|
||||
# Special case: if source is also a Union, check that each source member
|
||||
# is compatible with at least one target member
|
||||
if source_origin is Union or source_origin is UnionType:
|
||||
return all(
|
||||
any(is_type_compatible(source_arg, target_arg) for target_arg in target_args)
|
||||
for source_arg in source_args
|
||||
)
|
||||
# If source is not a Union, check if it's compatible with any target member
|
||||
return any(is_type_compatible(source_type, arg) for arg in target_args)
|
||||
|
||||
# Case 3: source is Union (and target is not Union) - each source member must be compatible with target
|
||||
if source_origin is Union or source_origin is UnionType:
|
||||
return all(is_type_compatible(arg, target_type) for arg in source_args)
|
||||
|
||||
# Case 4: both are non-generic types
|
||||
if source_origin is None and target_origin is None:
|
||||
# Only call issubclass if both are actual types, not UnionType or Any
|
||||
if isinstance(source_type, type) and isinstance(target_type, type):
|
||||
try:
|
||||
return issubclass(source_type, target_type)
|
||||
except TypeError:
|
||||
# Handle cases where issubclass doesn't work (e.g., with special forms)
|
||||
return False
|
||||
return source_type == target_type
|
||||
|
||||
# Case 5: different container types are not compatible
|
||||
if source_origin != target_origin:
|
||||
return False
|
||||
|
||||
# Case 6: same container type - check generic arguments
|
||||
if source_origin in [list, set]:
|
||||
if not source_args and not target_args:
|
||||
return True # Both are untyped
|
||||
if not source_args or not target_args:
|
||||
return True # One is untyped - assume compatible
|
||||
# For collections, source element type must be compatible with target element type
|
||||
return is_type_compatible(source_args[0], target_args[0])
|
||||
|
||||
# Case 7: tuple compatibility
|
||||
if source_origin is tuple:
|
||||
if not source_args and not target_args:
|
||||
return True # Both are untyped tuples
|
||||
if not source_args or not target_args:
|
||||
return True # One is untyped - assume compatible
|
||||
|
||||
# Handle Tuple[T, ...] (variable length)
|
||||
if len(source_args) == 2 and source_args[1] is Ellipsis:
|
||||
if len(target_args) == 2 and target_args[1] is Ellipsis:
|
||||
return is_type_compatible(source_args[0], target_args[0])
|
||||
return False # Variable length can't be assigned to fixed length
|
||||
|
||||
if len(target_args) == 2 and target_args[1] is Ellipsis:
|
||||
# Fixed length can be assigned to variable length if element types are compatible
|
||||
return all(is_type_compatible(source_arg, target_args[0]) for source_arg in source_args)
|
||||
|
||||
# Fixed length tuples must have same length and compatible element types
|
||||
if len(source_args) != len(target_args):
|
||||
return False
|
||||
return all(is_type_compatible(s_arg, t_arg) for s_arg, t_arg in zip(source_args, target_args, strict=False))
|
||||
|
||||
# Case 8: dict compatibility
|
||||
if source_origin is dict:
|
||||
if not source_args and not target_args:
|
||||
return True # Both are untyped dicts
|
||||
if not source_args or not target_args:
|
||||
return True # One is untyped - assume compatible
|
||||
if len(source_args) != 2 or len(target_args) != 2:
|
||||
return False # Malformed dict types
|
||||
# Both key and value types must be compatible
|
||||
return is_type_compatible(source_args[0], target_args[0]) and is_type_compatible(source_args[1], target_args[1])
|
||||
|
||||
# Case 9: custom generic classes - check if origins are the same and args are compatible
|
||||
if source_origin and target_origin and source_origin == target_origin:
|
||||
if not source_args and not target_args:
|
||||
return True # Both are untyped generics
|
||||
if not source_args or not target_args:
|
||||
return True # One is untyped - assume compatible
|
||||
if len(source_args) != len(target_args):
|
||||
return False # Different number of type parameters
|
||||
return all(is_type_compatible(s_arg, t_arg) for s_arg, t_arg in zip(source_args, target_args, strict=False))
|
||||
|
||||
# Case 10: fallback - check if source is subclass of target (for non-generic types)
|
||||
if source_origin is None and target_origin is None:
|
||||
try:
|
||||
# Only call issubclass if both are actual types, not UnionType or Any
|
||||
if isinstance(source_type, type) and isinstance(target_type, type):
|
||||
return issubclass(source_type, target_type)
|
||||
return source_type == target_type
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,462 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
import types
|
||||
from collections import defaultdict
|
||||
from collections.abc import Sequence
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from ..exceptions import WorkflowException
|
||||
from ._edge import Edge, EdgeGroup, FanInEdgeGroup, InternalEdgeGroup
|
||||
from ._executor import Executor
|
||||
from ._typing_utils import is_type_compatible
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# region Enums and Base Classes
|
||||
class ValidationTypeEnum(Enum):
|
||||
"""Enumeration of workflow validation types."""
|
||||
|
||||
EDGE_DUPLICATION = "EDGE_DUPLICATION"
|
||||
EXECUTOR_DUPLICATION = "EXECUTOR_DUPLICATION"
|
||||
TYPE_COMPATIBILITY = "TYPE_COMPATIBILITY"
|
||||
GRAPH_CONNECTIVITY = "GRAPH_CONNECTIVITY"
|
||||
HANDLER_OUTPUT_ANNOTATION = "HANDLER_OUTPUT_ANNOTATION"
|
||||
OUTPUT_VALIDATION = "OUTPUT_VALIDATION"
|
||||
|
||||
|
||||
class WorkflowValidationError(WorkflowException):
|
||||
"""Base exception for workflow validation errors."""
|
||||
|
||||
def __init__(self, message: str, validation_type: ValidationTypeEnum):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.validation_type = validation_type
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"[{self.validation_type.value}] {self.message}"
|
||||
|
||||
|
||||
class EdgeDuplicationError(WorkflowValidationError):
|
||||
"""Exception raised when duplicate edges are detected in the workflow."""
|
||||
|
||||
def __init__(self, edge_id: str):
|
||||
super().__init__(
|
||||
message=f"Duplicate edge detected: {edge_id}. Each edge in the workflow must be unique.",
|
||||
validation_type=ValidationTypeEnum.EDGE_DUPLICATION,
|
||||
)
|
||||
self.edge_id = edge_id
|
||||
|
||||
|
||||
class TypeCompatibilityError(WorkflowValidationError):
|
||||
"""Exception raised when type incompatibility is detected between connected executors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_executor_id: str,
|
||||
target_executor_id: str,
|
||||
source_types: list[type[Any] | types.UnionType],
|
||||
target_types: list[type[Any] | types.UnionType],
|
||||
):
|
||||
# Use a placeholder for incompatible types - will be computed in WorkflowGraphValidator
|
||||
super().__init__(
|
||||
message=f"Type incompatibility between executors '{source_executor_id}' -> '{target_executor_id}'. "
|
||||
f"Source executor outputs types {[str(t) for t in source_types]} but target executor "
|
||||
f"can only handle types {[str(t) for t in target_types]}.",
|
||||
validation_type=ValidationTypeEnum.TYPE_COMPATIBILITY,
|
||||
)
|
||||
self.source_executor_id = source_executor_id
|
||||
self.target_executor_id = target_executor_id
|
||||
self.source_types = source_types
|
||||
self.target_types = target_types
|
||||
|
||||
|
||||
class GraphConnectivityError(WorkflowValidationError):
|
||||
"""Exception raised when graph connectivity issues are detected."""
|
||||
|
||||
def __init__(self, message: str):
|
||||
super().__init__(message, validation_type=ValidationTypeEnum.GRAPH_CONNECTIVITY)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Workflow Graph Validator
|
||||
class WorkflowGraphValidator:
|
||||
"""Validator for workflow graphs.
|
||||
|
||||
This validator performs multiple validation checks:
|
||||
1. Edge duplication validation
|
||||
2. Type compatibility validation between connected executors
|
||||
3. Graph connectivity validation
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._edges: list[Edge] = []
|
||||
self._executors: dict[str, Executor] = {}
|
||||
|
||||
# region Core Validation Methods
|
||||
def validate_workflow(
|
||||
self,
|
||||
edge_groups: Sequence[EdgeGroup],
|
||||
executors: dict[str, Executor],
|
||||
start_executor: Executor,
|
||||
output_executors: list[str],
|
||||
intermediate_executors: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Validate the entire workflow graph.
|
||||
|
||||
Args:
|
||||
edge_groups: list of edge groups in the workflow
|
||||
executors: Map of executor IDs to executor instances
|
||||
start_executor: The starting executor
|
||||
output_executors: List of output executor IDs
|
||||
intermediate_executors: List of intermediate executor IDs
|
||||
|
||||
Raises:
|
||||
WorkflowValidationError: If any validation fails
|
||||
"""
|
||||
self._executors = executors
|
||||
self._edges = [edge for group in edge_groups for edge in group.edges]
|
||||
self._edge_groups = edge_groups
|
||||
|
||||
# If only the start executor exists, add it to the executor map
|
||||
# Handle the special case where the workflow consists of only a single executor and no edges.
|
||||
# In this scenario, the executor map will be empty because there are no edge groups to reference executors.
|
||||
# Adding the start executor to the map ensures that single-executor workflows (without any edges) are supported,
|
||||
# allowing validation and execution to proceed for workflows that do not require inter-executor communication.
|
||||
if not self._executors:
|
||||
self._executors[start_executor.id] = start_executor
|
||||
|
||||
# Validate that start_executor exists in the graph
|
||||
# It should because we check for it in the WorkflowBuilder
|
||||
# but we do it here for completeness.
|
||||
if start_executor.id not in self._executors:
|
||||
raise GraphConnectivityError(f"Start executor '{start_executor.id}' is not present in the workflow graph")
|
||||
|
||||
# Additional presence verification:
|
||||
# A start executor that is only injected via the builder (present in the executors map)
|
||||
# but not referenced by any edge while other executors ARE referenced indicates a
|
||||
# configuration error: the chosen start node is effectively disconnected / unknown to the
|
||||
# defined graph topology. For single-node workflows (no edges) we allow the start executor
|
||||
# to stand alone (handled above when we inject it into the map). We perform this refined
|
||||
# check only when there is at least one edge group defined.
|
||||
if self._edges: # Only evaluate when the workflow defines edges
|
||||
edge_executor_ids: set[str] = set()
|
||||
for e in self._edges:
|
||||
edge_executor_ids.add(e.source_id)
|
||||
edge_executor_ids.add(e.target_id)
|
||||
if start_executor.id not in edge_executor_ids:
|
||||
raise GraphConnectivityError(
|
||||
f"Start executor '{start_executor.id}' is not present in the workflow graph"
|
||||
)
|
||||
|
||||
# Run all checks
|
||||
self._validate_edge_duplication()
|
||||
self._validate_handler_output_annotations()
|
||||
self._validate_type_compatibility()
|
||||
self._validate_graph_connectivity(start_executor.id)
|
||||
self._validate_self_loops()
|
||||
self._validate_dead_ends()
|
||||
self._output_validation(output_executors, intermediate_executors or [])
|
||||
|
||||
def _validate_handler_output_annotations(self) -> None:
|
||||
"""Validate that each handler's ctx parameter is annotated with WorkflowContext[T].
|
||||
|
||||
Note: This validation is now primarily handled at handler registration time
|
||||
via the unified validation functions in _workflow_context.py when the @handler
|
||||
decorator is applied. This method is kept minimal for any edge cases.
|
||||
"""
|
||||
# The comprehensive validation is already done during handler registration:
|
||||
# 1. @handler and @response_handler decorators already have validation logic
|
||||
# 2. FunctionExecutor constructor also has validation logic
|
||||
# 3. Both use validate_workflow_context_annotation() for WorkflowContext validation
|
||||
#
|
||||
# All executors in the workflow must have gone through one of these paths,
|
||||
# so redundant validation here is unnecessary and has been removed.
|
||||
pass
|
||||
|
||||
# endregion
|
||||
|
||||
# region Edge and Type Validation
|
||||
def _validate_edge_duplication(self) -> None:
|
||||
"""Validate that there are no duplicate edges in the workflow.
|
||||
|
||||
Raises:
|
||||
EdgeDuplicationError: If duplicate edges are found
|
||||
"""
|
||||
seen_edge_ids: set[str] = set()
|
||||
|
||||
for edge in self._edges:
|
||||
edge_id = edge.id
|
||||
if edge_id in seen_edge_ids:
|
||||
raise EdgeDuplicationError(edge_id)
|
||||
seen_edge_ids.add(edge_id)
|
||||
|
||||
def _validate_type_compatibility(self) -> None:
|
||||
"""Validate type compatibility between connected executors.
|
||||
|
||||
This checks that the output types of source executors are compatible
|
||||
with the input types expected by target executors.
|
||||
|
||||
Raises:
|
||||
TypeCompatibilityError: If type incompatibility is detected
|
||||
"""
|
||||
for edge_group in self._edge_groups:
|
||||
for edge in edge_group.edges:
|
||||
self._validate_edge_type_compatibility(edge, edge_group)
|
||||
|
||||
def _validate_edge_type_compatibility(self, edge: Edge, edge_group: EdgeGroup) -> None:
|
||||
"""Validate type compatibility for a specific edge.
|
||||
|
||||
This checks that the output types of the source executor are compatible
|
||||
with the input types expected by the target executor.
|
||||
|
||||
Args:
|
||||
edge: The edge to validate
|
||||
edge_group: The edge group containing this edge
|
||||
|
||||
Raises:
|
||||
TypeCompatibilityError: If type incompatibility is detected
|
||||
"""
|
||||
if isinstance(edge_group, InternalEdgeGroup):
|
||||
# Skip type compatibility validation for internal edges
|
||||
return
|
||||
|
||||
source_executor = self._executors[edge.source_id]
|
||||
target_executor = self._executors[edge.target_id]
|
||||
|
||||
# Get output types from source executor
|
||||
source_output_types = list(source_executor.output_types)
|
||||
|
||||
# Get input types from target executor
|
||||
target_input_types = target_executor.input_types
|
||||
|
||||
# If either executor has no type information, log warning and skip validation
|
||||
# This allows for dynamic typing scenarios but warns about reduced validation coverage
|
||||
if not source_output_types or not target_input_types:
|
||||
if not source_output_types:
|
||||
logger.warning(
|
||||
f"Executor '{source_executor.id}' has no output type annotations. "
|
||||
f"Type compatibility validation will be skipped for edges from this executor. "
|
||||
f"Consider adding WorkflowContext[T] generics in handlers for better validation."
|
||||
)
|
||||
if not target_input_types:
|
||||
logger.warning(
|
||||
f"Executor '{target_executor.id}' has no input type annotations. "
|
||||
f"Type compatibility validation will be skipped for edges to this executor. "
|
||||
f"Consider adding type annotations to message handler parameters for better validation."
|
||||
)
|
||||
return
|
||||
|
||||
# Check if any source output type is compatible with any target input type
|
||||
compatible = False
|
||||
compatible_pairs: list[tuple[type[Any] | types.UnionType, type[Any] | types.UnionType]] = []
|
||||
|
||||
for source_type in source_output_types:
|
||||
for target_type in target_input_types:
|
||||
if isinstance(edge_group, FanInEdgeGroup):
|
||||
# If the edge is part of an edge group, the target expects a list of data types
|
||||
if is_type_compatible(list[source_type], target_type):
|
||||
compatible = True
|
||||
compatible_pairs.append((list[source_type], target_type))
|
||||
else:
|
||||
if is_type_compatible(source_type, target_type):
|
||||
compatible = True
|
||||
compatible_pairs.append((source_type, target_type))
|
||||
|
||||
# Log successful type compatibility for debugging
|
||||
if compatible:
|
||||
logger.debug(
|
||||
f"Type compatibility validated for edge '{source_executor.id}' -> '{target_executor.id}'. "
|
||||
f"Compatible type pairs: {[(str(s), str(t)) for s, t in compatible_pairs]}"
|
||||
)
|
||||
|
||||
if not compatible:
|
||||
# Enhanced error with more detailed information
|
||||
raise TypeCompatibilityError(
|
||||
source_executor.id,
|
||||
target_executor.id,
|
||||
source_output_types,
|
||||
target_input_types,
|
||||
)
|
||||
|
||||
# endregion
|
||||
|
||||
# region Graph Connectivity Validation
|
||||
def _validate_graph_connectivity(self, start_executor_id: str) -> None:
|
||||
"""Validate graph connectivity and detect potential issues.
|
||||
|
||||
This performs several checks:
|
||||
- Detects unreachable executors from the start node
|
||||
- Detects isolated executors (no incoming or outgoing edges)
|
||||
- Warns about potential infinite loops
|
||||
|
||||
Args:
|
||||
start_executor_id: The ID of the starting executor
|
||||
|
||||
Raises:
|
||||
GraphConnectivityError: If connectivity issues are detected
|
||||
"""
|
||||
# Build adjacency list for the graph
|
||||
graph: dict[str, list[str]] = defaultdict(list)
|
||||
all_executors = set(self._executors.keys())
|
||||
|
||||
for edge in self._edges:
|
||||
graph[edge.source_id].append(edge.target_id)
|
||||
|
||||
# Find reachable nodes from start
|
||||
reachable = self._find_reachable_nodes(graph, start_executor_id)
|
||||
|
||||
# Check for unreachable executors
|
||||
unreachable = all_executors - reachable
|
||||
if unreachable:
|
||||
raise GraphConnectivityError(
|
||||
f"The following executors are unreachable from the start executor '{start_executor_id}': "
|
||||
f"{sorted(unreachable)}. This may indicate a disconnected workflow graph."
|
||||
)
|
||||
|
||||
# Check for isolated executors (no edges)
|
||||
isolated_executors: list[str] = []
|
||||
for executor_id in all_executors:
|
||||
has_incoming = any(edge.target_id == executor_id for edge in self._edges)
|
||||
has_outgoing = any(edge.source_id == executor_id for edge in self._edges)
|
||||
|
||||
if not has_incoming and not has_outgoing and executor_id != start_executor_id:
|
||||
isolated_executors.append(executor_id)
|
||||
|
||||
if isolated_executors:
|
||||
raise GraphConnectivityError(
|
||||
f"The following executors are isolated (no incoming or outgoing edges): "
|
||||
f"{sorted(isolated_executors)}. Isolated executors will never be executed."
|
||||
)
|
||||
|
||||
def _find_reachable_nodes(self, graph: dict[str, list[str]], start: str) -> set[str]:
|
||||
"""Find all nodes reachable from the start node using DFS.
|
||||
|
||||
Args:
|
||||
graph: Adjacency list representation of the graph
|
||||
start: Starting node ID
|
||||
|
||||
Returns:
|
||||
Set of reachable node IDs
|
||||
"""
|
||||
visited: set[str] = set()
|
||||
stack = [start]
|
||||
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if node not in visited:
|
||||
visited.add(node)
|
||||
stack.extend(graph[node])
|
||||
|
||||
return visited
|
||||
|
||||
# endregion
|
||||
|
||||
# region Output Validation
|
||||
|
||||
def _output_validation(self, output_executors: list[str], intermediate_executors: list[str]) -> None:
|
||||
"""Validate that designated executors exist and have workflow output annotations."""
|
||||
overlap = sorted(set(output_executors).intersection(intermediate_executors))
|
||||
if overlap:
|
||||
raise WorkflowValidationError(
|
||||
f"Executors cannot be both output and intermediate designated: {overlap}",
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
)
|
||||
|
||||
for output_id in output_executors:
|
||||
if output_id not in self._executors:
|
||||
raise WorkflowValidationError(
|
||||
f"Output executor '{output_id}' is not present in the workflow graph",
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
)
|
||||
|
||||
output_executor = self._executors[output_id]
|
||||
if not output_executor.workflow_output_types:
|
||||
raise WorkflowValidationError(
|
||||
f"Output executor '{output_id}' must have output type annotations defined.",
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
)
|
||||
|
||||
for intermediate_id in intermediate_executors:
|
||||
if intermediate_id not in self._executors:
|
||||
raise WorkflowValidationError(
|
||||
f"Intermediate executor '{intermediate_id}' is not present in the workflow graph",
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
)
|
||||
|
||||
intermediate_executor = self._executors[intermediate_id]
|
||||
if not intermediate_executor.workflow_output_types:
|
||||
raise WorkflowValidationError(
|
||||
f"Intermediate executor '{intermediate_id}' must have output type annotations defined.",
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
)
|
||||
|
||||
# endregion
|
||||
|
||||
# region Additional Validation Scenarios
|
||||
def _validate_self_loops(self) -> None:
|
||||
"""Detect and log self-loops (edges from executor to itself).
|
||||
|
||||
Self-loops might indicate recursive processing which could be intentional
|
||||
but should be highlighted for review.
|
||||
"""
|
||||
self_loops = [edge for edge in self._edges if edge.source_id == edge.target_id]
|
||||
|
||||
for edge in self_loops:
|
||||
logger.warning(
|
||||
f"Self-loop detected: Executor '{edge.source_id}' connects to itself. "
|
||||
f"This may cause infinite recursion if not properly handled with conditions."
|
||||
)
|
||||
|
||||
def _validate_dead_ends(self) -> None:
|
||||
"""Identify executors that have no outgoing edges (potential dead ends).
|
||||
|
||||
These might be intentional final nodes or could indicate missing connections.
|
||||
"""
|
||||
executors_with_outgoing = {edge.source_id for edge in self._edges}
|
||||
all_executor_ids = set(self._executors.keys())
|
||||
dead_ends = all_executor_ids - executors_with_outgoing
|
||||
|
||||
if dead_ends:
|
||||
logger.info(
|
||||
f"Dead-end executors detected (no outgoing edges): {sorted(dead_ends)}. "
|
||||
f"Verify these are intended as final nodes in the workflow."
|
||||
)
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
def validate_workflow_graph(
|
||||
edge_groups: Sequence[EdgeGroup],
|
||||
executors: dict[str, Executor],
|
||||
start_executor: Executor,
|
||||
output_executors: list[str],
|
||||
intermediate_executors: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Convenience function to validate a workflow graph.
|
||||
|
||||
Args:
|
||||
edge_groups: list of edge groups in the workflow
|
||||
executors: Map of executor IDs to executor instances
|
||||
start_executor: The starting executor instance
|
||||
output_executors: List of output executor IDs
|
||||
intermediate_executors: List of intermediate executor IDs
|
||||
|
||||
Raises:
|
||||
WorkflowValidationError: If any validation fails
|
||||
"""
|
||||
validator = WorkflowGraphValidator()
|
||||
validator.validate_workflow(
|
||||
edge_groups,
|
||||
executors,
|
||||
start_executor,
|
||||
output_executors,
|
||||
intermediate_executors,
|
||||
)
|
||||
@@ -0,0 +1,442 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from ._edge import FanInEdgeGroup, InternalEdgeGroup
|
||||
from ._workflow import Workflow
|
||||
|
||||
# Import of WorkflowExecutor is performed lazily inside methods to avoid cycles
|
||||
|
||||
"""Workflow visualization module using graphviz and Mermaid."""
|
||||
|
||||
|
||||
class WorkflowViz:
|
||||
"""A class for visualizing workflows using graphviz and Mermaid."""
|
||||
|
||||
def __init__(self, workflow: Workflow):
|
||||
"""Initialize the WorkflowViz with a workflow.
|
||||
|
||||
Args:
|
||||
workflow: The workflow to visualize.
|
||||
"""
|
||||
self._workflow = workflow
|
||||
|
||||
def to_digraph(self, include_internal_executors: bool = False) -> str:
|
||||
"""Export the workflow as a DOT format digraph string.
|
||||
|
||||
Args:
|
||||
include_internal_executors (bool): Whether to include internal executors in the visualization.
|
||||
Default is False.
|
||||
|
||||
Returns:
|
||||
A string representation of the workflow in DOT format.
|
||||
"""
|
||||
lines = ["digraph Workflow {"]
|
||||
lines.append(" rankdir=TD;") # Top to bottom layout
|
||||
lines.append(" node [shape=box, style=filled, fillcolor=lightblue];")
|
||||
lines.append(" edge [color=black, arrowhead=vee];")
|
||||
lines.append("")
|
||||
|
||||
# Emit the top-level workflow nodes/edges
|
||||
self._emit_workflow_digraph(
|
||||
self._workflow,
|
||||
lines,
|
||||
indent=" ",
|
||||
include_internal_executors=include_internal_executors,
|
||||
)
|
||||
|
||||
# Emit sub-workflows hosted by WorkflowExecutor as nested clusters
|
||||
self._emit_sub_workflows_digraph(
|
||||
self._workflow,
|
||||
lines,
|
||||
indent=" ",
|
||||
include_internal_executors=include_internal_executors,
|
||||
)
|
||||
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def export(
|
||||
self,
|
||||
format: Literal["svg", "png", "pdf", "dot"] = "svg",
|
||||
filename: str | None = None,
|
||||
include_internal_executors: bool = False,
|
||||
) -> str:
|
||||
"""Export the workflow visualization to a file or return the file path.
|
||||
|
||||
Args:
|
||||
format: The output format. Supported formats: 'svg', 'png', 'pdf', 'dot'.
|
||||
filename: Optional filename to save the output. If None, creates a temporary file.
|
||||
include_internal_executors (bool): Whether to include internal executors in the visualization.
|
||||
Default is False.
|
||||
|
||||
Returns:
|
||||
The path to the saved file.
|
||||
|
||||
Raises:
|
||||
ImportError: If graphviz is not installed.
|
||||
ValueError: If an unsupported format is specified.
|
||||
"""
|
||||
# Validate format first
|
||||
if format not in ["svg", "png", "pdf", "dot"]:
|
||||
raise ValueError(f"Unsupported format: {format}. Supported formats: svg, png, pdf, dot")
|
||||
|
||||
if format == "dot":
|
||||
content = self.to_digraph(include_internal_executors=include_internal_executors)
|
||||
if filename:
|
||||
with open(filename, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return filename
|
||||
# Create temporary file for dot format
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".dot", delete=False, encoding="utf-8") as temp_file:
|
||||
temp_file.write(content)
|
||||
return temp_file.name
|
||||
|
||||
try:
|
||||
import graphviz
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"viz extra is required for export. Install it with: pip install graphviz>=0.20.0 "
|
||||
"The version needs to be at least 0.20.0. "
|
||||
"You also need to install graphviz separately. E.g., sudo apt-get install graphviz on Debian/Ubuntu "
|
||||
"or brew install graphviz on macOS. See https://graphviz.org/download/ for details."
|
||||
) from e
|
||||
|
||||
# Create a temporary graphviz Source object
|
||||
dot_content = self.to_digraph(include_internal_executors=include_internal_executors)
|
||||
source = graphviz.Source(dot_content)
|
||||
|
||||
try:
|
||||
if filename:
|
||||
# Save to specified file
|
||||
output_path = Path(filename)
|
||||
if output_path.suffix and output_path.suffix[1:] != format:
|
||||
raise ValueError(f"File extension {output_path.suffix} doesn't match format {format}")
|
||||
|
||||
# Remove extension if present since graphviz.render() adds it
|
||||
base_name = str(output_path.with_suffix(""))
|
||||
source.render(base_name, format=format, cleanup=True) # type: ignore
|
||||
|
||||
# Return the actual filename with extension
|
||||
return f"{base_name}.{format}"
|
||||
# Create temporary file
|
||||
with tempfile.NamedTemporaryFile(suffix=f".{format}", delete=False) as temp_file:
|
||||
temp_path = Path(temp_file.name)
|
||||
base_name = str(temp_path.with_suffix(""))
|
||||
|
||||
source.render(base_name, format=format, cleanup=True) # type: ignore
|
||||
return f"{base_name}.{format}"
|
||||
except graphviz.backend.execute.ExecutableNotFound as e:
|
||||
raise ImportError(
|
||||
"The graphviz executables are not found. The graphviz Python package is installed, but the "
|
||||
"graphviz executables (dot, neato, etc.) are not available on your system's PATH. "
|
||||
"Install graphviz executables: sudo apt-get install graphviz on Debian/Ubuntu, "
|
||||
"brew install graphviz on macOS, or download from https://graphviz.org/download/ for other platforms."
|
||||
) from e
|
||||
|
||||
def save_svg(self, filename: str, include_internal_executors: bool = False) -> str:
|
||||
"""Convenience method to save as SVG.
|
||||
|
||||
Args:
|
||||
filename: The filename to save the SVG file.
|
||||
include_internal_executors (bool): Whether to include internal executors in the visualization.
|
||||
Default is False.
|
||||
|
||||
Returns:
|
||||
The path to the saved SVG file.
|
||||
"""
|
||||
return self.export(format="svg", filename=filename, include_internal_executors=include_internal_executors)
|
||||
|
||||
def save_png(self, filename: str, include_internal_executors: bool = False) -> str:
|
||||
"""Convenience method to save as PNG.
|
||||
|
||||
Args:
|
||||
filename: The filename to save the PNG file.
|
||||
include_internal_executors (bool): Whether to include internal executors in the visualization.
|
||||
Default is False.
|
||||
|
||||
Returns:
|
||||
The path to the saved PNG file.
|
||||
"""
|
||||
return self.export(format="png", filename=filename, include_internal_executors=include_internal_executors)
|
||||
|
||||
def save_pdf(self, filename: str, include_internal_executors: bool = False) -> str:
|
||||
"""Convenience method to save as PDF.
|
||||
|
||||
Args:
|
||||
filename: The filename to save the PDF file.
|
||||
include_internal_executors (bool): Whether to include internal executors in the visualization.
|
||||
Default is False.
|
||||
|
||||
Returns:
|
||||
The path to the saved PDF file.
|
||||
"""
|
||||
return self.export(format="pdf", filename=filename, include_internal_executors=include_internal_executors)
|
||||
|
||||
def to_mermaid(self, include_internal_executors: bool = False) -> str:
|
||||
"""Export the workflow as a Mermaid flowchart string.
|
||||
|
||||
Args:
|
||||
include_internal_executors (bool): Whether to include internal executors in the visualization.
|
||||
Default is False.
|
||||
|
||||
Returns:
|
||||
A string representation of the workflow in Mermaid flowchart syntax.
|
||||
"""
|
||||
lines: list[str] = ["flowchart TD"]
|
||||
|
||||
# Emit top-level workflow
|
||||
self._emit_workflow_mermaid(
|
||||
self._workflow,
|
||||
lines,
|
||||
indent=" ",
|
||||
include_internal_executors=include_internal_executors,
|
||||
)
|
||||
|
||||
# Emit sub-workflows as Mermaid subgraphs
|
||||
self._emit_sub_workflows_mermaid(
|
||||
self._workflow,
|
||||
lines,
|
||||
indent=" ",
|
||||
include_internal_executors=include_internal_executors,
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# region Private helpers
|
||||
|
||||
def _fan_in_digest(self, target: str, sources: list[str]) -> str:
|
||||
sources_sorted = sorted(sources)
|
||||
return hashlib.sha256((target + "|" + "|".join(sources_sorted)).encode("utf-8")).hexdigest()[:8]
|
||||
|
||||
def _compute_fan_in_descriptors(self, workflow: Workflow | None = None) -> list[tuple[str, list[str], str]]:
|
||||
"""Return list of (node_id, sources, target) for fan-in groups.
|
||||
|
||||
node_id is DOT-oriented: fan_in::target::digest
|
||||
"""
|
||||
result: list[tuple[str, list[str], str]] = []
|
||||
workflow = workflow or self._workflow
|
||||
for group in workflow.edge_groups:
|
||||
if isinstance(group, FanInEdgeGroup):
|
||||
target = group.target_executor_ids[0]
|
||||
sources = list(group.source_executor_ids)
|
||||
digest = self._fan_in_digest(target, sources)
|
||||
node_id = f"fan_in::{target}::{digest}"
|
||||
result.append((node_id, sorted(sources), target))
|
||||
return result
|
||||
|
||||
def _compute_normal_edges(
|
||||
self,
|
||||
workflow: Workflow | None = None,
|
||||
include_internal_executors: bool = False,
|
||||
) -> list[tuple[str, str, bool]]:
|
||||
"""Return list of (source_id, target_id, is_conditional) for non-fan-in groups."""
|
||||
edges: list[tuple[str, str, bool]] = []
|
||||
workflow = workflow or self._workflow
|
||||
for group in workflow.edge_groups:
|
||||
if isinstance(group, FanInEdgeGroup):
|
||||
continue
|
||||
if isinstance(group, InternalEdgeGroup) and not include_internal_executors:
|
||||
continue
|
||||
for edge in group.edges:
|
||||
is_cond = getattr(edge, "_condition", None) is not None
|
||||
edges.append((edge.source_id, edge.target_id, is_cond))
|
||||
return edges
|
||||
|
||||
# endregion
|
||||
|
||||
# region Internal emitters (DOT)
|
||||
|
||||
def _emit_workflow_digraph(
|
||||
self,
|
||||
workflow: Workflow,
|
||||
lines: list[str],
|
||||
indent: str,
|
||||
ns: str | None = None,
|
||||
include_internal_executors: bool = False,
|
||||
) -> None:
|
||||
"""Emit DOT nodes/edges for the given workflow.
|
||||
|
||||
If ns (namespace) is provided, node ids are prefixed with f"{ns}/" for uniqueness,
|
||||
but labels remain the original executor ids.
|
||||
"""
|
||||
|
||||
def map_id(x: str) -> str:
|
||||
return f"{ns}/{x}" if ns else x
|
||||
|
||||
# Nodes
|
||||
start_executor_id = workflow.start_executor_id
|
||||
lines.append(
|
||||
f'{indent}"{map_id(start_executor_id)}" [fillcolor=lightgreen, label="{start_executor_id}\\n(Start)"];'
|
||||
)
|
||||
for executor_id in workflow.executors:
|
||||
if executor_id != start_executor_id:
|
||||
lines.append(f'{indent}"{map_id(executor_id)}" [label="{executor_id}"];')
|
||||
|
||||
# Fan-in nodes
|
||||
fan_in_nodes = self._compute_fan_in_descriptors(workflow)
|
||||
if fan_in_nodes:
|
||||
lines.append("")
|
||||
for node_id, _, _ in fan_in_nodes:
|
||||
lines.append(f'{indent}"{map_id(node_id)}" [shape=ellipse, fillcolor=lightgoldenrod, label="fan-in"];')
|
||||
|
||||
# Fan-in edges
|
||||
for node_id, sources, target in fan_in_nodes:
|
||||
for src in sources:
|
||||
lines.append(f'{indent}"{map_id(src)}" -> "{map_id(node_id)}";')
|
||||
lines.append(f'{indent}"{map_id(node_id)}" -> "{map_id(target)}";')
|
||||
|
||||
# Normal edges
|
||||
for src, tgt, is_cond in self._compute_normal_edges(
|
||||
workflow, include_internal_executors=include_internal_executors
|
||||
):
|
||||
edge_attr = ' [style=dashed, label="conditional"]' if is_cond else ""
|
||||
lines.append(f'{indent}"{map_id(src)}" -> "{map_id(tgt)}"{edge_attr};')
|
||||
|
||||
def _emit_sub_workflows_digraph(
|
||||
self,
|
||||
workflow: Workflow,
|
||||
lines: list[str],
|
||||
indent: str,
|
||||
include_internal_executors: bool = False,
|
||||
) -> None:
|
||||
"""Emit DOT subgraphs for any WorkflowExecutor instances found in the workflow."""
|
||||
# Lazy import to avoid any potential import cycles
|
||||
try:
|
||||
from ._workflow_executor import WorkflowExecutor
|
||||
except ImportError: # pragma: no cover - best-effort; if unavailable, skip subgraphs
|
||||
return
|
||||
|
||||
for exec_id, exec_obj in workflow.executors.items():
|
||||
if isinstance(exec_obj, WorkflowExecutor) and hasattr(exec_obj, "workflow") and exec_obj.workflow:
|
||||
subgraph_id = f"cluster_{uuid.uuid5(uuid.NAMESPACE_OID, exec_id).hex[:8]}"
|
||||
lines.append(f"{indent}subgraph {subgraph_id} {{")
|
||||
lines.append(f'{indent} label="sub-workflow: {exec_id}";')
|
||||
lines.append(f"{indent} style=dashed;")
|
||||
|
||||
# Emit the nested workflow inside this cluster using a namespace
|
||||
ns = exec_id
|
||||
self._emit_workflow_digraph(
|
||||
exec_obj.workflow,
|
||||
lines,
|
||||
indent=f"{indent} ",
|
||||
ns=ns,
|
||||
include_internal_executors=include_internal_executors,
|
||||
)
|
||||
|
||||
# Recurse into deeper nested sub-workflows
|
||||
self._emit_sub_workflows_digraph(
|
||||
exec_obj.workflow,
|
||||
lines,
|
||||
indent=f"{indent} ",
|
||||
include_internal_executors=include_internal_executors,
|
||||
)
|
||||
|
||||
lines.append(f"{indent}}}")
|
||||
|
||||
# endregion
|
||||
|
||||
# region Internal emitters (Mermaid)
|
||||
|
||||
def _emit_workflow_mermaid(
|
||||
self,
|
||||
workflow: Workflow,
|
||||
lines: list[str],
|
||||
indent: str,
|
||||
ns: str | None = None,
|
||||
include_internal_executors: bool = False,
|
||||
) -> None:
|
||||
def _san(s: str) -> str:
|
||||
s2 = re.sub(r"[^0-9A-Za-z_]", "_", s)
|
||||
if not s2 or not s2[0].isalpha():
|
||||
s2 = f"n_{s2}"
|
||||
return s2
|
||||
|
||||
def map_id(x: str) -> str:
|
||||
if ns:
|
||||
return f"{_san(ns)}__{_san(x)}"
|
||||
return _san(x)
|
||||
|
||||
# Nodes
|
||||
start_executor_id = workflow.start_executor_id
|
||||
lines.append(f'{indent}{map_id(start_executor_id)}["{start_executor_id} (Start)"];')
|
||||
for executor_id in workflow.executors:
|
||||
if executor_id == start_executor_id:
|
||||
continue
|
||||
lines.append(f'{indent}{map_id(executor_id)}["{executor_id}"];')
|
||||
|
||||
# Fan-in nodes
|
||||
fan_in_nodes_dot = self._compute_fan_in_descriptors(workflow)
|
||||
fan_in_nodes: list[tuple[str, list[str], str]] = []
|
||||
for dot_node_id, sources, target in fan_in_nodes_dot:
|
||||
digest = dot_node_id.split("::")[-1]
|
||||
base = f"{target}__{digest}"
|
||||
fan_node_id = f"fan_in__{_san(ns) + '__' if ns else ''}{_san(base)}"
|
||||
fan_in_nodes.append((fan_node_id, sources, target))
|
||||
|
||||
for fan_node_id, _, _ in fan_in_nodes:
|
||||
# Keep this line without trailing semicolon to match existing tests
|
||||
lines.append(f"{indent}{fan_node_id}((fan-in))")
|
||||
|
||||
# Fan-in edges
|
||||
for fan_node_id, sources, target in fan_in_nodes:
|
||||
for s in sources:
|
||||
lines.append(f"{indent}{map_id(s)} --> {fan_node_id};")
|
||||
lines.append(f"{indent}{fan_node_id} --> {map_id(target)};")
|
||||
|
||||
# Normal edges
|
||||
for src, tgt, is_cond in self._compute_normal_edges(
|
||||
workflow, include_internal_executors=include_internal_executors
|
||||
):
|
||||
s = map_id(src)
|
||||
t = map_id(tgt)
|
||||
if is_cond:
|
||||
lines.append(f"{indent}{s} -. conditional .-> {t};")
|
||||
else:
|
||||
lines.append(f"{indent}{s} --> {t};")
|
||||
|
||||
def _emit_sub_workflows_mermaid(
|
||||
self,
|
||||
workflow: Workflow,
|
||||
lines: list[str],
|
||||
indent: str,
|
||||
include_internal_executors: bool = False,
|
||||
) -> None:
|
||||
try:
|
||||
from ._workflow_executor import WorkflowExecutor
|
||||
except ImportError: # pragma: no cover
|
||||
return
|
||||
|
||||
def _san(s: str) -> str:
|
||||
s2 = re.sub(r"[^0-9A-Za-z_]", "_", s)
|
||||
if not s2 or not s2[0].isalpha():
|
||||
s2 = f"n_{s2}"
|
||||
return s2
|
||||
|
||||
for exec_id, exec_obj in workflow.executors.items():
|
||||
if isinstance(exec_obj, WorkflowExecutor) and hasattr(exec_obj, "workflow") and exec_obj.workflow:
|
||||
sg_id = _san(exec_id)
|
||||
lines.append(f"{indent}subgraph {sg_id}")
|
||||
# Render nested workflow within this subgraph using namespacing
|
||||
self._emit_workflow_mermaid(
|
||||
exec_obj.workflow,
|
||||
lines,
|
||||
indent=f"{indent} ",
|
||||
ns=exec_id,
|
||||
include_internal_executors=include_internal_executors,
|
||||
)
|
||||
# Recurse into deeper sub-workflows
|
||||
self._emit_sub_workflows_mermaid(
|
||||
exec_obj.workflow,
|
||||
lines,
|
||||
indent=f"{indent} ",
|
||||
include_internal_executors=include_internal_executors,
|
||||
)
|
||||
lines.append(f"{indent}end")
|
||||
|
||||
# endregion
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,896 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
import warnings
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any, Literal
|
||||
|
||||
from .._agents import SupportsAgentRun
|
||||
from ..observability import OtelAttr, capture_exception, create_workflow_span
|
||||
from ._agent_executor import AgentExecutor
|
||||
from ._agent_utils import resolve_agent_id
|
||||
from ._checkpoint import CheckpointStorage
|
||||
from ._const import DEFAULT_MAX_ITERATIONS
|
||||
from ._edge import (
|
||||
Case,
|
||||
Default,
|
||||
EdgeCondition,
|
||||
EdgeGroup,
|
||||
FanInEdgeGroup,
|
||||
FanOutEdgeGroup,
|
||||
InternalEdgeGroup,
|
||||
SingleEdgeGroup,
|
||||
SwitchCaseEdgeGroup,
|
||||
SwitchCaseEdgeGroupCase,
|
||||
SwitchCaseEdgeGroupDefault,
|
||||
)
|
||||
from ._executor import Executor
|
||||
from ._runner_context import InProcRunnerContext
|
||||
from ._validation import ValidationTypeEnum, WorkflowValidationError, validate_workflow_graph
|
||||
from ._workflow import (
|
||||
_MISSING, # pyright: ignore[reportPrivateUsage]
|
||||
Workflow,
|
||||
_coalesce_output_from_kwarg, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import Self # pragma: no cover
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ALL_OUTPUTS: Literal["all"] = "all"
|
||||
_ALL_OTHER_OUTPUTS: Literal["all_other"] = "all_other"
|
||||
_OutputSelection = list[Executor | SupportsAgentRun] | Literal["all"] | None
|
||||
_IntermediateOutputSelection = list[Executor | SupportsAgentRun] | Literal["all", "all_other"] | None
|
||||
_AnyOutputSelection = _OutputSelection | _IntermediateOutputSelection
|
||||
|
||||
|
||||
class WorkflowBuilder:
|
||||
"""A builder class for constructing workflows.
|
||||
|
||||
This class provides a fluent API for defining workflow graphs by connecting executors
|
||||
with edges and configuring execution parameters. Call :meth:`build` to create an
|
||||
immutable :class:`Workflow` instance.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from typing_extensions import Never
|
||||
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler
|
||||
|
||||
|
||||
class UpperCaseExecutor(Executor):
|
||||
@handler
|
||||
async def process(self, text: str, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.send_message(text.upper())
|
||||
|
||||
|
||||
class ReverseExecutor(Executor):
|
||||
@handler
|
||||
async def process(self, text: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output(text[::-1])
|
||||
|
||||
|
||||
upper = UpperCaseExecutor(id="upper")
|
||||
reverse = ReverseExecutor(id="reverse")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=upper).add_edge(upper, reverse).build()
|
||||
|
||||
# Run the workflow
|
||||
events = await workflow.run("hello")
|
||||
print(events.get_outputs()) # ['OLLEH']
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_iterations: int = DEFAULT_MAX_ITERATIONS,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
*,
|
||||
start_executor: Executor | SupportsAgentRun,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
output_from: list[Executor | SupportsAgentRun] | Literal["all"] | None = _MISSING,
|
||||
intermediate_output_from: _IntermediateOutputSelection = _MISSING,
|
||||
output_executors: list[Executor | SupportsAgentRun] | None = _MISSING,
|
||||
):
|
||||
"""Initialize the WorkflowBuilder.
|
||||
|
||||
Args:
|
||||
max_iterations: Maximum number of iterations for workflow convergence. Default is 100.
|
||||
name: A human-readable name for the workflow builder. This name will be the identifier
|
||||
for all workflow instances created from this builder. If not provided, a unique name
|
||||
will be generated. This will be useful for versioning, monitoring, checkpointing, and
|
||||
debugging workflows. Keeping this name unique across versions of your workflow definitions
|
||||
is recommended for better observability and management.
|
||||
description: Optional description of what the workflow does.
|
||||
start_executor: The starting executor for the workflow. Can be an Executor instance
|
||||
or SupportsAgentRun instance.
|
||||
checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence.
|
||||
output_from: Designates which executors emit workflow output
|
||||
(``type='output'`` workflow events). Pass ``"all"`` to explicitly select every
|
||||
executor with declared workflow output types.
|
||||
intermediate_output_from: Designates which executors emit intermediate output
|
||||
(``type='intermediate'`` workflow events). Pass ``"all"`` to select every executor
|
||||
with declared workflow output types as intermediate (no executor emits ``output``).
|
||||
Pass ``"all_other"`` to select every executor with declared workflow output types
|
||||
that is not selected by ``output_from``.
|
||||
If neither ``output_from`` nor ``intermediate_output_from`` is provided,
|
||||
omitted-selection compatibility behavior applies and every ``yield_output`` produces
|
||||
``type='output'``. If either is provided, explicit mode applies: listed
|
||||
workflow-output executors emit ``output``, listed intermediate executors emit
|
||||
``intermediate``, and unlisted executor yields are hidden.
|
||||
|
||||
Output selection behavior:
|
||||
- Omit both selections: every ``yield_output`` emits ``output`` for compatibility,
|
||||
with a deprecation warning.
|
||||
- ``output_from="all"``: every output-capable executor emits ``output``.
|
||||
- ``output_from=[A]``: only A emits ``output``; other executor payloads are hidden.
|
||||
- ``output_from=[A], intermediate_output_from="all_other"``: A emits ``output``;
|
||||
all other output-capable executors emit ``intermediate``.
|
||||
- ``intermediate_output_from="all_other"``: no executor emits ``output``; every
|
||||
output-capable executor emits ``intermediate``.
|
||||
- ``output_from=[], intermediate_output_from="all_other"``: no executor emits
|
||||
``output``; every output-capable executor emits ``intermediate``.
|
||||
- ``output_from=[A], intermediate_output_from=[B, C]``: A emits ``output``; B and C
|
||||
emit ``intermediate``; other executor payloads are hidden.
|
||||
output_executors: **Deprecated** alias for ``output_from``. Will be removed in a
|
||||
future version.
|
||||
"""
|
||||
output_from = _coalesce_output_from_kwarg(output_from, output_executors)
|
||||
if intermediate_output_from is _MISSING:
|
||||
intermediate_output_from = None
|
||||
self._edge_groups: list[EdgeGroup] = []
|
||||
self._executors: dict[str, Executor] = {}
|
||||
self._start_executor: Executor | None = None
|
||||
self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage
|
||||
self._max_iterations: int = max_iterations
|
||||
self._name: str = name or f"WorkflowBuilder-{uuid.uuid4()!s}"
|
||||
self._description: str | None = description
|
||||
# Maps underlying SupportsAgentRun object id -> wrapped Executor so we reuse the same wrapper
|
||||
# across start_executor / add_edge calls. This avoids multiple AgentExecutor instances
|
||||
# being created for the same agent.
|
||||
self._agent_wrappers: dict[str, Executor] = {}
|
||||
|
||||
# ``None`` for both means omitted-selection compatibility behavior
|
||||
# (every yield_output produces type='output').
|
||||
# If either is provided, explicit mode applies and unlisted executor yields are hidden.
|
||||
self._output_from: _OutputSelection = self._coerce_output_from(output_from)
|
||||
self._intermediate_output_from: _IntermediateOutputSelection = self._coerce_intermediate_output_from(
|
||||
intermediate_output_from
|
||||
)
|
||||
|
||||
# Set the start executor
|
||||
self._set_start_executor(start_executor)
|
||||
|
||||
# Agents auto-wrapped by builder now always stream incremental updates.
|
||||
|
||||
def _add_executor(self, executor: Executor) -> str:
|
||||
"""Add an executor to the map and return its ID."""
|
||||
existing = self._executors.get(executor.id)
|
||||
if existing is not None:
|
||||
if existing is executor:
|
||||
# Already added
|
||||
return executor.id
|
||||
# ID conflict
|
||||
raise ValueError(f"Duplicate executor ID '{executor.id}' detected in workflow.")
|
||||
|
||||
# New executor
|
||||
self._executors[executor.id] = executor
|
||||
# Add an internal edge group for each unique executor
|
||||
self._edge_groups.append(InternalEdgeGroup(executor.id))
|
||||
|
||||
return executor.id
|
||||
|
||||
def _maybe_wrap_agent(self, candidate: Executor | SupportsAgentRun) -> Executor:
|
||||
"""If the provided object implements SupportsAgentRun, wrap it in an AgentExecutor.
|
||||
|
||||
This allows fluent builder APIs to directly accept agents instead of
|
||||
requiring callers to manually instantiate AgentExecutor.
|
||||
|
||||
Args:
|
||||
candidate: The executor or agent to wrap.
|
||||
|
||||
Returns:
|
||||
An Executor instance, wrapping the agent if necessary.
|
||||
"""
|
||||
try: # Local import to avoid hard dependency at import time
|
||||
from agent_framework import SupportsAgentRun
|
||||
except Exception: # pragma: no cover - defensive
|
||||
SupportsAgentRun = object
|
||||
|
||||
if isinstance(candidate, Executor): # Already an executor
|
||||
return candidate
|
||||
if isinstance(candidate, SupportsAgentRun):
|
||||
# Reuse existing wrapper for the same agent instance if present
|
||||
agent_instance_id = str(id(candidate))
|
||||
existing = self._agent_wrappers.get(agent_instance_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
executor_id = resolve_agent_id(candidate)
|
||||
if executor_id in self._executors:
|
||||
raise ValueError(
|
||||
f"Duplicate executor ID '{executor_id}' from agent. "
|
||||
"Agent IDs or names must be unique within a workflow."
|
||||
)
|
||||
wrapper = AgentExecutor(candidate, id=executor_id)
|
||||
self._agent_wrappers[agent_instance_id] = wrapper
|
||||
return wrapper
|
||||
|
||||
raise TypeError(
|
||||
f"WorkflowBuilder expected an Executor or SupportsAgentRun instance; got {type(candidate).__name__}."
|
||||
)
|
||||
|
||||
def add_edge(
|
||||
self,
|
||||
source: Executor | SupportsAgentRun,
|
||||
target: Executor | SupportsAgentRun,
|
||||
condition: EdgeCondition | None = None,
|
||||
) -> Self:
|
||||
"""Add a directed edge between two executors.
|
||||
|
||||
The output types of the source and the input types of the target must be compatible.
|
||||
Messages sent by the source executor will be routed to the target executor.
|
||||
|
||||
Args:
|
||||
source: The source executor or agent for the edge.
|
||||
target: The target executor or agent for the edge.
|
||||
condition: An optional condition function `(data) -> bool | Awaitable[bool]`
|
||||
that determines whether the edge should be traversed.
|
||||
Example: `lambda data: data["ready"]`.
|
||||
|
||||
Returns:
|
||||
Self: The WorkflowBuilder instance for method chaining.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from typing_extensions import Never
|
||||
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler
|
||||
|
||||
|
||||
class ProcessorA(Executor):
|
||||
@handler
|
||||
async def process(self, data: str, ctx: WorkflowContext[int]) -> None:
|
||||
await ctx.send_message(len(data))
|
||||
|
||||
|
||||
class ProcessorB(Executor):
|
||||
@handler
|
||||
async def process(self, count: int, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output(f"Processed {count} characters")
|
||||
|
||||
|
||||
a = ProcessorA(id="a")
|
||||
b = ProcessorB(id="b")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=a).add_edge(a, b).build()
|
||||
"""
|
||||
source_exec = self._maybe_wrap_agent(source)
|
||||
target_exec = self._maybe_wrap_agent(target)
|
||||
source_id = self._add_executor(source_exec)
|
||||
target_id = self._add_executor(target_exec)
|
||||
self._edge_groups.append(SingleEdgeGroup(source_id, target_id, condition))
|
||||
return self
|
||||
|
||||
def add_fan_out_edges(
|
||||
self,
|
||||
source: Executor | SupportsAgentRun,
|
||||
targets: Sequence[Executor | SupportsAgentRun],
|
||||
) -> Self:
|
||||
"""Add multiple edges to the workflow where messages from the source will be sent to all targets.
|
||||
|
||||
The output types of the source and the input types of the targets must be compatible.
|
||||
Messages from the source will be broadcast to all target executors concurrently.
|
||||
|
||||
Args:
|
||||
source: The source executor or agent for the edges.
|
||||
targets: A list of target executors or agents for the edges.
|
||||
|
||||
Returns:
|
||||
Self: The WorkflowBuilder instance for method chaining.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler
|
||||
|
||||
|
||||
class DataSource(Executor):
|
||||
@handler
|
||||
async def generate(self, count: int, ctx: WorkflowContext[str]) -> None:
|
||||
for i in range(count):
|
||||
await ctx.send_message(f"data_{i}")
|
||||
|
||||
|
||||
class ValidatorA(Executor):
|
||||
@handler
|
||||
async def validate(self, data: str, ctx: WorkflowContext) -> None:
|
||||
print(f"ValidatorA: {data}")
|
||||
|
||||
|
||||
class ValidatorB(Executor):
|
||||
@handler
|
||||
async def validate(self, data: str, ctx: WorkflowContext) -> None:
|
||||
print(f"ValidatorB: {data}")
|
||||
|
||||
|
||||
source = DataSource(id="source")
|
||||
val_a = ValidatorA(id="val_a")
|
||||
val_b = ValidatorB(id="val_b")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=source).add_fan_out_edges(source, [val_a, val_b]).build()
|
||||
"""
|
||||
source_exec = self._maybe_wrap_agent(source)
|
||||
target_execs = [self._maybe_wrap_agent(t) for t in targets]
|
||||
source_id = self._add_executor(source_exec)
|
||||
target_ids = [self._add_executor(t) for t in target_execs]
|
||||
self._edge_groups.append(FanOutEdgeGroup(source_id, target_ids))
|
||||
|
||||
return self
|
||||
|
||||
def add_switch_case_edge_group(
|
||||
self,
|
||||
source: Executor | SupportsAgentRun,
|
||||
cases: Sequence[Case | Default],
|
||||
) -> Self:
|
||||
"""Add an edge group that represents a switch-case statement.
|
||||
|
||||
The output types of the source and the input types of the targets must be compatible.
|
||||
Messages from the source executor will be sent to one of the target executors based on
|
||||
the provided conditions.
|
||||
|
||||
Think of this as a switch statement where each target executor corresponds to a case.
|
||||
Each condition function will be evaluated in order, and the first one that returns True
|
||||
will determine which target executor receives the message.
|
||||
|
||||
The default case (if provided) will receive messages that fall through all conditions
|
||||
(i.e., no condition matched).
|
||||
|
||||
Args:
|
||||
source: The source executor or agent for the edge group.
|
||||
cases: A list of case objects that determine the target executor for each message.
|
||||
|
||||
Returns:
|
||||
Self: The WorkflowBuilder instance for method chaining.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler, Case, Default
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
score: int
|
||||
|
||||
|
||||
class Evaluator(Executor):
|
||||
@handler
|
||||
async def evaluate(self, text: str, ctx: WorkflowContext[Result]) -> None:
|
||||
await ctx.send_message(Result(score=len(text)))
|
||||
|
||||
|
||||
class HighScoreHandler(Executor):
|
||||
@handler
|
||||
async def handle(self, result: Result, ctx: WorkflowContext) -> None:
|
||||
print(f"High score: {result.score}")
|
||||
|
||||
|
||||
class LowScoreHandler(Executor):
|
||||
@handler
|
||||
async def handle(self, result: Result, ctx: WorkflowContext) -> None:
|
||||
print(f"Low score: {result.score}")
|
||||
|
||||
|
||||
evaluator = Evaluator(id="eval")
|
||||
high = HighScoreHandler(id="high")
|
||||
low = LowScoreHandler(id="low")
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=evaluator)
|
||||
.add_switch_case_edge_group(
|
||||
evaluator,
|
||||
[
|
||||
Case(condition=lambda r: r.score > 10, target=high),
|
||||
Default(target=low),
|
||||
],
|
||||
)
|
||||
.build()
|
||||
)
|
||||
"""
|
||||
source_exec = self._maybe_wrap_agent(source)
|
||||
source_id = self._add_executor(source_exec)
|
||||
# Convert case data types to internal types that only uses target_id.
|
||||
internal_cases: list[SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault] = []
|
||||
for case in cases:
|
||||
# Allow case targets to be agents
|
||||
case.target = self._maybe_wrap_agent(case.target)
|
||||
self._add_executor(case.target)
|
||||
if isinstance(case, Default):
|
||||
internal_cases.append(SwitchCaseEdgeGroupDefault(target_id=case.target.id))
|
||||
else:
|
||||
internal_cases.append(SwitchCaseEdgeGroupCase(condition=case.condition, target_id=case.target.id))
|
||||
self._edge_groups.append(SwitchCaseEdgeGroup(source_id, internal_cases))
|
||||
|
||||
return self
|
||||
|
||||
def add_multi_selection_edge_group(
|
||||
self,
|
||||
source: Executor | SupportsAgentRun,
|
||||
targets: Sequence[Executor | SupportsAgentRun],
|
||||
selection_func: Callable[[Any, list[str]], list[str]],
|
||||
) -> Self:
|
||||
"""Add an edge group that represents a multi-selection execution model.
|
||||
|
||||
The output types of the source and the input types of the targets must be compatible.
|
||||
Messages from the source executor will be sent to multiple target executors based on
|
||||
the provided selection function.
|
||||
|
||||
The selection function should take a message and a list of target executor IDs,
|
||||
and return a list of executor IDs indicating which target executors should receive the message.
|
||||
|
||||
Args:
|
||||
source: The source executor or agent for the edge group.
|
||||
targets: A list of target executors or agents for the edges.
|
||||
selection_func: A function that selects target executors for messages.
|
||||
Takes (message, list[executor_id]) and returns list[executor_id].
|
||||
|
||||
Returns:
|
||||
Self: The WorkflowBuilder instance for method chaining.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
priority: str
|
||||
data: str
|
||||
|
||||
|
||||
class TaskDispatcher(Executor):
|
||||
@handler
|
||||
async def dispatch(self, text: str, ctx: WorkflowContext[Task]) -> None:
|
||||
priority = "high" if len(text) > 10 else "low"
|
||||
await ctx.send_message(Task(priority=priority, data=text))
|
||||
|
||||
|
||||
class WorkerA(Executor):
|
||||
@handler
|
||||
async def process(self, task: Task, ctx: WorkflowContext) -> None:
|
||||
print(f"WorkerA processing: {task.data}")
|
||||
|
||||
|
||||
class WorkerB(Executor):
|
||||
@handler
|
||||
async def process(self, task: Task, ctx: WorkflowContext) -> None:
|
||||
print(f"WorkerB processing: {task.data}")
|
||||
|
||||
|
||||
dispatcher = TaskDispatcher(id="dispatcher")
|
||||
worker_a = WorkerA(id="worker_a")
|
||||
worker_b = WorkerB(id="worker_b")
|
||||
|
||||
|
||||
# Select workers based on task priority
|
||||
def select_workers(task: Task, available: list[str]) -> list[str]:
|
||||
if task.priority == "high":
|
||||
return available # Send to all workers
|
||||
return [available[0]] # Send to first worker only
|
||||
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=dispatcher)
|
||||
.add_multi_selection_edge_group(
|
||||
dispatcher,
|
||||
[worker_a, worker_b],
|
||||
selection_func=select_workers,
|
||||
)
|
||||
.build()
|
||||
)
|
||||
"""
|
||||
source_exec = self._maybe_wrap_agent(source)
|
||||
target_execs = [self._maybe_wrap_agent(t) for t in targets]
|
||||
source_id = self._add_executor(source_exec)
|
||||
target_ids = [self._add_executor(t) for t in target_execs]
|
||||
self._edge_groups.append(FanOutEdgeGroup(source_id, target_ids, selection_func))
|
||||
|
||||
return self
|
||||
|
||||
def add_fan_in_edges(
|
||||
self,
|
||||
sources: Sequence[Executor | SupportsAgentRun],
|
||||
target: Executor | SupportsAgentRun,
|
||||
) -> Self:
|
||||
"""Add multiple edges from sources to a single target executor.
|
||||
|
||||
The edges will be grouped together for synchronized processing, meaning
|
||||
the target executor will only be executed once all source executors have completed.
|
||||
|
||||
The target executor will receive a list of messages aggregated from all source executors.
|
||||
Thus the input types of the target executor must be compatible with a list of the output
|
||||
types of the source executors.
|
||||
|
||||
Args:
|
||||
sources: A list of source executors or agents for the edges.
|
||||
target: The target executor or agent for the edges.
|
||||
|
||||
Returns:
|
||||
Self: The WorkflowBuilder instance for method chaining.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from typing_extensions import Never
|
||||
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler
|
||||
|
||||
|
||||
class Producer(Executor):
|
||||
@handler
|
||||
async def produce(self, seed: int, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.send_message(f"result_{seed}")
|
||||
|
||||
|
||||
class Aggregator(Executor):
|
||||
@handler
|
||||
async def aggregate(self, results: list[str], ctx: WorkflowContext[Never, str]) -> None:
|
||||
combined = ", ".join(results)
|
||||
await ctx.yield_output(f"Combined: {combined}")
|
||||
|
||||
|
||||
prod_1 = Producer(id="prod_1")
|
||||
prod_2 = Producer(id="prod_2")
|
||||
agg = Aggregator(id="agg")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=prod_1).add_fan_in_edges([prod_1, prod_2], agg).build()
|
||||
"""
|
||||
source_execs = [self._maybe_wrap_agent(s) for s in sources]
|
||||
target_exec = self._maybe_wrap_agent(target)
|
||||
source_ids = [self._add_executor(s) for s in source_execs]
|
||||
target_id = self._add_executor(target_exec)
|
||||
self._edge_groups.append(FanInEdgeGroup(source_ids, target_id))
|
||||
|
||||
return self
|
||||
|
||||
def add_chain(self, executors: Sequence[Executor | SupportsAgentRun]) -> Self:
|
||||
"""Add a chain of executors to the workflow.
|
||||
|
||||
The output of each executor in the chain will be sent to the next executor in the chain.
|
||||
The input types of each executor must be compatible with the output types of the previous executor.
|
||||
|
||||
Cycles in the chain are not allowed, meaning an executor cannot appear more than once in the chain.
|
||||
|
||||
Args:
|
||||
executors: A list of executors or agents to chain together.
|
||||
|
||||
Returns:
|
||||
Self: The WorkflowBuilder instance for method chaining.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from typing_extensions import Never
|
||||
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler
|
||||
|
||||
|
||||
class Step1(Executor):
|
||||
@handler
|
||||
async def process(self, text: str, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.send_message(text.upper())
|
||||
|
||||
|
||||
class Step2(Executor):
|
||||
@handler
|
||||
async def process(self, text: str, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.send_message(text[::-1])
|
||||
|
||||
|
||||
class Step3(Executor):
|
||||
@handler
|
||||
async def process(self, text: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output(f"Final: {text}")
|
||||
|
||||
|
||||
step1 = Step1(id="step1")
|
||||
step2 = Step2(id="step2")
|
||||
step3 = Step3(id="step3")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=step1).add_chain([step1, step2, step3]).build()
|
||||
"""
|
||||
if len(executors) < 2:
|
||||
raise ValueError("At least two executors are required to form a chain.")
|
||||
|
||||
# Wrap each candidate first to ensure stable IDs before adding edges
|
||||
wrapped: list[Executor] = [self._maybe_wrap_agent(e) for e in executors]
|
||||
for i in range(len(wrapped) - 1):
|
||||
self.add_edge(wrapped[i], wrapped[i + 1])
|
||||
return self
|
||||
|
||||
def _set_start_executor(self, executor: Executor | SupportsAgentRun) -> None:
|
||||
"""Set the starting executor for the workflow (internal method).
|
||||
|
||||
Args:
|
||||
executor: The starting executor, which can be an Executor instance or SupportsAgentRun instance.
|
||||
"""
|
||||
if self._start_executor is not None:
|
||||
logger.warning(f"Overwriting existing start executor: {self._start_executor.id} for the workflow.")
|
||||
|
||||
wrapped = self._maybe_wrap_agent(executor)
|
||||
self._start_executor = wrapped
|
||||
# Ensure the start executor is present in the executor map so validation succeeds
|
||||
# even if no edges are added yet, or before edges wrap the same agent again.
|
||||
existing = self._executors.get(wrapped.id)
|
||||
if existing is not wrapped:
|
||||
self._add_executor(wrapped)
|
||||
|
||||
def _coerce_output_from(self, output_from: Any) -> _OutputSelection:
|
||||
"""Coerce workflow-output selection while preserving the explicit ``"all"`` literal."""
|
||||
if output_from is None:
|
||||
return None
|
||||
if output_from == _ALL_OUTPUTS:
|
||||
return _ALL_OUTPUTS
|
||||
if isinstance(output_from, str):
|
||||
raise ValueError(f"Unsupported output_from literal {output_from!r}; use 'all' or a list of executors.")
|
||||
return list(output_from)
|
||||
|
||||
def _coerce_intermediate_output_from(self, intermediate_output_from: Any) -> _IntermediateOutputSelection:
|
||||
"""Coerce intermediate-output selection and reject output-only literals."""
|
||||
if intermediate_output_from is None:
|
||||
return None
|
||||
if isinstance(intermediate_output_from, str):
|
||||
if intermediate_output_from == _ALL_OUTPUTS:
|
||||
return _ALL_OUTPUTS
|
||||
if intermediate_output_from == _ALL_OTHER_OUTPUTS:
|
||||
return _ALL_OTHER_OUTPUTS
|
||||
raise ValueError(
|
||||
f"Unsupported intermediate_output_from literal {intermediate_output_from!r}; "
|
||||
"use 'all', 'all_other', or a list of executors."
|
||||
)
|
||||
return list(intermediate_output_from)
|
||||
|
||||
def _resolve_designated_executor_ids(
|
||||
self,
|
||||
designated: _AnyOutputSelection,
|
||||
) -> list[str] | None:
|
||||
"""Resolve an optional designation list into executor IDs without mutating the graph."""
|
||||
if designated is None:
|
||||
return None
|
||||
if designated == _ALL_OUTPUTS:
|
||||
return [executor_id for executor_id, executor in self._executors.items() if executor.workflow_output_types]
|
||||
if designated == _ALL_OTHER_OUTPUTS:
|
||||
raise ValueError("intermediate_output_from='all_other' must be expanded relative to output_from.")
|
||||
ids: list[str] = []
|
||||
for item in designated:
|
||||
if isinstance(item, Executor):
|
||||
ids.append(item.id)
|
||||
elif isinstance(item, SupportsAgentRun):
|
||||
ids.append(resolve_agent_id(item))
|
||||
else:
|
||||
raise TypeError(
|
||||
"WorkflowBuilder expected designation entries to be Executor or SupportsAgentRun instances; "
|
||||
f"got {type(item).__name__}."
|
||||
)
|
||||
return ids
|
||||
|
||||
def _validate_designation_lists(
|
||||
self,
|
||||
output_executor_ids: list[str] | None,
|
||||
intermediate_executor_ids: list[str] | None,
|
||||
) -> None:
|
||||
"""Validate builder-level designation rules that need omitted-vs-explicit context."""
|
||||
explicit_mode = output_executor_ids is not None or intermediate_executor_ids is not None
|
||||
if not explicit_mode:
|
||||
return
|
||||
|
||||
output_ids = output_executor_ids or []
|
||||
intermediate_ids = intermediate_executor_ids or []
|
||||
if not output_ids and not intermediate_ids:
|
||||
raise WorkflowValidationError(
|
||||
"Explicit workflow output designation must include at least one output or intermediate executor.",
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
)
|
||||
|
||||
duplicate_outputs = sorted({executor_id for executor_id in output_ids if output_ids.count(executor_id) > 1})
|
||||
if duplicate_outputs:
|
||||
raise WorkflowValidationError(
|
||||
f"Duplicate output executor designation(s): {duplicate_outputs}",
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
)
|
||||
|
||||
duplicate_intermediates = sorted({
|
||||
executor_id for executor_id in intermediate_ids if intermediate_ids.count(executor_id) > 1
|
||||
})
|
||||
if duplicate_intermediates:
|
||||
raise WorkflowValidationError(
|
||||
f"Duplicate intermediate executor designation(s): {duplicate_intermediates}",
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
)
|
||||
|
||||
overlap = sorted(set(output_ids).intersection(intermediate_ids))
|
||||
if overlap:
|
||||
raise WorkflowValidationError(
|
||||
f"Executors cannot be both output and intermediate designated: {overlap}",
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
)
|
||||
|
||||
def build(self) -> Workflow:
|
||||
"""Build and return the constructed workflow.
|
||||
|
||||
This method performs validation before building the workflow to ensure:
|
||||
- A starting executor has been set
|
||||
- All edges connect valid executors
|
||||
- The graph is properly connected
|
||||
- Type compatibility between connected executors
|
||||
|
||||
Returns:
|
||||
Workflow: An immutable Workflow instance ready for execution.
|
||||
|
||||
Raises:
|
||||
ValueError: If starting executor is not set.
|
||||
WorkflowValidationError: If workflow validation fails (includes EdgeDuplicationError,
|
||||
TypeCompatibilityError, and GraphConnectivityError subclasses).
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from typing_extensions import Never
|
||||
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler
|
||||
|
||||
|
||||
class MyExecutor(Executor):
|
||||
@handler
|
||||
async def process(self, text: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output(text.upper())
|
||||
|
||||
|
||||
executor = MyExecutor(id="executor")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
|
||||
# The workflow is now immutable and ready to run
|
||||
events = await workflow.run("hello")
|
||||
print(events.get_outputs()) # ['HELLO']
|
||||
|
||||
# Workflows can be reused multiple times
|
||||
events2 = await workflow.run("world")
|
||||
print(events2.get_outputs()) # ['WORLD']
|
||||
|
||||
# Select one executor as Workflow Output.
|
||||
workflow = WorkflowBuilder(start_executor=executor, output_from=[executor]).build()
|
||||
events = await workflow.run("hello")
|
||||
print(events.get_outputs()) # ['HELLO']
|
||||
print(events.get_intermediate_outputs()) # []
|
||||
|
||||
# Make one executor Workflow Output and every other output-capable executor Intermediate Output.
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
start_executor=planner,
|
||||
output_from=[answerer],
|
||||
intermediate_output_from="all_other",
|
||||
)
|
||||
.add_edge(planner, answerer)
|
||||
.build()
|
||||
)
|
||||
events = await workflow.run("hello")
|
||||
print(events.get_outputs()) # outputs from answerer
|
||||
print(events.get_intermediate_outputs()) # outputs from planner
|
||||
|
||||
# Build a progress-only workflow: no Workflow Output, all output-capable executors are intermediate.
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=planner, intermediate_output_from="all_other")
|
||||
.add_edge(planner, answerer)
|
||||
.build()
|
||||
)
|
||||
events = await workflow.run("hello")
|
||||
print(events.get_outputs()) # []
|
||||
print(events.get_intermediate_outputs()) # outputs from planner and answerer
|
||||
|
||||
# Explicitly preserve all-output behavior without relying on omitted-selection compatibility.
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=planner, output_from="all").add_edge(planner, answerer).build()
|
||||
)
|
||||
events = await workflow.run("hello")
|
||||
print(events.get_outputs()) # outputs from planner and answerer
|
||||
"""
|
||||
# Create workflow build span that includes validation and workflow creation
|
||||
with create_workflow_span(OtelAttr.WORKFLOW_BUILD_SPAN) as span:
|
||||
try:
|
||||
# Add workflow build started event
|
||||
span.add_event(OtelAttr.BUILD_STARTED)
|
||||
|
||||
if not self._start_executor:
|
||||
raise ValueError(
|
||||
"Starting executor must be set via the start_executor constructor parameter before building."
|
||||
)
|
||||
|
||||
if self._output_from is None and self._intermediate_output_from is None:
|
||||
warnings.warn(
|
||||
"WorkflowBuilder built without explicit output_from or intermediate_output_from; "
|
||||
"every yield_output produces type='output' for compatibility. Pass output_from='all', "
|
||||
"output_from=[...], or intermediate_output_from=[...] to opt into explicit designation - "
|
||||
"explicit designation will be required in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
start_executor = self._start_executor
|
||||
executors = self._executors
|
||||
edge_groups = self._edge_groups
|
||||
output_ids = self._resolve_designated_executor_ids(self._output_from)
|
||||
intermediate_output_ids: list[str] | None
|
||||
if self._intermediate_output_from == _ALL_OTHER_OUTPUTS:
|
||||
output_ids_for_all_other = output_ids or []
|
||||
intermediate_output_ids = [
|
||||
executor_id
|
||||
for executor_id, executor in self._executors.items()
|
||||
if executor.workflow_output_types and executor_id not in output_ids_for_all_other
|
||||
]
|
||||
else:
|
||||
intermediate_output_ids = self._resolve_designated_executor_ids(self._intermediate_output_from)
|
||||
self._validate_designation_lists(output_ids, intermediate_output_ids)
|
||||
|
||||
explicit_mode = output_ids is not None or intermediate_output_ids is not None
|
||||
output_for_workflow: list[str] | None = output_ids if explicit_mode else None
|
||||
if explicit_mode and output_for_workflow is None:
|
||||
output_for_workflow = []
|
||||
intermediate_output_for_workflow: list[str] | None = intermediate_output_ids if explicit_mode else None
|
||||
if explicit_mode and intermediate_output_for_workflow is None:
|
||||
intermediate_output_for_workflow = []
|
||||
|
||||
# Perform validation before creating the workflow
|
||||
validate_workflow_graph(
|
||||
edge_groups,
|
||||
executors,
|
||||
start_executor,
|
||||
output_for_workflow or [],
|
||||
intermediate_output_for_workflow or [],
|
||||
)
|
||||
|
||||
# Add validation completed event
|
||||
span.add_event(OtelAttr.BUILD_VALIDATION_COMPLETED)
|
||||
|
||||
context = InProcRunnerContext(self._checkpoint_storage)
|
||||
|
||||
# Create workflow instance after validation
|
||||
workflow = Workflow(
|
||||
edge_groups,
|
||||
executors,
|
||||
start_executor,
|
||||
context,
|
||||
self._name,
|
||||
description=self._description,
|
||||
max_iterations=self._max_iterations,
|
||||
output_from=output_for_workflow,
|
||||
intermediate_output_from=intermediate_output_for_workflow,
|
||||
)
|
||||
build_attributes: dict[str, Any] = {
|
||||
OtelAttr.WORKFLOW_BUILDER_NAME: self._name,
|
||||
OtelAttr.WORKFLOW_ID: workflow.id,
|
||||
OtelAttr.WORKFLOW_DEFINITION: workflow.to_json(),
|
||||
}
|
||||
if self._description:
|
||||
build_attributes[OtelAttr.WORKFLOW_BUILDER_DESCRIPTION] = self._description
|
||||
span.set_attributes(build_attributes)
|
||||
|
||||
# Add workflow build completed event
|
||||
span.add_event(OtelAttr.BUILD_COMPLETED)
|
||||
|
||||
return workflow
|
||||
|
||||
except Exception as exc:
|
||||
attributes = {
|
||||
OtelAttr.BUILD_ERROR_MESSAGE: str(exc),
|
||||
OtelAttr.BUILD_ERROR_TYPE: type(exc).__name__,
|
||||
}
|
||||
span.add_event(OtelAttr.BUILD_ERROR, attributes) # type: ignore[reportArgumentType, arg-type]
|
||||
capture_exception(span, exc)
|
||||
raise
|
||||
@@ -0,0 +1,489 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import inspect
|
||||
import logging
|
||||
import uuid
|
||||
from types import UnionType
|
||||
from typing import TYPE_CHECKING, Any, Generic, Union, cast, get_args, get_origin
|
||||
|
||||
from opentelemetry.propagate import inject
|
||||
from opentelemetry.trace import SpanKind
|
||||
from typing_extensions import Never, TypeVar
|
||||
|
||||
from ..observability import OtelAttr, create_workflow_span
|
||||
from ._events import (
|
||||
WorkflowEvent,
|
||||
WorkflowEventSource,
|
||||
_framework_event_origin, # type: ignore
|
||||
)
|
||||
from ._runner_context import RunnerContext, WorkflowMessage
|
||||
from ._state import State
|
||||
from ._typing_utils import contains_typevar
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._executor import Executor
|
||||
|
||||
OutT = TypeVar("OutT", default=Never)
|
||||
W_OutT = TypeVar("W_OutT", default=Never)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def infer_output_types_from_ctx_annotation(
|
||||
ctx_annotation: Any,
|
||||
) -> tuple[list[type[Any] | UnionType], list[type[Any] | UnionType]]:
|
||||
"""Infer message types and workflow output types from the WorkflowContext generic parameters.
|
||||
|
||||
Examples:
|
||||
- WorkflowContext -> ([], [])
|
||||
- WorkflowContext[str] -> ([str], [])
|
||||
- WorkflowContext[str, int] -> ([str], [int])
|
||||
- WorkflowContext[str | int, bool | int] -> ([str, int], [bool, int])
|
||||
- WorkflowContext[Union[str, int], Union[bool, int]] -> ([str, int], [bool, int])
|
||||
- WorkflowContext[Any] -> ([Any], [])
|
||||
- WorkflowContext[Any, Any] -> ([Any], [Any])
|
||||
- WorkflowContext[Never, Never] -> ([], [])
|
||||
- WorkflowContext[Never, int] -> ([], [int])
|
||||
|
||||
Returns:
|
||||
Tuple of (message_types, workflow_output_types)
|
||||
"""
|
||||
# If no annotation or not parameterized, return empty lists
|
||||
try:
|
||||
origin = get_origin(ctx_annotation)
|
||||
except Exception:
|
||||
origin = None
|
||||
|
||||
# If annotation is unsubscripted WorkflowContext, nothing to infer
|
||||
if origin is None:
|
||||
return [], []
|
||||
|
||||
# Expecting WorkflowContext[OutT, W_OutT]
|
||||
if origin is not WorkflowContext:
|
||||
return [], []
|
||||
|
||||
args = list(get_args(ctx_annotation))
|
||||
if not args:
|
||||
return [], []
|
||||
|
||||
# WorkflowContext[OutT] -> message_types from OutT, no workflow output types
|
||||
if len(args) == 1:
|
||||
t = args[0]
|
||||
t_origin = get_origin(t)
|
||||
if t is Any:
|
||||
return [cast(type[Any], Any)], []
|
||||
|
||||
if t_origin in (Union, UnionType):
|
||||
msg_types: list[type[Any] | UnionType] = [arg for arg in get_args(t) if arg is not Any and arg is not Never]
|
||||
return msg_types, []
|
||||
|
||||
if t is Never:
|
||||
return [], []
|
||||
return [t], []
|
||||
|
||||
# WorkflowContext[OutT, W_OutT] -> message_types from OutT, workflow_output_types from W_OutT
|
||||
t_out, t_w_out = args[:2] # Take first two args in case there are more
|
||||
|
||||
# Process OutT for message_types
|
||||
message_types: list[type[Any] | UnionType] = []
|
||||
t_out_origin = get_origin(t_out)
|
||||
if t_out is Any:
|
||||
message_types = [cast(type[Any], Any)]
|
||||
elif t_out is not Never:
|
||||
if t_out_origin in (Union, UnionType):
|
||||
message_types = [arg for arg in get_args(t_out) if arg is not Any and arg is not Never]
|
||||
else:
|
||||
message_types = [t_out]
|
||||
|
||||
# Process W_OutT for workflow_output_types
|
||||
workflow_output_types: list[type[Any] | UnionType] = []
|
||||
t_w_out_origin = get_origin(t_w_out)
|
||||
if t_w_out is Any:
|
||||
workflow_output_types = [cast(type[Any], Any)]
|
||||
elif t_w_out is not Never:
|
||||
if t_w_out_origin in (Union, UnionType):
|
||||
workflow_output_types = [arg for arg in get_args(t_w_out) if arg is not Any and arg is not Never]
|
||||
else:
|
||||
workflow_output_types = [t_w_out]
|
||||
|
||||
return message_types, workflow_output_types
|
||||
|
||||
|
||||
def _is_workflow_context_type(annotation: Any) -> bool:
|
||||
"""Check if an annotation represents WorkflowContext, WorkflowContext[T], or WorkflowContext[T, U]."""
|
||||
origin = get_origin(annotation)
|
||||
if origin is WorkflowContext:
|
||||
return True
|
||||
# Also handle the case where the raw class is used
|
||||
return annotation is WorkflowContext
|
||||
|
||||
|
||||
def validate_workflow_context_annotation(
|
||||
annotation: Any,
|
||||
parameter_name: str,
|
||||
context_description: str,
|
||||
) -> tuple[list[type[Any] | UnionType], list[type[Any] | UnionType]]:
|
||||
"""Validate a WorkflowContext annotation and return inferred types.
|
||||
|
||||
Args:
|
||||
annotation: The type annotation to validate
|
||||
parameter_name: Name of the parameter (for error messages)
|
||||
context_description: Description of the context (e.g., "Function func1", "Handler method")
|
||||
|
||||
Returns:
|
||||
Tuple of (output_types, workflow_output_types)
|
||||
|
||||
Raises:
|
||||
ValueError: If the annotation is invalid
|
||||
"""
|
||||
if annotation == inspect.Parameter.empty:
|
||||
raise ValueError(
|
||||
f"{context_description} {parameter_name} must have a WorkflowContext, "
|
||||
f"WorkflowContext[T] or WorkflowContext[T, U] type annotation, "
|
||||
f"where T is output message type and U is workflow output type"
|
||||
)
|
||||
|
||||
if not _is_workflow_context_type(annotation):
|
||||
raise ValueError(
|
||||
f"{context_description} {parameter_name} must be annotated as "
|
||||
f"WorkflowContext, WorkflowContext[T], or WorkflowContext[T, U], "
|
||||
f"got {annotation}"
|
||||
)
|
||||
|
||||
# Validate type arguments for WorkflowContext[T] or WorkflowContext[T, U]
|
||||
type_args = get_args(annotation)
|
||||
|
||||
if len(type_args) > 2:
|
||||
raise ValueError(
|
||||
f"{context_description} {parameter_name} must have at most 2 type arguments, "
|
||||
"WorkflowContext, WorkflowContext[T], or WorkflowContext[T, U], "
|
||||
f"got {len(type_args)} arguments"
|
||||
)
|
||||
|
||||
if type_args:
|
||||
# Helper function to check if a value is a valid type annotation
|
||||
def _is_type_like(x: Any) -> bool:
|
||||
"""Check if a value is a type-like entity (class, type, or typing construct)."""
|
||||
return isinstance(x, type) or get_origin(x) is not None or x is Never
|
||||
|
||||
for i, type_arg in enumerate(type_args):
|
||||
param_description = "OutT" if i == 0 else "W_OutT"
|
||||
|
||||
# Allow Any explicitly
|
||||
if type_arg is Any:
|
||||
continue
|
||||
|
||||
# Check for unresolved TypeVar early with an actionable error message
|
||||
if contains_typevar(type_arg):
|
||||
raise ValueError(
|
||||
f"{context_description} {parameter_name} {param_description} "
|
||||
f"contains an unresolved TypeVar in '{type_arg}'. "
|
||||
f"Use @handler(input=ConcreteType, output=ConcreteType) with concrete types "
|
||||
f"for parameterized executors."
|
||||
)
|
||||
|
||||
# Check if it's a union type and validate each member
|
||||
union_origin = get_origin(type_arg)
|
||||
if union_origin in (Union, UnionType):
|
||||
union_members = get_args(type_arg)
|
||||
invalid_members = [m for m in union_members if not _is_type_like(m) and m is not Any]
|
||||
if invalid_members:
|
||||
raise ValueError(
|
||||
f"{context_description} {parameter_name} {param_description} "
|
||||
f"contains invalid type entries: {invalid_members}. "
|
||||
f"Use proper types or typing generics"
|
||||
)
|
||||
else:
|
||||
# Check if it's a valid type
|
||||
if not _is_type_like(type_arg):
|
||||
raise ValueError(
|
||||
f"{context_description} {parameter_name} {param_description} "
|
||||
f"contains invalid type entry: {type_arg}. "
|
||||
f"Use proper types or typing generics"
|
||||
)
|
||||
|
||||
return infer_output_types_from_ctx_annotation(annotation)
|
||||
|
||||
|
||||
# Event types reserved for framework lifecycle (not allowed from user code)
|
||||
_FRAMEWORK_LIFECYCLE_EVENT_TYPES: frozenset[str] = frozenset({"started", "status", "failed"})
|
||||
_OUTPUT_SELECTION_EVENT_TYPES: frozenset[str] = frozenset({"output", "intermediate"})
|
||||
|
||||
|
||||
class WorkflowContext(Generic[OutT, W_OutT]):
|
||||
"""Execution context that enables executors to interact with workflows and other executors.
|
||||
|
||||
## Overview
|
||||
WorkflowContext provides a controlled interface for executors to send messages, yield outputs,
|
||||
manage state, and interact with the broader workflow ecosystem. It enforces type safety through
|
||||
generic parameters while preventing direct access to internal runtime components.
|
||||
|
||||
## Type Parameters
|
||||
The context is parameterized to enforce type safety for different operations:
|
||||
|
||||
### WorkflowContext (no parameters)
|
||||
For executors that only perform side effects without sending messages or yielding outputs:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
async def log_handler(message: str, ctx: WorkflowContext) -> None:
|
||||
print(f"Received: {message}") # Only side effects
|
||||
|
||||
### WorkflowContext[OutT]
|
||||
Enables sending messages of type OutT to other executors:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
async def processor(message: str, ctx: WorkflowContext[int]) -> None:
|
||||
result = len(message)
|
||||
await ctx.send_message(result) # Send int to downstream executors
|
||||
|
||||
### WorkflowContext[OutT, W_OutT]
|
||||
Enables both sending messages (OutT) and yielding workflow outputs (W_OutT):
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
async def dual_output(message: str, ctx: WorkflowContext[int, str]) -> None:
|
||||
await ctx.send_message(42) # Send int message
|
||||
await ctx.yield_output("complete") # Yield str workflow output
|
||||
|
||||
### Union Types
|
||||
Multiple types can be specified using union notation:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
async def flexible(message: str, ctx: WorkflowContext[int | str, bool | dict]) -> None:
|
||||
await ctx.send_message("text") # or send 42
|
||||
await ctx.yield_output(True) # or yield {"status": "done"}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
executor: Executor,
|
||||
source_executor_ids: list[str],
|
||||
state: State,
|
||||
runner_context: RunnerContext,
|
||||
trace_contexts: list[dict[str, str]] | None = None,
|
||||
source_span_ids: list[str] | None = None,
|
||||
request_id: str | None = None,
|
||||
):
|
||||
"""Initialize the executor context with the given workflow context.
|
||||
|
||||
Args:
|
||||
executor: The executor instance that this context belongs to.
|
||||
source_executor_ids: The IDs of the source executors that sent messages to this executor.
|
||||
This is a list to support fan_in scenarios where multiple sources send aggregated
|
||||
messages to the same executor.
|
||||
state: The workflow state.
|
||||
runner_context: The runner context that provides methods to send messages and events.
|
||||
trace_contexts: Optional trace contexts from multiple sources for OpenTelemetry propagation.
|
||||
source_span_ids: Optional source span IDs from multiple sources for linking (not for nesting).
|
||||
request_id: Optional request ID if this context is for a `handle_response` handler.
|
||||
"""
|
||||
self._executor = executor
|
||||
self._executor_id = executor.id
|
||||
self._source_executor_ids = source_executor_ids
|
||||
self._runner_context = runner_context
|
||||
self._state = state
|
||||
|
||||
# Track messages sent via send_message() for executor_completed event (type='executor_completed')
|
||||
self._sent_messages: list[Any] = []
|
||||
|
||||
# Track outputs yielded via yield_output() for executor_completed event (type='executor_completed')
|
||||
self._yielded_outputs: list[Any] = []
|
||||
|
||||
# Store trace contexts and source span IDs for linking (supporting multiple sources)
|
||||
self._trace_contexts = trace_contexts or []
|
||||
self._source_span_ids = source_span_ids or []
|
||||
|
||||
# request info related
|
||||
self._request_id: str | None = request_id
|
||||
|
||||
if not self._source_executor_ids:
|
||||
raise ValueError("source_executor_ids cannot be empty. At least one source executor ID is required.")
|
||||
|
||||
@property
|
||||
def request_id(self) -> str | None:
|
||||
"""Get the request ID if this context is for a `handle_response` handler.
|
||||
|
||||
Returns:
|
||||
The request ID string or None if not applicable.
|
||||
"""
|
||||
return self._request_id
|
||||
|
||||
async def send_message(self, message: OutT, target_id: str | None = None) -> None:
|
||||
"""Send a message to the workflow context.
|
||||
|
||||
Args:
|
||||
message: The message to send. This must conform to the output type(s) declared on this context.
|
||||
target_id: The ID of the target executor to send the message to.
|
||||
If None, the message will be sent to all target executors.
|
||||
"""
|
||||
global OBSERVABILITY_SETTINGS
|
||||
from ..observability import OBSERVABILITY_SETTINGS
|
||||
|
||||
# Create publishing span (inherits current trace context automatically)
|
||||
attributes: dict[str, str] = {OtelAttr.MESSAGE_TYPE: type(message).__name__}
|
||||
if target_id:
|
||||
attributes[OtelAttr.MESSAGE_DESTINATION_EXECUTOR_ID] = target_id
|
||||
with create_workflow_span(OtelAttr.MESSAGE_SEND_SPAN, attributes, kind=SpanKind.PRODUCER) as span:
|
||||
# Create Message wrapper
|
||||
msg = WorkflowMessage(data=message, source_id=self._executor_id, target_id=target_id)
|
||||
|
||||
# Track sent message for executor_completed event (type='executor_completed')
|
||||
self._sent_messages.append(message)
|
||||
|
||||
# Inject current trace context if tracing enabled
|
||||
if OBSERVABILITY_SETTINGS.ENABLED and span and span.is_recording():
|
||||
trace_context: dict[str, str] = {}
|
||||
inject(trace_context) # Inject current trace context for message propagation
|
||||
|
||||
msg.trace_contexts = [trace_context]
|
||||
msg.source_span_ids = [format(span.get_span_context().span_id, "016x")]
|
||||
|
||||
await self._runner_context.send_message(msg)
|
||||
|
||||
async def yield_output(self, output: W_OutT) -> None:
|
||||
"""Yield an output from this executor.
|
||||
|
||||
The framework labels the resulting workflow event based on the workflow's explicit
|
||||
output designation:
|
||||
|
||||
- Omitted-selection compatibility behavior: every yield produces ``type='output'``.
|
||||
- Explicit mode: output-designated executors produce ``type='output'``,
|
||||
intermediate-designated executors produce ``type='intermediate'``, and
|
||||
unlisted executor yields are hidden from caller-facing events.
|
||||
|
||||
Whether a given executor produces ``output`` or ``intermediate`` events is fixed at
|
||||
workflow-build time via ``output_from`` / ``intermediate_output_from`` on
|
||||
:class:`WorkflowBuilder`; an executor cannot vary the label per yield. To change an
|
||||
executor's role, list it under a different designation when building the workflow.
|
||||
|
||||
Args:
|
||||
output: The output to yield. This must conform to the workflow output type(s)
|
||||
declared on this context.
|
||||
"""
|
||||
# Track yielded output for executor_completed event (type='executor_completed')
|
||||
# (deepcopy to capture state at yield time)
|
||||
self._yielded_outputs.append(copy.deepcopy(output))
|
||||
|
||||
event_type = self._runner_context.classify_yielded_output(self._executor_id)
|
||||
if event_type is None:
|
||||
return
|
||||
|
||||
with _framework_event_origin():
|
||||
event = WorkflowEvent(event_type, executor_id=self._executor_id, data=output)
|
||||
await self._runner_context.add_event(event)
|
||||
|
||||
async def add_event(self, event: WorkflowEvent[Any]) -> None:
|
||||
"""Add an event to the workflow context."""
|
||||
if event.origin == WorkflowEventSource.EXECUTOR and event.type in _OUTPUT_SELECTION_EVENT_TYPES:
|
||||
warning_msg = (
|
||||
f"Executor '{self._executor_id}' attempted to emit a '{event.type}' event directly, "
|
||||
"which is reserved for ctx.yield_output(). The event was ignored."
|
||||
)
|
||||
logger.warning(warning_msg)
|
||||
await self._runner_context.add_event(WorkflowEvent.warning(warning_msg))
|
||||
return
|
||||
if event.origin == WorkflowEventSource.EXECUTOR and event.type in _FRAMEWORK_LIFECYCLE_EVENT_TYPES:
|
||||
warning_msg = (
|
||||
f"Executor '{self._executor_id}' attempted to emit a '{event.type}' event, "
|
||||
"which is reserved for framework lifecycle notifications. The "
|
||||
"event was ignored."
|
||||
)
|
||||
logger.warning(warning_msg)
|
||||
await self._runner_context.add_event(WorkflowEvent.warning(warning_msg))
|
||||
return
|
||||
await self._runner_context.add_event(event)
|
||||
|
||||
async def request_info(self, request_data: object, response_type: type, *, request_id: str | None = None) -> None:
|
||||
"""Request information from outside of the workflow.
|
||||
|
||||
Calling this method will cause the workflow to emit a request_info event (type='request_info'), carrying the
|
||||
provided request_data and request_type. External systems listening for such events
|
||||
can then process the request and respond accordingly.
|
||||
|
||||
Executors must have the corresponding response handlers defined using the
|
||||
@response_handler decorator to handle the incoming responses.
|
||||
|
||||
Args:
|
||||
request_data: The data associated with the information request.
|
||||
response_type: The expected type of the response, used for validation.
|
||||
request_id: Optional unique identifier for the request. If not provided,
|
||||
a new UUID will be generated. This allows executors to track requests and responses.
|
||||
"""
|
||||
request_type: type = type(request_data)
|
||||
if not self._executor.is_request_supported(request_type, response_type):
|
||||
logger.warning(
|
||||
f"Executor '{self._executor_id}' requested info of type {request_type.__name__} "
|
||||
f"with expected response type {response_type.__name__}, but no matching "
|
||||
"response handler is defined. The request will not be ignored but responses will "
|
||||
"not be processed. Please define a response handler using the @response_handler decorator."
|
||||
)
|
||||
|
||||
request_info_event = WorkflowEvent.request_info(
|
||||
request_id=request_id or str(uuid.uuid4()),
|
||||
source_executor_id=self._executor_id,
|
||||
request_data=request_data,
|
||||
response_type=response_type,
|
||||
)
|
||||
await self._runner_context.add_request_info_event(request_info_event)
|
||||
|
||||
def get_state(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a value from the workflow state."""
|
||||
return self._state.get(key, default)
|
||||
|
||||
def set_state(self, key: str, value: Any) -> None:
|
||||
"""Set a value in the workflow state."""
|
||||
self._state.set(key, value)
|
||||
|
||||
def get_source_executor_id(self) -> str:
|
||||
"""Get the ID of the source executor that sent the message to this executor.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If there are multiple source executors, this method raises an error.
|
||||
"""
|
||||
if len(self._source_executor_ids) > 1:
|
||||
raise RuntimeError(
|
||||
"Cannot get source executor ID when there are multiple source executors. "
|
||||
"Access the full list via the source_executor_ids property instead."
|
||||
)
|
||||
return self._source_executor_ids[0]
|
||||
|
||||
@property
|
||||
def source_executor_ids(self) -> list[str]:
|
||||
"""Get the IDs of the source executors that sent messages to this executor."""
|
||||
return self._source_executor_ids
|
||||
|
||||
@property
|
||||
def state(self) -> State:
|
||||
"""Get the workflow state."""
|
||||
return self._state
|
||||
|
||||
def get_sent_messages(self) -> list[Any]:
|
||||
"""Get all messages sent via send_message() during this handler execution.
|
||||
|
||||
Returns:
|
||||
A list of messages that were sent to downstream executors.
|
||||
"""
|
||||
return self._sent_messages.copy()
|
||||
|
||||
def get_yielded_outputs(self) -> list[Any]:
|
||||
"""Get all outputs yielded via yield_output() during this handler execution.
|
||||
|
||||
Returns:
|
||||
A list of outputs that were yielded as workflow outputs.
|
||||
"""
|
||||
return self._yielded_outputs.copy()
|
||||
|
||||
def is_streaming(self) -> bool:
|
||||
"""Check if the workflow is running in streaming mode.
|
||||
|
||||
Returns:
|
||||
True if the workflow was started with stream=True, False otherwise.
|
||||
"""
|
||||
return self._runner_context.is_streaming()
|
||||
@@ -0,0 +1,697 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._workflow import Workflow
|
||||
|
||||
from ._checkpoint_encoding import decode_checkpoint_value
|
||||
from ._const import GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY
|
||||
from ._events import (
|
||||
WorkflowEvent,
|
||||
WorkflowRunState,
|
||||
_framework_event_origin, # type: ignore[reportPrivateUsage]
|
||||
)
|
||||
from ._executor import Executor, handler
|
||||
from ._request_info_mixin import response_handler
|
||||
from ._runner_context import WorkflowMessage
|
||||
from ._typing_utils import is_instance_of
|
||||
from ._workflow import WorkflowRunResult
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # pragma: no cover
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionContext:
|
||||
"""Context for tracking a single sub-workflow execution."""
|
||||
|
||||
# The ID of the execution context
|
||||
execution_id: str
|
||||
|
||||
# Responses that have been collected so far for requests that
|
||||
# were sent out in the previous iteration
|
||||
collected_responses: dict[str, Any] # request_id -> response_data
|
||||
|
||||
# Number of responses to be expected. If the WorkflowExecutor has
|
||||
# not received all responses, it won't run the sub workflow.
|
||||
expected_response_count: int
|
||||
|
||||
# Pending requests to be fulfilled. This will get updated as the
|
||||
# WorkflowExecutor receives responses.
|
||||
pending_requests: dict[str, WorkflowEvent] # request_id -> request_info_event
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubWorkflowResponseMessage:
|
||||
"""Message sent from a parent workflow to a sub-workflow via WorkflowExecutor to provide requested information.
|
||||
|
||||
This message wraps the response data along with the original WorkflowEvent emitted by the sub-workflow executor.
|
||||
|
||||
Attributes:
|
||||
data: The response data to the original request.
|
||||
source_event: The original WorkflowEvent emitted by the sub-workflow executor.
|
||||
"""
|
||||
|
||||
data: Any
|
||||
source_event: WorkflowEvent
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubWorkflowRequestMessage:
|
||||
"""Message sent from a sub-workflow to an executor in the parent workflow to request information.
|
||||
|
||||
This message wraps a WorkflowEvent emitted by the executor in the sub-workflow.
|
||||
|
||||
Attributes:
|
||||
source_event: The original WorkflowEvent emitted by the sub-workflow executor.
|
||||
executor_id: The ID of the WorkflowExecutor in the parent workflow that is
|
||||
responsible for this sub-workflow. This can be used to ensure that the response
|
||||
is sent back to the correct sub-workflow instance.
|
||||
"""
|
||||
|
||||
source_event: WorkflowEvent
|
||||
executor_id: str
|
||||
|
||||
def create_response(self, data: Any) -> SubWorkflowResponseMessage:
|
||||
"""Validate and wrap response data into a SubWorkflowResponseMessage.
|
||||
|
||||
Validation ensures the response data type matches the expected type from the original request.
|
||||
"""
|
||||
expected_data_type = self.source_event.response_type
|
||||
if not is_instance_of(data, expected_data_type):
|
||||
raise TypeError(
|
||||
f"Response data type {type(data)} does not match expected type {expected_data_type} "
|
||||
f"for request_id {self.source_event.request_id}"
|
||||
)
|
||||
|
||||
return SubWorkflowResponseMessage(data=data, source_event=self.source_event)
|
||||
|
||||
|
||||
class WorkflowExecutor(Executor):
|
||||
"""An executor that wraps a workflow to enable hierarchical workflow composition.
|
||||
|
||||
## Overview
|
||||
WorkflowExecutor makes a workflow behave as a single executor within a parent workflow,
|
||||
enabling nested workflow architectures. It handles the complete lifecycle of sub-workflow
|
||||
execution including event processing, output forwarding, and request/response coordination
|
||||
between parent and child workflows.
|
||||
|
||||
## Execution Model
|
||||
When invoked, WorkflowExecutor:
|
||||
1. Starts the wrapped workflow with the input message
|
||||
2. Runs the sub-workflow to completion or until it needs external input
|
||||
3. Processes the sub-workflow's complete event stream after execution
|
||||
4. Forwards outputs to the parent workflow as messages
|
||||
5. Handles external requests by routing them to the parent workflow
|
||||
6. Accumulates responses and resumes sub-workflow execution
|
||||
|
||||
## Event Stream Processing
|
||||
WorkflowExecutor processes events after sub-workflow completion:
|
||||
|
||||
### Output Forwarding
|
||||
All outputs from the sub-workflow are automatically forwarded to the parent:
|
||||
|
||||
#### When `allow_direct_output` is False (default):
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# An executor in the sub-workflow yields outputs
|
||||
await ctx.yield_output("sub-workflow result")
|
||||
|
||||
# WorkflowExecutor forwards to parent via ctx.send_message()
|
||||
# Parent receives the output as a regular message
|
||||
|
||||
#### When `allow_direct_output` is True:
|
||||
|
||||
.. code-block:: python
|
||||
# An executor in the sub-workflow yields outputs
|
||||
await ctx.yield_output("sub-workflow result")
|
||||
|
||||
# WorkflowExecutor yields output directly to parent workflow's event stream
|
||||
# The output of the sub-workflow is considered the output of the parent workflow
|
||||
# Caller of the parent workflow receives the output directly
|
||||
|
||||
### Request/Response Coordination
|
||||
When sub-workflows need external information:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# An executor in the sub-workflow makes request
|
||||
request = MyDataRequest(query="user info")
|
||||
|
||||
# WorkflowExecutor captures WorkflowEvent and wraps it in a SubWorkflowRequestMessage
|
||||
# then send it to the receiving executor in parent workflow. The executor in parent workflow
|
||||
# can handle the request locally or forward it to an external source.
|
||||
# The WorkflowExecutor tracks the pending request, and implements a response handler.
|
||||
# When the response is received, it executes the response handler to accumulate responses
|
||||
# and resume the sub-workflow when all expected responses are received.
|
||||
# The response handler expects a SubWorkflowResponseMessage wrapping the response data.
|
||||
|
||||
### State Management
|
||||
WorkflowExecutor maintains execution state across request/response cycles:
|
||||
- Tracks pending requests by request_id
|
||||
- Accumulates responses until all expected responses are received
|
||||
- Resumes sub-workflow execution with complete response batch
|
||||
- Handles concurrent executions and multiple pending requests
|
||||
|
||||
## Type System Integration
|
||||
WorkflowExecutor inherits its type signature from the wrapped workflow:
|
||||
|
||||
### Input Types
|
||||
Matches the wrapped workflow's start executor input types:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# If sub-workflow accepts str, WorkflowExecutor accepts str
|
||||
workflow_executor = WorkflowExecutor(my_workflow, id="wrapper")
|
||||
assert workflow_executor.input_types == my_workflow.input_types
|
||||
|
||||
### Output Types
|
||||
Combines sub-workflow outputs with request coordination types:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Includes all sub-workflow output types
|
||||
# Plus SubWorkflowRequestMessage if sub-workflow can make requests
|
||||
output_types = workflow.output_types + [SubWorkflowRequestMessage] # if applicable
|
||||
|
||||
## Error Handling
|
||||
WorkflowExecutor propagates sub-workflow failures:
|
||||
- Captures failed event (type='failed') from sub-workflow
|
||||
- Converts to error event in parent context
|
||||
- Provides detailed error information including sub-workflow ID
|
||||
|
||||
## Concurrent Execution Support
|
||||
WorkflowExecutor fully supports multiple concurrent sub-workflow executions:
|
||||
|
||||
### Per-Execution State Isolation
|
||||
Each sub-workflow invocation creates an isolated ExecutionContext:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Multiple concurrent invocations are supported
|
||||
workflow_executor = WorkflowExecutor(my_workflow, id="concurrent_executor")
|
||||
|
||||
# Each invocation gets its own execution context
|
||||
# Execution 1: processes input_1 independently
|
||||
# Execution 2: processes input_2 independently
|
||||
# No state interference between executions
|
||||
|
||||
### Request/Response Coordination
|
||||
Responses are correctly routed to the originating execution:
|
||||
- Each execution tracks its own pending requests and expected responses
|
||||
- Request-to-execution mapping ensures responses reach the correct sub-workflow
|
||||
- Response accumulation is isolated per execution
|
||||
- Automatic cleanup when execution completes
|
||||
|
||||
### Memory Management
|
||||
- Unlimited concurrent executions supported
|
||||
- Each execution has unique UUID-based identification
|
||||
- Cleanup of completed execution contexts
|
||||
- Thread-safe state management for concurrent access
|
||||
|
||||
### Important Considerations
|
||||
**Shared Workflow Instance**: All concurrent executions use the same underlying workflow instance.
|
||||
For proper isolation, ensure that the wrapped workflow and its executors are stateless.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Avoid: Stateful executor with instance variables
|
||||
class StatefulExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="stateful")
|
||||
self.data = [] # This will be shared across concurrent executions!
|
||||
|
||||
## Integration with Parent Workflows
|
||||
Parent workflows can intercept sub-workflow requests:
|
||||
|
||||
.. code-block:: python
|
||||
class ParentExecutor(Executor):
|
||||
@handler
|
||||
async def handle_subworkflow_request(
|
||||
self,
|
||||
request: SubWorkflowRequestMessage,
|
||||
ctx: WorkflowContext[SubWorkflowResponseMessage],
|
||||
) -> None:
|
||||
# Handle request locally or forward to external source
|
||||
if self.can_handle_locally(request):
|
||||
# Send response back to sub-workflow
|
||||
response = request.create_response(data="local response data")
|
||||
await ctx.send_message(response, target_id=request.source_executor_id)
|
||||
else:
|
||||
# Forward to external handler
|
||||
await ctx.request_info(request.source_event, response_type=request.source_event.response_type)
|
||||
|
||||
## Implementation Notes
|
||||
- Sub-workflows run to completion before processing their results
|
||||
- Event processing is atomic - all outputs are forwarded before requests
|
||||
- Response accumulation ensures sub-workflows receive complete response batches
|
||||
- Execution state is maintained for proper resumption after external requests
|
||||
- Concurrent executions are fully isolated and do not interfere with each other
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow: "Workflow",
|
||||
id: str,
|
||||
allow_direct_output: bool = False,
|
||||
propagate_request: bool = False,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""Initialize the WorkflowExecutor.
|
||||
|
||||
Args:
|
||||
workflow: The workflow to execute as a sub-workflow.
|
||||
id: Unique identifier for this executor.
|
||||
allow_direct_output: Whether to allow direct output from the sub-workflow.
|
||||
By default, outputs from the sub-workflow are sent to
|
||||
other executors in the parent workflow as messages.
|
||||
When this is set to true, the outputs are yielded
|
||||
directly from the WorkflowExecutor to the parent
|
||||
workflow's event stream.
|
||||
propagate_request: Whether to propagate requests from the sub-workflow to the
|
||||
parent workflow. If set to true, requests from the sub-workflow
|
||||
will be propagated as the original WorkflowEvent to the parent
|
||||
workflow. Otherwise, they will be wrapped in a SubWorkflowRequestMessage,
|
||||
which should be handled by an executor in the parent workflow.
|
||||
|
||||
Keyword Args:
|
||||
**kwargs: Additional keyword arguments passed to the parent constructor.
|
||||
"""
|
||||
super().__init__(id, **kwargs)
|
||||
self.workflow = workflow
|
||||
self.allow_direct_output = allow_direct_output
|
||||
|
||||
# Track execution contexts for concurrent sub-workflow executions
|
||||
self._execution_contexts: dict[str, ExecutionContext] = {} # execution_id -> ExecutionContext
|
||||
# Map request_id to execution_id for response routing
|
||||
self._request_to_execution: dict[str, str] = {} # request_id -> execution_id
|
||||
self._propagate_request = propagate_request
|
||||
|
||||
@property
|
||||
def input_types(self) -> list[type[Any] | types.UnionType]:
|
||||
"""Get the input types based on the underlying workflow's input types plus WorkflowExecutor-specific types.
|
||||
|
||||
Returns:
|
||||
A list of input types that the WorkflowExecutor can accept.
|
||||
"""
|
||||
input_types: list[type[Any] | types.UnionType] = list(self.workflow.input_types)
|
||||
|
||||
# WorkflowExecutor can also handle SubWorkflowResponseMessage for sub-workflow responses
|
||||
if SubWorkflowResponseMessage not in input_types:
|
||||
input_types.append(SubWorkflowResponseMessage)
|
||||
|
||||
return input_types
|
||||
|
||||
@property
|
||||
def output_types(self) -> list[type[Any] | types.UnionType]:
|
||||
"""Get the output types based on the underlying workflow's output types.
|
||||
|
||||
Returns:
|
||||
A list of output types that the underlying workflow can produce.
|
||||
Includes the SubWorkflowRequestMessage type if any executor in the
|
||||
sub-workflow is request-response capable.
|
||||
"""
|
||||
output_types: list[type[Any] | types.UnionType] = list(self.workflow.output_types)
|
||||
|
||||
is_request_response_capable = any(
|
||||
executor.is_request_response_capable for executor in self.workflow.executors.values()
|
||||
)
|
||||
|
||||
if is_request_response_capable:
|
||||
output_types.append(SubWorkflowRequestMessage)
|
||||
|
||||
return output_types
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = super().to_dict()
|
||||
data["workflow"] = self.workflow.to_dict()
|
||||
return data
|
||||
|
||||
def can_handle(self, message: WorkflowMessage) -> bool:
|
||||
"""Override can_handle to only accept messages that the wrapped workflow can handle.
|
||||
|
||||
This prevents the WorkflowExecutor from accepting messages that should go to other
|
||||
executors because the handler `process_workflow` has no type restrictions.
|
||||
"""
|
||||
if isinstance(message.data, SubWorkflowResponseMessage):
|
||||
# Always handle SubWorkflowResponseMessage
|
||||
return True
|
||||
|
||||
if (
|
||||
message.original_request_info_event is not None
|
||||
and message.original_request_info_event.request_id in self._request_to_execution
|
||||
):
|
||||
# Handle propagated responses for known requests
|
||||
return True
|
||||
|
||||
# For other messages, only handle if the wrapped workflow can accept them as input
|
||||
return any(is_instance_of(message.data, input_type) for input_type in self.workflow.input_types)
|
||||
|
||||
@handler
|
||||
async def process_workflow(self, input_data: object, ctx: WorkflowContext[Any, Any]) -> None:
|
||||
"""Execute the sub-workflow with raw input data.
|
||||
|
||||
This handler starts a new sub-workflow execution. When the sub-workflow
|
||||
needs external information, it pauses and sends a request to the parent.
|
||||
|
||||
Args:
|
||||
input_data: The input data to send to the sub-workflow.
|
||||
ctx: The workflow context from the parent.
|
||||
"""
|
||||
# Create execution context for this sub-workflow run
|
||||
execution_id = str(uuid.uuid4())
|
||||
execution_context = ExecutionContext(
|
||||
execution_id=execution_id,
|
||||
collected_responses={},
|
||||
expected_response_count=0,
|
||||
pending_requests={},
|
||||
)
|
||||
self._execution_contexts[execution_id] = execution_context
|
||||
|
||||
logger.debug(f"WorkflowExecutor {self.id} starting sub-workflow {self.workflow.id} execution {execution_id}")
|
||||
|
||||
try:
|
||||
# Get kwargs from parent workflow's State to propagate to subworkflow
|
||||
parent_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})
|
||||
|
||||
# Extract invocation kwargs recognised by Workflow.run()
|
||||
# The state stores resolved format (with __global__ wrapper for global kwargs).
|
||||
# Unwrap __global__ before passing to the subworkflow so it gets re-resolved
|
||||
# against the subworkflow's own executor IDs.
|
||||
fi_kwargs: dict[str, Any] | None = None
|
||||
ci_kwargs: dict[str, Any] | None = None
|
||||
for key in ("function_invocation_kwargs", "client_kwargs"):
|
||||
resolved = parent_kwargs.get(key)
|
||||
if isinstance(resolved, dict):
|
||||
# Unwrap global sentinel; pass per-executor dicts as-is
|
||||
unwrapped: dict[str, Any] = resolved.get(GLOBAL_KWARGS_KEY, resolved) # type: ignore
|
||||
if key == "function_invocation_kwargs":
|
||||
fi_kwargs = unwrapped # type: ignore
|
||||
else:
|
||||
ci_kwargs = unwrapped # type: ignore
|
||||
|
||||
# Run the sub-workflow and collect all events, passing parent kwargs
|
||||
result = await self.workflow.run(
|
||||
input_data,
|
||||
function_invocation_kwargs=fi_kwargs, # type: ignore
|
||||
client_kwargs=ci_kwargs, # type: ignore
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"WorkflowExecutor {self.id} sub-workflow {self.workflow.id} "
|
||||
f"execution {execution_id} completed with {len(result)} events"
|
||||
)
|
||||
|
||||
# Process the workflow result using shared logic
|
||||
await self._process_workflow_result(result, execution_context, ctx)
|
||||
finally:
|
||||
# Clean up execution context if it's completed (no pending requests)
|
||||
if execution_id in self._execution_contexts:
|
||||
exec_ctx = self._execution_contexts[execution_id]
|
||||
if not exec_ctx.pending_requests:
|
||||
del self._execution_contexts[execution_id]
|
||||
|
||||
@handler
|
||||
async def handle_message_wrapped_request_response(
|
||||
self,
|
||||
response: SubWorkflowResponseMessage,
|
||||
ctx: WorkflowContext[Any, Any],
|
||||
) -> None:
|
||||
"""Handle response from parent for a forwarded request.
|
||||
|
||||
This handler accumulates responses and only resumes the sub-workflow
|
||||
when all expected responses have been received for that execution.
|
||||
|
||||
Args:
|
||||
response: The response to a previous request.
|
||||
ctx: The workflow context.
|
||||
"""
|
||||
request_id = response.source_event.request_id
|
||||
await self._handle_response(
|
||||
request_id=request_id,
|
||||
response=response.data,
|
||||
ctx=ctx,
|
||||
)
|
||||
|
||||
@response_handler
|
||||
async def handle_propagated_request_response(
|
||||
self,
|
||||
original_request: Any,
|
||||
response: object,
|
||||
ctx: WorkflowContext[Any],
|
||||
) -> None:
|
||||
"""Handle response for a request that was propagated to the parent workflow.
|
||||
|
||||
Args:
|
||||
original_request: The original WorkflowEvent.
|
||||
response: The response data.
|
||||
ctx: The workflow context.
|
||||
"""
|
||||
if ctx.request_id is None:
|
||||
raise RuntimeError("WorkflowExecutor received a propagated response without a request ID in the context.")
|
||||
|
||||
await self._handle_response(
|
||||
request_id=ctx.request_id,
|
||||
response=response,
|
||||
ctx=ctx,
|
||||
)
|
||||
|
||||
@override
|
||||
async def on_checkpoint_save(self) -> dict[str, Any]:
|
||||
"""Get the current state of the WorkflowExecutor for checkpointing purposes."""
|
||||
return {
|
||||
"execution_contexts": {
|
||||
execution_id: execution_context for execution_id, execution_context in self._execution_contexts.items()
|
||||
},
|
||||
"request_to_execution": dict(self._request_to_execution),
|
||||
}
|
||||
|
||||
@override
|
||||
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
|
||||
"""Restore the WorkflowExecutor state from a checkpoint snapshot."""
|
||||
# Validate the state contains the right keys
|
||||
if "execution_contexts" not in state:
|
||||
raise KeyError("Missing 'execution_contexts' in WorkflowExecutor state.")
|
||||
if "request_to_execution" not in state:
|
||||
raise KeyError("Missing 'request_to_execution' in WorkflowExecutor state.")
|
||||
|
||||
# Validate the execution contexts stored in the state have the right keys and values
|
||||
execution_contexts: dict[str, ExecutionContext] | None = None
|
||||
try:
|
||||
execution_contexts = {
|
||||
key: decode_checkpoint_value(value) for key, value in state["execution_contexts"].items()
|
||||
}
|
||||
except Exception as ex:
|
||||
raise RuntimeError("Failed to deserialize execution context.") from ex
|
||||
|
||||
if not all(
|
||||
isinstance(key, str) and isinstance(value, ExecutionContext) for key, value in execution_contexts.items()
|
||||
):
|
||||
raise ValueError("Execution contexts must have 'str' as key and 'ExecutionContext' as value.")
|
||||
if not all(key == value.execution_id for key, value in execution_contexts.items()):
|
||||
raise ValueError("Execution contexts must have matching keys and IDs.")
|
||||
|
||||
# Validate the request_to_execution map contain the right data
|
||||
request_to_execution = state["request_to_execution"]
|
||||
if not all(isinstance(key, str) and isinstance(value, str) for key, value in request_to_execution.items()):
|
||||
raise ValueError("Request to execution map must have 'str' as key and 'str' as value.")
|
||||
if not all(value in execution_contexts for value in request_to_execution.values()):
|
||||
raise ValueError(
|
||||
"'request_to_execution` contains unknown execution ID that is not part of the execution contexts."
|
||||
)
|
||||
|
||||
self._execution_contexts = execution_contexts
|
||||
self._request_to_execution = request_to_execution
|
||||
|
||||
# Add the `request_info_event`s back to the sub workflow.
|
||||
# This is only a temporary solution to rehydrate the sub workflow with the requests.
|
||||
# The proper way would be to rehydrate the workflow from a checkpoint on a Workflow
|
||||
# API instead of the '_runner_context' object that should be hidden. And the sub workflow
|
||||
# should be rehydrated from a checkpoint object instead of from a subset of the state.
|
||||
# TODO(@taochen): Issue #1614 - how to handle the case when the parent workflow has checkpointing
|
||||
# set up but not the sub workflow?
|
||||
request_info_events = [
|
||||
request_info_event
|
||||
for execution_context in self._execution_contexts.values()
|
||||
for request_info_event in execution_context.pending_requests.values()
|
||||
]
|
||||
await asyncio.gather(*[
|
||||
self.workflow._runner_context.add_request_info_event(event) # pyright: ignore[reportPrivateUsage]
|
||||
for event in request_info_events
|
||||
])
|
||||
|
||||
async def _process_workflow_result(
|
||||
self,
|
||||
result: WorkflowRunResult,
|
||||
execution_context: ExecutionContext,
|
||||
ctx: WorkflowContext[Any],
|
||||
) -> None:
|
||||
"""Process the result from a workflow execution.
|
||||
|
||||
This method handles the common logic for processing outputs, request info events,
|
||||
and final states that is shared between process_workflow and handle_response.
|
||||
|
||||
Args:
|
||||
result: The workflow execution result.
|
||||
execution_context: The execution context for this sub-workflow run.
|
||||
ctx: The workflow context.
|
||||
"""
|
||||
# Collect all events from the workflow
|
||||
request_info_events = result.get_request_info_events()
|
||||
outputs = result.get_outputs()
|
||||
intermediate_outputs = result.get_intermediate_outputs()
|
||||
workflow_run_state = result.get_final_state()
|
||||
logger.debug(
|
||||
f"WorkflowExecutor {self.id} processing workflow result with "
|
||||
f"{len(outputs)} outputs, {len(intermediate_outputs)} intermediate outputs, "
|
||||
f"and {len(request_info_events)} request info events. "
|
||||
f"Workflow run state: {workflow_run_state}"
|
||||
)
|
||||
|
||||
# Process outputs
|
||||
if self.allow_direct_output:
|
||||
# Note that the executor is allowed to continue its own execution after yielding outputs.
|
||||
await asyncio.gather(*[ctx.yield_output(output) for output in outputs])
|
||||
else:
|
||||
await asyncio.gather(*[ctx.send_message(output) for output in outputs])
|
||||
|
||||
# Pipe sub-workflow intermediate emissions up through the parent's event stream.
|
||||
# Bypasses the parent's yield-output classifier so the 'intermediate' label is preserved
|
||||
# across the encapsulation boundary; uses this WorkflowExecutor's id as the source
|
||||
# so outer callers don't need to know the sub-workflow's internal executor layout.
|
||||
if intermediate_outputs:
|
||||
|
||||
async def _forward_intermediate_output(output: Any) -> None:
|
||||
with _framework_event_origin():
|
||||
event = WorkflowEvent("intermediate", executor_id=self.id, data=output)
|
||||
await ctx.add_event(event)
|
||||
|
||||
await asyncio.gather(*[_forward_intermediate_output(output) for output in intermediate_outputs])
|
||||
|
||||
# Process request info events
|
||||
for event in request_info_events:
|
||||
request_id = event.request_id
|
||||
response_type = event.response_type
|
||||
# Track the pending request in execution context
|
||||
execution_context.pending_requests[request_id] = event
|
||||
# Map request to execution for response routing
|
||||
self._request_to_execution[request_id] = execution_context.execution_id
|
||||
if self._propagate_request:
|
||||
# In a workflow where the parent workflow does not handle the request, the request
|
||||
# should be propagated via the `request_info` mechanism to an external source. And
|
||||
# a @response_handler would be required in the WorkflowExecutor to handle the response.
|
||||
await ctx.request_info(event.data, response_type, request_id=request_id)
|
||||
else:
|
||||
# In a workflow where the parent workflow has an executor that may intercept the
|
||||
# request and handle it directly, a message should be sent.
|
||||
await ctx.send_message(SubWorkflowRequestMessage(source_event=event, executor_id=self.id))
|
||||
|
||||
# Update expected response count for this execution
|
||||
execution_context.expected_response_count = len(request_info_events)
|
||||
|
||||
# Handle final state
|
||||
if workflow_run_state == WorkflowRunState.FAILED:
|
||||
# Find the failed event (type='failed').
|
||||
failed_events = [e for e in result if isinstance(e, WorkflowEvent) and e.type == "failed"]
|
||||
if failed_events:
|
||||
failed_event = failed_events[0]
|
||||
if failed_event.details is not None:
|
||||
error_type = failed_event.details.error_type
|
||||
error_message = failed_event.details.message
|
||||
exception = Exception(
|
||||
f"Sub-workflow {self.workflow.id} failed with error: {error_type} - {error_message}"
|
||||
)
|
||||
else:
|
||||
exception = Exception(f"Sub-workflow {self.workflow.id} failed with unknown error")
|
||||
error_event = WorkflowEvent.error(exception)
|
||||
await ctx.add_event(error_event)
|
||||
elif workflow_run_state == WorkflowRunState.IDLE:
|
||||
# Sub-workflow is idle - nothing more to do now
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} is idle with {len(self._execution_contexts)} active executions"
|
||||
)
|
||||
elif workflow_run_state == WorkflowRunState.CANCELLED:
|
||||
# Sub-workflow was cancelled - treat as completion
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} was cancelled with {len(self._execution_contexts)} active executions"
|
||||
)
|
||||
elif workflow_run_state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS:
|
||||
# Sub-workflow is still running with pending requests
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} is still in progress with {len(request_info_events)} "
|
||||
f"pending requests with {len(self._execution_contexts)} active executions"
|
||||
)
|
||||
elif workflow_run_state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
|
||||
# Sub-workflow is idle but has pending requests
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} is idle with pending requests: "
|
||||
f"{len(request_info_events)} with {len(self._execution_contexts)} active executions"
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(f"Unexpected workflow run state: {workflow_run_state}")
|
||||
|
||||
async def _handle_response(
|
||||
self,
|
||||
request_id: str,
|
||||
response: Any,
|
||||
ctx: WorkflowContext[Any],
|
||||
) -> None:
|
||||
execution_id = self._request_to_execution.get(request_id)
|
||||
if not execution_id or execution_id not in self._execution_contexts:
|
||||
logger.warning(
|
||||
f"WorkflowExecutor {self.id} received response for unknown request_id: {request_id}. "
|
||||
"This response will be ignored."
|
||||
)
|
||||
return
|
||||
|
||||
execution_context = self._execution_contexts[execution_id]
|
||||
|
||||
# Check if we have this pending request in the execution context
|
||||
if request_id not in execution_context.pending_requests:
|
||||
logger.warning(
|
||||
f"WorkflowExecutor {self.id} received response for unknown request_id: "
|
||||
f"{request_id} in execution {execution_id}, ignoring"
|
||||
)
|
||||
return
|
||||
|
||||
# Remove the request from pending list and request mapping
|
||||
execution_context.pending_requests.pop(request_id, None)
|
||||
self._request_to_execution.pop(request_id, None)
|
||||
|
||||
# Accumulate the response in this execution's context
|
||||
execution_context.collected_responses[request_id] = response
|
||||
# Check if we have all expected responses for this execution
|
||||
if len(execution_context.collected_responses) < execution_context.expected_response_count:
|
||||
logger.debug(
|
||||
f"WorkflowExecutor {self.id} execution {execution_id} waiting for more responses: "
|
||||
f"{len(execution_context.collected_responses)}/{execution_context.expected_response_count} received"
|
||||
)
|
||||
return # Wait for more responses
|
||||
|
||||
# Send all collected responses to the sub-workflow
|
||||
responses_to_send = dict(execution_context.collected_responses)
|
||||
execution_context.collected_responses.clear() # Clear for next batch
|
||||
|
||||
try:
|
||||
# Resume the sub-workflow with all collected responses
|
||||
result = await self.workflow.run(responses=responses_to_send)
|
||||
# Process the workflow result using shared logic
|
||||
await self._process_workflow_result(result, execution_context, ctx)
|
||||
finally:
|
||||
# Clean up execution context if it's completed (no pending requests)
|
||||
if not execution_context.pending_requests:
|
||||
del self._execution_contexts[execution_id]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""A2A integration namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports objects from:
|
||||
- ``agent-framework-a2a``
|
||||
|
||||
Supported classes:
|
||||
- A2AAgent
|
||||
- A2AAgentSession
|
||||
- A2AServiceSessionId
|
||||
- A2AExecutor
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
IMPORT_PATH = "agent_framework_a2a"
|
||||
PACKAGE_NAME = "agent-framework-a2a"
|
||||
_IMPORTS = ["A2AAgent", "A2AAgentSession", "A2AServiceSessionId", "A2AExecutor"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
try:
|
||||
return getattr(importlib.import_module(IMPORT_PATH), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`"
|
||||
) from exc
|
||||
raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return _IMPORTS
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_a2a import A2AAgent, A2AAgentSession, A2AExecutor, A2AServiceSessionId
|
||||
|
||||
__all__ = ["A2AAgent", "A2AAgentSession", "A2AExecutor", "A2AServiceSessionId"]
|
||||
@@ -0,0 +1,56 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""AG-UI integration namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports objects from:
|
||||
- ``agent-framework-ag-ui``
|
||||
|
||||
Supported classes and functions:
|
||||
- AgentFrameworkAgent
|
||||
- AgentFrameworkWorkflow
|
||||
- AGUIChatClient
|
||||
- AGUIEventConverter
|
||||
- AGUIHttpService
|
||||
- AGUIThreadSnapshot
|
||||
- AGUIThreadSnapshotStore
|
||||
- InMemoryAGUIThreadSnapshotStore
|
||||
- SnapshotScopeResolver
|
||||
- add_agent_framework_fastapi_endpoint
|
||||
- state_update
|
||||
- __version__
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
IMPORT_PATH = "agent_framework_ag_ui"
|
||||
PACKAGE_NAME = "agent-framework-ag-ui"
|
||||
_IMPORTS = [
|
||||
"AgentFrameworkAgent",
|
||||
"AgentFrameworkWorkflow",
|
||||
"add_agent_framework_fastapi_endpoint",
|
||||
"AGUIChatClient",
|
||||
"AGUIEventConverter",
|
||||
"AGUIHttpService",
|
||||
"AGUIThreadSnapshot",
|
||||
"AGUIThreadSnapshotStore",
|
||||
"InMemoryAGUIThreadSnapshotStore",
|
||||
"SnapshotScopeResolver",
|
||||
"state_update",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
try:
|
||||
return getattr(importlib.import_module(IMPORT_PATH), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`"
|
||||
) from exc
|
||||
raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return _IMPORTS
|
||||
@@ -0,0 +1,31 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_ag_ui import (
|
||||
AgentFrameworkAgent,
|
||||
AgentFrameworkWorkflow,
|
||||
AGUIChatClient,
|
||||
AGUIEventConverter,
|
||||
AGUIHttpService,
|
||||
AGUIThreadSnapshot,
|
||||
AGUIThreadSnapshotStore,
|
||||
InMemoryAGUIThreadSnapshotStore,
|
||||
SnapshotScopeResolver,
|
||||
__version__,
|
||||
add_agent_framework_fastapi_endpoint,
|
||||
state_update,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AGUIChatClient",
|
||||
"AGUIEventConverter",
|
||||
"AGUIHttpService",
|
||||
"AGUIThreadSnapshot",
|
||||
"AGUIThreadSnapshotStore",
|
||||
"AgentFrameworkAgent",
|
||||
"AgentFrameworkWorkflow",
|
||||
"InMemoryAGUIThreadSnapshotStore",
|
||||
"SnapshotScopeResolver",
|
||||
"__version__",
|
||||
"add_agent_framework_fastapi_endpoint",
|
||||
"state_update",
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Amazon Bedrock integration namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports objects from:
|
||||
- ``agent-framework-anthropic``
|
||||
- ``agent-framework-bedrock``
|
||||
|
||||
Supported classes:
|
||||
- AnthropicBedrockClient
|
||||
- BedrockChatClient
|
||||
- BedrockChatOptions
|
||||
- BedrockEmbeddingClient
|
||||
- BedrockEmbeddingOptions
|
||||
- BedrockEmbeddingSettings
|
||||
- BedrockGuardrailConfig
|
||||
- BedrockSettings
|
||||
- RawAnthropicBedrockClient
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"AnthropicBedrockClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
|
||||
"BedrockChatClient": ("agent_framework_bedrock", "agent-framework-bedrock"),
|
||||
"BedrockChatOptions": ("agent_framework_bedrock", "agent-framework-bedrock"),
|
||||
"BedrockEmbeddingClient": ("agent_framework_bedrock", "agent-framework-bedrock"),
|
||||
"BedrockEmbeddingOptions": ("agent_framework_bedrock", "agent-framework-bedrock"),
|
||||
"BedrockEmbeddingSettings": ("agent_framework_bedrock", "agent-framework-bedrock"),
|
||||
"BedrockGuardrailConfig": ("agent_framework_bedrock", "agent-framework-bedrock"),
|
||||
"BedrockSettings": ("agent_framework_bedrock", "agent-framework-bedrock"),
|
||||
"RawAnthropicBedrockClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
import_path, package_name = _IMPORTS[name]
|
||||
try:
|
||||
return getattr(importlib.import_module(import_path), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The '{package_name}' package is not installed, please do `pip install {package_name}`"
|
||||
) from exc
|
||||
raise AttributeError(f"Module `amazon` has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(_IMPORTS.keys())
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_anthropic import AnthropicBedrockClient, RawAnthropicBedrockClient
|
||||
from agent_framework_bedrock import (
|
||||
BedrockChatClient,
|
||||
BedrockChatOptions,
|
||||
BedrockEmbeddingClient,
|
||||
BedrockEmbeddingOptions,
|
||||
BedrockEmbeddingSettings,
|
||||
BedrockGuardrailConfig,
|
||||
BedrockSettings,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AnthropicBedrockClient",
|
||||
"BedrockChatClient",
|
||||
"BedrockChatOptions",
|
||||
"BedrockEmbeddingClient",
|
||||
"BedrockEmbeddingOptions",
|
||||
"BedrockEmbeddingSettings",
|
||||
"BedrockGuardrailConfig",
|
||||
"BedrockSettings",
|
||||
"RawAnthropicBedrockClient",
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Anthropic integration namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports objects from:
|
||||
- ``agent-framework-anthropic``
|
||||
- ``agent-framework-claude``
|
||||
|
||||
Supported classes:
|
||||
- AnthropicBedrockClient
|
||||
- AnthropicClient
|
||||
- AnthropicChatOptions
|
||||
- AnthropicFoundryClient
|
||||
- AnthropicVertexClient
|
||||
- ClaudeAgent
|
||||
- ClaudeAgentOptions
|
||||
- RawAnthropicBedrockClient
|
||||
- RawAnthropicClient
|
||||
- RawAnthropicFoundryClient
|
||||
- RawClaudeAgent
|
||||
- RawAnthropicVertexClient
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"AnthropicBedrockClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
|
||||
"AnthropicClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
|
||||
"AnthropicChatOptions": ("agent_framework_anthropic", "agent-framework-anthropic"),
|
||||
"AnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
|
||||
"AnthropicVertexClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
|
||||
"ClaudeAgent": ("agent_framework_claude", "agent-framework-claude"),
|
||||
"ClaudeAgentOptions": ("agent_framework_claude", "agent-framework-claude"),
|
||||
"RawAnthropicBedrockClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
|
||||
"RawAnthropicClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
|
||||
"RawAnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
|
||||
"RawClaudeAgent": ("agent_framework_claude", "agent-framework-claude"),
|
||||
"RawAnthropicVertexClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
import_path, package_name = _IMPORTS[name]
|
||||
try:
|
||||
return getattr(importlib.import_module(import_path), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The '{package_name}' package is not installed, please do `pip install {package_name}`"
|
||||
) from exc
|
||||
raise AttributeError(f"Module `anthropic` has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(_IMPORTS.keys())
|
||||
@@ -0,0 +1,28 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_anthropic import (
|
||||
AnthropicBedrockClient,
|
||||
AnthropicChatOptions,
|
||||
AnthropicClient,
|
||||
AnthropicFoundryClient,
|
||||
AnthropicVertexClient,
|
||||
RawAnthropicBedrockClient,
|
||||
RawAnthropicClient,
|
||||
RawAnthropicFoundryClient,
|
||||
RawAnthropicVertexClient,
|
||||
)
|
||||
from agent_framework_claude import ClaudeAgent, ClaudeAgentOptions
|
||||
|
||||
__all__ = [
|
||||
"AnthropicBedrockClient",
|
||||
"AnthropicChatOptions",
|
||||
"AnthropicClient",
|
||||
"AnthropicFoundryClient",
|
||||
"AnthropicVertexClient",
|
||||
"ClaudeAgent",
|
||||
"ClaudeAgentOptions",
|
||||
"RawAnthropicBedrockClient",
|
||||
"RawAnthropicClient",
|
||||
"RawAnthropicFoundryClient",
|
||||
"RawAnthropicVertexClient",
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Azure integration namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports objects from optional Azure connector packages.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"AgentCallbackContext": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||
"AgentFunctionApp": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"),
|
||||
"AgentResponseCallbackProtocol": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||
"AzureAISearchContextProvider": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"),
|
||||
"AzureAISearchSettings": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"),
|
||||
"CosmosHistoryProvider": ("agent_framework_azure_cosmos", "agent-framework-azure-cosmos"),
|
||||
"DurableAIAgent": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||
"DurableAIAgentClient": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||
"DurableAIAgentOrchestrationContext": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||
"DurableAIAgentWorker": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||
"DurableWorkflowClient": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
import_path, package_name = _IMPORTS[name]
|
||||
try:
|
||||
return getattr(importlib.import_module(import_path), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The package {package_name} is required to use `{name}`. "
|
||||
f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file."
|
||||
) from exc
|
||||
raise AttributeError(f"Module `azure` has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(_IMPORTS.keys())
|
||||
@@ -0,0 +1,34 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
# Type stubs for the agent_framework.azure lazy-loading namespace.
|
||||
# Install the relevant packages for full type support.
|
||||
|
||||
from agent_framework_azure_ai_search import (
|
||||
AzureAISearchContextProvider,
|
||||
AzureAISearchSettings,
|
||||
)
|
||||
from agent_framework_azure_cosmos import CosmosHistoryProvider
|
||||
from agent_framework_azurefunctions import AgentFunctionApp
|
||||
from agent_framework_durabletask import (
|
||||
AgentCallbackContext,
|
||||
AgentResponseCallbackProtocol,
|
||||
DurableAIAgent,
|
||||
DurableAIAgentClient,
|
||||
DurableAIAgentOrchestrationContext,
|
||||
DurableAIAgentWorker,
|
||||
DurableWorkflowClient,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentCallbackContext",
|
||||
"AgentFunctionApp",
|
||||
"AgentResponseCallbackProtocol",
|
||||
"AzureAISearchContextProvider",
|
||||
"AzureAISearchSettings",
|
||||
"CosmosHistoryProvider",
|
||||
"DurableAIAgent",
|
||||
"DurableAIAgentClient",
|
||||
"DurableAIAgentOrchestrationContext",
|
||||
"DurableAIAgentWorker",
|
||||
"DurableWorkflowClient",
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""ChatKit integration namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports objects from:
|
||||
- ``agent-framework-chatkit``
|
||||
|
||||
Supported classes and functions:
|
||||
- ThreadItemConverter
|
||||
- simple_to_agent_input
|
||||
- stream_agent_response
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
IMPORT_PATH = "agent_framework_chatkit"
|
||||
PACKAGE_NAME = "agent-framework-chatkit"
|
||||
_IMPORTS = ["ThreadItemConverter", "simple_to_agent_input", "stream_agent_response"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
try:
|
||||
return getattr(importlib.import_module(IMPORT_PATH), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`"
|
||||
) from exc
|
||||
raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return _IMPORTS
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_chatkit import (
|
||||
ThreadItemConverter,
|
||||
simple_to_agent_input,
|
||||
stream_agent_response,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ThreadItemConverter",
|
||||
"simple_to_agent_input",
|
||||
"stream_agent_response",
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Declarative integration namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports objects from:
|
||||
- ``agent-framework-declarative``
|
||||
|
||||
Supported classes include:
|
||||
- AgentFactory
|
||||
- WorkflowFactory
|
||||
- ExternalInputRequest
|
||||
- ExternalInputResponse
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
IMPORT_PATH = "agent_framework_declarative"
|
||||
PACKAGE_NAME = "agent-framework-declarative"
|
||||
_IMPORTS = [
|
||||
"AgentFactory",
|
||||
"AgentExternalInputRequest",
|
||||
"AgentExternalInputResponse",
|
||||
"DeclarativeActionError",
|
||||
"DeclarativeLoaderError",
|
||||
"DeclarativeWorkflowError",
|
||||
"DefaultHttpRequestHandler",
|
||||
"DefaultMCPToolHandler",
|
||||
"ExternalInputRequest",
|
||||
"ExternalInputResponse",
|
||||
"HttpRequestHandler",
|
||||
"HttpRequestInfo",
|
||||
"HttpRequestResult",
|
||||
"MCPToolApprovalRequest",
|
||||
"MCPToolHandler",
|
||||
"MCPToolInvocation",
|
||||
"MCPToolResult",
|
||||
"ProviderLookupError",
|
||||
"ProviderTypeMapping",
|
||||
"ToolApprovalRequest",
|
||||
"ToolApprovalResponse",
|
||||
"WorkflowFactory",
|
||||
"WorkflowState",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
try:
|
||||
return getattr(importlib.import_module(IMPORT_PATH), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`"
|
||||
) from exc
|
||||
raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return _IMPORTS
|
||||
@@ -0,0 +1,53 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_declarative import (
|
||||
AgentExternalInputRequest,
|
||||
AgentExternalInputResponse,
|
||||
AgentFactory,
|
||||
DeclarativeActionError,
|
||||
DeclarativeLoaderError,
|
||||
DeclarativeWorkflowError,
|
||||
DefaultHttpRequestHandler,
|
||||
DefaultMCPToolHandler,
|
||||
ExternalInputRequest,
|
||||
ExternalInputResponse,
|
||||
HttpRequestHandler,
|
||||
HttpRequestInfo,
|
||||
HttpRequestResult,
|
||||
MCPToolApprovalRequest,
|
||||
MCPToolHandler,
|
||||
MCPToolInvocation,
|
||||
MCPToolResult,
|
||||
ProviderLookupError,
|
||||
ProviderTypeMapping,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResponse,
|
||||
WorkflowFactory,
|
||||
WorkflowState,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentExternalInputRequest",
|
||||
"AgentExternalInputResponse",
|
||||
"AgentFactory",
|
||||
"DeclarativeActionError",
|
||||
"DeclarativeLoaderError",
|
||||
"DeclarativeWorkflowError",
|
||||
"DefaultHttpRequestHandler",
|
||||
"DefaultMCPToolHandler",
|
||||
"ExternalInputRequest",
|
||||
"ExternalInputResponse",
|
||||
"HttpRequestHandler",
|
||||
"HttpRequestInfo",
|
||||
"HttpRequestResult",
|
||||
"MCPToolApprovalRequest",
|
||||
"MCPToolHandler",
|
||||
"MCPToolInvocation",
|
||||
"MCPToolResult",
|
||||
"ProviderLookupError",
|
||||
"ProviderTypeMapping",
|
||||
"ToolApprovalRequest",
|
||||
"ToolApprovalResponse",
|
||||
"WorkflowFactory",
|
||||
"WorkflowState",
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""DevUI integration namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports objects from:
|
||||
- ``agent-framework-devui``
|
||||
|
||||
Supported classes and functions include:
|
||||
- DevServer
|
||||
- AgentFrameworkRequest
|
||||
- DiscoveryResponse
|
||||
- ResponseStreamEvent
|
||||
- serve
|
||||
- main
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
IMPORT_PATH = "agent_framework_devui"
|
||||
PACKAGE_NAME = "agent-framework-devui"
|
||||
_IMPORTS = [
|
||||
"AgentFrameworkRequest",
|
||||
"DevServer",
|
||||
"DiscoveryResponse",
|
||||
"EntityInfo",
|
||||
"OpenAIError",
|
||||
"OpenAIResponse",
|
||||
"ResponseStreamEvent",
|
||||
"main",
|
||||
"register_cleanup",
|
||||
"serve",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
try:
|
||||
return getattr(importlib.import_module(IMPORT_PATH), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`"
|
||||
) from exc
|
||||
raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return _IMPORTS
|
||||
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_devui import (
|
||||
AgentFrameworkRequest,
|
||||
DevServer,
|
||||
DiscoveryResponse,
|
||||
EntityInfo,
|
||||
OpenAIError,
|
||||
OpenAIResponse,
|
||||
ResponseStreamEvent,
|
||||
main,
|
||||
register_cleanup,
|
||||
serve,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentFrameworkRequest",
|
||||
"DevServer",
|
||||
"DiscoveryResponse",
|
||||
"EntityInfo",
|
||||
"OpenAIError",
|
||||
"OpenAIResponse",
|
||||
"ResponseStreamEvent",
|
||||
"main",
|
||||
"register_cleanup",
|
||||
"serve",
|
||||
]
|
||||
@@ -0,0 +1,263 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Exception hierarchy used across Agent Framework core and connectors.
|
||||
|
||||
See python/CODING_STANDARD.md § Exception Hierarchy for design rationale
|
||||
and guidance on choosing the correct exception class.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
logger = logging.getLogger("agent_framework")
|
||||
|
||||
|
||||
class AgentFrameworkException(Exception):
|
||||
"""Base exception for the Agent Framework.
|
||||
|
||||
Automatically logs the message as debug.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
inner_exception: Exception | None = None,
|
||||
log_level: Literal[0] | Literal[10] | Literal[20] | Literal[30] | Literal[40] | Literal[50] | None = 10,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""Create an AgentFrameworkException.
|
||||
|
||||
This emits a debug log (by default), with the inner_exception if provided.
|
||||
"""
|
||||
if log_level is not None:
|
||||
logger.log(log_level, message, exc_info=inner_exception)
|
||||
if inner_exception:
|
||||
super().__init__(message, inner_exception, *args)
|
||||
else:
|
||||
super().__init__(message, *args)
|
||||
|
||||
|
||||
# region Agent Exceptions
|
||||
|
||||
|
||||
class AgentException(AgentFrameworkException):
|
||||
"""Base class for all agent exceptions."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentInvalidAuthException(AgentException):
|
||||
"""An authentication error occurred in an agent."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentInvalidRequestException(AgentException):
|
||||
"""An invalid request was made to an agent."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentInvalidResponseException(AgentException):
|
||||
"""An invalid or unexpected response was received from an agent."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentContentFilterException(AgentException):
|
||||
"""A content filter was triggered by an agent."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Chat Client Exceptions
|
||||
|
||||
|
||||
class ChatClientException(AgentFrameworkException):
|
||||
"""Base class for all chat client exceptions."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ChatClientInvalidAuthException(ChatClientException):
|
||||
"""An authentication error occurred in a chat client."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ChatClientInvalidRequestException(ChatClientException):
|
||||
"""An invalid request was made to a chat client."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ChatClientInvalidResponseException(ChatClientException):
|
||||
"""An invalid or unexpected response was received from a chat client."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ChatClientContentFilterException(ChatClientException):
|
||||
"""A content filter was triggered by a chat client."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Integration Exceptions
|
||||
|
||||
|
||||
class IntegrationException(AgentFrameworkException):
|
||||
"""Base class for all external service/dependency integration exceptions."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationInitializationError(IntegrationException):
|
||||
"""A wrapped dependency/service lifecycle failure occurred during setup."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationInvalidAuthException(IntegrationException):
|
||||
"""An authentication error occurred in an external integration."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationInvalidRequestException(IntegrationException):
|
||||
"""An invalid request was made to an external integration."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationInvalidResponseException(IntegrationException):
|
||||
"""An invalid or unexpected response was received from an external integration."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationContentFilterException(IntegrationException):
|
||||
"""A content filter was triggered by an external integration."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Content Exceptions
|
||||
|
||||
|
||||
class ContentError(AgentFrameworkException):
|
||||
"""An error occurred while processing content."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AdditionItemMismatch(ContentError):
|
||||
"""A type mismatch occurred while merging content items."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Tool Exceptions
|
||||
|
||||
|
||||
class ToolException(AgentFrameworkException):
|
||||
"""Base class for all tool-related exceptions."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ToolExecutionException(ToolException):
|
||||
"""A tool or prompt call failed at runtime."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UserInputRequiredException(ToolException):
|
||||
"""Raised when a tool wrapping a sub-agent requires user input to proceed.
|
||||
|
||||
This exception carries the ``user_input_request`` Content items emitted by
|
||||
the sub-agent (e.g., ``oauth_consent_request``, ``function_approval_request``)
|
||||
so the tool invocation layer can propagate them to the parent agent's response
|
||||
instead of swallowing them as a generic tool error.
|
||||
|
||||
Args:
|
||||
contents: The user-input-request Content items from the sub-agent response.
|
||||
message: Human-readable description of why user input is needed.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
contents: list[Any],
|
||||
message: str = "Tool requires user input to proceed.",
|
||||
) -> None:
|
||||
"""Create a UserInputRequiredException.
|
||||
|
||||
Args:
|
||||
contents: The user-input-request Content items from the sub-agent response.
|
||||
message: Human-readable description of why user input is needed.
|
||||
"""
|
||||
super().__init__(message, log_level=None)
|
||||
self.contents = contents
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Middleware Exceptions
|
||||
|
||||
|
||||
class MiddlewareException(AgentFrameworkException):
|
||||
"""An error occurred during middleware execution."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Settings Exceptions
|
||||
|
||||
|
||||
class SettingNotFoundError(AgentFrameworkException):
|
||||
"""A required setting could not be resolved from any source."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Workflow Exceptions
|
||||
|
||||
|
||||
class WorkflowException(AgentFrameworkException):
|
||||
"""Base exception for workflow errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WorkflowRunnerException(WorkflowException):
|
||||
"""Base exception for workflow runner errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WorkflowConvergenceException(WorkflowRunnerException):
|
||||
"""Exception raised when a workflow runner fails to converge within the maximum iterations."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WorkflowCheckpointException(WorkflowRunnerException):
|
||||
"""Exception raised for errors related to workflow checkpoints."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -0,0 +1,63 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Foundry integration namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports objects from:
|
||||
- ``agent-framework-anthropic``
|
||||
- ``agent-framework-azure-contentunderstanding``
|
||||
- ``agent-framework-foundry``
|
||||
- ``agent-framework-foundry-local``
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"AnalysisSection": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"),
|
||||
"AnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
|
||||
"ContentUnderstandingContextProvider": (
|
||||
"agent_framework_azure_contentunderstanding",
|
||||
"agent-framework-azure-contentunderstanding",
|
||||
),
|
||||
"DocumentStatus": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"),
|
||||
"FileSearchBackend": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"),
|
||||
"FileSearchConfig": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"),
|
||||
"FoundryAgent": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryAgentOptions": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryChatOptions": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryEmbeddingOptions": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryEmbeddingSettings": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryEvals": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryMemoryProvider": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryLocalChatOptions": ("agent_framework_foundry_local", "agent-framework-foundry-local"),
|
||||
"FoundryLocalClient": ("agent_framework_foundry_local", "agent-framework-foundry-local"),
|
||||
"FoundryLocalSettings": ("agent_framework_foundry_local", "agent-framework-foundry-local"),
|
||||
"GeneratedEvaluatorRef": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"RawAnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
|
||||
"RawFoundryAgent": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"RawFoundryAgentChatClient": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"RawFoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"RawFoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"evaluate_foundry_target": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"evaluate_traces": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"to_prompt_agent": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
import_path, package_name = _IMPORTS[name]
|
||||
try:
|
||||
return getattr(importlib.import_module(import_path), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The package {package_name} is required to use `{name}`. "
|
||||
f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file."
|
||||
) from exc
|
||||
raise AttributeError(f"Module `foundry` has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(_IMPORTS.keys())
|
||||
@@ -0,0 +1,65 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
# Type stubs for the agent_framework.foundry lazy-loading namespace.
|
||||
# Install the relevant packages for full type support.
|
||||
|
||||
from agent_framework_anthropic import AnthropicFoundryClient, RawAnthropicFoundryClient
|
||||
from agent_framework_azure_contentunderstanding import (
|
||||
AnalysisSection,
|
||||
ContentUnderstandingContextProvider,
|
||||
DocumentStatus,
|
||||
FileSearchBackend,
|
||||
FileSearchConfig,
|
||||
)
|
||||
from agent_framework_foundry import (
|
||||
FoundryAgent,
|
||||
FoundryChatClient,
|
||||
FoundryChatOptions,
|
||||
FoundryEmbeddingClient,
|
||||
FoundryEmbeddingOptions,
|
||||
FoundryEmbeddingSettings,
|
||||
FoundryEvals,
|
||||
FoundryMemoryProvider,
|
||||
GeneratedEvaluatorRef,
|
||||
RawFoundryAgent,
|
||||
RawFoundryAgentChatClient,
|
||||
RawFoundryChatClient,
|
||||
RawFoundryEmbeddingClient,
|
||||
evaluate_foundry_target,
|
||||
evaluate_traces,
|
||||
to_prompt_agent,
|
||||
)
|
||||
from agent_framework_foundry_local import (
|
||||
FoundryLocalChatOptions,
|
||||
FoundryLocalClient,
|
||||
FoundryLocalSettings,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AnalysisSection",
|
||||
"AnthropicFoundryClient",
|
||||
"ContentUnderstandingContextProvider",
|
||||
"DocumentStatus",
|
||||
"FileSearchBackend",
|
||||
"FileSearchConfig",
|
||||
"FoundryAgent",
|
||||
"FoundryChatClient",
|
||||
"FoundryChatOptions",
|
||||
"FoundryEmbeddingClient",
|
||||
"FoundryEmbeddingOptions",
|
||||
"FoundryEmbeddingSettings",
|
||||
"FoundryEvals",
|
||||
"FoundryLocalChatOptions",
|
||||
"FoundryLocalClient",
|
||||
"FoundryLocalSettings",
|
||||
"FoundryMemoryProvider",
|
||||
"GeneratedEvaluatorRef",
|
||||
"RawAnthropicFoundryClient",
|
||||
"RawFoundryAgent",
|
||||
"RawFoundryAgentChatClient",
|
||||
"RawFoundryChatClient",
|
||||
"RawFoundryEmbeddingClient",
|
||||
"evaluate_foundry_target",
|
||||
"evaluate_traces",
|
||||
"to_prompt_agent",
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""GitHub integration namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports objects from:
|
||||
- ``agent-framework-github-copilot``
|
||||
|
||||
Supported classes:
|
||||
- GitHubCopilotAgent
|
||||
- GitHubCopilotOptions
|
||||
- GitHubCopilotSettings
|
||||
- RawGitHubCopilotAgent
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"GitHubCopilotAgent": ("agent_framework_github_copilot", "agent-framework-github-copilot"),
|
||||
"GitHubCopilotOptions": ("agent_framework_github_copilot", "agent-framework-github-copilot"),
|
||||
"GitHubCopilotSettings": ("agent_framework_github_copilot", "agent-framework-github-copilot"),
|
||||
"RawGitHubCopilotAgent": ("agent_framework_github_copilot", "agent-framework-github-copilot"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
import_path, package_name = _IMPORTS[name]
|
||||
try:
|
||||
return getattr(importlib.import_module(import_path), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The package {package_name} is required to use `{name}`. "
|
||||
f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file."
|
||||
) from exc
|
||||
raise AttributeError(f"Module `agent_framework.github` has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(_IMPORTS.keys())
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_github_copilot import (
|
||||
GitHubCopilotAgent,
|
||||
GitHubCopilotOptions,
|
||||
GitHubCopilotSettings,
|
||||
RawGitHubCopilotAgent,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"GitHubCopilotAgent",
|
||||
"GitHubCopilotOptions",
|
||||
"GitHubCopilotSettings",
|
||||
"RawGitHubCopilotAgent",
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Google integration namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports Google-hosted Anthropic clients from:
|
||||
- ``agent-framework-anthropic``
|
||||
|
||||
Supported classes:
|
||||
- AnthropicVertexClient
|
||||
- RawAnthropicVertexClient
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"AnthropicVertexClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
|
||||
"RawAnthropicVertexClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
import_path, package_name = _IMPORTS[name]
|
||||
try:
|
||||
return getattr(importlib.import_module(import_path), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The '{package_name}' package is not installed, please do `pip install {package_name}`"
|
||||
) from exc
|
||||
raise AttributeError(f"Module `google` has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(_IMPORTS.keys())
|
||||
@@ -0,0 +1,8 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_anthropic import AnthropicVertexClient, RawAnthropicVertexClient
|
||||
|
||||
__all__ = [
|
||||
"AnthropicVertexClient",
|
||||
"RawAnthropicVertexClient",
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Hyperlight CodeAct namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports objects from ``agent-framework-hyperlight``.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"AllowedDomain": ("agent_framework_hyperlight", "agent-framework-hyperlight"),
|
||||
"AllowedDomainInput": ("agent_framework_hyperlight", "agent-framework-hyperlight"),
|
||||
"FileMount": ("agent_framework_hyperlight", "agent-framework-hyperlight"),
|
||||
"FileMountInput": ("agent_framework_hyperlight", "agent-framework-hyperlight"),
|
||||
"HyperlightCodeActProvider": ("agent_framework_hyperlight", "agent-framework-hyperlight"),
|
||||
"HyperlightExecuteCodeTool": ("agent_framework_hyperlight", "agent-framework-hyperlight"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
import_path, package_name = _IMPORTS[name]
|
||||
try:
|
||||
return getattr(importlib.import_module(import_path), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The package {package_name} is required to use `{name}`. "
|
||||
f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file."
|
||||
) from exc
|
||||
raise AttributeError(f"Module `hyperlight` has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(_IMPORTS.keys())
|
||||
@@ -0,0 +1,10 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Lab namespace package for experimental Agent Framework integrations.
|
||||
|
||||
This module extends the package path so experimental lab integrations can be
|
||||
distributed in separate packages under the ``agent_framework.lab`` namespace.
|
||||
"""
|
||||
|
||||
# This makes agent_framework.lab a namespace package
|
||||
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
||||
@@ -0,0 +1,32 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Mem0 integration namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports objects from:
|
||||
- ``agent-framework-mem0``
|
||||
|
||||
Supported classes:
|
||||
- Mem0ContextProvider
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
IMPORT_PATH = "agent_framework_mem0"
|
||||
PACKAGE_NAME = "agent-framework-mem0"
|
||||
_IMPORTS = ["Mem0ContextProvider"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
try:
|
||||
return getattr(importlib.import_module(IMPORT_PATH), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`"
|
||||
) from exc
|
||||
raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return _IMPORTS
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_mem0 import (
|
||||
Mem0ContextProvider,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Mem0ContextProvider",
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Microsoft integration namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports objects from:
|
||||
- ``agent-framework-copilotstudio``
|
||||
- ``agent-framework-purview``
|
||||
|
||||
Supported classes:
|
||||
- CopilotStudioAgent
|
||||
- PurviewPolicyMiddleware
|
||||
- PurviewChatPolicyMiddleware
|
||||
- PurviewSettings
|
||||
- PurviewAppLocation
|
||||
- PurviewLocationType
|
||||
- PurviewAuthenticationError
|
||||
- PurviewPaymentRequiredError
|
||||
- PurviewRateLimitError
|
||||
- PurviewRequestError
|
||||
- PurviewServiceError
|
||||
- CacheProvider
|
||||
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"CopilotStudioAgent": ("agent_framework_copilotstudio", "agent-framework-copilotstudio"),
|
||||
"acquire_token": ("agent_framework_copilotstudio", "agent-framework-copilotstudio"),
|
||||
"PurviewPolicyMiddleware": ("agent_framework_purview", "agent-framework-purview"),
|
||||
"PurviewChatPolicyMiddleware": ("agent_framework_purview", "agent-framework-purview"),
|
||||
"PurviewSettings": ("agent_framework_purview", "agent-framework-purview"),
|
||||
"PurviewAppLocation": ("agent_framework_purview", "agent-framework-purview"),
|
||||
"PurviewLocationType": ("agent_framework_purview", "agent-framework-purview"),
|
||||
"PurviewAuthenticationError": ("agent_framework_purview", "agent-framework-purview"),
|
||||
"PurviewPaymentRequiredError": ("agent_framework_purview", "agent-framework-purview"),
|
||||
"PurviewRateLimitError": ("agent_framework_purview", "agent-framework-purview"),
|
||||
"PurviewRequestError": ("agent_framework_purview", "agent-framework-purview"),
|
||||
"PurviewServiceError": ("agent_framework_purview", "agent-framework-purview"),
|
||||
"CacheProvider": ("agent_framework_purview", "agent-framework-purview"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
import_path, package_name = _IMPORTS[name]
|
||||
try:
|
||||
return getattr(importlib.import_module(import_path), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The package {package_name} is required to use `{name}`. "
|
||||
f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file."
|
||||
) from exc
|
||||
raise AttributeError(f"Module `microsoft` has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(_IMPORTS.keys())
|
||||
@@ -0,0 +1,35 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_copilotstudio import (
|
||||
CopilotStudioAgent,
|
||||
acquire_token,
|
||||
)
|
||||
from agent_framework_purview import (
|
||||
CacheProvider,
|
||||
PurviewAppLocation,
|
||||
PurviewAuthenticationError,
|
||||
PurviewChatPolicyMiddleware,
|
||||
PurviewLocationType,
|
||||
PurviewPaymentRequiredError,
|
||||
PurviewPolicyMiddleware,
|
||||
PurviewRateLimitError,
|
||||
PurviewRequestError,
|
||||
PurviewServiceError,
|
||||
PurviewSettings,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CacheProvider",
|
||||
"CopilotStudioAgent",
|
||||
"PurviewAppLocation",
|
||||
"PurviewAuthenticationError",
|
||||
"PurviewChatPolicyMiddleware",
|
||||
"PurviewLocationType",
|
||||
"PurviewPaymentRequiredError",
|
||||
"PurviewPolicyMiddleware",
|
||||
"PurviewRateLimitError",
|
||||
"PurviewRequestError",
|
||||
"PurviewServiceError",
|
||||
"PurviewSettings",
|
||||
"acquire_token",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Ollama integration namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports objects from:
|
||||
- ``agent-framework-ollama``
|
||||
|
||||
Supported classes:
|
||||
- OllamaChatClient
|
||||
- OllamaChatOptions
|
||||
- OllamaEmbeddingClient
|
||||
- OllamaEmbeddingOptions
|
||||
- OllamaEmbeddingSettings
|
||||
- OllamaSettings
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
IMPORT_PATH = "agent_framework_ollama"
|
||||
PACKAGE_NAME = "agent-framework-ollama"
|
||||
_IMPORTS = [
|
||||
"OllamaChatClient",
|
||||
"OllamaChatOptions",
|
||||
"OllamaEmbeddingClient",
|
||||
"OllamaEmbeddingOptions",
|
||||
"OllamaEmbeddingSettings",
|
||||
"OllamaSettings",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
try:
|
||||
return getattr(importlib.import_module(IMPORT_PATH), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`"
|
||||
) from exc
|
||||
raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return _IMPORTS
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_ollama import (
|
||||
OllamaChatClient,
|
||||
OllamaChatOptions,
|
||||
OllamaEmbeddingClient,
|
||||
OllamaEmbeddingOptions,
|
||||
OllamaEmbeddingSettings,
|
||||
OllamaSettings,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"OllamaChatClient",
|
||||
"OllamaChatOptions",
|
||||
"OllamaEmbeddingClient",
|
||||
"OllamaEmbeddingOptions",
|
||||
"OllamaEmbeddingSettings",
|
||||
"OllamaSettings",
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""OpenAI namespace for Agent Framework clients.
|
||||
|
||||
This module lazily re-exports objects from the ``agent-framework-openai`` package.
|
||||
Install it with: ``pip install agent-framework-openai``
|
||||
|
||||
Supported classes include:
|
||||
- OpenAIChatClient (Responses API)
|
||||
- OpenAIChatCompletionClient (Chat Completions API)
|
||||
- OpenAIEmbeddingClient
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"OpenAIChatClient": ("agent_framework_openai", "agent-framework-openai"),
|
||||
"OpenAIChatOptions": ("agent_framework_openai", "agent-framework-openai"),
|
||||
"OpenAIContinuationToken": ("agent_framework_openai", "agent-framework-openai"),
|
||||
"RawOpenAIChatClient": ("agent_framework_openai", "agent-framework-openai"),
|
||||
"OpenAIChatCompletionClient": ("agent_framework_openai", "agent-framework-openai"),
|
||||
"OpenAIChatCompletionOptions": ("agent_framework_openai", "agent-framework-openai"),
|
||||
"RawOpenAIChatCompletionClient": ("agent_framework_openai", "agent-framework-openai"),
|
||||
"OpenAIEmbeddingClient": ("agent_framework_openai", "agent-framework-openai"),
|
||||
"OpenAIEmbeddingOptions": ("agent_framework_openai", "agent-framework-openai"),
|
||||
"OpenAISettings": ("agent_framework_openai", "agent-framework-openai"),
|
||||
"ContentFilterResultSeverity": ("agent_framework_openai", "agent-framework-openai"),
|
||||
"OpenAIContentFilterException": ("agent_framework_openai", "agent-framework-openai"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
import_path, package_name = _IMPORTS[name]
|
||||
try:
|
||||
return getattr(importlib.import_module(import_path), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The package {package_name} is required to use `{name}`. "
|
||||
f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file."
|
||||
) from exc
|
||||
raise AttributeError(f"Module `openai` has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(_IMPORTS.keys())
|
||||
@@ -0,0 +1,34 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
# Type stubs for the agent_framework.openai lazy-loading namespace.
|
||||
# Install agent-framework-openai for full type support.
|
||||
|
||||
from agent_framework_openai import (
|
||||
ContentFilterResultSeverity,
|
||||
OpenAIChatClient,
|
||||
OpenAIChatCompletionClient,
|
||||
OpenAIChatCompletionOptions,
|
||||
OpenAIChatOptions,
|
||||
OpenAIContentFilterException,
|
||||
OpenAIContinuationToken,
|
||||
OpenAIEmbeddingClient,
|
||||
OpenAIEmbeddingOptions,
|
||||
OpenAISettings,
|
||||
RawOpenAIChatClient,
|
||||
RawOpenAIChatCompletionClient,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ContentFilterResultSeverity",
|
||||
"OpenAIChatClient",
|
||||
"OpenAIChatCompletionClient",
|
||||
"OpenAIChatCompletionOptions",
|
||||
"OpenAIChatOptions",
|
||||
"OpenAIContentFilterException",
|
||||
"OpenAIContinuationToken",
|
||||
"OpenAIEmbeddingClient",
|
||||
"OpenAIEmbeddingOptions",
|
||||
"OpenAISettings",
|
||||
"RawOpenAIChatClient",
|
||||
"RawOpenAIChatCompletionClient",
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Orchestrations integration namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports objects from:
|
||||
- ``agent-framework-orchestrations``
|
||||
|
||||
Supported classes include:
|
||||
- SequentialBuilder
|
||||
- ConcurrentBuilder
|
||||
- GroupChatBuilder
|
||||
- MagenticBuilder
|
||||
- HandoffBuilder
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
IMPORT_PATH = "agent_framework_orchestrations"
|
||||
PACKAGE_NAME = "agent-framework-orchestrations"
|
||||
_IMPORTS = [
|
||||
# Sequential
|
||||
"SequentialBuilder",
|
||||
# Concurrent
|
||||
"ConcurrentBuilder",
|
||||
# Handoff
|
||||
"HandoffAgentExecutor",
|
||||
"HandoffAgentUserRequest",
|
||||
"HandoffBuilder",
|
||||
"HandoffConfiguration",
|
||||
"HandoffSentEvent",
|
||||
# Base orchestrator
|
||||
"BaseGroupChatOrchestrator",
|
||||
"GroupChatRequestMessage",
|
||||
"GroupChatRequestSentEvent",
|
||||
"GroupChatResponseReceivedEvent",
|
||||
"TerminationCondition",
|
||||
# Orchestration helpers
|
||||
"AgentRequestInfoResponse",
|
||||
"OrchestrationState",
|
||||
"clean_conversation_for_handoff",
|
||||
"create_completion_message",
|
||||
# Group Chat
|
||||
"AgentBasedGroupChatOrchestrator",
|
||||
"AgentOrchestrationOutput",
|
||||
"GroupChatBuilder",
|
||||
"GroupChatOrchestrator",
|
||||
"GroupChatSelectionFunction",
|
||||
"GroupChatState",
|
||||
# Magentic
|
||||
"MAGENTIC_MANAGER_NAME",
|
||||
"ORCH_MSG_KIND_INSTRUCTION",
|
||||
"ORCH_MSG_KIND_NOTICE",
|
||||
"ORCH_MSG_KIND_TASK_LEDGER",
|
||||
"ORCH_MSG_KIND_USER_TASK",
|
||||
"MagenticAgentExecutor",
|
||||
"MagenticBuilder",
|
||||
"MagenticContext",
|
||||
"MagenticManagerBase",
|
||||
"MagenticOrchestrator",
|
||||
"MagenticOrchestratorEvent",
|
||||
"MagenticOrchestratorEventType",
|
||||
"MagenticPlanReviewRequest",
|
||||
"MagenticPlanReviewResponse",
|
||||
"MagenticProgressLedger",
|
||||
"MagenticProgressLedgerItem",
|
||||
"MagenticResetSignal",
|
||||
"StandardMagenticManager",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
try:
|
||||
return getattr(importlib.import_module(IMPORT_PATH), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`"
|
||||
) from exc
|
||||
raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return _IMPORTS
|
||||
@@ -0,0 +1,75 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_orchestrations import (
|
||||
MAGENTIC_MANAGER_NAME,
|
||||
ORCH_MSG_KIND_INSTRUCTION,
|
||||
ORCH_MSG_KIND_NOTICE,
|
||||
ORCH_MSG_KIND_TASK_LEDGER,
|
||||
ORCH_MSG_KIND_USER_TASK,
|
||||
AgentBasedGroupChatOrchestrator,
|
||||
AgentOrchestrationOutput,
|
||||
AgentRequestInfoResponse,
|
||||
ConcurrentBuilder,
|
||||
GroupChatBuilder,
|
||||
GroupChatOrchestrator,
|
||||
GroupChatRequestMessage,
|
||||
GroupChatRequestSentEvent,
|
||||
GroupChatSelectionFunction,
|
||||
GroupChatState,
|
||||
HandoffAgentExecutor,
|
||||
HandoffAgentUserRequest,
|
||||
HandoffBuilder,
|
||||
HandoffConfiguration,
|
||||
HandoffSentEvent,
|
||||
MagenticAgentExecutor,
|
||||
MagenticBuilder,
|
||||
MagenticContext,
|
||||
MagenticManagerBase,
|
||||
MagenticOrchestrator,
|
||||
MagenticOrchestratorEvent,
|
||||
MagenticOrchestratorEventType,
|
||||
MagenticPlanReviewRequest,
|
||||
MagenticPlanReviewResponse,
|
||||
MagenticProgressLedger,
|
||||
MagenticProgressLedgerItem,
|
||||
MagenticResetSignal,
|
||||
SequentialBuilder,
|
||||
StandardMagenticManager,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MAGENTIC_MANAGER_NAME",
|
||||
"ORCH_MSG_KIND_INSTRUCTION",
|
||||
"ORCH_MSG_KIND_NOTICE",
|
||||
"ORCH_MSG_KIND_TASK_LEDGER",
|
||||
"ORCH_MSG_KIND_USER_TASK",
|
||||
"AgentBasedGroupChatOrchestrator",
|
||||
"AgentOrchestrationOutput",
|
||||
"AgentRequestInfoResponse",
|
||||
"ConcurrentBuilder",
|
||||
"GroupChatBuilder",
|
||||
"GroupChatOrchestrator",
|
||||
"GroupChatRequestMessage",
|
||||
"GroupChatRequestSentEvent",
|
||||
"GroupChatSelectionFunction",
|
||||
"GroupChatState",
|
||||
"HandoffAgentExecutor",
|
||||
"HandoffAgentUserRequest",
|
||||
"HandoffBuilder",
|
||||
"HandoffConfiguration",
|
||||
"HandoffSentEvent",
|
||||
"MagenticAgentExecutor",
|
||||
"MagenticBuilder",
|
||||
"MagenticContext",
|
||||
"MagenticManagerBase",
|
||||
"MagenticOrchestrator",
|
||||
"MagenticOrchestratorEvent",
|
||||
"MagenticOrchestratorEventType",
|
||||
"MagenticPlanReviewRequest",
|
||||
"MagenticPlanReviewResponse",
|
||||
"MagenticProgressLedger",
|
||||
"MagenticProgressLedgerItem",
|
||||
"MagenticResetSignal",
|
||||
"SequentialBuilder",
|
||||
"StandardMagenticManager",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Redis integration namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports objects from:
|
||||
- ``agent-framework-redis``
|
||||
|
||||
Supported classes:
|
||||
- RedisContextProvider
|
||||
- RedisHistoryProvider
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
IMPORT_PATH = "agent_framework_redis"
|
||||
PACKAGE_NAME = "agent-framework-redis"
|
||||
_IMPORTS = ["RedisContextProvider", "RedisHistoryProvider"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
try:
|
||||
return getattr(importlib.import_module(IMPORT_PATH), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`"
|
||||
) from exc
|
||||
raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return _IMPORTS
|
||||
@@ -0,0 +1,11 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_redis import (
|
||||
RedisContextProvider,
|
||||
RedisHistoryProvider,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RedisContextProvider",
|
||||
"RedisHistoryProvider",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
[project]
|
||||
name = "agent-framework-core"
|
||||
description = "Microsoft Agent Framework for building AI Agents with Python. This is the core package that has all the core abstractions and implementations."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.11.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"typing-extensions>=4.15.0,<5",
|
||||
"pydantic>=2,<3",
|
||||
"python-dotenv>=1,<2",
|
||||
"opentelemetry-api>=1.39.0,<2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
all = [
|
||||
"mcp>=1.24.0,<2",
|
||||
"agent-framework-a2a",
|
||||
"agent-framework-ag-ui",
|
||||
"agent-framework-anthropic",
|
||||
"agent-framework-azure-ai-search",
|
||||
"agent-framework-azure-cosmos",
|
||||
"agent-framework-azurefunctions",
|
||||
"agent-framework-bedrock",
|
||||
"agent-framework-chatkit",
|
||||
"agent-framework-claude",
|
||||
"agent-framework-copilotstudio",
|
||||
"agent-framework-declarative",
|
||||
"agent-framework-devui",
|
||||
"agent-framework-durabletask",
|
||||
"agent-framework-foundry",
|
||||
"agent-framework-foundry-local",
|
||||
"agent-framework-github-copilot; python_version >= '3.11'",
|
||||
"agent-framework-hyperlight; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'",
|
||||
"agent-framework-lab",
|
||||
"agent-framework-mem0",
|
||||
"agent-framework-ollama",
|
||||
"agent-framework-openai",
|
||||
"agent-framework-orchestrations",
|
||||
"agent-framework-purview",
|
||||
"agent-framework-redis",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
# These optional integrations depend on agent-framework-core, so core cannot list them as
|
||||
# runtime dependencies. They are declared here so isolated source checks can resolve their APIs.
|
||||
dev = [
|
||||
"agent-framework-azure-contentunderstanding",
|
||||
"agent-framework-tools",
|
||||
"azure-ai-agentserver-core>=2.0.0b7,<3",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ['tests']
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = []
|
||||
timeout = 120
|
||||
markers = [
|
||||
"integration: marks tests as integration tests that require external services",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
include = ["agent_framework"]
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
incremental = false
|
||||
python_version = "3.10"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework --cov-report=term-missing:skip-covered -n auto --dist worksteal tests'
|
||||
|
||||
[tool.flit.module]
|
||||
name = "agent_framework"
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user