From ddc8240cdacaefa03c08d30abeeeea4756e0315d Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 22 May 2026 15:53:17 +0000 Subject: [PATCH 1/9] Add a HarnessAgent with available features and sample --- .devcontainer/devcontainer.json | 8 +- .../packages/core/agent_framework/__init__.py | 8 + .../core/agent_framework/_compaction.py | 116 +++++ .../core/agent_framework/_harness/_agent.py | 430 ++++++++++++++++++ .../core/tests/core/test_compaction.py | 152 +++++++ .../core/tests/core/test_harness_agent.py | 298 ++++++++++++ .../_responses.py | 3 +- .../foundry_hosting/tests/test_responses.py | 2 + python/samples/02-agents/harness/README.md | 83 ++++ .../02-agents/harness/harness_research.py | 148 ++++++ 10 files changed, 1244 insertions(+), 4 deletions(-) create mode 100644 python/packages/core/agent_framework/_harness/_agent.py create mode 100644 python/packages/core/tests/core/test_harness_agent.py create mode 100644 python/samples/02-agents/harness/README.md create mode 100644 python/samples/02-agents/harness/harness_research.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f89859638b..5b36500faf 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,15 +1,19 @@ { "name": "Python 3", - "image": "mcr.microsoft.com/devcontainers/python:3.13-bullseye", + "image": "mcr.microsoft.com/devcontainers/python:3.14-bookworm", "features": { "ghcr.io/va-h/devcontainers-features/uv:1": {}, - "ghcr.io/devcontainers/features/azure-cli:1.2.8": {} + "ghcr.io/devcontainers/features/docker-in-docker:3": {}, + "ghcr.io/devcontainers/features/azure-cli:1.2.9": {}, + "ghcr.io/devcontainers/features/copilot-cli:1": {} }, "postCreateCommand": "bash ./devsetup.sh", "workspaceFolder": "/workspaces/agent-framework/python/", "customizations": { "vscode": { "extensions": [ + "GitHub.copilot", + "GitHub.vscode-github-actions", "ms-python.python", "ms-windows-ai-studio.windows-ai-studio", "littlefoxteam.vscode-python-test-adapter" diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 356051da3f..799e27ce7e 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -45,6 +45,7 @@ CharacterEstimatorTokenizer, CompactionProvider, CompactionStrategy, + ContextWindowCompactionStrategy, SelectiveToolCallCompactionStrategy, SlidingWindowStrategy, SummarizationStrategy, @@ -79,6 +80,10 @@ tool_calls_present, ) from ._feature_stage import ExperimentalFeature, ReleaseCandidateFeature +from ._harness._agent import ( + DEFAULT_HARNESS_INSTRUCTIONS, + HarnessAgent, +) from ._harness._memory import ( DEFAULT_MEMORY_SOURCE_ID, MemoryContextProvider, @@ -297,6 +302,7 @@ "AGENT_FRAMEWORK_USER_AGENT", "APP_INFO", "COMPACTION_STATE_KEY", + "DEFAULT_HARNESS_INSTRUCTIONS", "DEFAULT_MAX_ITERATIONS", "DEFAULT_MEMORY_SOURCE_ID", "DEFAULT_MODE_SOURCE_ID", @@ -352,6 +358,7 @@ "CompactionStrategy", "Content", "ContextProvider", + "ContextWindowCompactionStrategy", "ContinuationToken", "ConversationSplit", "ConversationSplitter", @@ -396,6 +403,7 @@ "FunctionalWorkflowAgent", "GeneratedEmbeddings", "GraphConnectivityError", + "HarnessAgent", "HistoryProvider", "InMemoryCheckpointStorage", "InMemoryHistoryProvider", diff --git a/python/packages/core/agent_framework/_compaction.py b/python/packages/core/agent_framework/_compaction.py index dd76d1f0f4..69e35726ea 100644 --- a/python/packages/core/agent_framework/_compaction.py +++ b/python/packages/core/agent_framework/_compaction.py @@ -1277,6 +1277,121 @@ async def after_run( # whether excluded messages are loaded on the next turn. +class ContextWindowCompactionStrategy: + """Token-budget compaction derived from a model's context window size. + + Computes an input budget from the model's context window and output token + limits, then applies a two-phase compaction pipeline: + + 1. **Tool result eviction** — collapses older tool-call groups into summaries + when included tokens exceed ``tool_eviction_threshold`` of the input budget. + 2. **Truncation** — removes oldest non-system groups when included tokens + exceed ``truncation_threshold`` of the input budget. + + The class uses two independent :class:`TokenBudgetComposedStrategy` + instances — one per phase — so each fires only when its own threshold + is exceeded. + + Examples: + .. code-block:: python + + from agent_framework import ContextWindowCompactionStrategy, CompactionProvider + + strategy = ContextWindowCompactionStrategy( + max_context_window_tokens=128_000, + max_output_tokens=16_384, + ) + provider = CompactionProvider(before_strategy=strategy) + """ + + DEFAULT_TOOL_EVICTION_THRESHOLD: float = 0.5 + """Default fraction of input budget at which tool result eviction triggers.""" + + DEFAULT_TRUNCATION_THRESHOLD: float = 0.8 + """Default fraction of input budget at which truncation triggers.""" + + def __init__( + self, + *, + max_context_window_tokens: int, + max_output_tokens: int, + tokenizer: TokenizerProtocol | None = None, + tool_eviction_threshold: float = DEFAULT_TOOL_EVICTION_THRESHOLD, + truncation_threshold: float = DEFAULT_TRUNCATION_THRESHOLD, + keep_last_tool_call_groups: int = 4, + ) -> None: + """Create a context-window compaction strategy. + + Keyword Args: + max_context_window_tokens: The model's maximum context window size + in tokens (e.g. 128,000). + max_output_tokens: The model's maximum output tokens per response + (e.g. 16,384). + tokenizer: Token counter for measuring message sizes. Defaults to + :class:`CharacterEstimatorTokenizer` (4 chars/token heuristic). + tool_eviction_threshold: Fraction of input budget (0.0, 1.0] at + which tool result eviction triggers. Defaults to 0.5. + truncation_threshold: Fraction of input budget (0.0, 1.0] at which + truncation triggers. Must be ≥ ``tool_eviction_threshold``. + Defaults to 0.8. + keep_last_tool_call_groups: Number of most recent tool-call groups + to retain verbatim during tool eviction. Older groups are + collapsed into summaries. Defaults to 4. + + Raises: + ValueError: If thresholds are out of range or inconsistent. + """ + if max_context_window_tokens <= 0: + raise ValueError("max_context_window_tokens must be positive.") + if max_output_tokens < 0 or max_output_tokens >= max_context_window_tokens: + raise ValueError("max_output_tokens must be >= 0 and < max_context_window_tokens.") + if not (0.0 < tool_eviction_threshold <= 1.0): + raise ValueError("tool_eviction_threshold must be in (0.0, 1.0].") + if not (0.0 < truncation_threshold <= 1.0): + raise ValueError("truncation_threshold must be in (0.0, 1.0].") + if truncation_threshold < tool_eviction_threshold: + raise ValueError("truncation_threshold must be >= tool_eviction_threshold.") + + resolved_tokenizer = tokenizer or CharacterEstimatorTokenizer() + input_budget = max_context_window_tokens - max_output_tokens + tool_eviction_tokens = int(input_budget * tool_eviction_threshold) + truncation_tokens = int(input_budget * truncation_threshold) + + self.max_context_window_tokens = max_context_window_tokens + self.max_output_tokens = max_output_tokens + self.input_budget_tokens = input_budget + self.tool_eviction_threshold = tool_eviction_threshold + self.truncation_threshold = truncation_threshold + + self._tool_eviction = TokenBudgetComposedStrategy( + token_budget=tool_eviction_tokens, + tokenizer=resolved_tokenizer, + strategies=[ + ToolResultCompactionStrategy(keep_last_tool_call_groups=keep_last_tool_call_groups), + ], + ) + self._truncation = TokenBudgetComposedStrategy( + token_budget=truncation_tokens, + tokenizer=resolved_tokenizer, + strategies=[ + TruncationStrategy( + max_n=truncation_tokens, + compact_to=tool_eviction_tokens, + tokenizer=resolved_tokenizer, + ), + ], + ) + + async def __call__(self, messages: list[Message]) -> bool: + """Apply the two-phase compaction pipeline. + + Returns: + True if compaction changed message inclusion; otherwise False. + """ + changed = await self._tool_eviction(messages) + return (await self._truncation(messages)) or changed + + __all__ = [ "COMPACTION_STATE_KEY", "EXCLUDED_KEY", @@ -1293,6 +1408,7 @@ async def after_run( "CharacterEstimatorTokenizer", "CompactionProvider", "CompactionStrategy", + "ContextWindowCompactionStrategy", "GroupKind", "SelectiveToolCallCompactionStrategy", "SlidingWindowStrategy", diff --git a/python/packages/core/agent_framework/_harness/_agent.py b/python/packages/core/agent_framework/_harness/_agent.py new file mode 100644 index 0000000000..392f29638c --- /dev/null +++ b/python/packages/core/agent_framework/_harness/_agent.py @@ -0,0 +1,430 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""HarnessAgent: a pre-configured bundled agent with batteries included. + +This module provides :class:`HarnessAgent`, a convenience class that assembles +the full agent pipeline from a chat client, wiring up function invocation, +per-service-call history persistence, compaction, and a rich set of default +context providers (todo, mode, memory, skills). +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Mapping, Sequence +from typing import TYPE_CHECKING, Any, Literal, overload + +from .._agents import AgentSession, BaseAgent +from .._compaction import CompactionProvider, ContextWindowCompactionStrategy, ToolResultCompactionStrategy +from .._feature_stage import ExperimentalFeature, experimental +from .._sessions import ContextProvider, HistoryProvider, InMemoryHistoryProvider +from .._skills import SkillsProvider +from .._types import AgentResponse, AgentResponseUpdate, AgentRunInputs, ResponseStream +from ._memory import MemoryContextProvider, MemoryStore +from ._mode import AgentModeProvider +from ._todo import TodoProvider + +if TYPE_CHECKING: + from .._clients import SupportsChatGetResponse + from .._compaction import CompactionStrategy, TokenizerProtocol + from .._middleware import MiddlewareTypes + from .._tools import ToolTypes + +DEFAULT_HARNESS_INSTRUCTIONS = """\ +You are a helpful AI assistant that uses tools to complete tasks. + +## General guidelines + +- Think through the task before acting. Break complex work into clear steps. +- Use the tools available to you to gather information, perform actions, and verify results. +- Explain your reasoning and thought process as you work through tasks. +- Explain what you learned and what you are going to do next between tool calls, \ +so the user can follow along with your thought process. +- Avoid making more than 4 tool calls in a row without explaining what you are doing. +- If a tool call fails or returns unexpected results, adapt your approach rather than \ +repeating the same call. +- When you have completed the task, present a clear and concise summary of what you did \ +and what you found. +""" + + +def _assemble_instructions( + harness_instructions: str | None, + agent_instructions: str | None, +) -> str | None: + """Assemble final instructions from harness + agent instructions.""" + harness = harness_instructions if harness_instructions is not None else DEFAULT_HARNESS_INSTRUCTIONS + agent = agent_instructions + + if not harness and not agent: + return DEFAULT_HARNESS_INSTRUCTIONS + if not harness: + return agent + if not agent: + return harness + return f"{harness}\n\n{agent}" + + +def _assemble_compaction_provider( + *, + disable_compaction: bool, + max_context_window_tokens: int, + max_output_tokens: int, + history_source_id: str, + before_compaction_strategy: CompactionStrategy | None, + after_compaction_strategy: CompactionStrategy | None, + tokenizer: TokenizerProtocol | None, +) -> CompactionProvider | None: + """Build the compaction provider from parameters or defaults.""" + if disable_compaction: + return None + + before_strategy = before_compaction_strategy or ContextWindowCompactionStrategy( + max_context_window_tokens=max_context_window_tokens, + max_output_tokens=max_output_tokens, + tokenizer=tokenizer, + ) + after_strategy = after_compaction_strategy or ToolResultCompactionStrategy(keep_last_tool_call_groups=2) + + return CompactionProvider( + before_strategy=before_strategy, + after_strategy=after_strategy, + tokenizer=tokenizer, + history_source_id=history_source_id, + ) + + +def _assemble_context_providers( + *, + history_provider: HistoryProvider, + compaction_provider: CompactionProvider | None, + disable_todo: bool, + todo_provider: TodoProvider | None, + disable_mode: bool, + mode_provider: AgentModeProvider | None, + disable_memory: bool, + memory_store: MemoryStore | None, + disable_skills: bool, + skills_provider: SkillsProvider | None, + skills_paths: Sequence[str] | None, + extra_context_providers: Sequence[ContextProvider] | None, +) -> list[ContextProvider]: + """Assemble the ordered list of context providers.""" + providers: list[ContextProvider] = [] + + # History first so other providers can access loaded messages. + providers.append(history_provider) + + # Compaction runs after history loads messages. + if compaction_provider is not None: + providers.append(compaction_provider) + + if not disable_todo: + providers.append(todo_provider or TodoProvider()) + + if not disable_mode: + providers.append(mode_provider or AgentModeProvider()) + + if not disable_memory and memory_store is not None: + providers.append(MemoryContextProvider(store=memory_store)) + + if not disable_skills: + skills: SkillsProvider | None = skills_provider + if skills is None: + skills = SkillsProvider.from_paths(*skills_paths) if skills_paths else SkillsProvider.from_paths(".") + providers.append(skills) + + # Append any user-supplied additional providers. + if extra_context_providers: + providers.extend(extra_context_providers) + + return providers + + +@experimental(feature_id=ExperimentalFeature.HARNESS) +class HarnessAgent(BaseAgent): + """A pre-configured agent that bundles function invocation, history persistence, compaction, and context providers. + + ``HarnessAgent`` assembles an :class:`~agent_framework.Agent` pipeline from a + caller-supplied chat client, automatically wiring: + + - **Function invocation** — automatic tool calling loop + - **Per-service-call history persistence** — persists history after every model call + - **Compaction** — context-window compaction before/after each run + - **TodoProvider** — todo list management + - **AgentModeProvider** — plan/execute mode tracking + - **MemoryContextProvider** — file-based durable memory (when ``memory_store`` provided) + - **SkillsProvider** — skill discovery and progressive loading + - **OpenTelemetry** — observability via ``AgentTelemetryLayer`` + + Each feature can be disabled or customized via keyword arguments. + + Examples: + Basic usage: + + .. code-block:: python + + from agent_framework import HarnessAgent + from agent_framework.openai import OpenAIChatClient + + agent = HarnessAgent( + client=OpenAIChatClient(model="gpt-4o"), + max_context_window_tokens=128_000, + max_output_tokens=16_384, + ) + session = agent.create_session() + response = await agent.run("Plan a weekend trip to Seattle", session=session) + + With customization: + + .. code-block:: python + + agent = HarnessAgent( + client=client, + max_context_window_tokens=200_000, + max_output_tokens=32_000, + name="research-agent", + agent_instructions="Focus on academic sources.", + disable_todo=True, + skills_paths=["./skills", "./custom-skills"], + ) + """ + + AGENT_PROVIDER_NAME = "microsoft.agent_framework.harness" + + def __init__( + self, + client: SupportsChatGetResponse[Any], + max_context_window_tokens: int, + max_output_tokens: int, + *, + id: str | None = None, + name: str | None = None, + description: str | None = None, + harness_instructions: str | None = None, + agent_instructions: str | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + history_provider: HistoryProvider | None = None, + disable_compaction: bool = False, + before_compaction_strategy: CompactionStrategy | None = None, + after_compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + disable_todo: bool = False, + todo_provider: TodoProvider | None = None, + disable_mode: bool = False, + mode_provider: AgentModeProvider | None = None, + disable_memory: bool = False, + memory_store: MemoryStore | None = None, + disable_skills: bool = False, + skills_provider: SkillsProvider | None = None, + skills_paths: Sequence[str] | None = None, + disable_telemetry: bool = False, + otel_provider_name: str | None = None, + context_providers: Sequence[ContextProvider] | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + default_options: Mapping[str, Any] | None = None, + ) -> None: + """Initialize a HarnessAgent. + + Args: + client: The chat client providing access to the underlying AI model. + max_context_window_tokens: Maximum tokens the model's context window supports. + max_output_tokens: Maximum output tokens per response. + + Keyword Args: + id: Optional agent ID (auto-generated UUID if omitted). + name: Optional agent name. + description: Optional agent description. + harness_instructions: Override the default harness-level instructions. + Set to empty string to omit harness instructions entirely. + agent_instructions: Agent-specific instructions appended after harness instructions. + tools: Additional tools to include in the agent's toolset. + history_provider: Custom history provider. When None, an InMemoryHistoryProvider is used. + disable_compaction: When True, skip compaction provider setup. + before_compaction_strategy: Custom before-run compaction strategy. + Defaults to ContextWindowCompactionStrategy (token-budget aware). + after_compaction_strategy: Custom after-run compaction strategy. + Defaults to ToolResultCompactionStrategy. + tokenizer: Custom tokenizer for compaction strategies. + disable_todo: When True, skip the TodoProvider. + todo_provider: Custom TodoProvider instance. Ignored when disable_todo is True. + disable_mode: When True, skip the AgentModeProvider. + mode_provider: Custom AgentModeProvider instance. Ignored when disable_mode is True. + disable_memory: When True, skip the MemoryContextProvider. + memory_store: Memory store instance. When provided (and disable_memory is False), + a MemoryContextProvider is added. + disable_skills: When True, skip the SkillsProvider. + skills_provider: Custom SkillsProvider instance. Ignored when disable_skills is True. + skills_paths: Paths for file-based skill discovery. + Ignored when skills_provider is set or disable_skills is True. + disable_telemetry: When True, use RawAgent (no telemetry layer) instead of Agent. + otel_provider_name: Custom OpenTelemetry provider/source name. + context_providers: Additional context providers to include after the built-in ones. + middleware: Additional middleware to include. + default_options: Provider-specific chat options (temperature, max_tokens, etc.). + + Raises: + ValueError: If max_context_window_tokens <= 0 or max_output_tokens < 0 + or max_output_tokens >= max_context_window_tokens. + """ + if max_context_window_tokens <= 0: + raise ValueError("max_context_window_tokens must be positive.") + if max_output_tokens < 0: + raise ValueError("max_output_tokens must be non-negative.") + if max_output_tokens >= max_context_window_tokens: + raise ValueError("max_output_tokens must be less than max_context_window_tokens.") + + super().__init__( + id=id, + name=name, + description=description, + ) + + # Build history provider. + resolved_history = history_provider or InMemoryHistoryProvider() + + # Build compaction provider. + compaction_provider = _assemble_compaction_provider( + disable_compaction=disable_compaction, + max_context_window_tokens=max_context_window_tokens, + max_output_tokens=max_output_tokens, + history_source_id=resolved_history.source_id, + before_compaction_strategy=before_compaction_strategy, + after_compaction_strategy=after_compaction_strategy, + tokenizer=tokenizer, + ) + + # Build context providers. + assembled_providers = _assemble_context_providers( + history_provider=resolved_history, + compaction_provider=compaction_provider, + disable_todo=disable_todo, + todo_provider=todo_provider, + disable_mode=disable_mode, + mode_provider=mode_provider, + disable_memory=disable_memory, + memory_store=memory_store, + disable_skills=disable_skills, + skills_provider=skills_provider, + skills_paths=skills_paths, + extra_context_providers=context_providers, + ) + + # Build instructions. + instructions = _assemble_instructions(harness_instructions, agent_instructions) + + # Build default options dict. + default_opts: dict[str, Any] = dict(default_options) if default_options else {} + default_opts.setdefault("max_tokens", max_output_tokens) + + # Determine agent class based on telemetry preference. + from .._agents import Agent as FullAgent + from .._agents import RawAgent + + agent_cls: type[RawAgent[Any]] = FullAgent if not disable_telemetry else RawAgent + + # Build additional kwargs for telemetry. + agent_kwargs: dict[str, Any] = {} + if agent_cls is FullAgent and otel_provider_name: + agent_kwargs["otel_agent_provider_name"] = otel_provider_name + + # Build the inner agent. + self._inner_agent = agent_cls( + client, + instructions, + id=self.id, + name=self.name, + description=self.description, + tools=tools, + default_options=default_opts, # type: ignore[arg-type] + context_providers=assembled_providers, + middleware=list(middleware) if middleware else None, + require_per_service_call_history_persistence=True, + **agent_kwargs, + ) + + # Store for introspection. + self.max_context_window_tokens = max_context_window_tokens + self.max_output_tokens = max_output_tokens + self.context_providers = self._inner_agent.context_providers + + @overload + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[False] = ..., + session: AgentSession | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + ) -> Awaitable[AgentResponse[Any]]: ... + + @overload + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[True], + session: AgentSession | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... + + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + """Run the harness agent. + + Delegates to the inner agent, which includes function invocation, + per-service-call persistence, compaction, and all configured providers. + + Args: + messages: The message(s) to send to the agent. + + Keyword Args: + stream: Whether to stream the response. + session: The conversation session. + function_invocation_kwargs: Keyword arguments forwarded to tool invocation. + client_kwargs: Additional client-specific keyword arguments. + + Returns: + When stream=False: An awaitable AgentResponse. + When stream=True: A ResponseStream of AgentResponseUpdate items. + """ + return self._inner_agent.run( + messages, + stream=stream, # type: ignore[arg-type] + session=session, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, + ) + + def create_session(self, *, session_id: str | None = None) -> AgentSession: + """Create a new conversation session. + + Keyword Args: + session_id: Optional session ID (generated if not provided). + + Returns: + A new AgentSession instance. + """ + return self._inner_agent.create_session(session_id=session_id) + + def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession: + """Get a session for a service-managed session ID. + + Args: + service_session_id: The service-managed session ID. + + Keyword Args: + session_id: Optional local session ID. + + Returns: + An AgentSession instance with the service_session_id set. + """ + return self._inner_agent.get_session(service_session_id, session_id=session_id) diff --git a/python/packages/core/tests/core/test_compaction.py b/python/packages/core/tests/core/test_compaction.py index 1db3260822..7a19f9eb10 100644 --- a/python/packages/core/tests/core/test_compaction.py +++ b/python/packages/core/tests/core/test_compaction.py @@ -19,6 +19,7 @@ ChatResponse, CompactionProvider, Content, + ContextWindowCompactionStrategy, Message, SelectiveToolCallCompactionStrategy, SlidingWindowStrategy, @@ -952,3 +953,154 @@ async def test_in_memory_history_provider_default_loads_all() -> None: loaded = await provider.get_messages(session_id="test", state=state) assert len(loaded) == 3 + + +# --- ContextWindowCompactionStrategy tests --- + + +async def test_context_window_strategy_noop_under_threshold() -> None: + """No compaction when total tokens are below 50% of input budget.""" + # input_budget = 1000 - 200 = 800; tool eviction threshold = 50% = 400 tokens + # CharacterEstimatorTokenizer: 4 chars/token + # Each short message ~4-5 tokens, total well under 400 + messages = [ + Message(role="system", contents=["sys"]), + Message(role="user", contents=["hello"]), + Message(role="assistant", contents=["hi"]), + ] + strategy = ContextWindowCompactionStrategy( + max_context_window_tokens=1000, + max_output_tokens=200, + ) + + changed = await strategy(messages) + + assert changed is False + assert len(included_messages(messages)) == 3 + + +async def test_context_window_strategy_tool_eviction_triggers_at_threshold() -> None: + """Tool eviction fires when tokens exceed 50% but not 80% — truncation should not fire.""" + # input_budget = 2000 - 200 = 1800 + # tool eviction at 50% = 900 tokens; truncation at 80% = 1440 tokens + # CharacterEstimatorTokenizer: 4 chars/token → need >3600 chars to exceed 900 tokens + # We'll create messages totaling ~1000 tokens (4000 chars) — over 900, under 1440 + messages = [ + Message(role="system", contents=["system prompt"]), + Message(role="user", contents=["u1"]), + _assistant_function_call("c1"), + _tool_result("c1", "result1 " * 100), # ~800 chars = 200 tokens + Message(role="user", contents=["u2"]), + _assistant_function_call("c2"), + _tool_result("c2", "result2 " * 100), # ~800 chars = 200 tokens + Message(role="user", contents=["u3"]), + _assistant_function_call("c3"), + _tool_result("c3", "result3 " * 100), # ~800 chars = 200 tokens + Message(role="user", contents=["u4"]), + _assistant_function_call("c4"), + _tool_result("c4", "result4 " * 100), # ~800 chars = 200 tokens + Message(role="user", contents=["u5"]), + _assistant_function_call("c5"), + _tool_result("c5", "result5 " * 100), # ~800 chars = 200 tokens + ] + strategy = ContextWindowCompactionStrategy( + max_context_window_tokens=2000, + max_output_tokens=200, + keep_last_tool_call_groups=2, + ) + + changed = await strategy(messages) + + assert changed is True + # The most recent 2 tool groups (c4, c5) should be kept verbatim; + # older ones (c1, c2, c3) should be collapsed into summaries. + projected = included_messages(messages) + # Verify that some tool results have been compacted (summary messages present). + summary_msgs = [m for m in projected if m.text and "[Tool results:" in m.text] + assert len(summary_msgs) > 0 + + +async def test_context_window_strategy_truncation_triggers_above_80_pct() -> None: + """Truncation fires when tokens exceed 80% of input budget.""" + # input_budget = 1000 - 100 = 900 + # tool eviction at 50% = 450 tokens; truncation at 80% = 720 tokens + # We'll create messages with no tool calls (so tool eviction does nothing) + # but exceeding 720 tokens total (>2880 chars) + messages = [ + Message(role="system", contents=["sys"]), + Message(role="user", contents=["u1 " * 400]), # ~1200 chars = 300 tokens + Message(role="assistant", contents=["a1 " * 400]), # ~1200 chars = 300 tokens + Message(role="user", contents=["u2 " * 400]), # ~1200 chars = 300 tokens + Message(role="assistant", contents=["a2 " * 400]), # ~1200 chars = 300 tokens + ] + strategy = ContextWindowCompactionStrategy( + max_context_window_tokens=1000, + max_output_tokens=100, + ) + + changed = await strategy(messages) + + assert changed is True + projected = included_messages(messages) + # System message should always be preserved + assert projected[0].role == "system" + # Some messages should have been excluded + assert len(projected) < 5 + + +async def test_context_window_strategy_keep_last_tool_call_groups_respected() -> None: + """The keep_last_tool_call_groups parameter controls how many groups are retained.""" + # Create enough tokens to trigger tool eviction (>50% of input budget) + # input_budget = 1000 - 100 = 900; threshold = 450 tokens + messages = [ + Message(role="system", contents=["sys"]), + Message(role="user", contents=["u1"]), + _assistant_function_call("c1"), + _tool_result("c1", "r1 " * 200), + Message(role="user", contents=["u2"]), + _assistant_function_call("c2"), + _tool_result("c2", "r2 " * 200), + Message(role="user", contents=["u3"]), + _assistant_function_call("c3"), + _tool_result("c3", "r3 " * 200), + ] + # keep_last_tool_call_groups=1: only the last group (c3) should be kept verbatim + strategy = ContextWindowCompactionStrategy( + max_context_window_tokens=1000, + max_output_tokens=100, + keep_last_tool_call_groups=1, + ) + + changed = await strategy(messages) + + assert changed is True + projected = included_messages(messages) + # The last tool call group (c3) should be in the projected messages + has_c3 = any( + c.call_id == "c3" for m in projected for c in m.contents if c.type in ("function_call", "function_result") + ) + assert has_c3 + + +def test_context_window_strategy_validates_thresholds() -> None: + """Invalid threshold combinations raise ValueError.""" + import pytest + + with pytest.raises(ValueError, match="max_context_window_tokens must be positive"): + ContextWindowCompactionStrategy(max_context_window_tokens=0, max_output_tokens=0) + + with pytest.raises(ValueError, match="max_output_tokens must be >= 0"): + ContextWindowCompactionStrategy(max_context_window_tokens=1000, max_output_tokens=1000) + + with pytest.raises(ValueError, match="tool_eviction_threshold must be in"): + ContextWindowCompactionStrategy( + max_context_window_tokens=1000, max_output_tokens=100, tool_eviction_threshold=0.0 + ) + + with pytest.raises(ValueError, match="truncation_threshold must be >= tool_eviction_threshold"): + ContextWindowCompactionStrategy( + max_context_window_tokens=1000, + max_output_tokens=100, + tool_eviction_threshold=0.8, + truncation_threshold=0.5, + ) diff --git a/python/packages/core/tests/core/test_harness_agent.py b/python/packages/core/tests/core/test_harness_agent.py new file mode 100644 index 0000000000..c472cdd68b --- /dev/null +++ b/python/packages/core/tests/core/test_harness_agent.py @@ -0,0 +1,298 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from collections.abc import AsyncIterator, Mapping +from typing import Any + +import pytest + +from agent_framework import ( + AgentSession, + ChatResponse, + CompactionProvider, + HarnessAgent, + InMemoryHistoryProvider, + Message, + SkillsProvider, + TodoProvider, +) +from agent_framework._harness._agent import DEFAULT_HARNESS_INSTRUCTIONS, _assemble_instructions +from agent_framework._harness._mode import AgentModeProvider +from agent_framework._sessions import ContextProvider + + +class _FakeChatClient: + """Minimal chat client stub for testing assembly.""" + + model = "test-model" + + async def get_response( + self, + *, + messages: list[Message], + options: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> ChatResponse: + return ChatResponse(messages=[Message(role="assistant", contents=["Hello"])]) + + async def get_streaming_response( + self, + *, + messages: list[Message], + options: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> AsyncIterator[Any]: + yield Message(role="assistant", contents=["Hello"]) # pragma: no cover + + +# --- Assembly Tests --- + + +def test_harness_agent_creates_with_defaults() -> None: + """HarnessAgent should assemble successfully with default options.""" + agent = HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + ) + assert agent.id is not None + assert agent._inner_agent is not None + + +def test_harness_agent_includes_all_default_providers() -> None: + """Default assembly should include history, compaction, todo, mode, skills.""" + agent = HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + ) + providers = agent.context_providers + provider_types = [type(p) for p in providers] + + assert InMemoryHistoryProvider in provider_types + assert CompactionProvider in provider_types + assert TodoProvider in provider_types + assert AgentModeProvider in provider_types + assert SkillsProvider in provider_types + + +def test_harness_agent_disable_todo() -> None: + """disable_todo=True should exclude TodoProvider.""" + agent = HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_todo=True, + ) + provider_types = [type(p) for p in agent.context_providers] + assert TodoProvider not in provider_types + + +def test_harness_agent_disable_mode() -> None: + """disable_mode=True should exclude AgentModeProvider.""" + agent = HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_mode=True, + ) + provider_types = [type(p) for p in agent.context_providers] + assert AgentModeProvider not in provider_types + + +def test_harness_agent_disable_memory() -> None: + """disable_memory=True should exclude MemoryContextProvider.""" + from agent_framework import MemoryContextProvider + + agent = HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_memory=True, + ) + provider_types = [type(p) for p in agent.context_providers] + assert MemoryContextProvider not in provider_types + + +def test_harness_agent_disable_skills() -> None: + """disable_skills=True should exclude SkillsProvider.""" + agent = HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_skills=True, + ) + provider_types = [type(p) for p in agent.context_providers] + assert SkillsProvider not in provider_types + + +def test_harness_agent_disable_compaction() -> None: + """disable_compaction=True should exclude CompactionProvider.""" + agent = HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_compaction=True, + ) + provider_types = [type(p) for p in agent.context_providers] + assert CompactionProvider not in provider_types + + +def test_harness_agent_disable_telemetry_uses_raw_agent() -> None: + """disable_telemetry=True should use RawAgent instead of Agent.""" + from agent_framework._agents import Agent as FullAgent + from agent_framework._agents import RawAgent + + agent = HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_telemetry=True, + ) + assert isinstance(agent._inner_agent, RawAgent) + assert not isinstance(agent._inner_agent, FullAgent) + + +def test_harness_agent_default_uses_full_agent() -> None: + """Default assembly should use Agent (with telemetry).""" + from agent_framework._agents import Agent as FullAgent + + agent = HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + ) + assert isinstance(agent._inner_agent, FullAgent) + + +# --- Validation Tests --- + + +def test_harness_agent_rejects_invalid_context_tokens() -> None: + """max_context_window_tokens must be positive.""" + with pytest.raises(ValueError, match="max_context_window_tokens must be positive"): + HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=0, + max_output_tokens=100, + ) + + +def test_harness_agent_rejects_negative_output_tokens() -> None: + """max_output_tokens must be non-negative.""" + with pytest.raises(ValueError, match="max_output_tokens must be non-negative"): + HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=1000, + max_output_tokens=-1, + ) + + +def test_harness_agent_rejects_output_gte_context() -> None: + """max_output_tokens must be less than max_context_window_tokens.""" + with pytest.raises(ValueError, match="max_output_tokens must be less than"): + HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=1000, + max_output_tokens=1000, + ) + + +# --- Instructions Tests --- + + +def test_default_instructions() -> None: + """None args should produce default harness instructions.""" + result = _assemble_instructions(None, None) + assert result == DEFAULT_HARNESS_INSTRUCTIONS + + +def test_custom_agent_instructions_appended() -> None: + """Agent instructions should be appended after harness instructions.""" + result = _assemble_instructions(None, "Focus on code review.") + assert DEFAULT_HARNESS_INSTRUCTIONS in result # type: ignore[operator] + assert "Focus on code review." in result # type: ignore[operator] + + +def test_empty_harness_instructions_uses_agent_only() -> None: + """Empty harness_instructions should return agent instructions only.""" + result = _assemble_instructions("", "Custom only.") + assert result == "Custom only." + + +# --- Identity Tests --- + + +def test_harness_agent_custom_identity() -> None: + """Custom id, name, description should propagate.""" + agent = HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + id="my-agent-id", + name="my-agent", + description="A test agent", + ) + assert agent.id == "my-agent-id" + assert agent.name == "my-agent" + assert agent.description == "A test agent" + + +# --- Session Tests --- + + +def test_harness_agent_create_session() -> None: + """create_session should return an AgentSession.""" + agent = HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + ) + session = agent.create_session() + assert isinstance(session, AgentSession) + + +def test_harness_agent_create_session_with_id() -> None: + """create_session should accept a custom session_id.""" + agent = HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + ) + session = agent.create_session(session_id="custom-id") + assert session.session_id == "custom-id" + + +# --- Protocol Tests --- + + +def test_harness_agent_satisfies_protocol() -> None: + """HarnessAgent should satisfy SupportsAgentRun protocol.""" + from agent_framework import SupportsAgentRun + + agent = HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + ) + assert isinstance(agent, SupportsAgentRun) + + +# --- Additional providers --- + + +def test_harness_agent_extra_context_providers() -> None: + """Additional context_providers should be appended.""" + + class _CustomProvider(ContextProvider): + pass + + custom = _CustomProvider("custom") + agent = HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + context_providers=[custom], + ) + assert custom in agent.context_providers diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index 49a461f9b1..688bda74ca 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -10,9 +10,8 @@ import tempfile import threading from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence -from contextlib import suppress -from pathlib import Path from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress +from pathlib import Path from typing import Protocol, cast from agent_framework import ( diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index 46a3d7f8ef..e04b9e0553 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -2892,6 +2892,8 @@ async def test_malicious_context_id_rejected_e2e(self, tmp_path: Any, context_fi f"before={before} after={after}" ) assert list(root.iterdir()) == [], f"Checkpoint directory created inside root for {context_field}={bad_id!r}" + + # region Agent lifecycle (lazy entry & OAuth consent surfacing) diff --git a/python/samples/02-agents/harness/README.md b/python/samples/02-agents/harness/README.md new file mode 100644 index 0000000000..bc39d9f708 --- /dev/null +++ b/python/samples/02-agents/harness/README.md @@ -0,0 +1,83 @@ +# HarnessAgent Samples + +This folder demonstrates the `HarnessAgent` — a pre-configured, batteries-included +agent that automatically assembles the full agent pipeline from a chat client. + +## What is HarnessAgent? + +`HarnessAgent` bundles the following features into a single class: + +| Feature | Description | +|---------|-------------| +| Function invocation | Automatic tool calling loop | +| Per-service-call persistence | History persisted after every model call | +| Compaction | Context-window management (sliding window + tool result compaction) | +| TodoProvider | Todo list management for planning and tracking | +| AgentModeProvider | Plan/execute mode tracking | +| MemoryContextProvider | File-based durable memory (when `memory_store` provided) | +| SkillsProvider | File-based skill discovery and progressive loading | +| OpenTelemetry | Built-in observability | + +Each feature can be disabled or customized via keyword arguments. + +## Samples + +| File | Description | +|------|-------------| +| `harness_research.py` | Interactive research assistant with web search and planning workflow | + +## Running + +```bash +# Set your Foundry environment variables +export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project-name" +export FOUNDRY_MODEL="your-model-deployment-name" + +# Authenticate with Azure (required for AzureCliCredential) +az login + +# Run the research sample +python samples/02-agents/harness/harness_research.py +``` + +## Key Concepts + +### Minimal Setup + +`HarnessAgent` requires only a chat client and token budget parameters: + +```python +from agent_framework import HarnessAgent +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential + +agent = HarnessAgent( + client=FoundryChatClient(credential=AzureCliCredential()), + max_context_window_tokens=128_000, + max_output_tokens=16_384, +) +``` + +### Customization + +Disable or customize any feature: + +```python +agent = HarnessAgent( + client=client, + max_context_window_tokens=128_000, + max_output_tokens=16_384, + name="my-agent", + agent_instructions="Custom instructions here.", + disable_todo=True, # Skip todo management + disable_mode=True, # Skip plan/execute modes + disable_compaction=True, # Skip compaction + disable_telemetry=True, # Skip OpenTelemetry +) +``` + +### Plan/Execute Workflow + +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 diff --git a/python/samples/02-agents/harness/harness_research.py b/python/samples/02-agents/harness/harness_research.py new file mode 100644 index 0000000000..4bc99eb029 --- /dev/null +++ b/python/samples/02-agents/harness/harness_research.py @@ -0,0 +1,148 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import HarnessAgent +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +load_dotenv() + +""" +HarnessAgent Research Assistant + +Demonstrates ``HarnessAgent`` — a pre-configured bundled agent that automatically +wires up function invocation, per-service-call history persistence, compaction, +and a rich set of context providers: + +- **TodoProvider** — the agent can create, track, and complete work items +- **AgentModeProvider** — plan/execute mode tracking (interactive vs. autonomous) +- **SkillsProvider** — file-based skill discovery and progressive loading +- **CompactionProvider** — automatic context-window management +- **InMemoryHistoryProvider** — session history with per-service-call persistence +- **OpenTelemetry** — built-in observability via AgentTelemetryLayer +- **Web Search** — real-time web search via ``get_web_search_tool()`` + +The sample creates a research-focused agent with web search capability and runs +a simple interactive chat loop. The agent will plan research tasks using todos, +switch between plan and execute modes, search the web for current information, +and track its progress. + +Special commands: + /exit — End the session. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint URL + FOUNDRY_MODEL — Model deployment name + +Authentication: + Run ``az login`` before running this sample. +""" + +RESEARCH_INSTRUCTIONS = """\ +## Research Assistant Instructions + +You are a research assistant. When given a research topic, research it thoroughly using web search and web browsing. +Use your knowledge to form good search queries and hypotheses, but always verify claims with the tools available to you rather than relying on memory alone. + +### Research quality + +Consult multiple sources when possible and cross-reference key claims. +When sources disagree, note the discrepancy and explain which source you consider more reliable and why. +If a web page fails to load or a search returns irrelevant results, try alternative search queries or sources before moving on. +Track your sources — you will need them when presenting results. + +### Presenting results + +When presenting your final findings: +- Use Markdown formatting for clarity. +- Use clear sections with headings for each major topic or sub-question. +- Cite your sources inline (e.g., "According to [source name](URL), ..."). +- End with a brief summary of key takeaways. +- In addition to returning the results to the user, save the final research report to file memory so it survives compaction and can be referenced later. +""" + + +async def main() -> None: + # Create the chat client. + # For authentication, run `az login` in terminal or replace AzureCliCredential + # with your preferred authentication option. + client = FoundryChatClient(credential=AzureCliCredential()) + + # Get the web search tool from the Foundry client for real-time information retrieval. + web_search_tool = client.get_web_search_tool() + + # Create a HarnessAgent with research-specific instructions. + # All other features (todo, mode, compaction, skills, telemetry) are + # automatically configured with sensible defaults. + agent = HarnessAgent( + client=client, + max_context_window_tokens=128_000, + max_output_tokens=16_384, + name="ResearchAgent", + description="A research assistant that plans and executes research tasks.", + agent_instructions=RESEARCH_INSTRUCTIONS, + tools=[web_search_tool], + disable_skills=True, # No SKILL.md files in this sample directory. + ) + + # Create a session to maintain conversation state across turns. + session = agent.create_session() + + print("Research Assistant (powered by HarnessAgent)") + print("=" * 50) + print("Enter a research topic to get started.") + print("Type /exit to end the session.\n") + + # Simple interactive chat loop. + while True: + user_input = input("You: ").strip() + if not user_input: + continue + if user_input.lower() == "/exit": + print("\nGoodbye!") + break + + # Run the agent with streaming and print the response as it arrives. + print("\nAssistant: ", end="", flush=True) + async for update in agent.run(user_input, session=session, stream=True): + if update.contents: + for content in update.contents: + # Print a brief message for each tool call in the stream. + if content.type == "function_call": + print(f"\n [calling tool: {content.name}]", flush=True) + print(" ", end="", flush=True) + # Show web search activity when the result arrives with action details. + elif content.type in ("search_tool_call", "search_tool_result") and getattr(content, "tool_name", None) == "web_search": + action = None + if content.type == "search_tool_result" and isinstance(content.result, dict): + action = content.result.get("action", {}) + elif content.type == "search_tool_call": + action = content.arguments if isinstance(content.arguments, dict) else None + if action: + action_type = action.get("type", "search") + if action_type == "search": + queries = action.get("queries") or [] + query_str = ", ".join(f'"{q}"' for q in queries) if queries else action.get("query", "") + print(f"\n 🌐 Web search: {query_str}", flush=True) + print(" ", end="", flush=True) + elif action_type == "open_page": + url = action.get("url", "(unknown)") + print(f"\n 🌐 Opening: {url}", flush=True) + print(" ", end="", flush=True) + elif action_type == "find_in_page": + pattern = action.get("pattern", "") + print(f'\n 🌐 Find in page: "{pattern}"', flush=True) + print(" ", end="", flush=True) + else: + print(f"\n 🌐 Web search: {action_type}", flush=True) + print(" ", end="", flush=True) + # Print text content as it streams in. + if update.text: + print(update.text, end="", flush=True) + print("\n") + + +if __name__ == "__main__": + asyncio.run(main()) From beb6462dd6fbc6f30c03cf25a584c7a59e15676f Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 22 May 2026 16:02:39 +0000 Subject: [PATCH 2/9] Fix formatting --- .../agent_framework_foundry_hosting/_responses.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index 77d84a2fec..459705ca97 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -10,10 +10,8 @@ import tempfile import threading from collections.abc import AsyncIterable, AsyncIterator, Generator, Sequence -from contextlib import suppress -from dataclasses import asdict, is_dataclass -from pathlib import Path from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress +from dataclasses import asdict, is_dataclass from pathlib import Path from typing import Protocol, cast From ef3c505ee7b086dfe50273f43f23614c4e1ebbb5 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 22 May 2026 16:37:11 +0000 Subject: [PATCH 3/9] Address PR comments and fix mypy error --- .../core/agent_framework/_harness/_agent.py | 19 +++--- .../core/tests/core/test_compaction.py | 33 ++++++---- .../core/tests/core/test_harness_agent.py | 64 ++++++++++++++++++- .../02-agents/harness/harness_research.py | 21 +++--- 4 files changed, 99 insertions(+), 38 deletions(-) diff --git a/python/packages/core/agent_framework/_harness/_agent.py b/python/packages/core/agent_framework/_harness/_agent.py index 392f29638c..ee417737cc 100644 --- a/python/packages/core/agent_framework/_harness/_agent.py +++ b/python/packages/core/agent_framework/_harness/_agent.py @@ -13,10 +13,10 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence from typing import TYPE_CHECKING, Any, Literal, overload -from .._agents import AgentSession, BaseAgent +from .._agents import BaseAgent from .._compaction import CompactionProvider, ContextWindowCompactionStrategy, ToolResultCompactionStrategy from .._feature_stage import ExperimentalFeature, experimental -from .._sessions import ContextProvider, HistoryProvider, InMemoryHistoryProvider +from .._sessions import AgentSession, ContextProvider, HistoryProvider, InMemoryHistoryProvider from .._skills import SkillsProvider from .._types import AgentResponse, AgentResponseUpdate, AgentRunInputs, ResponseStream from ._memory import MemoryContextProvider, MemoryStore @@ -53,15 +53,14 @@ def _assemble_instructions( ) -> str | None: """Assemble final instructions from harness + agent instructions.""" harness = harness_instructions if harness_instructions is not None else DEFAULT_HARNESS_INSTRUCTIONS - agent = agent_instructions - if not harness and not agent: - return DEFAULT_HARNESS_INSTRUCTIONS - if not harness: - return agent - if not agent: + if harness and agent_instructions: + return f"{harness}\n\n{agent_instructions}" + if harness: return harness - return f"{harness}\n\n{agent}" + if agent_instructions: + return agent_instructions + return None def _assemble_compaction_provider( @@ -396,7 +395,7 @@ def run( When stream=False: An awaitable AgentResponse. When stream=True: A ResponseStream of AgentResponseUpdate items. """ - return self._inner_agent.run( + return self._inner_agent.run( # type: ignore[return-value, call-overload, no-any-return, misc] messages, stream=stream, # type: ignore[arg-type] session=session, diff --git a/python/packages/core/tests/core/test_compaction.py b/python/packages/core/tests/core/test_compaction.py index 7a19f9eb10..9e9cd0b466 100644 --- a/python/packages/core/tests/core/test_compaction.py +++ b/python/packages/core/tests/core/test_compaction.py @@ -980,31 +980,33 @@ async def test_context_window_strategy_noop_under_threshold() -> None: async def test_context_window_strategy_tool_eviction_triggers_at_threshold() -> None: - """Tool eviction fires when tokens exceed 50% but not 80% — truncation should not fire.""" - # input_budget = 2000 - 200 = 1800 - # tool eviction at 50% = 900 tokens; truncation at 80% = 1440 tokens - # CharacterEstimatorTokenizer: 4 chars/token → need >3600 chars to exceed 900 tokens - # We'll create messages totaling ~1000 tokens (4000 chars) — over 900, under 1440 + """Tool eviction fires when tokens exceed 50% but truncation does not.""" + # input_budget = 20000 - 200 = 19800 + # tool eviction at 50% = 9900 tokens; truncation at 80% = 15840 tokens + # CharacterEstimatorTokenizer: 4 chars/token + # Each tool result: "x" * 8000 = 8000 chars = 2000 tokens + # 5 groups * ~2000 = ~10000+ tokens (exceeds 9900, under 15840) + # Tool eviction collapses older groups; truncation threshold not reached. messages = [ Message(role="system", contents=["system prompt"]), Message(role="user", contents=["u1"]), _assistant_function_call("c1"), - _tool_result("c1", "result1 " * 100), # ~800 chars = 200 tokens + _tool_result("c1", "x" * 8000), Message(role="user", contents=["u2"]), _assistant_function_call("c2"), - _tool_result("c2", "result2 " * 100), # ~800 chars = 200 tokens + _tool_result("c2", "x" * 8000), Message(role="user", contents=["u3"]), _assistant_function_call("c3"), - _tool_result("c3", "result3 " * 100), # ~800 chars = 200 tokens + _tool_result("c3", "x" * 8000), Message(role="user", contents=["u4"]), _assistant_function_call("c4"), - _tool_result("c4", "result4 " * 100), # ~800 chars = 200 tokens + _tool_result("c4", "x" * 8000), Message(role="user", contents=["u5"]), _assistant_function_call("c5"), - _tool_result("c5", "result5 " * 100), # ~800 chars = 200 tokens + _tool_result("c5", "x" * 8000), ] strategy = ContextWindowCompactionStrategy( - max_context_window_tokens=2000, + max_context_window_tokens=20000, max_output_tokens=200, keep_last_tool_call_groups=2, ) @@ -1012,12 +1014,15 @@ async def test_context_window_strategy_tool_eviction_triggers_at_threshold() -> changed = await strategy(messages) assert changed is True - # The most recent 2 tool groups (c4, c5) should be kept verbatim; - # older ones (c1, c2, c3) should be collapsed into summaries. projected = included_messages(messages) - # Verify that some tool results have been compacted (summary messages present). + # Verify that tool results were compacted (summary messages present). summary_msgs = [m for m in projected if m.text and "[Tool results:" in m.text] assert len(summary_msgs) > 0 + # Verify that the truncation phase did NOT fire — no messages excluded with "truncation" reason. + from agent_framework._compaction import EXCLUDE_REASON_KEY + + truncation_excluded = [m for m in messages if m.additional_properties.get(EXCLUDE_REASON_KEY) == "truncation"] + assert len(truncation_excluded) == 0 async def test_context_window_strategy_truncation_triggers_above_80_pct() -> None: diff --git a/python/packages/core/tests/core/test_harness_agent.py b/python/packages/core/tests/core/test_harness_agent.py index c472cdd68b..2e7a475a46 100644 --- a/python/packages/core/tests/core/test_harness_agent.py +++ b/python/packages/core/tests/core/test_harness_agent.py @@ -102,16 +102,60 @@ def test_harness_agent_disable_mode() -> None: def test_harness_agent_disable_memory() -> None: - """disable_memory=True should exclude MemoryContextProvider.""" + """disable_memory=True should exclude MemoryContextProvider even when memory_store is provided.""" from agent_framework import MemoryContextProvider + from agent_framework._harness._memory import MemoryStore - agent = HarnessAgent( + class _FakeMemoryStore(MemoryStore): + def list_topics(self, session, *, source_id): + return [] + + def get_topic(self, session, *, source_id, topic): + raise NotImplementedError + + def write_topic(self, session, record, *, source_id): + pass + + def delete_topic(self, session, *, source_id, topic): + pass + + def get_index_text(self, session, *, source_id): + return "" + + def get_transcripts_directory(self, session, *, source_id): + return "" + + def read_state(self, session, *, source_id): + return {} + + def rebuild_index(self, session, *, source_id): + pass + + def search_transcripts(self, session, *, source_id, query): + return [] + + def write_state(self, session, state, *, source_id): + pass + + # With memory_store provided and disable_memory=False, MemoryContextProvider should be present. + agent_with_memory = HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + memory_store=_FakeMemoryStore(), + ) + provider_types = [type(p) for p in agent_with_memory.context_providers] + assert MemoryContextProvider in provider_types + + # With memory_store provided and disable_memory=True, MemoryContextProvider should be absent. + agent_disabled = HarnessAgent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, + memory_store=_FakeMemoryStore(), disable_memory=True, ) - provider_types = [type(p) for p in agent.context_providers] + provider_types = [type(p) for p in agent_disabled.context_providers] assert MemoryContextProvider not in provider_types @@ -264,6 +308,20 @@ def test_harness_agent_create_session_with_id() -> None: assert session.session_id == "custom-id" +async def test_harness_agent_run_returns_response() -> None: + """agent.run() should delegate to inner agent and return a response.""" + agent = HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_skills=True, + ) + session = agent.create_session() + response = await agent.run("hello", session=session) + assert response.messages + assert response.messages[-1].role == "assistant" + + # --- Protocol Tests --- diff --git a/python/samples/02-agents/harness/harness_research.py b/python/samples/02-agents/harness/harness_research.py index 4bc99eb029..fc4115a55e 100644 --- a/python/samples/02-agents/harness/harness_research.py +++ b/python/samples/02-agents/harness/harness_research.py @@ -1,16 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. -import asyncio - -from agent_framework import HarnessAgent -from agent_framework.foundry import FoundryChatClient -from azure.identity import AzureCliCredential -from dotenv import load_dotenv - -load_dotenv() - -""" -HarnessAgent Research Assistant +"""HarnessAgent Research Assistant. Demonstrates ``HarnessAgent`` — a pre-configured bundled agent that automatically wires up function invocation, per-service-call history persistence, compaction, @@ -40,6 +30,13 @@ Run ``az login`` before running this sample. """ +import asyncio + +from agent_framework import HarnessAgent +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + RESEARCH_INSTRUCTIONS = """\ ## Research Assistant Instructions @@ -65,6 +62,8 @@ async def main() -> None: + load_dotenv() + # Create the chat client. # For authentication, run `az login` in terminal or replace AzureCliCredential # with your preferred authentication option. From d950e77edfaf2f746653214533f3b03b5c58ebb2 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 25 May 2026 10:39:48 +0000 Subject: [PATCH 4/9] Add web search support to HarnessAgent --- .../core/agent_framework/_harness/_agent.py | 18 ++++++- .../core/tests/core/test_harness_agent.py | 47 +++++++++++++++++++ .../02-agents/harness/harness_research.py | 6 +-- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/python/packages/core/agent_framework/_harness/_agent.py b/python/packages/core/agent_framework/_harness/_agent.py index ee417737cc..e758735e58 100644 --- a/python/packages/core/agent_framework/_harness/_agent.py +++ b/python/packages/core/agent_framework/_harness/_agent.py @@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, Literal, overload from .._agents import BaseAgent +from .._clients import SupportsWebSearchTool from .._compaction import CompactionProvider, ContextWindowCompactionStrategy, ToolResultCompactionStrategy from .._feature_stage import ExperimentalFeature, experimental from .._sessions import AgentSession, ContextProvider, HistoryProvider, InMemoryHistoryProvider @@ -216,6 +217,7 @@ def __init__( disable_skills: bool = False, skills_provider: SkillsProvider | None = None, skills_paths: Sequence[str] | None = None, + disable_web_search: bool = False, disable_telemetry: bool = False, otel_provider_name: str | None = None, context_providers: Sequence[ContextProvider] | None = None, @@ -255,6 +257,9 @@ def __init__( skills_provider: Custom SkillsProvider instance. Ignored when disable_skills is True. skills_paths: Paths for file-based skill discovery. Ignored when skills_provider is set or disable_skills is True. + 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. disable_telemetry: When True, use RawAgent (no telemetry layer) instead of Agent. otel_provider_name: Custom OpenTelemetry provider/source name. context_providers: Additional context providers to include after the built-in ones. @@ -311,6 +316,17 @@ def __init__( # Build instructions. instructions = _assemble_instructions(harness_instructions, agent_instructions) + # Assemble tools, auto-adding web search if supported. + assembled_tools: list[ToolTypes | Callable[..., Any]] = [] + if not disable_web_search and isinstance(client, SupportsWebSearchTool): + assembled_tools.append(client.get_web_search_tool()) + if tools is not None: + if isinstance(tools, Sequence): + assembled_tools.extend(tools) + else: + assembled_tools.append(tools) + final_tools: list[ToolTypes | Callable[..., Any]] | None = assembled_tools or None + # Build default options dict. default_opts: dict[str, Any] = dict(default_options) if default_options else {} default_opts.setdefault("max_tokens", max_output_tokens) @@ -333,7 +349,7 @@ def __init__( id=self.id, name=self.name, description=self.description, - tools=tools, + tools=final_tools, default_options=default_opts, # type: ignore[arg-type] context_providers=assembled_providers, middleware=list(middleware) if middleware else None, diff --git a/python/packages/core/tests/core/test_harness_agent.py b/python/packages/core/tests/core/test_harness_agent.py index 2e7a475a46..f1627050af 100644 --- a/python/packages/core/tests/core/test_harness_agent.py +++ b/python/packages/core/tests/core/test_harness_agent.py @@ -354,3 +354,50 @@ class _CustomProvider(ContextProvider): context_providers=[custom], ) assert custom in agent.context_providers + + +# --- Web Search Tool Tests --- + + +class _FakeWebSearchClient(_FakeChatClient): + """Fake client that supports web search tool.""" + + def get_web_search_tool(self, **kwargs: Any) -> str: + return "web_search_tool_instance" + + +def test_harness_agent_auto_adds_web_search_tool() -> None: + """Web search tool should be auto-added when client supports it.""" + agent = HarnessAgent( + client=_FakeWebSearchClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_skills=True, + ) + tools = agent._inner_agent.default_options.get("tools", []) + assert "web_search_tool_instance" in tools + + +def test_harness_agent_disable_web_search() -> None: + """disable_web_search=True should skip auto-adding the web search tool.""" + agent = HarnessAgent( + client=_FakeWebSearchClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_web_search=True, + disable_skills=True, + ) + tools = agent._inner_agent.default_options.get("tools", []) + assert "web_search_tool_instance" not in tools + + +def test_harness_agent_no_web_search_when_unsupported() -> None: + """Web search tool should NOT be added when client does not support it.""" + agent = HarnessAgent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_skills=True, + ) + tools = agent._inner_agent.default_options.get("tools", []) + assert "web_search_tool_instance" not in tools diff --git a/python/samples/02-agents/harness/harness_research.py b/python/samples/02-agents/harness/harness_research.py index fc4115a55e..2e2d7ab4dc 100644 --- a/python/samples/02-agents/harness/harness_research.py +++ b/python/samples/02-agents/harness/harness_research.py @@ -69,11 +69,8 @@ async def main() -> None: # with your preferred authentication option. client = FoundryChatClient(credential=AzureCliCredential()) - # Get the web search tool from the Foundry client for real-time information retrieval. - web_search_tool = client.get_web_search_tool() - # Create a HarnessAgent with research-specific instructions. - # All other features (todo, mode, compaction, skills, telemetry) are + # All other features (todo, mode, compaction, skills, telemetry, web search) are # automatically configured with sensible defaults. agent = HarnessAgent( client=client, @@ -82,7 +79,6 @@ async def main() -> None: name="ResearchAgent", description="A research assistant that plans and executes research tasks.", agent_instructions=RESEARCH_INSTRUCTIONS, - tools=[web_search_tool], disable_skills=True, # No SKILL.md files in this sample directory. ) From afed091fe310a9ea8463fe09c6a52c8d50e2f348 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 25 May 2026 11:35:48 +0000 Subject: [PATCH 5/9] Fix build warning --- python/packages/core/agent_framework/_harness/_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_harness/_agent.py b/python/packages/core/agent_framework/_harness/_agent.py index e758735e58..97e8ef97bb 100644 --- a/python/packages/core/agent_framework/_harness/_agent.py +++ b/python/packages/core/agent_framework/_harness/_agent.py @@ -322,7 +322,7 @@ def __init__( assembled_tools.append(client.get_web_search_tool()) if tools is not None: if isinstance(tools, Sequence): - assembled_tools.extend(tools) + assembled_tools.extend(tools) # pyright: ignore[reportUnknownArgumentType] else: assembled_tools.append(tools) final_tools: list[ToolTypes | Callable[..., Any]] | None = assembled_tools or None From 8567323c7111fbaab51745446daa97f300257e1f Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 26 May 2026 09:51:40 +0100 Subject: [PATCH 6/9] Apply suggestions from code review Co-authored-by: Eduard van Valkenburg --- python/packages/core/agent_framework/_harness/_agent.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/python/packages/core/agent_framework/_harness/_agent.py b/python/packages/core/agent_framework/_harness/_agent.py index 97e8ef97bb..b8e9c1ecfb 100644 --- a/python/packages/core/agent_framework/_harness/_agent.py +++ b/python/packages/core/agent_framework/_harness/_agent.py @@ -55,12 +55,7 @@ def _assemble_instructions( """Assemble final instructions from harness + agent instructions.""" harness = harness_instructions if harness_instructions is not None else DEFAULT_HARNESS_INSTRUCTIONS - if harness and agent_instructions: - return f"{harness}\n\n{agent_instructions}" - if harness: - return harness - if agent_instructions: - return agent_instructions + return f"{harness}\n\n{agent_instructions or ''}".strip() return None @@ -442,4 +437,4 @@ def get_session(self, service_session_id: str, *, session_id: str | None = None) Returns: An AgentSession instance with the service_session_id set. """ - return self._inner_agent.get_session(service_session_id, session_id=session_id) + return self._inner_agent.get_session(service_session_id=service_session_id, session_id=session_id) From 5d3bd48b7021f123d9eb2acc6f36a7eeb1a0cbeb Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 26 May 2026 10:10:06 +0000 Subject: [PATCH 7/9] Address PR comments --- .../core/agent_framework/_harness/_agent.py | 25 +++++++------------ .../core/tests/core/test_harness_agent.py | 17 +------------ 2 files changed, 10 insertions(+), 32 deletions(-) diff --git a/python/packages/core/agent_framework/_harness/_agent.py b/python/packages/core/agent_framework/_harness/_agent.py index b8e9c1ecfb..7d7f006f45 100644 --- a/python/packages/core/agent_framework/_harness/_agent.py +++ b/python/packages/core/agent_framework/_harness/_agent.py @@ -55,8 +55,7 @@ def _assemble_instructions( """Assemble final instructions from harness + agent instructions.""" harness = harness_instructions if harness_instructions is not None else DEFAULT_HARNESS_INSTRUCTIONS - return f"{harness}\n\n{agent_instructions or ''}".strip() - return None + return f"{harness}\n\n{agent_instructions or ''}".strip() or None def _assemble_compaction_provider( @@ -162,7 +161,7 @@ class HarnessAgent(BaseAgent): from agent_framework.openai import OpenAIChatClient agent = HarnessAgent( - client=OpenAIChatClient(model="gpt-4o"), + OpenAIChatClient(model="gpt-4o"), max_context_window_tokens=128_000, max_output_tokens=16_384, ) @@ -189,9 +188,9 @@ class HarnessAgent(BaseAgent): def __init__( self, client: SupportsChatGetResponse[Any], + *, max_context_window_tokens: int, max_output_tokens: int, - *, id: str | None = None, name: str | None = None, description: str | None = None, @@ -213,7 +212,6 @@ def __init__( skills_provider: SkillsProvider | None = None, skills_paths: Sequence[str] | None = None, disable_web_search: bool = False, - disable_telemetry: bool = False, otel_provider_name: str | None = None, context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, @@ -223,10 +221,10 @@ def __init__( Args: client: The chat client providing access to the underlying AI model. - max_context_window_tokens: Maximum tokens the model's context window supports. - max_output_tokens: Maximum output tokens per response. Keyword Args: + max_context_window_tokens: Maximum tokens the model's context window supports. + max_output_tokens: Maximum output tokens per response. id: Optional agent ID (auto-generated UUID if omitted). name: Optional agent name. description: Optional agent description. @@ -255,8 +253,7 @@ def __init__( 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. - disable_telemetry: When True, use RawAgent (no telemetry layer) instead of Agent. - otel_provider_name: Custom OpenTelemetry provider/source name. + otel_provider_name: Custom OpenTelemetry provider/source name for telemetry. context_providers: Additional context providers to include after the built-in ones. middleware: Additional middleware to include. default_options: Provider-specific chat options (temperature, max_tokens, etc.). @@ -326,19 +323,15 @@ def __init__( default_opts: dict[str, Any] = dict(default_options) if default_options else {} default_opts.setdefault("max_tokens", max_output_tokens) - # Determine agent class based on telemetry preference. + # Build additional kwargs for telemetry. from .._agents import Agent as FullAgent - from .._agents import RawAgent - agent_cls: type[RawAgent[Any]] = FullAgent if not disable_telemetry else RawAgent - - # Build additional kwargs for telemetry. agent_kwargs: dict[str, Any] = {} - if agent_cls is FullAgent and otel_provider_name: + if otel_provider_name: agent_kwargs["otel_agent_provider_name"] = otel_provider_name # Build the inner agent. - self._inner_agent = agent_cls( + self._inner_agent = FullAgent( client, instructions, id=self.id, diff --git a/python/packages/core/tests/core/test_harness_agent.py b/python/packages/core/tests/core/test_harness_agent.py index f1627050af..cefe4bb41c 100644 --- a/python/packages/core/tests/core/test_harness_agent.py +++ b/python/packages/core/tests/core/test_harness_agent.py @@ -183,21 +183,6 @@ def test_harness_agent_disable_compaction() -> None: assert CompactionProvider not in provider_types -def test_harness_agent_disable_telemetry_uses_raw_agent() -> None: - """disable_telemetry=True should use RawAgent instead of Agent.""" - from agent_framework._agents import Agent as FullAgent - from agent_framework._agents import RawAgent - - agent = HarnessAgent( - client=_FakeChatClient(), # type: ignore[arg-type] - max_context_window_tokens=128_000, - max_output_tokens=16_384, - disable_telemetry=True, - ) - assert isinstance(agent._inner_agent, RawAgent) - assert not isinstance(agent._inner_agent, FullAgent) - - def test_harness_agent_default_uses_full_agent() -> None: """Default assembly should use Agent (with telemetry).""" from agent_framework._agents import Agent as FullAgent @@ -249,7 +234,7 @@ def test_harness_agent_rejects_output_gte_context() -> None: def test_default_instructions() -> None: """None args should produce default harness instructions.""" result = _assemble_instructions(None, None) - assert result == DEFAULT_HARNESS_INSTRUCTIONS + assert result == DEFAULT_HARNESS_INSTRUCTIONS.strip() def test_custom_agent_instructions_appended() -> None: From 876e27fc524788e420535334f5cbf6e96933d72c Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 26 May 2026 10:47:50 +0000 Subject: [PATCH 8/9] Address PR comments --- .../packages/core/agent_framework/__init__.py | 4 +- .../core/agent_framework/_harness/_agent.py | 428 +++++++----------- .../core/tests/core/test_harness_agent.py | 101 ++--- python/samples/02-agents/harness/README.md | 20 +- .../02-agents/harness/harness_research.py | 17 +- 5 files changed, 236 insertions(+), 334 deletions(-) diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 799e27ce7e..ae50ab5f9f 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -82,7 +82,7 @@ from ._feature_stage import ExperimentalFeature, ReleaseCandidateFeature from ._harness._agent import ( DEFAULT_HARNESS_INSTRUCTIONS, - HarnessAgent, + create_harness_agent, ) from ._harness._memory import ( DEFAULT_MEMORY_SOURCE_ID, @@ -403,7 +403,6 @@ "FunctionalWorkflowAgent", "GeneratedEmbeddings", "GraphConnectivityError", - "HarnessAgent", "HistoryProvider", "InMemoryCheckpointStorage", "InMemoryHistoryProvider", @@ -507,6 +506,7 @@ "apply_compaction", "chat_middleware", "create_edge_runner", + "create_harness_agent", "detect_media_type_from_base64", "evaluate_agent", "evaluate_workflow", diff --git a/python/packages/core/agent_framework/_harness/_agent.py b/python/packages/core/agent_framework/_harness/_agent.py index 7d7f006f45..4ecaa4c2d7 100644 --- a/python/packages/core/agent_framework/_harness/_agent.py +++ b/python/packages/core/agent_framework/_harness/_agent.py @@ -1,8 +1,8 @@ # Copyright (c) Microsoft. All rights reserved. -"""HarnessAgent: a pre-configured bundled agent with batteries included. +"""Harness agent factory: a pre-configured bundled agent with batteries included. -This module provides :class:`HarnessAgent`, a convenience class that assembles +This module provides :func:`create_harness_agent`, a factory function that assembles the full agent pipeline from a chat client, wiring up function invocation, per-service-call history persistence, compaction, and a rich set of default context providers (todo, mode, memory, skills). @@ -10,21 +10,22 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Mapping, Sequence -from typing import TYPE_CHECKING, Any, Literal, overload +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING, Any -from .._agents import BaseAgent +from .._agents import Agent from .._clients import SupportsWebSearchTool from .._compaction import CompactionProvider, ContextWindowCompactionStrategy, ToolResultCompactionStrategy from .._feature_stage import ExperimentalFeature, experimental -from .._sessions import AgentSession, ContextProvider, HistoryProvider, InMemoryHistoryProvider +from .._sessions import ContextProvider, HistoryProvider, InMemoryHistoryProvider from .._skills import SkillsProvider -from .._types import AgentResponse, AgentResponseUpdate, AgentRunInputs, ResponseStream from ._memory import MemoryContextProvider, MemoryStore from ._mode import AgentModeProvider from ._todo import TodoProvider if TYPE_CHECKING: + from collections.abc import Mapping + from .._clients import SupportsChatGetResponse from .._compaction import CompactionStrategy, TokenizerProtocol from .._middleware import MiddlewareTypes @@ -134,12 +135,44 @@ def _assemble_context_providers( return providers -@experimental(feature_id=ExperimentalFeature.HARNESS) -class HarnessAgent(BaseAgent): - """A pre-configured agent that bundles function invocation, history persistence, compaction, and context providers. +HARNESS_AGENT_PROVIDER_NAME = "microsoft.agent_framework.harness" - ``HarnessAgent`` assembles an :class:`~agent_framework.Agent` pipeline from a - caller-supplied chat client, automatically wiring: + +@experimental(feature_id=ExperimentalFeature.HARNESS) +def create_harness_agent( + client: SupportsChatGetResponse[Any], + *, + id: str | None = None, + name: str | None = None, + description: str | None = None, + harness_instructions: str | None = None, + agent_instructions: str | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + max_context_window_tokens: int, + max_output_tokens: int, + history_provider: HistoryProvider | None = None, + disable_compaction: bool = False, + before_compaction_strategy: CompactionStrategy | None = None, + after_compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + disable_todo: bool = False, + todo_provider: TodoProvider | None = None, + disable_mode: bool = False, + mode_provider: AgentModeProvider | None = None, + disable_memory: bool = False, + memory_store: MemoryStore | None = None, + disable_skills: bool = False, + skills_provider: SkillsProvider | None = None, + skills_paths: Sequence[str] | None = None, + disable_web_search: bool = False, + otel_provider_name: str | None = None, + context_providers: Sequence[ContextProvider] | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + default_options: Mapping[str, Any] | None = None, +) -> Agent[Any]: + """Create a pre-configured agent with batteries included. + + Assembles an :class:`~agent_framework.Agent` from a chat client, automatically wiring: - **Function invocation** — automatic tool calling loop - **Per-service-call history persistence** — persists history after every model call @@ -157,10 +190,10 @@ class HarnessAgent(BaseAgent): .. code-block:: python - from agent_framework import HarnessAgent + from agent_framework import create_harness_agent from agent_framework.openai import OpenAIChatClient - agent = HarnessAgent( + agent = create_harness_agent( OpenAIChatClient(model="gpt-4o"), max_context_window_tokens=128_000, max_output_tokens=16_384, @@ -172,7 +205,7 @@ class HarnessAgent(BaseAgent): .. code-block:: python - agent = HarnessAgent( + agent = create_harness_agent( client=client, max_context_window_tokens=200_000, max_output_tokens=32_000, @@ -181,253 +214,122 @@ class HarnessAgent(BaseAgent): disable_todo=True, skills_paths=["./skills", "./custom-skills"], ) + + Args: + client: The chat client providing access to the underlying AI model. + + Keyword Args: + id: Optional agent ID (auto-generated UUID if omitted). + name: Optional agent name. + description: Optional agent description. + harness_instructions: Override the default harness-level instructions. + Set to empty string to omit harness instructions entirely. + agent_instructions: Agent-specific instructions appended after harness instructions. + tools: Additional tools to include in the agent's toolset. + max_context_window_tokens: Maximum tokens the model's context window supports. + max_output_tokens: Maximum output tokens per response. + history_provider: Custom history provider. When None, an InMemoryHistoryProvider is used. + disable_compaction: When True, skip compaction provider setup. + before_compaction_strategy: Custom before-run compaction strategy. + Defaults to ContextWindowCompactionStrategy (token-budget aware). + after_compaction_strategy: Custom after-run compaction strategy. + Defaults to ToolResultCompactionStrategy. + tokenizer: Custom tokenizer for compaction strategies. + disable_todo: When True, skip the TodoProvider. + todo_provider: Custom TodoProvider instance. Ignored when disable_todo is True. + disable_mode: When True, skip the AgentModeProvider. + mode_provider: Custom AgentModeProvider instance. Ignored when disable_mode is True. + disable_memory: When True, skip the MemoryContextProvider. + memory_store: Memory store instance. When provided (and disable_memory is False), + a MemoryContextProvider is added. + disable_skills: When True, skip the SkillsProvider. + skills_provider: Custom SkillsProvider instance. Ignored when disable_skills is True. + skills_paths: Paths for file-based skill discovery. + Ignored when skills_provider is set or disable_skills is True. + 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. + otel_provider_name: Custom OpenTelemetry provider/source name for telemetry. + context_providers: Additional context providers to include after the built-in ones. + middleware: Additional middleware to include. + default_options: Provider-specific chat options (temperature, max_tokens, etc.). + + Returns: + A fully configured :class:`~agent_framework.Agent` instance. + + Raises: + ValueError: If max_context_window_tokens <= 0 or max_output_tokens < 0 + or max_output_tokens >= max_context_window_tokens. """ + if max_context_window_tokens <= 0: + raise ValueError("max_context_window_tokens must be positive.") + if max_output_tokens < 0: + raise ValueError("max_output_tokens must be non-negative.") + if max_output_tokens >= max_context_window_tokens: + raise ValueError("max_output_tokens must be less than max_context_window_tokens.") + + # Build history provider. + resolved_history = history_provider or InMemoryHistoryProvider() + + # Build compaction provider. + compaction_provider = _assemble_compaction_provider( + disable_compaction=disable_compaction, + max_context_window_tokens=max_context_window_tokens, + max_output_tokens=max_output_tokens, + history_source_id=resolved_history.source_id, + before_compaction_strategy=before_compaction_strategy, + after_compaction_strategy=after_compaction_strategy, + tokenizer=tokenizer, + ) + + # Build context providers. + assembled_providers = _assemble_context_providers( + history_provider=resolved_history, + compaction_provider=compaction_provider, + disable_todo=disable_todo, + todo_provider=todo_provider, + disable_mode=disable_mode, + mode_provider=mode_provider, + disable_memory=disable_memory, + memory_store=memory_store, + disable_skills=disable_skills, + skills_provider=skills_provider, + skills_paths=skills_paths, + extra_context_providers=context_providers, + ) + + # Build instructions. + instructions = _assemble_instructions(harness_instructions, agent_instructions) + + # Assemble tools, auto-adding web search if supported. + assembled_tools: list[ToolTypes | Callable[..., Any]] = [] + if not disable_web_search and isinstance(client, SupportsWebSearchTool): + assembled_tools.append(client.get_web_search_tool()) + if tools is not None: + if isinstance(tools, Sequence): + assembled_tools.extend(tools) # pyright: ignore[reportUnknownArgumentType] + else: + assembled_tools.append(tools) + final_tools: list[ToolTypes | Callable[..., Any]] | None = assembled_tools or None + + # Build default options dict. + default_opts: dict[str, Any] = dict(default_options) if default_options else {} + default_opts.setdefault("max_tokens", max_output_tokens) + + agent = Agent( + client, + instructions, + id=id, + name=name, + description=description, + tools=final_tools, + default_options=default_opts, # type: ignore[arg-type] + context_providers=assembled_providers, + middleware=list(middleware) if middleware else None, + require_per_service_call_history_persistence=True, + ) + + # Set the telemetry provider name after construction. + agent.otel_provider_name = otel_provider_name or HARNESS_AGENT_PROVIDER_NAME - AGENT_PROVIDER_NAME = "microsoft.agent_framework.harness" - - def __init__( - self, - client: SupportsChatGetResponse[Any], - *, - max_context_window_tokens: int, - max_output_tokens: int, - id: str | None = None, - name: str | None = None, - description: str | None = None, - harness_instructions: str | None = None, - agent_instructions: str | None = None, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - history_provider: HistoryProvider | None = None, - disable_compaction: bool = False, - before_compaction_strategy: CompactionStrategy | None = None, - after_compaction_strategy: CompactionStrategy | None = None, - tokenizer: TokenizerProtocol | None = None, - disable_todo: bool = False, - todo_provider: TodoProvider | None = None, - disable_mode: bool = False, - mode_provider: AgentModeProvider | None = None, - disable_memory: bool = False, - memory_store: MemoryStore | None = None, - disable_skills: bool = False, - skills_provider: SkillsProvider | None = None, - skills_paths: Sequence[str] | None = None, - disable_web_search: bool = False, - otel_provider_name: str | None = None, - context_providers: Sequence[ContextProvider] | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - default_options: Mapping[str, Any] | None = None, - ) -> None: - """Initialize a HarnessAgent. - - Args: - client: The chat client providing access to the underlying AI model. - - Keyword Args: - max_context_window_tokens: Maximum tokens the model's context window supports. - max_output_tokens: Maximum output tokens per response. - id: Optional agent ID (auto-generated UUID if omitted). - name: Optional agent name. - description: Optional agent description. - harness_instructions: Override the default harness-level instructions. - Set to empty string to omit harness instructions entirely. - agent_instructions: Agent-specific instructions appended after harness instructions. - tools: Additional tools to include in the agent's toolset. - history_provider: Custom history provider. When None, an InMemoryHistoryProvider is used. - disable_compaction: When True, skip compaction provider setup. - before_compaction_strategy: Custom before-run compaction strategy. - Defaults to ContextWindowCompactionStrategy (token-budget aware). - after_compaction_strategy: Custom after-run compaction strategy. - Defaults to ToolResultCompactionStrategy. - tokenizer: Custom tokenizer for compaction strategies. - disable_todo: When True, skip the TodoProvider. - todo_provider: Custom TodoProvider instance. Ignored when disable_todo is True. - disable_mode: When True, skip the AgentModeProvider. - mode_provider: Custom AgentModeProvider instance. Ignored when disable_mode is True. - disable_memory: When True, skip the MemoryContextProvider. - memory_store: Memory store instance. When provided (and disable_memory is False), - a MemoryContextProvider is added. - disable_skills: When True, skip the SkillsProvider. - skills_provider: Custom SkillsProvider instance. Ignored when disable_skills is True. - skills_paths: Paths for file-based skill discovery. - Ignored when skills_provider is set or disable_skills is True. - 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. - otel_provider_name: Custom OpenTelemetry provider/source name for telemetry. - context_providers: Additional context providers to include after the built-in ones. - middleware: Additional middleware to include. - default_options: Provider-specific chat options (temperature, max_tokens, etc.). - - Raises: - ValueError: If max_context_window_tokens <= 0 or max_output_tokens < 0 - or max_output_tokens >= max_context_window_tokens. - """ - if max_context_window_tokens <= 0: - raise ValueError("max_context_window_tokens must be positive.") - if max_output_tokens < 0: - raise ValueError("max_output_tokens must be non-negative.") - if max_output_tokens >= max_context_window_tokens: - raise ValueError("max_output_tokens must be less than max_context_window_tokens.") - - super().__init__( - id=id, - name=name, - description=description, - ) - - # Build history provider. - resolved_history = history_provider or InMemoryHistoryProvider() - - # Build compaction provider. - compaction_provider = _assemble_compaction_provider( - disable_compaction=disable_compaction, - max_context_window_tokens=max_context_window_tokens, - max_output_tokens=max_output_tokens, - history_source_id=resolved_history.source_id, - before_compaction_strategy=before_compaction_strategy, - after_compaction_strategy=after_compaction_strategy, - tokenizer=tokenizer, - ) - - # Build context providers. - assembled_providers = _assemble_context_providers( - history_provider=resolved_history, - compaction_provider=compaction_provider, - disable_todo=disable_todo, - todo_provider=todo_provider, - disable_mode=disable_mode, - mode_provider=mode_provider, - disable_memory=disable_memory, - memory_store=memory_store, - disable_skills=disable_skills, - skills_provider=skills_provider, - skills_paths=skills_paths, - extra_context_providers=context_providers, - ) - - # Build instructions. - instructions = _assemble_instructions(harness_instructions, agent_instructions) - - # Assemble tools, auto-adding web search if supported. - assembled_tools: list[ToolTypes | Callable[..., Any]] = [] - if not disable_web_search and isinstance(client, SupportsWebSearchTool): - assembled_tools.append(client.get_web_search_tool()) - if tools is not None: - if isinstance(tools, Sequence): - assembled_tools.extend(tools) # pyright: ignore[reportUnknownArgumentType] - else: - assembled_tools.append(tools) - final_tools: list[ToolTypes | Callable[..., Any]] | None = assembled_tools or None - - # Build default options dict. - default_opts: dict[str, Any] = dict(default_options) if default_options else {} - default_opts.setdefault("max_tokens", max_output_tokens) - - # Build additional kwargs for telemetry. - from .._agents import Agent as FullAgent - - agent_kwargs: dict[str, Any] = {} - if otel_provider_name: - agent_kwargs["otel_agent_provider_name"] = otel_provider_name - - # Build the inner agent. - self._inner_agent = FullAgent( - client, - instructions, - id=self.id, - name=self.name, - description=self.description, - tools=final_tools, - default_options=default_opts, # type: ignore[arg-type] - context_providers=assembled_providers, - middleware=list(middleware) if middleware else None, - require_per_service_call_history_persistence=True, - **agent_kwargs, - ) - - # Store for introspection. - self.max_context_window_tokens = max_context_window_tokens - self.max_output_tokens = max_output_tokens - self.context_providers = self._inner_agent.context_providers - - @overload - def run( - self, - messages: AgentRunInputs | None = None, - *, - stream: Literal[False] = ..., - session: AgentSession | None = None, - function_invocation_kwargs: Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Any] | None = None, - ) -> Awaitable[AgentResponse[Any]]: ... - - @overload - def run( - self, - messages: AgentRunInputs | None = None, - *, - stream: Literal[True], - session: AgentSession | None = None, - function_invocation_kwargs: Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Any] | None = None, - ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... - - def run( - self, - messages: AgentRunInputs | None = None, - *, - stream: bool = False, - session: AgentSession | None = None, - function_invocation_kwargs: Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Any] | None = None, - ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: - """Run the harness agent. - - Delegates to the inner agent, which includes function invocation, - per-service-call persistence, compaction, and all configured providers. - - Args: - messages: The message(s) to send to the agent. - - Keyword Args: - stream: Whether to stream the response. - session: The conversation session. - function_invocation_kwargs: Keyword arguments forwarded to tool invocation. - client_kwargs: Additional client-specific keyword arguments. - - Returns: - When stream=False: An awaitable AgentResponse. - When stream=True: A ResponseStream of AgentResponseUpdate items. - """ - return self._inner_agent.run( # type: ignore[return-value, call-overload, no-any-return, misc] - messages, - stream=stream, # type: ignore[arg-type] - session=session, - function_invocation_kwargs=function_invocation_kwargs, - client_kwargs=client_kwargs, - ) - - def create_session(self, *, session_id: str | None = None) -> AgentSession: - """Create a new conversation session. - - Keyword Args: - session_id: Optional session ID (generated if not provided). - - Returns: - A new AgentSession instance. - """ - return self._inner_agent.create_session(session_id=session_id) - - def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession: - """Get a session for a service-managed session ID. - - Args: - service_session_id: The service-managed session ID. - - Keyword Args: - session_id: Optional local session ID. - - Returns: - An AgentSession instance with the service_session_id set. - """ - return self._inner_agent.get_session(service_session_id=service_session_id, session_id=session_id) + return agent diff --git a/python/packages/core/tests/core/test_harness_agent.py b/python/packages/core/tests/core/test_harness_agent.py index cefe4bb41c..42c451a8c6 100644 --- a/python/packages/core/tests/core/test_harness_agent.py +++ b/python/packages/core/tests/core/test_harness_agent.py @@ -11,11 +11,11 @@ AgentSession, ChatResponse, CompactionProvider, - HarnessAgent, InMemoryHistoryProvider, Message, SkillsProvider, TodoProvider, + create_harness_agent, ) from agent_framework._harness._agent import DEFAULT_HARNESS_INSTRUCTIONS, _assemble_instructions from agent_framework._harness._mode import AgentModeProvider @@ -49,20 +49,19 @@ async def get_streaming_response( # --- Assembly Tests --- -def test_harness_agent_creates_with_defaults() -> None: - """HarnessAgent should assemble successfully with default options.""" - agent = HarnessAgent( +def test_create_harness_agent_with_defaults() -> None: + """create_harness_agent should assemble successfully with default options.""" + agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, ) assert agent.id is not None - assert agent._inner_agent is not None -def test_harness_agent_includes_all_default_providers() -> None: +def test_create_harness_agent_includes_all_default_providers() -> None: """Default assembly should include history, compaction, todo, mode, skills.""" - agent = HarnessAgent( + agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, @@ -77,9 +76,9 @@ def test_harness_agent_includes_all_default_providers() -> None: assert SkillsProvider in provider_types -def test_harness_agent_disable_todo() -> None: +def test_create_harness_agent_disable_todo() -> None: """disable_todo=True should exclude TodoProvider.""" - agent = HarnessAgent( + agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, @@ -89,9 +88,9 @@ def test_harness_agent_disable_todo() -> None: assert TodoProvider not in provider_types -def test_harness_agent_disable_mode() -> None: +def test_create_harness_agent_disable_mode() -> None: """disable_mode=True should exclude AgentModeProvider.""" - agent = HarnessAgent( + agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, @@ -101,7 +100,7 @@ def test_harness_agent_disable_mode() -> None: assert AgentModeProvider not in provider_types -def test_harness_agent_disable_memory() -> None: +def test_create_harness_agent_disable_memory() -> None: """disable_memory=True should exclude MemoryContextProvider even when memory_store is provided.""" from agent_framework import MemoryContextProvider from agent_framework._harness._memory import MemoryStore @@ -138,7 +137,7 @@ def write_state(self, session, state, *, source_id): pass # With memory_store provided and disable_memory=False, MemoryContextProvider should be present. - agent_with_memory = HarnessAgent( + agent_with_memory = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, @@ -148,7 +147,7 @@ def write_state(self, session, state, *, source_id): assert MemoryContextProvider in provider_types # With memory_store provided and disable_memory=True, MemoryContextProvider should be absent. - agent_disabled = HarnessAgent( + agent_disabled = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, @@ -159,9 +158,9 @@ def write_state(self, session, state, *, source_id): assert MemoryContextProvider not in provider_types -def test_harness_agent_disable_skills() -> None: +def test_create_harness_agent_disable_skills() -> None: """disable_skills=True should exclude SkillsProvider.""" - agent = HarnessAgent( + agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, @@ -171,9 +170,9 @@ def test_harness_agent_disable_skills() -> None: assert SkillsProvider not in provider_types -def test_harness_agent_disable_compaction() -> None: +def test_create_harness_agent_disable_compaction() -> None: """disable_compaction=True should exclude CompactionProvider.""" - agent = HarnessAgent( + agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, @@ -183,45 +182,45 @@ def test_harness_agent_disable_compaction() -> None: assert CompactionProvider not in provider_types -def test_harness_agent_default_uses_full_agent() -> None: - """Default assembly should use Agent (with telemetry).""" +def test_create_harness_agent_returns_full_agent() -> None: + """Factory should return an Agent instance (with telemetry).""" from agent_framework._agents import Agent as FullAgent - agent = HarnessAgent( + agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, ) - assert isinstance(agent._inner_agent, FullAgent) + assert isinstance(agent, FullAgent) # --- Validation Tests --- -def test_harness_agent_rejects_invalid_context_tokens() -> None: +def test_create_harness_agent_rejects_invalid_context_tokens() -> None: """max_context_window_tokens must be positive.""" with pytest.raises(ValueError, match="max_context_window_tokens must be positive"): - HarnessAgent( + create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=0, max_output_tokens=100, ) -def test_harness_agent_rejects_negative_output_tokens() -> None: +def test_create_harness_agent_rejects_negative_output_tokens() -> None: """max_output_tokens must be non-negative.""" with pytest.raises(ValueError, match="max_output_tokens must be non-negative"): - HarnessAgent( + create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=1000, max_output_tokens=-1, ) -def test_harness_agent_rejects_output_gte_context() -> None: +def test_create_harness_agent_rejects_output_gte_context() -> None: """max_output_tokens must be less than max_context_window_tokens.""" with pytest.raises(ValueError, match="max_output_tokens must be less than"): - HarnessAgent( + create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=1000, max_output_tokens=1000, @@ -253,9 +252,9 @@ def test_empty_harness_instructions_uses_agent_only() -> None: # --- Identity Tests --- -def test_harness_agent_custom_identity() -> None: +def test_create_harness_agent_custom_identity() -> None: """Custom id, name, description should propagate.""" - agent = HarnessAgent( + agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, @@ -271,9 +270,9 @@ def test_harness_agent_custom_identity() -> None: # --- Session Tests --- -def test_harness_agent_create_session() -> None: +def test_create_harness_agent_create_session() -> None: """create_session should return an AgentSession.""" - agent = HarnessAgent( + agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, @@ -282,9 +281,9 @@ def test_harness_agent_create_session() -> None: assert isinstance(session, AgentSession) -def test_harness_agent_create_session_with_id() -> None: +def test_create_harness_agent_create_session_with_id() -> None: """create_session should accept a custom session_id.""" - agent = HarnessAgent( + agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, @@ -293,9 +292,9 @@ def test_harness_agent_create_session_with_id() -> None: assert session.session_id == "custom-id" -async def test_harness_agent_run_returns_response() -> None: - """agent.run() should delegate to inner agent and return a response.""" - agent = HarnessAgent( +async def test_create_harness_agent_run_returns_response() -> None: + """agent.run() should return a response.""" + agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, @@ -310,11 +309,11 @@ async def test_harness_agent_run_returns_response() -> None: # --- Protocol Tests --- -def test_harness_agent_satisfies_protocol() -> None: - """HarnessAgent should satisfy SupportsAgentRun protocol.""" +def test_create_harness_agent_satisfies_protocol() -> None: + """Returned agent should satisfy SupportsAgentRun protocol.""" from agent_framework import SupportsAgentRun - agent = HarnessAgent( + agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, @@ -325,14 +324,14 @@ def test_harness_agent_satisfies_protocol() -> None: # --- Additional providers --- -def test_harness_agent_extra_context_providers() -> None: +def test_create_harness_agent_extra_context_providers() -> None: """Additional context_providers should be appended.""" class _CustomProvider(ContextProvider): pass custom = _CustomProvider("custom") - agent = HarnessAgent( + agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, @@ -351,38 +350,38 @@ def get_web_search_tool(self, **kwargs: Any) -> str: return "web_search_tool_instance" -def test_harness_agent_auto_adds_web_search_tool() -> None: +def test_create_harness_agent_auto_adds_web_search_tool() -> None: """Web search tool should be auto-added when client supports it.""" - agent = HarnessAgent( + agent = create_harness_agent( client=_FakeWebSearchClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, disable_skills=True, ) - tools = agent._inner_agent.default_options.get("tools", []) + tools = agent.default_options.get("tools", []) assert "web_search_tool_instance" in tools -def test_harness_agent_disable_web_search() -> None: +def test_create_harness_agent_disable_web_search() -> None: """disable_web_search=True should skip auto-adding the web search tool.""" - agent = HarnessAgent( + agent = create_harness_agent( client=_FakeWebSearchClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, disable_web_search=True, disable_skills=True, ) - tools = agent._inner_agent.default_options.get("tools", []) + tools = agent.default_options.get("tools", []) assert "web_search_tool_instance" not in tools -def test_harness_agent_no_web_search_when_unsupported() -> None: +def test_create_harness_agent_no_web_search_when_unsupported() -> None: """Web search tool should NOT be added when client does not support it.""" - agent = HarnessAgent( + agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, disable_skills=True, ) - tools = agent._inner_agent.default_options.get("tools", []) + tools = agent.default_options.get("tools", []) assert "web_search_tool_instance" not in tools diff --git a/python/samples/02-agents/harness/README.md b/python/samples/02-agents/harness/README.md index bc39d9f708..3bf0f09110 100644 --- a/python/samples/02-agents/harness/README.md +++ b/python/samples/02-agents/harness/README.md @@ -1,11 +1,12 @@ -# HarnessAgent Samples +# Harness Agent Samples -This folder demonstrates the `HarnessAgent` — a pre-configured, batteries-included -agent that automatically assembles the full agent pipeline from a chat client. +This folder demonstrates `create_harness_agent` — a factory function that builds a +pre-configured, batteries-included agent by assembling the full agent pipeline +from a chat client. -## What is HarnessAgent? +## What is `create_harness_agent`? -`HarnessAgent` bundles the following features into a single class: +`create_harness_agent` bundles the following features into a single `Agent` instance: | Feature | Description | |---------|-------------| @@ -44,14 +45,14 @@ python samples/02-agents/harness/harness_research.py ### Minimal Setup -`HarnessAgent` requires only a chat client and token budget parameters: +`create_harness_agent` requires only a chat client and token budget parameters: ```python -from agent_framework import HarnessAgent +from agent_framework import create_harness_agent from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential -agent = HarnessAgent( +agent = create_harness_agent( client=FoundryChatClient(credential=AzureCliCredential()), max_context_window_tokens=128_000, max_output_tokens=16_384, @@ -63,7 +64,7 @@ agent = HarnessAgent( Disable or customize any feature: ```python -agent = HarnessAgent( +agent = create_harness_agent( client=client, max_context_window_tokens=128_000, max_output_tokens=16_384, @@ -72,7 +73,6 @@ agent = HarnessAgent( disable_todo=True, # Skip todo management disable_mode=True, # Skip plan/execute modes disable_compaction=True, # Skip compaction - disable_telemetry=True, # Skip OpenTelemetry ) ``` diff --git a/python/samples/02-agents/harness/harness_research.py b/python/samples/02-agents/harness/harness_research.py index 2e2d7ab4dc..03bfc0e7db 100644 --- a/python/samples/02-agents/harness/harness_research.py +++ b/python/samples/02-agents/harness/harness_research.py @@ -1,10 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. -"""HarnessAgent Research Assistant. +"""Harness Research Assistant. -Demonstrates ``HarnessAgent`` — a pre-configured bundled agent that automatically -wires up function invocation, per-service-call history persistence, compaction, -and a rich set of context providers: +Demonstrates ``create_harness_agent`` — a factory function that builds a +pre-configured agent with batteries included, automatically wiring up function +invocation, per-service-call history persistence, compaction, and a rich set of +context providers: - **TodoProvider** — the agent can create, track, and complete work items - **AgentModeProvider** — plan/execute mode tracking (interactive vs. autonomous) @@ -32,7 +33,7 @@ import asyncio -from agent_framework import HarnessAgent +from agent_framework import create_harness_agent from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -69,10 +70,10 @@ async def main() -> None: # with your preferred authentication option. client = FoundryChatClient(credential=AzureCliCredential()) - # Create a HarnessAgent with research-specific instructions. + # Create a harness agent with research-specific instructions. # All other features (todo, mode, compaction, skills, telemetry, web search) are # automatically configured with sensible defaults. - agent = HarnessAgent( + agent = create_harness_agent( client=client, max_context_window_tokens=128_000, max_output_tokens=16_384, @@ -85,7 +86,7 @@ async def main() -> None: # Create a session to maintain conversation state across turns. session = agent.create_session() - print("Research Assistant (powered by HarnessAgent)") + print("Research Assistant (powered by create_harness_agent)") print("=" * 50) print("Enter a research topic to get started.") print("Type /exit to end the session.\n") From 20422d2271e0d6e1754a9164a84fdcc20aa761d8 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 26 May 2026 14:27:48 +0000 Subject: [PATCH 9/9] Address further PR comments. --- .../core/agent_framework/_harness/_agent.py | 50 ++++++++++++------- .../core/tests/core/test_harness_agent.py | 29 +++++++---- .../02-agents/harness/harness_research.py | 1 - 3 files changed, 51 insertions(+), 29 deletions(-) diff --git a/python/packages/core/agent_framework/_harness/_agent.py b/python/packages/core/agent_framework/_harness/_agent.py index 4ecaa4c2d7..ce218576db 100644 --- a/python/packages/core/agent_framework/_harness/_agent.py +++ b/python/packages/core/agent_framework/_harness/_agent.py @@ -10,6 +10,7 @@ from __future__ import annotations +import logging from collections.abc import Callable, Sequence from typing import TYPE_CHECKING, Any @@ -31,6 +32,8 @@ from .._middleware import MiddlewareTypes from .._tools import ToolTypes +logger = logging.getLogger(__name__) + DEFAULT_HARNESS_INSTRUCTIONS = """\ You are a helpful AI assistant that uses tools to complete tasks. @@ -98,7 +101,6 @@ def _assemble_context_providers( mode_provider: AgentModeProvider | None, disable_memory: bool, memory_store: MemoryStore | None, - disable_skills: bool, skills_provider: SkillsProvider | None, skills_paths: Sequence[str] | None, extra_context_providers: Sequence[ContextProvider] | None, @@ -122,11 +124,11 @@ def _assemble_context_providers( if not disable_memory and memory_store is not None: providers.append(MemoryContextProvider(store=memory_store)) - if not disable_skills: - skills: SkillsProvider | None = skills_provider - if skills is None: - skills = SkillsProvider.from_paths(*skills_paths) if skills_paths else SkillsProvider.from_paths(".") - providers.append(skills) + # Skills are opt-in: only added when skills_provider or skills_paths is provided. + if skills_provider: + providers.append(skills_provider) + if skills_paths: + providers.append(SkillsProvider.from_paths(*skills_paths)) # Append any user-supplied additional providers. if extra_context_providers: @@ -161,7 +163,6 @@ def create_harness_agent( mode_provider: AgentModeProvider | None = None, disable_memory: bool = False, memory_store: MemoryStore | None = None, - disable_skills: bool = False, skills_provider: SkillsProvider | None = None, skills_paths: Sequence[str] | None = None, disable_web_search: bool = False, @@ -222,9 +223,14 @@ def create_harness_agent( id: Optional agent ID (auto-generated UUID if omitted). name: Optional agent name. description: Optional agent description. - harness_instructions: Override the default harness-level instructions. - Set to empty string to omit harness instructions entirely. - agent_instructions: Agent-specific instructions appended after harness instructions. + harness_instructions: Override the default harness-level system instructions that + govern agent behavior (how to use tools, report progress, structure responses). + These provide general "operating guidelines" independent of any specific task. + When None, ``DEFAULT_HARNESS_INSTRUCTIONS`` is used. Set to empty string ``""`` + to omit harness instructions entirely. + agent_instructions: Domain or task-specific instructions appended after harness + instructions. Use this for the agent's purpose, persona, or specialization + (e.g., "You are a research assistant focused on academic sources."). tools: Additional tools to include in the agent's toolset. max_context_window_tokens: Maximum tokens the model's context window supports. max_output_tokens: Maximum output tokens per response. @@ -242,13 +248,15 @@ def create_harness_agent( disable_memory: When True, skip the MemoryContextProvider. memory_store: Memory store instance. When provided (and disable_memory is False), a MemoryContextProvider is added. - disable_skills: When True, skip the SkillsProvider. - skills_provider: Custom SkillsProvider instance. Ignored when disable_skills is True. - skills_paths: Paths for file-based skill discovery. - Ignored when skills_provider is set or disable_skills is True. + skills_provider: Custom SkillsProvider instance for code-defined skills. + Can be combined with ``skills_paths`` to aggregate file and code-based skills. + skills_paths: Paths for file-based skill discovery (looks for SKILL.md files). + Can be combined with ``skills_provider``. When neither ``skills_provider`` + nor ``skills_paths`` is provided, no SkillsProvider is added. 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. + client implements SupportsWebSearchTool. A warning is logged if the client + does not support web search. otel_provider_name: Custom OpenTelemetry provider/source name for telemetry. context_providers: Additional context providers to include after the built-in ones. middleware: Additional middleware to include. @@ -292,7 +300,6 @@ def create_harness_agent( mode_provider=mode_provider, disable_memory=disable_memory, memory_store=memory_store, - disable_skills=disable_skills, skills_provider=skills_provider, skills_paths=skills_paths, extra_context_providers=context_providers, @@ -303,8 +310,15 @@ def create_harness_agent( # Assemble tools, auto-adding web search if supported. assembled_tools: list[ToolTypes | Callable[..., Any]] = [] - if not disable_web_search and isinstance(client, SupportsWebSearchTool): - assembled_tools.append(client.get_web_search_tool()) + if not disable_web_search: + if isinstance(client, SupportsWebSearchTool): + assembled_tools.append(client.get_web_search_tool()) + else: + logger.warning( + "Web search tool not available: client %r does not implement SupportsWebSearchTool. " + "Set disable_web_search=True to suppress this warning.", + type(client).__name__, + ) if tools is not None: if isinstance(tools, Sequence): assembled_tools.extend(tools) # pyright: ignore[reportUnknownArgumentType] diff --git a/python/packages/core/tests/core/test_harness_agent.py b/python/packages/core/tests/core/test_harness_agent.py index 42c451a8c6..c53147fd15 100644 --- a/python/packages/core/tests/core/test_harness_agent.py +++ b/python/packages/core/tests/core/test_harness_agent.py @@ -60,7 +60,7 @@ def test_create_harness_agent_with_defaults() -> None: def test_create_harness_agent_includes_all_default_providers() -> None: - """Default assembly should include history, compaction, todo, mode, skills.""" + """Default assembly should include history, compaction, todo, mode (no skills by default).""" agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, @@ -73,7 +73,7 @@ def test_create_harness_agent_includes_all_default_providers() -> None: assert CompactionProvider in provider_types assert TodoProvider in provider_types assert AgentModeProvider in provider_types - assert SkillsProvider in provider_types + assert SkillsProvider not in provider_types def test_create_harness_agent_disable_todo() -> None: @@ -158,16 +158,16 @@ def write_state(self, session, state, *, source_id): assert MemoryContextProvider not in provider_types -def test_create_harness_agent_disable_skills() -> None: - """disable_skills=True should exclude SkillsProvider.""" +def test_create_harness_agent_skills_paths_adds_provider() -> None: + """skills_paths should add a SkillsProvider.""" agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, - disable_skills=True, + skills_paths=["./test-skills"], ) provider_types = [type(p) for p in agent.context_providers] - assert SkillsProvider not in provider_types + assert SkillsProvider in provider_types def test_create_harness_agent_disable_compaction() -> None: @@ -298,7 +298,6 @@ async def test_create_harness_agent_run_returns_response() -> None: client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, - disable_skills=True, ) session = agent.create_session() response = await agent.run("hello", session=session) @@ -356,7 +355,6 @@ def test_create_harness_agent_auto_adds_web_search_tool() -> None: client=_FakeWebSearchClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, - disable_skills=True, ) tools = agent.default_options.get("tools", []) assert "web_search_tool_instance" in tools @@ -369,7 +367,6 @@ def test_create_harness_agent_disable_web_search() -> None: max_context_window_tokens=128_000, max_output_tokens=16_384, disable_web_search=True, - disable_skills=True, ) tools = agent.default_options.get("tools", []) assert "web_search_tool_instance" not in tools @@ -381,7 +378,19 @@ def test_create_harness_agent_no_web_search_when_unsupported() -> None: client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, - disable_skills=True, ) tools = agent.default_options.get("tools", []) assert "web_search_tool_instance" not in tools + + +def test_create_harness_agent_logs_warning_when_no_web_search(caplog: pytest.LogCaptureFixture) -> None: + """A warning should be logged when client doesn't support web search.""" + import logging + + with caplog.at_level(logging.WARNING, logger="agent_framework._harness._agent"): + create_harness_agent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + ) + assert any("SupportsWebSearchTool" in msg for msg in caplog.messages) diff --git a/python/samples/02-agents/harness/harness_research.py b/python/samples/02-agents/harness/harness_research.py index 03bfc0e7db..f1cb66228a 100644 --- a/python/samples/02-agents/harness/harness_research.py +++ b/python/samples/02-agents/harness/harness_research.py @@ -80,7 +80,6 @@ async def main() -> None: name="ResearchAgent", description="A research assistant that plans and executes research tasks.", agent_instructions=RESEARCH_INSTRUCTIONS, - disable_skills=True, # No SKILL.md files in this sample directory. ) # Create a session to maintain conversation state across turns.