Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions python/packages/core/agent_framework/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
SupportsGetEmbeddings,
SupportsImageGenerationTool,
SupportsMCPTool,
SupportsShellTool,
SupportsWebSearchTool,
)
from ._compaction import (
Expand Down Expand Up @@ -496,6 +497,7 @@
"SupportsGetEmbeddings",
"SupportsImageGenerationTool",
"SupportsMCPTool",
"SupportsShellTool",
"SupportsWebSearchTool",
"SwitchCaseEdgeGroup",
"SwitchCaseEdgeGroupCase",
Expand Down
30 changes: 30 additions & 0 deletions python/packages/core/agent_framework/_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,36 @@ def get_file_search_tool(**kwargs: Any) -> Any:
...


@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


Expand Down
74 changes: 73 additions & 1 deletion python/packages/core/agent_framework/_harness/_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
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
Expand All @@ -28,6 +28,8 @@
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
Expand Down Expand Up @@ -106,6 +108,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."""
Expand Down Expand Up @@ -137,13 +140,61 @@ 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)

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"


Expand Down Expand Up @@ -174,6 +225,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,
Expand Down Expand Up @@ -270,6 +323,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
Expand Down Expand Up @@ -307,6 +369,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,
Expand All @@ -321,6 +390,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,
)

Expand All @@ -338,6 +408,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]
Expand Down
10 changes: 2 additions & 8 deletions python/packages/core/agent_framework/_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -3516,9 +3516,7 @@ async def get_content(self) -> str:
result = await self._client.read_resource(_mcp_any_url(self._skill_md_uri))
text = _mcp_join_text(result)
if not text:
raise ValueError(
f"The MCP server returned no text content for SKILL.md resource '{self._skill_md_uri}'."
)
raise ValueError(f"The MCP server returned no text content for SKILL.md resource '{self._skill_md_uri}'.")
self._content = text
return text

Expand Down Expand Up @@ -3572,11 +3570,7 @@ def _validate_resource_name(name: str) -> str | None:
or ``None`` if the name is unsafe.
"""
normalized = name.replace("\\", "/")
if (
normalized.startswith("/")
or "://" in normalized
or any(seg == ".." for seg in normalized.split("/"))
):
if normalized.startswith("/") or "://" in normalized or any(seg == ".." for seg in normalized.split("/")):
logger.debug("Rejecting resource name with unsafe path components: %r", name)
return None
return normalized
Expand Down
124 changes: 124 additions & 0 deletions python/packages/core/tests/core/test_harness_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,3 +485,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(),
)
4 changes: 1 addition & 3 deletions python/packages/core/tests/core/test_mcp_observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,9 +281,7 @@ async def test_mcp_prompts_get_creates_client_span(span_exporter: InMemorySpanEx
async def test_mcp_prompts_get_mcp_error_sets_error_type(span_exporter: InMemorySpanExporter):
"""When session.get_prompt() raises McpError, the span should have error.type and ERROR status."""
tool = _make_connected_mcp_tool()
tool.session.get_prompt = AsyncMock(
side_effect=McpError(ErrorData(code=-32602, message="prompt not found"))
)
tool.session.get_prompt = AsyncMock(side_effect=McpError(ErrorData(code=-32602, message="prompt not found")))

span_exporter.clear()
with pytest.raises(ToolExecutionException):
Expand Down
Loading
Loading