Python: Integrate shell tool into harness agent (#6451)

* Integrate shell tool into AgentHarness

* Validate shell_executor exposes as_function() with a clear TypeError

Addresses PR review feedback: a public factory should fail fast with an
actionable error rather than a cryptic AttributeError when an incompatible
shell_executor is supplied. Validation happens upfront, regardless of whether
the client supports shell tools.

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

* Type shell harness params via TYPE_CHECKING import

Addresses PR review feedback: type shell_executor and
shell_environment_provider_options instead of Any, using a TYPE_CHECKING
import from agent_framework_tools.shell. The import never executes at
runtime, so there is no circular dependency, and the lazy runtime import of
ShellEnvironmentProvider is retained. Since ShellExecutor is a protocol
without as_function(), the validated getattr result is invoked directly.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
westey
2026-06-11 21:51:59 +01:00
committed by GitHub
Unverified
parent 8b0405de1b
commit 3d5421edc1
5 changed files with 252 additions and 1 deletions
@@ -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")),
)
```