mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into peibekwe/declarative-bugfix-python-new
This commit is contained in:
@@ -145,11 +145,12 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
// Ensure the client is started
|
||||
await this.EnsureClientStartedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Create or resume a session with streaming enabled
|
||||
// Create or resume a session with streaming enabled by default
|
||||
SessionConfig sessionConfig = this._sessionConfig != null
|
||||
? CopySessionConfig(this._sessionConfig)
|
||||
: new SessionConfig { Streaming = true };
|
||||
|
||||
bool isStreaming = sessionConfig.Streaming ?? true;
|
||||
CopilotSession copilotSession;
|
||||
if (typedSession.SessionId is not null)
|
||||
{
|
||||
@@ -178,7 +179,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
break;
|
||||
|
||||
case AssistantMessageEvent assistantMessage:
|
||||
channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(assistantMessage));
|
||||
channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(assistantMessage, isStreaming));
|
||||
break;
|
||||
|
||||
case AssistantUsageEvent usageEvent:
|
||||
@@ -271,19 +272,20 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies all supported properties from a source <see cref="SessionConfig"/> into a new instance
|
||||
/// with <see cref="SessionConfigBase.Streaming"/> set to <c>true</c>.
|
||||
/// Copies all supported properties from a source <see cref="SessionConfig"/> into a new instance,
|
||||
/// preserving <see cref="SessionConfigBase.Streaming"/> from the source (defaulting to <c>true</c> if unset).
|
||||
/// </summary>
|
||||
internal static SessionConfig CopySessionConfig(SessionConfig source)
|
||||
{
|
||||
SessionConfig copy = source.Clone();
|
||||
copy.Streaming = true;
|
||||
copy.Streaming = source.Streaming ?? true;
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies all supported properties from a source <see cref="SessionConfig"/> into a new
|
||||
/// <see cref="ResumeSessionConfig"/> with <see cref="SessionConfigBase.Streaming"/> set to <c>true</c>.
|
||||
/// <see cref="ResumeSessionConfig"/>, preserving <see cref="SessionConfigBase.Streaming"/>
|
||||
/// from the source (defaulting to <c>true</c> if unset).
|
||||
/// </summary>
|
||||
internal static ResumeSessionConfig CopyResumeSessionConfig(SessionConfig? source)
|
||||
{
|
||||
@@ -306,7 +308,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
SkillDirectories = source?.SkillDirectories,
|
||||
DisabledSkills = source?.DisabledSkills,
|
||||
InfiniteSessions = source?.InfiniteSessions,
|
||||
Streaming = true
|
||||
Streaming = source?.Streaming ?? true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -325,12 +327,18 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
};
|
||||
}
|
||||
|
||||
internal AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage)
|
||||
/// <summary>
|
||||
/// Converts an <see cref="AssistantMessageEvent"/> to an <see cref="AgentResponseUpdate"/>.
|
||||
/// When streaming is enabled, text was already delivered via delta events, so only raw metadata is emitted.
|
||||
/// When streaming is disabled, the full message text is emitted as <see cref="TextContent"/>.
|
||||
/// </summary>
|
||||
internal AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage, bool isStreaming)
|
||||
{
|
||||
AIContent content = new()
|
||||
{
|
||||
RawRepresentation = assistantMessage
|
||||
};
|
||||
// When streaming, text was already delivered via AssistantMessageDeltaEvent.
|
||||
// When not streaming, this is the only opportunity to emit the response text.
|
||||
AIContent content = isStreaming
|
||||
? new AIContent { RawRepresentation = assistantMessage }
|
||||
: new TextContent(assistantMessage.Data?.Content ?? string.Empty) { RawRepresentation = assistantMessage };
|
||||
|
||||
return new AgentResponseUpdate(ChatRole.Assistant, [content])
|
||||
{
|
||||
|
||||
+27
-3
@@ -3,6 +3,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
@@ -11,7 +12,11 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
internal record CheckpointFileIndexEntry(CheckpointInfo CheckpointInfo, string FileName);
|
||||
internal record CheckpointFileIndexEntry(
|
||||
CheckpointInfo CheckpointInfo,
|
||||
string FileName,
|
||||
string? ParentCheckpointId = null,
|
||||
bool HasParentMetadata = false);
|
||||
|
||||
/// <summary>
|
||||
/// Provides a file system-based implementation of a JSON checkpoint store that persists checkpoint data and index
|
||||
@@ -30,6 +35,8 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
|
||||
internal DirectoryInfo Directory { get; }
|
||||
internal HashSet<CheckpointInfo> CheckpointIndex { get; }
|
||||
private Dictionary<CheckpointInfo, string?> CheckpointParents { get; } = [];
|
||||
private HashSet<CheckpointInfo> CheckpointsWithKnownParent { get; } = [];
|
||||
|
||||
private static JsonTypeInfo<CheckpointFileIndexEntry> EntryTypeInfo => WorkflowsJsonUtilities.JsonContext.Default.CheckpointFileIndexEntry;
|
||||
|
||||
@@ -74,6 +81,11 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
// We never actually use the file names from the index entries since they can be derived from the CheckpointInfo, but it is useful to
|
||||
// have the UrlEncoded file names in the index file for human readability
|
||||
this.CheckpointIndex.Add(entry.CheckpointInfo);
|
||||
this.CheckpointParents[entry.CheckpointInfo] = entry.ParentCheckpointId;
|
||||
if (entry.HasParentMetadata)
|
||||
{
|
||||
this.CheckpointsWithKnownParent.Add(entry.CheckpointInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,7 +149,11 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
using Utf8JsonWriter jsonWriter = new(checkpointStream, new JsonWriterOptions() { Indented = false });
|
||||
value.WriteTo(jsonWriter);
|
||||
|
||||
CheckpointFileIndexEntry entry = new(key, fileName);
|
||||
string? parentCheckpointId = parent?.CheckpointId;
|
||||
this.CheckpointParents[key] = parentCheckpointId;
|
||||
this.CheckpointsWithKnownParent.Add(key);
|
||||
|
||||
CheckpointFileIndexEntry entry = new(key, fileName, parentCheckpointId, HasParentMetadata: true);
|
||||
JsonSerializer.Serialize(this._indexFile!, entry, EntryTypeInfo);
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(Environment.NewLine);
|
||||
await this._indexFile!.WriteAsync(bytes, 0, bytes.Length, CancellationToken.None).ConfigureAwait(false);
|
||||
@@ -148,6 +164,8 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.CheckpointIndex.Remove(key);
|
||||
this.CheckpointParents.Remove(key);
|
||||
this.CheckpointsWithKnownParent.Remove(key);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -184,6 +202,12 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
{
|
||||
this.CheckDisposed();
|
||||
|
||||
return new(this.CheckpointIndex);
|
||||
return new(this.CheckpointIndex
|
||||
.Where(checkpoint => checkpoint.SessionId == sessionId &&
|
||||
(withParent is null ||
|
||||
!this.CheckpointsWithKnownParent.Contains(checkpoint) ||
|
||||
(this.CheckpointParents.TryGetValue(checkpoint, out string? parentCheckpointId) &&
|
||||
parentCheckpointId == withParent.CheckpointId)))
|
||||
.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
+144
-4
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Copilot;
|
||||
using GitHub.Copilot.Rpc;
|
||||
@@ -222,7 +223,73 @@ public sealed class GitHubCopilotAgentTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_AssistantMessageEvent_DoesNotEmitTextContent()
|
||||
public void CopySessionConfig_WithStreamingDisabled_PreservesStreamingValue()
|
||||
{
|
||||
// Arrange
|
||||
var source = new SessionConfig
|
||||
{
|
||||
Streaming = false,
|
||||
Model = "gpt-4o",
|
||||
};
|
||||
|
||||
// Act
|
||||
SessionConfig result = GitHubCopilotAgent.CopySessionConfig(source);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Streaming);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CopySessionConfig_WithStreamingNull_DefaultsToTrue()
|
||||
{
|
||||
// Arrange
|
||||
var source = new SessionConfig
|
||||
{
|
||||
Model = "gpt-4o",
|
||||
};
|
||||
|
||||
// Act
|
||||
SessionConfig result = GitHubCopilotAgent.CopySessionConfig(source);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Streaming);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CopyResumeSessionConfig_WithStreamingDisabled_PreservesStreamingValue()
|
||||
{
|
||||
// Arrange
|
||||
var source = new SessionConfig
|
||||
{
|
||||
Streaming = false,
|
||||
Model = "gpt-4o",
|
||||
};
|
||||
|
||||
// Act
|
||||
ResumeSessionConfig result = GitHubCopilotAgent.CopyResumeSessionConfig(source);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Streaming);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CopyResumeSessionConfig_WithStreamingNull_DefaultsToTrue()
|
||||
{
|
||||
// Arrange
|
||||
var source = new SessionConfig
|
||||
{
|
||||
Model = "gpt-4o",
|
||||
};
|
||||
|
||||
// Act
|
||||
ResumeSessionConfig result = GitHubCopilotAgent.CopyResumeSessionConfig(source);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Streaming);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_AssistantMessageEventWhenStreaming_DoesNotEmitTextContent()
|
||||
{
|
||||
var assistantMessage = new AssistantMessageEvent
|
||||
{
|
||||
@@ -235,11 +302,84 @@ public sealed class GitHubCopilotAgentTests
|
||||
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);
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage, isStreaming: true);
|
||||
|
||||
// result.Text need to be empty because the content was already delivered via delta events, and we want to avoid emitting duplicate content in the response update.
|
||||
// The content should be delivered through TextContent in the Contents collection instead.
|
||||
// result.Text should be empty because content was already delivered via delta events.
|
||||
Assert.Empty(result.Text);
|
||||
Assert.DoesNotContain(result.Contents, c => c is TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_AssistantMessageEventWhenNotStreaming_EmitsTextContent()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedContent = "Full response text from non-streaming session";
|
||||
var assistantMessage = new AssistantMessageEvent
|
||||
{
|
||||
Data = new AssistantMessageData
|
||||
{
|
||||
MessageId = "msg-789",
|
||||
Content = ExpectedContent
|
||||
}
|
||||
};
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
const string TestId = "agent-id";
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: TestId, tools: null);
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage, isStreaming: false);
|
||||
|
||||
// Assert - text must be emitted since no delta events precede it in non-streaming mode.
|
||||
Assert.Equal(ExpectedContent, result.Text);
|
||||
Assert.Contains(result.Contents, c => c is TextContent);
|
||||
TextContent textContent = (TextContent)result.Contents.Single(c => c is TextContent);
|
||||
Assert.Equal(ExpectedContent, textContent.Text);
|
||||
Assert.Same(assistantMessage, textContent.RawRepresentation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_AssistantMessageEventWhenNotStreaming_HandlesEmptyContent()
|
||||
{
|
||||
// Arrange
|
||||
var assistantMessage = new AssistantMessageEvent
|
||||
{
|
||||
Data = new AssistantMessageData
|
||||
{
|
||||
MessageId = "msg-000",
|
||||
Content = string.Empty
|
||||
}
|
||||
};
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
const string TestId = "agent-id";
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: TestId, tools: null);
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage, isStreaming: false);
|
||||
|
||||
// Assert - should emit empty TextContent rather than throwing.
|
||||
Assert.Empty(result.Text);
|
||||
Assert.Contains(result.Contents, c => c is TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_AssistantMessageEventWhenNotStreaming_HandlesNullData()
|
||||
{
|
||||
// Arrange
|
||||
var assistantMessage = new AssistantMessageEvent
|
||||
{
|
||||
Data = null!
|
||||
};
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
const string TestId = "agent-id";
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: TestId, tools: null);
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage, isStreaming: false);
|
||||
|
||||
// Assert - null Data should produce empty TextContent via null-propagation fallback.
|
||||
Assert.Empty(result.Text);
|
||||
Assert.Contains(result.Contents, c => c is TextContent);
|
||||
Assert.Null(result.MessageId);
|
||||
Assert.Null(result.ResponseId);
|
||||
}
|
||||
}
|
||||
|
||||
+128
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
@@ -197,4 +198,131 @@ public sealed class FileSystemJsonCheckpointStoreTests
|
||||
retrieved.GetProperty("name").GetString().Should().Be("test");
|
||||
retrieved.GetProperty("value").GetInt32().Should().Be(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveIndexAsync_ShouldOnlyReturnCheckpointsForRequestedSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
using TempDirectory tempDirectory = new();
|
||||
string firstSessionId = Guid.NewGuid().ToString("N");
|
||||
string secondSessionId = Guid.NewGuid().ToString("N");
|
||||
CheckpointInfo firstCheckpoint;
|
||||
CheckpointInfo secondCheckpoint;
|
||||
|
||||
using (FileSystemJsonCheckpointStore store = new(tempDirectory))
|
||||
{
|
||||
firstCheckpoint = await store.CreateCheckpointAsync(firstSessionId, TestData);
|
||||
secondCheckpoint = await store.CreateCheckpointAsync(secondSessionId, TestData);
|
||||
|
||||
// Act
|
||||
CheckpointInfo[] firstSessionIndex = (await store.RetrieveIndexAsync(firstSessionId)).ToArray();
|
||||
|
||||
// Assert
|
||||
firstSessionIndex.Should().ContainSingle().Which.Should().Be(firstCheckpoint);
|
||||
firstSessionIndex.Should().NotContain(secondCheckpoint);
|
||||
}
|
||||
|
||||
using (FileSystemJsonCheckpointStore reopenedStore = new(tempDirectory))
|
||||
{
|
||||
CheckpointInfo[] secondSessionIndex = (await reopenedStore.RetrieveIndexAsync(secondSessionId)).ToArray();
|
||||
|
||||
secondSessionIndex.Should().ContainSingle().Which.Should().Be(secondCheckpoint);
|
||||
secondSessionIndex.Should().NotContain(firstCheckpoint);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveIndexAsync_ShouldFilterByParentCheckpointAsync()
|
||||
{
|
||||
// Arrange
|
||||
using TempDirectory tempDirectory = new();
|
||||
string sessionId = Guid.NewGuid().ToString("N");
|
||||
CheckpointInfo parentCheckpoint;
|
||||
CheckpointInfo childCheckpoint;
|
||||
CheckpointInfo unrelatedCheckpoint;
|
||||
|
||||
using (FileSystemJsonCheckpointStore store = new(tempDirectory))
|
||||
{
|
||||
parentCheckpoint = await store.CreateCheckpointAsync(sessionId, TestData);
|
||||
childCheckpoint = await store.CreateCheckpointAsync(sessionId, TestData, parentCheckpoint);
|
||||
unrelatedCheckpoint = await store.CreateCheckpointAsync(sessionId, TestData);
|
||||
|
||||
// Act
|
||||
CheckpointInfo[] childIndex = (await store.RetrieveIndexAsync(sessionId, parentCheckpoint)).ToArray();
|
||||
|
||||
// Assert
|
||||
childIndex.Should().ContainSingle().Which.Should().Be(childCheckpoint);
|
||||
childIndex.Should().NotContain(parentCheckpoint);
|
||||
childIndex.Should().NotContain(unrelatedCheckpoint);
|
||||
}
|
||||
|
||||
using (FileSystemJsonCheckpointStore reopenedStore = new(tempDirectory))
|
||||
{
|
||||
CheckpointInfo[] childIndex = (await reopenedStore.RetrieveIndexAsync(sessionId, parentCheckpoint)).ToArray();
|
||||
|
||||
childIndex.Should().ContainSingle().Which.Should().Be(childCheckpoint);
|
||||
childIndex.Should().NotContain(parentCheckpoint);
|
||||
childIndex.Should().NotContain(unrelatedCheckpoint);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveIndexAsync_ShouldKeepLegacyEntriesDiscoverableWithParentFilterAsync()
|
||||
{
|
||||
// Arrange
|
||||
using TempDirectory tempDirectory = new();
|
||||
string sessionId = Guid.NewGuid().ToString("N");
|
||||
CheckpointInfo parentCheckpoint;
|
||||
CheckpointInfo childCheckpoint;
|
||||
string childFileName;
|
||||
|
||||
using (FileSystemJsonCheckpointStore store = new(tempDirectory))
|
||||
{
|
||||
parentCheckpoint = await store.CreateCheckpointAsync(sessionId, TestData);
|
||||
childCheckpoint = await store.CreateCheckpointAsync(sessionId, TestData, parentCheckpoint);
|
||||
childFileName = store.GetFileNameForCheckpoint(sessionId, childCheckpoint);
|
||||
}
|
||||
|
||||
string indexPath = Path.Combine(tempDirectory.FullName, "index.jsonl");
|
||||
string legacyEntry = JsonSerializer.Serialize(new CheckpointFileIndexEntry(childCheckpoint, childFileName));
|
||||
File.WriteAllText(indexPath, legacyEntry + Environment.NewLine);
|
||||
|
||||
// Act
|
||||
using FileSystemJsonCheckpointStore reopenedStore = new(tempDirectory);
|
||||
CheckpointInfo[] childIndex = (await reopenedStore.RetrieveIndexAsync(sessionId, parentCheckpoint)).ToArray();
|
||||
|
||||
// Assert
|
||||
childIndex.Should().ContainSingle().Which.Should().Be(childCheckpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveIndexAsync_ShouldKeepLegacyChildDiscoverableWithUnrelatedParentFilterAsync()
|
||||
{
|
||||
// Arrange
|
||||
using TempDirectory tempDirectory = new();
|
||||
string sessionId = Guid.NewGuid().ToString("N");
|
||||
CheckpointInfo parentCheckpoint;
|
||||
CheckpointInfo childCheckpoint;
|
||||
CheckpointInfo unrelatedCheckpoint;
|
||||
string childFileName;
|
||||
|
||||
using (FileSystemJsonCheckpointStore store = new(tempDirectory))
|
||||
{
|
||||
parentCheckpoint = await store.CreateCheckpointAsync(sessionId, TestData);
|
||||
childCheckpoint = await store.CreateCheckpointAsync(sessionId, TestData, parentCheckpoint);
|
||||
unrelatedCheckpoint = await store.CreateCheckpointAsync(sessionId, TestData);
|
||||
childFileName = store.GetFileNameForCheckpoint(sessionId, childCheckpoint);
|
||||
}
|
||||
|
||||
string indexPath = Path.Combine(tempDirectory.FullName, "index.jsonl");
|
||||
string legacyEntry = JsonSerializer.Serialize(new CheckpointFileIndexEntry(childCheckpoint, childFileName));
|
||||
File.WriteAllText(indexPath, legacyEntry + Environment.NewLine);
|
||||
|
||||
// Act
|
||||
using FileSystemJsonCheckpointStore reopenedStore = new(tempDirectory);
|
||||
CheckpointInfo[] childIndex = (await reopenedStore.RetrieveIndexAsync(sessionId, unrelatedCheckpoint)).ToArray();
|
||||
|
||||
// Assert
|
||||
childIndex.Should().ContainSingle().Which.Should().Be(childCheckpoint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ from ._clients import (
|
||||
SupportsGetEmbeddings,
|
||||
SupportsImageGenerationTool,
|
||||
SupportsMCPTool,
|
||||
SupportsShellTool,
|
||||
SupportsWebSearchTool,
|
||||
)
|
||||
from ._compaction import (
|
||||
@@ -506,6 +507,7 @@ __all__ = [
|
||||
"SupportsGetEmbeddings",
|
||||
"SupportsImageGenerationTool",
|
||||
"SupportsMCPTool",
|
||||
"SupportsShellTool",
|
||||
"SupportsWebSearchTool",
|
||||
"SwitchCaseEdgeGroup",
|
||||
"SwitchCaseEdgeGroupCase",
|
||||
|
||||
@@ -819,6 +819,36 @@ class SupportsFileSearchTool(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsShellTool(Protocol):
|
||||
"""Protocol for clients that support shell tools.
|
||||
|
||||
This protocol enables runtime checking to determine if a client
|
||||
supports executing shell commands.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import SupportsShellTool
|
||||
|
||||
if isinstance(client, SupportsShellTool):
|
||||
tool = client.get_shell_tool(func=shell.as_function())
|
||||
agent = ChatAgent(client, tools=[tool])
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_shell_tool(**kwargs: Any) -> Any:
|
||||
"""Create a shell tool configuration.
|
||||
|
||||
Keyword Args:
|
||||
**kwargs: Provider-specific configuration options.
|
||||
|
||||
Returns:
|
||||
A tool configuration ready to pass to ChatAgent.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from collections.abc import Callable, Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .._agents import Agent, SupportsAgentRun
|
||||
from .._clients import SupportsWebSearchTool
|
||||
from .._clients import SupportsShellTool, SupportsWebSearchTool
|
||||
from .._compaction import CompactionProvider, ContextWindowCompactionStrategy, ToolResultCompactionStrategy
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._sessions import ContextProvider, HistoryProvider, InMemoryHistoryProvider
|
||||
@@ -28,6 +28,8 @@ from ._todo import TodoProvider
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
from agent_framework_tools.shell import ShellEnvironmentProviderOptions, ShellExecutor
|
||||
|
||||
from .._clients import SupportsChatGetResponse
|
||||
from .._compaction import CompactionStrategy, TokenizerProtocol
|
||||
from .._middleware import MiddlewareTypes
|
||||
@@ -128,6 +130,7 @@ def _assemble_context_providers(
|
||||
skills_paths: Sequence[str] | None,
|
||||
background_agents: Sequence[SupportsAgentRun] | None,
|
||||
background_agents_instructions: str | None,
|
||||
shell_context_provider: ContextProvider | None,
|
||||
extra_context_providers: Sequence[ContextProvider] | None,
|
||||
) -> list[ContextProvider]:
|
||||
"""Assemble the ordered list of context providers."""
|
||||
@@ -159,6 +162,10 @@ def _assemble_context_providers(
|
||||
if background_agents:
|
||||
providers.append(BackgroundAgentsProvider(background_agents, instructions=background_agents_instructions))
|
||||
|
||||
# Shell environment provider is opt-in: only added when a shell tool was wired.
|
||||
if shell_context_provider is not None:
|
||||
providers.append(shell_context_provider)
|
||||
|
||||
# Append any user-supplied additional providers.
|
||||
if extra_context_providers:
|
||||
providers.extend(extra_context_providers)
|
||||
@@ -166,6 +173,50 @@ def _assemble_context_providers(
|
||||
return providers
|
||||
|
||||
|
||||
def _assemble_shell(
|
||||
client: SupportsChatGetResponse[Any],
|
||||
shell_executor: ShellExecutor | None,
|
||||
shell_environment_provider_options: ShellEnvironmentProviderOptions | None,
|
||||
) -> tuple[ToolTypes | None, ContextProvider | None]:
|
||||
"""Build the shell tool and environment provider when a shell executor is supplied.
|
||||
|
||||
Returns a ``(tool, provider)`` tuple. Both are ``None`` when no shell executor is
|
||||
provided, or when the client does not support shell tools (a warning is logged in the
|
||||
latter case, since the environment provider is not useful without an execution path).
|
||||
|
||||
Raises:
|
||||
TypeError: If ``shell_executor`` does not expose a callable ``as_function()`` method.
|
||||
"""
|
||||
if shell_executor is None:
|
||||
return None, None
|
||||
|
||||
# ShellExecutor is a protocol without ``as_function()``, so the
|
||||
# contract is validated at runtime: a shell tool such as LocalShellTool/DockerShellTool exposes it.
|
||||
as_function = getattr(shell_executor, "as_function", None)
|
||||
if not callable(as_function):
|
||||
raise TypeError(
|
||||
f"shell_executor must expose a callable 'as_function()' method "
|
||||
f"(e.g. a LocalShellTool or DockerShellTool from agent-framework-tools), "
|
||||
f"but got {type(shell_executor).__name__}."
|
||||
)
|
||||
|
||||
if not isinstance(client, SupportsShellTool):
|
||||
logger.warning(
|
||||
"Shell tool not available: client %r does not implement SupportsShellTool. "
|
||||
"Skipping the shell tool and environment provider.",
|
||||
type(client).__name__,
|
||||
)
|
||||
return None, None
|
||||
|
||||
# Imported lazily: the shell types live in the separate agent-framework-tools package,
|
||||
# which depends on core, so core cannot import them at module load time.
|
||||
from agent_framework_tools.shell import ShellEnvironmentProvider
|
||||
|
||||
shell_tool = client.get_shell_tool(func=as_function())
|
||||
shell_provider = ShellEnvironmentProvider(shell_executor, shell_environment_provider_options)
|
||||
return shell_tool, shell_provider
|
||||
|
||||
|
||||
HARNESS_AGENT_PROVIDER_NAME = "microsoft.agent_framework.harness"
|
||||
|
||||
|
||||
@@ -196,6 +247,8 @@ def create_harness_agent(
|
||||
skills_paths: Sequence[str] | None = None,
|
||||
background_agents: Sequence[SupportsAgentRun] | None = None,
|
||||
background_agents_instructions: str | None = None,
|
||||
shell_executor: ShellExecutor | None = None,
|
||||
shell_environment_provider_options: ShellEnvironmentProviderOptions | None = None,
|
||||
disable_web_search: bool = False,
|
||||
otel_provider_name: str | None = None,
|
||||
context_providers: Sequence[ContextProvider] | None = None,
|
||||
@@ -298,6 +351,15 @@ def create_harness_agent(
|
||||
background_agents_instructions: Optional instruction override for the
|
||||
``BackgroundAgentsProvider``. May include ``{background_agents}`` placeholder
|
||||
which will be replaced with the agent listing.
|
||||
shell_executor: Optional shell tool that enables shell command execution. When
|
||||
provided, the shell tool and a ``ShellEnvironmentProvider`` are automatically
|
||||
added (provided the client supports shell tools; otherwise a warning is logged
|
||||
and both are skipped). The object must expose ``as_function()`` and satisfy the
|
||||
``ShellExecutor`` protocol -- e.g. a ``LocalShellTool`` or ``DockerShellTool`` from
|
||||
the ``agent-framework-tools`` package. The caller owns the executor's lifecycle.
|
||||
shell_environment_provider_options: Optional ``ShellEnvironmentProviderOptions``
|
||||
(from ``agent-framework-tools``) used to customize the ``ShellEnvironmentProvider``
|
||||
environment probing and instructions. Only used when ``shell_executor`` is provided.
|
||||
disable_web_search: When True, skip automatic web search tool inclusion.
|
||||
When False (default), the web search tool is automatically added if the
|
||||
client implements SupportsWebSearchTool. A warning is logged if the client
|
||||
@@ -340,6 +402,13 @@ def create_harness_agent(
|
||||
tokenizer=tokenizer,
|
||||
)
|
||||
|
||||
# Build the shell tool and environment provider (opt-in via shell_executor).
|
||||
shell_tool, shell_provider = _assemble_shell(
|
||||
client,
|
||||
shell_executor,
|
||||
shell_environment_provider_options,
|
||||
)
|
||||
|
||||
# Build context providers.
|
||||
assembled_providers = _assemble_context_providers(
|
||||
history_provider=resolved_history,
|
||||
@@ -354,6 +423,7 @@ def create_harness_agent(
|
||||
skills_paths=skills_paths,
|
||||
background_agents=background_agents,
|
||||
background_agents_instructions=background_agents_instructions,
|
||||
shell_context_provider=shell_provider,
|
||||
extra_context_providers=context_providers,
|
||||
)
|
||||
|
||||
@@ -371,6 +441,8 @@ def create_harness_agent(
|
||||
"Set disable_web_search=True to suppress this warning.",
|
||||
type(client).__name__,
|
||||
)
|
||||
if shell_tool is not None:
|
||||
assembled_tools.append(shell_tool)
|
||||
if tools is not None:
|
||||
if isinstance(tools, Sequence):
|
||||
assembled_tools.extend(tools) # pyright: ignore[reportUnknownArgumentType]
|
||||
|
||||
@@ -543,3 +543,127 @@ def test_create_harness_agent_empty_background_agents_list() -> None:
|
||||
)
|
||||
providers = agent.context_providers or []
|
||||
assert not any(isinstance(p, BackgroundAgentsProvider) for p in providers)
|
||||
|
||||
|
||||
# --- Shell Tool Tests ---
|
||||
|
||||
|
||||
class _FakeShellTool:
|
||||
"""Fake shell executor/tool exposing as_function()."""
|
||||
|
||||
def as_function(self) -> str:
|
||||
return "shell_fn"
|
||||
|
||||
|
||||
class _FakeShellClient(_FakeChatClient):
|
||||
"""Fake client that supports the shell tool."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.shell_func: Any = None
|
||||
|
||||
def get_shell_tool(self, *, func: Any = None, **kwargs: Any) -> str:
|
||||
self.shell_func = func
|
||||
return "shell_tool_instance"
|
||||
|
||||
|
||||
def test_create_harness_agent_adds_shell_tool_and_provider() -> None:
|
||||
"""Shell tool and ShellEnvironmentProvider should be added when a shell executor is supplied."""
|
||||
from agent_framework_tools.shell import ShellEnvironmentProvider
|
||||
|
||||
client = _FakeShellClient()
|
||||
agent = create_harness_agent(
|
||||
client=client, # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
disable_web_search=True,
|
||||
shell_executor=_FakeShellTool(),
|
||||
)
|
||||
tools = agent.default_options.get("tools", [])
|
||||
assert "shell_tool_instance" in tools
|
||||
assert client.shell_func == "shell_fn"
|
||||
providers = agent.context_providers or []
|
||||
assert any(isinstance(p, ShellEnvironmentProvider) for p in providers)
|
||||
|
||||
|
||||
def test_create_harness_agent_shell_passes_custom_options() -> None:
|
||||
"""Custom ShellEnvironmentProviderOptions should be forwarded to the provider."""
|
||||
from agent_framework_tools.shell import ShellEnvironmentProvider, ShellEnvironmentProviderOptions
|
||||
|
||||
options = ShellEnvironmentProviderOptions(probe_tools=("git",))
|
||||
agent = create_harness_agent(
|
||||
client=_FakeShellClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
disable_web_search=True,
|
||||
shell_executor=_FakeShellTool(),
|
||||
shell_environment_provider_options=options,
|
||||
)
|
||||
providers = agent.context_providers or []
|
||||
provider = next(p for p in providers if isinstance(p, ShellEnvironmentProvider))
|
||||
assert provider._options is options
|
||||
|
||||
|
||||
def test_create_harness_agent_shell_skipped_when_unsupported(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""When the client lacks get_shell_tool, both the tool and provider are skipped with a warning."""
|
||||
import logging
|
||||
|
||||
from agent_framework_tools.shell import ShellEnvironmentProvider
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="agent_framework._harness._agent"):
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
disable_web_search=True,
|
||||
shell_executor=_FakeShellTool(),
|
||||
)
|
||||
assert any("SupportsShellTool" in msg for msg in caplog.messages)
|
||||
providers = agent.context_providers or []
|
||||
assert not any(isinstance(p, ShellEnvironmentProvider) for p in providers)
|
||||
assert "tools" not in agent.default_options or not agent.default_options.get("tools")
|
||||
|
||||
|
||||
def test_create_harness_agent_no_shell_by_default() -> None:
|
||||
"""No shell tool or provider should be added when shell_executor is not provided."""
|
||||
from agent_framework_tools.shell import ShellEnvironmentProvider
|
||||
|
||||
agent = create_harness_agent(
|
||||
client=_FakeShellClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
disable_web_search=True,
|
||||
)
|
||||
providers = agent.context_providers or []
|
||||
assert not any(isinstance(p, ShellEnvironmentProvider) for p in providers)
|
||||
|
||||
|
||||
def test_create_harness_agent_shell_executor_without_as_function_raises() -> None:
|
||||
"""A shell_executor lacking a callable as_function() should raise a clear TypeError."""
|
||||
|
||||
class _BadExecutor:
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError, match="as_function"):
|
||||
create_harness_agent(
|
||||
client=_FakeShellClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
disable_web_search=True,
|
||||
shell_executor=_BadExecutor(),
|
||||
)
|
||||
|
||||
|
||||
def test_create_harness_agent_shell_executor_validated_before_client_check() -> None:
|
||||
"""The as_function() contract is validated upfront, even when the client lacks shell support."""
|
||||
|
||||
class _BadExecutor:
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError, match="as_function"):
|
||||
create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
disable_web_search=True,
|
||||
shell_executor=_BadExecutor(),
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ from a chat client.
|
||||
| AgentModeProvider | Plan/execute mode tracking |
|
||||
| MemoryContextProvider | File-based durable memory (when `memory_store` provided) |
|
||||
| SkillsProvider | File-based skill discovery and progressive loading |
|
||||
| Shell tool | Shell command execution + environment probing (when `shell_executor` provided) |
|
||||
| OpenTelemetry | Built-in observability |
|
||||
|
||||
Each feature can be disabled or customized via keyword arguments.
|
||||
@@ -91,3 +92,25 @@ agent = create_harness_agent(
|
||||
The `AgentModeProvider` enables a two-phase workflow:
|
||||
1. **Plan mode** — Interactive: the agent asks questions, creates todos, gets approval
|
||||
2. **Execute mode** — Autonomous: the agent works through todos independently
|
||||
|
||||
### Shell Tool
|
||||
|
||||
Pass a shell executor (e.g. `LocalShellTool` from `agent-framework-tools`) to enable shell
|
||||
command execution plus automatic environment probing via a `ShellEnvironmentProvider`. The
|
||||
tool is only wired when the chat client supports shell tools; otherwise a warning is logged
|
||||
and the shell tool/provider are skipped. The caller owns the executor's lifecycle.
|
||||
|
||||
```python
|
||||
from agent_framework_tools.shell import LocalShellTool, ShellEnvironmentProviderOptions
|
||||
|
||||
async with LocalShellTool(acknowledge_unsafe=True) as shell:
|
||||
agent = create_harness_agent(
|
||||
client=client,
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
shell_executor=shell,
|
||||
# Optional: customize environment probing.
|
||||
shell_environment_provider_options=ShellEnvironmentProviderOptions(probe_tools=("git", "python")),
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user