// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Tools.Shell;
///
/// An that probes the underlying shell
/// (OS, shell family/version, working directory, available CLI tools)
/// once per session and injects an authoritative instructions block so
/// the agent emits commands in the correct shell idiom.
///
///
///
/// This addresses a common failure mode where a model defaults to bash
/// syntax while talking to a PowerShell session (or vice versa). Probes
/// run through the supplied , so the same
/// provider works for both (host shell) and
/// (container shell).
///
///
/// The provider does not expose any new tools; it augments the system
/// prompt only (). Probe failures
/// are swallowed in a narrow set of cases — per-probe timeout
/// (, or an
/// caused by the
/// linked
/// token), policy rejection (),
/// and process spawn failures () —
/// and surfaced as entries in the snapshot.
/// Caller-requested cancellation (a
/// passed in by the host) is NOT swallowed and propagates as an
/// so shutdown paths work.
/// Other exceptions (e.g. argument errors, internal bugs) propagate
/// normally. A missing CLI never fails the agent: the model simply
/// sees fewer hints in its system prompt.
///
///
/// Why rather than
/// ? The shell environment
/// (OS, family, version, CWD, available CLIs) is stable runtime
/// metadata, not per-turn retrieved data. The framework's
/// AgentSkillsProvider uses Instructions for the same
/// reason; TextSearchProvider and ChatHistoryMemoryProvider
/// use Messages for retrieval payloads that are about
/// the user's question. System-prompt steering also has higher weight
/// in major providers (OpenAI, Anthropic) and benefits from prompt
/// caching, so injecting the env block as a fake user message would
/// be both weaker and more expensive.
///
///
public sealed class ShellEnvironmentProvider : AIContextProvider
{
private readonly IShellExecutor _executor;
private readonly ShellEnvironmentProviderOptions _options;
private Task? _snapshotTask;
///
/// Initializes a new instance of the class.
///
/// The shell executor used to run probe commands.
/// Optional configuration; defaults are used when .
/// is .
public ShellEnvironmentProvider(IShellExecutor executor, ShellEnvironmentProviderOptions? options = null)
{
this._executor = executor ?? throw new ArgumentNullException(nameof(executor));
this._options = options ?? new ShellEnvironmentProviderOptions();
}
///
/// Gets the most recently captured snapshot, or
/// if no probe has completed yet.
///
public ShellEnvironmentSnapshot? CurrentSnapshot { get; private set; }
///
/// Force a re-probe and refresh the cached snapshot. Useful when the
/// agent has changed something the snapshot depends on (e.g., installed
/// a new CLI mid-session).
///
/// Cancellation token.
/// The freshly captured snapshot.
public async Task RefreshAsync(CancellationToken cancellationToken = default)
{
var snapshot = await this.ProbeAsync(cancellationToken).ConfigureAwait(false);
this.CurrentSnapshot = snapshot;
this._snapshotTask = Task.FromResult(snapshot);
return snapshot;
}
///
protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
// First-call wins: subsequent concurrent callers await the same Task.
// If the cached task faults or is cancelled, clear it so the next call
// re-probes instead of permanently poisoning the provider.
var task = this._snapshotTask;
if (task is null)
{
var fresh = this.ProbeAsync(cancellationToken);
task = Interlocked.CompareExchange(ref this._snapshotTask, fresh, null) ?? fresh;
}
ShellEnvironmentSnapshot snapshot;
try
{
snapshot = await task.ConfigureAwait(false);
}
catch
{
// Replace the cached failed task with null only if no other thread
// has already done so. Concurrent waiters will all observe the
// failure once, but the next call starts a fresh probe.
_ = Interlocked.CompareExchange(ref this._snapshotTask, null, task);
throw;
}
this.CurrentSnapshot = snapshot;
var formatter = this._options.InstructionsFormatter ?? DefaultInstructionsFormatter;
return new AIContext { Instructions = formatter(snapshot) };
}
private async Task ProbeAsync(CancellationToken cancellationToken)
{
var family = this._options.OverrideFamily ?? DetectFamily();
await this._executor.InitializeAsync(cancellationToken).ConfigureAwait(false);
var (shellVersion, workingDir) = await this.ProbeShellAndCwdAsync(family, cancellationToken).ConfigureAwait(false);
var toolVersions = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var tool in this._options.ProbeTools)
{
// ProbeTools is user-supplied. Skip duplicates that differ only by
// case (e.g., "git" and "GIT") so we don't probe the same CLI twice
// and don't depend on dictionary insertion order for the result.
if (toolVersions.ContainsKey(tool))
{
continue;
}
toolVersions[tool] = await this.ProbeToolVersionAsync(tool, cancellationToken).ConfigureAwait(false);
}
return new ShellEnvironmentSnapshot(
Family: family,
OSDescription: RuntimeInformation.OSDescription,
ShellVersion: shellVersion,
WorkingDirectory: workingDir,
ToolVersions: toolVersions);
}
private async Task<(string? Version, string Cwd)> ProbeShellAndCwdAsync(ShellFamily family, CancellationToken cancellationToken)
{
var probe = family == ShellFamily.PowerShell
? "Write-Output (\"VERSION=\" + $PSVersionTable.PSVersion.ToString()); Write-Output (\"CWD=\" + (Get-Location).Path)"
: "echo \"VERSION=${BASH_VERSION:-${ZSH_VERSION:-unknown}}\"; echo \"CWD=$PWD\"";
var result = await this.RunProbeAsync(probe, cancellationToken).ConfigureAwait(false);
if (result is null)
{
return (null, string.Empty);
}
string? version = null;
string cwd = string.Empty;
foreach (var line in result.Stdout.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
{
if (line.StartsWith("VERSION=", StringComparison.Ordinal))
{
var v = line.Substring("VERSION=".Length).Trim();
version = string.IsNullOrEmpty(v) || v == "unknown" ? null : v;
}
else if (line.StartsWith("CWD=", StringComparison.Ordinal))
{
cwd = line.Substring("CWD=".Length).Trim();
}
}
return (version, cwd);
}
private static readonly System.Text.RegularExpressions.Regex s_toolNamePattern =
new("^[A-Za-z0-9._-]+$", System.Text.RegularExpressions.RegexOptions.Compiled);
private async Task ProbeToolVersionAsync(string tool, CancellationToken cancellationToken)
{
// The tool name is interpolated into a shell command, so reject anything that
// isn't a plain identifier. Whitespace, quotes, $, ;, |, &, etc. are not valid
// in any real CLI binary name and would otherwise allow shell injection if the
// configured tool list is sourced from untrusted input.
if (string.IsNullOrEmpty(tool) || !s_toolNamePattern.IsMatch(tool))
{
return null;
}
var probe = $"{tool} --version";
var result = await this.RunProbeAsync(probe, cancellationToken).ConfigureAwait(false);
if (result is null || result.ExitCode != 0)
{
return null;
}
// Some CLIs (java, gcc on older versions) emit `--version` to stderr.
var firstLine = FirstNonEmptyLine(result.Stdout) ?? FirstNonEmptyLine(result.Stderr);
return string.IsNullOrWhiteSpace(firstLine) ? null : firstLine!.Trim();
static string? FirstNonEmptyLine(string text) =>
text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
}
private async Task RunProbeAsync(string command, CancellationToken cancellationToken)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(this._options.ProbeTimeout);
try
{
return await this._executor.RunAsync(command, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Probe-timeout-driven cancellation: surface as a null snapshot field.
// Caller-driven cancellation is allowed to propagate.
return null;
}
catch (Exception ex) when (ex is ShellCommandRejectedException || ex is ShellExecutionException || ex is ShellTimeoutException)
{
return null;
}
}
private static ShellFamily DetectFamily() =>
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? ShellFamily.PowerShell
: ShellFamily.Posix;
///
/// Default formatter for the instructions block. Public so callers
/// who want to wrap or augment the default can call it directly.
///
/// The snapshot to render.
/// A multi-line markdown-style instructions block.
public static string DefaultInstructionsFormatter(ShellEnvironmentSnapshot snapshot)
{
var sb = new StringBuilder();
_ = sb.AppendLine("## Shell environment");
if (snapshot.Family == ShellFamily.PowerShell)
{
var version = snapshot.ShellVersion is null ? string.Empty : $" {snapshot.ShellVersion}";
_ = sb.Append("You are operating a PowerShell").Append(version).Append(" session on ").Append(snapshot.OSDescription).AppendLine(".");
_ = sb.AppendLine("Use PowerShell idioms, NOT bash:");
_ = sb.AppendLine("- Set environment variables with `$env:NAME = 'value'` (NOT `NAME=value`).");
_ = sb.AppendLine("- Change directory with `Set-Location` or `cd`. Paths use `\\` separators.");
_ = sb.AppendLine("- Reference environment variables as `$env:NAME` (NOT `$NAME`).");
_ = sb.AppendLine("- The system temp directory is `[System.IO.Path]::GetTempPath()` (NOT `/tmp`).");
_ = sb.AppendLine("- Pipe to `Out-Null` to suppress output (NOT `> /dev/null`).");
}
else
{
var version = snapshot.ShellVersion is null ? string.Empty : $" {snapshot.ShellVersion}";
_ = sb.Append("You are operating a POSIX shell").Append(version).Append(" session on ").Append(snapshot.OSDescription).AppendLine(".");
_ = sb.AppendLine("Use POSIX shell idioms (bash/sh).");
_ = sb.AppendLine("- Set environment variables for the next command with `export NAME=value`.");
_ = sb.AppendLine("- Reference environment variables as `$NAME` or `${NAME}`.");
_ = sb.AppendLine("- Paths use `/` separators.");
}
if (!string.IsNullOrEmpty(snapshot.WorkingDirectory))
{
_ = sb.Append("Working directory: ").AppendLine(snapshot.WorkingDirectory);
}
var installed = snapshot.ToolVersions
.Where(kv => kv.Value is not null)
.Select(kv => $"{kv.Key} ({kv.Value})")
.ToList();
var missing = snapshot.ToolVersions
.Where(kv => kv.Value is null)
.Select(kv => kv.Key)
.ToList();
if (installed.Count > 0)
{
_ = sb.Append("Available CLIs: ").AppendLine(string.Join(", ", installed));
}
if (missing.Count > 0)
{
_ = sb.Append("Not installed: ").AppendLine(string.Join(", ", missing));
}
return sb.ToString().TrimEnd();
}
}