Skip to content

base work#1

Open
MatthiasHowellYopp wants to merge 1 commit into
mainfrom
issue-5260
Open

base work#1
MatthiasHowellYopp wants to merge 1 commit into
mainfrom
issue-5260

Conversation

@MatthiasHowellYopp
Copy link
Copy Markdown
Owner

###Motivation and Context
Fixes microsoft#5260

The current agent-framework-redis package depends on redisvl, which requires Redis Stack's RediSearch module. This creates an incompatibility for teams running Valkey — whether self-hosted or through managed cloud services (AWS ElastiCache, GCP Memorystore) — since RedisVL has known incompatibilities with Valkey's search module. Additionally, even the RedisChatMessageStore (which only needs basic key-value operations) pulls in RedisVL as a transitive dependency.

This PR adds a dedicated agent-framework-valkey package that uses valkey-glide (the official Valkey Python client) directly, with no RedisVL dependency.

###Description
New package: agent-framework-valkey (python/packages/valkey/)

Two components:

ValkeyChatMessageStore — Persistent chat message storage implementing the HistoryProvider protocol. Uses only basic Valkey key-value/list operations via valkey-glide, so it works with any Valkey (or Redis OSS) server with no search module required.

ValkeyContextProvider — Long-term memory context provider implementing the ContextProvider protocol. Uses Valkey's native FT.CREATE / FT.SEARCH commands via valkey-glide's custom_command API for full-text and optional hybrid vector search. Requires valkey-search >= 1.2 (ships with valkey-bundle >= 9.1.0).

The package follows the same structure and patterns as agent-framework-redis and agent-framework-mem0. Key design decisions:

Uses valkey-glide (official Valkey client, Apache-2.0 licensed) instead of RedisVL
Vector search uses Valkey's native FT commands directly rather than an abstraction layer, keeping the dependency tree minimal
embed_fn is a simple async callable rather than a framework-specific vectorizer class, making it easy to plug in any embedding provider
Includes a sample (
valkey_sample.py
) demonstrating both components with Bedrock
Changes:

python/packages/valkey/ — new package with implementation, tests, README, LICENSE
pyproject.toml
— added workspace source and pyright test environment for valkey
PACKAGE_STATUS.md
— registered as alpha
uv.lock
— updated with valkey-glide resolution
All checks pass: ruff formatting/linting, pyright, mypy, and 39 unit tests at 88% coverage.

###Contribution Checklist

-[x] The code builds clean without any errors or warnings
-[x] The PR follows the Contribution Guidelines
-[x]All unit tests pass, and I have added new tests where possible
-[ ] Is this a breaking change? If yes, add "[BREAKING]" prefix to the title of the PR.

Copy link
Copy Markdown

@daric93 daric93 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting this together — the overall structure is clean, the package mirrors the Redis patterns well, and the documentation/sample code are solid. A few items to address before this is ready to merge; see the inline review comments for details.

Correctness bug:

  • Vector embeddings are stored/queried as hex strings instead of raw bytes, which will cause vector search to return incorrect results silently.

Performance:

  • save_messages does individual rpush calls instead of batching — easy fix with valkey-glide's multi-value rpush.

API consistency (within the package and vs. Redis):

  • Missing aclose() on ValkeyContextProvider (forcing __aexit__ anti-pattern in sample code)
  • ValkeyContextProvider lacks valkey_url support that ValkeyChatMessageStore has
  • No search_all() equivalent from the Redis package
  • No validation for mutually exclusive connection params

Test coverage gaps:

  • Zero tests for the vector/hybrid search path (embed_fn provided)
  • No tests for _search() error handling/exception wrapping
  • Fragile assertion logic in test_stores_partition_fields

Type safety:

  • embed_fn typed as Any instead of a proper Callable type

See inline comments for details and suggested fixes.

state: Optional session state. Unused for Valkey-backed history.
**kwargs: Additional arguments (unused).
"""
if not messages:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

save_messages pushes each message with a separate await client.rpush(key, [serialized]) call — one network round-trip per message. The Redis implementation uses pipeline(transaction=True) to batch these.

valkey-glide's rpush accepts a list of values in a single call:

await client.rpush(key, serialized_messages)

This would reduce N round-trips to 1 and align with how the Redis package handles this.

raise IntegrationInvalidRequestException(f"Failed to create Valkey search index: {exc}") from exc

self._index_created = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The embedding is stored as vec_bytes.hex() (a hex-encoded string), and the search query in _search() also passes vec_bytes.hex() as the PARAMS value. Valkey's VECTOR field expects raw binary bytes, not hex strings.

The Redis implementation stores np.asarray(...).tobytes() directly. With hex encoding, the stored data won't match the binary format the vector index expects, so vector search will silently return incorrect results.

Both _add() here and the FT.SEARCH PARAMS in _search() should pass raw bytes instead:

field_map[self.vector_field_name] = vec_bytes  # not vec_bytes.hex()

valkey-glide's hset and custom_command accept bytes values directly.

This bug is currently not caught because there are no tests exercising the vector search path (all tests use embed_fn=None). Adding a test with a mock embed_fn that asserts the value passed to hset is raw bytes would prevent regressions.

vector_dims: Dimensionality of embedding vectors. Required if embed_fn is set.
vector_field_name: The name of the vector field. Required for vector search.
vector_algorithm: Vector index algorithm ("FLAT" or "HNSW"). Defaults to "HNSW".
vector_distance_metric: Distance metric ("COSINE", "IP", or "L2"). Defaults to "COSINE".
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

embed_fn is an async callable that takes a string and returns a list of floats — but the parameter is typed as Any | None, which provides no type safety.

The docstring correctly describes the expected signature as async def embed(text: str) -> list[float], but the type system can't enforce this. Consider using a proper type:

from collections.abc import Callable, Awaitable

EmbedFn = Callable[[str], Awaitable[list[float]]]

embed_fn: EmbedFn | None = None,

The Redis implementation uses BaseVectorizer (a concrete type from redisvl) for the same purpose. Since this package intentionally avoids redisvl, a Callable type or a simple Protocol would be the idiomatic Python equivalent while keeping the dependency tree minimal.

async def __aenter__(self) -> Self:
"""Async context manager entry."""
return self

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ValkeyChatMessageStore has an explicit aclose() method, but ValkeyContextProvider only cleans up the client inside __aexit__. This forces callers to use the awkward pattern seen in the sample:

await context_provider.__aexit__(None, None, None)  # valkey_sample.py line 213

Add an aclose() method (like the chat store has) and have __aexit__ delegate to it:

async def aclose(self) -> None:
    if self._owns_client and self._client is not None:
        await self._client.close()
        self._client = None

async def __aexit__(self, ...):
    await self.aclose()

This is consistent with the chat store, with standard Python async resource patterns, and avoids the __aexit__ anti-pattern in the sample code.

self._client = None


__all__ = ["ValkeyContextProvider"]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Redis RedisContextProvider exposes a search_all() method that retrieves all documents in the index using paginated FilterQuery. The Valkey implementation has no equivalent.

This is useful for debugging, testing, and administrative tasks (e.g., verifying what's been stored). Consider adding a similar method using FT.SEARCH with a * query and LIMIT pagination:

async def search_all(self, page_size: int = 200) -> list[dict[str, Any]]:
    """Returns all documents in the index."""
    await self._ensure_index()
    client = await self._get_client()
    # Use FT.SEARCH with wildcard query and pagination
    ...

Not a blocker, but a feature gap vs. the Redis package worth noting.

valkey_url: str | None = None,
host: str = "localhost",
port: int = 6379,
*,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Redis RedisHistoryProvider validates that redis_url and credential_provider are mutually exclusive and raises ValueError if both are provided.

Here, if a caller passes both valkey_url and host/port, the URL silently wins (in _get_client). This can lead to confusing behavior where the user thinks they're connecting to host:port but the URL takes precedence.

Consider adding validation:

if valkey_url is not None and (host != "localhost" or port != 6379):
    raise ValueError("valkey_url and explicit host/port are mutually exclusive")

Or at minimum, document the precedence clearly in the docstring.

use_tls: bool = False,
index_name: str = "context_idx",
prefix: str = "context:",
vector_dims: int | None = None,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ValkeyChatMessageStore supports valkey_url for connection (with URL parsing), but ValkeyContextProvider only accepts host/port. Within the same package, users would expect a consistent connection interface across both components.

Consider adding valkey_url support here as well, reusing the same _parse_url logic (or extracting it to a shared utility).

provider = ValkeyContextProvider(source_id="ctx", user_id="u1", client=mock_glide_client)
assert "Memories" in provider.context_prompt


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All ValkeyContextProvider tests use text-only search (embed_fn=None). There are no tests covering the vector/hybrid search path. This leaves several critical code paths untested:

  1. _add() — embedding generation and storage (the .hex() bug from the other comment)
  2. _search() — KNN query construction with PARAMS
  3. _ensure_index() — vector field inclusion in FT.CREATE schema

The Redis package includes TestRedisContextProviderHybridQuery for this exact scenario. Consider adding equivalent tests with a mock embed_fn:

async def test_vector_search_path(self, mock_glide_client):
    mock_embed = AsyncMock(return_value=[0.1] * 128)
    provider = ValkeyContextProvider(
        source_id="ctx",
        user_id="u1",
        client=mock_glide_client,
        embed_fn=mock_embed,
        vector_field_name="embedding",
        vector_dims=128,
    )
    # Test _add stores embeddings correctly
    # Test _search constructs KNN query
    # Test _ensure_index includes vector field in schema

assert "ctx" in ctx.context_messages
msgs = ctx.context_messages["ctx"]
assert len(msgs) == 1
assert "Memory A" in msgs[0].text
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _search() method wraps unexpected exceptions in IntegrationInvalidRequestException, but there's no test verifying this behavior. A Valkey connection error or malformed response during search should be properly wrapped and re-raised.

Consider adding:

async def test_search_wraps_exceptions(self, mock_glide_client):
    mock_glide_client.custom_command = AsyncMock(
        side_effect=[None, ConnectionError("connection lost")]  # first call = FT.CREATE, second = FT.SEARCH
    )
    provider = ValkeyContextProvider(source_id="ctx", user_id="u1", client=mock_glide_client)

    with pytest.raises(IntegrationInvalidRequestException, match="Valkey search failed"):
        await provider._search(text="test")

Also worth testing that IntegrationInvalidRequestException raised directly inside _search (e.g., empty text) is not double-wrapped.

)
session = AgentSession(session_id="test-session")
ctx = SessionContext(input_messages=[Message(role="user", contents=["hello"])], session_id="s1")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

field_dict = call_args[1] if call_args[1] else call_args[0][1] relies on the falsy-ness of an empty kwargs dict to fall through to positional args. Since hset is called as client.hset(doc_id, field_map) (positional), call_args[1] is {} (falsy), so it falls through — but this works by accident.

Use the explicit positional form for clarity:

call_args = mock_glide_client.hset.call_args
field_dict = call_args[0][1]  # second positional arg

Or use call_args_list[0] if you want to be even more explicit about which call you're inspecting.

@MatthiasHowellYopp MatthiasHowellYopp force-pushed the issue-5260 branch 11 times, most recently from 65363e2 to 04d0446 Compare April 29, 2026 20:34
@MatthiasHowellYopp MatthiasHowellYopp force-pushed the issue-5260 branch 5 times, most recently from 377cb65 to b942984 Compare May 7, 2026 20:13
MatthiasHowellYopp pushed a commit that referenced this pull request May 13, 2026
* feat(dotnet): add Microsoft.Agents.AI.Tools.Shell with LocalShellTool

Ports Python LocalShellTool to .NET as a new package (net8/9/10).

- Microsoft.Agents.AI.Tools.Shell: LocalShellTool, ShellPolicy (deny-list
  guardrail), ShellResolver (cross-OS pwsh/powershell/cmd vs bash/sh),
  ShellResult with head+tail truncation, timeout + process-tree kill,
  AsAIFunction with required-by-default human approval gate.
- Persistent mode via ShellSession (sentinel protocol over pwsh/bash).
- acknowledgeUnsafe parity gate matches the Python implementation.
- Auto-injected platform context in the AIFunction description so the
  LLM sees the active OS and shell at tool-discovery time.
- 17 xunit.v3 tests cover policy allow/deny, echo roundtrip, exit
  codes, timeout/kill, AsAIFunction shape + approval wrapping,
  persistent cwd/env carry-over, head+tail truncation, sentinel race.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(shell): close Python parity gaps for LocalShellTool

Closes the .NET vs Python parity gaps identified in the competitive eval:

- Default mode flipped to ShellMode.Persistent (matches Python). Every
  call now reuses a long-lived shell so cd/exports/functions persist;
  pass mode: ShellMode.Stateless to opt out.
- New IShellExecutor interface — pluggable backend so future
  DockerShellTool / Hyperlight / SSH executors don't fork the framework.
  LocalShellTool implements it.
- Workdir confinement: confineWorkingDirectory (default true) re-anchors
  every persistent-mode command back to workingDirectory so a wandering
  cd in one call doesn't leak to the next. Mirrors Python _maybe_reanchor.
- Graceful interrupt on timeout: ShellSession sends SIGINT (POSIX) or
  Ctrl+C-on-stdin (Windows) before falling back to a hard close+respawn.
  Successfully-interrupted commands return exit 124 + TimedOut=true while
  preserving session state for the next call.
- cleanEnvironment opt-in: when true, only PATH/HOME/USER/USERNAME/
  USERPROFILE/SystemRoot/TEMP/TMP plus user-supplied vars are visible.
- shellArgv: IReadOnlyList<string> override accepted alongside the
  string shell binary param (mutually exclusive). Lets advanced callers
  inject flags like --rcfile or --login.
- Typed exceptions ShellTimeoutException and ShellExecutionException
  replace InvalidOperationException for launch / liveness failures.

Tests: 17 -> 23. New cases cover persistent-default ctor, mutually-
exclusive shell/shellArgv, confined re-anchor, confine-disabled leak,
clean-env strip, and IShellExecutor implementation. All green on net10.0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(shell): add DockerShellTool sandboxed shell tier

Ports the Python DockerShellTool to .NET. Mirrors the public surface of
LocalShellTool but executes commands inside an isolated container, where
the container is the security boundary. Stateless and persistent modes
both supported; persistent mode reuses ShellSession by launching
'docker exec -i <ctr> bash --noprofile --norc' as the long-lived REPL,
so the sentinel protocol works unchanged.

Defaults chosen for safety:
- --network none, --user 65534:65534 (nobody), --read-only root
- --cap-drop=ALL, --security-opt=no-new-privileges
- 512m memory cap, pids-limit 256, --tmpfs /tmp
- Optional host workdir mount, ro by default

Public surface:
- DockerShellTool ctor with image/container_name/mode/host_workdir/
  workdir/network/memory/pids_limit/user/read_only_root/extra_run_args/
  environment/policy/timeout/max_output_bytes/on_command/docker_binary
- StartAsync, CloseAsync, RunAsync, AsAIFunction, IShellExecutor impl
- IsAvailableAsync(binary) probe
- Static argv builders (BuildRunArgv, BuildExecArgv) — pure, side-
  effect free, so unit tests don't need a Docker daemon

AsAIFunction defaults to requireApproval: false (the container IS the
boundary). LocalShellTool keeps the opposite default.

Tests: 23 -> 35. 12 new tests cover argv builders, env/extra-args/host-
workdir flags, exec interactive vs stateless, container name uniqueness,
IShellExecutor implementation, AsAIFunction approval defaults, and
IsAvailableAsync false-path. None require Docker. Multi-TFM build
(net8/9/10) green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(shell): add DockerShellTool integration tests

Adds 9 end-to-end tests that exercise DockerShellTool against a live
Docker (or Podman) daemon. Tests are tagged [Trait("Category",
"Integration")] and auto-skip via Assert.Skip when no daemon is
available, so they are CI-safe.

Coverage:
- IsAvailableAsync probe
- Persistent mode basic command + state preservation across calls
- --network none blocks outbound DNS
- --read-only root prevents writes outside /tmp; /tmp tmpfs is writable
- --user 65534:65534 (nobody) is in effect
- Stateless mode: env vars do not leak across calls
- HostWorkdir bind-mount + read-only enforcement
- Environment variables passed via -e

Tests use debian:stable-slim (alpine ships only busybox sh, which
ShellSession persistent bash REPL cannot drive).

Run locally:
  dotnet test --filter "Category=Integration"
or filter by class on the test exe directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* style(shell): apply dotnet format pass

- Whitespace and code-style fixes from `dotnet format` across both
  projects
- Convert all new files to UTF-8 with BOM and LF line endings
  (repo convention)
- Rename ShellSession statics to s_ prefix (IDE1006)
- Add Async suffix to async test methods (IDE1006)

No behavioral changes. All 44 tests still pass on net10.0; multi-TFM
build (net8/net9/net10) green. `dotnet format --verify-no-changes`
now reports clean for both projects.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(shell): add DockerShellTool walkthrough with sequence diagrams

Explains the mental model (we shell out to the docker CLI; we never speak the engine API), the hardened docker run argv, persistent vs stateless lifecycles with mermaid sequence diagrams, the full agent-to-bash call ladder, and the failure modes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* PR 5604 review fixes (group a): libc DllImport, namespace cleanup, policy-msg dedup

Three quick-win review comments on PR microsoft#5604:

1. ShellSession: the libc `killpg` P/Invoke was annotated with
   `DllImportSearchPath.System32`, a Windows-only loader hint that does
   nothing for libc.so on POSIX. Switched to `SafeDirectories` (CA5392
   /CA5393 clean) and added a comment noting the call site is gated to
   non-Windows.

2. DockerShellToolTests: replaced the fully-qualified
   `Extensions.AI.ApprovalRequiredAIFunction` with a `using
   Microsoft.Extensions.AI;` import and the bare type name, matching
   `LocalShellToolTests`.

3. LocalShellTool / DockerShellTool: `AsAIFunction`'s catch block was
   producing a doubled "Command blocked by policy: Command rejected by
   policy: ..." prefix because the `ShellPolicyException` message
   already starts with "Command rejected by policy". Now we return
   `ex.Message` directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* PR 5604 review fix (group b): add ShellKind.Sh for /bin/sh fallback

Review comment (#3): when /bin/bash is missing the resolver fell back to
/bin/sh but tagged it as ShellKind.Bash, so the launcher passed bash-only
flags --noprofile --norc to dash/ash/busybox, which interpret them as
positional script names.

Fix:

* Added ShellKind.Sh for minimal POSIX shells (sh, dash, ash, busybox).
* /bin/sh fallback is now tagged Sh.
* ClassifyKind maps "SH" / "DASH" / "ASH" / "BUSYBOX" binary names to Sh.
* StatelessArgvForCommand emits just `-c <command>` for Sh (no
  bash-only flags); PersistentArgv emits no flags at all.
* LocalShellTool's system-prompt builder describes Sh distinctly and
  warns the model away from bash-only constructs.

Tests: ShellResolverTests covers Sh/Bash classification through the
observable argv output (14 new theory cases). Total: 58/58.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* PR 5604 review fix (group d): honor timeout=null, add DefaultTimeout

Review comment (microsoft#5): both LocalShellTool and DockerShellTool documented
`timeout: null` as "disables timeouts" but the constructor coerced null
to 30 seconds, making the documented disable mechanism unreachable
through the public API.

Fix:

* Drop the `?? TimeSpan.FromSeconds(30)` coercion in both ctors.
  `_timeout` now faithfully reflects what the caller passed (null =
  disabled). The downstream CTS-construction sites already short-circuit
  on null, so no other code changes are required.
* Add `public static readonly TimeSpan DefaultTimeout` (30 s) on both
  tools so callers who want a bounded timeout can opt in explicitly.

Tests:

* New `RunAsync_NullTimeout_DoesNotTimeOutAsync` confirms a quick
  command runs to completion when the caller passes `timeout: null`.
* New `DefaultTimeout_IsThirtySeconds` documents the constant.

Behavioral note: this is a deliberate change-of-default. Callers that
previously omitted `timeout` and relied on the implicit 30 s now get
"no timeout". They should pass `LocalShellTool.DefaultTimeout` or
`DockerShellTool.DefaultTimeout` explicitly to preserve the prior
behavior.

Tests: 60/60 (44 baseline + 14 resolver + 2 new timeout tests).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* PR 5604 review fix (group e): smart requireApproval default for DockerShellTool

Review comment (microsoft#6, design): requireApproval: false baked in a
safety decision the type cannot prove on its own. Callers can
weaken any isolation knob (network, user, readOnlyRoot, mount,
extraRunArgs) and still get an unapproved tool by default.

Fix:

* New public IsHardenedConfiguration property returns true iff the
  effective config matches the safe defaults: network=="none",
  non-root user, read-only root, host mount (if any) read-only,
  no extra run args.
* AsAIFunction's requireApproval parameter is now bool? defaulting
  to null. When null, approval is enabled iff
  IsHardenedConfiguration is false. Pass false explicitly to opt
  out, or true to force.
* docker-shell-tool.md updated with the new approval matrix.

Tests: 4 new theory cases + 2 facts cover hardened-default,
relaxed-network, root-user, writable-root, extraRunArgs, and
explicit-opt-out branches. Total: 66/66.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* PR 5604 review fix (group c): wrap POSIX shell in setsid for correct killpg

Review comment (#1): killpg(proc.Id, SIGINT) only behaves like a
process-group signal when proc.Id IS a process group id. Since the
.NET launcher does not call setsid() / setpgid() itself, the spawned
shell inherits the agent host's process group — so killpg targeted
the wrong group and the cancel signal could leak to the agent.

Fix:

* On non-Windows, EnsureStartedAsync probes for setsid (well-known
  paths first, then PATH). When found it wraps the shell launch as
  `setsid <shell> <args...>` so the spawned shell becomes a session
  leader (PID == PGID).
* A new _isSessionLeader flag tracks whether the wrap succeeded.
* InterruptCurrentCommandAsync only calls killpg when
  _isSessionLeader is true. Without setsid, killpg on an unsuited
  PID could signal the agent itself, so we skip the fast path and
  let the caller's hard close-and-respawn handle the timeout.
* Windows behaviour is unchanged (Ctrl+C-via-stdin to pwsh).

No public-API changes; existing tests cover the interrupt path and
all 66/66 still pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .Net: DockerShellTool design + caller-cancel container leak fixes (PR microsoft#5604)

Addresses three Copilot review findings on PR microsoft#5604.

Design (group f):
* StartAsync: change inner ResolvedShell from ShellKind.Bash to ShellKind.Sh.
  BuildExecArgv() already includes `--noprofile --norc` in ExtraArgv;
  Bash's PersistentArgv() was appending those flags a second time,
  yielding `bash --noprofile --norc --noprofile --norc`. Sh's
  PersistentArgv() returns Array.Empty so ExtraArgv is forwarded
  unchanged.
* BuildExecArgv: remove the dead `interactive: false` branch and the
  `interactive` parameter. The `false` path produced an unusable argv
  ending in `-c` with no command and was never invoked internally
  (stateless mode uses BuildRunArgvStateless). Updated tests and
  docs/docker-shell-tool.md sequence diagram.

Reliability (group g):
* RunStatelessAsync: add a second `catch (OperationCanceledException)`
  guarded on `cancellationToken.IsCancellationRequested` that issues
  `docker kill --signal KILL <perCallName>` before rethrowing.
  Previously, caller-driven cancellation bypassed the timeout-only
  catch and propagated without killing the container; because `--rm`
  only fires when PID 1 exits, the container ran indefinitely.
  Extracted the kill-by-name logic into a `BestEffortKillContainerAsync`
  helper shared by both the timeout and caller-cancel paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .Net: Fill PR microsoft#5604 test coverage gaps for Shell tools

Addresses the test-coverage findings in the latest Copilot review.

* ShellResultTests (new): direct branch coverage for
  ShellResult.FormatForModel() — empty stdout, non-empty stderr,
  truncated, timed-out, success, and the truncated-with-empty-stdout
  edge where the marker is intentionally suppressed. This method's
  string is what the language model sees, so it benefits from
  explicit unit-level coverage independent of integration tests.
* ShellSessionTests (new): direct unit tests for the internal
  TruncateHeadTail head-tail truncation utility — under-cap (no
  truncation), exactly at cap (no truncation), over-cap (truncated
  with marker, both head and tail preserved), and empty-string.
  Reachable via InternalsVisibleTo.
* LocalShellToolTests: Theory test exercising 8 representative
  patterns from ShellPolicy.DefaultDenyList (rm -rf /, mkfs.ext4,
  curl|sh, wget|sh, Remove-Item /, shutdown, reboot, Format-Volume)
  to catch deny-list regex regressions; previously only 1/16 was
  tested.
* LocalShellToolTests: explicit stderr-capture assertion (echo to
  stderr → result.Stderr contains the message). Stderr capture was
  not directly asserted anywhere in the suite.
* DockerShellToolTests: RunAsync_RejectedCommand throws
  ShellCommandRejectedException. The Docker-side policy check is a
  pure-logic path that runs before any docker invocation, so this
  test covers the rejection branch without needing a Docker daemon.

Total: 66 -> 85 tests, all passing on net10.0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(dotnet/shell): add ShellEnvironmentProvider for OS-aware shell instructions

Pairs LocalShellTool/DockerShellTool with an AIContextProvider that
probes the live shell once per session (OS, family, version, CWD,
configurable CLI versions) and injects authoritative instructions so
the agent uses platform-native idioms (PowerShell vs POSIX). Fixes the
class of bugs where the model emits 'VAR=value' / '/tmp' / '$VAR' on
a Windows PowerShell session.

- ShellEnvironmentProvider/Snapshot/Options public surface in the
  existing Microsoft.Agents.AI.Tools.Shell package (one new project
  reference to Microsoft.Agents.AI.Abstractions).
- Probes go through the same IShellExecutor that runs agent commands,
  so they respect the configured policy and (for DockerShellTool) the
  container boundary.
- 8 unit tests covering snapshot capture, default formatter idioms,
  missing-tool handling, custom formatter override, and refresh.
- Agent_Step21_ShellWithEnvironment sample replays the DEMO_TOKEN
  cross-call scenario using a persistent local shell.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(dotnet/shell): address PR review feedback round 3

- ShellEnvironmentProvider.cs split into one-type-per-file (ShellFamily,
  ShellEnvironmentSnapshot, ShellEnvironmentProviderOptions, plus the
  provider class) to match FoundryMemoryProvider/AgentSkillsProvider
  layout.
- csproj: drop IsPackable=false (package will publish on merge), add
  IsReleased=true and disable package validation baseline (first release),
  use TargetFrameworksCore, add InjectSharedDiagnosticIds and
  InjectExperimentalAttributeOnLegacy to align with shipping packages.
- Sample: refactor to demonstrate stateless mode first (independent
  read-only commands), then persistent mode (state carried across calls,
  e.g. DEMO_TOKEN). Strip narrative/historical comments.
- Move docker-shell-tool.md out of the package — that doc lives in
  the docs repo (semantic-kernel-pr/agent-framework, branch
  feat/dotnet-shell-tool).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR microsoft#5604 round 4 review feedback

- Sample (Agent_Step21_ShellWithEnvironment): add prominent WARNING block
  noting LocalShellTool runs real commands on the host. Restructure sample
  to demonstrate stateless mode first (cd does not carry across calls) then
  persistent mode (cd and env vars persist), motivating when to pick each.
- DockerShellTool class XML doc: reframe as a best-effort baseline rather
  than a security guarantee; list mitigations users should still apply.
- DockerShellTool ShellKind.Sh comment: rephrase as forward-looking design
  rationale (avoid duplicate --noprofile/--norc if Bash is reintroduced)
  instead of bug-history narrative.
- DockerShellTool.IsHardenedConfiguration / AsAIFunction XML docs: clarify
  these are configuration-shape checks and convenience defaults, not
  security guarantees.
- Drop IDisposable from LocalShellTool and DockerShellTool. The previous
  sync Dispose() blocked on DisposeAsync().GetAwaiter().GetResult() with a
  VSTHRD002 suppression, which is fragile under sync contexts. Both tools
  now expose IAsyncDisposable only; tests updated to await using.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add Async suffix to async test methods to satisfy IDE1006

Fixes check-format CI failure on PR microsoft#5604.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix CPU busy-spin in WaitForSentinelAsync

When new bytes arrived in the stdout read loop, the producer called
TrySetResult on _stdoutSignal but did not replace it with a fresh TCS.
A consumer looping inside WaitForSentinelAsync would then re-read the
same already-completed TCS, causing WaitAsync(100ms) to return
synchronously every iteration — a tight busy-spin that pinned a core
until the sentinel arrived or the timeout fired.

Swap the signal before completing the old one so the next consumer
iteration observes a fresh (uncompleted) TCS, matching the pattern
already used in ReadExitCodeAsync.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove unused onCommand audit hook from shell tools

The Action<string> onCommand callback was a redundant audit-logging seam:
no production callers, no Python parity, and the framework already
provides function-invocation middleware for cross-cutting concerns at
the AIFunction layer. Removing the parameter from LocalShellTool and
DockerShellTool keeps the public surface lean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Align Shell csproj with Foundry.Hosting preview-package conventions

- Add RootNamespace
- Move Title/Description into the primary PropertyGroup with
  TargetFrameworks/VersionSuffix to match the Foundry.Hosting layout
- Drop IsReleased (preview packages do not set it)
- Drop UTF-8 BOM

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Document why ShellEnvironmentProvider uses Instructions, not Messages

Expand the class XML doc to record the design rationale: the shell
environment is stable runtime metadata, not per-turn retrieval, so it
belongs in AIContext.Instructions (matching AgentSkillsProvider).
Messages is reserved for retrieval payloads (TextSearchProvider,
ChatHistoryMemoryProvider). System-role placement also has higher
steering weight and benefits from prompt caching in major providers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Clarify which probe failures ShellEnvironmentProvider swallows

Name the four exception types explicitly (timeout, policy rejection,
spawn failure, cancellation) and note that all other exceptions
propagate normally. Avoids the misleading impression that the provider
is a blanket try/catch.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Strip cross-language and bug-history narrative from shell tool comments

Remove "hard-won" framing and explicit "Mirrors the Python ..." cross
references from class XML docs and inline comments in ShellSession,
DockerShellTool, and ShellResolver. Comments now describe current
behavior without commentary on prior implementations or development
history.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR microsoft#5604 round 5 review feedback

- ShellResolver: classify only `bash` as ShellKind.Bash; sh/zsh/dash/ash/ksh/busybox now route through ShellKind.Sh so bash-only --noprofile/--norc flags are not emitted to shells that reject them. Update enum doc and tests.

- ShellEnvironmentProvider.ProbeToolVersionAsync: validate the tool name against ^[A-Za-z0-9._-]+$ before interpolating into a shell command (prevents injection if ProbeTools is sourced from untrusted config). Fall back to stderr when stdout is empty so CLIs like java/older gcc still report a version. Drop misleading 'quoted' comment.

- ShellSession.TruncateHeadTail: truncate by UTF-8 byte count on rune boundaries, honouring the documented maxOutputBytes contract for non-ASCII output.

- ShellEnvironmentProviderTests: drop reflection on private _options; assert against the options instance the test already owns. Rename misnamed RefreshAsync test to reflect re-probing semantics. Add coverage for invalid tool names and stderr-only version output.

- ShellSessionTests: add multi-byte UTF-8 truncation tests (byte-budget honoured, no rune split, no U+FFFD).

- Move DockerShellToolIntegrationTests.cs from the unit test project into a new Microsoft.Agents.AI.Tools.Shell.IntegrationTests project so 'dotnet test' on the unit suite no longer requires a Docker daemon. Wire the new project into agent-framework-dotnet.slnx.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR microsoft#5604 round 6 review feedback

- ShellSession.MaybeReanchor: switch from double-quoted to single-quoted literal-quoting per shell. Double quotes still expand $VAR, ``, and backticks in both PowerShell and POSIX, so a working directory containing shell metacharacters could trigger command substitution. Add QuotePowerShell (escape ' as '') and QuotePosix (close-and-reopen around ') helpers and route MaybeReanchor through them. Add tests covering ``, $VAR, backticks, and embedded single quotes.

- ShellEnvironmentProvider.RunProbeAsync: narrow the OperationCanceledException filter to `when (!cancellationToken.IsCancellationRequested)` so caller-driven cancellation propagates instead of being silently converted to a null snapshot. Update the class XML doc to call out the distinction. Add tests for both paths (caller cancellation throws, probe-timeout returns null fields).

- DockerShellTool.RunStatelessAsync / RunDockerCommandAsync: replace unbounded StringBuilder accumulators with a shared HeadTailBuffer (extracted from LocalShellTool into its own internal type). Caps memory at roughly maxOutputBytes regardless of how much output a command emits; drops the now-redundant trailing TruncateHeadTail call. RunDockerCommandAsync caps helper-command output at 1 MiB (defends against chatty docker pull progress streams). Add HeadTailBufferTests covering bounded behaviour over 10 MiB of streamed input.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR microsoft#5604 round 7 review feedback

- HeadTailBuffer: switch to UTF-8 byte-aware truncation. The class previously

  capped on UTF-16 char count while callers pass _maxOutputBytes, so multi-byte

  output could exceed the budget and head/tail boundaries could split surrogate

  pairs into orphaned halves. Now tracks UTF-8 byte counts and treats each rune

  as an indivisible unit (encode -> bytes -> head/tail), guaranteeing the final

  string round-trips through UTF-8 and never contains an unpaired surrogate.

  The truncation marker now reads `bytes` instead of `chars` to match.

- ShellEnvironmentProvider: clear cached _snapshotTask on failure. Previously a

  faulted/cancelled first probe permanently poisoned the provider — every later

  ProvideAIContextAsync await replayed the same exception. Now the failed task

  is cleared via a CompareExchange so the next caller starts a fresh probe.

Tests: added rune-boundary coverage for HeadTailBuffer, plus two regression

tests for poison-recovery (executor-throw and caller-cancellation paths).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR microsoft#5604 round 8 review feedback

- HeadTailBuffer odd-cap data loss: previously _halfCap = cap / 2 was used as

  both the head fill bound and the tail eviction threshold, so an odd cap (e.g.

  cap=5 -> halfCap=2) would silently drop a byte while ToFinalString still

  reported truncated == false. Split into _headCap = cap / 2 and _tailCap =

  cap - _headCap so head + tail budgets always sum to exactly cap; any input

  whose UTF-8 size is <= cap now round-trips losslessly.

- ShellSession.TakePrefixByBytes unpaired-high-surrogate: the prefix walker

  advanced 2 chars whenever it saw a high surrogate, without verifying that the

  next char was actually a low surrogate. Mirrored the pair check from

  TakeSuffixByBytes so unpaired surrogates are treated as a single (invalid)

  BMP char and the encoder substitutes U+FFFD as it would anywhere else.

- Centralize clean-environment preserved-vars list. The {PATH, HOME, USER,

  USERNAME, USERPROFILE, SystemRoot, TEMP, TMP} allowlist was duplicated in

  LocalShellTool (stateless launch) and ShellSession (persistent startup), so

  adding a new variable required touching both. Extracted into

  CleanEnvironmentHelper.PreservedVariables / ApplyPreserved; both call sites

  collapse to a single line.

Tests: HeadTailBuffer round-trip-at-odd-cap regression, ShellSession unpaired-

surrogate test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR microsoft#5604 round 9 review feedback

- ShellSession.TruncateHeadTail odd-cap budget: same fix applied to

  HeadTailBuffer last round but missed here. Use headCap = cap/2 +

  tailCap = cap - headCap so the head/tail budgets sum to exactly cap.

- Replace TakePrefixByBytes / TakeSuffixByBytes Encoder.Convert loops with

  rune iteration. The old code ignored Encoder.charsUsed and trusted the

  caller's hand-rolled surrogate-pair detection, which made the byte count

  fragile around unpaired surrogates. EnumerateRunes + Utf8SequenceLength

  is stateless and self-evidently correct.

- ShellEnvironmentProvider.ProbeAsync now skips case-insensitive duplicates

  in the user-supplied ProbeTools list. Previously {\"git\",\"GIT\"} would

  probe twice and rely on insertion order to determine the kept value.

- DockerShellToolTests.AsAIFunction_RelaxedConfig_DefaultsToApprovalGated:

  removed unused trailing �ool _ parameter and matching InlineData column.

Tests: added duplicate-ProbeTools regression test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR microsoft#5604 round 10 review feedback

* ShellSession.ReadLoopAsync: replace per-byte buf.Add(chunk[i]) loop with a single buf.AddRange(new ArraySegment<byte>(chunk, 0, n)) bulk copy on the read hot path.

* ShellPolicy: compile allow-list patterns with RegexOptions.IgnoreCase, matching the deny-list and avoiding case-mismatch surprises.

* LocalShellToolTests.RunAsync_NonZeroExit: drop the redundant ternary that selected between two identical 'exit 7' literals.

* DockerShellToolIntegrationTests.NetworkNone: fix the comment to reference 'getent' (matching the actual command) instead of the stale 'wget' phrasing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(dotnet): address PR microsoft#5604 round-3 review feedback

- Rename LocalShellTool/DockerShellTool -> LocalShellExecutor/DockerShellExecutor
- Rename IShellExecutor.StartAsync/CloseAsync -> InitializeAsync/ShutdownAsync
- Rename ShellDecision -> ShellPolicyOutcome
- Rename CleanEnvironmentHelper.ApplyPreserved -> EnvironmentSanitizer.RemoveNonPreserved
- Convert ShellRequest/ShellPolicyOutcome from record struct to plain readonly struct (with IEquatable<T>)
- Split ShellMode, ShellTimeoutException, ShellExecutionException into their own files
- Add DockerNetworkMode static class with None/Bridge/Host constants
- Convert DockerShellExecutor memory parameter from string to long? memoryBytes
- Use Throw.IfNull(image) in DockerShellExecutor ctor
- Make ShellResolver.EnvVarName public const
- Inline-comment each DefaultDenyList regex; document allow-precedence-over-deny on ShellPolicy.Evaluate

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(dotnet): address PR microsoft#5604 round-3 follow-up nits

- DockerShellExecutor / LocalShellExecutor: drop redundant IAsyncDisposable from class declarations (IShellExecutor : IAsyncDisposable already covers it)
- DockerShellExecutor: scope DefaultImage / DefaultContainerUser / DefaultNetwork / DefaultMemoryBytes / DefaultPidsLimit / DefaultContainerWorkdir to internal (only used as parameter defaults; tests have InternalsVisibleTo)
- DockerShellExecutor.RunAsync: blank line after the null-guard block (style consistency)
- csproj: move <Title>/<Description> below the nuget-package.props import so they are not overwritten by the shared defaults; refresh wording to match new executor names

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Refactor shell tool: abstract ShellExecutor, options classes, ContainerUser record

Round-3 review responses for PR microsoft#5604:

* Replace IShellExecutor interface with abstract ShellExecutor base class so the surface can be extended without breaking implementers (review feedback from @westey-m).

* Drop ShutdownAsync from the executor surface; DisposeAsync is the canonical teardown (review feedback from @SergeyMenshykh).

* Replace the long parameter lists on Local/DockerShellExecutor constructors with LocalShellExecutorOptions and DockerShellExecutorOptions classes so adding new knobs is no longer a breaking change (review feedback from @SergeyMenshykh).

* Introduce ContainerUser(Uid, Gid) record in place of a 'uid:gid' string for the Docker user, with Default and Root statics (review feedback from @lokitoth).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove IsHardenedConfiguration; AsAIFunction defaults to approval-gated

Addresses PR microsoft#5604 review thread AZpMj. The IsHardenedConfiguration
property was a configuration-shape check, not a security guarantee,
and using it to auto-disable approval gating gave false confidence.

- Delete IsHardenedConfiguration property.
- AsAIFunction(requireApproval: null) now always wraps in
  ApprovalRequiredAIFunction; callers must explicitly pass false to
  opt out.
- Update class- and method-level XML docs to drop hardened-attestation
  language and call out approval gating as the primary safety control.
- Drop two hardening-assertion tests and the relaxed-config theory;
  add one test asserting null requireApproval is approval-gated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Replace ShellExecutionException/ShellTimeoutException with standard exceptions

Addresses PR microsoft#5604 review threads AaqVP and Aasod. The custom
exception types added no behavior beyond the base type — only a
different name — so callers gain nothing from them.

- Delete ShellExecutionException.cs and ShellTimeoutException.cs.
- Process spawn failures (LocalShellExecutor, DockerShellExecutor)
  and broken-pipe to a long-lived shell (ShellSession) now throw
  IOException, which is the natural .NET shape for these failures.
- ShellTimeoutException was declared but never thrown; the only
  in-process timeout path uses the OperationCanceledException raised
  by the linked CancellationTokenSource. The catch-and-swallow in
  ShellEnvironmentProvider now matches IOException + TimeoutException.
- Update XML doc comments accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove ShellPolicy.DefaultDenyList; default policy is empty

Addresses PR microsoft#5604 review thread AY7Ba. A regex deny-list is
bypassed in seconds by hex escapes ($(echo -e "\x72\x6D")),
command substitution ($(base64 -d <<<...)), and envvar splicing
($(A=r B=m; echo $A$B)). No major agent framework uses regex
matching as a primary control; AutoGen explicitly removed theirs
in v2. The real defenses are approval gating (default) and the
Docker sandbox tier.

- Delete DefaultDenyList property from ShellPolicy.
- ShellPolicy(denyList: null) now means an empty deny-list.
- Rewrite ShellPolicy class XML docs to frame as a UX pre-filter
  for operator-supplied patterns, not as a security control.
- Update LocalShellExecutorOptions/DockerShellExecutorOptions
  Policy docs to match.
- Tests that exercise the deny-list mechanism now supply patterns
  explicitly, mirroring real operator usage.
- Add Policy_DefaultConstruction_AllowsAnyNonEmptyCommand test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Document single-session ownership for persistent shell mode

Several PR microsoft#5604 review threads (notably AaQh2) raised that the persistent
shell experience has no concurrency story. The framework's actual design
is "one executor per conversation" — there is no per-caller isolation —
but that contract was only stated briefly on ShellExecutor and not at all
on the types and properties developers reach for first.

Strengthen the docs in the places a user is most likely to land:

- ShellMode.Persistent: explicit single-session-ownership paragraph
  (state visible across calls, single pipe, no isolation, one per session).
- ShellExecutor: rewrite the Concurrency paragraph to enumerate what
  leaks (cwd, env, history, background jobs) and call out DI scoping.
- LocalShellExecutor: new Single-session-ownership paragraph mirroring
  the executor-level contract and pointing at Stateless mode as the
  escape hatch.
- DockerShellExecutor: same, framed around the container + bash REPL
  the persistent-mode executor owns end-to-end.
- ShellSession: add a Single-owner paragraph on the type docs and a
  comment on _runLock clarifying that it serializes the owner's calls,
  not multiple tenants.
- LocalShellExecutorOptions.Mode / DockerShellExecutorOptions.Mode:
  per-property note pointing at the executor remarks.

Docs-only; src builds clean with zero warnings, zero errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@MatthiasHowellYopp MatthiasHowellYopp force-pushed the issue-5260 branch 2 times, most recently from 0a6b52a to 9c1e3db Compare May 14, 2026 14:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Feature]: Add Valkey Context Provider and Chat Message Store

2 participants