mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
8b0405de1b
commit
3d5421edc1
@@ -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