mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b2ff3ed7a | ||
|
|
bad05a2bdc | ||
|
|
7e0767a0a0 | ||
|
|
af772997af | ||
|
|
b343625c1f | ||
|
|
9bc7b27813 | ||
|
|
6a2efeae7c | ||
|
|
6169df04cb | ||
|
|
331201294b |
@@ -99,7 +99,7 @@
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
|
||||
<!-- Agent SDKs -->
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0-beta.2" />
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0" />
|
||||
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
|
||||
<!-- M365 Agents SDK -->
|
||||
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
|
||||
|
||||
+1
@@ -6,6 +6,7 @@
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,21 +2,22 @@
|
||||
|
||||
// This sample shows how to create a GitHub Copilot agent with shell command permissions.
|
||||
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot;
|
||||
using GitHub.Copilot.Rpc;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
// Permission handler that prompts the user for approval
|
||||
static Task<PermissionRequestResult> PromptPermission(PermissionRequest request, PermissionInvocation invocation)
|
||||
static Task<PermissionDecision> PromptPermission(PermissionRequest request, PermissionInvocation invocation)
|
||||
{
|
||||
Console.WriteLine($"\n[Permission Request: {request.Kind}]");
|
||||
Console.Write("Approve? (y/n): ");
|
||||
|
||||
string? input = Console.ReadLine()?.Trim().ToUpperInvariant();
|
||||
PermissionRequestResultKind kind = input is "Y" or "YES"
|
||||
? PermissionRequestResultKind.Approved
|
||||
: PermissionRequestResultKind.Rejected;
|
||||
PermissionDecision decision = input is "Y" or "YES"
|
||||
? PermissionDecision.ApproveOnce()
|
||||
: PermissionDecision.Reject();
|
||||
|
||||
return Task.FromResult(new PermissionRequestResult { Kind = kind });
|
||||
return Task.FromResult(decision);
|
||||
}
|
||||
|
||||
// Create and start a Copilot client
|
||||
|
||||
@@ -36,7 +36,7 @@ dotnet run
|
||||
You can customize the agent by providing additional configuration:
|
||||
|
||||
```csharp
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
// Create and start a Copilot client
|
||||
|
||||
+19
-4
@@ -44,18 +44,33 @@ public static class HostedFoundryMemoryProviderScopes
|
||||
session => new FoundryMemoryProvider.State(new FoundryMemoryProviderScope(GetRequiredHostedContext(session).ChatId));
|
||||
|
||||
/// <summary>
|
||||
/// Returns a <c>stateInitializer</c> that scopes memories per (user, chat) pair, using
|
||||
/// <c>"{UserId}:{ChatId}"</c> as the partition key. Use this when memories should be visible
|
||||
/// only to the same user within the same conversation.
|
||||
/// Returns a <c>stateInitializer</c> that scopes memories per (user, chat) pair, composing
|
||||
/// <see cref="HostedSessionContext.UserId"/> and <see cref="HostedSessionContext.ChatId"/> into a
|
||||
/// single delimiter-safe partition key. Use this when memories should be visible only to the same
|
||||
/// user within the same conversation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both identity values are opaque strings that may contain any characters, including the <c>:</c>
|
||||
/// delimiter. To keep the composite key injective (so two distinct (user, chat) pairs can never
|
||||
/// collide), each part is escaped (<c>\</c> becomes <c>\\</c>, then <c>:</c> becomes <c>\:</c>) before
|
||||
/// being joined with a <c>::</c> separator.
|
||||
/// </remarks>
|
||||
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
|
||||
public static Func<AgentSession?, FoundryMemoryProvider.State> PerUserAndChat() =>
|
||||
session =>
|
||||
{
|
||||
var ctx = GetRequiredHostedContext(session);
|
||||
return new FoundryMemoryProvider.State(new FoundryMemoryProviderScope($"{ctx.UserId}:{ctx.ChatId}"));
|
||||
return new FoundryMemoryProvider.State(
|
||||
new FoundryMemoryProviderScope($"{EscapeScopePart(ctx.UserId)}::{EscapeScopePart(ctx.ChatId)}"));
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Escapes special characters in a scope part so that distinct (user, chat) pairs produce distinct
|
||||
/// composite scope keys. Backslashes are escaped first (<c>\</c> becomes <c>\\</c>), then colons
|
||||
/// (<c>:</c> becomes <c>\:</c>), ensuring the <c>{user}::{chat}</c> format is unambiguous.
|
||||
/// </summary>
|
||||
private static string EscapeScopePart(string part) => part.Replace("\\", "\\\\").Replace(":", "\\:");
|
||||
|
||||
private static HostedSessionContext GetRequiredHostedContext(AgentSession? session) =>
|
||||
session?.GetHostedContext()
|
||||
?? throw new InvalidOperationException(
|
||||
|
||||
@@ -6,7 +6,7 @@ using Microsoft.Agents.AI.GitHub.Copilot;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace GitHub.Copilot.SDK;
|
||||
namespace GitHub.Copilot;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="CopilotClient"/>
|
||||
|
||||
@@ -9,7 +9,7 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -169,7 +169,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
Channel<AgentResponseUpdate> channel = Channel.CreateUnbounded<AgentResponseUpdate>();
|
||||
|
||||
// Subscribe to session events
|
||||
using IDisposable subscription = copilotSession.On(evt =>
|
||||
using IDisposable subscription = copilotSession.On<SessionEvent>(evt =>
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
@@ -210,7 +210,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
string prompt = string.Join("\n", messages.Select(m => m.Text));
|
||||
|
||||
// Handle DataContent as attachments
|
||||
(List<UserMessageAttachmentFile>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
|
||||
(List<AttachmentFile>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
|
||||
messages,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -262,10 +262,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
|
||||
private async Task EnsureClientStartedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (this._copilotClient.State != ConnectionState.Connected)
|
||||
{
|
||||
await this._copilotClient.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
await this._copilotClient.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private ResumeSessionConfig CreateResumeConfig()
|
||||
@@ -275,36 +272,18 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Copies all supported properties from a source <see cref="SessionConfig"/> into a new instance
|
||||
/// with <see cref="SessionConfig.Streaming"/> set to <c>true</c>.
|
||||
/// with <see cref="SessionConfigBase.Streaming"/> set to <c>true</c>.
|
||||
/// </summary>
|
||||
internal static SessionConfig CopySessionConfig(SessionConfig source)
|
||||
{
|
||||
return new SessionConfig
|
||||
{
|
||||
Model = source.Model,
|
||||
ReasoningEffort = source.ReasoningEffort,
|
||||
Tools = source.Tools,
|
||||
SystemMessage = source.SystemMessage,
|
||||
AvailableTools = source.AvailableTools,
|
||||
ExcludedTools = source.ExcludedTools,
|
||||
Provider = source.Provider,
|
||||
OnPermissionRequest = source.OnPermissionRequest,
|
||||
OnUserInputRequest = source.OnUserInputRequest,
|
||||
Hooks = source.Hooks,
|
||||
WorkingDirectory = source.WorkingDirectory,
|
||||
ConfigDir = source.ConfigDir,
|
||||
McpServers = source.McpServers,
|
||||
CustomAgents = source.CustomAgents,
|
||||
SkillDirectories = source.SkillDirectories,
|
||||
DisabledSkills = source.DisabledSkills,
|
||||
InfiniteSessions = source.InfiniteSessions,
|
||||
Streaming = true
|
||||
};
|
||||
SessionConfig copy = source.Clone();
|
||||
copy.Streaming = true;
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies all supported properties from a source <see cref="SessionConfig"/> into a new
|
||||
/// <see cref="ResumeSessionConfig"/> with <see cref="ResumeSessionConfig.Streaming"/> set to <c>true</c>.
|
||||
/// <see cref="ResumeSessionConfig"/> with <see cref="SessionConfigBase.Streaming"/> set to <c>true</c>.
|
||||
/// </summary>
|
||||
internal static ResumeSessionConfig CopyResumeSessionConfig(SessionConfig? source)
|
||||
{
|
||||
@@ -321,7 +300,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
OnUserInputRequest = source?.OnUserInputRequest,
|
||||
Hooks = source?.Hooks,
|
||||
WorkingDirectory = source?.WorkingDirectory,
|
||||
ConfigDir = source?.ConfigDir,
|
||||
ConfigDirectory = source?.ConfigDirectory,
|
||||
McpServers = source?.McpServers,
|
||||
CustomAgents = source?.CustomAgents,
|
||||
SkillDirectories = source?.SkillDirectories,
|
||||
@@ -394,10 +373,10 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
|
||||
AdditionalPropertiesDictionary<long>? additionalCounts = null;
|
||||
|
||||
if (usageEvent.Data.CacheWriteTokens is double cacheWriteTokens)
|
||||
if (usageEvent.Data.CacheWriteTokens is long cacheWriteTokens)
|
||||
{
|
||||
additionalCounts ??= [];
|
||||
additionalCounts[nameof(AssistantUsageData.CacheWriteTokens)] = (long)cacheWriteTokens;
|
||||
additionalCounts[nameof(AssistantUsageData.CacheWriteTokens)] = cacheWriteTokens;
|
||||
}
|
||||
|
||||
if (usageEvent.Data.Cost is double cost)
|
||||
@@ -406,10 +385,10 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
additionalCounts[nameof(AssistantUsageData.Cost)] = (long)cost;
|
||||
}
|
||||
|
||||
if (usageEvent.Data.Duration is double duration)
|
||||
if (usageEvent.Data.Duration is TimeSpan duration)
|
||||
{
|
||||
additionalCounts ??= [];
|
||||
additionalCounts[nameof(AssistantUsageData.Duration)] = (long)duration;
|
||||
additionalCounts[nameof(AssistantUsageData.Duration)] = (long)duration.TotalMilliseconds;
|
||||
}
|
||||
|
||||
return additionalCounts;
|
||||
@@ -432,7 +411,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
|
||||
private static SessionConfig? GetSessionConfig(IList<AITool>? tools, string? instructions)
|
||||
{
|
||||
List<AIFunction>? mappedTools = tools is { Count: > 0 } ? tools.OfType<AIFunction>().ToList() : null;
|
||||
List<AIFunctionDeclaration>? mappedTools = tools is { Count: > 0 } ? tools.OfType<AIFunctionDeclaration>().ToList() : null;
|
||||
SystemMessageConfig? systemMessage = instructions is not null ? new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = instructions } : null;
|
||||
|
||||
if (mappedTools is null && systemMessage is null)
|
||||
@@ -443,11 +422,11 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
return new SessionConfig { Tools = mappedTools, SystemMessage = systemMessage };
|
||||
}
|
||||
|
||||
private static async Task<(List<UserMessageAttachmentFile>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
|
||||
private static async Task<(List<AttachmentFile>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<UserMessageAttachmentFile>? attachments = null;
|
||||
List<AttachmentFile>? attachments = null;
|
||||
string? tempDir = null;
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
@@ -461,7 +440,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
attachments ??= [];
|
||||
attachments.Add(new UserMessageAttachmentFile
|
||||
attachments.Add(new AttachmentFile
|
||||
{
|
||||
Path = tempFilePath,
|
||||
DisplayName = Path.GetFileName(tempFilePath)
|
||||
|
||||
+1
@@ -4,6 +4,7 @@
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -178,8 +178,14 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
|
||||
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options, loggerFactory);
|
||||
|
||||
return chatClient
|
||||
.AsBuilder()
|
||||
ChatClientBuilder chatClientBuilder = chatClient.AsBuilder();
|
||||
|
||||
if (options?.DisableNonApprovalRequiredFunctionBypassing is not true)
|
||||
{
|
||||
chatClientBuilder.UseNonApprovalRequiredFunctionBypassing();
|
||||
}
|
||||
|
||||
return chatClientBuilder
|
||||
.UseFunctionInvocation(loggerFactory, configure: options?.MaximumIterationsPerRequest is int maxIterations
|
||||
? ficc => ficc.MaximumIterationsPerRequest = maxIterations
|
||||
: null)
|
||||
|
||||
@@ -110,6 +110,20 @@ public sealed class HarnessAgentOptions
|
||||
/// </remarks>
|
||||
public ToolApprovalAgentOptions? ToolApprovalAgentOptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether bypassing of approval requests for tools that do not
|
||||
/// require approval is disabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="false"/> (the default), the underlying chat client pipeline includes the decorator
|
||||
/// added by <see cref="ChatClientBuilderExtensions.UseNonApprovalRequiredFunctionBypassing"/> above the
|
||||
/// function invocation middleware.
|
||||
/// This stores automatically approved function calls for tools that do not require approval in the session
|
||||
/// state when they are returned alongside tools that do, so that only tools that truly require human
|
||||
/// approval are surfaced to the caller.
|
||||
/// </remarks>
|
||||
public bool DisableNonApprovalRequiredFunctionBypassing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the <see cref="FileMemoryProvider"/> is disabled.
|
||||
/// </summary>
|
||||
|
||||
+25
-7
@@ -21,6 +21,18 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// from the ambient <see cref="HttpContext"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security warning:</strong> The configured <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>
|
||||
/// must uniquely identify the principal within the served population. Display names, usernames, email
|
||||
/// aliases, and other mutable or non-unique claims are <strong>unsafe</strong> isolation keys unless the
|
||||
/// host can prove their uniqueness across all callers: two distinct principals that share the same value
|
||||
/// would receive the same isolation key and could read or overwrite one another's persisted sessions.
|
||||
/// The default claim type is <see cref="ClaimTypes.NameIdentifier"/>, a stable unique subject identifier
|
||||
/// that is typically populated from the OpenID Connect <c>sub</c> claim via the default JWT inbound claim
|
||||
/// mapping (note that this differs from Entra's object identifier <c>oid</c> claim; override
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> if you need <c>oid</c> or your
|
||||
/// provider maps a different claim).
|
||||
/// </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.
|
||||
@@ -60,18 +72,24 @@ public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProv
|
||||
/// <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.
|
||||
/// configured claim type from the current user's identity, or <see langword="null"/> if the HTTP
|
||||
/// context is unavailable, the user is not authenticated, or the claim is not present.
|
||||
/// </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.
|
||||
/// This method only reads claims from an authenticated principal: if the current request has no
|
||||
/// authenticated user, it returns <see langword="null"/> rather than trusting claims on an
|
||||
/// unauthenticated identity. The claim value is retrieved 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);
|
||||
ClaimsPrincipal? user = this._httpContextAccessor?.HttpContext?.User;
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return new ValueTask<string?>((string?)null);
|
||||
}
|
||||
|
||||
Claim? claim = user?.Claims.FirstOrDefault(c => c.Type == this._claimType);
|
||||
|
||||
return new ValueTask<string?>(claim?.Value);
|
||||
}
|
||||
|
||||
+19
-6
@@ -14,17 +14,30 @@ public class ClaimsIdentitySessionIsolationKeyProviderOptions
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Defaults to <see cref="ClaimsIdentity.DefaultNameClaimType"/>, which typically corresponds to
|
||||
/// the user's name or unique identifier claim.
|
||||
/// Defaults to <see cref="ClaimTypes.NameIdentifier"/>, which corresponds to a stable, unique
|
||||
/// subject identifier for the authenticated principal. For OpenID Connect tokens (including those
|
||||
/// issued by Microsoft Entra ID), this is typically populated from the <c>sub</c> claim via the
|
||||
/// default JWT inbound claim mapping. Note that <c>sub</c> is distinct from Entra's object
|
||||
/// identifier (<c>oid</c>) claim; if you require the <c>oid</c> claim, or your provider does not map
|
||||
/// a unique identifier onto <see cref="ClaimTypes.NameIdentifier"/>, override <see cref="ClaimType"/>
|
||||
/// with the appropriate claim type.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security warning:</strong> The configured claim must uniquely identify the principal
|
||||
/// within the served population. Display names (<see cref="ClaimsIdentity.DefaultNameClaimType"/>
|
||||
/// / <see cref="ClaimTypes.Name"/>), usernames, email aliases, and other mutable or non-unique
|
||||
/// claims are <strong>unsafe</strong> isolation keys unless the host can prove their uniqueness
|
||||
/// across all callers. Two distinct principals that share the same value for a non-unique claim
|
||||
/// would receive the same session-isolation key and could read or overwrite one another's
|
||||
/// persisted sessions. Only override this value with a claim that is guaranteed unique and stable.
|
||||
/// </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>
|
||||
/// <item><description>A composite of tenant and subject identifiers — required for multi-tenant hosts where the subject is only unique per tenant</description></item>
|
||||
/// <item><description>Custom claim types specific to your authentication provider, provided they are unique and stable</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType;
|
||||
public string ClaimType { get; set; } = ClaimTypes.NameIdentifier;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -19,8 +20,28 @@ public static class ServiceCollectionExtensions
|
||||
/// <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>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <paramref name="options"/> is not supplied, the isolation key is derived from the
|
||||
/// <see cref="ClaimTypes.NameIdentifier"/> claim, a stable unique subject identifier. For OpenID
|
||||
/// Connect tokens (including Microsoft Entra ID), this is typically mapped from the <c>sub</c> claim
|
||||
/// by the default JWT inbound claim mapping. Authentication schemes that do not project a unique
|
||||
/// identifier onto <see cref="ClaimTypes.NameIdentifier"/> (or hosts that require a different claim
|
||||
/// such as Entra's <c>oid</c>) should override
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>; otherwise the key may be
|
||||
/// absent, which causes strict-mode session stores to fail.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security warning:</strong> If you override
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>, the chosen claim must
|
||||
/// uniquely identify the principal within the served population. Display names, usernames, email
|
||||
/// aliases, and other mutable or non-unique claims are <strong>unsafe</strong> isolation keys unless
|
||||
/// the host can prove their uniqueness across all callers, because distinct principals that share the
|
||||
/// same claim value would receive the same isolation key and could access one another's sessions.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IServiceCollection UseClaimsBasedSessionIsolation(
|
||||
this IServiceCollection services,
|
||||
|
||||
+10
-1
@@ -49,7 +49,7 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor<Foreach>
|
||||
EvaluationResult<DataValue> expressionResult = this.Evaluator.GetValue(this.Model.Items);
|
||||
if (expressionResult.Value is TableDataValue tableValue)
|
||||
{
|
||||
this._values = [.. tableValue.Values.Select(value => value.ToFormula())];
|
||||
this._values = [.. tableValue.Values.Select(ToLoopValue)];
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -99,6 +99,15 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor<Foreach>
|
||||
}
|
||||
}
|
||||
|
||||
// Power Fx wraps scalar array literals (`=[1, 2, 3]`) as `Table({Value: 1}, ...)`. Unwrap that single-column
|
||||
// `Value`-record shape so `Local.LoopValue` is the scalar; multi-field and other shapes pass through unchanged.
|
||||
private static FormulaValue ToLoopValue(DataValue value) =>
|
||||
value is RecordDataValue record
|
||||
&& record.Properties.Count == 1
|
||||
&& record.Properties.TryGetValue("Value", out DataValue? singleColumn)
|
||||
? singleColumn.ToFormula()
|
||||
: value.ToFormula();
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Persists the iteration cursor (<see cref="_index"/>), the materialized item snapshot
|
||||
|
||||
+4
-4
@@ -182,7 +182,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
|
||||
}
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")]
|
||||
public async Task WorkflowEventsSampleValidationAsync()
|
||||
{
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
|
||||
@@ -278,7 +278,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")]
|
||||
public async Task WorkflowSharedStateSampleValidationAsync()
|
||||
{
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
|
||||
@@ -376,7 +376,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")]
|
||||
public async Task SubWorkflowsSampleValidationAsync()
|
||||
{
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
|
||||
@@ -452,7 +452,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")]
|
||||
public async Task WorkflowHITLSampleValidationAsync()
|
||||
{
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
|
||||
|
||||
+46
-2
@@ -43,7 +43,7 @@ public class HostedFoundryMemoryProviderScopesTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUserAndChat_ComposesUserAndChatWithColon()
|
||||
public void PerUserAndChat_ComposesUserAndChatWithEscapedSeparator()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession(TestUserId, TestChatId);
|
||||
@@ -54,7 +54,51 @@ public class HostedFoundryMemoryProviderScopesTests
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal($"{TestUserId}:{TestChatId}", state.Scope.Scope);
|
||||
Assert.Equal($"{TestUserId}::{TestChatId}", state.Scope.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUserAndChat_EscapesColonsInUserAndChat()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession("alice:finance", "q2:final");
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat();
|
||||
|
||||
// Act
|
||||
var state = initializer(session);
|
||||
|
||||
// Assert - colons inside each part are escaped as \: , parts joined with ::
|
||||
Assert.Equal(@"alice\:finance::q2\:final", state.Scope.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUserAndChat_EscapesBackslashesInUserAndChat()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession(@"alice\corp", @"chat\1");
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat();
|
||||
|
||||
// Act
|
||||
var state = initializer(session);
|
||||
|
||||
// Assert - backslashes escaped first as \\ , parts joined with ::
|
||||
Assert.Equal(@"alice\\corp::chat\\1", state.Scope.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUserAndChat_DistinctContextsDoNotCollide()
|
||||
{
|
||||
// Arrange - two distinct (UserId, ChatId) pairs that collide under raw-colon composition.
|
||||
var sessionA = CreateTaggedSession("alice:finance", "q2");
|
||||
var sessionB = CreateTaggedSession("alice", "finance:q2");
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat();
|
||||
|
||||
// Act
|
||||
var scopeA = initializer(sessionA).Scope.Scope;
|
||||
var scopeB = initializer(sessionB).Scope.Scope;
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(scopeA, scopeB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+4
-3
@@ -4,7 +4,8 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot;
|
||||
using GitHub.Copilot.Rpc;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests;
|
||||
@@ -13,8 +14,8 @@ public class GitHubCopilotAgentTests
|
||||
{
|
||||
private const string SkipReason = "Integration tests require GitHub Copilot CLI installed. For local execution only.";
|
||||
|
||||
private static Task<PermissionRequestResult> OnPermissionRequestAsync(PermissionRequest request, PermissionInvocation invocation)
|
||||
=> Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved });
|
||||
private static Task<PermissionDecision> OnPermissionRequestAsync(PermissionRequest request, PermissionInvocation invocation)
|
||||
=> Task.FromResult(PermissionDecision.ApproveOnce());
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
public async Task RunAsync_WithSimplePrompt_ReturnsResponseAsync()
|
||||
|
||||
+1
@@ -3,6 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+5
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.GitHub.Copilot.UnitTests;
|
||||
@@ -16,7 +16,7 @@ public sealed class CopilotClientExtensionsTests
|
||||
public void AsAIAgent_WithAllParameters_ReturnsGitHubCopilotAgentWithSpecifiedProperties()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
|
||||
const string TestId = "test-agent-id";
|
||||
const string TestName = "Test Agent";
|
||||
@@ -37,7 +37,7 @@ public sealed class CopilotClientExtensionsTests
|
||||
public void AsAIAgent_WithMinimalParameters_ReturnsGitHubCopilotAgent()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
|
||||
// Act
|
||||
var agent = copilotClient.AsAIAgent(ownsClient: false, tools: null);
|
||||
@@ -61,7 +61,7 @@ public sealed class CopilotClientExtensionsTests
|
||||
public void AsAIAgent_WithOwnsClient_ReturnsAgentThatOwnsClient()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
|
||||
// Act
|
||||
var agent = copilotClient.AsAIAgent(ownsClient: true, tools: null);
|
||||
@@ -75,7 +75,7 @@ public sealed class CopilotClientExtensionsTests
|
||||
public void AsAIAgent_WithTools_ReturnsAgentWithTools()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
List<AITool> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
|
||||
// Act
|
||||
|
||||
+20
-21
@@ -3,7 +3,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot;
|
||||
using GitHub.Copilot.Rpc;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.GitHub.Copilot.UnitTests;
|
||||
@@ -17,7 +18,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
public void Constructor_WithCopilotClient_InitializesPropertiesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
const string TestId = "test-id";
|
||||
const string TestName = "test-name";
|
||||
const string TestDescription = "test-description";
|
||||
@@ -42,7 +43,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
public void Constructor_WithDefaultParameters_UsesBaseProperties()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
|
||||
// Act
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
@@ -58,7 +59,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
public async Task CreateSessionAsync_ReturnsGitHubCopilotAgentSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
// Act
|
||||
@@ -73,7 +74,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
public async Task CreateSessionAsync_WithSessionId_ReturnsSessionWithSessionIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
const string TestSessionId = "test-session-id";
|
||||
|
||||
@@ -90,7 +91,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
public void Constructor_WithTools_InitializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
List<AITool> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
|
||||
// Act
|
||||
@@ -105,12 +106,12 @@ public sealed class GitHubCopilotAgentTests
|
||||
public void CopySessionConfig_CopiesAllProperties()
|
||||
{
|
||||
// Arrange
|
||||
List<AIFunction> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
List<AIFunctionDeclaration> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
var hooks = new SessionHooks();
|
||||
var infiniteSessions = new InfiniteSessionConfig();
|
||||
var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" };
|
||||
PermissionRequestHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult());
|
||||
UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
|
||||
Func<PermissionRequest, PermissionInvocation, Task<PermissionDecision>> permissionHandler = (_, _) => Task.FromResult(PermissionDecision.ApproveOnce());
|
||||
Func<UserInputRequest, UserInputInvocation, Task<UserInputResponse>> userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
|
||||
var mcpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig() };
|
||||
|
||||
var source = new SessionConfig
|
||||
@@ -122,7 +123,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
AvailableTools = ["tool1", "tool2"],
|
||||
ExcludedTools = ["tool3"],
|
||||
WorkingDirectory = "/workspace",
|
||||
ConfigDir = "/config",
|
||||
ConfigDirectory = "/config",
|
||||
Hooks = hooks,
|
||||
InfiniteSessions = infiniteSessions,
|
||||
OnPermissionRequest = permissionHandler,
|
||||
@@ -137,17 +138,15 @@ public sealed class GitHubCopilotAgentTests
|
||||
// Assert
|
||||
Assert.Equal("gpt-4o", result.Model);
|
||||
Assert.Equal("high", result.ReasoningEffort);
|
||||
Assert.Same(tools, result.Tools);
|
||||
Assert.Same(systemMessage, result.SystemMessage);
|
||||
Assert.Equal(systemMessage, result.SystemMessage);
|
||||
Assert.Equal(new List<string> { "tool1", "tool2" }, result.AvailableTools);
|
||||
Assert.Equal(new List<string> { "tool3" }, result.ExcludedTools);
|
||||
Assert.Equal("/workspace", result.WorkingDirectory);
|
||||
Assert.Equal("/config", result.ConfigDir);
|
||||
Assert.Equal("/config", result.ConfigDirectory);
|
||||
Assert.Same(hooks, result.Hooks);
|
||||
Assert.Same(infiniteSessions, result.InfiniteSessions);
|
||||
Assert.Same(permissionHandler, result.OnPermissionRequest);
|
||||
Assert.Same(userInputHandler, result.OnUserInputRequest);
|
||||
Assert.Same(mcpServers, result.McpServers);
|
||||
Assert.Equal(new List<string> { "skill1" }, result.DisabledSkills);
|
||||
Assert.True(result.Streaming);
|
||||
}
|
||||
@@ -156,12 +155,12 @@ public sealed class GitHubCopilotAgentTests
|
||||
public void CopyResumeSessionConfig_CopiesAllProperties()
|
||||
{
|
||||
// Arrange
|
||||
List<AIFunction> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
List<AIFunctionDeclaration> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
var hooks = new SessionHooks();
|
||||
var infiniteSessions = new InfiniteSessionConfig();
|
||||
var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" };
|
||||
PermissionRequestHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult());
|
||||
UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
|
||||
Func<PermissionRequest, PermissionInvocation, Task<PermissionDecision>> permissionHandler = (_, _) => Task.FromResult(PermissionDecision.ApproveOnce());
|
||||
Func<UserInputRequest, UserInputInvocation, Task<UserInputResponse>> userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
|
||||
var mcpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig() };
|
||||
|
||||
var source = new SessionConfig
|
||||
@@ -173,7 +172,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
AvailableTools = ["tool1", "tool2"],
|
||||
ExcludedTools = ["tool3"],
|
||||
WorkingDirectory = "/workspace",
|
||||
ConfigDir = "/config",
|
||||
ConfigDirectory = "/config",
|
||||
Hooks = hooks,
|
||||
InfiniteSessions = infiniteSessions,
|
||||
OnPermissionRequest = permissionHandler,
|
||||
@@ -193,7 +192,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
Assert.Equal(new List<string> { "tool1", "tool2" }, result.AvailableTools);
|
||||
Assert.Equal(new List<string> { "tool3" }, result.ExcludedTools);
|
||||
Assert.Equal("/workspace", result.WorkingDirectory);
|
||||
Assert.Equal("/config", result.ConfigDir);
|
||||
Assert.Equal("/config", result.ConfigDirectory);
|
||||
Assert.Same(hooks, result.Hooks);
|
||||
Assert.Same(infiniteSessions, result.InfiniteSessions);
|
||||
Assert.Same(permissionHandler, result.OnPermissionRequest);
|
||||
@@ -218,7 +217,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
Assert.Null(result.OnUserInputRequest);
|
||||
Assert.Null(result.Hooks);
|
||||
Assert.Null(result.WorkingDirectory);
|
||||
Assert.Null(result.ConfigDir);
|
||||
Assert.Null(result.ConfigDirectory);
|
||||
Assert.True(result.Streaming);
|
||||
}
|
||||
|
||||
@@ -233,7 +232,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
Content = "Some streamed content that was already delivered via delta events"
|
||||
}
|
||||
};
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
const string TestId = "agent-id";
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: TestId, tools: null);
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage);
|
||||
|
||||
+1
@@ -3,6 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -27,6 +27,7 @@ public class HarnessAgentOptionsTests
|
||||
Assert.Null(options.ChatHistoryProvider);
|
||||
Assert.Null(options.AIContextProviders);
|
||||
Assert.False(options.DisableToolApproval);
|
||||
Assert.False(options.DisableNonApprovalRequiredFunctionBypassing);
|
||||
Assert.False(options.DisableFileMemory);
|
||||
Assert.False(options.DisableFileAccess);
|
||||
Assert.False(options.DisableWebSearch);
|
||||
@@ -80,6 +81,7 @@ public class HarnessAgentOptionsTests
|
||||
AIContextProviders = contextProviders,
|
||||
MaximumIterationsPerRequest = 42,
|
||||
DisableToolApproval = true,
|
||||
DisableNonApprovalRequiredFunctionBypassing = true,
|
||||
DisableFileMemory = true,
|
||||
FileMemoryStore = fileMemoryStore,
|
||||
DisableFileAccess = true,
|
||||
@@ -112,6 +114,7 @@ public class HarnessAgentOptionsTests
|
||||
Assert.Same(contextProviders, options.AIContextProviders);
|
||||
Assert.Equal(42, options.MaximumIterationsPerRequest);
|
||||
Assert.True(options.DisableToolApproval);
|
||||
Assert.True(options.DisableNonApprovalRequiredFunctionBypassing);
|
||||
Assert.True(options.DisableFileMemory);
|
||||
Assert.Same(fileMemoryStore, options.FileMemoryStore);
|
||||
Assert.True(options.DisableFileAccess);
|
||||
|
||||
@@ -691,6 +691,97 @@ public class HarnessAgentTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Feature: NonApprovalRequiredFunctionBypassing
|
||||
|
||||
/// <summary>
|
||||
/// Verify that by default, when a response contains a mix of tools that require approval and tools that do not,
|
||||
/// only the approval-required tool is surfaced to the caller. The non-approval-required tool is bypassed
|
||||
/// (stored as auto-approved) by the <c>NonApprovalRequiredFunctionBypassingChatClient</c> decorator.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NonApprovalRequiredFunctionBypassing_BypassesNonApprovalToolsByDefaultAsync()
|
||||
{
|
||||
// Arrange — the model requests both a normal tool and an approval-required tool in the same turn.
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "NormalTool");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "ApprovalTool"));
|
||||
|
||||
var mockClient = new Mock<IChatClient>();
|
||||
mockClient
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() => new ChatResponse(new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionCallContent("call1", "NormalTool"),
|
||||
new FunctionCallContent("call2", "ApprovalTool"),
|
||||
])));
|
||||
|
||||
// Disable ToolApproval so the approval requests surface in the response instead of being handled.
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.ChatOptions = new ChatOptions { Tools = [normalTool, approvalTool] };
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
|
||||
|
||||
// Assert — only the approval-required tool surfaces as an approval request; the normal tool is bypassed.
|
||||
var approvalRequests = response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.ToList();
|
||||
var approvalRequest = Assert.Single(approvalRequests);
|
||||
Assert.Equal("ApprovalTool", Assert.IsType<FunctionCallContent>(approvalRequest.ToolCall).Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when bypassing is disabled, all tools (including those that do not require approval) are surfaced
|
||||
/// as approval requests, reflecting the all-or-nothing behavior of <see cref="FunctionInvokingChatClient"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NonApprovalRequiredFunctionBypassing_SurfacesAllApprovalsWhenDisabledAsync()
|
||||
{
|
||||
// Arrange — the model requests both a normal tool and an approval-required tool in the same turn.
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "NormalTool");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "ApprovalTool"));
|
||||
|
||||
var mockClient = new Mock<IChatClient>();
|
||||
mockClient
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() => new ChatResponse(new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionCallContent("call1", "NormalTool"),
|
||||
new FunctionCallContent("call2", "ApprovalTool"),
|
||||
])));
|
||||
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.DisableNonApprovalRequiredFunctionBypassing = true;
|
||||
options.ChatOptions = new ChatOptions { Tools = [normalTool, approvalTool] };
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
|
||||
|
||||
// Assert — both tools surface as approval requests because bypassing is disabled.
|
||||
var approvalRequests = response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.Select(r => ((FunctionCallContent)r.ToolCall).Name)
|
||||
.ToList();
|
||||
Assert.Equal(2, approvalRequests.Count);
|
||||
Assert.Contains("NormalTool", approvalRequests);
|
||||
Assert.Contains("ApprovalTool", approvalRequests);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Feature: OpenTelemetry
|
||||
|
||||
/// <summary>
|
||||
|
||||
+7
-7
@@ -60,7 +60,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task SingleAgentSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "01_SingleAgent");
|
||||
@@ -148,7 +148,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task MultiAgentOrchestrationConcurrentSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "03_AgentOrchestration_Concurrency");
|
||||
@@ -198,7 +198,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task MultiAgentOrchestrationConditionalsSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "04_AgentOrchestration_Conditionals");
|
||||
@@ -216,7 +216,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task SingleAgentOrchestrationHITLSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "05_AgentOrchestration_HITL");
|
||||
@@ -272,7 +272,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task LongRunningToolsSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "06_LongRunningTools");
|
||||
@@ -362,7 +362,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task AgentAsMcpToolAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "07_AgentAsMcpTool");
|
||||
@@ -402,7 +402,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000)]
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task ReliableStreamingSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "08_ReliableStreaming");
|
||||
|
||||
+5
-5
@@ -62,7 +62,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
return default;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task SequentialWorkflowSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "01_SequentialWorkflow");
|
||||
@@ -168,7 +168,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task HITLWorkflowSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "03_WorkflowHITL");
|
||||
@@ -277,7 +277,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task WorkflowMcpToolSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "04_WorkflowMcpTool");
|
||||
@@ -333,7 +333,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task WorkflowAndAgentsSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "05_WorkflowAndAgents");
|
||||
@@ -385,7 +385,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
public async Task ConcurrentWorkflowSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "02_ConcurrentWorkflow");
|
||||
|
||||
+89
-6
@@ -16,6 +16,7 @@ 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 const string TestAuthenticationType = "TestAuth";
|
||||
|
||||
private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock;
|
||||
|
||||
@@ -101,7 +102,7 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
public async Task GetSessionIsolationKeyAsyncExtractsDefaultClaimTypeAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId);
|
||||
this.SetupHttpContextWithClaim(ClaimTypes.NameIdentifier, TestUserId);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
@@ -111,6 +112,25 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
Assert.Equal(TestUserId, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the default claim type is the stable, unique NameIdentifier claim rather than the
|
||||
/// non-unique display name claim. This guards against the session-isolation collision described in
|
||||
/// the security report where two principals sharing the same name claim received the same key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncIgnoresNameClaimByDefaultAsync()
|
||||
{
|
||||
// Arrange - only a display-name claim is present; the default provider must not use it.
|
||||
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync uses custom claim type when specified.
|
||||
/// </summary>
|
||||
@@ -191,10 +211,10 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
const string SecondValue = "second-value";
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, FirstValue),
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, SecondValue),
|
||||
new Claim(ClaimTypes.NameIdentifier, FirstValue),
|
||||
new Claim(ClaimTypes.NameIdentifier, SecondValue),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var identity = new ClaimsIdentity(claims, TestAuthenticationType);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext
|
||||
@@ -219,7 +239,7 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
public async Task GetSessionIsolationKeyAsyncHandlesEmptyClaimValueAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, string.Empty);
|
||||
this.SetupHttpContextWithClaim(ClaimTypes.NameIdentifier, string.Empty);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
@@ -229,6 +249,66 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for the session-isolation collision security report: two distinct authenticated
|
||||
/// principals that share the same display-name claim but have different stable identifiers and tenants
|
||||
/// must produce distinct isolation keys under the default options.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncDistinctForPrincipalsSharingNameClaimAsync()
|
||||
{
|
||||
// Arrange - both principals share the same name claim but differ by NameIdentifier and tenant.
|
||||
const string CommonName = "John Doe";
|
||||
|
||||
var principalA = CreatePrincipal(
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, CommonName),
|
||||
new Claim(ClaimTypes.NameIdentifier, "oid-user-a"),
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/tenantid", "tenant-a"));
|
||||
|
||||
var principalB = CreatePrincipal(
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, CommonName),
|
||||
new Claim(ClaimTypes.NameIdentifier, "oid-user-b"),
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/tenantid", "tenant-b"));
|
||||
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(new DefaultHttpContext { User = principalA });
|
||||
string? principalAKey = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(new DefaultHttpContext { User = principalB });
|
||||
string? principalBKey = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("oid-user-a", principalAKey);
|
||||
Assert.Equal("oid-user-b", principalBKey);
|
||||
Assert.NotEqual(principalAKey, principalBKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns null when the request's user is not authenticated,
|
||||
/// even if a claim of the configured type is present. The provider must not derive an isolation key
|
||||
/// from claims on an unauthenticated identity.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenUserNotAuthenticatedAsync()
|
||||
{
|
||||
// Arrange - identity has the claim but no authentication type, so IsAuthenticated is false.
|
||||
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, TestUserId) };
|
||||
var unauthenticatedIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(unauthenticatedIdentity);
|
||||
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.False(unauthenticatedIdentity.IsAuthenticated);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
@@ -236,7 +316,7 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
private void SetupHttpContextWithClaim(string claimType, string claimValue)
|
||||
{
|
||||
var claims = new[] { new Claim(claimType, claimValue) };
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var identity = new ClaimsIdentity(claims, TestAuthenticationType);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext
|
||||
@@ -247,5 +327,8 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(params Claim[] claims)
|
||||
=> new(new ClaimsIdentity(claims, TestAuthenticationType));
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
+61
@@ -170,6 +170,67 @@ public sealed class ForeachExecutorTest(ITestOutputHelper output) : WorkflowActi
|
||||
Assert.Equal("Engineer", currentValue.GetField("role").ToObject());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Power Fx wraps scalar array literals such as <c>=[1, 2, 3]</c> as <c>Table({Value: 1}, ...)</c>;
|
||||
/// the loop value must expose the bare scalar, not the single-column wrapper record.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ForeachTakeNextWithSingleColumnValueRecordAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string CurrentValueName = "CurrentValue";
|
||||
this.SetVariableState(CurrentValueName);
|
||||
|
||||
TableDataValue tableValue = DataValue.TableFromRecords(
|
||||
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(1))),
|
||||
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(2))),
|
||||
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(3))));
|
||||
|
||||
Foreach model = this.CreateModel(
|
||||
displayName: nameof(ForeachTakeNextWithSingleColumnValueRecordAsync),
|
||||
items: ValueExpression.Literal(tableValue),
|
||||
valueName: CurrentValueName,
|
||||
indexName: null);
|
||||
ForeachExecutor action = new(model, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
|
||||
|
||||
// Assert
|
||||
FormulaValue currentValue = this.State.Get(CurrentValueName);
|
||||
Assert.IsNotType<RecordValue>(currentValue, exactMatch: false);
|
||||
Assert.Equal(1m, currentValue.ToObject());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-field records whose only field is NOT named <c>Value</c> are not Power Fx auto-wraps;
|
||||
/// they are preserved as records so the field name remains accessible inside the loop body.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ForeachTakeNextWithSingleFieldNonValueRecordAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string CurrentValueName = "CurrentValue";
|
||||
this.SetVariableState(CurrentValueName);
|
||||
|
||||
TableDataValue tableValue = DataValue.TableFromRecords(
|
||||
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("name", new StringDataValue("Alice"))));
|
||||
|
||||
Foreach model = this.CreateModel(
|
||||
displayName: nameof(ForeachTakeNextWithSingleFieldNonValueRecordAsync),
|
||||
items: ValueExpression.Literal(tableValue),
|
||||
valueName: CurrentValueName,
|
||||
indexName: null);
|
||||
ForeachExecutor action = new(model, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
|
||||
|
||||
// Assert
|
||||
RecordValue currentValue = Assert.IsType<RecordValue>(this.State.Get(CurrentValueName), exactMatch: false);
|
||||
Assert.Equal("Alice", currentValue.GetField("name").ToObject());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForeachTakeLastAsync()
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any, cast
|
||||
from ag_ui.core import BaseEvent
|
||||
from agent_framework import SupportsAgentRun
|
||||
|
||||
from ._agent_run import run_agent_stream
|
||||
from ._agent_run import PendingApprovalEntry, run_agent_stream
|
||||
|
||||
|
||||
class AgentConfig:
|
||||
@@ -107,7 +107,7 @@ class AgentFrameworkAgent:
|
||||
# Populated when approval requests are emitted; consumed when responses arrive.
|
||||
# Prevents bypass, function name spoofing, and replay attacks.
|
||||
# Bounded to prevent unbounded growth from abandoned approval requests.
|
||||
self._pending_approvals: OrderedDict[str, str] = OrderedDict()
|
||||
self._pending_approvals: OrderedDict[str, PendingApprovalEntry] = OrderedDict()
|
||||
self._pending_approvals_max_size: int = 10_000
|
||||
|
||||
async def run(
|
||||
|
||||
@@ -8,7 +8,7 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import AsyncIterable, Awaitable
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
||||
|
||||
from ag_ui.core import (
|
||||
BaseEvent,
|
||||
@@ -56,6 +56,7 @@ from ._run_common import (
|
||||
_stringify_tool_result, # type: ignore
|
||||
)
|
||||
from ._utils import (
|
||||
canonical_function_arguments,
|
||||
convert_agui_tools_to_agent_framework,
|
||||
generate_event_id,
|
||||
get_conversation_id_from_update,
|
||||
@@ -407,7 +408,33 @@ def _make_approval_tool_result_events(resolved_approval_results: list[Content])
|
||||
return events
|
||||
|
||||
|
||||
def _evict_oldest_approvals(registry: dict[str, str], max_size: int = 10_000) -> None:
|
||||
class _PendingApproval(TypedDict):
|
||||
"""Pending approval details for a requested function call."""
|
||||
|
||||
name: str
|
||||
arguments: str | None
|
||||
|
||||
|
||||
PendingApprovalEntry = _PendingApproval | str
|
||||
|
||||
|
||||
def _make_pending_approval_entry(name: str, arguments: str | None) -> _PendingApproval:
|
||||
return {"name": name, "arguments": arguments}
|
||||
|
||||
|
||||
def _pending_approval_name(entry: PendingApprovalEntry) -> str | None:
|
||||
if isinstance(entry, str):
|
||||
return entry
|
||||
return entry["name"]
|
||||
|
||||
|
||||
def _pending_approval_arguments(entry: PendingApprovalEntry) -> str | None:
|
||||
if isinstance(entry, str):
|
||||
return None
|
||||
return entry["arguments"]
|
||||
|
||||
|
||||
def _evict_oldest_approvals(registry: dict[str, PendingApprovalEntry], max_size: int = 10_000) -> None:
|
||||
"""Evict the oldest entries from the pending-approvals registry (LRU).
|
||||
|
||||
Only effective when *registry* is an ``OrderedDict``; plain dicts are
|
||||
@@ -427,7 +454,7 @@ async def _resolve_approval_responses(
|
||||
tools: list[Any],
|
||||
agent: SupportsAgentRun,
|
||||
run_kwargs: dict[str, Any],
|
||||
pending_approvals: dict[str, str] | None = None,
|
||||
pending_approvals: dict[str, PendingApprovalEntry] | None = None,
|
||||
thread_id: str = "",
|
||||
) -> list[Content]:
|
||||
"""Execute approved function calls and replace approval content with results.
|
||||
@@ -480,7 +507,8 @@ async def _resolve_approval_responses(
|
||||
invalid_ids.add(resp_id)
|
||||
continue
|
||||
|
||||
pending_name = pending_approvals[registry_key]
|
||||
pending_entry = pending_approvals[registry_key]
|
||||
pending_name = _pending_approval_name(pending_entry)
|
||||
if resp_name != pending_name:
|
||||
logger.warning(
|
||||
"Rejected approval response id=%s: function name mismatch (response=%s, pending=%s)",
|
||||
@@ -491,6 +519,16 @@ async def _resolve_approval_responses(
|
||||
invalid_ids.add(resp_id)
|
||||
continue
|
||||
|
||||
pending_arguments = _pending_approval_arguments(pending_entry)
|
||||
response_arguments = canonical_function_arguments(resp.function_call)
|
||||
if pending_arguments is not None and response_arguments != pending_arguments:
|
||||
logger.warning(
|
||||
"Rejected approval response id=%s: function arguments mismatch",
|
||||
resp_id,
|
||||
)
|
||||
invalid_ids.add(resp_id)
|
||||
continue
|
||||
|
||||
# Valid — consume entry to prevent replay
|
||||
del pending_approvals[registry_key]
|
||||
if resp.approved:
|
||||
@@ -714,7 +752,7 @@ async def run_agent_stream(
|
||||
input_data: dict[str, Any],
|
||||
agent: SupportsAgentRun,
|
||||
config: AgentConfig,
|
||||
pending_approvals: dict[str, str] | None = None,
|
||||
pending_approvals: dict[str, PendingApprovalEntry] | None = None,
|
||||
) -> AsyncGenerator[BaseEvent]:
|
||||
"""Run agent and yield AG-UI events.
|
||||
|
||||
@@ -917,7 +955,10 @@ async def run_agent_stream(
|
||||
# Register pending approval requests so we can validate responses later
|
||||
if content_type == "function_approval_request" and pending_approvals is not None:
|
||||
if content.id and content.function_call and content.function_call.name:
|
||||
pending_approvals[f"{thread_id}:{content.id}"] = content.function_call.name
|
||||
pending_approvals[f"{thread_id}:{content.id}"] = _make_pending_approval_entry(
|
||||
content.function_call.name,
|
||||
canonical_function_arguments(content.function_call),
|
||||
)
|
||||
# Evict oldest entries if the registry exceeds a safe bound (LRU)
|
||||
_evict_oldest_approvals(pending_approvals, max_size=10_000)
|
||||
else:
|
||||
|
||||
@@ -56,6 +56,22 @@ def safe_json_parse(value: Any) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def canonical_function_arguments(function_call: Any) -> str | None:
|
||||
"""Return a stable representation of function-call arguments."""
|
||||
if function_call is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed_arguments = function_call.parse_arguments()
|
||||
except Exception:
|
||||
parsed_arguments = getattr(function_call, "arguments", None)
|
||||
|
||||
if parsed_arguments is None:
|
||||
parsed_arguments = {}
|
||||
|
||||
return json.dumps(make_json_safe(parsed_arguments), sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def get_role_value(message: Any) -> str:
|
||||
"""Extract role string from a message object.
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ from ._run_common import (
|
||||
_extract_resume_payload,
|
||||
_normalize_resume_interrupts,
|
||||
)
|
||||
from ._utils import generate_event_id, make_json_safe
|
||||
from ._utils import canonical_function_arguments, generate_event_id, make_json_safe
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -324,6 +324,29 @@ def _coerce_response_for_request(request_event: Any, value: Any) -> Any | None:
|
||||
return candidate
|
||||
|
||||
|
||||
def _approval_response_matches_request(request_id: str, request_event: Any, response: Any) -> bool:
|
||||
"""Check whether an approval response matches the pending approval request."""
|
||||
request_data = getattr(request_event, "data", None)
|
||||
if not isinstance(request_data, Content) or request_data.type != "function_approval_request":
|
||||
return True
|
||||
|
||||
if not isinstance(response, Content) or response.type != "function_approval_response":
|
||||
return False
|
||||
|
||||
if str(getattr(response, "id", "")) != request_id:
|
||||
return False
|
||||
|
||||
request_call = getattr(request_data, "function_call", None)
|
||||
response_call = getattr(response, "function_call", None)
|
||||
if request_call is None or response_call is None:
|
||||
return False
|
||||
|
||||
if getattr(response_call, "name", None) != getattr(request_call, "name", None):
|
||||
return False
|
||||
|
||||
return canonical_function_arguments(response_call) == canonical_function_arguments(request_call)
|
||||
|
||||
|
||||
def _single_pending_response_from_value(pending_events: dict[str, Any], value: Any) -> dict[str, Any]:
|
||||
"""Map a scalar resume payload to the single pending request (if unambiguous)."""
|
||||
if value is None or len(pending_events) != 1:
|
||||
@@ -343,6 +366,13 @@ def _single_pending_response_from_value(pending_events: dict[str, Any], value: A
|
||||
)
|
||||
return {}
|
||||
|
||||
if not _approval_response_matches_request(str(request_id), request_event, coerced_value):
|
||||
logger.info(
|
||||
"Ignoring pending request response for request_id=%s: approval response does not match pending request",
|
||||
request_id,
|
||||
)
|
||||
return {}
|
||||
|
||||
return {str(request_id): coerced_value}
|
||||
|
||||
|
||||
@@ -372,6 +402,12 @@ def _coerce_responses_for_pending_requests(
|
||||
_response_type_name(request_event),
|
||||
)
|
||||
continue
|
||||
if not _approval_response_matches_request(request_key, request_event, coerced_value):
|
||||
logger.info(
|
||||
"Ignoring resume response for request_id=%s: approval response does not match pending request",
|
||||
request_key,
|
||||
)
|
||||
continue
|
||||
normalized[request_key] = coerced_value
|
||||
return normalized
|
||||
|
||||
|
||||
@@ -1407,6 +1407,92 @@ async def test_fabricated_rejection_without_pending_approval_is_blocked(streamin
|
||||
assert False, "Fabricated rejection response leaked as function_result into LLM messages"
|
||||
|
||||
|
||||
async def test_approval_argument_mismatch_is_blocked(streaming_chat_client_stub):
|
||||
"""An approval response must not execute changed arguments for the pending call."""
|
||||
from agent_framework import tool
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
executed_args: list[dict[str, Any]] = []
|
||||
|
||||
@tool(
|
||||
name="update_record",
|
||||
description="Update a record",
|
||||
approval_mode="always_require",
|
||||
)
|
||||
def update_record(record_id: str, value: str) -> str:
|
||||
executed_args.append({"record_id": record_id, "value": value})
|
||||
return f"updated {record_id} to {value}"
|
||||
|
||||
async def stream_fn_approval(
|
||||
messages: MutableSequence[Message], options: ChatOptions, **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="update_record",
|
||||
call_id="call_update_001",
|
||||
arguments={"record_id": "alpha", "value": "approved"},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
wrapper = AgentFrameworkAgent(
|
||||
agent=Agent(
|
||||
client=streaming_chat_client_stub(stream_fn_approval),
|
||||
name="test_agent",
|
||||
instructions="Test",
|
||||
tools=[update_record],
|
||||
)
|
||||
)
|
||||
thread_id = "thread-argument-mismatch-test"
|
||||
|
||||
events1: list[Any] = []
|
||||
async for event in wrapper.run({"thread_id": thread_id, "messages": [{"role": "user", "content": "update"}]}):
|
||||
events1.append(event)
|
||||
|
||||
assert any("call_update_001" in k for k in wrapper._pending_approvals)
|
||||
|
||||
async def stream_fn_post(
|
||||
messages: MutableSequence[Message], options: ChatOptions, **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Done")])
|
||||
|
||||
wrapper.agent = Agent(
|
||||
client=streaming_chat_client_stub(stream_fn_post),
|
||||
name="test_agent",
|
||||
instructions="Test",
|
||||
tools=[update_record],
|
||||
)
|
||||
|
||||
turn2_input: dict[str, Any] = {
|
||||
"thread_id": thread_id,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "approve",
|
||||
"function_approvals": [
|
||||
{
|
||||
"id": "call_update_001",
|
||||
"call_id": "call_update_001",
|
||||
"name": "update_record",
|
||||
"approved": True,
|
||||
"arguments": {"record_id": "beta", "value": "changed"},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
events2: list[Any] = []
|
||||
async for event in wrapper.run(turn2_input):
|
||||
events2.append(event)
|
||||
|
||||
assert executed_args == []
|
||||
assert any("call_update_001" in k for k in wrapper._pending_approvals), (
|
||||
"Pending approval should be preserved after argument mismatch for legitimate retry"
|
||||
)
|
||||
|
||||
|
||||
async def test_state_update_end_to_end_via_real_tool_invocation(streaming_chat_client_stub):
|
||||
"""End-to-end coverage for issue #3167: a real ``@tool`` returning ``state_update`` must
|
||||
emit a deterministic STATE_SNAPSHOT through the full pipeline.
|
||||
|
||||
@@ -1352,6 +1352,70 @@ async def test_workflow_run_approval_via_messages_approved() -> None:
|
||||
assert not resumed_finished.get("interrupt")
|
||||
|
||||
|
||||
async def test_workflow_run_approval_argument_mismatch_keeps_interrupt_pending() -> None:
|
||||
"""Workflow approval responses must not resume with changed function arguments."""
|
||||
|
||||
handled_responses: list[dict[str, Any]] = []
|
||||
|
||||
class ApprovalExecutor(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="approval_executor")
|
||||
|
||||
@handler
|
||||
async def start(self, message: Any, ctx: WorkflowContext) -> None:
|
||||
del message
|
||||
function_call = Content.from_function_call(
|
||||
call_id="refund-call",
|
||||
name="submit_refund",
|
||||
arguments={"order_id": "12345", "amount": "$89.99"},
|
||||
)
|
||||
approval_request = Content.from_function_approval_request(id="approval-1", function_call=function_call)
|
||||
await ctx.request_info(approval_request, Content, request_id="approval-1")
|
||||
|
||||
@response_handler
|
||||
async def handle_approval(self, original_request: Content, response: Content, ctx: WorkflowContext) -> None:
|
||||
del original_request
|
||||
if response.function_call is not None:
|
||||
handled_responses.append(response.function_call.parse_arguments() or {})
|
||||
await ctx.yield_output("handled")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=ApprovalExecutor()).build()
|
||||
first_events = [
|
||||
event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow)
|
||||
]
|
||||
first_finished = [event for event in first_events if event.type == "RUN_FINISHED"][0].model_dump()
|
||||
interrupt_payload = cast(list[dict[str, Any]], first_finished.get("interrupt"))
|
||||
assert isinstance(interrupt_payload, list) and len(interrupt_payload) == 1
|
||||
|
||||
resumed_events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "",
|
||||
"function_approvals": [
|
||||
{
|
||||
"approved": True,
|
||||
"id": "approval-1",
|
||||
"call_id": "refund-call",
|
||||
"name": "submit_refund",
|
||||
"arguments": {"order_id": "99999", "amount": "$1000.00"},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
workflow,
|
||||
)
|
||||
]
|
||||
|
||||
assert handled_responses == []
|
||||
resumed_finished = [event for event in resumed_events if event.type == "RUN_FINISHED"][0].model_dump()
|
||||
assert resumed_finished.get("interrupt")
|
||||
|
||||
|
||||
async def test_workflow_run_approval_via_messages_denied() -> None:
|
||||
"""Denied approval response sent via messages (function_approvals) should satisfy the pending request."""
|
||||
|
||||
|
||||
@@ -92,12 +92,16 @@ OptionsCoT = TypeVar(
|
||||
def _merge_options(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Merge two options dicts, with override values taking precedence.
|
||||
|
||||
``None`` is treated as "unset": ``None`` overrides are skipped so they don't clobber a base
|
||||
value, and the merged result is stripped of any remaining ``None`` values in a final pass so
|
||||
unset options are never forwarded (e.g. an unset ``store`` is left for the service to default).
|
||||
|
||||
Args:
|
||||
base: The base options dict.
|
||||
override: The override options dict (values take precedence).
|
||||
|
||||
Returns:
|
||||
A new merged options dict.
|
||||
A new merged options dict containing no ``None`` values.
|
||||
"""
|
||||
result = dict(base)
|
||||
|
||||
@@ -123,7 +127,7 @@ def _merge_options(base: dict[str, Any], override: dict[str, Any]) -> dict[str,
|
||||
result["instructions"] = f"{result['instructions']}\n{value}"
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
return {key: value for key, value in result.items() if value is not None}
|
||||
|
||||
|
||||
def _sanitize_agent_name(agent_name: str | None) -> str | None:
|
||||
@@ -460,6 +464,9 @@ class BaseAgent(SerializationMixin):
|
||||
if provider_session is None and self.context_providers:
|
||||
provider_session = AgentSession()
|
||||
|
||||
# When per-service-call persistence is enabled, the per-service-call middleware owns
|
||||
# HistoryProvider persistence (in both the local and service-managed cases), so skip
|
||||
# them on the once-per-run path to avoid double persistence.
|
||||
per_service_call_history_required = self.require_per_service_call_history_persistence and any(
|
||||
isinstance(provider, HistoryProvider) for provider in self.context_providers
|
||||
)
|
||||
@@ -686,11 +693,16 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
|
||||
description: A brief description of the agent's purpose.
|
||||
context_providers: Context providers to include during agent invocation.
|
||||
middleware: List of middleware to intercept agent and function invocations.
|
||||
require_per_service_call_history_persistence: When True, history providers are invoked
|
||||
around each model call instead of once per ``run()`` when the service
|
||||
is not already storing history. If service-side storage is active for
|
||||
the run, the agent skips local history providers and relies on the
|
||||
service-managed conversation instead.
|
||||
require_per_service_call_history_persistence: When True (and a HistoryProvider is
|
||||
present), the provider always persists history via per-service-call middleware,
|
||||
regardless of whether the client stores history server-side. If the client does
|
||||
not store history, the middleware also loads providers around each model call and
|
||||
drives the function loop with a local conversation; if it does, loading is skipped
|
||||
(the service-managed conversation is the source of truth) and the middleware only
|
||||
persists. A warning is logged for providers with ``load_messages=True`` when
|
||||
loading is skipped because service-side storage is active. When no HistoryProvider
|
||||
is present, this flag has no effect (no middleware is installed and nothing is
|
||||
persisted).
|
||||
default_options: A TypedDict containing chat options. When using a typed agent like
|
||||
``Agent[OpenAIChatOptions]``, this enables IDE autocomplete for
|
||||
provider-specific options including temperature, max_tokens, model,
|
||||
@@ -791,22 +803,20 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
|
||||
self,
|
||||
*,
|
||||
session: AgentSession | None,
|
||||
options: Mapping[str, Any] | None,
|
||||
conversation_id: str | None,
|
||||
service_stores_history: bool,
|
||||
) -> list[HistoryProvider]:
|
||||
history_providers = self._get_history_providers()
|
||||
if not self.require_per_service_call_history_persistence or not history_providers:
|
||||
return []
|
||||
|
||||
conversation_id = (
|
||||
session.service_session_id
|
||||
if session and session.service_session_id
|
||||
else cast(str | None, (options or {}).get("conversation_id") or self.default_options.get("conversation_id"))
|
||||
)
|
||||
if service_stores_history:
|
||||
return []
|
||||
|
||||
if conversation_id is not None:
|
||||
# A live service-managed session id takes precedence over the resolved conversation id.
|
||||
if session and session.service_session_id:
|
||||
conversation_id = session.service_session_id
|
||||
# Without service-side storage the middleware persists locally and drives the function
|
||||
# loop with a local sentinel, which cannot be reconciled with an existing service-managed
|
||||
# conversation. When the service stores history, an existing conversation id is expected.
|
||||
if conversation_id is not None and not service_stores_history:
|
||||
raise AgentInvalidRequestException(
|
||||
"require_per_service_call_history_persistence cannot be used "
|
||||
"with an existing service-managed conversation."
|
||||
@@ -1167,18 +1177,34 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
|
||||
|
||||
input_messages = normalize_messages(messages)
|
||||
|
||||
# `store` in runtime or agent options takes precedence over client-level storage
|
||||
# indicators. An explicit `store=False` forces local (in-memory) history injection,
|
||||
# even if the client is configured to use service-side storage by default.
|
||||
store_ = opts.get("store", self.default_options.get("store", getattr(self.client, "STORES_BY_DEFAULT", False)))
|
||||
# Combine agent-level defaults with runtime options up front so the decisions below read
|
||||
# `store` from a single place rather than introspecting both dicts. _merge_options applies
|
||||
# the same precedence used for the actual client call (runtime wins; unset/None falls back
|
||||
# to the agent default).
|
||||
effective_options = _merge_options(self.default_options, opts)
|
||||
|
||||
# `store` in runtime or agent options takes precedence over the client's default
|
||||
# storage behavior. An explicit `store=False` forces local (in-memory) history
|
||||
# injection even when the client stores server-side by default; an explicit
|
||||
# `store=True` forces service-side storage. A `store=None`/unset value means the
|
||||
# service falls back to its own default.
|
||||
explicit_store = effective_options.get("store")
|
||||
# Internal behavior hint: will the service own history for this run? Only when the
|
||||
# user left `store` unset do we fall back to the client's STORES_BY_DEFAULT.
|
||||
service_stores_history = (
|
||||
explicit_store if explicit_store is not None else getattr(self.client, "STORES_BY_DEFAULT", False)
|
||||
)
|
||||
# Resolve conversation_id from the same combined view so an agent-level default is honored
|
||||
# when the runtime omits it (a live session id still takes precedence below).
|
||||
effective_conversation_id = effective_options.get("conversation_id")
|
||||
# Auto-inject InMemoryHistoryProvider when session is provided, no context providers
|
||||
# registered, and no service-side storage indicators
|
||||
if (
|
||||
session is not None
|
||||
and not self.context_providers
|
||||
and not session.service_session_id
|
||||
and not opts.get("conversation_id")
|
||||
and not store_
|
||||
and not effective_conversation_id
|
||||
and not service_stores_history
|
||||
):
|
||||
self.context_providers.append(InMemoryHistoryProvider())
|
||||
|
||||
@@ -1188,10 +1214,30 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
|
||||
|
||||
per_service_call_history_providers = self._resolve_per_service_call_history_providers(
|
||||
session=active_session,
|
||||
options=opts,
|
||||
service_stores_history=bool(store_),
|
||||
conversation_id=effective_conversation_id,
|
||||
service_stores_history=service_stores_history,
|
||||
)
|
||||
|
||||
# When require_per_service_call_history_persistence is set together with a
|
||||
# HistoryProvider, the per-service-call middleware (installed below) always persists
|
||||
# the provider. ``service_stores_history`` only selects how the middleware behaves:
|
||||
# - service does not store: the middleware also loads providers and drives the function
|
||||
# loop with a local sentinel conversation id, or
|
||||
# - service stores: the middleware skips loading (the service owns history) and simply
|
||||
# persists each service call while the real conversation id flows through.
|
||||
# In the service-managed case loading is skipped, so warn for providers that expect to load.
|
||||
history_providers = self._get_history_providers()
|
||||
if self.require_per_service_call_history_persistence and history_providers and service_stores_history:
|
||||
for provider in history_providers:
|
||||
if provider.load_messages:
|
||||
logger.warning(
|
||||
"HistoryProvider '%s' has load_messages=True but the chat client stores history "
|
||||
"server-side; skipping local history load and relying on the service-managed "
|
||||
"conversation. Set store=False to load from the provider, or load_messages=False "
|
||||
"to silence this warning.",
|
||||
provider.source_id,
|
||||
)
|
||||
|
||||
session_context, chat_options = await self._prepare_session_and_messages(
|
||||
session=active_session,
|
||||
input_messages=input_messages,
|
||||
@@ -1265,8 +1311,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
|
||||
}
|
||||
if model is not None:
|
||||
run_opts["model"] = model
|
||||
# Remove None values and merge with chat_options
|
||||
run_opts = {k: v for k, v in run_opts.items() if v is not None}
|
||||
# _merge_options strips unset (None) options, so e.g. an unset `store` is not forwarded
|
||||
# and the service decides its own default.
|
||||
co = _merge_options(chat_options, run_opts)
|
||||
|
||||
# Build session_messages from session context: context messages + input messages
|
||||
@@ -1280,6 +1326,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
|
||||
agent=self,
|
||||
session=active_session,
|
||||
providers=per_service_call_history_providers,
|
||||
service_stores_history=service_stores_history,
|
||||
)
|
||||
existing_middleware = effective_client_kwargs.get("middleware")
|
||||
if isinstance(existing_middleware, Sequence) and not isinstance(existing_middleware, (str, bytes)):
|
||||
@@ -1319,7 +1366,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
|
||||
"input_messages": input_messages,
|
||||
"session_messages": session_messages,
|
||||
"agent_name": agent_name,
|
||||
"suppress_response_id": bool(per_service_call_history_providers),
|
||||
"suppress_response_id": bool(per_service_call_history_providers) and not service_stores_history,
|
||||
"chat_options": co,
|
||||
"compaction_strategy": compaction_strategy or self.compaction_strategy,
|
||||
"tokenizer": tokenizer or self.tokenizer,
|
||||
@@ -1413,11 +1460,15 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
|
||||
options=options or {},
|
||||
)
|
||||
|
||||
# When per-service-call persistence is enabled, the per-service-call middleware owns
|
||||
# HistoryProvider loading (it loads locally when the service does not store history, or
|
||||
# relies on the service when it does), so skip them on the once-per-run before_run path.
|
||||
per_service_call_history_required = self.require_per_service_call_history_persistence and bool(
|
||||
self._get_history_providers()
|
||||
)
|
||||
|
||||
# Run before_run providers (forward order, skip HistoryProvider when per-service-call persistence owns history)
|
||||
# Run before_run providers (forward order, skip HistoryProvider when per-service-call
|
||||
# persistence owns loading)
|
||||
for provider in self.context_providers:
|
||||
if per_service_call_history_required and isinstance(provider, HistoryProvider):
|
||||
continue
|
||||
|
||||
@@ -604,10 +604,13 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
|
||||
and dict literals are accepted without specialized option typing.
|
||||
context_providers: Context providers to include during agent invocation.
|
||||
middleware: List of middleware to intercept agent and function invocations.
|
||||
require_per_service_call_history_persistence: Whether to require per-service-call
|
||||
chat history persistence. When enabled, history providers are invoked around
|
||||
each model call instead of once per ``run()`` when the service is not already
|
||||
storing history.
|
||||
require_per_service_call_history_persistence: When enabled (and a HistoryProvider is
|
||||
present), the provider always persists history after each model call. If the
|
||||
client does not store history server-side, history providers are also loaded and
|
||||
injected around each model call; if it does, provider loading is skipped and the
|
||||
service-managed conversation is the source of truth (persistence still happens
|
||||
after each model call). When no HistoryProvider is present, this flag has no
|
||||
effect (no middleware is installed and nothing is persisted).
|
||||
function_invocation_configuration: Optional function invocation configuration override.
|
||||
compaction_strategy: Optional agent-level compaction override. When omitted,
|
||||
client-level compaction defaults remain in effect for each call.
|
||||
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
import weakref
|
||||
@@ -36,6 +37,8 @@ if TYPE_CHECKING:
|
||||
from ._middleware import MiddlewareTypes
|
||||
|
||||
|
||||
logger = logging.getLogger("agent_framework")
|
||||
|
||||
# Registry of known types for state deserialization
|
||||
_STATE_TYPE_REGISTRY: dict[str, type] = {}
|
||||
|
||||
@@ -580,6 +583,7 @@ class PerServiceCallHistoryPersistingMiddleware(ChatMiddleware):
|
||||
agent: SupportsAgentRun,
|
||||
session: AgentSession,
|
||||
providers: Sequence[HistoryProvider],
|
||||
service_stores_history: bool = False,
|
||||
) -> None:
|
||||
"""Initialize the middleware.
|
||||
|
||||
@@ -587,10 +591,16 @@ class PerServiceCallHistoryPersistingMiddleware(ChatMiddleware):
|
||||
agent: The agent that owns the history providers.
|
||||
session: The active session for the current run.
|
||||
providers: The history providers participating in per-service-call persistence.
|
||||
service_stores_history: When True, the chat client stores history server-side. The
|
||||
middleware then skips loading providers and leaves the real conversation id
|
||||
untouched, persisting each service call without driving the function loop with a
|
||||
local sentinel. When False, the middleware loads providers and uses a local
|
||||
sentinel conversation id so the function loop runs without service-side storage.
|
||||
"""
|
||||
self._agent = agent
|
||||
self._session = session
|
||||
self._providers = list(providers)
|
||||
self._service_stores_history = service_stores_history
|
||||
|
||||
async def _prepare_service_call_context(self, messages: Sequence[Message]) -> SessionContext:
|
||||
"""Create a per-call SessionContext and load history providers into it."""
|
||||
@@ -602,6 +612,9 @@ class PerServiceCallHistoryPersistingMiddleware(ChatMiddleware):
|
||||
)
|
||||
for source_id, source_messages in context_messages.items():
|
||||
service_call_context.extend_messages(source_id, source_messages)
|
||||
# When the service stores history, it owns loading; the providers are write-only sinks.
|
||||
if self._service_stores_history:
|
||||
return service_call_context
|
||||
for provider in self._providers:
|
||||
if not provider.load_messages:
|
||||
continue
|
||||
@@ -652,17 +665,35 @@ class PerServiceCallHistoryPersistingMiddleware(ChatMiddleware):
|
||||
response: ChatResponse,
|
||||
) -> ChatResponse:
|
||||
"""Persist a model response and apply the local follow-up sentinel when needed."""
|
||||
if response.conversation_id is not None and not is_local_history_conversation_id(response.conversation_id):
|
||||
if (
|
||||
not self._service_stores_history
|
||||
and response.conversation_id is not None
|
||||
and not is_local_history_conversation_id(response.conversation_id)
|
||||
):
|
||||
raise ChatClientInvalidResponseException(
|
||||
"require_per_service_call_history_persistence cannot be used "
|
||||
"when the chat client returns a real conversation_id."
|
||||
)
|
||||
|
||||
# In storing mode the service is expected to echo a conversation id that the next run
|
||||
# resumes from. If it comes back empty, the provider still captures this turn but there is
|
||||
# no service id to load from next time, so cross-turn history can be lost silently. Warn
|
||||
# every time so this uncommon, easy-to-miss failure mode cannot fail quietly.
|
||||
if self._service_stores_history and response.conversation_id is None:
|
||||
logger.warning(
|
||||
"require_per_service_call_history_persistence is enabled with a chat client that "
|
||||
"stores history server-side, but the client returned no conversation_id; cross-turn "
|
||||
"history may not resume. Set store=False to load and resume from the HistoryProvider "
|
||||
"instead."
|
||||
)
|
||||
|
||||
await self._persist_service_call_response(
|
||||
service_call_context=service_call_context,
|
||||
response=response,
|
||||
)
|
||||
if _response_contains_follow_up_request(response):
|
||||
# The local sentinel only applies when the service does not store history; when it does,
|
||||
# the real conversation id already drives function-loop continuation.
|
||||
if not self._service_stores_history and _response_contains_follow_up_request(response):
|
||||
response.mark_internal_conversation_id()
|
||||
response.conversation_id = LOCAL_HISTORY_CONVERSATION_ID
|
||||
return response
|
||||
@@ -681,8 +712,12 @@ class PerServiceCallHistoryPersistingMiddleware(ChatMiddleware):
|
||||
result type for streaming or non-streaming execution.
|
||||
"""
|
||||
service_call_context = await self._prepare_service_call_context(context.messages)
|
||||
context.messages = service_call_context.get_messages(include_input=True)
|
||||
self._strip_local_conversation_id(context)
|
||||
# When the service stores history, leave the outgoing messages and the real conversation
|
||||
# id untouched (pass-through); the middleware only persists. Otherwise reconstruct the
|
||||
# outgoing messages from the loaded local history and strip the local sentinel.
|
||||
if not self._service_stores_history:
|
||||
context.messages = service_call_context.get_messages(include_input=True)
|
||||
self._strip_local_conversation_id(context)
|
||||
|
||||
await call_next()
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import contextlib
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, MutableSequence, Sequence
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -42,6 +43,8 @@ from agent_framework._mcp import MCPTool, _build_prefixed_mcp_name, _normalize_m
|
||||
from agent_framework._middleware import FunctionInvocationContext
|
||||
from agent_framework.exceptions import AgentInvalidRequestException, ChatClientInvalidResponseException
|
||||
|
||||
from .conftest import MockBaseChatClient
|
||||
|
||||
|
||||
class _FixedTokenizer:
|
||||
def __init__(self, token_count: int) -> None:
|
||||
@@ -609,6 +612,7 @@ async def test_streaming_per_service_call_persistence_hides_response_id_from_aft
|
||||
|
||||
async def test_per_service_call_persistence_uses_real_service_storage_when_client_stores_by_default(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
provider = _RecordingHistoryProvider()
|
||||
|
||||
@@ -649,15 +653,22 @@ async def test_per_service_call_persistence_uses_real_service_storage_when_clien
|
||||
require_per_service_call_history_persistence=True,
|
||||
)
|
||||
|
||||
result = await agent.run("What's the weather in Seattle?", session=session)
|
||||
with caplog.at_level(logging.WARNING, logger="agent_framework"):
|
||||
result = await agent.run("What's the weather in Seattle?", session=session)
|
||||
|
||||
provider_state = session.state[provider.source_id]
|
||||
|
||||
assert result.text == "It is sunny in Seattle."
|
||||
assert result.response_id == "resp_call_2"
|
||||
assert chat_client_base.call_count == 2
|
||||
# The service owns the conversation, so the provider never loads (issue #5798).
|
||||
assert "get_call_count" not in provider_state
|
||||
assert "save_call_count" not in provider_state
|
||||
# Persistence is owned by the per-service-call middleware: it persists once per service call
|
||||
# (issue #5798: the provider must never be silently bypassed when the service stores history).
|
||||
# This run makes two service calls (function call + final answer), so it persists twice.
|
||||
assert provider_state["save_call_count"] == 2
|
||||
# load_messages=True while the service stores history surfaces a warning.
|
||||
assert any("load_messages" in record.message for record in caplog.records)
|
||||
assert session.service_session_id == "resp_service_managed"
|
||||
|
||||
|
||||
@@ -1996,6 +2007,19 @@ def test_merge_options_none_values_ignored():
|
||||
assert result["key2"] == "value2"
|
||||
|
||||
|
||||
def test_merge_options_drops_none_base_values():
|
||||
"""Test _merge_options strips None values so unset options are never forwarded."""
|
||||
base = {"store": None, "temperature": 0.5}
|
||||
override = {"top_p": 0.9}
|
||||
|
||||
result = _merge_options(base, override)
|
||||
|
||||
# An unset base value (e.g. store=None from default_options) must not survive the merge.
|
||||
assert "store" not in result
|
||||
assert result["temperature"] == 0.5
|
||||
assert result["top_p"] == 0.9
|
||||
|
||||
|
||||
def test_merge_options_runtime_model_overrides_default_model() -> None:
|
||||
"""Test _merge_options lets a runtime model override a default model."""
|
||||
result = _merge_options({"model": "default-model"}, {"model": "runtime-model"})
|
||||
@@ -2658,3 +2682,449 @@ async def test_as_tool_raises_on_user_input_request(client: SupportsChatGetRespo
|
||||
assert len(exc_info.value.contents) == 1
|
||||
assert exc_info.value.contents[0].type == "oauth_consent_request"
|
||||
assert exc_info.value.contents[0].consent_link == "https://login.microsoftonline.com/consent"
|
||||
|
||||
|
||||
# region Per-service-call history persistence scenario matrix
|
||||
#
|
||||
# The driving field is ``require_per_service_call_history_persistence``. Every scenario runs a
|
||||
# single agent run that makes **two service calls** -- a function call followed by a final
|
||||
# completion -- so the *timing* of persistence is observable:
|
||||
#
|
||||
# * When the flag is ``True``, the per-service-call middleware persists the provider **after each
|
||||
# service call**. So the function-call turn is already saved by the time the second (final)
|
||||
# service call starts. This holds regardless of whether the chat client stores history
|
||||
# server-side (the bug in issue #5798 was that a storing client silently bypassed persistence).
|
||||
# * When the flag is ``False``, the provider persists **once, at the end of the run** -- nothing is
|
||||
# saved between the two service calls.
|
||||
#
|
||||
# ``SpyChatClient.saves_before_call`` records ``provider.save_calls`` at the start of every service
|
||||
# call, so ``[0, 1]`` means "the function-call turn was persisted before the final call" and
|
||||
# ``[0, 0]`` means "no persistence happened mid-run". The client's ``store`` / ``STORES_BY_DEFAULT``
|
||||
# only selects *how* the middleware behaves -- never *whether* the provider persists.
|
||||
|
||||
_PSC_SERVICE_CONVERSATION_ID = "svc-conversation"
|
||||
|
||||
_psc_stream_params = pytest.mark.parametrize("stream", [False, True], ids=["sync", "stream"])
|
||||
|
||||
|
||||
@tool(name="lookup_weather", approval_mode="never_require")
|
||||
def _psc_lookup_weather(location: str) -> str:
|
||||
return f"Weather in {location}: sunny"
|
||||
|
||||
|
||||
def _psc_function_call_script() -> list[tuple[str, ...]]:
|
||||
"""A fresh function-call-then-final-completion script (the client mutates it)."""
|
||||
return [
|
||||
("call", "call_1", "lookup_weather", '{"location": "Seattle"}'),
|
||||
("text", "It is sunny in Seattle."),
|
||||
]
|
||||
|
||||
|
||||
class _PscSpyHistoryProvider(HistoryProvider):
|
||||
"""In-memory history provider that records load/save calls for assertions."""
|
||||
|
||||
def __init__(self, source_id: str = "spy_history", **kwargs: Any) -> None:
|
||||
super().__init__(source_id, **kwargs)
|
||||
self._messages: list[Message] = []
|
||||
self.get_calls: int = 0
|
||||
self.save_calls: int = 0
|
||||
self.saved_batches: list[list[Message]] = []
|
||||
|
||||
async def get_messages(
|
||||
self, session_id: str | None, *, state: dict[str, Any] | None = None, **kwargs: Any
|
||||
) -> list[Message]:
|
||||
self.get_calls += 1
|
||||
return list(self._messages)
|
||||
|
||||
async def save_messages(
|
||||
self,
|
||||
session_id: str | None,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
state: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self.save_calls += 1
|
||||
self.saved_batches.append(list(messages))
|
||||
self._messages.extend(messages)
|
||||
|
||||
@property
|
||||
def stored_messages(self) -> list[Message]:
|
||||
return list(self._messages)
|
||||
|
||||
|
||||
class _PscSpyChatClient(MockBaseChatClient):
|
||||
"""Chat client that scripts a function-call/final-completion sequence.
|
||||
|
||||
It records, at the start of each service call, how many provider saves have already happened
|
||||
(``saves_before_call``), what messages it received, and what options it saw. When the effective
|
||||
``store`` is truthy it returns a stable ``conversation_id`` to mimic a server-managed
|
||||
conversation, so the framework propagates ``session.service_session_id``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
provider: _PscSpyHistoryProvider,
|
||||
stores_by_default: bool = False,
|
||||
script: list[tuple[str, ...]] | None = None,
|
||||
echo_conversation_id: bool = True,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.STORES_BY_DEFAULT = stores_by_default # type: ignore[attr-defined]
|
||||
self._provider = provider
|
||||
self._script = list(script) if script is not None else [("text", "ok")]
|
||||
self._echo_conversation_id = echo_conversation_id
|
||||
self.received_messages: list[list[Message]] = []
|
||||
self.received_options: list[dict[str, Any]] = []
|
||||
self.saves_before_call: list[int] = []
|
||||
|
||||
def _effective_store(self, options: dict[str, Any]) -> bool:
|
||||
store = options.get("store")
|
||||
if store is None:
|
||||
return bool(self.STORES_BY_DEFAULT)
|
||||
return bool(store)
|
||||
|
||||
def _next_contents(self) -> list[Content]:
|
||||
turn = self._script.pop(0) if self._script else ("text", "ok")
|
||||
if turn[0] == "call":
|
||||
_, call_id, name, args = turn
|
||||
return [Content.from_function_call(call_id=call_id, name=name, arguments=args)]
|
||||
return [Content.from_text(turn[1])]
|
||||
|
||||
def _inner_get_response( # type: ignore[override]
|
||||
self,
|
||||
*,
|
||||
messages: MutableSequence[Message],
|
||||
stream: bool,
|
||||
options: dict[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
||||
self.received_messages.append(list(messages))
|
||||
self.received_options.append(dict(options))
|
||||
self.saves_before_call.append(self._provider.save_calls)
|
||||
store_and_echo = self._effective_store(options) and self._echo_conversation_id
|
||||
conv_id = _PSC_SERVICE_CONVERSATION_ID if store_and_echo else None
|
||||
contents = self._next_contents()
|
||||
|
||||
if stream:
|
||||
|
||||
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
|
||||
self.call_count += 1
|
||||
yield ChatResponseUpdate(
|
||||
contents=contents,
|
||||
role="assistant",
|
||||
finish_reason="stop",
|
||||
conversation_id=conv_id,
|
||||
)
|
||||
|
||||
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
|
||||
response = ChatResponse.from_updates(updates, output_format_type=options.get("response_format"))
|
||||
if conv_id:
|
||||
response.conversation_id = conv_id
|
||||
return response
|
||||
|
||||
return ResponseStream(_stream(), finalizer=_finalize)
|
||||
|
||||
async def _get() -> ChatResponse:
|
||||
self.call_count += 1
|
||||
return ChatResponse(
|
||||
messages=Message(role="assistant", contents=contents),
|
||||
conversation_id=conv_id,
|
||||
)
|
||||
|
||||
return _get()
|
||||
|
||||
|
||||
def _psc_build_agent(
|
||||
client: _PscSpyChatClient,
|
||||
provider: _PscSpyHistoryProvider,
|
||||
*,
|
||||
require_per_service_call_history_persistence: bool,
|
||||
default_options: dict[str, Any] | None = None,
|
||||
) -> Agent:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if default_options is not None:
|
||||
kwargs["default_options"] = default_options
|
||||
return Agent(
|
||||
client=client,
|
||||
tools=[_psc_lookup_weather],
|
||||
context_providers=[provider],
|
||||
require_per_service_call_history_persistence=require_per_service_call_history_persistence,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
async def _psc_run(agent: Agent, text: str, session: AgentSession, *, stream: bool) -> str:
|
||||
if stream:
|
||||
chunks: list[str] = []
|
||||
async for update in agent.run(text, session=session, stream=True):
|
||||
chunks.append(update.text or "")
|
||||
return "".join(chunks)
|
||||
result = await agent.run(text, session=session)
|
||||
return result.text
|
||||
|
||||
|
||||
# driver=True (the contract under test): persistence happens per service call
|
||||
|
||||
|
||||
@_psc_stream_params
|
||||
async def test_psc_flag_on_store_false_persists_after_each_service_call(stream: bool) -> None:
|
||||
"""Mode A (flag on, service does not store): function-call turn is persisted before the final call."""
|
||||
provider = _PscSpyHistoryProvider()
|
||||
client = _PscSpyChatClient(provider=provider, stores_by_default=False, script=_psc_function_call_script())
|
||||
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=True)
|
||||
session = agent.create_session()
|
||||
|
||||
text = await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
|
||||
|
||||
assert text == "It is sunny in Seattle."
|
||||
# Two service calls: function call, then final completion.
|
||||
assert client.call_count == 2
|
||||
# The contract: the function-call turn was persisted *before* the second service call started.
|
||||
assert client.saves_before_call == [0, 1]
|
||||
assert provider.save_calls == 2
|
||||
# Mode A loads local history (the middleware injects it before each service call).
|
||||
assert provider.get_calls >= 1
|
||||
# No service-side storage, so no conversation id is propagated.
|
||||
assert session.service_session_id is None
|
||||
|
||||
|
||||
@_psc_stream_params
|
||||
async def test_psc_flag_on_stores_by_default_persists_after_each_service_call(
|
||||
stream: bool, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Mode B (flag on, service stores by default): still persists per service call, but skips load (issue #5798)."""
|
||||
provider = _PscSpyHistoryProvider() # load_messages=True by default
|
||||
client = _PscSpyChatClient(provider=provider, stores_by_default=True, script=_psc_function_call_script())
|
||||
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=True)
|
||||
session = agent.create_session()
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="agent_framework"):
|
||||
text = await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
|
||||
|
||||
assert text == "It is sunny in Seattle."
|
||||
assert client.call_count == 2
|
||||
# The invariant the bug violated: persistence still happens per service call when the service stores.
|
||||
assert client.saves_before_call == [0, 1]
|
||||
assert provider.save_calls == 2
|
||||
# The service owns loading, so the provider is never asked to load.
|
||||
assert provider.get_calls == 0
|
||||
# A warning surfaces the bypassed load (load_messages=True).
|
||||
assert any("load_messages" in record.message for record in caplog.records)
|
||||
# The real service conversation id propagates to the session.
|
||||
assert session.service_session_id == _PSC_SERVICE_CONVERSATION_ID
|
||||
|
||||
|
||||
@_psc_stream_params
|
||||
async def test_psc_flag_on_store_only_provider_no_load_no_warning(
|
||||
stream: bool, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Mode B with a store-only provider (load_messages=False): persists per call, no load, no warning."""
|
||||
provider = _PscSpyHistoryProvider(load_messages=False)
|
||||
client = _PscSpyChatClient(provider=provider, stores_by_default=True, script=_psc_function_call_script())
|
||||
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=True)
|
||||
session = agent.create_session()
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="agent_framework"):
|
||||
await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
|
||||
|
||||
assert client.saves_before_call == [0, 1]
|
||||
assert provider.save_calls == 2
|
||||
assert provider.get_calls == 0
|
||||
assert not any("load_messages" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
@_psc_stream_params
|
||||
async def test_psc_flag_on_store_false_override_behaves_as_mode_a(stream: bool) -> None:
|
||||
"""Flag on + storing client but store=False override: falls back to Mode A (local, per call)."""
|
||||
provider = _PscSpyHistoryProvider()
|
||||
client = _PscSpyChatClient(provider=provider, stores_by_default=True, script=_psc_function_call_script())
|
||||
agent = _psc_build_agent(
|
||||
client, provider, require_per_service_call_history_persistence=True, default_options={"store": False}
|
||||
)
|
||||
session = agent.create_session()
|
||||
|
||||
await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
|
||||
|
||||
assert client.saves_before_call == [0, 1]
|
||||
assert provider.save_calls == 2
|
||||
assert provider.get_calls >= 1
|
||||
# store=False forces local handling, so no real service conversation id.
|
||||
assert session.service_session_id is None
|
||||
|
||||
|
||||
@_psc_stream_params
|
||||
async def test_psc_flag_on_store_none_treated_as_absent(stream: bool, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Flag on + storing client + explicit store=None: None is "unset", so the storing default applies (Mode B)."""
|
||||
provider = _PscSpyHistoryProvider()
|
||||
client = _PscSpyChatClient(provider=provider, stores_by_default=True, script=_psc_function_call_script())
|
||||
agent = _psc_build_agent(
|
||||
client, provider, require_per_service_call_history_persistence=True, default_options={"store": None}
|
||||
)
|
||||
session = agent.create_session()
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="agent_framework"):
|
||||
await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
|
||||
|
||||
assert client.saves_before_call == [0, 1]
|
||||
assert provider.save_calls == 2
|
||||
assert provider.get_calls == 0
|
||||
assert session.service_session_id == _PSC_SERVICE_CONVERSATION_ID
|
||||
assert any("load_messages" in record.message for record in caplog.records)
|
||||
# store=None must not be forwarded to the client; the service decides its own default.
|
||||
assert all("store" not in options for options in client.received_options)
|
||||
|
||||
|
||||
@_psc_stream_params
|
||||
async def test_psc_flag_on_respects_store_outputs_flag(stream: bool) -> None:
|
||||
"""Flag on: the provider's store_inputs/store_outputs flags still apply per service call."""
|
||||
provider = _PscSpyHistoryProvider(store_inputs=True, store_outputs=False)
|
||||
client = _PscSpyChatClient(provider=provider, stores_by_default=True, script=_psc_function_call_script())
|
||||
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=True)
|
||||
session = agent.create_session()
|
||||
|
||||
await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
|
||||
|
||||
assert provider.save_calls == 2
|
||||
# Outputs disabled, so no assistant/tool-call messages were stored, only user/tool inputs.
|
||||
assert provider.stored_messages
|
||||
assert all(message.role != "assistant" for message in provider.stored_messages)
|
||||
|
||||
|
||||
# driver=False (control): persistence happens once, at the end of the run
|
||||
|
||||
|
||||
@_psc_stream_params
|
||||
async def test_psc_flag_off_store_false_persists_once_at_end(stream: bool) -> None:
|
||||
"""Flag off + non-storing client: nothing is persisted mid-run; one save at the end."""
|
||||
provider = _PscSpyHistoryProvider()
|
||||
client = _PscSpyChatClient(provider=provider, stores_by_default=False, script=_psc_function_call_script())
|
||||
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=False)
|
||||
session = agent.create_session()
|
||||
|
||||
text = await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
|
||||
|
||||
assert text == "It is sunny in Seattle."
|
||||
assert client.call_count == 2
|
||||
# The control contract: no save happened between the function call and the final completion.
|
||||
assert client.saves_before_call == [0, 0]
|
||||
assert provider.save_calls == 1
|
||||
|
||||
|
||||
@_psc_stream_params
|
||||
async def test_psc_flag_off_stores_by_default_persists_once_at_end(stream: bool) -> None:
|
||||
"""Flag off + storing client: once-per-run persistence, and the service conversation id propagates."""
|
||||
provider = _PscSpyHistoryProvider()
|
||||
client = _PscSpyChatClient(provider=provider, stores_by_default=True, script=_psc_function_call_script())
|
||||
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=False)
|
||||
session = agent.create_session()
|
||||
|
||||
await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
|
||||
|
||||
assert client.saves_before_call == [0, 0]
|
||||
assert provider.save_calls == 1
|
||||
assert session.service_session_id == _PSC_SERVICE_CONVERSATION_ID
|
||||
|
||||
|
||||
@_psc_stream_params
|
||||
async def test_psc_flag_on_storing_with_existing_conversation_id_does_not_raise(stream: bool) -> None:
|
||||
"""Allow side of the guard: flag on + storing client + an existing conversation_id resumes (no raise).
|
||||
|
||||
The non-storing path raises on an existing service-managed conversation id, but with a storing
|
||||
client the run must proceed and the service conversation id must propagate to the session.
|
||||
"""
|
||||
provider = _PscSpyHistoryProvider()
|
||||
client = _PscSpyChatClient(provider=provider, stores_by_default=True, script=_psc_function_call_script())
|
||||
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=True)
|
||||
session = agent.create_session()
|
||||
|
||||
if stream:
|
||||
chunks: list[str] = []
|
||||
async for update in agent.run(
|
||||
"What's the weather in Seattle?",
|
||||
session=session,
|
||||
stream=True,
|
||||
options={"conversation_id": "existing_conversation"},
|
||||
):
|
||||
chunks.append(update.text or "")
|
||||
text = "".join(chunks)
|
||||
else:
|
||||
result = await agent.run(
|
||||
"What's the weather in Seattle?",
|
||||
session=session,
|
||||
options={"conversation_id": "existing_conversation"},
|
||||
)
|
||||
text = result.text
|
||||
|
||||
assert text == "It is sunny in Seattle."
|
||||
# Persistence still happens per service call, and the real service id propagates to the session.
|
||||
assert provider.save_calls == 2
|
||||
assert provider.get_calls == 0
|
||||
assert session.service_session_id == _PSC_SERVICE_CONVERSATION_ID
|
||||
|
||||
|
||||
@_psc_stream_params
|
||||
async def test_psc_flag_on_storing_two_runs_same_session(stream: bool) -> None:
|
||||
"""Storing mode across two runs on one session: persistence keeps happening, id is stable, no load.
|
||||
|
||||
The second run exercises the precedence branch where the session already carries a
|
||||
service_session_id, which must continue to skip provider loading and keep persisting.
|
||||
"""
|
||||
provider = _PscSpyHistoryProvider()
|
||||
client = _PscSpyChatClient(provider=provider, stores_by_default=True, script=_psc_function_call_script())
|
||||
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=True)
|
||||
session = agent.create_session()
|
||||
|
||||
await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
|
||||
|
||||
assert provider.save_calls == 2
|
||||
assert provider.get_calls == 0
|
||||
first_run_service_id = session.service_session_id
|
||||
assert first_run_service_id == _PSC_SERVICE_CONVERSATION_ID
|
||||
|
||||
# Reset the scripted client for a second run on the same session.
|
||||
client._script = _psc_function_call_script()
|
||||
client.call_count = 0
|
||||
client.saves_before_call = []
|
||||
|
||||
await _psc_run(agent, "And in Portland?", session, stream=stream)
|
||||
|
||||
# Persistence keeps happening on the second run (two more saves), still per service call.
|
||||
assert client.saves_before_call == [2, 3]
|
||||
assert provider.save_calls == 4
|
||||
# Loading stays skipped and the service conversation id stays stable across runs.
|
||||
assert provider.get_calls == 0
|
||||
assert session.service_session_id == first_run_service_id
|
||||
|
||||
|
||||
@_psc_stream_params
|
||||
async def test_psc_flag_on_storing_without_conversation_id_warns_every_call(
|
||||
stream: bool, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Storing mode but the client returns no conversation_id: warn on every service call.
|
||||
|
||||
Without an echoed conversation id the next run has nothing to resume from, so cross-turn
|
||||
history can be lost silently. The warning fires per service call (no dedup) so the uncommon
|
||||
failure mode cannot pass unnoticed.
|
||||
"""
|
||||
provider = _PscSpyHistoryProvider()
|
||||
client = _PscSpyChatClient(
|
||||
provider=provider,
|
||||
stores_by_default=True,
|
||||
script=_psc_function_call_script(),
|
||||
echo_conversation_id=False,
|
||||
)
|
||||
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=True)
|
||||
session = agent.create_session()
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="agent_framework"):
|
||||
await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
|
||||
|
||||
# Persistence still happens, but no service id is captured to resume from.
|
||||
assert provider.save_calls == 2
|
||||
assert session.service_session_id is None
|
||||
# Two service calls -> the warning is emitted twice (one per call, not deduped).
|
||||
missing_id_warnings = [r for r in caplog.records if "returned no conversation_id" in r.message]
|
||||
assert len(missing_id_warnings) == 2
|
||||
|
||||
@@ -8,29 +8,34 @@ This module provides ``Mem0ContextProvider``, built on the new
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Awaitable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, TypedDict
|
||||
|
||||
from agent_framework import Message
|
||||
from agent_framework._sessions import AgentSession, ContextProvider, SessionContext
|
||||
from mem0 import AsyncMemory, AsyncMemoryClient
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import NotRequired, Self, TypedDict # pragma: no cover
|
||||
from typing import Self # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import NotRequired, Self, TypedDict # pragma: no cover
|
||||
from typing_extensions import Self # pragma: no cover
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework._agents import SupportsAgentRun
|
||||
|
||||
|
||||
class _MemorySearchResponse_v1_1(TypedDict):
|
||||
results: list[dict[str, Any]]
|
||||
relations: NotRequired[list[dict[str, Any]]]
|
||||
logger = logging.getLogger(__name__)
|
||||
MemoryRecord: TypeAlias = dict[str, object]
|
||||
|
||||
|
||||
_MemorySearchResponse_v2 = list[dict[str, Any]]
|
||||
class SearchResults(TypedDict):
|
||||
results: list[MemoryRecord]
|
||||
|
||||
|
||||
SearchResponse: TypeAlias = list[MemoryRecord] | SearchResults
|
||||
|
||||
|
||||
class Mem0ContextProvider(ContextProvider):
|
||||
@@ -106,28 +111,85 @@ class Mem0ContextProvider(ContextProvider):
|
||||
if not input_text.strip():
|
||||
return
|
||||
|
||||
filters = self._build_filters()
|
||||
# Query entity partitions independently to bypass strict logical AND limitations
|
||||
# Mem0 OSS and Platform SDKs expose inconsistent search typings.
|
||||
search_tasks: list[Awaitable[Any]] = []
|
||||
|
||||
# AsyncMemory (OSS) expects user_id/agent_id/run_id as direct kwargs
|
||||
# AsyncMemoryClient (Platform) expects them in a filters dict
|
||||
search_kwargs: dict[str, Any] = {"query": input_text}
|
||||
if isinstance(self.mem0_client, AsyncMemory):
|
||||
search_kwargs.update(filters)
|
||||
else:
|
||||
search_kwargs["filters"] = filters
|
||||
# 1. Query User partition independently
|
||||
if self.user_id:
|
||||
user_kwargs = self._build_search_kwargs(input_text, "user_id", self.user_id)
|
||||
search_tasks.append(self.mem0_client.search(**user_kwargs)) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
||||
|
||||
search_response: _MemorySearchResponse_v1_1 | _MemorySearchResponse_v2 = await self.mem0_client.search( # type: ignore[misc]
|
||||
**search_kwargs,
|
||||
)
|
||||
# 2. Query Agent partition independently
|
||||
if self.agent_id:
|
||||
agent_kwargs = self._build_search_kwargs(input_text, "agent_id", self.agent_id)
|
||||
search_tasks.append(self.mem0_client.search(**agent_kwargs)) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
||||
|
||||
if isinstance(search_response, list):
|
||||
memories = search_response
|
||||
elif isinstance(search_response, dict) and "results" in search_response:
|
||||
memories = search_response["results"]
|
||||
else:
|
||||
memories = [search_response]
|
||||
# Fall back to an app-scoped search when only application_id is configured
|
||||
if not search_tasks and self.application_id:
|
||||
app_kwargs: dict[str, Any] = {"query": input_text}
|
||||
if isinstance(self.mem0_client, AsyncMemory):
|
||||
app_kwargs["app_id"] = self.application_id
|
||||
else:
|
||||
app_kwargs["filters"] = {"app_id": self.application_id}
|
||||
search_tasks.append(self.mem0_client.search(**app_kwargs)) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
||||
if not search_tasks:
|
||||
return
|
||||
|
||||
line_separated_memories = "\n".join(memory.get("memory", "") for memory in memories)
|
||||
results: list[SearchResponse | BaseException] = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
|
||||
# Merge and deduplicate results
|
||||
memories: list[MemoryRecord] = []
|
||||
seen_memory_ids: set[str] = set()
|
||||
failed_tasks_count: int = 0
|
||||
|
||||
for search_response in results:
|
||||
if isinstance(search_response, asyncio.CancelledError):
|
||||
raise search_response
|
||||
|
||||
if isinstance(search_response, BaseException):
|
||||
failed_tasks_count += 1
|
||||
logger.error(
|
||||
"Mem0 partition search task failed: %s",
|
||||
search_response,
|
||||
exc_info=(type(search_response), search_response, search_response.__traceback__),
|
||||
)
|
||||
continue
|
||||
|
||||
current_memories: list[MemoryRecord] = []
|
||||
if isinstance(search_response, list):
|
||||
current_memories = [mem for mem in search_response if isinstance(mem, dict)]
|
||||
elif isinstance(search_response, dict):
|
||||
results_field = search_response.get("results")
|
||||
if isinstance(results_field, list):
|
||||
current_memories = [
|
||||
item
|
||||
for item in results_field
|
||||
if isinstance(item, dict) # pyright: ignore[reportUnknownVariableType]
|
||||
]
|
||||
else:
|
||||
logger.warning(
|
||||
"Unexpected Mem0 search response format: %s",
|
||||
type(results_field).__name__,
|
||||
)
|
||||
|
||||
for mem in current_memories:
|
||||
mem_id = mem.get("id")
|
||||
if mem_id is not None and not isinstance(mem_id, str):
|
||||
mem_id = str(mem_id)
|
||||
|
||||
if mem_id is not None and mem_id in seen_memory_ids:
|
||||
continue
|
||||
|
||||
if mem_id is not None:
|
||||
seen_memory_ids.add(mem_id)
|
||||
|
||||
memories.append(mem)
|
||||
|
||||
if failed_tasks_count == len(search_tasks):
|
||||
logger.error("All Mem0 retrieval tasks failed. Context provider is unable to verify memory state.")
|
||||
|
||||
line_separated_memories = "\n".join(str(memory.get("memory", "")) for memory in memories)
|
||||
if line_separated_memories:
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
@@ -159,12 +221,21 @@ class Mem0ContextProvider(ContextProvider):
|
||||
]
|
||||
|
||||
if messages:
|
||||
await self.mem0_client.add( # type: ignore[misc]
|
||||
messages=messages,
|
||||
user_id=self.user_id,
|
||||
agent_id=self.agent_id,
|
||||
metadata={"application_id": self.application_id},
|
||||
)
|
||||
add_kwargs: dict[str, Any] = {
|
||||
"messages": messages,
|
||||
"user_id": self.user_id,
|
||||
"agent_id": self.agent_id,
|
||||
}
|
||||
|
||||
# Inject the application scope using the matching signature format for each SDK variant
|
||||
if isinstance(self.mem0_client, AsyncMemory):
|
||||
if self.application_id:
|
||||
add_kwargs["app_id"] = self.application_id
|
||||
else:
|
||||
if self.application_id:
|
||||
add_kwargs["filters"] = {"app_id": self.application_id}
|
||||
|
||||
await self.mem0_client.add(**add_kwargs) # type: ignore[misc, call-arg]
|
||||
|
||||
# -- Internal methods ------------------------------------------------------
|
||||
|
||||
@@ -173,15 +244,21 @@ class Mem0ContextProvider(ContextProvider):
|
||||
if not self.agent_id and not self.user_id and not self.application_id:
|
||||
raise ValueError("At least one of the filters: agent_id, user_id, or application_id is required.")
|
||||
|
||||
def _build_filters(self) -> dict[str, Any]:
|
||||
"""Build search filters from initialization parameters."""
|
||||
filters: dict[str, Any] = {}
|
||||
if self.user_id:
|
||||
filters["user_id"] = self.user_id
|
||||
if self.agent_id:
|
||||
filters["agent_id"] = self.agent_id
|
||||
if self.application_id:
|
||||
filters["app_id"] = self.application_id
|
||||
def _build_search_kwargs(self, input_text: str, entity_key: str, entity_value: str) -> dict[str, Any]:
|
||||
"""Build search keyword arguments formatted for OSS vs Platform clients."""
|
||||
filters: dict[str, Any] = {"query": input_text}
|
||||
|
||||
if isinstance(self.mem0_client, AsyncMemory):
|
||||
# AsyncMemory (OSS) expects direct kwargs
|
||||
filters[entity_key] = entity_value
|
||||
if self.application_id:
|
||||
filters["app_id"] = self.application_id
|
||||
else:
|
||||
# AsyncMemoryClient (Platform) expects a filters dict
|
||||
filters["filters"] = {entity_key: entity_value}
|
||||
if self.application_id:
|
||||
filters["filters"]["app_id"] = self.application_id
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentResponse, Message
|
||||
@@ -193,39 +193,59 @@ class TestBeforeRun:
|
||||
assert call_kwargs["user_id"] == "u1"
|
||||
assert "filters" not in call_kwargs
|
||||
|
||||
async def test_oss_client_all_scoping_params(self, mock_oss_mem0_client: AsyncMock) -> None:
|
||||
"""OSS client with all scoping parameters passes them as direct kwargs."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_oss_client_all_scoping_params_except_app_id(self, mock_oss_mem0_client: AsyncMock) -> None:
|
||||
"""OSS client with all scoping parameters passes them as isolated concurrent kwargs."""
|
||||
mock_oss_mem0_client.search.return_value = []
|
||||
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0", mem0_client=mock_oss_mem0_client, user_id="u1", agent_id="a1", application_id="app1"
|
||||
source_id="mem0",
|
||||
mem0_client=mock_oss_mem0_client,
|
||||
user_id="u1",
|
||||
agent_id="a1"
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
|
||||
|
||||
mock_context = MagicMock(spec=SessionContext)
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.text = "hello"
|
||||
mock_context.input_messages = [mock_msg]
|
||||
mock_context.response = None
|
||||
|
||||
await provider.before_run(
|
||||
agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
) # type: ignore[arg-type]
|
||||
agent=MagicMock(), session=MagicMock(spec=AgentSession), context=mock_context, state={}
|
||||
)
|
||||
|
||||
call_kwargs = mock_oss_mem0_client.search.call_args.kwargs
|
||||
assert call_kwargs["user_id"] == "u1"
|
||||
assert call_kwargs["agent_id"] == "a1"
|
||||
assert "filters" not in call_kwargs
|
||||
# Re-aligned assertion: We expect 2 separate concurrent calls instead of 1 combined call
|
||||
assert mock_oss_mem0_client.search.call_count == 2
|
||||
mock_oss_mem0_client.search.assert_any_call(query="hello", user_id="u1")
|
||||
mock_oss_mem0_client.search.assert_any_call(query="hello", agent_id="a1")
|
||||
|
||||
async def test_platform_client_passes_filters_dict(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Platform AsyncMemoryClient should receive scoping params in a filters dict."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_client_passes_filters_dict_except_app_id(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Platform client passes scoping parameters concurrently inside the nested filters dictionary."""
|
||||
mock_mem0_client.search.return_value = []
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
|
||||
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0",
|
||||
mem0_client=mock_mem0_client,
|
||||
user_id="u1",
|
||||
agent_id="a1",
|
||||
)
|
||||
|
||||
mock_context = MagicMock(spec=SessionContext)
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.text = "hello"
|
||||
mock_context.input_messages = [mock_msg]
|
||||
mock_context.response = None
|
||||
|
||||
await provider.before_run(
|
||||
agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
) # type: ignore[arg-type]
|
||||
agent=MagicMock(), session=MagicMock(spec=AgentSession), context=mock_context, state={}
|
||||
)
|
||||
|
||||
call_kwargs = mock_mem0_client.search.call_args.kwargs
|
||||
assert call_kwargs["query"] == "Hello"
|
||||
assert "filters" in call_kwargs
|
||||
assert call_kwargs["filters"]["user_id"] == "u1"
|
||||
# Re-aligned assertion: Platform client isolates filters per call to bypass AND limitations
|
||||
assert mock_mem0_client.search.call_count == 2
|
||||
mock_mem0_client.search.assert_any_call(query="hello", filters={"user_id": "u1"})
|
||||
mock_mem0_client.search.assert_any_call(query="hello", filters={"agent_id": "a1"})
|
||||
|
||||
|
||||
# -- after_run tests -----------------------------------------------------------
|
||||
@@ -318,8 +338,8 @@ class TestAfterRun:
|
||||
with pytest.raises(ValueError, match="At least one of the filters"):
|
||||
await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
|
||||
|
||||
async def test_stores_with_application_id_metadata(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""application_id is passed in metadata."""
|
||||
async def test_stores_with_application_id_filters(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""application_id is passed in filters."""
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0", mem0_client=mock_mem0_client, user_id="u1", application_id="app1"
|
||||
)
|
||||
@@ -331,7 +351,7 @@ class TestAfterRun:
|
||||
agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
assert mock_mem0_client.add.call_args.kwargs["metadata"] == {"application_id": "app1"}
|
||||
assert mock_mem0_client.add.call_args.kwargs["filters"] == {"app_id": "app1"}
|
||||
|
||||
|
||||
# -- _validate_filters tests --------------------------------------------------
|
||||
@@ -358,15 +378,20 @@ class TestValidateFilters:
|
||||
provider._validate_filters()
|
||||
|
||||
|
||||
# -- _build_filters tests -----------------------------------------------------
|
||||
# -- _build_search_kwargs tests -----------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildFilters:
|
||||
"""Test _build_filters method."""
|
||||
class TestBuildSearchKwargs:
|
||||
"""Test _build_search_kwargs method."""
|
||||
|
||||
def test_user_id_only(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
assert provider._build_filters() == {"user_id": "u1"}
|
||||
|
||||
# Pass the 3 required arguments
|
||||
result = provider._build_search_kwargs("test query", "user_id", "u1")
|
||||
|
||||
# AsyncMock triggers the Platform client nested 'filters' structure
|
||||
assert result == {"query": "test query", "filters": {"user_id": "u1"}}
|
||||
|
||||
def test_all_params(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(
|
||||
@@ -376,28 +401,66 @@ class TestBuildFilters:
|
||||
agent_id="a1",
|
||||
application_id="app1",
|
||||
)
|
||||
assert provider._build_filters() == {
|
||||
"user_id": "u1",
|
||||
"agent_id": "a1",
|
||||
"app_id": "app1",
|
||||
|
||||
# Test that app_id correctly merges with the isolated target entity
|
||||
result = provider._build_search_kwargs("test query", "agent_id", "a1")
|
||||
|
||||
assert result == {
|
||||
"query": "test query",
|
||||
"filters": {
|
||||
"agent_id": "a1",
|
||||
"app_id": "app1",
|
||||
},
|
||||
}
|
||||
|
||||
def test_excludes_none_values(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
filters = provider._build_filters()
|
||||
assert "agent_id" not in filters
|
||||
assert "run_id" not in filters
|
||||
assert "app_id" not in filters
|
||||
|
||||
# application_id is None by default, it should not appear in the dictionary
|
||||
result = provider._build_search_kwargs("test query", "user_id", "u1")
|
||||
|
||||
assert "app_id" not in result.get("filters", {})
|
||||
|
||||
def test_no_run_id_in_search_filters(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""run_id is excluded from search filters so memories work across sessions."""
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
filters = provider._build_filters()
|
||||
assert "run_id" not in filters
|
||||
|
||||
result = provider._build_search_kwargs("test query", "user_id", "u1")
|
||||
|
||||
assert "run_id" not in result.get("filters", {})
|
||||
assert "run_id" not in result
|
||||
|
||||
def test_empty_when_no_params(self, mock_mem0_client: AsyncMock) -> None:
|
||||
# Validates base query payload generation
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client)
|
||||
assert provider._build_filters() == {}
|
||||
|
||||
result = provider._build_search_kwargs("test query", "custom_key", "custom_val")
|
||||
|
||||
assert result == {"query": "test query", "filters": {"custom_key": "custom_val"}}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_before_run_application_only_fallback(self, mock_mem0_client: AsyncMock) -> None:
|
||||
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0", mem0_client=mock_mem0_client, application_id="app_fallback_test"
|
||||
)
|
||||
|
||||
# Mock a valid message list and session container setup
|
||||
mock_context = MagicMock(spec=SessionContext)
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.text = "Retrieve systemic fallback memory traces"
|
||||
mock_context.input_messages = [mock_msg]
|
||||
mock_context.response = None
|
||||
|
||||
mock_mem0_client.search = AsyncMock(return_value=[{"id": "m1", "memory": "System configuration template"}])
|
||||
|
||||
await provider.before_run(
|
||||
agent=MagicMock(), session=MagicMock(spec=AgentSession), context=mock_context, state={}
|
||||
)
|
||||
|
||||
# Verify that an application-scoped search task executed successfully
|
||||
assert mock_mem0_client.search.call_count == 1
|
||||
mock_context.extend_messages.assert_called_once()
|
||||
|
||||
|
||||
# -- Context manager tests -----------------------------------------------------
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"**/demos/**",
|
||||
"**/_to_delete/**",
|
||||
"**/05-end-to-end/**",
|
||||
"**/harness/**",
|
||||
"**/agent_with_foundry_tracing.py",
|
||||
"**/azure_responses_client_with_foundry.py"
|
||||
],
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"**/demos/**",
|
||||
"**/_to_delete/**",
|
||||
"**/05-end-to-end/**",
|
||||
"**/harness/**",
|
||||
"**/agent_with_foundry_tracing.py",
|
||||
"**/azure_responses_client_with_foundry.py",
|
||||
"**/github_copilot/**"
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Harness Console
|
||||
|
||||
A Textual-based terminal UI for running and observing AI agents built with the Agent Framework.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from console import run_agent_async, build_default_observers
|
||||
|
||||
await run_agent_async(
|
||||
agent=my_agent,
|
||||
session=my_session,
|
||||
observers=build_default_observers(),
|
||||
)
|
||||
```
|
||||
|
||||
See [`harness_research.py`](../harness_research.py) for a complete example.
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
console/
|
||||
├── __init__.py # Public API exports
|
||||
├── harness_console.py # run_agent_async() entry point
|
||||
├── app.py # HarnessApp (Textual application)
|
||||
├── app_state.py # HarnessAppState, enums, data types
|
||||
├── agent_runner.py # HarnessAgentRunner (streaming orchestration)
|
||||
├── state_driver.py # IUXStateDriver protocol
|
||||
├── textual_state_driver.py # Textual implementation of IUXStateDriver
|
||||
├── formatters.py # Tool call formatters
|
||||
├── observers/ # Lifecycle observers
|
||||
│ ├── base.py # ConsoleObserver abstract base
|
||||
│ ├── text_output.py # Streaming text display
|
||||
│ ├── tool_call_display.py # Tool call formatting
|
||||
│ ├── tool_approval.py # User approval for tool calls
|
||||
│ ├── error_display.py # Error messages
|
||||
│ ├── usage_display.py # Token usage tracking
|
||||
│ └── reasoning_display.py # Reasoning/thinking blocks
|
||||
├── components/ # Textual UI widgets
|
||||
│ ├── scroll_panel.py # Conversation history
|
||||
│ ├── text_input.py # User text input
|
||||
│ ├── list_selection.py # Multiple choice selector
|
||||
│ ├── agent_status.py # Spinner + usage display
|
||||
│ └── agent_mode_help.py # Mode indicator + help text
|
||||
└── commands/ # Slash command handlers
|
||||
├── base.py # CommandHandler abstract base
|
||||
├── exit_handler.py # /exit
|
||||
├── mode_handler.py # /mode [plan|execute]
|
||||
├── todo_handler.py # /todos
|
||||
└── session_handler.py # /session-export, /session-import
|
||||
```
|
||||
|
||||
## Public API
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `run_agent_async` | Main entry point — runs the Textual app with an agent |
|
||||
| `build_default_observers` | Factory for the standard observer set |
|
||||
| `build_default_command_handlers` | Factory for slash command handlers |
|
||||
| `ConsoleObserver` | Base class for custom observers |
|
||||
| `ToolCallFormatter` | Base class for custom tool formatters |
|
||||
| `CommandHandler` | Base class for custom slash commands |
|
||||
|
||||
## Architecture
|
||||
|
||||
The console follows a unidirectional data flow:
|
||||
|
||||
```
|
||||
AgentRunner → Observers → StateDriver → AppState → Textual UI
|
||||
↑
|
||||
User Input (app.py)
|
||||
```
|
||||
|
||||
- **AgentRunner** streams responses from the agent and dispatches events to observers.
|
||||
- **Observers** process events (text chunks, tool calls, errors) and update the state driver.
|
||||
- **StateDriver** (`IUXStateDriver`) mutates `HarnessAppState` and notifies the UI.
|
||||
- **Textual App** reads state and syncs widgets on each notification.
|
||||
|
||||
### Key Design Choices
|
||||
|
||||
| Concern | Approach |
|
||||
|---------|----------|
|
||||
| Rendering | Textual widgets + Rich markup (no manual ANSI) |
|
||||
| State | Single `HarnessAppState` dataclass, mutated by driver |
|
||||
| Streaming text | Truncate-and-rewrite on RichLog for flicker-free updates |
|
||||
| Extensibility | Custom observers, formatters, and commands via base classes |
|
||||
| Follow-up questions | Observer returns `FollowUpQuestion` → UI shows prompt/choices |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `textual` — TUI framework
|
||||
- `rich` — Text formatting
|
||||
- `agent-framework` — Core agent framework
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Harness Console - A Textual-based TUI for AI agent interactions.
|
||||
|
||||
This package provides a rich terminal interface for running and observing
|
||||
AI agents, with streaming output, tool call display, follow-up questions,
|
||||
and token usage tracking.
|
||||
"""
|
||||
|
||||
from .commands import CommandHandler, build_default_command_handlers
|
||||
from .formatters import ToolCallFormatter
|
||||
from .harness_console import run_agent_async
|
||||
from .observers import (
|
||||
ConsoleObserver,
|
||||
build_default_observers,
|
||||
build_observers_with_planning,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CommandHandler",
|
||||
"ConsoleObserver",
|
||||
"ToolCallFormatter",
|
||||
"build_default_command_handlers",
|
||||
"build_default_observers",
|
||||
"build_observers_with_planning",
|
||||
"run_agent_async",
|
||||
]
|
||||
@@ -0,0 +1,343 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent runner orchestration for the harness console.
|
||||
|
||||
This module provides the HarnessAgentRunner class, which orchestrates agent
|
||||
invocations with observer lifecycle management. It handles:
|
||||
- User input dispatch
|
||||
- Agent streaming with observer notifications
|
||||
- Follow-up action collection
|
||||
- Streaming state management
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, AgentSession
|
||||
|
||||
from .app_state import FollowUpAction
|
||||
from .observers.base import ConsoleObserver
|
||||
from .state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class HarnessAgentRunner:
|
||||
"""Orchestrates agent invocations driven by user-input events from the UI.
|
||||
|
||||
The component invokes the runner's input handlers (run_turn) directly;
|
||||
the runner mutates UI state through the supplied IUXStateDriver.
|
||||
|
||||
This is a minimal implementation focusing on the core agent loop without
|
||||
command handling or complex message injection (those can be added later).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: Agent,
|
||||
observers: list[ConsoleObserver],
|
||||
state_driver: IUXStateDriver,
|
||||
*,
|
||||
max_context_window_tokens: int | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> None:
|
||||
"""Initialize the agent runner.
|
||||
|
||||
Args:
|
||||
agent: The agent to orchestrate.
|
||||
observers: List of console observers for lifecycle events.
|
||||
state_driver: The UI state driver for observer updates.
|
||||
max_context_window_tokens: Optional max context window size for usage display.
|
||||
max_output_tokens: Optional max output tokens for usage display.
|
||||
"""
|
||||
self._agent = agent
|
||||
self._observers = observers
|
||||
self._ux = state_driver
|
||||
self._max_context_window_tokens = max_context_window_tokens
|
||||
self._max_output_tokens = max_output_tokens
|
||||
self._input_gate = asyncio.Semaphore(1) # Single turn at a time
|
||||
|
||||
async def run_turn(
|
||||
self,
|
||||
user_input: str,
|
||||
session: AgentSession | None = None,
|
||||
) -> None:
|
||||
"""Run a single agent turn with the given user input.
|
||||
|
||||
Echoes the input, then delegates to the agent loop.
|
||||
|
||||
Args:
|
||||
user_input: The user's input text.
|
||||
session: Optional agent session for conversation history.
|
||||
"""
|
||||
async with self._input_gate:
|
||||
self._ux.write_user_input_echo(user_input)
|
||||
|
||||
from agent_framework import Message
|
||||
|
||||
messages = [Message(role="user", contents=[user_input])]
|
||||
await self._run_agent_loop(messages, session)
|
||||
|
||||
async def start_agent_turn(
|
||||
self,
|
||||
messages: list,
|
||||
session: AgentSession | None = None,
|
||||
) -> None:
|
||||
"""Resume the agent loop with pre-built messages (from follow-up responses).
|
||||
|
||||
Called by the app after the user finishes answering follow-up questions.
|
||||
If messages is empty, just completes the turn.
|
||||
|
||||
Args:
|
||||
messages: List of Message objects to send to the agent.
|
||||
session: Optional agent session.
|
||||
"""
|
||||
async with self._input_gate:
|
||||
if not messages:
|
||||
self._complete_turn()
|
||||
return
|
||||
await self._run_agent_loop(messages, session)
|
||||
|
||||
async def _run_agent_loop(
|
||||
self,
|
||||
messages: list,
|
||||
session: AgentSession | None,
|
||||
) -> None:
|
||||
"""Run the agent loop, re-invoking as needed for follow-up messages.
|
||||
|
||||
Loops while there are messages to send. After each stream:
|
||||
- Collects follow-up actions from observers
|
||||
- If questions exist → queue them and return (UI will collect answers)
|
||||
- If only direct messages → loop with those messages
|
||||
- If nothing → complete the turn
|
||||
|
||||
Args:
|
||||
messages: Initial messages to send.
|
||||
session: Optional agent session.
|
||||
"""
|
||||
next_messages = messages
|
||||
|
||||
while next_messages:
|
||||
# Configure run options
|
||||
options = self._configure_run_options(session)
|
||||
|
||||
# Begin streaming
|
||||
self._ux.begin_streaming()
|
||||
self._ux.begin_streaming_output()
|
||||
self._ux.set_show_spinner(True)
|
||||
|
||||
try:
|
||||
await self._stream_response_messages(next_messages, session, options)
|
||||
except Exception as ex:
|
||||
self._ux.append_info_line(
|
||||
f"❌ Stream error: {ex.__class__.__name__}:\n{ex}",
|
||||
color="red",
|
||||
)
|
||||
|
||||
# Stop spinner and end streaming output
|
||||
self._ux.set_show_spinner(False)
|
||||
|
||||
# Collect follow-up actions from observers
|
||||
follow_up_actions = await self._collect_follow_up_actions(session)
|
||||
|
||||
# Separate direct messages from questions
|
||||
has_follow_ups = len(follow_up_actions) > 0
|
||||
|
||||
# Write no-text warning if applicable
|
||||
await self._ux.write_no_text_warning(has_follow_ups)
|
||||
|
||||
# Enqueue all follow-up actions
|
||||
for action in follow_up_actions:
|
||||
self._ux.enqueue_follow_up_action(action)
|
||||
|
||||
# Check if there are pending questions (UI needs user input)
|
||||
if self._ux.has_pending_questions():
|
||||
# Pause — the UI will collect answers and call start_agent_turn
|
||||
return
|
||||
|
||||
# No questions — drain any accumulated direct messages and loop
|
||||
drained = self._ux.take_follow_up_responses()
|
||||
next_messages = drained if drained else None
|
||||
|
||||
self._complete_turn()
|
||||
|
||||
def _complete_turn(self) -> None:
|
||||
"""Complete the current turn (end streaming)."""
|
||||
self._ux.end_streaming()
|
||||
|
||||
def _configure_run_options(
|
||||
self,
|
||||
session: AgentSession | None,
|
||||
) -> dict:
|
||||
"""Configure run options via observers.
|
||||
|
||||
Each observer can modify the options dict to influence agent behavior.
|
||||
|
||||
Args:
|
||||
session: Optional agent session.
|
||||
|
||||
Returns:
|
||||
Options dict for agent.run().
|
||||
"""
|
||||
options = {}
|
||||
for observer in self._observers:
|
||||
observer.configure_run_options(options, self._agent, session)
|
||||
return options
|
||||
|
||||
async def _stream_response(
|
||||
self,
|
||||
user_input: str,
|
||||
session: AgentSession | None,
|
||||
options: dict,
|
||||
) -> None:
|
||||
"""Stream agent response from a text input and dispatch to observers.
|
||||
|
||||
Args:
|
||||
user_input: The user's input text.
|
||||
session: Optional agent session.
|
||||
options: Run options configured by observers.
|
||||
"""
|
||||
# Stream response using agent.run(stream=True)
|
||||
stream = self._agent.run(
|
||||
user_input,
|
||||
stream=True,
|
||||
session=session,
|
||||
options=options,
|
||||
)
|
||||
|
||||
# Process each update chunk
|
||||
async for update in stream:
|
||||
await self._dispatch_update(update, session)
|
||||
|
||||
# Extract usage from the final response
|
||||
self._extract_usage(stream)
|
||||
|
||||
async def _stream_response_messages(
|
||||
self,
|
||||
messages: list,
|
||||
session: AgentSession | None,
|
||||
options: dict,
|
||||
) -> None:
|
||||
"""Stream agent response from Message objects and dispatch to observers.
|
||||
|
||||
Args:
|
||||
messages: List of Message objects to send.
|
||||
session: Optional agent session.
|
||||
options: Run options configured by observers.
|
||||
"""
|
||||
stream = self._agent.run(
|
||||
messages,
|
||||
stream=True,
|
||||
session=session,
|
||||
options=options,
|
||||
)
|
||||
|
||||
async for update in stream:
|
||||
await self._dispatch_update(update, session)
|
||||
|
||||
self._extract_usage(stream)
|
||||
|
||||
def _extract_usage(self, stream) -> None:
|
||||
"""Extract token usage from a completed stream."""
|
||||
try:
|
||||
get_final = getattr(stream, "get_final_response", None)
|
||||
if not get_final:
|
||||
return
|
||||
|
||||
import inspect
|
||||
|
||||
if inspect.iscoroutinefunction(get_final):
|
||||
return
|
||||
|
||||
final_response = get_final()
|
||||
if final_response is None:
|
||||
return
|
||||
|
||||
usage = getattr(final_response, "usage_details", None)
|
||||
if not isinstance(usage, dict):
|
||||
return
|
||||
|
||||
input_tokens = usage.get("input_token_count", 0) or 0
|
||||
output_tokens = usage.get("output_token_count", 0) or 0
|
||||
if input_tokens or output_tokens:
|
||||
self._ux.set_usage_text(self._format_usage(input_tokens, output_tokens))
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
async def _dispatch_update(
|
||||
self,
|
||||
update, # AgentResponseUpdate
|
||||
session: AgentSession | None,
|
||||
) -> None:
|
||||
"""Dispatch a single update to all observers.
|
||||
|
||||
Calls observer lifecycle methods in order:
|
||||
1. on_response_update (once per update)
|
||||
2. on_content (for each content item)
|
||||
3. on_text (if text is present)
|
||||
|
||||
Args:
|
||||
update: The agent response update.
|
||||
session: Optional agent session.
|
||||
"""
|
||||
# on_response_update
|
||||
for observer in self._observers:
|
||||
await observer.on_response_update(self._ux, update, self._agent, session)
|
||||
|
||||
# on_content for each content item
|
||||
if hasattr(update, "contents") and update.contents:
|
||||
for content in update.contents:
|
||||
for observer in self._observers:
|
||||
await observer.on_content(self._ux, content, self._agent, session)
|
||||
|
||||
# on_text for text chunks
|
||||
if hasattr(update, "text") and update.text:
|
||||
for observer in self._observers:
|
||||
await observer.on_text(self._ux, update.text, self._agent, session)
|
||||
|
||||
async def _collect_follow_up_actions(
|
||||
self,
|
||||
session: AgentSession | None,
|
||||
) -> list[FollowUpAction]:
|
||||
"""Collect follow-up actions from all observers.
|
||||
|
||||
Called after streaming completes to gather any follow-up questions
|
||||
or messages from observers.
|
||||
|
||||
Args:
|
||||
session: Optional agent session.
|
||||
|
||||
Returns:
|
||||
List of follow-up actions from all observers.
|
||||
"""
|
||||
actions: list[FollowUpAction] = []
|
||||
for observer in self._observers:
|
||||
observer_actions = await observer.on_stream_complete(
|
||||
self._ux, self._agent, session
|
||||
)
|
||||
if observer_actions:
|
||||
actions.extend(observer_actions)
|
||||
return actions
|
||||
|
||||
def _format_usage(self, input_tokens: int, output_tokens: int) -> str:
|
||||
"""Format token counts matching C# harness style: 📊 Tokens — input: X | output: Y | total: Z."""
|
||||
total_tokens = input_tokens + output_tokens
|
||||
|
||||
input_budget = None
|
||||
if self._max_context_window_tokens and self._max_output_tokens:
|
||||
input_budget = self._max_context_window_tokens - self._max_output_tokens
|
||||
|
||||
return (
|
||||
f"📊 Tokens — input: {self._format_token_count(input_tokens, input_budget)}"
|
||||
f" | output: {self._format_token_count(output_tokens, self._max_output_tokens)}"
|
||||
f" | total: {self._format_token_count(total_tokens, self._max_context_window_tokens)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_token_count(count: int, budget: int | None) -> str:
|
||||
"""Format a token count, optionally showing budget percentage."""
|
||||
if budget and budget > 0:
|
||||
pct = count / budget * 100
|
||||
return f"{count:,}/{budget:,} ({pct:.1f}%)"
|
||||
return f"{count:,}"
|
||||
@@ -0,0 +1,541 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Main Textual application for the harness console.
|
||||
|
||||
This module provides the HarnessApp - the main Textual application that
|
||||
composes all UI components and integrates with the agent runner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from textual import on, work
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.css.query import NoMatches
|
||||
from textual.widgets import Input, Static
|
||||
|
||||
from .app_state import (
|
||||
BottomPanelMode,
|
||||
HarnessAppState,
|
||||
OutputEntryType,
|
||||
)
|
||||
from .components import (
|
||||
AgentModeAndHelp,
|
||||
AgentStatus,
|
||||
HarnessListSelection,
|
||||
HarnessScrollPanel,
|
||||
HarnessTextInput,
|
||||
PromptRule,
|
||||
)
|
||||
from .textual_state_driver import HarnessConsoleUXStateDriver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, AgentSession
|
||||
|
||||
from .agent_runner import HarnessAgentRunner
|
||||
from .commands import CommandHandler
|
||||
from .observers.base import ConsoleObserver
|
||||
|
||||
|
||||
class HarnessApp(App[None]):
|
||||
"""Main Textual application for the harness console.
|
||||
|
||||
Composes the scroll panel (conversation history), status bar (spinner, usage),
|
||||
mode/help display, and bottom panel (text input, list selection, or streaming
|
||||
indicator). Routes user input to the agent runner.
|
||||
"""
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
background: $background;
|
||||
}
|
||||
|
||||
#scroll-panel {
|
||||
height: 1fr;
|
||||
padding: 0 1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#bottom-panel {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#text-input-container {
|
||||
height: 1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#list-selection-container {
|
||||
height: auto;
|
||||
max-height: 12;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#streaming-indicator {
|
||||
height: 1;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#status-bar {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
#mode-help {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
#top-rule {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
#bottom-rule {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
#separator-rule {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
#text-input {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
border: none;
|
||||
padding: 0;
|
||||
min-height: 1;
|
||||
height: 1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.prompt-container {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
.prompt-label {
|
||||
width: 2;
|
||||
min-width: 2;
|
||||
height: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+c", "quit", "Quit", show=False),
|
||||
Binding("ctrl+q", "quit", "Quit", show=False),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: Agent,
|
||||
observers: list[ConsoleObserver],
|
||||
session: AgentSession | None = None,
|
||||
mode_colors: dict[str, str] | None = None,
|
||||
initial_mode: str | None = None,
|
||||
placeholder: str = "Type a message and press Enter...",
|
||||
title: str = "Harness Console",
|
||||
max_context_window_tokens: int | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
command_handlers: list[CommandHandler] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the harness console application.
|
||||
|
||||
Args:
|
||||
agent: The agent to run.
|
||||
observers: List of console observers.
|
||||
session: Optional agent session.
|
||||
mode_colors: Optional mode color mapping.
|
||||
initial_mode: Initial agent mode.
|
||||
placeholder: Input placeholder text.
|
||||
title: Application title.
|
||||
max_context_window_tokens: Optional max context window tokens for usage display.
|
||||
max_output_tokens: Optional max output tokens for usage display.
|
||||
command_handlers: Optional list of command handlers. If None, auto-detected.
|
||||
"""
|
||||
super().__init__()
|
||||
self.title = title
|
||||
self._agent = agent
|
||||
self._observers = observers
|
||||
self._session = session
|
||||
self._mode_colors = mode_colors
|
||||
self._initial_mode = initial_mode
|
||||
self._placeholder = placeholder
|
||||
self._max_context_window_tokens = max_context_window_tokens
|
||||
self._max_output_tokens = max_output_tokens
|
||||
|
||||
# Build command handlers
|
||||
if command_handlers is None:
|
||||
from .commands import build_default_command_handlers
|
||||
|
||||
self._command_handlers = build_default_command_handlers(
|
||||
agent, mode_colors=mode_colors
|
||||
)
|
||||
else:
|
||||
self._command_handlers = command_handlers
|
||||
|
||||
# Compute help text from command handlers
|
||||
help_parts = [
|
||||
h.get_help_text()
|
||||
for h in self._command_handlers
|
||||
if h.get_help_text() is not None
|
||||
]
|
||||
help_text = ", ".join(help_parts) if help_parts else None
|
||||
|
||||
# State and driver
|
||||
self._app_state = HarnessAppState(
|
||||
placeholder=placeholder,
|
||||
mode_text=initial_mode,
|
||||
help_text=help_text,
|
||||
)
|
||||
self._ux_driver = HarnessConsoleUXStateDriver(
|
||||
app_state=self._app_state,
|
||||
on_state_changed=self._on_state_changed,
|
||||
mode_colors=mode_colors,
|
||||
)
|
||||
|
||||
# Agent runner (created after init)
|
||||
self._runner: HarnessAgentRunner | None = None
|
||||
|
||||
@property
|
||||
def ux_driver(self) -> HarnessConsoleUXStateDriver:
|
||||
"""Get the UX state driver."""
|
||||
return self._ux_driver
|
||||
|
||||
@property
|
||||
def runner(self) -> HarnessAgentRunner | None:
|
||||
"""Get the agent runner."""
|
||||
return self._runner
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the application layout."""
|
||||
with Vertical():
|
||||
# Main scroll panel for conversation history
|
||||
yield HarnessScrollPanel(id="scroll-panel")
|
||||
|
||||
# Blank line separating scroll content from status area
|
||||
yield Static(" ", id="separator-rule")
|
||||
|
||||
# Status bar (spinner + usage)
|
||||
yield AgentStatus(id="status-bar")
|
||||
|
||||
# Top rule (mode-colored)
|
||||
yield PromptRule(id="top-rule")
|
||||
|
||||
# Bottom panel - switches between text input, list selection, streaming
|
||||
with Container(id="bottom-panel"):
|
||||
# Text input (default)
|
||||
with Container(id="text-input-container"):
|
||||
text_input = HarnessTextInput(id="text-input")
|
||||
text_input.placeholder = self._placeholder
|
||||
yield text_input
|
||||
|
||||
# List selection (for follow-up questions)
|
||||
with Container(id="list-selection-container"):
|
||||
yield HarnessListSelection(id="list-selection")
|
||||
|
||||
# Bottom rule (mode-colored)
|
||||
yield PromptRule(id="bottom-rule")
|
||||
|
||||
# Mode and help
|
||||
yield AgentModeAndHelp(id="mode-help")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize after mount."""
|
||||
# Create agent runner now that everything is set up
|
||||
from .agent_runner import HarnessAgentRunner
|
||||
|
||||
self._runner = HarnessAgentRunner(
|
||||
agent=self._agent,
|
||||
observers=self._observers,
|
||||
state_driver=self._ux_driver,
|
||||
max_context_window_tokens=self._max_context_window_tokens,
|
||||
max_output_tokens=self._max_output_tokens,
|
||||
)
|
||||
|
||||
# Set initial mode
|
||||
if self._initial_mode:
|
||||
self._ux_driver.current_mode = self._initial_mode
|
||||
|
||||
# Focus the text input
|
||||
try:
|
||||
text_input = self.query_one("#text-input", HarnessTextInput)
|
||||
text_input.focus_input()
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
# Set initial rule colors and mode display
|
||||
self._sync_mode_help()
|
||||
|
||||
# --- Event handlers ---
|
||||
|
||||
@on(HarnessTextInput.Submitted)
|
||||
def on_text_submitted(self, event: HarnessTextInput.Submitted) -> None:
|
||||
"""Handle text input submission."""
|
||||
text = event.value.strip()
|
||||
if not text:
|
||||
return
|
||||
|
||||
if self._app_state.pending_questions:
|
||||
# Answer the current follow-up question
|
||||
self._handle_follow_up_answer(text)
|
||||
elif self._app_state.mode == BottomPanelMode.STREAMING:
|
||||
# Input during streaming (message injection placeholder)
|
||||
pass
|
||||
elif text.startswith("/"):
|
||||
# Try command handlers
|
||||
self._try_command_handlers(text)
|
||||
else:
|
||||
# Normal user input - run agent turn
|
||||
self._run_agent_turn(text)
|
||||
|
||||
@work(exclusive=True, thread=False)
|
||||
async def _try_command_handlers(self, text: str) -> None:
|
||||
"""Try each command handler; fall through to agent if none match."""
|
||||
session = self._session
|
||||
if session is None:
|
||||
# No session — fall through to agent turn
|
||||
self._run_agent_turn(text)
|
||||
return
|
||||
|
||||
for handler in self._command_handlers:
|
||||
if await handler.try_handle(text, session, self._ux_driver):
|
||||
# Command handled — check for shutdown/session swap signals
|
||||
self._process_command_signals()
|
||||
return
|
||||
|
||||
# No handler matched — treat as normal agent input
|
||||
self._run_agent_turn(text)
|
||||
|
||||
def _process_command_signals(self) -> None:
|
||||
"""Check and process signals set by command handlers."""
|
||||
if self._app_state.shutdown_requested:
|
||||
self.exit()
|
||||
return
|
||||
|
||||
if self._app_state.replaced_session is not None:
|
||||
self._session = self._app_state.replaced_session # type: ignore[assignment]
|
||||
self._app_state.replaced_session = None
|
||||
self._ux_driver.append_info_line("Session replaced.")
|
||||
|
||||
self._sync_ui_from_state()
|
||||
|
||||
@on(HarnessListSelection.Selected)
|
||||
def on_list_selected(self, event: HarnessListSelection.Selected) -> None:
|
||||
"""Handle list selection."""
|
||||
self._handle_follow_up_answer(event.value)
|
||||
|
||||
# --- Agent turn ---
|
||||
|
||||
@work(exclusive=True, thread=False)
|
||||
async def _run_agent_turn(self, text: str) -> None:
|
||||
"""Run an agent turn in a background worker."""
|
||||
if self._runner is None:
|
||||
return
|
||||
|
||||
await self._runner.run_turn(text, session=self._session)
|
||||
|
||||
# After turn completes, check for follow-up questions
|
||||
self._sync_ui_from_state()
|
||||
|
||||
# --- Follow-up question handling ---
|
||||
|
||||
@work(exclusive=True, thread=False)
|
||||
async def _handle_follow_up_answer(self, answer: str) -> None:
|
||||
"""Handle a user's answer to a follow-up question."""
|
||||
if not self._app_state.pending_questions:
|
||||
return
|
||||
|
||||
question = self._app_state.pending_questions[0]
|
||||
|
||||
# Call the continuation
|
||||
result_message = await question.continuation(answer, self._ux_driver)
|
||||
|
||||
# Add result to accumulated responses
|
||||
if result_message is not None:
|
||||
self._ux_driver.add_follow_up_response(result_message)
|
||||
|
||||
# Advance to next question
|
||||
self._ux_driver.advance_follow_up_question()
|
||||
|
||||
# If no more questions, resume the agent with accumulated responses
|
||||
if not self._app_state.pending_questions:
|
||||
responses = self._ux_driver.take_follow_up_responses()
|
||||
if responses and self._runner:
|
||||
await self._runner.start_agent_turn(responses, session=self._session)
|
||||
|
||||
self._sync_ui_from_state()
|
||||
|
||||
# --- State synchronization ---
|
||||
|
||||
def _on_state_changed(self) -> None:
|
||||
"""Called by state driver when state changes - schedule UI sync.
|
||||
|
||||
Since the agent runner uses @work(thread=False), state changes happen
|
||||
on the main event loop. We use call_later to batch updates.
|
||||
"""
|
||||
self.call_later(self._sync_ui_from_state)
|
||||
|
||||
def _sync_ui_from_state(self) -> None:
|
||||
"""Synchronize UI components with current application state."""
|
||||
state = self._app_state
|
||||
|
||||
# Update scroll panel with new entries
|
||||
self._sync_scroll_panel()
|
||||
|
||||
# Update bottom panel mode
|
||||
self._sync_bottom_panel(state.mode)
|
||||
|
||||
# Hide status bar and mode/help during list selection (matching C#)
|
||||
is_list_mode = state.mode == BottomPanelMode.LIST_SELECTION
|
||||
self._sync_chrome_visibility(not is_list_mode)
|
||||
|
||||
# Update status bar
|
||||
self._sync_status_bar()
|
||||
|
||||
# Update mode/help display
|
||||
self._sync_mode_help()
|
||||
|
||||
def _sync_scroll_panel(self) -> None:
|
||||
"""Sync the scroll panel with output entries."""
|
||||
try:
|
||||
panel = self.query_one("#scroll-panel", HarnessScrollPanel)
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
entries = self._app_state.output_entries
|
||||
rendered_count = getattr(self, "_rendered_entry_count", 0)
|
||||
|
||||
if rendered_count < len(entries):
|
||||
# There are new entries to render
|
||||
for entry in entries[rendered_count:]:
|
||||
if entry.type == OutputEntryType.STREAMING_TEXT:
|
||||
panel.set_streaming_entry(entry)
|
||||
else:
|
||||
# End any active streaming before appending other entry types
|
||||
panel.end_streaming()
|
||||
panel.append_entry(entry)
|
||||
self._rendered_entry_count = len(entries)
|
||||
elif rendered_count == len(entries) and entries:
|
||||
# Same count — check if the last entry is a streaming entry that was mutated
|
||||
last_entry = entries[-1]
|
||||
if last_entry.type == OutputEntryType.STREAMING_TEXT:
|
||||
panel.set_streaming_entry(last_entry)
|
||||
|
||||
def _sync_bottom_panel(self, mode: BottomPanelMode) -> None:
|
||||
"""Switch the bottom panel between text input, list, and streaming."""
|
||||
try:
|
||||
text_container = self.query_one("#text-input-container")
|
||||
list_container = self.query_one("#list-selection-container")
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
if mode == BottomPanelMode.TEXT_INPUT:
|
||||
text_container.display = True
|
||||
list_container.display = False
|
||||
# Restore focus to text input
|
||||
try:
|
||||
text_input = self.query_one("#text-input", HarnessTextInput)
|
||||
text_input.focus_input()
|
||||
except NoMatches:
|
||||
pass
|
||||
elif mode == BottomPanelMode.LIST_SELECTION:
|
||||
text_container.display = False
|
||||
list_container.display = True
|
||||
self._sync_list_selection()
|
||||
elif mode == BottomPanelMode.STREAMING:
|
||||
text_container.display = True
|
||||
list_container.display = False
|
||||
|
||||
def _sync_list_selection(self) -> None:
|
||||
"""Sync the list selection widget with state."""
|
||||
try:
|
||||
list_widget = self.query_one("#list-selection", HarnessListSelection)
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
state = self._app_state
|
||||
list_widget.title = state.list_selection_title or ""
|
||||
list_widget.options = list(state.list_selection_options)
|
||||
list_widget.allow_custom_text = state.list_selection_custom_text_placeholder is not None
|
||||
|
||||
if state.list_selection_custom_text_placeholder:
|
||||
try:
|
||||
custom_input = list_widget.query_one("#custom-input", Input)
|
||||
custom_input.placeholder = state.list_selection_custom_text_placeholder
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Focus the option list so keyboard navigation works immediately
|
||||
list_widget.focus_list()
|
||||
|
||||
def _sync_status_bar(self) -> None:
|
||||
"""Sync the status bar with state."""
|
||||
try:
|
||||
status = self.query_one("#status-bar", AgentStatus)
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
state = self._app_state
|
||||
status.show_spinner = state.show_spinner
|
||||
status.usage_text = state.usage_text or ""
|
||||
|
||||
def _sync_mode_help(self) -> None:
|
||||
"""Sync the mode/help display and rule colors with state."""
|
||||
try:
|
||||
mode_help = self.query_one("#mode-help", AgentModeAndHelp)
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
state = self._app_state
|
||||
mode_help.mode = state.mode_text or ""
|
||||
mode_help.mode_color = state.mode_color or "blue"
|
||||
mode_help.help_text = state.help_text or ""
|
||||
|
||||
# Sync rule colors to match mode
|
||||
color = state.mode_color or "cyan"
|
||||
try:
|
||||
top_rule = self.query_one("#top-rule", PromptRule)
|
||||
top_rule.rule_color = color
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
try:
|
||||
bottom_rule = self.query_one("#bottom-rule", PromptRule)
|
||||
bottom_rule.rule_color = color
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def _sync_chrome_visibility(self, visible: bool) -> None:
|
||||
"""Show or hide chrome elements (status bar, mode/help).
|
||||
|
||||
During list selection mode, these are hidden to give more vertical
|
||||
space to the scroll panel and list picker.
|
||||
|
||||
Args:
|
||||
visible: Whether chrome elements should be visible.
|
||||
"""
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(NoMatches):
|
||||
self.query_one("#status-bar", AgentStatus).display = visible
|
||||
with contextlib.suppress(NoMatches):
|
||||
self.query_one("#mode-help", AgentModeAndHelp).display = visible
|
||||
|
||||
# --- Rendering count tracking ---
|
||||
|
||||
_rendered_entry_count: int = 0
|
||||
@@ -0,0 +1,260 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Application state and core data types for the harness console.
|
||||
|
||||
This module defines enums, dataclasses, follow-up action types, and the
|
||||
HarnessAppState dataclass which holds all UI state that may change during
|
||||
application execution. The state driver mutates this state to coordinate
|
||||
between the agent runner and the Textual UI components.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Message
|
||||
|
||||
from .state_driver import IUXStateDriver
|
||||
|
||||
|
||||
# region Enums
|
||||
|
||||
|
||||
class OutputEntryType(Enum):
|
||||
"""Type of output entry in the console conversation."""
|
||||
|
||||
USER_INPUT = "user_input"
|
||||
"""User input echo (e.g., 'You: hello')."""
|
||||
|
||||
STREAMING_TEXT = "streaming_text"
|
||||
"""In-progress streaming text from the agent (accumulated chunk by chunk)."""
|
||||
|
||||
INFO_LINE = "info_line"
|
||||
"""Informational line (tool calls, errors, usage, approval requests, etc.)."""
|
||||
|
||||
STREAM_FOOTER = "stream_footer"
|
||||
"""Stream footer (e.g., '(no text response from agent)')."""
|
||||
|
||||
PENDING_MESSAGE = "pending_message"
|
||||
"""Pending injected message notification."""
|
||||
|
||||
|
||||
class BottomPanelMode(Enum):
|
||||
"""Mode of the bottom panel UI."""
|
||||
|
||||
TEXT_INPUT = "text_input"
|
||||
"""Show text input for user messages."""
|
||||
|
||||
LIST_SELECTION = "list_selection"
|
||||
"""Show choice list for user selection."""
|
||||
|
||||
STREAMING = "streaming"
|
||||
"""Show 'streaming...' indicator while agent is generating."""
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Output Entry
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputEntry:
|
||||
"""A single output entry in the console conversation history.
|
||||
|
||||
Used internally by the state driver to track conversation output,
|
||||
including streaming text, tool calls, errors, and user input echoes.
|
||||
|
||||
Args:
|
||||
type: The type of output entry.
|
||||
text: The text content of the entry.
|
||||
color: Optional Rich color string (e.g., "cyan", "red", "dim").
|
||||
"""
|
||||
|
||||
type: OutputEntryType
|
||||
text: str
|
||||
color: str | None = None
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Follow-Up Actions
|
||||
|
||||
|
||||
class FollowUpAction:
|
||||
"""Base class for follow-up actions returned by observers.
|
||||
|
||||
Follow-up actions describe either a question to ask the user
|
||||
(via FollowUpQuestion subclasses) or a message to add directly
|
||||
to the next agent input (FollowUpMessage).
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class FollowUpQuestion(FollowUpAction):
|
||||
"""A question to ask the user with a continuation.
|
||||
|
||||
The continuation delegate is invoked with the user's answer and the
|
||||
UX state driver, and returns an optional Message to add to the next
|
||||
agent invocation.
|
||||
|
||||
Args:
|
||||
prompt: The question text shown to the user.
|
||||
continuation: Async function invoked with the user's answer and state driver.
|
||||
Returns an optional Message to add to the next agent input.
|
||||
"""
|
||||
|
||||
prompt: str
|
||||
continuation: Callable[[str, IUXStateDriver], Awaitable[Message | None]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextFollowUpQuestion(FollowUpQuestion):
|
||||
"""A free-form text question.
|
||||
|
||||
The user may type any response. This is the base FollowUpQuestion type
|
||||
with no additional constraints.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChoiceFollowUpQuestion(FollowUpQuestion):
|
||||
"""A multiple choice question.
|
||||
|
||||
The user picks from the provided choices, with an optional ability to
|
||||
enter custom text when allow_custom_text is True.
|
||||
|
||||
Args:
|
||||
prompt: The question text shown to the user.
|
||||
choices: List of pre-defined choices.
|
||||
allow_custom_text: If True, the user may type a custom response in
|
||||
addition to the listed choices.
|
||||
continuation: Async function invoked with the user's choice/text and
|
||||
state driver. Returns an optional Message to add to the next agent input.
|
||||
"""
|
||||
|
||||
choices: list[str]
|
||||
allow_custom_text: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class FollowUpMessage(FollowUpAction):
|
||||
"""A message to add directly to the next agent invocation without prompting.
|
||||
|
||||
Used when an observer wants to inject a message into the conversation
|
||||
without user interaction (e.g., automatic tool results, system messages).
|
||||
|
||||
Args:
|
||||
message: The Message to add to the conversation.
|
||||
"""
|
||||
|
||||
message: Message
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Application State
|
||||
|
||||
|
||||
@dataclass
|
||||
class HarnessAppState:
|
||||
"""All UI state for the harness console application.
|
||||
|
||||
This state is mutated by the UX state driver and read by the Textual
|
||||
app to update the UI.
|
||||
"""
|
||||
|
||||
# --- Bottom panel mode ---
|
||||
|
||||
mode: BottomPanelMode = BottomPanelMode.TEXT_INPUT
|
||||
"""Which component is shown in the bottom panel."""
|
||||
|
||||
# --- Follow-up question queue ---
|
||||
|
||||
pending_questions: list[FollowUpQuestion] = field(default_factory=list)
|
||||
"""Queue of follow-up questions waiting for user answers.
|
||||
|
||||
The head ([0]) is the question currently being displayed; subsequent items
|
||||
are dispatched in order as each is answered.
|
||||
"""
|
||||
|
||||
accumulated_follow_up_responses: list[Message] = field(default_factory=list)
|
||||
"""Accumulated follow-up response messages collected during the current agent turn.
|
||||
|
||||
Both direct FollowUpMessages emitted by observers and continuation results
|
||||
from answered questions. Consumed by the runner via take_follow_up_responses().
|
||||
"""
|
||||
|
||||
# --- Text input (active in TextInput / Streaming modes) ---
|
||||
|
||||
prompt: str = "> "
|
||||
"""The prompt string for text input mode."""
|
||||
|
||||
placeholder: str = ""
|
||||
"""Placeholder text shown when the input is empty."""
|
||||
|
||||
input_text: str = ""
|
||||
"""The current input text being typed."""
|
||||
|
||||
input_enabled: bool = True
|
||||
"""Whether input is enabled (disabled during streaming without injection)."""
|
||||
|
||||
streaming_prompt: str = "(agent is running...)"
|
||||
"""The prompt to show during streaming when input is disabled."""
|
||||
|
||||
# --- List selection (active in ListSelection mode) ---
|
||||
|
||||
list_selection_title: str | None = None
|
||||
"""Title text displayed above the list selection."""
|
||||
|
||||
list_selection_options: list[str] = field(default_factory=list)
|
||||
"""The list selection options."""
|
||||
|
||||
list_selection_index: int = 0
|
||||
"""The highlighted option index in list selection mode."""
|
||||
|
||||
list_selection_custom_text_placeholder: str | None = None
|
||||
"""Placeholder text for the custom text input option in the list."""
|
||||
|
||||
list_selection_custom_input_text: str = ""
|
||||
"""Current text being typed into the list's custom text option."""
|
||||
|
||||
# --- Scroll / output area ---
|
||||
|
||||
output_entries: list[OutputEntry] = field(default_factory=list)
|
||||
"""Output entries in the scroll area conversation history."""
|
||||
|
||||
queued_items: list[str] = field(default_factory=list)
|
||||
"""Queued input items to display (pending injected messages)."""
|
||||
|
||||
# --- Agent mode + status display ---
|
||||
|
||||
mode_color: str | None = None
|
||||
"""Rich color string for the rule borders and mode label."""
|
||||
|
||||
mode_text: str | None = None
|
||||
"""Current mode name displayed (e.g., 'plan', 'execute')."""
|
||||
|
||||
help_text: str | None = None
|
||||
"""Help text displayed below the bottom rule (available commands)."""
|
||||
|
||||
show_spinner: bool = False
|
||||
"""Whether the agent status spinner is visible."""
|
||||
|
||||
usage_text: str | None = None
|
||||
"""Formatted token usage text to display in the status bar."""
|
||||
|
||||
# --- Command handler signals ---
|
||||
|
||||
shutdown_requested: bool = False
|
||||
"""Set to True when /exit is invoked; the app should exit."""
|
||||
|
||||
replaced_session: object | None = None
|
||||
"""When set, the app should swap its session to this AgentSession."""
|
||||
@@ -0,0 +1,65 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Command handler package for the harness console.
|
||||
|
||||
Provides slash-command handling (e.g., /exit, /mode, /todos, /session-export)
|
||||
that intercepts user input before it reaches the agent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import CommandHandler
|
||||
from .exit_handler import ExitCommandHandler
|
||||
from .mode_handler import ModeCommandHandler
|
||||
from .session_handler import SessionCommandHandler
|
||||
from .todo_handler import TodoCommandHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent
|
||||
|
||||
__all__ = [
|
||||
"CommandHandler",
|
||||
"ExitCommandHandler",
|
||||
"ModeCommandHandler",
|
||||
"SessionCommandHandler",
|
||||
"TodoCommandHandler",
|
||||
"build_default_command_handlers",
|
||||
]
|
||||
|
||||
|
||||
def build_default_command_handlers(
|
||||
agent: Agent,
|
||||
*,
|
||||
mode_colors: dict[str, str] | None = None,
|
||||
) -> list[CommandHandler]:
|
||||
"""Build the default set of command handlers by inspecting the agent.
|
||||
|
||||
Auto-detects TodoProvider and AgentModeProvider from the agent's
|
||||
context_providers list.
|
||||
|
||||
Args:
|
||||
agent: The agent to inspect for providers.
|
||||
mode_colors: Optional mapping of mode names to Rich color strings.
|
||||
|
||||
Returns:
|
||||
List of command handlers in evaluation order.
|
||||
"""
|
||||
from agent_framework import AgentModeProvider, TodoProvider
|
||||
|
||||
todo_provider: TodoProvider | None = None
|
||||
mode_provider: AgentModeProvider | None = None
|
||||
|
||||
for provider in getattr(agent, "context_providers", []):
|
||||
if isinstance(provider, TodoProvider) and todo_provider is None:
|
||||
todo_provider = provider
|
||||
elif isinstance(provider, AgentModeProvider) and mode_provider is None:
|
||||
mode_provider = provider
|
||||
|
||||
return [
|
||||
ExitCommandHandler(),
|
||||
TodoCommandHandler(todo_provider),
|
||||
ModeCommandHandler(mode_provider, mode_colors),
|
||||
SessionCommandHandler(),
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Abstract base class for console command handlers.
|
||||
|
||||
Command handlers intercept user input starting with '/' and execute
|
||||
local commands before input reaches the agent. They are checked in order;
|
||||
the first handler that accepts the input prevents further handlers from
|
||||
being checked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import AgentSession
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class CommandHandler(ABC):
|
||||
"""Base class for console command handlers.
|
||||
|
||||
Subclasses implement get_help_text() for the mode bar and
|
||||
try_handle() to intercept matching commands.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_help_text(self) -> str | None:
|
||||
"""Get the help text for this command.
|
||||
|
||||
Displayed in the mode-and-help bar. Return None if the
|
||||
command is not currently available.
|
||||
|
||||
Returns:
|
||||
Help text like '/todos (show todo list)', or None.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def try_handle(
|
||||
self,
|
||||
user_input: str,
|
||||
session: AgentSession,
|
||||
ux: IUXStateDriver,
|
||||
) -> bool:
|
||||
"""Attempt to handle the given user input.
|
||||
|
||||
Args:
|
||||
user_input: The raw user input string.
|
||||
session: The current agent session.
|
||||
ux: The UX state driver for rendering output.
|
||||
|
||||
Returns:
|
||||
True if this handler handled the input; False otherwise.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,35 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Exit command handler — /exit to quit the console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import CommandHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import AgentSession
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class ExitCommandHandler(CommandHandler):
|
||||
"""Handle the /exit command to shut down the console application."""
|
||||
|
||||
def get_help_text(self) -> str | None:
|
||||
"""Return help text for the exit command."""
|
||||
return "/exit (quit)"
|
||||
|
||||
async def try_handle(
|
||||
self,
|
||||
user_input: str,
|
||||
session: AgentSession,
|
||||
ux: IUXStateDriver,
|
||||
) -> bool:
|
||||
"""Handle /exit by requesting shutdown."""
|
||||
if user_input.strip().lower() != "/exit":
|
||||
return False
|
||||
|
||||
ux.request_shutdown()
|
||||
return True
|
||||
@@ -0,0 +1,81 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Mode command handler — /mode to show or switch agent mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import CommandHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import AgentModeProvider, AgentSession
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class ModeCommandHandler(CommandHandler):
|
||||
"""Handle the /mode command to display or switch the current agent mode."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mode_provider: AgentModeProvider | None,
|
||||
mode_colors: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize with mode provider and color mapping.
|
||||
|
||||
Args:
|
||||
mode_provider: The mode provider, or None if not available.
|
||||
mode_colors: Optional mapping of mode names to Rich color strings.
|
||||
"""
|
||||
self._mode_provider = mode_provider
|
||||
self._mode_colors = mode_colors or {}
|
||||
|
||||
def get_help_text(self) -> str | None:
|
||||
"""Return help text, or None if mode provider is unavailable."""
|
||||
if self._mode_provider is None:
|
||||
return None
|
||||
return "/mode [plan|execute] (show or switch mode)"
|
||||
|
||||
async def try_handle(
|
||||
self,
|
||||
user_input: str,
|
||||
session: AgentSession,
|
||||
ux: IUXStateDriver,
|
||||
) -> bool:
|
||||
"""Handle /mode [name] command."""
|
||||
stripped = user_input.strip()
|
||||
lower = stripped.lower()
|
||||
|
||||
if not (lower == "/mode" or lower.startswith("/mode ")):
|
||||
return False
|
||||
|
||||
if self._mode_provider is None:
|
||||
ux.append_info_line("AgentModeProvider is not available.")
|
||||
return True
|
||||
|
||||
parts = stripped.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
# Show current mode
|
||||
from agent_framework import get_agent_mode
|
||||
|
||||
current = get_agent_mode(session)
|
||||
ux.append_info_line(f"Current mode: {current}")
|
||||
return True
|
||||
|
||||
# Switch mode
|
||||
new_mode = parts[1].strip()
|
||||
try:
|
||||
from agent_framework import set_agent_mode
|
||||
|
||||
normalized = set_agent_mode(session, new_mode)
|
||||
color = self._mode_colors.get(normalized)
|
||||
ux.set_mode(normalized, color)
|
||||
ux.append_info_line(
|
||||
f"Switched to {normalized} mode.",
|
||||
color=color,
|
||||
)
|
||||
except ValueError as ex:
|
||||
ux.append_info_line(str(ex), color="red")
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,107 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Session command handler — /session-export and /session-import."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import CommandHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import AgentSession
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class SessionCommandHandler(CommandHandler):
|
||||
"""Handle /session-export and /session-import commands."""
|
||||
|
||||
def get_help_text(self) -> str | None:
|
||||
"""Return help text for session commands."""
|
||||
return "/session-export <file> | /session-import <file>"
|
||||
|
||||
async def try_handle(
|
||||
self,
|
||||
user_input: str,
|
||||
session: AgentSession,
|
||||
ux: IUXStateDriver,
|
||||
) -> bool:
|
||||
"""Handle session export/import commands."""
|
||||
stripped = user_input.strip()
|
||||
command = stripped.split(None, 1)[0].lower() if stripped else ""
|
||||
|
||||
if command == "/session-export":
|
||||
await self._handle_export(stripped, session, ux)
|
||||
return True
|
||||
|
||||
if command == "/session-import":
|
||||
await self._handle_import(stripped, ux)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def _handle_export(
|
||||
self,
|
||||
user_input: str,
|
||||
session: AgentSession,
|
||||
ux: IUXStateDriver,
|
||||
) -> None:
|
||||
"""Export the current session to a JSON file."""
|
||||
parts = user_input.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
ux.append_info_line("Usage: /session-export <filename>")
|
||||
return
|
||||
|
||||
filename = parts[1].strip()
|
||||
try:
|
||||
serialized = session.to_dict()
|
||||
json_str = json.dumps(serialized, indent=2)
|
||||
self._write_file(filename, json_str)
|
||||
ux.append_info_line(f"Session exported to {filename}")
|
||||
except Exception as ex:
|
||||
ux.append_info_line(
|
||||
f"Failed to export session to {filename}: {ex}",
|
||||
color="red",
|
||||
)
|
||||
|
||||
async def _handle_import(
|
||||
self,
|
||||
user_input: str,
|
||||
ux: IUXStateDriver,
|
||||
) -> None:
|
||||
"""Import a session from a JSON file."""
|
||||
parts = user_input.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
ux.append_info_line("Usage: /session-import <filename>")
|
||||
return
|
||||
|
||||
filename = parts[1].strip()
|
||||
try:
|
||||
from agent_framework import AgentSession
|
||||
|
||||
json_str = self._read_file(filename)
|
||||
data = json.loads(json_str)
|
||||
new_session = AgentSession.from_dict(data)
|
||||
ux.replace_session(new_session)
|
||||
ux.append_info_line(f"Session imported from {filename}")
|
||||
except FileNotFoundError:
|
||||
ux.append_info_line(f"File not found: {filename}", color="red")
|
||||
except Exception as ex:
|
||||
ux.append_info_line(
|
||||
f"Failed to import session from {filename}: {ex}",
|
||||
color="red",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _write_file(filename: str, content: str) -> None:
|
||||
"""Write content to a file (sync helper to satisfy ASYNC230)."""
|
||||
with open(filename, "w", encoding="utf-8") as f: # noqa: ASYNC230
|
||||
f.write(content)
|
||||
|
||||
@staticmethod
|
||||
def _read_file(filename: str) -> str:
|
||||
"""Read content from a file (sync helper to satisfy ASYNC230)."""
|
||||
with open(filename, encoding="utf-8") as f: # noqa: ASYNC230
|
||||
return f.read()
|
||||
@@ -0,0 +1,66 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Todo command handler — /todos to display the todo list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import CommandHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import AgentSession, TodoProvider
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class TodoCommandHandler(CommandHandler):
|
||||
"""Handle the /todos command to display the current todo list."""
|
||||
|
||||
def __init__(self, todo_provider: TodoProvider | None) -> None:
|
||||
"""Initialize with the todo provider.
|
||||
|
||||
Args:
|
||||
todo_provider: The todo provider, or None if not available.
|
||||
"""
|
||||
self._todo_provider = todo_provider
|
||||
|
||||
def get_help_text(self) -> str | None:
|
||||
"""Return help text, or None if todo provider is unavailable."""
|
||||
if self._todo_provider is None:
|
||||
return None
|
||||
return "/todos (show todo list)"
|
||||
|
||||
async def try_handle(
|
||||
self,
|
||||
user_input: str,
|
||||
session: AgentSession,
|
||||
ux: IUXStateDriver,
|
||||
) -> bool:
|
||||
"""Handle /todos by displaying the todo list."""
|
||||
if user_input.strip().lower() != "/todos":
|
||||
return False
|
||||
|
||||
if self._todo_provider is None:
|
||||
ux.append_info_line("TodoProvider is not available.")
|
||||
return True
|
||||
|
||||
todos = await self._todo_provider.store.load_items(
|
||||
session, source_id=self._todo_provider.source_id
|
||||
)
|
||||
|
||||
if not todos:
|
||||
ux.append_info_line("No todos yet.")
|
||||
return True
|
||||
|
||||
ux.append_info_line("── Todo List ──")
|
||||
for item in todos:
|
||||
status = "✓" if item.is_complete else "○"
|
||||
color = "dim" if item.is_complete else None
|
||||
description = f" — {item.description}" if item.description else ""
|
||||
ux.append_info_line(
|
||||
f"[{status}] #{item.id} {item.title}{description}",
|
||||
color=color,
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""UI components for the harness console.
|
||||
|
||||
This module provides Textual widgets for building the harness console UI,
|
||||
including status displays, input fields, choice selectors, and scrolling panels.
|
||||
"""
|
||||
|
||||
from .agent_status import AgentStatus
|
||||
from .list_selection import HarnessListSelection
|
||||
from .mode_help import AgentModeAndHelp
|
||||
from .prompt_rule import PromptRule
|
||||
from .scroll_panel import HarnessScrollPanel
|
||||
from .text_input import HarnessTextInput
|
||||
|
||||
__all__ = [
|
||||
"AgentStatus",
|
||||
"AgentModeAndHelp",
|
||||
"HarnessListSelection",
|
||||
"PromptRule",
|
||||
"HarnessScrollPanel",
|
||||
"HarnessTextInput",
|
||||
]
|
||||
@@ -0,0 +1,66 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent status widget with spinner animation and usage statistics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class AgentStatus(Static):
|
||||
"""Agent status bar with animated spinner and token usage display.
|
||||
|
||||
Displays an animated braille pattern spinner when the agent is active,
|
||||
along with token usage statistics. The component automatically updates
|
||||
the spinner animation at ~10fps for smooth visual feedback.
|
||||
|
||||
Attributes:
|
||||
show_spinner: Whether to display the animated spinner.
|
||||
usage_text: Token usage text to display (e.g., "1.2K in / 856 out").
|
||||
"""
|
||||
|
||||
# Braille pattern spinner frames for smooth animation
|
||||
SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
|
||||
show_spinner: reactive[bool] = reactive(False)
|
||||
usage_text: reactive[str] = reactive("")
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
"""Initialize the agent status widget."""
|
||||
super().__init__(**kwargs)
|
||||
self._spinner_index = 0
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Start the spinner animation timer when the widget is mounted."""
|
||||
# Update spinner at ~10fps (every 0.1 seconds)
|
||||
self.set_interval(0.1, self._advance_spinner)
|
||||
|
||||
def _advance_spinner(self) -> None:
|
||||
"""Advance the spinner to the next frame."""
|
||||
if self.show_spinner:
|
||||
self._spinner_index = (self._spinner_index + 1) % len(self.SPINNER_FRAMES)
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> str:
|
||||
"""Render the status bar with spinner and usage text.
|
||||
|
||||
Returns:
|
||||
Formatted string with Rich markup for spinner and usage display.
|
||||
"""
|
||||
if not self.show_spinner and not self.usage_text:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
|
||||
if self.show_spinner:
|
||||
frame = self.SPINNER_FRAMES[self._spinner_index]
|
||||
parts.append(f"[cyan]{frame}[/cyan]")
|
||||
else:
|
||||
# Keep consistent spacing when spinner is off
|
||||
parts.append(" ")
|
||||
|
||||
if self.usage_text:
|
||||
parts.append(f"[dim]{self.usage_text}[/dim]")
|
||||
|
||||
return " ".join(parts)
|
||||
@@ -0,0 +1,269 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""List selection widget with optional custom text input."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container
|
||||
from textual.css.query import NoMatches
|
||||
from textual.events import Key
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Input, Label, OptionList
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
|
||||
class HarnessListSelection(Widget):
|
||||
"""List selection widget with numbered choices and optional custom text input.
|
||||
|
||||
Displays a title, a list of numbered choices that can be selected via
|
||||
keyboard navigation or number keys (1-9), and an optional custom text
|
||||
input field at the bottom.
|
||||
|
||||
All child nodes (title label, option list, custom input) are always
|
||||
present in the DOM; visibility is toggled via reactive watchers.
|
||||
|
||||
Navigation:
|
||||
- Down arrow on last list item moves focus to the custom text input
|
||||
- Up arrow on the custom text input moves focus back to the option list
|
||||
- When custom input has focus, the option list highlight is cleared
|
||||
|
||||
Attributes:
|
||||
title: The title text displayed above the options.
|
||||
options: List of option strings to display.
|
||||
allow_custom_text: Whether to show a custom text input field.
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
HarnessListSelection {
|
||||
height: auto;
|
||||
max-height: 12;
|
||||
}
|
||||
|
||||
HarnessListSelection .list-selection-container {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
HarnessListSelection #selection-title {
|
||||
height: auto;
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
padding: 0 0 0 0;
|
||||
}
|
||||
|
||||
HarnessListSelection #option-list {
|
||||
height: auto;
|
||||
max-height: 8;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
HarnessListSelection #custom-input {
|
||||
height: auto;
|
||||
min-height: 1;
|
||||
margin-top: 0;
|
||||
border: tall transparent;
|
||||
}
|
||||
|
||||
HarnessListSelection #custom-input:focus {
|
||||
border: tall $accent;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("1", "select_option(0)", "Select option 1", show=False),
|
||||
Binding("2", "select_option(1)", "Select option 2", show=False),
|
||||
Binding("3", "select_option(2)", "Select option 3", show=False),
|
||||
Binding("4", "select_option(3)", "Select option 4", show=False),
|
||||
Binding("5", "select_option(4)", "Select option 5", show=False),
|
||||
Binding("6", "select_option(5)", "Select option 6", show=False),
|
||||
Binding("7", "select_option(6)", "Select option 7", show=False),
|
||||
Binding("8", "select_option(7)", "Select option 8", show=False),
|
||||
Binding("9", "select_option(8)", "Select option 9", show=False),
|
||||
]
|
||||
|
||||
title: reactive[str] = reactive("")
|
||||
options: reactive[list[str]] = reactive(list, always_update=True)
|
||||
allow_custom_text: reactive[bool] = reactive(False)
|
||||
|
||||
class Selected(Message):
|
||||
"""Message sent when an option is selected.
|
||||
|
||||
Attributes:
|
||||
value: The selected option text or custom text.
|
||||
"""
|
||||
|
||||
def __init__(self, value: str) -> None:
|
||||
"""Initialize the Selected message.
|
||||
|
||||
Args:
|
||||
value: The selected option text or custom text.
|
||||
"""
|
||||
self.value = value
|
||||
super().__init__()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the widget — all nodes are always present.
|
||||
|
||||
Yields:
|
||||
Title label (hidden if empty), option list, custom input (hidden by default).
|
||||
"""
|
||||
with Container(classes="list-selection-container"):
|
||||
yield Label("", id="selection-title")
|
||||
yield OptionList(id="option-list")
|
||||
yield Input(
|
||||
placeholder="Or type a custom response...",
|
||||
id="custom-input",
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Configure initial visibility after mount."""
|
||||
title_label = self.query_one("#selection-title", Label)
|
||||
title_label.display = bool(self.title)
|
||||
|
||||
custom_input = self.query_one("#custom-input", Input)
|
||||
custom_input.display = self.allow_custom_text
|
||||
|
||||
self._update_options()
|
||||
|
||||
def on_key(self, event: Key) -> None:
|
||||
"""Handle key navigation between option list and custom input.
|
||||
|
||||
Args:
|
||||
event: The key event.
|
||||
"""
|
||||
if not self.allow_custom_text:
|
||||
return
|
||||
|
||||
option_list = self.query_one("#option-list", OptionList)
|
||||
custom_input = self.query_one("#custom-input", Input)
|
||||
|
||||
# Down arrow on last item → move to custom input
|
||||
if event.key == "down" and option_list.has_focus:
|
||||
last_index = option_list.option_count - 1
|
||||
if last_index >= 0 and option_list.highlighted == last_index:
|
||||
option_list.highlighted = None # type: ignore[assignment]
|
||||
custom_input.focus()
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
||||
# Up arrow on custom input → move back to option list (last item)
|
||||
elif event.key == "up" and custom_input.has_focus:
|
||||
last_index = option_list.option_count - 1
|
||||
if last_index >= 0:
|
||||
option_list.highlighted = last_index
|
||||
option_list.focus()
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
||||
@on(Input.Changed, "#custom-input")
|
||||
def on_custom_input_focused_or_changed(self, event: Input.Changed) -> None:
|
||||
"""Clear option list highlight when user is typing in custom input.
|
||||
|
||||
Args:
|
||||
event: The input changed event.
|
||||
"""
|
||||
option_list = self.query_one("#option-list", OptionList)
|
||||
option_list.highlighted = None # type: ignore[assignment]
|
||||
|
||||
def watch_title(self, new_title: str) -> None:
|
||||
"""Update the title label when the title changes.
|
||||
|
||||
Args:
|
||||
new_title: The new title text.
|
||||
"""
|
||||
try:
|
||||
label = self.query_one("#selection-title", Label)
|
||||
label.update(new_title)
|
||||
label.display = bool(new_title)
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def watch_options(self, new_options: list[str]) -> None:
|
||||
"""Update the option list when options change.
|
||||
|
||||
Args:
|
||||
new_options: The new list of options.
|
||||
"""
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(NoMatches):
|
||||
self._update_options()
|
||||
|
||||
def watch_allow_custom_text(self, allow: bool) -> None:
|
||||
"""Show/hide the custom input field.
|
||||
|
||||
Args:
|
||||
allow: Whether to show the custom text input.
|
||||
"""
|
||||
try:
|
||||
custom_input = self.query_one("#custom-input", Input)
|
||||
custom_input.display = allow
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def _update_options(self) -> None:
|
||||
"""Update the OptionList with numbered options."""
|
||||
try:
|
||||
option_list = self.query_one("#option-list", OptionList)
|
||||
option_list.clear_options()
|
||||
|
||||
for i, option_text in enumerate(self.options):
|
||||
display_text = f"{i + 1}. {option_text}" if i < 9 else f" {option_text}"
|
||||
option_list.add_option(Option(display_text, id=str(i)))
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
@on(OptionList.OptionSelected)
|
||||
def on_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
"""Handle option selection from the list.
|
||||
|
||||
Args:
|
||||
event: The OptionList.OptionSelected event.
|
||||
"""
|
||||
option_index = int(event.option.id or "0")
|
||||
if 0 <= option_index < len(self.options):
|
||||
selected_value = self.options[option_index]
|
||||
self.post_message(self.Selected(selected_value))
|
||||
|
||||
@on(Input.Submitted)
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""Handle custom text input submission.
|
||||
|
||||
Args:
|
||||
event: The Input.Submitted event.
|
||||
"""
|
||||
if self.allow_custom_text and event.value:
|
||||
self.post_message(self.Selected(event.value))
|
||||
event.input.clear()
|
||||
|
||||
def action_select_option(self, index: int) -> None:
|
||||
"""Select an option by index (0-based).
|
||||
|
||||
Args:
|
||||
index: The option index to select.
|
||||
"""
|
||||
if 0 <= index < len(self.options):
|
||||
selected_value = self.options[index]
|
||||
self.post_message(self.Selected(selected_value))
|
||||
|
||||
def focus_list(self) -> None:
|
||||
"""Focus the option list."""
|
||||
try:
|
||||
option_list = self.query_one("#option-list", OptionList)
|
||||
option_list.focus()
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def focus_custom_input(self) -> None:
|
||||
"""Focus the custom text input field."""
|
||||
if self.allow_custom_text:
|
||||
try:
|
||||
custom_input = self.query_one("#custom-input", Input)
|
||||
custom_input.focus()
|
||||
except NoMatches:
|
||||
pass
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent mode and help text display widget."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.text import Text
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class AgentModeAndHelp(Static):
|
||||
"""Widget displaying the current agent mode and help text.
|
||||
|
||||
Shows the current agent mode (e.g., "plan", "execute") in a colored label,
|
||||
followed by available commands and help text in a dimmed style. Used in
|
||||
the fixed bottom area of the console.
|
||||
|
||||
Attributes:
|
||||
mode: Current mode name (e.g., "plan", "execute"), or None if no mode.
|
||||
mode_color: Rich color string for the mode label (e.g., "yellow", "green").
|
||||
help_text: Help text to display (e.g., "/exit to quit, /mode to switch").
|
||||
"""
|
||||
|
||||
mode: reactive[str | None] = reactive(None)
|
||||
mode_color: reactive[str] = reactive("yellow")
|
||||
help_text: reactive[str] = reactive("")
|
||||
|
||||
def render(self) -> Text:
|
||||
"""Render the mode indicator and help text.
|
||||
|
||||
Returns:
|
||||
Rich Text object with styled mode and help display.
|
||||
"""
|
||||
result = Text()
|
||||
|
||||
if self.mode:
|
||||
result.append(f"[{self.mode}]", style=self.mode_color)
|
||||
|
||||
if self.help_text:
|
||||
if self.mode:
|
||||
result.append(" ")
|
||||
result.append(self.help_text, style="dim")
|
||||
|
||||
if not result.plain:
|
||||
result.append(" ")
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,31 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Mode-colored horizontal rule."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class PromptRule(Static):
|
||||
"""A full-width horizontal rule colored by the current agent mode.
|
||||
|
||||
Renders a line of '─' characters across the terminal width,
|
||||
colored to match the current mode (e.g., cyan for plan, green for execute).
|
||||
|
||||
Attributes:
|
||||
rule_color: Rich color string for the rule (e.g., "cyan", "green").
|
||||
"""
|
||||
|
||||
rule_color: reactive[str] = reactive("cyan")
|
||||
|
||||
def render(self) -> str:
|
||||
"""Render the horizontal rule.
|
||||
|
||||
Returns:
|
||||
Formatted string with Rich markup.
|
||||
"""
|
||||
color = self.rule_color
|
||||
width = self.size.width or 80
|
||||
return f"[{color}]{'─' * width}[/{color}]"
|
||||
@@ -0,0 +1,127 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Scrolling panel for conversation history display."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from textual.widgets import RichLog
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app_state import OutputEntry
|
||||
|
||||
|
||||
class HarnessScrollPanel(RichLog):
|
||||
"""Scrolling panel for displaying conversation history.
|
||||
|
||||
Uses Textual's RichLog widget for efficient append-only rendering with
|
||||
Rich text formatting support. Automatically scrolls to the bottom when
|
||||
new entries are added.
|
||||
|
||||
For streaming text, the panel uses a truncate-and-rewrite strategy: it
|
||||
tracks where streaming began in the RichLog lines list, and on each update
|
||||
truncates back to that point and rewrites the full accumulated text as a
|
||||
single write. This ensures consistent rendering without line-break artifacts
|
||||
between streamed chunks.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
"""Initialize the scroll panel.
|
||||
|
||||
Args:
|
||||
**kwargs: Additional arguments passed to RichLog.
|
||||
"""
|
||||
super().__init__(
|
||||
**kwargs,
|
||||
auto_scroll=True, # Automatically scroll to bottom
|
||||
wrap=True, # Wrap long lines instead of horizontal scroll
|
||||
markup=True, # Enable Rich markup
|
||||
highlight=True, # Enable syntax highlighting
|
||||
)
|
||||
self._entries: list[OutputEntry] = []
|
||||
self._is_streaming = False
|
||||
self._streaming_line_start: int = 0
|
||||
|
||||
def append_entry(self, entry: OutputEntry) -> None:
|
||||
"""Append a new output entry to the conversation history.
|
||||
|
||||
Args:
|
||||
entry: The output entry to append.
|
||||
"""
|
||||
self._entries.append(entry)
|
||||
text = self._format_entry(entry)
|
||||
self.write(text)
|
||||
|
||||
def set_streaming_entry(self, entry: OutputEntry) -> None:
|
||||
"""Set or update the current streaming entry.
|
||||
|
||||
On each update, truncates the RichLog back to where streaming
|
||||
started, then rewrites the full streaming text as a single block.
|
||||
This ensures no spurious line breaks between chunks while avoiding
|
||||
a full rewrite of all entries.
|
||||
|
||||
Args:
|
||||
entry: The streaming entry (will be mutated externally).
|
||||
"""
|
||||
if not self._is_streaming:
|
||||
# First streaming chunk — record where streaming lines begin
|
||||
self._is_streaming = True
|
||||
self._entries.append(entry)
|
||||
self._streaming_line_start = len(self.lines)
|
||||
|
||||
# Truncate lines back to where streaming started
|
||||
if len(self.lines) > self._streaming_line_start:
|
||||
del self.lines[self._streaming_line_start:]
|
||||
from textual.geometry import Size
|
||||
|
||||
self.virtual_size = Size(self._widest_line_width, len(self.lines))
|
||||
|
||||
# Write full streaming text as a single renderable
|
||||
formatted = self._format_text(entry.text, entry.color)
|
||||
self.write(formatted)
|
||||
|
||||
def end_streaming(self) -> None:
|
||||
"""End the current streaming mode."""
|
||||
if self._is_streaming:
|
||||
self._is_streaming = False
|
||||
self._streaming_line_start = 0
|
||||
|
||||
def _rewrite_all(self) -> None:
|
||||
"""Clear and rewrite all entries from scratch."""
|
||||
self.clear()
|
||||
for entry in self._entries:
|
||||
self.write(self._format_entry(entry))
|
||||
|
||||
def _format_entry(self, entry: OutputEntry) -> str:
|
||||
"""Format an output entry with Rich markup.
|
||||
|
||||
Args:
|
||||
entry: The entry to format.
|
||||
|
||||
Returns:
|
||||
Formatted string with Rich markup for color and styling.
|
||||
"""
|
||||
return self._format_text(entry.text, entry.color)
|
||||
|
||||
@staticmethod
|
||||
def _format_text(text: str, color: str | None) -> str:
|
||||
"""Format text with optional Rich color markup.
|
||||
|
||||
Args:
|
||||
text: The text to format.
|
||||
color: Optional Rich color name.
|
||||
|
||||
Returns:
|
||||
Formatted string.
|
||||
"""
|
||||
if color:
|
||||
return f"[{color}]{text}[/{color}]"
|
||||
return text
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Clear all conversation history from the panel."""
|
||||
self._entries.clear()
|
||||
self._is_streaming = False
|
||||
self._streaming_line_start = 0
|
||||
self.clear()
|
||||
@@ -0,0 +1,102 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Text input widget with inline prompt for the harness console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Input, Label
|
||||
|
||||
|
||||
class HarnessTextInput(Widget):
|
||||
"""Text input widget with a prompt label on the left.
|
||||
|
||||
Displays a prompt (e.g., "> ") followed by a borderless input field.
|
||||
Sits between the two mode-colored horizontal rules.
|
||||
|
||||
Attributes:
|
||||
prompt: The prompt text displayed on the left (e.g., "> ").
|
||||
placeholder: Placeholder text shown when the input is empty.
|
||||
"""
|
||||
|
||||
prompt: reactive[str] = reactive("> ")
|
||||
placeholder: reactive[str] = reactive("")
|
||||
|
||||
class Submitted(Message):
|
||||
"""Message sent when the input is submitted.
|
||||
|
||||
Attributes:
|
||||
value: The submitted text value.
|
||||
"""
|
||||
|
||||
def __init__(self, value: str) -> None:
|
||||
"""Initialize the Submitted message.
|
||||
|
||||
Args:
|
||||
value: The submitted text value.
|
||||
"""
|
||||
self.value = value
|
||||
super().__init__()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the prompt label and input field.
|
||||
|
||||
Yields:
|
||||
A horizontal container with the prompt and input field.
|
||||
"""
|
||||
with Horizontal(classes="prompt-container"):
|
||||
yield Label(self.prompt, classes="prompt-label", id="prompt-label")
|
||||
yield Input(placeholder=self.placeholder, classes="input-field", id="input-field")
|
||||
|
||||
def watch_prompt(self, new_prompt: str) -> None:
|
||||
"""Update the prompt label when the prompt attribute changes.
|
||||
|
||||
Args:
|
||||
new_prompt: The new prompt text.
|
||||
"""
|
||||
try:
|
||||
label = self.query_one("#prompt-label", Label)
|
||||
label.update(new_prompt)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def watch_placeholder(self, new_placeholder: str) -> None:
|
||||
"""Update the input placeholder when the placeholder attribute changes.
|
||||
|
||||
Args:
|
||||
new_placeholder: The new placeholder text.
|
||||
"""
|
||||
try:
|
||||
input_field = self.query_one("#input-field", Input)
|
||||
input_field.placeholder = new_placeholder
|
||||
except Exception:
|
||||
# Input doesn't exist yet (before compose), ignore
|
||||
pass
|
||||
|
||||
@on(Input.Submitted)
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""Handle input submission.
|
||||
|
||||
Clears the input field and posts a Submitted message with the value.
|
||||
|
||||
Args:
|
||||
event: The Input.Submitted event.
|
||||
"""
|
||||
value = event.value
|
||||
event.input.clear()
|
||||
self.post_message(self.Submitted(value))
|
||||
|
||||
def focus_input(self) -> None:
|
||||
"""Focus the input field."""
|
||||
input_field = self.query_one(".input-field", Input)
|
||||
input_field.focus()
|
||||
|
||||
def clear_input(self) -> None:
|
||||
"""Clear the input field."""
|
||||
input_field = self.query_one(".input-field", Input)
|
||||
input_field.clear()
|
||||
@@ -0,0 +1,503 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tool call formatters for displaying function calls in the harness console.
|
||||
|
||||
This module provides formatters that convert raw function call content into
|
||||
human-readable display strings. Each formatter handles specific tool patterns
|
||||
(e.g., web_search, todos_*, etc.) and the FallbackToolFormatter provides
|
||||
generic formatting for any unmatched tools.
|
||||
|
||||
Usage:
|
||||
from harness.console.formatters import build_default_formatters, format_tool_call
|
||||
from agent_framework import Content
|
||||
|
||||
call = Content.from_function_call(
|
||||
call_id="call_1",
|
||||
name="web_search",
|
||||
arguments={"query": "Python async"}
|
||||
)
|
||||
formatters = build_default_formatters()
|
||||
result = format_tool_call(formatters, call) # "web_search (Python async)"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import Content
|
||||
|
||||
# region Helper Functions
|
||||
|
||||
|
||||
def get_argument_value(call: Content, param_name: str) -> Any:
|
||||
"""Extract an argument value from a function call.
|
||||
|
||||
Handles both dict and JSON string arguments.
|
||||
|
||||
Args:
|
||||
call: The function call content.
|
||||
param_name: The parameter name to extract.
|
||||
|
||||
Returns:
|
||||
The argument value, or None if not found.
|
||||
"""
|
||||
if call.arguments is None:
|
||||
return None
|
||||
|
||||
if isinstance(call.arguments, str):
|
||||
# arguments is a JSON string, parse it
|
||||
try:
|
||||
args_dict = json.loads(call.arguments)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
if not isinstance(args_dict, dict):
|
||||
return None
|
||||
elif isinstance(call.arguments, dict):
|
||||
args_dict = call.arguments
|
||||
else:
|
||||
return None
|
||||
|
||||
return args_dict.get(param_name)
|
||||
|
||||
|
||||
def as_int_list(value: Any) -> list[int] | None:
|
||||
"""Convert a value to a list of integers, or None if not possible.
|
||||
|
||||
Args:
|
||||
value: The value to convert (should be a list).
|
||||
|
||||
Returns:
|
||||
A list of integers, or None if conversion fails.
|
||||
"""
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
|
||||
result: list[int] = []
|
||||
for item in value:
|
||||
if isinstance(item, int):
|
||||
result.append(item)
|
||||
else:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
result.append(int(item))
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def as_dict_list(value: Any) -> list[dict[str, Any]] | None:
|
||||
"""Convert a value to a list of dicts, or None if not possible.
|
||||
|
||||
Args:
|
||||
value: The value to convert (should be a list).
|
||||
|
||||
Returns:
|
||||
A list of dicts, or None if value is not a list of dicts.
|
||||
"""
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
result.append(item)
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def truncate(text: str, max_length: int) -> str:
|
||||
"""Truncate a string to the specified maximum length, appending an ellipsis if truncated.
|
||||
|
||||
Args:
|
||||
text: The text to truncate.
|
||||
max_length: The maximum length.
|
||||
|
||||
Returns:
|
||||
The truncated string.
|
||||
"""
|
||||
return text if len(text) <= max_length else text[:max_length] + "…"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Base Class
|
||||
|
||||
|
||||
class ToolCallFormatter(ABC):
|
||||
"""Base class for tool call formatters that produce human-readable display strings
|
||||
for function call content items shown in the console.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def can_format(self, call: Content) -> bool:
|
||||
"""Return True if this formatter can handle the given function call.
|
||||
|
||||
Args:
|
||||
call: The function call content to check.
|
||||
|
||||
Returns:
|
||||
True if this formatter should be used; otherwise False.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def format_detail(self, call: Content) -> str | None:
|
||||
"""Return the detail portion of the formatted output for the given tool call,
|
||||
or None if only the tool name should be displayed.
|
||||
|
||||
Args:
|
||||
call: The function call content to format.
|
||||
|
||||
Returns:
|
||||
A detail string to append after the tool name, or None.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Concrete Formatters
|
||||
|
||||
|
||||
class FallbackToolFormatter(ToolCallFormatter):
|
||||
"""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.
|
||||
"""
|
||||
|
||||
def can_format(self, call: Content) -> bool:
|
||||
"""Always returns True - this formatter matches everything."""
|
||||
return True
|
||||
|
||||
def format_detail(self, call: Content) -> str | None:
|
||||
"""Format arguments as generic (key: value, ...) pairs."""
|
||||
if call.arguments is None:
|
||||
return None
|
||||
|
||||
# Parse arguments
|
||||
if isinstance(call.arguments, str):
|
||||
try:
|
||||
args_dict = json.loads(call.arguments)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
elif isinstance(call.arguments, dict):
|
||||
args_dict = call.arguments
|
||||
else:
|
||||
return None
|
||||
|
||||
if not args_dict:
|
||||
return None
|
||||
|
||||
# Build argument list
|
||||
parts: list[str] = []
|
||||
for key, value in args_dict.items():
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
# Convert value to string
|
||||
if isinstance(value, bool):
|
||||
str_value = "true" if value else "false"
|
||||
elif isinstance(value, (int, float)):
|
||||
str_value = str(value)
|
||||
elif isinstance(value, str):
|
||||
str_value = value
|
||||
else:
|
||||
# Complex types - skip for now
|
||||
continue
|
||||
|
||||
parts.append(f"{key}: {truncate(str_value, 40)}")
|
||||
|
||||
return f"({', '.join(parts)})" if parts else None
|
||||
|
||||
|
||||
class WebSearchToolFormatter(ToolCallFormatter):
|
||||
"""Formats web_search tool calls, showing the search query."""
|
||||
|
||||
def can_format(self, call: Content) -> bool:
|
||||
"""Match web_search tool calls."""
|
||||
return call.name == "web_search"
|
||||
|
||||
def format_detail(self, call: Content) -> str | None:
|
||||
"""Extract and format the query parameter."""
|
||||
value = get_argument_value(call, "query")
|
||||
return f"({value})" if value else None
|
||||
|
||||
|
||||
class TodoToolFormatter(ToolCallFormatter):
|
||||
"""Formats todos_* tool calls with tree-view output for added items
|
||||
and structured output for complete/remove operations.
|
||||
"""
|
||||
|
||||
def can_format(self, call: Content) -> bool:
|
||||
"""Match todos_* tool calls."""
|
||||
return call.name is not None and call.name.startswith("todos_")
|
||||
|
||||
def format_detail(self, call: Content) -> str | None:
|
||||
"""Format based on the specific todos operation."""
|
||||
if call.name == "todos_add":
|
||||
return self._format_add_todos(call)
|
||||
if call.name == "todos_complete":
|
||||
return self._format_complete_todos(call)
|
||||
if call.name == "todos_remove":
|
||||
return self._format_id_list(call, "ids", "Remove")
|
||||
return None
|
||||
|
||||
def _format_add_todos(self, call: Content) -> str | None:
|
||||
"""Format todos_add with tree view of titles."""
|
||||
todos = as_dict_list(get_argument_value(call, "todos"))
|
||||
if not todos:
|
||||
return None
|
||||
|
||||
titles: list[str] = []
|
||||
for todo in todos:
|
||||
title = todo.get("title")
|
||||
if title and isinstance(title, str):
|
||||
titles.append(title)
|
||||
|
||||
if not titles:
|
||||
return None
|
||||
|
||||
# Build tree view
|
||||
count = len(titles)
|
||||
plural = "s" if count != 1 else ""
|
||||
lines = [f"({count} item{plural})"]
|
||||
for i, title in enumerate(titles):
|
||||
connector = "├─" if i < count - 1 else "└─"
|
||||
lines.append(f"\n {connector} {title}")
|
||||
|
||||
return "".join(lines)
|
||||
|
||||
def _format_complete_todos(self, call: Content) -> str | None:
|
||||
"""Format todos_complete with tree view of IDs and reasons."""
|
||||
items = as_dict_list(get_argument_value(call, "items"))
|
||||
if not items:
|
||||
return None
|
||||
|
||||
entries: list[tuple[int, str | None]] = []
|
||||
for item in items:
|
||||
todo_id = item.get("id")
|
||||
if not isinstance(todo_id, int):
|
||||
continue
|
||||
|
||||
reason = item.get("reason")
|
||||
reason_str = str(reason) if reason is not None and not isinstance(reason, str) else reason
|
||||
entries.append((todo_id, reason_str))
|
||||
|
||||
if not entries:
|
||||
return None
|
||||
|
||||
# Build tree view
|
||||
lines: list[str] = []
|
||||
for i, (todo_id, reason) in enumerate(entries):
|
||||
connector = "├─" if i < len(entries) - 1 else "└─"
|
||||
line = f"\n {connector} Complete #{todo_id}"
|
||||
if reason:
|
||||
line += f" — {truncate(reason, 80)}"
|
||||
lines.append(line)
|
||||
|
||||
return "".join(lines)
|
||||
|
||||
def _format_id_list(self, call: Content, param_name: str, verb: str) -> str | None:
|
||||
"""Format a list of IDs with a verb (e.g., Remove #1, Remove #2)."""
|
||||
ids = as_int_list(get_argument_value(call, param_name))
|
||||
if not ids:
|
||||
return None
|
||||
|
||||
lines: list[str] = []
|
||||
for i, todo_id in enumerate(ids):
|
||||
connector = "├─" if i < len(ids) - 1 else "└─"
|
||||
lines.append(f"\n {connector} {verb} #{todo_id}")
|
||||
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
class ModeToolFormatter(ToolCallFormatter):
|
||||
"""Formats AgentMode_* tool calls, showing the target mode for Set operations."""
|
||||
|
||||
def can_format(self, call: Content) -> bool:
|
||||
"""Match AgentMode_* tool calls."""
|
||||
return call.name is not None and call.name.startswith("AgentMode_")
|
||||
|
||||
def format_detail(self, call: Content) -> str | None:
|
||||
"""Format based on the specific AgentMode operation."""
|
||||
if call.name == "AgentMode_Set":
|
||||
value = get_argument_value(call, "mode")
|
||||
return f"({value})" if value else None
|
||||
return None
|
||||
|
||||
|
||||
class BackgroundAgentToolFormatter(ToolCallFormatter):
|
||||
"""Formats BackgroundAgents_* tool calls with human-readable details
|
||||
for task start, continue, wait, and result retrieval operations.
|
||||
"""
|
||||
|
||||
def can_format(self, call: Content) -> bool:
|
||||
"""Match BackgroundAgents_* tool calls."""
|
||||
return call.name is not None and call.name.startswith("BackgroundAgents_")
|
||||
|
||||
def format_detail(self, call: Content) -> str | None:
|
||||
"""Format based on the specific BackgroundAgents operation."""
|
||||
if call.name == "BackgroundAgents_StartTask":
|
||||
return self._format_start_background_task(call)
|
||||
if call.name == "BackgroundAgents_WaitForFirstCompletion":
|
||||
return self._format_id_list(call, "taskIds", "Wait for")
|
||||
if call.name == "BackgroundAgents_GetTaskResults":
|
||||
return self._format_single_id(call, "taskId")
|
||||
if call.name == "BackgroundAgents_ContinueTask":
|
||||
return self._format_continue_task(call)
|
||||
if call.name == "BackgroundAgents_ClearCompletedTask":
|
||||
return self._format_single_id(call, "taskId")
|
||||
return None
|
||||
|
||||
def _format_start_background_task(self, call: Content) -> str | None:
|
||||
"""Format StartTask with agent name and description."""
|
||||
agent_name = get_argument_value(call, "agentName")
|
||||
description = get_argument_value(call, "description")
|
||||
|
||||
if agent_name is None and description is None:
|
||||
return None
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
if agent_name is not None and description is not None:
|
||||
lines.append(f"\n ├─ Agent: {agent_name}")
|
||||
lines.append(f'\n └─ "{truncate(description, 80)}"')
|
||||
elif agent_name is not None:
|
||||
lines.append(f"\n └─ Agent: {agent_name}")
|
||||
else:
|
||||
lines.append(f'\n └─ "{truncate(description, 80)}"') # type: ignore[arg-type]
|
||||
|
||||
return "".join(lines)
|
||||
|
||||
def _format_id_list(self, call: Content, param_name: str, verb: str) -> str | None:
|
||||
"""Format a list of task IDs with a verb."""
|
||||
ids = as_int_list(get_argument_value(call, param_name))
|
||||
if not ids:
|
||||
return None
|
||||
|
||||
lines: list[str] = []
|
||||
for i, task_id in enumerate(ids):
|
||||
connector = "├─" if i < len(ids) - 1 else "└─"
|
||||
lines.append(f"\n {connector} {verb} #{task_id}")
|
||||
|
||||
return "".join(lines)
|
||||
|
||||
def _format_single_id(self, call: Content, param_name: str) -> str | None:
|
||||
"""Format a single task ID in parentheses."""
|
||||
task_id = get_argument_value(call, param_name)
|
||||
if isinstance(task_id, int):
|
||||
return f"(task #{task_id})"
|
||||
return None
|
||||
|
||||
def _format_continue_task(self, call: Content) -> str | None:
|
||||
"""Format ContinueTask with task ID and optional text."""
|
||||
task_id = get_argument_value(call, "taskId")
|
||||
text = get_argument_value(call, "text")
|
||||
|
||||
if not isinstance(task_id, int):
|
||||
return None
|
||||
|
||||
if text:
|
||||
lines = [
|
||||
f"\n ├─ Task #{task_id}",
|
||||
f'\n └─ "{truncate(text, 80)}"',
|
||||
]
|
||||
return "".join(lines)
|
||||
|
||||
return f"\n └─ Task #{task_id}"
|
||||
|
||||
|
||||
class FileMemoryToolFormatter(ToolCallFormatter):
|
||||
"""Formats FileMemory_* tool calls, showing file names and search patterns
|
||||
with tree-view corners for save operations.
|
||||
"""
|
||||
|
||||
def can_format(self, call: Content) -> bool:
|
||||
"""Match FileMemory_* tool calls."""
|
||||
return call.name is not None and call.name.startswith("FileMemory_")
|
||||
|
||||
def format_detail(self, call: Content) -> str | None:
|
||||
"""Format based on the specific FileMemory operation."""
|
||||
if call.name == "FileMemory_SaveFile":
|
||||
return self._format_save_file(call)
|
||||
if call.name in ("FileMemory_ReadFile", "FileMemory_DeleteFile"):
|
||||
value = get_argument_value(call, "fileName")
|
||||
return f"({value})" if value else None
|
||||
if call.name == "FileMemory_SearchFiles":
|
||||
return self._format_search_files(call)
|
||||
return None
|
||||
|
||||
def _format_save_file(self, call: Content) -> str | None:
|
||||
"""Format SaveFile with file name and description indicator."""
|
||||
file_name = get_argument_value(call, "fileName")
|
||||
description = get_argument_value(call, "description")
|
||||
|
||||
if not file_name:
|
||||
return None
|
||||
|
||||
if description:
|
||||
return f"\n └─ {file_name} (with description)"
|
||||
return f"\n └─ {file_name}"
|
||||
|
||||
def _format_search_files(self, call: Content) -> str | None:
|
||||
"""Format SearchFiles with regex pattern and optional file pattern."""
|
||||
pattern = get_argument_value(call, "regexPattern")
|
||||
file_pattern = get_argument_value(call, "filePattern")
|
||||
|
||||
if not pattern:
|
||||
return None
|
||||
|
||||
if file_pattern:
|
||||
return f"(/{pattern}/ in {file_pattern})"
|
||||
return f"(/{pattern}/)"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Public API Functions
|
||||
|
||||
|
||||
def format_tool_call(formatters: list[ToolCallFormatter], call: Content) -> str:
|
||||
"""Format a tool call using the first matching formatter from the provided list.
|
||||
|
||||
Returns "{toolName} {detail}" when a formatter produces detail,
|
||||
or just "{toolName}" otherwise.
|
||||
|
||||
Args:
|
||||
formatters: List of formatters to try in order.
|
||||
call: The function call content to format.
|
||||
|
||||
Returns:
|
||||
Formatted string representation of the tool call.
|
||||
"""
|
||||
for formatter in formatters:
|
||||
if formatter.can_format(call):
|
||||
detail = formatter.format_detail(call)
|
||||
tool_name = call.name or "Unknown"
|
||||
return f"{tool_name} {detail}" if detail is not None else tool_name
|
||||
|
||||
return call.name or "Unknown"
|
||||
|
||||
|
||||
def build_default_formatters() -> list[ToolCallFormatter]:
|
||||
"""Create the default list of tool call formatters.
|
||||
|
||||
The FallbackToolFormatter is always last. Users can call this function
|
||||
and combine the result with their own formatters.
|
||||
|
||||
Returns:
|
||||
A list of all built-in tool call formatters.
|
||||
"""
|
||||
return [
|
||||
TodoToolFormatter(),
|
||||
ModeToolFormatter(),
|
||||
BackgroundAgentToolFormatter(),
|
||||
FileMemoryToolFormatter(),
|
||||
WebSearchToolFormatter(),
|
||||
FallbackToolFormatter(),
|
||||
]
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -0,0 +1,87 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Main entry point for the harness console.
|
||||
|
||||
Provides the top-level run_agent_async() function that creates and runs
|
||||
the Textual-based harness console application.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .app import HarnessApp
|
||||
from .observers import build_default_observers
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, AgentSession
|
||||
|
||||
from .commands import CommandHandler
|
||||
from .observers.base import ConsoleObserver
|
||||
|
||||
|
||||
async def run_agent_async(
|
||||
agent: Agent,
|
||||
*,
|
||||
session: AgentSession | None = None,
|
||||
observers: list[ConsoleObserver] | None = None,
|
||||
command_handlers: list[CommandHandler] | None = None,
|
||||
mode_colors: dict[str, str] | None = None,
|
||||
initial_mode: str | None = None,
|
||||
placeholder: str = "Type a message and press Enter...",
|
||||
title: str = "Harness Console",
|
||||
max_context_window_tokens: int | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> None:
|
||||
"""Run the harness console with the given agent.
|
||||
|
||||
This is the main entry point for the harness console. Creates a Textual
|
||||
application with the configured observers and runs it until the user exits.
|
||||
|
||||
Args:
|
||||
agent: The agent to run conversations with.
|
||||
session: Optional agent session for conversation history.
|
||||
observers: List of console observers. If None, uses defaults.
|
||||
command_handlers: List of command handlers. If None, auto-detected from agent.
|
||||
mode_colors: Mapping of mode names to Rich color strings.
|
||||
initial_mode: Initial agent mode text.
|
||||
placeholder: Input placeholder text.
|
||||
title: Application title.
|
||||
max_context_window_tokens: Optional max context window size for usage display.
|
||||
max_output_tokens: Optional max output tokens for usage display.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from console import run_agent_async
|
||||
|
||||
agent = Agent(
|
||||
client=OpenAIChatClient(),
|
||||
instructions="You are helpful.",
|
||||
)
|
||||
|
||||
await run_agent_async(agent)
|
||||
"""
|
||||
resolved_observers = observers or build_default_observers()
|
||||
resolved_mode_colors = mode_colors or {
|
||||
"plan": "cyan",
|
||||
"execute": "green",
|
||||
}
|
||||
resolved_session = session or agent.create_session()
|
||||
|
||||
app = HarnessApp(
|
||||
agent=agent,
|
||||
observers=resolved_observers,
|
||||
session=resolved_session,
|
||||
mode_colors=resolved_mode_colors,
|
||||
initial_mode=initial_mode,
|
||||
placeholder=placeholder,
|
||||
title=title,
|
||||
max_context_window_tokens=max_context_window_tokens,
|
||||
max_output_tokens=max_output_tokens,
|
||||
command_handlers=command_handlers,
|
||||
)
|
||||
|
||||
await app.run_async()
|
||||
@@ -0,0 +1,122 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Console observers for agent streaming lifecycle.
|
||||
|
||||
This module provides observers that display events during agent streaming
|
||||
and collect follow-up actions. All observers use the IUXStateDriver interface
|
||||
to update the UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import ConsoleObserver
|
||||
from .error_display import ErrorDisplayObserver
|
||||
from .planning_output import PlanningOutputObserver
|
||||
from .reasoning_display import ReasoningDisplayObserver
|
||||
from .text_output import TextOutputObserver
|
||||
from .tool_approval import ToolApprovalObserver
|
||||
from .tool_call_display import ToolCallDisplayObserver
|
||||
from .usage_display import UsageDisplayObserver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent
|
||||
|
||||
|
||||
def build_default_observers() -> list[ConsoleObserver]:
|
||||
"""Build the default set of observers for the harness console.
|
||||
|
||||
Returns a standard observer list covering:
|
||||
- Text output (streaming text display)
|
||||
- Tool call display (formatted tool invocations)
|
||||
- Error display (error messages)
|
||||
- Usage display (token counts)
|
||||
- Reasoning display (reasoning/thinking blocks)
|
||||
- Tool approval (user approval for tool calls)
|
||||
|
||||
Note: PlanningOutputObserver is NOT included here because it requires
|
||||
a mode_provider. Use build_observers_with_planning() for agents that
|
||||
have an AgentModeProvider (i.e. agents created with create_harness_agent).
|
||||
|
||||
Returns:
|
||||
List of default console observers.
|
||||
"""
|
||||
return [
|
||||
TextOutputObserver(),
|
||||
ToolCallDisplayObserver(),
|
||||
ErrorDisplayObserver(),
|
||||
UsageDisplayObserver(),
|
||||
ReasoningDisplayObserver(),
|
||||
ToolApprovalObserver(),
|
||||
]
|
||||
|
||||
|
||||
def build_observers_with_planning(
|
||||
agent: Agent,
|
||||
plan_mode_name: str = "plan",
|
||||
execution_mode_name: str = "execute",
|
||||
*,
|
||||
mode_colors: dict[str, str] | None = None,
|
||||
) -> list[ConsoleObserver]:
|
||||
"""Build observers with planning support (structured output in plan mode).
|
||||
|
||||
Replaces TextOutputObserver with PlanningOutputObserver, which configures
|
||||
structured JSON output via response_format when in plan mode. This enables
|
||||
the list picker UI for clarification and approval questions.
|
||||
|
||||
Requires that the agent has an AgentModeProvider in its context_providers
|
||||
(automatically added by create_harness_agent).
|
||||
|
||||
Args:
|
||||
agent: The agent to resolve the AgentModeProvider from.
|
||||
plan_mode_name: The mode name that represents planning mode.
|
||||
execution_mode_name: The mode name to switch to on approval.
|
||||
mode_colors: Optional mapping of mode names to Rich color strings.
|
||||
|
||||
Returns:
|
||||
List of observers with planning support.
|
||||
|
||||
Raises:
|
||||
ValueError: If the agent has no AgentModeProvider.
|
||||
"""
|
||||
from agent_framework import AgentModeProvider
|
||||
|
||||
mode_provider = next(
|
||||
(p for p in agent.context_providers if isinstance(p, AgentModeProvider)),
|
||||
None,
|
||||
)
|
||||
if mode_provider is None:
|
||||
msg = (
|
||||
"Planning observers require an AgentModeProvider on the agent. "
|
||||
"Use create_harness_agent() or add AgentModeProvider to context_providers."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
return [
|
||||
ToolCallDisplayObserver(),
|
||||
ToolApprovalObserver(),
|
||||
ErrorDisplayObserver(),
|
||||
ReasoningDisplayObserver(),
|
||||
UsageDisplayObserver(),
|
||||
PlanningOutputObserver(
|
||||
mode_provider,
|
||||
plan_mode_name,
|
||||
execution_mode_name,
|
||||
mode_colors=mode_colors,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ConsoleObserver",
|
||||
"ErrorDisplayObserver",
|
||||
"PlanningOutputObserver",
|
||||
"ReasoningDisplayObserver",
|
||||
"TextOutputObserver",
|
||||
"ToolApprovalObserver",
|
||||
"ToolCallDisplayObserver",
|
||||
"UsageDisplayObserver",
|
||||
"build_default_observers",
|
||||
"build_observers_with_planning",
|
||||
]
|
||||
@@ -0,0 +1,125 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Base class for console observers.
|
||||
|
||||
Observers participate in the agent streaming lifecycle, displaying events
|
||||
and optionally returning follow-up actions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, Content, Message
|
||||
|
||||
from ..app_state import FollowUpAction
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class ConsoleObserver:
|
||||
"""Base class for console observers.
|
||||
|
||||
Observers participate in the agent streaming lifecycle, displaying
|
||||
events (tool calls, errors, reasoning, etc.) and optionally returning
|
||||
follow-up actions (questions, approval requests).
|
||||
|
||||
All methods have default no-op implementations, so subclasses only
|
||||
override the methods they need.
|
||||
"""
|
||||
|
||||
def configure_run_options(
|
||||
self,
|
||||
options: dict[str, Any],
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Configure run options before agent invocation.
|
||||
|
||||
Override to set options such as response_format, max_tokens, etc.
|
||||
|
||||
Args:
|
||||
options: Dictionary of chat options to modify.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def on_response_update(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
update: Message,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Called for each response update chunk.
|
||||
|
||||
Override to inspect update-level metadata or handle provider-specific
|
||||
events in the raw representation.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
update: The message update chunk.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def on_content(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
content: Content,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Called for each content item in the response.
|
||||
|
||||
Override to handle specific content types (function calls, errors, etc.).
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
content: The content item from the response.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def on_text(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
text: str,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Called for each text chunk in the response.
|
||||
|
||||
Override to accumulate and display streaming text.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
text: The text chunk.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def on_stream_complete(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> list[FollowUpAction] | None:
|
||||
"""Called when streaming completes.
|
||||
|
||||
Override to return follow-up actions (questions to ask the user,
|
||||
messages to inject into the next turn, etc.).
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
|
||||
Returns:
|
||||
Optional list of follow-up actions to queue, or None.
|
||||
"""
|
||||
return None
|
||||
@@ -0,0 +1,72 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Error display observer for showing errors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .base import ConsoleObserver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, Content
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class ErrorDisplayObserver(ConsoleObserver):
|
||||
"""Displays error content from the agent response.
|
||||
|
||||
Shows errors with an ❌ prefix in red to make them easily visible.
|
||||
"""
|
||||
|
||||
async def on_content(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
content: Content,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Display error content.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
content: The content item to check for errors.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
# Check if this is an error content type
|
||||
# The exact content type check depends on the agent framework's Content class
|
||||
if hasattr(content, "type") and content.type == "error":
|
||||
error_text = self._format_error(content)
|
||||
ux.append_info_line(error_text, "red")
|
||||
elif getattr(content, "error", None):
|
||||
error_text = f"❌ Error: {content.error}" # type: ignore[reportAttributeAccessIssue]
|
||||
ux.append_info_line(error_text, "red")
|
||||
|
||||
def _format_error(self, content: Content) -> str:
|
||||
"""Format error content for display.
|
||||
|
||||
Args:
|
||||
content: The error content.
|
||||
|
||||
Returns:
|
||||
Formatted error string.
|
||||
"""
|
||||
error_text = "❌ Error"
|
||||
|
||||
# Try to extract error message
|
||||
if hasattr(content, "message"):
|
||||
error_text += f": {content.message}"
|
||||
elif hasattr(content, "text"):
|
||||
error_text += f": {content.text}"
|
||||
|
||||
# Try to add error code if available
|
||||
if hasattr(content, "error_code") and content.error_code:
|
||||
error_text += f" (code: {content.error_code})"
|
||||
|
||||
# Try to add details if available
|
||||
if hasattr(content, "details") and getattr(content, "details", None):
|
||||
error_text += f" — {content.details}" # type: ignore[reportAttributeAccessIssue]
|
||||
|
||||
return error_text
|
||||
@@ -0,0 +1,71 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Pydantic models for structured planning output.
|
||||
|
||||
These models define the JSON schema that the agent produces when in planning
|
||||
mode via `response_format`. The schema enables consistent rendering of
|
||||
clarification questions and approval requests in the console UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PlanningResponseType(str, Enum):
|
||||
"""Type of planning response from the agent."""
|
||||
|
||||
CLARIFICATION = "clarification"
|
||||
"""The agent needs clarification and presents options for the user to choose from."""
|
||||
|
||||
APPROVAL = "approval"
|
||||
"""The agent is seeking approval to proceed with execution."""
|
||||
|
||||
|
||||
class PlanningQuestion(BaseModel):
|
||||
"""A single question or item within a PlanningResponse.
|
||||
|
||||
For clarification: contains the question text and optional choices.
|
||||
For approval: contains the plan summary for the user to approve.
|
||||
"""
|
||||
|
||||
message: str = Field(
|
||||
description=(
|
||||
"For clarifications, this has the question that needs to be clarified "
|
||||
"with the user. For approvals, this would contain a summary of the "
|
||||
"execution plan that the user needs to approve."
|
||||
),
|
||||
)
|
||||
choices: list[str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"For clarifications, this has a list of options that the user can "
|
||||
"choose from. null for approvals."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PlanningResponse(BaseModel):
|
||||
"""Structured response from the agent while in planning mode.
|
||||
|
||||
Used with structured output (`response_format`) to enable consistent
|
||||
rendering of clarification questions and approval requests.
|
||||
"""
|
||||
|
||||
type: PlanningResponseType = Field(
|
||||
description=(
|
||||
"Use 'clarification' when you need clarification around the user "
|
||||
"request and you want to present the user with options to choose from. "
|
||||
"Use 'approval' when you are ready to start execution, but need "
|
||||
"approval to start executing."
|
||||
),
|
||||
)
|
||||
questions: list[PlanningQuestion] = Field(
|
||||
description=(
|
||||
"For clarifications, this has one or more questions to ask the user "
|
||||
"(each with choices). For approvals, this has exactly one item "
|
||||
"containing the plan summary for the user to approve."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,242 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Planning output observer for structured agent responses in plan mode.
|
||||
|
||||
In planning mode, this observer configures structured JSON output via
|
||||
response_format, collects streamed text silently, then deserializes the
|
||||
result as a PlanningResponse to present clarification/approval questions.
|
||||
|
||||
In execution mode, text is streamed through directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from rich.markup import escape
|
||||
|
||||
from ..app_state import (
|
||||
ChoiceFollowUpQuestion,
|
||||
FollowUpAction,
|
||||
TextFollowUpQuestion,
|
||||
)
|
||||
from .base import ConsoleObserver
|
||||
from .planning_models import PlanningResponse, PlanningResponseType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, AgentModeProvider, Message
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class PlanningOutputObserver(ConsoleObserver):
|
||||
"""Mode-aware observer that uses structured output in plan mode.
|
||||
|
||||
In planning mode:
|
||||
- Configures response_format to PlanningResponse schema
|
||||
- Collects streamed text silently
|
||||
- Deserializes JSON into PlanningResponse
|
||||
- Builds follow-up questions (clarification or approval)
|
||||
|
||||
In execution mode:
|
||||
- Streams text directly to the UX driver
|
||||
|
||||
If JSON parsing fails, falls back to rendering the raw text as regular
|
||||
output so the user always sees what the agent produced.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mode_provider: AgentModeProvider,
|
||||
plan_mode_name: str,
|
||||
execution_mode_name: str,
|
||||
*,
|
||||
mode_colors: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the planning output observer.
|
||||
|
||||
Args:
|
||||
mode_provider: The mode provider for reading/switching modes.
|
||||
plan_mode_name: The mode name that represents planning mode.
|
||||
execution_mode_name: The mode name to switch to on approval.
|
||||
mode_colors: Optional mapping of mode names to Rich color strings.
|
||||
"""
|
||||
self._mode_provider = mode_provider
|
||||
self._plan_mode_name = plan_mode_name
|
||||
self._execution_mode_name = execution_mode_name
|
||||
self._mode_colors = mode_colors or {}
|
||||
self._text_collector: list[str] = []
|
||||
|
||||
def configure_run_options(
|
||||
self,
|
||||
options: dict[str, Any],
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Set response_format to PlanningResponse when in plan mode."""
|
||||
if self._is_planning_mode(session):
|
||||
options["response_format"] = PlanningResponse
|
||||
|
||||
async def on_text(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
text: str,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Collect text in plan mode; stream through in execute mode."""
|
||||
if self._is_planning_mode_from_ux(ux):
|
||||
self._text_collector.append(text)
|
||||
else:
|
||||
ux.write_text(escape(text))
|
||||
|
||||
async def on_stream_complete(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> list[FollowUpAction] | None:
|
||||
"""Parse collected text as PlanningResponse and build follow-up actions."""
|
||||
if not self._is_planning_mode_from_ux(ux):
|
||||
self._text_collector.clear()
|
||||
return None
|
||||
|
||||
collected_text = "".join(self._text_collector)
|
||||
self._text_collector.clear()
|
||||
|
||||
if not collected_text.strip():
|
||||
return None
|
||||
|
||||
# Attempt to deserialize structured response
|
||||
try:
|
||||
planning_response = PlanningResponse.model_validate_json(collected_text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# JSON parsing failed — fall back to rendering as regular text
|
||||
ux.write_text(escape(collected_text))
|
||||
return None
|
||||
|
||||
if planning_response.type == PlanningResponseType.CLARIFICATION:
|
||||
return self._build_clarification_actions(planning_response)
|
||||
|
||||
if planning_response.type == PlanningResponseType.APPROVAL:
|
||||
if not planning_response.questions:
|
||||
ux.append_info_line("(approval response had no content)", "yellow")
|
||||
return None
|
||||
question = planning_response.questions[0]
|
||||
return [self._build_approval_action(question, session)]
|
||||
|
||||
# Unexpected type — fall back to rendering as regular text
|
||||
ux.write_text(escape(collected_text))
|
||||
return None
|
||||
|
||||
def _is_planning_mode(self, session: Any) -> bool:
|
||||
"""Check if session is in planning mode."""
|
||||
from agent_framework import get_agent_mode
|
||||
|
||||
try:
|
||||
current_mode = get_agent_mode(session)
|
||||
except (AttributeError, TypeError):
|
||||
return True # No mode provider → treat as planning
|
||||
return current_mode.lower() == self._plan_mode_name.lower()
|
||||
|
||||
def _is_planning_mode_from_ux(self, ux: IUXStateDriver) -> bool:
|
||||
"""Check if UX is in planning mode."""
|
||||
current = ux.current_mode
|
||||
if current is None:
|
||||
return True
|
||||
return current.lower() == self._plan_mode_name.lower()
|
||||
|
||||
def _build_clarification_actions(
|
||||
self,
|
||||
response: PlanningResponse,
|
||||
) -> list[FollowUpAction]:
|
||||
"""Build follow-up questions for clarification."""
|
||||
actions: list[FollowUpAction] = []
|
||||
|
||||
for question in response.questions:
|
||||
prompt = question.message
|
||||
cont = self._make_clarification_continuation(prompt)
|
||||
|
||||
if question.choices and len(question.choices) > 0:
|
||||
actions.append(
|
||||
ChoiceFollowUpQuestion(
|
||||
prompt=prompt,
|
||||
choices=question.choices,
|
||||
allow_custom_text=True,
|
||||
continuation=cont,
|
||||
)
|
||||
)
|
||||
else:
|
||||
actions.append(
|
||||
TextFollowUpQuestion(
|
||||
prompt=prompt,
|
||||
continuation=cont,
|
||||
)
|
||||
)
|
||||
|
||||
return actions
|
||||
|
||||
@staticmethod
|
||||
def _make_clarification_continuation(prompt: str):
|
||||
"""Create a clarification continuation closure capturing the prompt."""
|
||||
|
||||
async def continuation(
|
||||
answer: str,
|
||||
ux: IUXStateDriver,
|
||||
) -> Message | None:
|
||||
if not answer.strip():
|
||||
ux.append_info_line(f"🔹 {prompt}\n └─ (no answer)", "dim")
|
||||
return None
|
||||
|
||||
ux.append_info_line(f"🔹 {prompt}\n └─ [green]{answer}[/green]", "dim")
|
||||
|
||||
from agent_framework import Message
|
||||
|
||||
return Message(role="user", contents=[f"Q: {prompt}\nA: {answer}"])
|
||||
|
||||
return continuation
|
||||
|
||||
def _build_approval_action(
|
||||
self,
|
||||
question: Any,
|
||||
session: Any,
|
||||
) -> ChoiceFollowUpQuestion:
|
||||
"""Build the approval follow-up question."""
|
||||
approve_option = "Approve and switch to execute mode"
|
||||
prompt = question.message
|
||||
|
||||
async def continuation(
|
||||
selection: str,
|
||||
ux: IUXStateDriver,
|
||||
) -> Message | None:
|
||||
ux.append_info_line(
|
||||
f"🔹 {prompt}\n └─ [green]{selection}[/green]",
|
||||
"dim",
|
||||
)
|
||||
|
||||
if selection == approve_option:
|
||||
from agent_framework import set_agent_mode
|
||||
|
||||
set_agent_mode(session, self._execution_mode_name)
|
||||
exec_color = self._mode_colors.get(self._execution_mode_name)
|
||||
ux.set_mode(self._execution_mode_name, exec_color)
|
||||
ux.append_info_line(
|
||||
f"✅ Switched to {self._execution_mode_name} mode.",
|
||||
exec_color,
|
||||
)
|
||||
from agent_framework import Message
|
||||
|
||||
return Message(role="user", contents=["Approved"])
|
||||
|
||||
# Custom freeform input — treat as suggested changes
|
||||
from agent_framework import Message
|
||||
|
||||
return Message(role="user", contents=[selection])
|
||||
|
||||
return ChoiceFollowUpQuestion(
|
||||
prompt=prompt,
|
||||
choices=[approve_option],
|
||||
allow_custom_text=True,
|
||||
continuation=continuation,
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Reasoning display observer for showing thinking content."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from rich.markup import escape
|
||||
|
||||
from .base import ConsoleObserver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, Content
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class ReasoningDisplayObserver(ConsoleObserver):
|
||||
"""Displays reasoning/thinking content from the agent.
|
||||
|
||||
Some models (like o1) provide reasoning steps that show their
|
||||
internal thought process. This observer displays them with a 💭 prefix
|
||||
in a dimmed style.
|
||||
"""
|
||||
|
||||
async def on_content(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
content: Content,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Display reasoning content.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
content: The content item to check for reasoning.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
reasoning_text = self._extract_reasoning(content)
|
||||
if reasoning_text:
|
||||
# Display reasoning in dim style to differentiate from main output
|
||||
ux.append_info_line(f"💭 {escape(reasoning_text)}", "dim")
|
||||
|
||||
def _extract_reasoning(self, content: Content) -> str | None:
|
||||
"""Extract reasoning text from content.
|
||||
|
||||
Args:
|
||||
content: The content item to extract reasoning from.
|
||||
|
||||
Returns:
|
||||
The reasoning text, or None if no reasoning is present.
|
||||
"""
|
||||
# Check for reasoning content type
|
||||
if hasattr(content, "type") and content.type in {"text_reasoning", "reasoning"}:
|
||||
if hasattr(content, "text"):
|
||||
return content.text
|
||||
content_attr = getattr(content, "content", None)
|
||||
if content_attr:
|
||||
return str(content_attr)
|
||||
|
||||
# Check for reasoning attribute
|
||||
reasoning = getattr(content, "reasoning", None)
|
||||
if reasoning is not None:
|
||||
if isinstance(reasoning, str):
|
||||
return reasoning
|
||||
if hasattr(reasoning, "text"):
|
||||
return reasoning.text
|
||||
|
||||
# Check for thinking attribute (alternative name)
|
||||
thinking = getattr(content, "thinking", None)
|
||||
if thinking is not None:
|
||||
if isinstance(thinking, str):
|
||||
return thinking
|
||||
if hasattr(thinking, "text"):
|
||||
return thinking.text
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,59 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Text output observer for streaming agent text."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from rich.markup import escape
|
||||
|
||||
from .base import ConsoleObserver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class TextOutputObserver(ConsoleObserver):
|
||||
"""Displays streaming text output from the agent.
|
||||
|
||||
Writes text chunks incrementally to the UX state driver as they arrive,
|
||||
allowing real-time display during streaming.
|
||||
"""
|
||||
|
||||
async def on_text(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
text: str,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Write each text chunk directly to the UX driver.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
text: The text chunk to display.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
ux.write_text(escape(text))
|
||||
|
||||
async def on_stream_complete(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> list | None:
|
||||
"""No-op on stream complete (state managed by UX driver).
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
|
||||
Returns:
|
||||
None (no follow-up actions).
|
||||
"""
|
||||
return None
|
||||
@@ -0,0 +1,139 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tool approval observer for user confirmation of tool calls.
|
||||
|
||||
Detects function_approval_request content items during streaming, displays
|
||||
approval notifications, and after the stream completes presents one
|
||||
ChoiceFollowUpQuestion per pending approval request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..app_state import ChoiceFollowUpQuestion, FollowUpAction
|
||||
from .base import ConsoleObserver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, Content, Message
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class ToolApprovalObserver(ConsoleObserver):
|
||||
"""Asks user to approve tool calls before execution.
|
||||
|
||||
Collects `function_approval_request` content during streaming and presents
|
||||
a multi-choice approval question for each after the stream completes.
|
||||
The continuation builds a `function_approval_response` Content to inject
|
||||
into the next agent turn.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the tool approval observer."""
|
||||
self._approval_requests: list[Content] = []
|
||||
|
||||
async def on_content(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
content: Content,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Collect function_approval_request content for approval.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
content: The content item to check.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
if content.type == "function_approval_request":
|
||||
self._approval_requests.append(content)
|
||||
tool_name = self._format_tool_name(content)
|
||||
ux.append_info_line(f"⚠️ Approval needed: {tool_name}", "yellow")
|
||||
|
||||
async def on_stream_complete(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> list[FollowUpAction] | None:
|
||||
"""Build approval questions for collected requests.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
|
||||
Returns:
|
||||
List of ChoiceFollowUpQuestions, one per approval request.
|
||||
"""
|
||||
if not self._approval_requests:
|
||||
return None
|
||||
|
||||
actions: list[FollowUpAction] = []
|
||||
for request in self._approval_requests:
|
||||
actions.append(self._build_approval_question(request))
|
||||
|
||||
self._approval_requests.clear()
|
||||
return actions
|
||||
|
||||
def _build_approval_question(self, request: Content) -> ChoiceFollowUpQuestion:
|
||||
"""Build a multi-choice approval question for a single request."""
|
||||
tool_name = self._format_tool_name(request)
|
||||
prompt = f"🔐 Tool approval: {tool_name}"
|
||||
|
||||
# TODO(westey-m): Add "Always approve" options when the framework supports
|
||||
# CreateAlwaysApproveToolResponse / CreateAlwaysApproveToolWithArgumentsResponse.
|
||||
choices = [
|
||||
"Approve this call",
|
||||
"Deny",
|
||||
]
|
||||
|
||||
async def continuation(
|
||||
selection: str,
|
||||
ux: IUXStateDriver,
|
||||
) -> Message | None:
|
||||
from agent_framework import Message
|
||||
|
||||
if selection == "Deny":
|
||||
response_content = request.to_function_approval_response(approved=False)
|
||||
action_label = "❌ Denied"
|
||||
color = "red"
|
||||
else:
|
||||
response_content = request.to_function_approval_response(approved=True)
|
||||
action_label = "✅ Approved"
|
||||
color = "green"
|
||||
|
||||
ux.append_info_line(
|
||||
f"🔹 {prompt}\n └─ [{color}]{action_label}[/{color}]",
|
||||
"dim",
|
||||
)
|
||||
|
||||
return Message(role="user", contents=[response_content])
|
||||
|
||||
return ChoiceFollowUpQuestion(
|
||||
prompt=prompt,
|
||||
choices=choices,
|
||||
allow_custom_text=False,
|
||||
continuation=continuation,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_tool_name(content: Content) -> str:
|
||||
"""Extract a readable tool name from approval request content."""
|
||||
# The function_call is stored on the approval request content
|
||||
function_call = getattr(content, "function_call", None)
|
||||
if function_call is not None:
|
||||
from ..formatters import build_default_formatters, format_tool_call
|
||||
|
||||
try:
|
||||
return format_tool_call(build_default_formatters(), function_call)
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
# Fall back to name attribute
|
||||
name = getattr(function_call, "name", None)
|
||||
if name:
|
||||
return str(name)
|
||||
return "unknown tool"
|
||||
@@ -0,0 +1,53 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tool call display observer using formatters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..formatters import build_default_formatters, format_tool_call
|
||||
from .base import ConsoleObserver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, Content
|
||||
|
||||
from ..formatters import ToolCallFormatter
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class ToolCallDisplayObserver(ConsoleObserver):
|
||||
"""Displays tool call notifications using formatters.
|
||||
|
||||
Shows tool calls with a 🔧 prefix and uses the formatter system to
|
||||
display them in a user-friendly format.
|
||||
"""
|
||||
|
||||
def __init__(self, formatters: list[ToolCallFormatter] | None = None) -> None:
|
||||
"""Initialize the tool call display observer.
|
||||
|
||||
Args:
|
||||
formatters: Optional list of tool formatters. If None, uses
|
||||
default formatters from build_default_formatters().
|
||||
"""
|
||||
self._formatters = formatters or build_default_formatters()
|
||||
|
||||
async def on_content(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
content: Content,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Display function call content.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
content: The content item to check for function calls.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
# Check if this is a function call content type
|
||||
if content.type == "function_call":
|
||||
formatted = format_tool_call(self._formatters, content)
|
||||
ux.append_info_line(f"🔧 {formatted}", "yellow")
|
||||
@@ -0,0 +1,56 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Usage display observer for token usage statistics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .base import ConsoleObserver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class UsageDisplayObserver(ConsoleObserver):
|
||||
"""Displays token usage as a proportion of the context window.
|
||||
|
||||
Shows current token usage as reported by the API immediately when
|
||||
usage information becomes available (via Content items or the final response).
|
||||
The display shows input/output/total relative to configured budgets.
|
||||
"""
|
||||
|
||||
async def on_content(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
content: Any,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Update usage display immediately when usage content arrives.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
content: A content item from the response.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
if getattr(content, "type", None) == "usage":
|
||||
usage_details = getattr(content, "usage_details", None)
|
||||
if isinstance(usage_details, dict):
|
||||
# Pass through to state driver — the runner handles formatting
|
||||
ux.set_usage_text(self._format_from_details(usage_details))
|
||||
|
||||
@staticmethod
|
||||
def _format_from_details(usage: dict) -> str:
|
||||
"""Format usage details dict into display text.
|
||||
|
||||
This is a fallback formatter for when usage arrives as Content
|
||||
before the runner's final response processing.
|
||||
"""
|
||||
input_tokens = usage.get("input_token_count", 0) or 0
|
||||
output_tokens = usage.get("output_token_count", 0) or 0
|
||||
total_tokens = usage.get("total_token_count", 0) or input_tokens + output_tokens
|
||||
return f"📊 Tokens — input: {input_tokens:,} | output: {output_tokens:,} | total: {total_tokens:,}"
|
||||
@@ -0,0 +1,338 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""State driver interface for UI updates.
|
||||
|
||||
This module defines the IUXStateDriver Protocol, which observers use to
|
||||
update the UI during agent streaming. This is an interface-only definition;
|
||||
the concrete implementation will be in a separate module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import AgentSession
|
||||
|
||||
from .app_state import FollowUpAction
|
||||
|
||||
|
||||
class IUXStateDriver(Protocol):
|
||||
"""Protocol for UI state driver.
|
||||
|
||||
Observers call these methods to update the UI during agent streaming.
|
||||
This is an interface-only definition - concrete implementation comes later.
|
||||
|
||||
The state driver acts as a controller between the agent framework (model)
|
||||
and the Textual UI components (view), coordinating all UI updates.
|
||||
"""
|
||||
|
||||
def append_info_line(self, text: str, color: str | None = None) -> None:
|
||||
"""Append an informational line to the output.
|
||||
|
||||
Used for displaying tool calls, errors, warnings, and other
|
||||
informational messages that aren't part of the agent's text response.
|
||||
|
||||
Args:
|
||||
text: The text to display.
|
||||
color: Optional Rich color string (e.g., "yellow", "red", "dim").
|
||||
"""
|
||||
...
|
||||
|
||||
def append_stream_footer(self, text: str) -> None:
|
||||
"""Append a footer line after streaming ends.
|
||||
|
||||
Used for displaying final status messages like "(no text response)"
|
||||
or other closing information.
|
||||
|
||||
Args:
|
||||
text: The footer text to display.
|
||||
"""
|
||||
...
|
||||
|
||||
def begin_streaming(self) -> None:
|
||||
"""Begin streaming mode.
|
||||
|
||||
Switches the bottom panel to streaming mode (shows "Streaming..." indicator),
|
||||
starts the spinner animation, and prepares for streaming text updates.
|
||||
"""
|
||||
...
|
||||
|
||||
def update_streaming_text(self, accumulated_text: str) -> None:
|
||||
"""Update the accumulated streaming text.
|
||||
|
||||
Called repeatedly during streaming to update the displayed text as
|
||||
new chunks arrive from the agent. The text should accumulate across
|
||||
multiple calls.
|
||||
|
||||
Args:
|
||||
accumulated_text: The full accumulated text so far.
|
||||
"""
|
||||
...
|
||||
|
||||
def write_text(self, text: str, color: str | None = None) -> None:
|
||||
"""Write a streaming text chunk incrementally.
|
||||
|
||||
Appends the text to the current streaming entry. If the streaming
|
||||
entry is no longer the last output item (e.g., an info_line was
|
||||
inserted), creates a new streaming entry.
|
||||
|
||||
Args:
|
||||
text: The text chunk to append.
|
||||
color: Optional Rich color string.
|
||||
"""
|
||||
...
|
||||
|
||||
def end_streaming(self) -> None:
|
||||
"""End streaming mode.
|
||||
|
||||
Stops the spinner, switches the bottom panel back to text input mode,
|
||||
and finalizes the streaming output.
|
||||
"""
|
||||
...
|
||||
|
||||
def enqueue_follow_up_action(self, action: FollowUpAction) -> None:
|
||||
"""Add a follow-up action to the queue.
|
||||
|
||||
Follow-up actions can be questions to ask the user or messages to
|
||||
inject into the next agent turn. The state driver queues these and
|
||||
processes them after streaming completes.
|
||||
|
||||
Args:
|
||||
action: The follow-up action to queue.
|
||||
"""
|
||||
...
|
||||
|
||||
def has_pending_questions(self) -> bool:
|
||||
"""Check if there are pending follow-up questions awaiting user answers.
|
||||
|
||||
Returns:
|
||||
True if there are unanswered questions in the queue.
|
||||
"""
|
||||
...
|
||||
|
||||
def take_follow_up_responses(self) -> list:
|
||||
"""Take and clear all accumulated follow-up response messages.
|
||||
|
||||
Returns:
|
||||
List of Message objects accumulated from follow-up actions.
|
||||
"""
|
||||
...
|
||||
|
||||
async def write_no_text_warning(self, has_follow_up_actions: bool) -> None:
|
||||
"""Write a warning if the agent produced no text output.
|
||||
|
||||
Called after streaming completes. If no text was received and no
|
||||
follow-up actions exist, writes a "(no text response)" footer.
|
||||
|
||||
Args:
|
||||
has_follow_up_actions: Whether follow-up actions exist.
|
||||
"""
|
||||
...
|
||||
|
||||
def set_mode(self, mode: str | None, mode_color: str | None = None) -> None:
|
||||
"""Set the current agent mode.
|
||||
|
||||
Updates the mode indicator in the UI (e.g., "[plan]", "[execute]")
|
||||
with the specified color.
|
||||
|
||||
Args:
|
||||
mode: The mode name (e.g., "plan", "execute"), or None to hide.
|
||||
mode_color: Optional Rich color string for the mode label.
|
||||
"""
|
||||
...
|
||||
|
||||
def set_show_spinner(self, show: bool) -> None:
|
||||
"""Show or hide the spinner animation.
|
||||
|
||||
The spinner provides visual feedback that the agent is processing.
|
||||
|
||||
Args:
|
||||
show: True to show the spinner, False to hide it.
|
||||
"""
|
||||
...
|
||||
|
||||
def set_usage_text(self, usage_text: str | None) -> None:
|
||||
"""Set the token usage text.
|
||||
|
||||
Displays token usage statistics (e.g., "1.2K in / 856 out") in
|
||||
the status bar.
|
||||
|
||||
Args:
|
||||
usage_text: The formatted usage text, or None to hide.
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
def current_mode(self) -> str | None:
|
||||
"""Get the current agent mode.
|
||||
|
||||
Returns:
|
||||
The current mode name, or None if no mode is set.
|
||||
"""
|
||||
...
|
||||
|
||||
def begin_streaming_output(self) -> None:
|
||||
"""Reset per-turn streaming bookkeeping.
|
||||
|
||||
Called at the start of each agent turn to reset streaming state
|
||||
(e.g., clear accumulated text, reset flags).
|
||||
"""
|
||||
...
|
||||
|
||||
def write_user_input_echo(self, text: str) -> None:
|
||||
"""Echo user input to the output area.
|
||||
|
||||
Displays the user's submitted input in the conversation history,
|
||||
typically with a "You: " prefix.
|
||||
|
||||
Args:
|
||||
text: The user's input text.
|
||||
"""
|
||||
...
|
||||
|
||||
def request_shutdown(self) -> None:
|
||||
"""Request the application to shut down.
|
||||
|
||||
Called by the /exit command handler to signal that the user
|
||||
wants to quit the console.
|
||||
"""
|
||||
...
|
||||
|
||||
def replace_session(self, session: AgentSession) -> None:
|
||||
"""Replace the current agent session.
|
||||
|
||||
Called by the /session-import command handler to swap the
|
||||
active session with one loaded from a file.
|
||||
|
||||
Args:
|
||||
session: The new session to use.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class SimpleConsoleStateDriver:
|
||||
"""Simple console-based state driver for testing.
|
||||
|
||||
This is a minimal implementation that logs all operations to the console.
|
||||
Useful for testing the agent runner without a full UI.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the simple state driver."""
|
||||
self._streaming = False
|
||||
self._spinner_visible = False
|
||||
self._current_mode: str | None = None
|
||||
print("[SimpleConsoleStateDriver initialized]")
|
||||
|
||||
def append_info_line(self, text: str, color: str | None = None) -> None:
|
||||
"""Append an informational line to the output."""
|
||||
color_prefix = f"[{color}]" if color else ""
|
||||
print(f"{color_prefix} {text}")
|
||||
|
||||
def append_stream_footer(self, text: str) -> None:
|
||||
"""Append a footer line after streaming ends."""
|
||||
print(f"[Footer] {text}")
|
||||
|
||||
async def write_info_line(self, text: str, color: str | None = None) -> None:
|
||||
"""Async version of append_info_line."""
|
||||
self.append_info_line(text, color)
|
||||
|
||||
def write_user_input_echo(self, text: str) -> None:
|
||||
"""Echo user input to the output."""
|
||||
print(f"\n[User] {text}\n")
|
||||
|
||||
def begin_streaming(self) -> None:
|
||||
"""Begin streaming mode."""
|
||||
self._streaming = True
|
||||
print("[▶ Streaming started]")
|
||||
|
||||
def begin_streaming_output(self) -> None:
|
||||
"""Begin streaming output to the scroll panel."""
|
||||
print("[▶ Streaming output started]")
|
||||
|
||||
def update_streaming_text(self, text: str) -> None:
|
||||
"""Update the currently streaming text."""
|
||||
# Truncate for readability
|
||||
display_text = text[:80] + "..." if len(text) > 80 else text
|
||||
print(f"[Assistant] {display_text}", end="", flush=True)
|
||||
|
||||
def write_text(self, text: str, color: str | None = None) -> None:
|
||||
"""Write a streaming text chunk."""
|
||||
print(text, end="", flush=True)
|
||||
|
||||
async def end_streaming_output(self) -> None:
|
||||
"""End streaming output."""
|
||||
print("\n[▪ Streaming output ended]")
|
||||
|
||||
def end_streaming(self) -> None:
|
||||
"""End streaming mode."""
|
||||
self._streaming = False
|
||||
print("[▪ Streaming ended]")
|
||||
|
||||
def set_show_spinner(self, show: bool) -> None:
|
||||
"""Show or hide the spinner."""
|
||||
self._spinner_visible = show
|
||||
status = "visible" if show else "hidden"
|
||||
print(f"[Spinner: {status}]")
|
||||
|
||||
def set_mode(self, mode: str | None, mode_color: str | None = None) -> None:
|
||||
"""Set the current mode text."""
|
||||
self._current_mode = mode
|
||||
color_str = f" ({mode_color})" if mode_color else ""
|
||||
print(f"[Mode: {mode or 'default'}{color_str}]")
|
||||
|
||||
@property
|
||||
def current_mode(self) -> str | None:
|
||||
"""Get the current agent mode."""
|
||||
return self._current_mode
|
||||
|
||||
def set_usage_text(self, usage_text: str | None) -> None:
|
||||
"""Set the usage display text."""
|
||||
if usage_text:
|
||||
print(f"[Usage: {usage_text}]")
|
||||
|
||||
def enqueue_follow_up_action(self, action) -> None:
|
||||
"""Enqueue a follow-up action.
|
||||
|
||||
Args:
|
||||
action: The follow-up action to enqueue.
|
||||
"""
|
||||
action_type = type(action).__name__
|
||||
print(f"[Follow-up queued: {action_type}]")
|
||||
|
||||
def has_pending_questions(self) -> bool:
|
||||
"""Check if there are pending follow-up questions."""
|
||||
return False
|
||||
|
||||
def take_follow_up_responses(self) -> list:
|
||||
"""Take and clear all accumulated follow-up responses."""
|
||||
return []
|
||||
|
||||
async def write_no_text_warning(self, has_follow_up_actions: bool) -> None:
|
||||
"""Write a warning if no text was produced."""
|
||||
if not has_follow_up_actions:
|
||||
print("[▪ (no text response from agent)]")
|
||||
|
||||
def update_last_entry(self, entry_type, new_text: str) -> None:
|
||||
"""Update the last output entry (placeholder for now).
|
||||
|
||||
Args:
|
||||
entry_type: The type of entry to update.
|
||||
new_text: The new text content.
|
||||
"""
|
||||
# Simplified: just print the update
|
||||
display_text = new_text[:80] + "..." if len(new_text) > 80 else new_text
|
||||
print(f"[Update last entry: {display_text}]", flush=True)
|
||||
|
||||
def request_shutdown(self) -> None:
|
||||
"""Request application shutdown."""
|
||||
print("[Shutdown requested]")
|
||||
|
||||
def replace_session(self, session) -> None:
|
||||
"""Replace the active session.
|
||||
|
||||
Args:
|
||||
session: The new session to use.
|
||||
"""
|
||||
print(f"[Session replaced: {getattr(session, 'id', 'unknown')}]")
|
||||
@@ -0,0 +1,400 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Textual-based UX state driver implementation.
|
||||
|
||||
This module provides the full HarnessConsoleUXStateDriver that connects
|
||||
the agent runner and observers to the Textual UI components. It mutates
|
||||
the application state and triggers UI updates through the Textual app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .app_state import (
|
||||
BottomPanelMode,
|
||||
ChoiceFollowUpQuestion,
|
||||
FollowUpAction,
|
||||
FollowUpMessage,
|
||||
FollowUpQuestion,
|
||||
HarnessAppState,
|
||||
OutputEntry,
|
||||
OutputEntryType,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Message
|
||||
|
||||
|
||||
# Default mode colors (mode name -> Rich color string)
|
||||
DEFAULT_MODE_COLORS: dict[str, str] = {
|
||||
"plan": "cyan",
|
||||
"execute": "green",
|
||||
"review": "yellow",
|
||||
"default": "blue",
|
||||
}
|
||||
|
||||
|
||||
def get_mode_color(mode: str | None, mode_colors: dict[str, str] | None = None) -> str:
|
||||
"""Get the color for a mode name.
|
||||
|
||||
Args:
|
||||
mode: The mode name.
|
||||
mode_colors: Optional custom mode color mapping.
|
||||
|
||||
Returns:
|
||||
A Rich color string for the mode.
|
||||
"""
|
||||
colors = mode_colors or DEFAULT_MODE_COLORS
|
||||
if mode is None:
|
||||
return colors.get("default", "blue")
|
||||
return colors.get(mode, colors.get("default", "blue"))
|
||||
|
||||
|
||||
class HarnessConsoleUXStateDriver:
|
||||
"""Full Textual-based UX state driver.
|
||||
|
||||
Implements the IUXStateDriver protocol by mutating application state
|
||||
and calling back into the Textual app to trigger UI updates.
|
||||
|
||||
The driver owns the output entry list and streaming state, and produces
|
||||
state snapshots that the app uses to render the UI.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_state: HarnessAppState,
|
||||
on_state_changed: Callable[[], None],
|
||||
mode_colors: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the state driver.
|
||||
|
||||
Args:
|
||||
app_state: The application state object to mutate.
|
||||
on_state_changed: Callback invoked after state changes to trigger UI refresh.
|
||||
mode_colors: Optional mapping of mode names to Rich color strings.
|
||||
"""
|
||||
self._state = app_state
|
||||
self._on_state_changed = on_state_changed
|
||||
self._mode_colors = mode_colors
|
||||
|
||||
# Streaming bookkeeping
|
||||
self._has_received_any_text = False
|
||||
self._current_streaming_entry: OutputEntry | None = None
|
||||
self._current_streaming_entry_index: int = -1
|
||||
self._last_entry_type: OutputEntryType | None = None
|
||||
|
||||
@property
|
||||
def state(self) -> HarnessAppState:
|
||||
"""Get the current application state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def current_mode(self) -> str | None:
|
||||
"""Get the current agent mode."""
|
||||
return self._state.mode_text
|
||||
|
||||
@current_mode.setter
|
||||
def current_mode(self, value: str | None) -> None:
|
||||
"""Set the current agent mode."""
|
||||
self._state.mode_text = value
|
||||
self._state.mode_color = get_mode_color(value, self._mode_colors)
|
||||
self._notify()
|
||||
|
||||
# --- Streaming lifecycle ---
|
||||
|
||||
def begin_streaming(self) -> None:
|
||||
"""Begin streaming mode - switch bottom panel and show spinner."""
|
||||
self._state.mode = BottomPanelMode.STREAMING
|
||||
self._state.show_spinner = True
|
||||
self._state.input_enabled = False
|
||||
self._notify()
|
||||
|
||||
def begin_streaming_output(self) -> None:
|
||||
"""Reset per-turn streaming bookkeeping."""
|
||||
self._has_received_any_text = False
|
||||
self._current_streaming_entry = None
|
||||
self._current_streaming_entry_index = -1
|
||||
|
||||
def end_streaming(self) -> None:
|
||||
"""End streaming mode - return to text input."""
|
||||
self._state.mode = BottomPanelMode.TEXT_INPUT
|
||||
self._state.show_spinner = False
|
||||
self._state.input_enabled = True
|
||||
self._notify()
|
||||
|
||||
async def end_streaming_output(self) -> None:
|
||||
"""Finalize streaming output - add trailing newline if text was received."""
|
||||
if self._has_received_any_text:
|
||||
self._current_streaming_entry = None
|
||||
self._last_entry_type = OutputEntryType.STREAM_FOOTER
|
||||
self._notify()
|
||||
|
||||
def set_show_spinner(self, show: bool) -> None:
|
||||
"""Show or hide the spinner."""
|
||||
self._state.show_spinner = show
|
||||
self._notify()
|
||||
|
||||
# --- Text output ---
|
||||
|
||||
def write_user_input_echo(self, text: str) -> None:
|
||||
"""Echo user input to the output area."""
|
||||
entry = OutputEntry(
|
||||
type=OutputEntryType.USER_INPUT,
|
||||
text=f"You: {text}",
|
||||
color="green",
|
||||
)
|
||||
self._append_entry(entry)
|
||||
self._last_entry_type = OutputEntryType.USER_INPUT
|
||||
self._notify()
|
||||
|
||||
def append_info_line(self, text: str, color: str | None = None) -> None:
|
||||
"""Append an informational line to the output."""
|
||||
effective_color = color or get_mode_color(self._state.mode_text, self._mode_colors)
|
||||
|
||||
# Add separator when transitioning from streaming text
|
||||
prefix = ""
|
||||
if self._last_entry_type in (OutputEntryType.STREAMING_TEXT, OutputEntryType.STREAM_FOOTER):
|
||||
prefix = "" # Textual handles spacing via widget layout
|
||||
|
||||
entry = OutputEntry(
|
||||
type=OutputEntryType.INFO_LINE,
|
||||
text=prefix + text,
|
||||
color=effective_color,
|
||||
)
|
||||
self._append_entry(entry)
|
||||
self._last_entry_type = OutputEntryType.INFO_LINE
|
||||
self._notify()
|
||||
|
||||
def append_stream_footer(self, text: str) -> None:
|
||||
"""Append a footer line after streaming ends."""
|
||||
entry = OutputEntry(
|
||||
type=OutputEntryType.STREAM_FOOTER,
|
||||
text=text,
|
||||
color="dim",
|
||||
)
|
||||
self._append_entry(entry)
|
||||
self._last_entry_type = OutputEntryType.STREAM_FOOTER
|
||||
self._notify()
|
||||
|
||||
async def write_info_line(self, text: str, color: str | None = None) -> None:
|
||||
"""Async version of append_info_line."""
|
||||
self.append_info_line(text, color)
|
||||
|
||||
def write_text(self, text: str, color: str | None = None) -> None:
|
||||
"""Write streaming text from the agent.
|
||||
|
||||
Accumulates text into the current streaming entry. If the streaming
|
||||
entry is still the last output item, appends to it in place. Otherwise
|
||||
starts a new streaming entry.
|
||||
|
||||
Args:
|
||||
text: The text chunk to append.
|
||||
color: Optional Rich color.
|
||||
"""
|
||||
self._last_entry_type = OutputEntryType.STREAMING_TEXT
|
||||
self._has_received_any_text = True
|
||||
|
||||
effective_color = color or get_mode_color(self._state.mode_text, self._mode_colors)
|
||||
|
||||
if (
|
||||
self._current_streaming_entry is not None
|
||||
and self._current_streaming_entry_index == len(self._state.output_entries) - 1
|
||||
):
|
||||
# Append to existing streaming entry in place
|
||||
self._current_streaming_entry.text += text
|
||||
# Update the entry in the list (same object, but trigger notify)
|
||||
else:
|
||||
# Start a fresh streaming entry
|
||||
self._current_streaming_entry = OutputEntry(
|
||||
type=OutputEntryType.STREAMING_TEXT,
|
||||
text=text,
|
||||
color=effective_color,
|
||||
)
|
||||
self._state.output_entries.append(self._current_streaming_entry)
|
||||
self._current_streaming_entry_index = len(self._state.output_entries) - 1
|
||||
|
||||
self._notify()
|
||||
|
||||
def update_streaming_text(self, accumulated_text: str) -> None:
|
||||
"""Update the accumulated streaming text (full replacement).
|
||||
|
||||
Alternative to write_text() - replaces the entire streaming entry text.
|
||||
If an info_line was appended after the streaming entry (e.g., a tool
|
||||
call), creates a new streaming entry at the end of the list so the
|
||||
UI can render it.
|
||||
|
||||
Args:
|
||||
accumulated_text: The full accumulated text so far.
|
||||
"""
|
||||
effective_color = get_mode_color(self._state.mode_text, self._mode_colors)
|
||||
|
||||
if (
|
||||
self._current_streaming_entry is not None
|
||||
and self._current_streaming_entry_index == len(self._state.output_entries) - 1
|
||||
):
|
||||
# Streaming entry is still the last entry — update in place
|
||||
self._current_streaming_entry.text = accumulated_text
|
||||
else:
|
||||
# Either no current entry, or it's no longer at the end (an
|
||||
# info_line was appended after it). Create a new streaming entry
|
||||
# so the panel can render the continued text.
|
||||
self._current_streaming_entry = OutputEntry(
|
||||
type=OutputEntryType.STREAMING_TEXT,
|
||||
text=accumulated_text,
|
||||
color=effective_color,
|
||||
)
|
||||
self._state.output_entries.append(self._current_streaming_entry)
|
||||
self._current_streaming_entry_index = len(self._state.output_entries) - 1
|
||||
|
||||
self._last_entry_type = OutputEntryType.STREAMING_TEXT
|
||||
self._has_received_any_text = True
|
||||
self._notify()
|
||||
|
||||
async def write_no_text_warning(self, has_follow_up_actions: bool) -> None:
|
||||
"""Write '(no text response)' warning if no text was received."""
|
||||
if not self._has_received_any_text and not has_follow_up_actions:
|
||||
self.append_stream_footer("(no text response from agent)")
|
||||
|
||||
# --- Usage and mode ---
|
||||
|
||||
def set_usage_text(self, usage_text: str | None) -> None:
|
||||
"""Set the token usage text."""
|
||||
self._state.usage_text = usage_text
|
||||
self._notify()
|
||||
|
||||
def set_mode(self, mode: str | None, mode_color: str | None = None) -> None:
|
||||
"""Set the current mode."""
|
||||
self._state.mode_text = mode
|
||||
self._state.mode_color = mode_color or get_mode_color(mode, self._mode_colors)
|
||||
self._notify()
|
||||
|
||||
# --- Follow-up actions ---
|
||||
|
||||
def enqueue_follow_up_action(self, action: FollowUpAction) -> None:
|
||||
"""Enqueue a follow-up action."""
|
||||
if isinstance(action, FollowUpMessage):
|
||||
self._state.accumulated_follow_up_responses.append(action.message)
|
||||
elif isinstance(action, FollowUpQuestion):
|
||||
self.queue_follow_up_questions([action])
|
||||
|
||||
def queue_follow_up_questions(self, questions: list[FollowUpQuestion]) -> None:
|
||||
"""Queue follow-up questions for user interaction.
|
||||
|
||||
Args:
|
||||
questions: List of questions to queue.
|
||||
"""
|
||||
if not questions:
|
||||
return
|
||||
|
||||
was_empty = len(self._state.pending_questions) == 0
|
||||
self._state.pending_questions.extend(questions)
|
||||
|
||||
if was_empty:
|
||||
self._configure_for_head_question(self._state.pending_questions[0])
|
||||
|
||||
self._notify()
|
||||
|
||||
def add_follow_up_response(self, response: Message) -> None:
|
||||
"""Add a follow-up response message."""
|
||||
self._state.accumulated_follow_up_responses.append(response)
|
||||
|
||||
def advance_follow_up_question(self) -> None:
|
||||
"""Advance to the next follow-up question.
|
||||
|
||||
Removes the head question from the queue. If more questions remain,
|
||||
configures the UI for the next one. Otherwise returns to text input.
|
||||
"""
|
||||
if not self._state.pending_questions:
|
||||
return
|
||||
|
||||
self._state.pending_questions.pop(0)
|
||||
|
||||
if self._state.pending_questions:
|
||||
self._configure_for_head_question(self._state.pending_questions[0])
|
||||
else:
|
||||
# No more questions - return to text input
|
||||
self._state.mode = BottomPanelMode.TEXT_INPUT
|
||||
self._state.list_selection_options = []
|
||||
self._state.list_selection_title = None
|
||||
self._state.list_selection_custom_text_placeholder = None
|
||||
self._state.list_selection_index = 0
|
||||
self._state.list_selection_custom_input_text = ""
|
||||
|
||||
self._notify()
|
||||
|
||||
def take_follow_up_responses(self) -> list[Message]:
|
||||
"""Take and clear all accumulated follow-up responses.
|
||||
|
||||
Returns:
|
||||
List of accumulated response messages.
|
||||
"""
|
||||
responses = list(self._state.accumulated_follow_up_responses)
|
||||
self._state.accumulated_follow_up_responses.clear()
|
||||
return responses
|
||||
|
||||
def has_pending_questions(self) -> bool:
|
||||
"""Check if there are pending follow-up questions.
|
||||
|
||||
Returns:
|
||||
True if unanswered questions exist in the queue.
|
||||
"""
|
||||
return len(self._state.pending_questions) > 0
|
||||
|
||||
# --- Queued messages (message injection) ---
|
||||
|
||||
def set_queued_messages(self, pending: list[str]) -> None:
|
||||
"""Set the queued message display.
|
||||
|
||||
Args:
|
||||
pending: List of pending message texts.
|
||||
"""
|
||||
self._state.queued_items = [f"💬 {text}" for text in pending]
|
||||
self._notify()
|
||||
|
||||
# --- Internal helpers ---
|
||||
|
||||
def _append_entry(self, entry: OutputEntry) -> None:
|
||||
"""Append an output entry to the state."""
|
||||
self._state.output_entries.append(entry)
|
||||
|
||||
def _configure_for_head_question(self, question: FollowUpQuestion) -> None:
|
||||
"""Configure the UI for the current head question.
|
||||
|
||||
Args:
|
||||
question: The question to display.
|
||||
"""
|
||||
if isinstance(question, ChoiceFollowUpQuestion):
|
||||
self._state.mode = BottomPanelMode.LIST_SELECTION
|
||||
self._state.list_selection_options = list(question.choices)
|
||||
self._state.list_selection_title = question.prompt
|
||||
self._state.list_selection_custom_text_placeholder = (
|
||||
"✏️ Type a custom response..." if question.allow_custom_text else None
|
||||
)
|
||||
self._state.list_selection_index = 0
|
||||
self._state.list_selection_custom_input_text = ""
|
||||
else:
|
||||
# Text question - show as info line and switch to text input
|
||||
self.append_info_line(question.prompt)
|
||||
self._state.mode = BottomPanelMode.TEXT_INPUT
|
||||
self._state.list_selection_options = []
|
||||
self._state.list_selection_title = None
|
||||
|
||||
def _notify(self) -> None:
|
||||
"""Notify the app that state has changed."""
|
||||
self._on_state_changed()
|
||||
|
||||
def request_shutdown(self) -> None:
|
||||
"""Request the application to shut down."""
|
||||
self._state.shutdown_requested = True
|
||||
self._notify()
|
||||
|
||||
def replace_session(self, session) -> None:
|
||||
"""Replace the current agent session.
|
||||
|
||||
Args:
|
||||
session: The new AgentSession to use.
|
||||
"""
|
||||
self._state.replaced_session = session
|
||||
self._notify()
|
||||
@@ -1,6 +1,19 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "agent-framework",
|
||||
# "textual>=6.2.1",
|
||||
# "rich>=13.7.1",
|
||||
# "azure-identity",
|
||||
# "python-dotenv",
|
||||
# ]
|
||||
# ///
|
||||
# Run with any PEP 723 compatible runner, e.g.:
|
||||
# uv run samples/02-agents/harness/harness_research.py
|
||||
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Harness Research Assistant.
|
||||
"""Harness Research Assistant with Console UI.
|
||||
|
||||
Demonstrates ``create_harness_agent`` — a factory function that builds a
|
||||
pre-configured agent with batteries included, automatically wiring up function
|
||||
@@ -16,12 +29,9 @@ context providers:
|
||||
- **Web Search** — real-time web search via ``get_web_search_tool()``
|
||||
|
||||
The sample creates a research-focused agent with web search capability and runs
|
||||
a simple interactive chat loop. The agent will plan research tasks using todos,
|
||||
switch between plan and execute modes, search the web for current information,
|
||||
and track its progress.
|
||||
|
||||
Special commands:
|
||||
/exit — End the session.
|
||||
it inside the Textual-based harness console. The agent will plan research tasks
|
||||
using todos, switch between plan and execute modes, search the web for current
|
||||
information, and track its progress.
|
||||
|
||||
Environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint URL
|
||||
@@ -36,19 +46,24 @@ import asyncio
|
||||
from agent_framework import create_harness_agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from console import build_observers_with_planning, run_agent_async
|
||||
from dotenv import load_dotenv
|
||||
|
||||
RESEARCH_INSTRUCTIONS = """\
|
||||
## Research Assistant Instructions
|
||||
|
||||
You are a research assistant. When given a research topic, research it thoroughly using web search and web browsing.
|
||||
Use your knowledge to form good search queries and hypotheses, but always verify claims with the tools available to you rather than relying on memory alone.
|
||||
You are a research assistant. When given a research topic, research it
|
||||
thoroughly using web search and web browsing. Use your knowledge to form good
|
||||
search queries and hypotheses, but always verify claims with the tools
|
||||
available to you rather than relying on memory alone.
|
||||
|
||||
### Research quality
|
||||
|
||||
Consult multiple sources when possible and cross-reference key claims.
|
||||
When sources disagree, note the discrepancy and explain which source you consider more reliable and why.
|
||||
If a web page fails to load or a search returns irrelevant results, try alternative search queries or sources before moving on.
|
||||
When sources disagree, note the discrepancy and explain which source you
|
||||
consider more reliable and why.
|
||||
If a web page fails to load or a search returns irrelevant results, try
|
||||
alternative search queries or sources before moving on.
|
||||
Track your sources — you will need them when presenting results.
|
||||
|
||||
### Presenting results
|
||||
@@ -58,7 +73,8 @@ When presenting your final findings:
|
||||
- Use clear sections with headings for each major topic or sub-question.
|
||||
- Cite your sources inline (e.g., "According to [source name](URL), ...").
|
||||
- End with a brief summary of key takeaways.
|
||||
- In addition to returning the results to the user, save the final research report to file memory so it survives compaction and can be referenced later.
|
||||
- In addition to returning the results to the user, save the final research
|
||||
report to file memory so it survives compaction and can be referenced later.
|
||||
"""
|
||||
|
||||
|
||||
@@ -82,64 +98,17 @@ async def main() -> None:
|
||||
agent_instructions=RESEARCH_INSTRUCTIONS,
|
||||
)
|
||||
|
||||
# Create a session to maintain conversation state across turns.
|
||||
session = agent.create_session()
|
||||
|
||||
print("Research Assistant (powered by create_harness_agent)")
|
||||
print("=" * 50)
|
||||
print("Enter a research topic to get started.")
|
||||
print("Type /exit to end the session.\n")
|
||||
|
||||
# Simple interactive chat loop.
|
||||
while True:
|
||||
user_input = input("You: ").strip()
|
||||
if not user_input:
|
||||
continue
|
||||
if user_input.lower() == "/exit":
|
||||
print("\nGoodbye!")
|
||||
break
|
||||
|
||||
# Run the agent with streaming and print the response as it arrives.
|
||||
print("\nAssistant: ", end="", flush=True)
|
||||
async for update in agent.run(user_input, session=session, stream=True):
|
||||
if update.contents:
|
||||
for content in update.contents:
|
||||
# Print a brief message for each tool call in the stream.
|
||||
if content.type == "function_call":
|
||||
print(f"\n [calling tool: {content.name}]", flush=True)
|
||||
print(" ", end="", flush=True)
|
||||
# Show web search activity when the result arrives with action details.
|
||||
elif (
|
||||
content.type in ("search_tool_call", "search_tool_result")
|
||||
and getattr(content, "tool_name", None) == "web_search"
|
||||
):
|
||||
action = None
|
||||
if content.type == "search_tool_result" and isinstance(content.result, dict):
|
||||
action = content.result.get("action", {})
|
||||
elif content.type == "search_tool_call":
|
||||
action = content.arguments if isinstance(content.arguments, dict) else None
|
||||
if action:
|
||||
action_type = action.get("type", "search")
|
||||
if action_type == "search":
|
||||
queries = action.get("queries") or []
|
||||
query_str = ", ".join(f'"{q}"' for q in queries) if queries else action.get("query", "")
|
||||
print(f"\n 🌐 Web search: {query_str}", flush=True)
|
||||
print(" ", end="", flush=True)
|
||||
elif action_type == "open_page":
|
||||
url = action.get("url", "(unknown)")
|
||||
print(f"\n 🌐 Opening: {url}", flush=True)
|
||||
print(" ", end="", flush=True)
|
||||
elif action_type == "find_in_page":
|
||||
pattern = action.get("pattern", "")
|
||||
print(f'\n 🌐 Find in page: "{pattern}"', flush=True)
|
||||
print(" ", end="", flush=True)
|
||||
else:
|
||||
print(f"\n 🌐 Web search: {action_type}", flush=True)
|
||||
print(" ", end="", flush=True)
|
||||
# Print text content as it streams in.
|
||||
if update.text:
|
||||
print(update.text, end="", flush=True)
|
||||
print("\n")
|
||||
# Run the harness console with the research agent.
|
||||
await run_agent_async(
|
||||
agent,
|
||||
session=agent.create_session(),
|
||||
observers=build_observers_with_planning(agent),
|
||||
initial_mode="plan",
|
||||
title="🔬 Research Assistant",
|
||||
placeholder="Enter a research topic...",
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Generated
+123
-28
@@ -543,7 +543,7 @@ requires-dist = [
|
||||
{ name = "agent-framework-core", editable = "packages/core" },
|
||||
{ name = "agent-framework-openai", editable = "packages/openai" },
|
||||
{ name = "azure-ai-inference", specifier = ">=1.0.0b9,<1.0.0b10" },
|
||||
{ name = "azure-ai-projects", specifier = ">=2.1.0,<3.0" },
|
||||
{ name = "azure-ai-projects", specifier = ">=2.2.0,<3.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1242,7 +1242,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "azure-ai-projects"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -1252,9 +1252,9 @@ dependencies = [
|
||||
{ name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/76/3fdede8eddfe5927a571898a15f0288ba30fee78e5ba099f88df3ded70af/azure_ai_projects-2.1.0.tar.gz", hash = "sha256:f0749fa9a174255aa1a5550fb6078208521518472907a4c6dd552767d9b39caa", size = 543343, upload-time = "2026-04-20T17:06:48.751Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/24342aea74fe75b0a8378b6eff665b9c1cb63f855c1a96f70a0095e474a2/azure_ai_projects-2.2.0.tar.gz", hash = "sha256:58ee31bb031cfb004051145c545294bb0d32de679c670c312ef384845bd72cef", size = 668496, upload-time = "2026-05-30T00:20:59.099Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/f6/4984e7772a97c7a9e6505a3de8e55a5070fa2b02cd7e980da91e0d9b9b97/azure_ai_projects-2.1.0-py3-none-any.whl", hash = "sha256:6f259d8eb9167d2dfd372006d0221a8118faeaeb05829fa898b595bc6f19c699", size = 274309, upload-time = "2026-04-20T17:06:50.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/cf/90f27a2b48c9b748f84194b07e565f900e7f0ce0500da9b9f067dca599d3/azure_ai_projects-2.2.0-py3-none-any.whl", hash = "sha256:8f89bdaca4df1bd479d3bd2bd0f19a0905d60be6d17b84a69e8fabd82eac5906", size = 344307, upload-time = "2026-05-30T00:21:00.672Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1451,30 +1451,30 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.42.59"
|
||||
version = "1.43.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "jmespath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "s3transfer", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/4e/499cb52aaee9468c346bcc1158965e24e72b4e2a20052725b680e0ac949b/boto3-1.42.59.tar.gz", hash = "sha256:6c4a14a4eb37b58a9048901bdeefbe1c529638b73e8f55413319a25f010ca211", size = 112725, upload-time = "2026-02-27T20:25:33.228Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/36/028c12ed6ed85009a21b5472eb76c27f9b0341c6986f06f83475b40aaf51/boto3-1.43.1.tar.gz", hash = "sha256:9e4f85a7884797ff0f52c257094730ed228aaa07fa8134775ff8f86909cf4f2a", size = 113175, upload-time = "2026-04-30T20:27:04.569Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/c0/22d868b9408dc5a33935a72896ec8d638b2766c459668d1b37c3e5ac2066/boto3-1.42.59-py3-none-any.whl", hash = "sha256:7a66e3e8e2087ea4403e135e9de592e6d63fc9a91080d8dac415bb74df873a72", size = 140557, upload-time = "2026-02-27T20:25:31.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/d1/b8b2d5420c51cd8f7ec044ceecbf24b060156680b26519e1d482e160c3c8/boto3-1.43.1-py3-none-any.whl", hash = "sha256:3840bf0345b9aefcc5915176a19d227f63cfba7778c65e6e52d61c6ea0a10fdc", size = 140498, upload-time = "2026-04-30T20:27:01.791Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.42.81"
|
||||
version = "1.43.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fa/5f/b0bb9a8768398fb131e1fe722c9cc5b18f74d21ca1970efe8576912b2c6e/botocore-1.42.81.tar.gz", hash = "sha256:48e6f6f52de1cc107a34810309b8ca998ea9bb719a3fe4c06f903a604b3138cb", size = 15129980, upload-time = "2026-04-01T19:35:23.439Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/03/9dc102506c3ebc3758a9e8602e7dafb78789993bcac5daa82398b56ae884/botocore-1.43.25.tar.gz", hash = "sha256:faab543ca6ae6f8fdc5f6318240bebfb8c05cd25823715fe02aad7edf0c4b383", size = 15478403, upload-time = "2026-06-08T19:49:13.505Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/33/c7a01649a6cb7219b233d2ed071ab925e52cdb64e15ce935024c0007376f/botocore-1.42.81-py3-none-any.whl", hash = "sha256:bcef8c93c20ebeba95e4f8b9edfbffbc78a0e11235425a92ee32e48fd8e03c37", size = 14807198, upload-time = "2026-04-01T19:35:20.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/a5/6ceef332b18c348be9ec26aeff0da5b3f7ea046bf3fc654feecf6974c4d9/botocore-1.43.25-py3-none-any.whl", hash = "sha256:ef1d210ac9085ea0e5fc6ad2e63f0c7e97103c74f52156bf944289aaf6278d1b", size = 15161721, upload-time = "2026-06-08T19:49:08.751Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2690,6 +2690,99 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/82/fcb6520612bec0c39b973a6c0954b6a0d948aadfe8f7e9487f60ceb8bfa6/googleapis_common_protos-1.73.1-py3-none-any.whl", hash = "sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8", size = 297556, upload-time = "2026-03-26T22:15:58.455Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "granian"
|
||||
version = "2.5.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/b1/100c5add0409559ddbbecca5835c17217b7a2e026eff999bfa359a630686/granian-2.5.7.tar.gz", hash = "sha256:4702a7bcc736454803426bd2c4e7a374739ae1e4b11d27bcdc49b691d316fa0c", size = 112206, upload-time = "2025-11-05T12:18:29.258Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/6f/7719fc97aa081915024939f0d35fdae57dfd3d7214f7ef4a7fa664abbbc3/granian-2.5.7-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7d84a254e9c88da874ba349f7892278a871acc391ab6af21cc32f58d27cd50a9", size = 2854526, upload-time = "2025-11-05T12:15:29.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cd/af33b780602f962c282ba3341131f7ee3b224a6c856a9fb11a017750a48f/granian-2.5.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8857d5a6ed94ea64d6b92d1d5fa8f7c1676bbecd71e6ca3d71fcd7118448af1d", size = 2537151, upload-time = "2025-11-05T12:15:31.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/58/1a0d529d3d3ddc11b2b292b8f2a7566812d8691de7b1fc8ea5c8f36fd81a/granian-2.5.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9914dfc93f04a53a92d8cfdb059c11d620ff83e9326a99880491a9c5bc5940ef", size = 3017277, upload-time = "2025-11-05T12:15:33.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/78/2a3c198ee379392d9998e4ff0cfd9ffa95b2d2c683bd15a7266a09325d43/granian-2.5.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24c972fe009ca3a08fd7fb182e07fcb16bffe49c87b1c3489a6986c9e9248dc1", size = 2859098, upload-time = "2025-11-05T12:15:35.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/44/7b9fba226083170e9ba221b23ab29d7ffcb761b1ef2b6ed6dac2081bc7fe/granian-2.5.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:034df207e62f104d39db479b693e03072c7eb8e202493cdf58948ff83e753cca", size = 3119567, upload-time = "2025-11-05T12:15:36.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/76/f1e348991c031a50d30d3ab0625fec3b7e811092cdb0d1e996885abf1605/granian-2.5.7-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:0719052a27caca73bf4000ccdb0339a9d6705e7a4b6613b9fa88ba27c72ba659", size = 2901389, upload-time = "2025-11-05T12:15:39.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/69/71b3d7d90d56fda5617fd98838ac481756ad64f76c1fc1b5e21c43a51f15/granian-2.5.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:be5b9224ec2583ea3b6ca90788b7f59253b6e07fcf817d14c205e6611faaf2be", size = 2989856, upload-time = "2025-11-05T12:15:41.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/42/603db3d0ede778adc979c6acc1eaafa5c670c795f5e0e14feb07772ed197/granian-2.5.7-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:ff246af31840369a1d06030f4d291c6a93841f68ee1f836036bce6625ae73b30", size = 3147378, upload-time = "2025-11-05T12:15:42.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/b5/cc557e30ba23c2934c33935768dd0233ef7a10b1e8c81dbbc63d5e2562b5/granian-2.5.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cf79375e37a63217f9c1dc4ad15200bc5a89860b321ca30d8a5086a6ea1202e4", size = 3210930, upload-time = "2025-11-05T12:15:45.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/67/ba90520cafcd13b5c76d147d713556b9eef877ca001f9ccf44d5443738b6/granian-2.5.7-cp310-cp310-win_amd64.whl", hash = "sha256:b4269a390054c0f71d9ce9d7c75ce2da0c59e78cb522016eb2f5a506c3eb6573", size = 2176887, upload-time = "2025-11-05T12:15:46.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/21/da3ade91b49ae99146daac6426701cc25b2c5f1413b6c8cb1cc048877036/granian-2.5.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7aa90dcda1fbf03604e229465380138954d9c000eca2947a94dcfbd765414d32", size = 2854652, upload-time = "2025-11-05T12:15:48.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/67/a6fa402ca5ebddebec5d46dacf646ce073872e5251915a725f6abf2a23bb/granian-2.5.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:da4f27323be1188f9e325711016ee108840e14a5971bb4b4d15b65b2d1b00a2d", size = 2537539, upload-time = "2025-11-05T12:15:50.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/70/accb5afd83ef785bd9e32067a13547c51cb0139076a8f2857d6d436773df/granian-2.5.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ca5b7028b6ebafce30419ddb6ee7fbfb236fdd0da89427811324ddd38c7d314", size = 3017554, upload-time = "2025-11-05T12:15:52.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/45/98356af5f36af2b6b47a91fef0d326c275e508bf4bcf0c08bd35ed314db8/granian-2.5.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b83e95b18be5dfa92296bc8acfeb353488123399c90cc5f0eccf451e88bc4caf", size = 2859127, upload-time = "2025-11-05T12:15:54.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/7a/04d3ec13b197509c40340ec80414fbbc2b0913f6e1a18c3987cc608c8571/granian-2.5.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9aad9e920441232a7b8ad33bef7f04aae986e0e386ab7f13312477c3ea2c85df", size = 3119494, upload-time = "2025-11-05T12:15:56.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/5d/1a82a596725824f6e76b8f7b853ceb464cd0334b2b8143c278aa46f23b6d/granian-2.5.7-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:777d35961d5139d203cf54d872ad5979b171e6496a471a5bcb8032f4471bdec6", size = 2901511, upload-time = "2025-11-05T12:15:58.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/45/b53d6d7df5cd35c3b8bb329f5ee1c7b31ead7a61a6f2046f6562028d7e1b/granian-2.5.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae72c7ba1e8f35d3021dafb2ba6c4ef89f93f877218f8c6ed1cb672145cd81ad", size = 2989828, upload-time = "2025-11-05T12:16:00.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/80/bb57b0fa24fcd518cd64442249459bd214ab1ec5f32590fd30389944261c/granian-2.5.7-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3764d87edd3fddaf557dce32be396a2a56dfc5b9ad2989b1f98952983ae4a21c", size = 3147694, upload-time = "2025-11-05T12:16:01.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/00/f8747aaf8dcd488e4462db89f7273dd9ae702fd17a58d72193b48eff0470/granian-2.5.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f5e21bbf1daebb0219253576cac4e5edc8fa8356ad85d66577c4f3ea2d5c6e3c", size = 3211169, upload-time = "2025-11-05T12:16:03.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/69/8593d539898a870692cad447d22c2c4cc34566ad9070040ca216db6ac184/granian-2.5.7-cp311-cp311-win_amd64.whl", hash = "sha256:d210dd98852825c8a49036a6ec23cdfaa7689d1cb12ddc651c6466b412047349", size = 2176921, upload-time = "2025-11-05T12:16:04.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/cf/f76d05e950f76924ffb6c5212561be4dd93fa569518869cc1233a0c77613/granian-2.5.7-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:41e3a293ac23c76d18628d1bd8376ce3230fb3afe3cf71126b8885e8da4e40c4", size = 2850787, upload-time = "2025-11-05T12:16:06.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/d7/6972aa8c38d26b4cf9f35bcc9b7d3a26a3aa930e612d5913d8f4181331a1/granian-2.5.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8b345b539bcbe6dedf8a9323b0c960530cb1fb2cfb887139e6ae9513b6c04d8c", size = 2529552, upload-time = "2025-11-05T12:16:07.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/b4/cd5958b6af674a32296a0fef73fb499c2bf2874025062323f5dbc838f4fc/granian-2.5.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e4d7ba8e3223e2bf974860a59c29b06fa805a98ad4304be4e77180d3a28f55", size = 3009131, upload-time = "2025-11-05T12:16:08.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/69/f3828de736c2802fd7fcac0bb1a0387b3332d432f0eeacb8116094926f06/granian-2.5.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e727d3518f038b64cb0352b34f43b387aafe5eb12b6c4b57ef598b811e40d4ed", size = 2852544, upload-time = "2025-11-05T12:16:10.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/c3/b8c65cf86d473b6e99e6d985c678cb192c9b9776a966a2f4b009696bb650/granian-2.5.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59fe2b352a828a2b04bcfd105e623d66786f217759d2d6245651a7b81e4ac294", size = 3131904, upload-time = "2025-11-05T12:16:13.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/7e/b60421bddf187ab2a46682423e4a94b2b22a6ddff6842bf9ca2194e62ac2/granian-2.5.7-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ec5fb593c2d436a323e711010e79718e6d5d1491d0d660fb7c9d97f7e5900830", size = 2908851, upload-time = "2025-11-05T12:16:15.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/cf/3f2426e19dc955a74dc94a5a47c4170e68acb060c541ac080f71a9d55d5d/granian-2.5.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:48fbc25f3717d01e11547afe0e9cdf9d7c41c9f316b9623a40c22ea6b2128d36", size = 2993270, upload-time = "2025-11-05T12:16:17.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/2e/67e1e05ee0d503cc6e9fe53b03f69eb2f267a589d7b40873d120c417385f/granian-2.5.7-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:770935fec3374b814d21c01508c0697842d7c3750731a8ea129738b537ac594c", size = 3134662, upload-time = "2025-11-05T12:16:18.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d5/9d3242bbd911434c4f3d4f14c48e73774a8ddb591e0f975eaeeaef1d5081/granian-2.5.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5db2600c92f74da74f624d2fdb01afe9e9365b50bd4e695a78e54961dc132f1b", size = 3220446, upload-time = "2025-11-05T12:16:20.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/27/b2baa0443a42d8eb59f3dfbe8186e8c80a090655584af4611f22f1592d7a/granian-2.5.7-cp312-cp312-win_amd64.whl", hash = "sha256:bc368bdeb21646a965adf9f43dd2f4a770647e50318ba1b7cf387d4916ed7e69", size = 2179465, upload-time = "2025-11-05T12:16:22.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/ec/bf1b7eefe824630d1d3ae9a8af397d823f2339d3adec71e9ee49d667409c/granian-2.5.7-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:fafb9c17def635bb0a5e20e145601598a6767b879bc2501663dbb45a57d1bc2e", size = 2850581, upload-time = "2025-11-05T12:16:23.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/f7/5172daf1968c3a2337c51c50f4a3013aaab564d012d3a79e8390cc66403b/granian-2.5.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9616a197eba637d59242661be8a46127c3f79f7c9bbfa44c0ea8c8c790a11d5e", size = 2529452, upload-time = "2025-11-05T12:16:25.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/10/4344ccacc3f8dea973d630306491de43fbd4a0248e3f7cc9ff09ed5cc524/granian-2.5.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cfd7a09d5eb00a271ec79e3e0bbf069aa62ce376b64825bdeacb668d2b2a4041", size = 3008798, upload-time = "2025-11-05T12:16:26.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/33/638cf8c7f23ab905d3f6a371b5f87d03fd611678424223a0f1d0f7766cc7/granian-2.5.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1438a82264690fce6e82de66a95c77f5b0a5c33b93269eb85fc69ce0112c12d5", size = 2852309, upload-time = "2025-11-05T12:16:28.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/42/6ec25d37ffc1f08679e6b325e9f9ac199ba5def948904c9205cd34fbfe6b/granian-2.5.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3573121da77aac1af64cf90a88f29b2daecbf92458beec187421a382039f366", size = 3131335, upload-time = "2025-11-05T12:16:29.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/db85dac58d84d3e50e427fe5b60b4f8e8a561d9784971fa3b2879198ad88/granian-2.5.7-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:34cdb82024efbcc9de01c7505213be17e4ba5e7a3acabe74ecd93ba31de7673e", size = 2908705, upload-time = "2025-11-05T12:16:31.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/25/a38fd12e1661bbd8535203a8b61240feac7b6b96726bff4de23b0078ab9f/granian-2.5.7-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:572451e94de69df228e4314cb91a50dee1565c4a53d33ffac5936c6ec9c5aba2", size = 2993118, upload-time = "2025-11-05T12:16:32.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/cd/852913a0fc30efc24495453c0f973dd74ef13aa0561afb352afa4b6ecbc2/granian-2.5.7-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6e1679a4b102511b483774397134d244108851ae7a1e8bef09a8ef927ab4d370", size = 3134260, upload-time = "2025-11-05T12:16:34.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/64/0dff100ce1e43c700918b39656cc000b1163c144eac3a12563a5f692dcd1/granian-2.5.7-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:285be70dcf3c70121afec03e691596db94bd786f9bebc229e9e0319686857d82", size = 3219987, upload-time = "2025-11-05T12:16:36.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/ab/e66cf9bf57800dd7c2a2a4b8f23124603fce561a65a176f4cf3794a85b92/granian-2.5.7-cp313-cp313-win_amd64.whl", hash = "sha256:1273c9b1d38d19bcdd550a9a846d07112e541cfa1f99be04fbb926f2a003df3d", size = 2179201, upload-time = "2025-11-05T12:16:37.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0e/feca4a20e7b9e7de0e58103278c6581ebf3d5c1b972ed1c2dcfd25741f15/granian-2.5.7-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:75b9798bc13baa76e35165e5a778cd58a7258d5a2112ed6ef84ef84874244856", size = 2776744, upload-time = "2025-11-05T12:16:41.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/fe/65ca38ba9b9f4805495d96ed7b774dfd300f7c944f088db39c676c16501e/granian-2.5.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4cb8247728680ca308b7dc41a6d27582b78e15e902377e89000711f1126524dd", size = 2465942, upload-time = "2025-11-05T12:16:43.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/d1/b9dea32fbafabe5c7b049fb0209149a37c6b8468c698d066448cbe88dc85/granian-2.5.7-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64348b83f1ad2f7a29df7932dc518ad669cb61a08a9cde02ca8ede8e9b110506", size = 3015413, upload-time = "2025-11-05T12:16:45.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9e/d29485ab18896e4d911e33b006af7a9b7098316a78938d6b7455c523fea5/granian-2.5.7-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e2292d4a4661c79d471fa0ff6fe640018c923b6a6dd1bb5383b368b3d5ec2a0c", size = 2783371, upload-time = "2025-11-05T12:16:46.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/cd/58c67dc191caeecbbb15ee39d433136dd064c13778b4551661bd902b5a78/granian-2.5.7-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:45903d2f2f88a9cd4a7d0b8ec329db1fb2d9e15bf38153087a3b217b9cdb0046", size = 2979946, upload-time = "2025-11-05T12:16:48.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/0b/04e4977df3ef7607a8b6625caed7cac107a049120d2452c33392d4544875/granian-2.5.7-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:106e8988e42e527c18b763be5faae7e8f602caac6cb93657793638fc9ab41c98", size = 3123177, upload-time = "2025-11-05T12:16:49.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/89/4e10e18fc107e5929143a06d9257646963cf5621c928b3d2774e5a85652a/granian-2.5.7-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:711632e602c4ea08b827bf6095c2c6fbe6005c7a05f142ae2b4d9e1d45cefbd9", size = 3211773, upload-time = "2025-11-05T12:16:51.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/81/94e416056d8b4b1cd09cc8065a1e240b0af99f21301c209571530cd83dd0/granian-2.5.7-cp313-cp313t-win_amd64.whl", hash = "sha256:1c571733aa0fdb6755be9ffb3cd728ef965ae565ba896e407d6019bad929d7bb", size = 2174154, upload-time = "2025-11-05T12:16:53.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/89/207ebcbd084ed992ecb3739376fd292e6a5bf6ae80b35f06e4f382e1f193/granian-2.5.7-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:74ad35feeafc12efdc27d59a393f8b95235095c4e46c8b8dd6d50ee9e928118d", size = 2834664, upload-time = "2025-11-05T12:16:54.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/4b/f941c645d5e3ab495f0cb056abebdb16fb761f713c35a830521f4531674b/granian-2.5.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:875f5cc36b960039bfc99a37af32ad98b3abe753a6de92a9f91268c16bfeb192", size = 2510662, upload-time = "2025-11-05T12:16:56.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/14/af9bbf26389f6d0cbdd7445cc969da50965363b2c9635acdae08eb4f2d9b/granian-2.5.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:478123ee817f742a6f67050ae4de46bc807c874e397a379cf9fb9ed68b66d7ad", size = 3003249, upload-time = "2025-11-05T12:16:58.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/0e/4fa5d4317ff88eab5d061cb45339fdf09a044ae9c7b2496b81c2de5bc2c6/granian-2.5.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d0d0530960250ac9b78494999f2687c627ac5060013e4c63856afb493c2518", size = 2844121, upload-time = "2025-11-05T12:16:59.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/05/977fcfe66c9ecd72da47e5185bcd78150efcb5d3bca1ba77860fe8f7bad7/granian-2.5.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50cf8cb02253bfc42ee1bb6c5912507f83bea0a39c3d8a09988939407e08787b", size = 3125524, upload-time = "2025-11-05T12:17:02.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/c0/fd4d0b455d34c493cfbc6f450e0005206ab41a68f65f16f89e9ae84669ed/granian-2.5.7-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:78015fcb4d055e0eb2454d07f167ca2aa9f48609f90484750b99ca9b719701c4", size = 2902047, upload-time = "2025-11-05T12:17:04.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/55/13d53add16a349b5c9384afac14b519a54b7fa4bf73540338296f0963ee7/granian-2.5.7-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bd254e68cc8471b725aa6610b68a5e004aa92b8db53c0d01c408bef8bc9cdcb4", size = 2988366, upload-time = "2025-11-05T12:17:05.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/b3/addad51cef2472105b664b608a2b8eccc5691d08c532862cd21b52023661/granian-2.5.7-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:028480ddef683df00064664e7bf58358650722dfa40c2a6dcbf50b3d1996dbb0", size = 3128826, upload-time = "2025-11-05T12:17:07.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/2c/ceab57671c7ade9305ed9e86471507b7721e92435509bb3ecab7e1c28fa8/granian-2.5.7-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b42a254b2884b3060dcafc49dee477f3f6e8c63c567f179dbec7853d6739f124", size = 3212960, upload-time = "2025-11-05T12:17:09.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/5b/5458d995ed5a1fe4a7aa1e2587f550c00ec80d373531e270080e4d5e1ca5/granian-2.5.7-cp314-cp314-win_amd64.whl", hash = "sha256:8f6466077c76d92f8926885280166e6874640bbab11ce10c4a3b04c0ee182ac6", size = 2168248, upload-time = "2025-11-05T12:17:10.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/6d/3c6fdf84e9de25e0023302d5efd98d70fd6147cae98453591a317539bba6/granian-2.5.7-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:6fc06ac1c147e2f01639aa5c7c0f9553f8c6b283665d13d5527a051e917db150", size = 2763007, upload-time = "2025-11-05T12:17:12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/92/3fc35058908d1ecb3cb556de729e6f5853e888ac7022a141885f6a3079a5/granian-2.5.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dec92e09f512aaf532bb75de69b858958113efe52b16a9c5ef19d64063b4956c", size = 2448084, upload-time = "2025-11-05T12:17:13.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/82/3fc67aa247dcac09c948ae8a3dc02568d4eb8135f9938594ee5d2ba25a4f/granian-2.5.7-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01e18c9c63b89370e42d65bc4eccec349d0b676ee69ccbcbbf9bedf606ded129", size = 3008404, upload-time = "2025-11-05T12:17:15.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/4c/11f293a60892df7cfdcbb1648ddc31e9d4471b52843e4e838a2a58773fff/granian-2.5.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:035e3b145827a12fb25de5b5122a11d9dad93a943e2251d83ee593b28b0397dc", size = 2781744, upload-time = "2025-11-05T12:17:17.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/a0/d4f0063938431201fc7884c7e7bfc5488e3de09957cce37090af9131b7f4/granian-2.5.7-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:21278e2862d7e52996b03260a2a65288c731474c71a6d8311ef78025696b883d", size = 2977678, upload-time = "2025-11-05T12:17:19.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/85/327e15e9e96eb35fcca3fbd9848df6bc180f7fb04c9116e22d3c10ada98e/granian-2.5.7-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:fd6a7645117034753ec91e667316e93f3d0325f79462979af3e2e316278ae235", size = 3116889, upload-time = "2025-11-05T12:17:21.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/5c/67224ee8fa71ee3748d931c34cf6f85e30c77b2a3ac0b1ca70c640b37d10/granian-2.5.7-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:133d3453d29c5a22648c879d078d097a4ea74b8f84c530084c32debdfdd9d5fd", size = 3203908, upload-time = "2025-11-05T12:17:23.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/e0/df08a75311c8d9505dc4f381a4a21bbfeed58b8c8f6d7c3a34b049ad9c34/granian-2.5.7-cp314-cp314t-win_amd64.whl", hash = "sha256:ab8f0f4f22d2efcce194f5b1d66beef2ba3d4bcd18f9afd6b749afa48fdb9a7d", size = 2161670, upload-time = "2025-11-05T12:17:25.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/25/2a4112983df5ce0ec8407121ad72c17d27ebfad57085749b8e4164d69e63/granian-2.5.7-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdae1c86357bfe895ffd0065c0403913bc008f752e2f77ab363d4e3b4276009b", size = 2838744, upload-time = "2025-11-05T12:17:45.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/0a/eb0c5b71355e8f99b89dc335f16cd5108763c554e96a2aae5e7162ef4997/granian-2.5.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:bc1d8aaf5bfc5fc9f8f590a42e9f88a43d19ad71f670c6969fa791b52ce1f5ec", size = 2538706, upload-time = "2025-11-05T12:17:47.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/9c/4c592c5a813a921033a37a0f003278b1f772a6c9abd16f821bcb119151f0/granian-2.5.7-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:288b62c19aea5b162d27e229469b6307a78cb272aa8fcc296dbfca9fbbda4d8f", size = 3117369, upload-time = "2025-11-05T12:17:49.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/35/96af9f0995a7c45f0cd31261ab6284e5d6028afa17c6fcfe757cccb0afb5/granian-2.5.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:66c3d2619dc5e845d658cf3ed4f7370f83d5323a85ff8338e7c7a27d9a333841", size = 2904972, upload-time = "2025-11-05T12:17:50.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/93/45c253983c2001f534ba2c7bc1e53718fc8cecf196b1e1a0469d5874ae54/granian-2.5.7-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:323e35d5d5054d2568fc824798471e7d33314f47aebd556c4fbf4894e539347d", size = 2991986, upload-time = "2025-11-05T12:17:52.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/77/c03e60c7bed386ab16cf15b317dea7f95dde5095af6e17cbd657cd82c21b/granian-2.5.7-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:026ef2588a2b991b250768bf47538fd5fd864549535f885239b6908b214299c4", size = 3163649, upload-time = "2025-11-05T12:17:54.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/c9/2bce3db4e3da8d3a697c363c8f699b71f05b7f7a0458e1ba345eaea53fcd/granian-2.5.7-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:4717a62c0a1b79372c495b99ade18bfc3c4a365242bf75770c96a4767a9bcf66", size = 3201886, upload-time = "2025-11-05T12:17:56.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/66/997ebfd8cc4a0640befb970bc846a76437d1f0b55dff179e69f29fa4615b/granian-2.5.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:4b57ae0a2e1dbc7a248e3c08440b490b3f247e7e4f997faa72e82f5a89d0ea4c", size = 2175219, upload-time = "2025-11-05T12:17:58.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/0f/da2588ac78254a4d0be90a6f733d0bb7dd1edb78a10d9e59fa9837687e94/granian-2.5.7-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:bee545c9b9e38eabcdd675e3fec1a2112b8193dc864739952b9de8131433a31c", size = 2838886, upload-time = "2025-11-05T12:17:59.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/34/75def8343534e9d48362c43c3cbd06242a2d7804fbfbc824c8aa9fb75a30/granian-2.5.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:73c76c0f1ee46506224e92df193b4d271ea89f0d82cd69301784ca85bc1db515", size = 2538597, upload-time = "2025-11-05T12:18:01.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/5d/d828d97aad050cfc5b18a0163b532c289a35ad214e31f5a129695b2b4cae/granian-2.5.7-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68879c27aed972f647a8e8ef37f9046f71d7507dc9b3ceffa97d2fbffe6a16c8", size = 3117570, upload-time = "2025-11-05T12:18:03.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/57/b8380f3d6b6dcdcd454d720cf11dbecb0e2071a870f44eb834011f14b573/granian-2.5.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ea9cbdfbd750813866dcc9c020018e5f20a57a4e3a83bd049ccc1f6da0559b75", size = 2905089, upload-time = "2025-11-05T12:18:05.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/e9/04a7c3b83650afc4a4ad82b67e6306d99f80ac1a6aacb3a8ba182f7359d6/granian-2.5.7-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d142ff5ee6027515370e56f95d179ec3e81bd265d5b4958de2b19adcdf34887d", size = 2991867, upload-time = "2025-11-05T12:18:07.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/bf/a1cdbff73cbac4fddf817d06c13ce6cdc75c22d6da1b257e3563fea4c3c5/granian-2.5.7-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:222f0fb1688a62ca23cb3da974cefa69e7fdc40fd548d1ae87a953225e1d1cbb", size = 3164141, upload-time = "2025-11-05T12:18:09.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/cc/35c6a55ac2c211e86a9f0c728eb81b6ad19f05a3055d79c6f11a1b71f5d5/granian-2.5.7-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:40494c6cda1ad881ae07efbb2dc4a1ca8f12d5c6cf28d1ab8b0f2db13826617b", size = 3201599, upload-time = "2025-11-05T12:18:10.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/0a/5a95a3889532bc5a5f652cdc78dae8ffa16d4228b4d35256a98be89e33ef/granian-2.5.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c3942d08af2c8b67d0ef569b6c567284433ebf09b4af3ea68388abb7caccad2b", size = 2175240, upload-time = "2025-11-05T12:18:12.956Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "graphviz"
|
||||
version = "0.21"
|
||||
@@ -3535,7 +3628,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.83.14"
|
||||
version = "1.87.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -3551,9 +3644,9 @@ dependencies = [
|
||||
{ name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/7c/c095649380adc96c8630273c1768c2ad1e74aa2ee1dd8dd05d218a60569f/litellm-1.83.14.tar.gz", hash = "sha256:24aef9b47cdc424c833e32f3727f411741c690832cd1fe4405e0077144fe09c9", size = 14836599, upload-time = "2026-04-26T03:16:10.176Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/e5/d0ac1c8f55e2c8d8799589e831bef0d450e69e02ecb511901ffc8de054d9/litellm-1.87.1.tar.gz", hash = "sha256:70ac9d6b25f56ad30de6ff95d26fac3b3fc697a95da582b6072d25d8dc73d493", size = 15455709, upload-time = "2026-06-04T16:23:23.339Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/5c/1b5691575420135e90578543b2bf219497caa33cfd0af64cb38f30288450/litellm-1.83.14-py3-none-any.whl", hash = "sha256:92b11ba2a32cf80707ddf388d18526696c7999a21b418c5e3b6eda1243d2cfdb", size = 16457054, upload-time = "2026-04-26T03:16:05.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/18/8275c95ef09e81ab0c01a162c7b780ce3fbc49066b5d532c6b6ab3dc0118/litellm-1.87.1-py3-none-any.whl", hash = "sha256:dd4e00278cdb846d52e99a09d732575a897273540b54eb044247ecbc0d98f67c", size = 17105482, upload-time = "2026-06-04T16:23:20.769Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -3566,12 +3659,14 @@ proxy = [
|
||||
{ name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "fastapi-sso", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "granian", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "gunicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "litellm-enterprise", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "litellm-proxy-extras", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "orjson", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "polars", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pynacl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pyroscope-io", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
@@ -3588,20 +3683,20 @@ proxy = [
|
||||
|
||||
[[package]]
|
||||
name = "litellm-enterprise"
|
||||
version = "0.1.39"
|
||||
version = "0.1.41"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/0b/79fb68abf7c787d951dd367f662c52b922278548f244f5d36e623cdb2161/litellm_enterprise-0.1.39.tar.gz", hash = "sha256:434e2c15280218bb9224adbbac878bcffe0b8a75b0b46deeb0b90bc4f2e2152b", size = 69465, upload-time = "2026-04-26T03:09:36.828Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/07/73412b99c6065ae49a5e87b5f5810b94c1743d7cd41d3a701ebf2c0a64d2/litellm_enterprise-0.1.41.tar.gz", hash = "sha256:3bbf37b6e997e28f9a39489ba532ac98f19b5176180ce091c04156ce3f048d54", size = 70437, upload-time = "2026-05-17T02:05:49.282Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b0/30df9b36366559efd9c1fae39c67856481c7418056eb2196266bda605bc8/litellm_enterprise-0.1.39-py3-none-any.whl", hash = "sha256:e5f48745fb127dc4f72fd1fa7cdeba0ddd4066dc5f0d9e8e87eea4e4571d42b3", size = 136645, upload-time = "2026-04-26T03:09:35.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/16/284b7304dbf6eea7fe79352ca808f310b7e724648e5f3cf7c13a7a54d682/litellm_enterprise-0.1.41-py3-none-any.whl", hash = "sha256:7b31fd807dee8e1900fd15d8344e4509b6aaf05e10a211fc91c30a95e227685f", size = 137669, upload-time = "2026-05-17T02:05:48.24Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.69"
|
||||
version = "0.4.73"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/e8/0176368d64ffaaf7ff7da07a7833ef05cd92484cf21167a9291cb311568f/litellm_proxy_extras-0.4.69.tar.gz", hash = "sha256:8c24a01a4dffb137e95c709a47ab68053591ccdf7d78a038c57348f5b2ab990d", size = 41220, upload-time = "2026-04-26T03:12:12.122Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/37/bed736f8a623b7891e9ff272fd60c2f08fc4a8fed372885f5df9ec09b769/litellm_proxy_extras-0.4.73.tar.gz", hash = "sha256:d4fb1238fb56cdaa21aef6b1d7683c2c0fe3a148ecd423f8bf4cef3c3d07bd36", size = 43599, upload-time = "2026-05-20T00:00:05.495Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/58/165a96b061fa90824ffbce13191262d4a0089510284a973805e5854e2c03/litellm_proxy_extras-0.4.69-py3-none-any.whl", hash = "sha256:4aee8dab05d1a6f91ba89da729d241122eaad4cbe64f39b19ea6a855543146c4", size = 113230, upload-time = "2026-04-26T03:12:10.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/64/7e85f5f47495ebb0bb5f30a4f4b54b64277a40a14be79c34052df97ac7ab/litellm_proxy_extras-0.4.73-py3-none-any.whl", hash = "sha256:a4f460d15dd01a095dadb26f7660a259fa2a8757a9e27dee68c159a872b8db7e", size = 118593, upload-time = "2026-05-20T00:00:04.017Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5867,16 +5962,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.13.1"
|
||||
version = "2.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6084,11 +6179,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.26"
|
||||
version = "0.0.27"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6607,14 +6702,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "s3transfer"
|
||||
version = "0.16.0"
|
||||
version = "0.17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/b3/bcdc2f58fa92592db511beda154c2c08d28f21f6c4637f06a42a24b10c21/s3transfer-0.17.1.tar.gz", hash = "sha256:042dd5e3b1b512355e35a23f0223e426b7042e80b97830ea2680ddce327fc45e", size = 159439, upload-time = "2026-05-26T19:45:01.714Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/dd/904873250a6554fbae40cddbf9198e3cc37a2f1319d5e1a5ce82fe269c17/s3transfer-0.17.1-py3-none-any.whl", hash = "sha256:5b9827d1044159bbb01b86ef8902760ea39281927f5de31de75e1d657177bf4c", size = 88264, upload-time = "2026-05-26T19:45:00.452Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user