mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47fa59f8e9 | ||
|
|
68357b0250 | ||
|
|
410268b624 | ||
|
|
67f3db6280 | ||
|
|
2ef20cd0aa | ||
|
|
27671974c2 | ||
|
|
7432105ebe | ||
|
|
3256550c55 | ||
|
|
190ca75b6a | ||
|
|
8058fb1c5b | ||
|
|
189e64bfdd | ||
|
|
3047ad3066 | ||
|
|
0e12640c70 | ||
|
|
ae666a4887 |
@@ -1,499 +0,0 @@
|
||||
# PR #5696 — Gap Analysis vs. the MSRC Session-Scoping Reports
|
||||
|
||||
This note evaluates how completely the changes in
|
||||
[PR #5696 — ".NET: Support ClaimsIdentity-based scoping of agent sessions"](https://github.com/microsoft/agent-framework/pull/5696)
|
||||
address two related MSRC reports against the .NET hosting layer:
|
||||
|
||||
- **Part A** — the *A2A persistence-default* report (the originally-filed
|
||||
variant): an A2A server, configured per the in-tree quickstart, persists
|
||||
sessions keyed only by the wire-supplied `contextId`, with no
|
||||
principal/owner dimension on the `AgentSessionStore` contract.
|
||||
- **Part B** — the *AG-UI `ThreadId` session-hijack* report: the AG-UI
|
||||
hosting endpoint trusts the client-supplied `RunAgentInput.ThreadId` as
|
||||
the sole session-lookup key.
|
||||
|
||||
Both reports are rated **Low / Defense-in-Depth** by MSRC because the
|
||||
default registered store is a no-op (`NoopAgentSessionStore`); the
|
||||
cross-user takeover only materializes when an integrator (a) registers a
|
||||
persistent store and (b) does not scope the conversation-id namespace by
|
||||
principal.
|
||||
|
||||
> **Design constraint set by the team (applies to both parts):**
|
||||
> flipping defaults so that the integrator is *required* to register a
|
||||
> `SessionIsolationKeyProvider` before persistence works is **explicitly
|
||||
> out of scope**. First-run / single-user / prototyping scenarios — where
|
||||
> there is no `ClaimsPrincipal`, no auth scheme, and no isolation
|
||||
> provider — must continue to work "out of the box". Any auto-isolation
|
||||
> mechanism must therefore degrade to a transparent no-op when no
|
||||
> principal context is configured.
|
||||
|
||||
---
|
||||
|
||||
## Part A — A2A persistence-default report
|
||||
|
||||
> Reconstructed from the prior session's notes; the verbatim text was not
|
||||
> committed to the repo. Findings re-verified against PR #5696 head.
|
||||
|
||||
### The reported issue
|
||||
|
||||
The A2A hosting layer's quickstart pattern —
|
||||
|
||||
```csharp
|
||||
services.AddAIAgent(...)
|
||||
.WithSessionStore(new InMemoryAgentSessionStore())
|
||||
.AsA2AServer();
|
||||
```
|
||||
|
||||
— produces a server in which any caller who knows (or guesses) another
|
||||
caller's `contextId` can resume that other caller's persisted thread,
|
||||
because:
|
||||
|
||||
1. `AgentSessionStore.GetSessionAsync(AIAgent agent, string conversationId, CancellationToken)`
|
||||
is the only lookup primitive; the contract carries no principal
|
||||
dimension.
|
||||
2. `A2AAgentHandler` resolves the keyed `AgentSessionStore` from DI and
|
||||
passes it directly to `AIHostAgent`, with the wire `contextId` flowing
|
||||
through unchanged.
|
||||
3. The persistent stores in-tree (`InMemoryAgentSessionStore`,
|
||||
sample-grade SQLite/Redis demos) treat `(agent, conversationId)` as a
|
||||
complete primary key.
|
||||
|
||||
Because the documented A2A quickstart leads developers straight at this
|
||||
shape, any multi-user A2A deployment that copies the quickstart inherits
|
||||
the cross-tenant takeover.
|
||||
|
||||
### How PR #5696 changes the A2A path
|
||||
|
||||
The PR introduces an isolation-key abstraction and **auto-wraps on the
|
||||
A2A code path** when an integrator opts in:
|
||||
|
||||
- `SessionIsolationKeyProvider` returns an opaque key from ambient
|
||||
context (the shipped impl reads `HttpContext.User`).
|
||||
- `IsolationKeyScopedAgentSessionStore` is a `DelegatingAgentSessionStore`
|
||||
that prefixes the inbound `conversationId` with `{escapedKey}::` before
|
||||
forwarding to the inner store. It carries a `Strict` knob:
|
||||
- `Strict = false` — when the provider returns no key (e.g. anonymous
|
||||
request, no auth scheme configured), the call falls through with the
|
||||
bare `conversationId`. This is the "out-of-box" mode.
|
||||
- `Strict = true` — a missing key throws / refuses the operation.
|
||||
- `A2AServerServiceCollectionExtensions.CreateA2AServer` resolves the
|
||||
keyed store, checks the `GetService<IsolationKeyScopedAgentSessionStore>()`
|
||||
service-locator chain, and wraps if needed with
|
||||
`Strict = isolationKeyProvider != null`.
|
||||
|
||||
Net effect on A2A:
|
||||
|
||||
| Scenario | Before PR | After PR |
|
||||
|---|---|---|
|
||||
| No persistent store registered | Ephemeral (`NoopAgentSessionStore`) | Unchanged — still ephemeral |
|
||||
| Persistent store, **no** `UseClaimsBasedSessionIsolation` | Single-namespace persistence (vulnerable in multi-user) | **Same single-namespace persistence** — wrapper installed but in `Strict = false` no-op pass-through, so the out-of-box first-run scenario is preserved |
|
||||
| Persistent store **plus** `services.UseClaimsBasedSessionIsolation(...)` | Still vulnerable (no wiring existed) | Auto-scoped per principal claim; missing claim → strict refusal |
|
||||
|
||||
### Item-by-item map of the A2A report's recommended fixes
|
||||
|
||||
| Recommended fix | Status |
|
||||
|---|---|
|
||||
| Add a principal/owner dimension to the persistence contract so multi-user safety is *expressible* without per-store rewrites | ✅ Achieved via decorator (`IsolationKeyScopedAgentSessionStore`) rather than a 3-arg `AgentSessionStore` virtual |
|
||||
| Make the safe path **engage automatically** on the canonical A2A registration shape when an isolation provider is configured | ✅ `CreateA2AServer` auto-wraps the resolved keyed store |
|
||||
| Preserve out-of-box single-user behaviour (no auth → still works) | ✅ `Strict = false` when no provider is registered; no behavioural change to the no-provider scenario |
|
||||
| Document the trust model on `AgentSessionStore` / `InMemoryAgentSessionStore` / the A2A quickstart so integrators understand what `(agent, conversationId)` *isn't* | ❌ Not addressed; XML on the legacy surfaces is unchanged |
|
||||
| Update the A2A end-to-end sample to model the safe pattern with a working auth scheme | ❌ Sample unchanged |
|
||||
|
||||
### Residual A2A gaps
|
||||
|
||||
- **A-G1.** Trust-model XML still missing on `AgentSessionStore`,
|
||||
`InMemoryAgentSessionStore`, and the A2A registration entry points.
|
||||
An integrator reading IntelliSense on the canonical pattern is not
|
||||
prompted toward `UseClaimsBasedSessionIsolation`.
|
||||
- **A-G2.** No A2A sample exercises the safe configuration end-to-end.
|
||||
- **A-G3.** Same `conversationId`-mutation contract caveats as Part B
|
||||
(B-G3 below): inner stores see a rewritten id; logging/telemetry sinks
|
||||
will leak the isolation key; stores with id-shape constraints must
|
||||
accept escaped keys of arbitrary length.
|
||||
|
||||
The headline A2A finding (the *handler-level* auto-wrap when a provider
|
||||
is registered) **is** addressed by this PR, within the agreed
|
||||
constraint. What remains for A2A is documentation and samples.
|
||||
|
||||
---
|
||||
|
||||
## Part B — AG-UI `ThreadId` session-hijack report
|
||||
|
||||
> The Microsoft Agent Framework AG-UI hosting endpoint (.NET) trusts the
|
||||
> client-supplied `RunAgentInput.ThreadId` as the sole key for persisted
|
||||
> session lookup. […] The `AgentSessionStore` abstract contract carries
|
||||
> only `(AIAgent agent, string conversationId)` with no principal/owner
|
||||
> parameter, making caller-scoped sessions architecturally impossible
|
||||
> without a custom implementation.
|
||||
|
||||
The MSRC severity rationale is *Low / Defense in Depth* — the default
|
||||
AG-UI store is `NoopAgentSessionStore`, so the cross-user takeover only
|
||||
materializes when the integrator (a) registers a persistent store and
|
||||
(b) does not scope the thread-ID namespace by principal. The framework's
|
||||
job here is to make the safe path obvious and to document the trust
|
||||
assumption.
|
||||
|
||||
### What the PR delivers (AG-UI-relevant pieces only)
|
||||
|
||||
- `SessionIsolationKeyProvider` abstraction
|
||||
(`dotnet/src/Microsoft.Agents.AI.Hosting/SessionIsolationKeyProvider.cs`).
|
||||
- `IsolationKeyScopedAgentSessionStore` decorator that prefixes
|
||||
`conversationId` with `{escapedKey}::` before forwarding to the inner
|
||||
store, with a `Strict` knob.
|
||||
- `DelegatingAgentSessionStore` base + `AgentSessionStore.GetService(...)`
|
||||
service-locator hooks so wrappers can be discovered in a chain.
|
||||
- `Microsoft.Agents.AI.Hosting.AspNetCore` package shipping
|
||||
`ClaimsIdentitySessionIsolationKeyProvider` and
|
||||
`services.UseClaimsBasedSessionIsolation(...)`.
|
||||
- Auto-wrap on the **`HostedAgentBuilderExtensions.WithSessionStore(...)`**
|
||||
path: stores registered through `WithSessionStore(...)` /
|
||||
`WithInMemorySessionStore(...)` are wrapped in
|
||||
`IsolationKeyScopedAgentSessionStore` by default
|
||||
(`withIsolation: true`). When no provider is registered, the wrapper
|
||||
is constructed with `Strict = false` so it passes the bare
|
||||
`conversationId` through — i.e. the "out-of-box first-run" constraint
|
||||
above is honoured.
|
||||
- AG-UI sample
|
||||
(`dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs`)
|
||||
gets a single commented reminder pointing at
|
||||
`UseClaimsBasedSessionIsolation`.
|
||||
|
||||
### Item-by-item map of MSRC's recommended fixes to the PR
|
||||
|
||||
#### MSRC Fix #1 — Strengthen XML doc on `MapAGUI`, `InMemoryAgentSessionStore`, `AgentSessionStore` to state the trust model when a persistent store is in play
|
||||
|
||||
**Status: ❌ Not addressed on the named surfaces.**
|
||||
|
||||
- `AGUIEndpointRouteBuilderExtensions.MapAGUI(...)` doc
|
||||
(`dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs:70-76`)
|
||||
still reads:
|
||||
> If an `AgentSessionStore` is registered in dependency injection
|
||||
> keyed by the agent's name, it will be used to persist conversation
|
||||
> sessions across requests using the AG-UI thread ID as the
|
||||
> conversation identifier. If no session store is registered, sessions
|
||||
> are ephemeral (not persisted).
|
||||
No mention of the trust assumption (thread-id = chain-resume token, not
|
||||
an authorization token; multi-user hosts must scope by principal).
|
||||
- `InMemoryAgentSessionStore` XML
|
||||
(`dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs:13-27`)
|
||||
warns about restart loss but says nothing about the multi-user
|
||||
takeover risk.
|
||||
- `AgentSessionStore` base class XML
|
||||
(`dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs`) only
|
||||
describes the persistence contract; it does not call out that
|
||||
`(agent, conversationId)` carries no principal dimension and that
|
||||
custom implementations must compose one for multi-user hosts.
|
||||
|
||||
The new types (`IsolationKeyScopedAgentSessionStore`,
|
||||
`SessionIsolationKeyProvider`, `ClaimsIdentitySessionIsolationKeyProvider`,
|
||||
`UseClaimsBasedSessionIsolation`) are themselves well documented, but
|
||||
nothing on the legacy IntelliSense path prompts the integrator to look
|
||||
for them.
|
||||
|
||||
#### MSRC Fix #2 — Compose principal into the conversation key inside the AG-UI handler when a `ClaimsPrincipal` is available, gated by a versioned opt-in (e.g. `MapAGUIOptions { ScopeSessionsByPrincipal = true }`)
|
||||
|
||||
**Status: ⚠️ Partially addressed; opt-in path exists but does not cover
|
||||
the canonical AG-UI registration shape.**
|
||||
|
||||
The PR does *not* edit the AG-UI handler to read `HttpContext.User`. It
|
||||
takes a different architectural shape: the scoping is done by a
|
||||
decorator inserted around the store. That decorator must actually be
|
||||
present in the chain that `MapAGUI` resolves, which means one of these
|
||||
must be true:
|
||||
|
||||
1. The integrator registered the store via the
|
||||
`IHostedAgentBuilder.WithSessionStore(...)` / `WithInMemorySessionStore(...)`
|
||||
helper. Those auto-wrap with `IsolationKeyScopedAgentSessionStore`
|
||||
(the new `withIsolation: true` default).
|
||||
2. The integrator manually composes
|
||||
`new IsolationKeyScopedAgentSessionStore(innerStore, provider)` and
|
||||
registers that as the keyed store.
|
||||
|
||||
But the **AG-UI handler resolves the store directly from DI**:
|
||||
|
||||
```csharp
|
||||
// AGUIEndpointRouteBuilderExtensions.cs:85-86
|
||||
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(aiAgent.Name);
|
||||
var hostAgent = new AIHostAgent(aiAgent, agentSessionStore ?? new NoopAgentSessionStore());
|
||||
```
|
||||
|
||||
So an integrator who follows the most natural "I want persistence" line —
|
||||
|
||||
```csharp
|
||||
services.AddKeyedSingleton<AgentSessionStore>(agentName, new InMemoryAgentSessionStore());
|
||||
// + services.UseClaimsBasedSessionIsolation(...);
|
||||
```
|
||||
|
||||
— still gets **no scoping on AG-UI**, even with an isolation provider
|
||||
registered, because the keyed store goes straight into `AIHostAgent`
|
||||
without ever passing through `WithSessionStore`'s wrapping logic. That
|
||||
is precisely the precondition pattern the MSRC report calls out. (See
|
||||
gap **B-G1** below.)
|
||||
|
||||
There is also no `MapAGUIOptions` / no MapAGUI-level opt-in flag of the
|
||||
shape MSRC suggested. Discoverability of the safe path lives entirely on
|
||||
a separate `services.UseClaimsBasedSessionIsolation(...)` extension and
|
||||
on whether the integrator happened to use the right store-registration
|
||||
helper.
|
||||
|
||||
#### MSRC Fix #3 (optional) — Add a principal-aware overload to `AgentSessionStore` so custom stores can scope by principal without injecting `IHttpContextAccessor`
|
||||
|
||||
**Status: ✅ Achieved via decorator instead of a virtual overload.**
|
||||
|
||||
The PR does not add a 3-arg `GetSessionAsync(agent, conversationId, scopeId, ct)`
|
||||
virtual on `AgentSessionStore`. Instead, scope composition is done
|
||||
upstream by `IsolationKeyScopedAgentSessionStore`, and the inner store
|
||||
sees a mutated `conversationId` of the form
|
||||
`{escapedKey}::{conversationId}`. This achieves the *outcome* MSRC asked
|
||||
for (custom stores no longer need `IHttpContextAccessor`) but with two
|
||||
documented-by-implication trade-offs noted in **B-G3** below.
|
||||
|
||||
### Remaining gaps for the AG-UI threat scenario
|
||||
|
||||
#### B-G1. `MapAGUI` does not auto-wrap the keyed store; safe path requires a non-obvious registration shape
|
||||
|
||||
This is the headline gap relative to the MSRC AG-UI report. The
|
||||
isolation wrapper is auto-installed only on the
|
||||
`HostedAgentBuilder.WithSessionStore(...)` path and on the
|
||||
A2A `CreateA2AServer` code path. `MapAGUI` reads the keyed
|
||||
`AgentSessionStore` from DI directly
|
||||
(`AGUIEndpointRouteBuilderExtensions.cs:85`) and never inspects whether
|
||||
an `IsolationKeyScopedAgentSessionStore` is in the chain.
|
||||
|
||||
Concrete consequence: the canonical "I want persistent AG-UI sessions"
|
||||
snippet —
|
||||
|
||||
```csharp
|
||||
services.AddKeyedSingleton<AgentSessionStore>("myAgent", new InMemoryAgentSessionStore());
|
||||
services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
endpoints.MapAGUI("myAgent", "/ag-ui");
|
||||
```
|
||||
|
||||
does *not* scope by principal. The integrator must additionally know to
|
||||
either (a) register their store via the `WithSessionStore` builder
|
||||
helper, or (b) hand-compose
|
||||
`new IsolationKeyScopedAgentSessionStore(inner, provider)` themselves.
|
||||
|
||||
**Suggested closure**: in `MapAGUI` (mirroring the A2A pattern), if the
|
||||
resolved store does not already expose
|
||||
`IsolationKeyScopedAgentSessionStore` via the new `GetService(...)`
|
||||
chain, wrap it. Use the same `Strict = isolationKeyProvider != null`
|
||||
construction the A2A path uses, so the no-provider case stays a
|
||||
transparent no-op (preserving the out-of-box constraint).
|
||||
|
||||
#### B-G2. Trust-model docs missing on the three legacy AG-UI surfaces (MSRC Fix #1)
|
||||
|
||||
`MapAGUI`, `InMemoryAgentSessionStore`, and `AgentSessionStore` need the
|
||||
explicit "thread id is a chain-resume identifier, not an authorization
|
||||
token; multi-user hosts must scope sessions by principal" wording, with
|
||||
a pointer to `UseClaimsBasedSessionIsolation` /
|
||||
`IsolationKeyScopedAgentSessionStore`. Today nothing on those surfaces
|
||||
flags the risk or points readers at the new safe-path APIs.
|
||||
|
||||
#### B-G3. Decorator approach mutates `conversationId` seen by inner stores, undocumented
|
||||
|
||||
`IsolationKeyScopedAgentSessionStore` rewrites the `conversationId` to
|
||||
`{escapedKey}::{conversationId}` before forwarding. Two things follow
|
||||
that custom-store authors would benefit from being told:
|
||||
|
||||
1. Stores that log, echo, or surface `conversationId` (telemetry,
|
||||
audit, error messages) will leak the isolation key into those sinks.
|
||||
2. Stores with constraints on conversation-id shape (length, allowed
|
||||
characters, URL-safety, hashing) must accept `\\`/`\:`-escaped keys
|
||||
of arbitrary length.
|
||||
|
||||
A short note on `IsolationKeyScopedAgentSessionStore` (and ideally on
|
||||
`AgentSessionStore` itself — "implementations should treat
|
||||
`conversationId` as opaque") would close this.
|
||||
|
||||
#### B-G4. Only one provider implementation ships; `ClaimType` lookup is single-claim only
|
||||
|
||||
The MSRC suggested fix uses
|
||||
`ClaimTypes.NameIdentifier ?? Identity.Name` as the fallback chain.
|
||||
`ClaimsIdentitySessionIsolationKeyProvider` only resolves a single
|
||||
configured `ClaimType` and does not fall back to `Identity.Name`. That
|
||||
will silently produce a `null` key (and, in the non-strict default
|
||||
configuration on the A2A auto-wrap path, a silent no-op) for valid
|
||||
authenticated identities that don't carry the configured claim. A
|
||||
second-chance fallback list, or shipping a small set of common
|
||||
providers (e.g. `Identity.Name`, an HTTP-header provider, an mTLS
|
||||
subject provider), would close the discoverability gap on the safe
|
||||
path.
|
||||
|
||||
#### B-G5. AG-UI sample is a commented reminder, not a working safe configuration
|
||||
|
||||
`samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs` adds a
|
||||
single commented-out line. The sample's runtime behaviour is unchanged;
|
||||
copy-paste users still land on the unscoped path. At least one AG-UI
|
||||
sample should compile and run with `UseClaimsBasedSessionIsolation`
|
||||
plus a working auth scheme so the safe pattern is shown end-to-end. The
|
||||
sample also needs to demonstrate registering the store through
|
||||
`WithSessionStore(...)` rather than via raw `AddKeyedSingleton` so that
|
||||
the decorator chain actually engages — unless **B-G1** is fixed first.
|
||||
|
||||
#### B-G6. Asymmetry between A2A and AG-UI auto-wrap behaviour
|
||||
|
||||
`A2AServerServiceCollectionExtensions.CreateA2AServer` was edited to
|
||||
auto-wrap any keyed store with `IsolationKeyScopedAgentSessionStore`
|
||||
when one isn't already in the chain
|
||||
(`A2AServerServiceCollectionExtensions.cs:140-146` after the PR).
|
||||
The equivalent change was *not* made on the AG-UI endpoint
|
||||
extension, so the two hosting layers now have asymmetric behaviour:
|
||||
isolation kicks in automatically on A2A but only on AG-UI when the
|
||||
integrator routes their store through the builder helpers. This
|
||||
re-creates, in a different shape, the "asymmetric defaults are a
|
||||
perpetual source of integrator confusion" anti-pattern that MSRC
|
||||
called out for the persistence default. (Note: this is **not** a
|
||||
"flip the default" ask — it's a "make the two hosting layers behave
|
||||
the same when an isolation provider is registered" ask. The
|
||||
out-of-box no-provider behaviour stays unchanged.)
|
||||
|
||||
### Summary scorecard (AG-UI threat surface)
|
||||
|
||||
| MSRC ask | Status |
|
||||
|---|---|
|
||||
| #1 Trust-model XML on `MapAGUI`, `InMemoryAgentSessionStore`, `AgentSessionStore` | ❌ Not addressed |
|
||||
| #2 Principal-scoping inside the AG-UI handler (versioned opt-in) | ⚠️ Equivalent opt-in mechanism exists (`UseClaimsBasedSessionIsolation` + decorator), but the AG-UI handler does not engage it for stores registered the canonical way |
|
||||
| #3 Optional principal-aware store overload | ✅ Achieved via decorator (with caveats — see B-G3) |
|
||||
| AG-UI sample shows safe pattern | ⚠️ Commented reminder only |
|
||||
| Symmetric behaviour with the A2A hosting layer | ❌ A2A auto-wraps in handler; AG-UI does not |
|
||||
|
||||
---
|
||||
|
||||
## Part C — Synthesis across the two reports
|
||||
|
||||
The A2A and AG-UI reports describe the **same root cause** at the
|
||||
abstraction layer (`AgentSessionStore` only knows
|
||||
`(agent, conversationId)`; the wire-supplied id is the entire lookup
|
||||
key) reached through two different hosting surfaces. Read together they
|
||||
say:
|
||||
|
||||
1. **The contract gap is real, and the PR closes it correctly.** Adding
|
||||
a principal dimension by *decoration* (`IsolationKeyScopedAgentSessionStore`)
|
||||
rather than by adding a third parameter to every `AgentSessionStore`
|
||||
override was the right call: it keeps the contract source-compatible,
|
||||
it stacks naturally with future scoping dimensions (tenant, session
|
||||
tag), and it lets a single isolation provider cover every hosting
|
||||
surface without per-surface plumbing.
|
||||
2. **"Auto-wrap when the integrator opts in" is the right shape under
|
||||
the team's design constraint.** The PR does not change defaults: with
|
||||
no `UseClaimsBasedSessionIsolation` registered, both A2A and the
|
||||
builder path remain bit-for-bit compatible. The wrapper engages only
|
||||
when an isolation provider is in the container, and even then defers
|
||||
to `Strict = false` when the provider returns no key — so the
|
||||
first-run / single-user / no-auth scenario continues to function.
|
||||
3. **Coverage is asymmetric across hosting surfaces.** The PR added the
|
||||
handler-level auto-wrap to the A2A path (`CreateA2AServer`) and to
|
||||
the agent-builder path (`WithSessionStore`), but **not** to the
|
||||
AG-UI handler (`MapAGUI`). The result: an integrator who follows
|
||||
the canonical AG-UI snippet
|
||||
(`AddKeyedSingleton<AgentSessionStore>(name, store)` +
|
||||
`endpoints.MapAGUI(name, "/ag-ui")`) plus
|
||||
`UseClaimsBasedSessionIsolation(...)` still gets an unscoped store.
|
||||
This is the headline residual issue (**B-G1 / B-G6**) and is also
|
||||
the reason the AG-UI report exists as a separate finding even after
|
||||
the A2A path is fixed.
|
||||
4. **Documentation and samples lag the runtime fix on both surfaces.**
|
||||
`AgentSessionStore`, `InMemoryAgentSessionStore`, the A2A quickstart,
|
||||
and the AG-UI quickstart all still describe the persistence contract
|
||||
without flagging the trust assumption that `(agent, conversationId)`
|
||||
carries no principal dimension and that multi-user hosts must compose
|
||||
one. The new types are well-documented, but nothing on the legacy
|
||||
IntelliSense path points readers at them. A second-time reader of
|
||||
either MSRC report could read the entire surface and miss the safe
|
||||
path (**A-G1 / B-G2**).
|
||||
5. **One shared caveat applies to both surfaces.** Because scoping is
|
||||
done by rewriting `conversationId` to `{escapedKey}::{conversationId}`
|
||||
before forwarding, any inner store that logs, echoes, audits, or
|
||||
constrains the shape of `conversationId` is silently affected. This
|
||||
needs to be a documented contract on `IsolationKeyScopedAgentSessionStore`
|
||||
(and ideally a one-line "treat `conversationId` as opaque" note on
|
||||
`AgentSessionStore` itself). It applies identically to A2A
|
||||
(**A-G3**) and AG-UI (**B-G3**).
|
||||
|
||||
### Combined scorecard
|
||||
|
||||
| MSRC ask (collapsed across both reports) | Status |
|
||||
|---|---|
|
||||
| Make principal-scoping *expressible* without rewriting every store | ✅ Decorator (`IsolationKeyScopedAgentSessionStore`) |
|
||||
| Auto-engage scoping on the **A2A** canonical registration shape when an isolation provider is present | ✅ `CreateA2AServer` wraps |
|
||||
| Auto-engage scoping on the **AG-UI** canonical registration shape when an isolation provider is present | ❌ `MapAGUI` does not wrap |
|
||||
| Preserve out-of-box single-user/no-auth behaviour | ✅ `Strict = false` when no provider; `NoopAgentSessionStore` default unchanged |
|
||||
| Trust-model XML on `AgentSessionStore`, `InMemoryAgentSessionStore`, `MapAGUI`, A2A entry points | ❌ Not addressed |
|
||||
| Document the `conversationId`-mutation contract the decorator imposes | ❌ Not addressed |
|
||||
| Ship enough provider variants to be discoverable beyond a single Claims case | ⚠️ Only `ClaimsIdentitySessionIsolationKeyProvider`, single-claim, no `Identity.Name` fallback |
|
||||
| Working safe-path samples for both A2A and AG-UI | ❌ AG-UI sample is a commented reminder; no A2A sample update |
|
||||
|
||||
---
|
||||
|
||||
## Part D — Next steps (respecting "out-of-box default must keep working")
|
||||
|
||||
All of the following preserve the design constraint: **with no
|
||||
isolation provider registered, behaviour must not change** — same
|
||||
default store (`NoopAgentSessionStore`), same wire contract, same
|
||||
prototype-friendly first-run experience. None of them require an
|
||||
integrator to register auth or an isolation provider before persistence
|
||||
works.
|
||||
|
||||
Listed in priority order, with the closure mapping back to the gap IDs
|
||||
above.
|
||||
|
||||
1. **Mirror the A2A auto-wrap inside `MapAGUI`.** In the AG-UI endpoint
|
||||
extension, after resolving the keyed `AgentSessionStore`, walk the
|
||||
`GetService<IsolationKeyScopedAgentSessionStore>()` chain; if it is
|
||||
absent, wrap with `Strict = isolationKeyProvider != null`. The
|
||||
`Strict = false` branch keeps the "no provider registered → bare
|
||||
`conversationId` passes through" behaviour the constraint requires.
|
||||
The `Strict = true` branch engages only when the integrator has
|
||||
already opted in by calling `UseClaimsBasedSessionIsolation(...)`.
|
||||
*Closes B-G1 and B-G6 (asymmetry); produces no out-of-box behaviour
|
||||
change.*
|
||||
|
||||
2. **Add the trust-model XML paragraph to the five legacy surfaces.**
|
||||
`AgentSessionStore`, `InMemoryAgentSessionStore`, `MapAGUI`, the A2A
|
||||
`CreateA2AServer` / `AsA2AServer` entry points. The text needs to
|
||||
say: (a) `conversationId` / `ThreadId` / `contextId` arrives from
|
||||
the wire and is not an authorization token; (b) `(agent, conversationId)`
|
||||
has no principal dimension, so persistent stores are single-namespace
|
||||
by default; (c) multi-user hosts should compose one via
|
||||
`UseClaimsBasedSessionIsolation` or a custom
|
||||
`SessionIsolationKeyProvider`. Pure docs change, no behaviour
|
||||
impact. *Closes A-G1, B-G2.*
|
||||
|
||||
3. **Document the `conversationId`-mutation contract** on
|
||||
`IsolationKeyScopedAgentSessionStore` and add a one-liner on
|
||||
`AgentSessionStore` saying inner stores must treat `conversationId`
|
||||
as opaque (don't parse, don't impose length/charset constraints,
|
||||
expect logs/telemetry to surface it verbatim). *Closes A-G3, B-G3.*
|
||||
|
||||
4. **Convert the AG-UI sample into a working safe configuration** (and
|
||||
add an equivalent slice to one A2A sample). The sample should
|
||||
register an auth scheme, call `UseClaimsBasedSessionIsolation`, and
|
||||
register the store via the canonical endpoint shape — so once
|
||||
step 1 lands, copy-paste users land on the safe path automatically.
|
||||
The unsafe path must still be reachable (constraint), but the
|
||||
in-tree sample shouldn't model it. *Closes A-G2 follow-up, B-G5.*
|
||||
|
||||
5. **Widen the provider story.** Either extend
|
||||
`ClaimsIdentitySessionIsolationKeyProvider` to take an ordered
|
||||
fallback chain (e.g. `ClaimTypes.NameIdentifier` → `Identity.Name`),
|
||||
or ship one or two additional providers (HTTP-header,
|
||||
mTLS-subject) so the safe path is discoverable beyond a single
|
||||
claims-based deployment. Whichever shape is chosen must continue to
|
||||
return `null` for anonymous requests so the `Strict = false`
|
||||
pass-through semantics keep holding. *Closes B-G4.*
|
||||
|
||||
### What is explicitly **not** proposed
|
||||
|
||||
- Flipping the default registered store from `NoopAgentSessionStore` to
|
||||
any persistent default — out of scope per the constraint.
|
||||
- Requiring `UseClaimsBasedSessionIsolation` (or any
|
||||
`SessionIsolationKeyProvider`) before persistence works — out of
|
||||
scope per the constraint.
|
||||
- Adding a third `scopeId` parameter to `AgentSessionStore` virtuals
|
||||
(would force every existing custom store to be recompiled and
|
||||
re-implemented). The decorator approach already covers this case;
|
||||
the only ask is a documented contract on inner stores.
|
||||
- Treating an authenticated request without the configured claim as a
|
||||
hard error in the no-isolation-provider scenario — only applies when
|
||||
the integrator opted into `Strict = true` by registering a provider.
|
||||
@@ -242,6 +242,7 @@
|
||||
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InputArguments/InputArguments.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
|
||||
@@ -590,7 +591,6 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<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.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
|
||||
|
||||
@@ -478,6 +478,17 @@ internal static class WorkflowSamples
|
||||
ExpectedOutputDescription = ["The output should show a workflow invoking a function tool (e.g. a menu plugin) to answer a question about the soup of the day."],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Workflow_Declarative_InvokeFoundryToolboxMcp",
|
||||
ProjectPath = "samples/03-workflows/Declarative/InvokeFoundryToolboxMcp",
|
||||
RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME", "FOUNDRY_TOOLBOX_NAME", "FOUNDRY_AGENT_TOOLSET_API_VERSION"],
|
||||
Inputs = ["How do I use Azure OpenAI with my data?"],
|
||||
InputDelayMs = 3000,
|
||||
ExpectedOutputDescription = ["The output should show a workflow using Foundry Toolbox MCP tools to search Microsoft Learn documentation and web search to provide a summary of results."],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Workflow_Declarative_InvokeMcpTool",
|
||||
|
||||
@@ -9,42 +9,30 @@ namespace Harness.ConsoleReactiveComponents;
|
||||
/// </summary>
|
||||
public record TextPanelProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the items to render in the panel.</summary>
|
||||
public IReadOnlyList<object> Items { get; init; } = [];
|
||||
/// <summary>Gets the items to render in the panel. Each item is a pre-rendered
|
||||
/// console string (may include ANSI escape sequences and newlines).</summary>
|
||||
public IReadOnlyList<string> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a list of items vertically using a custom render delegate.
|
||||
/// A component that renders a list of pre-rendered string items vertically.
|
||||
/// Designed for rendering dynamic items in a non-scroll region that may be
|
||||
/// re-rendered on each update. If the component's <see cref="ConsoleReactiveComponent.Height"/>
|
||||
/// exceeds the number of output lines, leftover lines are erased.
|
||||
/// </summary>
|
||||
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
|
||||
{
|
||||
private readonly Func<object, string> _renderItem;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TextPanel"/> class.
|
||||
/// </summary>
|
||||
/// <param name="renderItem">A delegate that renders an item and returns the text to display (may contain newlines).</param>
|
||||
public TextPanel(Func<object, string> renderItem)
|
||||
{
|
||||
this._renderItem = renderItem;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the height (in lines) needed to render all items.
|
||||
/// </summary>
|
||||
/// <param name="items">The items to measure.</param>
|
||||
/// <param name="renderItem">The render delegate to use for measuring.</param>
|
||||
/// <returns>The total number of lines all items will occupy.</returns>
|
||||
public static int CalculateHeight(IReadOnlyList<object> items, Func<object, string> renderItem)
|
||||
public static int CalculateHeight(IReadOnlyList<string> items)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
{
|
||||
string text = renderItem(items[i]);
|
||||
total += CountLines(text);
|
||||
total += CountLines(items[i]);
|
||||
}
|
||||
|
||||
return total;
|
||||
@@ -57,7 +45,7 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
|
||||
|
||||
for (int i = 0; i < props.Items.Count; i++)
|
||||
{
|
||||
string text = this._renderItem(props.Items[i]);
|
||||
string text = props.Items[i];
|
||||
string[] lines = text.Split('\n');
|
||||
int lineCount = CountLines(text);
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@ namespace Harness.ConsoleReactiveComponents;
|
||||
/// </summary>
|
||||
public record TextScrollPanelProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the items to render in the scroll panel.</summary>
|
||||
public IReadOnlyList<object> Items { get; init; } = [];
|
||||
/// <summary>Gets the items to render in the scroll panel. Each item is a pre-rendered
|
||||
/// console string (may include ANSI escape sequences and newlines).</summary>
|
||||
public IReadOnlyList<string> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -20,21 +21,17 @@ public record TextScrollPanelProps : ConsoleReactiveProps
|
||||
public record TextScrollPanelState(int RenderedCount = 0) : ConsoleReactiveState;
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders items within a scroll area using a custom render delegate.
|
||||
/// A component that renders pre-rendered string items within a scroll area.
|
||||
/// All items are considered finalized — only new items since the last render are output.
|
||||
/// Use <see cref="Reset"/> to force a full re-render.
|
||||
/// </summary>
|
||||
public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, TextScrollPanelState>
|
||||
{
|
||||
private readonly Func<object, string> _renderItem;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TextScrollPanel"/> class.
|
||||
/// </summary>
|
||||
/// <param name="renderItem">A delegate that renders a single item and returns the text to display (may contain newlines).</param>
|
||||
public TextScrollPanel(Func<object, string> renderItem)
|
||||
public TextScrollPanel()
|
||||
{
|
||||
this._renderItem = renderItem;
|
||||
this.State = new TextScrollPanelState();
|
||||
}
|
||||
|
||||
@@ -60,8 +57,7 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
|
||||
// Output only new items since last rendered
|
||||
for (int i = state.RenderedCount; i < props.Items.Count; i++)
|
||||
{
|
||||
string text = this._renderItem(props.Items[i]);
|
||||
Console.Write(text);
|
||||
Console.Write(props.Items[i]);
|
||||
}
|
||||
|
||||
// Update state to track what we've rendered
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.ConsoleSandbox;
|
||||
|
||||
/// <summary>
|
||||
/// Determines which component is shown in the bottom panel.
|
||||
/// </summary>
|
||||
public enum BottomPanelMode
|
||||
{
|
||||
/// <summary>Show the list selection component.</summary>
|
||||
ListSelection,
|
||||
|
||||
/// <summary>Show the text input component.</summary>
|
||||
TextInput
|
||||
}
|
||||
|
||||
public record AppComponentProps : ConsoleReactiveProps
|
||||
{
|
||||
public IReadOnlyList<string> Items { get; init; } = Array.Empty<string>();
|
||||
public IReadOnlyList<object> ScrollItems { get; init; } = [];
|
||||
|
||||
/// <summary>Gets the bottom panel mode.</summary>
|
||||
public BottomPanelMode Mode { get; init; } = BottomPanelMode.ListSelection;
|
||||
|
||||
/// <summary>Gets the prompt string for text input mode.</summary>
|
||||
public string Prompt { get; init; } = "> ";
|
||||
|
||||
/// <summary>Gets the placeholder text shown when the input is empty.</summary>
|
||||
public string Placeholder { get; init; } = "";
|
||||
|
||||
/// <summary>Gets the highlight color for the active list item. Defaults to <see cref="ConsoleColor.Cyan"/>.</summary>
|
||||
public ConsoleColor ListHighlightColor { get; init; } = ConsoleColor.Cyan;
|
||||
|
||||
/// <summary>Gets the placeholder text for the custom text input option in the list. If <c>null</c>, no custom option is shown.</summary>
|
||||
public string? ListCustomTextPlaceholder { get; init; }
|
||||
|
||||
/// <summary>Gets the foreground color for the rule borders. If <c>null</c>, uses the default terminal color.</summary>
|
||||
public ConsoleColor? RuleColor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal state for the <see cref="AppComponent"/>.
|
||||
/// </summary>
|
||||
public record AppComponentState : ConsoleReactiveState
|
||||
{
|
||||
/// <summary>Gets the selected index in list selection mode.</summary>
|
||||
public int SelectedIndex { get; init; }
|
||||
|
||||
/// <summary>Gets the current input text being typed in text input mode.</summary>
|
||||
public string InputText { get; init; } = "";
|
||||
|
||||
/// <summary>Gets the current text being typed into the list's custom text option.</summary>
|
||||
public string ListInputText { get; init; } = "";
|
||||
}
|
||||
|
||||
public class AppComponent : ConsoleReactiveComponent<AppComponentProps, AppComponentState>
|
||||
{
|
||||
private readonly TopBottomRule _rule = new();
|
||||
private readonly ListSelection _listSelection = new();
|
||||
private readonly TextInput _textInput = new();
|
||||
private readonly TextScrollPanel _textScrollPanel;
|
||||
private readonly TextPanel _textPanel;
|
||||
private readonly Func<object, string> _renderItem;
|
||||
private readonly Action<string> _onTextInputSubmit;
|
||||
private readonly Action<string> _onListInputSubmit;
|
||||
private bool _resizedSinceLastRender;
|
||||
private int _lastScrollBottom;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AppComponent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="renderScrollItem">A delegate that renders a single scroll panel item and returns the text to display.</param>
|
||||
/// <param name="onTextInputSubmit">A callback invoked with the input text when the user presses Enter in text input mode.</param>
|
||||
/// <param name="onListInputSubmit">A callback invoked with the selected or typed text when the user presses Enter in list selection mode.</param>
|
||||
public AppComponent(Func<object, string> renderScrollItem, Action<string> onTextInputSubmit, Action<string> onListInputSubmit)
|
||||
{
|
||||
this._renderItem = renderScrollItem;
|
||||
this._onTextInputSubmit = onTextInputSubmit;
|
||||
this._onListInputSubmit = onListInputSubmit;
|
||||
this._textScrollPanel = new TextScrollPanel(renderScrollItem);
|
||||
this._textPanel = new TextPanel(renderScrollItem);
|
||||
this.State = new AppComponentState();
|
||||
KeyEventListener.Instance.KeyPressed += this.OnKeyPressed;
|
||||
ConsoleResizeListener.Instance.ConsoleResized += this.OnConsoleResized;
|
||||
}
|
||||
|
||||
private void OnKeyPressed(object? sender, KeyPressEventArgs e)
|
||||
{
|
||||
if (this.Props!.Mode == BottomPanelMode.TextInput)
|
||||
{
|
||||
this.HandleTextInputKey(e);
|
||||
}
|
||||
else
|
||||
{
|
||||
this.HandleListSelectionKey(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleTextInputKey(KeyPressEventArgs e)
|
||||
{
|
||||
if (e.KeyInfo.Key == ConsoleKey.Enter)
|
||||
{
|
||||
string text = this.State!.InputText;
|
||||
this.SetState(this.State with { InputText = "" });
|
||||
this._onTextInputSubmit(text);
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
if (this.State!.InputText.Length > 0)
|
||||
{
|
||||
this.SetState(this.State with { InputText = this.State.InputText[..^1] });
|
||||
}
|
||||
}
|
||||
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
|
||||
{
|
||||
this.SetState(this.State! with { InputText = this.State.InputText + e.KeyInfo.KeyChar });
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleListSelectionKey(KeyPressEventArgs e)
|
||||
{
|
||||
int maxIndex = this.Props!.Items.Count - 1;
|
||||
if (this.Props.ListCustomTextPlaceholder != null)
|
||||
{
|
||||
maxIndex = this.Props.Items.Count; // extra option at the end
|
||||
}
|
||||
|
||||
bool isOnCustomTextOption = this.Props.ListCustomTextPlaceholder != null
|
||||
&& this.State!.SelectedIndex == this.Props.Items.Count;
|
||||
|
||||
if (e.KeyInfo.Key == ConsoleKey.UpArrow)
|
||||
{
|
||||
this.SetState(this.State! with { SelectedIndex = Math.Max(0, this.State.SelectedIndex - 1) });
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.DownArrow)
|
||||
{
|
||||
this.SetState(this.State! with { SelectedIndex = Math.Min(maxIndex, this.State.SelectedIndex + 1) });
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.Enter)
|
||||
{
|
||||
if (isOnCustomTextOption)
|
||||
{
|
||||
string text = this.State!.ListInputText;
|
||||
this.SetState(this.State with { ListInputText = "" });
|
||||
this._onListInputSubmit(text);
|
||||
}
|
||||
else
|
||||
{
|
||||
this._onListInputSubmit(this.Props.Items[this.State!.SelectedIndex]);
|
||||
}
|
||||
}
|
||||
else if (isOnCustomTextOption)
|
||||
{
|
||||
// Typing only works when on the custom text option
|
||||
if (e.KeyInfo.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
if (this.State!.ListInputText.Length > 0)
|
||||
{
|
||||
this.SetState(this.State with { ListInputText = this.State.ListInputText[..^1] });
|
||||
}
|
||||
}
|
||||
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
|
||||
{
|
||||
this.SetState(this.State! with { ListInputText = this.State.ListInputText + e.KeyInfo.KeyChar });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConsoleResized(object? sender, ConsoleResizeEventArgs e)
|
||||
{
|
||||
this._resizedSinceLastRender = true;
|
||||
this.Render();
|
||||
}
|
||||
|
||||
public override void RenderCore(AppComponentProps props, AppComponentState state)
|
||||
{
|
||||
// Determine the text panel height for the last scroll item
|
||||
object? lastItem = props.ScrollItems.Count > 0 ? props.ScrollItems[^1] : null;
|
||||
IReadOnlyList<object> lastItems = lastItem != null ? [lastItem] : [];
|
||||
int textPanelHeight = TextPanel.CalculateHeight(lastItems, this._renderItem);
|
||||
if (textPanelHeight > 0)
|
||||
{
|
||||
textPanelHeight++; // Extra line for spacing between text panel and rule
|
||||
}
|
||||
|
||||
// Build the bottom panel child based on mode
|
||||
ConsoleReactiveComponent bottomChild;
|
||||
int bottomChildHeight;
|
||||
|
||||
if (props.Mode == BottomPanelMode.TextInput)
|
||||
{
|
||||
var textInputProps = new TextInputProps
|
||||
{
|
||||
Prompt = props.Prompt,
|
||||
Text = state.InputText,
|
||||
Placeholder = props.Placeholder
|
||||
};
|
||||
|
||||
bottomChildHeight = TextInput.CalculateHeight(textInputProps, Console.WindowWidth);
|
||||
this._textInput.Width = Console.WindowWidth;
|
||||
this._textInput.Height = bottomChildHeight;
|
||||
this._textInput.Props = textInputProps;
|
||||
bottomChild = this._textInput;
|
||||
}
|
||||
else
|
||||
{
|
||||
var listProps = new ListSelectionProps
|
||||
{
|
||||
Items = props.Items,
|
||||
SelectedIndex = state.SelectedIndex,
|
||||
HighlightColor = props.ListHighlightColor,
|
||||
CustomTextPlaceholder = props.ListCustomTextPlaceholder,
|
||||
CustomText = state.ListInputText
|
||||
};
|
||||
|
||||
bottomChildHeight = ListSelection.CalculateHeight(listProps);
|
||||
this._listSelection.Height = bottomChildHeight;
|
||||
this._listSelection.Props = listProps;
|
||||
bottomChild = this._listSelection;
|
||||
}
|
||||
|
||||
var ruleProps = new TopBottomRuleProps
|
||||
{
|
||||
Width = Console.WindowWidth,
|
||||
Color = props.RuleColor,
|
||||
Children = [bottomChild]
|
||||
};
|
||||
|
||||
int ruleHeight = TopBottomRule.CalculateHeight(ruleProps);
|
||||
int scrollBottom = Console.WindowHeight - ruleHeight - textPanelHeight;
|
||||
|
||||
// If scroll region changed or a clear is needed, reset everything
|
||||
if (this._resizedSinceLastRender || (this._lastScrollBottom != 0 && scrollBottom != this._lastScrollBottom))
|
||||
{
|
||||
Console.Write(AnsiEscapes.EraseEntireScreen);
|
||||
Console.Write(AnsiEscapes.EraseScrollbackBuffer);
|
||||
this._textScrollPanel.Reset();
|
||||
this._resizedSinceLastRender = false;
|
||||
}
|
||||
|
||||
this._lastScrollBottom = scrollBottom;
|
||||
|
||||
Console.Write(AnsiEscapes.SetScrollRegion(scrollBottom));
|
||||
|
||||
// Render text scroll panel in the scroll area (all items except the last)
|
||||
IReadOnlyList<object> scrollItems = props.ScrollItems.Count > 1
|
||||
? props.ScrollItems.Take(props.ScrollItems.Count - 1).ToList()
|
||||
: [];
|
||||
|
||||
this._textScrollPanel.X = 1;
|
||||
this._textScrollPanel.Y = 1;
|
||||
this._textScrollPanel.Width = Console.WindowWidth;
|
||||
this._textScrollPanel.Height = scrollBottom;
|
||||
this._textScrollPanel.Props = new TextScrollPanelProps
|
||||
{
|
||||
Items = scrollItems
|
||||
};
|
||||
this._textScrollPanel.Render();
|
||||
|
||||
// Render the text panel for the last (dynamic) item just below the scroll region
|
||||
this._textPanel.X = 1;
|
||||
this._textPanel.Y = scrollBottom + 1;
|
||||
this._textPanel.Width = Console.WindowWidth;
|
||||
this._textPanel.Height = textPanelHeight;
|
||||
this._textPanel.Props = new TextPanelProps
|
||||
{
|
||||
Items = lastItems,
|
||||
};
|
||||
this._textPanel.Render();
|
||||
|
||||
// Render the bottom rule + child below the text panel
|
||||
this._rule.X = 1;
|
||||
this._rule.Y = scrollBottom + textPanelHeight + 1;
|
||||
this._rule.Props = ruleProps;
|
||||
this._rule.Render();
|
||||
|
||||
// Position cursor for natural typing appearance
|
||||
if (props.Mode == BottomPanelMode.TextInput)
|
||||
{
|
||||
int promptLength = props.Prompt.Length;
|
||||
int textWidth = Console.WindowWidth - promptLength;
|
||||
int textLength = state.InputText.Length;
|
||||
|
||||
// The TextInput starts at rule.Y + 1 (first row inside the rule)
|
||||
int textInputY = this._rule.Y + 1;
|
||||
|
||||
if (textWidth <= 0 || textLength == 0)
|
||||
{
|
||||
// Cursor right after the prompt
|
||||
Console.Write(AnsiEscapes.MoveCursor(textInputY, promptLength + 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculate which row and column the cursor lands on
|
||||
int cursorRow = textLength < textWidth ? 0 : 1 + ((textLength - textWidth) / textWidth);
|
||||
int cursorCol = textLength < textWidth ? textLength : (textLength - textWidth) % textWidth;
|
||||
Console.Write(AnsiEscapes.MoveCursor(textInputY + cursorRow, promptLength + cursorCol + 1));
|
||||
}
|
||||
}
|
||||
else if (props.Mode == BottomPanelMode.ListSelection
|
||||
&& props.ListCustomTextPlaceholder != null
|
||||
&& state.SelectedIndex == props.Items.Count)
|
||||
{
|
||||
// Cursor after the typed text in the custom text option
|
||||
// The custom text option is at rule.Y + 1 + Items.Count (0-based row inside rule)
|
||||
int customOptionY = this._rule.Y + 1 + props.Items.Count;
|
||||
// "> " prefix is 2 chars, then the typed text
|
||||
int cursorCol = 2 + state.ListInputText.Length + 1;
|
||||
Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public abstract class CommandHandler
|
||||
/// </summary>
|
||||
/// <param name="input">The raw user input string.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
/// <param name="ux">The UX container for rendering output.</param>
|
||||
/// <param name="ux">The UX state driver for rendering output.</param>
|
||||
/// <returns><see langword="true"/> if this handler handled the input; <see langword="false"/> otherwise.</returns>
|
||||
public abstract ValueTask<bool> TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux);
|
||||
public abstract ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux);
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <c>/exit</c> command to shut down the console application.
|
||||
/// </summary>
|
||||
public sealed class ExitCommandHandler : CommandHandler
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override string? GetHelpText() => "/exit (quit)";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||
{
|
||||
if (!input.Equals("/exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new ValueTask<bool>(false);
|
||||
}
|
||||
|
||||
ux.RequestShutdown();
|
||||
return new ValueTask<bool>(true);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -7,7 +7,7 @@ namespace Harness.Shared.Console.Commands;
|
||||
/// <summary>
|
||||
/// Handles the <c>/mode</c> command to display or switch the current agent mode.
|
||||
/// </summary>
|
||||
internal sealed class ModeCommandHandler : CommandHandler
|
||||
public sealed class ModeCommandHandler : CommandHandler
|
||||
{
|
||||
private readonly AgentModeProvider? _modeProvider;
|
||||
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||
@@ -27,7 +27,7 @@ internal sealed class ModeCommandHandler : CommandHandler
|
||||
public override string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux)
|
||||
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||
{
|
||||
if (!input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase) && !input.Equals("/mode", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ namespace Harness.Shared.Console.Commands;
|
||||
/// <summary>
|
||||
/// Handles the <c>/todos</c> command to display the current todo list.
|
||||
/// </summary>
|
||||
internal sealed class TodoCommandHandler : CommandHandler
|
||||
public sealed class TodoCommandHandler : CommandHandler
|
||||
{
|
||||
private readonly TodoProvider? _todoProvider;
|
||||
|
||||
@@ -24,7 +24,7 @@ internal sealed class TodoCommandHandler : CommandHandler
|
||||
public override string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux)
|
||||
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||
{
|
||||
if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an action returned by an observer at the end of an agent turn.
|
||||
/// Subtypes describe either a question to ask the user (<see cref="FollowUpQuestion"/>)
|
||||
/// or a message to add directly to the next agent input (<see cref="FollowUpMessage"/>).
|
||||
/// </summary>
|
||||
public abstract record FollowUpAction;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a question that should be presented to the user. The
|
||||
/// <see cref="Continuation"/> delegate is invoked with the user's answer and the
|
||||
/// UX state driver, and returns an optional <see cref="ChatMessage"/> to add to the
|
||||
/// next agent invocation.
|
||||
/// </summary>
|
||||
/// <param name="Prompt">The question text shown to the user.</param>
|
||||
/// <param name="Continuation">
|
||||
/// Invoked with the user's answer and the UX state driver. The driver lets the
|
||||
/// continuation write output (e.g., an action label like "Approved") in addition
|
||||
/// to producing an optional <see cref="ChatMessage"/> for the next agent invocation.
|
||||
/// </param>
|
||||
public abstract record FollowUpQuestion(
|
||||
string Prompt,
|
||||
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation) : FollowUpAction;
|
||||
|
||||
/// <summary>
|
||||
/// A free-form text question. The user may type any response.
|
||||
/// </summary>
|
||||
/// <param name="Prompt">The question text shown to the user.</param>
|
||||
/// <param name="Continuation">Continuation that builds the response message.</param>
|
||||
public sealed record TextFollowUpQuestion(
|
||||
string Prompt,
|
||||
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation)
|
||||
: FollowUpQuestion(Prompt, Continuation);
|
||||
|
||||
/// <summary>
|
||||
/// A choice question. The user picks from <paramref name="Choices"/>, optionally with
|
||||
/// the ability to enter custom text when <paramref name="AllowCustomText"/> is true.
|
||||
/// </summary>
|
||||
/// <param name="Prompt">The question text shown to the user.</param>
|
||||
/// <param name="Choices">The list of pre-defined choices.</param>
|
||||
/// <param name="AllowCustomText">If true, the user may type a custom response in addition to the listed choices.</param>
|
||||
/// <param name="Continuation">Continuation that builds the response message.</param>
|
||||
public sealed record ChoiceFollowUpQuestion(
|
||||
string Prompt,
|
||||
IReadOnlyList<string> Choices,
|
||||
bool AllowCustomText,
|
||||
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation)
|
||||
: FollowUpQuestion(Prompt, Continuation);
|
||||
|
||||
/// <summary>
|
||||
/// A message to add directly to the next agent invocation without prompting the user.
|
||||
/// </summary>
|
||||
/// <param name="Message">The chat message to add.</param>
|
||||
public sealed record FollowUpMessage(ChatMessage Message) : FollowUpAction;
|
||||
@@ -0,0 +1,279 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.Shared.Console.Commands;
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates agent invocations driven by user-input events from the UI.
|
||||
/// The component invokes the runner's input handlers (<see cref="OnUserInputAsync"/>,
|
||||
/// <see cref="OnStreamingInputAsync"/>, <see cref="StartAgentTurnAsync"/>) directly;
|
||||
/// the runner mutates UI state through the supplied <see cref="IUXStateDriver"/>.
|
||||
/// All per-turn follow-up state (pending questions and accumulated responses) lives
|
||||
/// in the component's state record — the runner reads/writes it exclusively through
|
||||
/// the driver and holds no per-turn fields itself.
|
||||
/// </summary>
|
||||
public sealed class HarnessAgentRunner : IDisposable
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
private readonly AgentSession _session;
|
||||
private readonly AgentModeProvider? _modeProvider;
|
||||
private readonly MessageInjectingChatClient? _messageInjector;
|
||||
private readonly IReadOnlyList<CommandHandler> _commandHandlers;
|
||||
private readonly IReadOnlyList<ConsoleObserver> _observers;
|
||||
private readonly IUXStateDriver _ux;
|
||||
|
||||
private readonly SemaphoreSlim _inputGate = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HarnessAgentRunner"/> class.
|
||||
/// </summary>
|
||||
public HarnessAgentRunner(
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
AgentModeProvider? modeProvider,
|
||||
MessageInjectingChatClient? messageInjector,
|
||||
IReadOnlyList<CommandHandler> commandHandlers,
|
||||
IReadOnlyList<ConsoleObserver> observers,
|
||||
IUXStateDriver ux)
|
||||
{
|
||||
this._agent = agent;
|
||||
this._session = session;
|
||||
this._modeProvider = modeProvider;
|
||||
this._messageInjector = messageInjector;
|
||||
this._commandHandlers = commandHandlers;
|
||||
this._observers = observers;
|
||||
this._ux = ux;
|
||||
|
||||
this.HelpText = string.Join(
|
||||
", ",
|
||||
commandHandlers
|
||||
.Select(h => h.GetHelpText())
|
||||
.Where(t => t is not null)!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the help text describing all available commands (joined by ", "), suitable
|
||||
/// for display in the mode-and-help bar. Computed from the supplied
|
||||
/// <c>commandHandlers</c>.
|
||||
/// </summary>
|
||||
public string HelpText { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose() => this._inputGate.Dispose();
|
||||
|
||||
/// <summary>
|
||||
/// Handles a top-level user input submission (TextInput mode, no pending question).
|
||||
/// Dispatches to command handlers, or starts an agent turn.
|
||||
/// </summary>
|
||||
internal async Task OnUserInputAsync(string text)
|
||||
{
|
||||
await this._inputGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
this._ux.WriteUserInputEcho(text);
|
||||
|
||||
foreach (var handler in this._commandHandlers)
|
||||
{
|
||||
if (await handler.TryHandleAsync(text, this._session, this._ux).ConfigureAwait(false))
|
||||
{
|
||||
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.RunAgentLoopAsync([new ChatMessage(ChatRole.User, text)]).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._inputGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a user input submission while an agent turn is streaming. The text is
|
||||
/// enqueued via the <see cref="MessageInjectingChatClient"/> so it can be picked up
|
||||
/// by the agent on its next opportunity.
|
||||
/// </summary>
|
||||
internal Task OnStreamingInputAsync(string text)
|
||||
{
|
||||
if (this._messageInjector is null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
this._messageInjector.EnqueueMessages(this._session, [new ChatMessage(ChatRole.User, text)]);
|
||||
this._ux.SetQueuedMessages(this._messageInjector.GetPendingMessages(this._session));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes (or completes) a turn after the user has answered all pending follow-up
|
||||
/// questions. The component invokes this with the messages drained from
|
||||
/// <see cref="IUXStateDriver.TakeFollowUpResponses"/>; an empty list simply ends
|
||||
/// the streaming display state without invoking the agent.
|
||||
/// </summary>
|
||||
internal async Task StartAgentTurnAsync(IList<ChatMessage> messages)
|
||||
{
|
||||
await this._inputGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (messages.Count == 0)
|
||||
{
|
||||
this.CompleteTurn();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.RunAgentLoopAsync(messages).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._inputGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunAgentLoopAsync(IList<ChatMessage> messages)
|
||||
{
|
||||
IList<ChatMessage>? nextMessages = messages;
|
||||
IReadOnlyList<ChatMessage> lastPendingMessages = this._messageInjector?.GetPendingMessages(this._session) ?? [];
|
||||
|
||||
while (nextMessages is not null)
|
||||
{
|
||||
var runOptions = new AgentRunOptions();
|
||||
foreach (var observer in this._observers)
|
||||
{
|
||||
observer.ConfigureRunOptions(runOptions, this._agent, this._session);
|
||||
}
|
||||
|
||||
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
|
||||
this._ux.BeginStreaming();
|
||||
this._ux.BeginStreamingOutput();
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var update in this._agent.RunStreamingAsync(nextMessages, this._session, runOptions))
|
||||
{
|
||||
if (this._modeProvider is not null)
|
||||
{
|
||||
string currentMode = this._modeProvider.GetMode(this._session);
|
||||
if (currentMode != this._ux.CurrentMode)
|
||||
{
|
||||
this._ux.CurrentMode = currentMode;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
foreach (var observer in this._observers)
|
||||
{
|
||||
await observer.OnContentAsync(this._ux, content, this._agent, this._session).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
foreach (var observer in this._observers)
|
||||
{
|
||||
await observer.OnTextAsync(this._ux, update.Text, this._agent, this._session).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
this.SyncQueuedMessageDisplay(ref lastPendingMessages);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await this._ux.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Final sync after streaming.
|
||||
this.SyncQueuedMessageDisplay(ref lastPendingMessages);
|
||||
|
||||
this._ux.StopSpinner();
|
||||
await this._ux.EndStreamingOutputAsync().ConfigureAwait(false);
|
||||
|
||||
// Collect FollowUpActions from each observer.
|
||||
var directMessages = new List<ChatMessage>();
|
||||
var questions = new List<FollowUpQuestion>();
|
||||
foreach (var observer in this._observers)
|
||||
{
|
||||
var actions = await observer.OnStreamCompleteAsync(this._ux, this._agent, this._session).ConfigureAwait(false);
|
||||
if (actions is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case FollowUpMessage msg:
|
||||
directMessages.Add(msg.Message);
|
||||
break;
|
||||
case FollowUpQuestion q:
|
||||
questions.Add(q);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool hasFollowUpActions = directMessages.Count > 0 || questions.Count > 0;
|
||||
await this._ux.WriteNoTextWarningAsync(hasFollowUpActions).ConfigureAwait(false);
|
||||
|
||||
// Add any direct messages to the accumulator regardless of whether questions follow —
|
||||
// they're sent on the next agent invocation, either by us (if no questions) or by
|
||||
// the component (after the user finishes answering, via StartAgentTurnAsync).
|
||||
foreach (var msg in directMessages)
|
||||
{
|
||||
this._ux.AddFollowUpResponse(msg);
|
||||
}
|
||||
|
||||
if (questions.Count > 0)
|
||||
{
|
||||
// Pause: hand control back to the UX to collect answers.
|
||||
this._ux.QueueFollowUpQuestions(questions);
|
||||
return;
|
||||
}
|
||||
|
||||
// No questions to ask — drain anything we just accumulated and loop with it.
|
||||
IReadOnlyList<ChatMessage> drained = this._ux.TakeFollowUpResponses();
|
||||
nextMessages = drained.Count > 0 ? [.. drained] : null;
|
||||
}
|
||||
|
||||
this.CompleteTurn();
|
||||
}
|
||||
|
||||
private void CompleteTurn()
|
||||
{
|
||||
this._ux.EndStreaming();
|
||||
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronizes the queued items display with the message injector's pending messages.
|
||||
/// Messages that have been consumed (drained by the service) are echoed to the output
|
||||
/// area as regular user-input entries.
|
||||
/// </summary>
|
||||
private void SyncQueuedMessageDisplay(ref IReadOnlyList<ChatMessage> lastPendingMessages)
|
||||
{
|
||||
if (this._messageInjector is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pending = this._messageInjector.GetPendingMessages(this._session);
|
||||
|
||||
int consumedCount = lastPendingMessages.Count - pending.Count;
|
||||
for (int i = 0; i < consumedCount && i < lastPendingMessages.Count; i++)
|
||||
{
|
||||
string text = lastPendingMessages[i].Text ?? string.Empty;
|
||||
this._ux.WriteUserInputEcho(text);
|
||||
}
|
||||
|
||||
lastPendingMessages = pending;
|
||||
this._ux.SetQueuedMessages(pending);
|
||||
}
|
||||
}
|
||||
@@ -3,171 +3,89 @@
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
using Harness.Shared.Console.Components;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Determines which component is shown in the bottom panel.
|
||||
/// </summary>
|
||||
public enum BottomPanelMode
|
||||
{
|
||||
/// <summary>Show the text input component for user input.</summary>
|
||||
TextInput,
|
||||
|
||||
/// <summary>Show the list selection component for interactive prompts.</summary>
|
||||
ListSelection,
|
||||
|
||||
/// <summary>Show a disabled input indicator during agent streaming.</summary>
|
||||
Streaming,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments for the <see cref="HarnessAppComponent.InputSubmitted"/> event.
|
||||
/// </summary>
|
||||
public sealed class InputSubmittedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InputSubmittedEventArgs"/> class.
|
||||
/// </summary>
|
||||
/// <param name="text">The submitted text.</param>
|
||||
/// <param name="mode">The bottom panel mode in which the input was submitted.</param>
|
||||
public InputSubmittedEventArgs(string text, BottomPanelMode mode)
|
||||
{
|
||||
this.Text = text;
|
||||
this.Mode = mode;
|
||||
}
|
||||
|
||||
/// <summary>Gets the submitted text.</summary>
|
||||
public string Text { get; }
|
||||
|
||||
/// <summary>Gets the bottom panel mode in which the input was submitted.</summary>
|
||||
public BottomPanelMode Mode { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="HarnessAppComponent"/>.
|
||||
/// </summary>
|
||||
public record HarnessAppComponentProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets or sets the list selection choices (for ListSelection mode).</summary>
|
||||
public IReadOnlyList<string> Items { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Gets or sets the scroll items (output entries) to render in the scroll panel.</summary>
|
||||
public IReadOnlyList<object> ScrollItems { get; set; } = [];
|
||||
|
||||
/// <summary>Gets or sets the bottom panel mode.</summary>
|
||||
public BottomPanelMode Mode { get; set; } = BottomPanelMode.TextInput;
|
||||
|
||||
/// <summary>Gets or sets the prompt string for text input mode.</summary>
|
||||
public string Prompt { get; set; } = "You: ";
|
||||
|
||||
/// <summary>Gets or sets the placeholder text shown when the input is empty.</summary>
|
||||
public string Placeholder { get; set; } = "";
|
||||
|
||||
/// <summary>Gets or sets the highlight color for the active list item.</summary>
|
||||
public ConsoleColor ListHighlightColor { get; set; } = ConsoleColor.Cyan;
|
||||
|
||||
/// <summary>Gets or sets the placeholder text for the custom text input option in the list.</summary>
|
||||
public string? ListCustomTextPlaceholder { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the foreground color for the rule borders and mode label.</summary>
|
||||
public ConsoleColor? ModeColor { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the current mode name displayed below the bottom rule (e.g. "plan").</summary>
|
||||
public string? ModeText { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the help text displayed below the bottom rule (available commands).</summary>
|
||||
public string? HelpText { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the title text displayed above the list selection (for interactive prompts).</summary>
|
||||
public string? ListTitle { get; set; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether input is enabled during streaming.</summary>
|
||||
public bool InputEnabled { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the prompt to show during streaming when input is disabled.</summary>
|
||||
public string StreamingPrompt { get; set; } = "(agent is running...)";
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the agent status spinner is visible.</summary>
|
||||
public bool ShowSpinner { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the formatted token usage text to display in the status bar.</summary>
|
||||
public string? UsageText { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the queued input items to display above the rule.</summary>
|
||||
public IReadOnlyList<object> QueuedItems { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal state for <see cref="HarnessAppComponent"/>.
|
||||
/// </summary>
|
||||
public record HarnessAppComponentState : ConsoleReactiveState
|
||||
{
|
||||
/// <summary>Gets the selected index in list selection mode.</summary>
|
||||
public int SelectedIndex { get; init; }
|
||||
|
||||
/// <summary>Gets the current input text being typed.</summary>
|
||||
public string InputText { get; init; } = "";
|
||||
|
||||
/// <summary>Gets the current text being typed into the list's custom text option.</summary>
|
||||
public string ListInputText { get; init; } = "";
|
||||
|
||||
/// <summary>Gets the current console width in columns.</summary>
|
||||
public int ConsoleWidth { get; init; }
|
||||
|
||||
/// <summary>Gets the current console height in rows.</summary>
|
||||
public int ConsoleHeight { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The main application component for the Harness console. Manages the scroll region
|
||||
/// and bottom panel (text input, list selection, or streaming indicator), and emits
|
||||
/// an <see cref="InputSubmitted"/> event when the user submits text in any mode.
|
||||
/// and bottom panel (text input, list selection, or streaming indicator). Owns the
|
||||
/// <see cref="HarnessConsoleUXStateDriver"/> and routes user input events to the
|
||||
/// registered <see cref="HarnessAgentRunner"/>.
|
||||
/// </summary>
|
||||
public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentProps, HarnessAppComponentState>, IDisposable
|
||||
public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps, HarnessAppComponentState>, IDisposable
|
||||
{
|
||||
private readonly TopBottomRule _rule = new();
|
||||
private readonly ListSelection _listSelection = new();
|
||||
private readonly TextInput _textInput = new();
|
||||
private readonly TextScrollPanel _textScrollPanel;
|
||||
private readonly TextPanel _textPanel;
|
||||
private readonly TextPanel _queuedPanel;
|
||||
private readonly TextScrollPanel _textScrollPanel = new();
|
||||
private readonly TextPanel _textPanel = new();
|
||||
private readonly TextPanel _queuedPanel = new();
|
||||
private readonly AgentStatus _agentStatus = new();
|
||||
private readonly AgentModeAndHelp _modeAndHelp = new();
|
||||
private readonly Func<object, string> _renderItem;
|
||||
private bool _resizedSinceLastRender;
|
||||
private readonly HarnessConsoleUXStateDriver _uxDriver;
|
||||
private readonly TaskCompletionSource<bool> _shutdownTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly SemaphoreSlim _followUpGate = new(1, 1);
|
||||
private int _scrollRegionBottom;
|
||||
private bool _resizedSinceLastRender = true;
|
||||
private bool _deactivated;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HarnessAppComponent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="renderScrollItem">A delegate that renders a single output entry and returns the text to display.</param>
|
||||
public HarnessAppComponent(Func<object, string> renderScrollItem)
|
||||
/// <param name="placeholder">Placeholder text shown when the input is empty.</param>
|
||||
/// <param name="initialMode">The current agent mode, used to colour the rule and prompt.</param>
|
||||
/// <param name="inputEnabled">Whether the bottom-panel input accepts keystrokes during streaming.</param>
|
||||
/// <param name="runnerFactory">Factory invoked with the component's <see cref="IUXStateDriver"/>
|
||||
/// to construct the <see cref="HarnessAgentRunner"/> that owns the agent loop.</param>
|
||||
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
||||
public HarnessAppComponent(
|
||||
string placeholder,
|
||||
string? initialMode,
|
||||
bool inputEnabled,
|
||||
Func<IUXStateDriver, HarnessAgentRunner> runnerFactory,
|
||||
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
this._renderItem = renderScrollItem;
|
||||
this._textScrollPanel = new TextScrollPanel(renderScrollItem);
|
||||
this._textPanel = new TextPanel(renderScrollItem);
|
||||
this._queuedPanel = new TextPanel(renderScrollItem);
|
||||
this.Props = new ConsoleReactiveProps();
|
||||
this.State = new HarnessAppComponentState
|
||||
{
|
||||
Mode = BottomPanelMode.TextInput,
|
||||
Prompt = "> ",
|
||||
Placeholder = placeholder,
|
||||
ModeColor = ModeColors.Get(initialMode, modeColors),
|
||||
ModeText = initialMode,
|
||||
InputEnabled = inputEnabled,
|
||||
ConsoleWidth = System.Console.WindowWidth,
|
||||
ConsoleHeight = System.Console.WindowHeight,
|
||||
};
|
||||
|
||||
this._uxDriver = new HarnessConsoleUXStateDriver(
|
||||
getState: () => this.State!,
|
||||
setState: s => this.SetState(s),
|
||||
requestShutdown: () => this._shutdownTcs.TrySetResult(true),
|
||||
modeColors: modeColors);
|
||||
|
||||
this.Runner = runnerFactory(this._uxDriver);
|
||||
|
||||
// Seed help text now that the runner (which knows the registered command handlers)
|
||||
// is available. Direct assignment — no Render is triggered until the caller invokes Render().
|
||||
this.State = this.State with { HelpText = this.Runner.HelpText };
|
||||
|
||||
KeyEventListener.Instance.KeyPressed += this.OnKeyPressed;
|
||||
ConsoleResizeListener.Instance.ConsoleResized += this.OnConsoleResized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the 1-based row number of the last row in the output scroll region.
|
||||
/// Gets the agent runner that owns the agent loop. Constructed by the factory
|
||||
/// passed to the component's constructor.
|
||||
/// </summary>
|
||||
public int ScrollRegionBottom { get; private set; }
|
||||
public HarnessAgentRunner Runner { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the user submits input via Enter, in any mode (text input, list selection,
|
||||
/// or streaming injection). Consumers inspect <see cref="InputSubmittedEventArgs.Mode"/>
|
||||
/// to decide how to handle the submission.
|
||||
/// Completes when a command handler requests application shutdown (e.g. the user types <c>/exit</c>).
|
||||
/// Awaited by <see cref="HarnessConsole.RunAgentAsync"/>.
|
||||
/// </summary>
|
||||
public event EventHandler<InputSubmittedEventArgs>? InputSubmitted;
|
||||
public Task ShutdownTask => this._shutdownTcs.Task;
|
||||
|
||||
/// <summary>
|
||||
/// Deactivates the component, resetting the scroll region and unsubscribing from events.
|
||||
@@ -184,9 +102,6 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
this._agentStatus.Dispose();
|
||||
KeyEventListener.Instance.KeyPressed -= this.OnKeyPressed;
|
||||
ConsoleResizeListener.Instance.ConsoleResized -= this.OnConsoleResized;
|
||||
System.Console.Write(AnsiEscapes.ResetScrollRegion);
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(System.Console.WindowHeight, 1));
|
||||
System.Console.WriteLine();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -205,20 +120,23 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
if (disposing)
|
||||
{
|
||||
this.Deactivate();
|
||||
this._followUpGate.Dispose();
|
||||
this.Runner.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnKeyPressed(object? sender, KeyPressEventArgs e)
|
||||
{
|
||||
if (this.Props!.Mode == BottomPanelMode.TextInput)
|
||||
BottomPanelMode mode = this.State!.Mode;
|
||||
if (mode == BottomPanelMode.TextInput)
|
||||
{
|
||||
this.HandleTextInputKey(e);
|
||||
}
|
||||
else if (this.Props.Mode == BottomPanelMode.ListSelection)
|
||||
else if (mode == BottomPanelMode.ListSelection)
|
||||
{
|
||||
this.HandleListSelectionKey(e);
|
||||
}
|
||||
else if (this.Props.Mode == BottomPanelMode.Streaming && this.Props.InputEnabled)
|
||||
else if (mode == BottomPanelMode.Streaming && this.State.InputEnabled)
|
||||
{
|
||||
this.HandleStreamingInputKey(e);
|
||||
}
|
||||
@@ -235,7 +153,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
}
|
||||
|
||||
this.SetState(this.State with { InputText = "" });
|
||||
this.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(text, BottomPanelMode.TextInput));
|
||||
this.DispatchTextInputSubmission(text);
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
@@ -252,51 +170,50 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
|
||||
private void HandleListSelectionKey(KeyPressEventArgs e)
|
||||
{
|
||||
int maxIndex = this.Props!.Items.Count - 1;
|
||||
if (this.Props.ListCustomTextPlaceholder != null)
|
||||
int maxIndex = this.State!.ListSelectionOptions.Count - 1;
|
||||
if (this.State.ListSelectionCustomTextPlaceholder != null)
|
||||
{
|
||||
maxIndex = this.Props.Items.Count;
|
||||
maxIndex = this.State.ListSelectionOptions.Count;
|
||||
}
|
||||
|
||||
bool isOnCustomTextOption = this.Props.ListCustomTextPlaceholder != null
|
||||
&& this.State!.SelectedIndex == this.Props.Items.Count;
|
||||
bool isOnCustomTextOption = this.State.ListSelectionCustomTextPlaceholder != null
|
||||
&& this.State.ListSelectionIndex == this.State.ListSelectionOptions.Count;
|
||||
|
||||
if (e.KeyInfo.Key == ConsoleKey.UpArrow)
|
||||
{
|
||||
this.SetState(this.State! with { SelectedIndex = Math.Max(0, this.State.SelectedIndex - 1) });
|
||||
this.SetState(this.State with { ListSelectionIndex = Math.Max(0, this.State.ListSelectionIndex - 1) });
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.DownArrow)
|
||||
{
|
||||
this.SetState(this.State! with { SelectedIndex = Math.Min(maxIndex, this.State.SelectedIndex + 1) });
|
||||
this.SetState(this.State with { ListSelectionIndex = Math.Min(maxIndex, this.State.ListSelectionIndex + 1) });
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.Enter)
|
||||
{
|
||||
string result = isOnCustomTextOption
|
||||
? this.State!.ListInputText
|
||||
: this.Props.Items[this.State!.SelectedIndex];
|
||||
? this.State.ListSelectionCustomInputText
|
||||
: this.State.ListSelectionOptions[this.State.ListSelectionIndex];
|
||||
|
||||
this.SetState(this.State with { ListInputText = "", SelectedIndex = 0 });
|
||||
this.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(result, BottomPanelMode.ListSelection));
|
||||
this.SetState(this.State with { ListSelectionCustomInputText = "", ListSelectionIndex = 0 });
|
||||
this.DispatchListSelectionSubmission(result);
|
||||
}
|
||||
else if (isOnCustomTextOption)
|
||||
{
|
||||
if (e.KeyInfo.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
if (this.State!.ListInputText.Length > 0)
|
||||
if (this.State.ListSelectionCustomInputText.Length > 0)
|
||||
{
|
||||
this.SetState(this.State with { ListInputText = this.State.ListInputText[..^1] });
|
||||
this.SetState(this.State with { ListSelectionCustomInputText = this.State.ListSelectionCustomInputText[..^1] });
|
||||
}
|
||||
}
|
||||
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
|
||||
{
|
||||
this.SetState(this.State! with { ListInputText = this.State.ListInputText + e.KeyInfo.KeyChar });
|
||||
this.SetState(this.State with { ListSelectionCustomInputText = this.State.ListSelectionCustomInputText + e.KeyInfo.KeyChar });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleStreamingInputKey(KeyPressEventArgs e)
|
||||
{
|
||||
// During streaming with input enabled, capture text for message injection
|
||||
if (e.KeyInfo.Key == ConsoleKey.Enter)
|
||||
{
|
||||
string text = this.State!.InputText;
|
||||
@@ -306,7 +223,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
}
|
||||
|
||||
this.SetState(this.State with { InputText = "" });
|
||||
this.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(text, BottomPanelMode.Streaming));
|
||||
_ = this.Runner.OnStreamingInputAsync(text);
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
@@ -321,6 +238,90 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
}
|
||||
}
|
||||
|
||||
private void DispatchTextInputSubmission(string text)
|
||||
{
|
||||
if (this.State!.PendingQuestions.Count > 0)
|
||||
{
|
||||
_ = this.HandleFollowUpAnswerAsync(text);
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = this.Runner.OnUserInputAsync(text);
|
||||
}
|
||||
}
|
||||
|
||||
private void DispatchListSelectionSubmission(string text)
|
||||
{
|
||||
// List selection is only used to answer FollowUpQuestions.
|
||||
_ = this.HandleFollowUpAnswerAsync(text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a user answer to the head of the pending follow-up question queue:
|
||||
/// awaits the question's continuation (which is responsible for echoing both the
|
||||
/// question and answer to the scroll area as it sees fit), appends any returned
|
||||
/// chat message to the response accumulator, advances the queue, and — when the
|
||||
/// queue empties — drains the accumulator and resumes the runner.
|
||||
/// </summary>
|
||||
private async Task HandleFollowUpAnswerAsync(string text)
|
||||
{
|
||||
IReadOnlyList<ChatMessage>? messagesToSend = null;
|
||||
|
||||
await this._followUpGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
HarnessConsoleUXStateDriver ux = this._uxDriver;
|
||||
IReadOnlyList<FollowUpQuestion> queue = this.State!.PendingQuestions;
|
||||
if (queue.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FollowUpQuestion head = queue[0];
|
||||
|
||||
ChatMessage? response;
|
||||
try
|
||||
{
|
||||
response = await head.Continuation(text, ux).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"❌ Follow-up handler error: {ex.GetType().Name}: {ex.Message}", ConsoleColor.Red).ConfigureAwait(false);
|
||||
response = null;
|
||||
}
|
||||
|
||||
if (response is not null)
|
||||
{
|
||||
ux.AddFollowUpResponse(response);
|
||||
}
|
||||
|
||||
ux.AdvanceFollowUpQuestion();
|
||||
|
||||
if (this.State!.PendingQuestions.Count == 0)
|
||||
{
|
||||
messagesToSend = ux.TakeFollowUpResponses();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._followUpGate.Release();
|
||||
}
|
||||
|
||||
// Resume the agent outside the gate — StartAgentTurnAsync runs the full agent
|
||||
// loop which may queue new follow-up questions (re-entering this method).
|
||||
if (messagesToSend is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await this.Runner.StartAgentTurnAsync([.. messagesToSend]).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await this._uxDriver.WriteInfoLineAsync($"❌ Agent error: {ex.GetType().Name}: {ex.Message}", ConsoleColor.Red).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConsoleResized(object? sender, ConsoleResizeEventArgs e)
|
||||
{
|
||||
this._resizedSinceLastRender = true;
|
||||
@@ -332,35 +333,40 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(HarnessAppComponentProps props, HarnessAppComponentState state)
|
||||
public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentState state)
|
||||
{
|
||||
if (this._deactivated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the text panel height for the last scroll item
|
||||
IReadOnlyList<object> lastItems = props.ScrollItems.Count > 0
|
||||
? [props.ScrollItems[^1]]
|
||||
IReadOnlyList<string> lastItems = state.ScrollAreaContentItems.Count > 0
|
||||
? [state.ScrollAreaContentItems[^1]]
|
||||
: [];
|
||||
int textPanelHeight = TextPanel.CalculateHeight(lastItems, this._renderItem);
|
||||
int textPanelHeight = TextPanel.CalculateHeight(lastItems);
|
||||
if (textPanelHeight > 0)
|
||||
{
|
||||
textPanelHeight++; // Extra line for spacing between text panel and rule
|
||||
}
|
||||
|
||||
// Calculate queued items panel height
|
||||
int queuedPanelHeight = TextPanel.CalculateHeight(props.QueuedItems, this._renderItem);
|
||||
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems);
|
||||
|
||||
// Build the bottom panel child based on mode
|
||||
ConsoleReactiveComponent bottomChild;
|
||||
int bottomChildHeight;
|
||||
|
||||
if (props.Mode == BottomPanelMode.ListSelection)
|
||||
if (state.Mode == BottomPanelMode.ListSelection)
|
||||
{
|
||||
var listProps = new ListSelectionProps
|
||||
{
|
||||
Title = props.ListTitle,
|
||||
Items = props.Items,
|
||||
SelectedIndex = state.SelectedIndex,
|
||||
HighlightColor = props.ListHighlightColor,
|
||||
CustomTextPlaceholder = props.ListCustomTextPlaceholder,
|
||||
CustomText = state.ListInputText,
|
||||
Title = state.ListSelectionTitle,
|
||||
Items = state.ListSelectionOptions,
|
||||
SelectedIndex = state.ListSelectionIndex,
|
||||
HighlightColor = state.ListHighlightColor,
|
||||
CustomTextPlaceholder = state.ListSelectionCustomTextPlaceholder,
|
||||
CustomText = state.ListSelectionCustomInputText,
|
||||
};
|
||||
|
||||
bottomChildHeight = ListSelection.CalculateHeight(listProps);
|
||||
@@ -368,25 +374,25 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
this._listSelection.Props = listProps;
|
||||
bottomChild = this._listSelection;
|
||||
}
|
||||
else if (props.Mode == BottomPanelMode.Streaming)
|
||||
else if (state.Mode == BottomPanelMode.Streaming)
|
||||
{
|
||||
TextInputProps textInputProps;
|
||||
if (props.InputEnabled)
|
||||
if (state.InputEnabled)
|
||||
{
|
||||
textInputProps = new TextInputProps
|
||||
{
|
||||
Prompt = props.Prompt,
|
||||
Prompt = state.Prompt,
|
||||
Text = state.InputText,
|
||||
Placeholder = props.Placeholder,
|
||||
Placeholder = state.Placeholder,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
textInputProps = new TextInputProps
|
||||
{
|
||||
Prompt = props.Prompt,
|
||||
Prompt = state.Prompt,
|
||||
Text = "",
|
||||
Placeholder = props.StreamingPrompt,
|
||||
Placeholder = state.StreamingPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -400,9 +406,9 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
{
|
||||
var textInputProps = new TextInputProps
|
||||
{
|
||||
Prompt = props.Prompt,
|
||||
Prompt = state.Prompt,
|
||||
Text = state.InputText,
|
||||
Placeholder = props.Placeholder,
|
||||
Placeholder = state.Placeholder,
|
||||
};
|
||||
|
||||
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
|
||||
@@ -415,46 +421,52 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
var ruleProps = new TopBottomRuleProps
|
||||
{
|
||||
Width = state.ConsoleWidth,
|
||||
Color = props.ModeColor,
|
||||
Color = state.ModeColor,
|
||||
Children = [bottomChild],
|
||||
};
|
||||
|
||||
// Calculate the agent status height
|
||||
var agentStatusProps = new AgentStatusProps
|
||||
{
|
||||
ShowSpinner = props.ShowSpinner,
|
||||
UsageText = props.UsageText,
|
||||
ShowSpinner = state.ShowSpinner,
|
||||
UsageText = state.UsageText,
|
||||
};
|
||||
int agentStatusHeight = AgentStatus.CalculateHeight(agentStatusProps);
|
||||
|
||||
// Calculate the mode-and-help height
|
||||
var modeAndHelpProps = new AgentModeAndHelpProps
|
||||
{
|
||||
Mode = props.ModeText,
|
||||
ModeColor = props.ModeColor,
|
||||
HelpText = props.HelpText,
|
||||
Mode = state.ModeText,
|
||||
ModeColor = state.ModeColor,
|
||||
HelpText = state.HelpText,
|
||||
};
|
||||
int modeAndHelpHeight = AgentModeAndHelp.CalculateHeight(modeAndHelpProps);
|
||||
|
||||
// Hide agent status and mode/help during follow-up questions (ListSelection mode)
|
||||
// as they clutter the UI and aren't relevant.
|
||||
bool showStatusAndHelp = state.Mode != BottomPanelMode.ListSelection;
|
||||
int agentStatusHeight = showStatusAndHelp ? AgentStatus.CalculateHeight(agentStatusProps) : 0;
|
||||
int modeAndHelpHeight = showStatusAndHelp ? AgentModeAndHelp.CalculateHeight(modeAndHelpProps) : 0;
|
||||
|
||||
int ruleHeight = TopBottomRule.CalculateHeight(ruleProps);
|
||||
int scrollBottom = Math.Max(1, state.ConsoleHeight - ruleHeight - textPanelHeight - agentStatusHeight - queuedPanelHeight - modeAndHelpHeight);
|
||||
int nonScrollHeight = ruleHeight + textPanelHeight + agentStatusHeight + queuedPanelHeight + modeAndHelpHeight + 1; // +1 for bottom padding
|
||||
int scrollBottom = Math.Max(1, state.ConsoleHeight - nonScrollHeight);
|
||||
|
||||
// If scroll region changed or a clear is needed, reset everything
|
||||
if (this._resizedSinceLastRender || (this.ScrollRegionBottom != 0 && scrollBottom != this.ScrollRegionBottom))
|
||||
if (this._resizedSinceLastRender || (this._scrollRegionBottom != 0 && scrollBottom != this._scrollRegionBottom))
|
||||
{
|
||||
// Reset scroll region to full screen before erasing so the erase covers all rows —
|
||||
// some terminals only erase within the active DECSTBM region.
|
||||
System.Console.Write(AnsiEscapes.ResetScrollRegion);
|
||||
System.Console.Write(AnsiEscapes.EraseEntireScreen);
|
||||
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
|
||||
this._textScrollPanel.Reset();
|
||||
this._resizedSinceLastRender = false;
|
||||
}
|
||||
|
||||
this.ScrollRegionBottom = scrollBottom;
|
||||
this._scrollRegionBottom = scrollBottom;
|
||||
|
||||
System.Console.Write(AnsiEscapes.SetScrollRegion(scrollBottom));
|
||||
|
||||
// Render text scroll panel in the scroll area (all items except the last)
|
||||
IReadOnlyList<object> scrollItems = props.ScrollItems.Count > 1
|
||||
? props.ScrollItems.Take(props.ScrollItems.Count - 1).ToList()
|
||||
IReadOnlyList<string> scrollItems = state.ScrollAreaContentItems.Count > 1
|
||||
? state.ScrollAreaContentItems.Take(state.ScrollAreaContentItems.Count - 1).ToList()
|
||||
: [];
|
||||
|
||||
this._textScrollPanel.X = 1;
|
||||
@@ -486,18 +498,21 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
this._queuedPanel.Height = queuedPanelHeight;
|
||||
this._queuedPanel.Props = new TextPanelProps
|
||||
{
|
||||
Items = props.QueuedItems,
|
||||
Items = state.QueuedItems,
|
||||
};
|
||||
this._queuedPanel.Render();
|
||||
|
||||
// Render the agent status line between queued items and rule
|
||||
int agentStatusY = queuedPanelY + queuedPanelHeight;
|
||||
this._agentStatus.X = 1;
|
||||
this._agentStatus.Y = agentStatusY;
|
||||
this._agentStatus.Width = state.ConsoleWidth;
|
||||
this._agentStatus.Height = agentStatusHeight;
|
||||
this._agentStatus.Props = agentStatusProps;
|
||||
this._agentStatus.Render();
|
||||
if (showStatusAndHelp)
|
||||
{
|
||||
this._agentStatus.X = 1;
|
||||
this._agentStatus.Y = agentStatusY;
|
||||
this._agentStatus.Width = state.ConsoleWidth;
|
||||
this._agentStatus.Height = agentStatusHeight;
|
||||
this._agentStatus.Props = agentStatusProps;
|
||||
this._agentStatus.Render();
|
||||
}
|
||||
|
||||
// Render the bottom rule + child below the agent status
|
||||
this._rule.X = 1;
|
||||
@@ -506,24 +521,27 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
this._rule.Render();
|
||||
|
||||
// Render the mode-and-help line below the bottom rule
|
||||
int modeAndHelpY = this._rule.Y + ruleHeight;
|
||||
this._modeAndHelp.X = 1;
|
||||
this._modeAndHelp.Y = modeAndHelpY;
|
||||
this._modeAndHelp.Width = state.ConsoleWidth;
|
||||
this._modeAndHelp.Height = modeAndHelpHeight;
|
||||
this._modeAndHelp.Props = modeAndHelpProps;
|
||||
this._modeAndHelp.Render();
|
||||
if (showStatusAndHelp)
|
||||
{
|
||||
int modeAndHelpY = this._rule.Y + ruleHeight;
|
||||
this._modeAndHelp.X = 1;
|
||||
this._modeAndHelp.Y = modeAndHelpY;
|
||||
this._modeAndHelp.Width = state.ConsoleWidth;
|
||||
this._modeAndHelp.Height = modeAndHelpHeight;
|
||||
this._modeAndHelp.Props = modeAndHelpProps;
|
||||
this._modeAndHelp.Render();
|
||||
}
|
||||
|
||||
// Position cursor for natural typing appearance
|
||||
this.PositionCursor(props, state);
|
||||
this.PositionCursor(state);
|
||||
}
|
||||
|
||||
private void PositionCursor(HarnessAppComponentProps props, HarnessAppComponentState state)
|
||||
private void PositionCursor(HarnessAppComponentState state)
|
||||
{
|
||||
if (props.Mode == BottomPanelMode.TextInput
|
||||
|| (props.Mode == BottomPanelMode.Streaming && props.InputEnabled))
|
||||
if (state.Mode == BottomPanelMode.TextInput
|
||||
|| (state.Mode == BottomPanelMode.Streaming && state.InputEnabled))
|
||||
{
|
||||
int promptLength = props.Prompt.Length;
|
||||
int promptLength = state.Prompt.Length;
|
||||
int textWidth = state.ConsoleWidth - promptLength;
|
||||
int textLength = state.InputText.Length;
|
||||
|
||||
@@ -540,13 +558,13 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(textInputY + cursorRow, promptLength + cursorCol + 1));
|
||||
}
|
||||
}
|
||||
else if (props.Mode == BottomPanelMode.ListSelection
|
||||
&& props.ListCustomTextPlaceholder != null
|
||||
&& state.SelectedIndex == props.Items.Count)
|
||||
else if (state.Mode == BottomPanelMode.ListSelection
|
||||
&& state.ListSelectionCustomTextPlaceholder != null
|
||||
&& state.ListSelectionIndex == state.ListSelectionOptions.Count)
|
||||
{
|
||||
int titleLines = props.ListTitle?.Split('\n').Length ?? 0;
|
||||
int customOptionY = this._rule.Y + 1 + titleLines + props.Items.Count;
|
||||
int cursorCol = 2 + state.ListInputText.Length + 1;
|
||||
int titleLines = state.ListSelectionTitle?.Split('\n').Length ?? 0;
|
||||
int customOptionY = this._rule.Y + 1 + titleLines + state.ListSelectionOptions.Count;
|
||||
int cursorCol = 2 + state.ListSelectionCustomInputText.Length + 1;
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Determines which component is shown in the bottom panel.
|
||||
/// </summary>
|
||||
public enum BottomPanelMode
|
||||
{
|
||||
/// <summary>Show the text input component for user input.</summary>
|
||||
TextInput,
|
||||
|
||||
/// <summary>Show the list selection component for interactive prompts.</summary>
|
||||
ListSelection,
|
||||
|
||||
/// <summary>Show a disabled input indicator during agent streaming.</summary>
|
||||
Streaming,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal state for <see cref="HarnessAppComponent"/>. All UI fields that may
|
||||
/// change after construction live here; they are mutated exclusively via
|
||||
/// <see cref="ConsoleReactiveComponent{TProps,TState}.SetState"/> by the
|
||||
/// owning <see cref="HarnessConsoleUXStateDriver"/>.
|
||||
/// </summary>
|
||||
public record HarnessAppComponentState : ConsoleReactiveState
|
||||
{
|
||||
// --- Console dimensions ---
|
||||
|
||||
/// <summary>Gets the current console width in columns.</summary>
|
||||
public int ConsoleWidth { get; init; }
|
||||
|
||||
/// <summary>Gets the current console height in rows.</summary>
|
||||
public int ConsoleHeight { get; init; }
|
||||
|
||||
// --- Bottom panel mode ---
|
||||
|
||||
/// <summary>Gets the bottom panel mode.</summary>
|
||||
public BottomPanelMode Mode { get; init; } = BottomPanelMode.TextInput;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the queue of follow-up questions waiting for user answers. The head
|
||||
/// (<c>[0]</c>) is the question currently being displayed; subsequent items
|
||||
/// are dispatched in order as each is answered. While this queue is non-empty,
|
||||
/// the next user submission is treated as the answer to the head question
|
||||
/// instead of going to the agent runner's normal input handler.
|
||||
/// </summary>
|
||||
public IReadOnlyList<FollowUpQuestion> PendingQuestions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the accumulated follow-up response messages collected during the
|
||||
/// current agent turn — both direct <see cref="FollowUpMessage"/>s emitted
|
||||
/// by observers and continuation results from answered questions. Consumed
|
||||
/// by the runner via <see cref="IUXStateDriver.TakeFollowUpResponses"/>
|
||||
/// before the next agent invocation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ChatMessage> AccumulatedFollowUpResponses { get; init; } = [];
|
||||
|
||||
// --- Text input (active in TextInput / Streaming modes) ---
|
||||
|
||||
/// <summary>Gets the prompt string for text input mode.</summary>
|
||||
public string Prompt { get; init; } = "> ";
|
||||
|
||||
/// <summary>Gets the placeholder text shown when the input is empty.</summary>
|
||||
public string Placeholder { get; init; } = "";
|
||||
|
||||
/// <summary>Gets the current input text being typed.</summary>
|
||||
public string InputText { get; init; } = "";
|
||||
|
||||
/// <summary>Gets a value indicating whether input is enabled during streaming.</summary>
|
||||
public bool InputEnabled { get; init; }
|
||||
|
||||
/// <summary>Gets the prompt to show during streaming when input is disabled.</summary>
|
||||
public string StreamingPrompt { get; init; } = "(agent is running...)";
|
||||
|
||||
// --- List selection (active in ListSelection mode) ---
|
||||
|
||||
/// <summary>Gets the title text displayed above the list selection (for interactive prompts).</summary>
|
||||
public string? ListSelectionTitle { get; init; }
|
||||
|
||||
/// <summary>Gets the list selection options.</summary>
|
||||
public IReadOnlyList<string> ListSelectionOptions { get; init; } = [];
|
||||
|
||||
/// <summary>Gets the highlighted option index in list selection mode.</summary>
|
||||
public int ListSelectionIndex { get; init; }
|
||||
|
||||
/// <summary>Gets the placeholder text for the custom text input option in the list.</summary>
|
||||
public string? ListSelectionCustomTextPlaceholder { get; init; }
|
||||
|
||||
/// <summary>Gets the current text being typed into the list's custom text option.</summary>
|
||||
public string ListSelectionCustomInputText { get; init; } = "";
|
||||
|
||||
/// <summary>Gets the highlight color for the active list item.</summary>
|
||||
public ConsoleColor ListHighlightColor { get; init; } = ConsoleColor.Cyan;
|
||||
|
||||
// --- Scroll / output area ---
|
||||
|
||||
/// <summary>Gets the items rendered in the scroll-area. Each item is a pre-rendered
|
||||
/// console string (may include ANSI escape sequences and newlines).</summary>
|
||||
public IReadOnlyList<string> ScrollAreaContentItems { get; init; } = [];
|
||||
|
||||
/// <summary>Gets the queued input items to display above the rule. Each item is a
|
||||
/// pre-rendered console string (may include ANSI escape sequences and newlines).</summary>
|
||||
public IReadOnlyList<string> QueuedItems { get; init; } = [];
|
||||
|
||||
// --- Agent mode + status display ---
|
||||
|
||||
/// <summary>Gets the foreground color for the rule borders and mode label.</summary>
|
||||
public ConsoleColor? ModeColor { get; init; }
|
||||
|
||||
/// <summary>Gets the current mode name displayed below the bottom rule (e.g. "plan").</summary>
|
||||
public string? ModeText { get; init; }
|
||||
|
||||
/// <summary>Gets the help text displayed below the bottom rule (available commands).</summary>
|
||||
public string? HelpText { get; init; }
|
||||
|
||||
/// <summary>Gets a value indicating whether the agent status spinner is visible.</summary>
|
||||
public bool ShowSpinner { get; init; }
|
||||
|
||||
/// <summary>Gets the formatted token usage text to display in the status bar.</summary>
|
||||
public string? UsageText { get; init; }
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.Shared.Console.Commands;
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
@@ -15,244 +13,58 @@ public static class HarnessConsole
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs an interactive console session with the specified agent.
|
||||
/// Supports streaming output, tool call display, spinner animation,
|
||||
/// optional planning UX with structured output, and the <c>/todos</c> command.
|
||||
/// Constructs the reactive UI component and the <see cref="HarnessAgentRunner"/>,
|
||||
/// wires them together, and awaits the component's <see cref="HarnessAppComponent.ShutdownTask"/>
|
||||
/// (which completes when the user types <c>/exit</c>).
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent to interact with.</param>
|
||||
/// <param name="title">The title displayed in the console header.</param>
|
||||
/// <param name="userPrompt">A short prompt to the user, displayed below the title.</param>
|
||||
/// <param name="userPrompt">A short prompt to the user, displayed as a placeholder in the input area.</param>
|
||||
/// <param name="options">Optional configuration options for the console session.</param>
|
||||
public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt, HarnessConsoleOptions? options = null)
|
||||
public static async Task RunAgentAsync(AIAgent agent, string userPrompt, HarnessConsoleOptions? options = null)
|
||||
{
|
||||
options ??= new();
|
||||
|
||||
if (options.EnablePlanningUx
|
||||
&& (string.IsNullOrWhiteSpace(options.PlanningModeName) || string.IsNullOrWhiteSpace(options.ExecutionModeName)))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"When EnablePlanningUx is true, both PlanningModeName and ExecutionModeName must be configured.",
|
||||
nameof(options));
|
||||
}
|
||||
// Null means use defaults; an explicit (possibly empty) list means use exactly what was provided.
|
||||
var observers = options.Observers
|
||||
?? HarnessConsoleOptions.BuildDefaultObservers();
|
||||
var commandHandlers = options.CommandHandlers
|
||||
?? HarnessConsoleOptions.BuildDefaultCommandHandlers(agent, options.ModeColors);
|
||||
|
||||
var todoProvider = agent.GetService<TodoProvider>();
|
||||
var modeProvider = agent.GetService<AgentModeProvider>();
|
||||
var messageInjector = agent.GetService<MessageInjectingChatClient>();
|
||||
|
||||
var commandHandlers = new List<CommandHandler>
|
||||
{
|
||||
new TodoCommandHandler(todoProvider),
|
||||
new ModeCommandHandler(modeProvider, options.ModeColors),
|
||||
};
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
using var ux = new HarnessUXContainer(
|
||||
using var component = new HarnessAppComponent(
|
||||
placeholder: userPrompt,
|
||||
initialMode: modeProvider?.GetMode(session),
|
||||
inputEnabled: messageInjector is not null,
|
||||
runnerFactory: ux => new HarnessAgentRunner(
|
||||
agent: agent,
|
||||
session: session,
|
||||
modeProvider: modeProvider,
|
||||
messageInjector: messageInjector,
|
||||
commandHandlers: commandHandlers,
|
||||
observers: observers,
|
||||
ux: ux),
|
||||
modeColors: options.ModeColors);
|
||||
|
||||
// Streaming-mode submissions are enqueued for injection; the queued display
|
||||
// is then refreshed from the injector's current pending list.
|
||||
ux.StreamingInputReceived += (sender, e) =>
|
||||
// Trigger the initial render of the component now that state is seeded.
|
||||
component.Render();
|
||||
|
||||
try
|
||||
{
|
||||
if (messageInjector is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
messageInjector.EnqueueMessages(session, [new ChatMessage(ChatRole.User, e.Text)]);
|
||||
ux.ShowQueuedMessages(messageInjector.GetPendingMessages(session));
|
||||
};
|
||||
|
||||
var commandHelp = commandHandlers
|
||||
.Select(h => h.GetHelpText())
|
||||
.Where(t => t is not null)
|
||||
.Append("exit (quit)")!;
|
||||
|
||||
ux.Initialize(title, commandHelp!, messageInjector is not null);
|
||||
|
||||
string userInput = await ux.WaitForInputAsync();
|
||||
|
||||
while (!string.IsNullOrWhiteSpace(userInput) && !userInput.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
await component.ShutdownTask.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ux.WriteUserInputEcho(userInput);
|
||||
|
||||
// Check command handlers first — first one to handle wins.
|
||||
bool handled = false;
|
||||
foreach (var handler in commandHandlers)
|
||||
{
|
||||
if (await handler.TryHandleAsync(userInput, session, ux).ConfigureAwait(false))
|
||||
{
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!handled)
|
||||
{
|
||||
await RunAgentTurnAsync(agent, session, modeProvider, messageInjector, options, ux, userInput);
|
||||
}
|
||||
|
||||
ux.CurrentMode = modeProvider?.GetMode(session);
|
||||
userInput = await ux.WaitForInputAsync();
|
||||
component.Deactivate();
|
||||
}
|
||||
|
||||
ux.Deactivate();
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(AnsiEscapes.ResetScrollRegion);
|
||||
System.Console.Write(AnsiEscapes.EraseEntireScreen);
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(1, 1));
|
||||
System.Console.WriteLine("Goodbye!");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one or more agent invocations for a single user turn, using the current
|
||||
/// observers. Re-invokes automatically for tool approvals and mode-driven follow-ups
|
||||
/// (e.g., planning clarification loops).
|
||||
/// </summary>
|
||||
private static async Task RunAgentTurnAsync(
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
AgentModeProvider? modeProvider,
|
||||
MessageInjectingChatClient? messageInjector,
|
||||
HarnessConsoleOptions options,
|
||||
HarnessUXContainer ux,
|
||||
string userInput)
|
||||
{
|
||||
IList<ChatMessage>? nextMessages = [new ChatMessage(ChatRole.User, userInput)];
|
||||
IReadOnlyList<ChatMessage> lastPendingMessages = messageInjector?.GetPendingMessages(session) ?? [];
|
||||
|
||||
while (nextMessages is not null)
|
||||
{
|
||||
var observers = CreateObservers(options, modeProvider, session);
|
||||
|
||||
var runOptions = new AgentRunOptions();
|
||||
foreach (var observer in observers)
|
||||
{
|
||||
observer.ConfigureRunOptions(runOptions);
|
||||
}
|
||||
|
||||
ux.CurrentMode = modeProvider?.GetMode(session);
|
||||
ux.BeginStreaming();
|
||||
ux.BeginStreamingOutput();
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var update in agent.RunStreamingAsync(nextMessages, session, runOptions))
|
||||
{
|
||||
// Update mode color if the mode changed during streaming.
|
||||
if (modeProvider is not null)
|
||||
{
|
||||
string currentMode = modeProvider.GetMode(session);
|
||||
if (currentMode != ux.CurrentMode)
|
||||
{
|
||||
ux.CurrentMode = currentMode;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
foreach (var observer in observers)
|
||||
{
|
||||
await observer.OnContentAsync(ux, content);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
foreach (var observer in observers)
|
||||
{
|
||||
await observer.OnTextAsync(ux, update.Text);
|
||||
}
|
||||
}
|
||||
|
||||
SyncQueuedMessageDisplay(messageInjector, session, ux, ref lastPendingMessages);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red);
|
||||
}
|
||||
|
||||
// Final sync after streaming — messages may have been consumed during the last iteration.
|
||||
SyncQueuedMessageDisplay(messageInjector, session, ux, ref lastPendingMessages);
|
||||
|
||||
// Stop spinner before observer completions (which may prompt for input).
|
||||
ux.StopSpinner();
|
||||
|
||||
// Close the streaming output to provide visual separation from observer output.
|
||||
await ux.EndStreamingOutputAsync();
|
||||
|
||||
var combinedMessages = new List<ChatMessage>();
|
||||
bool hasObserverMessages = false;
|
||||
foreach (var observer in observers)
|
||||
{
|
||||
var messages = await observer.OnStreamCompleteAsync(ux, agent, session, options);
|
||||
if (messages is { Count: > 0 })
|
||||
{
|
||||
combinedMessages.AddRange(messages);
|
||||
hasObserverMessages = true;
|
||||
}
|
||||
}
|
||||
|
||||
await ux.WriteNoTextWarningAsync(hasFollowUpMessages: hasObserverMessages);
|
||||
|
||||
ux.EndStreaming();
|
||||
|
||||
nextMessages = combinedMessages.Count > 0 ? combinedMessages : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronizes the queued items display with the message injector's pending messages.
|
||||
/// Messages that have been consumed (drained by the service) are echoed to the output
|
||||
/// area as regular user-input entries.
|
||||
/// </summary>
|
||||
private static void SyncQueuedMessageDisplay(
|
||||
MessageInjectingChatClient? messageInjector,
|
||||
AgentSession session,
|
||||
HarnessUXContainer ux,
|
||||
ref IReadOnlyList<ChatMessage> lastPendingMessages)
|
||||
{
|
||||
if (messageInjector is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pending = messageInjector.GetPendingMessages(session);
|
||||
|
||||
// If previously pending messages exceed current pending count, some were consumed.
|
||||
int consumedCount = lastPendingMessages.Count - pending.Count;
|
||||
for (int i = 0; i < consumedCount && i < lastPendingMessages.Count; i++)
|
||||
{
|
||||
string text = lastPendingMessages[i].Text ?? string.Empty;
|
||||
ux.WriteUserInputEcho(text);
|
||||
}
|
||||
|
||||
lastPendingMessages = pending;
|
||||
ux.ShowQueuedMessages(pending);
|
||||
}
|
||||
|
||||
private static List<ConsoleObserver> CreateObservers(HarnessConsoleOptions options, AgentModeProvider? modeProvider, AgentSession session)
|
||||
{
|
||||
var observers = new List<ConsoleObserver>
|
||||
{
|
||||
new ToolCallDisplayObserver(),
|
||||
new ToolApprovalObserver(),
|
||||
new ErrorDisplayObserver(),
|
||||
new ReasoningDisplayObserver(),
|
||||
new UsageDisplayObserver(options.MaxContextWindowTokens, options.MaxOutputTokens),
|
||||
};
|
||||
|
||||
if (options.EnablePlanningUx
|
||||
&& modeProvider is not null
|
||||
&& string.Equals(modeProvider.GetMode(session), options.PlanningModeName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
observers.Add(new PlanningOutputObserver(modeProvider));
|
||||
}
|
||||
else
|
||||
{
|
||||
observers.Add(new TextOutputObserver());
|
||||
}
|
||||
|
||||
return observers;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
using Harness.Shared.Console.Commands;
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
@@ -8,45 +14,120 @@ namespace Harness.Shared.Console;
|
||||
public class HarnessConsoleOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the optional maximum context window size in tokens.
|
||||
/// When set, token usage is displayed as a percentage of the budget.
|
||||
/// Gets or sets the list of console observers that participate in the agent response
|
||||
/// streaming lifecycle. Use the factory methods on this class to create common observer sets.
|
||||
/// When <see langword="null"/> (the default), a default set of observers is used.
|
||||
/// Set to an empty list to disable all observers.
|
||||
/// </summary>
|
||||
public int? MaxContextWindowTokens { get; set; }
|
||||
public IReadOnlyList<ConsoleObserver>? Observers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the optional maximum output tokens.
|
||||
/// Used with <see cref="MaxContextWindowTokens"/> to show input/output budget breakdown.
|
||||
/// Gets or sets the list of command handlers to check before sending user input to the agent.
|
||||
/// Use <see cref="BuildDefaultCommandHandlers"/> to create the default set.
|
||||
/// When <see langword="null"/> (the default), a default set of handlers is used.
|
||||
/// Set to an empty list to disable all command handlers.
|
||||
/// </summary>
|
||||
public int? MaxOutputTokens { get; set; }
|
||||
public IReadOnlyList<CommandHandler>? CommandHandlers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the planning UX is enabled.
|
||||
/// When <see langword="true"/> and the agent is in the mode specified by <see cref="PlanningModeName"/>,
|
||||
/// the console uses structured output to present clarification questions and approval requests
|
||||
/// instead of streaming free-form text.
|
||||
/// The default mode-to-color mapping used when no custom <see cref="ModeColors"/> are provided.
|
||||
/// </summary>
|
||||
/// <value>Defaults to <see langword="false"/>.</value>
|
||||
public bool EnablePlanningUx { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the agent mode that activates the planning UX.
|
||||
/// Must be set when <see cref="EnablePlanningUx"/> is <see langword="true"/>.
|
||||
/// </summary>
|
||||
public string? PlanningModeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the agent mode to switch to when the user approves a plan.
|
||||
/// Must be set when <see cref="EnablePlanningUx"/> is <see langword="true"/>.
|
||||
/// </summary>
|
||||
public string? ExecutionModeName { get; set; }
|
||||
public static readonly IReadOnlyDictionary<string, ConsoleColor> DefaultModeColors = new ReadOnlyDictionary<string, ConsoleColor>(
|
||||
new Dictionary<string, ConsoleColor>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["plan"] = ConsoleColor.Cyan,
|
||||
["execute"] = ConsoleColor.Green,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a mapping of agent mode names to console colors.
|
||||
/// When a mode is not found in this dictionary, the default color (<see cref="ConsoleColor.Gray"/>) is used.
|
||||
/// </summary>
|
||||
public Dictionary<string, ConsoleColor> ModeColors { get; set; } = new(StringComparer.OrdinalIgnoreCase)
|
||||
public Dictionary<string, ConsoleColor> ModeColors { get; set; } = new(DefaultModeColors, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Creates the default set of observers without planning support.
|
||||
/// Includes tool call display, tool approval, error display, reasoning display,
|
||||
/// usage display, and text output.
|
||||
/// </summary>
|
||||
/// <param name="maxContextWindowTokens">Optional maximum context window size in tokens for usage display.</param>
|
||||
/// <param name="maxOutputTokens">Optional maximum output tokens for usage display.</param>
|
||||
/// <param name="toolFormatters">Optional tool call formatters. When <see langword="null"/>,
|
||||
/// each observer uses the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/>.</param>
|
||||
/// <returns>A list of observers for a standard (non-planning) console session.</returns>
|
||||
public static List<ConsoleObserver> BuildDefaultObservers(
|
||||
int? maxContextWindowTokens = null,
|
||||
int? maxOutputTokens = null,
|
||||
IReadOnlyList<ToolCallFormatter>? toolFormatters = null)
|
||||
{
|
||||
["plan"] = ConsoleColor.Cyan,
|
||||
["execute"] = ConsoleColor.Green,
|
||||
};
|
||||
return
|
||||
[
|
||||
new ToolCallDisplayObserver(toolFormatters),
|
||||
new ToolApprovalObserver(toolFormatters),
|
||||
new ErrorDisplayObserver(),
|
||||
new ReasoningDisplayObserver(),
|
||||
new UsageDisplayObserver(maxContextWindowTokens, maxOutputTokens),
|
||||
new TextOutputObserver(),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the default set of observers with planning support.
|
||||
/// Includes a <see cref="PlanningOutputObserver"/> instead of <see cref="TextOutputObserver"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent, used to resolve <see cref="AgentModeProvider"/>.</param>
|
||||
/// <param name="planModeName">The mode name that represents the planning mode.</param>
|
||||
/// <param name="executionModeName">The mode name to switch to when the user approves a plan.</param>
|
||||
/// <param name="modeColors">Optional mode-to-color mapping for display.
|
||||
/// Defaults to <see cref="DefaultModeColors"/> when <see langword="null"/>.</param>
|
||||
/// <param name="maxContextWindowTokens">Optional maximum context window size in tokens for usage display.</param>
|
||||
/// <param name="maxOutputTokens">Optional maximum output tokens for usage display.</param>
|
||||
/// <param name="toolFormatters">Optional tool call formatters. When <see langword="null"/>,
|
||||
/// each observer uses the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/>.</param>
|
||||
/// <returns>A list of observers for a planning-enabled console session.</returns>
|
||||
public static List<ConsoleObserver> BuildObserversWithPlanning(
|
||||
AIAgent agent,
|
||||
string planModeName,
|
||||
string executionModeName,
|
||||
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null,
|
||||
int? maxContextWindowTokens = null,
|
||||
int? maxOutputTokens = null,
|
||||
IReadOnlyList<ToolCallFormatter>? toolFormatters = null)
|
||||
{
|
||||
var modeProvider = agent.GetService<AgentModeProvider>()
|
||||
?? throw new InvalidOperationException("Planning requires an AgentModeProvider service on the agent.");
|
||||
|
||||
return
|
||||
[
|
||||
new ToolCallDisplayObserver(toolFormatters),
|
||||
new ToolApprovalObserver(toolFormatters),
|
||||
new ErrorDisplayObserver(),
|
||||
new ReasoningDisplayObserver(),
|
||||
new UsageDisplayObserver(maxContextWindowTokens, maxOutputTokens),
|
||||
new PlanningOutputObserver(modeProvider, planModeName, executionModeName, modeColors ?? DefaultModeColors),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the default set of command handlers.
|
||||
/// Includes exit, todo, and mode command handlers.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent, used to resolve <see cref="TodoProvider"/> and <see cref="AgentModeProvider"/>.</param>
|
||||
/// <param name="modeColors">Optional mode-to-color mapping for the mode command display.
|
||||
/// Defaults to <see cref="DefaultModeColors"/> when <see langword="null"/>.</param>
|
||||
/// <returns>A list of command handlers for a standard console session.</returns>
|
||||
public static List<CommandHandler> BuildDefaultCommandHandlers(
|
||||
AIAgent agent,
|
||||
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
var todoProvider = agent.GetService<TodoProvider>();
|
||||
var modeProvider = agent.GetService<AgentModeProvider>();
|
||||
|
||||
return
|
||||
[
|
||||
new ExitCommandHandler(),
|
||||
new TodoCommandHandler(todoProvider),
|
||||
new ModeCommandHandler(modeProvider, modeColors ?? DefaultModeColors),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IUXStateDriver"/> implementation. Owned by
|
||||
/// <see cref="HarnessAppComponent"/>; mutates the component's state via a
|
||||
/// <c>SetState</c>-style callback. Each public operation updates state and lets
|
||||
/// the component's render-skip optimization handle the actual draw.
|
||||
/// </summary>
|
||||
internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver
|
||||
{
|
||||
private readonly Func<HarnessAppComponentState> _getState;
|
||||
private readonly Action<HarnessAppComponentState> _setState;
|
||||
private readonly Action _requestShutdown;
|
||||
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||
private readonly List<string> _outputItems = [];
|
||||
private readonly object _stateLock = new();
|
||||
|
||||
private OutputEntryType? _lastEntryType;
|
||||
private bool _hasReceivedAnyText;
|
||||
private OutputEntry? _currentStreamingEntry;
|
||||
private int _currentStreamingEntryIndex = -1;
|
||||
private string? _currentMode;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HarnessConsoleUXStateDriver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="getState">Returns the component's current state.</param>
|
||||
/// <param name="setState">Replaces the component's state and triggers a re-render.</param>
|
||||
/// <param name="requestShutdown">Callback invoked when a command handler requests application shutdown.</param>
|
||||
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
||||
public HarnessConsoleUXStateDriver(
|
||||
Func<HarnessAppComponentState> getState,
|
||||
Action<HarnessAppComponentState> setState,
|
||||
Action requestShutdown,
|
||||
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
this._getState = getState;
|
||||
this._setState = setState;
|
||||
this._requestShutdown = requestShutdown;
|
||||
this._modeColors = modeColors;
|
||||
this._currentMode = getState().ModeText;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string? CurrentMode
|
||||
{
|
||||
get => this._currentMode;
|
||||
set
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
this._currentMode = value;
|
||||
return s with
|
||||
{
|
||||
ModeColor = ModeColors.Get(value, this._modeColors),
|
||||
ModeText = value,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void BeginStreaming() =>
|
||||
this.UpdateState(s => s with
|
||||
{
|
||||
Mode = BottomPanelMode.Streaming,
|
||||
ShowSpinner = true,
|
||||
});
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void StopSpinner() =>
|
||||
this.UpdateState(s => s with { ShowSpinner = false });
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void EndStreaming() =>
|
||||
this.UpdateState(s => s with
|
||||
{
|
||||
Mode = BottomPanelMode.TextInput,
|
||||
ShowSpinner = false,
|
||||
});
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void BeginStreamingOutput()
|
||||
{
|
||||
lock (this._stateLock)
|
||||
{
|
||||
this._hasReceivedAnyText = false;
|
||||
this._currentStreamingEntry = null;
|
||||
this._currentStreamingEntryIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SetUsageText(string usageText) =>
|
||||
this.UpdateState(s => s with { UsageText = usageText });
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SetQueuedMessages(IReadOnlyList<ChatMessage> pending)
|
||||
{
|
||||
var newQueued = new List<string>(pending.Count);
|
||||
foreach (var msg in pending)
|
||||
{
|
||||
string text = msg.Text ?? string.Empty;
|
||||
newQueued.Add(RenderEntry($" 💬 {text}\n", ConsoleColor.DarkGray));
|
||||
}
|
||||
|
||||
this.UpdateState(s => s with { QueuedItems = newQueued });
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void QueueFollowUpQuestions(IReadOnlyList<FollowUpQuestion> questions)
|
||||
{
|
||||
if (questions.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
bool wasEmpty = s.PendingQuestions.Count == 0;
|
||||
|
||||
var combined = new List<FollowUpQuestion>(s.PendingQuestions.Count + questions.Count);
|
||||
combined.AddRange(s.PendingQuestions);
|
||||
combined.AddRange(questions);
|
||||
|
||||
HarnessAppComponentState next = s with { PendingQuestions = combined };
|
||||
|
||||
if (wasEmpty)
|
||||
{
|
||||
next = this.ConfigureForHeadQuestion(next, combined[0]);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void AddFollowUpResponse(ChatMessage response)
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
var combined = new List<ChatMessage>(s.AccumulatedFollowUpResponses.Count + 1);
|
||||
combined.AddRange(s.AccumulatedFollowUpResponses);
|
||||
combined.Add(response);
|
||||
return s with { AccumulatedFollowUpResponses = combined };
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void AdvanceFollowUpQuestion()
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
if (s.PendingQuestions.Count == 0)
|
||||
{
|
||||
return s;
|
||||
}
|
||||
|
||||
var remaining = s.PendingQuestions.Skip(1).ToList();
|
||||
HarnessAppComponentState next = s with { PendingQuestions = remaining };
|
||||
|
||||
if (remaining.Count > 0)
|
||||
{
|
||||
return this.ConfigureForHeadQuestion(next, remaining[0]);
|
||||
}
|
||||
|
||||
return next with
|
||||
{
|
||||
Mode = BottomPanelMode.TextInput,
|
||||
ListSelectionOptions = [],
|
||||
ListSelectionTitle = null,
|
||||
ListSelectionCustomTextPlaceholder = null,
|
||||
ListSelectionIndex = 0,
|
||||
ListSelectionCustomInputText = "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<ChatMessage> TakeFollowUpResponses()
|
||||
{
|
||||
return this.UpdateState(s =>
|
||||
{
|
||||
IReadOnlyList<ChatMessage> responses = s.AccumulatedFollowUpResponses;
|
||||
if (responses.Count == 0)
|
||||
{
|
||||
return (s, responses);
|
||||
}
|
||||
|
||||
return (s with { AccumulatedFollowUpResponses = [] }, responses);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the bottom-panel display fields on the supplied state for the
|
||||
/// given head question. For text questions, also writes the prompt as an
|
||||
/// info line above the input row as a side effect.
|
||||
/// </summary>
|
||||
private HarnessAppComponentState ConfigureForHeadQuestion(HarnessAppComponentState state, FollowUpQuestion question)
|
||||
{
|
||||
if (question is ChoiceFollowUpQuestion choice)
|
||||
{
|
||||
return state with
|
||||
{
|
||||
Mode = BottomPanelMode.ListSelection,
|
||||
ListSelectionOptions = choice.Choices.ToList(),
|
||||
ListSelectionTitle = choice.Prompt,
|
||||
ListSelectionCustomTextPlaceholder = choice.AllowCustomText ? "✏️ Type a custom response..." : null,
|
||||
ListSelectionIndex = 0,
|
||||
ListSelectionCustomInputText = "",
|
||||
};
|
||||
}
|
||||
|
||||
// Text question — prompt is rendered as an info line above the input row.
|
||||
// We append entries and capture the scroll snapshot inline so the caller's
|
||||
// single _setState picks up both the new output and the UI mode change.
|
||||
ConsoleColor ruleColor = ModeColors.Get(this._currentMode, this._modeColors);
|
||||
List<string> scrollSnapshot = this.AppendOutputEntriesAndSnapshot(
|
||||
new OutputEntry(OutputEntryType.InfoLine, "\n", ruleColor),
|
||||
new OutputEntry(OutputEntryType.InfoLine, $" {question.Prompt}", ruleColor));
|
||||
|
||||
return state with
|
||||
{
|
||||
Mode = BottomPanelMode.TextInput,
|
||||
ListSelectionOptions = [],
|
||||
ListSelectionTitle = null,
|
||||
ListSelectionCustomTextPlaceholder = null,
|
||||
ListSelectionIndex = 0,
|
||||
ListSelectionCustomInputText = "",
|
||||
ScrollAreaContentItems = scrollSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void WriteUserInputEcho(string text)
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
|
||||
OutputEntryType.UserInput,
|
||||
$"\nYou: {text}\n\n",
|
||||
ConsoleColor.Green));
|
||||
return s with { ScrollAreaContentItems = snapshot };
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task WriteInfoAsync(string text, ConsoleColor? color = null) =>
|
||||
this.WriteInfoCoreAsync(text, color, newLine: false);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task WriteInfoLineAsync(string text, ConsoleColor? color = null) =>
|
||||
this.WriteInfoCoreAsync(text, color, newLine: true);
|
||||
|
||||
private Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine)
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
// Add a blank line separator when transitioning from streaming text or user input.
|
||||
string prefix = this._lastEntryType is OutputEntryType.StreamingText or OutputEntryType.StreamFooter
|
||||
? "\n "
|
||||
: " ";
|
||||
|
||||
string fullText = newLine ? prefix + text + "\n\n" : prefix + text;
|
||||
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
|
||||
OutputEntryType.InfoLine,
|
||||
fullText,
|
||||
color ?? ModeColors.Get(this._currentMode, this._modeColors)));
|
||||
return s with { ScrollAreaContentItems = snapshot };
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task WriteTextAsync(string text, ConsoleColor? color = null)
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
this._lastEntryType = OutputEntryType.StreamingText;
|
||||
this._hasReceivedAnyText = true;
|
||||
|
||||
ConsoleColor effectiveColor = color ?? ModeColors.Get(this._currentMode, this._modeColors);
|
||||
|
||||
if (this._currentStreamingEntry is not null
|
||||
&& this._currentStreamingEntryIndex == this._outputItems.Count - 1)
|
||||
{
|
||||
// The streaming entry is still the last item — safe to replace in place.
|
||||
this._currentStreamingEntry = this._currentStreamingEntry with
|
||||
{
|
||||
Text = this._currentStreamingEntry.Text + text,
|
||||
};
|
||||
this._outputItems[^1] = RenderEntry(this._currentStreamingEntry.Text, this._currentStreamingEntry.Color);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Either the first text delta or other entries (tool calls, info lines)
|
||||
// were appended after the previous streaming entry — start a fresh one.
|
||||
const string Prefix = "\n";
|
||||
this._currentStreamingEntry = new OutputEntry(OutputEntryType.StreamingText, Prefix + text, effectiveColor);
|
||||
this._outputItems.Add(RenderEntry(this._currentStreamingEntry.Text, this._currentStreamingEntry.Color));
|
||||
this._currentStreamingEntryIndex = this._outputItems.Count - 1;
|
||||
}
|
||||
|
||||
return s with { ScrollAreaContentItems = new List<string>(this._outputItems) };
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task EndStreamingOutputAsync()
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
if (this._hasReceivedAnyText)
|
||||
{
|
||||
this._outputItems.Add(RenderEntry("\n", null));
|
||||
this._currentStreamingEntry = null;
|
||||
this._lastEntryType = OutputEntryType.StreamFooter;
|
||||
return s with { ScrollAreaContentItems = new List<string>(this._outputItems) };
|
||||
}
|
||||
|
||||
return s;
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task WriteNoTextWarningAsync(bool hasFollowUpActions)
|
||||
{
|
||||
if (!this._hasReceivedAnyText && !hasFollowUpActions)
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
|
||||
OutputEntryType.StreamFooter,
|
||||
" (no text response from agent)\n",
|
||||
ConsoleColor.DarkYellow));
|
||||
return s with { ScrollAreaContentItems = snapshot };
|
||||
});
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the supplied text with ANSI foreground color escape sequences (or returns
|
||||
/// the text unchanged when no color is specified). Output is appended to
|
||||
/// <see cref="_outputItems"/> and consumed verbatim by <see cref="TextScrollPanel"/>
|
||||
/// and <see cref="TextPanel"/>.
|
||||
/// </summary>
|
||||
private static string RenderEntry(string text, ConsoleColor? color) =>
|
||||
color.HasValue
|
||||
? $"{AnsiEscapes.SetForegroundColor(color.Value)}{text}{AnsiEscapes.ResetAttributes}"
|
||||
: text;
|
||||
|
||||
private void UpdateState(Func<HarnessAppComponentState, HarnessAppComponentState> update)
|
||||
{
|
||||
lock (this._stateLock)
|
||||
{
|
||||
this._setState(update(this._getState()));
|
||||
}
|
||||
}
|
||||
|
||||
private T UpdateState<T>(Func<HarnessAppComponentState, (HarnessAppComponentState State, T Result)> update)
|
||||
{
|
||||
lock (this._stateLock)
|
||||
{
|
||||
var (newState, result) = update(this._getState());
|
||||
this._setState(newState);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends one or more output entries to the output list, updates
|
||||
/// <see cref="_lastEntryType"/> to the last entry's type, and returns a
|
||||
/// snapshot of <see cref="_outputItems"/>. Must be called inside a locked
|
||||
/// context (e.g. within an <see cref="UpdateState"/> callback).
|
||||
/// </summary>
|
||||
private List<string> AppendOutputEntriesAndSnapshot(params OutputEntry[] entries)
|
||||
{
|
||||
this.AppendOutputEntriesCore(entries);
|
||||
return new List<string>(this._outputItems);
|
||||
}
|
||||
|
||||
private void AppendOutputEntriesCore(OutputEntry[] entries)
|
||||
{
|
||||
foreach (OutputEntry entry in entries)
|
||||
{
|
||||
this._outputItems.Add(RenderEntry(entry.Text, entry.Color));
|
||||
}
|
||||
|
||||
if (entries.Length > 0)
|
||||
{
|
||||
this._lastEntryType = entries[^1].Type;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RequestShutdown() => this._requestShutdown();
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments raised when the user submits text while the bottom panel is in
|
||||
/// streaming mode (i.e. an agent turn is in progress).
|
||||
/// </summary>
|
||||
public sealed class StreamingInputReceivedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StreamingInputReceivedEventArgs"/> class.
|
||||
/// </summary>
|
||||
/// <param name="text">The submitted text.</param>
|
||||
public StreamingInputReceivedEventArgs(string text)
|
||||
{
|
||||
this.Text = text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the submitted text.
|
||||
/// </summary>
|
||||
public string Text { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Façade over the harness UI: owns the <see cref="HarnessAppComponent"/>, manages
|
||||
/// its props, dispatches input submissions, and provides the high-level read/write
|
||||
/// operations used by observers, command handlers, and the harness loop.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All callers interact with the UI exclusively through this class. The underlying
|
||||
/// <see cref="HarnessAppComponent"/> and its props are an implementation detail and
|
||||
/// must not be exposed.
|
||||
/// </remarks>
|
||||
public sealed class HarnessUXContainer : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The prompt displayed in the bottom-panel input area.
|
||||
/// </summary>
|
||||
private const string UserPrompt = "> ";
|
||||
|
||||
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||
private readonly List<object> _outputItems = [];
|
||||
private readonly HarnessAppComponent _appComponent;
|
||||
private readonly object _outputLock = new();
|
||||
|
||||
private TaskCompletionSource<string>? _pendingInputTcs;
|
||||
private OutputEntryType? _lastEntryType;
|
||||
private bool _hasReceivedAnyText;
|
||||
private OutputEntry? _currentStreamingEntry;
|
||||
private string? _currentMode;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HarnessUXContainer"/> class.
|
||||
/// </summary>
|
||||
/// <param name="placeholder">Placeholder text shown when the input is empty.</param>
|
||||
/// <param name="initialMode">The current agent mode, used to colour the rule and prompt.</param>
|
||||
/// <param name="inputEnabled">Whether the bottom-panel input accepts keystrokes during streaming.</param>
|
||||
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
||||
public HarnessUXContainer(
|
||||
string placeholder,
|
||||
string? initialMode,
|
||||
bool inputEnabled,
|
||||
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
this._modeColors = modeColors;
|
||||
this._currentMode = initialMode;
|
||||
|
||||
this._appComponent = new HarnessAppComponent(RenderOutputEntry)
|
||||
{
|
||||
Props = new HarnessAppComponentProps
|
||||
{
|
||||
ScrollItems = this._outputItems,
|
||||
Mode = BottomPanelMode.TextInput,
|
||||
Prompt = UserPrompt,
|
||||
Placeholder = placeholder,
|
||||
ModeColor = ModeColors.Get(initialMode, modeColors),
|
||||
ModeText = initialMode,
|
||||
InputEnabled = inputEnabled,
|
||||
},
|
||||
};
|
||||
|
||||
this._appComponent.InputSubmitted += this.OnInputSubmitted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user submits text while the bottom panel is in streaming mode.
|
||||
/// Subscribers typically enqueue the text into a message-injecting chat client.
|
||||
/// </summary>
|
||||
public event EventHandler<StreamingInputReceivedEventArgs>? StreamingInputReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current agent mode (e.g. "plan", "execute"). Updating this
|
||||
/// also refreshes the rule colour and bottom-panel prompt to match the new mode.
|
||||
/// </summary>
|
||||
public string? CurrentMode
|
||||
{
|
||||
get => this._currentMode;
|
||||
set
|
||||
{
|
||||
this._currentMode = value;
|
||||
this._appComponent.Props = this._appComponent.Props! with
|
||||
{
|
||||
ModeColor = ModeColors.Get(value, this._modeColors),
|
||||
ModeText = value,
|
||||
};
|
||||
this._appComponent.Render();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs the initial screen clear, sets the help text in the mode-and-help bar,
|
||||
/// and adds the title to the output area.
|
||||
/// </summary>
|
||||
/// <param name="title">The title displayed in the console header.</param>
|
||||
/// <param name="commandHelpTexts">The command help strings displayed in the mode-and-help bar.</param>
|
||||
/// <param name="messageInjectionActive">Whether streaming-time message injection is enabled.</param>
|
||||
public void Initialize(string title, IEnumerable<string> commandHelpTexts, bool messageInjectionActive)
|
||||
{
|
||||
// Set the help text on the mode-and-help bar (persists below the rule).
|
||||
this._appComponent.Props = this._appComponent.Props! with
|
||||
{
|
||||
HelpText = string.Join(", ", commandHelpTexts),
|
||||
ModeText = this._currentMode,
|
||||
};
|
||||
|
||||
System.Console.Write(AnsiEscapes.EraseEntireScreen);
|
||||
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
|
||||
this._appComponent.Render();
|
||||
|
||||
this.AppendOutputEntries(
|
||||
new OutputEntry(OutputEntryType.InfoLine, $"=== {title} ===\n", ConsoleColor.White),
|
||||
new OutputEntry(OutputEntryType.InfoLine, "\n"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores the cursor and exits the alternate screen, ending the interactive UI.
|
||||
/// </summary>
|
||||
public void Deactivate() => this._appComponent.Deactivate();
|
||||
|
||||
/// <summary>
|
||||
/// Switches the bottom panel to streaming mode and starts the spinner.
|
||||
/// </summary>
|
||||
public void BeginStreaming()
|
||||
{
|
||||
this._appComponent.Props = this._appComponent.Props! with
|
||||
{
|
||||
Mode = BottomPanelMode.Streaming,
|
||||
ShowSpinner = true,
|
||||
};
|
||||
this._appComponent.Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the spinner without leaving streaming mode. Use between the end of the
|
||||
/// stream and any observer-driven prompts (e.g. tool approvals).
|
||||
/// </summary>
|
||||
public void StopSpinner()
|
||||
{
|
||||
this._appComponent.Props = this._appComponent.Props! with { ShowSpinner = false };
|
||||
this._appComponent.Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switches the bottom panel back to text-input mode and stops the spinner.
|
||||
/// </summary>
|
||||
public void EndStreaming()
|
||||
{
|
||||
this._appComponent.Props = this._appComponent.Props! with
|
||||
{
|
||||
Mode = BottomPanelMode.TextInput,
|
||||
ShowSpinner = false,
|
||||
};
|
||||
this._appComponent.Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets per-turn streaming bookkeeping in preparation for a new agent turn.
|
||||
/// </summary>
|
||||
public void BeginStreamingOutput()
|
||||
{
|
||||
this._hasReceivedAnyText = false;
|
||||
this._currentStreamingEntry = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the formatted usage text shown on the agent status bar.
|
||||
/// </summary>
|
||||
public void SetUsageText(string usageText)
|
||||
{
|
||||
this._appComponent.Props = this._appComponent.Props! with { UsageText = usageText };
|
||||
this._appComponent.Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the usage text from the agent status bar.
|
||||
/// </summary>
|
||||
public void ClearUsageText()
|
||||
{
|
||||
this._appComponent.Props = this._appComponent.Props! with { UsageText = null };
|
||||
this._appComponent.Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the queued-message display with one entry per pending message.
|
||||
/// </summary>
|
||||
public void ShowQueuedMessages(IReadOnlyList<ChatMessage> pending)
|
||||
{
|
||||
var newQueued = new List<object>(pending.Count);
|
||||
foreach (var msg in pending)
|
||||
{
|
||||
string text = msg.Text ?? string.Empty;
|
||||
newQueued.Add(new OutputEntry(OutputEntryType.UserInput, $" 💬 {text}\n", ConsoleColor.DarkGray));
|
||||
}
|
||||
|
||||
this._appComponent.Props = this._appComponent.Props! with { QueuedItems = newQueued };
|
||||
this._appComponent.Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Echoes a submitted user input as a regular user-input entry in the output area,
|
||||
/// using the current mode-aware prompt prefix.
|
||||
/// </summary>
|
||||
/// <param name="text">The user-entered text.</param>
|
||||
public void WriteUserInputEcho(string text)
|
||||
{
|
||||
this.AppendOutputEntries(new OutputEntry(
|
||||
OutputEntryType.UserInput,
|
||||
$"\nYou: {text}\n",
|
||||
ConsoleColor.Green));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes informational output as an output entry, without a trailing newline.
|
||||
/// </summary>
|
||||
public Task WriteInfoAsync(string text, ConsoleColor? color = null) =>
|
||||
this.WriteInfoCoreAsync(text, color, newLine: false);
|
||||
|
||||
/// <summary>
|
||||
/// Writes informational output as an output entry, followed by a newline.
|
||||
/// </summary>
|
||||
public Task WriteInfoLineAsync(string text, ConsoleColor? color = null) =>
|
||||
this.WriteInfoCoreAsync(text, color, newLine: true);
|
||||
|
||||
private Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine)
|
||||
{
|
||||
// Add a blank line separator when transitioning from streaming text or user input.
|
||||
string prefix = this._lastEntryType is OutputEntryType.StreamingText or OutputEntryType.StreamFooter
|
||||
? "\n\n "
|
||||
: " ";
|
||||
|
||||
string fullText = newLine ? prefix + text + "\n" : prefix + text;
|
||||
this.AppendOutputEntries(new OutputEntry(
|
||||
OutputEntryType.InfoLine,
|
||||
fullText,
|
||||
color ?? ModeColors.Get(this.CurrentMode, this._modeColors)));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes streaming text output from the agent. Successive calls accumulate into a
|
||||
/// single streaming entry that is re-rendered by the text panel.
|
||||
/// </summary>
|
||||
public Task WriteTextAsync(string text, ConsoleColor? color = null)
|
||||
{
|
||||
lock (this._outputLock)
|
||||
{
|
||||
this._lastEntryType = OutputEntryType.StreamingText;
|
||||
this._hasReceivedAnyText = true;
|
||||
|
||||
ConsoleColor effectiveColor = color ?? ModeColors.Get(this.CurrentMode, this._modeColors);
|
||||
|
||||
if (this._currentStreamingEntry is not null)
|
||||
{
|
||||
this._currentStreamingEntry = this._currentStreamingEntry with
|
||||
{
|
||||
Text = this._currentStreamingEntry.Text + text,
|
||||
};
|
||||
this._outputItems[^1] = this._currentStreamingEntry;
|
||||
}
|
||||
else
|
||||
{
|
||||
const string Prefix = "\n";
|
||||
this._currentStreamingEntry = new OutputEntry(OutputEntryType.StreamingText, Prefix + text, effectiveColor);
|
||||
this._outputItems.Add(this._currentStreamingEntry);
|
||||
}
|
||||
|
||||
this._appComponent.Props = this._appComponent.Props! with
|
||||
{
|
||||
ScrollItems = new List<object>(this._outputItems),
|
||||
};
|
||||
}
|
||||
|
||||
this._appComponent.Render();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a blank-line separator to visually close the streaming output section.
|
||||
/// Call before observer completions so their output is visually separated.
|
||||
/// </summary>
|
||||
public Task EndStreamingOutputAsync()
|
||||
{
|
||||
lock (this._outputLock)
|
||||
{
|
||||
this._outputItems.Add(new OutputEntry(OutputEntryType.StreamFooter, "\n"));
|
||||
this._currentStreamingEntry = null;
|
||||
this._lastEntryType = OutputEntryType.StreamFooter;
|
||||
this._appComponent.Props = this._appComponent.Props! with
|
||||
{
|
||||
ScrollItems = new List<object>(this._outputItems),
|
||||
};
|
||||
}
|
||||
|
||||
this._appComponent.Render();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a "(no text response from agent)" warning if no text was received
|
||||
/// and no observer produced follow-up messages. Call after observer completions.
|
||||
/// </summary>
|
||||
/// <param name="hasFollowUpMessages">Whether any observer produced follow-up messages.</param>
|
||||
public Task WriteNoTextWarningAsync(bool hasFollowUpMessages)
|
||||
{
|
||||
if (!this._hasReceivedAnyText && !hasFollowUpMessages)
|
||||
{
|
||||
this.AppendOutputEntries(new OutputEntry(
|
||||
OutputEntryType.StreamFooter,
|
||||
" (no text response from agent)\n",
|
||||
ConsoleColor.DarkYellow));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a line of input from the user. If <paramref name="prompt"/> is supplied
|
||||
/// it is rendered as an info line above the input row before reading.
|
||||
/// </summary>
|
||||
public async Task<string?> ReadLineAsync(string? prompt = null, ConsoleColor? promptColor = null)
|
||||
{
|
||||
if (prompt is not null)
|
||||
{
|
||||
ConsoleColor ruleColor = ModeColors.Get(this.CurrentMode, this._modeColors);
|
||||
this.AppendOutputEntries(
|
||||
new OutputEntry(OutputEntryType.InfoLine, "\n", ruleColor),
|
||||
new OutputEntry(OutputEntryType.InfoLine, $" {prompt}", promptColor ?? ruleColor));
|
||||
}
|
||||
|
||||
this._appComponent.Props = this._appComponent.Props! with { Mode = BottomPanelMode.TextInput };
|
||||
this._appComponent.Render();
|
||||
|
||||
string input = await this.WaitForInputAsync();
|
||||
|
||||
this.AppendOutputEntries(new OutputEntry(
|
||||
OutputEntryType.UserInput,
|
||||
$"\nYou: {input}\n",
|
||||
ConsoleColor.Green));
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Presents a selection prompt with the given choices and waits for the user's
|
||||
/// selection. The title is displayed above the list in the bottom panel. After
|
||||
/// selection the bottom panel is restored to text-input mode and both the question
|
||||
/// and selection are echoed in the output area.
|
||||
/// </summary>
|
||||
public async Task<string> ReadSelectionAsync(string title, IList<string> choices)
|
||||
{
|
||||
this._appComponent.Props = this._appComponent.Props! with
|
||||
{
|
||||
Mode = BottomPanelMode.ListSelection,
|
||||
Items = choices.ToList(),
|
||||
ListTitle = title,
|
||||
ListCustomTextPlaceholder = "✏️ Type a custom response...",
|
||||
};
|
||||
this._appComponent.Render();
|
||||
|
||||
string selection = await this.WaitForInputAsync();
|
||||
|
||||
this._appComponent.Props = this._appComponent.Props with { Mode = BottomPanelMode.TextInput };
|
||||
|
||||
this.AppendOutputEntries(
|
||||
new OutputEntry(
|
||||
OutputEntryType.InfoLine,
|
||||
$"\n {title}\n",
|
||||
ModeColors.Get(this.CurrentMode, this._modeColors)),
|
||||
new OutputEntry(
|
||||
OutputEntryType.UserInput,
|
||||
$"\nYou: {selection}\n",
|
||||
ConsoleColor.Green));
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Awaits the next non-streaming user input submission.
|
||||
/// </summary>
|
||||
public Task<string> WaitForInputAsync()
|
||||
{
|
||||
this._pendingInputTcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
return this._pendingInputTcs.Task;
|
||||
}
|
||||
|
||||
private void OnInputSubmitted(object? sender, InputSubmittedEventArgs e)
|
||||
{
|
||||
if (e.Mode == BottomPanelMode.Streaming)
|
||||
{
|
||||
this.StreamingInputReceived?.Invoke(this, new StreamingInputReceivedEventArgs(e.Text));
|
||||
}
|
||||
else
|
||||
{
|
||||
var waiter = this._pendingInputTcs;
|
||||
this._pendingInputTcs = null;
|
||||
waiter?.TrySetResult(e.Text);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
this._appComponent.InputSubmitted -= this.OnInputSubmitted;
|
||||
this._appComponent.Deactivate();
|
||||
this._appComponent.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders an <see cref="OutputEntry"/> to a string with ANSI color codes.
|
||||
/// Used as the render delegate for the <see cref="HarnessAppComponent"/>.
|
||||
/// </summary>
|
||||
private static string RenderOutputEntry(object item)
|
||||
{
|
||||
if (item is not OutputEntry entry)
|
||||
{
|
||||
return item?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
if (entry.Color.HasValue)
|
||||
{
|
||||
return $"{AnsiEscapes.SetForegroundColor(entry.Color.Value)}{entry.Text}{AnsiEscapes.ResetAttributes}";
|
||||
}
|
||||
|
||||
return entry.Text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends one or more output entries to the output list under lock,
|
||||
/// updates <see cref="_lastEntryType"/> to the last entry's type, and renders.
|
||||
/// </summary>
|
||||
private void AppendOutputEntries(params OutputEntry[] entries)
|
||||
{
|
||||
lock (this._outputLock)
|
||||
{
|
||||
foreach (OutputEntry entry in entries)
|
||||
{
|
||||
this._outputItems.Add(entry);
|
||||
}
|
||||
|
||||
if (entries.Length > 0)
|
||||
{
|
||||
this._lastEntryType = entries[^1].Type;
|
||||
}
|
||||
|
||||
this._appComponent.Props = this._appComponent.Props! with
|
||||
{
|
||||
ScrollItems = new List<object>(this._outputItems),
|
||||
};
|
||||
}
|
||||
|
||||
this._appComponent.Render();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the harness UI state. All callers (observers, command handlers,
|
||||
/// the agent runner) interact with the UI exclusively through this interface, which
|
||||
/// internally translates each operation into a <c>SetState</c> call on the underlying
|
||||
/// reactive component.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface is intentionally narrow: it does not expose blocking input methods.
|
||||
/// The agent runner orchestrates input flow via <see cref="FollowUpQuestion"/>
|
||||
/// objects returned from observers.
|
||||
/// </remarks>
|
||||
public interface IUXStateDriver
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the current agent mode (e.g. "plan", "execute"). Setting also
|
||||
/// refreshes the rule colour and bottom-panel prompt to match the new mode.
|
||||
/// </summary>
|
||||
string? CurrentMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Echoes a submitted user input as a regular user-input entry in the output area.
|
||||
/// </summary>
|
||||
void WriteUserInputEcho(string text);
|
||||
|
||||
/// <summary>
|
||||
/// Writes informational output as an output entry, without a trailing newline.
|
||||
/// </summary>
|
||||
Task WriteInfoAsync(string text, ConsoleColor? color = null);
|
||||
|
||||
/// <summary>
|
||||
/// Writes informational output as an output entry, followed by a newline.
|
||||
/// </summary>
|
||||
Task WriteInfoLineAsync(string text, ConsoleColor? color = null);
|
||||
|
||||
/// <summary>
|
||||
/// Writes streaming text output from the agent. Successive calls accumulate into a
|
||||
/// single streaming entry that is re-rendered by the text panel.
|
||||
/// </summary>
|
||||
Task WriteTextAsync(string text, ConsoleColor? color = null);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a blank-line separator to visually close the streaming output section.
|
||||
/// </summary>
|
||||
Task EndStreamingOutputAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Shows a "(no text response from agent)" warning if no text was received
|
||||
/// and no observer produced follow-up actions.
|
||||
/// </summary>
|
||||
Task WriteNoTextWarningAsync(bool hasFollowUpActions);
|
||||
|
||||
/// <summary>
|
||||
/// Switches the bottom panel to streaming mode and starts the spinner.
|
||||
/// </summary>
|
||||
void BeginStreaming();
|
||||
|
||||
/// <summary>
|
||||
/// Stops the spinner without leaving streaming mode.
|
||||
/// </summary>
|
||||
void StopSpinner();
|
||||
|
||||
/// <summary>
|
||||
/// Switches the bottom panel back to text-input mode and stops the spinner.
|
||||
/// </summary>
|
||||
void EndStreaming();
|
||||
|
||||
/// <summary>
|
||||
/// Resets per-turn streaming bookkeeping in preparation for a new agent turn.
|
||||
/// </summary>
|
||||
void BeginStreamingOutput();
|
||||
|
||||
/// <summary>
|
||||
/// Sets the formatted usage text shown on the agent status bar.
|
||||
/// </summary>
|
||||
void SetUsageText(string usageText);
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the queued-message display with one entry per pending message.
|
||||
/// </summary>
|
||||
void SetQueuedMessages(IReadOnlyList<ChatMessage> pending);
|
||||
|
||||
/// <summary>
|
||||
/// Appends the supplied questions to the pending follow-up question queue in
|
||||
/// component state. If the queue was empty, the bottom-panel display is
|
||||
/// reconfigured to present the new head question.
|
||||
/// </summary>
|
||||
void QueueFollowUpQuestions(IReadOnlyList<FollowUpQuestion> questions);
|
||||
|
||||
/// <summary>
|
||||
/// Appends a message to the accumulated follow-up response list in component state.
|
||||
/// Called by the runner for direct <see cref="FollowUpMessage"/> outputs and by
|
||||
/// the component when a question's continuation produces a response.
|
||||
/// </summary>
|
||||
void AddFollowUpResponse(ChatMessage response);
|
||||
|
||||
/// <summary>
|
||||
/// Pops the head of the pending follow-up question queue. Reconfigures the
|
||||
/// bottom-panel display for the new head, or restores the default text-input
|
||||
/// mode if the queue is now empty.
|
||||
/// </summary>
|
||||
void AdvanceFollowUpQuestion();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current accumulated follow-up responses and clears them in state.
|
||||
/// Called by the runner immediately before invoking the next agent turn.
|
||||
/// </summary>
|
||||
IReadOnlyList<ChatMessage> TakeFollowUpResponses();
|
||||
|
||||
/// <summary>
|
||||
/// Signals that the application should shut down. Completes the shutdown task
|
||||
/// on the owning component.
|
||||
/// </summary>
|
||||
void RequestShutdown();
|
||||
}
|
||||
+22
-17
@@ -18,36 +18,41 @@ public abstract class ConsoleObserver
|
||||
/// Override to set options such as <see cref="AgentRunOptions.ResponseFormat"/>.
|
||||
/// </summary>
|
||||
/// <param name="options">The run options to configure.</param>
|
||||
public virtual void ConfigureRunOptions(AgentRunOptions options)
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
public virtual void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called for each <see cref="AIContent"/> item in the response stream.
|
||||
/// </summary>
|
||||
/// <param name="ux">The harness UX container, used for rendering output and interacting with the user.</param>
|
||||
/// <param name="ux">The UX state driver, used for rendering output.</param>
|
||||
/// <param name="content">The content item from the stream.</param>
|
||||
public virtual Task OnContentAsync(HarnessUXContainer ux, AIContent content) => Task.CompletedTask;
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
public virtual Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Called for each text update in the response stream.
|
||||
/// </summary>
|
||||
/// <param name="ux">The harness UX container, used for rendering output and interacting with the user.</param>
|
||||
/// <param name="ux">The UX state driver, used for rendering output.</param>
|
||||
/// <param name="text">The text from the update.</param>
|
||||
public virtual Task OnTextAsync(HarnessUXContainer ux, string text) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Called after the response stream completes. Returns messages to include in the
|
||||
/// next agent invocation, or <see langword="null"/> if no re-invocation is needed.
|
||||
/// </summary>
|
||||
/// <param name="ux">The harness UX container, used for rendering output and interacting with the user.</param>
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
/// <param name="options">The console options.</param>
|
||||
/// <returns>Messages to send to the agent, or <see langword="null"/> if no action is needed.</returns>
|
||||
public virtual Task<IList<ChatMessage>?> OnStreamCompleteAsync(
|
||||
HarnessUXContainer ux,
|
||||
public virtual Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Called after the response stream completes. Returns a heterogeneous list of
|
||||
/// follow-up actions (questions to ask the user, and/or messages to add directly to
|
||||
/// the next agent invocation), or <see langword="null"/> if no follow-up is needed.
|
||||
/// </summary>
|
||||
/// <param name="ux">The UX state driver, used for rendering output.</param>
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
/// <returns>Follow-up actions to process after the stream completes, or <see langword="null"/>.</returns>
|
||||
public virtual Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
|
||||
IUXStateDriver ux,
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
HarnessConsoleOptions options) => Task.FromResult<IList<ChatMessage>?>(null);
|
||||
AgentSession session) => Task.FromResult<IList<FollowUpAction>?>(null);
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
@@ -7,10 +8,10 @@ namespace Harness.Shared.Console.Observers;
|
||||
/// <summary>
|
||||
/// Displays error content (❌) from the response stream.
|
||||
/// </summary>
|
||||
internal sealed class ErrorDisplayObserver : ConsoleObserver
|
||||
public sealed class ErrorDisplayObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
|
||||
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (content is ErrorContent errorContent)
|
||||
{
|
||||
|
||||
+100
-55
@@ -2,51 +2,77 @@
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Planning observer that configures structured output, collects streamed text,
|
||||
/// and deserializes it as a <see cref="PlanningResponse"/>. Renders clarification
|
||||
/// questions and approval prompts, and manages mode switching when the user approves a plan.
|
||||
/// Planning observer that is mode-aware: in planning mode it configures structured
|
||||
/// JSON output, collects streamed text, and deserializes it as a <see cref="PlanningResponse"/>;
|
||||
/// in execution mode it passes text straight through to <see cref="IUXStateDriver.WriteTextAsync"/>
|
||||
/// for live streaming display.
|
||||
/// </summary>
|
||||
internal sealed class PlanningOutputObserver : ConsoleObserver
|
||||
public sealed class PlanningOutputObserver : ConsoleObserver
|
||||
{
|
||||
private readonly StringBuilder _textCollector = new();
|
||||
private readonly AgentModeProvider _modeProvider;
|
||||
private readonly string _planModeName;
|
||||
private readonly string _executionModeName;
|
||||
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PlanningOutputObserver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="modeProvider">The mode provider for switching modes on approval.</param>
|
||||
public PlanningOutputObserver(AgentModeProvider modeProvider)
|
||||
/// <param name="planModeName">The mode name that represents the planning mode.</param>
|
||||
/// <param name="executionModeName">The mode name to switch to when the user approves a plan.</param>
|
||||
/// <param name="modeColors">Optional mode-to-color mapping for display.</param>
|
||||
public PlanningOutputObserver(AgentModeProvider modeProvider, string planModeName, string executionModeName, IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
this._modeProvider = modeProvider;
|
||||
this._planModeName = planModeName;
|
||||
this._executionModeName = executionModeName;
|
||||
this._modeColors = modeColors;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void ConfigureRunOptions(AgentRunOptions options)
|
||||
public override void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
|
||||
{
|
||||
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<PlanningResponse>();
|
||||
if (this.IsPlanningMode(this._modeProvider.GetMode(session)))
|
||||
{
|
||||
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<PlanningResponse>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task OnTextAsync(HarnessUXContainer ux, string text)
|
||||
public override Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session)
|
||||
{
|
||||
// Collect text silently instead of displaying it.
|
||||
this._textCollector.Append(text);
|
||||
return Task.CompletedTask;
|
||||
if (this.IsPlanningMode(ux.CurrentMode))
|
||||
{
|
||||
// Planning mode: collect text silently for JSON parsing after the stream.
|
||||
this._textCollector.Append(text);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Execution mode: stream text directly to the console.
|
||||
return ux.WriteTextAsync(text);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
|
||||
HarnessUXContainer ux,
|
||||
public override async Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
|
||||
IUXStateDriver ux,
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
HarnessConsoleOptions options)
|
||||
AgentSession session)
|
||||
{
|
||||
if (!this.IsPlanningMode(ux.CurrentMode))
|
||||
{
|
||||
// Execution mode: text was already streamed live; nothing to parse.
|
||||
this._textCollector.Clear();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read collected text from our stream observation.
|
||||
string collectedText = this._textCollector.ToString();
|
||||
this._textCollector.Clear();
|
||||
@@ -75,10 +101,9 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render based on response type.
|
||||
if (planningResponse.Type == PlanningResponseType.Clarification)
|
||||
{
|
||||
return AsUserMessages(await this.RenderClarificationsAndCollectResponsesAsync(ux, planningResponse));
|
||||
return BuildClarificationActions(planningResponse);
|
||||
}
|
||||
|
||||
if (planningResponse.Type == PlanningResponseType.Approval)
|
||||
@@ -90,67 +115,87 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
|
||||
return null;
|
||||
}
|
||||
|
||||
string response = await this.RenderApprovalAndCollectResponseAsync(ux, question, options);
|
||||
if (response == "Approved")
|
||||
{
|
||||
this._modeProvider.SetMode(session, options.ExecutionModeName!);
|
||||
|
||||
await ux.WriteInfoLineAsync($"✅ Switched to {options.ExecutionModeName} mode.",
|
||||
ModeColors.Get(options.ExecutionModeName, options.ModeColors));
|
||||
}
|
||||
|
||||
return AsUserMessages(response);
|
||||
return new List<FollowUpAction> { this.BuildApprovalAction(question, session) };
|
||||
}
|
||||
|
||||
await ux.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IList<ChatMessage>? AsUserMessages(string? text) =>
|
||||
text is not null ? [new ChatMessage(ChatRole.User, text)] : null;
|
||||
|
||||
private async Task<string?> RenderClarificationsAndCollectResponsesAsync(HarnessUXContainer ux, PlanningResponse response)
|
||||
private static List<FollowUpAction> BuildClarificationActions(PlanningResponse response)
|
||||
{
|
||||
var answers = new List<string>();
|
||||
var actions = new List<FollowUpAction>(response.Questions.Count);
|
||||
|
||||
foreach (var question in response.Questions)
|
||||
{
|
||||
string? answer;
|
||||
string prompt = question.Message;
|
||||
|
||||
async Task<ChatMessage?> Continuation(string answer, IUXStateDriver ux)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(answer))
|
||||
{
|
||||
string noAnswer = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray)}(no answer){AnsiEscapes.ResetAttributes}";
|
||||
await ux.WriteInfoLineAsync(noAnswer, ConsoleColor.Gray).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
string formatted = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.Green)}{answer}{AnsiEscapes.ResetAttributes}";
|
||||
await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false);
|
||||
|
||||
return new ChatMessage(ChatRole.User, $"Q: {prompt}\nA: {answer}");
|
||||
}
|
||||
|
||||
if (question.Choices is { Count: > 0 })
|
||||
{
|
||||
answer = await ux.ReadSelectionAsync(
|
||||
question.Message,
|
||||
question.Choices);
|
||||
actions.Add(new ChoiceFollowUpQuestion(
|
||||
Prompt: prompt,
|
||||
Choices: question.Choices,
|
||||
AllowCustomText: true,
|
||||
Continuation: Continuation));
|
||||
}
|
||||
else
|
||||
{
|
||||
answer = (await ux.ReadLineAsync(question.Message))?.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(answer))
|
||||
{
|
||||
answers.Add($"Q: {question.Message}\nA: {answer}");
|
||||
actions.Add(new TextFollowUpQuestion(
|
||||
Prompt: prompt,
|
||||
Continuation: Continuation));
|
||||
}
|
||||
}
|
||||
|
||||
return answers.Count > 0 ? string.Join("\n\n", answers) : null;
|
||||
return actions;
|
||||
}
|
||||
|
||||
private async Task<string> RenderApprovalAndCollectResponseAsync(HarnessUXContainer ux, PlanningQuestion question, HarnessConsoleOptions options)
|
||||
private ChoiceFollowUpQuestion BuildApprovalAction(PlanningQuestion question, AgentSession session)
|
||||
{
|
||||
var choices = new List<string>
|
||||
{
|
||||
"Approve and switch to execute mode",
|
||||
};
|
||||
const string ApproveOption = "Approve and switch to execute mode";
|
||||
var choices = new List<string> { ApproveOption };
|
||||
|
||||
string selection = await ux.ReadSelectionAsync(question.Message, choices);
|
||||
return new ChoiceFollowUpQuestion(
|
||||
Prompt: question.Message,
|
||||
Choices: choices,
|
||||
AllowCustomText: true,
|
||||
Continuation: async (selection, ux) =>
|
||||
{
|
||||
string formatted = $"🔹 {question.Message}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.Green)}{selection}{AnsiEscapes.ResetAttributes}";
|
||||
await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false);
|
||||
|
||||
if (selection == choices[0])
|
||||
{
|
||||
return "Approved";
|
||||
}
|
||||
if (selection == ApproveOption)
|
||||
{
|
||||
this._modeProvider.SetMode(session, this._executionModeName);
|
||||
await ux.WriteInfoLineAsync(
|
||||
$"✅ Switched to {this._executionModeName} mode.",
|
||||
ModeColors.Get(this._executionModeName, this._modeColors)).ConfigureAwait(false);
|
||||
return new ChatMessage(ChatRole.User, "Approved");
|
||||
}
|
||||
|
||||
// Custom freeform input — treat as suggested changes.
|
||||
return selection;
|
||||
// Custom freeform input — treat as suggested changes.
|
||||
return new ChatMessage(ChatRole.User, selection);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the current mode matches the configured plan mode name.
|
||||
/// A <see langword="null"/> mode (no mode provider) is also treated as planning mode.
|
||||
/// </summary>
|
||||
private bool IsPlanningMode(string? currentMode) =>
|
||||
currentMode is null || string.Equals(currentMode, this._planModeName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
@@ -7,10 +8,10 @@ namespace Harness.Shared.Console.Observers;
|
||||
/// <summary>
|
||||
/// Displays reasoning content in dark magenta from the response stream.
|
||||
/// </summary>
|
||||
internal sealed class ReasoningDisplayObserver : ConsoleObserver
|
||||
public sealed class ReasoningDisplayObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
|
||||
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text))
|
||||
{
|
||||
|
||||
+4
-2
@@ -1,15 +1,17 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Streams agent text output directly to the console.
|
||||
/// Used in normal (non-planning) mode.
|
||||
/// </summary>
|
||||
internal sealed class TextOutputObserver : ConsoleObserver
|
||||
public sealed class TextOutputObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnTextAsync(HarnessUXContainer ux, string text)
|
||||
public override async Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session)
|
||||
{
|
||||
await ux.WriteTextAsync(text);
|
||||
}
|
||||
|
||||
+67
-48
@@ -1,5 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -7,86 +9,103 @@ namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Collects <see cref="ToolApprovalRequestContent"/> items during the response stream,
|
||||
/// displays approval-needed notifications inline, and prompts the user for approval
|
||||
/// decisions after the stream completes.
|
||||
/// displays approval-needed notifications inline, and after the stream completes returns
|
||||
/// one <see cref="ChoiceFollowUpQuestion"/> per pending approval request. Each question's
|
||||
/// continuation produces a separate <see cref="ChatMessage"/> carrying the approval
|
||||
/// response content.
|
||||
/// </summary>
|
||||
internal sealed class ToolApprovalObserver : ConsoleObserver
|
||||
public sealed class ToolApprovalObserver : ConsoleObserver
|
||||
{
|
||||
private readonly List<ToolApprovalRequestContent> _approvalRequests = [];
|
||||
private readonly IReadOnlyList<ToolCallFormatter> _formatters;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ToolApprovalObserver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="formatters">Optional list of tool formatters. When <see langword="null"/>,
|
||||
/// the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/> are used.</param>
|
||||
public ToolApprovalObserver(IReadOnlyList<ToolCallFormatter>? formatters = null)
|
||||
{
|
||||
this._formatters = formatters ?? ToolCallFormatter.BuildDefaultToolFormatters();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
|
||||
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (content is ToolApprovalRequestContent approvalRequest)
|
||||
{
|
||||
this._approvalRequests.Add(approvalRequest);
|
||||
string toolName = approvalRequest.ToolCall is FunctionCallContent fc
|
||||
? ToolCallFormatter.Format(fc)
|
||||
? ToolCallFormatter.Format(this._formatters, fc)
|
||||
: approvalRequest.ToolCall?.ToString() ?? "unknown";
|
||||
await ux.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
|
||||
HarnessUXContainer ux,
|
||||
public override Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
|
||||
IUXStateDriver ux,
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
HarnessConsoleOptions options)
|
||||
AgentSession session)
|
||||
{
|
||||
if (this._approvalRequests.Count == 0)
|
||||
{
|
||||
return null;
|
||||
return Task.FromResult<IList<FollowUpAction>?>(null);
|
||||
}
|
||||
|
||||
var actions = new List<FollowUpAction>(this._approvalRequests.Count);
|
||||
foreach (var request in this._approvalRequests)
|
||||
{
|
||||
actions.Add(this.BuildApprovalQuestion(request));
|
||||
}
|
||||
|
||||
var messages = await PromptForApprovalsAsync(ux, this._approvalRequests);
|
||||
this._approvalRequests.Clear();
|
||||
return messages;
|
||||
return Task.FromResult<IList<FollowUpAction>?>(actions);
|
||||
}
|
||||
|
||||
private static async Task<List<ChatMessage>?> PromptForApprovalsAsync(HarnessUXContainer ux, List<ToolApprovalRequestContent> approvalRequests)
|
||||
private ChoiceFollowUpQuestion BuildApprovalQuestion(ToolApprovalRequestContent request)
|
||||
{
|
||||
if (approvalRequests.Count == 0)
|
||||
string toolName = request.ToolCall is FunctionCallContent fc
|
||||
? ToolCallFormatter.Format(this._formatters, fc)
|
||||
: request.ToolCall?.ToString() ?? "unknown";
|
||||
|
||||
var choices = new List<string>
|
||||
{
|
||||
return null;
|
||||
}
|
||||
"Approve this call",
|
||||
"Always approve this tool (any arguments)",
|
||||
"Always approve this tool with these arguments",
|
||||
"Deny",
|
||||
};
|
||||
|
||||
var responses = new List<AIContent>();
|
||||
foreach (var request in approvalRequests)
|
||||
{
|
||||
string toolName = request.ToolCall is FunctionCallContent fc
|
||||
? ToolCallFormatter.Format(fc)
|
||||
: request.ToolCall?.ToString() ?? "unknown";
|
||||
string prompt = $"🔐 Tool approval: {toolName}";
|
||||
|
||||
var choices = new List<string>
|
||||
return new ChoiceFollowUpQuestion(
|
||||
Prompt: prompt,
|
||||
Choices: choices,
|
||||
AllowCustomText: false,
|
||||
Continuation: async (selection, ux) =>
|
||||
{
|
||||
"Approve this call",
|
||||
"Always approve this tool (any arguments)",
|
||||
"Always approve this tool with these arguments",
|
||||
"Deny",
|
||||
};
|
||||
AIContent response = selection switch
|
||||
{
|
||||
"Always approve this tool (any arguments)" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"),
|
||||
"Always approve this tool with these arguments" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"),
|
||||
"Deny" => request.CreateResponse(approved: false, reason: "User denied"),
|
||||
_ => request.CreateResponse(approved: true, reason: "User approved"),
|
||||
};
|
||||
|
||||
string selection = await ux.ReadSelectionAsync($"🔐 Tool approval: {toolName}", choices);
|
||||
AIContent response = selection switch
|
||||
{
|
||||
"Always approve this tool (any arguments)" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"),
|
||||
"Always approve this tool with these arguments" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"),
|
||||
"Deny" => request.CreateResponse(approved: false, reason: "User denied"),
|
||||
_ => request.CreateResponse(approved: true, reason: "User approved"),
|
||||
};
|
||||
string action = selection switch
|
||||
{
|
||||
"Always approve this tool (any arguments)" => "✅ Always approved (any args)",
|
||||
"Always approve this tool with these arguments" => "✅ Always approved (these args)",
|
||||
"Deny" => "❌ Denied",
|
||||
_ => "✅ Approved",
|
||||
};
|
||||
|
||||
string action = selection switch
|
||||
{
|
||||
"Always approve this tool (any arguments)" => "✅ Always approved (any args)",
|
||||
"Always approve this tool with these arguments" => "✅ Always approved (these args)",
|
||||
"Deny" => "❌ Denied",
|
||||
_ => "✅ Approved",
|
||||
};
|
||||
await ux.WriteInfoLineAsync($" {action}", ConsoleColor.DarkGray);
|
||||
ConsoleColor answerColor = selection == "Deny" ? ConsoleColor.Red : ConsoleColor.Green;
|
||||
string formatted = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(answerColor)}{action}{AnsiEscapes.ResetAttributes}";
|
||||
await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false);
|
||||
|
||||
responses.Add(response);
|
||||
}
|
||||
|
||||
return [new ChatMessage(ChatRole.User, responses)];
|
||||
return new ChatMessage(ChatRole.User, [response]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+17
-3
@@ -1,5 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
@@ -8,14 +10,26 @@ namespace Harness.Shared.Console.Observers;
|
||||
/// Displays tool call notifications (🔧) for <see cref="FunctionCallContent"/>
|
||||
/// and <see cref="ToolCallContent"/> items in the response stream.
|
||||
/// </summary>
|
||||
internal sealed class ToolCallDisplayObserver : ConsoleObserver
|
||||
public sealed class ToolCallDisplayObserver : ConsoleObserver
|
||||
{
|
||||
private readonly IReadOnlyList<ToolCallFormatter> _formatters;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ToolCallDisplayObserver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="formatters">Optional list of tool formatters. When <see langword="null"/>,
|
||||
/// the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/> are used.</param>
|
||||
public ToolCallDisplayObserver(IReadOnlyList<ToolCallFormatter>? formatters = null)
|
||||
{
|
||||
this._formatters = formatters ?? ToolCallFormatter.BuildDefaultToolFormatters();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
|
||||
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (content is FunctionCallContent functionCall)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(functionCall)}...", ConsoleColor.DarkYellow);
|
||||
await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(this._formatters, functionCall)}...", ConsoleColor.DarkYellow);
|
||||
}
|
||||
else if (content is ToolCallContent toolCall)
|
||||
{
|
||||
|
||||
-288
@@ -1,288 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <see cref="FunctionCallContent"/> instances into human-readable strings
|
||||
/// for console display.
|
||||
/// </summary>
|
||||
public static class ToolCallFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a formatted string for the given tool call, with human-readable
|
||||
/// details for known tools (todos, mode, sub-agents, web tools).
|
||||
/// </summary>
|
||||
/// <param name="call">The function call content to format.</param>
|
||||
/// <returns>A formatted string describing the tool call.</returns>
|
||||
public static string Format(FunctionCallContent call)
|
||||
{
|
||||
string? detail = call.Name switch
|
||||
{
|
||||
// Todo tools
|
||||
"TodoList_Add" => FormatAddTodos(call),
|
||||
"TodoList_Complete" => FormatIdList(call, "ids", "Complete"),
|
||||
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
|
||||
"TodoList_GetRemaining" => null,
|
||||
"TodoList_GetAll" => null,
|
||||
|
||||
// Mode tools
|
||||
"AgentMode_Set" => FormatStringArg(call, "mode"),
|
||||
"AgentMode_Get" => null,
|
||||
|
||||
// Sub-agent tools
|
||||
"SubAgents_StartTask" => FormatStartSubTask(call),
|
||||
"SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
|
||||
"SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
|
||||
"SubAgents_GetAllTasks" => null,
|
||||
"SubAgents_ContinueTask" => FormatContinueTask(call),
|
||||
"SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
|
||||
|
||||
// File memory tools
|
||||
"FileMemory_SaveFile" => FormatSaveFile(call),
|
||||
"FileMemory_ReadFile" => FormatStringArg(call, "fileName"),
|
||||
"FileMemory_DeleteFile" => FormatStringArg(call, "fileName"),
|
||||
"FileMemory_ListFiles" => null,
|
||||
"FileMemory_SearchFiles" => FormatSearchFiles(call),
|
||||
|
||||
// External tools
|
||||
"web_search" => FormatStringArg(call, "query"),
|
||||
"DownloadUri" => FormatStringArg(call, "uri"),
|
||||
|
||||
_ => FormatFallback(call),
|
||||
};
|
||||
|
||||
return detail is not null ? $"{call.Name} {detail}" : call.Name;
|
||||
}
|
||||
|
||||
private static string? FormatAddTodos(FunctionCallContent call)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue("todos", out object? todosObj) != true || todosObj is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var titles = new List<string>();
|
||||
|
||||
if (todosObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in jsonArray.EnumerateArray())
|
||||
{
|
||||
string? title = item.TryGetProperty("title", out JsonElement titleElement)
|
||||
? titleElement.GetString()
|
||||
: null;
|
||||
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
titles.Add(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (titles.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"({titles.Count} item{(titles.Count == 1 ? "" : "s")})");
|
||||
foreach (string title in titles)
|
||||
{
|
||||
sb.Append($"\n • {title}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
|
||||
{
|
||||
List<int>? ids = GetIntList(call, paramName);
|
||||
if (ids is null || ids.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $"({verb} #{string.Join(", #", ids)})";
|
||||
}
|
||||
|
||||
private static string? FormatSingleId(FunctionCallContent call, string paramName)
|
||||
{
|
||||
int? id = GetInt(call, paramName);
|
||||
return id.HasValue ? $"(task #{id.Value})" : null;
|
||||
}
|
||||
|
||||
private static string? FormatStartSubTask(FunctionCallContent call)
|
||||
{
|
||||
string? agentName = GetString(call, "agentName");
|
||||
string? description = GetString(call, "description");
|
||||
|
||||
if (agentName is null && description is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder("(");
|
||||
if (agentName is not null)
|
||||
{
|
||||
sb.Append($"agent: {agentName}");
|
||||
}
|
||||
|
||||
if (description is not null)
|
||||
{
|
||||
if (agentName is not null)
|
||||
{
|
||||
sb.Append(", ");
|
||||
}
|
||||
|
||||
sb.Append($"\"{Truncate(description, 60)}\"");
|
||||
}
|
||||
|
||||
sb.Append(')');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? FormatContinueTask(FunctionCallContent call)
|
||||
{
|
||||
int? taskId = GetInt(call, "taskId");
|
||||
string? text = GetString(call, "text");
|
||||
|
||||
if (!taskId.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return text is not null
|
||||
? $"(task #{taskId.Value}, \"{Truncate(text, 50)}\")"
|
||||
: $"(task #{taskId.Value})";
|
||||
}
|
||||
|
||||
private static string? FormatSaveFile(FunctionCallContent call)
|
||||
{
|
||||
string? fileName = GetString(call, "fileName");
|
||||
string? description = GetString(call, "description");
|
||||
|
||||
if (fileName is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(description)
|
||||
? $"({fileName})"
|
||||
: $"({fileName}, with description)";
|
||||
}
|
||||
|
||||
private static string? FormatSearchFiles(FunctionCallContent call)
|
||||
{
|
||||
string? pattern = GetString(call, "regexPattern");
|
||||
string? filePattern = GetString(call, "filePattern");
|
||||
|
||||
if (pattern is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(filePattern)
|
||||
? $"(/{pattern}/)"
|
||||
: $"(/{pattern}/ in {filePattern})";
|
||||
}
|
||||
|
||||
private static string? FormatStringArg(FunctionCallContent call, string paramName)
|
||||
{
|
||||
string? value = GetString(call, paramName);
|
||||
return value is not null ? $"({value})" : null;
|
||||
}
|
||||
|
||||
private static string? FormatFallback(FunctionCallContent call)
|
||||
{
|
||||
if (call.Arguments is null || call.Arguments.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var kvp in call.Arguments)
|
||||
{
|
||||
string? stringValue = kvp.Value switch
|
||||
{
|
||||
JsonElement je => je.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => je.GetString(),
|
||||
JsonValueKind.Number => je.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => null,
|
||||
},
|
||||
not null => kvp.Value.ToString(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (stringValue is not null)
|
||||
{
|
||||
parts.Add($"{kvp.Key}: {Truncate(stringValue, 40)}");
|
||||
}
|
||||
}
|
||||
|
||||
return parts.Count > 0 ? $"({string.Join(", ", parts)})" : null;
|
||||
}
|
||||
|
||||
private static string? GetString(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(),
|
||||
string s => s,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
private static int? GetInt(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(),
|
||||
int i => i,
|
||||
_ => int.TryParse(value.ToString(), out int parsed) ? parsed : null,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<int>? GetIntList(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new List<int>();
|
||||
|
||||
if (value is JsonElement je && je.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in je.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
result.Add(item.GetInt32());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.Count > 0 ? result : null;
|
||||
}
|
||||
|
||||
private static string Truncate(string text, int maxLength)
|
||||
{
|
||||
return text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength), "…");
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
@@ -7,7 +8,7 @@ namespace Harness.Shared.Console.Observers;
|
||||
/// <summary>
|
||||
/// Displays token usage statistics (📊) from the response stream.
|
||||
/// </summary>
|
||||
internal sealed class UsageDisplayObserver : ConsoleObserver
|
||||
public sealed class UsageDisplayObserver : ConsoleObserver
|
||||
{
|
||||
private readonly int? _maxContextWindowTokens;
|
||||
private readonly int? _maxOutputTokens;
|
||||
@@ -24,7 +25,7 @@ internal sealed class UsageDisplayObserver : ConsoleObserver
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task OnContentAsync(HarnessUXContainer ux, AIContent content)
|
||||
public override Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (content is UsageContent usage)
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Harness.Shared.Console;
|
||||
/// <summary>
|
||||
/// Represents the type of an output entry in the console conversation.
|
||||
/// </summary>
|
||||
public enum OutputEntryType
|
||||
internal enum OutputEntryType
|
||||
{
|
||||
/// <summary>User input echo (e.g. "You: hello").</summary>
|
||||
UserInput,
|
||||
@@ -25,9 +25,10 @@ public enum OutputEntryType
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single output entry in the console conversation history.
|
||||
/// These entries are rendered by the <see cref="HarnessAppComponent"/> via its render delegate.
|
||||
/// Used internally by <see cref="HarnessConsoleUXStateDriver"/> to track
|
||||
/// the in-progress streaming entry and last-entry type for spacing decisions.
|
||||
/// </summary>
|
||||
/// <param name="Type">The type of output entry.</param>
|
||||
/// <param name="Text">The text content of the entry.</param>
|
||||
/// <param name="Color">Optional foreground color for rendering.</param>
|
||||
public record OutputEntry(OutputEntryType Type, string Text, ConsoleColor? Color = null);
|
||||
internal sealed record OutputEntry(OutputEntryType Type, string Text, ConsoleColor? Color = null);
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Catch-all formatter that handles any tool not matched by a more specific formatter.
|
||||
/// Displays a generic summary of the tool's arguments. This formatter should always be
|
||||
/// placed last in the formatter list.
|
||||
/// </summary>
|
||||
public sealed class FallbackToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call)
|
||||
{
|
||||
if (call.Arguments is null || call.Arguments.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var kvp in call.Arguments)
|
||||
{
|
||||
string? stringValue = kvp.Value switch
|
||||
{
|
||||
JsonElement je => je.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => je.GetString(),
|
||||
JsonValueKind.Number => je.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => null,
|
||||
},
|
||||
not null => kvp.Value.ToString(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (stringValue is not null)
|
||||
{
|
||||
parts.Add($"{kvp.Key}: {Truncate(stringValue, 40)}");
|
||||
}
|
||||
}
|
||||
|
||||
return parts.Count > 0 ? $"({string.Join(", ", parts)})" : null;
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>FileMemory_*</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);
|
||||
|
||||
/// <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),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? FormatSaveFile(FunctionCallContent call)
|
||||
{
|
||||
string? fileName = GetStringArgumentValue(call, "fileName");
|
||||
string? description = GetStringArgumentValue(call, "description");
|
||||
|
||||
if (fileName is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(description)
|
||||
? $"\n └─ {fileName}"
|
||||
: $"\n └─ {fileName} (with description)";
|
||||
}
|
||||
|
||||
private static string? FormatSearchFiles(FunctionCallContent call)
|
||||
{
|
||||
string? pattern = GetStringArgumentValue(call, "regexPattern");
|
||||
string? filePattern = GetStringArgumentValue(call, "filePattern");
|
||||
|
||||
if (pattern is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(filePattern)
|
||||
? $"(/{pattern}/)"
|
||||
: $"(/{pattern}/ in {filePattern})";
|
||||
}
|
||||
|
||||
private static string? FormatStringArg(FunctionCallContent call, string paramName)
|
||||
{
|
||||
string? value = GetStringArgumentValue(call, paramName);
|
||||
return value is not null ? $"({value})" : null;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>AgentMode_*</c> tool calls, showing the target mode for Set operations.
|
||||
/// </summary>
|
||||
public sealed class ModeToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("AgentMode_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"AgentMode_Set" => FormatStringArg(call, "mode"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? FormatStringArg(FunctionCallContent call, string paramName)
|
||||
{
|
||||
string? value = GetStringArgumentValue(call, paramName);
|
||||
return value is not null ? $"({value})" : null;
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>SubAgents_*</c> tool calls with human-readable details
|
||||
/// for task start, continue, wait, and result retrieval operations.
|
||||
/// </summary>
|
||||
public sealed class SubAgentToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("SubAgents_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"SubAgents_StartTask" => FormatStartSubTask(call),
|
||||
"SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
|
||||
"SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
|
||||
"SubAgents_ContinueTask" => FormatContinueTask(call),
|
||||
"SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? FormatStartSubTask(FunctionCallContent call)
|
||||
{
|
||||
string? agentName = GetStringArgumentValue(call, "agentName");
|
||||
string? description = GetStringArgumentValue(call, "description");
|
||||
|
||||
if (agentName is null && description is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
if (agentName is not null && description is not null)
|
||||
{
|
||||
sb.Append($"\n ├─ Agent: {agentName}");
|
||||
sb.Append($"\n └─ \"{Truncate(description, 80)}\"");
|
||||
}
|
||||
else if (agentName is not null)
|
||||
{
|
||||
sb.Append($"\n └─ Agent: {agentName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($"\n └─ \"{Truncate(description!, 80)}\"");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
|
||||
{
|
||||
List<int>? ids = GetIntListArgumentValue(call, paramName);
|
||||
if (ids is null || ids.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < ids.Count; i++)
|
||||
{
|
||||
string connector = i < ids.Count - 1 ? "├─" : "└─";
|
||||
sb.Append($"\n {connector} {verb} #{ids[i]}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? FormatSingleId(FunctionCallContent call, string paramName)
|
||||
{
|
||||
int? id = GetIntArgumentValue(call, paramName);
|
||||
return id.HasValue ? $"(task #{id.Value})" : null;
|
||||
}
|
||||
|
||||
private static string? FormatContinueTask(FunctionCallContent call)
|
||||
{
|
||||
int? taskId = GetIntArgumentValue(call, "taskId");
|
||||
string? text = GetStringArgumentValue(call, "text");
|
||||
|
||||
if (!taskId.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (text is not null)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"\n ├─ Task #{taskId.Value}");
|
||||
sb.Append($"\n └─ \"{Truncate(text, 80)}\"");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
return $"\n └─ Task #{taskId.Value}";
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>TodoList_*</c> tool calls with tree-view output for added items
|
||||
/// and structured output for complete/remove operations.
|
||||
/// </summary>
|
||||
public sealed class TodoToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("TodoList_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"TodoList_Add" => FormatAddTodos(call),
|
||||
"TodoList_Complete" => FormatIdList(call, "ids", "Complete"),
|
||||
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? FormatAddTodos(FunctionCallContent call)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue("todos", out object? todosObj) != true || todosObj is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var titles = new List<string>();
|
||||
|
||||
if (todosObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in jsonArray.EnumerateArray())
|
||||
{
|
||||
string? title = item.TryGetProperty("title", out JsonElement titleElement)
|
||||
? titleElement.GetString()
|
||||
: null;
|
||||
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
titles.Add(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (titles.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"({titles.Count} item{(titles.Count == 1 ? "" : "s")})");
|
||||
for (int i = 0; i < titles.Count; i++)
|
||||
{
|
||||
string connector = i < titles.Count - 1 ? "├─" : "└─";
|
||||
sb.Append($"\n {connector} {titles[i]}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
|
||||
{
|
||||
List<int>? ids = GetIntListArgumentValue(call, paramName);
|
||||
if (ids is null || ids.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < ids.Count; i++)
|
||||
{
|
||||
string connector = i < ids.Count - 1 ? "├─" : "└─";
|
||||
sb.Append($"\n {connector} {verb} #{ids[i]}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for tool call formatters that produce human-readable display strings
|
||||
/// for <see cref="FunctionCallContent"/> items shown in the console.
|
||||
/// </summary>
|
||||
public abstract class ToolCallFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> if this formatter can handle the given function call.
|
||||
/// </summary>
|
||||
/// <param name="call">The function call content to check.</param>
|
||||
/// <returns><see langword="true"/> if this formatter should be used; otherwise <see langword="false"/>.</returns>
|
||||
public abstract bool CanFormat(FunctionCallContent call);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the detail portion of the formatted output for the given tool call,
|
||||
/// or <see langword="null"/> if only the tool name should be displayed.
|
||||
/// </summary>
|
||||
/// <param name="call">The function call content to format.</param>
|
||||
/// <returns>A detail string to append after the tool name, or <see langword="null"/>.</returns>
|
||||
public abstract string? FormatDetail(FunctionCallContent call);
|
||||
|
||||
/// <summary>
|
||||
/// Formats a tool call using the first matching formatter from the provided list.
|
||||
/// Returns <c>"{toolName} {detail}"</c> when a formatter produces detail,
|
||||
/// or just <c>"{toolName}"</c> otherwise.
|
||||
/// </summary>
|
||||
internal static string Format(IReadOnlyList<ToolCallFormatter> formatters, FunctionCallContent call)
|
||||
{
|
||||
foreach (var formatter in formatters)
|
||||
{
|
||||
if (formatter.CanFormat(call))
|
||||
{
|
||||
string? detail = formatter.FormatDetail(call);
|
||||
return detail is not null ? $"{call.Name} {detail}" : call.Name;
|
||||
}
|
||||
}
|
||||
|
||||
return call.Name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the default list of tool call formatters. The <see cref="FallbackToolFormatter"/>
|
||||
/// is always last. Users can call this method and combine the result with their own formatters.
|
||||
/// </summary>
|
||||
/// <returns>A list of all built-in tool call formatters.</returns>
|
||||
public static List<ToolCallFormatter> BuildDefaultToolFormatters()
|
||||
{
|
||||
return
|
||||
[
|
||||
new TodoToolFormatter(),
|
||||
new ModeToolFormatter(),
|
||||
new SubAgentToolFormatter(),
|
||||
new FileMemoryToolFormatter(),
|
||||
new WebSearchToolFormatter(),
|
||||
new FallbackToolFormatter(),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a string argument value from a function call.
|
||||
/// </summary>
|
||||
protected static string? GetStringArgumentValue(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(),
|
||||
string s => s,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts an integer argument value from a function call.
|
||||
/// </summary>
|
||||
protected static int? GetIntArgumentValue(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(),
|
||||
int i => i,
|
||||
_ => int.TryParse(value.ToString(), out int parsed) ? parsed : null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a list of integer argument values from a function call.
|
||||
/// </summary>
|
||||
protected static List<int>? GetIntListArgumentValue(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new List<int>();
|
||||
|
||||
if (value is JsonElement je && je.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in je.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
result.Add(item.GetInt32());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.Count > 0 ? result : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Truncates a string to the specified maximum length, appending an ellipsis if truncated.
|
||||
/// </summary>
|
||||
protected static string Truncate(string text, int maxLength)
|
||||
{
|
||||
return text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength), "…");
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>web_search</c> tool calls, showing the search query.
|
||||
/// </summary>
|
||||
public sealed class WebSearchToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) =>
|
||||
call.Name is "web_search";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call)
|
||||
{
|
||||
string? value = GetStringArgumentValue(call, "query");
|
||||
return value is not null ? $"({value})" : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>DownloadUri</c> tool calls, showing the target URI.
|
||||
/// </summary>
|
||||
public sealed class DownloadUriToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) =>
|
||||
call.Name is "DownloadUri";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call)
|
||||
{
|
||||
string? value = GetStringArgumentValue(call, "uri");
|
||||
return value is not null ? $"({value})" : null;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@
|
||||
//
|
||||
// Special commands:
|
||||
// /todos — Display the current todo list without invoking the agent.
|
||||
// exit — End the session.
|
||||
// /mode — Get or set the current agent mode.
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
@@ -16,6 +17,7 @@
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
@@ -158,13 +160,15 @@ AIAgent agent =
|
||||
// Run the interactive console session using the shared HarnessConsole helper.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
agent,
|
||||
title: "Research Assistant",
|
||||
userPrompt: "Enter a research topic to get started.",
|
||||
new HarnessConsoleOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
EnablePlanningUx = true,
|
||||
PlanningModeName = "plan",
|
||||
ExecutionModeName = "execute"
|
||||
Observers = HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
executionModeName: "execute",
|
||||
maxContextWindowTokens: MaxContextWindowTokens,
|
||||
maxOutputTokens: MaxOutputTokens,
|
||||
toolFormatters: [new DownloadUriToolFormatter(), .. ToolCallFormatter.BuildDefaultToolFormatters()]),
|
||||
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// equipped with Foundry's hosted web search tool.
|
||||
//
|
||||
// Special commands:
|
||||
// exit — End the session.
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
@@ -103,5 +103,4 @@ AIAgent parentAgent =
|
||||
// Run the interactive console session.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
parentAgent,
|
||||
title: "Stock Price Researcher (SubAgents Demo)",
|
||||
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):");
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// Ask the agent to analyze the data, produce summaries, or create new output files.
|
||||
//
|
||||
// Special commands:
|
||||
// exit — End the session.
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
@@ -85,5 +85,4 @@ AIAgent agent =
|
||||
// Run the interactive console session.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
agent,
|
||||
title: "Data Processing Assistant",
|
||||
userPrompt: "Ask me to analyze the data files, produce summaries, or create output files.");
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
|
||||
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
|
||||
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="OpenAI" />
|
||||
<PackageReference Include="System.ClientModel" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Foundry\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Mcp\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="InvokeFoundryToolboxMcp.yaml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
#
|
||||
# This workflow demonstrates invoking MCP tools through a Foundry toolbox MCP proxy.
|
||||
#
|
||||
# The toolbox is provisioned with TWO different tool types:
|
||||
# 1. A Foundry built-in web_search tool
|
||||
# 2. A Microsoft Learn MCP server (microsoft_docs)
|
||||
# Both are surfaced through the same MCP-compatible toolbox endpoint.
|
||||
#
|
||||
# The workflow:
|
||||
# 1. Accepts a documentation/web search query as input
|
||||
# 2. Lists the tools exposed by the Foundry toolbox using reserved toolName: tools/list
|
||||
# 3. Invokes the microsoft_docs_search MCP tool
|
||||
# 4. Invokes the built-in web_search tool against the same toolbox endpoint
|
||||
# 5. Uses an agent to summarize and combine both result sets
|
||||
#
|
||||
# Example input:
|
||||
# How do I use Azure OpenAI with my data?
|
||||
#
|
||||
kind: Workflow
|
||||
trigger:
|
||||
|
||||
kind: OnConversationStart
|
||||
id: workflow_invoke_foundry_toolbox_mcp
|
||||
actions:
|
||||
|
||||
# Set the search query from user input.
|
||||
- kind: SetVariable
|
||||
id: set_search_query
|
||||
variable: Local.SearchQuery
|
||||
value: =System.LastMessage.Text
|
||||
|
||||
# List tools exposed by the Foundry toolbox MCP proxy.
|
||||
- kind: InvokeMcpTool
|
||||
id: list_toolbox_tools
|
||||
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
|
||||
serverLabel: foundry_toolbox
|
||||
toolName: tools/list
|
||||
conversationId: =System.ConversationId
|
||||
headers:
|
||||
Foundry-Features: Toolboxes=V1Preview
|
||||
output:
|
||||
autoSend: true
|
||||
result: Local.ToolboxTools
|
||||
|
||||
# Invoke a specific tool exposed through the toolbox and add the result to the conversation.
|
||||
- kind: InvokeMcpTool
|
||||
id: search_docs_with_toolbox
|
||||
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
|
||||
serverLabel: foundry_toolbox
|
||||
toolName: =Env.FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL & "___microsoft_docs_search"
|
||||
conversationId: =System.ConversationId
|
||||
headers:
|
||||
Foundry-Features: Toolboxes=V1Preview
|
||||
arguments:
|
||||
query: =Local.SearchQuery
|
||||
output:
|
||||
autoSend: true
|
||||
result: Local.SearchResult
|
||||
|
||||
# Invoke the web_search built-in tool through the same toolbox proxy. The toolbox surfaces
|
||||
# built-in Foundry tools (like web_search) alongside MCP tools through one MCP-compatible
|
||||
# endpoint. Note that web_search expects argument 'search_query' (not 'query').
|
||||
- kind: InvokeMcpTool
|
||||
id: search_web_with_toolbox
|
||||
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
|
||||
serverLabel: foundry_toolbox
|
||||
toolName: =Env.FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME
|
||||
conversationId: =System.ConversationId
|
||||
headers:
|
||||
Foundry-Features: Toolboxes=V1Preview
|
||||
arguments:
|
||||
search_query: =Local.SearchQuery
|
||||
output:
|
||||
autoSend: true
|
||||
result: Local.WebSearchResult
|
||||
|
||||
# Use the agent to summarize what happened and answer from the toolbox result.
|
||||
- kind: InvokeAzureAgent
|
||||
id: summarize_toolbox_result
|
||||
agent:
|
||||
name: FoundryToolboxMcpAgent
|
||||
conversationId: =System.ConversationId
|
||||
input:
|
||||
messages: =UserMessage("Combine the Microsoft Learn docs results and the Foundry web search results in the conversation to answer the query " & Local.SearchQuery)
|
||||
output:
|
||||
autoSend: true
|
||||
messages: Local.Summary
|
||||
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates using InvokeMcpTool to call MCP tools through a Foundry toolbox.
|
||||
// It creates a sample toolbox that exposes Microsoft Learn MCP tools, lists the toolbox tools
|
||||
// through the reserved tools/list operation, then calls microsoft_docs_search from the workflow.
|
||||
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http.Headers;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Mcp;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using OpenAI.Responses;
|
||||
using Shared.Foundry;
|
||||
using Shared.Workflows;
|
||||
|
||||
#pragma warning disable OPENAI001 // Experimental API
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental
|
||||
|
||||
namespace Demo.Workflows.Declarative.InvokeFoundryToolboxMcp;
|
||||
|
||||
/// <summary>
|
||||
/// Demonstrates a workflow that uses InvokeMcpTool to call MCP tools exposed through a Foundry toolbox.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This sample provisions a toolbox with Microsoft Learn MCP tools, uses the reserved
|
||||
/// <c>tools/list</c> tool name to list the toolbox tools, calls one specific toolbox tool,
|
||||
/// and has a Foundry agent summarize the results.
|
||||
/// </remarks>
|
||||
internal sealed class Program
|
||||
{
|
||||
private const string ToolboxNameSetting = "FOUNDRY_TOOLBOX_NAME";
|
||||
private const string ToolboxApiVersionSetting = "FOUNDRY_AGENT_TOOLSET_API_VERSION";
|
||||
private const string ToolboxMcpServerUrlSetting = "FOUNDRY_TOOLBOX_MCP_SERVER_URL";
|
||||
private const string DocsServerLabelSetting = "FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL";
|
||||
private const string WebSearchToolNameSetting = "FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME";
|
||||
private const string DefaultToolboxName = "declarative_foundry_toolbox_mcp";
|
||||
private const string DefaultToolboxApiVersion = "v1";
|
||||
private const string DefaultDocsServerLabel = "microsoft_docs";
|
||||
private const string DefaultWebSearchToolName = "web_search";
|
||||
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
// Initialize configuration
|
||||
IConfiguration configuration = Application.InitializeConfig();
|
||||
Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
|
||||
string toolboxName = configuration[ToolboxNameSetting] ?? DefaultToolboxName;
|
||||
string toolboxApiVersion = configuration[ToolboxApiVersionSetting] ?? DefaultToolboxApiVersion;
|
||||
string docsServerLabel = configuration[DocsServerLabelSetting] ?? DefaultDocsServerLabel;
|
||||
string webSearchToolName = configuration[WebSearchToolNameSetting] ?? DefaultWebSearchToolName;
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
DefaultAzureCredential credential = new();
|
||||
|
||||
// Ensure sample toolbox and agent exist in Foundry
|
||||
string toolboxEndpoint = await CreateSampleToolboxAsync(toolboxName, docsServerLabel, foundryEndpoint, credential);
|
||||
string toolboxMcpServerUrl = BuildToolboxMcpServerUrl(toolboxEndpoint, toolboxName, toolboxApiVersion);
|
||||
IConfiguration workflowConfiguration = new ConfigurationBuilder()
|
||||
.AddConfiguration(configuration)
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[ToolboxMcpServerUrlSetting] = toolboxMcpServerUrl,
|
||||
[DocsServerLabelSetting] = docsServerLabel,
|
||||
[WebSearchToolNameSetting] = webSearchToolName,
|
||||
})
|
||||
.Build();
|
||||
|
||||
await CreateAgentAsync(foundryEndpoint, configuration, credential);
|
||||
|
||||
// Get input from command line or console
|
||||
string workflowInput = Application.GetInput(args);
|
||||
|
||||
// Create the MCP tool handler for invoking the Foundry toolbox MCP proxy.
|
||||
ConcurrentBag<HttpClient> createdHttpClients = [];
|
||||
DefaultMcpToolHandler mcpToolHandler = new(
|
||||
httpClientProvider: async (serverUrl, _) =>
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
|
||||
if (!string.Equals(serverUrl, toolboxMcpServerUrl, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
FoundryToolboxBearerTokenHandler handler = new(credential)
|
||||
{
|
||||
InnerHandler = new HttpClientHandler()
|
||||
};
|
||||
HttpClient httpClient = new(handler);
|
||||
createdHttpClients.Add(httpClient);
|
||||
return httpClient;
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
// Create the workflow factory with MCP tool provider
|
||||
WorkflowFactory workflowFactory = new("InvokeFoundryToolboxMcp.yaml", foundryEndpoint)
|
||||
{
|
||||
Configuration = workflowConfiguration,
|
||||
McpToolHandler = mcpToolHandler
|
||||
};
|
||||
|
||||
// Execute the workflow
|
||||
WorkflowRunner runner = new() { UseJsonCheckpoints = true };
|
||||
await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up connections and dispose created HttpClients
|
||||
await mcpToolHandler.DisposeAsync();
|
||||
|
||||
foreach (HttpClient httpClient in createdHttpClients)
|
||||
{
|
||||
httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration, TokenCredential credential)
|
||||
{
|
||||
AIProjectClient aiProjectClient = new(foundryEndpoint, credential);
|
||||
|
||||
await aiProjectClient.CreateAgentAsync(
|
||||
agentName: "FoundryToolboxMcpAgent",
|
||||
agentDefinition: DefineToolboxAgent(configuration),
|
||||
agentDescription: "Summarizes Foundry toolbox MCP tool results");
|
||||
}
|
||||
|
||||
private static DeclarativeAgentDefinition DefineToolboxAgent(IConfiguration configuration)
|
||||
{
|
||||
return new DeclarativeAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel))
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
You are a helpful assistant that explains results produced by tools exposed through a Foundry toolbox.
|
||||
The conversation history contains output from BOTH a Microsoft Learn documentation search (MCP) and a Foundry web search.
|
||||
Synthesize an answer that draws on both sources, calls out where they agree or differ, and notes which toolbox tool produced each fact when it is relevant.
|
||||
Be concise.
|
||||
"""
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<string> CreateSampleToolboxAsync(string name, string serverLabel, Uri foundryEndpoint, TokenCredential credential)
|
||||
{
|
||||
AgentAdministrationClientOptions options = new();
|
||||
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
|
||||
AgentAdministrationClient adminClient = new(foundryEndpoint, credential, options);
|
||||
AgentToolboxes toolboxClient = adminClient.GetAgentToolboxes();
|
||||
|
||||
try
|
||||
{
|
||||
await toolboxClient.DeleteToolboxAsync(name);
|
||||
Console.WriteLine($"Deleted existing toolbox '{name}'");
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Toolbox does not exist.
|
||||
}
|
||||
|
||||
ProjectsAgentTool webTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateWebSearchTool());
|
||||
|
||||
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
|
||||
serverLabel: serverLabel,
|
||||
serverUri: new Uri("https://learn.microsoft.com/api/mcp"),
|
||||
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
|
||||
|
||||
ToolboxVersion created = (await toolboxClient.CreateToolboxVersionAsync(
|
||||
name: name,
|
||||
tools: [webTool, mcpTool],
|
||||
description: "Sample toolbox combining Foundry web search with the Microsoft Learn MCP tools for the declarative InvokeFoundryToolboxMcp sample.")).Value;
|
||||
|
||||
Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))");
|
||||
|
||||
return $"{foundryEndpoint.ToString().TrimEnd('/')}/toolboxes";
|
||||
}
|
||||
|
||||
private static string BuildToolboxMcpServerUrl(string toolboxEndpoint, string toolboxName, string apiVersion) =>
|
||||
$"{toolboxEndpoint.TrimEnd('/')}/{toolboxName}/mcp?api-version={Uri.EscapeDataString(apiVersion)}";
|
||||
|
||||
private sealed class FoundryToolboxBearerTokenHandler(TokenCredential credential) : DelegatingHandler
|
||||
{
|
||||
private static readonly TokenRequestContext s_tokenContext =
|
||||
new(["https://ai.azure.com/.default"]);
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AccessToken token = await credential.GetTokenAsync(s_tokenContext, cancellationToken);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
|
||||
{
|
||||
private const string FeatureHeader = "Foundry-Features";
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Add(FeatureHeader, feature);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Add(FeatureHeader, feature);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,10 +101,6 @@ else
|
||||
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
|
||||
}
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
builder.AddA2AServer(hostA2AAgent);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -49,10 +49,6 @@ var agent = new AzureOpenAIClient(
|
||||
AGUIServerSerializerContext.Default.Options)
|
||||
]);
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
// Register the agent with the host and configure it to use an in-memory session store
|
||||
// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
|
||||
builder
|
||||
|
||||
@@ -28,10 +28,6 @@ builder.AddDevUI();
|
||||
builder.AddOpenAIChatCompletions();
|
||||
builder.AddOpenAIResponses();
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
var pirateAgentBuilder = builder.AddAIAgent(
|
||||
"pirate",
|
||||
instructions: "You are a pirate. Speak like a pirate",
|
||||
@@ -152,10 +148,6 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
|
||||
pirateAgentBuilder.AddA2AServer();
|
||||
knightsKnavesAgentBuilder.AddA2AServer();
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapOpenApi();
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// <see cref="HarnessAgent"/> assembles the following pipeline from a caller-supplied <see cref="IChatClient"/>:
|
||||
/// <list type="number">
|
||||
/// <item><description><see cref="FunctionInvokingChatClient"/> — automatic function/tool invocation.</description></item>
|
||||
/// <item><description><see cref="MessageInjectingChatClient"/> — allows external code to inject messages into the conversation mid-stream.</description></item>
|
||||
/// <item><description><see cref="PerServiceCallChatHistoryPersistingChatClient"/> — persists chat history after every individual service call within a function-invocation loop.</description></item>
|
||||
/// <item><description><see cref="AIContextProviderChatClient"/> with a <see cref="CompactionProvider"/> — applies context-window compaction before each call so long function-invocation loops do not overflow the context window.</description></item>
|
||||
/// </list>
|
||||
@@ -110,6 +111,7 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
return chatClient
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation()
|
||||
.UseMessageInjection()
|
||||
.UsePerServiceCallChatHistoryPersistence()
|
||||
.UseAIContextProviders(compactionProvider)
|
||||
.BuildAIAgent(new ChatClientAgentOptions
|
||||
|
||||
-1
@@ -27,7 +27,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -28,23 +28,6 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agentBuilder">The agent builder whose name identifies the agent.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="agentBuilder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The A2A <c>contextId</c> arrives from the wire
|
||||
/// and is treated as a chain-resume identifier — <em>not</em> as an authorization
|
||||
/// token. The <see cref="AgentSessionStore"/> contract carries no principal/owner
|
||||
/// dimension, so when a persistent store is registered any caller who knows or
|
||||
/// guesses another caller's <c>contextId</c> can resume that other caller's
|
||||
/// persisted thread. Hosts that serve more than one user must compose a principal
|
||||
/// dimension into the lookup key — typically by calling
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>). When no isolation provider is
|
||||
/// registered, behavior is unchanged — the bare <c>contextId</c> is used as the
|
||||
/// conversation identifier, which is appropriate for first-run / single-user /
|
||||
/// prototyping scenarios but unsafe for multi-user hosts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
@@ -63,13 +46,6 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
@@ -89,13 +65,6 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agent">The agent instance to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
@@ -114,13 +83,6 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
@@ -152,13 +114,6 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agent">The agent instance to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
@@ -185,17 +140,9 @@ public static class A2AServerServiceCollectionExtensions
|
||||
var agentSessionStore = serviceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
|
||||
var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
|
||||
|
||||
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
|
||||
var isolationKeyProvider = serviceProvider.GetService<SessionIsolationKeyProvider>();
|
||||
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
|
||||
{
|
||||
agentSessionStore ??= new InMemoryAgentSessionStore();
|
||||
agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
|
||||
}
|
||||
|
||||
var hostAgent = new AIHostAgent(
|
||||
innerAgent: agent,
|
||||
sessionStore: agentSessionStore);
|
||||
sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore());
|
||||
|
||||
agentHandler = new A2AAgentHandler(hostAgent, runMode);
|
||||
}
|
||||
|
||||
-20
@@ -73,26 +73,6 @@ public static class AGUIEndpointRouteBuilderExtensions
|
||||
/// it will be used to persist conversation sessions across requests using the AG-UI thread ID as the
|
||||
/// conversation identifier. If no session store is registered, sessions are ephemeral (not persisted).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The AG-UI <c>RunAgentInput.ThreadId</c> arrives
|
||||
/// from the wire and is treated as a chain-resume identifier — <em>not</em> as an
|
||||
/// authorization token. The <see cref="AgentSessionStore"/> contract carries no
|
||||
/// principal/owner dimension, so when a persistent store is registered any caller
|
||||
/// who knows or guesses another caller's <c>ThreadId</c> can resume that other
|
||||
/// caller's persisted thread. Hosts that serve more than one user must compose a
|
||||
/// principal dimension into the lookup key. The recommended way is to wrap the
|
||||
/// keyed <see cref="AgentSessionStore"/> in
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/>, typically by calling
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>) and registering the store via the
|
||||
/// <c>WithSessionStore(...)</c> / <c>WithInMemorySessionStore(...)</c> helpers on
|
||||
/// <see cref="IHostedAgentBuilder"/> so that the wrapper is applied. When no
|
||||
/// isolation provider is registered, behavior is unchanged — the bare
|
||||
/// <c>ThreadId</c> is used as the conversation identifier, which is appropriate
|
||||
/// for first-run / single-user / prototyping scenarios but unsafe for
|
||||
/// multi-user hosts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapAGUI(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="SessionIsolationKeyProvider"/> that extracts the session isolation key from a claim
|
||||
/// in the current user's identity, as provided by ASP.NET Core's <see cref="IHttpContextAccessor"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This provider is suitable for ASP.NET Core web applications where session isolation is based on
|
||||
/// authenticated user identity. It reads a specified claim type (e.g., name, email, or a custom identifier)
|
||||
/// from the ambient <see cref="HttpContext"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the <see cref="HttpContext"/> is unavailable, the user is not authenticated, or the specified claim
|
||||
/// is missing, the provider returns <see langword="null"/>. The consuming <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// will then enforce strict or pass-through behavior based on its configuration.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This class relies on <see cref="IHttpContextAccessor"/>, which uses <see cref="AsyncLocal{T}"/>
|
||||
/// to provide access to the current <see cref="HttpContext"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly IHttpContextAccessor? _httpContextAccessor;
|
||||
private readonly string _claimType;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpContextAccessor">
|
||||
/// The <see cref="IHttpContextAccessor"/> used to retrieve the current HTTP context and user claims.
|
||||
/// </param>
|
||||
/// <param name="options">The options for configuring the provider. If null, defaults are used.</param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> is null, empty, or whitespace.
|
||||
/// </exception>
|
||||
public ClaimsIdentitySessionIsolationKeyProvider(
|
||||
IHttpContextAccessor? httpContextAccessor,
|
||||
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
|
||||
{
|
||||
options ??= new ClaimsIdentitySessionIsolationKeyProviderOptions();
|
||||
this._httpContextAccessor = httpContextAccessor;
|
||||
this._claimType = Throw.IfNullOrWhitespace(options.ClaimType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the session isolation key from the current user's claims.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous operation. The task result contains the value of the
|
||||
/// configured claim type from the current user's identity, or <see langword="null"/> if the claim
|
||||
/// is not present or the HTTP context is unavailable.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// This method retrieves the claim value from <c>HttpContext.User.Claims</c>. If multiple claims
|
||||
/// of the specified type exist, the first match is returned.
|
||||
/// </remarks>
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Claim? claim = this._httpContextAccessor?
|
||||
.HttpContext?
|
||||
.User?.Claims.FirstOrDefault(c => c.Type == this._claimType);
|
||||
|
||||
return new ValueTask<string?>(claim?.Value);
|
||||
}
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
|
||||
/// </summary>
|
||||
public class ClaimsIdentitySessionIsolationKeyProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the claim type to extract from the user's identity for session isolation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Defaults to <see cref="ClaimsIdentity.DefaultNameClaimType"/>, which typically corresponds to
|
||||
/// the user's name or unique identifier claim.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Common alternatives include:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>ClaimTypes.NameIdentifier</c> — Stable user identifier</description></item>
|
||||
/// <item><description><c>ClaimTypes.Email</c> — Email address</description></item>
|
||||
/// <item><description>Custom claim types specific to your authentication provider</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType;
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<RootNamespace>Microsoft.Agents.AI.Hosting.AspNetCore</RootNamespace>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<NoWarn>$(NoWarn)</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Hosting ASP.NET Core</Title>
|
||||
<Description>Provides Microsoft Agent Framework support for hosting agents in an ASP.NET Core context.</Description>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring AI hosting services in an <see cref="IServiceCollection"/>.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a <see cref="SessionIsolationKeyProvider"/> that uses claims from the current user's identity
|
||||
/// to generate session isolation keys.
|
||||
/// </summary>
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
|
||||
/// <param name="options"> Optional configuration for the claims-based session isolation key provider.</param>
|
||||
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
|
||||
/// <remarks>
|
||||
/// This method requires <see cref="IHttpContextAccessor"/> to be registered in the service collection.
|
||||
/// Ensure that <c>services.AddHttpContextAccessor()</c> has been called before using this method.
|
||||
/// </remarks>
|
||||
public static IServiceCollection UseClaimsBasedSessionIsolation(
|
||||
this IServiceCollection services,
|
||||
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
|
||||
{
|
||||
options ??= new();
|
||||
ServiceDescriptor descriptor = new(typeof(SessionIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Scoped);
|
||||
services.Add(descriptor);
|
||||
|
||||
return services;
|
||||
|
||||
object CreateIsolationKeyProvider(IServiceProvider serviceProvider)
|
||||
{
|
||||
IHttpContextAccessor contextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
|
||||
|
||||
return new ClaimsIdentitySessionIsolationKeyProvider(contextAccessor, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
@@ -11,39 +9,9 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// Defines the contract for storing and retrieving agent conversation threads.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Implementations of this interface enable persistent storage of conversation threads,
|
||||
/// allowing conversations to be resumed across HTTP requests, application restarts,
|
||||
/// or different service instances in hosted scenarios.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The <c>conversationId</c> passed to
|
||||
/// <see cref="GetSessionAsync"/> and <see cref="SaveSessionAsync"/> typically originates
|
||||
/// from the wire (for example, an AG-UI <c>RunAgentInput.ThreadId</c> or an A2A
|
||||
/// <c>contextId</c>). It is a chain-resume identifier, <em>not</em> an authorization
|
||||
/// token, and the <c>(agent, conversationId)</c> tuple carries no principal/owner
|
||||
/// dimension. Hosts that serve more than one user from the same registered store must
|
||||
/// therefore compose a principal dimension into the lookup key, otherwise any caller
|
||||
/// who knows or guesses another caller's <c>conversationId</c> can resume
|
||||
/// that other caller's persisted thread. The framework provides
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> as a decorator that rewrites
|
||||
/// <c>conversationId</c> to include an isolation key resolved from a
|
||||
/// <see cref="SessionIsolationKeyProvider"/> (for example, the ASP.NET Core
|
||||
/// <c>ClaimsIdentitySessionIsolationKeyProvider</c> wired up via
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c>). When no provider is registered, the
|
||||
/// store behaves as a single-namespace persistence layer — appropriate for
|
||||
/// single-user / first-run / prototyping scenarios but unsafe for multi-user hosts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Implementer guidance.</strong> Implementations should treat
|
||||
/// <c>conversationId</c> as opaque: do not parse it, do not impose length
|
||||
/// or character-set constraints on it, and do not assume it round-trips to the value
|
||||
/// the caller originally supplied (decorators such as
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> may rewrite it before forwarding).
|
||||
/// Be aware that any logging, telemetry, or audit sink that surfaces
|
||||
/// <c>conversationId</c> will also surface the isolation prefix when a
|
||||
/// scoping decorator is in the chain.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class AgentSessionStore
|
||||
{
|
||||
@@ -75,35 +43,4 @@ public abstract class AgentSessionStore
|
||||
AIAgent agent,
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
|
||||
/// <param name="serviceType">The type of object being requested.</param>
|
||||
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
|
||||
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="serviceType"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the <see cref="AgentSessionStore"/>,
|
||||
/// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains
|
||||
/// to verify that specific store implementations are present.
|
||||
/// </remarks>
|
||||
public virtual object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
_ = Throw.IfNull(serviceType);
|
||||
|
||||
return serviceKey is null && serviceType.IsInstanceOfType(this)
|
||||
? this
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of type <typeparamref name="TService"/>.</summary>
|
||||
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
|
||||
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
|
||||
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
|
||||
/// <remarks>
|
||||
/// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the <see cref="AgentSessionStore"/>,
|
||||
/// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains
|
||||
/// to verify that specific store implementations are present.
|
||||
/// </remarks>
|
||||
public TService? GetService<TService>(object? serviceKey = null)
|
||||
=> this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an abstract base class for agent session stores that delegate operations to an inner store
|
||||
/// instance while allowing for extensibility and customization.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="DelegatingAgentSessionStore"/> implements the decorator pattern for <see cref="AgentSessionStore"/>s,
|
||||
/// enabling the creation of pipelines where each layer can add functionality while delegating core operations to an
|
||||
/// underlying store.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The default implementation provides transparent pass-through behavior, forwarding all operations to the inner store.
|
||||
/// Derived classes can override specific methods to add custom behavior while maintaining compatibility with the store
|
||||
/// interface.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class DelegatingAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DelegatingAgentSessionStore"/> class with the specified inner
|
||||
/// store.
|
||||
/// </summary>
|
||||
/// <param name="innerStore">The underlying session store instance that will handle the core operations.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="innerStore"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// The inner session store serves as the foundation of the delegation chain. All operations not overridden by
|
||||
/// derived classes will be forwarded to this store.
|
||||
/// </remarks>
|
||||
protected DelegatingAgentSessionStore(AgentSessionStore innerStore)
|
||||
{
|
||||
this.InnerStore = Throw.IfNull(innerStore);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the inner session store instance that receives delegated operations.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The underlying <see cref="AgentSessionStore"/> instance that handles core storage operations.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// Derived classes can use this property to access the inner session store for custom delegation scenarios
|
||||
/// or to forward operations with additional processing.
|
||||
/// </remarks>
|
||||
protected AgentSessionStore InnerStore { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.GetSessionAsync(agent, conversationId, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.SaveSessionAsync(agent, conversationId, session, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// This implementation first checks if this instance satisfies the service request.
|
||||
/// If not, it chains the request to the inner store, allowing services to be retrieved
|
||||
/// from any store in the delegation chain.
|
||||
/// </remarks>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
// First, check if this instance satisfies the request
|
||||
object? service = base.GetService(serviceType, serviceKey);
|
||||
if (service is not null)
|
||||
{
|
||||
return service;
|
||||
}
|
||||
|
||||
// Chain to the inner store
|
||||
return this.InnerStore.GetService(serviceType, serviceKey);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
@@ -17,11 +16,12 @@ public static class HostedAgentBuilderExtensions
|
||||
/// Configures the host agent builder to use an in-memory session store for agent session management.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host agent builder to configure with the in-memory session store.</param>
|
||||
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
|
||||
/// <returns>The same <paramref name="builder"/> instance, configured to use an in-memory session store.</returns>
|
||||
public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder, bool withIsolation = true)
|
||||
=> builder.WithSessionStore(new InMemoryAgentSessionStore(), withIsolation);
|
||||
public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder)
|
||||
{
|
||||
builder.ServiceCollection.AddKeyedSingleton<AgentSessionStore>(builder.Name, new InMemoryAgentSessionStore());
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the specified agent session store with the host agent builder, enabling session-specific storage for
|
||||
@@ -29,11 +29,12 @@ public static class HostedAgentBuilderExtensions
|
||||
/// </summary>
|
||||
/// <param name="builder">The host agent builder to configure with the session store. Cannot be null.</param>
|
||||
/// <param name="store">The agent session store instance to register. Cannot be null.</param>
|
||||
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
|
||||
/// <returns>The same host agent builder instance, allowing for method chaining.</returns>
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store, bool withIsolation = true)
|
||||
=> builder.WithSessionStore((sp, key) => store, ServiceLifetime.Singleton, withIsolation);
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store)
|
||||
{
|
||||
builder.ServiceCollection.AddKeyedSingleton(builder.Name, store);
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the host agent builder to use a custom session store implementation for agent sessions.
|
||||
@@ -43,36 +44,16 @@ public static class HostedAgentBuilderExtensions
|
||||
/// name.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the session store registration. Defaults to <see cref="ServiceLifetime.Singleton"/>
|
||||
/// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime.</param>
|
||||
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
|
||||
/// <returns>The same host agent builder instance, enabling further configuration.</returns>
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton, bool withIsolation = true)
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, key) =>
|
||||
{
|
||||
Throw.IfNull(key);
|
||||
var keyString = key as string;
|
||||
Throw.IfNullOrEmpty(keyString);
|
||||
|
||||
AgentSessionStore store = createAgentSessionStore(sp, keyString) ??
|
||||
return createAgentSessionStore(sp, keyString) ??
|
||||
throw new InvalidOperationException($"The agent session store factory did not return a valid {nameof(AgentSessionStore)} instance for key '{keyString}'.");
|
||||
|
||||
if (withIsolation && store.GetService<IsolationKeyScopedAgentSessionStore>() is null)
|
||||
{
|
||||
var isolationKeyProvider = sp.GetService<SessionIsolationKeyProvider>();
|
||||
|
||||
// Best efforts options getting
|
||||
IsolationKeyScopedAgentSessionStoreOptions? options = sp.GetService<IsolationKeyScopedAgentSessionStoreOptions>();
|
||||
if (options is null)
|
||||
{
|
||||
var optionsProvider = sp.GetService<IOptions<IsolationKeyScopedAgentSessionStoreOptions>>();
|
||||
options = optionsProvider?.Value;
|
||||
}
|
||||
|
||||
store = new IsolationKeyScopedAgentSessionStore(store, isolationKeyProvider, options ?? new());
|
||||
}
|
||||
|
||||
return store;
|
||||
}, lifetime);
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating <see cref="AgentSessionStore"/> that scopes session keys by an isolation key
|
||||
/// provided by a <see cref="SessionIsolationKeyProvider"/>, ensuring that sessions are isolated
|
||||
/// per logical partition (e.g., user, tenant, or composite key).
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
|
||||
{
|
||||
private readonly SessionIsolationKeyProvider? _keyProvider;
|
||||
private readonly bool _strict;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentSessionStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerStore">The underlying <see cref="AgentSessionStore"/> to delegate to.</param>
|
||||
/// <param name="keyProvider">
|
||||
/// The <see cref="SessionIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
|
||||
/// </param>
|
||||
/// <param name="options">The options for configuring the session store. If null, defaults are used.</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// <paramref name="innerStore"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
public IsolationKeyScopedAgentSessionStore(
|
||||
AgentSessionStore innerStore,
|
||||
SessionIsolationKeyProvider? keyProvider,
|
||||
IsolationKeyScopedAgentSessionStoreOptions? options = null)
|
||||
: base(innerStore)
|
||||
{
|
||||
this._keyProvider = keyProvider;
|
||||
options ??= new IsolationKeyScopedAgentSessionStoreOptions();
|
||||
this._strict = options.Strict;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves the isolation key from the provider and validates it if in strict mode.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The isolation key string, or <see langword="null"/> if no key is available and non-strict mode is enabled.
|
||||
/// </returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The provider returned <see langword="null"/> and strict mode is enabled.
|
||||
/// </exception>
|
||||
private async ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string? key = this._keyProvider != null
|
||||
? await this._keyProvider.GetSessionIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
if (this._strict && key == null)
|
||||
{
|
||||
throw new InvalidOperationException("Session isolation key is required but was not provided by the configured SessionIsolationKeyProvider.");
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes special characters in the isolation key to ensure unambiguous scoped conversation IDs.
|
||||
/// </summary>
|
||||
/// <param name="key">The raw isolation key.</param>
|
||||
/// <returns>The escaped isolation key.</returns>
|
||||
/// <remarks>
|
||||
/// Backslashes are escaped first (\ becomes \\), then colons (: becomes \:).
|
||||
/// This ensures the scoped conversation ID format {key}::{conversationId} can be parsed correctly.
|
||||
/// </remarks>
|
||||
private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:");
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a scoped conversation ID by prefixing the bare conversation ID with the escaped isolation key.
|
||||
/// </summary>
|
||||
/// <param name="bareConversationId">The original conversation ID.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The scoped conversation ID in the format {escapedKey}::{conversationId}, or the bare conversation ID
|
||||
/// if no isolation key is available and non-strict mode is enabled.
|
||||
/// </returns>
|
||||
private async ValueTask<string> GetScopedConversationIdAsync(string bareConversationId, CancellationToken cancellationToken)
|
||||
{
|
||||
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (key == null)
|
||||
{
|
||||
return bareConversationId;
|
||||
}
|
||||
|
||||
return $"{EscapeIsolationKey(key)}::{bareConversationId}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
return await this.InnerStore.GetSessionAsync(agent, scopedConversationId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
await this.InnerStore.SaveSessionAsync(agent, scopedConversationId, session, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring <see cref="IsolationKeyScopedAgentSessionStore"/>.
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether an exception should be thrown when the isolation key cannot be determined.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// If <see langword="true"/> (default), the store will throw an <see cref="System.InvalidOperationException"/>
|
||||
/// when <see cref="SessionIsolationKeyProvider.GetSessionIsolationKeyAsync"/> returns <see langword="null"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If <see langword="false"/>, the conversation ID is passed through unmodified when the isolation key is absent,
|
||||
/// allowing unscoped access to the underlying session store. This mode is suitable for development scenarios
|
||||
/// or mixed environments where not all requests have isolation keys.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool Strict { get; set; } = true;
|
||||
}
|
||||
@@ -24,20 +24,6 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// For production use with multiple instances or persistence across restarts, use a durable storage implementation
|
||||
/// such as Redis, SQL Server, or Azure Cosmos DB.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Multi-user warning.</strong> This store keys threads by
|
||||
/// <c>(agent.Id, conversationId)</c> only — it has no principal/owner dimension. When
|
||||
/// the conversation identifier originates from the wire (for example, an AG-UI
|
||||
/// <c>RunAgentInput.ThreadId</c> or an A2A <c>contextId</c>), any caller who knows
|
||||
/// or guesses another caller's identifier can resume that other caller's persisted
|
||||
/// thread. Multi-user hosts must wrap this store in
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> (typically by calling
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>) so that the conversation namespace is
|
||||
/// scoped per principal. See the trust-model remarks on
|
||||
/// <see cref="AgentSessionStore"/> for the full background.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class InMemoryAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an abstract base class for resolving session isolation keys used to scope agent sessions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Session isolation keys enable multi-tenant or multi-user scenarios by scoping agent session storage
|
||||
/// to a specific logical partition (e.g., user ID, tenant ID, or composite key). Derived classes
|
||||
/// implement the key resolution logic appropriate to their hosting environment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When a key is unavailable or cannot be determined, implementations should return <see langword="null"/>.
|
||||
/// The consuming session store can then enforce strict behavior (throwing an exception) or fall back
|
||||
/// to unscoped storage based on its configuration.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class SessionIsolationKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves the session isolation key for the current request or execution context.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous operation. The task result contains the isolation key string,
|
||||
/// or <see langword="null"/> if no key is available in the current context.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Implementations should extract the key from ambient context (e.g., HTTP request headers, claims,
|
||||
/// or environment variables). If the key cannot be determined, return <see langword="null"/> to allow
|
||||
/// the caller to decide on strict vs. pass-through behavior.
|
||||
/// </remarks>
|
||||
public abstract ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -3,12 +3,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
@@ -24,6 +27,14 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp;
|
||||
/// </remarks>
|
||||
public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Reserved <c>toolName</c> value that maps an <see cref="IMcpToolHandler.InvokeToolAsync"/> request
|
||||
/// to the MCP protocol <c>tools/list</c> discovery operation.
|
||||
/// </summary>
|
||||
public const string ListToolsToolName = "tools/list";
|
||||
|
||||
private static readonly JsonWriterOptions s_toolListJsonWriterOptions = new() { Indented = true };
|
||||
|
||||
private readonly Func<string, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
|
||||
private readonly Dictionary<string, McpClient> _clients = [];
|
||||
private readonly Dictionary<string, HttpClient> _ownedHttpClients = [];
|
||||
@@ -53,9 +64,18 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore
|
||||
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
|
||||
if (IsListToolsToolName(toolName))
|
||||
{
|
||||
ThrowIfListToolsArgumentsSpecified(arguments);
|
||||
McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
|
||||
IList<McpClientTool> tools = await listToolsClient.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return CreateListToolsResultContent(tools.Select(tool => tool.ProtocolTool));
|
||||
}
|
||||
|
||||
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
|
||||
|
||||
// Convert IDictionary to IReadOnlyDictionary for CallToolAsync
|
||||
IReadOnlyDictionary<string, object?>? readOnlyArguments = arguments is null
|
||||
? null
|
||||
@@ -72,6 +92,23 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
return resultContent;
|
||||
}
|
||||
|
||||
internal static bool IsListToolsToolName(string toolName) =>
|
||||
string.Equals(toolName, ListToolsToolName, StringComparison.Ordinal);
|
||||
|
||||
internal static McpServerToolResultContent CreateListToolsResultContent(IEnumerable<Tool> tools)
|
||||
{
|
||||
Throw.IfNull(tools);
|
||||
|
||||
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString())
|
||||
{
|
||||
Outputs = []
|
||||
};
|
||||
|
||||
resultContent.Outputs.Add(new TextContent(SerializeToolsList(tools)));
|
||||
|
||||
return resultContent;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
@@ -183,6 +220,16 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
return hashCode.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static void ThrowIfListToolsArgumentsSpecified(IDictionary<string, object?>? arguments)
|
||||
{
|
||||
if (arguments is { Count: > 0 })
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The reserved MCP '{ListToolsToolName}' operation does not accept tool arguments.",
|
||||
nameof(arguments));
|
||||
}
|
||||
}
|
||||
|
||||
private static void PopulateResultContent(McpServerToolResultContent resultContent, CallToolResult result)
|
||||
{
|
||||
// Ensure Outputs list is initialized
|
||||
@@ -230,6 +277,17 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
TextContentBlock text => new TextContent(text.Text),
|
||||
ImageContentBlock image => CreateDataContent(image.Data, image.MimeType ?? "image/*"),
|
||||
AudioContentBlock audio => CreateDataContent(audio.Data, audio.MimeType ?? "audio/*"),
|
||||
EmbeddedResourceBlock embedded => ConvertEmbeddedResource(embedded),
|
||||
_ => new TextContent(block.ToString() ?? string.Empty),
|
||||
};
|
||||
}
|
||||
|
||||
private static AIContent ConvertEmbeddedResource(EmbeddedResourceBlock block)
|
||||
{
|
||||
return block.Resource switch
|
||||
{
|
||||
TextResourceContents text => new TextContent(text.Text),
|
||||
BlobResourceContents blob => CreateDataContent(blob.Blob, blob.MimeType ?? "application/octet-stream"),
|
||||
_ => new TextContent(block.ToString() ?? string.Empty),
|
||||
};
|
||||
}
|
||||
@@ -255,4 +313,39 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
|
||||
return new DataContent($"data:{mediaType};base64,{base64}", mediaType);
|
||||
}
|
||||
|
||||
private static string SerializeToolsList(IEnumerable<Tool> tools)
|
||||
{
|
||||
using MemoryStream stream = new();
|
||||
using (Utf8JsonWriter writer = new(stream, s_toolListJsonWriterOptions))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteStartArray("tools");
|
||||
|
||||
foreach (Tool tool in tools)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", tool.Name);
|
||||
writer.WriteString("description", tool.Description);
|
||||
writer.WritePropertyName("inputSchema");
|
||||
tool.InputSchema.WriteTo(writer);
|
||||
writer.WritePropertyName("outputSchema");
|
||||
if (tool.OutputSchema is JsonElement outputSchema)
|
||||
{
|
||||
outputSchema.WriteTo(writer);
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(stream.GetBuffer(), 0, (int)stream.Length);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
private bool _emitAgentResponseUpdateEvents;
|
||||
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
|
||||
private bool _returnToPrevious;
|
||||
private string? _name;
|
||||
private string? _description;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
|
||||
@@ -97,6 +99,20 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="WorkflowBuilder.WithName(string)"/>
|
||||
public TBuilder WithName(string name)
|
||||
{
|
||||
this._name = name;
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="WorkflowBuilder.WithDescription(string)"/>
|
||||
public TBuilder WithDescription(string description)
|
||||
{
|
||||
this._description = description;
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a value indicating whether agent streaming update events should be emitted during execution.
|
||||
/// If <see langword="null"/>, the value will be taken from the <see cref="TurnToken"/>
|
||||
@@ -330,7 +346,16 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
builder.AddEdge(start, executors[this._initialAgent.Id]);
|
||||
}
|
||||
|
||||
// Build the workflow.
|
||||
if (!string.IsNullOrWhiteSpace(this._name))
|
||||
{
|
||||
builder.WithName(this._name);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(this._description))
|
||||
{
|
||||
builder.WithDescription(this._description);
|
||||
}
|
||||
|
||||
return builder.WithOutputFrom(end).Build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
@@ -140,7 +141,15 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent)
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="WorkflowBuilder.Build"/>
|
||||
public Workflow Build() => this.ReduceToWorkflowBuilder().Build();
|
||||
public Workflow Build()
|
||||
{
|
||||
if (this._team.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("At least one participant must be added via AddParticipants() before building the workflow.");
|
||||
}
|
||||
|
||||
return this.ReduceToWorkflowBuilder().Build();
|
||||
}
|
||||
|
||||
private TaskLimits Limits => new(
|
||||
MaxRoundCount: this._maxRounds,
|
||||
|
||||
+24
-9
@@ -101,6 +101,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
return base.ConfigureProtocol(protocolBuilder)
|
||||
.SendsMessage<ChatMessage>()
|
||||
.SendsMessage<ResetChatSignal>()
|
||||
.YieldsOutput<List<ChatMessage>>()
|
||||
.ConfigureRoutes(ConfigureRoutes);
|
||||
|
||||
void ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddPortHandler<MagenticPlanReviewRequest, MagenticPlanReviewResponse>(
|
||||
@@ -109,7 +110,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
out this._planReviewPort);
|
||||
}
|
||||
|
||||
private ValueTask SubmitPlanReviewRequestAsync(MagenticTaskContext taskContext, IWorkflowContext workflowContext)
|
||||
private ValueTask SubmitPlanReviewRequestAsync(MagenticTaskContext taskContext, IWorkflowContext workflowContext, bool replanAfterStall = false)
|
||||
{
|
||||
MagenticProgressLedger? progressLedger = taskContext.ProgressLedger;
|
||||
if (progressLedger?.IsStarted is not true)
|
||||
@@ -117,7 +118,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
progressLedger = null;
|
||||
}
|
||||
|
||||
MagenticPlanReviewRequest request = new(taskContext.TaskLedger!.CurrentPlan, progressLedger, taskContext.IsStalled);
|
||||
MagenticPlanReviewRequest request = new(taskContext.TaskLedger!.CurrentPlan, progressLedger, replanAfterStall);
|
||||
|
||||
return this._planReviewPort!.PostRequestAsync(request);
|
||||
}
|
||||
@@ -146,7 +147,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
|
||||
if (this._taskContext.IsTerminated)
|
||||
{
|
||||
throw new InvalidOperationException("Magentic Orchestration has already been terminated and cannot process new messages. Please start a new session.");
|
||||
throw new InvalidOperationException("This Magentic orchestration has already terminated. To process new messages, create a new workflow instance.");
|
||||
}
|
||||
|
||||
if (response.IsApproved)
|
||||
@@ -161,7 +162,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask UpdatePlanAndDelegateAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
private async ValueTask UpdatePlanAndDelegateAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken, bool replanAfterStall = false)
|
||||
{
|
||||
bool isReplan = taskContext.TaskLedger != null;
|
||||
|
||||
@@ -177,7 +178,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
|
||||
if (requirePlanSignoff)
|
||||
{
|
||||
await this.SubmitPlanReviewRequestAsync(taskContext, context).ConfigureAwait(false);
|
||||
await this.SubmitPlanReviewRequestAsync(taskContext, context, replanAfterStall).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -187,9 +188,22 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
|
||||
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// First Turn: Initialize the task context and send the initial messages to the planner agent
|
||||
this._taskContext ??= new(messages, team, limits, emitEvents, []);
|
||||
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
if (this._taskContext?.IsTerminated == true)
|
||||
{
|
||||
throw new InvalidOperationException("This Magentic orchestration has already terminated. To process new messages, create a new workflow instance.");
|
||||
}
|
||||
|
||||
if (this._taskContext == null)
|
||||
{
|
||||
// First Turn: Initialize the task context and create the initial plan
|
||||
this._taskContext = new(messages, team, limits, emitEvents, []);
|
||||
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Subsequent turns: agent returned control, go directly to coordination (progress ledger only, no replan)
|
||||
await this.RunCoordinationRoundAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private ChatMessage? _fullTaskLedgerMessage;
|
||||
@@ -288,10 +302,11 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
|
||||
private async ValueTask ResetAndReplanAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
bool wasStalled = taskContext.IsStalled;
|
||||
taskContext.Reset();
|
||||
await context.SendMessageAsync(new ResetChatSignal(), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken, replanAfterStall: wasStalled).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask PrepareFinalAnswerAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ internal class MagenticTaskContext(List<ChatMessage> taskDefinition, List<AIAgen
|
||||
|
||||
public bool IsTerminated { get; internal set; }
|
||||
|
||||
public bool IsStalled => this.TaskCounters.StallCount >= this.TaskLimits.MaxStallCount;
|
||||
public bool IsStalled => this.TaskCounters.StallCount > this.TaskLimits.MaxStallCount;
|
||||
|
||||
public (bool HitRoundLimit, bool HitResetLimit) CheckLimits()
|
||||
{
|
||||
|
||||
@@ -36,9 +36,13 @@ public sealed class SwitchBuilder
|
||||
Throw.IfNull(executors);
|
||||
|
||||
HashSet<int> indicies = [];
|
||||
int executorIndex = 0;
|
||||
|
||||
foreach (ExecutorBinding executor in executors)
|
||||
{
|
||||
// Explicit name: null element inside the collection argument.
|
||||
Throw.IfNull(executor, $"{nameof(executors)}[{executorIndex++}]");
|
||||
|
||||
if (!this._executorIndicies.TryGetValue(executor.Id, out int index))
|
||||
{
|
||||
index = this._executors.Count;
|
||||
@@ -64,8 +68,13 @@ public sealed class SwitchBuilder
|
||||
{
|
||||
Throw.IfNull(executors);
|
||||
|
||||
int executorIndex = 0;
|
||||
|
||||
foreach (ExecutorBinding executor in executors)
|
||||
{
|
||||
// Explicit name: null element inside the collection argument.
|
||||
Throw.IfNull(executor, $"{nameof(executors)}[{executorIndex++}]");
|
||||
|
||||
if (!this._executorIndicies.TryGetValue(executor.Id, out int index))
|
||||
{
|
||||
index = this._executors.Count;
|
||||
|
||||
@@ -25,7 +25,11 @@ public static class WorkflowBuilderExtensions
|
||||
/// <param name="target">The target executor to which messages will be forwarded.</param>
|
||||
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
|
||||
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding target)
|
||||
=> builder.ForwardMessage<TMessage>(source, [target], condition: null);
|
||||
{
|
||||
Throw.IfNull(target, nameof(target));
|
||||
|
||||
return builder.ForwardMessage<TMessage>(source, [target], condition: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds edges to the workflow that forward messages of the specified type from the source executor to
|
||||
@@ -52,6 +56,8 @@ public static class WorkflowBuilderExtensions
|
||||
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
|
||||
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, IEnumerable<ExecutorBinding> targets, Func<TMessage, bool>? condition = null)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(source);
|
||||
Throw.IfNull(targets);
|
||||
|
||||
Func<object?, bool> predicate = WorkflowBuilder.CreateConditionFunc<TMessage>(IsAllowedTypeAndMatchingCondition)!;
|
||||
@@ -62,14 +68,16 @@ public static class WorkflowBuilderExtensions
|
||||
if (targets is ICollection<ExecutorBinding> { Count: 1 })
|
||||
#endif
|
||||
{
|
||||
return builder.AddEdge(source, targets.First(), predicate);
|
||||
return builder.AddEdge(source, Throw.IfNull(targets.First(), nameof(targets)), predicate);
|
||||
}
|
||||
|
||||
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets));
|
||||
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets.Select(ValidateTarget)));
|
||||
|
||||
// The reason we can check for "not null" here is that CreateConditionFunc<T> will do the correct unwrapping
|
||||
// logic for PortableValues.
|
||||
bool IsAllowedTypeAndMatchingCondition(TMessage? message) => message != null && (condition == null || condition(message));
|
||||
|
||||
ExecutorBinding ValidateTarget(ExecutorBinding target) => Throw.IfNull(target, nameof(targets));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -81,7 +89,11 @@ public static class WorkflowBuilderExtensions
|
||||
/// <param name="target">The target executor to which messages, except those of type <typeparamref name="TMessage"/>, will be forwarded.</param>
|
||||
/// <returns>The updated <see cref="WorkflowBuilder"/> instance with the added edges.</returns>
|
||||
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding target)
|
||||
=> builder.ForwardExcept<TMessage>(source, [target]);
|
||||
{
|
||||
Throw.IfNull(target, nameof(target));
|
||||
|
||||
return builder.ForwardExcept<TMessage>(source, [target]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds edges from the specified source to the provided executors, excluding messages of a specified type.
|
||||
@@ -93,6 +105,8 @@ public static class WorkflowBuilderExtensions
|
||||
/// <returns>The updated <see cref="WorkflowBuilder"/> instance with the added edges.</returns>
|
||||
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, IEnumerable<ExecutorBinding> targets)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(source);
|
||||
Throw.IfNull(targets);
|
||||
|
||||
Func<object?, bool> predicate = WorkflowBuilder.CreateConditionFunc<TMessage>((Func<object?, bool>)IsAllowedType)!;
|
||||
@@ -103,14 +117,16 @@ public static class WorkflowBuilderExtensions
|
||||
if (targets is ICollection<ExecutorBinding> { Count: 1 })
|
||||
#endif
|
||||
{
|
||||
return builder.AddEdge(source, targets.First(), predicate);
|
||||
return builder.AddEdge(source, Throw.IfNull(targets.First(), nameof(targets)), predicate);
|
||||
}
|
||||
|
||||
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets));
|
||||
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets.Select(ValidateTarget)));
|
||||
|
||||
// The reason we can check for "null" here is that CreateConditionFunc<T> will do the correct unwrapping
|
||||
// logic for PortableValues.
|
||||
static bool IsAllowedType(object? message) => message is null;
|
||||
|
||||
ExecutorBinding ValidateTarget(ExecutorBinding target) => Throw.IfNull(target, nameof(targets));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -129,6 +145,7 @@ public static class WorkflowBuilderExtensions
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(source);
|
||||
Throw.IfNull(executors);
|
||||
|
||||
HashSet<string> seenExecutors = [source.Id];
|
||||
|
||||
|
||||
-251
@@ -1,251 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
|
||||
/// </summary>
|
||||
public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
{
|
||||
private const string TestUserId = "test-user-id";
|
||||
private const string CustomClaimType = "custom-claim-type";
|
||||
private const string CustomClaimValue = "custom-claim-value";
|
||||
|
||||
private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProviderTests"/> class.
|
||||
/// </summary>
|
||||
public ClaimsIdentitySessionIsolationKeyProviderTests()
|
||||
{
|
||||
this._httpContextAccessorMock = new Mock<IHttpContextAccessor>();
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor uses default options when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UsesDefaultOptionsWhenNull()
|
||||
{
|
||||
// Act & Assert - should not throw
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object, options: null);
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor accepts null IHttpContextAccessor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithNullHttpContextAccessor_DoesNotThrow()
|
||||
{
|
||||
// Act & Assert - should not throw
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = null! }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotEmpty()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = string.Empty }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is whitespace.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotWhitespace()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = " " }));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSessionIsolationKeyAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync extracts the claim value from the default claim type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncExtractsDefaultClaimTypeAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TestUserId, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync uses custom claim type when specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncUsesCustomClaimTypeAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(CustomClaimType, CustomClaimValue);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = CustomClaimType });
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(CustomClaimValue, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns null when the specified claim is missing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenClaimMissingAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim("other-claim", "value");
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify behavior when HttpContextAccessor returns null HttpContext.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext?)null);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify behavior when HttpContextAccessor itself is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextAccessorNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns the first matching claim when multiple exist.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsFirstMatchingClaimAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string FirstValue = "first-value";
|
||||
const string SecondValue = "second-value";
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, FirstValue),
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, SecondValue),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
User = principal
|
||||
};
|
||||
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(FirstValue, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync handles empty claim values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncHandlesEmptyClaimValueAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, string.Empty);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupHttpContextWithClaim(string claimType, string claimValue)
|
||||
{
|
||||
var claims = new[] { new Claim(claimType, claimValue) };
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
User = principal
|
||||
};
|
||||
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
-400
@@ -1,400 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="DelegatingAgentSessionStore"/> class.
|
||||
/// </summary>
|
||||
public class DelegatingAgentSessionStoreTests
|
||||
{
|
||||
private readonly Mock<AgentSessionStore> _innerStoreMock;
|
||||
private readonly Mock<AIAgent> _agentMock;
|
||||
private readonly TestDelegatingAgentSessionStore _delegatingStore;
|
||||
private readonly AgentSession _testSession;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DelegatingAgentSessionStoreTests"/> class.
|
||||
/// </summary>
|
||||
public DelegatingAgentSessionStoreTests()
|
||||
{
|
||||
this._innerStoreMock = new Mock<AgentSessionStore>();
|
||||
this._agentMock = new Mock<AIAgent>();
|
||||
this._testSession = new TestAgentSession();
|
||||
|
||||
// Setup inner store mock
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
this._delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentNullException when innerStore is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresInnerStore() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("innerStore", () => new TestDelegatingAgentSessionStore(null!));
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor sets the inner store correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithValidInnerStore_SetsInnerStore()
|
||||
{
|
||||
// Act
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._innerStoreMock.Object, delegatingStore.InnerStore);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Method Delegation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync delegates to inner store with correct parameters.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncDelegatesToInnerStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var expectedCancellationToken = new CancellationToken();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(
|
||||
It.Is<AIAgent>(a => a == this._agentMock.Object),
|
||||
It.Is<string>(c => c == ExpectedConversationId),
|
||||
It.Is<CancellationToken>(ct => ct == expectedCancellationToken)))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
// Act
|
||||
var session = await this._delegatingStore.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedCancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._testSession, session);
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync delegates to inner store with correct parameters.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncDelegatesToInnerStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var expectedCancellationToken = new CancellationToken();
|
||||
var expectedSession = new TestAgentSession();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(
|
||||
It.Is<AIAgent>(a => a == this._agentMock.Object),
|
||||
It.Is<string>(c => c == ExpectedConversationId),
|
||||
It.Is<AgentSession>(s => s == expectedSession),
|
||||
It.Is<CancellationToken>(ct => ct == expectedCancellationToken)))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
// Act
|
||||
await this._delegatingStore.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedSession,
|
||||
expectedCancellationToken);
|
||||
|
||||
// Assert
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedSession,
|
||||
expectedCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync awaits the inner store's result before returning.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncAwaitsInnerStoreResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var taskCompletionSource = new TaskCompletionSource<AgentSession>();
|
||||
|
||||
var innerStoreMock = new Mock<AgentSessionStore>();
|
||||
innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask<AgentSession>(taskCompletionSource.Task));
|
||||
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object);
|
||||
|
||||
// Act
|
||||
var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, ExpectedConversationId);
|
||||
|
||||
// Assert
|
||||
Assert.False(resultTask.IsCompleted);
|
||||
taskCompletionSource.SetResult(this._testSession);
|
||||
Assert.True(resultTask.IsCompleted);
|
||||
Assert.Same(this._testSession, await resultTask);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync awaits the inner store's completion before returning.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var expectedSession = new TestAgentSession();
|
||||
var taskCompletionSource = new TaskCompletionSource();
|
||||
|
||||
var innerStoreMock = new Mock<AgentSessionStore>();
|
||||
innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask(taskCompletionSource.Task));
|
||||
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object);
|
||||
|
||||
// Act
|
||||
var resultTask = delegatingStore.SaveSessionAsync(this._agentMock.Object, ExpectedConversationId, expectedSession);
|
||||
|
||||
// Assert
|
||||
Assert.False(resultTask.IsCompleted);
|
||||
taskCompletionSource.SetResult();
|
||||
Assert.True(resultTask.IsCompleted);
|
||||
await resultTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetService Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting the exact type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForExactType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting a base type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForBaseType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(DelegatingAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting AgentSessionStore.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForAgentSessionStoreType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(AgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains to inner store when type is not satisfied by outer store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceChainsToInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
|
||||
|
||||
// Act
|
||||
var result = delegatingStore.GetService(typeof(ConcreteAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(innerStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains through multiple delegation layers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceChainsThoughMultipleDelegationLayers()
|
||||
{
|
||||
// Arrange - create a three-layer chain: outer -> middle -> inner
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var middleStore = new AnotherDelegatingAgentSessionStore(innerStore);
|
||||
var outerStore = new TestDelegatingAgentSessionStore(middleStore);
|
||||
|
||||
// Act - request the innermost store type
|
||||
var result = outerStore.GetService(typeof(ConcreteAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(innerStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService can find a store in the middle of the delegation chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceFindsMiddleStoreInChain()
|
||||
{
|
||||
// Arrange - create a three-layer chain: outer -> middle -> inner
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var middleStore = new AnotherDelegatingAgentSessionStore(innerStore);
|
||||
var outerStore = new TestDelegatingAgentSessionStore(middleStore);
|
||||
|
||||
// Act - request the middle store type
|
||||
var result = outerStore.GetService(typeof(AnotherDelegatingAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(middleStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when the requested type is not found in the chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsNullWhenTypeNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
|
||||
|
||||
// Act
|
||||
var result = delegatingStore.GetService(typeof(string));
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when a service key is provided but not matched.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsNullWhenServiceKeyProvided()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore), "some-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService throws ArgumentNullException when serviceType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceThrowsWhenServiceTypeIsNull() =>
|
||||
Assert.Throws<ArgumentNullException>("serviceType", () => this._delegatingStore.GetService(null!));
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericReturnsItself()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService<TestDelegatingAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method chains to inner store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericChainsToInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
|
||||
|
||||
// Act
|
||||
var result = delegatingStore.GetService<ConcreteAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(innerStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method returns null when type not found.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericReturnsNullWhenTypeNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService<string>();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Implementation
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of DelegatingAgentSessionStore for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore)
|
||||
{
|
||||
public new AgentSessionStore InnerStore => base.InnerStore;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Another delegating store implementation for testing multi-layer chains.
|
||||
/// </summary>
|
||||
private sealed class AnotherDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore);
|
||||
|
||||
/// <summary>
|
||||
/// Concrete (non-delegating) session store for testing GetService chaining.
|
||||
/// </summary>
|
||||
private sealed class ConcreteAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> new(new TestAgentSession());
|
||||
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
|
||||
#endregion
|
||||
}
|
||||
+24
@@ -103,6 +103,30 @@ public class HostApplicationBuilderWorkflowExtensionsTests
|
||||
Assert.Contains(workflowDescriptors, d => (string)d.ServiceKey! == "workflow3");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a handoff workflow can be named from the DI workflow key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddWorkflow_HandoffWorkflowWithName_ResolvesWorkflow()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "handoffWorkflow";
|
||||
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.Setup(a => a.Name).Returns("handoffAgent");
|
||||
|
||||
#pragma warning disable MAAIW001 // This test covers hosting handoff workflows.
|
||||
builder.AddWorkflow(WorkflowName, (sp, key) =>
|
||||
AgentWorkflowBuilder.CreateHandoffBuilderWith(mockAgent.Object)
|
||||
.WithName(key)
|
||||
.Build());
|
||||
#pragma warning restore MAAIW001
|
||||
|
||||
var workflow = builder.Build().Services.GetRequiredKeyedService<Workflow>(WorkflowName);
|
||||
|
||||
Assert.Equal(WorkflowName, workflow.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddWorkflow handles empty strings for name.
|
||||
/// </summary>
|
||||
|
||||
-430
@@ -1,430 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="IsolationKeyScopedAgentSessionStore"/>.
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStoreTests
|
||||
{
|
||||
private const string TestIsolationKey = "test-key";
|
||||
private const string TestConversationId = "test-conversation-id";
|
||||
|
||||
private readonly Mock<AgentSessionStore> _innerStoreMock;
|
||||
private readonly Mock<AIAgent> _agentMock;
|
||||
private readonly AgentSession _testSession;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentSessionStoreTests"/> class.
|
||||
/// </summary>
|
||||
public IsolationKeyScopedAgentSessionStoreTests()
|
||||
{
|
||||
this._innerStoreMock = new Mock<AgentSessionStore>();
|
||||
this._agentMock = new Mock<AIAgent>();
|
||||
this._testSession = new TestAgentSession();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentNullException when innerStore is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("innerStore", () =>
|
||||
new IsolationKeyScopedAgentSessionStore(null!, provider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor uses default options when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UsesDefaultOptionsWhenNull()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
|
||||
// Act & Assert - should not throw
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider, options: null);
|
||||
Assert.NotNull(store);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSessionAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync scopes the conversation ID with the isolation key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncScopesConversationIdWithKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"{TestIsolationKey}::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync throws InvalidOperationException when key is null in strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = true });
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId));
|
||||
|
||||
Assert.Contains("Session isolation key is required", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync does not throw when key is null in non-strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = false });
|
||||
|
||||
// Act - should not throw
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert - conversation ID should be passed through unmodified
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
TestConversationId,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync returns the session from the inner store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncReturnsSessionFromInnerStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
var result = await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._testSession, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SaveSessionAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync scopes the conversation ID with the isolation key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncScopesConversationIdWithKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
var sessionToSave = new TestAgentSession();
|
||||
|
||||
// Act
|
||||
await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave);
|
||||
|
||||
// Assert
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"{TestIsolationKey}::{TestConversationId}",
|
||||
sessionToSave,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync throws InvalidOperationException when key is null in strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = true });
|
||||
var sessionToSave = new TestAgentSession();
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave));
|
||||
|
||||
Assert.Contains("Session isolation key is required", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync does not throw when key is null in non-strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = false });
|
||||
var sessionToSave = new TestAgentSession();
|
||||
|
||||
// Act - should not throw
|
||||
await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave);
|
||||
|
||||
// Assert - conversation ID should be passed through unmodified
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
TestConversationId,
|
||||
sessionToSave,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Escaping Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that colons in the isolation key are escaped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EscapesColonsInIsolationKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string KeyWithColon = "key:with:colons";
|
||||
var provider = new TestSessionIsolationKeyProvider(KeyWithColon);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert - colons should be escaped as \:
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"key\\:with\\:colons::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that backslashes in the isolation key are escaped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EscapesBackslashesInIsolationKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string KeyWithBackslash = @"domain\key";
|
||||
var provider = new TestSessionIsolationKeyProvider(KeyWithBackslash);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert - backslashes should be escaped as \\
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"domain\\\\key::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that both backslashes and colons in the isolation key are escaped correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EscapesBothBackslashesAndColonsInIsolationKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string KeyWithBoth = @"domain\key:role";
|
||||
var provider = new TestSessionIsolationKeyProvider(KeyWithBoth);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert - backslashes escaped first, then colons
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"domain\\\\key\\:role::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Isolation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that different isolation keys result in different scoped conversation IDs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DifferentKeysResultInDifferentScopedConversationIdsAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string Key1 = "key-1";
|
||||
const string Key2 = "key-2";
|
||||
string? capturedConversationId1 = null;
|
||||
string? capturedConversationId2 = null;
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<AIAgent, string, CancellationToken>((_, conversationId, _) =>
|
||||
{
|
||||
if (capturedConversationId1 == null)
|
||||
{
|
||||
capturedConversationId1 = conversationId;
|
||||
}
|
||||
else
|
||||
{
|
||||
capturedConversationId2 = conversationId;
|
||||
}
|
||||
})
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
// Act - Key 1
|
||||
var provider1 = new TestSessionIsolationKeyProvider(Key1);
|
||||
var store1 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider1);
|
||||
await store1.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Act - Key 2
|
||||
var provider2 = new TestSessionIsolationKeyProvider(Key2);
|
||||
var store2 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider2);
|
||||
await store2.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal($"{Key1}::{TestConversationId}", capturedConversationId1);
|
||||
Assert.Equal($"{Key2}::{TestConversationId}", capturedConversationId2);
|
||||
Assert.NotEqual(capturedConversationId1, capturedConversationId2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetService Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService can retrieve IsolationKeyScopedAgentSessionStore from a delegation chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsIsolationKeyScopedAgentSessionStore()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
var result = store.GetService<IsolationKeyScopedAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(store, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains through to find inner store types.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceChainsToInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var concreteInnerStore = new ConcreteAgentSessionStore();
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(concreteInnerStore, provider);
|
||||
|
||||
// Act
|
||||
var result = store.GetService<ConcreteAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(concreteInnerStore, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Classes
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly string? _key;
|
||||
|
||||
public TestSessionIsolationKeyProvider(string? key)
|
||||
{
|
||||
this._key = key;
|
||||
}
|
||||
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ValueTask<string?>(this._key);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete (non-delegating) session store for testing GetService chaining.
|
||||
/// </summary>
|
||||
private sealed class ConcreteAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> new(new TestAgentSession());
|
||||
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
-1
@@ -6,7 +6,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SessionIsolationKeyProvider"/> and its contract.
|
||||
/// </summary>
|
||||
public class SessionIsolationKeyProviderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that a concrete provider can return a non-null isolation key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNonNullKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedKey = "test-key";
|
||||
var provider = new TestSessionIsolationKeyProvider(ExpectedKey);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ExpectedKey, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that a concrete provider can return null when no key is available.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenNoKeyAvailableAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that cancellation token is passed through to the provider implementation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncPassesCancellationTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestCancellableSessionIsolationKeyProvider();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<TaskCanceledException>(
|
||||
async () => await provider.GetSessionIsolationKeyAsync(cts.Token));
|
||||
}
|
||||
|
||||
#region Test Implementations
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly string? _key;
|
||||
|
||||
public TestSessionIsolationKeyProvider(string? key)
|
||||
{
|
||||
this._key = key;
|
||||
}
|
||||
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ValueTask<string?>(this._key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation that respects cancellation tokens.
|
||||
/// </summary>
|
||||
private sealed class TestCancellableSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
public override async ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
return "key";
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+157
@@ -4,6 +4,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
@@ -320,6 +321,92 @@ public sealed class DefaultMcpToolHandlerTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reserved Tools/List Tests
|
||||
|
||||
[Fact]
|
||||
public void IsListToolsToolName_WithReservedName_ShouldReturnTrue()
|
||||
{
|
||||
// Act
|
||||
bool result = DefaultMcpToolHandler.IsListToolsToolName(DefaultMcpToolHandler.ListToolsToolName);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsListToolsToolName_WithRegularToolName_ShouldReturnFalse()
|
||||
{
|
||||
// Act
|
||||
bool result = DefaultMcpToolHandler.IsListToolsToolName("search");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeToolAsync_WithListToolsArguments_ShouldThrowArgumentExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
DefaultMcpToolHandler handler = new();
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
Func<Task> act = async () => await handler.InvokeToolAsync(
|
||||
serverUrl: "http://localhost:12345/mcp",
|
||||
serverLabel: "test",
|
||||
toolName: DefaultMcpToolHandler.ListToolsToolName,
|
||||
arguments: new Dictionary<string, object?> { ["ignored"] = true },
|
||||
headers: null,
|
||||
connectionName: null);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithMessage("*does not accept tool arguments*");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await handler.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateListToolsResultContent_WithTools_ShouldSerializeToolMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
JsonElement inputSchema = JsonSerializer.Deserialize<JsonElement>(
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [ "query" ]
|
||||
}
|
||||
""");
|
||||
Tool tool = new()
|
||||
{
|
||||
Name = "search",
|
||||
Description = "Searches documentation.",
|
||||
InputSchema = inputSchema
|
||||
};
|
||||
|
||||
// Act
|
||||
McpServerToolResultContent result = DefaultMcpToolHandler.CreateListToolsResultContent([tool]);
|
||||
|
||||
// Assert
|
||||
TextContent text = result.Outputs.Should().ContainSingle().Subject.Should().BeOfType<TextContent>().Subject;
|
||||
using JsonDocument document = JsonDocument.Parse(text.Text);
|
||||
JsonElement listedTool = document.RootElement.GetProperty("tools")[0];
|
||||
listedTool.GetProperty("name").GetString().Should().Be("search");
|
||||
listedTool.GetProperty("description").GetString().Should().Be("Searches documentation.");
|
||||
listedTool.GetProperty("inputSchema").GetProperty("properties").GetProperty("query").GetProperty("type").GetString().Should().Be("string");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Interface Implementation Tests
|
||||
|
||||
[Fact]
|
||||
@@ -488,5 +575,75 @@ public sealed class DefaultMcpToolHandlerTests
|
||||
dataContent.MediaType.Should().Be("audio/*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertContentBlock_EmbeddedResourceBlock_WithTextResource_ShouldReturnTextContent()
|
||||
{
|
||||
// Arrange
|
||||
EmbeddedResourceBlock block = new()
|
||||
{
|
||||
Resource = new TextResourceContents
|
||||
{
|
||||
Text = "embedded text payload",
|
||||
Uri = "resource://example",
|
||||
MimeType = "text/plain",
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
result.Should().BeOfType<TextContent>()
|
||||
.Which.Text.Should().Be("embedded text payload");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_ShouldReturnDataContent()
|
||||
{
|
||||
// Arrange
|
||||
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
|
||||
EmbeddedResourceBlock block = new()
|
||||
{
|
||||
Resource = new BlobResourceContents
|
||||
{
|
||||
Blob = new ReadOnlyMemory<byte>(base64Bytes),
|
||||
Uri = "resource://example.bin",
|
||||
MimeType = "application/zip",
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
|
||||
dataContent.MediaType.Should().Be("application/zip");
|
||||
dataContent.Uri.Should().Be("data:application/zip;base64,UklGRiQA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_NullMimeType_DefaultsToOctetStream()
|
||||
{
|
||||
// Arrange
|
||||
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
|
||||
EmbeddedResourceBlock block = new()
|
||||
{
|
||||
Resource = new BlobResourceContents
|
||||
{
|
||||
Blob = new ReadOnlyMemory<byte>(base64Bytes),
|
||||
Uri = "resource://example.bin",
|
||||
MimeType = null!,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
|
||||
dataContent.MediaType.Should().Be("application/octet-stream");
|
||||
dataContent.Uri.Should().Be("data:application/octet-stream;base64,UklGRiQA");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
+38
@@ -432,6 +432,44 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl
|
||||
VerifyInvocationEvent(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithReservedListToolsNameAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
const string ListToolsToolName = "tools/list";
|
||||
string? capturedToolName = null;
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithReservedListToolsNameAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: ListToolsToolName);
|
||||
Mock<IMcpToolHandler> mockProvider = new();
|
||||
mockProvider.Setup(provider => provider.InvokeToolAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IDictionary<string, object?>?>(),
|
||||
It.IsAny<IDictionary<string, string>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
|
||||
(_, _, toolName, _, _, _, _) => capturedToolName = toolName)
|
||||
.ReturnsAsync(new McpServerToolResultContent("list-tools-call-id")
|
||||
{
|
||||
Outputs = [new TextContent("{\"tools\":[]}")]
|
||||
});
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
VerifyInvocationEvent(events);
|
||||
Assert.Equal(ListToolsToolName, capturedToolName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithMultipleContentTypesAsync()
|
||||
{
|
||||
|
||||
@@ -86,6 +86,23 @@ public class HandoffOrchestrationTests
|
||||
target.Reason.Should().Be("instructions");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHandoffs_WithNameAndDescription_SetsWorkflowMetadata()
|
||||
{
|
||||
const string WorkflowName = "handoff-workflow";
|
||||
const string WorkflowDescription = "A handoff workflow";
|
||||
|
||||
DoubleEchoAgent agent = new("agent");
|
||||
|
||||
var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(agent)
|
||||
.WithName(WorkflowName)
|
||||
.WithDescription(WorkflowDescription)
|
||||
.Build();
|
||||
|
||||
Assert.Equal(WorkflowName, workflow.Name);
|
||||
Assert.Equal(WorkflowDescription, workflow.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_NoTransfers_ResponseServedByOriginalAgentAsync()
|
||||
{
|
||||
|
||||
+22
-1
@@ -34,7 +34,13 @@ public sealed class InputWaiterTests : IDisposable
|
||||
[Fact]
|
||||
public async Task InputWaiter_WaitForInputAsync_BlocksUntilSignaledAsync()
|
||||
{
|
||||
Task waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(5));
|
||||
// Use the no-timeout overload so that the wait can only be released by SignalInput.
|
||||
// A finite timeout would make this test's logic racy: the component correctly
|
||||
// honors the timeout, but if the test thread is starved of CPU time (CI load,
|
||||
// GC pause) long enough for the timeout to fire, waitTask completes before
|
||||
// SignalInput is called and the "should not complete before signaled" assertion
|
||||
// flakes. Timeout behavior is covered separately below.
|
||||
Task waitTask = this._waiter.WaitForInputAsync(CancellationToken.None);
|
||||
|
||||
Task completedBeforeSignal = await Task.WhenAny(waitTask, Task.Delay(100));
|
||||
completedBeforeSignal.Should().NotBeSameAs(
|
||||
@@ -100,6 +106,21 @@ public sealed class InputWaiterTests : IDisposable
|
||||
this._waiter.SignalInput();
|
||||
await this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InputWaiter_WaitForInputAsync_CompletesWhenTimeoutExpiresAsync()
|
||||
{
|
||||
// Verify that a finite timeout releases the block even without a signal.
|
||||
// We only assert that it *does* complete (within a generous outer bound);
|
||||
// we intentionally do not assert that it stays blocked until the timeout,
|
||||
// because that would re-introduce the same wall-clock flakiness
|
||||
// described in BlocksUntilSignaledAsync (see comment on that test).
|
||||
Task waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
Task completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(5)));
|
||||
completed.Should().BeSameAs(waitTask, "the wait task should complete once the timeout expires");
|
||||
await waitTask;
|
||||
}
|
||||
}
|
||||
|
||||
public class OutputFilterTests
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -133,31 +133,31 @@ public sealed class ObservabilityTests : IDisposable
|
||||
activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowCompleted, "activity should have workflow completed event");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_DefaultAsync()
|
||||
{
|
||||
await this.TestWorkflowEndToEndActivitiesAsync("Default");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_OffThreadAsync()
|
||||
{
|
||||
await this.TestWorkflowEndToEndActivitiesAsync("OffThread");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_ConcurrentAsync()
|
||||
{
|
||||
await this.TestWorkflowEndToEndActivitiesAsync("Concurrent");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_LockstepAsync()
|
||||
{
|
||||
await this.TestWorkflowEndToEndActivitiesAsync("Lockstep");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task CreatesWorkflowActivities_WithCorrectNameAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -182,7 +182,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
tags.Should().ContainKey(Tags.WorkflowDefinition);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task TelemetryDisabledByDefault_CreatesNoActivitiesAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -200,7 +200,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
capturedActivities.Should().BeEmpty("No activities should be created when telemetry is disabled (default).");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task WithOpenTelemetry_UsesProvidedActivitySourceAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -235,7 +235,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
"All activities should come from the user-provided ActivitySource.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task DisableWorkflowBuild_PreventsWorkflowBuildActivityAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -255,7 +255,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
"WorkflowBuild activity should be disabled.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task DisableWorkflowRun_PreventsWorkflowRunActivityAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -285,7 +285,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
"Other activities should still be created.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task DisableExecutorProcess_PreventsExecutorProcessActivityAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -312,7 +312,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
"Other activities should still be created.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task DisableEdgeGroupProcess_PreventsEdgeGroupProcessActivityAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -333,7 +333,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
"Other activities should still be created.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task DisableMessageSend_PreventsMessageSendActivityAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -382,7 +382,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
return builder.WithOpenTelemetry(configure: opts => opts.DisableMessageSend = true).Build();
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task EnableSensitiveData_LogsExecutorInputAndOutputAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -413,7 +413,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
tags[Tags.ExecutorOutput].Should().Contain("HELLO", "Output should contain the transformed value.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task EnableSensitiveData_Disabled_DoesNotLogInputOutputAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -442,7 +442,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
tags.Should().NotContainKey(Tags.ExecutorOutput, "Output should NOT be logged when EnableSensitiveData is false.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task EnableSensitiveData_LogsMessageSendContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -474,7 +474,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
tags.Should().ContainKey(Tags.MessageSourceId, "Source ID should be logged.");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task EnableSensitiveData_Disabled_DoesNotLogMessageContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
@@ -157,4 +158,301 @@ public partial class WorkflowBuilderSmokeTests
|
||||
workflow3.Name.Should().Be("Named Only");
|
||||
workflow3.Description.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardMessage_WithSingleTarget_CreatesDirectEdge()
|
||||
{
|
||||
// Arrange
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor target = new("target");
|
||||
|
||||
// Act
|
||||
Workflow workflow = new WorkflowBuilder(source.Id)
|
||||
.ForwardMessage<string>(source, target)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Edge edge = GetSingleEdge(workflow, source.Id);
|
||||
edge.Kind.Should().Be(EdgeKind.Direct);
|
||||
edge.DirectEdgeData.Should().NotBeNull();
|
||||
edge.DirectEdgeData!.SourceId.Should().Be(source.Id);
|
||||
edge.DirectEdgeData!.SinkId.Should().Be(target.Id);
|
||||
edge.DirectEdgeData.Condition.Should().NotBeNull();
|
||||
edge.DirectEdgeData.Condition!("message").Should().BeTrue();
|
||||
edge.DirectEdgeData.Condition!(42).Should().BeFalse();
|
||||
edge.DirectEdgeData.Condition!(null).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardMessage_WithMultipleTargets_CreatesFanOutEdge()
|
||||
{
|
||||
// Arrange
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor target1 = new("target1");
|
||||
NoOpExecutor target2 = new("target2");
|
||||
|
||||
// Act
|
||||
Workflow workflow = new WorkflowBuilder(source.Id)
|
||||
.ForwardMessage<string>(source, [target1, target2], message => message == "match")
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Edge edge = GetSingleEdge(workflow, source.Id);
|
||||
edge.Kind.Should().Be(EdgeKind.FanOut);
|
||||
edge.FanOutEdgeData.Should().NotBeNull();
|
||||
edge.FanOutEdgeData!.SourceId.Should().Be(source.Id);
|
||||
edge.FanOutEdgeData!.SinkIds.Should().Equal([target1.Id, target2.Id]);
|
||||
edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull();
|
||||
edge.FanOutEdgeData.EdgeAssigner!("match", 2).Should().Equal([0, 1]);
|
||||
edge.FanOutEdgeData.EdgeAssigner!("other", 2).Should().BeEmpty();
|
||||
edge.FanOutEdgeData.EdgeAssigner!(42, 2).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardExcept_WithSingleTarget_CreatesDirectEdge()
|
||||
{
|
||||
// Arrange
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor target = new("target");
|
||||
|
||||
// Act
|
||||
Workflow workflow = new WorkflowBuilder(source.Id)
|
||||
.ForwardExcept<string>(source, target)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Edge edge = GetSingleEdge(workflow, source.Id);
|
||||
edge.Kind.Should().Be(EdgeKind.Direct);
|
||||
edge.DirectEdgeData.Should().NotBeNull();
|
||||
edge.DirectEdgeData!.SourceId.Should().Be(source.Id);
|
||||
edge.DirectEdgeData!.SinkId.Should().Be(target.Id);
|
||||
edge.DirectEdgeData.Condition.Should().NotBeNull();
|
||||
edge.DirectEdgeData.Condition!("message").Should().BeFalse();
|
||||
edge.DirectEdgeData.Condition!(42).Should().BeTrue();
|
||||
edge.DirectEdgeData.Condition!(null).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardExcept_WithMultipleTargets_CreatesFanOutEdge()
|
||||
{
|
||||
// Arrange
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor target1 = new("target1");
|
||||
NoOpExecutor target2 = new("target2");
|
||||
|
||||
// Act
|
||||
Workflow workflow = new WorkflowBuilder(source.Id)
|
||||
.ForwardExcept<string>(source, [target1, target2])
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Edge edge = GetSingleEdge(workflow, source.Id);
|
||||
edge.Kind.Should().Be(EdgeKind.FanOut);
|
||||
edge.FanOutEdgeData.Should().NotBeNull();
|
||||
edge.FanOutEdgeData!.SourceId.Should().Be(source.Id);
|
||||
edge.FanOutEdgeData!.SinkIds.Should().Equal([target1.Id, target2.Id]);
|
||||
edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull();
|
||||
edge.FanOutEdgeData.EdgeAssigner!(42, 2).Should().Equal([0, 1]);
|
||||
edge.FanOutEdgeData.EdgeAssigner!("message", 2).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddChain_CreatesSequentialDirectEdges()
|
||||
{
|
||||
// Arrange
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor middle = new("middle");
|
||||
NoOpExecutor end = new("end");
|
||||
|
||||
// Act
|
||||
Workflow workflow = new WorkflowBuilder(source.Id)
|
||||
.AddChain(source, [middle, end])
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Edge firstEdge = GetSingleEdge(workflow, source.Id);
|
||||
firstEdge.Kind.Should().Be(EdgeKind.Direct);
|
||||
firstEdge.DirectEdgeData!.SourceId.Should().Be(source.Id);
|
||||
firstEdge.DirectEdgeData.SinkId.Should().Be(middle.Id);
|
||||
|
||||
Edge secondEdge = GetSingleEdge(workflow, middle.Id);
|
||||
secondEdge.Kind.Should().Be(EdgeKind.Direct);
|
||||
secondEdge.DirectEdgeData!.SourceId.Should().Be(middle.Id);
|
||||
secondEdge.DirectEdgeData.SinkId.Should().Be(end.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddChain_WhenExecutorRepeats_Throws()
|
||||
{
|
||||
// Arrange
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor middle = new("middle");
|
||||
|
||||
// Act
|
||||
Action act = () => new WorkflowBuilder(source.Id)
|
||||
.AddChain(source, [middle, source]);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("executors");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddExternalCall_CreatesRequestPortAndRoundTripEdges()
|
||||
{
|
||||
// Arrange
|
||||
const string PortId = "port1";
|
||||
NoOpExecutor source = new("start");
|
||||
|
||||
// Act
|
||||
Workflow workflow = new WorkflowBuilder(source.Id)
|
||||
.AddExternalCall<string, int>(source, PortId)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
workflow.Ports.Should().ContainKey(PortId);
|
||||
workflow.Ports[PortId].Request.Should().Be(typeof(string));
|
||||
workflow.Ports[PortId].Response.Should().Be(typeof(int));
|
||||
workflow.ExecutorBindings.Should().ContainKey(PortId);
|
||||
|
||||
Edge requestEdge = GetSingleEdge(workflow, source.Id);
|
||||
requestEdge.Kind.Should().Be(EdgeKind.Direct);
|
||||
requestEdge.DirectEdgeData!.SourceId.Should().Be(source.Id);
|
||||
requestEdge.DirectEdgeData.SinkId.Should().Be(PortId);
|
||||
|
||||
Edge responseEdge = GetSingleEdge(workflow, PortId);
|
||||
responseEdge.Kind.Should().Be(EdgeKind.Direct);
|
||||
responseEdge.DirectEdgeData!.SourceId.Should().Be(PortId);
|
||||
responseEdge.DirectEdgeData.SinkId.Should().Be(source.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSwitch_CreatesFanOutEdgeWithCasesAndDefault()
|
||||
{
|
||||
// Arrange
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor stringTarget = new("string-target");
|
||||
NoOpExecutor intTarget = new("int-target");
|
||||
NoOpExecutor defaultTarget = new("default-target");
|
||||
|
||||
// Act
|
||||
Workflow workflow = new WorkflowBuilder(source.Id)
|
||||
.AddSwitch(source, switchBuilder => switchBuilder
|
||||
.AddCase<string>(message => message == "match", [stringTarget])
|
||||
.AddCase<int>(message => message > 0, [intTarget])
|
||||
.WithDefault([defaultTarget]))
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Edge edge = GetSingleEdge(workflow, source.Id);
|
||||
edge.Kind.Should().Be(EdgeKind.FanOut);
|
||||
edge.FanOutEdgeData.Should().NotBeNull();
|
||||
edge.FanOutEdgeData!.SourceId.Should().Be(source.Id);
|
||||
edge.FanOutEdgeData!.SinkIds.Should().Equal([stringTarget.Id, intTarget.Id, defaultTarget.Id]);
|
||||
edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull();
|
||||
edge.FanOutEdgeData.EdgeAssigner!("match", 3).Should().Equal([0]);
|
||||
edge.FanOutEdgeData.EdgeAssigner!(2, 3).Should().Equal([1]);
|
||||
edge.FanOutEdgeData.EdgeAssigner!("other", 3).Should().Equal([2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardMessage_InvalidArguments_Throw()
|
||||
{
|
||||
// Arrange
|
||||
WorkflowBuilder builder = new("start");
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor target = new("target");
|
||||
|
||||
// Act/Assert
|
||||
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).ForwardMessage<string>(source, target));
|
||||
Assert.Throws<ArgumentNullException>("source", () => builder.ForwardMessage<string>(null!, target));
|
||||
Assert.Throws<ArgumentNullException>("target", () => builder.ForwardMessage<string>(source, (ExecutorBinding)null!));
|
||||
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardMessage<string>(source, (IEnumerable<ExecutorBinding>)null!));
|
||||
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardMessage<string>(source, [target, null!]));
|
||||
Assert.Throws<ArgumentException>("targets", () => builder.ForwardMessage<string>(source, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardExcept_InvalidArguments_Throw()
|
||||
{
|
||||
// Arrange
|
||||
WorkflowBuilder builder = new("start");
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor target = new("target");
|
||||
|
||||
// Act/Assert
|
||||
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).ForwardExcept<string>(source, target));
|
||||
Assert.Throws<ArgumentNullException>("source", () => builder.ForwardExcept<string>(null!, target));
|
||||
Assert.Throws<ArgumentNullException>("target", () => builder.ForwardExcept<string>(source, (ExecutorBinding)null!));
|
||||
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardExcept<string>(source, (IEnumerable<ExecutorBinding>)null!));
|
||||
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardExcept<string>(source, [target, null!]));
|
||||
Assert.Throws<ArgumentException>("targets", () => builder.ForwardExcept<string>(source, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddChain_InvalidArguments_Throw()
|
||||
{
|
||||
// Arrange
|
||||
WorkflowBuilder builder = new("start");
|
||||
NoOpExecutor source = new("start");
|
||||
NoOpExecutor target = new("target");
|
||||
NoOpExecutor otherTarget = new("other-target");
|
||||
|
||||
// Act/Assert
|
||||
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).AddChain(source, [target]));
|
||||
Assert.Throws<ArgumentNullException>("source", () => builder.AddChain(null!, [target]));
|
||||
Assert.Throws<ArgumentNullException>("executors", () => builder.AddChain(source, null!));
|
||||
Assert.Throws<ArgumentNullException>("executors", () => builder.AddChain(source, [target, null!]));
|
||||
Assert.Throws<ArgumentException>("executors", () => builder.AddChain(source, [target, source]));
|
||||
Assert.Throws<ArgumentException>("executors", () => builder.AddChain(source, [target, otherTarget, target]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddExternalCall_InvalidArguments_Throw()
|
||||
{
|
||||
// Arrange
|
||||
WorkflowBuilder builder = new("start");
|
||||
NoOpExecutor source = new("start");
|
||||
|
||||
// Act/Assert
|
||||
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).AddExternalCall<string, int>(source, "port"));
|
||||
Assert.Throws<ArgumentNullException>("source", () => builder.AddExternalCall<string, int>(null!, "port"));
|
||||
Assert.Throws<ArgumentNullException>("portId", () => builder.AddExternalCall<string, int>(source, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSwitch_InvalidArguments_Throw()
|
||||
{
|
||||
// Arrange
|
||||
WorkflowBuilder builder = new("start");
|
||||
NoOpExecutor source = new("start");
|
||||
|
||||
// Act/Assert
|
||||
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).AddSwitch(source, _ => { }));
|
||||
Assert.Throws<ArgumentNullException>("source", () => builder.AddSwitch(null!, _ => { }));
|
||||
Assert.Throws<ArgumentNullException>("configureSwitch", () => builder.AddSwitch(source, null!));
|
||||
Assert.Throws<ArgumentException>("targets", () => builder.AddSwitch(source, _ => { }));
|
||||
Assert.Throws<ArgumentException>("targets", () => builder.AddSwitch(source, switchBuilder => switchBuilder.AddCase<string>(_ => true, [])));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SwitchBuilder_InvalidArguments_Throw()
|
||||
{
|
||||
// Arrange
|
||||
SwitchBuilder switchBuilder = new();
|
||||
NoOpExecutor target = new("target");
|
||||
|
||||
// Act/Assert
|
||||
Assert.Throws<ArgumentNullException>("predicate", () => switchBuilder.AddCase<string>(null!, [target]));
|
||||
Assert.Throws<ArgumentNullException>("executors", () => switchBuilder.AddCase<string>(_ => true, null!));
|
||||
Assert.Throws<ArgumentNullException>("executors[1]", () => switchBuilder.AddCase<string>(_ => true, [target, null!]));
|
||||
Assert.Throws<ArgumentNullException>("executors", () => switchBuilder.WithDefault(null!));
|
||||
Assert.Throws<ArgumentNullException>("executors[1]", () => switchBuilder.WithDefault([target, null!]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the only edge emitted by the specified workflow source.
|
||||
/// </summary>
|
||||
private static Edge GetSingleEdge(Workflow workflow, string sourceId)
|
||||
=> workflow.Edges[sourceId].Should().ContainSingle().Subject;
|
||||
}
|
||||
|
||||
+6
-6
@@ -67,7 +67,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
|
||||
/// Bug: The Activity created by LockstepRunEventStream.TakeEventStreamAsync is never
|
||||
/// disposed because yield break in async iterators does not trigger using disposal.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task WorkflowRunActivity_IsStopped_LockstepAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -111,7 +111,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
|
||||
/// Verifies that the workflow_invoke Activity is stopped when using the OffThread (Default)
|
||||
/// execution environment (StreamingRunEventStream).
|
||||
/// </summary>
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task WorkflowRunActivity_IsStopped_OffThreadAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -156,7 +156,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
|
||||
/// (StreamingRun.WatchStreamAsync) with the OffThread execution environment.
|
||||
/// This matches the exact usage pattern described in the issue.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task WorkflowRunActivity_IsStopped_Streaming_OffThreadAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -203,7 +203,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
|
||||
/// streaming invocation, even when using the same workflow in a multi-turn pattern,
|
||||
/// and that each session gets its own session activity.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task WorkflowRunActivity_IsStopped_Streaming_OffThread_MultiTurnAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -264,7 +264,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
|
||||
/// Verifies that all started activities (not just workflow_invoke) are properly stopped.
|
||||
/// This ensures no spans are "leaked" without being exported.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task AllActivities_AreStopped_AfterWorkflowCompletionAsync()
|
||||
{
|
||||
// Arrange
|
||||
@@ -305,7 +305,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
|
||||
/// be parented under the workflow session span. The run activity should
|
||||
/// still nest correctly under the session.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Flaky test - temporarily disabled.")]
|
||||
[Fact]
|
||||
public async Task Lockstep_SessionActivity_DoesNotLeak_IntoCaller_ActivityCurrentAsync()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
+24
-1
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.4.0] - 2026-05-14
|
||||
|
||||
### Added
|
||||
- **agent-framework-core**: Forward MCP tool call metadata ([#5815](https://github.com/microsoft/agent-framework/pull/5815))
|
||||
- **agent-framework-core**: Support `list[str]` arguments for file-based skill scripts ([#5850](https://github.com/microsoft/agent-framework/pull/5850))
|
||||
- **agent-framework-core**: Strip server-issued response item IDs under storage ([#5690](https://github.com/microsoft/agent-framework/pull/5690))
|
||||
- **agent-framework-ag-ui**: Add tool result display channel ([#5762](https://github.com/microsoft/agent-framework/pull/5762))
|
||||
- **agent-framework-ag-ui**: Promote to release candidate stage ([#5844](https://github.com/microsoft/agent-framework/pull/5844))
|
||||
- **agent-framework-devui**: Improvements for DevUI ([#5840](https://github.com/microsoft/agent-framework/pull/5840))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-core**: [BREAKING — experimental skills API] Align file skill folder discovery with agentskills.io spec ([#5807](https://github.com/microsoft/agent-framework/pull/5807))
|
||||
- **agent-framework-core**: [BREAKING — experimental skills API] Extract skill spec metadata into `SkillFrontmatter` ([#5775](https://github.com/microsoft/agent-framework/pull/5775))
|
||||
- **agent-framework-devui**: [BREAKING] Tighten default access controls and CORS posture ([#5740](https://github.com/microsoft/agent-framework/pull/5740))
|
||||
- **agent-framework-a2a**: [BREAKING] Migrate to a2a-sdk v1.0 ([#5752](https://github.com/microsoft/agent-framework/pull/5752))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-a2a**: Fix A2A v1.0 non-streaming response and sample runtime issues ([#5849](https://github.com/microsoft/agent-framework/pull/5849))
|
||||
- **agent-framework-foundry-hosting**: Reject path-traversal context IDs in checkpoint storage ([#5851](https://github.com/microsoft/agent-framework/pull/5851))
|
||||
- **agent-framework-core**: Prevent MCP message_handler deadlock on notification reload ([#4866](https://github.com/microsoft/agent-framework/pull/4866))
|
||||
|
||||
## [1.3.0] - 2026-05-07
|
||||
|
||||
### Added
|
||||
@@ -1050,7 +1071,9 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.2.2...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.4.0...HEAD
|
||||
[1.4.0]: https://github.com/microsoft/agent-framework/compare/python-1.3.0...python-1.4.0
|
||||
[1.3.0]: https://github.com/microsoft/agent-framework/compare/python-1.2.2...python-1.3.0
|
||||
[1.2.2]: https://github.com/microsoft/agent-framework/compare/python-1.2.1...python-1.2.2
|
||||
[1.2.1]: https://github.com/microsoft/agent-framework/compare/python-1.2.0...python-1.2.1
|
||||
[1.2.0]: https://github.com/microsoft/agent-framework/compare/python-1.1.1...python-1.2.0
|
||||
|
||||
@@ -16,7 +16,7 @@ Status is grouped into these buckets:
|
||||
| --- | --- | --- |
|
||||
| `agent-framework` | `python/` | `released` |
|
||||
| `agent-framework-a2a` | `python/packages/a2a` | `beta` |
|
||||
| `agent-framework-ag-ui` | `python/packages/ag-ui` | `beta` |
|
||||
| `agent-framework-ag-ui` | `python/packages/ag-ui` | `rc` |
|
||||
| `agent-framework-anthropic` | `python/packages/anthropic` | `beta` |
|
||||
| `agent-framework-azure-contentunderstanding` | `python/packages/azure-contentunderstanding` | `alpha` |
|
||||
| `agent-framework-azure-ai-search` | `python/packages/azure-ai-search` | `beta` |
|
||||
|
||||
@@ -42,7 +42,7 @@ request_handler = DefaultRequestHandler(
|
||||
app = Starlette(
|
||||
routes=[
|
||||
*create_agent_card_routes(my_agent_card),
|
||||
*create_jsonrpc_routes(request_handler),
|
||||
*create_jsonrpc_routes(request_handler, "/"),
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
@@ -78,7 +78,7 @@ class A2AExecutor(AgentExecutor):
|
||||
app = Starlette(
|
||||
routes=[
|
||||
*create_agent_card_routes(public_agent_card),
|
||||
*create_jsonrpc_routes(request_handler),
|
||||
*create_jsonrpc_routes(request_handler, "/"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -365,6 +365,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
|
||||
all_updates: list[AgentResponseUpdate] = []
|
||||
streamed_artifact_ids_by_task: dict[str, set[str]] = {}
|
||||
# In non-streaming mode, accumulate intermediate status content so it
|
||||
# can be surfaced when the terminal event arrives (mirroring v0.3.x
|
||||
# behavior where the full Task history was available at completion).
|
||||
pending_updates_by_task: dict[str, list[AgentResponseUpdate]] = {}
|
||||
async for item in a2a_stream:
|
||||
payload_type = item.WhichOneof("payload")
|
||||
if payload_type == "message":
|
||||
@@ -391,27 +395,55 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
)
|
||||
if task.status.state in TERMINAL_TASK_STATES:
|
||||
streamed_artifact_ids_by_task.pop(task.id, None)
|
||||
# If the terminal Task has no content, flush accumulated updates
|
||||
if not updates or all(not u.contents for u in updates):
|
||||
pending = pending_updates_by_task.pop(task.id, [])
|
||||
for update in pending:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
else:
|
||||
pending_updates_by_task.pop(task.id, None)
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
elif payload_type == "status_update":
|
||||
status_event = item.status_update
|
||||
updates = self._updates_from_task_update_event(status_event)
|
||||
is_terminal = status_event.status.state in TERMINAL_TASK_STATES
|
||||
if emit_intermediate:
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
elif is_terminal:
|
||||
if updates:
|
||||
# Terminal event with content — discard accumulated intermediates
|
||||
pending_updates_by_task.pop(status_event.task_id, None)
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
else:
|
||||
# Terminal event with NO content — flush accumulated updates
|
||||
pending = pending_updates_by_task.pop(status_event.task_id, [])
|
||||
for update in pending:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
else:
|
||||
# Non-streaming intermediate: accumulate for later
|
||||
if updates:
|
||||
pending_updates_by_task.setdefault(status_event.task_id, []).extend(updates)
|
||||
elif payload_type == "artifact_update":
|
||||
artifact_event = item.artifact_update
|
||||
updates = self._updates_from_task_update_event(artifact_event)
|
||||
# Always yield artifact updates — they carry actual response
|
||||
# content (files, data). Track IDs so that a subsequent
|
||||
# terminal Task doesn't duplicate the same artifacts.
|
||||
if updates:
|
||||
streamed_artifact_ids_by_task.setdefault(artifact_event.task_id, set()).add(
|
||||
artifact_event.artifact.artifact_id
|
||||
)
|
||||
if emit_intermediate:
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
else:
|
||||
raise NotImplementedError(f"Unsupported StreamResponse payload: {payload_type}")
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
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.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"a2a-sdk>=1.0.0,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -1570,4 +1570,102 @@ async def test_none_metadata_leaves_additional_properties_empty(
|
||||
assert not response.additional_properties
|
||||
|
||||
|
||||
async def test_non_streaming_terminal_status_update_surfaces_content(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Non-streaming run() should surface content from terminal status_update events."""
|
||||
completed_msg = A2AMessage(
|
||||
message_id="msg-complete",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Done! Here is your answer.")],
|
||||
)
|
||||
status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=completed_msg)
|
||||
event = TaskStatusUpdateEvent(task_id="task-ts", context_id="ctx-ts", status=status)
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=event))
|
||||
|
||||
response = await a2a_agent.run("Hello")
|
||||
|
||||
assert len(response.messages) == 1
|
||||
assert response.messages[0].text == "Done! Here is your answer."
|
||||
|
||||
|
||||
async def test_non_streaming_accumulates_working_content_for_empty_terminal(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Non-streaming run() accumulates WORKING content and flushes on empty terminal event."""
|
||||
# Intermediate WORKING event with content
|
||||
working_msg = A2AMessage(
|
||||
message_id="msg-working",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Here is your answer from working state.")],
|
||||
)
|
||||
working_status = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=working_msg)
|
||||
working_event = TaskStatusUpdateEvent(task_id="task-acc", context_id="ctx-acc", status=working_status)
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=working_event))
|
||||
|
||||
# Terminal COMPLETED event with NO content
|
||||
completed_status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED)
|
||||
completed_event = TaskStatusUpdateEvent(task_id="task-acc", context_id="ctx-acc", status=completed_status)
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=completed_event))
|
||||
|
||||
response = await a2a_agent.run("Hello")
|
||||
|
||||
# The accumulated WORKING content is flushed when terminal arrives empty
|
||||
assert len(response.messages) == 1
|
||||
assert response.messages[0].text == "Here is your answer from working state."
|
||||
|
||||
|
||||
async def test_non_streaming_intermediate_discarded_when_terminal_has_content(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Non-streaming: if terminal event has content, intermediate content is discarded."""
|
||||
# Intermediate WORKING event
|
||||
working_msg = A2AMessage(
|
||||
message_id="msg-working",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Still thinking...")],
|
||||
)
|
||||
working_status = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=working_msg)
|
||||
working_event = TaskStatusUpdateEvent(task_id="task-wi", context_id="ctx-wi", status=working_status)
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=working_event))
|
||||
|
||||
# Terminal COMPLETED event WITH content
|
||||
completed_msg = A2AMessage(
|
||||
message_id="msg-final",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Final answer")],
|
||||
)
|
||||
completed_status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=completed_msg)
|
||||
completed_event = TaskStatusUpdateEvent(task_id="task-wi", context_id="ctx-wi", status=completed_status)
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=completed_event))
|
||||
|
||||
response = await a2a_agent.run("Hello")
|
||||
|
||||
# Terminal content supersedes accumulated intermediates
|
||||
assert len(response.messages) == 1
|
||||
assert response.messages[0].text == "Final answer"
|
||||
|
||||
|
||||
async def test_non_streaming_artifact_update_surfaces_content(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Non-streaming run() should surface content from artifact_update events."""
|
||||
artifact = Artifact(
|
||||
artifact_id="art-ns",
|
||||
parts=[Part(text="Artifact content")],
|
||||
)
|
||||
event = TaskArtifactUpdateEvent(task_id="task-anu", context_id="ctx-anu", artifact=artifact, append=False)
|
||||
mock_a2a_client.responses.append(StreamResponse(artifact_update=event))
|
||||
|
||||
# Terminal task with the same artifact ID — should be deduped
|
||||
mock_a2a_client.add_task_response("task-anu", [{"id": "art-ns", "content": "Artifact content"}])
|
||||
|
||||
response = await a2a_agent.run("Hello")
|
||||
|
||||
# Artifact update + terminal task with same artifact ID = content emitted once from
|
||||
# the artifact_update, then the duplicate from the task is filtered by streamed_artifact_ids
|
||||
assert len(response.messages) == 1
|
||||
assert response.messages[0].text == "Artifact content"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0rc1"
|
||||
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.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"ag-ui-protocol>=0.1.16,<0.2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<0.42.0"
|
||||
|
||||
@@ -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.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
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.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"anthropic>=0.80.0,<0.80.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
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.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"azure-search-documents>=11.7.0b2,<11.7.0b3",
|
||||
]
|
||||
|
||||
|
||||
@@ -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.0a260507"
|
||||
version = "1.0.0a260514"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"agent-framework-foundry>=1.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"agent-framework-foundry>=1.4.0,<2",
|
||||
"azure-ai-contentunderstanding>=1.0.1,<1.1",
|
||||
"aiohttp>=3.9,<4",
|
||||
"filetype>=1.2,<2",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
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.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"azure-cosmos>=4.3.0,<5",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
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.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"agent-framework-durabletask",
|
||||
"azure-functions>=1.24.0,<2",
|
||||
"azure-functions-durable>=1.3.1,<2",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
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.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"boto3>=1.35.0,<2.0.0",
|
||||
"botocore>=1.35.0,<2.0.0",
|
||||
]
|
||||
|
||||
@@ -21,6 +21,7 @@ from chatkit.types import (
|
||||
HiddenContextItem,
|
||||
ImageAttachment,
|
||||
SDKHiddenContextItem,
|
||||
StructuredInputItem,
|
||||
TaskItem,
|
||||
ThreadItem,
|
||||
UserMessageItem,
|
||||
@@ -527,6 +528,9 @@ class ThreadItemConverter:
|
||||
case GeneratedImageItem():
|
||||
# TODO(evmattso): Implement generated image handling in a future PR
|
||||
return []
|
||||
case StructuredInputItem():
|
||||
# TODO(evmattso): Implement structured input handling in a future PR
|
||||
return []
|
||||
case _:
|
||||
assert_never(item)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
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.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"openai-chatkit>=1.4.1,<2.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
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.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"claude-agent-sdk>=0.1.36,<0.1.49",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260507"
|
||||
version = "1.0.0b260514"
|
||||
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.3.0,<2",
|
||||
"agent-framework-core>=1.4.0,<2",
|
||||
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
|
||||
]
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user