Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b55992bb67 | |||
| e8cec71ed8 | |||
| d7e63d7d0e | |||
| f59d5c67d8 | |||
| 26a0a7e8be | |||
| 616315339e | |||
| fcc5576b04 | |||
| 6cc7ddb73e | |||
| 39f4b5ec72 |
@@ -2,7 +2,7 @@ name: Issue Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, labeled]
|
||||
types: [opened, typed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -12,9 +12,7 @@ permissions:
|
||||
concurrency:
|
||||
group: >-
|
||||
issue-triage-${{ github.repository }}-${{
|
||||
((github.event.action == 'opened' && contains(github.event.issue.labels.*.name, 'bug'))
|
||||
|| (github.event.action == 'labeled' && github.event.label.name == 'bug'))
|
||||
&& github.event.issue.number
|
||||
github.event.issue.type.name == 'Bug' && github.event.issue.number
|
||||
|| github.run_id
|
||||
}}
|
||||
cancel-in-progress: true
|
||||
@@ -28,7 +26,7 @@ env:
|
||||
jobs:
|
||||
team_check:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ (github.event.action == 'opened' && contains(github.event.issue.labels.*.name, 'bug')) || (github.event.action == 'labeled' && github.event.label.name == 'bug') }}
|
||||
if: ${{ github.event.issue.type.name == 'Bug' }}
|
||||
outputs:
|
||||
is_team_member: ${{ steps.check.outputs.is_team_member }}
|
||||
issue_number: ${{ steps.issue.outputs.issue_number }}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: eavanvalkenburg
|
||||
date: 2026-06-11
|
||||
deciders: eavanvalkenburg
|
||||
---
|
||||
|
||||
# Python minimal hosting core and pluggable channels
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Agent Framework has several protocol-specific hosting surfaces. App authors who want one agent or workflow on multiple protocols must compose servers, routes, middleware, session handling, and lifecycle code by hand.
|
||||
|
||||
We will introduce a small Python hosting core that owns the common server shape and leaves protocol details inside channel packages. The first public contract must be intentionally narrow so Python can ship a base contract before adding identity linking, proactive delivery, or multicast behavior. Other language implementations may reuse the same conceptual boundary, but this ADR records the Python decision.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Keep the first host easy to explain: one app, one hostable target, one or more channels.
|
||||
- Reuse Agent Framework's existing agent, workflow, session, history, and checkpoint primitives.
|
||||
- Let channel packages own protocol parsing, protocol responses, authentication details, and native command surfaces.
|
||||
- Make session continuity explicit through a channel-supplied `ChannelSession(isolation_key=...)`.
|
||||
- Avoid approving cross-channel identity and delivery semantics before their safety model is reviewed.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. Keep only protocol-specific hosts.
|
||||
2. Ship a large hosting core with identity linking, authorization, background delivery, active-channel routing, and multicast in v1.
|
||||
3. Ship a minimal host/channel core now and track linking/multicast as follow-up work.
|
||||
|
||||
### Keep only protocol-specific hosts
|
||||
|
||||
- Good: no new abstraction or package surface.
|
||||
- Neutral: each protocol can continue evolving independently.
|
||||
- Bad: every multi-channel app still has to compose servers, lifecycle, and session handling by hand.
|
||||
|
||||
### Ship the large cross-channel host in v1
|
||||
|
||||
- Good: the richest cross-channel scenarios are available immediately.
|
||||
- Neutral: the host becomes the natural place to demonstrate identity and delivery policy.
|
||||
- Bad: v1 becomes a security-sensitive identity and delivery system before the safety model is reviewed.
|
||||
|
||||
### Ship the minimal core now
|
||||
|
||||
- Good: the host/channel boundary can be implemented, tested, and explained without solving linking and durable delivery at the same time.
|
||||
- Neutral: apps that need richer behavior must build it locally or wait for ADR-0028 follow-up work.
|
||||
- Bad: proactive delivery and multicast scenarios are deliberately absent from v1.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: **minimal host/channel core now, follow-up enhancements later**.
|
||||
|
||||
`AgentFrameworkHost` owns:
|
||||
|
||||
- one application object,
|
||||
- one hostable target (`SupportsAgentRun` agent-compatible object or a `Workflow`), and
|
||||
- one or more channels.
|
||||
|
||||
Channels own:
|
||||
|
||||
- contributed routes, middleware, commands, and lifecycle callbacks,
|
||||
- protocol-native request parsing into `ChannelRequest`,
|
||||
- protocol-native rendering of the originating response, and
|
||||
- any channel-specific authentication or signature validation.
|
||||
|
||||
The host owns:
|
||||
|
||||
- route/lifecycle aggregation,
|
||||
- invocation of the target,
|
||||
- `ChannelSession(isolation_key=...)` to `AgentSession` resolution and caching,
|
||||
- `reset_session(isolation_key=...)`,
|
||||
- host-level middleware, including Foundry isolation middleware only when the Foundry hosting environment flag is present,
|
||||
- invocation of per-channel hooks (`ChannelRunHook`, `ChannelResponseHook`, `ChannelStreamUpdateHook`), and
|
||||
- workflow checkpoint wiring through an explicit `checkpoint_location`.
|
||||
|
||||
`ChannelIdentity`, when present, is request metadata only. In v1 it is not a linking, authorization, or delivery key.
|
||||
|
||||
### Trust boundary for `isolation_key`
|
||||
|
||||
The host treats `ChannelSession.isolation_key` as a session partition key, not as proof of identity. Channels or host middleware must authenticate and authorize any externally supplied value before passing it to the host. For example, a Responses caller must not be allowed to choose an arbitrary `previous_response_id` or header-derived key unless the platform or middleware has already established that the caller owns that conversation. The host deliberately does not infer that trust from the string itself.
|
||||
|
||||
### Hook ownership
|
||||
|
||||
Channels provide hook configuration and protocol-native context. The host invokes those hooks as part of the common invocation pipeline:
|
||||
|
||||
- `ChannelRunHook` runs after channel parsing and before target invocation.
|
||||
- `ChannelResponseHook` runs after target invocation and before the originating channel serializes its response.
|
||||
- `ChannelStreamUpdateHook` is applied by the host while the channel consumes streamed updates because streaming serialization is protocol-specific.
|
||||
|
||||
`ChannelStreamUpdateHook` is an update hook, not a final-response sanitizer. Channels that use it for redaction or filtering must also apply equivalent policy to any final response they render. Channels choose whether the response is streaming before run hooks execute.
|
||||
|
||||
This keeps hook call conventions centralized while leaving protocol payload parsing and response formatting in channel packages.
|
||||
|
||||
### State owned by v1
|
||||
|
||||
`state_dir` is limited to host-owned local files for reset-session aliases and workflow checkpoint path derivation. It does not store linked identities, active-channel state, response-routing state, continuation records, durable runner queues, or delivery attempts. Those storage concerns belong to ADR-0028.
|
||||
|
||||
## Non-goals for v1
|
||||
|
||||
The following are deliberately **not** part of the v1 contract:
|
||||
|
||||
- cross-channel identity linking (`IdentityLinker`, `local_identity_link`, or `agent-framework-hosting-entra`),
|
||||
- identity allowlists or authorization policy (`IdentityAllowlist`, `AuthPolicy`),
|
||||
- response routing beyond the originating channel (`ResponseTarget`, active channel, specific linked channel, `all_linked`),
|
||||
- push or payload codecs (`ChannelPush`, `ChannelPushCodec`),
|
||||
- background/continuation delivery,
|
||||
- durable task runners (`DurableTaskRunner`, `InProcessTaskRunner`),
|
||||
- retry/replay policy (`RetryPolicy`),
|
||||
- fan-out, multicast, or all-linked delivery,
|
||||
- confidentiality tiers and `LinkPolicy`, and
|
||||
- a host-level multi-agent router.
|
||||
|
||||
These areas are follow-up enhancements covered by [ADR-0028](0028-hosting-linking-multicast-enhancements.md). They are not prerequisites for shipping or using the v1 host.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- The host/channel model can be implemented and tested without designing a security-sensitive identity graph.
|
||||
- Existing and new channel packages can share one Starlette app, middleware stack, lifecycle, and target invocation path.
|
||||
- Session continuity is explicit and debuggable: two channels share history only when they produce the same `isolation_key`.
|
||||
- Hook invocation is centralized in the host, so channels do not each invent the call convention.
|
||||
|
||||
Negative:
|
||||
|
||||
- Apps that need OAuth linking, allowlists, proactive messages, or multicast must continue to implement those behaviors outside the v1 host.
|
||||
- Some richer cross-channel scenarios from the original design move to a separate decision and validation cycle.
|
||||
- The host must document `isolation_key` trust clearly because it now provides the shared session boundary.
|
||||
|
||||
## Validation Gates
|
||||
|
||||
Before this ADR is accepted:
|
||||
|
||||
- A sample can expose one target on multiple channels with one `AgentFrameworkHost` and no handwritten Starlette route composition.
|
||||
- Built-in channel tests prove that routes, commands, startup, and shutdown callbacks are contributed by channels and aggregated by the host.
|
||||
- Session tests prove that identical `ChannelSession.isolation_key` values resolve to the same cached `AgentSession`, and `reset_session` rotates that mapping.
|
||||
- Channel tests prove that each channel renders only its own originating response; there is no host-level push, multicast, or active-channel delivery path.
|
||||
- Workflow tests or samples use an explicit `checkpoint_location`.
|
||||
- Foundry isolation middleware is documented and covered by integration or contract tests, including the non-Foundry case where raw isolation headers are ignored.
|
||||
- The v1 API and packages do not expose the removed symbols or packages listed in [Non-goals for v1](#non-goals-for-v1).
|
||||
- The Python spec is updated to match this simplified contract and uses "public", "stable", or "released" terminology for Agent Framework APIs.
|
||||
|
||||
## More Information
|
||||
|
||||
- Python v1 specification: [SPEC-002](../specs/002-python-hosting-channels.md)
|
||||
- Follow-up linking and multicast ADR: [ADR-0028](0028-hosting-linking-multicast-enhancements.md)
|
||||
@@ -1,132 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: eavanvalkenburg
|
||||
date: 2026-06-11
|
||||
deciders: eavanvalkenburg
|
||||
---
|
||||
|
||||
# Hosting linking and multicast enhancements
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
[ADR-0027](0027-hosting-channels.md) defines the minimal v1 hosting core: originating-channel responses, explicit `ChannelSession.isolation_key`, and no host-level identity linking, push, multicast, background delivery, or durable runners.
|
||||
|
||||
This ADR tracks the richer cross-channel behaviors that were removed from v1. These enhancements are **follow-up work** and are **not prerequisites** for shipping, using, or stabilizing the v1 host/channel core.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Cross-channel continuity must not create accidental cross-user, cross-tenant, or cross-channel data leaks.
|
||||
- Non-originating delivery must be observable, idempotent, retryable, and supportable.
|
||||
- Protocol payloads must remain channel-native while still being safe to persist and replay.
|
||||
- App authors need opt-in policy controls, not hidden defaults.
|
||||
- The enhancement stack should layer on top of the v1 host without reshaping the minimal channel contract.
|
||||
|
||||
## Enhancement Areas
|
||||
|
||||
The follow-up design should cover these capabilities together because they share identity, storage, delivery, and replay concerns:
|
||||
|
||||
- **Cross-channel identity linking** — a user can connect multiple `ChannelIdentity` values to one channel-neutral `isolation_key`.
|
||||
- **Authorization and allowlist policy** — channels or hosts can require verified identity, allow specific native identities or claims, and deny unknown callers.
|
||||
- **Non-originating response delivery** — a run can respond somewhere other than the request's originating protocol when explicitly configured.
|
||||
- **Active-channel routing** — delivery can target the most recently observed linked channel for an `isolation_key`.
|
||||
- **Multicast / all-linked delivery** — delivery can fan out to every linked channel or a selected set.
|
||||
- **Background runs and continuation tokens** — long-running requests can return immediately and complete later, with a polling/status fallback.
|
||||
- **Durable delivery runners** — delivery work can survive process restarts and support dead-letter handling.
|
||||
- **Retry and replay semantics** — delivery attempts are bounded, deduplicated, and safe to replay.
|
||||
- **Payload serialization** — channel-specific payloads can be persisted, redacted, versioned, and reconstructed without losing protocol fidelity.
|
||||
|
||||
Candidate API names from the broader design (`IdentityLinker`, `IdentityAllowlist`, `AuthPolicy`, `ResponseTarget`, `ChannelPush`, `ChannelPushCodec`, `DurableTaskRunner`, `InProcessTaskRunner`, `RetryPolicy`, `LinkPolicy`) remain design vocabulary for this ADR. They are not approved v1 APIs.
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option A — Leave all behavior to applications
|
||||
|
||||
Applications implement linking, authorization, push, retry, and serialization independently.
|
||||
|
||||
- Good: the hosting core stays very small.
|
||||
- Neutral: advanced apps can still build what they need.
|
||||
- Bad: every app must solve the same security and delivery problems, likely inconsistently.
|
||||
|
||||
### Option B — Add the full enhancement stack to v1
|
||||
|
||||
The first host release includes linking, authorization, active channel, multicast, background runs, durable runners, and codecs.
|
||||
|
||||
- Good: the original cross-channel experience is available immediately.
|
||||
- Neutral: samples can demonstrate rich end-to-end flows.
|
||||
- Bad: v1 becomes security-sensitive, storage-heavy, and harder to stabilize.
|
||||
|
||||
### Option C — Layer opt-in enhancement packages after v1
|
||||
|
||||
Ship the minimal host first, then add linking, authorization, and delivery packages behind explicit configuration.
|
||||
|
||||
- Good: v1 remains simple while leaving room for a reviewed, supportable enhancement stack.
|
||||
- Neutral: apps that need advanced delivery wait for follow-up packages.
|
||||
- Bad: the first release does not satisfy proactive or all-linked scenarios.
|
||||
|
||||
### Option D — Build only platform-specific integrations
|
||||
|
||||
Implement linking and proactive delivery separately in Telegram, Activity Protocol, Discord, and future channels.
|
||||
|
||||
- Good: each package can match its protocol exactly.
|
||||
- Neutral: some shared abstractions may emerge later.
|
||||
- Bad: cross-channel behavior becomes fragmented and hard to reason about.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Proposed direction: **Option C — layered opt-in enhancement packages after v1**.
|
||||
|
||||
The minimal host remains the foundation. Follow-up packages may add linking, authorization, delivery, and durable execution, but must be explicitly enabled and must pass the validation gates below before becoming part of the public contract.
|
||||
|
||||
## Safety Requirements
|
||||
|
||||
### Threat model
|
||||
|
||||
The design must account for:
|
||||
|
||||
- spoofed channel-native identities,
|
||||
- stolen or replayed link challenges,
|
||||
- cross-tenant or cross-confidentiality data leakage,
|
||||
- unsolicited proactive messages,
|
||||
- malicious payloads persisted for replay,
|
||||
- denial-of-service through fan-out or retry storms, and
|
||||
- privacy leakage through logs, metrics, or support tooling.
|
||||
|
||||
Required mitigations include verified identity claims where available, signed and expiring link challenges, explicit user consent, per-channel capability checks, default-deny policy options, tenant partitioning, and uninformative denial messages on shared channels.
|
||||
|
||||
### Idempotency and replay
|
||||
|
||||
Exactly-once delivery is not a realistic guarantee. The design must provide:
|
||||
|
||||
- stable run, continuation, and delivery-attempt identifiers,
|
||||
- channel-level idempotency keys where protocols support them,
|
||||
- bounded retry with jitter and explicit terminal states,
|
||||
- replay windows and expiration,
|
||||
- duplicate suppression for persisted attempts, and
|
||||
- clear semantics for "delivered", "accepted by platform", and "observed by user".
|
||||
|
||||
### Storage
|
||||
|
||||
Enhancement storage must stay distinct from v1 `AgentSession` history and workflow checkpoints unless an implementation deliberately backs them with the same physical store.
|
||||
|
||||
Stored data should be schema-versioned, minimized, encrypted or otherwise protected as appropriate, and partitioned by tenant/project. Link records, continuation records, active-channel state, delivery attempts, dead letters, and serialized payloads need independent TTL and deletion policies.
|
||||
|
||||
### Observability and support
|
||||
|
||||
The design must include structured logs, traces, and metrics for link attempts, authorization decisions, delivery scheduling, retries, replay, and dead-letter outcomes. Logs must avoid message content and sensitive identity claims by default. Operators need a way to inspect, revoke, replay, or purge stuck records safely.
|
||||
|
||||
## Validation Gates
|
||||
|
||||
Before these enhancements are accepted:
|
||||
|
||||
- A reviewed threat model covers identity linking, authorization, non-originating delivery, multicast, and replay.
|
||||
- Cross-channel linking tests prove a verified identity can link two channels and that unlink/deny paths do not leak information.
|
||||
- Authorization tests cover native-id allowlists, verified-claim allowlists, default-deny behavior, and misconfiguration failures.
|
||||
- Delivery tests cover originating-only, specific-channel, active-channel, selected-channel, and all-linked routing.
|
||||
- Background/continuation tests cover polling fallback, cancellation or expiration, process restart, retry, and dead-letter behavior.
|
||||
- Codec tests prove payloads are versioned, redacted where needed, backward compatible, and rejected safely when unknown.
|
||||
- Multicast tests prove fan-out is bounded, independently retried, and idempotent per destination.
|
||||
- Observability tests or manual validation prove support operators can correlate a request to delivery attempts without exposing sensitive content.
|
||||
|
||||
## Relationship to ADR-0027
|
||||
|
||||
ADR-0027 remains valid without any of these enhancements. This ADR extends the hosting model only after the safety, storage, and support requirements above are satisfied.
|
||||
@@ -1,320 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: eavanvalkenburg
|
||||
date: 2026-06-11
|
||||
deciders: eavanvalkenburg
|
||||
---
|
||||
|
||||
# Python hosting core and pluggable channels
|
||||
|
||||
## Scope
|
||||
|
||||
This specification is the Python implementation plan for [ADR-0027](../decisions/0027-hosting-channels.md). It documents the simplified v1 host/channel contract only.
|
||||
|
||||
The v1 contract is:
|
||||
|
||||
- `AgentFrameworkHost` owns one Starlette app, one hostable target, and one or more channels.
|
||||
- A hostable target is either a `SupportsAgentRun`-compatible agent or a `Workflow`.
|
||||
- Channels contribute routes, middleware, commands, and lifecycle callbacks.
|
||||
- Channels parse protocol-native input into `ChannelRequest`.
|
||||
- Channels render their own originating response.
|
||||
- Session continuity is explicit: a channel supplies `ChannelSession(isolation_key=...)`, and the host resolves/caches an `AgentSession` for that key.
|
||||
- The host invokes `ChannelRunHook` and `ChannelResponseHook`; channels provide hook configuration and protocol context.
|
||||
|
||||
The host does not link identities, route responses to other channels, run background continuations, or multicast in v1. Those enhancements are tracked in [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md).
|
||||
|
||||
## Goals
|
||||
|
||||
- Let an app expose one agent or workflow on multiple protocols without handwritten Starlette composition.
|
||||
- Keep protocol parsing and response formatting inside channel packages.
|
||||
- Provide one session-resolution path shared by all channels.
|
||||
- Keep the channel authoring surface small enough for new channels to implement.
|
||||
- Preserve full-fidelity agent and workflow results until a channel decides how to render them.
|
||||
|
||||
## Non-goals for v1
|
||||
|
||||
The following are removed from the v1 implementation pass:
|
||||
|
||||
- `IdentityLinker`, `IdentityAllowlist`, `AuthPolicy`, and `LinkPolicy`
|
||||
- `ResponseTarget`, active-channel routing, `all_linked`, fan-out, and multicast
|
||||
- `ChannelPush` and `ChannelPushCodec`
|
||||
- `DurableTaskRunner`, `InProcessTaskRunner`, and `RetryPolicy`
|
||||
- continuation tokens and background delivery
|
||||
- confidentiality tiers
|
||||
- `agent-framework-hosting-entra`
|
||||
- `local_identity_link`
|
||||
|
||||
These are follow-up design topics, not hidden requirements of the v1 host.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Import surface | Contents |
|
||||
|---|---|---|
|
||||
| `agent-framework-hosting` | `agent_framework_hosting` | `AgentFrameworkHost`, channel protocols, key request/result types, hooks, `reset_session`, state-path helpers. |
|
||||
| `agent-framework-hosting-responses` | `agent_framework_hosting_responses` | `ResponsesChannel`. |
|
||||
| `agent-framework-hosting-invocations` | `agent_framework_hosting_invocations` | `InvocationsChannel`. |
|
||||
| `agent-framework-hosting-telegram` | `agent_framework_hosting_telegram` | `TelegramChannel` and Telegram command helpers. |
|
||||
| `agent-framework-hosting-activity-protocol` | `agent_framework_hosting_activity_protocol` | `ActivityProtocolChannel` for Activity Protocol over Azure Bot Service. |
|
||||
| `agent-framework-hosting-discord` | `agent_framework_hosting_discord` | `DiscordChannel` and Discord command/interaction helpers. |
|
||||
| `agent-framework-foundry-hosting` | `agent_framework.foundry_hosting` | Foundry isolation middleware and Foundry-backed hosting helpers usable with the v1 host. |
|
||||
|
||||
Channel packages may depend on their native SDKs. The core hosting package should not depend on channel SDKs or on top-level legacy protocol hosts.
|
||||
|
||||
## Key Types
|
||||
|
||||
### `AgentFrameworkHost`
|
||||
|
||||
The host constructor accepts:
|
||||
|
||||
- `target`: one `SupportsAgentRun`-compatible object or one `Workflow`
|
||||
- `channels`: one or more `Channel` instances
|
||||
- optional Starlette middleware
|
||||
- optional `state_dir`
|
||||
- optional workflow `checkpoint_location`
|
||||
|
||||
The host exposes:
|
||||
|
||||
- `app`: the canonical Starlette ASGI application
|
||||
- `serve(...)`: a convenience wrapper for local serving
|
||||
- `reset_session(isolation_key: str)`: rotate the cached `AgentSession` for a host-tracked conversation
|
||||
|
||||
`state_dir` is narrowed to v1 host-owned local files only:
|
||||
|
||||
- session aliases (`isolation_key` to current `AgentSession` id), and
|
||||
- workflow checkpoint paths when the app chooses the host-provided file layout.
|
||||
|
||||
It is not a store for identity links, continuations, active-channel state, delivery attempts, or multicast payloads.
|
||||
|
||||
Externally supplied isolation keys are trusted only after the channel or host middleware has authenticated and authorized the caller. The host uses `isolation_key` as a partition key; the string itself is not proof of identity or ownership.
|
||||
|
||||
### `Channel`
|
||||
|
||||
A channel implements a small protocol:
|
||||
|
||||
- declare a stable channel id/name,
|
||||
- contribute routes, middleware, commands, and lifecycle callbacks,
|
||||
- parse inbound protocol data into `ChannelRequest`,
|
||||
- call the host through `ChannelContext.run(...)` or `ChannelContext.run_stream(...)`, and
|
||||
- serialize the returned result to the originating protocol response.
|
||||
|
||||
Channels own protocol authentication, signature validation, native command registration, and protocol-specific error bodies.
|
||||
|
||||
### `ChannelContribution`
|
||||
|
||||
`ChannelContribution` is the channel's host-facing contribution:
|
||||
|
||||
- Starlette routes and optional middleware,
|
||||
- native command descriptors,
|
||||
- startup and shutdown callbacks, and
|
||||
- any channel-local metadata needed by the package.
|
||||
|
||||
The host aggregates contributions but does not interpret protocol payloads.
|
||||
|
||||
### `ChannelRequest`
|
||||
|
||||
`ChannelRequest` is the host-neutral request envelope produced by a channel. It carries:
|
||||
|
||||
- target input,
|
||||
- optional `ChannelSession`,
|
||||
- optional `ChannelIdentity`,
|
||||
- options and attributes produced by the channel, and
|
||||
- request metadata useful to hooks and context providers.
|
||||
|
||||
The host may pass attributes through to context providers and middleware. Channels should treat attributes as a documented extension bag, not as a cross-channel delivery contract.
|
||||
|
||||
### `ChannelSession`
|
||||
|
||||
`ChannelSession(isolation_key=...)` is the only v1 session-continuity mechanism.
|
||||
|
||||
When a request contains an isolation key:
|
||||
|
||||
1. The host looks up or creates the cached `AgentSession` for that key.
|
||||
2. The target runs with that `AgentSession` when the target is an agent.
|
||||
3. `reset_session(isolation_key)` rotates the alias so the next request starts a new conversation.
|
||||
|
||||
If two channels produce the same isolation key on the same host, they share the same cached session. If they produce different keys, they do not share session state.
|
||||
|
||||
### `ChannelIdentity`
|
||||
|
||||
`ChannelIdentity` is optional request metadata such as channel id, native user id, tenant id, claims, or display attributes.
|
||||
|
||||
In v1, `ChannelIdentity` does not link channels, authorize callers, select delivery destinations, or imply that two identities should share an `AgentSession`. A channel that wants shared history must still produce the same `ChannelSession.isolation_key`.
|
||||
|
||||
### Hooks
|
||||
|
||||
Hooks are optional and channel-owned:
|
||||
|
||||
- `ChannelRunHook`: runs after channel parsing and before host invocation; returns the `ChannelRequest` to execute.
|
||||
- `ChannelResponseHook`: runs after target completion and before the originating channel renders a one-shot response.
|
||||
- `ChannelStreamUpdateHook`: the host applies it to streamed updates before the originating channel serializes the stream.
|
||||
|
||||
Common uses include adapting chat text into workflow inputs, enforcing deployment-specific options, flattening rich output for text-only protocols, or filtering streamed updates for a protocol. Stream update hooks are update-only; they do not automatically sanitize `get_final_response()` output. Channels choose their response transport from the parsed protocol request before invoking run hooks.
|
||||
|
||||
### `HostedRunResult`
|
||||
|
||||
`HostedRunResult[T]` wraps the target's full-fidelity result plus the resolved `AgentSession | None`.
|
||||
|
||||
- Agent targets produce `HostedRunResult[AgentResponse]`.
|
||||
- Workflow targets produce `HostedRunResult[WorkflowRunResult]`.
|
||||
|
||||
The host does not flatten, filter, or translate the result. Each channel decides how much of the result its protocol can carry.
|
||||
|
||||
## Host Behavior
|
||||
|
||||
1. `AgentFrameworkHost` builds one Starlette app and asks each channel for its contribution.
|
||||
2. A channel route receives a protocol-native request.
|
||||
3. The channel validates/parses the native payload and creates `ChannelRequest`.
|
||||
4. The channel passes the request, optional `ChannelRunHook`, and protocol-native context to the host.
|
||||
5. The host invokes `ChannelRunHook`, if configured, and receives the prepared request.
|
||||
6. The host resolves an `AgentSession` from `ChannelSession.isolation_key` when present.
|
||||
7. The host invokes the agent or workflow target.
|
||||
8. The host wraps the result in `HostedRunResult` or the streaming equivalent.
|
||||
9. The host invokes `ChannelResponseHook`, if configured, for non-streaming/final response shaping.
|
||||
10. The host applies stream update hooks while the channel consumes streams; the channel renders the originating protocol response.
|
||||
|
||||
There is no host-level route from one channel's request to another channel's response in v1.
|
||||
|
||||
## Workflow Checkpoints
|
||||
|
||||
Workflow checkpointing is explicit. Apps either configure checkpoint storage on the workflow itself or pass a `checkpoint_location` to the host so the workflow dispatch path can use the intended file location.
|
||||
|
||||
`state_dir` may provide a conventional location for workflow checkpoint files, but checkpointing is still opt-in and separate from agent session history. Checkpoints are workflow-runtime state, not channel state and not identity-link state.
|
||||
|
||||
## Foundry Isolation Middleware
|
||||
|
||||
V1 keeps Foundry isolation as middleware rather than as a channel-linking feature.
|
||||
|
||||
The middleware is installed only when the Foundry hosting environment flag is present. In that environment it reads Foundry-provided isolation values at the trusted hosting boundary, exposes them as read-only request context for Foundry-aware history or memory providers, and rejects unsafe session resumes when the live isolation context does not match persisted session context. Outside Foundry, raw isolation headers are ignored unless an app supplies its own trusted middleware.
|
||||
|
||||
This middleware does not create cross-channel identity links and does not authorize non-Foundry channels.
|
||||
|
||||
## Current Channels
|
||||
|
||||
### Responses
|
||||
|
||||
`ResponsesChannel` exposes the OpenAI-compatible Responses API shape. It maps request body fields such as input, options, and conversation identifiers into `ChannelRequest`, and it renders Responses-compatible one-shot or streaming responses.
|
||||
|
||||
Responses session continuity uses a channel-selected `isolation_key`, commonly derived from a response/conversation id, caller-provided session id, Foundry isolation context, or deployment-specific request metadata.
|
||||
|
||||
### Invocations
|
||||
|
||||
`InvocationsChannel` exposes an invocation endpoint for server-side callers and tools. It maps the request body into `ChannelRequest` and renders the invocation result on the same HTTP response.
|
||||
|
||||
Invocations is useful for typed workflow inputs because a `ChannelRunHook` can translate the request body into the workflow's expected input type.
|
||||
|
||||
### Telegram
|
||||
|
||||
`TelegramChannel` supports webhook or polling transport, native command registration, and message rendering back to the originating Telegram chat.
|
||||
|
||||
The channel chooses a default `isolation_key` from Telegram-native data such as chat id, user id, or a configured user/chat scope. A `/new` or equivalent command may call `reset_session` for that isolation key.
|
||||
|
||||
### Activity Protocol
|
||||
|
||||
`ActivityChannel` supports Activity Protocol requests, typically through Azure Bot Service for Teams, Web Chat, and other Bot Framework-fronted surfaces.
|
||||
|
||||
The channel maps incoming `Activity` objects to `ChannelRequest` and renders a reply activity to the originating conversation. Proactive Activity delivery, active-channel routing, and all-linked fan-out are not v1 host semantics.
|
||||
|
||||
### Discord
|
||||
|
||||
`DiscordChannel` supports Discord messages, slash commands, and interactions as channel-native input.
|
||||
|
||||
The channel maps Discord-native user, guild, channel, thread, and interaction data into `ChannelRequest` metadata and a configured `ChannelSession.isolation_key`. It renders the result to the originating Discord response path.
|
||||
|
||||
## High-level Samples
|
||||
|
||||
### One agent on Responses
|
||||
|
||||
```python
|
||||
host = AgentFrameworkHost(
|
||||
target=agent,
|
||||
channels=[ResponsesChannel()],
|
||||
)
|
||||
|
||||
app = host.app
|
||||
```
|
||||
|
||||
### One agent on multiple channels
|
||||
|
||||
```python
|
||||
host = AgentFrameworkHost(
|
||||
target=agent,
|
||||
channels=[
|
||||
ResponsesChannel(),
|
||||
InvocationsChannel(),
|
||||
TelegramChannel(bot_token=os.environ["TELEGRAM_BOT_TOKEN"]),
|
||||
],
|
||||
)
|
||||
|
||||
host.serve(host="localhost", port=8000)
|
||||
```
|
||||
|
||||
The host owns one Starlette app. Each channel contributes its own routes and renders its own response.
|
||||
|
||||
### Adapting a request before execution
|
||||
|
||||
```python
|
||||
from dataclasses import replace
|
||||
|
||||
|
||||
def enforce_options(request: ChannelRequest) -> ChannelRequest:
|
||||
options = dict(request.options or {})
|
||||
options["temperature"] = 0
|
||||
return replace(request, options=options)
|
||||
|
||||
|
||||
host = AgentFrameworkHost(
|
||||
target=agent,
|
||||
channels=[ResponsesChannel(run_hook=enforce_options)],
|
||||
)
|
||||
```
|
||||
|
||||
### Workflow with explicit checkpoints
|
||||
|
||||
```python
|
||||
host = AgentFrameworkHost(
|
||||
target=workflow,
|
||||
channels=[InvocationsChannel(run_hook=adapt_to_workflow_input)],
|
||||
checkpoint_location=Path("./.af-hosting/workflow_checkpoints"),
|
||||
)
|
||||
```
|
||||
|
||||
The hook adapts channel-native input to the workflow's typed input. Checkpoints use the explicit workflow checkpoint location, not identity-link or delivery storage.
|
||||
|
||||
### Message channel reset command
|
||||
|
||||
```python
|
||||
async def new_chat(context):
|
||||
if context.request.session is not None:
|
||||
await context.host.reset_session(context.request.session.isolation_key)
|
||||
await context.reply("Started a new conversation.")
|
||||
```
|
||||
|
||||
Telegram, Activity Protocol, and Discord can expose equivalent native commands when their protocols support them.
|
||||
|
||||
## Follow-up Enhancements
|
||||
|
||||
See [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md) for the deferred design covering:
|
||||
|
||||
- cross-channel identity linking,
|
||||
- authorization and allowlists,
|
||||
- non-originating response delivery,
|
||||
- active-channel routing,
|
||||
- multicast and all-linked delivery,
|
||||
- background runs and continuation tokens,
|
||||
- durable delivery runners,
|
||||
- retry/replay semantics, and
|
||||
- payload serialization.
|
||||
|
||||
Those enhancements must layer on top of this v1 contract without requiring v1 users to adopt them.
|
||||
|
||||
## Validation Gates
|
||||
|
||||
The Python implementation should be considered complete when:
|
||||
|
||||
- a sample uses one `AgentFrameworkHost` with multiple channels and no manual Starlette route composition,
|
||||
- each current channel has contract tests for route contribution, lifecycle, request parsing, hooks, and originating response rendering,
|
||||
- session tests prove shared `isolation_key` values share an `AgentSession` and `reset_session` rotates it,
|
||||
- workflow tests or samples use explicit `checkpoint_location`,
|
||||
- Foundry isolation middleware is covered by integration or contract tests,
|
||||
- no v1 package exposes the removed linking, multicast, durable-runner, or continuation APIs, and
|
||||
- this spec and ADR-0027 remain aligned.
|
||||
@@ -331,6 +331,9 @@
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/HostedLocalCodeAct.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj" />
|
||||
</Folder>
|
||||
@@ -616,6 +619,7 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.LocalCodeAct/Microsoft.Agents.AI.LocalCodeAct.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Mcp/Microsoft.Agents.AI.Mcp.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
@@ -671,6 +675,7 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/Microsoft.Agents.AI.LocalCodeAct.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Mcp.UnitTests/Microsoft.Agents.AI.Mcp.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
|
||||
|
||||
+7
-7
@@ -6,22 +6,22 @@ using Microsoft.Extensions.AI;
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>BackgroundAgents_*</c> tool calls with human-readable details
|
||||
/// Formats <c>background_agents_*</c> tool calls with human-readable details
|
||||
/// for task start, continue, wait, and result retrieval operations.
|
||||
/// </summary>
|
||||
public sealed class BackgroundAgentToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("BackgroundAgents_", StringComparison.Ordinal);
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("background_agents_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"BackgroundAgents_StartTask" => FormatStartBackgroundTask(call),
|
||||
"BackgroundAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
|
||||
"BackgroundAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
|
||||
"BackgroundAgents_ContinueTask" => FormatContinueTask(call),
|
||||
"BackgroundAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
|
||||
"background_agents_start_task" => FormatStartBackgroundTask(call),
|
||||
"background_agents_wait_for_first_completion" => FormatIdList(call, "taskIds", "Wait for"),
|
||||
"background_agents_get_task_results" => FormatSingleId(call, "taskId"),
|
||||
"background_agents_continue_task" => FormatContinueTask(call),
|
||||
"background_agents_clear_completed_task" => FormatSingleId(call, "taskId"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
+6
-6
@@ -5,21 +5,21 @@ using Microsoft.Extensions.AI;
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>FileMemory_*</c> tool calls, showing file names and search patterns
|
||||
/// Formats <c>file_memory_*</c> tool calls, showing file names and search patterns
|
||||
/// with tree-view corners for save operations.
|
||||
/// </summary>
|
||||
public sealed class FileMemoryToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("FileMemory_", StringComparison.Ordinal);
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("file_memory_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"FileMemory_SaveFile" => FormatSaveFile(call),
|
||||
"FileMemory_ReadFile" => FormatStringArg(call, "fileName"),
|
||||
"FileMemory_DeleteFile" => FormatStringArg(call, "fileName"),
|
||||
"FileMemory_SearchFiles" => FormatSearchFiles(call),
|
||||
"file_memory_save_file" => FormatSaveFile(call),
|
||||
"file_memory_read_file" => FormatStringArg(call, "fileName"),
|
||||
"file_memory_delete_file" => FormatStringArg(call, "fileName"),
|
||||
"file_memory_search_files" => FormatSearchFiles(call),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -89,6 +89,13 @@ AIAgent agent =
|
||||
OpenTelemetrySourceName = TracingSourceName, // Use our custom source name so spans are captured by the TracerProvider above.
|
||||
FileMemoryStore = new FileSystemAgentFileStore( // Configure the file memory provider to store files in a local folder called "agent-files".
|
||||
Path.Combine(AppContext.BaseDirectory, "agent-files")),
|
||||
// The built in ModeProvider has two default modes: "plan" and "execute".
|
||||
// Adding a loop evaluator so that in "execute" mode, the harness keeps re-invoking itself until every todo item is complete.
|
||||
LoopEvaluators =
|
||||
[
|
||||
new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = ["execute"] }),
|
||||
],
|
||||
LoopAgentOptions = new LoopAgentOptions { MaxIterations = 10 }, // Safety cap on the number of autonomous passes per turn.
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
|
||||
@@ -9,6 +9,7 @@ Key features showcased:
|
||||
- **Web Search** — the agent can search the web for current information via `ResponseTool.CreateWebSearchTool()`
|
||||
- **TodoProvider** — the agent creates and manages a todo list to track research questions
|
||||
- **AgentModeProvider** — the agent switches between "plan" mode (breaking down the topic) and "execute" mode (answering each research question)
|
||||
- **TodoCompletionLoopEvaluator** — in "execute" mode the agent loops automatically, re-invoking itself until every todo item is complete (capped by `LoopAgentOptions.MaxIterations`). The loop is scoped to "execute" mode, so "plan" mode stays interactive. The `HarnessAgent` wraps itself in a `LoopAgent` automatically whenever `LoopEvaluators` is supplied.
|
||||
- **Interactive conversation** — you can review the agent's plan, provide feedback, and approve before execution begins
|
||||
- **Streaming output** — responses are streamed token-by-token for a natural experience
|
||||
- **`/todos` command** — view the current todo list at any time without invoking the agent
|
||||
@@ -47,7 +48,7 @@ The sample starts an interactive conversation loop. You can:
|
||||
1. **Enter a research topic** — the agent will analyze it and create a plan with todos
|
||||
2. **Review and adjust** — provide feedback on the plan, ask for changes, or approve it
|
||||
3. **Type `/todos`** — to see the current todo list at any time
|
||||
4. **Watch execution** — once approved, tell the agent to proceed and it will work through each todo
|
||||
4. **Watch execution** — once approved, the agent will switch to "execute" mode and process each todo autonomously until the whole plan is complete
|
||||
5. **Type `exit`** — to end the session
|
||||
|
||||
The prompt and agent output are colored by the current mode: **cyan** during planning, **green** during execution.
|
||||
|
||||
+10
-10
@@ -9,16 +9,16 @@ A parent agent receives a list of stock tickers and uses a web-search background
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ StockPriceResearcher │
|
||||
│ (Parent Agent) │
|
||||
│ │
|
||||
│ BackgroundAgentsProvider │
|
||||
│ ├─ BackgroundAgents_StartTask │
|
||||
│ ├─ BackgroundAgents_WaitFor... │
|
||||
│ ├─ BackgroundAgents_GetTaskResults │
|
||||
│ └─ ... │
|
||||
└────────────┬───────────────────────────┘
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ StockPriceResearcher │
|
||||
│ (Parent Agent) │
|
||||
│ │
|
||||
│ BackgroundAgentsProvider │
|
||||
│ ├─ background_agents_start_task │
|
||||
│ ├─ background_agents_wait_for_first_completion│
|
||||
│ ├─ background_agents_get_task_results │
|
||||
│ └─ ... │
|
||||
└─────────────┬────────────────────────────────────┘
|
||||
│ delegates to
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
|
||||
@@ -116,7 +116,7 @@ async Task TodoLoopAsync()
|
||||
{
|
||||
var todoProvider = context.Agent.GetService<TodoProvider>()
|
||||
?? throw new InvalidOperationException("The agent did not expose a TodoProvider.");
|
||||
var remaining = await todoProvider.GetRemainingTodosAsync(context.Session).ConfigureAwait(false);
|
||||
var remaining = await todoProvider.GetRemainingTodosAsync(context.Session, cancellationToken).ConfigureAwait(false);
|
||||
return remaining.Count > 0
|
||||
? LoopEvaluation.Continue($"Not all todos are complete yet ({remaining.Count} remaining). Please complete the remaining todo items.")
|
||||
: LoopEvaluation.Stop();
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
FOUNDRY_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
FOUNDRY_MODEL=gpt-4o
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
LOCAL_CODEACT_PYTHON=python3
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python 3 so LocalCodeAct can spawn the embedded runner / validator.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENV LOCAL_CODEACT_PYTHON=python3
|
||||
ENTRYPOINT ["dotnet", "HostedLocalCodeAct.dll"]
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry and
|
||||
# Microsoft.Agents.AI.LocalCodeAct sources, which means a standard multi-stage
|
||||
# Docker build cannot resolve dependencies outside this folder. Instead, pre-publish
|
||||
# the app targeting the container runtime and copy the output into the container:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-local-codeact .
|
||||
# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-local-codeact -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-local-codeact
|
||||
#
|
||||
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python 3 so LocalCodeAct can spawn the embedded runner / validator.
|
||||
RUN apk add --no-cache python3
|
||||
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENV LOCAL_CODEACT_PYTHON=python3
|
||||
ENTRYPOINT ["dotnet", "HostedLocalCodeAct.dll"]
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.LocalCodeAct\Microsoft.Agents.AI.LocalCodeAct.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.LocalCodeAct" Version="1.6.1-preview.260514.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Hosted Local CodeAct sample. Wires Microsoft.Agents.AI.LocalCodeAct into a
|
||||
// Foundry hosted agent. The model only sees a single `execute_code` tool;
|
||||
// `compute` and `fetch_data` are registered as sandbox-only host tools that
|
||||
// generated Python reaches via `await call_tool(...)`. This mirrors the Python
|
||||
// `foundry_hosted_agent.py` sample for the local-codeact package.
|
||||
//
|
||||
// SECURITY: LocalCodeAct executes LLM-generated Python in the agent process.
|
||||
// Only deploy this sample to an externally sandboxed environment such as a
|
||||
// Foundry hosted-agent container.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Agents.AI.LocalCodeAct;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o";
|
||||
string pythonExecutable = Environment.GetEnvironmentVariable("LOCAL_CODEACT_PYTHON")
|
||||
?? (OperatingSystem.IsWindows() ? "python.exe" : "python3");
|
||||
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
// ── Sandbox-only tools (model never sees these directly) ─────────────────────
|
||||
|
||||
[Description("Perform a math operation: add, subtract, multiply, or divide.")]
|
||||
static double Compute(
|
||||
[Description("Operation: add, subtract, multiply, or divide.")] string operation,
|
||||
[Description("First numeric operand.")] double a,
|
||||
[Description("Second numeric operand.")] double b) => operation switch
|
||||
{
|
||||
"add" => a + b,
|
||||
"subtract" => a - b,
|
||||
"multiply" => a * b,
|
||||
"divide" => b == 0 ? double.PositiveInfinity : a / b,
|
||||
_ => throw new ArgumentException($"Unknown operation '{operation}'.", nameof(operation)),
|
||||
};
|
||||
|
||||
[Description("Fetch records from a named simulated table (users or products).")]
|
||||
static IReadOnlyList<IReadOnlyDictionary<string, object>> FetchData(
|
||||
[Description("Name of the simulated table to query.")] string table)
|
||||
{
|
||||
Dictionary<string, IReadOnlyList<IReadOnlyDictionary<string, object>>> data = new()
|
||||
{
|
||||
["users"] =
|
||||
[
|
||||
new Dictionary<string, object> { ["id"] = 1, ["name"] = "Alice", ["role"] = "admin" },
|
||||
new Dictionary<string, object> { ["id"] = 2, ["name"] = "Bob", ["role"] = "user" },
|
||||
new Dictionary<string, object> { ["id"] = 3, ["name"] = "Charlie", ["role"] = "admin" },
|
||||
],
|
||||
["products"] =
|
||||
[
|
||||
new Dictionary<string, object> { ["id"] = 101, ["name"] = "Widget", ["price"] = 9.99 },
|
||||
new Dictionary<string, object> { ["id"] = 102, ["name"] = "Gadget", ["price"] = 19.99 },
|
||||
],
|
||||
};
|
||||
|
||||
return data.TryGetValue(table, out var rows) ? rows : [];
|
||||
}
|
||||
|
||||
// ── LocalCodeAct provider with sandbox-only host tools ───────────────────────
|
||||
|
||||
var codeActOptions = new LocalCodeActProviderOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
AIFunctionFactory.Create(Compute, name: "compute"),
|
||||
AIFunctionFactory.Create(FetchData, name: "fetch_data"),
|
||||
],
|
||||
ExecutionLimits = new ProcessExecutionLimits { TimeoutSeconds = 5 },
|
||||
};
|
||||
|
||||
var codeAct = new LocalCodeActProvider(pythonExecutable, codeActOptions);
|
||||
|
||||
// ── Build the hosted agent ───────────────────────────────────────────────────
|
||||
|
||||
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-local-codeact",
|
||||
Description = "Hosted CodeAct agent with sandbox-only compute and fetch_data tools.",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = deploymentName,
|
||||
Instructions =
|
||||
"""
|
||||
You are a helpful assistant. Keep your answers brief. Prefer orchestrating your work
|
||||
in a single `execute_code` block using `await call_tool(...)` over issuing many
|
||||
direct tool calls. The sandbox exposes `compute` and `fetch_data` via `call_tool`.
|
||||
""",
|
||||
},
|
||||
AIContextProviders = [codeAct],
|
||||
});
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
|
||||
app.Run();
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
# Hosted-LocalCodeAct
|
||||
|
||||
A hosted agent that uses [`Microsoft.Agents.AI.LocalCodeAct`](../../../../../src/Microsoft.Agents.AI.LocalCodeAct/README.md)
|
||||
to give the model a single `execute_code` tool. Two sandbox-only host tools,
|
||||
`compute` and `fetch_data`, are registered on `LocalCodeActProvider` and are
|
||||
reachable from inside generated Python via `await call_tool(...)` — never as
|
||||
direct LLM tool calls.
|
||||
|
||||
This mirrors the Python
|
||||
[`foundry_hosted_agent.py`](https://github.com/microsoft/agent-framework/blob/main/python/packages/local_codeact/samples/foundry_hosted_agent.py)
|
||||
sample for the `agent-framework-local-codeact` package.
|
||||
|
||||
> **⚠️ Security:** LocalCodeAct executes LLM-generated Python in the agent
|
||||
> process. The package is not a sandbox — it relies on the Foundry hosted-agent
|
||||
> container (or another externally sandboxed environment) for process,
|
||||
> filesystem, and network isolation. Do not run this outside of a sandbox.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- Python 3 available on `PATH` (used by `LocalCodeActProvider` to execute the
|
||||
embedded runner and validator). Override with the `LOCAL_CODEACT_PYTHON`
|
||||
environment variable if you need a specific interpreter path.
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the template and fill in your project endpoint:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set your Azure AI Foundry project endpoint:
|
||||
|
||||
```env
|
||||
FOUNDRY_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
FOUNDRY_MODEL=gpt-4o
|
||||
LOCAL_CODEACT_PYTHON=python3
|
||||
```
|
||||
|
||||
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
|
||||
## Running directly (contributors)
|
||||
|
||||
This project uses `ProjectReference` to build against the local Agent Framework
|
||||
source, including the `Microsoft.Agents.AI.LocalCodeAct` package.
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct
|
||||
AGENT_NAME=hosted-local-codeact dotnet run
|
||||
```
|
||||
|
||||
The agent will start on `http://localhost:8088`.
|
||||
|
||||
### Test it
|
||||
|
||||
Using the Azure Developer CLI:
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "Fetch all users, find the admins, multiply 7 by 6, and print the users, admins, and the multiplication result. Use execute_code with await call_tool(...)."
|
||||
```
|
||||
|
||||
Or with curl:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "Fetch all users, find the admins, multiply 7 by 6, and print the users, admins, and the multiplication result. Use execute_code with await call_tool(...).", "model": "hosted-local-codeact"}'
|
||||
```
|
||||
|
||||
## Running with Docker
|
||||
|
||||
Since this project uses `ProjectReference`, use `Dockerfile.contributor` which
|
||||
takes a pre-published output. The image installs Python 3 so the embedded
|
||||
runner and validator scripts can execute.
|
||||
|
||||
### 1. Publish for the container runtime (Linux Alpine)
|
||||
|
||||
```bash
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
```
|
||||
|
||||
### 2. Build the Docker image
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.contributor -t hosted-local-codeact .
|
||||
```
|
||||
|
||||
### 3. Run the container
|
||||
|
||||
Generate a bearer token on your host and pass it to the container:
|
||||
|
||||
```bash
|
||||
# Generate token (expires in ~1 hour)
|
||||
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
|
||||
# Run with token
|
||||
docker run --rm -p 8088:8088 \
|
||||
-e AGENT_NAME=hosted-local-codeact \
|
||||
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
--env-file .env \
|
||||
hosted-local-codeact
|
||||
```
|
||||
|
||||
### 4. Test it
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "Fetch all users and print the admins."
|
||||
```
|
||||
|
||||
## How CodeAct works here
|
||||
|
||||
`LocalCodeActProvider` is registered as an `AIContextProvider`. On every run it
|
||||
injects:
|
||||
|
||||
- A single `execute_code` tool that the model can call with a Python snippet.
|
||||
- CodeAct instructions that teach the model to use `await call_tool(...)` for
|
||||
the provider-owned host tools, rather than asking for direct tool calls.
|
||||
|
||||
The provider-owned host tools in this sample:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `compute(operation, a, b)` | Math operation: `add`, `subtract`, `multiply`, `divide`. |
|
||||
| `fetch_data(table)` | Returns rows from a simulated `users` or `products` table. |
|
||||
|
||||
`execute_code` runs the generated Python in a separate Python process governed
|
||||
by `ProcessExecutionLimits` (5 second timeout in this sample) and the
|
||||
default-on AST allow-list validator that rejects disallowed imports, builtins,
|
||||
and dynamic-eval constructs before execution.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent
|
||||
spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-local-codeact && cd hosted-local-codeact
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from
|
||||
source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See
|
||||
the commented section in `HostedLocalCodeAct.csproj` for the `PackageReference`
|
||||
alternative.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-local-codeact
|
||||
displayName: "Hosted Local CodeAct Agent"
|
||||
|
||||
description: >
|
||||
A hosted agent that uses the CodeAct pattern via
|
||||
Microsoft.Agents.AI.LocalCodeAct. The model only sees an `execute_code`
|
||||
tool and orchestrates `compute` and `fetch_data` sandbox-only host tools
|
||||
via `await call_tool(...)` from inside generated Python.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Local CodeAct
|
||||
- Agent Framework
|
||||
|
||||
template:
|
||||
name: hosted-local-codeact
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.5"
|
||||
memory: 1Gi
|
||||
parameters:
|
||||
properties: []
|
||||
resources: []
|
||||
@@ -0,0 +1,9 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-local-codeact
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.5"
|
||||
memory: 1Gi
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Bind MCP threadId to the current agent and guard cross-agent session dispatch ([#6531](https://github.com/microsoft/agent-framework/pull/6531))
|
||||
- Added support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436))
|
||||
|
||||
## v1.0.0-preview.260219.1
|
||||
|
||||
@@ -79,6 +79,15 @@ internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient)
|
||||
RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames);
|
||||
AgentSessionId sessionId = durableSession.SessionId;
|
||||
|
||||
// The session must belong to this agent.
|
||||
if (!string.Equals(sessionId.Name, this.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The provided session belongs to agent '{sessionId.Name}' but was passed to agent '{this.Name}'. " +
|
||||
"Sessions cannot be reused across agents.",
|
||||
paramName: nameof(session));
|
||||
}
|
||||
|
||||
AgentRunHandle agentRunHandle = await this._agentClient.RunAgentAsync(sessionId, request, cancellationToken);
|
||||
|
||||
if (isFireAndForget)
|
||||
|
||||
@@ -59,6 +59,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="ToolApprovalAgent"/> — "don't ask again" tool approval rules enabling safe unattended execution. Disable with <see cref="HarnessAgentOptions.DisableToolApproval"/>.</description></item>
|
||||
/// <item><description><see cref="OpenTelemetryAgent"/> — OpenTelemetry instrumentation following semantic conventions for generative AI. Disable with <see cref="HarnessAgentOptions.DisableOpenTelemetry"/>.</description></item>
|
||||
/// <item><description><see cref="LoopAgent"/> — re-invokes the agent until the configured evaluators are satisfied. Applied as the outermost decorator (so each iteration is a complete agent run) and only when <see cref="HarnessAgentOptions.LoopEvaluators"/> supplies at least one evaluator; otherwise omitted.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -142,6 +143,18 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
|
||||
AIAgentBuilder builder = innerAgent.AsBuilder();
|
||||
|
||||
// Register the loop decorator first so it ends up outermost (AIAgentBuilder applies factories in reverse): the
|
||||
// loop drives complete agent runs, each independently tool-approved and OpenTelemetry-traced. Only added when at
|
||||
// least one evaluator is supplied; otherwise the agent behaves as a single-shot agent.
|
||||
if (options?.LoopEvaluators is IEnumerable<LoopEvaluator> loopEvaluators)
|
||||
{
|
||||
List<LoopEvaluator> evaluatorList = loopEvaluators.ToList();
|
||||
if (evaluatorList.Count > 0)
|
||||
{
|
||||
builder.Use((inner, _) => new LoopAgent(inner, evaluatorList, options.LoopAgentOptions, loggerFactory));
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.DisableToolApproval is not true)
|
||||
{
|
||||
builder.UseToolApproval(options?.ToolApprovalAgentOptions);
|
||||
|
||||
@@ -146,6 +146,33 @@ public sealed class HarnessAgentOptions
|
||||
/// </remarks>
|
||||
public IEnumerable<AIContextProvider>? AIContextProviders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ordered collection of <see cref="LoopEvaluator"/> instances that, when supplied, cause the
|
||||
/// <see cref="HarnessAgent"/> to be wrapped in a <see cref="LoopAgent"/> decorator.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When this collection is non-<see langword="null"/> and contains at least one evaluator, the harness agent is
|
||||
/// wrapped in a <see cref="LoopAgent"/> that re-invokes the agent until the evaluators are satisfied. The loop is
|
||||
/// applied as the outermost decorator, so each iteration is a complete agent run (including tool approval and
|
||||
/// OpenTelemetry instrumentation).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <see langword="null"/> or empty (the default), no <see cref="LoopAgent"/> is added and the agent behaves
|
||||
/// as a single-shot agent.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IEnumerable<LoopEvaluator>? LoopEvaluators { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets optional configuration for the <see cref="LoopAgent"/> created from <see cref="LoopEvaluators"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/>, the <see cref="LoopAgent"/> uses its default settings. This property is ignored
|
||||
/// when <see cref="LoopEvaluators"/> is <see langword="null"/> or empty.
|
||||
/// </remarks>
|
||||
public LoopAgentOptions? LoopAgentOptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of function-invocation loop iterations per request.
|
||||
/// </summary>
|
||||
|
||||
@@ -363,9 +363,10 @@ internal static class BuiltInFunctions
|
||||
|
||||
string agentName = context.Name;
|
||||
|
||||
// Derive session id: try to parse provided threadId, otherwise create a new one.
|
||||
// Bind the caller-supplied threadId as a session key under the current agent name,
|
||||
// mirroring the behavior of RunAgentHttpAsync.
|
||||
AgentSessionId sessionId = context.Arguments.TryGetValue("threadId", out object? threadObj) && threadObj is string threadId && !string.IsNullOrWhiteSpace(threadId)
|
||||
? AgentSessionId.Parse(threadId)
|
||||
? new AgentSessionId(agentName, threadId)
|
||||
: new AgentSessionId(agentName, functionContext.InvocationId);
|
||||
|
||||
AIAgent agentProxy = client.AsDurableAgentProxy(functionContext, agentName);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Bind MCP threadId to the current agent and guard cross-agent session dispatch ([#6531](https://github.com/microsoft/agent-framework/pull/6531))
|
||||
- Support returning workflow results from HTTP trigger endpoint ([#5321](https://github.com/microsoft/agent-framework/pull/5321))
|
||||
- Added MCP tool trigger support for durable workflows ([#4768](https://github.com/microsoft/agent-framework/pull/4768))
|
||||
- Added Azure Functions hosting support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436))
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when AST validation of generated Python code fails.
|
||||
/// </summary>
|
||||
public sealed class CodeValidationException : Exception
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="CodeValidationException"/> class.</summary>
|
||||
public CodeValidationException()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="CodeValidationException"/> class.</summary>
|
||||
/// <param name="message">Validation error message.</param>
|
||||
public CodeValidationException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="CodeValidationException"/> class.</summary>
|
||||
/// <param name="message">Validation error message.</param>
|
||||
/// <param name="innerException">Underlying exception.</param>
|
||||
public CodeValidationException(string message, Exception innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct;
|
||||
|
||||
/// <summary>
|
||||
/// File mount access mode.
|
||||
/// </summary>
|
||||
public enum FileMountMode
|
||||
{
|
||||
/// <summary>Read-only access. Files are not scanned for capture after execution.</summary>
|
||||
ReadOnly,
|
||||
|
||||
/// <summary>Read-write access. New or modified files are captured after execution.</summary>
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a host directory exposed to locally executed code.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Unlike a true sandbox, mounts in this package expose <see cref="HostPath"/>
|
||||
/// directly to the subprocess. The <see cref="MountPath"/> is metadata used to
|
||||
/// describe the mount to the model in the function description and to label
|
||||
/// captured files. Real isolation must come from the surrounding sandbox
|
||||
/// (container, VM, Foundry hosted agent, etc.).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class FileMount
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileMount"/> class.
|
||||
/// </summary>
|
||||
/// <param name="hostPath">Path on the host filesystem to expose to the subprocess. Must exist.</param>
|
||||
/// <param name="mountPath">
|
||||
/// Logical path used to describe the mount to the model (for example <c>"/input/data.csv"</c>).
|
||||
/// </param>
|
||||
/// <param name="mode">Access mode for the mount. Defaults to <see cref="FileMountMode.ReadWrite"/>.</param>
|
||||
/// <param name="writeBytesLimit">
|
||||
/// Optional per-mount write capture limit (in bytes). When <see langword="null"/>, the global
|
||||
/// <see cref="ProcessExecutionLimits.MaxCapturedFileBytes"/> applies.
|
||||
/// </param>
|
||||
public FileMount(string hostPath, string mountPath, FileMountMode mode = FileMountMode.ReadWrite, long? writeBytesLimit = null)
|
||||
{
|
||||
this.HostPath = Throw.IfNullOrWhitespace(hostPath);
|
||||
this.MountPath = Throw.IfNullOrWhitespace(mountPath);
|
||||
this.Mode = mode;
|
||||
this.WriteBytesLimit = writeBytesLimit;
|
||||
}
|
||||
|
||||
/// <summary>Gets the host filesystem path exposed to the subprocess.</summary>
|
||||
public string HostPath { get; }
|
||||
|
||||
/// <summary>Gets the logical mount path used to describe the mount to the model.</summary>
|
||||
public string MountPath { get; }
|
||||
|
||||
/// <summary>Gets the access mode for the mount.</summary>
|
||||
public FileMountMode Mode { get; }
|
||||
|
||||
/// <summary>Gets the optional per-mount write capture limit (in bytes).</summary>
|
||||
public long? WriteBytesLimit { get; }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates a single execution: optional validation, snapshot of writable mounts,
|
||||
/// running the subprocess, capturing written files, and assembling the final content list.
|
||||
/// </summary>
|
||||
internal sealed class CodeExecutor
|
||||
{
|
||||
private readonly string _pythonExecutable;
|
||||
private readonly string _runnerScript;
|
||||
private readonly CodeValidator? _validator;
|
||||
private readonly ProcessExecutionLimits _limits;
|
||||
private readonly IReadOnlyDictionary<string, string>? _environment;
|
||||
private readonly string? _workingDirectory;
|
||||
|
||||
public CodeExecutor(
|
||||
string pythonExecutable,
|
||||
string runnerScript,
|
||||
CodeValidator? validator,
|
||||
ProcessExecutionLimits limits,
|
||||
IReadOnlyDictionary<string, string>? environment,
|
||||
string? workingDirectory)
|
||||
{
|
||||
this._pythonExecutable = pythonExecutable;
|
||||
this._runnerScript = runnerScript;
|
||||
this._validator = validator;
|
||||
this._limits = limits;
|
||||
this._environment = environment;
|
||||
this._workingDirectory = workingDirectory;
|
||||
}
|
||||
|
||||
/// <summary>Immutable snapshot of provider state captured at the start of an invocation.</summary>
|
||||
public sealed class RunSnapshot
|
||||
{
|
||||
public RunSnapshot(IReadOnlyList<AIFunction> tools, IReadOnlyList<FileMount> fileMounts)
|
||||
{
|
||||
this.Tools = tools;
|
||||
this.FileMounts = fileMounts;
|
||||
}
|
||||
|
||||
public IReadOnlyList<AIFunction> Tools { get; }
|
||||
|
||||
public IReadOnlyList<FileMount> FileMounts { get; }
|
||||
}
|
||||
|
||||
public async Task<List<AIContent>> ExecuteAsync(RunSnapshot snapshot, string code, CancellationToken cancellationToken)
|
||||
{
|
||||
if (this._validator is not null)
|
||||
{
|
||||
await this._validator.ValidateAsync(code, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var preState = FileMountHelper.SnapshotWritableMounts(snapshot.FileMounts);
|
||||
|
||||
var bridge = new ProcessBridge(
|
||||
this._pythonExecutable,
|
||||
this._runnerScript,
|
||||
snapshot.Tools,
|
||||
this._limits,
|
||||
this._environment,
|
||||
this._workingDirectory);
|
||||
|
||||
var result = await bridge.RunAsync(code, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var captured = FileMountHelper.CaptureWrittenFiles(snapshot.FileMounts, preState, this._limits);
|
||||
|
||||
return BuildContents(result, captured);
|
||||
}
|
||||
|
||||
private static List<AIContent> BuildContents(ProcessBridge.ExecutionResult result, List<AIContent> capturedFiles)
|
||||
{
|
||||
var contents = new List<AIContent>();
|
||||
|
||||
if (!string.IsNullOrEmpty(result.Stdout))
|
||||
{
|
||||
var stdoutText = result.StdoutTruncated ? result.Stdout + "\n[stdout truncated]" : result.Stdout;
|
||||
contents.Add(new TextContent(stdoutText));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(result.Stderr))
|
||||
{
|
||||
var stderrText = result.StderrTruncated ? result.Stderr + "\n[stderr truncated]" : result.Stderr;
|
||||
contents.Add(new TextContent("stderr:\n" + stderrText));
|
||||
}
|
||||
|
||||
if (result.OutputPresent && result.Output.HasValue)
|
||||
{
|
||||
contents.Add(new TextContent("result:\n" + result.Output.Value.GetRawText()));
|
||||
}
|
||||
|
||||
contents.AddRange(capturedFiles);
|
||||
|
||||
if (contents.Count == 0)
|
||||
{
|
||||
contents.Add(new TextContent("Code executed successfully without output."));
|
||||
}
|
||||
|
||||
return contents;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the embedded Python AST validator in a child process with a strict timeout.
|
||||
/// </summary>
|
||||
internal sealed class CodeValidator
|
||||
{
|
||||
private readonly string _pythonExecutable;
|
||||
private readonly string _validatorScript;
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly IReadOnlyList<string>? _allowedImports;
|
||||
private readonly IReadOnlyList<string>? _blockedImports;
|
||||
private readonly IReadOnlyList<string>? _allowedBuiltins;
|
||||
private readonly IReadOnlyList<string>? _blockedBuiltins;
|
||||
|
||||
public CodeValidator(
|
||||
string pythonExecutable,
|
||||
string validatorScript,
|
||||
TimeSpan timeout,
|
||||
IReadOnlyList<string>? allowedImports,
|
||||
IReadOnlyList<string>? blockedImports,
|
||||
IReadOnlyList<string>? allowedBuiltins,
|
||||
IReadOnlyList<string>? blockedBuiltins)
|
||||
{
|
||||
this._pythonExecutable = pythonExecutable;
|
||||
this._validatorScript = validatorScript;
|
||||
this._timeout = timeout;
|
||||
this._allowedImports = allowedImports;
|
||||
this._blockedImports = blockedImports;
|
||||
this._allowedBuiltins = allowedBuiltins;
|
||||
this._blockedBuiltins = blockedBuiltins;
|
||||
}
|
||||
|
||||
/// <summary>Validates Python source code against the configured allow-lists.</summary>
|
||||
/// <exception cref="CodeValidationException">Thrown when validation fails.</exception>
|
||||
public async Task ValidateAsync(string code, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new JsonObject
|
||||
{
|
||||
["code"] = code,
|
||||
};
|
||||
|
||||
AddList(request, "allowed_imports", this._allowedImports);
|
||||
AddList(request, "blocked_imports", this._blockedImports);
|
||||
AddList(request, "allowed_builtins", this._allowedBuiltins);
|
||||
AddList(request, "blocked_builtins", this._blockedBuiltins);
|
||||
|
||||
var requestJson = request.ToJsonString();
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = this._pythonExecutable,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add("-I");
|
||||
startInfo.ArgumentList.Add(this._validatorScript);
|
||||
|
||||
using var process = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("Failed to start Python validator process.");
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(this._timeout);
|
||||
|
||||
try
|
||||
{
|
||||
await process.StandardInput.WriteLineAsync(requestJson.AsMemory(), timeoutCts.Token).ConfigureAwait(false);
|
||||
await process.StandardInput.FlushAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
process.StandardInput.Close();
|
||||
|
||||
var stdoutTask = process.StandardOutput.ReadToEndAsync(timeoutCts.Token);
|
||||
var stderrTask = process.StandardError.ReadToEndAsync(timeoutCts.Token);
|
||||
|
||||
await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
|
||||
var stdout = await stdoutTask.ConfigureAwait(false);
|
||||
var stderr = await stderrTask.ConfigureAwait(false);
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new CodeValidationException(ExtractError(stdout, stderr));
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
TryKill(process);
|
||||
throw new CodeValidationException($"Code validation exceeded {this._timeout.TotalSeconds:F0} seconds.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
TryKill(process);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractError(string output, string errorOutput)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(errorOutput) ? "Code validation failed." : errorOutput;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(output);
|
||||
if (doc.RootElement.TryGetProperty("errors", out var errors) && errors.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var err in errors.EnumerateArray())
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append("; ");
|
||||
}
|
||||
|
||||
sb.Append(err.ValueKind == JsonValueKind.String ? err.GetString() : err.ToString());
|
||||
}
|
||||
|
||||
return sb.Length > 0 ? sb.ToString() : output;
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("message", out var message) && message.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return message.GetString() ?? output;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// fall through
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private static void TryKill(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
#pragma warning disable CA1031 // Do not catch general exception types
|
||||
// best-effort cleanup
|
||||
#pragma warning restore CA1031
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddList(JsonObject obj, string key, IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
obj[key] = new JsonArray(values.Select(v => (JsonNode?)JsonValue.Create(v)).ToArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the embedded Python <c>runner.py</c> and <c>validator.py</c> scripts to a temporary
|
||||
/// directory and caches their paths for the lifetime of the process.
|
||||
/// </summary>
|
||||
internal static class EmbeddedScripts
|
||||
{
|
||||
private static readonly object s_syncRoot = new();
|
||||
private static string? s_runnerPath;
|
||||
private static string? s_validatorPath;
|
||||
|
||||
/// <summary>Returns the path to the embedded <c>runner.py</c>, extracting it on first access.</summary>
|
||||
public static string GetRunnerScriptPath() => GetOrExtract("runner.py", ref s_runnerPath);
|
||||
|
||||
/// <summary>Returns the path to the embedded <c>validator.py</c>, extracting it on first access.</summary>
|
||||
public static string GetValidatorScriptPath() => GetOrExtract("validator.py", ref s_validatorPath);
|
||||
|
||||
private static string GetOrExtract(string fileName, ref string? cached)
|
||||
{
|
||||
if (cached is not null && File.Exists(cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
lock (s_syncRoot)
|
||||
{
|
||||
if (cached is not null && File.Exists(cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var path = Extract(fileName);
|
||||
cached = path;
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Extract(string fileName)
|
||||
{
|
||||
var assembly = typeof(EmbeddedScripts).Assembly;
|
||||
var resourceName = $"Microsoft.Agents.AI.LocalCodeAct.Resources.{fileName}";
|
||||
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException($"Embedded resource '{resourceName}' not found.");
|
||||
|
||||
var dir = Path.Combine(Path.GetTempPath(), "agentframework-localcodeact-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, fileName);
|
||||
|
||||
using var fileStream = File.Create(path);
|
||||
stream.CopyTo(fileStream);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Run-scoped <see cref="AIFunction"/> that exposes <c>execute_code</c> to the model.
|
||||
/// </summary>
|
||||
internal sealed class ExecuteCodeFunction : AIFunction
|
||||
{
|
||||
private const string ExecuteCodeName = "execute_code";
|
||||
|
||||
private readonly CodeExecutor _executor;
|
||||
private readonly CodeExecutor.RunSnapshot _snapshot;
|
||||
private readonly AIFunction _inner;
|
||||
|
||||
public ExecuteCodeFunction(CodeExecutor executor, CodeExecutor.RunSnapshot snapshot, string description)
|
||||
{
|
||||
this._executor = executor;
|
||||
this._snapshot = snapshot;
|
||||
this._inner = AIFunctionFactory.Create(
|
||||
this.ExecuteCodeAsync,
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = ExecuteCodeName,
|
||||
Description = description,
|
||||
});
|
||||
}
|
||||
|
||||
public override string Name => this._inner.Name;
|
||||
|
||||
public override string Description => this._inner.Description;
|
||||
|
||||
public override JsonElement JsonSchema => this._inner.JsonSchema;
|
||||
|
||||
protected override ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) =>
|
||||
this._inner.InvokeAsync(arguments, cancellationToken);
|
||||
|
||||
private async ValueTask<object?> ExecuteCodeAsync(
|
||||
[Description("Python source code to execute locally in the agent environment.")] string code,
|
||||
CancellationToken cancellationToken)
|
||||
=> string.IsNullOrWhiteSpace(code)
|
||||
? throw new ArgumentException("Parameter 'code' must not be empty.", nameof(code))
|
||||
: await this._executor.ExecuteAsync(this._snapshot, code, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem helpers for read-write mount snapshotting and capture.
|
||||
/// </summary>
|
||||
internal static class FileMountHelper
|
||||
{
|
||||
/// <summary>Normalizes and validates a mount path (must be a clean absolute POSIX-style path).</summary>
|
||||
public static string NormalizeMountPath(string mountPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mountPath))
|
||||
{
|
||||
throw new ArgumentException("Mount path must not be empty.", nameof(mountPath));
|
||||
}
|
||||
|
||||
var raw = mountPath.Trim().Replace('\\', '/');
|
||||
var parts = raw.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(p => p != ".")
|
||||
.ToList();
|
||||
|
||||
if (parts.Any(p => p == ".."))
|
||||
{
|
||||
throw new ArgumentException("Mount path must not contain '..' segments.", nameof(mountPath));
|
||||
}
|
||||
|
||||
if (parts.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("Mount path must point to a concrete absolute path.", nameof(mountPath));
|
||||
}
|
||||
|
||||
return "/" + string.Join("/", parts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a FileMount and returns a normalized copy (resolved host path, normalized mount path).
|
||||
/// </summary>
|
||||
public static FileMount Normalize(FileMount mount)
|
||||
{
|
||||
if (mount is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(mount));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mount.HostPath))
|
||||
{
|
||||
throw new ArgumentException("HostPath must not be empty.", nameof(mount));
|
||||
}
|
||||
|
||||
var fullHost = Path.GetFullPath(mount.HostPath);
|
||||
if (!Directory.Exists(fullHost) && !File.Exists(fullHost))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"FileMount host path '{mount.HostPath}' does not exist.");
|
||||
}
|
||||
|
||||
if (mount.WriteBytesLimit.HasValue && mount.WriteBytesLimit.Value < 0)
|
||||
{
|
||||
throw new ArgumentException("WriteBytesLimit must be non-negative when set.", nameof(mount));
|
||||
}
|
||||
|
||||
return new FileMount(fullHost, NormalizeMountPath(mount.MountPath), mount.Mode, mount.WriteBytesLimit);
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of (size, last-write-time ticks) per relative path under a writable mount.</summary>
|
||||
public sealed class MountSnapshot
|
||||
{
|
||||
public MountSnapshot(IReadOnlyDictionary<string, (long Size, long Ticks)> files)
|
||||
{
|
||||
this.Files = files;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, (long Size, long Ticks)> Files { get; }
|
||||
}
|
||||
|
||||
/// <summary>Captures the current file inventory of read-write mounts before execution.</summary>
|
||||
public static Dictionary<string, MountSnapshot> SnapshotWritableMounts(IReadOnlyList<FileMount> mounts)
|
||||
{
|
||||
var snapshot = new Dictionary<string, MountSnapshot>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var mount in mounts)
|
||||
{
|
||||
if (mount.Mode != FileMountMode.ReadWrite)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var root = new DirectoryInfo(mount.HostPath);
|
||||
if (!root.Exists)
|
||||
{
|
||||
snapshot[mount.MountPath] = new MountSnapshot(new Dictionary<string, (long, long)>());
|
||||
continue;
|
||||
}
|
||||
|
||||
var files = new Dictionary<string, (long Size, long Ticks)>(StringComparer.Ordinal);
|
||||
foreach (var file in EnumerateRealFiles(root))
|
||||
{
|
||||
var rel = MakeRelative(root.FullName, file.FullName);
|
||||
files[rel] = (file.Length, file.LastWriteTimeUtc.Ticks);
|
||||
}
|
||||
|
||||
snapshot[mount.MountPath] = new MountSnapshot(files);
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/// <summary>Captures files that were created or modified in read-write mounts since the snapshot was taken.</summary>
|
||||
public static List<AIContent> CaptureWrittenFiles(
|
||||
IReadOnlyList<FileMount> mounts,
|
||||
IReadOnlyDictionary<string, MountSnapshot> preState,
|
||||
ProcessExecutionLimits limits)
|
||||
{
|
||||
var captured = new List<AIContent>();
|
||||
long totalBytes = 0;
|
||||
|
||||
foreach (var mount in mounts)
|
||||
{
|
||||
if (mount.Mode != FileMountMode.ReadWrite)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var root = new DirectoryInfo(mount.HostPath);
|
||||
if (!root.Exists)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
preState.TryGetValue(mount.MountPath, out var before);
|
||||
var beforeFiles = before?.Files ?? new Dictionary<string, (long, long)>();
|
||||
long mountBytes = 0;
|
||||
var perMountLimit = mount.WriteBytesLimit ?? limits.MaxCapturedFileBytes;
|
||||
|
||||
foreach (var file in EnumerateRealFiles(root).OrderBy(f => f.FullName, StringComparer.Ordinal))
|
||||
{
|
||||
var rel = MakeRelative(root.FullName, file.FullName);
|
||||
var current = (file.Length, file.LastWriteTimeUtc.Ticks);
|
||||
|
||||
if (beforeFiles.TryGetValue(rel, out var previous) && previous == current)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var sandboxPath = mount.MountPath.TrimEnd('/') + "/" + rel;
|
||||
|
||||
if (file.Length > limits.MaxCapturedFileBytes)
|
||||
{
|
||||
captured.Add(new TextContent($"[file {sandboxPath} omitted: exceeds per-file capture limit]"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mountBytes + file.Length > perMountLimit)
|
||||
{
|
||||
captured.Add(new TextContent($"[file {sandboxPath} omitted: per-mount capture limit reached]"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (totalBytes + file.Length > limits.MaxTotalCapturedFileBytes)
|
||||
{
|
||||
captured.Add(new TextContent($"[file {sandboxPath} omitted: total capture limit reached]"));
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[] data;
|
||||
try
|
||||
{
|
||||
data = File.ReadAllBytes(file.FullName);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
captured.Add(new DataContent(data, GuessMediaType(file.Name))
|
||||
{
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
["path"] = sandboxPath,
|
||||
},
|
||||
});
|
||||
|
||||
mountBytes += file.Length;
|
||||
totalBytes += file.Length;
|
||||
}
|
||||
}
|
||||
|
||||
return captured;
|
||||
}
|
||||
|
||||
private static string MakeRelative(string root, string full)
|
||||
{
|
||||
var rel = Path.GetRelativePath(root, full);
|
||||
return rel.Replace(Path.DirectorySeparatorChar, '/');
|
||||
}
|
||||
|
||||
private static IEnumerable<FileInfo> EnumerateRealFiles(DirectoryInfo root)
|
||||
{
|
||||
var stack = new Stack<DirectoryInfo>();
|
||||
stack.Push(root);
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var current = stack.Pop();
|
||||
FileSystemInfo[] entries;
|
||||
try
|
||||
{
|
||||
entries = current.GetFileSystemInfos();
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry is DirectoryInfo dir)
|
||||
{
|
||||
stack.Push(dir);
|
||||
}
|
||||
else if (entry is FileInfo file)
|
||||
{
|
||||
yield return file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GuessMediaType(string fileName)
|
||||
{
|
||||
#pragma warning disable CA1308 // Normalize strings to uppercase - file extensions are conventionally lowercase
|
||||
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
#pragma warning restore CA1308
|
||||
return extension switch
|
||||
{
|
||||
".txt" => "text/plain",
|
||||
".json" => "application/json",
|
||||
".xml" => "application/xml",
|
||||
".html" => "text/html",
|
||||
".css" => "text/css",
|
||||
".js" => "application/javascript",
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".svg" => "image/svg+xml",
|
||||
".pdf" => "application/pdf",
|
||||
".zip" => "application/zip",
|
||||
".csv" => "text/csv",
|
||||
".md" => "text/markdown",
|
||||
".py" => "text/x-python",
|
||||
".cs" => "text/x-csharp",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.Internal;
|
||||
|
||||
internal static class InstructionBuilder
|
||||
{
|
||||
public static string BuildContextInstructions() =>
|
||||
"You can execute Python code locally by calling the `execute_code` tool. "
|
||||
+ "Any tools listed in the tool's description are only accessible from within the executed "
|
||||
+ "code via `await call_tool(\"<name>\", **kwargs)` — they cannot be invoked directly. "
|
||||
+ "State does not persist between calls; pass any required values in the code you execute.";
|
||||
|
||||
public static string BuildExecuteCodeDescription(
|
||||
IReadOnlyList<AIFunction> tools,
|
||||
IReadOnlyList<FileMount> fileMounts)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("Executes Python code locally in the agent environment. ");
|
||||
sb.Append("Pass the full source to execute via the `code` parameter. ");
|
||||
sb.Append("Returns the captured stdout/stderr and the value of a top-level `result` variable when set.");
|
||||
|
||||
if (tools.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("The following host tools are available inside the executed code via `await call_tool(\"<name>\", **kwargs)`:");
|
||||
foreach (var tool in tools)
|
||||
{
|
||||
sb.Append("- `");
|
||||
sb.Append(tool.Name);
|
||||
sb.Append('`');
|
||||
if (!string.IsNullOrWhiteSpace(tool.Description))
|
||||
{
|
||||
sb.Append(": ");
|
||||
sb.Append(tool.Description);
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
if (fileMounts.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Filesystem access (host paths are exposed directly; mount paths shown are for description):");
|
||||
foreach (var mount in fileMounts)
|
||||
{
|
||||
sb.Append("- `");
|
||||
sb.Append(mount.MountPath);
|
||||
sb.Append("` -> `");
|
||||
sb.Append(mount.HostPath);
|
||||
sb.Append("` (");
|
||||
sb.Append(mount.Mode);
|
||||
sb.AppendLine(")");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Parent-side IPC bridge that launches the Python runner, sends a single execution request,
|
||||
/// services tool calls, and returns the final execution result.
|
||||
/// </summary>
|
||||
internal sealed class ProcessBridge
|
||||
{
|
||||
private static readonly JsonSerializerOptions s_jsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
private readonly string _pythonExecutable;
|
||||
private readonly string _runnerScript;
|
||||
private readonly IReadOnlyDictionary<string, AIFunction> _tools;
|
||||
private readonly ProcessExecutionLimits _limits;
|
||||
private readonly IReadOnlyDictionary<string, string>? _environment;
|
||||
private readonly string? _workingDirectory;
|
||||
|
||||
public ProcessBridge(
|
||||
string pythonExecutable,
|
||||
string runnerScript,
|
||||
IReadOnlyList<AIFunction> tools,
|
||||
ProcessExecutionLimits limits,
|
||||
IReadOnlyDictionary<string, string>? environment,
|
||||
string? workingDirectory)
|
||||
{
|
||||
this._pythonExecutable = pythonExecutable;
|
||||
this._runnerScript = runnerScript;
|
||||
this._tools = tools.ToDictionary(t => t.Name, StringComparer.Ordinal);
|
||||
this._limits = limits;
|
||||
this._environment = environment;
|
||||
this._workingDirectory = workingDirectory;
|
||||
}
|
||||
|
||||
/// <summary>Represents the parsed final result returned by the Python runner.</summary>
|
||||
public sealed class ExecutionResult
|
||||
{
|
||||
public string Stdout { get; init; } = string.Empty;
|
||||
public string Stderr { get; init; } = string.Empty;
|
||||
public bool OutputPresent { get; init; }
|
||||
public JsonElement? Output { get; init; }
|
||||
public bool StdoutTruncated { get; init; }
|
||||
public bool StderrTruncated { get; init; }
|
||||
}
|
||||
|
||||
public async Task<ExecutionResult> RunAsync(string code, CancellationToken cancellationToken)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = this._pythonExecutable,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add("-I");
|
||||
startInfo.ArgumentList.Add(this._runnerScript);
|
||||
|
||||
if (!string.IsNullOrEmpty(this._workingDirectory))
|
||||
{
|
||||
startInfo.WorkingDirectory = this._workingDirectory;
|
||||
}
|
||||
|
||||
this.ConfigureEnvironment(startInfo);
|
||||
|
||||
using var process = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("Failed to start Python runner process.");
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(this._limits.TimeoutSeconds));
|
||||
|
||||
var stderrTask = ReadCappedAsync(process.StandardError, this._limits.MaxStderrBytes, timeoutCts.Token);
|
||||
|
||||
try
|
||||
{
|
||||
return await this.CommunicateAsync(process, code, stderrTask, timeoutCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
TryKill(process);
|
||||
throw new TimeoutException($"Generated code exceeded {this._limits.TimeoutSeconds} seconds.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
TryKill(process);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureEnvironment(ProcessStartInfo startInfo)
|
||||
{
|
||||
// Null => inherit the parent environment (documented contract on
|
||||
// LocalCodeActProviderOptions.Environment). Callers wanting a scrubbed
|
||||
// environment pass an empty dictionary.
|
||||
if (this._environment is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
startInfo.Environment.Clear();
|
||||
foreach (var kvp in this._environment)
|
||||
{
|
||||
startInfo.Environment[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
// Without these on Windows, Python may fail to load its standard library.
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
foreach (var key in new[] { "SYSTEMROOT", "SYSTEMDRIVE", "COMSPEC", "PATHEXT", "TEMP", "TMP" })
|
||||
{
|
||||
if (!startInfo.Environment.ContainsKey(key))
|
||||
{
|
||||
var existing = Environment.GetEnvironmentVariable(key);
|
||||
if (!string.IsNullOrEmpty(existing))
|
||||
{
|
||||
startInfo.Environment[key] = existing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ExecutionResult> CommunicateAsync(
|
||||
Process process,
|
||||
string code,
|
||||
Task<(string Text, bool Truncated)> stderrTask,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new JsonObject
|
||||
{
|
||||
["code"] = code,
|
||||
["tool_names"] = new JsonArray(this._tools.Keys.Select(k => (JsonNode?)JsonValue.Create(k)).ToArray()),
|
||||
["max_stdout_bytes"] = this._limits.MaxStdoutBytes,
|
||||
["max_stderr_bytes"] = this._limits.MaxStderrBytes,
|
||||
};
|
||||
|
||||
await process.StandardInput.WriteLineAsync(request.ToJsonString(s_jsonOptions).AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var line = await process.StandardOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (line is null)
|
||||
{
|
||||
var stderr = await stderrTask.ConfigureAwait(false);
|
||||
throw new InvalidOperationException(
|
||||
$"Local CodeAct subprocess exited without a result. stderr: {stderr.Text}");
|
||||
}
|
||||
|
||||
JsonObject message;
|
||||
try
|
||||
{
|
||||
message = JsonNode.Parse(line) as JsonObject
|
||||
?? throw new InvalidOperationException("Subprocess produced a non-object JSON message.");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to parse JSON message from subprocess: {line}", ex);
|
||||
}
|
||||
|
||||
switch ((string?)message["type"])
|
||||
{
|
||||
case "complete":
|
||||
return this.ParseComplete(message);
|
||||
|
||||
case "error":
|
||||
var excType = (string?)message["exc_type"] ?? "Error";
|
||||
var msg = (string?)message["message"] ?? "Unknown subprocess error.";
|
||||
var tb = (string?)message["traceback"];
|
||||
throw new InvalidOperationException(
|
||||
string.IsNullOrEmpty(tb) ? $"{excType}: {msg}" : $"{excType}: {msg}\n{tb}");
|
||||
|
||||
case "tool_call":
|
||||
await this.HandleToolCallAsync(process, message, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown message types are ignored to remain forward compatible.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ExecutionResult ParseComplete(JsonObject message)
|
||||
{
|
||||
var result = message["result"] as JsonObject ?? new JsonObject();
|
||||
|
||||
var json = result.ToJsonString();
|
||||
if (Encoding.UTF8.GetByteCount(json) > this._limits.MaxResultBytes)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Generated code result exceeded the configured max of {this._limits.MaxResultBytes} bytes.");
|
||||
}
|
||||
|
||||
JsonElement? output = null;
|
||||
if (result["output"] is JsonNode outputNode)
|
||||
{
|
||||
output = JsonDocument.Parse(outputNode.ToJsonString()).RootElement.Clone();
|
||||
}
|
||||
|
||||
return new ExecutionResult
|
||||
{
|
||||
Stdout = (string?)result["stdout"] ?? string.Empty,
|
||||
Stderr = (string?)result["stderr"] ?? string.Empty,
|
||||
OutputPresent = (bool?)result["output_present"] ?? false,
|
||||
Output = output,
|
||||
StdoutTruncated = (bool?)result["stdout_truncated"] ?? false,
|
||||
StderrTruncated = (bool?)result["stderr_truncated"] ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task HandleToolCallAsync(Process process, JsonObject message, CancellationToken cancellationToken)
|
||||
{
|
||||
// call_id is Python's id(kwargs) which can be a 64-bit value on 64-bit Python.
|
||||
long callId = 0;
|
||||
if (message["call_id"] is JsonValue cidValue && cidValue.TryGetValue<long>(out var parsedId))
|
||||
{
|
||||
callId = parsedId;
|
||||
}
|
||||
|
||||
var name = (string?)message["name"];
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
await SendToolResponseAsync(process, callId, ok: false, result: null,
|
||||
excType: "ToolError", excMessage: "Tool call missing 'name'.", cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._tools.TryGetValue(name!, out var tool))
|
||||
{
|
||||
await SendToolResponseAsync(process, callId, ok: false, result: null,
|
||||
excType: "UnknownTool", excMessage: $"Unknown tool: {name}", cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var kwargs = message["kwargs"] as JsonObject ?? new JsonObject();
|
||||
var arguments = new AIFunctionArguments();
|
||||
foreach (var (key, value) in kwargs)
|
||||
{
|
||||
arguments[key] = value;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await tool.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
|
||||
await SendToolResponseAsync(process, callId, ok: true, result, excType: null, excMessage: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
#pragma warning disable CA1031
|
||||
catch (Exception ex)
|
||||
#pragma warning restore CA1031
|
||||
{
|
||||
await SendToolResponseAsync(process, callId, ok: false, result: null,
|
||||
excType: ex.GetType().Name, excMessage: ex.Message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendToolResponseAsync(
|
||||
Process process,
|
||||
long callId,
|
||||
bool ok,
|
||||
object? result,
|
||||
string? excType,
|
||||
string? excMessage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var response = new JsonObject
|
||||
{
|
||||
["call_id"] = callId,
|
||||
["ok"] = ok,
|
||||
};
|
||||
|
||||
if (ok)
|
||||
{
|
||||
response["result"] = SerializeResult(result);
|
||||
}
|
||||
else
|
||||
{
|
||||
response["exc_type"] = excType;
|
||||
response["message"] = excMessage;
|
||||
}
|
||||
|
||||
await process.StandardInput.WriteLineAsync(response.ToJsonString(s_jsonOptions).AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static JsonNode? SerializeResult(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value is JsonNode node)
|
||||
{
|
||||
return node;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var typeInfo = AIJsonUtilities.DefaultOptions.GetTypeInfo(value.GetType());
|
||||
var json = JsonSerializer.Serialize(value, typeInfo);
|
||||
return JsonNode.Parse(json);
|
||||
}
|
||||
#pragma warning disable CA1031
|
||||
catch
|
||||
#pragma warning restore CA1031
|
||||
{
|
||||
return JsonValue.Create(value.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(string Text, bool Truncated)> ReadCappedAsync(StreamReader reader, int maxBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var buffer = new char[4096];
|
||||
var truncated = false;
|
||||
var totalBytes = 0;
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var read = await reader.ReadAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var chunk = new string(buffer, 0, read);
|
||||
var chunkBytes = Encoding.UTF8.GetByteCount(chunk);
|
||||
if (totalBytes + chunkBytes > maxBytes)
|
||||
{
|
||||
var remaining = Math.Max(0, maxBytes - totalBytes);
|
||||
if (remaining > 0)
|
||||
{
|
||||
sb.Append(chunk[..Math.Min(chunk.Length, remaining)]);
|
||||
}
|
||||
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
sb.Append(chunk);
|
||||
totalBytes += chunkBytes;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Allow caller to propagate the timeout exception.
|
||||
}
|
||||
#pragma warning disable CA1031
|
||||
catch
|
||||
#pragma warning restore CA1031
|
||||
{
|
||||
// Best effort: return what we have so far.
|
||||
}
|
||||
|
||||
return (sb.ToString(), truncated);
|
||||
}
|
||||
|
||||
private static void TryKill(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
#pragma warning disable CA1031
|
||||
catch
|
||||
#pragma warning restore CA1031
|
||||
{
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.LocalCodeAct.Internal;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AIContextProvider"/> that injects a local Python <c>execute_code</c> tool
|
||||
/// into the agent's tool surface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Generated code is executed in a child Python process with default-on AST allow-list
|
||||
/// validation, configurable resource limits, an isolated environment, and capture of files
|
||||
/// written under <see cref="FileMountMode.ReadWrite"/> mounts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security:</strong> This package is NOT a sandbox. It is intended for environments
|
||||
/// that already provide process, filesystem, and network isolation (Foundry hosted agents,
|
||||
/// Azure Container Instances, dedicated VMs, etc.).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LocalCodeActProvider : AIContextProvider, IDisposable
|
||||
{
|
||||
/// <summary>Fixed state key used to enforce a single provider per agent.</summary>
|
||||
internal const string FixedStateKey = "LocalCodeActProvider";
|
||||
|
||||
private static readonly IReadOnlyList<string> s_stateKeys = [FixedStateKey];
|
||||
|
||||
private readonly CodeExecutor _executor;
|
||||
|
||||
private readonly ConcurrentDictionary<string, AIFunction> _tools = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, FileMount> _fileMounts = new(StringComparer.Ordinal);
|
||||
private volatile bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="LocalCodeActProvider"/> class.</summary>
|
||||
/// <param name="pythonExecutablePath">Path to the Python interpreter used for execution and validation.</param>
|
||||
/// <param name="options">Optional provider configuration.</param>
|
||||
public LocalCodeActProvider(string pythonExecutablePath, LocalCodeActProviderOptions? options = null)
|
||||
{
|
||||
_ = Throw.IfNullOrWhitespace(pythonExecutablePath);
|
||||
options ??= new LocalCodeActProviderOptions();
|
||||
|
||||
var limits = options.ExecutionLimits ?? new ProcessExecutionLimits();
|
||||
var runnerScript = options.RunnerScriptPath ?? EmbeddedScripts.GetRunnerScriptPath();
|
||||
|
||||
CodeValidator? validator = null;
|
||||
if (!options.ValidationDisabled)
|
||||
{
|
||||
var validatorScript = options.ValidatorScriptPath ?? EmbeddedScripts.GetValidatorScriptPath();
|
||||
validator = new CodeValidator(
|
||||
pythonExecutablePath,
|
||||
validatorScript,
|
||||
TimeSpan.FromSeconds(limits.ValidationTimeoutSeconds),
|
||||
options.AllowedImports?.ToList(),
|
||||
options.BlockedImports?.ToList(),
|
||||
options.AllowedBuiltins?.ToList(),
|
||||
options.BlockedBuiltins?.ToList());
|
||||
}
|
||||
|
||||
this._executor = new CodeExecutor(
|
||||
pythonExecutablePath,
|
||||
runnerScript,
|
||||
validator,
|
||||
limits,
|
||||
options.Environment,
|
||||
options.WorkingDirectory);
|
||||
|
||||
if (options.Tools is not null)
|
||||
{
|
||||
foreach (var tool in options.Tools.Where(t => t is not null))
|
||||
{
|
||||
this._tools[tool.Name] = tool;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.FileMounts is not null)
|
||||
{
|
||||
foreach (var mount in options.FileMounts.Where(m => m is not null))
|
||||
{
|
||||
var normalized = FileMountHelper.Normalize(mount);
|
||||
this._fileMounts[normalized.MountPath] = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<string> StateKeys => s_stateKeys;
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Tool registry
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// <summary>Adds tools to the provider-owned tool registry. Duplicate names replace existing entries.</summary>
|
||||
public void AddTools(params AIFunction[] tools)
|
||||
{
|
||||
_ = Throw.IfNull(tools);
|
||||
this.ThrowIfDisposed();
|
||||
foreach (var tool in tools.Where(t => t is not null))
|
||||
{
|
||||
this._tools[tool.Name] = tool;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the currently registered tools.</summary>
|
||||
public IReadOnlyList<AIFunction> GetTools()
|
||||
{
|
||||
return this._tools.Values.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Removes tools by name.</summary>
|
||||
public void RemoveTools(params string[] names)
|
||||
{
|
||||
_ = Throw.IfNull(names);
|
||||
this.ThrowIfDisposed();
|
||||
foreach (var name in names.Where(n => n is not null))
|
||||
{
|
||||
_ = this._tools.TryRemove(name, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes all registered tools.</summary>
|
||||
public void ClearTools()
|
||||
{
|
||||
this.ThrowIfDisposed();
|
||||
this._tools.Clear();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// File mounts
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// <summary>Adds file mounts. Duplicate mount paths replace existing entries.</summary>
|
||||
public void AddFileMounts(params FileMount[] mounts)
|
||||
{
|
||||
_ = Throw.IfNull(mounts);
|
||||
this.ThrowIfDisposed();
|
||||
foreach (var mount in mounts.Where(m => m is not null))
|
||||
{
|
||||
var normalized = FileMountHelper.Normalize(mount);
|
||||
this._fileMounts[normalized.MountPath] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the currently registered file mounts.</summary>
|
||||
public IReadOnlyList<FileMount> GetFileMounts()
|
||||
{
|
||||
return this._fileMounts.Values.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Removes file mounts by mount path.</summary>
|
||||
public void RemoveFileMounts(params string[] mountPaths)
|
||||
{
|
||||
_ = Throw.IfNull(mountPaths);
|
||||
this.ThrowIfDisposed();
|
||||
foreach (var path in mountPaths.Where(p => p is not null))
|
||||
{
|
||||
_ = this._fileMounts.TryRemove(path, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes all registered file mounts.</summary>
|
||||
public void ClearFileMounts()
|
||||
{
|
||||
this.ThrowIfDisposed();
|
||||
this._fileMounts.Clear();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// AIContextProvider implementation
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(context);
|
||||
|
||||
CodeExecutor.RunSnapshot snapshot;
|
||||
this.ThrowIfDisposed();
|
||||
snapshot = new CodeExecutor.RunSnapshot(
|
||||
this._tools.Values.ToList(),
|
||||
this._fileMounts.Values.ToList());
|
||||
|
||||
var description = InstructionBuilder.BuildExecuteCodeDescription(snapshot.Tools, snapshot.FileMounts);
|
||||
var executeCode = new ExecuteCodeFunction(this._executor, snapshot, description);
|
||||
|
||||
var instructions = InstructionBuilder.BuildContextInstructions();
|
||||
|
||||
return new ValueTask<AIContext>(new AIContext
|
||||
{
|
||||
Instructions = instructions,
|
||||
Tools = [executeCode],
|
||||
});
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this._disposed, this);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
this._disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for <see cref="LocalCodeActProvider"/> and <see cref="LocalExecuteCodeFunction"/>.
|
||||
/// </summary>
|
||||
public sealed class LocalCodeActProviderOptions
|
||||
{
|
||||
/// <summary>Gets or sets the resource limits applied to subprocess execution and capture.</summary>
|
||||
public ProcessExecutionLimits? ExecutionLimits { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the initial set of host tools available to generated code via <c>await call_tool(...)</c>.
|
||||
/// </summary>
|
||||
public IEnumerable<AIFunction>? Tools { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the initial set of file mounts exposed to generated code.
|
||||
/// </summary>
|
||||
public IEnumerable<FileMount>? FileMounts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets environment variables passed to the subprocess.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/>, the subprocess inherits the parent process environment
|
||||
/// (the default <see cref="System.Diagnostics.ProcessStartInfo"/> behavior). To run with
|
||||
/// a restricted environment, supply a dictionary containing only the variables the
|
||||
/// subprocess should see — pass an empty dictionary for a fully scrubbed environment.
|
||||
/// On Windows, a small set of system variables (SYSTEMROOT, SYSTEMDRIVE, COMSPEC,
|
||||
/// PATHEXT, TEMP, TMP) is back-filled from the parent environment when not already
|
||||
/// present so Python can locate its standard library.
|
||||
/// </remarks>
|
||||
public IReadOnlyDictionary<string, string>? Environment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the working directory used for the subprocess. When <see langword="null"/>
|
||||
/// the current working directory of the host process is used.
|
||||
/// </summary>
|
||||
public string? WorkingDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the optional override path to the Python runner script. When <see langword="null"/>
|
||||
/// the embedded <c>runner.py</c> is extracted to a temporary directory and used.
|
||||
/// </summary>
|
||||
public string? RunnerScriptPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the optional override path to the Python validator script. When <see langword="null"/>
|
||||
/// the embedded <c>validator.py</c> is extracted to a temporary directory and used.
|
||||
/// </summary>
|
||||
public string? ValidatorScriptPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether AST allow-list validation is disabled. Defaults to <see langword="false"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Disabling validation removes a critical defense-in-depth control. Only disable when the
|
||||
/// generated code is trusted or when running inside a strong external sandbox.
|
||||
/// </remarks>
|
||||
public bool ValidationDisabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the set of imports allowed by the validator. When <see langword="null"/>
|
||||
/// the validator's built-in defaults are used. Setting a value replaces the defaults.
|
||||
/// </summary>
|
||||
public IEnumerable<string>? AllowedImports { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the set of imports blocked by the validator. When <see langword="null"/>
|
||||
/// the validator's built-in defaults are used. Setting a value replaces the defaults.
|
||||
/// </summary>
|
||||
public IEnumerable<string>? BlockedImports { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the set of builtins allowed by the validator. When <see langword="null"/>
|
||||
/// the validator's built-in defaults are used. Setting a value replaces the defaults.
|
||||
/// </summary>
|
||||
public IEnumerable<string>? AllowedBuiltins { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the set of builtins blocked by the validator. When <see langword="null"/>
|
||||
/// the validator's built-in defaults are used. Setting a value replaces the defaults.
|
||||
/// </summary>
|
||||
public IEnumerable<string>? BlockedBuiltins { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.LocalCodeAct.Internal;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct;
|
||||
|
||||
/// <summary>
|
||||
/// Standalone <c>execute_code</c> <see cref="AIFunction"/> that runs Python locally in a subprocess.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use this when you want to expose code execution directly as a model-facing function without
|
||||
/// the <see cref="LocalCodeActProvider"/> indirection. Tools and file mounts are captured at
|
||||
/// construction time and immutable for the lifetime of the function.
|
||||
/// </remarks>
|
||||
public sealed class LocalExecuteCodeFunction : AIFunction
|
||||
{
|
||||
private const string ExecuteCodeName = "execute_code";
|
||||
|
||||
private readonly CodeExecutor _executor;
|
||||
private readonly CodeExecutor.RunSnapshot _snapshot;
|
||||
private readonly AIFunction _inner;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="LocalExecuteCodeFunction"/> class.</summary>
|
||||
/// <param name="pythonExecutablePath">Path to the Python interpreter used for execution and validation.</param>
|
||||
/// <param name="options">Optional function configuration.</param>
|
||||
public LocalExecuteCodeFunction(string pythonExecutablePath, LocalCodeActProviderOptions? options = null)
|
||||
{
|
||||
_ = Throw.IfNullOrWhitespace(pythonExecutablePath);
|
||||
options ??= new LocalCodeActProviderOptions();
|
||||
|
||||
var limits = options.ExecutionLimits ?? new ProcessExecutionLimits();
|
||||
var runnerScript = options.RunnerScriptPath ?? EmbeddedScripts.GetRunnerScriptPath();
|
||||
|
||||
CodeValidator? validator = null;
|
||||
if (!options.ValidationDisabled)
|
||||
{
|
||||
var validatorScript = options.ValidatorScriptPath ?? EmbeddedScripts.GetValidatorScriptPath();
|
||||
validator = new CodeValidator(
|
||||
pythonExecutablePath,
|
||||
validatorScript,
|
||||
TimeSpan.FromSeconds(limits.ValidationTimeoutSeconds),
|
||||
options.AllowedImports?.ToList(),
|
||||
options.BlockedImports?.ToList(),
|
||||
options.AllowedBuiltins?.ToList(),
|
||||
options.BlockedBuiltins?.ToList());
|
||||
}
|
||||
|
||||
var tools = options.Tools?.Where(t => t is not null).ToList() ?? new List<AIFunction>();
|
||||
var fileMounts = options.FileMounts?.Where(m => m is not null).Select(FileMountHelper.Normalize).ToList() ?? new List<FileMount>();
|
||||
|
||||
this._executor = new CodeExecutor(
|
||||
pythonExecutablePath,
|
||||
runnerScript,
|
||||
validator,
|
||||
limits,
|
||||
options.Environment,
|
||||
options.WorkingDirectory);
|
||||
|
||||
this._snapshot = new CodeExecutor.RunSnapshot(tools, fileMounts);
|
||||
this._inner = AIFunctionFactory.Create(
|
||||
this.ExecuteCodeAsync,
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = ExecuteCodeName,
|
||||
Description = InstructionBuilder.BuildExecuteCodeDescription(tools, fileMounts),
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Name => this._inner.Name;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Description => this._inner.Description;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override JsonElement JsonSchema => this._inner.JsonSchema;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) =>
|
||||
this._inner.InvokeAsync(arguments, cancellationToken);
|
||||
|
||||
private async ValueTask<object?> ExecuteCodeAsync(
|
||||
[Description("Python source code to execute locally in the agent environment.")] string code,
|
||||
CancellationToken cancellationToken)
|
||||
=> string.IsNullOrWhiteSpace(code)
|
||||
? throw new ArgumentException("Parameter 'code' must not be empty.", nameof(code))
|
||||
: await this._executor.ExecuteAsync(this._snapshot, code, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework - Local CodeAct integration</Title>
|
||||
<Description>Provides local Python code execution (CodeAct) with AST validation for Microsoft Agent Framework. Requires external sandboxing (e.g., container, VM).</Description>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="/" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.LocalCodeAct.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Embed Python runner and validator scripts as resources -->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\**\*.py" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct;
|
||||
|
||||
/// <summary>
|
||||
/// Resource limits for subprocess code execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These limits provide defense-in-depth controls to prevent runaway code execution,
|
||||
/// but are NOT a security sandbox. Real sandboxing must come from external container/VM
|
||||
/// isolation (for example, Foundry hosted agents, Docker, or Azure Container Instances).
|
||||
/// </remarks>
|
||||
public sealed class ProcessExecutionLimits
|
||||
{
|
||||
/// <summary>Gets or sets the maximum execution time for the subprocess, in seconds. Default is 30.</summary>
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>Gets or sets the maximum time the AST validator subprocess may run, in seconds. Default is 10.</summary>
|
||||
public int ValidationTimeoutSeconds { get; set; } = 10;
|
||||
|
||||
/// <summary>Gets or sets the maximum bytes of stdout captured from the subprocess. Default is 10 MiB.</summary>
|
||||
public int MaxStdoutBytes { get; set; } = 10 * 1024 * 1024;
|
||||
|
||||
/// <summary>Gets or sets the maximum bytes of stderr captured from the subprocess. Default is 10 MiB.</summary>
|
||||
public int MaxStderrBytes { get; set; } = 10 * 1024 * 1024;
|
||||
|
||||
/// <summary>Gets or sets the maximum serialized result size in bytes. Default is 10 MiB.</summary>
|
||||
public int MaxResultBytes { get; set; } = 10 * 1024 * 1024;
|
||||
|
||||
/// <summary>Gets or sets the maximum bytes captured per file under read-write mounts. Default is 1 MiB.</summary>
|
||||
public int MaxCapturedFileBytes { get; set; } = 1024 * 1024;
|
||||
|
||||
/// <summary>Gets or sets the maximum total bytes captured across all read-write mounts. Default is 10 MiB.</summary>
|
||||
public int MaxTotalCapturedFileBytes { get; set; } = 10 * 1024 * 1024;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
# Microsoft.Agents.AI.LocalCodeAct
|
||||
|
||||
Local CodeAct integration for Microsoft Agent Framework.
|
||||
|
||||
> [!WARNING]
|
||||
> This package runs LLM-generated Python code in the local environment. It is **NOT**
|
||||
> a Python security sandbox and is not safe for untrusted prompts or code on a
|
||||
> developer workstation or production host without an external sandbox.
|
||||
|
||||
`Microsoft.Agents.AI.LocalCodeAct` is intended for environments that already
|
||||
provide process, filesystem, network, and credential isolation (e.g., Azure
|
||||
container instances, VMs, or Foundry hosted agents). It provides the familiar
|
||||
CodeAct provider pattern used by the Hyperlight package while executing Python
|
||||
locally in the agent environment.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
dotnet add package Microsoft.Agents.AI.LocalCodeAct --prerelease
|
||||
```
|
||||
|
||||
This is a preview package.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```csharp
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.LocalCodeAct;
|
||||
|
||||
var options = new LocalCodeActProviderOptions()
|
||||
{
|
||||
ExecutionLimits = new ProcessExecutionLimits { TimeoutSeconds = 5 },
|
||||
};
|
||||
|
||||
using var provider = new LocalCodeActProvider("/usr/bin/python3", options);
|
||||
|
||||
// Register provider with your AIAgent's context providers.
|
||||
```
|
||||
|
||||
## What the Package Controls
|
||||
|
||||
- **AST validation** (default on): Validates generated code against allow-lists
|
||||
before execution.
|
||||
- **Subprocess execution**: Runs generated code in a child Python process.
|
||||
- **Explicit Python path**: the provider and standalone function constructors require a Python executable path (no default).
|
||||
- **Isolated environment**: Does not inherit host environment variables unless
|
||||
explicitly provided.
|
||||
- **No shell invocation**: Launches Python directly without a shell.
|
||||
- **Resource limits**: Applies timeout, stdout, stderr, and result-size limits.
|
||||
- **Tool gating**: Only provider-owned host tools can be invoked from generated
|
||||
code via `await call_tool("<name>", ...)`.
|
||||
- **File capture**: Captures new files under configured **read-write** mounts
|
||||
while skipping symlinks. Modifications to pre-existing files are not captured.
|
||||
|
||||
These are defense-in-depth controls, not a containment boundary. The AST
|
||||
validator blocks common dangerous operations (`eval`, `exec`,
|
||||
`import subprocess`, attribute access for `os.system`, `__class__`, etc.) but
|
||||
does not make Python execution safe on an unsandboxed host.
|
||||
|
||||
## What the Package Does NOT Protect
|
||||
|
||||
- Malicious Python code working within allowed imports and operations.
|
||||
- Network access unless the surrounding environment blocks it.
|
||||
- Prompt-injected exfiltration through allowed host tools.
|
||||
- Resource exhaustion outside the configured limits.
|
||||
- Log, stdout, stderr, or result poisoning.
|
||||
|
||||
**Use Azure container instances, VMs, Foundry hosted agents, or equivalent
|
||||
infrastructure as the actual security boundary.**
|
||||
|
||||
## Host Tools
|
||||
|
||||
Register host tools via the options or on the provider directly:
|
||||
|
||||
```csharp
|
||||
var addFunction = AIFunctionFactory.Create(
|
||||
(int a, int b) => a + b,
|
||||
name: "add",
|
||||
description: "Adds two integers.");
|
||||
|
||||
using var provider = new LocalCodeActProvider("/usr/bin/python3", new LocalCodeActProviderOptions
|
||||
{
|
||||
Tools = new[] { addFunction },
|
||||
});
|
||||
|
||||
// Or mutate after construction:
|
||||
provider.AddTools(addFunction);
|
||||
```
|
||||
|
||||
Inside `execute_code`:
|
||||
|
||||
```python
|
||||
total = await call_tool("add", a=2, b=3)
|
||||
print(total)
|
||||
```
|
||||
|
||||
## Code Validation
|
||||
|
||||
By default, the package validates Python code against allow-lists before
|
||||
execution. The validator runs in its own short-lived Python subprocess with a
|
||||
dedicated timeout (`ProcessExecutionLimits.ValidationTimeoutSeconds`).
|
||||
|
||||
- **Allowed imports**: `math`, `random`, `json`, `datetime`, `pathlib`, `os`
|
||||
(only `os.environ`, `os.path` attributes are reachable), etc.
|
||||
- **Blocked imports**: `subprocess`, `sys`, `socket`, `importlib`, network and
|
||||
threading modules, etc.
|
||||
- **Allowed builtins**: `print`, `len`, `str`, type constructors, etc.
|
||||
- **Blocked builtins**: `eval`, `exec`, `compile`, `__import__`, `open`,
|
||||
`getattr`, `setattr`, etc.
|
||||
|
||||
See [`Resources/validator.py`](Resources/validator.py) for the full default
|
||||
allow-lists.
|
||||
|
||||
### Customizing Validation
|
||||
|
||||
Override the default lists:
|
||||
|
||||
```csharp
|
||||
using var provider = new LocalCodeActProvider("/usr/bin/python3", new LocalCodeActProviderOptions
|
||||
{
|
||||
AllowedImports = new[] { "math", "datetime", "mymodule" },
|
||||
BlockedImports = new[] { "subprocess", "sys" },
|
||||
AllowedBuiltins = new[] { "print", "len", "str", "int" },
|
||||
BlockedBuiltins = new[] { "eval", "exec", "compile" },
|
||||
});
|
||||
```
|
||||
|
||||
Custom lists **replace** the defaults (not augment).
|
||||
|
||||
### Disabling Validation
|
||||
|
||||
Set `ValidationDisabled = true` to skip the AST validator entirely. Doing so
|
||||
removes a critical defense-in-depth control. Only disable when the generated
|
||||
code is trusted or when running inside a strong external sandbox.
|
||||
|
||||
## File Mounts
|
||||
|
||||
Mount host directories to expose them to generated code:
|
||||
|
||||
```csharp
|
||||
using var provider = new LocalCodeActProvider("/usr/bin/python3", new LocalCodeActProviderOptions
|
||||
{
|
||||
FileMounts = new[]
|
||||
{
|
||||
new FileMount("/tmp/data", "/input", FileMountMode.ReadOnly),
|
||||
new FileMount("/tmp/output", "/output", FileMountMode.ReadWrite),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Generated code accesses mounts via `HostPath`. `MountPath` is descriptive
|
||||
metadata only — the subprocess sees the real host path. Read-write mounts are
|
||||
scanned for **new** files after execution, and those files are returned as
|
||||
`DataContent`. Symlinks are skipped.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Pass environment variables explicitly. The subprocess does NOT inherit the host
|
||||
environment by default:
|
||||
|
||||
```csharp
|
||||
using var provider = new LocalCodeActProvider("/usr/bin/python3", new LocalCodeActProviderOptions
|
||||
{
|
||||
Environment = new Dictionary<string, string>
|
||||
{
|
||||
["API_KEY"] = "...",
|
||||
["LOG_LEVEL"] = "INFO",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Standalone Function
|
||||
|
||||
If you do not want the provider machinery you can expose `execute_code` directly:
|
||||
|
||||
```csharp
|
||||
var function = new LocalExecuteCodeFunction("/usr/bin/python3");
|
||||
```
|
||||
|
||||
`LocalExecuteCodeFunction` snapshots tools and mounts at construction time and
|
||||
is safe to reuse across invocations.
|
||||
|
||||
## Execution Modes
|
||||
|
||||
The .NET implementation only supports subprocess execution. There is no
|
||||
"unsafe in-process" mode in .NET.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,210 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Child-process runner for local CodeAct subprocess mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import keyword
|
||||
import sys
|
||||
import traceback
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, TextIO, cast
|
||||
|
||||
|
||||
class _CappedTextIO(io.TextIOBase):
|
||||
def __init__(self, limit: int) -> None:
|
||||
super().__init__()
|
||||
self._limit = max(0, limit)
|
||||
self._buffer = io.StringIO()
|
||||
self.truncated = False
|
||||
|
||||
def writable(self) -> bool:
|
||||
return True
|
||||
|
||||
def write(self, value: str) -> int:
|
||||
text = str(value)
|
||||
current = self._buffer.tell()
|
||||
remaining = max(0, self._limit - current)
|
||||
if remaining:
|
||||
self._buffer.write(text[:remaining])
|
||||
if len(text) > remaining:
|
||||
self.truncated = True
|
||||
return len(text)
|
||||
|
||||
def getvalue(self) -> str:
|
||||
return self._buffer.getvalue()
|
||||
|
||||
|
||||
def _json_safe_mapping(value: Mapping[Any, Any]) -> dict[str, object]:
|
||||
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||
|
||||
|
||||
def _json_safe_sequence(value: Sequence[Any]) -> list[object]:
|
||||
return [_json_safe(item) for item in value]
|
||||
|
||||
|
||||
def _json_safe(value: object) -> object:
|
||||
try:
|
||||
json.dumps(value)
|
||||
except (TypeError, ValueError):
|
||||
if isinstance(value, Mapping):
|
||||
return _json_safe_mapping(cast("Mapping[Any, Any]", value)) # type: ignore[redundant-cast]
|
||||
if isinstance(value, (list, tuple)):
|
||||
return _json_safe_sequence(cast("Sequence[Any]", value))
|
||||
return repr(value)
|
||||
return value
|
||||
|
||||
|
||||
def _compile_main(code: str) -> tuple[Any, bool]:
|
||||
module = ast.parse(code, mode="exec")
|
||||
body = list(module.body)
|
||||
output_present = bool(body and isinstance(body[-1], ast.Expr))
|
||||
if output_present:
|
||||
last_expr = body[-1]
|
||||
if isinstance(last_expr, ast.Expr):
|
||||
body[-1] = ast.Return(value=last_expr.value)
|
||||
else:
|
||||
body.append(ast.Return(value=ast.Constant(value=None)))
|
||||
|
||||
async_function_def = cast(Any, ast.AsyncFunctionDef)
|
||||
function = async_function_def(
|
||||
name="__local_codeact_main__",
|
||||
args=ast.arguments(
|
||||
posonlyargs=[],
|
||||
args=[],
|
||||
kwonlyargs=[],
|
||||
kw_defaults=[],
|
||||
defaults=[],
|
||||
),
|
||||
body=body,
|
||||
decorator_list=[],
|
||||
returns=None,
|
||||
type_comment=None,
|
||||
)
|
||||
wrapped = ast.Module(body=[function], type_ignores=[])
|
||||
ast.fix_missing_locations(wrapped)
|
||||
return compile(wrapped, "<local-codeact>", "exec"), output_present
|
||||
|
||||
|
||||
def _send(control: TextIO, payload: Mapping[str, Any]) -> None:
|
||||
control.write(json.dumps(payload, separators=(",", ":")) + "\n")
|
||||
control.flush()
|
||||
|
||||
|
||||
async def _read_response(call_id: int) -> dict[str, Any]:
|
||||
line = await asyncio.to_thread(sys.stdin.readline)
|
||||
if not line:
|
||||
raise RuntimeError("Parent process closed the tool bridge.")
|
||||
response_value: Any = json.loads(line)
|
||||
if not isinstance(response_value, dict):
|
||||
raise RuntimeError("Received an invalid tool bridge response.")
|
||||
response = cast("dict[str, Any]", response_value)
|
||||
if response.get("call_id") != call_id:
|
||||
raise RuntimeError("Received an invalid tool bridge response.")
|
||||
if not response.get("ok"):
|
||||
exc_type = str(response.get("exc_type") or "RuntimeError")
|
||||
message = str(response.get("message") or "Tool call failed.")
|
||||
raise RuntimeError(f"{exc_type}: {message}")
|
||||
return response
|
||||
|
||||
|
||||
def _make_tool(name: str, *, control: TextIO, bridge_lock: asyncio.Lock) -> Any:
|
||||
async def _tool(**kwargs: Any) -> Any:
|
||||
return await _call_tool(name, control=control, bridge_lock=bridge_lock, kwargs=kwargs)
|
||||
|
||||
_tool.__name__ = name
|
||||
return _tool
|
||||
|
||||
|
||||
async def _call_tool(
|
||||
name: str,
|
||||
*,
|
||||
control: TextIO,
|
||||
bridge_lock: asyncio.Lock,
|
||||
kwargs: Mapping[str, Any],
|
||||
) -> Any:
|
||||
call_id = id(kwargs)
|
||||
async with bridge_lock:
|
||||
_send(
|
||||
control,
|
||||
{
|
||||
"type": "tool_call",
|
||||
"call_id": call_id,
|
||||
"name": name,
|
||||
"kwargs": _json_safe(dict(kwargs)),
|
||||
},
|
||||
)
|
||||
response = await _read_response(call_id)
|
||||
return response.get("result")
|
||||
|
||||
|
||||
async def _execute(request: Mapping[str, Any], control: TextIO) -> dict[str, Any]:
|
||||
code = str(request.get("code") or "")
|
||||
stdout = _CappedTextIO(int(request.get("max_stdout_bytes") or 0))
|
||||
stderr = _CappedTextIO(int(request.get("max_stderr_bytes") or 0))
|
||||
tool_names_value = request.get("tool_names")
|
||||
tool_names = (
|
||||
[str(name) for name in cast("Sequence[Any]", tool_names_value)] if isinstance(tool_names_value, list) else []
|
||||
)
|
||||
bridge_lock = asyncio.Lock()
|
||||
|
||||
async def call_tool(name: str, **kwargs: Any) -> Any:
|
||||
return await _call_tool(name, control=control, bridge_lock=bridge_lock, kwargs=kwargs)
|
||||
|
||||
globals_dict: dict[str, Any] = {
|
||||
"__builtins__": __builtins__,
|
||||
"asyncio": asyncio,
|
||||
"call_tool": call_tool,
|
||||
}
|
||||
for tool_name in tool_names:
|
||||
if tool_name.isidentifier() and not keyword.iskeyword(tool_name):
|
||||
globals_dict[tool_name] = _make_tool(tool_name, control=control, bridge_lock=bridge_lock)
|
||||
|
||||
compiled, output_present = _compile_main(code)
|
||||
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
||||
exec(compiled, globals_dict, globals_dict) # noqa: S102 # nosec B102 - this runner exists to execute generated code.
|
||||
output = await globals_dict["__local_codeact_main__"]()
|
||||
|
||||
return {
|
||||
"stdout": stdout.getvalue(),
|
||||
"stderr": stderr.getvalue(),
|
||||
"stdout_truncated": stdout.truncated,
|
||||
"stderr_truncated": stderr.truncated,
|
||||
"output_present": output_present,
|
||||
"output": _json_safe(output),
|
||||
}
|
||||
|
||||
|
||||
async def _main() -> int:
|
||||
control = sys.stdout
|
||||
line = await asyncio.to_thread(sys.stdin.readline)
|
||||
if not line:
|
||||
return 1
|
||||
try:
|
||||
request_value: Any = json.loads(line)
|
||||
if not isinstance(request_value, dict):
|
||||
raise ValueError("Expected a JSON object request.")
|
||||
request = cast("dict[str, Any]", request_value)
|
||||
result = await _execute(request, control)
|
||||
_send(control, {"type": "complete", "result": result})
|
||||
return 0
|
||||
except BaseException as exc:
|
||||
_send(
|
||||
control,
|
||||
{
|
||||
"type": "error",
|
||||
"exc_type": type(exc).__name__,
|
||||
"message": str(exc),
|
||||
"traceback": traceback.format_exc(limit=20),
|
||||
},
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(_main()))
|
||||
@@ -0,0 +1,492 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""AST validation for generated Python code."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import builtins as _builtins
|
||||
from typing import Any
|
||||
|
||||
_PYTHON_BUILTIN_NAMES: frozenset[str] = frozenset(dir(_builtins))
|
||||
|
||||
# Allowed imports that generated code may use.
|
||||
ALLOWED_IMPORTS: set[str] = {
|
||||
"asyncio",
|
||||
"pathlib",
|
||||
"json",
|
||||
"math",
|
||||
"datetime",
|
||||
"time",
|
||||
"itertools",
|
||||
"functools",
|
||||
"collections",
|
||||
"typing",
|
||||
"dataclasses",
|
||||
"decimal",
|
||||
"fractions",
|
||||
"re",
|
||||
"base64",
|
||||
"hashlib",
|
||||
"uuid",
|
||||
"random",
|
||||
"os", # Limited to os.environ, os.path - validated via attribute access
|
||||
}
|
||||
|
||||
# Blocked imports that expose dangerous capabilities.
|
||||
BLOCKED_IMPORTS: set[str] = {
|
||||
"sys",
|
||||
"subprocess",
|
||||
"socket",
|
||||
"urllib",
|
||||
"requests",
|
||||
"http",
|
||||
"ftplib",
|
||||
"smtplib",
|
||||
"telnetlib",
|
||||
"multiprocessing",
|
||||
"threading",
|
||||
"ctypes",
|
||||
"shutil",
|
||||
"tempfile",
|
||||
"importlib",
|
||||
"builtins",
|
||||
"__builtin__",
|
||||
}
|
||||
|
||||
# Allowed `os` attribute names. Generated code may only touch `os.environ` and
|
||||
# `os.path`; everything else (file I/O, process control, mutating helpers, etc.)
|
||||
# is rejected by default. Users may pass a custom allow-list via
|
||||
# ``allowed_os_attrs`` on the validator entry points.
|
||||
ALLOWED_OS_ATTRS: set[str] = {"environ", "path"}
|
||||
|
||||
# Allowed builtin function names that generated code may call.
|
||||
# Note: getattr/setattr/hasattr/delattr are NOT included because they can bypass
|
||||
# AST attribute restrictions (e.g., getattr(os, 'system')('...') avoids os.system check).
|
||||
# User-defined functions and registered tools are allowed at runtime.
|
||||
ALLOWED_BUILTINS: set[str] = {
|
||||
"print",
|
||||
"len",
|
||||
"str",
|
||||
"int",
|
||||
"float",
|
||||
"bool",
|
||||
"list",
|
||||
"dict",
|
||||
"tuple",
|
||||
"set",
|
||||
"frozenset",
|
||||
"range",
|
||||
"enumerate",
|
||||
"zip",
|
||||
"map",
|
||||
"filter",
|
||||
"sorted",
|
||||
"reversed",
|
||||
"sum",
|
||||
"min",
|
||||
"max",
|
||||
"abs",
|
||||
"round",
|
||||
"pow",
|
||||
"divmod",
|
||||
"all",
|
||||
"any",
|
||||
"chr",
|
||||
"ord",
|
||||
"hex",
|
||||
"oct",
|
||||
"bin",
|
||||
"format",
|
||||
"repr",
|
||||
"ascii",
|
||||
"bytes",
|
||||
"bytearray",
|
||||
"memoryview",
|
||||
"isinstance",
|
||||
"issubclass",
|
||||
"callable",
|
||||
"type",
|
||||
"id",
|
||||
"hash",
|
||||
"next",
|
||||
"iter",
|
||||
"slice",
|
||||
}
|
||||
|
||||
# Blocked builtin function names that expose dangerous capabilities.
|
||||
BLOCKED_BUILTINS: set[str] = {
|
||||
"eval",
|
||||
"exec",
|
||||
"compile",
|
||||
"__import__",
|
||||
"globals",
|
||||
"locals",
|
||||
"vars",
|
||||
"dir",
|
||||
"open", # File I/O must go through pathlib with explicit mounts
|
||||
"input",
|
||||
"help",
|
||||
"breakpoint",
|
||||
"exit",
|
||||
"quit",
|
||||
"copyright",
|
||||
"credits",
|
||||
"license",
|
||||
"delattr",
|
||||
"getattr", # Can bypass AST attribute checks: getattr(os, 'system')
|
||||
"setattr", # Can bypass AST attribute checks
|
||||
"hasattr", # Can probe for dangerous attributes
|
||||
}
|
||||
|
||||
# Allowed AST node types for code structure and operations.
|
||||
ALLOWED_AST_NODES: set[type[ast.AST]] = {
|
||||
ast.Module,
|
||||
ast.Expr,
|
||||
ast.Assign,
|
||||
ast.AugAssign,
|
||||
ast.AnnAssign,
|
||||
ast.For,
|
||||
ast.AsyncFor,
|
||||
ast.While,
|
||||
ast.If,
|
||||
ast.With,
|
||||
ast.AsyncWith,
|
||||
ast.Try,
|
||||
ast.ExceptHandler,
|
||||
ast.Pass,
|
||||
ast.Break,
|
||||
ast.Continue,
|
||||
ast.Return,
|
||||
ast.Await,
|
||||
# Comparisons and boolean operations
|
||||
ast.Compare,
|
||||
ast.BoolOp,
|
||||
ast.UnaryOp,
|
||||
ast.And,
|
||||
ast.Or,
|
||||
ast.Not,
|
||||
ast.Eq,
|
||||
ast.NotEq,
|
||||
ast.Lt,
|
||||
ast.LtE,
|
||||
ast.Gt,
|
||||
ast.GtE,
|
||||
ast.In,
|
||||
ast.NotIn,
|
||||
ast.Is,
|
||||
ast.IsNot,
|
||||
ast.UAdd,
|
||||
ast.USub,
|
||||
ast.Invert,
|
||||
# Data access
|
||||
ast.Name,
|
||||
ast.Load,
|
||||
ast.Store,
|
||||
ast.Del,
|
||||
ast.Attribute,
|
||||
ast.Subscript,
|
||||
ast.Slice,
|
||||
# Literals
|
||||
ast.Constant,
|
||||
ast.List,
|
||||
ast.Tuple,
|
||||
ast.Set,
|
||||
ast.Dict,
|
||||
# Arithmetic and bitwise operations
|
||||
ast.BinOp,
|
||||
ast.Add,
|
||||
ast.Sub,
|
||||
ast.Mult,
|
||||
ast.Div,
|
||||
ast.Mod,
|
||||
ast.FloorDiv,
|
||||
ast.Pow,
|
||||
ast.LShift,
|
||||
ast.RShift,
|
||||
ast.BitOr,
|
||||
ast.BitXor,
|
||||
ast.BitAnd,
|
||||
# Function calls and comprehensions
|
||||
ast.Call,
|
||||
ast.keyword,
|
||||
ast.ListComp,
|
||||
ast.SetComp,
|
||||
ast.DictComp,
|
||||
ast.GeneratorExp,
|
||||
ast.comprehension,
|
||||
# Control flow helpers
|
||||
ast.IfExp,
|
||||
ast.JoinedStr,
|
||||
ast.FormattedValue,
|
||||
# Imports (validated separately)
|
||||
ast.Import,
|
||||
ast.ImportFrom,
|
||||
ast.alias,
|
||||
# Function definitions (for local helpers)
|
||||
ast.FunctionDef,
|
||||
ast.AsyncFunctionDef,
|
||||
ast.arguments,
|
||||
ast.arg,
|
||||
# Lambda expressions
|
||||
ast.Lambda,
|
||||
# Match statements (Python 3.10+)
|
||||
ast.Match,
|
||||
ast.match_case,
|
||||
ast.MatchValue,
|
||||
ast.MatchSingleton,
|
||||
ast.MatchSequence,
|
||||
ast.MatchMapping,
|
||||
ast.MatchClass,
|
||||
ast.MatchStar,
|
||||
ast.MatchAs,
|
||||
ast.MatchOr,
|
||||
# Starred expressions
|
||||
ast.Starred,
|
||||
}
|
||||
|
||||
|
||||
class CodeValidationError(ValueError):
|
||||
"""Raised when generated code violates the allow-list policy."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class _CodeValidator(ast.NodeVisitor):
|
||||
"""AST visitor that validates generated code against allow-lists."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
allowed_imports: set[str] | None = None,
|
||||
blocked_imports: set[str] | None = None,
|
||||
allowed_builtins: set[str] | None = None,
|
||||
blocked_builtins: set[str] | None = None,
|
||||
allowed_os_attrs: set[str] | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._errors: list[str] = []
|
||||
self._allowed_imports = allowed_imports if allowed_imports is not None else ALLOWED_IMPORTS
|
||||
self._blocked_imports = blocked_imports if blocked_imports is not None else BLOCKED_IMPORTS
|
||||
self._allowed_builtins = allowed_builtins if allowed_builtins is not None else ALLOWED_BUILTINS
|
||||
self._blocked_builtins = blocked_builtins if blocked_builtins is not None else BLOCKED_BUILTINS
|
||||
self._allowed_os_attrs = allowed_os_attrs if allowed_os_attrs is not None else ALLOWED_OS_ATTRS
|
||||
|
||||
def validate(self, code: str) -> None:
|
||||
"""Validate code and raise CodeValidationError if it violates policy."""
|
||||
try:
|
||||
tree = ast.parse(code, mode="exec")
|
||||
except SyntaxError as exc:
|
||||
raise CodeValidationError(f"Syntax error in generated code: {exc}") from exc
|
||||
|
||||
self._errors = []
|
||||
self.visit(tree)
|
||||
|
||||
if self._errors:
|
||||
raise CodeValidationError(
|
||||
"Generated code violates allow-list policy:\n" + "\n".join(f"- {err}" for err in self._errors)
|
||||
)
|
||||
|
||||
def visit(self, node: ast.AST) -> Any:
|
||||
"""Visit a node and check if its type is allowed."""
|
||||
node_type = type(node)
|
||||
if node_type not in ALLOWED_AST_NODES:
|
||||
self._errors.append(f"AST node type '{node_type.__name__}' is not allowed")
|
||||
return None
|
||||
return super().visit(node)
|
||||
|
||||
def visit_Import(self, node: ast.Import) -> None:
|
||||
"""Validate import statements."""
|
||||
for alias_node in node.names:
|
||||
module_name = alias_node.name.split(".")[0]
|
||||
if module_name in self._blocked_imports:
|
||||
self._errors.append(f"Import of '{alias_node.name}' is not allowed (blocked: {module_name})")
|
||||
elif module_name not in self._allowed_imports:
|
||||
self._errors.append(f"Import of '{alias_node.name}' is not allowed (not in allow-list)")
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
"""Validate from-import statements."""
|
||||
if node.module is None:
|
||||
self._errors.append("Relative imports are not allowed")
|
||||
return
|
||||
|
||||
module_name = node.module.split(".")[0]
|
||||
if module_name in self._blocked_imports:
|
||||
self._errors.append(f"Import from '{node.module}' is not allowed (blocked: {module_name})")
|
||||
elif module_name not in self._allowed_imports:
|
||||
self._errors.append(f"Import from '{node.module}' is not allowed (not in allow-list)")
|
||||
elif module_name == "os":
|
||||
# Mirror the os.* attribute allow-list for ``from os import X``,
|
||||
# otherwise ``from os import system`` would bypass visit_Attribute.
|
||||
for alias_node in node.names:
|
||||
if alias_node.name not in self._allowed_os_attrs:
|
||||
self._errors.append(f"Import from 'os' of '{alias_node.name}' is not allowed")
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None:
|
||||
"""Validate function calls.
|
||||
|
||||
For names that match a real Python builtin we enforce both the block-list
|
||||
and the allow-list. Names that are not builtins are treated as user-defined
|
||||
functions or registered tools and are allowed (validated at runtime).
|
||||
"""
|
||||
if isinstance(node.func, ast.Name):
|
||||
func_name = node.func.id
|
||||
if func_name in self._blocked_builtins:
|
||||
self._errors.append(f"Call to builtin '{func_name}' is not allowed")
|
||||
elif func_name in _PYTHON_BUILTIN_NAMES and func_name not in self._allowed_builtins:
|
||||
# Real builtin that wasn't explicitly allowed — reject so the allow-list is meaningful.
|
||||
self._errors.append(f"Call to builtin '{func_name}' is not in the allowed builtins list")
|
||||
|
||||
# Check for attribute access to dangerous methods
|
||||
if isinstance(node.func, ast.Attribute):
|
||||
attr_name = node.func.attr
|
||||
# Block common dangerous attribute methods
|
||||
if (
|
||||
attr_name.startswith("__")
|
||||
and attr_name.endswith("__")
|
||||
and attr_name not in {"__init__", "__str__", "__repr__", "__eq__", "__hash__"}
|
||||
):
|
||||
self._errors.append(f"Call to dunder method '{attr_name}' is not allowed")
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Attribute(self, node: ast.Attribute) -> None:
|
||||
"""Validate attribute access."""
|
||||
# Enforce the `os` attribute allow-list. Anything outside `ALLOWED_OS_ATTRS`
|
||||
# (file I/O, process control, mutating helpers, etc.) is rejected so the
|
||||
# validator matches the documented `os.environ` / `os.path`-only contract.
|
||||
if isinstance(node.value, ast.Name) and node.value.id == "os" and node.attr not in self._allowed_os_attrs:
|
||||
self._errors.append(f"Access to os.{node.attr} is not allowed")
|
||||
|
||||
# Block access to certain dangerous attributes
|
||||
if (
|
||||
node.attr.startswith("__")
|
||||
and node.attr.endswith("__")
|
||||
and node.attr
|
||||
not in {
|
||||
"__name__",
|
||||
"__doc__",
|
||||
"__dict__",
|
||||
"__class__",
|
||||
"__module__",
|
||||
"__file__",
|
||||
"__init__",
|
||||
"__str__",
|
||||
"__repr__",
|
||||
"__eq__",
|
||||
"__hash__",
|
||||
"__len__",
|
||||
"__iter__",
|
||||
"__next__",
|
||||
"__enter__",
|
||||
"__exit__",
|
||||
"__aenter__",
|
||||
"__aexit__",
|
||||
}
|
||||
):
|
||||
self._errors.append(f"Access to attribute '{node.attr}' is not allowed")
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def validate_code(
|
||||
code: str,
|
||||
*,
|
||||
allowed_imports: set[str] | None = None,
|
||||
blocked_imports: set[str] | None = None,
|
||||
allowed_builtins: set[str] | None = None,
|
||||
blocked_builtins: set[str] | None = None,
|
||||
allowed_os_attrs: set[str] | None = None,
|
||||
) -> None:
|
||||
"""Validate generated code against AST allow-lists.
|
||||
|
||||
Args:
|
||||
code: Python source code to validate.
|
||||
allowed_imports: Custom set of allowed module names (replaces defaults).
|
||||
blocked_imports: Custom set of blocked module names (replaces defaults).
|
||||
allowed_builtins: Custom set of allowed builtin names (replaces defaults).
|
||||
blocked_builtins: Custom set of blocked builtin names (replaces defaults).
|
||||
allowed_os_attrs: Custom set of allowed ``os`` attribute names
|
||||
(replaces the default ``{"environ", "path"}`` allow-list).
|
||||
|
||||
Raises:
|
||||
CodeValidationError: If the code violates the allow-list policy.
|
||||
"""
|
||||
validator = _CodeValidator(
|
||||
allowed_imports=allowed_imports,
|
||||
blocked_imports=blocked_imports,
|
||||
allowed_builtins=allowed_builtins,
|
||||
blocked_builtins=blocked_builtins,
|
||||
allowed_os_attrs=allowed_os_attrs,
|
||||
)
|
||||
validator.validate(code)
|
||||
|
||||
|
||||
def _main() -> int:
|
||||
"""Script entrypoint: read a JSON request from stdin and validate it.
|
||||
|
||||
Request shape:
|
||||
{
|
||||
"code": "...",
|
||||
"allowed_imports": [...]?,
|
||||
"blocked_imports": [...]?,
|
||||
"allowed_builtins": [...]?,
|
||||
"blocked_builtins": [...]?,
|
||||
"allowed_os_attrs": [...]?
|
||||
}
|
||||
|
||||
On success: exit code 0, no output required.
|
||||
On validation failure: exit code 1, JSON {"errors": ["..."]} on stdout.
|
||||
On request error: exit code 2, JSON {"message": "..."} on stdout.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
raw = sys.stdin.read()
|
||||
try:
|
||||
request = json.loads(raw) if raw.strip() else {}
|
||||
if not isinstance(request, dict):
|
||||
raise ValueError("Validator request must be a JSON object.")
|
||||
code = request.get("code")
|
||||
if not isinstance(code, str):
|
||||
raise ValueError("Validator request must include a 'code' string field.")
|
||||
except Exception as exc: # noqa: BLE001 - report any parse error to caller
|
||||
json.dump({"message": f"Invalid validator request: {exc}"}, sys.stdout)
|
||||
return 2
|
||||
|
||||
def _as_set(value: Any) -> set[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("Validator allow/block lists must be arrays of strings.")
|
||||
return {str(item) for item in value}
|
||||
|
||||
try:
|
||||
validate_code(
|
||||
code,
|
||||
allowed_imports=_as_set(request.get("allowed_imports")),
|
||||
blocked_imports=_as_set(request.get("blocked_imports")),
|
||||
allowed_builtins=_as_set(request.get("allowed_builtins")),
|
||||
blocked_builtins=_as_set(request.get("blocked_builtins")),
|
||||
allowed_os_attrs=_as_set(request.get("allowed_os_attrs")),
|
||||
)
|
||||
except CodeValidationError as exc:
|
||||
message = str(exc)
|
||||
lines = [line.lstrip("- ").rstrip() for line in message.splitlines() if line.strip()]
|
||||
if lines and lines[0].startswith("Generated code violates"):
|
||||
lines = lines[1:]
|
||||
if not lines:
|
||||
lines = [message]
|
||||
json.dump({"errors": lines}, sys.stdout)
|
||||
return 1
|
||||
except Exception as exc: # noqa: BLE001 - convert unexpected errors to a structured response
|
||||
json.dump({"errors": [f"{type(exc).__name__}: {exc}"]}, sys.stdout)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(_main())
|
||||
@@ -60,9 +60,6 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
|
||||
this._knownValidInputTypes = knownValidInputTypes != null
|
||||
? [.. knownValidInputTypes]
|
||||
: [];
|
||||
|
||||
// Initialize the runners for each of the edges, along with the state for edges that need it.
|
||||
this.EdgeMap = new EdgeMap(this.RunContext, this.Workflow.Edges, this.Workflow.Ports.Values, this.Workflow.StartExecutorId, this.StepTracer);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ISuperStepRunner.SessionId"/>
|
||||
@@ -154,7 +151,6 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
|
||||
private Workflow Workflow { get; init; }
|
||||
internal InProcessRunnerContext RunContext { get; init; }
|
||||
private ICheckpointManager? CheckpointManager { get; }
|
||||
private EdgeMap EdgeMap { get; init; }
|
||||
|
||||
public ConcurrentEventSink OutgoingEvents { get; } = new();
|
||||
|
||||
@@ -358,7 +354,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
|
||||
// Create a representation of the current workflow if it does not already exist.
|
||||
this._workflowInfoCache ??= this.Workflow.ToWorkflowInfo();
|
||||
|
||||
Dictionary<EdgeId, PortableValue> edgeData = await this.EdgeMap.ExportStateAsync().ConfigureAwait(false);
|
||||
Dictionary<EdgeId, PortableValue> edgeData = await this.RunContext.ExportEdgeStateAsync().ConfigureAwait(false);
|
||||
|
||||
await prepareTask.ConfigureAwait(false);
|
||||
await this.RunContext.StateManager.PublishUpdatesAsync(this.StepTracer).ConfigureAwait(false);
|
||||
@@ -422,7 +418,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
|
||||
|
||||
Task executorNotifyTask = this.RunContext.NotifyCheckpointLoadedAsync(cancellationToken);
|
||||
|
||||
await this.EdgeMap.ImportStateAsync(checkpoint).ConfigureAwait(false);
|
||||
await this.RunContext.ImportEdgeStateAsync(checkpoint).ConfigureAwait(false);
|
||||
await Task.WhenAll(executorNotifyTask,
|
||||
restoreCheckpointIndexTask.AsTask()).ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -411,6 +411,20 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
||||
return new(result);
|
||||
}
|
||||
|
||||
internal ValueTask<Dictionary<EdgeId, PortableValue>> ExportEdgeStateAsync()
|
||||
{
|
||||
this.CheckEnded();
|
||||
|
||||
return this._edgeMap.ExportStateAsync();
|
||||
}
|
||||
|
||||
internal ValueTask ImportEdgeStateAsync(Checkpoint checkpoint)
|
||||
{
|
||||
this.CheckEnded();
|
||||
|
||||
return this._edgeMap.ImportStateAsync(checkpoint);
|
||||
}
|
||||
|
||||
internal async ValueTask RepublishUnservicedRequestsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.CheckEnded();
|
||||
|
||||
+14
-14
@@ -26,12 +26,12 @@ namespace Microsoft.Agents.AI;
|
||||
/// <para>
|
||||
/// This provider exposes the following tools to the agent:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>BackgroundAgents_StartTask</c> — Start a background task on a named agent with text input. Returns the task ID.</description></item>
|
||||
/// <item><description><c>BackgroundAgents_WaitForFirstCompletion</c> — Block until the first of the specified tasks completes. Returns the completed task's ID.</description></item>
|
||||
/// <item><description><c>BackgroundAgents_GetTaskResults</c> — Retrieve the text output of a completed background task.</description></item>
|
||||
/// <item><description><c>BackgroundAgents_GetAllTasks</c> — List all background tasks with their IDs, statuses, descriptions, and agent names.</description></item>
|
||||
/// <item><description><c>BackgroundAgents_ContinueTask</c> — Send follow-up input to a completed background task's session to resume work.</description></item>
|
||||
/// <item><description><c>BackgroundAgents_ClearCompletedTask</c> — Remove a completed background task and release its session to free memory.</description></item>
|
||||
/// <item><description><c>background_agents_start_task</c> — Start a background task on a named agent with text input. Returns the task ID.</description></item>
|
||||
/// <item><description><c>background_agents_wait_for_first_completion</c> — Block until the first of the specified tasks completes. Returns the completed task's ID.</description></item>
|
||||
/// <item><description><c>background_agents_get_task_results</c> — Retrieve the text output of a completed background task.</description></item>
|
||||
/// <item><description><c>background_agents_get_all_tasks</c> — List all background tasks with their IDs, statuses, descriptions, and agent names.</description></item>
|
||||
/// <item><description><c>background_agents_continue_task</c> — Send follow-up input to a completed background task's session to resume work.</description></item>
|
||||
/// <item><description><c>background_agents_clear_completed_task</c> — Remove a completed background task and release its session to free memory.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
@@ -43,10 +43,10 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
## BackgroundAgents
|
||||
You have access to background agents that can perform work on your behalf.
|
||||
|
||||
- Use the `BackgroundAgents_*` list of tools to start tasks on background agents and check their results.
|
||||
- Use the `background_agents_*` list of 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 BackgroundAgents_ClearCompletedTask to free memory, unless you plan to continue it with BackgroundAgents_ContinueTask.
|
||||
- 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}
|
||||
""";
|
||||
@@ -256,7 +256,7 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "BackgroundAgents_StartTask",
|
||||
Name = "background_agents_start_task",
|
||||
Description = "Start a background task on a named background agent. Returns a confirmation message containing the task ID.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -314,7 +314,7 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "BackgroundAgents_WaitForFirstCompletion",
|
||||
Name = "background_agents_wait_for_first_completion",
|
||||
Description = "Block until the first of the specified background tasks completes. Provide one or more task IDs. Returns a status message containing the ID of the task that completed first.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -341,7 +341,7 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "BackgroundAgents_GetTaskResults",
|
||||
Name = "background_agents_get_task_results",
|
||||
Description = "Get the text output of a background task by its ID. Returns the result text if complete, or status information if still running or failed.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -367,7 +367,7 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "BackgroundAgents_GetAllTasks",
|
||||
Name = "background_agents_get_all_tasks",
|
||||
Description = "List all background tasks with their IDs, statuses, agent names, and descriptions.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -416,7 +416,7 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "BackgroundAgents_ContinueTask",
|
||||
Name = "background_agents_continue_task",
|
||||
Description = "Send follow-up input to a completed or failed background task to resume its work. The background task's session is preserved, so the agent retains conversational context.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -449,7 +449,7 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "BackgroundAgents_ClearCompletedTask",
|
||||
Name = "background_agents_clear_completed_task",
|
||||
Description = "Remove a completed or failed background task and release its session to free memory. Use this after retrieving results when you no longer need to continue the task.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
|
||||
@@ -49,13 +49,13 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
|
||||
private const string DefaultInstructions =
|
||||
"""
|
||||
## File Based Memory
|
||||
You have access to a session-scoped, file-based memory system via the `FileMemory_*` tools for storing and retrieving information across interactions.
|
||||
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.
|
||||
|
||||
- Use descriptive file names (e.g., "projectarchitecture.md", "userpreferences.md").
|
||||
- Include a description when saving a file to help with future discovery.
|
||||
- Before starting new tasks, use FileMemory_ListFiles and FileMemory_SearchFiles to check for relevant existing memories to avoid duplicate work.
|
||||
- Before starting new tasks, use file_memory_list_files and file_memory_search_files to check for relevant existing memories to avoid duplicate work.
|
||||
- Keep memories up-to-date by overwriting files when information changes.
|
||||
- When you receive large amounts of data (e.g., downloaded web pages, API responses, research results),
|
||||
save them to files if they will be required later, so that they are not lost when older context is compacted or truncated.
|
||||
@@ -129,7 +129,7 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
|
||||
[
|
||||
new ChatMessage(ChatRole.User,
|
||||
"The following is your memory index — a list of files you have previously saved. " +
|
||||
"You can read any of these files using the FileMemory_ReadFile tool.\n\n" +
|
||||
"You can read any of these files using the file_memory_read_file tool.\n\n" +
|
||||
indexContent),
|
||||
];
|
||||
}
|
||||
@@ -319,11 +319,11 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
|
||||
|
||||
return
|
||||
[
|
||||
AIFunctionFactory.Create(this.SaveFileAsync, new AIFunctionFactoryOptions { Name = "FileMemory_SaveFile", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.ReadFileAsync, new AIFunctionFactoryOptions { Name = "FileMemory_ReadFile", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.DeleteFileAsync, new AIFunctionFactoryOptions { Name = "FileMemory_DeleteFile", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.ListFilesAsync, new AIFunctionFactoryOptions { Name = "FileMemory_ListFiles", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.SearchFilesAsync, new AIFunctionFactoryOptions { Name = "FileMemory_SearchFiles", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.SaveFileAsync, new AIFunctionFactoryOptions { Name = "file_memory_save_file", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.ReadFileAsync, new AIFunctionFactoryOptions { Name = "file_memory_read_file", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.DeleteFileAsync, new AIFunctionFactoryOptions { Name = "file_memory_delete_file", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.ListFilesAsync, new AIFunctionFactoryOptions { Name = "file_memory_list_files", SerializerOptions = serializerOptions }),
|
||||
AIFunctionFactory.Create(this.SearchFilesAsync, new AIFunctionFactoryOptions { Name = "file_memory_search_files", SerializerOptions = serializerOptions }),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ public sealed class LoopAgent : DelegatingAIAgent
|
||||
/// </summary>
|
||||
/// <param name="innerAgent">The underlying agent to invoke in a loop.</param>
|
||||
/// <param name="evaluators">
|
||||
/// The ordered set of <see cref="LoopEvaluator"/> that decide whether to re-invoke the agent. They are evaluated in
|
||||
/// The ordered collection of <see cref="LoopEvaluator"/> that decide whether to re-invoke the agent. They are evaluated in
|
||||
/// order after each iteration and the first that asks to re-invoke wins.
|
||||
/// </param>
|
||||
/// <param name="options">Optional configuration for the loop. When <see langword="null"/>, defaults are used.</param>
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Out-of-the-box implementations include <see cref="AIJudgeLoopEvaluator"/>, <see cref="DelegateLoopEvaluator"/>,
|
||||
/// and <see cref="CompletionMarkerLoopEvaluator"/>. Implementations should be stateless and safe to share across
|
||||
/// <see cref="CompletionMarkerLoopEvaluator"/>, and <see cref="TodoCompletionLoopEvaluator"/>. Implementations should be stateless and safe to share across
|
||||
/// concurrent loop runs; any per-run state must be stored on the supplied <see cref="LoopContext"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="LoopEvaluator"/> that keeps re-invoking the wrapped agent until a <see cref="TodoProvider"/> has no
|
||||
/// remaining (incomplete) todo items, optionally only while the agent is operating in one of a configured set of modes
|
||||
/// tracked by an <see cref="AgentModeProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The required <see cref="TodoProvider"/> — and, when modes are configured, the <see cref="AgentModeProvider"/> — are
|
||||
/// not supplied directly. They are resolved at evaluation time from the looped agent via
|
||||
/// <see cref="AIAgent.GetService{TService}(object?)"/>. This works because an agent surfaces its registered
|
||||
/// <see cref="AIContextProvider"/> instances through <c>GetService</c>, so a single <see cref="TodoProvider"/> (and
|
||||
/// <see cref="AgentModeProvider"/>) attached to the agent's session is discovered automatically. It also means this
|
||||
/// evaluator can be added directly to a harness agent's loop without any additional wiring.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When one or more modes are configured, the evaluator only requests re-invocation while the session's current mode is
|
||||
/// one of those modes; in any other mode it returns <see cref="LoopEvaluation.Stop"/> (which, per <see cref="LoopAgent"/>
|
||||
/// semantics, declines to drive continuation rather than vetoing other evaluators). When no modes are configured the
|
||||
/// evaluator applies in every mode and no <see cref="AgentModeProvider"/> is required.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// While incomplete todos remain the evaluator continues with feedback built from a template (see
|
||||
/// <see cref="TodoCompletionLoopEvaluatorOptions.FeedbackMessageTemplate"/>) with the remaining todo list substituted
|
||||
/// for <see cref="RemainingTodosPlaceholder"/>. How that feedback is delivered to the agent (and whether the session is
|
||||
/// reset) is decided by the <see cref="LoopAgent"/> that consumes this evaluator.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class TodoCompletionLoopEvaluator : LoopEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// The placeholder token within <see cref="DefaultFeedbackMessageTemplate"/> (or a custom
|
||||
/// <see cref="TodoCompletionLoopEvaluatorOptions.FeedbackMessageTemplate"/>) that is replaced, on each evaluation,
|
||||
/// with a formatted list of the remaining (incomplete) todo items.
|
||||
/// </summary>
|
||||
public const string RemainingTodosPlaceholder = "{remaining_todos}";
|
||||
|
||||
/// <summary>The default template used to build the feedback produced while incomplete todo items remain.</summary>
|
||||
public const string DefaultFeedbackMessageTemplate =
|
||||
"You still have incomplete todo items. Continue working until every item is complete, marking each item as " +
|
||||
"complete when finished. The following items are still open:\n" + RemainingTodosPlaceholder;
|
||||
|
||||
private readonly HashSet<string>? _modes;
|
||||
private readonly string _feedbackMessageTemplate;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TodoCompletionLoopEvaluator"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">
|
||||
/// Optional configuration for the evaluator, including <see cref="TodoCompletionLoopEvaluatorOptions.Modes"/> and
|
||||
/// the feedback message template. When <see langword="null"/>, defaults are used (applies in every mode).
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <see cref="TodoCompletionLoopEvaluatorOptions.Modes"/> is non-<see langword="null"/> but empty, or contains a
|
||||
/// <see langword="null"/>, empty, or whitespace mode name.
|
||||
/// </exception>
|
||||
public TodoCompletionLoopEvaluator(TodoCompletionLoopEvaluatorOptions? options = null)
|
||||
{
|
||||
if (options?.Modes is not null)
|
||||
{
|
||||
var modeSet = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (string mode in options.Modes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mode))
|
||||
{
|
||||
throw new ArgumentException("Mode names must not be null, empty, or whitespace.", nameof(options));
|
||||
}
|
||||
|
||||
modeSet.Add(mode);
|
||||
}
|
||||
|
||||
if (modeSet.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one mode must be supplied when modes are specified. Leave Modes null to apply in every mode.", nameof(options));
|
||||
}
|
||||
|
||||
this._modes = modeSet;
|
||||
}
|
||||
|
||||
this._feedbackMessageTemplate = options?.FeedbackMessageTemplate ?? DefaultFeedbackMessageTemplate;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<LoopEvaluation> EvaluateAsync(LoopContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(context);
|
||||
|
||||
TodoProvider todoProvider = context.Agent.GetService<TodoProvider>()
|
||||
?? throw new InvalidOperationException(
|
||||
$"{nameof(TodoCompletionLoopEvaluator)} requires a {nameof(TodoProvider)} to be registered on the agent, but none could be resolved via GetService.");
|
||||
|
||||
// When modes are configured, only drive re-invocation while the current mode is one of them.
|
||||
if (this._modes is not null)
|
||||
{
|
||||
AgentModeProvider modeProvider = context.Agent.GetService<AgentModeProvider>()
|
||||
?? throw new InvalidOperationException(
|
||||
$"{nameof(TodoCompletionLoopEvaluator)} was configured with modes but no {nameof(AgentModeProvider)} could be resolved from the agent via GetService.");
|
||||
|
||||
string currentMode = modeProvider.GetMode(context.Session);
|
||||
if (!this._modes.Contains(currentMode))
|
||||
{
|
||||
return LoopEvaluation.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
List<TodoItem> remaining = await todoProvider.GetRemainingTodosAsync(context.Session, cancellationToken).ConfigureAwait(false);
|
||||
if (remaining.Count == 0)
|
||||
{
|
||||
return LoopEvaluation.Stop();
|
||||
}
|
||||
|
||||
string feedback = this._feedbackMessageTemplate.Replace(RemainingTodosPlaceholder, FormatRemainingTodos(remaining));
|
||||
return LoopEvaluation.Continue(feedback);
|
||||
}
|
||||
|
||||
private static string FormatRemainingTodos(List<TodoItem> remaining)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < remaining.Count; i++)
|
||||
{
|
||||
TodoItem item = remaining[i];
|
||||
sb.Append("- ").Append(item.Id).Append(": ").Append(item.Title);
|
||||
if (!string.IsNullOrWhiteSpace(item.Description))
|
||||
{
|
||||
sb.Append(" — ").Append(item.Description);
|
||||
}
|
||||
|
||||
if (i < remaining.Count - 1)
|
||||
{
|
||||
sb.Append('\n');
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides configuration options for <see cref="TodoCompletionLoopEvaluator"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class TodoCompletionLoopEvaluatorOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the set of mode names for which the evaluator drives re-invocation, or <see langword="null"/> to
|
||||
/// apply in every mode.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/>, the evaluator applies in every mode and no <see cref="AgentModeProvider"/> is
|
||||
/// required. When non-<see langword="null"/> it must contain at least one non-empty mode name; mode names are
|
||||
/// matched ordinally and an <see cref="AgentModeProvider"/> must be resolvable from the agent at evaluation time.
|
||||
/// </remarks>
|
||||
public IEnumerable<string>? Modes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the template used to build the feedback produced while incomplete todo items remain,
|
||||
/// or <see langword="null"/> to use <see cref="TodoCompletionLoopEvaluator.DefaultFeedbackMessageTemplate"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Any occurrence of <see cref="TodoCompletionLoopEvaluator.RemainingTodosPlaceholder"/> in the template is
|
||||
/// replaced, on each evaluation, with a formatted list of the remaining (incomplete) todo items. When the
|
||||
/// placeholder is absent the rendered list is not appended.
|
||||
/// </remarks>
|
||||
public string? FeedbackMessageTemplate { get; set; }
|
||||
}
|
||||
@@ -100,11 +100,12 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
/// Modifying their properties will mutate the provider's state directly.
|
||||
/// </remarks>
|
||||
/// <param name="session">The agent session to read todos from.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A list of all todo items. The items are live references to internal state.</returns>
|
||||
public async Task<IReadOnlyList<TodoItem>> GetAllTodosAsync(AgentSession? session)
|
||||
public async Task<IReadOnlyList<TodoItem>> GetAllTodosAsync(AgentSession? session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SemaphoreSlim sessionLock = this.GetSessionLock(session);
|
||||
await sessionLock.WaitAsync().ConfigureAwait(false);
|
||||
await sessionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
TodoState state = this._sessionState.GetOrInitializeState(session);
|
||||
@@ -124,11 +125,12 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
/// Modifying their properties will mutate the provider's state directly.
|
||||
/// </remarks>
|
||||
/// <param name="session">The agent session to read todos from.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A list of incomplete todo items. The items are live references to internal state.</returns>
|
||||
public async Task<List<TodoItem>> GetRemainingTodosAsync(AgentSession? session)
|
||||
public async Task<List<TodoItem>> GetRemainingTodosAsync(AgentSession? session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SemaphoreSlim sessionLock = this.GetSessionLock(session);
|
||||
await sessionLock.WaitAsync().ConfigureAwait(false);
|
||||
await sessionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
TodoState state = this._sessionState.GetOrInitializeState(session);
|
||||
|
||||
@@ -53,4 +53,16 @@ public sealed class AgentSessionIdTests
|
||||
AgentSessionId sessionId = entityId;
|
||||
});
|
||||
}
|
||||
|
||||
// Ensures the 2-arg constructor treats the key as opaque and never re-interprets
|
||||
// it as a serialized session id, so the resulting Name always comes from the first
|
||||
// argument regardless of the key's shape.
|
||||
[Fact]
|
||||
public void ConstructorTreatsKeyAsOpaqueValue()
|
||||
{
|
||||
AgentSessionId sessionId = new("agentA", "@dafx-agentB@some-key");
|
||||
|
||||
Assert.Equal("agentA", sessionId.Name);
|
||||
Assert.Equal("@dafx-agentB@some-key", sessionId.Key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.UnitTests;
|
||||
|
||||
public sealed class DurableAIAgentProxyTests
|
||||
{
|
||||
// Verifies the proxy rejects a session whose agent name differs from its own,
|
||||
// and that the durable client is never called when this happens.
|
||||
[Fact]
|
||||
public async Task RunAsync_ThrowsWhenSessionBelongsToDifferentAgentAsync()
|
||||
{
|
||||
StubDurableAgentClient client = new();
|
||||
DurableAIAgentProxy proxy = new("agentA", client);
|
||||
DurableAgentSession session = new(new AgentSessionId("agentB", "shared-key"));
|
||||
|
||||
ArgumentException ex = await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
proxy.RunAsync(new ChatMessage(ChatRole.User, "hello"), session));
|
||||
|
||||
Assert.Equal("session", ex.ParamName);
|
||||
Assert.Contains("agentB", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("agentA", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(0, client.CallCount);
|
||||
}
|
||||
|
||||
// Control test: when the session's agent name matches the proxy's name,
|
||||
// the request is forwarded to the durable client.
|
||||
[Fact]
|
||||
public async Task RunAsync_AllowsSessionWhenAgentNameMatchesAsync()
|
||||
{
|
||||
AgentSessionId sessionId = new("agentA", "shared-key");
|
||||
InvalidOperationException sentinel = new("reached the client");
|
||||
StubDurableAgentClient client = new() { Throw = sentinel };
|
||||
DurableAIAgentProxy proxy = new("agentA", client);
|
||||
DurableAgentSession session = new(sessionId);
|
||||
|
||||
// Reaching the durable client (and therefore propagating the sentinel) proves the
|
||||
// name-matching guard accepted this session.
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
proxy.RunAsync(new ChatMessage(ChatRole.User, "hello"), session));
|
||||
|
||||
Assert.Same(sentinel, ex);
|
||||
Assert.Equal(1, client.CallCount);
|
||||
Assert.Equal(sessionId, client.LastSessionId);
|
||||
}
|
||||
|
||||
// Ensures the agent-name comparison is case-insensitive, so casing differences
|
||||
// are neither a false-positive rejection nor a bypass.
|
||||
[Fact]
|
||||
public async Task RunAsync_AgentNameComparisonIsCaseInsensitiveAsync()
|
||||
{
|
||||
AgentSessionId sessionId = new("AGENTA", "shared-key");
|
||||
InvalidOperationException sentinel = new("reached the client");
|
||||
StubDurableAgentClient client = new() { Throw = sentinel };
|
||||
DurableAIAgentProxy proxy = new("agentA", client);
|
||||
DurableAgentSession session = new(sessionId);
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
proxy.RunAsync(new ChatMessage(ChatRole.User, "hello"), session));
|
||||
|
||||
Assert.Same(sentinel, ex);
|
||||
Assert.Equal(1, client.CallCount);
|
||||
}
|
||||
|
||||
private sealed class StubDurableAgentClient : IDurableAgentClient
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
public AgentSessionId LastSessionId { get; private set; }
|
||||
public Exception? Throw { get; set; }
|
||||
|
||||
public Task<AgentRunHandle> RunAgentAsync(
|
||||
AgentSessionId sessionId,
|
||||
RunRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
this.CallCount++;
|
||||
this.LastSessionId = sessionId;
|
||||
if (this.Throw is not null)
|
||||
{
|
||||
return Task.FromException<AgentRunHandle>(this.Throw);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Test did not configure a response.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
#if NET
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
@@ -26,6 +27,8 @@ public class HarnessAgentOptionsTests
|
||||
Assert.Null(options.HarnessInstructions);
|
||||
Assert.Null(options.ChatHistoryProvider);
|
||||
Assert.Null(options.AIContextProviders);
|
||||
Assert.Null(options.LoopEvaluators);
|
||||
Assert.Null(options.LoopAgentOptions);
|
||||
Assert.False(options.DisableToolApproval);
|
||||
Assert.False(options.DisableNonApprovalRequiredFunctionBypassing);
|
||||
Assert.False(options.DisableFileMemory);
|
||||
@@ -64,6 +67,8 @@ public class HarnessAgentOptionsTests
|
||||
var skillsSource = new Mock<AgentSkillsSource>().Object;
|
||||
var backgroundAgents = new AIAgent[] { new Mock<AIAgent>().Object };
|
||||
var backgroundAgentsOptions = new BackgroundAgentsProviderOptions();
|
||||
var loopEvaluators = new LoopEvaluator[] { new DelegateLoopEvaluator((_, _) => new ValueTask<LoopEvaluation>(LoopEvaluation.Stop())) };
|
||||
var loopAgentOptions = new LoopAgentOptions();
|
||||
#if NET
|
||||
var shellExecutor = new Mock<ShellExecutor>().Object;
|
||||
var shellEnvOptions = new ShellEnvironmentProviderOptions();
|
||||
@@ -96,6 +101,8 @@ public class HarnessAgentOptionsTests
|
||||
OpenTelemetrySourceName = "custom-source",
|
||||
BackgroundAgents = backgroundAgents,
|
||||
BackgroundAgentsProviderOptions = backgroundAgentsOptions,
|
||||
LoopEvaluators = loopEvaluators,
|
||||
LoopAgentOptions = loopAgentOptions,
|
||||
#if NET
|
||||
ShellExecutor = shellExecutor,
|
||||
ShellEnvironmentProviderOptions = shellEnvOptions,
|
||||
@@ -129,6 +136,8 @@ public class HarnessAgentOptionsTests
|
||||
Assert.Equal("custom-source", options.OpenTelemetrySourceName);
|
||||
Assert.Same(backgroundAgents, options.BackgroundAgents);
|
||||
Assert.Same(backgroundAgentsOptions, options.BackgroundAgentsProviderOptions);
|
||||
Assert.Same(loopEvaluators, options.LoopEvaluators);
|
||||
Assert.Same(loopAgentOptions, options.LoopAgentOptions);
|
||||
#if NET
|
||||
Assert.Same(shellExecutor, options.ShellExecutor);
|
||||
Assert.Same(shellEnvOptions, options.ShellEnvironmentProviderOptions);
|
||||
|
||||
@@ -1820,4 +1820,121 @@ public class HarnessAgentTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Feature: Loop
|
||||
|
||||
/// <summary>
|
||||
/// Verify that no <see cref="LoopAgent"/> is added when no loop evaluators are supplied.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Loop_ExcludedByDefault()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
|
||||
// Assert
|
||||
Assert.Null(agent.GetService<LoopAgent>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that an empty loop evaluator collection does not add a <see cref="LoopAgent"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Loop_EmptyEvaluators_Excluded()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.LoopEvaluators = [];
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
|
||||
// Assert
|
||||
Assert.Null(agent.GetService<LoopAgent>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that a <see cref="LoopAgent"/> is added when at least one evaluator is supplied, while the inner
|
||||
/// <see cref="ChatClientAgent"/> remains resolvable through the decorator chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Loop_IncludedWhenEvaluatorsProvided()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.LoopEvaluators = [new DelegateLoopEvaluator((_, _) => new ValueTask<LoopEvaluation>(LoopEvaluation.Stop()))];
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.GetService<LoopAgent>());
|
||||
Assert.NotNull(agent.GetService<ChatClientAgent>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the <see cref="LoopAgent"/> is the outermost decorator, wrapping the <see cref="ToolApprovalAgent"/>
|
||||
/// (which is itself resolvable through the loop).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Loop_IsOutermost_WrappingToolApproval()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.DisableToolApproval = false;
|
||||
options.LoopEvaluators = [new DelegateLoopEvaluator((_, _) => new ValueTask<LoopEvaluation>(LoopEvaluation.Stop()))];
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
|
||||
// Assert — the loop is the outermost decorator: it is resolvable, it wraps the tool approval agent, and
|
||||
// looking *down* from the tool approval agent does not surface the loop (proving the loop sits above it).
|
||||
Assert.NotNull(agent.GetService<LoopAgent>());
|
||||
var toolApproval = agent.GetService<ToolApprovalAgent>();
|
||||
Assert.NotNull(toolApproval);
|
||||
Assert.Null(toolApproval.GetService<LoopAgent>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the loop actually drives re-invocation: an evaluator that continues once before stopping causes the
|
||||
/// inner chat client to be invoked twice.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Loop_DrivesReinvocationAsync()
|
||||
{
|
||||
// Arrange — inner client returns a response on each call.
|
||||
var mockClient = new Mock<IChatClient>();
|
||||
mockClient
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "working")));
|
||||
|
||||
var options = CreateAllDisabledOptions();
|
||||
// Continue once (iteration 1), then stop on the second evaluation.
|
||||
options.LoopEvaluators = [new DelegateLoopEvaluator((ctx, _) =>
|
||||
new ValueTask<LoopEvaluation>(ctx.Iteration < 2 ? LoopEvaluation.Continue() : LoopEvaluation.Stop()))];
|
||||
var agent = new HarnessAgent(mockClient.Object, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "go")], session);
|
||||
|
||||
// Assert — the inner client was invoked once per iteration (two iterations).
|
||||
mockClient.Verify(
|
||||
c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Exactly(2));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Agents.AI.LocalCodeAct.Internal;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="FileMountHelper"/> covering the capture-limit branches
|
||||
/// (per-file, per-mount, and total) that produce textual omission placeholders
|
||||
/// instead of <see cref="DataContent"/>.
|
||||
/// </summary>
|
||||
public sealed class FileMountHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void CaptureWrittenFiles_PerFileLimit_ReturnsTextPlaceholder()
|
||||
{
|
||||
var dir = Directory.CreateTempSubdirectory("fmh-perfile-").FullName;
|
||||
try
|
||||
{
|
||||
var mount = FileMountHelper.Normalize(new FileMount(dir, "/output", FileMountMode.ReadWrite));
|
||||
var pre = FileMountHelper.SnapshotWritableMounts(new[] { mount });
|
||||
|
||||
File.WriteAllBytes(Path.Combine(dir, "big.bin"), new byte[2048]);
|
||||
|
||||
// Per-file limit of 1024 bytes — file is 2048 -> should be omitted via TextContent.
|
||||
var limits = new ProcessExecutionLimits { MaxCapturedFileBytes = 1024 };
|
||||
var captured = FileMountHelper.CaptureWrittenFiles(new[] { mount }, pre, limits);
|
||||
|
||||
var text = Assert.Single(captured.OfType<TextContent>());
|
||||
Assert.Contains("/output/big.bin", text.Text);
|
||||
Assert.Contains("per-file capture limit", text.Text);
|
||||
Assert.Empty(captured.OfType<DataContent>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaptureWrittenFiles_PerMountLimit_OmitsSecondFile()
|
||||
{
|
||||
var dir = Directory.CreateTempSubdirectory("fmh-permount-").FullName;
|
||||
try
|
||||
{
|
||||
// WriteBytesLimit caps total bytes captured *for this mount*.
|
||||
var mount = FileMountHelper.Normalize(
|
||||
new FileMount(dir, "/output", FileMountMode.ReadWrite, writeBytesLimit: 600));
|
||||
var pre = FileMountHelper.SnapshotWritableMounts(new[] { mount });
|
||||
|
||||
// Two files of 400 bytes each — first fits, second exceeds the 600-byte per-mount cap.
|
||||
File.WriteAllBytes(Path.Combine(dir, "a.bin"), new byte[400]);
|
||||
File.WriteAllBytes(Path.Combine(dir, "b.bin"), new byte[400]);
|
||||
|
||||
var limits = new ProcessExecutionLimits(); // per-file/total caps high enough not to fire.
|
||||
var captured = FileMountHelper.CaptureWrittenFiles(new[] { mount }, pre, limits);
|
||||
|
||||
Assert.Single(captured.OfType<DataContent>());
|
||||
var text = Assert.Single(captured.OfType<TextContent>());
|
||||
Assert.Contains("per-mount capture limit", text.Text);
|
||||
Assert.Contains("/output/b.bin", text.Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaptureWrittenFiles_TotalLimit_OmitsAcrossMounts()
|
||||
{
|
||||
var dirA = Directory.CreateTempSubdirectory("fmh-totalA-").FullName;
|
||||
var dirB = Directory.CreateTempSubdirectory("fmh-totalB-").FullName;
|
||||
try
|
||||
{
|
||||
var mountA = FileMountHelper.Normalize(new FileMount(dirA, "/a", FileMountMode.ReadWrite));
|
||||
var mountB = FileMountHelper.Normalize(new FileMount(dirB, "/b", FileMountMode.ReadWrite));
|
||||
var mounts = new[] { mountA, mountB };
|
||||
var pre = FileMountHelper.SnapshotWritableMounts(mounts);
|
||||
|
||||
File.WriteAllBytes(Path.Combine(dirA, "a.bin"), new byte[500]);
|
||||
File.WriteAllBytes(Path.Combine(dirB, "b.bin"), new byte[500]);
|
||||
|
||||
// Total capture limit set so the first file fits and the second triggers
|
||||
// the cross-mount total cap.
|
||||
var limits = new ProcessExecutionLimits { MaxTotalCapturedFileBytes = 600 };
|
||||
var captured = FileMountHelper.CaptureWrittenFiles(mounts, pre, limits);
|
||||
|
||||
Assert.Single(captured.OfType<DataContent>());
|
||||
var text = Assert.Single(captured.OfType<TextContent>());
|
||||
Assert.Contains("total capture limit", text.Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dirA, recursive: true);
|
||||
Directory.Delete(dirB, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests;
|
||||
|
||||
public sealed class FileMountTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_AssignsProperties()
|
||||
{
|
||||
var tempDir = System.IO.Directory.CreateTempSubdirectory("filemount-test-").FullName;
|
||||
try
|
||||
{
|
||||
var mount = new FileMount(tempDir, "/app/data", FileMountMode.ReadWrite, writeBytesLimit: 1024);
|
||||
|
||||
Assert.Equal(tempDir, mount.HostPath);
|
||||
Assert.Equal("/app/data", mount.MountPath);
|
||||
Assert.Equal(FileMountMode.ReadWrite, mount.Mode);
|
||||
Assert.Equal(1024L, mount.WriteBytesLimit);
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultsAreReadWriteWithNoLimit()
|
||||
{
|
||||
var tempDir = System.IO.Directory.CreateTempSubdirectory("filemount-test-").FullName;
|
||||
try
|
||||
{
|
||||
var mount = new FileMount(tempDir, "/app/data");
|
||||
Assert.Equal(FileMountMode.ReadWrite, mount.Mode);
|
||||
Assert.Null(mount.WriteBytesLimit);
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_RequiresPaths()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new FileMount("", "/app/data"));
|
||||
Assert.Throws<ArgumentException>(() => new FileMount("/host/data", ""));
|
||||
_ = Assert.Throws<ArgumentNullException>(() => new FileMount(null!, "/app/data"));
|
||||
_ = Assert.Throws<ArgumentNullException>(() => new FileMount("/host/data", null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Agents.AI.LocalCodeAct.Internal;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests;
|
||||
|
||||
public sealed class InstructionBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildContextInstructions_ContainsExecuteCodeName()
|
||||
{
|
||||
var instructions = InstructionBuilder.BuildContextInstructions();
|
||||
Assert.Contains("execute_code", instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExecuteCodeDescription_MentionsToolsWhenProvided()
|
||||
{
|
||||
var tools = new List<AIFunction> { new TestTool("get_weather", "Returns current weather.") };
|
||||
var description = InstructionBuilder.BuildExecuteCodeDescription(tools, new List<FileMount>());
|
||||
|
||||
Assert.Contains("get_weather", description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExecuteCodeDescription_MentionsMountsWhenProvided()
|
||||
{
|
||||
var mounts = new List<FileMount> { new("/host/data", "/app/data") };
|
||||
var description = InstructionBuilder.BuildExecuteCodeDescription(new List<AIFunction>(), mounts);
|
||||
|
||||
Assert.Contains("/app/data", description);
|
||||
}
|
||||
|
||||
private sealed class TestTool : AIFunction
|
||||
{
|
||||
public TestTool(string name, string description)
|
||||
{
|
||||
this.Name = name;
|
||||
this.Description = description;
|
||||
}
|
||||
|
||||
public override string Name { get; }
|
||||
|
||||
public override string Description { get; }
|
||||
|
||||
protected override System.Threading.Tasks.ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, System.Threading.CancellationToken cancellationToken) =>
|
||||
new((object?)null);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests;
|
||||
|
||||
public sealed class LocalCodeActProviderOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProviderConstructor_RequiresPythonExecutablePath()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new LocalCodeActProvider(""));
|
||||
Assert.Throws<ArgumentException>(() => new LocalCodeActProvider(" "));
|
||||
_ = Assert.Throws<ArgumentNullException>(() => new LocalCodeActProvider(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecuteCodeFunctionConstructor_RequiresPythonExecutablePath()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new LocalExecuteCodeFunction(""));
|
||||
Assert.Throws<ArgumentException>(() => new LocalExecuteCodeFunction(" "));
|
||||
_ = Assert.Throws<ArgumentNullException>(() => new LocalExecuteCodeFunction(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidationDisabled_DefaultsToFalse()
|
||||
{
|
||||
var options = new LocalCodeActProviderOptions();
|
||||
Assert.False(options.ValidationDisabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests;
|
||||
|
||||
public sealed class LocalCodeActProviderTests
|
||||
{
|
||||
private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
|
||||
|
||||
private static AIContextProvider.InvokingContext NewInvokingContext() =>
|
||||
new(s_mockAgent, session: null, new AIContext());
|
||||
|
||||
private static LocalCodeActProviderOptions Options() =>
|
||||
new()
|
||||
{
|
||||
ValidationDisabled = true, // No subprocess will be launched in these tests
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task ProvideAIContextAsync_ReturnsExecuteCodeToolAndInstructionsAsync()
|
||||
{
|
||||
using var provider = new LocalCodeActProvider("/usr/bin/python3", Options());
|
||||
|
||||
var context = await provider.InvokingAsync(NewInvokingContext());
|
||||
|
||||
Assert.NotNull(context);
|
||||
Assert.NotNull(context!.Tools);
|
||||
var tools = context.Tools!.ToList();
|
||||
Assert.Single(tools);
|
||||
var function = Assert.IsAssignableFrom<AIFunction>(tools[0]);
|
||||
Assert.Equal("execute_code", function.Name);
|
||||
Assert.False(string.IsNullOrWhiteSpace(context.Instructions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAndRemoveTools_RoundTrips()
|
||||
{
|
||||
using var provider = new LocalCodeActProvider("/usr/bin/python3", Options());
|
||||
|
||||
var tool = new TestTool("ping");
|
||||
provider.AddTools(tool);
|
||||
|
||||
Assert.Contains(provider.GetTools(), t => t.Name == "ping");
|
||||
|
||||
provider.RemoveTools("ping");
|
||||
Assert.DoesNotContain(provider.GetTools(), t => t.Name == "ping");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAndRemoveFileMounts_RoundTrips()
|
||||
{
|
||||
using var provider = new LocalCodeActProvider("/usr/bin/python3", Options());
|
||||
|
||||
var tempDir = System.IO.Directory.CreateTempSubdirectory("localcodeact-test-").FullName;
|
||||
try
|
||||
{
|
||||
var mount = new FileMount(tempDir, "/app/data");
|
||||
provider.AddFileMounts(mount);
|
||||
|
||||
Assert.Contains(provider.GetFileMounts(), m => m.MountPath == "/app/data");
|
||||
|
||||
provider.RemoveFileMounts("/app/data");
|
||||
Assert.DoesNotContain(provider.GetFileMounts(), m => m.MountPath == "/app/data");
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearMethods_EmptyState()
|
||||
{
|
||||
using var provider = new LocalCodeActProvider("/usr/bin/python3", Options());
|
||||
|
||||
var tempDir1 = System.IO.Directory.CreateTempSubdirectory("localcodeact-test-").FullName;
|
||||
var tempDir2 = System.IO.Directory.CreateTempSubdirectory("localcodeact-test-").FullName;
|
||||
try
|
||||
{
|
||||
provider.AddTools(new TestTool("a"), new TestTool("b"));
|
||||
provider.AddFileMounts(new FileMount(tempDir1, "/m/1"), new FileMount(tempDir2, "/m/2"));
|
||||
|
||||
provider.ClearTools();
|
||||
provider.ClearFileMounts();
|
||||
|
||||
Assert.Empty(provider.GetTools());
|
||||
Assert.Empty(provider.GetFileMounts());
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.Directory.Delete(tempDir1, recursive: true);
|
||||
System.IO.Directory.Delete(tempDir2, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestTool : AIFunction
|
||||
{
|
||||
public TestTool(string name)
|
||||
{
|
||||
this.Name = name;
|
||||
}
|
||||
|
||||
public override string Name { get; }
|
||||
|
||||
public override string Description => "test tool";
|
||||
|
||||
protected override ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, System.Threading.CancellationToken cancellationToken) =>
|
||||
new((object?)null);
|
||||
}
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that launch a real Python subprocess. Skipped automatically when
|
||||
/// no Python interpreter is discoverable on PATH.
|
||||
/// </summary>
|
||||
public sealed class LocalExecuteCodeFunctionIntegrationTests
|
||||
{
|
||||
private static readonly string? s_python = FindPython();
|
||||
|
||||
private static void SkipIfNoPython()
|
||||
{
|
||||
if (s_python is null)
|
||||
{
|
||||
Assert.Skip("No Python interpreter found on PATH; skipping integration test.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteCode_PrintsAndReturnsResultAsync()
|
||||
{
|
||||
SkipIfNoPython();
|
||||
|
||||
var function = new LocalExecuteCodeFunction(s_python!);
|
||||
|
||||
var args = new AIFunctionArguments
|
||||
{
|
||||
["code"] = "print('hello world')\n1 + 2",
|
||||
};
|
||||
|
||||
var result = await function.InvokeAsync(args, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
var combined = GetResultText(result);
|
||||
Assert.Contains("hello world", combined);
|
||||
Assert.Contains("3", combined);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteCode_ValidationBlocksDisallowedImportAsync()
|
||||
{
|
||||
SkipIfNoPython();
|
||||
|
||||
var function = new LocalExecuteCodeFunction(s_python!);
|
||||
|
||||
var args = new AIFunctionArguments
|
||||
{
|
||||
["code"] = "import subprocess",
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<CodeValidationException>(async () =>
|
||||
await function.InvokeAsync(args, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteCode_CapturesFilesInWritableMountAsync()
|
||||
{
|
||||
SkipIfNoPython();
|
||||
|
||||
var hostDir = Directory.CreateTempSubdirectory("localcodeact-mount-").FullName;
|
||||
try
|
||||
{
|
||||
var options = new LocalCodeActProviderOptions
|
||||
{
|
||||
FileMounts = new[]
|
||||
{
|
||||
new FileMount(hostDir, "/output", FileMountMode.ReadWrite),
|
||||
},
|
||||
};
|
||||
|
||||
var function = new LocalExecuteCodeFunction(s_python!, options);
|
||||
|
||||
// Use os.path.join via the actual host path - the mount path is descriptive metadata only
|
||||
var escapedPath = hostDir.Replace("\\", "\\\\", StringComparison.Ordinal);
|
||||
var args = new AIFunctionArguments
|
||||
{
|
||||
["code"] = $"from pathlib import Path\nPath(r'{escapedPath}/out.txt').write_text('captured')",
|
||||
};
|
||||
|
||||
var result = await function.InvokeAsync(args, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
AssertResultContainsDataContent(result, "/output/out.txt");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(hostDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteCode_UnknownToolNameReturnsErrorToGeneratedCodeAsync()
|
||||
{
|
||||
SkipIfNoPython();
|
||||
|
||||
// No tools are registered, so any call_tool from generated code resolves to
|
||||
// the "Unknown tool" branch in ProcessBridge.HandleToolCallAsync.
|
||||
var function = new LocalExecuteCodeFunction(s_python!);
|
||||
|
||||
var args = new AIFunctionArguments
|
||||
{
|
||||
["code"] = @"
|
||||
try:
|
||||
await call_tool('definitely_not_registered', x=1)
|
||||
print('NO_ERROR')
|
||||
except Exception as exc:
|
||||
print('GOT_ERROR:' + type(exc).__name__ + ':' + str(exc))
|
||||
",
|
||||
};
|
||||
|
||||
var result = await function.InvokeAsync(args, CancellationToken.None);
|
||||
var combined = GetResultText(result);
|
||||
Assert.Contains("GOT_ERROR", combined);
|
||||
Assert.Contains("definitely_not_registered", combined);
|
||||
Assert.DoesNotContain("NO_ERROR", combined);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteCode_ToolThrowingExceptionPropagatesToGeneratedCodeAsync()
|
||||
{
|
||||
SkipIfNoPython();
|
||||
|
||||
// Tool that always throws — exercises ProcessBridge.HandleToolCallAsync exception path
|
||||
// which sends a structured error response back to the subprocess.
|
||||
Func<string, string> faulty = message => throw new InvalidOperationException("intentional: " + message);
|
||||
var faultyTool = AIFunctionFactory.Create(faulty, name: "faulty");
|
||||
|
||||
var options = new LocalCodeActProviderOptions
|
||||
{
|
||||
Tools = new[] { faultyTool },
|
||||
};
|
||||
var function = new LocalExecuteCodeFunction(s_python!, options);
|
||||
|
||||
var args = new AIFunctionArguments
|
||||
{
|
||||
["code"] = @"
|
||||
try:
|
||||
await call_tool('faulty', message='boom')
|
||||
print('NO_ERROR')
|
||||
except Exception as exc:
|
||||
print('GOT_ERROR:' + type(exc).__name__ + ':' + str(exc))
|
||||
",
|
||||
};
|
||||
var result = await function.InvokeAsync(args, CancellationToken.None);
|
||||
var combined = GetResultText(result);
|
||||
Assert.Contains("GOT_ERROR", combined);
|
||||
Assert.Contains("InvalidOperationException", combined);
|
||||
Assert.Contains("intentional: boom", combined);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validator_TimeoutKillsProcessAndThrowsAsync()
|
||||
{
|
||||
SkipIfNoPython();
|
||||
|
||||
// Custom validator script that ignores stdin and blocks forever so the
|
||||
// parent timeout fires and exercises the timeout catch in CodeValidator.
|
||||
var tempDir = Directory.CreateTempSubdirectory("localcodeact-vtimeout-").FullName;
|
||||
try
|
||||
{
|
||||
var scriptPath = Path.Combine(tempDir, "hang_validator.py");
|
||||
File.WriteAllText(scriptPath, "import time\nwhile True:\n time.sleep(60)\n");
|
||||
|
||||
var validator = new Internal.CodeValidator(
|
||||
s_python!,
|
||||
scriptPath,
|
||||
TimeSpan.FromSeconds(1),
|
||||
allowedImports: null,
|
||||
blockedImports: null,
|
||||
allowedBuiltins: null,
|
||||
blockedBuiltins: null);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<CodeValidationException>(
|
||||
async () => await validator.ValidateAsync("print('x')", CancellationToken.None));
|
||||
Assert.Contains("exceeded", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetResultText(object? result) =>
|
||||
result switch
|
||||
{
|
||||
IEnumerable<AIContent> contents => string.Join("\n", contents.OfType<TextContent>().Select(t => t.Text)),
|
||||
JsonElement element => element.GetRawText(),
|
||||
_ => result?.ToString() ?? string.Empty,
|
||||
};
|
||||
|
||||
private static void AssertResultContainsDataContent(object? result, string expectedPath)
|
||||
{
|
||||
if (result is IEnumerable<AIContent> contents)
|
||||
{
|
||||
Assert.Contains(contents, c => c is DataContent);
|
||||
return;
|
||||
}
|
||||
|
||||
var json = Assert.IsType<JsonElement>(result).GetRawText();
|
||||
Assert.Contains(expectedPath, json);
|
||||
}
|
||||
|
||||
private static string? FindPython()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("LOCAL_CODEACT_PYTHON");
|
||||
if (!string.IsNullOrWhiteSpace(configured) && IsUsablePython(configured))
|
||||
{
|
||||
return configured;
|
||||
}
|
||||
|
||||
var executableNames = OperatingSystem.IsWindows()
|
||||
? new[] { "python3.exe", "python.exe" }
|
||||
: new[] { "python3", "python" };
|
||||
|
||||
foreach (var name in executableNames)
|
||||
{
|
||||
var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||
foreach (var dir in path.Split(Path.PathSeparator))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidate = Path.Combine(dir, name);
|
||||
if (File.Exists(candidate) && IsUsablePython(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsUsablePython(string candidate)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = candidate,
|
||||
ArgumentList = { "--version" },
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
if (process is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!process.WaitForExit(milliseconds: 5000))
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
return false;
|
||||
}
|
||||
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.LocalCodeAct\Microsoft.Agents.AI.LocalCodeAct.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests;
|
||||
|
||||
public sealed class ProcessExecutionLimitsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_AreReasonable()
|
||||
{
|
||||
var limits = new ProcessExecutionLimits();
|
||||
|
||||
Assert.True(limits.TimeoutSeconds > 0);
|
||||
Assert.True(limits.MaxStdoutBytes > 0);
|
||||
Assert.True(limits.MaxStderrBytes > 0);
|
||||
Assert.True(limits.ValidationTimeoutSeconds > 0);
|
||||
Assert.True(limits.MaxResultBytes > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Properties_AreMutable()
|
||||
{
|
||||
var limits = new ProcessExecutionLimits
|
||||
{
|
||||
TimeoutSeconds = 60,
|
||||
MaxStdoutBytes = 1024,
|
||||
MaxStderrBytes = 512,
|
||||
ValidationTimeoutSeconds = 5,
|
||||
MaxResultBytes = 2048,
|
||||
};
|
||||
|
||||
Assert.Equal(60, limits.TimeoutSeconds);
|
||||
Assert.Equal(1024, limits.MaxStdoutBytes);
|
||||
Assert.Equal(512, limits.MaxStderrBytes);
|
||||
Assert.Equal(5, limits.ValidationTimeoutSeconds);
|
||||
Assert.Equal(2048, limits.MaxResultBytes);
|
||||
}
|
||||
}
|
||||
+38
-38
@@ -156,7 +156,7 @@ public class BackgroundAgentsProviderTests
|
||||
var tcs = new TaskCompletionSource<AgentResponse>();
|
||||
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
|
||||
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
|
||||
|
||||
// Act
|
||||
object? result = await startBackgroundTask.InvokeAsync(new AIFunctionArguments
|
||||
@@ -183,7 +183,7 @@ public class BackgroundAgentsProviderTests
|
||||
// Arrange
|
||||
var agent = CreateMockAgent("Research", "Research agent");
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
|
||||
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
|
||||
|
||||
// Act
|
||||
object? result = await startBackgroundTask.InvokeAsync(new AIFunctionArguments
|
||||
@@ -215,7 +215,7 @@ public class BackgroundAgentsProviderTests
|
||||
return callCount == 1 ? tcs1.Task : tcs2.Task;
|
||||
});
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
|
||||
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
|
||||
|
||||
// Act
|
||||
object? result1 = await startBackgroundTask.InvokeAsync(new AIFunctionArguments
|
||||
@@ -253,8 +253,8 @@ public class BackgroundAgentsProviderTests
|
||||
var tcs = new TaskCompletionSource<AgentResponse>();
|
||||
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
|
||||
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
|
||||
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
|
||||
AIFunction waitForFirst = GetTool(tools, "background_agents_wait_for_first_completion");
|
||||
|
||||
// Start one task
|
||||
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
|
||||
@@ -288,7 +288,7 @@ public class BackgroundAgentsProviderTests
|
||||
// Arrange
|
||||
var agent = CreateMockAgent("Research", "Research agent");
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
|
||||
AIFunction waitForFirst = GetTool(tools, "background_agents_wait_for_first_completion");
|
||||
|
||||
// Act
|
||||
object? result = await waitForFirst.InvokeAsync(new AIFunctionArguments
|
||||
@@ -314,9 +314,9 @@ public class BackgroundAgentsProviderTests
|
||||
var tcs = new TaskCompletionSource<AgentResponse>();
|
||||
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
|
||||
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
|
||||
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
|
||||
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
|
||||
AIFunction waitForFirst = GetTool(tools, "background_agents_wait_for_first_completion");
|
||||
AIFunction getResults = GetTool(tools, "background_agents_get_task_results");
|
||||
|
||||
// Start a task
|
||||
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
|
||||
@@ -355,8 +355,8 @@ public class BackgroundAgentsProviderTests
|
||||
var tcs = new TaskCompletionSource<AgentResponse>();
|
||||
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
|
||||
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
|
||||
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
|
||||
AIFunction getResults = GetTool(tools, "background_agents_get_task_results");
|
||||
|
||||
// Start a task (don't complete it)
|
||||
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
|
||||
@@ -387,7 +387,7 @@ public class BackgroundAgentsProviderTests
|
||||
// Arrange
|
||||
var agent = CreateMockAgent("Research", "Research agent");
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
|
||||
AIFunction getResults = GetTool(tools, "background_agents_get_task_results");
|
||||
|
||||
// Act
|
||||
object? result = await getResults.InvokeAsync(new AIFunctionArguments
|
||||
@@ -409,9 +409,9 @@ public class BackgroundAgentsProviderTests
|
||||
var tcs = new TaskCompletionSource<AgentResponse>();
|
||||
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
|
||||
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
|
||||
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
|
||||
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
|
||||
AIFunction waitForFirst = GetTool(tools, "background_agents_wait_for_first_completion");
|
||||
AIFunction getResults = GetTool(tools, "background_agents_get_task_results");
|
||||
|
||||
// Start a task
|
||||
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
|
||||
@@ -456,8 +456,8 @@ public class BackgroundAgentsProviderTests
|
||||
var tcs = new TaskCompletionSource<AgentResponse>();
|
||||
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
|
||||
AIFunction getAllTasks = GetTool(tools, "BackgroundAgents_GetAllTasks");
|
||||
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
|
||||
AIFunction getAllTasks = GetTool(tools, "background_agents_get_all_tasks");
|
||||
|
||||
// Start a task
|
||||
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
|
||||
@@ -490,9 +490,9 @@ public class BackgroundAgentsProviderTests
|
||||
var tcs = new TaskCompletionSource<AgentResponse>();
|
||||
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
|
||||
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
|
||||
AIFunction getAllTasks = GetTool(tools, "BackgroundAgents_GetAllTasks");
|
||||
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
|
||||
AIFunction waitForFirst = GetTool(tools, "background_agents_wait_for_first_completion");
|
||||
AIFunction getAllTasks = GetTool(tools, "background_agents_get_all_tasks");
|
||||
|
||||
// Start and complete a task
|
||||
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
|
||||
@@ -525,7 +525,7 @@ public class BackgroundAgentsProviderTests
|
||||
// Arrange
|
||||
var agent = CreateMockAgent("Research", "Research agent");
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction getAllTasks = GetTool(tools, "BackgroundAgents_GetAllTasks");
|
||||
AIFunction getAllTasks = GetTool(tools, "background_agents_get_all_tasks");
|
||||
|
||||
// Act
|
||||
object? result = await getAllTasks.InvokeAsync(new AIFunctionArguments());
|
||||
@@ -554,10 +554,10 @@ public class BackgroundAgentsProviderTests
|
||||
return callCount == 1 ? tcs1.Task : tcs2.Task;
|
||||
});
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
|
||||
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
|
||||
AIFunction continueTask = GetTool(tools, "BackgroundAgents_ContinueTask");
|
||||
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
|
||||
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
|
||||
AIFunction waitForFirst = GetTool(tools, "background_agents_wait_for_first_completion");
|
||||
AIFunction continueTask = GetTool(tools, "background_agents_continue_task");
|
||||
AIFunction getResults = GetTool(tools, "background_agents_get_task_results");
|
||||
|
||||
// Start and complete a task
|
||||
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
|
||||
@@ -606,8 +606,8 @@ public class BackgroundAgentsProviderTests
|
||||
var tcs = new TaskCompletionSource<AgentResponse>();
|
||||
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
|
||||
AIFunction continueTask = GetTool(tools, "BackgroundAgents_ContinueTask");
|
||||
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
|
||||
AIFunction continueTask = GetTool(tools, "background_agents_continue_task");
|
||||
|
||||
// Start a task (don't complete it)
|
||||
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
|
||||
@@ -639,7 +639,7 @@ public class BackgroundAgentsProviderTests
|
||||
// Arrange
|
||||
var agent = CreateMockAgent("Research", "Research agent");
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction continueTask = GetTool(tools, "BackgroundAgents_ContinueTask");
|
||||
AIFunction continueTask = GetTool(tools, "background_agents_continue_task");
|
||||
|
||||
// Act
|
||||
object? result = await continueTask.InvokeAsync(new AIFunctionArguments
|
||||
@@ -666,10 +666,10 @@ public class BackgroundAgentsProviderTests
|
||||
var tcs = new TaskCompletionSource<AgentResponse>();
|
||||
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
|
||||
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
|
||||
AIFunction clearTask = GetTool(tools, "BackgroundAgents_ClearCompletedTask");
|
||||
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
|
||||
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
|
||||
AIFunction waitForFirst = GetTool(tools, "background_agents_wait_for_first_completion");
|
||||
AIFunction clearTask = GetTool(tools, "background_agents_clear_completed_task");
|
||||
AIFunction getResults = GetTool(tools, "background_agents_get_task_results");
|
||||
|
||||
// Start and complete a task
|
||||
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
|
||||
@@ -711,8 +711,8 @@ public class BackgroundAgentsProviderTests
|
||||
var tcs = new TaskCompletionSource<AgentResponse>();
|
||||
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
|
||||
AIFunction clearTask = GetTool(tools, "BackgroundAgents_ClearCompletedTask");
|
||||
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
|
||||
AIFunction clearTask = GetTool(tools, "background_agents_clear_completed_task");
|
||||
|
||||
// Start a task (don't complete it)
|
||||
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
|
||||
@@ -743,7 +743,7 @@ public class BackgroundAgentsProviderTests
|
||||
// Arrange
|
||||
var agent = CreateMockAgent("Research", "Research agent");
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
AIFunction clearTask = GetTool(tools, "BackgroundAgents_ClearCompletedTask");
|
||||
AIFunction clearTask = GetTool(tools, "background_agents_clear_completed_task");
|
||||
|
||||
// Act
|
||||
object? result = await clearTask.InvokeAsync(new AIFunctionArguments
|
||||
@@ -794,7 +794,7 @@ public class BackgroundAgentsProviderTests
|
||||
var tcs = new TaskCompletionSource<AgentResponse>();
|
||||
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
|
||||
var (tools, _) = await CreateToolsWithProviderAsync(agent);
|
||||
var startTool = GetTool(tools, "BackgroundAgents_StartTask");
|
||||
var startTool = GetTool(tools, "background_agents_start_task");
|
||||
|
||||
AgentRunContext? contextBefore = AIAgent.CurrentRunContext;
|
||||
|
||||
@@ -854,8 +854,8 @@ public class BackgroundAgentsProviderTests
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
|
||||
// Assert — instructions contain tool usage guidance and agent list
|
||||
Assert.Contains("BackgroundAgents_*", result.Instructions);
|
||||
Assert.Contains("BackgroundAgents_ClearCompletedTask", result.Instructions);
|
||||
Assert.Contains("background_agents_*", result.Instructions);
|
||||
Assert.Contains("background_agents_clear_completed_task", result.Instructions);
|
||||
Assert.Contains("Research", result.Instructions);
|
||||
Assert.Contains("Research agent", result.Instructions);
|
||||
}
|
||||
|
||||
+28
-28
@@ -86,7 +86,7 @@ public class FileMemoryProviderTests
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
var saveFile = GetTool(tools, "file_memory_save_file");
|
||||
|
||||
// Act
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
@@ -107,7 +107,7 @@ public class FileMemoryProviderTests
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
var saveFile = GetTool(tools, "file_memory_save_file");
|
||||
|
||||
// Act
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
@@ -130,7 +130,7 @@ public class FileMemoryProviderTests
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
var saveFile = GetTool(tools, "file_memory_save_file");
|
||||
|
||||
// Save with description first.
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
@@ -159,7 +159,7 @@ public class FileMemoryProviderTests
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, state, session) = await CreateToolsAsync(store, _ => new FileMemoryState { WorkingFolder = "session123" });
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
var saveFile = GetTool(tools, "file_memory_save_file");
|
||||
|
||||
// Act
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
@@ -186,7 +186,7 @@ public class FileMemoryProviderTests
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Stored content");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var readFile = GetTool(tools, "FileMemory_ReadFile");
|
||||
var readFile = GetTool(tools, "file_memory_read_file");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(readFile, new AIFunctionArguments
|
||||
@@ -204,7 +204,7 @@ public class FileMemoryProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _, session) = await CreateToolsAsync();
|
||||
var readFile = GetTool(tools, "FileMemory_ReadFile");
|
||||
var readFile = GetTool(tools, "file_memory_read_file");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(readFile, new AIFunctionArguments
|
||||
@@ -228,7 +228,7 @@ public class FileMemoryProviderTests
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Content");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var deleteFile = GetTool(tools, "FileMemory_DeleteFile");
|
||||
var deleteFile = GetTool(tools, "file_memory_delete_file");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(deleteFile, new AIFunctionArguments
|
||||
@@ -250,7 +250,7 @@ public class FileMemoryProviderTests
|
||||
await store.WriteFileAsync("notes.md", "Content");
|
||||
await store.WriteFileAsync("notes_description.md", "Description");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var deleteFile = GetTool(tools, "FileMemory_DeleteFile");
|
||||
var deleteFile = GetTool(tools, "file_memory_delete_file");
|
||||
|
||||
// Act
|
||||
await InvokeWithRunContextAsync(deleteFile, new AIFunctionArguments
|
||||
@@ -276,7 +276,7 @@ public class FileMemoryProviderTests
|
||||
await store.WriteFileAsync("notes_description.md", "A description");
|
||||
await store.WriteFileAsync("other.md", "Other content");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var listFiles = GetTool(tools, "FileMemory_ListFiles");
|
||||
var listFiles = GetTool(tools, "file_memory_list_files");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(listFiles, new AIFunctionArguments(), session);
|
||||
@@ -300,7 +300,7 @@ public class FileMemoryProviderTests
|
||||
await store.WriteFileAsync("notes.md", "Content");
|
||||
await store.WriteFileAsync("notes_description.md", "Desc");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var listFiles = GetTool(tools, "FileMemory_ListFiles");
|
||||
var listFiles = GetTool(tools, "file_memory_list_files");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(listFiles, new AIFunctionArguments(), session);
|
||||
@@ -322,7 +322,7 @@ public class FileMemoryProviderTests
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Important research findings about AI");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var searchFiles = GetTool(tools, "FileMemory_SearchFiles");
|
||||
var searchFiles = GetTool(tools, "file_memory_search_files");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(searchFiles, new AIFunctionArguments
|
||||
@@ -347,7 +347,7 @@ public class FileMemoryProviderTests
|
||||
await store.WriteFileAsync("notes.md", "Important data");
|
||||
await store.WriteFileAsync("data.txt", "Important data");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var searchFiles = GetTool(tools, "FileMemory_SearchFiles");
|
||||
var searchFiles = GetTool(tools, "file_memory_search_files");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(searchFiles, new AIFunctionArguments
|
||||
@@ -422,7 +422,7 @@ public class FileMemoryProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _, session) = await CreateToolsAsync();
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
var saveFile = GetTool(tools, "file_memory_save_file");
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
@@ -439,7 +439,7 @@ public class FileMemoryProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _, session) = await CreateToolsAsync();
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
var saveFile = GetTool(tools, "file_memory_save_file");
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
@@ -456,7 +456,7 @@ public class FileMemoryProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _, session) = await CreateToolsAsync();
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
var saveFile = GetTool(tools, "file_memory_save_file");
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
@@ -473,7 +473,7 @@ public class FileMemoryProviderTests
|
||||
// Arrange — "notes..md" is not a path traversal attempt.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
var saveFile = GetTool(tools, "file_memory_save_file");
|
||||
|
||||
// Act
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
@@ -496,7 +496,7 @@ public class FileMemoryProviderTests
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
var saveFile = GetTool(tools, "file_memory_save_file");
|
||||
|
||||
// Act
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
@@ -517,7 +517,7 @@ public class FileMemoryProviderTests
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
var saveFile = GetTool(tools, "file_memory_save_file");
|
||||
|
||||
// Act
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
@@ -539,8 +539,8 @@ public class FileMemoryProviderTests
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
var deleteFile = GetTool(tools, "FileMemory_DeleteFile");
|
||||
var saveFile = GetTool(tools, "file_memory_save_file");
|
||||
var deleteFile = GetTool(tools, "file_memory_delete_file");
|
||||
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
@@ -573,7 +573,7 @@ public class FileMemoryProviderTests
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
var saveFile = GetTool(tools, "file_memory_save_file");
|
||||
|
||||
// Act — save 55 files
|
||||
for (int i = 0; i < 55; i++)
|
||||
@@ -607,8 +607,8 @@ public class FileMemoryProviderTests
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
var listFiles = GetTool(tools, "FileMemory_ListFiles");
|
||||
var saveFile = GetTool(tools, "file_memory_save_file");
|
||||
var listFiles = GetTool(tools, "file_memory_list_files");
|
||||
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
@@ -639,7 +639,7 @@ public class FileMemoryProviderTests
|
||||
var initContext = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext initResult = await provider.InvokingAsync(initContext);
|
||||
var saveFile = GetTool(initResult.Tools!, "FileMemory_SaveFile");
|
||||
var saveFile = GetTool(initResult.Tools!, "file_memory_save_file");
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "research.md",
|
||||
@@ -802,7 +802,7 @@ public class FileMemoryProviderTests
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
var saveFile = GetTool(tools, "file_memory_save_file");
|
||||
const int FileCount = 20;
|
||||
|
||||
// Act — save multiple files in parallel.
|
||||
@@ -835,8 +835,8 @@ public class FileMemoryProviderTests
|
||||
// Arrange — pre-populate files that will be deleted.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
var deleteFile = GetTool(tools, "FileMemory_DeleteFile");
|
||||
var saveFile = GetTool(tools, "file_memory_save_file");
|
||||
var deleteFile = GetTool(tools, "file_memory_delete_file");
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
@@ -900,7 +900,7 @@ public class FileMemoryProviderTests
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
var saveFile = GetTool(result.Tools!, "FileMemory_SaveFile");
|
||||
var saveFile = GetTool(result.Tools!, "file_memory_save_file");
|
||||
provider.Dispose();
|
||||
|
||||
// Act & Assert — the disposed SemaphoreSlim should throw ObjectDisposedException.
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="TodoCompletionLoopEvaluator"/> class.
|
||||
/// </summary>
|
||||
public class TodoCompletionLoopEvaluatorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that the constructor throws when a non-null but empty modes collection is supplied.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TodoCompletionLoopEvaluator_EmptyModes_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = [] }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the constructor throws when a mode name is null, empty, or whitespace.
|
||||
/// </summary>
|
||||
/// <param name="mode">The invalid mode name.</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void TodoCompletionLoopEvaluator_InvalidModeName_Throws(string? mode)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = [mode!] }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the constructor succeeds with null modes (applies in every mode) and with a valid mode set.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TodoCompletionLoopEvaluator_ValidConstruction_Succeeds()
|
||||
{
|
||||
// Act & Assert
|
||||
_ = new TodoCompletionLoopEvaluator();
|
||||
_ = new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = ["execute"] });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that evaluation throws when no <see cref="TodoProvider"/> can be resolved from the agent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NoTodoProvider_ThrowsAsync()
|
||||
{
|
||||
// Arrange — a bare agent that resolves no providers.
|
||||
var evaluator = new TodoCompletionLoopEvaluator();
|
||||
var context = CreateContext(new Mock<AIAgent>().Object, new ChatClientAgentSession());
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => await evaluator.EvaluateAsync(context));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that evaluation throws when modes are configured but no <see cref="AgentModeProvider"/> can be resolved.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ModesConfiguredButNoModeProvider_ThrowsAsync()
|
||||
{
|
||||
// Arrange — agent has a TodoProvider but no AgentModeProvider.
|
||||
var todoProvider = new TodoProvider();
|
||||
var session = new ChatClientAgentSession();
|
||||
SeedTodos(session, (1, "Task one", null, false));
|
||||
AIAgent agent = CreateAgent(todoProvider);
|
||||
var evaluator = new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = ["execute"] });
|
||||
LoopContext context = CreateContext(agent, session);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => await evaluator.EvaluateAsync(context));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that, with no modes configured, the evaluator continues while incomplete todos remain and the feedback
|
||||
/// lists those todos.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NoModes_RemainingTodos_ContinuesWithFeedbackAsync()
|
||||
{
|
||||
// Arrange
|
||||
var todoProvider = new TodoProvider();
|
||||
var session = new ChatClientAgentSession();
|
||||
SeedTodos(session, (1, "Write code", null, false), (2, "Add tests", "cover edge cases", false), (3, "Done item", null, true));
|
||||
AIAgent agent = CreateAgent(todoProvider);
|
||||
var evaluator = new TodoCompletionLoopEvaluator();
|
||||
LoopContext context = CreateContext(agent, session);
|
||||
|
||||
// Act
|
||||
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(evaluation.ShouldReinvoke);
|
||||
Assert.NotNull(evaluation.Feedback);
|
||||
Assert.Contains("Write code", evaluation.Feedback!);
|
||||
Assert.Contains("Add tests", evaluation.Feedback!);
|
||||
Assert.Contains("cover edge cases", evaluation.Feedback!);
|
||||
// Completed items must not appear in the feedback list.
|
||||
Assert.DoesNotContain("Done item", evaluation.Feedback!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that, with no modes configured, the evaluator stops when there are no incomplete todos.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NoModes_NoRemainingTodos_StopsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var todoProvider = new TodoProvider();
|
||||
var session = new ChatClientAgentSession();
|
||||
SeedTodos(session, (1, "Completed", null, true));
|
||||
AIAgent agent = CreateAgent(todoProvider);
|
||||
var evaluator = new TodoCompletionLoopEvaluator();
|
||||
LoopContext context = CreateContext(agent, session);
|
||||
|
||||
// Act
|
||||
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.False(evaluation.ShouldReinvoke);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that, when the current mode is one of the configured modes and incomplete todos remain, the evaluator
|
||||
/// continues.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ModeMatches_RemainingTodos_ContinuesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var todoProvider = new TodoProvider();
|
||||
var modeProvider = new AgentModeProvider();
|
||||
var session = new ChatClientAgentSession();
|
||||
modeProvider.SetMode(session, "execute");
|
||||
SeedTodos(session, (1, "Work item", null, false));
|
||||
AIAgent agent = CreateAgent(todoProvider, modeProvider);
|
||||
var evaluator = new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = ["execute"] });
|
||||
LoopContext context = CreateContext(agent, session);
|
||||
|
||||
// Act
|
||||
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(evaluation.ShouldReinvoke);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that, when the current mode is one of the configured modes but no incomplete todos remain, the evaluator
|
||||
/// stops.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ModeMatches_NoRemainingTodos_StopsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var todoProvider = new TodoProvider();
|
||||
var modeProvider = new AgentModeProvider();
|
||||
var session = new ChatClientAgentSession();
|
||||
modeProvider.SetMode(session, "execute");
|
||||
SeedTodos(session, (1, "Already done", null, true));
|
||||
AIAgent agent = CreateAgent(todoProvider, modeProvider);
|
||||
var evaluator = new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = ["execute"] });
|
||||
LoopContext context = CreateContext(agent, session);
|
||||
|
||||
// Act
|
||||
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.False(evaluation.ShouldReinvoke);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that, when the current mode is not one of the configured modes, the evaluator stops even if incomplete
|
||||
/// todos remain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ModeDoesNotMatch_StopsEvenWithRemainingTodosAsync()
|
||||
{
|
||||
// Arrange
|
||||
var todoProvider = new TodoProvider();
|
||||
var modeProvider = new AgentModeProvider();
|
||||
var session = new ChatClientAgentSession();
|
||||
modeProvider.SetMode(session, "plan");
|
||||
SeedTodos(session, (1, "Still open", null, false));
|
||||
AIAgent agent = CreateAgent(todoProvider, modeProvider);
|
||||
var evaluator = new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = ["execute"] });
|
||||
LoopContext context = CreateContext(agent, session);
|
||||
|
||||
// Act
|
||||
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.False(evaluation.ShouldReinvoke);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that a custom feedback template with the remaining-todos placeholder is honored.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_CustomTemplate_IsHonoredAsync()
|
||||
{
|
||||
// Arrange
|
||||
var todoProvider = new TodoProvider();
|
||||
var session = new ChatClientAgentSession();
|
||||
SeedTodos(session, (1, "Remaining task", null, false));
|
||||
AIAgent agent = CreateAgent(todoProvider);
|
||||
var options = new TodoCompletionLoopEvaluatorOptions
|
||||
{
|
||||
FeedbackMessageTemplate = "Keep going. Open:\n" + TodoCompletionLoopEvaluator.RemainingTodosPlaceholder,
|
||||
};
|
||||
var evaluator = new TodoCompletionLoopEvaluator(options: options);
|
||||
LoopContext context = CreateContext(agent, session);
|
||||
|
||||
// Act
|
||||
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(evaluation.ShouldReinvoke);
|
||||
Assert.StartsWith("Keep going. Open:", evaluation.Feedback);
|
||||
Assert.Contains("Remaining task", evaluation.Feedback!);
|
||||
}
|
||||
|
||||
private static ChatClientAgent CreateAgent(params AIContextProvider[] providers)
|
||||
{
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
return new ChatClientAgent(chatClient, new ChatClientAgentOptions { AIContextProviders = providers });
|
||||
}
|
||||
|
||||
private static LoopContext CreateContext(AIAgent agent, AgentSession session) => new(
|
||||
agent,
|
||||
session,
|
||||
[new ChatMessage(ChatRole.User, "do the work")],
|
||||
new AgentResponse([new ChatMessage(ChatRole.Assistant, "in progress")]));
|
||||
|
||||
private static void SeedTodos(AgentSession session, params (int Id, string Title, string? Description, bool IsComplete)[] items)
|
||||
{
|
||||
var state = new TodoState { NextId = items.Length + 1 };
|
||||
foreach ((int id, string title, string? description, bool isComplete) in items)
|
||||
{
|
||||
state.Items.Add(new TodoItem
|
||||
{
|
||||
Id = id,
|
||||
Title = title,
|
||||
Description = description,
|
||||
IsComplete = isComplete,
|
||||
});
|
||||
}
|
||||
|
||||
// Persist under the TodoProvider's state key so the provider reads it back via GetRemainingTodosAsync.
|
||||
session.StateBag.SetValue(nameof(TodoProvider), state, AgentJsonUtilities.DefaultOptions);
|
||||
}
|
||||
}
|
||||
@@ -321,6 +321,72 @@ public class CheckpointResumeTests
|
||||
"the workflow should finish once the replayed request receives a fresh response");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that fan-in edge state buffered before a checkpoint is still present after resume.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
|
||||
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
|
||||
internal async Task Checkpoint_Resume_PreservesFanInBarrierBufferedMessagesAsync(ExecutionEnvironment environment)
|
||||
{
|
||||
// Arrange
|
||||
const string RequestPortId = "Approval";
|
||||
const string SinkId = "Sink";
|
||||
|
||||
ExecutorBinding beforePause = new PreCheckpointBarrierSource("BeforePause", RequestPortId, SinkId);
|
||||
ExecutorBinding afterResume = new PostCheckpointBarrierSource("AfterResume", SinkId);
|
||||
ExecutorBinding sink = new BarrierSink(SinkId);
|
||||
RequestPort<ApprovalRequest, ApprovalReply> requestPort = RequestPort.Create<ApprovalRequest, ApprovalReply>(RequestPortId);
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(beforePause)
|
||||
.AddEdge(beforePause, requestPort)
|
||||
.AddEdge(requestPort, afterResume)
|
||||
.AddFanInBarrierEdge([beforePause, afterResume], sink)
|
||||
.Build();
|
||||
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
|
||||
InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment();
|
||||
|
||||
ExternalRequest pendingRequest;
|
||||
CheckpointInfo checkpoint;
|
||||
|
||||
await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager)
|
||||
.RunStreamingAsync(workflow, "start"))
|
||||
{
|
||||
(pendingRequest, checkpoint) = await CapturePendingRequestAndCheckpointAsync(firstRun);
|
||||
}
|
||||
|
||||
// Act
|
||||
await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager)
|
||||
.ResumeStreamingAsync(workflow, checkpoint);
|
||||
|
||||
List<WorkflowEvent> resumedEvents = await ReadToHaltAsync(resumed);
|
||||
ExternalRequest replayedRequest = resumedEvents.OfType<RequestInfoEvent>()
|
||||
.Select(evt => evt.Request)
|
||||
.Should()
|
||||
.ContainSingle("resume should replay the request captured after the first fan-in source")
|
||||
.Subject;
|
||||
|
||||
await resumed.SendResponseAsync(replayedRequest.CreateResponse(new ApprovalReply("yes")));
|
||||
|
||||
List<WorkflowEvent> completionEvents = await ReadToHaltAsync(resumed);
|
||||
|
||||
// Assert
|
||||
completionEvents.OfType<WorkflowErrorEvent>().Should().BeEmpty(
|
||||
"resuming across a partially satisfied fan-in barrier should not raise workflow errors");
|
||||
|
||||
string[] outputs = [.. completionEvents.OfType<BarrierReleasedEvent>().Select(evt => evt.Source)];
|
||||
outputs.Should().BeEquivalentTo(["before", "after"],
|
||||
"the barrier should release the contribution buffered before the checkpoint and the one produced after resume");
|
||||
|
||||
RunStatus status = await resumed.GetStatusAsync();
|
||||
status.Should().Be(RunStatus.Idle,
|
||||
"the fan-in target should run after the post-resume source contributes");
|
||||
|
||||
pendingRequest.RequestId.Should().Be(replayedRequest.RequestId,
|
||||
"the replayed request should be the one from the checkpointed superstep");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a resumed parent workflow re-emits pending requests that originated in a subworkflow.
|
||||
/// </summary>
|
||||
@@ -484,4 +550,48 @@ public class CheckpointResumeTests
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private sealed record BarrierContribution(string Source);
|
||||
|
||||
private sealed record ApprovalRequest(string Prompt);
|
||||
|
||||
private sealed record ApprovalReply(string Value);
|
||||
|
||||
private sealed class BarrierReleasedEvent(string source) : WorkflowEvent
|
||||
{
|
||||
public string Source { get; } = source;
|
||||
}
|
||||
|
||||
private sealed class PreCheckpointBarrierSource(string id, string requestPortId, string sinkId) : Executor(id)
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<string>(this.HandleAsync))
|
||||
.SendsMessage<BarrierContribution>()
|
||||
.SendsMessage<ApprovalRequest>();
|
||||
|
||||
private async ValueTask HandleAsync(string input, IWorkflowContext ctx)
|
||||
{
|
||||
await ctx.SendMessageAsync(new BarrierContribution("before"), sinkId).ConfigureAwait(false);
|
||||
await ctx.SendMessageAsync(new ApprovalRequest("continue?"), requestPortId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PostCheckpointBarrierSource(string id, string sinkId) : Executor(id)
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<ApprovalReply>(this.HandleAsync))
|
||||
.SendsMessage<BarrierContribution>();
|
||||
|
||||
private ValueTask HandleAsync(ApprovalReply reply, IWorkflowContext ctx)
|
||||
=> ctx.SendMessageAsync(new BarrierContribution("after"), sinkId);
|
||||
}
|
||||
|
||||
private sealed class BarrierSink(string id) : Executor(id)
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<BarrierContribution>(this.HandleAsync));
|
||||
|
||||
private ValueTask HandleAsync(BarrierContribution contribution, IWorkflowContext ctx)
|
||||
=> ctx.AddEventAsync(new BarrierReleasedEvent(contribution.Source));
|
||||
}
|
||||
}
|
||||
|
||||
+33
-1
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.9.0] - 2026-06-18
|
||||
|
||||
### Added
|
||||
- **agent-framework-core**: Add `AgentLoopMiddleware` for re-running agents in a loop ([#6174](https://github.com/microsoft/agent-framework/pull/6174))
|
||||
- **agent-framework-core**: Integrate tool approval into the harness agent ([#6522](https://github.com/microsoft/agent-framework/pull/6522))
|
||||
- **agent-framework-core**: Add tool approval middleware ([#6414](https://github.com/microsoft/agent-framework/pull/6414))
|
||||
- **agent-framework-core**: Integrate the shell tool into the harness agent ([#6451](https://github.com/microsoft/agent-framework/pull/6451))
|
||||
- **agent-framework-core**: Capture context provider instructions in agent telemetry ([#6515](https://github.com/microsoft/agent-framework/pull/6515))
|
||||
- **agent-framework-core**, **agent-framework-ag-ui**: Add opt-in AG-UI thread snapshot persistence and hydration ([#6471](https://github.com/microsoft/agent-framework/pull/6471))
|
||||
- **agent-framework-foundry-hosting**: Emit failed events for hosted agent responses ([#6502](https://github.com/microsoft/agent-framework/pull/6502))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-core**: [BREAKING] Add sampling guardrails to MCP tools — deny server-initiated sampling by default and add `sampling_approval_callback`, `sampling_max_tokens`, and `sampling_max_requests` parameters ([#6413](https://github.com/microsoft/agent-framework/pull/6413))
|
||||
- **agent-framework-core**: [BREAKING] Align FileAccess tools with .NET, adding directory discovery and recursive search ([#6476](https://github.com/microsoft/agent-framework/pull/6476))
|
||||
- **agent-framework-declarative**: [BREAKING] Additional fixes for declarative workflow execution ([#6489](https://github.com/microsoft/agent-framework/pull/6489))
|
||||
- **agent-framework-azure-contentunderstanding**: Adopt `azure-ai-contentunderstanding` `to_llm_input` in the CU context provider ([#5796](https://github.com/microsoft/agent-framework/pull/5796))
|
||||
- **agent-framework-orchestrations**: Promote to stable (`1.0.0`)
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-core**: Stop forwarding the unsupported `function_invocation_configuration` kwarg from `as_agent` ([#6520](https://github.com/microsoft/agent-framework/pull/6520))
|
||||
- **agent-framework-core**: Fix MCP `allowed_tools` empty-list handling ([#6296](https://github.com/microsoft/agent-framework/pull/6296))
|
||||
- **agent-framework-core**: Disable harness compaction when max tokens are not provided ([#6410](https://github.com/microsoft/agent-framework/pull/6410))
|
||||
- **agent-framework-core**: Parse MCP `CallToolResult.structuredContent` to prevent tool results returning `None` ([#6421](https://github.com/microsoft/agent-framework/pull/6421))
|
||||
- **agent-framework-core**: Catch bare `ImportError` during hosted-environment detection so optional Foundry hosting probing cannot crash user-agent setup
|
||||
- **agent-framework-anthropic**, **agent-framework-core**, **agent-framework-openai**: Fix OTel usage detail attributes ([#6493](https://github.com/microsoft/agent-framework/pull/6493))
|
||||
- **agent-framework-foundry**, **agent-framework-openai**: Fix Azure AI Search citation URLs ([#6453](https://github.com/microsoft/agent-framework/pull/6453))
|
||||
- **agent-framework-foundry**: Fix `aiohttp` dependency specification ([#6567](https://github.com/microsoft/agent-framework/pull/6567))
|
||||
- **agent-framework-declarative**: Fix declarative workflow execution ([#6468](https://github.com/microsoft/agent-framework/pull/6468))
|
||||
- **samples**: Fix harness console rendering a single streamed tool call multiple times ([#6549](https://github.com/microsoft/agent-framework/pull/6549))
|
||||
- **samples**: Fix `ollama_chat_client.py` to pass tools via the options dict ([#6480](https://github.com/microsoft/agent-framework/pull/6480))
|
||||
|
||||
## [1.8.1] - 2026-06-09
|
||||
|
||||
### Added
|
||||
@@ -1189,7 +1220,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.8.1...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.9.0...HEAD
|
||||
[1.9.0]: https://github.com/microsoft/agent-framework/compare/python-1.8.1...python-1.9.0
|
||||
[1.8.1]: https://github.com/microsoft/agent-framework/compare/python-1.8.0...python-1.8.1
|
||||
[1.8.0]: https://github.com/microsoft/agent-framework/compare/python-1.7.0...python-1.8.0
|
||||
[1.7.0]: https://github.com/microsoft/agent-framework/compare/python-1.6.0...python-1.7.0
|
||||
|
||||
@@ -34,7 +34,6 @@ Status is grouped into these buckets:
|
||||
| `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` |
|
||||
| `agent-framework-gemini` | `python/packages/gemini` | `alpha` |
|
||||
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `rc` |
|
||||
| `agent-framework-hosting-discord` | `python/packages/hosting-discord` | `alpha` |
|
||||
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` |
|
||||
| `agent-framework-lab` | `python/packages/lab` | `beta` |
|
||||
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
|
||||
@@ -42,7 +41,7 @@ Status is grouped into these buckets:
|
||||
| `agent-framework-monty` | `python/packages/monty` | `alpha` |
|
||||
| `agent-framework-ollama` | `python/packages/ollama` | `beta` |
|
||||
| `agent-framework-openai` | `python/packages/openai` | `released` |
|
||||
| `agent-framework-orchestrations` | `python/packages/orchestrations` | `beta` |
|
||||
| `agent-framework-orchestrations` | `python/packages/orchestrations` | `released` |
|
||||
| `agent-framework-purview` | `python/packages/purview` | `beta` |
|
||||
| `agent-framework-redis` | `python/packages/redis` | `beta` |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0rc4"
|
||||
version = "1.0.0rc5"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.8.1,<2",
|
||||
"agent-framework-core>=1.9.0,<2",
|
||||
"ag-ui-protocol>=0.1.16,<0.2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<1"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260604"
|
||||
version = "1.0.0b260618"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"agent-framework-core>=1.9.0,<2",
|
||||
"anthropic>=0.80.0,<0.80.1",
|
||||
]
|
||||
|
||||
|
||||
+68
-21
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
@@ -28,6 +29,7 @@ from agent_framework import (
|
||||
)
|
||||
from agent_framework._sessions import AgentSession
|
||||
from agent_framework._settings import load_settings
|
||||
from azure.ai.contentunderstanding import to_llm_input
|
||||
from azure.ai.contentunderstanding.aio import ContentUnderstandingClient
|
||||
from azure.ai.contentunderstanding.models import AnalysisInput, AnalysisResult
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
@@ -39,7 +41,6 @@ if TYPE_CHECKING:
|
||||
from ._detection import (
|
||||
detect_and_strip_files,
|
||||
)
|
||||
from ._extraction import extract_sections, format_result
|
||||
from ._models import AnalysisSection, DocumentEntry, DocumentStatus, FileSearchConfig
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
@@ -59,6 +60,27 @@ MEDIA_TYPE_ANALYZER_MAP: dict[str, str] = {
|
||||
}
|
||||
DEFAULT_ANALYZER: str = "prebuilt-documentSearch"
|
||||
|
||||
# Matches the leading YAML front-matter block emitted by ``to_llm_input``.
|
||||
# A rendered text with no markdown body (e.g. when the CU result has empty
|
||||
# ``markdown`` and no fields) is recognised by an empty tail after this match.
|
||||
# Accept both LF and CRLF line endings so body detection works cross-platform.
|
||||
_FRONT_MATTER_RE: re.Pattern[str] = re.compile(r"\A---\r?\n.*?\r?\n---(?:\r?\n|\Z)", flags=re.DOTALL)
|
||||
|
||||
|
||||
def _has_renderable_body(text: str) -> bool:
|
||||
"""Return True when ``text`` has any non-whitespace content beyond YAML front matter.
|
||||
|
||||
Used to skip ``file_search`` uploads when CU produced a result with no
|
||||
markdown content — uploading a front-matter-only stub would pollute the
|
||||
vector store without giving the LLM anything searchable.
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
match = _FRONT_MATTER_RE.match(text)
|
||||
if match is None:
|
||||
return bool(text.strip())
|
||||
return bool(text[match.end() :].strip())
|
||||
|
||||
|
||||
class ContentUnderstandingSettings(TypedDict, total=False):
|
||||
"""Settings for ContentUnderstandingContextProvider with auto-loading from environment.
|
||||
@@ -263,8 +285,8 @@ class ContentUnderstandingContextProvider(ContextProvider):
|
||||
pending_tokens: dict[str, dict[str, str]] = state.setdefault("_pending_tokens", {})
|
||||
pending_uploads: list[tuple[str, DocumentEntry]] = state.setdefault("_pending_uploads", [])
|
||||
|
||||
# 1. Resolve pending background analyses via continuation tokens
|
||||
await self._resolve_pending_tokens(pending_tokens, pending_uploads, documents, context)
|
||||
# Resolve pending Content Understanding analysis from its continuation tokens
|
||||
await self._resolve_pending_analysis(pending_tokens, pending_uploads, documents, context)
|
||||
|
||||
# 1b. Upload any documents that completed in the background (file_search mode)
|
||||
if pending_uploads:
|
||||
@@ -415,7 +437,7 @@ class ContentUnderstandingContextProvider(ContextProvider):
|
||||
context.extend_messages(
|
||||
self,
|
||||
[
|
||||
Message(role="user", contents=[format_result(entry["filename"], entry["result"])]),
|
||||
Message(role="user", contents=[entry["result"] or ""]),
|
||||
],
|
||||
)
|
||||
context.extend_messages(
|
||||
@@ -428,7 +450,7 @@ class ContentUnderstandingContextProvider(ContextProvider):
|
||||
f"The user just uploaded '{entry['filename']}'."
|
||||
" It has been analyzed using Azure Content Understanding."
|
||||
" The document content (markdown) and extracted fields"
|
||||
" (JSON) are provided above."
|
||||
" (YAML front matter) are provided above."
|
||||
" If the user's question is ambiguous,"
|
||||
" prioritize this most recently uploaded document."
|
||||
" Use specific field values and cite page numbers"
|
||||
@@ -561,7 +583,7 @@ class ContentUnderstandingContextProvider(ContextProvider):
|
||||
|
||||
# Analysis completed within timeout
|
||||
analysis_duration = round(time.monotonic() - t0, 2)
|
||||
extracted = self._extract_sections(result)
|
||||
rendered = self._render_for_llm(result, filename)
|
||||
logger.info("Analyzed '%s' with analyzer '%s' in %.1fs.", filename, resolved_analyzer, analysis_duration)
|
||||
return DocumentEntry(
|
||||
status=DocumentStatus.READY,
|
||||
@@ -571,7 +593,7 @@ class ContentUnderstandingContextProvider(ContextProvider):
|
||||
analyzed_at=datetime.now(tz=timezone.utc).isoformat(),
|
||||
analysis_duration_s=analysis_duration,
|
||||
upload_duration_s=None,
|
||||
result=extracted,
|
||||
result=rendered,
|
||||
error=None,
|
||||
)
|
||||
|
||||
@@ -596,10 +618,10 @@ class ContentUnderstandingContextProvider(ContextProvider):
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pending Token Resolution
|
||||
# Pending Analysis Resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _resolve_pending_tokens(
|
||||
async def _resolve_pending_analysis(
|
||||
self,
|
||||
pending_tokens: dict[str, dict[str, str]],
|
||||
pending_uploads: list[tuple[str, DocumentEntry]],
|
||||
@@ -658,10 +680,10 @@ class ContentUnderstandingContextProvider(ContextProvider):
|
||||
continue
|
||||
|
||||
completed_keys.append(doc_key)
|
||||
extracted = self._extract_sections(result) # pyright: ignore[reportUnknownArgumentType]
|
||||
rendered = self._render_for_llm(result, entry["filename"]) # pyright: ignore[reportUnknownArgumentType]
|
||||
entry["status"] = DocumentStatus.READY
|
||||
entry["analyzed_at"] = datetime.now(tz=timezone.utc).isoformat()
|
||||
entry["result"] = extracted
|
||||
entry["result"] = rendered
|
||||
entry["error"] = None
|
||||
logger.info("Background analysis of '%s' completed.", entry["filename"])
|
||||
|
||||
@@ -672,7 +694,7 @@ class ContentUnderstandingContextProvider(ContextProvider):
|
||||
context.extend_messages(
|
||||
self,
|
||||
[
|
||||
Message(role="user", contents=[format_result(entry["filename"], extracted)]),
|
||||
Message(role="user", contents=[rendered]),
|
||||
],
|
||||
)
|
||||
context.extend_messages(
|
||||
@@ -708,11 +730,36 @@ class ContentUnderstandingContextProvider(ContextProvider):
|
||||
del pending_tokens[key]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Output Extraction & Formatting (delegates to _extraction module)
|
||||
# LLM Input Rendering (delegates to azure.ai.contentunderstanding.to_llm_input)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _extract_sections(self, result: AnalysisResult) -> dict[str, object]:
|
||||
return extract_sections(result, self.output_sections)
|
||||
def _render_for_llm(
|
||||
self,
|
||||
result: AnalysisResult,
|
||||
filename: str,
|
||||
) -> str:
|
||||
"""Render a CU ``AnalysisResult`` into LLM-friendly text.
|
||||
|
||||
Maps the MAF ``output_sections`` list to ``to_llm_input`` kwargs:
|
||||
|
||||
- ``"markdown" in output_sections`` -> ``include_markdown=True``
|
||||
- ``"fields" in output_sections`` -> ``include_fields=True``
|
||||
|
||||
Args:
|
||||
result: The CU analysis result.
|
||||
filename: Document filename, surfaced to the LLM via the
|
||||
``source`` front matter key.
|
||||
|
||||
Returns:
|
||||
A YAML-front-matter-prefixed text block ready for direct LLM
|
||||
consumption or vector store upload.
|
||||
"""
|
||||
return to_llm_input(
|
||||
result,
|
||||
include_markdown="markdown" in self.output_sections,
|
||||
include_fields="fields" in self.output_sections,
|
||||
metadata={"source": filename},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool Registration
|
||||
@@ -801,10 +848,10 @@ class ContentUnderstandingContextProvider(ContextProvider):
|
||||
if not result:
|
||||
return False
|
||||
|
||||
# Upload the full formatted content (markdown + fields + segments),
|
||||
# not just raw markdown — consistent with what non-file_search mode injects.
|
||||
formatted = format_result(entry["filename"], result)
|
||||
if not formatted:
|
||||
if not _has_renderable_body(result):
|
||||
# Empty CU result (e.g. blank markdown, no fields) — skip the
|
||||
# upload so the vector store stays clean. The DocumentEntry still
|
||||
# records the front-matter-only ``result`` so callers can introspect.
|
||||
return False
|
||||
|
||||
entry["status"] = DocumentStatus.UPLOADING
|
||||
@@ -812,7 +859,7 @@ class ContentUnderstandingContextProvider(ContextProvider):
|
||||
|
||||
try:
|
||||
upload_coro = self.file_search.backend.upload_file(
|
||||
self.file_search.vector_store_id, f"{doc_key}.md", formatted.encode("utf-8")
|
||||
self.file_search.vector_store_id, f"{doc_key}.md", result.encode("utf-8")
|
||||
)
|
||||
file_id = await asyncio.wait_for(upload_coro, timeout=timeout)
|
||||
upload_duration = round(time.monotonic() - t0, 2)
|
||||
@@ -822,7 +869,7 @@ class ContentUnderstandingContextProvider(ContextProvider):
|
||||
self._all_uploaded_file_ids.append(file_id)
|
||||
entry["status"] = DocumentStatus.READY
|
||||
entry["upload_duration_s"] = upload_duration
|
||||
logger.info("Uploaded '%s' to vector store in %.1fs (%s bytes).", doc_key, upload_duration, len(formatted))
|
||||
logger.info("Uploaded '%s' to vector store in %.1fs (%s bytes).", doc_key, upload_duration, len(result))
|
||||
return True
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
|
||||
-297
@@ -1,297 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Output extraction and formatting for Azure Content Understanding results.
|
||||
|
||||
Converts CU ``AnalysisResult`` objects into plain Python dicts suitable
|
||||
for LLM consumption, and formats them as human-readable text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
from azure.ai.contentunderstanding.models import AnalysisResult
|
||||
|
||||
from ._models import AnalysisSection
|
||||
|
||||
|
||||
def extract_sections(
|
||||
result: AnalysisResult,
|
||||
output_sections: list[AnalysisSection],
|
||||
) -> dict[str, object]:
|
||||
"""Extract configured sections from a CU analysis result.
|
||||
|
||||
For single-segment results (documents, images, short audio), returns a flat
|
||||
dict with ``markdown`` and ``fields`` at the top level.
|
||||
|
||||
For multi-segment results (e.g. video split into scenes), fields are kept
|
||||
with their respective segments in a ``segments`` list so the LLM can see
|
||||
which fields belong to which part of the content:
|
||||
- ``segments``: list of per-segment dicts with ``markdown``, ``fields``,
|
||||
``start_time_s``, and ``end_time_s``
|
||||
- ``markdown``: still concatenated at top level for file_search uploads
|
||||
- ``duration_seconds``: computed from the global time span
|
||||
- ``kind`` / ``resolution``: taken from the first segment
|
||||
"""
|
||||
extracted: dict[str, object] = {}
|
||||
contents = result.contents
|
||||
if not contents:
|
||||
return extracted
|
||||
|
||||
# --- Warnings from the CU service (ODataV4Format with code/message/target) ---
|
||||
if result.warnings:
|
||||
warnings_out: list[dict[str, str]] = []
|
||||
for w in result.warnings:
|
||||
entry: dict[str, str] = {}
|
||||
code = getattr(w, "code", None)
|
||||
if code:
|
||||
entry["code"] = code
|
||||
msg = getattr(w, "message", None)
|
||||
entry["message"] = msg if msg else str(w)
|
||||
target = getattr(w, "target", None)
|
||||
if target:
|
||||
entry["target"] = target
|
||||
warnings_out.append(entry)
|
||||
extracted["warnings"] = warnings_out
|
||||
|
||||
# --- Media metadata (from first segment) ---
|
||||
first = contents[0]
|
||||
kind = getattr(first, "kind", None)
|
||||
if kind:
|
||||
extracted["kind"] = kind
|
||||
width = getattr(first, "width", None)
|
||||
height = getattr(first, "height", None)
|
||||
if width and height:
|
||||
extracted["resolution"] = f"{width}x{height}"
|
||||
|
||||
# Compute total duration from the global time span of all segments.
|
||||
global_start: int | None = None
|
||||
global_end: int | None = None
|
||||
for content in contents:
|
||||
s = getattr(content, "start_time_ms", None)
|
||||
if s is None:
|
||||
s = getattr(content, "startTimeMs", None)
|
||||
e = getattr(content, "end_time_ms", None)
|
||||
if e is None:
|
||||
e = getattr(content, "endTimeMs", None)
|
||||
if s is not None:
|
||||
global_start = s if global_start is None else min(global_start, s)
|
||||
if e is not None:
|
||||
global_end = e if global_end is None else max(global_end, e)
|
||||
if global_start is not None and global_end is not None:
|
||||
extracted["duration_seconds"] = round((global_end - global_start) / 1000, 1)
|
||||
|
||||
is_multi_segment = len(contents) > 1
|
||||
|
||||
# --- Single-segment: flat output (documents, images, short audio) ---
|
||||
if not is_multi_segment:
|
||||
if "markdown" in output_sections and contents[0].markdown:
|
||||
extracted["markdown"] = contents[0].markdown
|
||||
if "fields" in output_sections and contents[0].fields:
|
||||
fields: dict[str, object] = {}
|
||||
for name, field in contents[0].fields.items():
|
||||
entry_dict: dict[str, object] = {
|
||||
"type": getattr(field, "type", None),
|
||||
"value": extract_field_value(field),
|
||||
}
|
||||
confidence = getattr(field, "confidence", None)
|
||||
if confidence is not None:
|
||||
entry_dict["confidence"] = confidence
|
||||
fields[name] = entry_dict
|
||||
if fields:
|
||||
extracted["fields"] = fields
|
||||
# Content-level category (e.g. from classifier analyzers)
|
||||
category = getattr(contents[0], "category", None)
|
||||
if category:
|
||||
extracted["category"] = category
|
||||
return extracted
|
||||
|
||||
# --- Multi-segment: per-segment output (video scenes, long audio) ---
|
||||
# Each segment keeps its own markdown + fields together so the LLM can
|
||||
# see which fields (e.g. Summary) belong to which part of the content.
|
||||
segments_out: list[dict[str, object]] = []
|
||||
md_parts: list[str] = [] # also collect for top-level concatenated markdown
|
||||
|
||||
for content in contents:
|
||||
seg: dict[str, object] = {}
|
||||
|
||||
# Time range for this segment
|
||||
s = getattr(content, "start_time_ms", None)
|
||||
if s is None:
|
||||
s = getattr(content, "startTimeMs", None)
|
||||
e = getattr(content, "end_time_ms", None)
|
||||
if e is None:
|
||||
e = getattr(content, "endTimeMs", None)
|
||||
if s is not None:
|
||||
seg["start_time_s"] = round(s / 1000, 1)
|
||||
if e is not None:
|
||||
seg["end_time_s"] = round(e / 1000, 1)
|
||||
|
||||
# Per-segment markdown
|
||||
if "markdown" in output_sections and content.markdown:
|
||||
seg["markdown"] = content.markdown
|
||||
md_parts.append(content.markdown)
|
||||
|
||||
# Per-segment fields
|
||||
if "fields" in output_sections and content.fields:
|
||||
seg_fields: dict[str, object] = {}
|
||||
for name, field in content.fields.items():
|
||||
seg_entry: dict[str, object] = {
|
||||
"type": getattr(field, "type", None),
|
||||
"value": extract_field_value(field),
|
||||
}
|
||||
confidence = getattr(field, "confidence", None)
|
||||
if confidence is not None:
|
||||
seg_entry["confidence"] = confidence
|
||||
seg_fields[name] = seg_entry
|
||||
if seg_fields:
|
||||
seg["fields"] = seg_fields
|
||||
|
||||
# Per-segment category (e.g. from classifier analyzers)
|
||||
category = getattr(content, "category", None)
|
||||
if category:
|
||||
seg["category"] = category
|
||||
|
||||
segments_out.append(seg)
|
||||
|
||||
extracted["segments"] = segments_out
|
||||
|
||||
# Top-level concatenated markdown (used by file_search for vector store upload)
|
||||
if md_parts:
|
||||
extracted["markdown"] = "\n\n---\n\n".join(md_parts)
|
||||
|
||||
return extracted
|
||||
|
||||
|
||||
def extract_field_value(field: Any) -> object:
|
||||
"""Extract the plain Python value from a CU ``ContentField``.
|
||||
|
||||
Uses the SDK's ``.value`` convenience property, which dynamically
|
||||
reads the correct ``value_*`` attribute for each field type.
|
||||
Object and array types are recursively flattened so that the
|
||||
output contains only plain Python primitives (str, int, float,
|
||||
date, dict, list) -- no SDK model objects or raw wire format
|
||||
(``valueNumber``, ``spans``, ``source``, etc.).
|
||||
"""
|
||||
field_type = getattr(field, "type", None)
|
||||
raw = getattr(field, "value", None)
|
||||
|
||||
# Object fields -> recursively resolve nested sub-fields
|
||||
if field_type == "object" and raw is not None and isinstance(raw, dict):
|
||||
return {str(k): flatten_field(v) for k, v in cast(dict[str, Any], raw).items()}
|
||||
|
||||
# Array fields -> list of flattened items (each with value + optional confidence)
|
||||
if field_type == "array" and raw is not None and isinstance(raw, list):
|
||||
return [flatten_field(item) for item in cast(list[Any], raw)]
|
||||
|
||||
# Scalar fields (string, number, date, etc.) -- .value returns native Python type
|
||||
return raw
|
||||
|
||||
|
||||
def flatten_field(field: Any) -> object:
|
||||
"""Flatten a CU ``ContentField`` into a ``{type, value, confidence}`` dict.
|
||||
|
||||
Used for sub-fields inside object and array types to preserve
|
||||
per-field confidence scores. Confidence is omitted when ``None``
|
||||
to reduce token usage.
|
||||
"""
|
||||
field_type = getattr(field, "type", None)
|
||||
value = extract_field_value(field)
|
||||
confidence = getattr(field, "confidence", None)
|
||||
|
||||
result: dict[str, object] = {"type": field_type, "value": value}
|
||||
if confidence is not None:
|
||||
result["confidence"] = confidence
|
||||
return result
|
||||
|
||||
|
||||
def format_result(filename: str, result: dict[str, object]) -> str:
|
||||
"""Format extracted CU result for LLM consumption.
|
||||
|
||||
For multi-segment results (video/audio with ``segments``), each segment's
|
||||
markdown and fields are grouped together so the LLM can see which fields
|
||||
belong to which part of the content.
|
||||
"""
|
||||
kind = result.get("kind")
|
||||
is_video = kind == "audioVisual"
|
||||
is_audio = kind == "audio"
|
||||
|
||||
# Header -- media-aware label
|
||||
if is_video:
|
||||
label = "Video analysis"
|
||||
elif is_audio:
|
||||
label = "Audio analysis"
|
||||
else:
|
||||
label = "Document analysis"
|
||||
parts: list[str] = [f'{label} of "{filename}":']
|
||||
|
||||
# Media metadata line (duration, resolution)
|
||||
meta_items: list[str] = []
|
||||
duration = result.get("duration_seconds")
|
||||
if duration is not None:
|
||||
mins, secs = divmod(int(duration), 60) # type: ignore[call-overload]
|
||||
meta_items.append(f"Duration: {mins}:{secs:02d}")
|
||||
resolution = result.get("resolution")
|
||||
if resolution:
|
||||
meta_items.append(f"Resolution: {resolution}")
|
||||
if meta_items:
|
||||
parts.append(" | ".join(meta_items))
|
||||
|
||||
# --- Multi-segment: format each segment with its own content + fields ---
|
||||
raw_segments = result.get("segments")
|
||||
segments: list[dict[str, object]] = (
|
||||
cast(list[dict[str, object]], raw_segments) if isinstance(raw_segments, list) else []
|
||||
)
|
||||
if segments:
|
||||
for i, seg in enumerate(segments):
|
||||
# Segment header with time range
|
||||
start = seg.get("start_time_s")
|
||||
end = seg.get("end_time_s")
|
||||
if start is not None and end is not None:
|
||||
s_min, s_sec = divmod(int(start), 60) # type: ignore[call-overload]
|
||||
e_min, e_sec = divmod(int(end), 60) # type: ignore[call-overload]
|
||||
parts.append(f"\n### Segment {i + 1} ({s_min}:{s_sec:02d} - {e_min}:{e_sec:02d})")
|
||||
else:
|
||||
parts.append(f"\n### Segment {i + 1}")
|
||||
|
||||
# Segment markdown
|
||||
seg_md = seg.get("markdown")
|
||||
if seg_md:
|
||||
parts.append(f"\n```markdown\n{seg_md}\n```")
|
||||
|
||||
# Segment fields
|
||||
seg_fields = seg.get("fields")
|
||||
if isinstance(seg_fields, dict) and seg_fields:
|
||||
fields_json = json.dumps(seg_fields, indent=2, default=str)
|
||||
parts.append(f"\n**Fields:**\n```json\n{fields_json}\n```")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
# --- Single-segment: flat format ---
|
||||
fields_raw = result.get("fields")
|
||||
fields: dict[str, object] = cast(dict[str, object], fields_raw) if isinstance(fields_raw, dict) else {}
|
||||
|
||||
# For audio: promote Summary field as prose before markdown
|
||||
if is_audio and fields:
|
||||
summary_field = fields.get("Summary")
|
||||
if isinstance(summary_field, dict):
|
||||
sf = cast(dict[str, object], summary_field)
|
||||
if sf.get("value"):
|
||||
parts.append(f"\n## Summary\n\n{sf['value']}")
|
||||
|
||||
# Markdown content
|
||||
markdown = result.get("markdown")
|
||||
if markdown:
|
||||
parts.append(f"\n## Content\n\n```markdown\n{markdown}\n```")
|
||||
|
||||
# Fields section
|
||||
if fields:
|
||||
remaining = dict(fields)
|
||||
if is_audio:
|
||||
remaining = {k: v for k, v in remaining.items() if k != "Summary"}
|
||||
if remaining:
|
||||
fields_json = json.dumps(remaining, indent=2, default=str)
|
||||
parts.append(f"\n## Extracted Fields\n\n```json\n{fields_json}\n```")
|
||||
|
||||
return "\n".join(parts)
|
||||
+7
-1
@@ -43,7 +43,13 @@ class DocumentEntry(TypedDict):
|
||||
analyzed_at: str | None
|
||||
analysis_duration_s: float | None
|
||||
upload_duration_s: float | None
|
||||
result: dict[str, object] | None
|
||||
result: str | None
|
||||
"""LLM-ready text rendered by ``azure.ai.contentunderstanding.to_llm_input``.
|
||||
|
||||
Stored as a string (YAML front matter + markdown body) so every consumer
|
||||
(LLM context injection, vector store upload) can use it without re-rendering.
|
||||
``None`` until analysis completes successfully.
|
||||
"""
|
||||
error: str | None
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Content Understanding integration for Microsoft Agent Frame
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260521"
|
||||
version = "1.0.0a260618"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,9 +23,9 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-core>=1.9.0,<2",
|
||||
"agent-framework-foundry>=1.6.0,<2",
|
||||
"azure-ai-contentunderstanding>=1.0.1,<1.1",
|
||||
"azure-ai-contentunderstanding>=1.2.0b2,<2",
|
||||
"aiohttp>=3.9,<4",
|
||||
"filetype>=1.2,<2",
|
||||
]
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
@@ -17,7 +18,6 @@ from agent_framework_azure_contentunderstanding import (
|
||||
DocumentStatus,
|
||||
)
|
||||
from agent_framework_azure_contentunderstanding._detection import SUPPORTED_MEDIA_TYPES, derive_doc_key
|
||||
from agent_framework_azure_contentunderstanding._extraction import format_result
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -506,118 +506,80 @@ class TestListDocumentsTool:
|
||||
|
||||
|
||||
class TestOutputFiltering:
|
||||
"""Validate that output_sections controls what `_render_for_llm` emits.
|
||||
|
||||
Rendering is delegated to ``azure.ai.contentunderstanding.to_llm_input``:
|
||||
- ``"markdown" in output_sections`` -> ``include_markdown=True``.
|
||||
- ``"fields" in output_sections`` -> ``include_fields=True``.
|
||||
- ``metadata={"source": <filename>}`` is always supplied.
|
||||
|
||||
Note: detailed field/JSON shape is owned by the SDK and exercised in the
|
||||
SDK's own ``to_llm_input`` tests. We only assert MAF-level wiring here.
|
||||
"""
|
||||
|
||||
def test_default_markdown_and_fields(self, pdf_analysis_result: AnalysisResult) -> None:
|
||||
provider = _make_provider()
|
||||
result = provider._extract_sections(pdf_analysis_result)
|
||||
rendered = provider._render_for_llm(pdf_analysis_result, "report.pdf")
|
||||
|
||||
assert "markdown" in result
|
||||
assert "fields" in result
|
||||
assert "Contoso" in str(result["markdown"])
|
||||
# YAML front matter with source key.
|
||||
assert "source: report.pdf" in rendered
|
||||
# PDF fixture contains "Contoso" in its markdown body.
|
||||
assert "Contoso" in rendered
|
||||
|
||||
def test_markdown_only(self, pdf_analysis_result: AnalysisResult) -> None:
|
||||
provider = _make_provider(output_sections=["markdown"])
|
||||
result = provider._extract_sections(pdf_analysis_result)
|
||||
rendered = provider._render_for_llm(pdf_analysis_result, "report.pdf")
|
||||
|
||||
assert "markdown" in result
|
||||
assert "fields" not in result
|
||||
# Markdown body still present; no ``fields:`` front-matter section.
|
||||
assert "Contoso" in rendered
|
||||
assert "\nfields:" not in rendered
|
||||
assert not rendered.startswith("fields:")
|
||||
|
||||
def test_fields_only(self, invoice_analysis_result: AnalysisResult) -> None:
|
||||
provider = _make_provider(output_sections=["fields"])
|
||||
result = provider._extract_sections(invoice_analysis_result)
|
||||
rendered = provider._render_for_llm(invoice_analysis_result, "invoice.pdf")
|
||||
|
||||
assert "markdown" not in result
|
||||
assert "fields" in result
|
||||
fields = result["fields"]
|
||||
assert isinstance(fields, dict)
|
||||
assert "VendorName" in fields
|
||||
# ``fields:`` YAML key is emitted; vendor name appears under it.
|
||||
assert "fields:" in rendered
|
||||
assert "VendorName" in rendered
|
||||
assert "TechServe Global Partners" in rendered
|
||||
|
||||
def test_field_values_extracted(self, invoice_analysis_result: AnalysisResult) -> None:
|
||||
provider = _make_provider()
|
||||
result = provider._extract_sections(invoice_analysis_result)
|
||||
rendered = provider._render_for_llm(invoice_analysis_result, "invoice.pdf")
|
||||
|
||||
fields = result.get("fields")
|
||||
assert isinstance(fields, dict)
|
||||
assert "VendorName" in fields
|
||||
assert fields["VendorName"]["value"] is not None
|
||||
assert fields["VendorName"]["confidence"] is not None
|
||||
# Both sections present.
|
||||
assert "fields:" in rendered
|
||||
# Field values visible to the LLM (vendor + a known line-item description).
|
||||
assert "TechServe Global Partners" in rendered
|
||||
assert "Consulting Services" in rendered
|
||||
|
||||
def test_invoice_field_extraction_matches_expected(self, invoice_analysis_result: AnalysisResult) -> None:
|
||||
"""Full invoice field extraction should match expected JSON structure.
|
||||
def test_source_metadata_uses_filename(self, pdf_analysis_result: AnalysisResult) -> None:
|
||||
"""Per-document ``source`` key carries the original filename."""
|
||||
provider = _make_provider()
|
||||
rendered = provider._render_for_llm(pdf_analysis_result, "custom_name.pdf")
|
||||
assert "source: custom_name.pdf" in rendered
|
||||
|
||||
This test defines the complete expected output for all fields in the
|
||||
invoice fixture, making it easy to review the extraction behavior at
|
||||
a glance. Confidence is only present when the CU service provides it.
|
||||
def test_page_markers_passed_through_to_llm_input(self, pdf_analysis_result: AnalysisResult) -> None:
|
||||
"""Decision H: MAF must not strip page markers emitted by the SDK helper.
|
||||
|
||||
Today the SDK helper (``azure.ai.contentunderstanding.to_llm_input``)
|
||||
injects ``<!-- page N -->`` markers per page. Per
|
||||
``cognitive-services/ContentUnderstanding-Docs#249`` (Decision 4) it
|
||||
will switch to ``<!-- InputPageNumber: N -->`` once the service ships
|
||||
the marker natively. Either format must reach the LLM unchanged --
|
||||
this test guards against MAF accidentally regex-stripping them.
|
||||
"""
|
||||
provider = _make_provider()
|
||||
result = provider._extract_sections(invoice_analysis_result)
|
||||
fields = result.get("fields")
|
||||
rendered = provider._render_for_llm(pdf_analysis_result, "report.pdf")
|
||||
|
||||
expected_fields = {
|
||||
"VendorName": {
|
||||
"type": "string",
|
||||
"value": "TechServe Global Partners",
|
||||
"confidence": 0.71,
|
||||
},
|
||||
"DueDate": {
|
||||
"type": "date",
|
||||
# SDK .value returns datetime.date for date fields
|
||||
"value": fields["DueDate"]["value"], # dynamic — date object
|
||||
"confidence": 0.793,
|
||||
},
|
||||
"InvoiceDate": {
|
||||
"type": "date",
|
||||
"value": fields["InvoiceDate"]["value"],
|
||||
"confidence": 0.693,
|
||||
},
|
||||
"InvoiceId": {
|
||||
"type": "string",
|
||||
"value": "INV-100",
|
||||
"confidence": 0.489,
|
||||
},
|
||||
"AmountDue": {
|
||||
"type": "object",
|
||||
# No confidence — object types don't have it
|
||||
"value": {
|
||||
"Amount": {"type": "number", "value": 610.0, "confidence": 0.758},
|
||||
"CurrencyCode": {"type": "string", "value": "USD"},
|
||||
},
|
||||
},
|
||||
"SubtotalAmount": {
|
||||
"type": "object",
|
||||
"value": {
|
||||
"Amount": {"type": "number", "value": 100.0, "confidence": 0.902},
|
||||
"CurrencyCode": {"type": "string", "value": "USD"},
|
||||
},
|
||||
},
|
||||
"LineItems": {
|
||||
"type": "array",
|
||||
"value": [
|
||||
{
|
||||
"type": "object",
|
||||
"value": {
|
||||
"Description": {"type": "string", "value": "Consulting Services", "confidence": 0.664},
|
||||
"Quantity": {"type": "number", "value": 2.0, "confidence": 0.957},
|
||||
"UnitPrice": {
|
||||
"type": "object",
|
||||
"value": {
|
||||
"Amount": {"type": "number", "value": 30.0, "confidence": 0.956},
|
||||
"CurrencyCode": {"type": "string", "value": "USD"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"value": {
|
||||
"Description": {"type": "string", "value": "Document Fee", "confidence": 0.712},
|
||||
"Quantity": {"type": "number", "value": 3.0, "confidence": 0.939},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
assert fields == expected_fields
|
||||
legacy = re.findall(r"<!--\s*page\s+\d+\s*-->", rendered)
|
||||
future = re.findall(r"<!--\s*InputPageNumber:\s*\d+\s*-->", rendered)
|
||||
# PDF fixture has 5 pages; expect 5 markers in whichever format is in use.
|
||||
assert len(legacy) == 5 or len(future) == 5, (
|
||||
"Expected SDK-injected page markers to be passed through to LLM input. "
|
||||
f"Found legacy={len(legacy)}, future={len(future)}."
|
||||
)
|
||||
|
||||
|
||||
class TestDuplicateDocumentKey:
|
||||
@@ -1027,239 +989,63 @@ class TestErrorHandling:
|
||||
|
||||
|
||||
class TestMultiModalFixtures:
|
||||
"""Verify ``_render_for_llm`` produces sensible output for each modality.
|
||||
|
||||
Detailed shape of the YAML/Markdown payload is the SDK's responsibility and
|
||||
is exercised by ``azure-ai-contentunderstanding`` tests. Here we only check
|
||||
that the MAF wiring (filename surfaced as ``source``, key content visible)
|
||||
works for each fixture kind.
|
||||
"""
|
||||
|
||||
def test_pdf_fixture_loads(self, pdf_analysis_result: AnalysisResult) -> None:
|
||||
provider = _make_provider()
|
||||
result = provider._extract_sections(pdf_analysis_result)
|
||||
assert "markdown" in result
|
||||
assert "Contoso" in str(result["markdown"])
|
||||
rendered = provider._render_for_llm(pdf_analysis_result, "report.pdf")
|
||||
assert "source: report.pdf" in rendered
|
||||
assert "Contoso" in rendered
|
||||
|
||||
def test_audio_fixture_loads(self, audio_analysis_result: AnalysisResult) -> None:
|
||||
provider = _make_provider()
|
||||
result = provider._extract_sections(audio_analysis_result)
|
||||
assert "markdown" in result
|
||||
assert "Call Center" in str(result["markdown"])
|
||||
rendered = provider._render_for_llm(audio_analysis_result, "call.mp3")
|
||||
assert "source: call.mp3" in rendered
|
||||
assert "Call Center" in rendered
|
||||
|
||||
def test_video_fixture_loads(self, video_analysis_result: AnalysisResult) -> None:
|
||||
provider = _make_provider()
|
||||
result = provider._extract_sections(video_analysis_result)
|
||||
assert "markdown" in result
|
||||
# All 3 segments should be concatenated at top level (for file_search)
|
||||
md = str(result["markdown"])
|
||||
assert "Contoso Product Demo" in md
|
||||
assert "real-time monitoring" in md
|
||||
assert "contoso.com/cloud-manager" in md
|
||||
# Duration should span all segments: (42000 - 1000) / 1000 = 41.0
|
||||
assert result.get("duration_seconds") == 41.0
|
||||
# kind from first segment
|
||||
assert result.get("kind") == "audioVisual"
|
||||
# resolution from first segment
|
||||
assert result.get("resolution") == "640x480"
|
||||
# Multi-segment: fields should be in per-segment list, not merged at top level
|
||||
assert "fields" not in result # no top-level fields for multi-segment
|
||||
segments = result.get("segments")
|
||||
assert isinstance(segments, list)
|
||||
assert len(segments) == 3
|
||||
# Each segment should have its own fields and time range
|
||||
seg0 = segments[0]
|
||||
assert "fields" in seg0
|
||||
assert "Summary" in seg0["fields"]
|
||||
assert seg0.get("start_time_s") == 1.0
|
||||
assert seg0.get("end_time_s") == 14.0
|
||||
seg2 = segments[2]
|
||||
assert "fields" in seg2
|
||||
assert "Summary" in seg2["fields"]
|
||||
assert seg2.get("start_time_s") == 36.0
|
||||
assert seg2.get("end_time_s") == 42.0
|
||||
rendered = provider._render_for_llm(video_analysis_result, "demo.mp4")
|
||||
assert "source: demo.mp4" in rendered
|
||||
# All 3 segments should be visible in the rendered text.
|
||||
assert "Contoso Product Demo" in rendered
|
||||
assert "real-time monitoring" in rendered
|
||||
assert "contoso.com/cloud-manager" in rendered
|
||||
# Each segment must render its own YAML front matter with a timeRange entry.
|
||||
# This guards against multi-segment results being collapsed into one block.
|
||||
assert rendered.count("timeRange:") == 3
|
||||
# Segments must be rendered in chronological order (1s, 15s, 36s starts).
|
||||
assert (
|
||||
rendered.index("Contoso Product Demo")
|
||||
< rendered.index("real-time monitoring")
|
||||
< rendered.index("contoso.com/cloud-manager")
|
||||
)
|
||||
|
||||
def test_image_fixture_loads(self, image_analysis_result: AnalysisResult) -> None:
|
||||
provider = _make_provider()
|
||||
result = provider._extract_sections(image_analysis_result)
|
||||
assert "markdown" in result
|
||||
rendered = provider._render_for_llm(image_analysis_result, "image.png")
|
||||
assert "source: image.png" in rendered
|
||||
# Non-empty body (image markdown caption from CU).
|
||||
assert len(rendered) > len("source: image.png")
|
||||
|
||||
def test_invoice_fixture_loads(self, invoice_analysis_result: AnalysisResult) -> None:
|
||||
provider = _make_provider()
|
||||
result = provider._extract_sections(invoice_analysis_result)
|
||||
assert "markdown" in result
|
||||
assert "fields" in result
|
||||
fields = result["fields"]
|
||||
assert isinstance(fields, dict)
|
||||
assert "VendorName" in fields
|
||||
# Single-segment: should NOT have segments key
|
||||
assert "segments" not in result
|
||||
rendered = provider._render_for_llm(invoice_analysis_result, "invoice.pdf")
|
||||
assert "source: invoice.pdf" in rendered
|
||||
assert "fields:" in rendered
|
||||
assert "VendorName" in rendered
|
||||
|
||||
|
||||
class TestFormatResult:
|
||||
def test_format_includes_markdown_and_fields(self) -> None:
|
||||
result: dict[str, object] = {
|
||||
"markdown": "# Hello World",
|
||||
"fields": {"Name": {"type": "string", "value": "Test", "confidence": 0.9}},
|
||||
}
|
||||
formatted = format_result("test.pdf", result)
|
||||
|
||||
assert 'Document analysis of "test.pdf"' in formatted
|
||||
assert "# Hello World" in formatted
|
||||
assert "Extracted Fields" in formatted
|
||||
assert '"Name"' in formatted
|
||||
|
||||
def test_format_markdown_only(self) -> None:
|
||||
result: dict[str, object] = {"markdown": "# Just Text"}
|
||||
formatted = format_result("doc.pdf", result)
|
||||
|
||||
assert "# Just Text" in formatted
|
||||
assert "Extracted Fields" not in formatted
|
||||
|
||||
def test_format_multi_segment_video(self) -> None:
|
||||
"""Multi-segment results should format each segment with its own content + fields."""
|
||||
result: dict[str, object] = {
|
||||
"kind": "audioVisual",
|
||||
"duration_seconds": 41.0,
|
||||
"resolution": "640x480",
|
||||
"markdown": "scene1\n\n---\n\nscene2", # concatenated for file_search
|
||||
"segments": [
|
||||
{
|
||||
"start_time_s": 1.0,
|
||||
"end_time_s": 14.0,
|
||||
"markdown": "Welcome to the Contoso demo.",
|
||||
"fields": {
|
||||
"Summary": {"type": "string", "value": "Product intro"},
|
||||
"Speakers": {
|
||||
"type": "object",
|
||||
"value": {"count": 1, "names": ["Host"]},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"start_time_s": 15.0,
|
||||
"end_time_s": 31.0,
|
||||
"markdown": "Here we show real-time monitoring.",
|
||||
"fields": {
|
||||
"Summary": {"type": "string", "value": "Feature walkthrough"},
|
||||
"Speakers": {
|
||||
"type": "object",
|
||||
"value": {"count": 2, "names": ["Host", "Engineer"]},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
formatted = format_result("demo.mp4", result)
|
||||
|
||||
expected = (
|
||||
'Video analysis of "demo.mp4":\n'
|
||||
"Duration: 0:41 | Resolution: 640x480\n"
|
||||
"\n### Segment 1 (0:01 - 0:14)\n"
|
||||
"\n```markdown\nWelcome to the Contoso demo.\n```\n"
|
||||
"\n**Fields:**\n```json\n"
|
||||
"{\n"
|
||||
' "Summary": {\n'
|
||||
' "type": "string",\n'
|
||||
' "value": "Product intro"\n'
|
||||
" },\n"
|
||||
' "Speakers": {\n'
|
||||
' "type": "object",\n'
|
||||
' "value": {\n'
|
||||
' "count": 1,\n'
|
||||
' "names": [\n'
|
||||
' "Host"\n'
|
||||
" ]\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n```\n"
|
||||
"\n### Segment 2 (0:15 - 0:31)\n"
|
||||
"\n```markdown\nHere we show real-time monitoring.\n```\n"
|
||||
"\n**Fields:**\n```json\n"
|
||||
"{\n"
|
||||
' "Summary": {\n'
|
||||
' "type": "string",\n'
|
||||
' "value": "Feature walkthrough"\n'
|
||||
" },\n"
|
||||
' "Speakers": {\n'
|
||||
' "type": "object",\n'
|
||||
' "value": {\n'
|
||||
' "count": 2,\n'
|
||||
' "names": [\n'
|
||||
' "Host",\n'
|
||||
' "Engineer"\n'
|
||||
" ]\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n```"
|
||||
)
|
||||
assert formatted == expected
|
||||
|
||||
# Verify ordering: segment 1 markdown+fields appear before segment 2
|
||||
seg1_pos = formatted.index("Segment 1")
|
||||
seg2_pos = formatted.index("Segment 2")
|
||||
contoso_pos = formatted.index("Welcome to the Contoso demo.")
|
||||
monitoring_pos = formatted.index("Here we show real-time monitoring.")
|
||||
intro_pos = formatted.index("Product intro")
|
||||
walkthrough_pos = formatted.index("Feature walkthrough")
|
||||
host_only_pos = formatted.index('"count": 1')
|
||||
host_engineer_pos = formatted.index('"count": 2')
|
||||
assert (
|
||||
seg1_pos
|
||||
< contoso_pos
|
||||
< intro_pos
|
||||
< host_only_pos
|
||||
< seg2_pos
|
||||
< monitoring_pos
|
||||
< walkthrough_pos
|
||||
< host_engineer_pos
|
||||
)
|
||||
|
||||
def test_format_single_segment_no_segments_key(self) -> None:
|
||||
"""Single-segment results should NOT have segments key — flat format."""
|
||||
result: dict[str, object] = {
|
||||
"kind": "document",
|
||||
"markdown": "# Invoice content",
|
||||
"fields": {
|
||||
"VendorName": {"type": "string", "value": "Contoso", "confidence": 0.95},
|
||||
"ShippingAddress": {
|
||||
"type": "object",
|
||||
"value": {"street": "123 Main St", "city": "Redmond", "state": "WA"},
|
||||
"confidence": 0.88,
|
||||
},
|
||||
},
|
||||
}
|
||||
formatted = format_result("invoice.pdf", result)
|
||||
|
||||
expected = (
|
||||
'Document analysis of "invoice.pdf":\n'
|
||||
"\n## Content\n\n"
|
||||
"```markdown\n# Invoice content\n```\n"
|
||||
"\n## Extracted Fields\n\n"
|
||||
"```json\n"
|
||||
"{\n"
|
||||
' "VendorName": {\n'
|
||||
' "type": "string",\n'
|
||||
' "value": "Contoso",\n'
|
||||
' "confidence": 0.95\n'
|
||||
" },\n"
|
||||
' "ShippingAddress": {\n'
|
||||
' "type": "object",\n'
|
||||
' "value": {\n'
|
||||
' "street": "123 Main St",\n'
|
||||
' "city": "Redmond",\n'
|
||||
' "state": "WA"\n'
|
||||
" },\n"
|
||||
' "confidence": 0.88\n'
|
||||
" }\n"
|
||||
"}\n"
|
||||
"```"
|
||||
)
|
||||
assert formatted == expected
|
||||
|
||||
# Verify ordering: header → markdown content → fields
|
||||
header_pos = formatted.index('Document analysis of "invoice.pdf"')
|
||||
content_header_pos = formatted.index("## Content")
|
||||
markdown_pos = formatted.index("# Invoice content")
|
||||
fields_header_pos = formatted.index("## Extracted Fields")
|
||||
vendor_pos = formatted.index("Contoso")
|
||||
address_pos = formatted.index("ShippingAddress")
|
||||
street_pos = formatted.index("123 Main St")
|
||||
assert (
|
||||
header_pos < content_header_pos < markdown_pos < fields_header_pos < vendor_pos < address_pos < street_pos
|
||||
)
|
||||
# NOTE: ``TestFormatResult`` (4 tests) was deleted as part of the migration to
|
||||
# ``azure.ai.contentunderstanding.to_llm_input``. The legacy ``format_result``
|
||||
# helper no longer exists; rendering shape (YAML front matter + Markdown body,
|
||||
# segment serialization, reserved-key handling) is owned and tested by the SDK.
|
||||
|
||||
|
||||
class TestSupportedMediaTypes:
|
||||
@@ -1869,10 +1655,15 @@ class TestAnalyzerAutoDetectionE2E:
|
||||
|
||||
|
||||
class TestWarningsExtraction:
|
||||
"""Verify that CU analysis warnings are included in extracted output."""
|
||||
"""Verify that CU RAI warnings are surfaced via ``to_llm_input`` rendering.
|
||||
|
||||
The SDK serializes ``result.warnings`` under the reserved ``rai_warnings``
|
||||
YAML front-matter key. Telemetry filtering of stray ``LLMStats:`` lines is
|
||||
handled by the SDK helper (azure-ai-contentunderstanding >= 1.2.0b2).
|
||||
"""
|
||||
|
||||
def test_warnings_included_when_present(self) -> None:
|
||||
"""Non-empty warnings list should appear with code/message/target (RAI warnings)."""
|
||||
"""Non-empty warnings should appear under ``rai_warnings`` front-matter key."""
|
||||
provider = _make_provider()
|
||||
fixture = {
|
||||
"contents": [
|
||||
@@ -1895,32 +1686,25 @@ class TestWarningsExtraction:
|
||||
],
|
||||
}
|
||||
result_obj = AnalysisResult(fixture)
|
||||
extracted = provider._extract_sections(result_obj)
|
||||
assert "warnings" in extracted
|
||||
warnings = extracted["warnings"]
|
||||
assert isinstance(warnings, list)
|
||||
assert len(warnings) == 2
|
||||
# First warning has code + message + target
|
||||
assert warnings[0]["code"] == "ContentFiltered"
|
||||
assert warnings[0]["message"] == "Content was filtered due to Responsible AI policy."
|
||||
assert warnings[0]["target"] == "contents/0/markdown"
|
||||
# Second warning has code + message but no target
|
||||
assert warnings[1]["code"] == "ContentFiltered"
|
||||
assert warnings[1]["message"] == "Violence content detected and filtered."
|
||||
assert "target" not in warnings[1]
|
||||
rendered = provider._render_for_llm(result_obj, "doc.pdf")
|
||||
|
||||
assert "rai_warnings:" in rendered
|
||||
assert "ContentFiltered" in rendered
|
||||
assert "Content was filtered due to Responsible AI policy." in rendered
|
||||
assert "Violence content detected and filtered." in rendered
|
||||
|
||||
def test_warnings_omitted_when_empty(self, pdf_analysis_result: AnalysisResult) -> None:
|
||||
"""Empty/None warnings should not appear in extracted result."""
|
||||
"""The PDF fixture has no warnings, so ``rai_warnings:`` should not appear."""
|
||||
provider = _make_provider()
|
||||
extracted = provider._extract_sections(pdf_analysis_result)
|
||||
assert "warnings" not in extracted
|
||||
rendered = provider._render_for_llm(pdf_analysis_result, "report.pdf")
|
||||
assert "rai_warnings:" not in rendered
|
||||
|
||||
|
||||
class TestCategoryExtraction:
|
||||
"""Verify that content-level category is included in extracted output."""
|
||||
"""Verify category metadata (from classifier analyzers) is rendered into output."""
|
||||
|
||||
def test_category_included_single_segment(self) -> None:
|
||||
"""Category from classifier analyzer should appear in single-segment output."""
|
||||
"""Category from classifier should appear under the ``category`` front-matter key."""
|
||||
provider = _make_provider()
|
||||
fixture = {
|
||||
"contents": [
|
||||
@@ -1933,11 +1717,12 @@ class TestCategoryExtraction:
|
||||
],
|
||||
}
|
||||
result_obj = AnalysisResult(fixture)
|
||||
extracted = provider._extract_sections(result_obj)
|
||||
assert extracted.get("category") == "Legal Contract"
|
||||
rendered = provider._render_for_llm(result_obj, "contract.pdf")
|
||||
assert "category:" in rendered
|
||||
assert "Legal Contract" in rendered
|
||||
|
||||
def test_category_in_multi_segment_video(self) -> None:
|
||||
"""Each segment should carry its own category in multi-segment output."""
|
||||
"""Each segment's category should be visible in the rendered text."""
|
||||
provider = _make_provider()
|
||||
fixture = {
|
||||
"contents": [
|
||||
@@ -1972,39 +1757,31 @@ class TestCategoryExtraction:
|
||||
],
|
||||
}
|
||||
result_obj = AnalysisResult(fixture)
|
||||
extracted = provider._extract_sections(result_obj)
|
||||
rendered = provider._render_for_llm(result_obj, "promo.mp4")
|
||||
|
||||
# Top-level metadata
|
||||
assert extracted["kind"] == "audioVisual"
|
||||
assert extracted["duration_seconds"] == 60.0
|
||||
|
||||
# Segments should have per-segment category
|
||||
segments = extracted["segments"]
|
||||
assert isinstance(segments, list)
|
||||
assert len(segments) == 2
|
||||
|
||||
# First segment: ProductDemo
|
||||
assert segments[0]["category"] == "ProductDemo"
|
||||
assert segments[0]["start_time_s"] == 0.0
|
||||
assert segments[0]["end_time_s"] == 30.0
|
||||
assert segments[0]["markdown"] == "Opening scene with product showcase."
|
||||
assert "Summary" in segments[0]["fields"]
|
||||
|
||||
# Second segment: Testimonial
|
||||
assert segments[1]["category"] == "Testimonial"
|
||||
assert segments[1]["start_time_s"] == 30.0
|
||||
assert segments[1]["end_time_s"] == 60.0
|
||||
assert segments[1]["markdown"] == "Customer testimonial segment."
|
||||
|
||||
# Top-level concatenated markdown for file_search
|
||||
assert "Opening scene" in extracted["markdown"]
|
||||
assert "Customer testimonial" in extracted["markdown"]
|
||||
# Both segments' markdown content visible.
|
||||
assert "Opening scene with product showcase." in rendered
|
||||
assert "Customer testimonial segment." in rendered
|
||||
# Both categories visible.
|
||||
assert "ProductDemo" in rendered
|
||||
assert "Testimonial" in rendered
|
||||
# Segments must be rendered in source order, not arbitrary.
|
||||
assert rendered.index("Opening scene with product showcase.") < rendered.index("Customer testimonial segment.")
|
||||
# Category-to-segment mapping must be correct. The SDK separates segments
|
||||
# with a ``*****`` line, so split on it and verify each block carries the
|
||||
# right category alongside the right markdown body.
|
||||
blocks = rendered.split("*****")
|
||||
assert len(blocks) == 2, f"expected 2 segment blocks, got {len(blocks)}"
|
||||
assert "Opening scene with product showcase." in blocks[0]
|
||||
assert "category: ProductDemo" in blocks[0]
|
||||
assert "Customer testimonial segment." in blocks[1]
|
||||
assert "category: Testimonial" in blocks[1]
|
||||
|
||||
def test_category_omitted_when_none(self, pdf_analysis_result: AnalysisResult) -> None:
|
||||
"""No category should be in output when analyzer doesn't classify."""
|
||||
"""No category should be in output when the analyzer doesn't classify."""
|
||||
provider = _make_provider()
|
||||
extracted = provider._extract_sections(pdf_analysis_result)
|
||||
assert "category" not in extracted
|
||||
rendered = provider._render_for_llm(pdf_analysis_result, "report.pdf")
|
||||
assert "category:" not in rendered
|
||||
|
||||
|
||||
class TestContentRangeSupport:
|
||||
|
||||
@@ -111,10 +111,12 @@ async def test_before_run_e2e() -> None:
|
||||
assert "invoice.pdf" in docs
|
||||
doc_entry = docs["invoice.pdf"]
|
||||
assert doc_entry["status"] == "ready"
|
||||
assert doc_entry["result"] is not None
|
||||
assert doc_entry["result"].get("markdown")
|
||||
assert len(doc_entry["result"]["markdown"]) > 10
|
||||
assert "CONTOSO LTD." in doc_entry["result"]["markdown"]
|
||||
# ``result`` is now the rendered string from ``to_llm_input``.
|
||||
rendered = doc_entry["result"]
|
||||
assert isinstance(rendered, str)
|
||||
assert len(rendered) > 10
|
||||
assert "source: invoice.pdf" in rendered
|
||||
assert "CONTOSO LTD." in rendered
|
||||
|
||||
|
||||
# Raw GitHub URL for a public invoice PDF from the CU samples repo
|
||||
@@ -172,10 +174,11 @@ async def test_before_run_uri_content() -> None:
|
||||
|
||||
doc_entry = docs["invoice.pdf"]
|
||||
assert doc_entry["status"] == "ready"
|
||||
assert doc_entry["result"] is not None
|
||||
assert doc_entry["result"].get("markdown")
|
||||
assert len(doc_entry["result"]["markdown"]) > 10
|
||||
assert "CONTOSO LTD." in doc_entry["result"]["markdown"]
|
||||
rendered = doc_entry["result"]
|
||||
assert isinstance(rendered, str)
|
||||
assert len(rendered) > 10
|
||||
assert "source: invoice.pdf" in rendered
|
||||
assert "CONTOSO LTD." in rendered
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@@ -235,10 +238,11 @@ async def test_before_run_data_uri_content() -> None:
|
||||
|
||||
doc_entry = docs["invoice_b64.pdf"]
|
||||
assert doc_entry["status"] == "ready"
|
||||
assert doc_entry["result"] is not None
|
||||
assert doc_entry["result"].get("markdown")
|
||||
assert len(doc_entry["result"]["markdown"]) > 10
|
||||
assert "CONTOSO LTD." in doc_entry["result"]["markdown"]
|
||||
rendered = doc_entry["result"]
|
||||
assert isinstance(rendered, str)
|
||||
assert len(rendered) > 10
|
||||
assert "source: invoice_b64.pdf" in rendered
|
||||
assert "CONTOSO LTD." in rendered
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@@ -307,6 +311,6 @@ async def test_before_run_background_analysis() -> None:
|
||||
await cu.before_run(agent=MagicMock(), session=session, context=context2, state=state)
|
||||
|
||||
assert docs["invoice.pdf"]["status"] == "ready"
|
||||
assert docs["invoice.pdf"]["result"] is not None
|
||||
assert docs["invoice.pdf"]["result"].get("markdown")
|
||||
assert "CONTOSO LTD." in docs["invoice.pdf"]["result"]["markdown"]
|
||||
rendered = docs["invoice.pdf"]["result"]
|
||||
assert isinstance(rendered, str)
|
||||
assert "CONTOSO LTD." in rendered
|
||||
|
||||
@@ -21,7 +21,7 @@ class TestDocumentEntry:
|
||||
"analyzed_at": "2026-01-01T00:00:00+00:00",
|
||||
"analysis_duration_s": 1.23,
|
||||
"upload_duration_s": None,
|
||||
"result": {"markdown": "# Title"},
|
||||
"result": "---\nsource: invoice.pdf\n---\n# Title",
|
||||
"error": None,
|
||||
}
|
||||
assert entry["status"] == DocumentStatus.READY
|
||||
@@ -29,6 +29,7 @@ class TestDocumentEntry:
|
||||
assert entry["analyzer_id"] == "prebuilt-documentSearch"
|
||||
assert entry["analysis_duration_s"] == 1.23
|
||||
assert entry["upload_duration_s"] is None
|
||||
assert isinstance(entry["result"], str)
|
||||
|
||||
def test_failed_entry(self) -> None:
|
||||
entry: DocumentEntry = {
|
||||
|
||||
@@ -76,7 +76,7 @@ def _detect_hosted_environment() -> None:
|
||||
try:
|
||||
if importlib.util.find_spec("azure.ai.agentserver.core") is None:
|
||||
return
|
||||
except (ModuleNotFoundError, ValueError):
|
||||
except (ImportError, ValueError):
|
||||
return
|
||||
with contextlib.suppress(ImportError, AttributeError):
|
||||
from azure.ai.agentserver.core import ( # pyright: ignore[reportMissingImports]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.8.1"
|
||||
version = "1.9.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -58,6 +58,14 @@ all = [
|
||||
"agent-framework-redis",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
# agent-framework-tools depends on agent-framework-core, so core cannot list it as a
|
||||
# runtime dependency. It is declared here (dev only) so the harness shell-tool integration
|
||||
# can be type-checked and tested in isolated environments without a circular runtime dependency.
|
||||
dev = [
|
||||
"agent-framework-tools",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from typing import Any
|
||||
|
||||
@@ -566,6 +567,13 @@ class _FakeShellClient(_FakeChatClient):
|
||||
return "shell_tool_instance"
|
||||
|
||||
|
||||
_requires_shell_tools = pytest.mark.skipif(
|
||||
importlib.util.find_spec("agent_framework_tools") is None,
|
||||
reason="agent-framework-tools is not installed in this environment",
|
||||
)
|
||||
|
||||
|
||||
@_requires_shell_tools
|
||||
def test_create_harness_agent_adds_shell_tool_and_provider() -> None:
|
||||
"""Shell tool and ShellEnvironmentProvider should be added when a shell executor is supplied."""
|
||||
from agent_framework_tools.shell import ShellEnvironmentProvider
|
||||
@@ -585,6 +593,7 @@ def test_create_harness_agent_adds_shell_tool_and_provider() -> None:
|
||||
assert any(isinstance(p, ShellEnvironmentProvider) for p in providers)
|
||||
|
||||
|
||||
@_requires_shell_tools
|
||||
def test_create_harness_agent_shell_passes_custom_options() -> None:
|
||||
"""Custom ShellEnvironmentProviderOptions should be forwarded to the provider."""
|
||||
from agent_framework_tools.shell import ShellEnvironmentProvider, ShellEnvironmentProviderOptions
|
||||
@@ -603,6 +612,7 @@ def test_create_harness_agent_shell_passes_custom_options() -> None:
|
||||
assert provider._options is options
|
||||
|
||||
|
||||
@_requires_shell_tools
|
||||
def test_create_harness_agent_shell_skipped_when_unsupported(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""When the client lacks get_shell_tool, both the tool and provider are skipped with a warning."""
|
||||
import logging
|
||||
@@ -623,6 +633,7 @@ def test_create_harness_agent_shell_skipped_when_unsupported(caplog: pytest.LogC
|
||||
assert "tools" not in agent.default_options or not agent.default_options.get("tools")
|
||||
|
||||
|
||||
@_requires_shell_tools
|
||||
def test_create_harness_agent_no_shell_by_default() -> None:
|
||||
"""No shell tool or provider should be added when shell_executor is not provided."""
|
||||
from agent_framework_tools.shell import ShellEnvironmentProvider
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0rc1"
|
||||
version = "1.0.0rc2"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"agent-framework-core>=1.9.0,<2",
|
||||
"httpx>=0.27,<1",
|
||||
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.8.1"
|
||||
version = "1.8.2"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,9 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.8.1,<2",
|
||||
"agent-framework-openai>=1.8.1,<2",
|
||||
"agent-framework-core>=1.9.0,<2",
|
||||
"agent-framework-openai>=1.8.2,<2",
|
||||
"aiohttp>=3.9,<4",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"azure-ai-projects>=2.2.0,<3.0",
|
||||
]
|
||||
|
||||
@@ -2,16 +2,6 @@
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._history_provider import (
|
||||
FoundryHostedAgentHistoryProvider,
|
||||
bind_request_context,
|
||||
get_current_request_context,
|
||||
)
|
||||
from ._ids import (
|
||||
foundry_item_id,
|
||||
foundry_response_id,
|
||||
foundry_response_id_factory,
|
||||
)
|
||||
from ._invocations import InvocationsHostServer
|
||||
from ._responses import ResponsesHostServer
|
||||
|
||||
@@ -20,13 +10,4 @@ try:
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = [
|
||||
"FoundryHostedAgentHistoryProvider",
|
||||
"InvocationsHostServer",
|
||||
"ResponsesHostServer",
|
||||
"bind_request_context",
|
||||
"foundry_item_id",
|
||||
"foundry_response_id",
|
||||
"foundry_response_id_factory",
|
||||
"get_current_request_context",
|
||||
]
|
||||
__all__ = ["InvocationsHostServer", "ResponsesHostServer"]
|
||||
|
||||
@@ -1,991 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Foundry Hosted Agent history provider.
|
||||
|
||||
A standalone :class:`agent_framework.HistoryProvider` implementation that
|
||||
sources conversation history from the Foundry Hosted Agent storage backend.
|
||||
|
||||
Transport is delegated to the SDK's
|
||||
:class:`azure.ai.agentserver.responses.FoundryStorageProvider` (when running
|
||||
inside a Foundry Hosted Agent container) or
|
||||
:class:`azure.ai.agentserver.responses.InMemoryResponseProvider` (for local
|
||||
development). Both implement the same read/write surface
|
||||
(``get_history_item_ids`` / ``get_items`` / ``create_response``), so this
|
||||
provider's persistence logic stays backend-agnostic.
|
||||
|
||||
Allowed dependencies (deliberately narrow):
|
||||
|
||||
* :mod:`agent_framework` (core, for ``HistoryProvider`` / ``Message``)
|
||||
* :mod:`azure.ai.agentserver.responses` (for the storage backends,
|
||||
``IsolationContext`` typing, and ``OutputItem`` deserialization)
|
||||
* :mod:`azure.core.credentials_async` (typing of token credentials)
|
||||
|
||||
It MUST NOT depend on any ``agent_framework_hosting*`` package at module
|
||||
import time. (The host's isolation contextvar is consulted lazily via an
|
||||
``import`` inside :func:`_host_isolation` so the dependency stays soft.)
|
||||
|
||||
Environment variables read:
|
||||
|
||||
* ``FOUNDRY_HOSTING_ENVIRONMENT`` — non-empty marks "running inside Foundry"
|
||||
and selects the SDK-backed storage transport. Detection is delegated to
|
||||
:class:`azure.ai.agentserver.core.AgentConfig` so a future SDK rename
|
||||
propagates without touching this module.
|
||||
* ``FOUNDRY_PROJECT_ENDPOINT`` — base URL of the Foundry project; required
|
||||
when running hosted unless an explicit ``endpoint=`` is supplied.
|
||||
* ``FOUNDRY_AGENT_NAME`` / ``FOUNDRY_AGENT_VERSION`` — stamped onto the
|
||||
``agent_reference`` field of every persisted response envelope.
|
||||
* ``MODEL_DEPLOYMENT_NAME`` / ``AZURE_AI_MODEL_DEPLOYMENT_NAME`` — model
|
||||
field stamped on the persisted envelope (must match a real deployment).
|
||||
|
||||
Note on ``FOUNDRY_AGENT_SESSION_ID``: this env var identifies the
|
||||
*container instance*, not the conversation, so it is **not** consulted as
|
||||
a fallback ``previous_response_id``. The host-bound
|
||||
``previous_response_id`` (set by :class:`ResponsesChannel` from the
|
||||
request envelope) is the authoritative anchor. The value is still
|
||||
persisted into the ``agent_session_id`` envelope field for operator
|
||||
correlation only.
|
||||
|
||||
Local fallback: when ``FOUNDRY_HOSTING_ENVIRONMENT`` is unset, the provider
|
||||
transparently falls back to :class:`InMemoryResponseProvider` so the same
|
||||
agent code runs in dev. Pass ``local_storage_root`` to use a persistent
|
||||
file-based store instead of in-memory; histories are then laid out as
|
||||
``{root}/{user_key or "~none"}/{chat_key or "~none"}/{session_id}.jsonl``
|
||||
via :class:`agent_framework.FileHistoryProvider`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from base64 import urlsafe_b64encode
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from agent_framework import FileHistoryProvider, HistoryProvider, Message
|
||||
from azure.ai.agentserver.core import AgentConfig
|
||||
from azure.ai.agentserver.responses import (
|
||||
FoundryStorageProvider,
|
||||
FoundryStorageSettings,
|
||||
InMemoryResponseProvider,
|
||||
IsolationContext,
|
||||
)
|
||||
from azure.ai.agentserver.responses._id_generator import IdGenerator
|
||||
from azure.ai.agentserver.responses.models import OutputItem, ResponseObject
|
||||
from azure.ai.agentserver.responses.store._foundry_errors import ( # pyright: ignore[reportPrivateUsage]
|
||||
FoundryBadRequestError,
|
||||
FoundryResourceNotFoundError,
|
||||
FoundryStorageError,
|
||||
)
|
||||
|
||||
from ._shared import (
|
||||
_messages_to_output_items, # pyright: ignore[reportPrivateUsage]
|
||||
_output_items_to_messages, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator, Sequence
|
||||
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Environment variable name — re-declared (not imported) so this module
|
||||
# stays decoupled from the private ``azure.ai.agentserver.core._config``
|
||||
# constants while still matching exactly. Hosted-vs-local detection is
|
||||
# delegated to :class:`AgentConfig` so a future SDK rename propagates.
|
||||
_ENV_FOUNDRY_PROJECT_ENDPOINT = "FOUNDRY_PROJECT_ENDPOINT"
|
||||
|
||||
# Per-request isolation context. The owning Channel is expected to set this
|
||||
# from the inbound request (e.g. user / tenant headers) for the duration of
|
||||
# an ``agent.run(...)`` call. When unset, requests are made without
|
||||
# isolation headers (matches how ``ResponseContext`` behaves with no
|
||||
# ``IsolationContext``).
|
||||
_isolation_var: ContextVar[IsolationContext | None] = ContextVar(
|
||||
"agent_framework_foundry_hosting_isolation",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def set_current_isolation(isolation: IsolationContext | None) -> Any:
|
||||
"""Set the per-request isolation context for downstream history calls.
|
||||
|
||||
Channels that drive an agent backed by :class:`FoundryHostedAgentHistoryProvider`
|
||||
should call this before invoking ``agent.run(...)`` and reset the token
|
||||
afterwards.
|
||||
|
||||
Args:
|
||||
isolation: The isolation context to associate with the current
|
||||
``contextvars`` context, or ``None`` to clear it.
|
||||
|
||||
Returns:
|
||||
A token suitable for :func:`reset_current_isolation` that restores
|
||||
the previous value.
|
||||
"""
|
||||
return _isolation_var.set(isolation)
|
||||
|
||||
|
||||
def reset_current_isolation(token: Any) -> None:
|
||||
"""Restore a previously-saved isolation context.
|
||||
|
||||
Args:
|
||||
token: A token returned by :func:`set_current_isolation`.
|
||||
"""
|
||||
_isolation_var.reset(token)
|
||||
|
||||
|
||||
def get_current_isolation() -> IsolationContext | None:
|
||||
"""Return the isolation context bound to the current async context, if any.
|
||||
|
||||
Returns:
|
||||
The :class:`IsolationContext` for the current request, or ``None``
|
||||
when no channel has set one.
|
||||
"""
|
||||
return _isolation_var.get()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _RequestContext:
|
||||
"""Per-request anchors the host binds before invoking the agent.
|
||||
|
||||
``response_id`` is the id this provider's :meth:`save_messages` call
|
||||
will write under, so the channel and the storage backend agree on
|
||||
one stable handle per turn (the channel surfaces the same id on the
|
||||
response envelope, the next turn arrives with this value as
|
||||
``previous_response_id`` and the chain walks).
|
||||
|
||||
``previous_response_id`` is the prior turn's anchor (``None`` on
|
||||
first turn). Used to seed ``history_item_ids`` on the new write so
|
||||
the storage chain stays connected, and to load history without
|
||||
needing to know the channel's session minting convention.
|
||||
|
||||
Per-request Foundry isolation keys (the
|
||||
``x-agent-{user,chat}-isolation-key`` headers) are *not* carried
|
||||
here; the host's own ASGI middleware lifts them off every inbound
|
||||
HTTP request into a contextvar
|
||||
(:func:`agent_framework_hosting.get_current_isolation_keys`) which
|
||||
this provider consults at storage-call time. Keeping the headers
|
||||
out of the per-request bind means channels never have to import
|
||||
Foundry-specific types and the host owns the (intentional) coupling
|
||||
to those two well-known headers.
|
||||
"""
|
||||
|
||||
response_id: str
|
||||
previous_response_id: str | None
|
||||
|
||||
|
||||
_request_var: ContextVar[_RequestContext | None] = ContextVar(
|
||||
"agent_framework_foundry_hosting_request",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_request_context(
|
||||
*,
|
||||
response_id: str,
|
||||
previous_response_id: str | None = None,
|
||||
**_unused: Any,
|
||||
) -> Iterator[None]:
|
||||
"""Bind the per-request response-chain anchors for this provider.
|
||||
|
||||
Intended for the host (or any caller orchestrating an
|
||||
``agent.run(...)``) to call immediately before invocation, so the
|
||||
provider's :meth:`save_messages` writes under a known, stable
|
||||
``response_id`` (the same one the channel surfaces to the client)
|
||||
and walks ``previous_response_id`` for history continuity. Unknown
|
||||
keyword arguments are accepted and ignored so the host can extend
|
||||
the ``ChannelRequest.attributes`` contract without breaking existing
|
||||
providers. Foundry isolation keys flow through a separate
|
||||
host-installed contextvar; see the class docstring on
|
||||
:class:`_RequestContext`.
|
||||
|
||||
The binding is scoped to the current ``contextvars.Context``, so
|
||||
concurrent requests in the same process do not interfere.
|
||||
"""
|
||||
token = _request_var.set(
|
||||
_RequestContext(
|
||||
response_id=response_id,
|
||||
previous_response_id=previous_response_id,
|
||||
)
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_request_var.reset(token)
|
||||
|
||||
|
||||
def get_current_request_context() -> _RequestContext | None:
|
||||
"""Return the per-request response chain anchors, if bound."""
|
||||
return _request_var.get()
|
||||
|
||||
|
||||
def _host_isolation() -> IsolationContext | None:
|
||||
"""Lift the host-bound isolation contextvar into our local type.
|
||||
|
||||
The host installs an ASGI middleware that reads
|
||||
``x-agent-{user,chat}-isolation-key`` off every inbound HTTP request
|
||||
and stores them in a generic ``IsolationKeys`` slot on a contextvar
|
||||
we import from :mod:`agent_framework_hosting`. We translate it into
|
||||
our :class:`IsolationContext` shape on demand so the provider stays
|
||||
in charge of the storage-side type while the host stays free of any
|
||||
Foundry-specific dependencies.
|
||||
"""
|
||||
# Soft dep: ``agent_framework_hosting`` may not be installed (this
|
||||
# provider is also usable standalone). The whole block is wrapped in
|
||||
# ``# pyright: ignore`` so the optional import does not block type
|
||||
# checking when the package isn't on sys.path; when it is, pyright
|
||||
# picks up the real types automatically.
|
||||
try:
|
||||
from agent_framework_hosting import ( # pyright: ignore[reportMissingImports]
|
||||
get_current_isolation_keys, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
except ImportError: # pragma: no cover - hosting is a soft dep
|
||||
return None
|
||||
keys = get_current_isolation_keys() # pyright: ignore[reportUnknownVariableType]
|
||||
if keys is None or keys.is_empty: # pyright: ignore[reportUnknownMemberType]
|
||||
return None
|
||||
return IsolationContext(
|
||||
user_key=keys.user_key, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
||||
chat_key=keys.chat_key, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
||||
)
|
||||
|
||||
|
||||
# Type alias for the storage backend surface this provider depends on.
|
||||
# Both ``FoundryStorageProvider`` and ``InMemoryResponseProvider`` from
|
||||
# ``azure.ai.agentserver.responses`` expose the same
|
||||
# ``get_history_item_ids`` / ``get_items`` / ``create_response`` methods.
|
||||
_StorageBackend = "FoundryStorageProvider | InMemoryResponseProvider"
|
||||
|
||||
|
||||
# Sentinel directory name used in place of a missing ``user_key`` /
|
||||
# ``chat_key`` when laying out file-based local history. The tilde
|
||||
# prefix is reserved (``_is_safe_isolation_segment`` rejects keys that
|
||||
# start with one) so a real isolation key can never collide with the
|
||||
# sentinel after sanitisation.
|
||||
_ISOLATION_NONE_MARKER = "~none"
|
||||
_ISOLATION_ENCODED_PREFIX = "~iso-"
|
||||
|
||||
# Windows reserved file/directory stems. Mirrors
|
||||
# ``FileHistoryProvider._WINDOWS_RESERVED_FILE_STEMS`` so the directory
|
||||
# layer enforces the same portability constraints the file layer does.
|
||||
_WINDOWS_RESERVED_STEMS = frozenset({
|
||||
"CON",
|
||||
"PRN",
|
||||
"AUX",
|
||||
"NUL",
|
||||
*(f"COM{i}" for i in range(1, 10)),
|
||||
*(f"LPT{i}" for i in range(1, 10)),
|
||||
})
|
||||
|
||||
|
||||
def _is_safe_isolation_segment(value: str) -> bool:
|
||||
"""Return whether ``value`` is safe to use directly as a directory name.
|
||||
|
||||
Rules mirror :meth:`FileHistoryProvider._is_literal_session_file_stem_safe`,
|
||||
with the additional rule that a leading tilde is reserved for our
|
||||
sentinel/encoded prefixes so real keys can never collide with them.
|
||||
"""
|
||||
if (
|
||||
not value
|
||||
or value.startswith((".", "~"))
|
||||
or value.endswith((" ", "."))
|
||||
or value.upper() in _WINDOWS_RESERVED_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 _encode_isolation_segment(value: str | None) -> str:
|
||||
"""Encode an isolation key into a filesystem-safe directory name.
|
||||
|
||||
* ``None`` / empty → ``"~none"`` sentinel.
|
||||
* Already-safe values pass through unchanged.
|
||||
* Anything else is base64-url-encoded and prefixed with ``"~iso-"``
|
||||
so it is unambiguous and never collides with a real (safe) key.
|
||||
"""
|
||||
if value is None or value == "":
|
||||
return _ISOLATION_NONE_MARKER
|
||||
if _is_safe_isolation_segment(value):
|
||||
return value
|
||||
encoded = urlsafe_b64encode(value.encode("utf-8")).decode("ascii").rstrip("=")
|
||||
return f"{_ISOLATION_ENCODED_PREFIX}{encoded}"
|
||||
|
||||
|
||||
class FoundryHostedAgentHistoryProvider(HistoryProvider):
|
||||
"""``HistoryProvider`` backed by Foundry Hosted Agent storage.
|
||||
|
||||
Wraps :class:`azure.ai.agentserver.responses.FoundryStorageProvider`
|
||||
when running inside a Foundry Hosted Agent container, or
|
||||
:class:`InMemoryResponseProvider` for local development. The
|
||||
selection is driven by the ``FOUNDRY_HOSTING_ENVIRONMENT``
|
||||
environment variable.
|
||||
|
||||
For local runs that need to *persist* history across process
|
||||
restarts, pass ``local_storage_root``: the provider then writes
|
||||
each conversation to
|
||||
``{root}/{user_key or "~none"}/{chat_key or "~none"}/{session_id}.jsonl``
|
||||
via :class:`agent_framework.FileHistoryProvider`. The Foundry
|
||||
response-chain semantics (``previous_response_id`` walking,
|
||||
``caresp_*`` id stamping, ``ResponseObject`` envelopes) are
|
||||
bypassed in file mode — the on-disk format is plain JSONL of
|
||||
:class:`Message` payloads, identical to ``FileHistoryProvider``
|
||||
standalone usage. ``local_storage_root`` is ignored when running
|
||||
hosted (Foundry storage always wins).
|
||||
|
||||
``session_id`` semantics: in hosted / in-memory mode the value
|
||||
passed to :meth:`get_messages` and :meth:`save_messages` is treated
|
||||
as the Responses ``previous_response_id`` (or ``conversation_id``)
|
||||
whose chain to load. When omitted (and no host-bound chain anchor
|
||||
is set), :meth:`get_messages` returns an empty list (a fresh
|
||||
conversation). In file mode ``session_id`` is used as the literal
|
||||
filename stem (``FileHistoryProvider`` sanitises unsafe values).
|
||||
"""
|
||||
|
||||
DEFAULT_SOURCE_ID: ClassVar[str] = "foundry_hosted_agent"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
credential: AsyncTokenCredential | None = None,
|
||||
endpoint: str | None = None,
|
||||
history_limit: int = 100,
|
||||
source_id: str = DEFAULT_SOURCE_ID,
|
||||
load_messages: bool = True,
|
||||
store_inputs: bool = True,
|
||||
store_context_messages: bool = False,
|
||||
store_context_from: set[str] | None = None,
|
||||
store_outputs: bool = True,
|
||||
local_storage_root: str | Path | None = None,
|
||||
) -> None:
|
||||
"""Initialize the provider.
|
||||
|
||||
Args:
|
||||
credential: Async token credential used to authenticate against
|
||||
the Foundry storage API. Required when running hosted
|
||||
(``FOUNDRY_HOSTING_ENVIRONMENT`` is set). Ignored in
|
||||
local-mode (the in-memory / file backends need no auth).
|
||||
endpoint: Foundry project endpoint URL. Defaults to the value
|
||||
of the ``FOUNDRY_PROJECT_ENDPOINT`` environment variable.
|
||||
Required when running hosted.
|
||||
history_limit: Maximum number of history items to fetch per
|
||||
``get_messages`` call. Mirrors the agent-server runtime's
|
||||
``ResponseContext._history_limit``. Default ``100``.
|
||||
Ignored in file mode (``FileHistoryProvider`` returns the
|
||||
full session file each call).
|
||||
source_id: Unique identifier for this provider instance, as
|
||||
required by ``HistoryProvider``.
|
||||
load_messages: Whether to load messages before invocation.
|
||||
Default ``True``.
|
||||
store_inputs: Whether to mirror input messages into Foundry
|
||||
storage. Default ``True`` — the Foundry Hosted Agents
|
||||
runtime does not persist Responses turns automatically, so
|
||||
without this the chain would never be visible to subsequent
|
||||
requests. Set ``False`` only if you know an external writer
|
||||
is populating storage on your behalf.
|
||||
store_context_messages: Whether to mirror context-provider
|
||||
messages. Default ``False``.
|
||||
store_context_from: If set, only mirror context messages from
|
||||
these source IDs.
|
||||
store_outputs: Whether to mirror response messages into Foundry
|
||||
storage. Default ``True`` for the same reason as
|
||||
``store_inputs``.
|
||||
local_storage_root: When set, *and* the provider is running
|
||||
outside a Foundry Hosted Agent container, persist history
|
||||
to JSONL files under
|
||||
``{root}/{user_key or "~none"}/{chat_key or "~none"}/{session_id}.jsonl``
|
||||
instead of using the in-memory backend. Ignored when
|
||||
hosted (with a one-time INFO log). Defaults to ``None``
|
||||
(in-memory local fallback).
|
||||
"""
|
||||
super().__init__(
|
||||
source_id=source_id,
|
||||
load_messages=load_messages,
|
||||
store_inputs=store_inputs,
|
||||
store_context_messages=store_context_messages,
|
||||
store_context_from=store_context_from,
|
||||
store_outputs=store_outputs,
|
||||
)
|
||||
|
||||
self._history_limit = history_limit
|
||||
self._credential = credential
|
||||
self._endpoint = endpoint or os.environ.get(_ENV_FOUNDRY_PROJECT_ENDPOINT) or None
|
||||
self._backend: FoundryStorageProvider | InMemoryResponseProvider | None = None
|
||||
|
||||
self._local_storage_root: Path | None = (
|
||||
Path(local_storage_root).resolve() if local_storage_root is not None else None
|
||||
)
|
||||
# Cache one ``FileHistoryProvider`` per (user_key, chat_key)
|
||||
# tuple. Bounded by the number of distinct isolation scopes the
|
||||
# process sees; cleared on ``aclose``.
|
||||
self._file_providers: dict[tuple[str, str], FileHistoryProvider] = {}
|
||||
self._hosted_local_root_warned = False
|
||||
if self._local_storage_root is not None and self.is_hosted_environment():
|
||||
self._warn_hosted_local_root_ignored()
|
||||
|
||||
# Observability: number of ``save_messages`` calls dropped by
|
||||
# :class:`FoundryStorageError` from ``backend.create_response``.
|
||||
# Operators / health probes can read this attribute directly to
|
||||
# detect silent persistence loss; never decremented.
|
||||
self.failed_writes: int = 0
|
||||
|
||||
@staticmethod
|
||||
def is_hosted_environment() -> bool:
|
||||
"""Return ``True`` when running inside a Foundry Hosted Agent container.
|
||||
|
||||
Delegates to :meth:`azure.ai.agentserver.core.AgentConfig.from_env`
|
||||
so the detection rule stays in lockstep with the Foundry SDK; if
|
||||
the platform ever renames the underlying signal (today
|
||||
``FOUNDRY_HOSTING_ENVIRONMENT``) the SDK update is picked up
|
||||
automatically without a code change here.
|
||||
"""
|
||||
return AgentConfig.from_env().is_hosted
|
||||
|
||||
def _resolve_backend(self) -> FoundryStorageProvider | InMemoryResponseProvider:
|
||||
"""Return the storage backend, constructing it lazily on first use.
|
||||
|
||||
* If ``FOUNDRY_HOSTING_ENVIRONMENT`` is set, build a
|
||||
:class:`FoundryStorageProvider` (requires ``credential`` and a
|
||||
resolved ``endpoint``).
|
||||
* Otherwise, fall back to a process-local
|
||||
:class:`InMemoryResponseProvider` so dev/local runs work without
|
||||
additional configuration.
|
||||
"""
|
||||
if self._backend is not None:
|
||||
return self._backend
|
||||
|
||||
if self.is_hosted_environment():
|
||||
if self._credential is None:
|
||||
raise RuntimeError(
|
||||
"FoundryHostedAgentHistoryProvider requires an async credential when running "
|
||||
"inside a Foundry Hosted Agent container. Pass credential=... ."
|
||||
)
|
||||
if not self._endpoint:
|
||||
raise RuntimeError(
|
||||
"FoundryHostedAgentHistoryProvider needs a Foundry project endpoint. Pass "
|
||||
"endpoint=... or set the FOUNDRY_PROJECT_ENDPOINT environment variable."
|
||||
)
|
||||
self._backend = FoundryStorageProvider(
|
||||
credential=self._credential,
|
||||
settings=FoundryStorageSettings.from_endpoint(self._endpoint),
|
||||
)
|
||||
logger.debug(
|
||||
"FoundryHostedAgentHistoryProvider using FoundryStorageProvider against %s",
|
||||
self._endpoint,
|
||||
)
|
||||
return self._backend
|
||||
|
||||
logger.info(
|
||||
"FOUNDRY_HOSTING_ENVIRONMENT is unset — FoundryHostedAgentHistoryProvider falling "
|
||||
"back to InMemoryResponseProvider for local development.",
|
||||
)
|
||||
self._backend = InMemoryResponseProvider()
|
||||
return self._backend
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Release storage resources held by this provider.
|
||||
|
||||
Safe to call multiple times. Closes the lazily-constructed
|
||||
backend if one was created and drops any cached file-history
|
||||
providers. ``InMemoryResponseProvider`` and
|
||||
``FileHistoryProvider`` have no ``aclose`` and are closed
|
||||
implicitly on garbage collection.
|
||||
"""
|
||||
self._file_providers.clear()
|
||||
if self._backend is None:
|
||||
return
|
||||
aclose = getattr(self._backend, "aclose", None)
|
||||
if aclose is not None:
|
||||
await aclose()
|
||||
self._backend = None
|
||||
|
||||
def _warn_hosted_local_root_ignored(self) -> None:
|
||||
"""Log (once) that ``local_storage_root`` is being ignored under hosted mode."""
|
||||
if self._hosted_local_root_warned:
|
||||
return
|
||||
self._hosted_local_root_warned = True
|
||||
logger.info(
|
||||
"FoundryHostedAgentHistoryProvider ignored local_storage_root=%s because "
|
||||
"FOUNDRY_HOSTING_ENVIRONMENT is set; Foundry storage takes precedence "
|
||||
"when hosted.",
|
||||
self._local_storage_root,
|
||||
)
|
||||
|
||||
def _resolve_local_file_provider(
|
||||
self,
|
||||
isolation: IsolationContext | None,
|
||||
) -> FileHistoryProvider | None:
|
||||
"""Return a ``FileHistoryProvider`` for the current isolation, or ``None``.
|
||||
|
||||
Returns ``None`` when ``local_storage_root`` is unset *or* the
|
||||
provider is running in hosted mode (in which case Foundry
|
||||
storage handles persistence). Otherwise builds — and caches —
|
||||
one provider per (user_key, chat_key) tuple, rooted at the
|
||||
sanitised ``{root}/{user_segment}/{chat_segment}`` directory.
|
||||
|
||||
Raises:
|
||||
ValueError: If the resolved isolation directory escapes
|
||||
``local_storage_root`` (defence in depth — the
|
||||
sanitisation should already prevent this).
|
||||
"""
|
||||
if self._local_storage_root is None:
|
||||
return None
|
||||
if self.is_hosted_environment():
|
||||
self._warn_hosted_local_root_ignored()
|
||||
return None
|
||||
|
||||
user_key = isolation.user_key if isolation is not None else None
|
||||
chat_key = isolation.chat_key if isolation is not None else None
|
||||
cache_key = (user_key or "", chat_key or "")
|
||||
cached = self._file_providers.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
user_segment = _encode_isolation_segment(user_key)
|
||||
chat_segment = _encode_isolation_segment(chat_key)
|
||||
target_dir = (self._local_storage_root / user_segment / chat_segment).resolve()
|
||||
if not target_dir.is_relative_to(self._local_storage_root):
|
||||
raise ValueError(
|
||||
"Isolation segments resolved outside of local_storage_root: "
|
||||
f"user_key={user_key!r} chat_key={chat_key!r}"
|
||||
)
|
||||
|
||||
provider = FileHistoryProvider(
|
||||
target_dir,
|
||||
source_id=f"{self.source_id}__file__{user_segment}__{chat_segment}",
|
||||
load_messages=self.load_messages,
|
||||
store_inputs=self.store_inputs,
|
||||
store_context_messages=self.store_context_messages,
|
||||
store_context_from=self.store_context_from,
|
||||
store_outputs=self.store_outputs,
|
||||
)
|
||||
self._file_providers[cache_key] = provider
|
||||
logger.debug(
|
||||
"FoundryHostedAgentHistoryProvider created file backend for isolation (user=%s, chat=%s) at %s",
|
||||
user_key,
|
||||
chat_key,
|
||||
target_dir,
|
||||
)
|
||||
return provider
|
||||
|
||||
async def get_messages(
|
||||
self,
|
||||
session_id: str | None,
|
||||
*,
|
||||
state: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> list[Message]:
|
||||
"""Load conversation history for the given Foundry response chain.
|
||||
|
||||
Args:
|
||||
session_id: The Responses ``previous_response_id`` /
|
||||
``conversation_id`` to anchor history on. When ``None`` /
|
||||
empty, an empty history is returned (fresh conversation).
|
||||
state: Unused — kept for ``HistoryProvider`` compatibility.
|
||||
**kwargs: Extensibility hook; ``isolation`` may be supplied
|
||||
explicitly to override the contextvar.
|
||||
|
||||
Returns:
|
||||
The conversation history materialised as a list of
|
||||
:class:`agent_framework.Message`, oldest-first.
|
||||
|
||||
Notes:
|
||||
History anchoring follows the Foundry response-id chain. The
|
||||
preferred anchor is the per-request ``previous_response_id``
|
||||
bound by the host via :func:`bind_request_context` — that's
|
||||
the prior turn's resp id, written by *this* provider's
|
||||
previous :meth:`save_messages` call, so the chain is
|
||||
guaranteed walkable. When unbound (e.g. local dev calling
|
||||
the provider directly), we fall back to the ``session_id``
|
||||
argument as long as it's ``resp_*``-shaped; opaque tokens
|
||||
(such as chat-isolation-key values) are skipped because the
|
||||
storage backend rejects them with HTTP 400 "Malformed
|
||||
identifier".
|
||||
|
||||
When ``local_storage_root`` is configured (and the provider
|
||||
is running outside a Foundry Hosted Agent container), this
|
||||
method instead delegates to a per-isolation
|
||||
:class:`FileHistoryProvider` and ``session_id`` is used as
|
||||
the literal file stem.
|
||||
"""
|
||||
isolation = kwargs.get("isolation") or _host_isolation() or get_current_isolation()
|
||||
file_provider = self._resolve_local_file_provider(isolation)
|
||||
if file_provider is not None:
|
||||
return await file_provider.get_messages(session_id, state=state, **kwargs)
|
||||
|
||||
bound = get_current_request_context()
|
||||
# Prefer the host-bound previous_response_id over the session_id
|
||||
# the framework feeds in: the bound value is the id we ourselves
|
||||
# wrote on the previous turn, so we know it's storage-valid.
|
||||
anchor = bound.previous_response_id if bound is not None else None
|
||||
if anchor is None and session_id and session_id.startswith(("caresp_", "resp_")):
|
||||
anchor = session_id
|
||||
if anchor is None:
|
||||
# No walkable anchor → fresh conversation, nothing to load.
|
||||
# Note: we intentionally do NOT fall back to
|
||||
# ``FOUNDRY_AGENT_SESSION_ID`` — per the Foundry SDK that env
|
||||
# var identifies the *container instance*, not the
|
||||
# conversation, so it doesn't yield a walkable response-id
|
||||
# chain. The host-bound ``previous_response_id`` (set by
|
||||
# ``ResponsesChannel`` from the request envelope) is the
|
||||
# authoritative anchor.
|
||||
return []
|
||||
|
||||
backend = self._resolve_backend()
|
||||
|
||||
try:
|
||||
item_ids = await backend.get_history_item_ids(
|
||||
anchor,
|
||||
None,
|
||||
self._history_limit,
|
||||
isolation=isolation,
|
||||
)
|
||||
except (FoundryBadRequestError, FoundryResourceNotFoundError) as err:
|
||||
# 400 / 404 here means the anchor isn't storage-valid — treat
|
||||
# it as an empty history rather than failing the whole request.
|
||||
logger.debug(
|
||||
"get_messages: anchor %r rejected by storage (%s); returning empty history",
|
||||
anchor,
|
||||
type(err).__name__,
|
||||
)
|
||||
return []
|
||||
if not item_ids:
|
||||
return []
|
||||
|
||||
items = await backend.get_items(item_ids, isolation=isolation)
|
||||
# ``get_items`` may return ``None`` placeholders for missing IDs.
|
||||
resolved = [item for item in items if item is not None]
|
||||
return await _output_items_to_messages(resolved)
|
||||
|
||||
async def save_messages(
|
||||
self,
|
||||
session_id: str | None,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
state: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Persist messages for ``session_id`` into Foundry storage.
|
||||
|
||||
Unlike the standalone ``azure.ai.agentserver`` runtime — which
|
||||
owns response orchestration end-to-end and writes turns
|
||||
authoritatively — the Agent Framework hosting stack treats
|
||||
``HistoryProvider`` as the *only* persistence path. Without this
|
||||
method actively writing, a deployed hosted agent would silently
|
||||
drop every turn.
|
||||
|
||||
Strategy:
|
||||
|
||||
* Use the host-bound ``response_id`` as the envelope id (mints
|
||||
a fresh ``caresp_*`` id when unbound, e.g. local dev).
|
||||
* Anchor the new write to the previous turn via
|
||||
``previous_response_id``, walking the prior turn's history
|
||||
item ids forward so the full transcript stays visible.
|
||||
* Split items by role: ``"message"`` (user/system inputs) into
|
||||
``input_items``, everything else (assistant outputs, tool
|
||||
calls, reasoning, ...) into ``response.output``.
|
||||
|
||||
Args:
|
||||
session_id: The Responses ``previous_response_id`` /
|
||||
``conversation_id`` the messages belong to.
|
||||
messages: The messages selected for persistence by the base
|
||||
``HistoryProvider`` after-run hook.
|
||||
state: Unused — kept for ``HistoryProvider`` compatibility.
|
||||
**kwargs: Extensibility hook; ``isolation`` may be supplied
|
||||
explicitly to override the contextvar.
|
||||
|
||||
Notes:
|
||||
When ``local_storage_root`` is configured (and the provider
|
||||
is running outside a Foundry Hosted Agent container), this
|
||||
method instead delegates to a per-isolation
|
||||
:class:`FileHistoryProvider` and ``session_id`` is used as
|
||||
the literal file stem. The Foundry response-chain stamping
|
||||
described above is bypassed entirely in that mode.
|
||||
"""
|
||||
if not messages:
|
||||
return
|
||||
|
||||
isolation = kwargs.get("isolation") or _host_isolation() or get_current_isolation()
|
||||
file_provider = self._resolve_local_file_provider(isolation)
|
||||
if file_provider is not None:
|
||||
await file_provider.save_messages(session_id, messages, state=state, **kwargs)
|
||||
return
|
||||
|
||||
bound = get_current_request_context()
|
||||
# Prefer the host-bound response_id so the channel envelope and
|
||||
# the storage write agree on a single id per turn — which is
|
||||
# what makes the next turn's ``previous_response_id`` walkable.
|
||||
# Without a binding (e.g. local dev calling ``save_messages``
|
||||
# directly), fall back to a fresh Foundry-format response id.
|
||||
# Free-form ``resp_<uuid>`` ids carry no embedded partition key
|
||||
# and the storage backend rejects writes with a server error;
|
||||
# ``IdGenerator.new_response_id()`` mints a ``caresp_*`` id with
|
||||
# the partition-key segment the backend expects. The chain
|
||||
# walks only when ``session_id`` is itself a ``caresp_*``-shaped
|
||||
# value (i.e. a previous response id), matching the prefix the
|
||||
# ``ResponsesChannel`` factory uses.
|
||||
if bound is not None:
|
||||
response_id = bound.response_id
|
||||
previous_response_id = bound.previous_response_id
|
||||
else:
|
||||
if not session_id:
|
||||
return
|
||||
response_id = IdGenerator.new_response_id()
|
||||
previous_response_id = session_id if session_id.startswith(("caresp_", "resp_")) else None
|
||||
|
||||
# Note: we intentionally do NOT consult ``FOUNDRY_AGENT_SESSION_ID``
|
||||
# as a fallback ``previous_response_id`` here. Per the Foundry SDK
|
||||
# that env var identifies the *container instance*, not the
|
||||
# conversation, so chaining off it produces an unwalkable history.
|
||||
# The host-bound ``previous_response_id`` (set by
|
||||
# ``ResponsesChannel`` from the request envelope) is the only
|
||||
# authoritative anchor; if it's missing the new turn is the start
|
||||
# of a fresh chain.
|
||||
|
||||
logger.debug(
|
||||
"save_messages: response_id=%r previous_response_id=%r isolation=%s",
|
||||
response_id,
|
||||
previous_response_id,
|
||||
"<set>" if isolation else "<None>",
|
||||
)
|
||||
backend = self._resolve_backend()
|
||||
|
||||
# The agentserver runtime puts INBOUND items (user/system messages
|
||||
# the request sent in) in the envelope's ``input_items`` axis and
|
||||
# OUTBOUND items (assistant outputs, tool calls, reasoning) in
|
||||
# ``response.output``. See
|
||||
# ``_resolve_input_items_for_persistence`` (orchestrator.py:61) +
|
||||
# ``_extract_response_snapshot_from_events`` in
|
||||
# ``azure.ai.agentserver.responses``: ``input_items`` comes from
|
||||
# ``ctx.input_items`` (request inputs only); ``response.output``
|
||||
# is populated from the lifecycle event stream.
|
||||
#
|
||||
# Putting everything in ``input_items`` with ``response.output: []``
|
||||
# is a schema violation that the storage backend rejects with an
|
||||
# opaque HTTP 500. Split by role to mirror the runtime.
|
||||
all_items = _messages_to_output_items(list(messages), id_prefix=response_id)
|
||||
|
||||
# Re-stamp every item id via ``IdGenerator`` so each carries a
|
||||
# Foundry-format ``{type-prefix}_<partitionKey><entropy>``
|
||||
# identifier, with the response_id as the partition-key hint
|
||||
# (co-locates each item with the response record). Free-form
|
||||
# ``{response_id}_itm_N`` ids are rejected by the storage
|
||||
# backend with an opaque HTTP 500 because the partition-key
|
||||
# extractor cannot parse them. ``IdGenerator.new_item_id``
|
||||
# dispatches by *Item* (input) type and returns ``None`` for
|
||||
# our *OutputItem* (storage) instances, so we dispatch by the
|
||||
# ``type`` discriminator string instead.
|
||||
ITEM_ID_FACTORY: dict[str, Any] = {
|
||||
"message": IdGenerator.new_message_item_id,
|
||||
"output_message": IdGenerator.new_output_message_item_id,
|
||||
"function_call": IdGenerator.new_function_call_item_id,
|
||||
"function_call_output": IdGenerator.new_function_call_output_item_id,
|
||||
"reasoning": IdGenerator.new_reasoning_item_id,
|
||||
"file_search_call": IdGenerator.new_file_search_call_item_id,
|
||||
"web_search_call": IdGenerator.new_web_search_call_item_id,
|
||||
"image_generation_call": IdGenerator.new_image_gen_call_item_id,
|
||||
"code_interpreter_call": IdGenerator.new_code_interpreter_call_item_id,
|
||||
"computer_call": IdGenerator.new_computer_call_item_id,
|
||||
"computer_call_output": IdGenerator.new_computer_call_output_item_id,
|
||||
"local_shell_call": IdGenerator.new_local_shell_call_item_id,
|
||||
"local_shell_call_output": IdGenerator.new_local_shell_call_output_item_id,
|
||||
"mcp_call": IdGenerator.new_mcp_call_item_id,
|
||||
"mcp_list_tools": IdGenerator.new_mcp_list_tools_item_id,
|
||||
"mcp_approval_request": IdGenerator.new_mcp_approval_request_item_id,
|
||||
"mcp_approval_response": IdGenerator.new_mcp_approval_response_item_id,
|
||||
"custom_tool_call": IdGenerator.new_custom_tool_call_item_id,
|
||||
"custom_tool_call_output": IdGenerator.new_custom_tool_call_output_item_id,
|
||||
}
|
||||
for item in all_items:
|
||||
factory = ITEM_ID_FACTORY.get(getattr(item, "type", "") or "")
|
||||
if factory is None:
|
||||
continue
|
||||
new_id = factory(response_id)
|
||||
# Plain attribute assignment — the SDK ``OutputItem`` models
|
||||
# are ``MutableMapping``s with ``__setattr__`` wired to dict
|
||||
# set, so this is expected to succeed for every type listed
|
||||
# above. The previous ``contextlib.suppress`` masked SDK
|
||||
# contract changes (next save would silently retain the
|
||||
# synthetic prefix-based id and the storage backend would
|
||||
# reject the entire ``create_response`` with HTTP 500).
|
||||
# Letting it raise surfaces those breakages to the test
|
||||
# suite instead.
|
||||
item.id = new_id # type: ignore[attr-defined]
|
||||
|
||||
input_items: list[Any] = []
|
||||
output_items: list[Any] = []
|
||||
for item in all_items:
|
||||
item_type = getattr(item, "type", None)
|
||||
if item_type == "message":
|
||||
input_items.append(item)
|
||||
else:
|
||||
# ``output_message``, tool calls, reasoning, etc. all
|
||||
# belong to the response output stream.
|
||||
output_items.append(item)
|
||||
|
||||
# Walk the previous response's history chain so the new write
|
||||
# carries the full transcript forward. Without this, each turn
|
||||
# would only see the messages saved on that very turn.
|
||||
history_item_ids: list[str] | None = None
|
||||
if previous_response_id is not None:
|
||||
try:
|
||||
history_item_ids = await backend.get_history_item_ids(
|
||||
previous_response_id,
|
||||
None,
|
||||
self._history_limit,
|
||||
isolation=isolation,
|
||||
)
|
||||
except (FoundryBadRequestError, FoundryResourceNotFoundError) as err:
|
||||
# Don't let history fetch failures torpedo the write —
|
||||
# we still want to persist the new turn even if the
|
||||
# chain seed is unreachable for some reason.
|
||||
logger.warning(
|
||||
"save_messages: failed to walk previous_response_id=%r (%s); writing new turn without history seed",
|
||||
previous_response_id,
|
||||
type(err).__name__,
|
||||
)
|
||||
|
||||
# Mirror what the agentserver runtime serialises onto the wire
|
||||
# (see ``_extract_response_snapshot_from_events`` +
|
||||
# ``strip_nulls`` in
|
||||
# ``azure.ai.agentserver.responses.streaming._helpers``):
|
||||
#
|
||||
# * ``agent_reference`` (Required on the response envelope) —
|
||||
# built from ``FOUNDRY_AGENT_NAME`` / ``FOUNDRY_AGENT_VERSION``,
|
||||
# which the hosted platform sets per-deploy (sentinel fallback
|
||||
# for local dev so the envelope stays well-formed).
|
||||
# * ``agent_session_id`` (S-038) — forcibly stamped by the
|
||||
# runtime; sourced from ``FOUNDRY_AGENT_SESSION_ID``.
|
||||
# * ``conversation`` is intentionally omitted: the (user, chat)
|
||||
# isolation headers are the Foundry storage partition key,
|
||||
# and the chat-isolation-key value is opaque (the API
|
||||
# returns "Malformed identifier"/HTTP 400 if used as a
|
||||
# body-level ``conversation_id``).
|
||||
# * Per-item ``response_id`` / ``agent_reference`` are NOT
|
||||
# stamped here — those B20/B21 defaults only apply to items
|
||||
# inside ``response.output_item.added/done`` *events* (see
|
||||
# ``_coerce_handler_event``); items inside ``input_items``
|
||||
# and ``response.output`` go through ``to_output_item`` which
|
||||
# never sets these fields, and the storage validator returns
|
||||
# HTTP 400 ``invalid_payload`` when extras leak in.
|
||||
agent_name = os.environ.get("FOUNDRY_AGENT_NAME") or "agent-framework-host"
|
||||
agent_version = os.environ.get("FOUNDRY_AGENT_VERSION") or None
|
||||
agent_reference: dict[str, Any] = {"type": "agent_reference", "name": agent_name}
|
||||
if agent_version:
|
||||
agent_reference["version"] = agent_version
|
||||
|
||||
agent_session_id = os.environ.get("FOUNDRY_AGENT_SESSION_ID") or None
|
||||
# ``model`` must be a real deployed model name — the storage
|
||||
# validator rejects arbitrary strings. Pull it from the
|
||||
# platform-provided ``MODEL_DEPLOYMENT_NAME`` (set in agent.yaml)
|
||||
# and fall back to ``AZURE_AI_MODEL_DEPLOYMENT_NAME`` for local
|
||||
# dev. When neither is set we omit the field entirely (it is
|
||||
# ``Optional[str]`` per the ResponseObject schema).
|
||||
model_deployment = (
|
||||
os.environ.get("MODEL_DEPLOYMENT_NAME") or os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME") or None
|
||||
)
|
||||
|
||||
# Build the wire payload to match exactly what the agentserver
|
||||
# runtime emits via ``_extract_response_snapshot_from_events``
|
||||
# for a synthetic ``status=completed`` snapshot:
|
||||
#
|
||||
# {id, object, output, created_at, [model], agent_reference,
|
||||
# status, completed_at, [agent_session_id]}
|
||||
#
|
||||
# ``previous_response_id`` is appended when chaining; the runtime
|
||||
# threads it through the same code path.
|
||||
now = int(time.time())
|
||||
response_body: dict[str, Any] = {
|
||||
"id": response_id,
|
||||
# SDK mirror: ``streaming/_helpers.py:244`` always stamps
|
||||
# ``response_id`` alongside ``id`` on the snapshot before it
|
||||
# reaches ``serialize_create_request``.
|
||||
"response_id": response_id,
|
||||
"object": "response",
|
||||
# S-040 auto-stamp: the orchestrator (``_orchestrator.py:1706``)
|
||||
# echoes ``background`` from the request to every response
|
||||
# envelope; storage rejects payloads that omit it.
|
||||
"background": False,
|
||||
# ``ResponseObject`` schema (``_models.py:13995``) declares
|
||||
# ``parallel_tool_calls: bool`` as REQUIRED. The SDK's synthetic
|
||||
# fallback path (``_build_events``) never sets it because it's
|
||||
# only invoked for failure recovery; real handler events carry
|
||||
# it through. Storage rejects payloads that omit it.
|
||||
"parallel_tool_calls": False,
|
||||
# Same story for ``instructions`` (``_models.py:13989``) —
|
||||
# required ``str | list[Item]`` field.
|
||||
"instructions": "",
|
||||
"output": [item.as_dict() for item in output_items],
|
||||
"created_at": now,
|
||||
"agent_reference": agent_reference,
|
||||
"status": "completed",
|
||||
"completed_at": now,
|
||||
}
|
||||
if model_deployment is not None:
|
||||
response_body["model"] = model_deployment
|
||||
if agent_session_id is not None:
|
||||
response_body["agent_session_id"] = agent_session_id
|
||||
if previous_response_id is not None:
|
||||
response_body["previous_response_id"] = previous_response_id
|
||||
response = ResponseObject(response_body)
|
||||
|
||||
try:
|
||||
await backend.create_response(
|
||||
response,
|
||||
input_items=input_items,
|
||||
history_item_ids=history_item_ids,
|
||||
isolation=isolation,
|
||||
)
|
||||
except FoundryStorageError as exc:
|
||||
# Storage-validation failures (4xx ``invalid_payload`` /
|
||||
# ``not_found``, opaque 5xx) are best-effort losses: the
|
||||
# caller's run already produced output and we don't want to
|
||||
# crash the whole turn over a chain-write the user can't
|
||||
# recover from. They are still observable: every drop bumps
|
||||
# ``failed_writes`` (operators can poll it / surface in
|
||||
# health probes) and the full traceback + ``response_body``
|
||||
# is logged.
|
||||
#
|
||||
# Network / TLS / DNS errors, expired-credential 401/403s,
|
||||
# and bugs in the wire-payload builder above (e.g. a
|
||||
# required-field regression) deliberately propagate so they
|
||||
# surface to the caller and trigger retry / alerting paths
|
||||
# instead of being silently dropped here.
|
||||
self.failed_writes += 1
|
||||
err_body = getattr(exc, "response_body", None)
|
||||
logger.exception(
|
||||
"FoundryHostedAgentHistoryProvider.save_messages: storage rejected "
|
||||
"%d message(s) (response_id=%s, previous_response_id=%s, error_body=%s, "
|
||||
"failed_writes=%d).",
|
||||
len(messages),
|
||||
response_id,
|
||||
previous_response_id,
|
||||
err_body,
|
||||
self.failed_writes,
|
||||
)
|
||||
return
|
||||
logger.debug(
|
||||
"FoundryHostedAgentHistoryProvider.save_messages: persisted %d message(s) "
|
||||
"(response_id=%s, previous_response_id=%s).",
|
||||
len(messages),
|
||||
response_id,
|
||||
previous_response_id,
|
||||
)
|
||||
|
||||
|
||||
# Re-export ``OutputItem`` for callers that want to construct test items
|
||||
# without reaching into the SDK's ``models`` namespace directly.
|
||||
__all__ = [
|
||||
"FoundryHostedAgentHistoryProvider",
|
||||
"OutputItem",
|
||||
"bind_request_context",
|
||||
"get_current_isolation",
|
||||
"get_current_request_context",
|
||||
"reset_current_isolation",
|
||||
"set_current_isolation",
|
||||
]
|
||||
@@ -1,72 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Foundry-storage-compatible identifier helpers.
|
||||
|
||||
The Foundry hosted-agent storage backend partitions records by extracting
|
||||
an embedded partition-key segment from every record/item id. The id
|
||||
format is ``{prefix}_{18charPartitionKey}{32charEntropy}`` (or a 48-char
|
||||
legacy body). Free-form ids such as ``resp_<uuid hex>`` carry no valid
|
||||
partition key and the storage API rejects writes with an opaque
|
||||
``HTTP 500 server_error``.
|
||||
|
||||
These helpers wrap :class:`azure.ai.agentserver.responses._id_generator.IdGenerator`
|
||||
so callers (e.g. the ``ResponsesChannel.response_id_factory`` argument
|
||||
or :class:`FoundryHostedAgentHistoryProvider.save_messages`) can mint
|
||||
ids that the storage backend accepts without leaking the SDK import
|
||||
path into user code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from azure.ai.agentserver.responses._id_generator import IdGenerator
|
||||
|
||||
__all__ = [
|
||||
"foundry_item_id",
|
||||
"foundry_response_id",
|
||||
"foundry_response_id_factory",
|
||||
]
|
||||
|
||||
|
||||
def foundry_response_id(previous_response_id: str | None = None) -> str:
|
||||
"""Mint a Foundry-storage-compatible response id (``caresp_*``).
|
||||
|
||||
Args:
|
||||
previous_response_id: When supplied (and shaped like a Foundry
|
||||
id with an embedded partition key), the new id co-locates
|
||||
with the chain by reusing that partition key. The storage
|
||||
backend rejects chained writes whose new record sits in a
|
||||
different partition than the prior one.
|
||||
|
||||
Returns:
|
||||
A new id of the form ``caresp_<18charPartitionKey><32charEntropy>``.
|
||||
"""
|
||||
return IdGenerator.new_response_id(previous_response_id or "")
|
||||
|
||||
|
||||
def foundry_response_id_factory() -> Any:
|
||||
"""Return a callable suitable for ``ResponsesChannel(response_id_factory=...)``.
|
||||
|
||||
The returned callable accepts an optional ``previous_response_id``
|
||||
hint which the channel passes for chained turns so the new id
|
||||
inherits the prior turn's partition key (Foundry storage requirement).
|
||||
"""
|
||||
return foundry_response_id
|
||||
|
||||
|
||||
def foundry_item_id(item: Any, response_id: str | None = None) -> str | None:
|
||||
"""Mint a Foundry-storage-compatible item id for *item*.
|
||||
|
||||
Dispatches via :meth:`IdGenerator.new_item_id` so the id picks up
|
||||
the right type prefix (``msg`` / ``om`` / ``fc`` / ``rs`` / ...).
|
||||
When ``response_id`` is supplied it acts as a partition-key hint so
|
||||
every item written under one response co-locates with the response
|
||||
record (Foundry storage requirement).
|
||||
|
||||
Returns:
|
||||
A new id of the form ``{type-prefix}_<partitionKey><entropy>``,
|
||||
or ``None`` when *item* is an unrecognised / reference-only type
|
||||
(mirrors the SDK helper's contract).
|
||||
"""
|
||||
return IdGenerator.new_item_id(item, response_id)
|
||||
@@ -901,6 +901,9 @@ class _OutputItemTracker:
|
||||
self._accumulated.clear()
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Option Conversion
|
||||
|
||||
|
||||
@@ -936,6 +939,7 @@ def _to_chat_options(request: CreateResponse) -> tuple[ChatOptions, bool]:
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Input Message Conversion
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ description = "Foundry Hosting integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260609"
|
||||
version = "1.0.0a260618"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.8.1,<2",
|
||||
"agent-framework-core>=1.9.0,<2",
|
||||
"azure-ai-agentserver-core>=2.0.0b3,<3",
|
||||
"azure-ai-agentserver-responses>=1.0.0b7,<2",
|
||||
"azure-ai-agentserver-invocations>=1.0.0b3,<2",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
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
|
||||
@@ -1,36 +0,0 @@
|
||||
# agent-framework-hosting-a2a
|
||||
|
||||
Agent-to-Agent (A2A) protocol channel for `agent-framework-hosting`.
|
||||
|
||||
Exposes the hosted target (an `Agent` or a `Workflow`) as an A2A peer agent: it
|
||||
publishes an agent card and JSON-RPC routes and drives every request through the
|
||||
host pipeline, so host sessions, request metadata, and run/response hooks all
|
||||
apply.
|
||||
|
||||
```python
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework_hosting import AgentFrameworkHost
|
||||
from agent_framework_hosting_a2a import A2AChannel
|
||||
|
||||
agent = OpenAIChatClient().as_agent(name="Assistant")
|
||||
|
||||
host = AgentFrameworkHost(
|
||||
target=agent,
|
||||
channels=[A2AChannel(url="https://my-host.example.com/")],
|
||||
)
|
||||
host.serve(port=8000)
|
||||
```
|
||||
|
||||
By default the channel mounts at the app root so the well-known agent card is
|
||||
reachable at `/.well-known/agent-card.json`, with the JSON-RPC endpoint at `/`.
|
||||
The A2A `context_id` maps onto the host session (caller-supplied session family).
|
||||
A default agent card is derived from the target's name and description; pass a
|
||||
fully-specified `agent_card` to override it. To advertise additional protocol
|
||||
bindings in the generated card, pass `supported_interfaces`.
|
||||
|
||||
> **Note:** Task state is held in an in-memory A2A task store for this version; it
|
||||
> is independent of the host's session storage and is not persisted across
|
||||
> restarts.
|
||||
|
||||
The base host plumbing lives in
|
||||
[`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/).
|
||||
@@ -1,24 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""A2A (Agent-to-Agent) channel for :mod:`agent_framework_hosting`.
|
||||
|
||||
Exposes the hosted target (an ``Agent`` or a ``Workflow``) as an A2A peer agent
|
||||
— publishing an agent card and JSON-RPC routes — while routing every request
|
||||
through the host pipeline so sessions, request metadata, and hooks apply.
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._channel import A2AChannel
|
||||
from ._executor import HostAgentExecutor
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = [
|
||||
"A2AChannel",
|
||||
"HostAgentExecutor",
|
||||
"__version__",
|
||||
]
|
||||
@@ -1,141 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""A2A (Agent-to-Agent) channel for :mod:`agent_framework_hosting`.
|
||||
|
||||
Exposes the hosted target as an A2A peer agent: it publishes an agent card and
|
||||
JSON-RPC routes, and drives every request through the host pipeline via
|
||||
:class:`HostAgentExecutor`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from a2a.server.request_handlers import DefaultRequestHandler
|
||||
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill
|
||||
from agent_framework_hosting import (
|
||||
ChannelContext,
|
||||
ChannelContribution,
|
||||
ChannelResponseHook,
|
||||
ChannelRunHook,
|
||||
)
|
||||
|
||||
from ._executor import HostAgentExecutor
|
||||
|
||||
|
||||
class A2AChannel:
|
||||
"""Channel that exposes the hosted target over the A2A protocol.
|
||||
|
||||
The A2A ``context_id`` maps onto the host session (caller-supplied session
|
||||
family) and each request is routed through :class:`ChannelContext`, so host
|
||||
session resolution and hooks apply.
|
||||
|
||||
Note:
|
||||
Task state is held in an in-memory A2A task store for this version; it
|
||||
is independent of the host's session storage and is not persisted.
|
||||
"""
|
||||
|
||||
name: str = "a2a"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str | None = None,
|
||||
path: str = "",
|
||||
url: str = "/",
|
||||
agent_name: str | None = None,
|
||||
agent_description: str | None = None,
|
||||
agent_version: str = "1.0.0",
|
||||
agent_card: AgentCard | None = None,
|
||||
skills: Sequence[AgentSkill] | None = None,
|
||||
supported_interfaces: Sequence[AgentInterface] | None = None,
|
||||
streaming: bool = True,
|
||||
rpc_url: str = "/",
|
||||
card_url: str = "/.well-known/agent-card.json",
|
||||
run_hook: ChannelRunHook | None = None,
|
||||
response_hook: ChannelResponseHook | None = None,
|
||||
) -> None:
|
||||
"""Configure the A2A channel.
|
||||
|
||||
Keyword Args:
|
||||
name: Override the channel name (defaults to ``"a2a"``).
|
||||
path: Sub-path to mount the channel under; empty string (default)
|
||||
mounts the agent-card and JSON-RPC routes at the app root so
|
||||
the well-known card path is reachable.
|
||||
url: Public URL advertised in the agent card's interface (the base
|
||||
URL clients use to reach the JSON-RPC endpoint).
|
||||
agent_name: Name advertised in the default agent card. Defaults to
|
||||
the hosted target's name.
|
||||
agent_description: Description advertised in the default agent card.
|
||||
Defaults to the hosted target's description.
|
||||
agent_version: Version advertised in the default agent card.
|
||||
agent_card: A fully-specified agent card; when provided it takes
|
||||
precedence over the ``agent_*``/``url``/``skills`` fields.
|
||||
skills: Skills advertised in the default agent card.
|
||||
supported_interfaces: Interfaces advertised in the default agent card.
|
||||
Defaults to one JSON-RPC interface using ``url``.
|
||||
streaming: Consume the target via streaming and publish incremental
|
||||
A2A task artifacts (default ``True``).
|
||||
rpc_url: Path for the JSON-RPC endpoint (relative to ``path``).
|
||||
card_url: Path for the agent-card endpoint (relative to ``path``).
|
||||
run_hook: Optional run hook applied to each request.
|
||||
response_hook: Optional response hook applied to originating replies.
|
||||
"""
|
||||
if name is not None:
|
||||
self.name = name
|
||||
self.path = path
|
||||
self._url = url
|
||||
self._agent_name = agent_name
|
||||
self._agent_description = agent_description
|
||||
self._agent_version = agent_version
|
||||
self._agent_card = agent_card
|
||||
self._skills = list(skills) if skills is not None else []
|
||||
self._supported_interfaces = list(supported_interfaces) if supported_interfaces is not None else None
|
||||
self._streaming = streaming
|
||||
self._rpc_url = rpc_url
|
||||
self._card_url = card_url
|
||||
self._run_hook = run_hook
|
||||
self._response_hook = response_hook
|
||||
|
||||
def _build_agent_card(self, context: ChannelContext) -> AgentCard:
|
||||
"""Derive a default agent card from the hosted target, if not supplied."""
|
||||
if self._agent_card is not None:
|
||||
return self._agent_card
|
||||
target: Any = context.target
|
||||
name = self._agent_name or getattr(target, "name", None) or self.name
|
||||
description = self._agent_description or getattr(target, "description", None) or f"{name} (A2A)"
|
||||
return AgentCard(
|
||||
name=name,
|
||||
description=description,
|
||||
version=self._agent_version,
|
||||
default_input_modes=["text"],
|
||||
default_output_modes=["text"],
|
||||
capabilities=AgentCapabilities(streaming=self._streaming),
|
||||
supported_interfaces=self._supported_interfaces
|
||||
or [AgentInterface(url=self._url, protocol_binding="JSONRPC")],
|
||||
skills=self._skills,
|
||||
)
|
||||
|
||||
def contribute(self, context: ChannelContext) -> ChannelContribution:
|
||||
"""Build the A2A request handler and contribute its routes."""
|
||||
agent_card = self._build_agent_card(context)
|
||||
executor = HostAgentExecutor(
|
||||
context,
|
||||
channel_name=self.name,
|
||||
streaming=self._streaming,
|
||||
run_hook=self._run_hook,
|
||||
response_hook=self._response_hook,
|
||||
)
|
||||
handler = DefaultRequestHandler(
|
||||
agent_executor=executor,
|
||||
task_store=InMemoryTaskStore(),
|
||||
agent_card=agent_card,
|
||||
)
|
||||
routes = [
|
||||
*create_agent_card_routes(agent_card, card_url=self._card_url),
|
||||
*create_jsonrpc_routes(handler, self._rpc_url),
|
||||
]
|
||||
return ChannelContribution(routes=routes)
|
||||
@@ -1,195 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Host-routed A2A :class:`AgentExecutor`.
|
||||
|
||||
Unlike ``agent_framework_a2a.A2AExecutor`` (which calls ``agent.run`` directly
|
||||
and manages its own session), :class:`HostAgentExecutor` routes every incoming
|
||||
A2A request through the host pipeline via :class:`ChannelContext` — so host
|
||||
session resolution, request metadata, and run/response hooks all apply. The A2A
|
||||
``context_id`` maps onto :class:`ChannelSession` (caller-supplied session
|
||||
family).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
from asyncio import CancelledError
|
||||
from typing import Any, cast
|
||||
|
||||
from a2a.server.agent_execution import AgentExecutor, RequestContext
|
||||
from a2a.server.events import EventQueue
|
||||
from a2a.server.tasks import TaskUpdater
|
||||
from a2a.types import Part, Task, TaskState
|
||||
from agent_framework import Content
|
||||
from agent_framework_hosting import (
|
||||
ChannelContext,
|
||||
ChannelIdentity,
|
||||
ChannelRequest,
|
||||
ChannelResponseHook,
|
||||
ChannelRunHook,
|
||||
ChannelSession,
|
||||
logger,
|
||||
)
|
||||
|
||||
try:
|
||||
from a2a.helpers import new_task_from_user_message
|
||||
except ImportError: # pragma: no cover - older a2a-sdk layout
|
||||
from a2a.utils import new_task_from_user_message # type: ignore[no-redef, attr-defined, import-not-found]
|
||||
|
||||
_DATA_URI_PATTERN = re.compile(r"^data:(?P<media_type>[^;]+);base64,(?P<data>[A-Za-z0-9+/=]+)$")
|
||||
|
||||
|
||||
def _contents_to_parts(contents: list[Content]) -> list[Part]:
|
||||
"""Convert Agent Framework contents into A2A parts (text, uri, inline data)."""
|
||||
parts: list[Part] = []
|
||||
for content in contents:
|
||||
if content.type == "text" and content.text:
|
||||
parts.append(Part(text=content.text))
|
||||
elif content.type == "uri" and content.uri:
|
||||
parts.append(Part(url=content.uri, media_type=content.media_type or ""))
|
||||
elif content.type == "data" and content.uri:
|
||||
match = _DATA_URI_PATTERN.match(content.uri)
|
||||
if match is None:
|
||||
logger.warning("A2AChannel could not parse data URI; omitted.")
|
||||
continue
|
||||
parts.append(Part(raw=base64.b64decode(match.group("data")), media_type=content.media_type or ""))
|
||||
else:
|
||||
logger.warning("A2AChannel does not support content type: %s. Omitted.", content.type)
|
||||
return parts
|
||||
|
||||
|
||||
class HostAgentExecutor(AgentExecutor):
|
||||
"""A2A executor that drives the hosted target through :class:`ChannelContext`."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context: ChannelContext,
|
||||
*,
|
||||
channel_name: str,
|
||||
streaming: bool = True,
|
||||
run_hook: ChannelRunHook | None = None,
|
||||
response_hook: ChannelResponseHook | None = None,
|
||||
) -> None:
|
||||
"""Bind the executor to the host context.
|
||||
|
||||
Args:
|
||||
context: The host-supplied :class:`ChannelContext`.
|
||||
|
||||
Keyword Args:
|
||||
channel_name: The owning channel's name (stamped on requests).
|
||||
streaming: When ``True`` (default) the target is consumed via
|
||||
:meth:`ChannelContext.run_stream` and incremental updates are
|
||||
published as A2A task artifacts; otherwise the full reply is
|
||||
published as a single working-state message.
|
||||
run_hook: Optional :data:`ChannelRunHook` applied to the request.
|
||||
response_hook: Optional :data:`ChannelResponseHook` applied to the
|
||||
originating final response.
|
||||
"""
|
||||
super().__init__()
|
||||
self._ctx = context
|
||||
self._channel_name = channel_name
|
||||
self._streaming = streaming
|
||||
self._run_hook = run_hook
|
||||
self._response_hook = response_hook
|
||||
|
||||
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
|
||||
"""Publish a cancellation event for the in-flight task."""
|
||||
if context.context_id is None:
|
||||
raise ValueError("Context ID must be provided in the RequestContext")
|
||||
updater = TaskUpdater(event_queue, context.task_id or "", context.context_id)
|
||||
await updater.cancel()
|
||||
|
||||
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
|
||||
"""Route an A2A request through the host and publish task events."""
|
||||
if context.context_id is None:
|
||||
raise ValueError("Context ID must be provided in the RequestContext")
|
||||
if context.message is None:
|
||||
raise ValueError("Message must be provided in the RequestContext")
|
||||
|
||||
query = context.get_user_input()
|
||||
task: Task | None = context.current_task
|
||||
if not task:
|
||||
task = cast(Task, new_task_from_user_message(context.message)) # type: ignore[redundant-cast]
|
||||
await event_queue.enqueue_event(task)
|
||||
|
||||
task_id: str = task.id
|
||||
updater = TaskUpdater(event_queue, task_id, context.context_id)
|
||||
await updater.submit()
|
||||
|
||||
try:
|
||||
await updater.start_work()
|
||||
request = self._build_request(query, context, task_id)
|
||||
if request.stream:
|
||||
await self._run_stream(request, updater, protocol_request=context.message)
|
||||
else:
|
||||
await self._run(request, updater, protocol_request=context.message)
|
||||
await updater.complete()
|
||||
except CancelledError:
|
||||
await updater.update_status(state=TaskState.TASK_STATE_CANCELED)
|
||||
except Exception as exc:
|
||||
logger.exception("A2AChannel encountered an error during execution.")
|
||||
await updater.update_status(
|
||||
state=TaskState.TASK_STATE_FAILED,
|
||||
message=updater.new_agent_message([Part(text=str(exc))]),
|
||||
)
|
||||
|
||||
def _build_request(self, query: Any, context: RequestContext, task_id: str) -> ChannelRequest:
|
||||
"""Build the channel-neutral request from the A2A request context."""
|
||||
context_id = cast(str, context.context_id)
|
||||
return ChannelRequest(
|
||||
channel=self._channel_name,
|
||||
operation="message.create",
|
||||
input=query if isinstance(query, str) else str(query),
|
||||
session=ChannelSession(isolation_key=context_id),
|
||||
stream=self._streaming,
|
||||
identity=ChannelIdentity(channel=self._channel_name, native_id=context_id),
|
||||
attributes={"task_id": task_id},
|
||||
)
|
||||
|
||||
async def _run(self, request: ChannelRequest, updater: TaskUpdater, *, protocol_request: Any) -> None:
|
||||
"""Non-streaming: run the target and publish the reply as task messages."""
|
||||
result = await self._ctx.run(
|
||||
request,
|
||||
run_hook=self._run_hook,
|
||||
protocol_request=protocol_request,
|
||||
response_hook=self._response_hook,
|
||||
channel_name=self._channel_name,
|
||||
)
|
||||
response: Any = result.result
|
||||
messages: list[Any] = list(getattr(response, "messages", None) or [])
|
||||
for message in messages:
|
||||
if getattr(message, "role", None) == "user":
|
||||
continue
|
||||
contents: list[Content] = list(getattr(message, "contents", None) or [])
|
||||
parts = _contents_to_parts(contents)
|
||||
if parts:
|
||||
await updater.update_status(
|
||||
state=TaskState.TASK_STATE_WORKING,
|
||||
message=updater.new_agent_message(parts=parts),
|
||||
)
|
||||
|
||||
async def _run_stream(self, request: ChannelRequest, updater: TaskUpdater, *, protocol_request: Any) -> None:
|
||||
"""Streaming: publish incremental updates as task artifacts."""
|
||||
streamed_ids: set[str] = set()
|
||||
stream = await self._ctx.run_stream(
|
||||
request,
|
||||
run_hook=self._run_hook,
|
||||
protocol_request=protocol_request,
|
||||
response_hook=self._response_hook,
|
||||
channel_name=self._channel_name,
|
||||
)
|
||||
async for update in stream:
|
||||
contents: list[Content] = list(getattr(update, "contents", None) or [])
|
||||
parts = _contents_to_parts(contents)
|
||||
if not parts:
|
||||
continue
|
||||
message_id: str | None = getattr(update, "message_id", None)
|
||||
await updater.add_artifact(
|
||||
parts=parts,
|
||||
artifact_id=message_id,
|
||||
append=True if message_id is not None and message_id in streamed_ids else None,
|
||||
)
|
||||
if message_id is not None:
|
||||
streamed_ids.add(message_id)
|
||||
await stream.get_final_response()
|
||||
@@ -1,102 +0,0 @@
|
||||
[project]
|
||||
name = "agent-framework-hosting-a2a"
|
||||
description = "Agent-to-Agent (A2A) protocol channel for agent-framework-hosting."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260424"
|
||||
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 :: 3 - Alpha",
|
||||
"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 = [
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"agent-framework-hosting>=1.0.0a260424,<2",
|
||||
"a2a-sdk>=1.0.0,<2",
|
||||
"starlette>=0.37",
|
||||
]
|
||||
|
||||
[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.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
include = ["agent_framework_hosting_a2a"]
|
||||
exclude = ['tests']
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
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_hosting_a2a"]
|
||||
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_hosting_a2a"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_a2a --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
|
||||
[dependency-groups]
|
||||
dev = []
|
||||
@@ -1,309 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for :class:`A2AChannel` and :class:`HostAgentExecutor`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Awaitable
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
from a2a.server.events import EventQueue
|
||||
from a2a.types import AgentCard, AgentInterface, Message, Part, Role, Task, TaskState
|
||||
from agent_framework import AgentResponse, Content
|
||||
from agent_framework import Message as AFMessage
|
||||
from agent_framework_a2a import A2AAgent
|
||||
from agent_framework_hosting import AgentFrameworkHost, ChannelContribution, ChannelRequest, HostedRunResult
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from agent_framework_hosting_a2a import A2AChannel, HostAgentExecutor
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fakes #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeResp:
|
||||
text: str
|
||||
messages: list[Message] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeUpdate:
|
||||
text: str
|
||||
contents: list[Content] = field(default_factory=list)
|
||||
message_id: str | None = None
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
def __init__(self, chunks: list[str]) -> None:
|
||||
self._chunks = chunks
|
||||
self._final = _FakeResp(text="".join(chunks))
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[_FakeUpdate]:
|
||||
async def _gen() -> AsyncIterator[_FakeUpdate]:
|
||||
for i, c in enumerate(self._chunks):
|
||||
yield _FakeUpdate(text=c, contents=[Content.from_text(text=c)], message_id=f"m{i}")
|
||||
|
||||
return _gen()
|
||||
|
||||
async def get_final_response(self) -> _FakeResp:
|
||||
return self._final
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeTarget:
|
||||
name: str = "Assistant"
|
||||
description: str = "A helpful assistant."
|
||||
|
||||
|
||||
class _FakeContext:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
reply: str = "hello",
|
||||
chunks: list[str] | None = None,
|
||||
) -> None:
|
||||
self.target = _FakeTarget()
|
||||
self._reply = reply
|
||||
self._chunks = chunks or [reply]
|
||||
self.requests: list[ChannelRequest] = []
|
||||
|
||||
async def run(
|
||||
self,
|
||||
request: ChannelRequest,
|
||||
*,
|
||||
run_hook: Any | None = None,
|
||||
protocol_request: Any | None = None,
|
||||
response_hook: Any | None = None,
|
||||
channel_name: str | None = None,
|
||||
) -> HostedRunResult[Any]:
|
||||
if run_hook is not None:
|
||||
maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request)
|
||||
if isinstance(maybe_request, Awaitable):
|
||||
request = await maybe_request
|
||||
else:
|
||||
request = maybe_request
|
||||
self.requests.append(request)
|
||||
msg = Message(role=Role.ROLE_AGENT, parts=[Part(text=self._reply)])
|
||||
result = HostedRunResult(_FakeResp(text=self._reply, messages=[msg]))
|
||||
if response_hook is not None:
|
||||
maybe_result = response_hook(result, request=request, channel_name=channel_name or request.channel)
|
||||
if isinstance(maybe_result, Awaitable):
|
||||
return await maybe_result
|
||||
return maybe_result
|
||||
return result
|
||||
|
||||
async def run_stream(
|
||||
self,
|
||||
request: ChannelRequest,
|
||||
*,
|
||||
run_hook: Any | None = None,
|
||||
protocol_request: Any | None = None,
|
||||
stream_update_hook: Any | None = None,
|
||||
response_hook: Any | None = None,
|
||||
channel_name: str | None = None,
|
||||
) -> _FakeStream:
|
||||
if run_hook is not None:
|
||||
maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request)
|
||||
if isinstance(maybe_request, Awaitable):
|
||||
request = await maybe_request
|
||||
else:
|
||||
request = maybe_request
|
||||
self.requests.append(request)
|
||||
return _FakeStream(self._chunks)
|
||||
|
||||
|
||||
class _RecordingEventQueue(EventQueue):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.events: list[Any] = []
|
||||
|
||||
async def enqueue_event(self, event: Any) -> None:
|
||||
self.events.append(event)
|
||||
await super().enqueue_event(event)
|
||||
|
||||
|
||||
class _FakeRequestContext:
|
||||
def __init__(self, *, context_id: str, text: str, current_task: Task | None = None) -> None:
|
||||
self.context_id = context_id
|
||||
self.task_id: str | None = None
|
||||
self.message = Message(
|
||||
message_id="msg-1",
|
||||
context_id=context_id,
|
||||
role=Role.ROLE_USER,
|
||||
parts=[Part(text=text)],
|
||||
)
|
||||
self.current_task = current_task
|
||||
self._text = text
|
||||
|
||||
def get_user_input(self) -> str:
|
||||
return self._text
|
||||
|
||||
|
||||
class _HostedAgent:
|
||||
name = "HostedAssistant"
|
||||
description = "A hosted test assistant."
|
||||
|
||||
async def run(self, messages: Any = None, *, stream: bool = False, **_kwargs: Any) -> AgentResponse[Any]:
|
||||
text = messages.text if isinstance(messages, AFMessage) else str(messages)
|
||||
return AgentResponse(messages=[AFMessage(role="assistant", contents=[Content.from_text(text=f"host: {text}")])])
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _serve_app(app: ASGIApp, *, port: int) -> AsyncIterator[str]:
|
||||
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning", lifespan="on")
|
||||
server = uvicorn.Server(config)
|
||||
task = asyncio.create_task(server.serve())
|
||||
try:
|
||||
for _ in range(100):
|
||||
if server.started:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
else:
|
||||
raise RuntimeError("Test A2A server did not start")
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
finally:
|
||||
server.should_exit = True
|
||||
await task
|
||||
|
||||
|
||||
def _status_states(events: list[Any]) -> list[int]:
|
||||
states: list[int] = []
|
||||
for event in events:
|
||||
status = getattr(event, "status", None)
|
||||
if status is not None and getattr(status, "state", None):
|
||||
states.append(status.state)
|
||||
return states
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# A2AChannel tests #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_default_name_and_root_path() -> None:
|
||||
channel = A2AChannel()
|
||||
assert channel.name == "a2a"
|
||||
assert channel.path == ""
|
||||
|
||||
|
||||
def test_build_agent_card_defaults_from_target() -> None:
|
||||
channel = A2AChannel(url="https://example.com/")
|
||||
card = channel._build_agent_card(_FakeContext()) # type: ignore[arg-type]
|
||||
assert card.name == "Assistant"
|
||||
assert card.description == "A helpful assistant."
|
||||
assert card.capabilities.streaming is True
|
||||
assert card.supported_interfaces[0].url == "https://example.com/"
|
||||
|
||||
|
||||
def test_build_agent_card_accepts_supported_interfaces() -> None:
|
||||
interfaces = [
|
||||
AgentInterface(url="https://example.com/jsonrpc", protocol_binding="JSONRPC"),
|
||||
AgentInterface(url="https://example.com/grpc", protocol_binding="GRPC"),
|
||||
]
|
||||
channel = A2AChannel(supported_interfaces=interfaces)
|
||||
card = channel._build_agent_card(_FakeContext()) # type: ignore[arg-type]
|
||||
assert card.supported_interfaces == interfaces
|
||||
|
||||
|
||||
def test_build_agent_card_override_wins() -> None:
|
||||
custom = AgentCard(name="Custom", description="custom card", version="9.9.9")
|
||||
channel = A2AChannel(agent_card=custom)
|
||||
card = channel._build_agent_card(_FakeContext()) # type: ignore[arg-type]
|
||||
assert card.name == "Custom"
|
||||
assert card.version == "9.9.9"
|
||||
|
||||
|
||||
def test_contribute_returns_card_and_jsonrpc_routes() -> None:
|
||||
channel = A2AChannel(url="https://example.com/")
|
||||
contribution = channel.contribute(_FakeContext()) # type: ignore[arg-type]
|
||||
assert isinstance(contribution, ChannelContribution)
|
||||
paths = {getattr(r, "path", None) for r in contribution.routes}
|
||||
assert "/.well-known/agent-card.json" in paths
|
||||
assert any(p == "/" for p in paths)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# HostAgentExecutor tests #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_execute_routes_through_host_and_completes() -> None:
|
||||
ctx = _FakeContext(reply="hi back")
|
||||
executor = HostAgentExecutor(ctx, channel_name="a2a", streaming=False) # type: ignore[arg-type]
|
||||
queue = _RecordingEventQueue()
|
||||
request_context = _FakeRequestContext(context_id="conv-1", text="hello")
|
||||
|
||||
await executor.execute(request_context, queue) # type: ignore[arg-type]
|
||||
|
||||
# Routed through the host with the context id mapped onto the session.
|
||||
assert len(ctx.requests) == 1
|
||||
request = ctx.requests[0]
|
||||
assert request.channel == "a2a"
|
||||
assert request.input == "hello"
|
||||
assert request.session is not None
|
||||
assert request.session.isolation_key == "conv-1"
|
||||
assert request.identity is not None
|
||||
assert request.identity.native_id == "conv-1"
|
||||
# Task progressed to a completed state.
|
||||
assert TaskState.TASK_STATE_COMPLETED in _status_states(queue.events)
|
||||
|
||||
|
||||
async def test_execute_streaming_emits_artifacts() -> None:
|
||||
ctx = _FakeContext(chunks=["foo", "bar"])
|
||||
executor = HostAgentExecutor(ctx, channel_name="a2a", streaming=True) # type: ignore[arg-type]
|
||||
queue = _RecordingEventQueue()
|
||||
request_context = _FakeRequestContext(context_id="conv-2", text="hello")
|
||||
|
||||
await executor.execute(request_context, queue) # type: ignore[arg-type]
|
||||
|
||||
artifact_events = [e for e in queue.events if getattr(e, "artifact", None)]
|
||||
assert artifact_events, "expected at least one artifact update event"
|
||||
assert ctx.requests[0].stream is True
|
||||
assert TaskState.TASK_STATE_COMPLETED in _status_states(queue.events)
|
||||
|
||||
|
||||
async def test_execute_requires_context_id() -> None:
|
||||
ctx = _FakeContext()
|
||||
executor = HostAgentExecutor(ctx, channel_name="a2a") # type: ignore[arg-type]
|
||||
queue = _RecordingEventQueue()
|
||||
request_context = _FakeRequestContext(context_id="x", text="hello")
|
||||
request_context.context_id = None # type: ignore[assignment]
|
||||
|
||||
with pytest.raises(ValueError, match="Context ID"):
|
||||
await executor.execute(request_context, queue) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def test_a2a_agent_can_call_hosted_channel(unused_tcp_port: int) -> None:
|
||||
host = AgentFrameworkHost(target=_HostedAgent(), channels=[A2AChannel(streaming=False)])
|
||||
|
||||
async with (
|
||||
_serve_app(host.app, port=unused_tcp_port) as base_url,
|
||||
A2AAgent(
|
||||
url=base_url,
|
||||
timeout=5.0,
|
||||
) as agent,
|
||||
):
|
||||
response = await agent.run("hello")
|
||||
|
||||
assert response.messages[0].text == "host: hello"
|
||||
|
||||
|
||||
def test_contents_to_parts_conversion() -> None:
|
||||
from agent_framework_hosting_a2a._executor import _contents_to_parts
|
||||
|
||||
contents = [
|
||||
Content.from_text(text="hello"),
|
||||
Content.from_uri(uri="https://x/y.png", media_type="image/png"),
|
||||
Content.from_data(data=b"AAAA", media_type="image/png"),
|
||||
]
|
||||
parts = _contents_to_parts(contents)
|
||||
assert parts[0].text == "hello"
|
||||
assert parts[1].url == "https://x/y.png"
|
||||
assert parts[2].raw == b"AAAA"
|
||||
@@ -1,21 +0,0 @@
|
||||
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
|
||||
@@ -1,42 +0,0 @@
|
||||
# agent-framework-hosting-activity-protocol
|
||||
|
||||
Bot Framework **Activity Protocol** channel for
|
||||
[agent-framework-hosting](../hosting). Connects to **Azure Bot Service** so
|
||||
the same agent can be reached from Microsoft Teams, Slack, Webex,
|
||||
Telegram-via-bot-channel, and any other channel Azure Bot Service
|
||||
supports — without having to learn each channel's native protocol.
|
||||
|
||||
> Looking for a deeper Microsoft Teams integration with adaptive cards,
|
||||
> message extensions, dialogs, SSO, etc? That is intentionally separate from
|
||||
> this Activity Protocol channel, which focuses on Azure Bot Service
|
||||
> compatibility rather than Teams-specific affordances.
|
||||
|
||||
Handles inbound `message` activities, outbound replies, mid-stream
|
||||
`updateActivity` edits, typing indicators, and both client-secret and
|
||||
certificate credential modes for the outbound Bot Framework token.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from agent_framework_hosting import AgentFrameworkHost
|
||||
from agent_framework_hosting_activity_protocol import ActivityProtocolChannel
|
||||
|
||||
host = AgentFrameworkHost(
|
||||
target=my_agent,
|
||||
channels=[
|
||||
ActivityProtocolChannel(
|
||||
app_id="<entra app id>",
|
||||
client_secret="<entra client secret>",
|
||||
tenant_id="botframework.com", # or your tenant id
|
||||
)
|
||||
],
|
||||
)
|
||||
host.serve()
|
||||
```
|
||||
|
||||
For tenants that disallow client secrets, supply `certificate_path=` (and
|
||||
optionally `certificate_password=`) instead. See the docstring at the top of
|
||||
`_channel.py` for the openssl one-liner that generates a usable PEM.
|
||||
|
||||
In dev mode (no credentials), the channel skips outbound auth so the Bot
|
||||
Framework Emulator can hit the endpoint without setup.
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Bot Framework Activity Protocol channel for :mod:`agent_framework_hosting`."""
|
||||
|
||||
from ._channel import ActivityProtocolChannel, activity_protocol_isolation_key
|
||||
|
||||
__all__ = ["ActivityProtocolChannel", "activity_protocol_isolation_key"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user