From 19b23673667d9e3d99c9a04e6034d0f6818b144e Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Fri, 15 May 2026 10:47:00 +0100 Subject: [PATCH 1/9] Python: Parse YAML block scalars in SKILL.md frontmatter (#5863) The frontmatter parser previously matched only single-line `key: value` pairs, so block scalar indicators (`|` literal, `>` folded, with chomping `-`/`+`) were silently truncated to the indicator character. Multi-line descriptions like `description: >\n ...` lost their content. Add `_parse_yaml_scalar_value()` which detects block scalar indicators, collects indented continuation lines, strips the common leading indentation, joins per scalar style (newlines for `|`, spaces for `>`), and applies chomping per the YAML 1.2 spec. Update `_extract_frontmatter()` to use the helper for unquoted values. Adds 15 unit tests covering literal/folded styles, all chomping variants, indentation handling, content containing colons, non-description fields, tab indentation, blank-line preservation, and a regression test for plain values. Fixes #5713. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/_skills.py | 95 ++++++++++- .../packages/core/tests/core/test_skills.py | 151 ++++++++++++++++-- 2 files changed, 236 insertions(+), 10 deletions(-) diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index c1d0c77e45..ba550e7095 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -1513,6 +1513,97 @@ YAML_INDENTED_KV_RE = re.compile( # must not start or end with a hyphen, and must not contain consecutive hyphens. VALID_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$") +# Block scalar indicator characters recognised by the lightweight YAML parser. +_BLOCK_SCALAR_INDICATORS = ("|", ">") + + +def _parse_yaml_scalar_value(yaml_content: str, kv_match: re.Match[str]) -> str: + """Resolve the scalar value for an unquoted YAML key-value match. + + If the captured value starts with a YAML block scalar indicator (``|`` or + ``>``), the function reads subsequent indented continuation lines, strips + the common leading indentation, and joins them according to the scalar + style (literal preserves newlines, folded replaces them with spaces). + + Chomping indicators are respected per YAML 1.2 §8.1.1.2: + + * ``-`` (strip) — final line break and trailing empty lines excluded + * ``+`` (keep) — final line break and any trailing empty lines preserved + * default (clip) — final line break preserved, trailing empty lines excluded + + For plain (non-block-scalar) values the captured text is returned as-is. + Note: explicit indentation indicators (e.g. ``|2``) are not supported; + indentation is auto-detected from the common leading whitespace. + """ + value: str = kv_match.group(3) + + if not value or value[0] not in _BLOCK_SCALAR_INDICATORS: + return value + + scalar_style = value[0] + keep_trailing_newline = len(value) > 1 and value[1] == "+" + strip_trailing_newline = len(value) > 1 and value[1] == "-" + + # Find the start of the next line after this key-value match. + next_line_start = yaml_content.find("\n", kv_match.end()) + if next_line_start < 0: + return value + next_line_start += 1 # skip the newline character itself + + # Collect indented continuation lines (or blank lines within the block). + block_lines: list[str] = [] + pos = next_line_start + while pos < len(yaml_content): + line_end = yaml_content.find("\n", pos) + if line_end < 0: + line = yaml_content[pos:] + line_end = len(yaml_content) + else: + line = yaml_content[pos:line_end] + + if not line or line.isspace(): + # Blank / whitespace-only lines are part of the block. + block_lines.append("") + pos = line_end + 1 if line_end < len(yaml_content) else line_end + continue + + if line[0] not in (" ", "\t"): + # Non-indented, non-blank line — end of the block. + break + + block_lines.append(line) + pos = line_end + 1 if line_end < len(yaml_content) else line_end + + # Strip trailing blank lines collected from the block. + while block_lines and block_lines[-1] == "": + block_lines.pop() + + if not block_lines: + return "" + + # Determine the common leading indentation across non-empty lines. + # Only space/tab characters count as indentation (matches YAML semantics). + def _indent_width(s: str) -> int: + i = 0 + while i < len(s) and s[i] in (" ", "\t"): + i += 1 + return i + + common_indent = min(_indent_width(line) for line in block_lines if line) + normalized = [line[common_indent:] if line else "" for line in block_lines] + + # Literal preserves newlines; folded joins non-empty lines with spaces. + parsed = "\n".join(normalized) if scalar_style == "|" else " ".join(line for line in normalized if line) + + if keep_trailing_newline: + return parsed + "\n" + if strip_trailing_newline: + return parsed + # Clip (default): literal gets a trailing newline, folded does not. + if scalar_style == "|": + return parsed + "\n" + return parsed + # Default system prompt template for advertising available skills to the model. # Use {skills} as the placeholder for the generated skills XML list. @@ -2879,7 +2970,9 @@ class FileSkillsSource(SkillsSource): for kv_match in YAML_KV_RE.finditer(yaml_content): key = kv_match.group(1) - value = kv_match.group(2) if kv_match.group(2) is not None else kv_match.group(3) + value = ( + kv_match.group(2) if kv_match.group(2) is not None else _parse_yaml_scalar_value(yaml_content, kv_match) + ) key_lower = key.lower() if key_lower == "name": diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py index c386da2ff3..415d6ea857 100644 --- a/python/packages/core/tests/core/test_skills.py +++ b/python/packages/core/tests/core/test_skills.py @@ -319,9 +319,7 @@ class TestDiscoverResourceFiles: refs = skill_dir / "references" refs.mkdir(parents=True) (refs / "doc.md").write_text("content", encoding="utf-8") - resources = FileSkillsSource._discover_resource_files( - str(skill_dir), directories=("references", "references") - ) + resources = FileSkillsSource._discover_resource_files(str(skill_dir), directories=("references", "references")) assert resources == ["references/doc.md"] def test_results_are_sorted(self, tmp_path: Path) -> None: @@ -1675,9 +1673,7 @@ class TestValidateAndNormalizeDirectoryNames: FileSkillsSource._validate_and_normalize_directory_names([" "]) def test_multiple_directories(self) -> None: - result = FileSkillsSource._validate_and_normalize_directory_names( - [".", "references", "assets", "scripts"] - ) + result = FileSkillsSource._validate_and_normalize_directory_names([".", "references", "assets", "scripts"]) assert result == [".", "references", "assets", "scripts"] def test_default_resource_directories(self) -> None: @@ -2163,6 +2159,145 @@ class TestExtractFrontmatterEdgeCases: assert result.description == desc +# --------------------------------------------------------------------------- +# Tests: _extract_frontmatter block scalar parsing +# --------------------------------------------------------------------------- + + +class TestExtractFrontmatterBlockScalars: + """Tests for YAML block scalar (| and >) parsing in _extract_frontmatter.""" + + def test_literal_block_scalar(self) -> None: + content = "---\nname: test-skill\ndescription: |\n Line one\n Line two\n---\nBody." + result = FileSkillsSource._extract_frontmatter(content, "test.md") + assert result is not None + assert result.description == "Line one\nLine two\n" + + def test_folded_block_scalar(self) -> None: + content = "---\nname: test-skill\ndescription: >\n This is a multi-line\n description block\n---\nBody." + result = FileSkillsSource._extract_frontmatter(content, "test.md") + assert result is not None + assert result.description == "This is a multi-line description block" + + def test_literal_strip_chomping(self) -> None: + content = "---\nname: test-skill\ndescription: |-\n No trailing newline\n---\nBody." + result = FileSkillsSource._extract_frontmatter(content, "test.md") + assert result is not None + assert result.description == "No trailing newline" + + def test_folded_strip_chomping(self) -> None: + content = "---\nname: test-skill\ndescription: >-\n Folded with\n strip chomping\n---\nBody." + result = FileSkillsSource._extract_frontmatter(content, "test.md") + assert result is not None + assert result.description == "Folded with strip chomping" + + def test_literal_keep_chomping(self) -> None: + content = "---\nname: test-skill\ndescription: |+\n Keep trailing\n---\nBody." + result = FileSkillsSource._extract_frontmatter(content, "test.md") + assert result is not None + assert result.description == "Keep trailing\n" + + def test_folded_keep_chomping(self) -> None: + content = "---\nname: test-skill\ndescription: >+\n Keep trailing\n newline\n---\nBody." + result = FileSkillsSource._extract_frontmatter(content, "test.md") + assert result is not None + assert result.description == "Keep trailing newline\n" + + def test_block_scalar_no_continuation_lines(self) -> None: + content = "---\nname: test-skill\ndescription: |\nlicense: MIT\n---\nBody." + result = FileSkillsSource._extract_frontmatter(content, "test.md") + # description becomes empty string which fails validation (empty/whitespace) + assert result is None + + def test_block_scalar_varying_indentation(self) -> None: + content = ( + "---\n" + "name: test-skill\n" + "description: |\n" + " Line with 4-space indent\n" + " Line with 4-space indent\n" + "---\n" + "Body." + ) + result = FileSkillsSource._extract_frontmatter(content, "test.md") + assert result is not None + assert result.description == "Line with 4-space indent\nLine with 4-space indent\n" + + def test_folded_block_scalar_real_skill_format(self) -> None: + """End-to-end test matching the format used in .github/skills/ SKILL.md files.""" + content = ( + "---\n" + "name: python-development\n" + "description: >\n" + " Coding standards, conventions, and patterns for developing Python code in the\n" + " Agent Framework repository. Use this when writing or modifying Python source\n" + " files in the python/ directory.\n" + "---\n" + "\n" + "# Python Development Standards\n" + ) + result = FileSkillsSource._extract_frontmatter(content, "test.md") + assert result is not None + assert result.description == ( + "Coding standards, conventions, and patterns for developing Python code in the " + "Agent Framework repository. Use this when writing or modifying Python source " + "files in the python/ directory." + ) + + def test_block_scalar_with_other_fields_after(self) -> None: + content = "---\nname: test-skill\ndescription: >\n A folded\n description\nlicense: MIT\n---\nBody." + result = FileSkillsSource._extract_frontmatter(content, "test.md") + assert result is not None + assert result.description == "A folded description" + assert result.license == "MIT" + + def test_plain_value_unchanged(self) -> None: + """Non-block-scalar values must not be affected by the block scalar logic.""" + content = "---\nname: test-skill\ndescription: A simple description.\n---\nBody." + result = FileSkillsSource._extract_frontmatter(content, "test.md") + assert result is not None + assert result.description == "A simple description." + + def test_block_scalar_content_with_colons(self) -> None: + """Lines inside a block scalar that look like YAML key-value pairs must be preserved verbatim.""" + content = ( + "---\nname: test-skill\ndescription: |\n Some text with colon: in it\n Another: line here\n---\nBody." + ) + result = FileSkillsSource._extract_frontmatter(content, "test.md") + assert result is not None + assert result.description == "Some text with colon: in it\nAnother: line here\n" + + def test_block_scalar_on_license_field(self) -> None: + """Block scalars should work on any field, not only description.""" + content = ( + "---\n" + "name: test-skill\n" + "description: A skill.\n" + "license: >\n" + " Custom license\n" + " spanning multiple lines\n" + "---\n" + "Body." + ) + result = FileSkillsSource._extract_frontmatter(content, "test.md") + assert result is not None + assert result.license == "Custom license spanning multiple lines" + + def test_block_scalar_tab_indentation(self) -> None: + """Tab characters should count as indentation for block scalar continuation lines.""" + content = "---\nname: test-skill\ndescription: |\n\tTab-indented line one\n\tTab-indented line two\n---\nBody." + result = FileSkillsSource._extract_frontmatter(content, "test.md") + assert result is not None + assert result.description == "Tab-indented line one\nTab-indented line two\n" + + def test_block_scalar_blank_line_within_block(self) -> None: + """Blank lines within a block scalar should be preserved as paragraph separators.""" + content = "---\nname: test-skill\ndescription: |\n First paragraph\n\n Second paragraph\n---\nBody." + result = FileSkillsSource._extract_frontmatter(content, "test.md") + assert result is not None + assert result.description == "First paragraph\n\nSecond paragraph\n" + + # --------------------------------------------------------------------------- # Tests: Skill spec fields (via SkillFrontmatter) # --------------------------------------------------------------------------- @@ -5498,9 +5633,7 @@ class TestArrayStyleScriptArgs: return "ok" assert isinstance(my_runner, SkillScriptRunner) - skill = FileSkill( - frontmatter=SkillFrontmatter(name="s", description="d"), content="c", path=f"{_ABS}/test" - ) + skill = FileSkill(frontmatter=SkillFrontmatter(name="s", description="d"), content="c", path=f"{_ABS}/test") script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py") result = my_runner(skill, script, args=["--flag", "value"]) assert result == "ok" From d81a8753d72612fd5c53e05724dc28ea7577c61c Mon Sep 17 00:00:00 2001 From: Challa Ravi Date: Fri, 15 May 2026 16:32:37 +0530 Subject: [PATCH 2/9] add AgentSession StateBag edge case coverage (#5838) Co-authored-by: Challa Ravindranath --- .../AgentSessionTests.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionTests.cs index b80f0a4fd2..5b14d41f74 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionTests.cs @@ -24,6 +24,45 @@ public class AgentSessionTests Assert.Equal("value1", session.StateBag.GetValue("key1")); } + [Fact] + public void StateBag_Default_IsEmpty() + { + // Arrange & Act + var session = new TestAgentSession(); + + // Assert + Assert.Equal(0, session.StateBag.Count); + } + + [Fact] + public void StateBag_MultipleKeys_StoreAndRetrieveIndependently() + { + // Arrange + var session = new TestAgentSession(); + + // Act + session.StateBag.SetValue("key1", "value1"); + session.StateBag.SetValue("key2", "value2"); + + // Assert + Assert.Equal("value1", session.StateBag.GetValue("key1")); + Assert.Equal("value2", session.StateBag.GetValue("key2")); + } + + [Fact] + public void StateBag_OverwriteValue_ReturnsUpdatedValue() + { + // Arrange + var session = new TestAgentSession(); + session.StateBag.SetValue("key1", "original"); + + // Act + session.StateBag.SetValue("key1", "updated"); + + // Assert + Assert.Equal("updated", session.StateBag.GetValue("key1")); + } + #endregion #region GetService Method Tests From 0d09d40f0f21fef24dca75ff9f2716e44c25a086 Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Fri, 15 May 2026 07:59:22 -0700 Subject: [PATCH 3/9] Python: Fix GitHubCopilotAgent to include tools added by ContextProvider.before_run in session creation (#5780) * Fix GitHubCopilotAgent ignoring tools from context providers (#5736) _create_session and _resume_session only forwarded self._tools (constructor tools) to CopilotClient.create_session, dropping any tools contributed by context providers via session_context.extend_tools() during before_run. Merge provider-contributed tools into runtime_options in both _run_impl and _stream_updates before session creation, mirroring how RawAgent handles the merge at lines 1435-1440 in _agents.py. Update _create_session and _resume_session to combine self._tools with the merged runtime tools. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Fix GitHubCopilotAgent to include tools added by ContextProvider.before_run in session creation Fixes #5736 * Fix provider tool merge to avoid mutating caller's list - Replace in-place .extend() with fresh list creation in both _run_impl and _stream_updates paths to prevent mutating the caller-provided options['tools'] list (shallow copy issue) - Also handles immutable Sequence types (e.g. tuple) correctly - Add test for provider tools forwarded via _resume_session path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #5736: review comment fixes --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../a2a/agent_framework_a2a/_agent.py | 4 +- .../agent_framework_github_copilot/_agent.py | 20 +- .../tests/test_github_copilot_agent.py | 228 ++++++++++++++++++ python/uv.lock | 2 +- 4 files changed, 244 insertions(+), 10 deletions(-) diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index a6f041ca64..bc175ccc48 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -157,9 +157,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): self.client = factory.create(agent_card, interceptors=interceptors) # type: ignore except Exception as transport_error: # Transport negotiation failed - fall back to minimal agent card with JSONRPC - fallback_url = ( - agent_card.supported_interfaces[0].url if agent_card.supported_interfaces else url - ) + fallback_url = agent_card.supported_interfaces[0].url if agent_card.supported_interfaces else url if not fallback_url: raise ValueError( "A2A transport negotiation failed and no fallback URL is available. " diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 49d7f0f6ee..0fc9c9dcf6 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -520,8 +520,11 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]): session_context = await self._run_before_providers(session=session, input_messages=input_messages, options=opts) - # NOTE: session is created after providers run so that future provider-contributed - # tools/config could be folded into runtime_options before session creation. + # Merge provider-contributed tools into runtime_options before session creation. + if session_context.tools: + existing = list(opts.get("tools") or []) + opts["tools"] = existing + list(session_context.tools) + copilot_session = await self._get_or_create_session(session, streaming=False, runtime_options=opts) # Build the prompt from the full set of messages in the session context, @@ -605,8 +608,11 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]): session_context = await self._run_before_providers(session=session, input_messages=input_messages, options=opts) - # NOTE: session is created after providers run so that future provider-contributed - # tools/config could be folded into runtime_options before session creation. + # Merge provider-contributed tools into runtime_options before session creation. + if session_context.tools: + existing = list(opts.get("tools") or []) + opts["tools"] = existing + list(session_context.tools) + copilot_session = await self._get_or_create_session(session, streaming=True, runtime_options=opts) if _ctx_holder is not None: @@ -891,7 +897,8 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]): mcp_servers = opts.get("mcp_servers") or self._mcp_servers or None provider = opts.get("provider") or self._provider or None instruction_directories = opts.get("instruction_directories", self._instruction_directories) - tools = self._prepare_tools(self._tools) if self._tools else None + all_tools = list(self._tools or []) + list(opts.get("tools") or []) + tools = self._prepare_tools(all_tools) if all_tools else None return await self._client.create_session( on_permission_request=permission_handler, @@ -929,7 +936,8 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]): mcp_servers = opts.get("mcp_servers") or self._mcp_servers or None provider = opts.get("provider") or self._provider or None instruction_directories = opts.get("instruction_directories", self._instruction_directories) - tools = self._prepare_tools(self._tools) if self._tools else None + all_tools = list(self._tools or []) + list(opts.get("tools") or []) + tools = self._prepare_tools(all_tools) if all_tools else None return await self._client.resume_session( session_id, diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 321ca9880e..a0f0caef72 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -2477,3 +2477,231 @@ class TestGitHubCopilotAgentContextProviders: with pytest.raises(ValueError, match="on_function_approval"): async for _ in agent.run("hello", stream=True, options={"on_function_approval": lambda _c: True}): pass + + async def test_provider_tools_forwarded_to_session( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test that tools added by context providers are forwarded to session creation.""" + mock_session.send_and_wait.return_value = assistant_message_event + + class ToolInjectingProvider(ContextProvider): + def __init__(self) -> None: + super().__init__(source_id="tool-injector") + + async def before_run( + self, + *, + agent: Any, + session: AgentSession, + context: Any, + state: dict[str, Any], + ) -> None: + from agent_framework._tools import normalize_tools + + def load_skill(skill_name: str) -> str: + """Load a skill by name.""" + return f"Loaded: {skill_name}" + + context.extend_tools(self.source_id, normalize_tools([load_skill])) + + provider = ToolInjectingProvider() + agent = GitHubCopilotAgent(client=mock_client, context_providers=[provider]) + session = agent.create_session() + await agent.run("Hello", session=session) + + call_kwargs = mock_client.create_session.call_args.kwargs + assert call_kwargs.get("tools") is not None + tool_names = [t.name for t in call_kwargs["tools"]] + assert "load_skill" in tool_names + + async def test_provider_tools_merged_with_constructor_tools( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test that provider tools are merged with constructor tools, not replacing them.""" + mock_session.send_and_wait.return_value = assistant_message_event + + def my_tool(x: str) -> str: + """A constructor tool.""" + return x + + class ToolInjectingProvider(ContextProvider): + def __init__(self) -> None: + super().__init__(source_id="tool-injector") + + async def before_run( + self, + *, + agent: Any, + session: AgentSession, + context: Any, + state: dict[str, Any], + ) -> None: + from agent_framework._tools import normalize_tools + + def load_skill(skill_name: str) -> str: + """Load a skill by name.""" + return f"Loaded: {skill_name}" + + context.extend_tools(self.source_id, normalize_tools([load_skill])) + + provider = ToolInjectingProvider() + agent = GitHubCopilotAgent( + client=mock_client, + tools=[my_tool], + context_providers=[provider], + ) + session = agent.create_session() + await agent.run("Hello", session=session) + + call_kwargs = mock_client.create_session.call_args.kwargs + assert call_kwargs.get("tools") is not None + tool_names = [t.name for t in call_kwargs["tools"]] + assert "my_tool" in tool_names + assert "load_skill" in tool_names + + async def test_provider_tools_forwarded_in_streaming( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + """Test that provider tools are forwarded in the streaming path.""" + events = [assistant_delta_event, session_idle_event] + + def mock_on(handler: Any) -> Any: + for event in events: + handler(event) + return lambda: None + + mock_session.on = mock_on + + class ToolInjectingProvider(ContextProvider): + def __init__(self) -> None: + super().__init__(source_id="tool-injector") + + async def before_run( + self, + *, + agent: Any, + session: AgentSession, + context: Any, + state: dict[str, Any], + ) -> None: + from agent_framework._tools import normalize_tools + + def load_skill(skill_name: str) -> str: + """Load a skill by name.""" + return f"Loaded: {skill_name}" + + context.extend_tools(self.source_id, normalize_tools([load_skill])) + + provider = ToolInjectingProvider() + agent = GitHubCopilotAgent(client=mock_client, context_providers=[provider]) + session = agent.create_session() + async for _ in agent.run("Hello", stream=True, session=session): + pass + + call_kwargs = mock_client.create_session.call_args.kwargs + assert call_kwargs.get("tools") is not None + tool_names = [t.name for t in call_kwargs["tools"]] + assert "load_skill" in tool_names + + async def test_provider_tools_forwarded_to_resume_session( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test that provider tools are forwarded when resuming an existing session.""" + mock_session.send_and_wait.return_value = assistant_message_event + + class ToolInjectingProvider(ContextProvider): + def __init__(self) -> None: + super().__init__(source_id="tool-injector") + + async def before_run( + self, + *, + agent: Any, + session: AgentSession, + context: Any, + state: dict[str, Any], + ) -> None: + from agent_framework._tools import normalize_tools + + def load_skill(skill_name: str) -> str: + """Load a skill by name.""" + return f"Loaded: {skill_name}" + + context.extend_tools(self.source_id, normalize_tools([load_skill])) + + provider = ToolInjectingProvider() + agent = GitHubCopilotAgent(client=mock_client, context_providers=[provider]) + session = agent.create_session() + session.service_session_id = "existing-id" + await agent.run("Hello", session=session) + + mock_client.create_session.assert_not_called() + mock_client.resume_session.assert_called_once() + call_kwargs = mock_client.resume_session.call_args.kwargs + assert call_kwargs.get("tools") is not None + tool_names = [t.name for t in call_kwargs["tools"]] + assert "load_skill" in tool_names + + async def test_provider_tools_forwarded_to_resume_session_streaming( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + """Test that provider tools are forwarded when resuming an existing session in streaming mode.""" + events = [assistant_delta_event, session_idle_event] + + def mock_on(handler: Any) -> Any: + for event in events: + handler(event) + return lambda: None + + mock_session.on = mock_on + + class ToolInjectingProvider(ContextProvider): + def __init__(self) -> None: + super().__init__(source_id="tool-injector") + + async def before_run( + self, + *, + agent: Any, + session: AgentSession, + context: Any, + state: dict[str, Any], + ) -> None: + from agent_framework._tools import normalize_tools + + def load_skill(skill_name: str) -> str: + """Load a skill by name.""" + return f"Loaded: {skill_name}" + + context.extend_tools(self.source_id, normalize_tools([load_skill])) + + provider = ToolInjectingProvider() + agent = GitHubCopilotAgent(client=mock_client, context_providers=[provider]) + session = agent.create_session() + session.service_session_id = "existing-id" + async for _ in agent.run("Hello", stream=True, session=session): + pass + + mock_client.create_session.assert_not_called() + mock_client.resume_session.assert_called_once() + call_kwargs = mock_client.resume_session.call_args.kwargs + assert call_kwargs.get("tools") is not None + tool_names = [t.name for t in call_kwargs["tools"]] + assert "load_skill" in tool_names diff --git a/python/uv.lock b/python/uv.lock index 3469125e6d..5154479024 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -602,7 +602,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0b2,<=1.0.0b2" }, ] [[package]] From c885ca3d7af85db768b9105bf25d02ba62a52677 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 15 May 2026 16:51:45 +0100 Subject: [PATCH 4/9] .NET: Fix bug in store-false helper to ensure addition rather than replacement (#5895) * Fix bug in store-false helper to ensure addition rather than replacement * Address PR comments --- .../OpenAIResponseClientExtensions.cs | 21 ++++- .../ProjectResponsesClientExtensionsTests.cs | 79 ++++++++++++++++++- .../OpenAIResponseClientExtensionsTests.cs | 79 ++++++++++++++++++- 3 files changed, 174 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs index 4ceff75743..642c0da203 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs @@ -120,9 +120,24 @@ public static class OpenAIResponseClientExtensions return Throw.IfNull(responseClient) .AsIChatClient(model) .AsBuilder() - .ConfigureOptions(x => x.RawRepresentationFactory = _ => includeReasoningEncryptedContent - ? new CreateResponseOptions() { StoredOutputEnabled = false, IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent } } - : new CreateResponseOptions() { StoredOutputEnabled = false }) + .ConfigureOptions(x => + { + var previousFactory = x.RawRepresentationFactory; + x.RawRepresentationFactory = state => + { + var responseOptions = previousFactory?.Invoke(state) as CreateResponseOptions ?? new CreateResponseOptions(); + + responseOptions.StoredOutputEnabled = false; + + if (includeReasoningEncryptedContent && + !responseOptions.IncludedProperties.Contains(IncludedResponseProperty.ReasoningEncryptedContent)) + { + responseOptions.IncludedProperties.Add(IncludedResponseProperty.ReasoningEncryptedContent); + } + + return responseOptions; + }; + }) .Build(); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ProjectResponsesClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ProjectResponsesClientExtensionsTests.cs index fda9962e12..b1424bb403 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ProjectResponsesClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ProjectResponsesClientExtensionsTests.cs @@ -129,6 +129,75 @@ public sealed class ProjectResponsesClientExtensionsTests Assert.DoesNotContain(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); } + /// + /// Verify that AsIChatClientWithStoredOutputDisabled preserves an existing RawRepresentationFactory + /// set on ChatOptions, augmenting it with StoredOutputEnabled and ReasoningEncryptedContent + /// rather than replacing it. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_PreservesExistingRawRepresentationFactory() + { + // Arrange + var responseClient = CreateTestClient(); + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(); + + // Simulate a caller setting their own RawRepresentationFactory on ChatOptions + // (e.g., to add WebSearchCallActionSources). + var options = new ChatOptions + { + RawRepresentationFactory = _ => new CreateResponseOptions + { + IncludedProperties = { IncludedResponseProperty.WebSearchCallActionSources }, + }, + }; + + // Act + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options); + + // Assert + Assert.NotNull(createResponseOptions); + Assert.False(createResponseOptions.StoredOutputEnabled); + Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); + Assert.Contains(IncludedResponseProperty.WebSearchCallActionSources, createResponseOptions.IncludedProperties); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled does not duplicate ReasoningEncryptedContent + /// when the existing factory already includes it. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_DoesNotDuplicateReasoningEncryptedContent() + { + // Arrange + var responseClient = CreateTestClient(); + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(); + + // Simulate a caller that already includes ReasoningEncryptedContent + var options = new ChatOptions + { + RawRepresentationFactory = _ => new CreateResponseOptions + { + IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent }, + }, + }; + + // Act + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options); + + // Assert - ReasoningEncryptedContent should appear exactly once + Assert.NotNull(createResponseOptions); + int count = 0; + foreach (var prop in createResponseOptions.IncludedProperties) + { + if (prop == IncludedResponseProperty.ReasoningEncryptedContent) + { + count++; + } + } + + Assert.Equal(1, count); + } + /// /// Verify that AsIChatClientWithStoredOutputDisabled works with an optional deployment name. /// @@ -153,6 +222,15 @@ public sealed class ProjectResponsesClientExtensionsTests /// by using reflection to access the configure action and invoking it on a test . /// private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient) + { + return GetCreateResponseOptionsFromPipeline(chatClient, new ChatOptions()); + } + + /// + /// Overload that runs the configure action on caller-supplied , + /// useful for testing that existing factories are preserved. + /// + private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient, ChatOptions options) { var configureField = chatClient.GetType().GetField("_configureOptions", BindingFlags.NonPublic | BindingFlags.Instance); Assert.NotNull(configureField); @@ -160,7 +238,6 @@ public sealed class ProjectResponsesClientExtensionsTests var configureAction = configureField.GetValue(chatClient) as Action; Assert.NotNull(configureAction); - var options = new ChatOptions(); configureAction(options); Assert.NotNull(options.RawRepresentationFactory); diff --git a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIResponseClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIResponseClientExtensionsTests.cs index 1205889e19..eede6ec637 100644 --- a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIResponseClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIResponseClientExtensionsTests.cs @@ -370,6 +370,75 @@ public sealed class OpenAIResponseClientExtensionsTests Assert.DoesNotContain(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); } + /// + /// Verify that AsIChatClientWithStoredOutputDisabled preserves an existing RawRepresentationFactory + /// set on ChatOptions, augmenting it with StoredOutputEnabled and ReasoningEncryptedContent + /// rather than replacing it. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_PreservesExistingRawRepresentationFactory() + { + // Arrange + var responseClient = new TestOpenAIResponseClient(); + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(); + + // Simulate a caller setting their own RawRepresentationFactory on ChatOptions + // (e.g., to add WebSearchCallActionSources). + var options = new ChatOptions + { + RawRepresentationFactory = _ => new CreateResponseOptions + { + IncludedProperties = { IncludedResponseProperty.WebSearchCallActionSources }, + }, + }; + + // Act + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options); + + // Assert + Assert.NotNull(createResponseOptions); + Assert.False(createResponseOptions.StoredOutputEnabled); + Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); + Assert.Contains(IncludedResponseProperty.WebSearchCallActionSources, createResponseOptions.IncludedProperties); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled does not duplicate ReasoningEncryptedContent + /// when the existing factory already includes it. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_DoesNotDuplicateReasoningEncryptedContent() + { + // Arrange + var responseClient = new TestOpenAIResponseClient(); + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(); + + // Simulate a caller that already includes ReasoningEncryptedContent + var options = new ChatOptions + { + RawRepresentationFactory = _ => new CreateResponseOptions + { + IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent }, + }, + }; + + // Act + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options); + + // Assert - ReasoningEncryptedContent should appear exactly once + Assert.NotNull(createResponseOptions); + int count = 0; + foreach (var prop in createResponseOptions.IncludedProperties) + { + if (prop == IncludedResponseProperty.ReasoningEncryptedContent) + { + count++; + } + } + + Assert.Equal(1, count); + } + /// /// A simple test IServiceProvider implementation for testing. /// @@ -394,6 +463,15 @@ public sealed class OpenAIResponseClientExtensionsTests /// by using reflection to access the configure action and invoking it on a test . /// private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient) + { + return GetCreateResponseOptionsFromPipeline(chatClient, new ChatOptions()); + } + + /// + /// Overload that runs the configure action on caller-supplied , + /// useful for testing that existing factories are preserved. + /// + private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient, ChatOptions options) { // The ConfigureOptionsChatClient stores the configure action in a private field. var configureField = chatClient.GetType().GetField("_configureOptions", BindingFlags.NonPublic | BindingFlags.Instance); @@ -402,7 +480,6 @@ public sealed class OpenAIResponseClientExtensionsTests var configureAction = configureField.GetValue(chatClient) as Action; Assert.NotNull(configureAction); - var options = new ChatOptions(); configureAction(options); Assert.NotNull(options.RawRepresentationFactory); From 9b772f3413bfed78b54626040a8851a54994cc00 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 15 May 2026 18:30:01 +0100 Subject: [PATCH 5/9] .NET: Add observer for OpenAIWebSearch (#5894) * Add observer for OpenAIWebSearch * Update reference in comment * Use types where possible. --- .../Observers/ToolCallDisplayObserver.cs | 4 + ...OpenAIResponsesWebSearchDisplayObserver.cs | 206 ++++++++++++++++++ .../Harness_Step01_Research/Program.cs | 16 +- 3 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 dotnet/samples/02-agents/Harness/Harness_Step01_Research/OpenAIResponsesWebSearchDisplayObserver.cs diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallDisplayObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallDisplayObserver.cs index d47ce4c636..2f4c342ac4 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallDisplayObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallDisplayObserver.cs @@ -31,6 +31,10 @@ public sealed class ToolCallDisplayObserver : ConsoleObserver { await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(this._formatters, functionCall)}...", ConsoleColor.DarkYellow); } + else if (content is WebSearchToolCallContent) + { + // Handled by OpenAIResponsesWebSearchDisplayObserver when present; skip here to avoid duplication. + } else if (content is ToolCallContent toolCall) { await ux.WriteInfoLineAsync($"🔧 Calling tool: {toolCall}...", ConsoleColor.DarkYellow); diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/OpenAIResponsesWebSearchDisplayObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/OpenAIResponsesWebSearchDisplayObserver.cs new file mode 100644 index 0000000000..f680ea6ff3 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/OpenAIResponsesWebSearchDisplayObserver.cs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft. All rights reserved. + +#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage. + +using System.Text; +using Harness.Shared.Console; +using Harness.Shared.Console.Observers; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI.Responses; + +namespace SampleApp; + +/// +/// Displays web search activity in the scroll area. Shows search queries, +/// page opens, and find-in-page actions as they stream in from the API. +/// +internal sealed class OpenAIResponsesWebSearchDisplayObserver : ConsoleObserver +{ + private const int MaxQueryDisplayLength = 120; + + /// + public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) + { + if (content is WebSearchToolResultContent resultContent + && resultContent.RawRepresentation is WebSearchCallResponseItem wscri) + { + await WriteActionAsync(ux, wscri, resultContent.Outputs); + } + } + + private static async Task WriteActionAsync(IUXStateDriver ux, WebSearchCallResponseItem wscri, IList? outputs) + { + WebSearchAction? action = wscri.Action; + if (action is null) + { + await ux.WriteInfoLineAsync("🌐 Web Search Tool (no action details)", ConsoleColor.DarkCyan); + return; + } + + switch (action) + { + case WebSearchFindInPageAction findInPage: + await WriteFindInPageAsync(ux, findInPage); + break; + + case WebSearchOpenPageAction openPage: + await WriteOpenPageAsync(ux, openPage); + break; + + case WebSearchSearchAction search: + await WriteSearchAsync(ux, search, outputs); + break; + + default: + await ux.WriteInfoLineAsync("🌐 Web Search Tool (unknown action)", ConsoleColor.DarkCyan); + break; + } + } + + private static async Task WriteSearchAsync(IUXStateDriver ux, WebSearchSearchAction search, IList? outputs) + { + // Read queries directly from the typed action. + IList queries = search.Queries; + + if (queries.Count == 0) + { + await ux.WriteInfoLineAsync("🌐 Web Search Tool: search", ConsoleColor.DarkCyan); + return; + } + + var sb = new StringBuilder(); + sb.Append("🌐 Web Search Tool: search"); + + // Show the search queries. + bool hasResults = outputs is { Count: > 0 }; + for (int i = 0; i < queries.Count; i++) + { + string connector = (i < queries.Count - 1 || hasResults) ? "├─" : "└─"; + string query = Truncate(queries[i], MaxQueryDisplayLength); + sb.Append($"\n {connector} \"{query}\""); + } + + // Show search result sources (URLs + titles) when available. + // Sources come from M.E.AI's Outputs when IncludedResponseProperty.WebSearchCallActionSources is set, + // or directly from the SDK's WebSearchSearchAction.Sources. + if (hasResults) + { + sb.Append("\n │"); + for (int i = 0; i < outputs!.Count; i++) + { + string connector = i < outputs.Count - 1 ? "├─" : "└─"; + string line = FormatOutput(outputs[i]); + sb.Append($"\n {connector} {line}"); + } + } + else if (search.Sources is { Count: > 0 } sources) + { + sb.Append("\n │"); + for (int i = 0; i < sources.Count; i++) + { + string connector = i < sources.Count - 1 ? "├─" : "└─"; + string line = FormatSource(sources[i]); + sb.Append($"\n {connector} {line}"); + } + } + + await ux.WriteInfoLineAsync(sb.ToString(), ConsoleColor.DarkCyan); + } + + private static async Task WriteOpenPageAsync(IUXStateDriver ux, WebSearchOpenPageAction openPage) + { + string url = openPage.Uri?.AbsoluteUri ?? "(unknown)"; + await ux.WriteInfoLineAsync( + $"🌐 Web Search Tool: open page\n └─ {url}", + ConsoleColor.DarkCyan); + } + + private static async Task WriteFindInPageAsync(IUXStateDriver ux, WebSearchFindInPageAction findInPage) + { + string url = findInPage.Uri?.AbsoluteUri ?? "(unknown)"; + string pattern = findInPage.Pattern ?? "(unknown)"; + + await ux.WriteInfoLineAsync( + $"🌐 Web Search Tool: find in page\n ├─ \"{Truncate(pattern, MaxQueryDisplayLength)}\"\n └─ {url}", + ConsoleColor.DarkCyan); + } + + /// + /// Formats a single search result source from the SDK's for display. + /// + private static string FormatSource(WebSearchActionSource source) + { + if (source is WebSearchActionUriSource uriSource) + { + string url = uriSource.Uri?.AbsoluteUri ?? "(unknown)"; + + // WebSearchActionUriSource doesn't expose a title property, + // but the API may include one in the raw response JSON. + string? title = GetTitleFromRawRepresentation(uriSource); + + return title is not null + ? $"{Truncate(title, MaxQueryDisplayLength)} — {url}" + : url; + } + + return source.ToString() ?? "(unknown source)"; + } + + /// + /// Formats a single search result output from M.E.AI's for display. + /// + private static string FormatOutput(AIContent output) + { + if (output is UriContent uriContent) + { + string url = uriContent.Uri?.AbsoluteUri ?? "(unknown)"; + + // Try to extract a title from the raw JSON of the source. + // The SDK's WebSearchActionUriSource doesn't expose a title property, + // but the API may include one in the raw response. + string? title = GetTitleFromRawRepresentation(uriContent.RawRepresentation) + ?? (uriContent.AdditionalProperties?.TryGetValue("title", out var t) is true ? t?.ToString() : null); + + return title is not null + ? $"{Truncate(title, MaxQueryDisplayLength)} — {url}" + : url; + } + + return output.ToString() ?? "(unknown output)"; + } + + /// + /// Attempts to extract a "title" field from a raw representation object by serializing it to JSON. + /// The SDK's doesn't expose a title property, + /// but the API may include one in the raw JSON — this is forward-compatible for when + /// the SDK adds title support. + /// + private static string? GetTitleFromRawRepresentation(object? rawRepresentation) + { + if (rawRepresentation is null) + { + return null; + } + + try + { + var data = System.ClientModel.Primitives.ModelReaderWriter.Write(rawRepresentation); + using var doc = System.Text.Json.JsonDocument.Parse(data); + if (doc.RootElement.TryGetProperty("title", out var titleEl) + && titleEl.ValueKind == System.Text.Json.JsonValueKind.String) + { + return titleEl.GetString(); + } + } + catch + { + // Serialization may not be supported for this object type. + } + + return null; + } + + private static string Truncate(string text, int maxLength) + => text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength - 1), "…"); +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs index 1c9e93588c..3b9cc83f94 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs @@ -163,12 +163,14 @@ await HarnessConsole.RunAgentAsync( userPrompt: "Enter a research topic to get started.", new HarnessConsoleOptions { - Observers = HarnessConsoleOptions.BuildObserversWithPlanning( - agent, - planModeName: "plan", - executionModeName: "execute", - maxContextWindowTokens: MaxContextWindowTokens, - maxOutputTokens: MaxOutputTokens, - toolFormatters: [new DownloadUriToolFormatter(), .. ToolCallFormatter.BuildDefaultToolFormatters()]), + Observers = [ + new OpenAIResponsesWebSearchDisplayObserver(), + .. HarnessConsoleOptions.BuildObserversWithPlanning( + agent, + planModeName: "plan", + executionModeName: "execute", + maxContextWindowTokens: MaxContextWindowTokens, + maxOutputTokens: MaxOutputTokens, + toolFormatters: [new DownloadUriToolFormatter(), .. ToolCallFormatter.BuildDefaultToolFormatters()])], CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent), }); From da308f5f1e56b2415e4c49744c9de78c850d20f3 Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Fri, 15 May 2026 10:31:57 -0700 Subject: [PATCH 6/9] Python: New Foundry Hosted Agents samples: RAG, Skills, and Memory (#5822) * WIP: Add rag sample; need deployment testing * Rag sample ready * Add Foundry Skills sample * WIP: Foundry memory * Done: Foundry Memory * Address Copilot comments * Fix README * Restore uv.loack --- .../foundry-hosted-agents/README.md | 5 +- .../08_azure_search_rag/.dockerignore | 8 + .../08_azure_search_rag/.env.example | 4 + .../responses/08_azure_search_rag/Dockerfile | 16 ++ .../responses/08_azure_search_rag/README.md | 186 ++++++++++++++++++ .../08_azure_search_rag/agent.manifest.yaml | 38 ++++ .../responses/08_azure_search_rag/agent.yaml | 16 ++ .../responses/08_azure_search_rag/main.py | 59 ++++++ .../08_azure_search_rag/provision_index.py | 119 +++++++++++ .../08_azure_search_rag/requirements.txt | 3 + .../responses/09_foundry_skills/.dockerignore | 10 + .../responses/09_foundry_skills/.env.example | 4 + .../responses/09_foundry_skills/.gitignore | 1 + .../responses/09_foundry_skills/Dockerfile | 16 ++ .../responses/09_foundry_skills/README.md | 137 +++++++++++++ .../09_foundry_skills/agent.manifest.yaml | 32 +++ .../responses/09_foundry_skills/agent.yaml | 14 ++ .../responses/09_foundry_skills/main.py | 111 +++++++++++ .../09_foundry_skills/provision_skills.py | 90 +++++++++ .../09_foundry_skills/requirements.txt | 3 + .../skills/escalation-policy/SKILL.md | 30 +++ .../skills/support-style/SKILL.md | 25 +++ .../responses/10_foundry_memory/.dockerignore | 8 + .../responses/10_foundry_memory/.env.example | 6 + .../responses/10_foundry_memory/Dockerfile | 16 ++ .../responses/10_foundry_memory/README.md | 122 ++++++++++++ .../10_foundry_memory/agent.manifest.yaml | 33 ++++ .../responses/10_foundry_memory/agent.yaml | 14 ++ .../responses/10_foundry_memory/main.py | 71 +++++++ .../provision_memory_store.py | 90 +++++++++ .../10_foundry_memory/requirements.txt | 3 + 31 files changed, 1289 insertions(+), 1 deletion(-) create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/.dockerignore create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/.env.example create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/Dockerfile create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/README.md create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/agent.manifest.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/agent.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/main.py create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/provision_index.py create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/requirements.txt create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/.dockerignore create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/.env.example create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/.gitignore create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/Dockerfile create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/README.md create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/agent.manifest.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/agent.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/main.py create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/provision_skills.py create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/requirements.txt create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/skills/escalation-policy/SKILL.md create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/skills/support-style/SKILL.md create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/.dockerignore create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/.env.example create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/Dockerfile create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/README.md create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/agent.manifest.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/agent.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/main.py create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/provision_memory_store.py create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/requirements.txt diff --git a/python/samples/04-hosting/foundry-hosted-agents/README.md b/python/samples/04-hosting/foundry-hosted-agents/README.md index 720da5e085..bb55657564 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/README.md @@ -15,7 +15,10 @@ This directory contains samples that demonstrate how to use hosted [Agent Framew | 5 | [Workflows](responses/05_workflows/) | An agent with a multi-step orchestrated workflow, demonstrating chaining prompts through an orchestrated flow. | | 6 | [Files](responses/06_files/) | An agent demonstrating how to work with files in a hosted agent session, including uploading files to a hosted agent session and having the agent read and manipulate those files at runtime. | | 7 | [Observability](responses/07_observability/) | A sample demonstrating how to enable observability for the agent deployed to Foundry. | -| 8 | [Using deployed agent](responses/using_deployed_agent.py) | A sample demonstrating how to invoke an agent that has already been deployed to Foundry, showing how to interact with a hosted agent in code. | +| 8 | [Azure AI Search RAG](responses/08_azure_search_rag/) | An agent with Retrieval Augmented Generation (RAG) capabilities backed by Azure AI Search, grounding answers in documents indexed in a pre-provisioned search index. | +| 9 | [Foundry Skills](responses/09_foundry_skills/) | An agent that uploads `SKILL.md` files to the Foundry Skills REST API and downloads them at startup, decoupling tone/policy guidelines from agent code. | +| 10 | [Foundry Memory](responses/10_foundry_memory/) | An agent with persistent semantic memory backed by an Azure AI Foundry Memory Store, using `FoundryMemoryProvider` to remember user facts across sessions. | +| 11 | [Using deployed agent](responses/using_deployed_agent.py) | A sample demonstrating how to invoke an agent that has already been deployed to Foundry, showing how to interact with a hosted agent in code. | ### Invocations API diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/.dockerignore b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/.dockerignore new file mode 100644 index 0000000000..2629367b12 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/.dockerignore @@ -0,0 +1,8 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +.env +provision_index.py diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/.env.example b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/.env.example new file mode 100644 index 0000000000..4a2d919abe --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/.env.example @@ -0,0 +1,4 @@ +FOUNDRY_PROJECT_ENDPOINT="..." +AZURE_AI_MODEL_DEPLOYMENT_NAME="..." +AZURE_SEARCH_ENDPOINT="https://.search.windows.net" +AZURE_SEARCH_INDEX_NAME="contoso-outdoors" diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/Dockerfile b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/Dockerfile new file mode 100644 index 0000000000..0cc939d9b3 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY . user_agent/ +WORKDIR /app/user_agent + +RUN if [ -f requirements.txt ]; then \ + pip install -r requirements.txt; \ + else \ + echo "No requirements.txt found"; \ + fi + +EXPOSE 8088 + +CMD ["python", "main.py"] diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/README.md new file mode 100644 index 0000000000..091a1d0d6b --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/README.md @@ -0,0 +1,186 @@ +# What this sample demonstrates + +An [Agent Framework](https://github.com/microsoft/agent-framework) agent with **Retrieval Augmented Generation (RAG)** capabilities backed by **Azure AI Search**, hosted using the **Responses protocol**. The agent grounds its answers in product documentation by running a search against an Azure AI Search index before each model invocation, then citing the source in its response. + +## How It Works + +### Model Integration + +The agent uses `FoundryChatClient` from the Agent Framework to create a Responses client from the project endpoint and model deployment. + +### RAG via Azure AI Search + +`AzureAISearchContextProvider` runs a search against the configured Azure AI Search index **before each model invocation** and injects the top results into the model context. The agent then composes a grounded answer and cites the source document. + +See [main.py](main.py) for the full implementation. + +### Agent Hosting + +The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol. + +## Prerequisites + +- An Azure AI Foundry project with a deployed model (e.g., `gpt-4.1-mini`) +- An Azure AI Search service ([create one](https://learn.microsoft.com/azure/search/search-create-service-portal)) +- **A pre-provisioned search index** with the schema and content described below +- Azure CLI logged in (`az login`) + +### Required RBAC + +Your identity (or the Managed Identity running the container in production) needs: + +- **Azure AI User** on the Foundry project scope +- **Search Index Data Reader** on the Azure AI Search service (the sample only reads from the index) + +## Provisioning the search index (one time) + +The sample assumes the search index already exists and contains documents the agent can retrieve from. Provision it once via the Azure Portal, the [REST API](https://learn.microsoft.com/azure/search/search-how-to-create-search-index), or one of the snippets below. + +### Option A: Python script (recommended) + +[`provision_index.py`](provision_index.py) creates the index (if it doesn't already exist) and seeds it with the three Contoso Outdoors documents using `DefaultAzureCredential`. Your identity needs the following roles on the **Azure AI Search service** scope: + +- **Search Service Contributor** — to create the index +- **Search Index Data Contributor** — to upload documents + +> Note: `Search Service Contributor` only covers control-plane operations (create/list/delete indexes). It does **not** grant document write access — `Search Index Data Contributor` is required for that even if you already have `Search Service Contributor`. + +Grant the roles to your signed-in user (replace `` and ``): + +```powershell +$searchId = az search service show -n -g --query id -o tsv +$me = az ad signed-in-user show --query id -o tsv + +az role assignment create --assignee $me --role "Search Service Contributor" --scope $searchId +az role assignment create --assignee $me --role "Search Index Data Contributor" --scope $searchId +``` + +Role propagation typically takes 1–5 minutes. Also confirm the search service has RBAC enabled (Portal → search service → **Keys** → **API Access control** → "Both" or "Role-based access control"); if it is set to "API Key" only, every AAD request returns `403 Forbidden`. + +Then, from this directory: + +```bash +export AZURE_SEARCH_ENDPOINT="https://.search.windows.net" +export AZURE_SEARCH_INDEX_NAME="contoso-outdoors" +python provision_index.py +``` + +Or in PowerShell: + +```powershell +$env:AZURE_SEARCH_ENDPOINT="https://.search.windows.net" +$env:AZURE_SEARCH_INDEX_NAME="contoso-outdoors" +python provision_index.py +``` + +The script is safe to re-run: if the index already exists, it leaves the schema untouched and merges-or-uploads the documents. To change the schema, delete the index first (Azure AI Search does not allow modifying existing field attributes) and re-run the script. + +### Index schema + +| Field | Type | Attributes | +|---|---|---| +| `id` | `Edm.String` | key, filterable | +| `content` | `Edm.String` | searchable (full-text) | +| `sourceName` | `Edm.String` | retrievable, filterable | +| `sourceLink` | `Edm.String` | retrievable | + +### Option B: Azure CLI + REST + +```bash +SEARCH_ENDPOINT="https://.search.windows.net" +INDEX_NAME="contoso-outdoors" +TOKEN=$(az account get-access-token --resource https://search.azure.com --query accessToken -o tsv) + +# 1. Create the index. +curl -X PUT "$SEARCH_ENDPOINT/indexes/$INDEX_NAME?api-version=2024-07-01" \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{ + "name": "contoso-outdoors", + "fields": [ + { "name": "id", "type": "Edm.String", "key": true, "filterable": true, "searchable": false, "retrievable": true }, + { "name": "content", "type": "Edm.String", "key": false, "filterable": false, "searchable": true, "retrievable": true, "analyzer": "standard.lucene" }, + { "name": "sourceName", "type": "Edm.String", "key": false, "filterable": true, "searchable": false, "retrievable": true }, + { "name": "sourceLink", "type": "Edm.String", "key": false, "filterable": false, "searchable": false, "retrievable": true } + ] + }' + +# 2. Upload three Contoso Outdoors documents matching the queries below. +curl -X POST "$SEARCH_ENDPOINT/indexes/$INDEX_NAME/docs/index?api-version=2024-07-01" \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{ + "value": [ + { "@search.action": "mergeOrUpload", "id": "return-policy", "sourceName": "Contoso Outdoors Return Policy", "sourceLink": "https://contoso.com/policies/returns", "content": "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection. As a thank you, every accepted return ships back with a complimentary Contoso TrailRunner sticker pack, item code TR-CANARY-7821." }, + { "@search.action": "mergeOrUpload", "id": "shipping-guide", "sourceName": "Contoso Outdoors Shipping Guide", "sourceLink": "https://contoso.com/help/shipping", "content": "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout. Use promo code SHIP-CANARY-4493 at checkout for a one-time free overnight upgrade on your first order." }, + { "@search.action": "mergeOrUpload", "id": "tent-care", "sourceName": "TrailRunner Tent Care Instructions", "sourceLink": "https://contoso.com/manuals/trailrunner-tent", "content": "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating. Replacement waterproofing kits are stocked under SKU TENT-CANARY-9067." } + ] + }' +``` + +You can also point the sample at any existing index that exposes a retrievable text field such as `content`. + +## Running the Agent Host + +Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host. + +In addition to the standard environment variables, this sample requires: + +```bash +export AZURE_SEARCH_ENDPOINT="https://.search.windows.net" +export AZURE_SEARCH_INDEX_NAME="contoso-outdoors" +``` + +Or in PowerShell: + +```powershell +$env:AZURE_SEARCH_ENDPOINT="https://.search.windows.net" +$env:AZURE_SEARCH_INDEX_NAME="contoso-outdoors" +``` + +You can also place these in a `.env` file next to `main.py` — see [`.env.example`](.env.example). + +## Interacting with the agent + +> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Use this README for sample queries you can send to the agent. + +Send a POST request to the server with a JSON body containing an `"input"` field to interact with the agent. For example: + +```bash +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "What is your return policy?"}' +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "How long does shipping take?"}' +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "How do I clean my tent?"}' +``` + +Or with `azd`: + +```bash +azd ai agent invoke --local "What is your return policy?" +``` + +## How RAG works in this sample + +`AzureAISearchContextProvider` runs a search against the configured Azure AI Search index **before each model invocation**. When the index is seeded with the three Contoso Outdoors documents from the provisioning section above: + +| User query mentions | Search result injected | +|---|---| +| "return", "refund" | Contoso Outdoors Return Policy (canary token: `TR-CANARY-7821`) | +| "shipping", "promo" | Contoso Outdoors Shipping Guide (canary token: `SHIP-CANARY-4493`) | +| "tent", "fabric" | TrailRunner Tent Care Instructions (canary token: `TENT-CANARY-9067`) | + +The model receives the top three search results as additional context and cites the source in its response. Each seeded document includes a unique `*-CANARY-*` token that does not exist in any model training data, so you can prove an answer was grounded in retrieved content (not fabricated from training) by asking for the canary and checking it appears in the response. + +Replace the seed documents (or point the sample at an existing index with your own content) to ground the agent in your own knowledge base. + +## Deploying the Agent to Foundry + +To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory. + +When deploying, make sure `AZURE_SEARCH_ENDPOINT` and `AZURE_SEARCH_INDEX_NAME` are set in your `azd` environment so they get injected into the hosted container per [`agent.manifest.yaml`](agent.manifest.yaml): + +```bash +azd env set AZURE_SEARCH_ENDPOINT "https://.search.windows.net" +azd env set AZURE_SEARCH_INDEX_NAME "contoso-outdoors" +``` + +If these are not set, running `azd ai agent init -m ` will prompt you to enter them interactively. + +The deployed agent's Managed Identity needs **Search Index Data Reader** on the Azure AI Search service. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/agent.manifest.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/agent.manifest.yaml new file mode 100644 index 0000000000..0d8eb419a9 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/agent.manifest.yaml @@ -0,0 +1,38 @@ +name: agent-framework-agent-azure-search-rag-responses +description: > + An Agent Framework agent with Retrieval Augmented Generation (RAG) capabilities + backed by Azure AI Search. Uses AzureAISearchContextProvider to ground answers + in product documentation indexed in Azure AI Search before each model invocation. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - RAG + - Azure AI Search +template: + name: agent-framework-agent-azure-search-rag-responses + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + - name: AZURE_SEARCH_ENDPOINT + value: "{{AZURE_SEARCH_ENDPOINT}}" + - name: AZURE_SEARCH_INDEX_NAME + value: "{{AZURE_SEARCH_INDEX_NAME}}" +parameters: + properties: + - name: AZURE_SEARCH_ENDPOINT + secret: false + description: The endpoint of the Azure AI Search service to use for RAG (e.g., https://my-search-service.search.windows.net) + - name: AZURE_SEARCH_INDEX_NAME + secret: false + description: The name of the Azure AI Search index to use for RAG (e.g., contoso-outdoors) +resources: + - kind: model + id: gpt-4.1-mini + name: AZURE_AI_MODEL_DEPLOYMENT_NAME diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/agent.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/agent.yaml new file mode 100644 index 0000000000..c6d58b23de --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/agent.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: agent-framework-agent-azure-search-rag-responses +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: "0.5Gi" +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: AZURE_SEARCH_ENDPOINT + value: ${AZURE_SEARCH_ENDPOINT} + - name: AZURE_SEARCH_INDEX_NAME + value: ${AZURE_SEARCH_INDEX_NAME} diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/main.py new file mode 100644 index 0000000000..73639ef8a4 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/main.py @@ -0,0 +1,59 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +from agent_framework import Agent +from agent_framework.azure import AzureAISearchContextProvider +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.identity import DefaultAzureCredential +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + + +async def main(): + credential = DefaultAzureCredential() + + # Connect to a pre-provisioned Azure AI Search index. The index is expected to + # exist and contain documents with the schema described in README.md + # (id / content / sourceName / sourceLink). The context provider runs a search + # against this index before each model invocation and injects the matching + # documents into the model context. + search_provider = AzureAISearchContextProvider( + source_id="azure_search_rag", + endpoint=os.environ["AZURE_SEARCH_ENDPOINT"], + index_name=os.environ["AZURE_SEARCH_INDEX_NAME"], + credential=credential, + mode="semantic", + top_k=3, + ) + + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=credential, + ) + + async with search_provider: + agent = Agent( + client=client, + instructions=( + "You are a helpful support specialist for Contoso Outdoors. " + "Answer questions using the provided context and cite the source " + "document when available." + ), + context_providers=[search_provider], + # History will be managed by the hosting infrastructure, thus there + # is no need to store history by the service. Learn more at: + # https://developers.openai.com/api/reference/resources/responses/methods/create + default_options={"store": False}, + ) + server = ResponsesHostServer(agent) + await server.run_async() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/provision_index.py b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/provision_index.py new file mode 100644 index 0000000000..596c6ecd90 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/provision_index.py @@ -0,0 +1,119 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Provision the Azure AI Search index used by this sample. + +Creates (or updates) the ``contoso-outdoors`` index with the schema documented +in README.md and seeds it with three Contoso Outdoors documents containing +unique canary tokens. Safe to re-run; existing documents are merged-or-uploaded. + +Usage (from this directory, with the venv activated and ``az login`` done): + + python provision_index.py + +Required env vars (also read from a local ``.env`` file if present): + + AZURE_SEARCH_ENDPOINT e.g. https://.search.windows.net + AZURE_SEARCH_INDEX_NAME e.g. contoso-outdoors + +Your identity needs ``Search Service Contributor`` (to create the index) and +``Search Index Data Contributor`` (to upload documents) on the search service. +""" + +import asyncio +import os + +from azure.core.exceptions import ResourceNotFoundError +from azure.identity.aio import DefaultAzureCredential +from azure.search.documents.aio import SearchClient +from azure.search.documents.indexes.aio import SearchIndexClient +from azure.search.documents.indexes.models import ( + SearchableField, # pyright: ignore[reportUnknownVariableType] + SearchFieldDataType, + SearchIndex, + SimpleField, # pyright: ignore[reportUnknownVariableType] +) +from dotenv import load_dotenv + +DOCUMENTS: list[dict[str, str]] = [ + { + "id": "return-policy", + "sourceName": "Contoso Outdoors Return Policy", + "sourceLink": "https://contoso.com/policies/returns", + "content": ( + "Customers may return any item within 30 days of delivery. Items should be " + "unused and include original packaging. Refunds are issued to the original " + "payment method within 5 business days of inspection. As a thank you, every " + "accepted return ships back with a complimentary Contoso TrailRunner sticker " + "pack, item code TR-CANARY-7821." + ), + }, + { + "id": "shipping-guide", + "sourceName": "Contoso Outdoors Shipping Guide", + "sourceLink": "https://contoso.com/help/shipping", + "content": ( + "Standard shipping is free on orders over $50 and typically arrives in 3-5 " + "business days within the continental United States. Expedited options are " + "available at checkout. Use promo code SHIP-CANARY-4493 at checkout for a " + "one-time free overnight upgrade on your first order." + ), + }, + { + "id": "tent-care", + "sourceName": "TrailRunner Tent Care Instructions", + "sourceLink": "https://contoso.com/manuals/trailrunner-tent", + "content": ( + "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow " + "it to air dry completely before storage and avoid prolonged UV exposure to " + "extend the lifespan of the waterproof coating. Replacement waterproofing " + "kits are stocked under SKU TENT-CANARY-9067." + ), + }, +] + + +def build_index(name: str) -> SearchIndex: + return SearchIndex( + name=name, + fields=[ + SimpleField(name="id", type=SearchFieldDataType.String, key=True, filterable=True), + SearchableField(name="content", type=SearchFieldDataType.String, analyzer_name="standard.lucene"), + SimpleField(name="sourceName", type=SearchFieldDataType.String, filterable=True, retrievable=True), + SimpleField(name="sourceLink", type=SearchFieldDataType.String, retrievable=True), + ], + ) + + +async def main() -> None: + load_dotenv() + + endpoint = os.environ["AZURE_SEARCH_ENDPOINT"] + index_name = os.environ["AZURE_SEARCH_INDEX_NAME"] + + async with ( + DefaultAzureCredential() as credential, + SearchIndexClient(endpoint=endpoint, credential=credential) as index_client, + SearchClient(endpoint=endpoint, index_name=index_name, credential=credential) as search_client, + ): + index = build_index(index_name) + try: + await index_client.get_index(index_name) + print( + f"Index '{index_name}' already exists; leaving schema as-is " + "(delete the index manually to change the schema)." + ) + except ResourceNotFoundError: + print(f"Creating index '{index_name}'...") + await index_client.create_index(index) + + print(f"Uploading {len(DOCUMENTS)} document(s)...") + results = await search_client.merge_or_upload_documents(documents=DOCUMENTS) # type: ignore[arg-type] + failed = [(r.key, r.error_message) for r in results if not r.succeeded] + if failed: + raise RuntimeError(f"Failed to upload documents: {failed}") + + print("Done.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/requirements.txt b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/requirements.txt new file mode 100644 index 0000000000..9a77cae319 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/08_azure_search_rag/requirements.txt @@ -0,0 +1,3 @@ +agent-framework +agent-framework-azure-ai-search +agent-framework-foundry-hosting diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/.dockerignore b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/.dockerignore new file mode 100644 index 0000000000..d7a4f0d7fa --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/.dockerignore @@ -0,0 +1,10 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +.env +provision_skills.py +skills +downloaded_skills diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/.env.example b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/.env.example new file mode 100644 index 0000000000..379c3edd05 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/.env.example @@ -0,0 +1,4 @@ +FOUNDRY_PROJECT_ENDPOINT="..." +AZURE_AI_MODEL_DEPLOYMENT_NAME="..." +# Comma-separated list of Foundry skill names to download at startup. +SKILL_NAMES="support-style,escalation-policy" diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/.gitignore b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/.gitignore new file mode 100644 index 0000000000..ae8a1dfbe8 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/.gitignore @@ -0,0 +1 @@ +downloaded_skills/ diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/Dockerfile b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/Dockerfile new file mode 100644 index 0000000000..0cc939d9b3 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY . user_agent/ +WORKDIR /app/user_agent + +RUN if [ -f requirements.txt ]; then \ + pip install -r requirements.txt; \ + else \ + echo "No requirements.txt found"; \ + fi + +EXPOSE 8088 + +CMD ["python", "main.py"] diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/README.md new file mode 100644 index 0000000000..0831efdb83 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/README.md @@ -0,0 +1,137 @@ +# What this sample demonstrates + +An [Agent Framework](https://github.com/microsoft/agent-framework) agent that loads its behavioral guidelines from [**Foundry Skills**](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/skills?view=foundry&pivots=python) at startup, hosted using the **Responses protocol**. Skills are authored once as `SKILL.md` files, uploaded to your Foundry project through `AIProjectClient.beta.skills`, and downloaded by the agent on boot so updates ship without code changes. + +## How It Works + +### Authoring skills + +Each skill is a Markdown file with a YAML front matter block. This sample ships two source skills under [`skills/`](skills/): + +| Skill | Purpose | +|---|---| +| [`support-style`](skills/support-style/SKILL.md) | Voice, formatting, and signature rules for Contoso Outdoors support replies. | +| [`escalation-policy`](skills/escalation-policy/SKILL.md) | When and how to escalate a customer ticket. | + +Each `SKILL.md` includes a unique `*-CANARY-*` token that the model is asked to echo, so you can prove the skill was loaded from Foundry (not hallucinated) by checking the response. + +> The `name` and `description` values in the YAML front matter must be **unquoted** — quoting them causes the Skills REST API to return HTTP 500 on import. + +### Uploading skills with `AIProjectClient` + +[`provision_skills.py`](provision_skills.py) walks `skills/*/SKILL.md`, packages each file as an in-memory ZIP (with `SKILL.md` at the archive root), and imports it through [`AIProjectClient.beta.skills.create_from_package`](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/skills?view=foundry&pivots=python#option-2-import-from-a-skillmd-zip). The client is constructed with `allow_preview=True` (Skills is a preview feature) and authenticates with `DefaultAzureCredential`. Existing skills are deleted first via `beta.skills.delete` so the script is safe to re-run after editing a `SKILL.md`, and `beta.skills.list` is called at the end to verify each skill round-trips. + +### Downloading skills at agent startup + +[`main.py`](main.py) reads the comma-separated `SKILL_NAMES` env var, opens an `AIProjectClient` (also with `allow_preview=True`), and for each skill name streams the ZIP archive from `beta.skills.download(name)` and unpacks it into a **separate runtime directory** at `downloaded_skills//` (kept distinct from the static `skills/` source folder so the two never get confused — `skills/` is the input to `provision_skills.py`, `downloaded_skills/` is the output of `main.py`'s bootstrap step). + +A [`SkillsProvider`](../../../../../packages/core/agent_framework/_skills.py) is then built over `downloaded_skills/` and attached to the `Agent` as a context provider. The provider follows the [Agent Skills](https://agentskills.io/) progressive-disclosure pattern: + +1. **Advertise** — skill names and descriptions are injected into the system prompt at session start (~100 tokens per skill). +2. **Load** — the model calls the `load_skill` tool when it decides a skill is relevant to the user's turn, and the full `SKILL.md` body is returned. + +This means the model only pays the token cost for a skill's full body when it actually needs it, and updating a skill in Foundry + restarting the agent is enough to pick up the change — no code redeploy required. + +### Agent Hosting + +The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol. + +## Prerequisites + +- An Azure AI Foundry project with a deployed model (e.g., `gpt-4.1-mini`) +- Azure CLI logged in (`az login`) + +### Required RBAC + +Your identity (or the Managed Identity running the container in production) needs **Azure AI User** on the Foundry project scope. This single role covers both authoring skills with `provision_skills.py` and downloading them from `main.py`. + +## Provisioning the skills (one time) + +From this directory, with the venv activated and `az login` done: + +```bash +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +python provision_skills.py +``` + +Or in PowerShell: + +```powershell +$env:FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +python provision_skills.py +``` + +Expected output: + +```text +Provisioning skill 'escalation-policy' from skills/escalation-policy/SKILL.md... + Imported skill 'escalation-policy' (id=skill_..., has_blob=True). +Provisioning skill 'support-style' from skills/support-style/SKILL.md... + Imported skill 'support-style' (id=skill_..., has_blob=True). +Done. +``` + +Re-running the script after editing a `SKILL.md` re-imports the skill, replacing the previous version. + +> To remove a skill manually, call `project.beta.skills.delete("")` on an `AIProjectClient` constructed with `allow_preview=True`. + +## Running the Agent Host + +Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host. + +In addition to the standard environment variables, this sample requires: + +```bash +export SKILL_NAMES="support-style,escalation-policy" +``` + +Or in PowerShell: + +```powershell +$env:SKILL_NAMES="support-style,escalation-policy" +``` + +You can also place these in a `.env` file next to `main.py` — see [`.env.example`](.env.example). + +On startup you should see: + +```text +Downloading skill 'support-style' from Foundry... +Downloading skill 'escalation-policy' from Foundry... +``` + +The downloaded `SKILL.md` files land under `downloaded_skills//SKILL.md` next to `main.py`. This directory is recreated from scratch on every run, so deleting it manually is never necessary. + +## Interacting with the agent + +> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Use this README for sample queries you can send to the agent. + +Send a POST request to the server with a JSON body containing an `"input"` field to interact with the agent. For example: + +```bash +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Hi, I am Alex. I just want to confirm I can return my tent within 30 days."}' +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "I want a $750 refund on Order #A-1042 right now or I am calling my lawyer."}' +``` + +| Prompt mentions | Skill that should drive the response | +|---|---| +| Routine return / shipping / care question | Model loads `support-style` (canary `STYLE-CANARY-3318`) — no escalation. | +| Injury, legal threat, press, or refund > $500 | Model loads `escalation-policy` (canary `ESC-CANARY-7742`) **and** `support-style`. | + +Because skills are loaded on demand, the canary token in a response also proves the model actually invoked `load_skill` for the matching skill (not just saw its name in the advertised list). + +## Deploying the Agent to Foundry + +To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory. + +When deploying, make sure `SKILL_NAMES` is set in your `azd` environment so it gets injected into the hosted container per [`agent.manifest.yaml`](agent.manifest.yaml): + +```bash +azd env set SKILL_NAMES "support-style,escalation-policy" +``` + +If it is not set, running `azd ai agent init -m ` will prompt you to enter it interactively. + +The deployed agent's Managed Identity needs **Azure AI User** on the Foundry project to download skills at startup. Make sure you have run `provision_skills.py` against the same Foundry project before deploying — otherwise the agent will fail to start with HTTP 404 on the skill download. + +> The `skills/` source folder is **not** deployed to Foundry — only the downloaded skills are used at runtime. The `provision_skills.py` step is required to upload the skills to Foundry before the agent can download them. \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/agent.manifest.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/agent.manifest.yaml new file mode 100644 index 0000000000..eedd8a17c6 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/agent.manifest.yaml @@ -0,0 +1,32 @@ +name: agent-framework-agent-foundry-skills-responses +description: > + An Agent Framework agent that downloads its instructions from the Foundry + Skills REST API at startup, demonstrating how to decouple behavioral + guidelines (tone, escalation policy, etc.) from agent code. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Foundry Skills +template: + name: agent-framework-agent-foundry-skills-responses + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + - name: SKILL_NAMES + value: "{{SKILL_NAMES}}" +parameters: + properties: + - name: SKILL_NAMES + secret: false + description: Comma-separated list of Foundry skill names to download at startup (e.g., support-style,escalation-policy) +resources: + - kind: model + id: gpt-4.1-mini + name: AZURE_AI_MODEL_DEPLOYMENT_NAME diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/agent.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/agent.yaml new file mode 100644 index 0000000000..c56d14ac40 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/agent.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: agent-framework-agent-foundry-skills-responses +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: "0.5Gi" +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: SKILL_NAMES + value: ${SKILL_NAMES} diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/main.py new file mode 100644 index 0000000000..5cac1493bb --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/main.py @@ -0,0 +1,111 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Foundry Skills hosted agent sample. + +At startup, this agent downloads each Foundry Skill named in +``SKILL_NAMES`` from the project's ``beta.skills`` API, unpacks each +one into a separate runtime directory under ``downloaded_skills/``, and wires +that directory into a :class:`SkillsProvider` so the agent advertises the +skills to the model and loads them on demand (progressive disclosure). + +Upload the skills to Foundry once with ``provision_skills.py`` before running +this sample. +""" + +import asyncio +import io +import logging +import os +import shutil +import zipfile +from pathlib import Path +from typing import Final + +from agent_framework import Agent, SkillsProvider +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.ai.projects.aio import AIProjectClient +from azure.identity.aio import DefaultAzureCredential +from dotenv import load_dotenv + +load_dotenv() + +# Runtime directory where skills downloaded from Foundry are unpacked. +# Kept separate from the static ``skills/`` source folder so the two never +# get confused: the source folder is the input to ``provision_skills.py`` +# and the runtime folder is the output of this script's bootstrap step. +DOWNLOADED_SKILLS_DIR: Final = Path(__file__).parent / "downloaded_skills" + +logger = logging.getLogger(__name__) + + +def _safe_extract_zip(zf: zipfile.ZipFile, dest_dir: Path) -> None: + """Extract ``zf`` into ``dest_dir``, rejecting entries that escape it (zip-slip guard).""" + dest_root = dest_dir.resolve() + for member in zf.infolist(): + member_path = (dest_root / member.filename).resolve() + if dest_root != member_path and dest_root not in member_path.parents: + raise RuntimeError(f"Refusing to extract unsafe path '{member.filename}' outside of '{dest_root}'.") + zf.extractall(dest_dir) + + +async def _bootstrap_skills(endpoint: str, skill_names: list[str], target_dir: Path) -> None: + """Download each named skill via ``project.beta.skills`` and unpack it as ``//SKILL.md``.""" + if target_dir.exists(): # noqa: ASYNC240 + shutil.rmtree(target_dir) + target_dir.mkdir(parents=True) # noqa: ASYNC240 + + async with ( + DefaultAzureCredential() as credential, + AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project, + ): + for name in skill_names: + logger.info(f"Downloading skill '{name}' from Foundry...") + stream = await project.beta.skills.download(name) + zip_bytes = b"".join([chunk async for chunk in stream]) + skill_dir = target_dir / name + skill_dir.mkdir() + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + _safe_extract_zip(zf, skill_dir) + if not (skill_dir / "SKILL.md").is_file(): + raise RuntimeError(f"Downloaded archive for '{name}' did not contain a SKILL.md at the root.") + + +async def main() -> None: + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + skill_names = [name.strip() for name in os.environ["SKILL_NAMES"].split(",") if name.strip()] + if not skill_names: + raise RuntimeError("SKILL_NAMES must list at least one skill name.") + + # Pull the latest copy of each skill from Foundry into a runtime-only folder. + await _bootstrap_skills(project_endpoint, skill_names, DOWNLOADED_SKILLS_DIR) + + # Build a SkillsProvider over the unpacked folder. The provider advertises + # each skill's name + description to the model and exposes the ``load_skill`` + # tool the model uses to retrieve the full SKILL.md body on demand. No + # script_runner is configured because the skills in this sample are + # instruction-only. + skills_provider = SkillsProvider.from_paths(skill_paths=str(DOWNLOADED_SKILLS_DIR)) + + async with DefaultAzureCredential() as credential: + client = FoundryChatClient( + project_endpoint=project_endpoint, + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=credential, + ) + + agent = Agent( + client=client, + instructions="You are a customer-support assistant for Contoso Outdoors.", + context_providers=[skills_provider], + # History will be managed by the hosting infrastructure, thus there + # is no need to store history by the service. Learn more at: + # https://developers.openai.com/api/reference/resources/responses/methods/create + default_options={"store": False}, + ) + server = ResponsesHostServer(agent) + await server.run_async() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/provision_skills.py b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/provision_skills.py new file mode 100644 index 0000000000..c8eda282da --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/provision_skills.py @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Provision Foundry Skills used by this sample. + +For each ``skills//SKILL.md`` file in this directory, this script packages +the file as an in-memory ZIP and imports it through the Foundry project's +:class:`~azure.ai.projects.aio.AIProjectClient` so the skill becomes downloadable +by any hosted agent in the project. + +If a skill with the same name already exists in Foundry, it is deleted first +so the script is safe to re-run after editing a ``SKILL.md`` file. + +Usage (from this directory, with the venv activated and ``az login`` done): + + python provision_skills.py + +Required env vars (also read from a local ``.env`` file if present): + + FOUNDRY_PROJECT_ENDPOINT e.g. https://.services.ai.azure.com/api/projects/ + +Your identity needs the ``Azure AI User`` role on the Foundry project. +""" + +import asyncio +import io +import os +import zipfile +from pathlib import Path + +from azure.ai.projects.aio import AIProjectClient +from azure.core.exceptions import ResourceNotFoundError +from azure.identity.aio import DefaultAzureCredential +from dotenv import load_dotenv + +SKILLS_DIR = Path(__file__).parent / "skills" + + +def _zip_skill_md(skill_md: Path) -> bytes: + """Return the bytes of a ZIP archive containing ``SKILL.md`` at the root.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr("SKILL.md", skill_md.read_text(encoding="utf-8")) + return buffer.getvalue() + + +async def _delete_skill_if_exists(project: AIProjectClient, name: str) -> None: + try: + await project.beta.skills.delete(name) + except ResourceNotFoundError: + return + print(f" Deleted existing skill '{name}'.") + + +async def main() -> None: + load_dotenv() + + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + + skill_files = sorted(SKILLS_DIR.glob("*/SKILL.md")) + if not skill_files: + raise RuntimeError(f"No SKILL.md files found under {SKILLS_DIR}.") + + async with ( + DefaultAzureCredential() as credential, + AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project, + ): + for skill_md in skill_files: + name = skill_md.parent.name + print(f"Provisioning skill '{name}' from {skill_md.relative_to(SKILLS_DIR.parent)}...") + await _delete_skill_if_exists(project, name) + imported = await project.beta.skills.create_from_package(_zip_skill_md(skill_md)) + print(f" Imported skill '{imported.name}' (id={imported.skill_id}, has_blob={imported.has_blob}).") + + print("Verifying skills via project.beta.skills.list()...") + listed = {skill.name: skill async for skill in project.beta.skills.list()} + for skill_md in skill_files: + name = skill_md.parent.name + skill = listed.get(name) + if skill is None: + raise RuntimeError(f"Skill '{name}' was imported but is not present in the project listing.") + print( + f" OK '{skill.name}': id={skill.skill_id}, " + f"description={skill.description!r}, has_blob={skill.has_blob}" + ) + + print("Done.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/requirements.txt b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/requirements.txt new file mode 100644 index 0000000000..8bd2801eff --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/requirements.txt @@ -0,0 +1,3 @@ +agent-framework +agent-framework-foundry-hosting +azure-ai-projects diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/skills/escalation-policy/SKILL.md b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/skills/escalation-policy/SKILL.md new file mode 100644 index 0000000000..ec04c9aa86 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/skills/escalation-policy/SKILL.md @@ -0,0 +1,30 @@ +--- +name: escalation-policy +description: When and how to escalate Contoso Outdoors customer-support tickets. +--- + +# Contoso Outdoors Escalation Policy + +You must follow this escalation policy on every conversation. + +## Escalate immediately when the customer + +- Reports an injury, allergic reaction, or other safety incident. +- Mentions legal action, regulators, or the press. +- Has waited more than 14 days for a refund that was already approved. +- Requests a refund larger than $500. + +## How to escalate + +1. Acknowledge the issue in one sentence. +2. Tell the customer you are escalating to a senior specialist. +3. Provide the escalation reference `ESC-CANARY-7742` and the SLA: a senior + specialist will reply within 1 business day. +4. Do not promise a specific outcome (refund, replacement, compensation) on + escalated tickets — only the senior specialist can commit to one. + +## Do not escalate + +- Routine returns within the standard 30-day window. +- Shipping status questions. +- Product care and usage questions. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/skills/support-style/SKILL.md b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/skills/support-style/SKILL.md new file mode 100644 index 0000000000..a516811a48 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/09_foundry_skills/skills/support-style/SKILL.md @@ -0,0 +1,25 @@ +--- +name: support-style +description: Contoso Outdoors customer-support tone and formatting guidelines. +--- + +# Contoso Outdoors Support Style + +You are speaking on behalf of Contoso Outdoors customer support. + +## Voice + +- Warm, concise, and confident — never apologetic in a hand-wringing way. +- Use the customer's name when it is known. +- Sign every response with `— Contoso Outdoors Support`. + +## Formatting + +- Keep replies to 1–3 short paragraphs unless the customer asks for detail. +- Use bullet lists only when enumerating concrete steps or options. +- Always reference order numbers as `Order #` (e.g. `Order #A-1042`). + +## Canary + +To prove this skill was loaded, include the token `STYLE-CANARY-3318` on a +separate line at the bottom of every response, prefixed with `# `. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/.dockerignore b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/.dockerignore new file mode 100644 index 0000000000..0848068228 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/.dockerignore @@ -0,0 +1,8 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +.env +provision_memory_store.py diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/.env.example b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/.env.example new file mode 100644 index 0000000000..7ac02ecb2e --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/.env.example @@ -0,0 +1,6 @@ +FOUNDRY_PROJECT_ENDPOINT="..." +AZURE_AI_MODEL_DEPLOYMENT_NAME="..." +# Embedding model deployment (only needed by provision_memory_store.py). +AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME="text-embedding-3-small" +# Name of the Foundry Memory Store the agent should read/write to. +MEMORY_STORE_NAME="agent_framework_memory" diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/Dockerfile b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/Dockerfile new file mode 100644 index 0000000000..0cc939d9b3 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY . user_agent/ +WORKDIR /app/user_agent + +RUN if [ -f requirements.txt ]; then \ + pip install -r requirements.txt; \ + else \ + echo "No requirements.txt found"; \ + fi + +EXPOSE 8088 + +CMD ["python", "main.py"] diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/README.md new file mode 100644 index 0000000000..8659521885 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/README.md @@ -0,0 +1,122 @@ +# What this sample demonstrates + +An [Agent Framework](https://github.com/microsoft/agent-framework) agent with persistent semantic memory backed by an **Azure AI Foundry Memory Store**, hosted using the **Responses protocol**. The agent remembers facts the user has shared (e.g., dietary preferences, name) across sessions by retrieving and updating memories around every model invocation via `FoundryMemoryProvider`. + +## How It Works + +### Model Integration + +The agent uses `FoundryChatClient` from the Agent Framework to create a Responses client from the project endpoint and model deployment. `allow_preview=True` is passed so the same `AIProjectClient` can also call the preview `beta.memory_stores` API. + +### Memory via Foundry Memory Store + +`FoundryMemoryProvider` is wired into the agent as a context provider. Around each model invocation it: + +1. **Retrieves user-profile memories** for the configured `scope` (e.g., user id) on the first turn of a session. +2. **Searches for contextual memories** matching the current user message and injects them into the model context. +3. **Updates the store** with new facts inferred from the conversation. + +Crucially, the provider is constructed with `project_client=client.project_client` — i.e. it reuses the `AIProjectClient` that `FoundryChatClient` already created, instead of allocating a second one. This keeps a single authentication context and connection pool for both chat and memory operations. + +See [main.py](main.py) for the full implementation. + +### Agent Hosting + +The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol. + +## Prerequisites + +- An Azure AI Foundry project with: + - A deployed chat model (e.g., `gpt-4.1-mini`) + - A deployed embedding model (e.g., `text-embedding-3-small`) — used by the memory store itself, not by the agent at runtime +- Azure CLI logged in (`az login`) + +### Required RBAC + +Your identity (or the Managed Identity running the container in production) needs **Azure AI User** on the Foundry project scope. This single role covers both provisioning the memory store with `provision_memory_store.py` and reading/writing memories from `main.py`. + +## Provisioning the memory store (one time) + +[`provision_memory_store.py`](provision_memory_store.py) creates a Foundry Memory Store with the user-profile capability enabled (and chat-summary disabled) using `AIProjectClient.beta.memory_stores.create`. It is safe to re-run: if a store with the same name already exists, the script leaves it alone. + +From this directory, with the venv activated and `az login` done: + +```bash +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4.1-mini" +export AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME="text-embedding-3-small" +export MEMORY_STORE_NAME="agent_framework_memory" +python provision_memory_store.py +``` + +Or in PowerShell: + +```powershell +$env:FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4.1-mini" +$env:AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME="text-embedding-3-small" +$env:MEMORY_STORE_NAME="agent_framework_memory" +python provision_memory_store.py +``` + +Expected output (first run): + +```text +Creating memory store 'agent_framework_memory'... +Created memory store 'agent_framework_memory' (id=memstore_...). +``` + +> To delete the store manually, call `project.beta.memory_stores.delete("")` on an `AIProjectClient` constructed with `allow_preview=True`. + +## Running the Agent Host + +Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host. + +In addition to the standard environment variables, this sample requires: + +```bash +export MEMORY_STORE_NAME="agent_framework_memory" +``` + +Or in PowerShell: + +```powershell +$env:MEMORY_STORE_NAME="agent_framework_memory" +``` + +You can also place these in a `.env` file next to `main.py` — see [`.env.example`](.env.example). + +## Interacting with the agent + +> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. + +Send a POST request to the server with a JSON body containing an `"input"` field to interact with the agent. The first request seeds a memory; subsequent requests (especially in new sessions) should be able to recall it because memories are persisted across Foundry Hosted Agents sessions. + +> In this sample, the memory is scoped to the user by specifying `scope="{{$userId}}"`, thus memories are isolated across different users but shared across different sessions from the same user. + +```bash +# 1. Tell the agent something to remember. +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" \ + -d '{"input": "I prefer dark roast coffee and I am allergic to nuts."}' + +# Wait a few seconds for the memory to be stored, then start a fresh conversation: +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" \ + -d '{"input": "Can you recommend a coffee and a snack for me?"}' + +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" \ + -d '{"input": "What do you remember about my preferences?"}' +``` + +## Deploying the Agent to Foundry + +To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory. + +When deploying, make sure `MEMORY_STORE_NAME` and `FOUNDRY_MEMORY_SCOPE` are set in your `azd` environment so they get injected into the hosted container per [`agent.manifest.yaml`](agent.manifest.yaml): + +```bash +azd env set MEMORY_STORE_NAME "agent_framework_memory" +``` + +If these are not set, running `azd ai agent init -m ` will prompt you to enter them interactively. + +The deployed agent's Managed Identity needs **Azure AI User** on the Foundry project to read and write memories at runtime. Make sure you have run `provision_memory_store.py` against the same Foundry project before deploying — otherwise the agent will fail on the first turn when it tries to read from a non-existent store. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/agent.manifest.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/agent.manifest.yaml new file mode 100644 index 0000000000..42979d092d --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/agent.manifest.yaml @@ -0,0 +1,33 @@ +name: agent-framework-agent-foundry-memory-responses +description: > + An Agent Framework agent with persistent semantic memory backed by an + Azure AI Foundry Memory Store. Uses FoundryMemoryProvider to retrieve and + store memories around each model invocation, allowing the agent to remember + facts about a user across sessions. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Foundry Memory +template: + name: agent-framework-agent-foundry-memory-responses + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + - name: MEMORY_STORE_NAME + value: "{{MEMORY_STORE_NAME}}" +parameters: + properties: + - name: MEMORY_STORE_NAME + secret: false + description: The name of the pre-provisioned Foundry Memory Store the agent will use (e.g., agent_framework_memory) +resources: + - kind: model + id: gpt-4.1-mini + name: AZURE_AI_MODEL_DEPLOYMENT_NAME diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/agent.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/agent.yaml new file mode 100644 index 0000000000..502a4c7904 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/agent.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: agent-framework-agent-foundry-memory-responses +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: "0.5Gi" +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: MEMORY_STORE_NAME + value: ${MEMORY_STORE_NAME} diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/main.py new file mode 100644 index 0000000000..43ce3a4467 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/main.py @@ -0,0 +1,71 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Foundry Memory hosted agent sample. + +This agent uses :class:`FoundryMemoryProvider` to give an otherwise stateless +hosted agent persistent, semantic memory backed by an Azure AI Foundry +Memory Store. The store itself is provisioned once via +``provision_memory_store.py`` and its name is passed in through the +``MEMORY_STORE_NAME`` environment variable. + +Unlike the standalone ``azure_ai_foundry_memory.py`` sample, here we construct +the :class:`FoundryChatClient` first and then reuse its underlying +``AIProjectClient`` for the memory provider, so both share a single client +instance and authentication context. +""" + +import asyncio +import os + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient, FoundryMemoryProvider +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.identity.aio import DefaultAzureCredential +from dotenv import load_dotenv + +load_dotenv() + + +async def main() -> None: + # The chat client owns the AIProjectClient. ``allow_preview=True`` is required + # so the same client can call the preview ``beta.memory_stores`` API used by + # FoundryMemoryProvider. + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=DefaultAzureCredential(), + allow_preview=True, + ) + + # Reuse the project_client that FoundryChatClient just created, instead of + # constructing a second one for the memory provider. + memory_provider = FoundryMemoryProvider( + project_client=client.project_client, + memory_store_name=os.environ["MEMORY_STORE_NAME"], + # Scope memories by user id, so each user that interacts with the agent + # has their own isolated memories in the store (assuming those users are + # granted access). `{{userId}}` is a special placeholder that the hosting + # infrastructure will replace with the actual user id at runtime. + scope="{{$userId}}", + ) + + agent = Agent( + client=client, + instructions=( + "You are a helpful assistant that remembers facts the user has shared " + "across conversations. Relevant memories from previous interactions are " + "automatically provided to you in the system context. Use them when " + "answering, and acknowledge when you are relying on remembered facts." + ), + context_providers=[memory_provider], + # History will be managed by the hosting infrastructure, thus there + # is no need to store history by the service. Learn more at: + # https://developers.openai.com/api/reference/resources/responses/methods/create + default_options={"store": False}, + ) + server = ResponsesHostServer(agent) + await server.run_async() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/provision_memory_store.py b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/provision_memory_store.py new file mode 100644 index 0000000000..fc402800ab --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/provision_memory_store.py @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Provision the Azure AI Foundry Memory Store used by this sample. + +Creates the memory store named by ``MEMORY_STORE_NAME`` if it does not +already exist. The store is configured with the user-profile capability so the +agent can remember stable facts about a user across sessions; chat-summary is +disabled to keep the demo focused on durable preferences. Safe to re-run: if a +store with the same name already exists, the script leaves it alone. + +Usage (from this directory, with the venv activated and ``az login`` done): + + python provision_memory_store.py + +Required env vars (also read from a local ``.env`` file if present): + + FOUNDRY_PROJECT_ENDPOINT e.g. https://.services.ai.azure.com/api/projects/ + AZURE_AI_MODEL_DEPLOYMENT_NAME Chat model deployment used by the memory store + AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME Embedding model deployment used by the memory store + MEMORY_STORE_NAME Name of the memory store to create + +Your identity needs ``Azure AI User`` on the Foundry project scope. +""" + +import asyncio +import os + +from azure.ai.projects.aio import AIProjectClient +from azure.ai.projects.models import ( + MemoryStoreDefaultDefinition, + MemoryStoreDefaultOptions, +) +from azure.core.exceptions import ResourceNotFoundError +from azure.identity.aio import DefaultAzureCredential +from dotenv import load_dotenv + +load_dotenv() + + +async def main() -> None: + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + memory_store_name = os.environ["MEMORY_STORE_NAME"] + chat_model = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + embedding_model = os.environ["AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME"] + + async with ( + DefaultAzureCredential() as credential, + AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project, + ): + try: + existing = await project.beta.memory_stores.get(name=memory_store_name) + print(f"Memory store '{existing.name}' already exists (id={existing.id}); leaving as-is.") + return + except ResourceNotFoundError: + pass + + print(f"Creating memory store '{memory_store_name}'...") + definition = MemoryStoreDefaultDefinition( + chat_model=chat_model, + embedding_model=embedding_model, + options=MemoryStoreDefaultOptions( + chat_summary_enabled=False, + user_profile_enabled=True, + user_profile_details=( + "Avoid irrelevant or sensitive data, such as age, financials, precise location, and credentials" + ), + ), + ) + created = await project.beta.memory_stores.create( + name=memory_store_name, + description="Memory store for the Agent Framework foundry-hosted memory sample", + definition=definition, + ) + print(f"Created memory store '{created.name}' (id={created.id}).") + + # Verify the store actually exists on the service by reading it back. + # ``create`` returns the requested definition, but a follow-up ``get`` + # confirms the store is persisted and reachable for the agent at runtime. + try: + verified = await project.beta.memory_stores.get(name=memory_store_name) + except ResourceNotFoundError as exc: + raise RuntimeError( + f"Memory store '{memory_store_name}' was not found after creation; " + "the service may not have persisted it." + ) from exc + print(f"Verified memory store '{verified.name}' is available on the service (id={verified.id}).") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/requirements.txt b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/requirements.txt new file mode 100644 index 0000000000..8bd2801eff --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/10_foundry_memory/requirements.txt @@ -0,0 +1,3 @@ +agent-framework +agent-framework-foundry-hosting +azure-ai-projects From a60e541c9ac53e9cd944986cafd5b044d8d004d0 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sat, 16 May 2026 05:52:25 +0800 Subject: [PATCH 7/9] .NET: fix: avoid AGUI tool result message id collisions (#5800) * fix: avoid AGUI tool result message id collisions * fix: split mixed tool result message ids --- .../ChatResponseUpdateAGUIExtensions.cs | 18 +++- .../AGUIStreamingMessageIdTests.cs | 87 +++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs index ad8435842b..144a560f7f 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs @@ -458,8 +458,9 @@ internal static class ChatResponseUpdateAGUIExtensions // This ensures all AGUI events have a valid messageId regardless of agent type. if (string.IsNullOrWhiteSpace(chatResponse.MessageId)) { - streamingMessageId ??= Guid.NewGuid().ToString("N"); - chatResponse.MessageId = streamingMessageId; + chatResponse.MessageId = ContainsToolResult(chatResponse) + ? Guid.NewGuid().ToString("N") + : (streamingMessageId ??= Guid.NewGuid().ToString("N")); } if (chatResponse is { Contents.Count: > 0 } && @@ -725,4 +726,17 @@ internal static class ChatResponseUpdateAGUIExtensions _ => JsonSerializer.Serialize(functionResultContent.Result, options.GetTypeInfo(functionResultContent.Result.GetType())), }; } + + private static bool ContainsToolResult(ChatResponseUpdate chatResponse) + { + foreach (AIContent content in chatResponse.Contents) + { + if (content is FunctionResultContent) + { + return true; + } + } + + return false; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs index 5c55408ff8..502e23d81c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs @@ -149,6 +149,93 @@ public sealed class AGUIStreamingMessageIdTests "ParentMessageId should have a generated fallback for empty provider MessageId"); } + /// + /// Tool results are separate tool-role messages, so their fallback IDs must not + /// collide with the assistant message that requested the tool call. + /// + [Fact] + public async Task ToolResults_NullMessageId_GeneratesDistinctMessageIdAsync() + { + FunctionCallContent functionCall = new("call_abc123", "GetWeather") + { + Arguments = new Dictionary { ["location"] = "San Francisco" } + }; + + List providerUpdates = + [ + new ChatResponseUpdate(ChatRole.Assistant, "Checking the weather"), + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [functionCall] + }, + new ChatResponseUpdate(ChatRole.Tool, [new FunctionResultContent("call_abc123", "72F and sunny")]) + ]; + + List aguiEvents = []; + await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync() + .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) + { + aguiEvents.Add(evt); + } + + TextMessageStartEvent textStart = Assert.Single(aguiEvents.OfType()); + ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType()); + ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType()); + + Assert.Equal(textStart.MessageId, toolCallStart.ParentMessageId); + Assert.Equal("call_abc123", toolCallResult.ToolCallId); + Assert.False(string.IsNullOrEmpty(toolCallResult.MessageId)); + Assert.NotEqual(textStart.MessageId, toolCallResult.MessageId); + } + + [Fact] + public async Task ToolResults_WithTextContent_GeneratesDistinctMessageIdAsync() + { + FunctionCallContent functionCall = new("call_abc123", "GetWeather") + { + Arguments = new Dictionary { ["location"] = "San Francisco" } + }; + + List providerUpdates = + [ + new ChatResponseUpdate(ChatRole.Assistant, "Checking the weather"), + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [functionCall] + }, + new ChatResponseUpdate + { + Role = ChatRole.Tool, + Contents = + [ + new TextContent("Tool says: "), + new FunctionResultContent("call_abc123", "72F and sunny") + ] + } + ]; + + List aguiEvents = []; + await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync() + .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) + { + aguiEvents.Add(evt); + } + + TextMessageStartEvent[] textStarts = aguiEvents.OfType().ToArray(); + TextMessageContentEvent toolText = Assert.Single( + aguiEvents.OfType(), + content => content.Delta == "Tool says: "); + ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType()); + ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType()); + + Assert.Equal(textStarts[0].MessageId, toolCallStart.ParentMessageId); + Assert.NotEqual(textStarts[0].MessageId, toolCallResult.MessageId); + Assert.Equal(toolCallResult.MessageId, toolText.MessageId); + Assert.Equal(textStarts[^1].MessageId, toolCallResult.MessageId); + } + /// /// When a provider properly sets MessageId (e.g., OpenAI), the AGUI pipeline /// produces valid events with correct messageId values. From ddc0fcf81fb3e058f766fdaf1da8209845aa7a4c Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 18 May 2026 11:07:16 +0100 Subject: [PATCH 8/9] .NET: Adding default providers and tools to HarnessAgent (#5896) * Adding default providers and tools to HarnessAgent * Address PR comments * Add further comments to clarify certain setings. * Apply suggestion from @SergeyMenshykh Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> --------- Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> --- .../Harness_Step01_Research/Program.cs | 102 +-- .../Program.cs | 26 +- .../Harness_Step03_DataProcessing.csproj | 2 +- .../Harness_Step03_DataProcessing/Program.cs | 23 +- .../Harness_Step03_DataProcessing/README.md | 8 +- .../{data => working}/sales.csv | 0 .../HarnessAgent.cs | 142 +++- .../HarnessAgentOptions.cs | 151 +++- .../Harness/AgentMode/AgentModeProvider.cs | 51 +- .../Harness/FileMemory/FileMemoryProvider.cs | 2 +- .../Harness/Todo/TodoProvider.cs | 4 +- .../HarnessAgentOptionsTests.cs | 48 ++ .../HarnessAgentTests.cs | 783 ++++++++++++++++-- 13 files changed, 1154 insertions(+), 188 deletions(-) rename dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/{data => working}/sales.csv (100%) diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs index 3b9cc83f94..8c6b679500 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs @@ -1,8 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample demonstrates how to use a HarnessAgent with the Harness AIContextProviders -// (TodoProvider and AgentModeProvider) for interactive research tasks with web search -// capabilities powered by Azure AI Foundry. +// This sample demonstrates how to use a HarnessAgent for interactive research tasks. +// The HarnessAgent comes pre-configured with TodoProvider, AgentModeProvider, FileMemoryProvider, +// ToolApproval, WebSearch, and OpenTelemetry — so this sample only needs custom instructions +// and a WebBrowsingTool. // The agent plans research tasks, creates a todo list, gets user approval, // and then executes each step — all within an interactive conversation loop. // @@ -34,86 +35,32 @@ const int MaxOutputTokens = 128_000; // and research-focused instructions including the mandatory planning workflow. var 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. - ## Mandatory planning workflow - - For every new substantive user request, including short factual questions, your behavior is determined by the mode you are in. - If you are in plan mode, start with the *Plan Mode* steps, and if you are in execute mode, skip directly to the *Execute Mode* steps below. - - *Plan Mode* - - 1. Analyze the request with the purpose of building a research plan. - 2. Create a list of todo items. - 3. If needed, use the provided tools to do some exploratory checks to help build a plan and determine what clarifying questions you may need from the user. - 4. Ask for clarifications from the user where needed. - 1. Ask each clarification one by one. - 2. When asking for clarification and you have specific options in mind, present them to the user, so they can choose the option instead of having to retype the entire response. - 3. Do not proceed until you have received all the needed clarifications. - 4. Do short exploratory research if it helps with being able to ask sensible clarifications from the user. - 5. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes. - 6. Present the plan to the user and ask for approval to switch to execute mode and process the plan. - 7. When approval is granted, always switch to execute mode (using the `AgentMode_Set` tool), and follow the steps for *Execute mode*. - - *Execute Mode* - - 1. If you don't have a plan or tasks yet, analyse the user request and create tasks and a plan. (**Skip this step if you came from plan mode**) - 2. Work autonomously — use your best judgement to make decisions and keep progressing without asking the user questions. The goal is to have a complete, useful result ready when the user returns. - 3. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable option, note your choice, and keep going. - 4. Mark tasks as completed as you finish them. - 5. Continue working, thinking and calling tools until you have the research result for the user. - - ## General Instructions - - - You must check the current mode after any user input, since the user may have changed the mode themselves, - e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, meaning they want to review a plan first before execution. - - 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. - - Do not answer the underlying question before the plan has been presented and approved. - - This rule applies even when the answer seems obvious or the task seems small. - - For short requests, use a brief micro-plan rather than skipping planning. The only exceptions are: - - greetings, - - pure acknowledgments, - - clarification questions needed to form the plan, - - follow-up questions about results you have already presented, - - meta-discussion about the workflow itself. - - **Todo management** - - Mark each todo complete as you finish it so the list stays current. - If a todo turns out to be unnecessary or is blocked, remove it and briefly explain why. - Once the user finishes with a topic and moves onto a new one, clean up old completed todos by deleting them. - - **Research quality** + ### 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** + ### 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. - - Save the final research report to file memory so it survives compaction and can be referenced later. - - **File memory** - - Use the FileMemory_* tools to: - - Store downloaded search results or web pages. - - Store plans. - - Read the current plan to make sure tasks were done according to plan. - - Store findings. - - Check for relevant previously downloaded data / findings before starting new research. + - 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. """; // Create the agent using AsHarnessAgent, which pre-configures function invocation, -// per-service-call chat history persistence, and in-loop compaction. -// Then wrap with UseToolApproval to allow auto-approving tools once confirmed. +// per-service-call chat history persistence, in-loop compaction, TodoProvider, AgentModeProvider, +// FileMemoryProvider, ToolApproval, WebSearch, AgentSkillsProvider, and OpenTelemetry. +// Only custom instructions, a WebBrowsingTool, and FileAccess opt-out are needed. AIAgent agent = // Create an OpenAIClient that communicates with the Foundry responses service. new OpenAIClient( @@ -127,35 +74,26 @@ AIAgent agent = RetryPolicy = new ClientRetryPolicy(3) // Enable retries to improve resiliency. }) .GetResponsesClient() - .AsIChatClientWithStoredOutputDisabled(deploymentName) // We want to manage chat history locally (not stored in the responses service), so that we can manage compaction ourselves. + .AsIChatClientWithStoredOutputDisabled(deploymentName) // We want to manage chat history locally (not stored in the responses service), so that we can manage compaction ourselves. .AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions { Name = "ResearchAgent", Description = "A research assistant that plans and executes research tasks.", - AIContextProviders = - [ - new TodoProvider(), // Add an AIContextProvider to allow the agent to create a TODO list, which is stored in the session. - new AgentModeProvider(), // Add an AIContextProvider that tracks the agent mode and allows switching mode. Current mode is stored in the session. - new FileMemoryProvider( // Add an AIContextProvider that can store memories in files under a session specific working folder. - new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")), - (_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() }) - ], + DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session + FileMemoryStore = new FileSystemAgentFileStore( // Configure the file memory provider to store files in a local folder called "agent-files". + Path.Combine(AppContext.BaseDirectory, "agent-files")), ChatOptions = new ChatOptions { Instructions = instructions, Tools = [ - ResponseTool.CreateWebSearchTool().AsAITool(), // Add the foundry hosted web search tool that runs in the service. - new WebBrowsingTool( // Add a local web browsing tool that converts html to markdown. + new WebBrowsingTool( // Add a local web browsing tool that converts html to markdown. new WebBrowsingToolOptions { AllowPublicNetworks = true }), ], - MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs. + MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs. Reasoning = new() { Effort = ReasoningEffort.Medium }, }, - }) - .AsBuilder() - .UseToolApproval() // Add the ability to auto approve tools once a user has said they don't want to be asked again. Approval rules are tied to the session. - .Build(); + }); // Run the interactive console session using the shared HarnessConsole helper. await HarnessConsole.RunAgentAsync( diff --git a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs index 721da3339c..bb4c50e0d7 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs @@ -2,8 +2,9 @@ // This sample demonstrates how to use the SubAgentsProvider to delegate work to sub-agents. // A parent agent is given a list of stock tickers and instructed to find the closing price -// for each ticker on December 31, 2025. It delegates the web searches to a sub-agent -// equipped with Foundry's hosted web search tool. +// for each ticker on December 31, 2025. It delegates the web searches to a sub-agent. +// The HarnessAgent provides built-in WebSearch (HostedWebSearchTool) so no manual web search +// tool configuration is needed on the sub-agent. // // Special commands: // /exit — End the session. @@ -26,7 +27,8 @@ const int MaxContextWindowTokens = 1_050_000; const int MaxOutputTokens = 128_000; // --- Sub-agent: Web Search Agent --- -// This agent can search the web and is used by the parent agent to look up stock prices. +// This agent uses the HarnessAgent's built-in HostedWebSearchTool to search the web. +// Features not needed by this sub-agent are disabled. AIAgent webSearchAgent = new OpenAIClient( new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), @@ -41,13 +43,14 @@ AIAgent webSearchAgent = { Name = "WebSearchAgent", Description = "An agent that can search the web to find information.", + DisableTodoProvider = true, + DisableAgentModeProvider = true, + DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session + DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory + DisableToolApproval = true, // If enabled, this allows don't-ask-again approval functionality. ChatOptions = new ChatOptions { Instructions = "You are a web search assistant. When asked to find information, use the web search tool to look it up and return a concise, factual answer.", - Tools = - [ - ResponseTool.CreateWebSearchTool().AsAITool(), - ], }, }); @@ -75,6 +78,9 @@ var parentInstructions = - Present results in a clean markdown table format. """; +// --- Parent agent: Stock Price Researcher --- +// This agent orchestrates the sub-agent to look up stock prices in parallel. +// Most features are disabled since the parent only needs SubAgentsProvider. AIAgent parentAgent = new OpenAIClient( new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), @@ -89,6 +95,12 @@ AIAgent parentAgent = { Name = "StockPriceResearcher", Description = "An agent that researches stock prices using sub-agents.", + DisableTodoProvider = true, + DisableAgentModeProvider = true, + DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session + DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory + DisableToolApproval = true, // If enabled, this allows don't-ask-again approval functionality. + DisableWebSearch = true, AIContextProviders = [ new SubAgentsProvider([webSearchAgent]), diff --git a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj index 2d2a47d6be..c65a7552a6 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj +++ b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj @@ -19,7 +19,7 @@ - + diff --git a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs index b1b5bc5f2d..6b77e31f15 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs @@ -1,10 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample demonstrates how to use a HarnessAgent with the FileAccessProvider +// This sample demonstrates how to use a HarnessAgent with the default FileAccessProvider // to give an agent access to a folder of CSV data files. The agent can read, analyze, // and extract information from the data, then write results back as new files. // -// The sample includes a pre-populated `data/` folder with sales transaction data. +// The sample includes a pre-populated `working/` folder with sales transaction data. +// The HarnessAgent's default FileAccessProvider uses `{cwd}/working` as its working directory, +// which matches this sample's folder layout. // Ask the agent to analyze the data, produce summaries, or create new output files. // // Special commands: @@ -27,10 +29,6 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYME const int MaxContextWindowTokens = 1_050_000; const int MaxOutputTokens = 128_000; -// Point the file store at the data/ folder that ships with the sample. -var dataFolder = Path.Combine(AppContext.BaseDirectory, "data"); -var fileStore = new FileSystemAgentFileStore(dataFolder); - var instructions = """ You are a data analyst assistant. You have access to a folder of data files via the FileAccess_* tools. @@ -56,7 +54,9 @@ var instructions = - Always 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. """; -// Create the chat client from the OpenAI provider. +// Create the agent using AsHarnessAgent. The FileAccessStore is explicitly set to the +// sample's working/ folder (copied to the output directory) so it works regardless of cwd. +// Unused features are disabled. AIAgent agent = new OpenAIClient( new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), @@ -71,10 +71,11 @@ AIAgent agent = { Name = "DataAnalyst", Description = "A data analyst assistant that reads, analyzes, and processes data files.", - AIContextProviders = - [ - new FileAccessProvider(fileStore), - ], + FileAccessStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "working")), + DisableTodoProvider = true, + DisableAgentModeProvider = true, + DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session + DisableWebSearch = true, ChatOptions = new ChatOptions { Instructions = instructions, diff --git a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md index eb61ba9654..a9d6cba384 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md +++ b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md @@ -1,11 +1,11 @@ # What this sample demonstrates -This sample demonstrates how to use a `HarnessAgent` with the `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, and in-loop compaction — so the sample only needs to supply the chat client, token limits, and application-specific options. +This sample demonstrates how to use a `HarnessAgent` with the default `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, in-loop compaction, tool approval, and OpenTelemetry — so the sample only needs to supply the chat client, token limits, custom instructions, and opt out of unused features. Key features showcased: - **HarnessAgent** — a pre-configured agent that wraps a `ChatClientAgent` with function invocation, per-service-call persistence, and context-window compaction -- **FileAccessProvider** — gives the agent tools to read, write, list, search, and delete files in a shared data folder +- **FileAccessProvider** — the HarnessAgent's default file access provider uses `{cwd}/working` as its working directory, matching this sample's `working/` folder - **CSV data processing** — the agent reads sales transaction data and performs analysis on demand - **Output file creation** — the agent can write summaries, filtered data, or reports back to the data folder - **Streaming output** — responses are streamed token-by-token for a natural experience @@ -39,7 +39,7 @@ dotnet run --project samples/02-agents/Harness/Harness_Step03_DataProcessing ## What to Expect -The sample starts an interactive conversation with a data analyst agent. The `data/` folder contains a `sales.csv` file with ~50 rows of sales transaction data (date, product, category, quantity, unit price, region, salesperson). +The sample starts an interactive conversation with a data analyst agent. The `working/` folder contains a `sales.csv` file with ~50 rows of sales transaction data (date, product, category, quantity, unit price, region, salesperson). You can ask the agent to: @@ -53,7 +53,7 @@ E.g. try the following prompt `Please process the sales.csv file by first filter ## Sample Data -The included `data/sales.csv` contains sales transactions from January to March 2025 with the following columns: +The included `working/sales.csv` contains sales transactions from January to March 2025 with the following columns: | Column | Description | | --- | --- | diff --git a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/data/sales.csv b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/working/sales.csv similarity index 100% rename from dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/data/sales.csv rename to dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/working/sales.csv diff --git a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs index c22adca090..a08511e882 100644 --- a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.IO; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; @@ -10,7 +13,8 @@ namespace Microsoft.Agents.AI; /// /// A pre-configured that wraps a with -/// function invocation, per-service-call chat history persistence, and in-loop compaction. +/// function invocation, per-service-call chat history persistence, in-loop compaction, and a rich set +/// of default context providers and agent decorators. /// /// /// @@ -23,6 +27,27 @@ namespace Microsoft.Agents.AI; /// /// /// +/// By default, the following context providers are included (each can be disabled via ): +/// +/// — todo list management. +/// — agent mode tracking (plan/execute). +/// — file-based session memory. +/// — shared file access. +/// — skill discovery and loading. +/// +/// +/// +/// The agent is also wrapped with the following decorators by default (each can be disabled): +/// +/// — "don't ask again" tool approval rules. +/// — OpenTelemetry instrumentation. +/// +/// +/// +/// A is added to the chat options by default (can be disabled via +/// ). +/// +/// /// The underlying is configured with /// and /// set to @@ -48,7 +73,9 @@ public sealed class HarnessAgent : DelegatingAIAgent - 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 between tool calls so the user can follow your progress. + - 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. """; @@ -74,15 +101,15 @@ public sealed class HarnessAgent : DelegatingAIAgent /// additional context providers, and chat history provider. /// When , the agent uses built-in default settings. /// - /// + /// /// is . /// - /// + /// /// is not positive, or /// is negative or greater than or equal to . /// public HarnessAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options = null) - : base(BuildInnerAgent( + : base(BuildAgent( Throw.IfNull(chatClient), maxContextWindowTokens, maxOutputTokens, @@ -90,6 +117,25 @@ public sealed class HarnessAgent : DelegatingAIAgent { } + private static AIAgent BuildAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options) + { + ChatClientAgent innerAgent = BuildInnerAgent(chatClient, maxContextWindowTokens, maxOutputTokens, options); + + AIAgentBuilder builder = innerAgent.AsBuilder(); + + if (options?.DisableToolApproval is not true) + { + builder.UseToolApproval(); + } + + if (options?.DisableOpenTelemetry is not true) + { + builder.UseOpenTelemetry(); + } + + return builder.Build(); + } + private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options) { var compactionStrategy = new ContextWindowCompactionStrategy( @@ -102,15 +148,28 @@ public sealed class HarnessAgent : DelegatingAIAgent ChatReducer = compactionStrategy.AsChatReducer(), }); - string instructions = options?.ChatOptions?.Instructions ?? DefaultInstructions; + string harnessInstructions = options?.HarnessInstructions ?? DefaultInstructions; + string? agentInstructions = options?.ChatOptions?.Instructions; - ChatOptions chatOptions = BuildChatOptions(options?.ChatOptions, instructions, maxOutputTokens); + string instructions = (string.IsNullOrWhiteSpace(harnessInstructions), string.IsNullOrWhiteSpace(agentInstructions)) switch + { + (true, true) => harnessInstructions, + (true, false) => agentInstructions!, + (false, true) => harnessInstructions, + (false, false) => $"{harnessInstructions}\n\n{agentInstructions}", + }; + + ChatOptions chatOptions = BuildChatOptions(options, instructions, maxOutputTokens); var compactionProvider = new CompactionProvider(compactionStrategy); + IEnumerable contextProviders = BuildContextProviders(options); + return chatClient .AsBuilder() - .UseFunctionInvocation() + .UseFunctionInvocation(configure: options?.MaximumIterationsPerRequest is int maxIterations + ? ficc => ficc.MaximumIterationsPerRequest = maxIterations + : null) .UseMessageInjection() .UsePerServiceCallChatHistoryPersistence() .UseAIContextProviders(compactionProvider) @@ -121,17 +180,78 @@ public sealed class HarnessAgent : DelegatingAIAgent Description = options?.Description, ChatOptions = chatOptions, ChatHistoryProvider = chatHistoryProvider, - AIContextProviders = options?.AIContextProviders, + AIContextProviders = contextProviders, UseProvidedChatClientAsIs = true, RequirePerServiceCallChatHistoryPersistence = true, }); } - private static ChatOptions BuildChatOptions(ChatOptions? source, string instructions, int maxOutputTokens) + private static ChatOptions BuildChatOptions(HarnessAgentOptions? options, string instructions, int maxOutputTokens) { - ChatOptions result = source?.Clone() ?? new ChatOptions(); + ChatOptions result = options?.ChatOptions?.Clone() ?? new ChatOptions(); result.Instructions = instructions; result.MaxOutputTokens ??= maxOutputTokens; + + if (options?.DisableWebSearch is not true) + { + result.Tools ??= []; + result.Tools.Add(new HostedWebSearchTool()); + } + return result; } + + private static List BuildContextProviders(HarnessAgentOptions? options) + { + var providers = new List(); + + if (options?.DisableTodoProvider is not true) + { + providers.Add(new TodoProvider()); + } + + if (options?.DisableAgentModeProvider is not true) + { + providers.Add(new AgentModeProvider(options?.AgentModeProviderOptions)); + } + + if (options?.DisableFileMemory is not true) + { + AgentFileStore fileMemoryStore = options?.FileMemoryStore + ?? new FileSystemAgentFileStore( + Path.Combine(Directory.GetCurrentDirectory(), "agent-file-memory")); + + providers.Add(new FileMemoryProvider( + fileMemoryStore, + _ => new FileMemoryState + { + WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString(), + })); + } + + if (options?.DisableFileAccess is not true) + { + AgentFileStore fileAccessStore = options?.FileAccessStore + ?? new FileSystemAgentFileStore( + Path.Combine(Directory.GetCurrentDirectory(), "working")); + + providers.Add(new FileAccessProvider(fileAccessStore)); + } + + if (options?.DisableAgentSkillsProvider is not true) + { + AgentSkillsProvider skillsProvider = options?.AgentSkillsSource is AgentSkillsSource source + ? new AgentSkillsProvider(source) + : new AgentSkillsProvider(Directory.GetCurrentDirectory()); + + providers.Add(skillsProvider); + } + + if (options?.AIContextProviders is IEnumerable userProviders) + { + providers.AddRange(userProviders); + } + + return providers; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgentOptions.cs index 38856484c3..117f7f380e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgentOptions.cs @@ -36,13 +36,31 @@ public sealed class HarnessAgentOptions /// Use to supply additional tools the agent can invoke. /// /// - /// Use to override the 's built-in - /// default instructions. When is or not set, - /// the default instructions are used. + /// Use to provide agent-specific instructions (e.g., research methodology, + /// data analysis workflow). These are combined with to form the final instructions + /// sent to the model: harness instructions appear first, followed by agent-specific instructions. + /// When is , only + /// (or the default) is used. /// /// public ChatOptions? ChatOptions { get; set; } + /// + /// Gets or sets the harness-level instructions that control general tool usage and behavior patterns. + /// + /// + /// + /// Harness instructions provide guidance on how to use tools, explain reasoning, and structure work. + /// They are combined with . (agent-specific instructions) + /// to produce the final instructions sent to the model: harness instructions first, then agent-specific instructions. + /// + /// + /// When (the default), is used. + /// Set to to omit harness instructions entirely. + /// + /// + public string? HarnessInstructions { get; set; } + /// /// Gets or sets the to use for storing chat history. /// @@ -61,4 +79,131 @@ public sealed class HarnessAgentOptions /// . /// public IEnumerable? AIContextProviders { get; set; } + + /// + /// Gets or sets the maximum number of function-invocation loop iterations per request. + /// + /// + /// When set, this value is passed to . + /// When , the default is used. + /// + public int? MaximumIterationsPerRequest { get; set; } + + /// + /// Gets or sets a value indicating whether the wrapper is disabled. + /// + /// + /// When (the default), the agent is wrapped with tool approval middleware + /// that supports "don't ask again" auto-approval rules. + /// + public bool DisableToolApproval { get; set; } + + /// + /// Gets or sets a value indicating whether the is disabled. + /// + /// + /// When (the default), a is included in the + /// agent's context providers, using either or a default + /// rooted at {cwd}/agent-file-memory/{timestamp}_{guid}. + /// + public bool DisableFileMemory { get; set; } + + /// + /// Gets or sets a custom for the . + /// + /// + /// When and is , + /// a default is created. + /// This property is ignored when is . + /// + public AgentFileStore? FileMemoryStore { get; set; } + + /// + /// Gets or sets a value indicating whether the is disabled. + /// + /// + /// When (the default), a is included in the + /// agent's context providers, using either or a default + /// rooted at {cwd}/working. + /// + public bool DisableFileAccess { get; set; } + + /// + /// Gets or sets a custom for the . + /// + /// + /// When and is , + /// a default is created. + /// This property is ignored when is . + /// + public AgentFileStore? FileAccessStore { get; set; } + + /// + /// Gets or sets a value indicating whether the is disabled. + /// + /// + /// When (the default), a is added + /// to .. + /// + public bool DisableWebSearch { get; set; } + + /// + /// Gets or sets a value indicating whether the is disabled. + /// + /// + /// When (the default), a is included + /// in the agent's context providers for tracking work items. + /// + public bool DisableTodoProvider { get; set; } + + /// + /// Gets or sets a value indicating whether the is disabled. + /// + /// + /// When (the default), an is included + /// in the agent's context providers. Use to configure + /// custom modes. + /// + public bool DisableAgentModeProvider { get; set; } + + /// + /// Gets or sets custom options for the . + /// + /// + /// When , the uses its built-in default + /// modes ("plan" and "execute"). This property is ignored when + /// is . + /// + public AgentModeProviderOptions? AgentModeProviderOptions { get; set; } + + /// + /// Gets or sets a value indicating whether the is disabled. + /// + /// + /// When (the default), an is included + /// in the agent's context providers. Use to provide a custom + /// skills source; otherwise, the provider defaults to file-based skill discovery from the current + /// working directory. + /// + public bool DisableAgentSkillsProvider { get; set; } + + /// + /// Gets or sets a custom for the . + /// + /// + /// When and is , + /// the provider defaults to file-based skill discovery from the current working directory. + /// This property is ignored when is . + /// + public AgentSkillsSource? AgentSkillsSource { get; set; } + + /// + /// Gets or sets a value indicating whether the wrapper is disabled. + /// + /// + /// When (the default), the agent is wrapped with an + /// that provides OpenTelemetry instrumentation + /// following the Semantic Conventions for Generative AI systems. + /// + public bool DisableOpenTelemetry { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs index 714f52c577..a7f4aca286 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs @@ -45,20 +45,54 @@ public sealed class AgentModeProvider : AIContextProvider """ ## Agent Mode - You can operate in different modes. Depending on the mode you are in, you will be required to follow different processes. + - You can operate in different modes. Depending on the mode you are in, you will be required to follow different processes. + - You must check the current mode after any user input, since the user may have changed the mode themselves, + e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, meaning they want to review a plan first before execution. Use the AgentMode_Get tool to check your current operating mode. Use the AgentMode_Set tool to switch between modes as your work progresses. Only use AgentMode_Set if the user explicitly instructs/allows you to change modes. - {available_modes} - You are currently operating in the {current_mode} mode. + + ### Mandatory Mode based Workflow + + For every new substantive user request, including short factual questions, your behavior is determined by the mode you are in. + + {available_modes} """; private static readonly IReadOnlyList s_defaultModes = [ - new("plan", "Use this mode when analyzing requirements, breaking down tasks, and creating plans. This is the interactive mode — ask clarifying questions, discuss options, and get user approval before proceeding."), - new("execute", "Use this mode when carrying out approved plans. Work autonomously using your best judgement — do not ask the user questions or wait for feedback. Make reasonable decisions on your own so that there is a complete, useful result when the user returns. If you encounter ambiguity, choose the most reasonable option and note your choice."), + new( + "plan", + """ + Use this mode when analyzing requirements, breaking down tasks, and creating plans. This is the interactive mode — ask clarifying questions, discuss options, and get user approval before proceeding. + + Process to follow when in plan mode: + 1. Analyze the request with the purpose of building a research plan. + 2. Create a list of todo items. + 3. If needed, use the provided tools to do some exploratory checks to help build a plan and determine what clarifying questions you may need from the user. + 4. Ask for clarifications from the user where needed. + 1. Ask each clarification one by one. + 2. When asking for clarification and you have specific options in mind, present them to the user, so they can choose the option instead of having to retype the entire response. + 3. Do not proceed until you have received all the needed clarifications. + 4. Do short exploratory research if it helps with being able to ask sensible clarifications from the user. + 5. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes. + 6. Present the plan to the user and ask for approval to switch to execute mode and process the plan. + 7. When approval is granted, always switch to execute mode (using the `AgentMode_Set` tool), and follow the steps for *Execute mode*. + """), + new( + "execute", + """ + Use this mode when carrying out approved plans. Work autonomously using your best judgment — do not ask the user questions or wait for feedback. + + Process to follow when in execute mode: + 1. If you don't have a plan or tasks yet, analyze the user request and create tasks and a plan. (**Skip this step if you came from plan mode**) + 2. Work autonomously — use your best judgment to make decisions and keep progressing without asking the user questions. The goal is to have a complete, useful result ready when the user returns. + 3. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable option, note your choice, and keep going. + 4. Mark tasks as completed as you finish them. + 5. Continue working, thinking and calling tools until you have the research result for the user. + """), ]; private readonly ProviderSessionState _sessionState; @@ -187,12 +221,15 @@ public sealed class AgentModeProvider : AIContextProvider private string BuildInstructions(string currentMode) { - // Build list of modes text: var modesListBuilder = new StringBuilder(); foreach (var mode in this._modes) { - modesListBuilder.AppendLine($"- \"{mode.Name}\": {mode.Description}"); + modesListBuilder.AppendLine($"#### {mode.Name}"); + modesListBuilder.AppendLine(); + modesListBuilder.AppendLine(mode.Description.TrimEnd()); + modesListBuilder.AppendLine(); } + var modesListText = modesListBuilder.ToString(); return new StringBuilder(this._instructions) diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs index 7052cd0b42..8394cf5ef8 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs @@ -55,7 +55,7 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable - Use descriptive file names (e.g., "projectarchitecture.md", "userpreferences.md"). - Include a description when saving a file to help with future discovery. - - Before starting new tasks, use FileMemory_ListFiles and FileMemory_SearchFiles to check for relevant existing memories. + - Before starting new tasks, use FileMemory_ListFiles and FileMemory_SearchFiles to check for relevant existing memories to avoid duplicate work. - Keep memories up-to-date by overwriting files when information changes. - When you receive large amounts of data (e.g., downloaded web pages, API responses, research results), save them to files if they will be required later, so that they are not lost when older context is compacted or truncated. diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs index f5f222e28b..bb39e71c20 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs @@ -48,9 +48,9 @@ public sealed class TodoProvider : AIContextProvider, IDisposable You have access to a todo list for tracking work items. While planning, make sure that you break down complex tasks into manageable todo items and add them to the list. Ask questions from the user where clarification is needed to create effective todos. - If the user provides feedback on your plan, adjust your todos accordingly by adding new items or removing irrelevant ones. + If the user provides feedback on your plan, adjust your todos accordingly by adding new items or removing irrelevant/old ones. During execution, use the todo list to keep track of what needs to be done, mark items as complete when finished, and remove any items that are no longer needed. - When a user changes the topic or changes their mind, ensure that you update the todo list accordingly by removing irrelevant items or adding new ones as needed. + When a user changes the topic or changes their mind, ensure that you update the todo list accordingly by removing irrelevant/old items or adding new ones as needed. Use these tools to manage your tasks: - Use TodoList_Add to break down complex work into trackable items (supports adding one or many at once). diff --git a/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentOptionsTests.cs index f07a08046c..8e74853f71 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentOptionsTests.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using Moq; + namespace Microsoft.Agents.AI.UnitTests; public class HarnessAgentOptionsTests @@ -18,8 +20,22 @@ public class HarnessAgentOptionsTests Assert.Null(options.Name); Assert.Null(options.Description); Assert.Null(options.ChatOptions); + Assert.Null(options.HarnessInstructions); Assert.Null(options.ChatHistoryProvider); Assert.Null(options.AIContextProviders); + Assert.False(options.DisableToolApproval); + Assert.False(options.DisableFileMemory); + Assert.False(options.DisableFileAccess); + Assert.False(options.DisableWebSearch); + Assert.False(options.DisableTodoProvider); + Assert.False(options.DisableAgentModeProvider); + Assert.False(options.DisableAgentSkillsProvider); + Assert.False(options.DisableOpenTelemetry); + Assert.Null(options.MaximumIterationsPerRequest); + Assert.Null(options.FileMemoryStore); + Assert.Null(options.FileAccessStore); + Assert.Null(options.AgentModeProviderOptions); + Assert.Null(options.AgentSkillsSource); } /// @@ -31,6 +47,10 @@ public class HarnessAgentOptionsTests // Arrange var chatHistoryProvider = new InMemoryChatHistoryProvider(); var contextProviders = new AIContextProvider[] { new TodoProvider() }; + var fileMemoryStore = new Mock().Object; + var fileAccessStore = new Mock().Object; + var agentModeOptions = new AgentModeProviderOptions(); + var skillsSource = new Mock().Object; // Act var options = new HarnessAgentOptions @@ -39,8 +59,22 @@ public class HarnessAgentOptionsTests Name = "test-name", Description = "test-description", ChatOptions = new() { Temperature = 0.5f, Instructions = "custom instructions" }, + HarnessInstructions = "custom harness instructions", ChatHistoryProvider = chatHistoryProvider, AIContextProviders = contextProviders, + MaximumIterationsPerRequest = 42, + DisableToolApproval = true, + DisableFileMemory = true, + FileMemoryStore = fileMemoryStore, + DisableFileAccess = true, + FileAccessStore = fileAccessStore, + DisableWebSearch = true, + DisableTodoProvider = true, + DisableAgentModeProvider = true, + AgentModeProviderOptions = agentModeOptions, + DisableAgentSkillsProvider = true, + AgentSkillsSource = skillsSource, + DisableOpenTelemetry = true, }; // Assert @@ -50,7 +84,21 @@ public class HarnessAgentOptionsTests Assert.NotNull(options.ChatOptions); Assert.Equal(0.5f, options.ChatOptions!.Temperature); Assert.Equal("custom instructions", options.ChatOptions.Instructions); + Assert.Equal("custom harness instructions", options.HarnessInstructions); Assert.Same(chatHistoryProvider, options.ChatHistoryProvider); Assert.Same(contextProviders, options.AIContextProviders); + Assert.Equal(42, options.MaximumIterationsPerRequest); + Assert.True(options.DisableToolApproval); + Assert.True(options.DisableFileMemory); + Assert.Same(fileMemoryStore, options.FileMemoryStore); + Assert.True(options.DisableFileAccess); + Assert.Same(fileAccessStore, options.FileAccessStore); + Assert.True(options.DisableWebSearch); + Assert.True(options.DisableTodoProvider); + Assert.True(options.DisableAgentModeProvider); + Assert.Same(agentModeOptions, options.AgentModeProviderOptions); + Assert.True(options.DisableAgentSkillsProvider); + Assert.Same(skillsSource, options.AgentSkillsSource); + Assert.True(options.DisableOpenTelemetry); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs index 29464431c3..0d963a1061 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs @@ -15,6 +15,21 @@ public class HarnessAgentTests private const int TestMaxContextWindowTokens = 100_000; private const int TestMaxOutputTokens = 10_000; + /// + /// Creates a HarnessAgent with all default features disabled to isolate tests for specific behaviors. + /// + private static HarnessAgentOptions CreateAllDisabledOptions() => new() + { + DisableToolApproval = true, + DisableOpenTelemetry = true, + DisableFileMemory = true, + DisableFileAccess = true, + DisableWebSearch = true, + DisableTodoProvider = true, + DisableAgentModeProvider = true, + DisableAgentSkillsProvider = true, + }; + #region Constructor Validation /// @@ -81,13 +96,12 @@ public class HarnessAgentTests { // Arrange var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.Name = "TestAgent"; + options.Description = "A test agent"; // Act - var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions - { - Name = "TestAgent", - Description = "A test agent", - }); + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); // Assert Assert.Equal("TestAgent", agent.Name); @@ -102,12 +116,11 @@ public class HarnessAgentTests { // Arrange var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.Id = "my-agent-id"; // Act - var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions - { - Id = "my-agent-id", - }); + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); // Assert Assert.Equal("my-agent-id", agent.Id); @@ -127,7 +140,7 @@ public class HarnessAgentTests var chatClient = new Mock().Object; // Act - var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens); + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions()); var innerAgent = agent.GetService(); // Assert @@ -136,19 +149,18 @@ public class HarnessAgentTests } /// - /// Verify that default instructions are used when options is provided but ChatOptions.Instructions is null. + /// Verify that default instructions are used when options is provided but neither HarnessInstructions nor ChatOptions.Instructions is set. /// [Fact] - public void Instructions_DefaultsWhenChatOptionsInstructionsIsNull() + public void Instructions_DefaultsWhenBothNull() { // Arrange var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.ChatOptions = new ChatOptions { Temperature = 0.5f }; // Act - var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions - { - ChatOptions = new ChatOptions { Temperature = 0.5f }, - }); + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); var innerAgent = agent.GetService(); // Assert @@ -157,24 +169,106 @@ public class HarnessAgentTests } /// - /// Verify that ChatOptions.Instructions overrides the defaults. + /// Verify that ChatOptions.Instructions is appended to the default HarnessInstructions. /// [Fact] - public void Instructions_CanBeOverriddenViaChatOptions() + public void Instructions_CombinesDefaultHarnessWithAgentInstructions() { // Arrange var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.ChatOptions = new ChatOptions { Instructions = "You are a custom assistant." }; // Act - var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions - { - ChatOptions = new ChatOptions { Instructions = "You are a custom assistant." }, - }); + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); var innerAgent = agent.GetService(); // Assert Assert.NotNull(innerAgent); - Assert.Equal("You are a custom assistant.", innerAgent!.Instructions); + var expected = $"{HarnessAgent.DefaultInstructions}\n\nYou are a custom assistant."; + Assert.Equal(expected, innerAgent!.Instructions); + } + + /// + /// Verify that custom HarnessInstructions replaces the default. + /// + [Fact] + public void Instructions_CustomHarnessInstructionsReplacesDefault() + { + // Arrange + var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.HarnessInstructions = "Custom harness rules."; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var innerAgent = agent.GetService(); + + // Assert + Assert.NotNull(innerAgent); + Assert.Equal("Custom harness rules.", innerAgent!.Instructions); + } + + /// + /// Verify that custom HarnessInstructions and ChatOptions.Instructions are combined. + /// + [Fact] + public void Instructions_CombinesCustomHarnessWithAgentInstructions() + { + // Arrange + var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.HarnessInstructions = "Custom harness rules."; + options.ChatOptions = new ChatOptions { Instructions = "You are a research agent." }; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var innerAgent = agent.GetService(); + + // Assert + Assert.NotNull(innerAgent); + Assert.Equal("Custom harness rules.\n\nYou are a research agent.", innerAgent!.Instructions); + } + + /// + /// Verify that empty HarnessInstructions omits harness portion, using only agent instructions. + /// + [Fact] + public void Instructions_EmptyHarnessInstructionsUsesOnlyAgentInstructions() + { + // Arrange + var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.HarnessInstructions = string.Empty; + options.ChatOptions = new ChatOptions { Instructions = "Agent only instructions." }; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var innerAgent = agent.GetService(); + + // Assert + Assert.NotNull(innerAgent); + Assert.Equal("Agent only instructions.", innerAgent!.Instructions); + } + + /// + /// Verify that empty HarnessInstructions with no agent instructions results in empty string. + /// + [Fact] + public void Instructions_EmptyHarnessInstructionsWithNoAgentInstructions() + { + // Arrange + var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.HarnessInstructions = string.Empty; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var innerAgent = agent.GetService(); + + // Assert + Assert.NotNull(innerAgent); + Assert.Equal(string.Empty, innerAgent!.Instructions); } #endregion @@ -191,7 +285,7 @@ public class HarnessAgentTests var chatClient = new Mock().Object; // Act - var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens); + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions()); var innerAgent = agent.GetService(); // Assert @@ -208,12 +302,11 @@ public class HarnessAgentTests // Arrange var chatClient = new Mock().Object; var customProvider = new InMemoryChatHistoryProvider(); + var options = CreateAllDisabledOptions(); + options.ChatHistoryProvider = customProvider; // Act - var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions - { - ChatHistoryProvider = customProvider, - }); + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); var innerAgent = agent.GetService(); // Assert @@ -235,7 +328,7 @@ public class HarnessAgentTests var chatClient = new Mock().Object; // Act - var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens); + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions()); var innerAgent = agent.GetService(); // Assert @@ -256,7 +349,7 @@ public class HarnessAgentTests var rawClient = mockClient.Object; // Act - var agent = new HarnessAgent(rawClient, TestMaxContextWindowTokens, TestMaxOutputTokens); + var agent = new HarnessAgent(rawClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions()); var innerAgent = agent.GetService(); // Assert — the pipeline wraps the raw client, so the outer client is not the same object. @@ -269,45 +362,45 @@ public class HarnessAgentTests #region AIContextProviders /// - /// Verify that additional AIContextProviders from options are passed to the inner ChatClientAgent, - /// not merged into the chat client builder pipeline. + /// Verify that additional AIContextProviders from options are passed to the inner ChatClientAgent. /// [Fact] public void AIContextProviders_ArePassedToInnerAgent() { // Arrange var chatClient = new Mock().Object; - var todoProvider = new TodoProvider(); + var customProvider = new TodoProvider(); + var options = CreateAllDisabledOptions(); + options.AIContextProviders = [customProvider]; // Act - var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions - { - AIContextProviders = [todoProvider], - }); + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); var innerAgent = agent.GetService(); - // Assert — the TodoProvider should appear in the inner agent's AIContextProviders. + // Assert — the custom provider should appear in the inner agent's AIContextProviders. Assert.NotNull(innerAgent); Assert.NotNull(innerAgent!.AIContextProviders); - Assert.Contains(todoProvider, innerAgent.AIContextProviders!); + Assert.Contains(customProvider, innerAgent.AIContextProviders!); } /// - /// Verify that when no AIContextProviders are specified, the inner agent has no additional providers. + /// Verify that when all default providers are disabled and no user AIContextProviders are specified, + /// the inner agent has an empty providers list. /// [Fact] - public void AIContextProviders_IsNullWhenNoneSpecified() + public void AIContextProviders_IsEmptyWhenAllDisabledAndNoneSpecified() { // Arrange var chatClient = new Mock().Object; // Act - var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens); + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions()); var innerAgent = agent.GetService(); // Assert Assert.NotNull(innerAgent); - Assert.Null(innerAgent!.AIContextProviders); + Assert.NotNull(innerAgent!.AIContextProviders); + Assert.Empty(innerAgent.AIContextProviders!); } #endregion @@ -332,13 +425,10 @@ public class HarnessAgentTests .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done"))); - var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions - { - ChatOptions = new ChatOptions - { - Tools = [tool], - }, - }); + var options = CreateAllDisabledOptions(); + options.ChatOptions = new ChatOptions { Tools = [tool] }; + + var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options); var session = await agent.CreateSessionAsync(); // Act @@ -389,7 +479,7 @@ public class HarnessAgentTests var chatClient = new Mock().Object; // Act - var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens); + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions()); // Assert Assert.Same(agent, agent.GetService()); @@ -405,7 +495,7 @@ public class HarnessAgentTests var chatClient = new Mock().Object; // Act - var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens); + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions()); // Assert Assert.NotNull(agent.GetService()); @@ -430,7 +520,7 @@ public class HarnessAgentTests It.IsAny())) .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello!"))); - var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens); + var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions()); var session = await agent.CreateSessionAsync(); // Act @@ -487,19 +577,19 @@ public class HarnessAgentTests { // Arrange var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.Name = "ExtensionAgent"; + options.ChatOptions = new ChatOptions { Instructions = "Custom instructions" }; // Act - var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions - { - Name = "ExtensionAgent", - ChatOptions = new ChatOptions { Instructions = "Custom instructions" }, - }); + var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens, options); var innerAgent = agent.GetService(); // Assert Assert.Equal("ExtensionAgent", agent.Name); Assert.NotNull(innerAgent); - Assert.Equal("Custom instructions", innerAgent!.Instructions); + var expected = $"{HarnessAgent.DefaultInstructions}\n\nCustom instructions"; + Assert.Equal(expected, innerAgent!.Instructions); } /// @@ -513,4 +603,579 @@ public class HarnessAgentTests } #endregion + + #region Feature: ToolApproval + + /// + /// Verify that ToolApprovalAgent is included in the pipeline by default. + /// + [Fact] + public void ToolApproval_IncludedByDefault() + { + // Arrange + var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.DisableToolApproval = false; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + + // Assert + Assert.NotNull(agent.GetService()); + } + + /// + /// Verify that ToolApprovalAgent is excluded when disabled. + /// + [Fact] + public void ToolApproval_ExcludedWhenDisabled() + { + // Arrange + var chatClient = new Mock().Object; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions()); + + // Assert + Assert.Null(agent.GetService()); + } + + #endregion + + #region Feature: OpenTelemetry + + /// + /// Verify that OpenTelemetryAgent is included in the pipeline by default. + /// + [Fact] + public void OpenTelemetry_IncludedByDefault() + { + // Arrange + var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.DisableOpenTelemetry = false; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + + // Assert + Assert.NotNull(agent.GetService()); + } + + /// + /// Verify that OpenTelemetryAgent is excluded when disabled. + /// + [Fact] + public void OpenTelemetry_ExcludedWhenDisabled() + { + // Arrange + var chatClient = new Mock().Object; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions()); + + // Assert + Assert.Null(agent.GetService()); + } + + #endregion + + #region Feature: WebSearch + + /// + /// Verify that HostedWebSearchTool is added to ChatOptions.Tools by default. + /// + [Fact] + public async Task WebSearch_IncludedByDefaultAsync() + { + // Arrange + var mockClient = new Mock(); + ChatOptions? capturedOptions = null; + mockClient + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done"))); + + var options = CreateAllDisabledOptions(); + options.DisableWebSearch = false; + + var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var session = await agent.CreateSessionAsync(); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session); + + // Assert + Assert.NotNull(capturedOptions?.Tools); + Assert.Contains(capturedOptions!.Tools!, t => t is HostedWebSearchTool); + } + + /// + /// Verify that HostedWebSearchTool is not added when disabled. + /// + [Fact] + public async Task WebSearch_ExcludedWhenDisabledAsync() + { + // Arrange + var mockClient = new Mock(); + ChatOptions? capturedOptions = null; + mockClient + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done"))); + + var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions()); + var session = await agent.CreateSessionAsync(); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session); + + // Assert + Assert.NotNull(capturedOptions); + if (capturedOptions!.Tools != null) + { + Assert.DoesNotContain(capturedOptions.Tools, t => t is HostedWebSearchTool); + } + } + + /// + /// Verify that user-provided tools are preserved alongside the default HostedWebSearchTool. + /// + [Fact] + public async Task WebSearch_CoexistsWithUserToolsAsync() + { + // Arrange + var mockClient = new Mock(); + ChatOptions? capturedOptions = null; + mockClient + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done"))); + + var userTool = AIFunctionFactory.Create(() => "test", "UserTool"); + var options = CreateAllDisabledOptions(); + options.DisableWebSearch = false; + options.ChatOptions = new ChatOptions { Tools = [userTool] }; + + var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var session = await agent.CreateSessionAsync(); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session); + + // Assert + Assert.NotNull(capturedOptions?.Tools); + Assert.Contains(capturedOptions!.Tools!, t => t is HostedWebSearchTool); + Assert.Contains(capturedOptions.Tools!, t => t == userTool); + } + + #endregion + + #region Feature: TodoProvider + + /// + /// Verify that TodoProvider is included in AIContextProviders by default. + /// + [Fact] + public void TodoProvider_IncludedByDefault() + { + // Arrange + var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.DisableTodoProvider = false; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var innerAgent = agent.GetService(); + + // Assert + Assert.NotNull(innerAgent?.AIContextProviders); + Assert.Contains(innerAgent!.AIContextProviders!, p => p is TodoProvider); + } + + /// + /// Verify that TodoProvider is excluded when disabled. + /// + [Fact] + public void TodoProvider_ExcludedWhenDisabled() + { + // Arrange + var chatClient = new Mock().Object; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions()); + var innerAgent = agent.GetService(); + + // Assert + Assert.NotNull(innerAgent); + if (innerAgent!.AIContextProviders != null) + { + Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is TodoProvider); + } + } + + #endregion + + #region Feature: AgentModeProvider + + /// + /// Verify that AgentModeProvider is included in AIContextProviders by default. + /// + [Fact] + public void AgentModeProvider_IncludedByDefault() + { + // Arrange + var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.DisableAgentModeProvider = false; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var innerAgent = agent.GetService(); + + // Assert + Assert.NotNull(innerAgent?.AIContextProviders); + Assert.Contains(innerAgent!.AIContextProviders!, p => p is AgentModeProvider); + } + + /// + /// Verify that AgentModeProvider is excluded when disabled. + /// + [Fact] + public void AgentModeProvider_ExcludedWhenDisabled() + { + // Arrange + var chatClient = new Mock().Object; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions()); + var innerAgent = agent.GetService(); + + // Assert + Assert.NotNull(innerAgent); + if (innerAgent!.AIContextProviders != null) + { + Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is AgentModeProvider); + } + } + + /// + /// Verify that custom AgentModeProviderOptions are passed through. + /// + [Fact] + public void AgentModeProvider_UsesCustomOptions() + { + // Arrange + var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.DisableAgentModeProvider = false; + options.AgentModeProviderOptions = new AgentModeProviderOptions + { + Modes = + [ + new AgentModeProviderOptions.AgentMode("custom-mode", "A custom mode for testing"), + ], + DefaultMode = "custom-mode", + }; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var innerAgent = agent.GetService(); + + // Assert — AgentModeProvider should be present (we can't easily inspect its internal options, + // but we verify it is created and present). + Assert.NotNull(innerAgent?.AIContextProviders); + Assert.Contains(innerAgent!.AIContextProviders!, p => p is AgentModeProvider); + } + + #endregion + + #region Feature: FileMemoryProvider + + /// + /// Verify that FileMemoryProvider is included in AIContextProviders by default. + /// + [Fact] + public void FileMemoryProvider_IncludedByDefault() + { + // Arrange + var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.DisableFileMemory = false; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var innerAgent = agent.GetService(); + + // Assert + Assert.NotNull(innerAgent?.AIContextProviders); + Assert.Contains(innerAgent!.AIContextProviders!, p => p is FileMemoryProvider); + } + + /// + /// Verify that FileMemoryProvider is excluded when disabled. + /// + [Fact] + public void FileMemoryProvider_ExcludedWhenDisabled() + { + // Arrange + var chatClient = new Mock().Object; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions()); + var innerAgent = agent.GetService(); + + // Assert + Assert.NotNull(innerAgent); + if (innerAgent!.AIContextProviders != null) + { + Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is FileMemoryProvider); + } + } + + /// + /// Verify that a custom FileMemoryStore is used when provided. + /// + [Fact] + public void FileMemoryProvider_UsesCustomStore() + { + // Arrange + var chatClient = new Mock().Object; + var customStore = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.DisableFileMemory = false; + options.FileMemoryStore = customStore; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var innerAgent = agent.GetService(); + + // Assert — FileMemoryProvider should be present with the custom store. + Assert.NotNull(innerAgent?.AIContextProviders); + Assert.Contains(innerAgent!.AIContextProviders!, p => p is FileMemoryProvider); + } + + #endregion + + #region Feature: FileAccessProvider + + /// + /// Verify that FileAccessProvider is included in AIContextProviders by default. + /// + [Fact] + public void FileAccessProvider_IncludedByDefault() + { + // Arrange + var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.DisableFileAccess = false; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var innerAgent = agent.GetService(); + + // Assert + Assert.NotNull(innerAgent?.AIContextProviders); + Assert.Contains(innerAgent!.AIContextProviders!, p => p is FileAccessProvider); + } + + /// + /// Verify that FileAccessProvider is excluded when disabled. + /// + [Fact] + public void FileAccessProvider_ExcludedWhenDisabled() + { + // Arrange + var chatClient = new Mock().Object; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions()); + var innerAgent = agent.GetService(); + + // Assert + Assert.NotNull(innerAgent); + if (innerAgent!.AIContextProviders != null) + { + Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is FileAccessProvider); + } + } + + /// + /// Verify that a custom FileAccessStore is used when provided. + /// + [Fact] + public void FileAccessProvider_UsesCustomStore() + { + // Arrange + var chatClient = new Mock().Object; + var customStore = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.DisableFileAccess = false; + options.FileAccessStore = customStore; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var innerAgent = agent.GetService(); + + // Assert — FileAccessProvider should be present with the custom store. + Assert.NotNull(innerAgent?.AIContextProviders); + Assert.Contains(innerAgent!.AIContextProviders!, p => p is FileAccessProvider); + } + + #endregion + + #region Feature: AgentSkillsProvider + + /// + /// Verify that AgentSkillsProvider is included in AIContextProviders by default. + /// + [Fact] + public void AgentSkillsProvider_IncludedByDefault() + { + // Arrange + var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.DisableAgentSkillsProvider = false; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var innerAgent = agent.GetService(); + + // Assert + Assert.NotNull(innerAgent?.AIContextProviders); + Assert.Contains(innerAgent!.AIContextProviders!, p => p is AgentSkillsProvider); + } + + /// + /// Verify that AgentSkillsProvider is excluded when disabled. + /// + [Fact] + public void AgentSkillsProvider_ExcludedWhenDisabled() + { + // Arrange + var chatClient = new Mock().Object; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions()); + var innerAgent = agent.GetService(); + + // Assert + Assert.NotNull(innerAgent); + if (innerAgent!.AIContextProviders != null) + { + Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is AgentSkillsProvider); + } + } + + /// + /// Verify that a custom AgentSkillsSource is used when provided. + /// + [Fact] + public void AgentSkillsProvider_UsesCustomSource() + { + // Arrange + var chatClient = new Mock().Object; + var customSource = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.DisableAgentSkillsProvider = false; + options.AgentSkillsSource = customSource; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var innerAgent = agent.GetService(); + + // Assert — AgentSkillsProvider should be present. + Assert.NotNull(innerAgent?.AIContextProviders); + Assert.Contains(innerAgent!.AIContextProviders!, p => p is AgentSkillsProvider); + } + + #endregion + + #region Feature: MaximumIterationsPerRequest + + /// + /// Verify that MaximumIterationsPerRequest configures the FunctionInvokingChatClient. + /// + [Fact] + public void MaximumIterationsPerRequest_ConfiguresFunctionInvokingChatClient() + { + // Arrange + var chatClient = new Mock().Object; + var options = CreateAllDisabledOptions(); + options.MaximumIterationsPerRequest = 42; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options); + var innerAgent = agent.GetService(); + var ficc = innerAgent!.ChatClient.GetService(); + + // Assert + Assert.NotNull(ficc); + Assert.Equal(42, ficc!.MaximumIterationsPerRequest); + } + + /// + /// Verify that the default MaximumIterationsPerRequest is used when not set. + /// + [Fact] + public void MaximumIterationsPerRequest_UsesDefaultWhenNotSet() + { + // Arrange + var chatClient = new Mock().Object; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions()); + var innerAgent = agent.GetService(); + var ficc = innerAgent!.ChatClient.GetService(); + + // Assert — default is not 0 and not our custom value. + Assert.NotNull(ficc); + Assert.NotEqual(0, ficc!.MaximumIterationsPerRequest); + } + + #endregion + + #region Feature: All Defaults Enabled + + /// + /// Verify that when no options are provided, all default features are enabled. + /// + [Fact] + public async Task AllDefaults_AllFeaturesEnabledAsync() + { + // Arrange + var mockClient = new Mock(); + ChatOptions? capturedOptions = null; + mockClient + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done"))); + + // Act + var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens); + var innerAgent = agent.GetService(); + + // Assert — agent wrappers + Assert.NotNull(agent.GetService()); + Assert.NotNull(agent.GetService()); + + // Assert — default context providers + Assert.NotNull(innerAgent); + Assert.NotNull(innerAgent!.AIContextProviders); + + var providers = innerAgent.AIContextProviders!.ToList(); + Assert.Contains(providers, p => p is TodoProvider); + Assert.Contains(providers, p => p is AgentModeProvider); + Assert.Contains(providers, p => p is FileMemoryProvider); + Assert.Contains(providers, p => p is FileAccessProvider); + Assert.Contains(providers, p => p is AgentSkillsProvider); + + // Assert — HostedWebSearchTool is present in the tools sent to the model + var session = await agent.CreateSessionAsync(); + await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session); + Assert.NotNull(capturedOptions?.Tools); + Assert.Contains(capturedOptions!.Tools!, t => t is HostedWebSearchTool); + } + + #endregion } From 7cea5e162a5a80f29dc986bfb324c7bc5bd81c09 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 18 May 2026 16:37:25 +0100 Subject: [PATCH 9/9] .NET: Require TODO finish reason and rename SubAgents to BackgroundAgents (#5902) * Require TODO finish reason and rename SubAgents to BackgroundAgents * Address PR comments --- dotnet/agent-framework-dotnet.slnx | 2 +- ...ter.cs => BackgroundAgentToolFormatter.cs} | 18 +- .../ToolFormatters/TodoToolFormatter.cs | 46 ++++- .../ToolFormatters/ToolCallFormatter.cs | 2 +- ...ep02_Research_WithBackgroundAgents.csproj} | 0 .../Program.cs | 28 +-- .../README.md | 30 +-- dotnet/samples/02-agents/Harness/README.md | 2 +- .../Microsoft.Agents.AI/AgentJsonUtilities.cs | 14 +- .../BackgroundAgentRuntimeState.cs} | 10 +- .../BackgroundAgentState.cs} | 10 +- .../BackgroundAgentsProvider.cs} | 162 +++++++-------- .../BackgroundAgentsProviderOptions.cs} | 14 +- .../BackgroundTaskInfo.cs} | 18 +- .../BackgroundTaskStatus.cs} | 12 +- .../Harness/Todo/TodoCompleteInput.cs | 26 +++ .../Harness/Todo/TodoProvider.cs | 8 +- .../BackgroundAgentsProviderTests.cs} | 186 +++++++++--------- .../Harness/Todo/TodoProviderTests.cs | 39 +++- 19 files changed, 361 insertions(+), 266 deletions(-) rename dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/{SubAgentToolFormatter.cs => BackgroundAgentToolFormatter.cs} (78%) rename dotnet/samples/02-agents/Harness/{Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj => Harness_Step02_Research_WithBackgroundAgents/Harness_Step02_Research_WithBackgroundAgents.csproj} (100%) rename dotnet/samples/02-agents/Harness/{Harness_Step02_Research_WithSubAgents => Harness_Step02_Research_WithBackgroundAgents}/Program.cs (81%) rename dotnet/samples/02-agents/Harness/{Harness_Step02_Research_WithSubAgents => Harness_Step02_Research_WithBackgroundAgents}/README.md (53%) rename dotnet/src/Microsoft.Agents.AI/Harness/{SubAgents/SubAgentRuntimeState.cs => BackgroundAgents/BackgroundAgentRuntimeState.cs} (67%) rename dotnet/src/Microsoft.Agents.AI/Harness/{SubAgents/SubAgentState.cs => BackgroundAgents/BackgroundAgentState.cs} (62%) rename dotnet/src/Microsoft.Agents.AI/Harness/{SubAgents/SubAgentsProvider.cs => BackgroundAgents/BackgroundAgentsProvider.cs} (63%) rename dotnet/src/Microsoft.Agents.AI/Harness/{SubAgents/SubAgentsProviderOptions.cs => BackgroundAgents/BackgroundAgentsProviderOptions.cs} (72%) rename dotnet/src/Microsoft.Agents.AI/Harness/{SubAgents/SubTaskInfo.cs => BackgroundAgents/BackgroundTaskInfo.cs} (62%) rename dotnet/src/Microsoft.Agents.AI/Harness/{SubAgents/SubTaskStatus.cs => BackgroundAgents/BackgroundTaskStatus.cs} (57%) create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoCompleteInput.cs rename dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/{SubAgents/SubAgentsProviderTests.cs => BackgroundAgents/BackgroundAgentsProviderTests.cs} (77%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index e1b7c9a71b..d494768139 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -122,7 +122,7 @@ - + diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/SubAgentToolFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/BackgroundAgentToolFormatter.cs similarity index 78% rename from dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/SubAgentToolFormatter.cs rename to dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/BackgroundAgentToolFormatter.cs index 915491d354..4907abbd89 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/SubAgentToolFormatter.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/BackgroundAgentToolFormatter.cs @@ -6,26 +6,26 @@ using Microsoft.Extensions.AI; namespace Harness.Shared.Console.ToolFormatters; /// -/// Formats SubAgents_* tool calls with human-readable details +/// Formats BackgroundAgents_* tool calls with human-readable details /// for task start, continue, wait, and result retrieval operations. /// -public sealed class SubAgentToolFormatter : ToolCallFormatter +public sealed class BackgroundAgentToolFormatter : ToolCallFormatter { /// - public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("SubAgents_", StringComparison.Ordinal); + public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("BackgroundAgents_", StringComparison.Ordinal); /// public override string? FormatDetail(FunctionCallContent call) => call.Name switch { - "SubAgents_StartTask" => FormatStartSubTask(call), - "SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"), - "SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"), - "SubAgents_ContinueTask" => FormatContinueTask(call), - "SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"), + "BackgroundAgents_StartTask" => FormatStartBackgroundTask(call), + "BackgroundAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"), + "BackgroundAgents_GetTaskResults" => FormatSingleId(call, "taskId"), + "BackgroundAgents_ContinueTask" => FormatContinueTask(call), + "BackgroundAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"), _ => null, }; - private static string? FormatStartSubTask(FunctionCallContent call) + private static string? FormatStartBackgroundTask(FunctionCallContent call) { string? agentName = GetStringArgumentValue(call, "agentName"); string? description = GetStringArgumentValue(call, "description"); diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/TodoToolFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/TodoToolFormatter.cs index 98e041ede7..b907c4afb1 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/TodoToolFormatter.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/TodoToolFormatter.cs @@ -19,7 +19,7 @@ public sealed class TodoToolFormatter : ToolCallFormatter public override string? FormatDetail(FunctionCallContent call) => call.Name switch { "TodoList_Add" => FormatAddTodos(call), - "TodoList_Complete" => FormatIdList(call, "ids", "Complete"), + "TodoList_Complete" => FormatCompleteTodos(call), "TodoList_Remove" => FormatIdList(call, "ids", "Remove"), _ => null, }; @@ -64,6 +64,50 @@ public sealed class TodoToolFormatter : ToolCallFormatter return sb.ToString(); } + private static string? FormatCompleteTodos(FunctionCallContent call) + { + if (call.Arguments?.TryGetValue("items", out object? itemsObj) != true || itemsObj is null) + { + return null; + } + + var entries = new List<(int Id, string? Reason)>(); + + if (itemsObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in jsonArray.EnumerateArray()) + { + if (!item.TryGetProperty("id", out JsonElement idElement) || !idElement.TryGetInt32(out int id)) + { + continue; + } + + string? reason = item.TryGetProperty("reason", out JsonElement reasonElement) + ? reasonElement.GetString() + : null; + entries.Add((id, reason)); + } + } + + if (entries.Count == 0) + { + return null; + } + + var sb = new StringBuilder(); + for (int i = 0; i < entries.Count; i++) + { + string connector = i < entries.Count - 1 ? "├─" : "└─"; + sb.Append($"\n {connector} Complete #{entries[i].Id}"); + if (!string.IsNullOrEmpty(entries[i].Reason)) + { + sb.Append($" — {Truncate(entries[i].Reason!, 80)}"); + } + } + + return sb.ToString(); + } + private static string? FormatIdList(FunctionCallContent call, string paramName, string verb) { List? ids = GetIntListArgumentValue(call, paramName); diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ToolCallFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ToolCallFormatter.cs index f8a131dd74..e8edfa5177 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ToolCallFormatter.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ToolCallFormatter.cs @@ -56,7 +56,7 @@ public abstract class ToolCallFormatter [ new TodoToolFormatter(), new ModeToolFormatter(), - new SubAgentToolFormatter(), + new BackgroundAgentToolFormatter(), new FileMemoryToolFormatter(), new WebSearchToolFormatter(), new FallbackToolFormatter(), diff --git a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Harness_Step02_Research_WithBackgroundAgents.csproj similarity index 100% rename from dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj rename to dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Harness_Step02_Research_WithBackgroundAgents.csproj diff --git a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Program.cs similarity index 81% rename from dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs rename to dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Program.cs index bb4c50e0d7..c88c958247 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Program.cs @@ -1,10 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample demonstrates how to use the SubAgentsProvider to delegate work to sub-agents. +// This sample demonstrates how to use the BackgroundAgentsProvider to delegate work to background agents. // A parent agent is given a list of stock tickers and instructed to find the closing price -// for each ticker on December 31, 2025. It delegates the web searches to a sub-agent. +// for each ticker on December 31, 2025. It delegates the web searches to a background agent. // The HarnessAgent provides built-in WebSearch (HostedWebSearchTool) so no manual web search -// tool configuration is needed on the sub-agent. +// tool configuration is needed on the background agent. // // Special commands: // /exit — End the session. @@ -26,7 +26,7 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYME const int MaxContextWindowTokens = 1_050_000; const int MaxOutputTokens = 128_000; -// --- Sub-agent: Web Search Agent --- +// --- Background agent: Web Search Agent --- // This agent uses the HarnessAgent's built-in HostedWebSearchTool to search the web. // Features not needed by this sub-agent are disabled. AIAgent webSearchAgent = @@ -55,26 +55,26 @@ AIAgent webSearchAgent = }); // --- Parent agent: Stock Price Researcher --- -// This agent orchestrates the sub-agent to look up stock prices in parallel. +// This agent orchestrates the background agent to look up stock prices in parallel. var parentInstructions = """ - You are a stock price research assistant. You have access to a web search sub-agent that can look up information on the web. + You are a stock price research assistant. You have access to a web search background agent that can look up information on the web. When given a list of stock tickers, your job is to find the closing price for each ticker on December 31, 2025. ## Workflow - 1. For each ticker, start a sub-task on the WebSearchAgent asking it to find the closing price on December 31, 2025. - - Start all sub-tasks before waiting for any of them to complete, so they run concurrently. - 2. Wait for all sub-tasks to complete. - 3. Retrieve the results from each sub-task. + 1. For each ticker, start a background task on the WebSearchAgent asking it to find the closing price on December 31, 2025. + - Start all background tasks before waiting for any of them to complete, so they run concurrently. + 2. Wait for all background tasks to complete. + 3. Retrieve the results from each background task. 4. Present a summary table with the ticker symbol and closing price for each stock. 5. Clear all completed tasks to free memory. ## Important - - Always delegate web searches to the WebSearchAgent sub-agent. Do not try to answer from memory. - - If a sub-task fails or returns unclear results, continue the task with a more specific query. + - Always delegate web searches to the WebSearchAgent background agent. Do not try to answer from memory. + - If a background task fails or returns unclear results, continue the task with a more specific query. - Present results in a clean markdown table format. """; @@ -94,7 +94,7 @@ AIAgent parentAgent = .AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions { Name = "StockPriceResearcher", - Description = "An agent that researches stock prices using sub-agents.", + Description = "An agent that researches stock prices using background agents.", DisableTodoProvider = true, DisableAgentModeProvider = true, DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session @@ -103,7 +103,7 @@ AIAgent parentAgent = DisableWebSearch = true, AIContextProviders = [ - new SubAgentsProvider([webSearchAgent]), + new BackgroundAgentsProvider([webSearchAgent]), ], ChatOptions = new ChatOptions { diff --git a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/README.md b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/README.md similarity index 53% rename from dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/README.md rename to dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/README.md index a52ebe0372..c04f68d13c 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/README.md +++ b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/README.md @@ -1,24 +1,24 @@ -# Harness Step 02 — SubAgents (Stock Price Research) +# Harness Step 02 — BackgroundAgents (Stock Price Research) -This sample demonstrates how to use the **SubAgentsProvider** to delegate work from a parent agent to sub-agents. Both agents use `HarnessAgent` for pre-configured function invocation, per-service-call persistence, and context-window compaction. +This sample demonstrates how to use the **BackgroundAgentsProvider** to delegate work from a parent agent to background agents. Both agents use `HarnessAgent` for pre-configured function invocation, per-service-call persistence, and context-window compaction. ## What It Does -A parent agent receives a list of stock tickers and uses a web-search sub-agent to find the closing price for each ticker on December 31, 2025. The sub-tasks run concurrently, and results are presented in a summary table. +A parent agent receives a list of stock tickers and uses a web-search background agent to find the closing price for each ticker on December 31, 2025. The background tasks run concurrently, and results are presented in a summary table. ### Architecture ``` -┌─────────────────────────────────┐ -│ StockPriceResearcher │ -│ (Parent Agent) │ -│ │ -│ SubAgentsProvider │ -│ ├─ SubAgents_StartTask │ -│ ├─ SubAgents_WaitFor... │ -│ ├─ SubAgents_GetTaskResults │ -│ └─ ... │ -└────────────┬────────────────────┘ +┌────────────────────────────────────────┐ +│ StockPriceResearcher │ +│ (Parent Agent) │ +│ │ +│ BackgroundAgentsProvider │ +│ ├─ BackgroundAgents_StartTask │ +│ ├─ BackgroundAgents_WaitFor... │ +│ ├─ BackgroundAgents_GetTaskResults │ +│ └─ ... │ +└────────────┬───────────────────────────┘ │ delegates to ▼ ┌─────────────────────────────────┐ @@ -40,7 +40,7 @@ A parent agent receives a list of stock tickers and uses a web-search sub-agent ## Running the Sample ```bash -cd dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents +cd dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents dotnet run ``` @@ -50,4 +50,4 @@ When prompted, enter a list of stock tickers such as: BAC, MSFT, BA ``` -The parent agent will delegate each ticker lookup to the web search sub-agent concurrently and present the results in a table. +The parent agent will delegate each ticker lookup to the web search background agent concurrently and present the results in a table. diff --git a/dotnet/samples/02-agents/Harness/README.md b/dotnet/samples/02-agents/Harness/README.md index d868323648..16fad9ac62 100644 --- a/dotnet/samples/02-agents/Harness/README.md +++ b/dotnet/samples/02-agents/Harness/README.md @@ -7,5 +7,5 @@ Samples demonstrating the [Harness AIContextProviders](../../../src/Microsoft.Ag | Sample | Description | | --- | --- | | [Harness_Step01_Research](./Harness_Step01_Research/README.md) | Using a ChatClientAgent with TodoProvider and AgentModeProvider for research, showcasing planning mode and todo management | -| [Harness_Step02_Research_WithSubAgents](./Harness_Step02_Research_WithSubAgents/README.md) | Using SubAgentsProvider to delegate stock price lookups to a web-search sub-agent concurrently | +| [Harness_Step02_Research_WithBackgroundAgents](./Harness_Step02_Research_WithBackgroundAgents/README.md) | Using BackgroundAgentsProvider to delegate stock price lookups to a web-search background agent concurrently | | [Harness_Step03_DataProcessing](./Harness_Step03_DataProcessing/README.md) | Using FileAccessProvider to give an agent access to CSV data files for reading, analysis, and output generation | diff --git a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs index f3bc543f03..e28144f45c 100644 --- a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs @@ -74,9 +74,11 @@ internal static partial class AgentJsonUtilities [JsonSerializable(typeof(TodoState))] [JsonSerializable(typeof(TodoItem))] [JsonSerializable(typeof(TodoItemInput))] + [JsonSerializable(typeof(TodoCompleteInput))] [JsonSerializable(typeof(List), TypeInfoPropertyName = "IntList")] [JsonSerializable(typeof(List), TypeInfoPropertyName = "TodoItemList")] [JsonSerializable(typeof(List), TypeInfoPropertyName = "TodoItemInputList")] + [JsonSerializable(typeof(List), TypeInfoPropertyName = "TodoCompleteInputList")] // AgentModeProvider types [JsonSerializable(typeof(AgentModeState))] @@ -95,12 +97,12 @@ internal static partial class AgentJsonUtilities [JsonSerializable(typeof(FileListEntry))] [JsonSerializable(typeof(List), TypeInfoPropertyName = "FileListEntryList")] - // SubAgentsProvider types - [JsonSerializable(typeof(SubAgentState))] - [JsonSerializable(typeof(SubAgentRuntimeState))] - [JsonSerializable(typeof(SubTaskInfo))] - [JsonSerializable(typeof(SubTaskStatus))] - [JsonSerializable(typeof(List), TypeInfoPropertyName = "SubTaskInfoList")] + // BackgroundAgentsProvider types + [JsonSerializable(typeof(BackgroundAgentState))] + [JsonSerializable(typeof(BackgroundAgentRuntimeState))] + [JsonSerializable(typeof(BackgroundTaskInfo))] + [JsonSerializable(typeof(BackgroundTaskStatus))] + [JsonSerializable(typeof(List), TypeInfoPropertyName = "BackgroundTaskInfoList")] [ExcludeFromCodeCoverage] internal sealed partial class JsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentRuntimeState.cs b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentRuntimeState.cs similarity index 67% rename from dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentRuntimeState.cs rename to dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentRuntimeState.cs index 7b3096dba8..f8e2f3accc 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentRuntimeState.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentRuntimeState.cs @@ -7,15 +7,15 @@ using System.Threading.Tasks; namespace Microsoft.Agents.AI; /// -/// Holds non-serializable runtime references for in-flight sub-tasks within a single parent session. +/// Holds non-serializable runtime references for in-flight background tasks within a single parent session. /// /// /// Properties are marked with because /// and are not JSON-serializable. After deserialization (e.g., after a restart), /// a fresh empty instance is created and any previously-running tasks are marked as -/// by . +/// by . /// -internal sealed class SubAgentRuntimeState +internal sealed class BackgroundAgentRuntimeState { /// /// Gets the mapping of task IDs to their in-flight instances. @@ -24,9 +24,9 @@ internal sealed class SubAgentRuntimeState public Dictionary> InFlightTasks { get; } = []; /// - /// Gets the mapping of task IDs to their sub-agent instances, + /// Gets the mapping of task IDs to their background agent instances, /// needed for ContinueTask. /// [JsonIgnore] - public Dictionary SubTaskSessions { get; } = []; + public Dictionary BackgroundTaskSessions { get; } = []; } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentState.cs b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentState.cs similarity index 62% rename from dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentState.cs rename to dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentState.cs index 4e086fb910..223ebd98c5 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentState.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentState.cs @@ -8,21 +8,21 @@ using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI; /// -/// Represents the serializable state of sub-tasks managed by the , +/// Represents the serializable state of background tasks managed by the , /// stored in the session's . /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -internal sealed class SubAgentState +internal sealed class BackgroundAgentState { /// - /// Gets or sets the next ID to assign to a new sub-task. + /// Gets or sets the next ID to assign to a new background task. /// [JsonPropertyName("nextTaskId")] public int NextTaskId { get; set; } = 1; /// - /// Gets the list of sub-task metadata entries. + /// Gets the list of background task metadata entries. /// [JsonPropertyName("tasks")] - public List Tasks { get; set; } = []; + public List Tasks { get; set; } = []; } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs similarity index 63% rename from dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs rename to dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs index 254e082523..3e347f9983 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs @@ -15,56 +15,56 @@ using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; /// -/// An that enables an agent to delegate work to sub-agents asynchronously. +/// An that enables an agent to delegate work to background agents asynchronously. /// /// /// -/// The allows a parent agent to start sub-tasks on child agents, -/// wait for their completion, and retrieve results. Each sub-task runs in its own session and +/// The allows a parent agent to start background tasks on child agents, +/// wait for their completion, and retrieve results. Each background task runs in its own session and /// executes concurrently. /// /// /// This provider exposes the following tools to the agent: /// -/// SubAgents_StartTask — Start a sub-task on a named agent with text input. Returns the task ID. -/// SubAgents_WaitForFirstCompletion — Block until the first of the specified tasks completes. Returns the completed task's ID. -/// SubAgents_GetTaskResults — Retrieve the text output of a completed sub-task. -/// SubAgents_GetAllTasks — List all sub-tasks with their IDs, statuses, descriptions, and agent names. -/// SubAgents_ContinueTask — Send follow-up input to a completed sub-task's session to resume work. -/// SubAgents_ClearCompletedTask — Remove a completed sub-task and release its session to free memory. +/// BackgroundAgents_StartTask — Start a background task on a named agent with text input. Returns the task ID. +/// BackgroundAgents_WaitForFirstCompletion — Block until the first of the specified tasks completes. Returns the completed task's ID. +/// BackgroundAgents_GetTaskResults — Retrieve the text output of a completed background task. +/// BackgroundAgents_GetAllTasks — List all background tasks with their IDs, statuses, descriptions, and agent names. +/// BackgroundAgents_ContinueTask — Send follow-up input to a completed background task's session to resume work. +/// BackgroundAgents_ClearCompletedTask — Remove a completed background task and release its session to free memory. /// /// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed class SubAgentsProvider : AIContextProvider +public sealed class BackgroundAgentsProvider : AIContextProvider { private const string DefaultInstructions = """ - ## SubAgents - You have access to sub-agents that can perform work on your behalf. + ## BackgroundAgents + You have access to background agents that can perform work on your behalf. - - Use the `SubAgents_*` list of tools to start tasks on sub agents and check their results. - - Creating a sub task does not block, and sub-tasks run concurrently. + - Use the `BackgroundAgents_*` list of tools to start tasks on background agents and check their results. + - Creating a background task does not block, and background tasks run concurrently. - Important: Always wait for outstanding tasks to finish before you finish processing. - - Important: After retrieving results from a completed task, clear it with SubAgents_ClearCompletedTask to free memory, unless you plan to continue it with SubAgents_ContinueTask. + - Important: After retrieving results from a completed task, clear it with BackgroundAgents_ClearCompletedTask to free memory, unless you plan to continue it with BackgroundAgents_ContinueTask. - {sub_agents} + {background_agents} """; private readonly Dictionary _agents; - private readonly ProviderSessionState _sessionState; - private readonly ProviderSessionState _runtimeSessionState; + private readonly ProviderSessionState _sessionState; + private readonly ProviderSessionState _runtimeSessionState; private readonly string _instructions; private IReadOnlyList? _stateKeys; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The collection of sub-agents available for delegation. + /// The collection of background agents available for delegation. /// Optional settings controlling the provider behavior. /// is . /// An agent has a null or empty name, or agent names are not unique. - public SubAgentsProvider(IEnumerable agents, SubAgentsProviderOptions? options = null) + public BackgroundAgentsProvider(IEnumerable agents, BackgroundAgentsProviderOptions? options = null) { _ = Throw.IfNull(agents); @@ -74,15 +74,15 @@ public sealed class SubAgentsProvider : AIContextProvider string agentListText = options?.AgentListBuilder is not null ? options.AgentListBuilder(this._agents) : BuildDefaultAgentListText(this._agents); - this._instructions = baseInstructions.Replace("{sub_agents}", agentListText); + this._instructions = baseInstructions.Replace("{background_agents}", agentListText); - this._sessionState = new ProviderSessionState( - _ => new SubAgentState(), + this._sessionState = new ProviderSessionState( + _ => new BackgroundAgentState(), this.GetType().Name, AgentJsonUtilities.DefaultOptions); - this._runtimeSessionState = new ProviderSessionState( - _ => new SubAgentRuntimeState(), + this._runtimeSessionState = new ProviderSessionState( + _ => new BackgroundAgentRuntimeState(), this.GetType().Name + "_Runtime", AgentJsonUtilities.DefaultOptions); } @@ -93,8 +93,8 @@ public sealed class SubAgentsProvider : AIContextProvider /// protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) { - SubAgentState state = this._sessionState.GetOrInitializeState(context.Session); - SubAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(context.Session); + BackgroundAgentState state = this._sessionState.GetOrInitializeState(context.Session); + BackgroundAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(context.Session); return new ValueTask(new AIContext { @@ -113,12 +113,12 @@ public sealed class SubAgentsProvider : AIContextProvider { if (string.IsNullOrWhiteSpace(agent.Name)) { - throw new ArgumentException("All sub-agents must have a non-empty Name.", nameof(agents)); + throw new ArgumentException("All background agents must have a non-empty Name.", nameof(agents)); } if (dict.ContainsKey(agent.Name)) { - throw new ArgumentException($"Duplicate sub-agent name: '{agent.Name}'. Agent names must be unique (case-insensitive).", nameof(agents)); + throw new ArgumentException($"Duplicate background agent name: '{agent.Name}'. Agent names must be unique (case-insensitive).", nameof(agents)); } dict[agent.Name] = agent; @@ -126,19 +126,19 @@ public sealed class SubAgentsProvider : AIContextProvider if (dict.Count == 0) { - throw new ArgumentException("At least one sub-agent must be provided.", nameof(agents)); + throw new ArgumentException("At least one background agent must be provided.", nameof(agents)); } return dict; } /// - /// Builds the default text listing available sub-agents and their descriptions. + /// Builds the default text listing available background agents and their descriptions. /// private static string BuildDefaultAgentListText(IReadOnlyDictionary agents) { var sb = new StringBuilder(); - sb.AppendLine("Available sub-agents:"); + sb.AppendLine("Available background agents:"); foreach (var kvp in agents) { sb.Append("- ").Append(kvp.Key); @@ -156,12 +156,12 @@ public sealed class SubAgentsProvider : AIContextProvider /// /// Refreshes the status of in-flight tasks in the given state for the specified session. /// - private void TryRefreshTaskState(SubAgentState state, SubAgentRuntimeState runtimeState, AgentSession? session) + private void TryRefreshTaskState(BackgroundAgentState state, BackgroundAgentRuntimeState runtimeState, AgentSession? session) { bool changed = false; - foreach (SubTaskInfo task in state.Tasks) + foreach (BackgroundTaskInfo task in state.Tasks) { - if (task.Status != SubTaskStatus.Running) + if (task.Status != BackgroundTaskStatus.Running) { continue; } @@ -169,7 +169,7 @@ public sealed class SubAgentsProvider : AIContextProvider if (!runtimeState.InFlightTasks.TryGetValue(task.Id, out Task? inFlight)) { // In-flight reference lost (e.g., after restart/deserialization). - task.Status = SubTaskStatus.Lost; + task.Status = BackgroundTaskStatus.Lost; changed = true; continue; } @@ -188,32 +188,32 @@ public sealed class SubAgentsProvider : AIContextProvider } /// - /// Finalizes a task by extracting results from the completed Task and updating the SubTaskInfo. + /// Finalizes a task by extracting results from the completed Task and updating the BackgroundTaskInfo. /// - private static void FinalizeTask(SubTaskInfo taskInfo, Task completedTask, SubAgentRuntimeState runtimeState) + private static void FinalizeTask(BackgroundTaskInfo taskInfo, Task completedTask, BackgroundAgentRuntimeState runtimeState) { if (completedTask.Status == TaskStatus.RanToCompletion) { - taskInfo.Status = SubTaskStatus.Completed; + taskInfo.Status = BackgroundTaskStatus.Completed; #pragma warning disable VSTHRD002 // Avoid problematic synchronous waits — task is already completed taskInfo.ResultText = completedTask.Result.Text; #pragma warning restore VSTHRD002 } else if (completedTask.IsFaulted) { - taskInfo.Status = SubTaskStatus.Failed; + taskInfo.Status = BackgroundTaskStatus.Failed; taskInfo.ErrorText = completedTask.Exception?.InnerException?.Message ?? completedTask.Exception?.Message ?? "Unknown error"; } else if (completedTask.IsCanceled) { - taskInfo.Status = SubTaskStatus.Failed; + taskInfo.Status = BackgroundTaskStatus.Failed; taskInfo.ErrorText = "Task was canceled."; } runtimeState.InFlightTasks.Remove(taskInfo.Id); } - private AITool[] CreateTools(SubAgentState state, SubAgentRuntimeState runtimeState, AgentSession? session) + private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeState runtimeState, AgentSession? session) { var serializerOptions = AgentJsonUtilities.DefaultOptions; @@ -221,43 +221,43 @@ public sealed class SubAgentsProvider : AIContextProvider [ AIFunctionFactory.Create( async ( - [Description("The name of the sub agent to delegate the task to.")] string agentName, - [Description("The request to pass to the sub agent.")] string input, + [Description("The name of the background agent to delegate the task to.")] string agentName, + [Description("The request to pass to the background agent.")] string input, [Description("A description of the task used to identify the task later.")] string description) => { if (!this._agents.TryGetValue(agentName, out AIAgent? agent)) { - return $"Error: No sub-agent found with name '{agentName}'. Available agents: {string.Join(", ", this._agents.Keys)}"; + return $"Error: No background agent found with name '{agentName}'. Available agents: {string.Join(", ", this._agents.Keys)}"; } int taskId = state.NextTaskId++; - var taskInfo = new SubTaskInfo + var taskInfo = new BackgroundTaskInfo { Id = taskId, AgentName = agentName, Description = description, - Status = SubTaskStatus.Running, + Status = BackgroundTaskStatus.Running, }; state.Tasks.Add(taskInfo); - // Create a dedicated session for this sub-task so it can be continued later. + // Create a dedicated session for this background task so it can be continued later. AgentSession subSession = await agent.CreateSessionAsync().ConfigureAwait(false); // Wrap in Task.Run to fork the ExecutionContext. AIAgent.RunAsync is a non-async // method that synchronously sets the static AsyncLocal CurrentRunContext. Without - // this isolation, the sub-agent's RunAsync would overwrite the outer (calling) + // this isolation, the background agent's RunAsync would overwrite the outer (calling) // agent's CurrentRunContext, corrupting all subsequent tool invocations in the // same FICC batch. runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(input, subSession)); - runtimeState.SubTaskSessions[taskId] = subSession; + runtimeState.BackgroundTaskSessions[taskId] = subSession; this._sessionState.SaveState(session, state); - return $"Sub-task {taskId} started on agent '{agentName}'."; + return $"Background task {taskId} started on agent '{agentName}'."; }, new AIFunctionFactoryOptions { - Name = "SubAgents_StartTask", - Description = "Start a sub-task on a named sub-agent. Returns a confirmation message containing the task ID.", + Name = "BackgroundAgents_StartTask", + Description = "Start a background task on a named background agent. Returns a confirmation message containing the task ID.", SerializerOptions = serializerOptions, }), @@ -287,7 +287,7 @@ public sealed class SubAgentsProvider : AIContextProvider this._sessionState.SaveState(session, state); // Check if any of the requested IDs are already complete. - SubTaskInfo? alreadyComplete = state.Tasks.FirstOrDefault(t => taskIds.Contains(t.Id) && t.Status != SubTaskStatus.Running); + BackgroundTaskInfo? alreadyComplete = state.Tasks.FirstOrDefault(t => taskIds.Contains(t.Id) && t.Status != BackgroundTaskStatus.Running); if (alreadyComplete is not null) { return $"Task {alreadyComplete.Id} is not running; current status: {alreadyComplete.Status}."; @@ -303,7 +303,7 @@ public sealed class SubAgentsProvider : AIContextProvider var completedEntry = waitableTasks.First(t => t.Task == completedTask); // Finalize the completed task. - SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == completedEntry.Id); + BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == completedEntry.Id); if (taskInfo is not null) { FinalizeTask(taskInfo, completedEntry.Task, runtimeState); @@ -314,8 +314,8 @@ public sealed class SubAgentsProvider : AIContextProvider }, new AIFunctionFactoryOptions { - Name = "SubAgents_WaitForFirstCompletion", - Description = "Block until the first of the specified sub-tasks completes. Provide one or more task IDs. Returns a status message containing the ID of the task that completed first.", + Name = "BackgroundAgents_WaitForFirstCompletion", + Description = "Block until the first of the specified background tasks completes. Provide one or more task IDs. Returns a status message containing the ID of the task that completed first.", SerializerOptions = serializerOptions, }), @@ -324,7 +324,7 @@ public sealed class SubAgentsProvider : AIContextProvider { this.TryRefreshTaskState(state, runtimeState, session); - SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId); + BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId); if (taskInfo is null) { return $"Error: No task found with ID {taskId}."; @@ -332,17 +332,17 @@ public sealed class SubAgentsProvider : AIContextProvider return taskInfo.Status switch { - SubTaskStatus.Completed => taskInfo.ResultText ?? "(no output)", - SubTaskStatus.Failed => $"Task failed: {taskInfo.ErrorText ?? "Unknown error"}", - SubTaskStatus.Lost => "Task state was lost (reference unavailable).", - SubTaskStatus.Running => $"Task {taskId} is still running.", + BackgroundTaskStatus.Completed => taskInfo.ResultText ?? "(no output)", + BackgroundTaskStatus.Failed => $"Task failed: {taskInfo.ErrorText ?? "Unknown error"}", + BackgroundTaskStatus.Lost => "Task state was lost (reference unavailable).", + BackgroundTaskStatus.Running => $"Task {taskId} is still running.", _ => $"Task {taskId} has status: {taskInfo.Status}.", }; }, new AIFunctionFactoryOptions { - Name = "SubAgents_GetTaskResults", - Description = "Get the text output of a sub-task by its ID. Returns the result text if complete, or status information if still running or failed.", + Name = "BackgroundAgents_GetTaskResults", + Description = "Get the text output of a background task by its ID. Returns the result text if complete, or status information if still running or failed.", SerializerOptions = serializerOptions, }), @@ -358,7 +358,7 @@ public sealed class SubAgentsProvider : AIContextProvider var sb = new StringBuilder(); sb.AppendLine("Tasks:"); - foreach (SubTaskInfo task in state.Tasks) + foreach (BackgroundTaskInfo task in state.Tasks) { sb.Append("- Task ").Append(task.Id).Append(" [").Append(task.Status).Append("] (").Append(task.AgentName).Append("): ").AppendLine(task.Description); } @@ -367,8 +367,8 @@ public sealed class SubAgentsProvider : AIContextProvider }, new AIFunctionFactoryOptions { - Name = "SubAgents_GetAllTasks", - Description = "List all sub-tasks with their IDs, statuses, agent names, and descriptions.", + Name = "BackgroundAgents_GetAllTasks", + Description = "List all background tasks with their IDs, statuses, agent names, and descriptions.", SerializerOptions = serializerOptions, }), @@ -377,18 +377,18 @@ public sealed class SubAgentsProvider : AIContextProvider { this.TryRefreshTaskState(state, runtimeState, session); - SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId); + BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId); if (taskInfo is null) { return $"Error: No task found with ID {taskId}."; } - if (taskInfo.Status == SubTaskStatus.Lost) + if (taskInfo.Status == BackgroundTaskStatus.Lost) { return $"Error: Task {taskId} cannot be continued because its session was lost (e.g., after a session restore). Start a new task instead."; } - if (taskInfo.Status == SubTaskStatus.Running) + if (taskInfo.Status == BackgroundTaskStatus.Running) { return $"Error: Task {taskId} is still running. Wait for it to complete before continuing."; } @@ -398,17 +398,17 @@ public sealed class SubAgentsProvider : AIContextProvider return $"Error: Agent '{taskInfo.AgentName}' is no longer available."; } - if (!runtimeState.SubTaskSessions.TryGetValue(taskId, out AgentSession? subSession)) + if (!runtimeState.BackgroundTaskSessions.TryGetValue(taskId, out AgentSession? subSession)) { return $"Error: Session for task {taskId} is no longer available."; } // Reset task state and start a new run on the existing session. - taskInfo.Status = SubTaskStatus.Running; + taskInfo.Status = BackgroundTaskStatus.Running; taskInfo.ResultText = null; taskInfo.ErrorText = null; - // Wrap in Task.Run to isolate the ExecutionContext (see StartSubTask comment). + // Wrap in Task.Run to isolate the ExecutionContext (see StartBackgroundTask comment). runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(text, subSession)); this._sessionState.SaveState(session, state); @@ -416,8 +416,8 @@ public sealed class SubAgentsProvider : AIContextProvider }, new AIFunctionFactoryOptions { - Name = "SubAgents_ContinueTask", - Description = "Send follow-up input to a completed or failed sub-task to resume its work. The sub-task's session is preserved, so the agent retains conversational context.", + Name = "BackgroundAgents_ContinueTask", + Description = "Send follow-up input to a completed or failed background task to resume its work. The background task's session is preserved, so the agent retains conversational context.", SerializerOptions = serializerOptions, }), @@ -426,13 +426,13 @@ public sealed class SubAgentsProvider : AIContextProvider { this.TryRefreshTaskState(state, runtimeState, session); - SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId); + BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId); if (taskInfo is null) { return $"Error: No task found with ID {taskId}."; } - if (taskInfo.Status == SubTaskStatus.Running) + if (taskInfo.Status == BackgroundTaskStatus.Running) { return $"Error: Task {taskId} is still running. Wait for it to complete before clearing."; } @@ -442,15 +442,15 @@ public sealed class SubAgentsProvider : AIContextProvider // Clean up runtime references. runtimeState.InFlightTasks.Remove(taskId); - runtimeState.SubTaskSessions.Remove(taskId); + runtimeState.BackgroundTaskSessions.Remove(taskId); this._sessionState.SaveState(session, state); return $"Task {taskId} cleared."; }, new AIFunctionFactoryOptions { - Name = "SubAgents_ClearCompletedTask", - Description = "Remove a completed or failed sub-task and release its session to free memory. Use this after retrieving results when you no longer need to continue the task.", + Name = "BackgroundAgents_ClearCompletedTask", + Description = "Remove a completed or failed background task and release its session to free memory. Use this after retrieving results when you no longer need to continue the task.", SerializerOptions = serializerOptions, }), ]; diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProviderOptions.cs similarity index 72% rename from dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs rename to dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProviderOptions.cs index 27a4c1530d..83d8cd959f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProviderOptions.cs @@ -8,21 +8,21 @@ using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI; /// -/// Options controlling the behavior of . +/// Options controlling the behavior of . /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed class SubAgentsProviderOptions +public sealed class BackgroundAgentsProviderOptions { /// - /// Gets or sets custom instructions provided to the agent for using the sub-agent tools. + /// Gets or sets custom instructions provided to the agent for using the background agent tools. /// /// - /// Use the {sub_agents} placeholder to allow the provider to inject - /// the formatted list of available sub agents. + /// Use the {background_agents} placeholder to allow the provider to inject + /// the formatted list of available background agents. /// /// /// When (the default), the provider uses built-in instructions - /// that guide the agent on how to use the sub-agent tools. + /// that guide the agent on how to use the background agent tools. /// The agent list is always appended after the instructions regardless of this setting. /// public string? Instructions { get; set; } @@ -33,7 +33,7 @@ public sealed class SubAgentsProviderOptions /// /// When (the default), the provider generates a standard list of agent names and descriptions. /// When set, this function receives the dictionary of available agents (keyed by name) and should return - /// a formatted string describing the available sub-agents. + /// a formatted string describing the available background agents. /// public Func, string>? AgentListBuilder { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskInfo.cs b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundTaskInfo.cs similarity index 62% rename from dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskInfo.cs rename to dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundTaskInfo.cs index 91b6084ece..98f36c7e9d 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundTaskInfo.cs @@ -7,43 +7,43 @@ using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI; /// -/// Represents the metadata and result of a sub-task managed by the . +/// Represents the metadata and result of a background task managed by the . /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed class SubTaskInfo +public sealed class BackgroundTaskInfo { /// - /// Gets or sets the unique identifier for this sub-task. + /// Gets or sets the unique identifier for this background task. /// [JsonPropertyName("id")] public int Id { get; set; } /// - /// Gets or sets the name of the agent that is executing this sub-task. + /// Gets or sets the name of the agent that is executing this background task. /// [JsonPropertyName("agentName")] public string AgentName { get; set; } = string.Empty; /// - /// Gets or sets a description of what this sub-task is doing. + /// Gets or sets a description of what this background task is doing. /// [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; /// - /// Gets or sets the current status of this sub-task. + /// Gets or sets the current status of this background task. /// [JsonPropertyName("status")] - public SubTaskStatus Status { get; set; } + public BackgroundTaskStatus Status { get; set; } /// - /// Gets or sets the text result of the sub-task, populated when the task completes successfully. + /// Gets or sets the text result of the background task, populated when the task completes successfully. /// [JsonPropertyName("resultText")] public string? ResultText { get; set; } /// - /// Gets or sets the error message if the sub-task failed. + /// Gets or sets the error message if the background task failed. /// [JsonPropertyName("errorText")] public string? ErrorText { get; set; } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskStatus.cs b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundTaskStatus.cs similarity index 57% rename from dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskStatus.cs rename to dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundTaskStatus.cs index f5e66f6f72..b3dfeee671 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskStatus.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundTaskStatus.cs @@ -6,28 +6,28 @@ using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI; /// -/// Represents the status of a sub-task managed by the . +/// Represents the status of a background task managed by the . /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public enum SubTaskStatus +public enum BackgroundTaskStatus { /// - /// The sub-task is currently running. + /// The background task is currently running. /// Running, /// - /// The sub-task completed successfully. + /// The background task completed successfully. /// Completed, /// - /// The sub-task failed with an error. + /// The background task failed with an error. /// Failed, /// - /// The sub-task's in-flight reference was lost (e.g., after a restart), + /// The background task's in-flight reference was lost (e.g., after a restart), /// and its final state cannot be determined. /// Lost, diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoCompleteInput.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoCompleteInput.cs new file mode 100644 index 0000000000..355c494337 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoCompleteInput.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents the input for completing a single todo item via the . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class TodoCompleteInput +{ + /// + /// Gets or sets the ID of the todo item to mark as complete. + /// + [JsonPropertyName("id")] + public int Id { get; set; } + + /// + /// Gets or sets the reason describing how or why the item was completed. + /// + [JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs index bb39e71c20..429c6b5646 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs @@ -54,7 +54,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable Use these tools to manage your tasks: - Use TodoList_Add to break down complex work into trackable items (supports adding one or many at once). - - Use TodoList_Complete to mark items as done when finished (supports one or many at once). + - Use TodoList_Complete to mark items as done when finished (supports one or many at once). Include a reason describing how the items were completed. - Use TodoList_GetRemaining to check what work is still pending. - Use TodoList_GetAll to review the full list including completed items. - Use TodoList_Remove to remove items that are no longer needed (supports one or many at once). @@ -235,14 +235,14 @@ public sealed class TodoProvider : AIContextProvider, IDisposable }), AIFunctionFactory.Create( - async (List ids) => + async (List items) => { SemaphoreSlim sessionLock = this.GetSessionLock(session); await sessionLock.WaitAsync().ConfigureAwait(false); try { TodoState state = this._sessionState.GetOrInitializeState(session); - var idSet = new HashSet(ids); + var idSet = new HashSet(items.Select(i => i.Id)); int completed = 0; foreach (TodoItem item in state.Items) { @@ -268,7 +268,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable new AIFunctionFactoryOptions { Name = "TodoList_Complete", - Description = "Mark one or more todo items as complete by their IDs. Returns the number of items that were found and marked complete.", + Description = "Mark one or more todo items as complete. Each entry has an ID and a reason describing how/why the item was completed. Returns the number of items that were found and marked complete.", SerializerOptions = serializerOptions, }), diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs similarity index 77% rename from dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs rename to dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs index 4f76d610b3..b8a6051a4c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs @@ -13,9 +13,9 @@ using Moq.Protected; namespace Microsoft.Agents.AI.UnitTests; /// -/// Unit tests for the class. +/// Unit tests for the class. /// -public class SubAgentsProviderTests +public class BackgroundAgentsProviderTests { #region Constructor Tests @@ -26,7 +26,7 @@ public class SubAgentsProviderTests public void Constructor_NullAgents_Throws() { // Act & Assert - Assert.Throws(() => new SubAgentsProvider(null!)); + Assert.Throws(() => new BackgroundAgentsProvider(null!)); } /// @@ -36,7 +36,7 @@ public class SubAgentsProviderTests public void Constructor_EmptyAgents_Throws() { // Act & Assert - Assert.Throws(() => new SubAgentsProvider(Array.Empty())); + Assert.Throws(() => new BackgroundAgentsProvider(Array.Empty())); } /// @@ -49,7 +49,7 @@ public class SubAgentsProviderTests var agent = CreateMockAgent(null!, "desc"); // Act & Assert - Assert.Throws(() => new SubAgentsProvider(new[] { agent })); + Assert.Throws(() => new BackgroundAgentsProvider(new[] { agent })); } /// @@ -62,7 +62,7 @@ public class SubAgentsProviderTests var agent = CreateMockAgent("", "desc"); // Act & Assert - Assert.Throws(() => new SubAgentsProvider(new[] { agent })); + Assert.Throws(() => new BackgroundAgentsProvider(new[] { agent })); } /// @@ -76,7 +76,7 @@ public class SubAgentsProviderTests var agent2 = CreateMockAgent("research", "Agent 2"); // Act & Assert - Assert.Throws(() => new SubAgentsProvider(new[] { agent1, agent2 })); + Assert.Throws(() => new BackgroundAgentsProvider(new[] { agent1, agent2 })); } /// @@ -90,7 +90,7 @@ public class SubAgentsProviderTests var agent2 = CreateMockAgent("Writer", "Writer agent"); // Act - var provider = new SubAgentsProvider(new[] { agent1, agent2 }); + var provider = new BackgroundAgentsProvider(new[] { agent1, agent2 }); // Assert Assert.NotNull(provider); @@ -108,7 +108,7 @@ public class SubAgentsProviderTests { // Arrange var agent = CreateMockAgent("Research", "Research agent"); - var provider = new SubAgentsProvider(new[] { agent }); + var provider = new BackgroundAgentsProvider(new[] { agent }); var context = CreateInvokingContext(); // Act @@ -129,7 +129,7 @@ public class SubAgentsProviderTests // Arrange var agent1 = CreateMockAgent("Research", "Performs research"); var agent2 = CreateMockAgent("Writer", "Writes content"); - var provider = new SubAgentsProvider(new[] { agent1, agent2 }); + var provider = new BackgroundAgentsProvider(new[] { agent1, agent2 }); var context = CreateInvokingContext(); // Act @@ -144,22 +144,22 @@ public class SubAgentsProviderTests #endregion - #region StartSubTask Tests + #region StartBackgroundTask Tests /// - /// Verify that StartSubTask returns a task ID. + /// Verify that StartBackgroundTask returns a task ID. /// [Fact] - public async Task StartSubTask_ReturnsTaskIdAsync() + public async Task StartBackgroundTask_ReturnsTaskIdAsync() { // Arrange var tcs = new TaskCompletionSource(); var agent = CreateMockAgentWithRunResult("Research", tcs.Task); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); + AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask"); // Act - object? result = await startSubTask.InvokeAsync(new AIFunctionArguments + object? result = await startBackgroundTask.InvokeAsync(new AIFunctionArguments { ["agentName"] = "Research", ["input"] = "Find information about AI", @@ -175,18 +175,18 @@ public class SubAgentsProviderTests } /// - /// Verify that StartSubTask with invalid agent name returns an error. + /// Verify that StartBackgroundTask with invalid agent name returns an error. /// [Fact] - public async Task StartSubTask_InvalidAgentName_ReturnsErrorAsync() + public async Task StartBackgroundTask_InvalidAgentName_ReturnsErrorAsync() { // Arrange var agent = CreateMockAgent("Research", "Research agent"); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); + AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask"); // Act - object? result = await startSubTask.InvokeAsync(new AIFunctionArguments + object? result = await startBackgroundTask.InvokeAsync(new AIFunctionArguments { ["agentName"] = "NonExistent", ["input"] = "Some input", @@ -200,10 +200,10 @@ public class SubAgentsProviderTests } /// - /// Verify that StartSubTask assigns sequential IDs. + /// Verify that StartBackgroundTask assigns sequential IDs. /// [Fact] - public async Task StartSubTask_AssignsSequentialIdsAsync() + public async Task StartBackgroundTask_AssignsSequentialIdsAsync() { // Arrange var tcs1 = new TaskCompletionSource(); @@ -215,16 +215,16 @@ public class SubAgentsProviderTests return callCount == 1 ? tcs1.Task : tcs2.Task; }); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); + AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask"); // Act - object? result1 = await startSubTask.InvokeAsync(new AIFunctionArguments + object? result1 = await startBackgroundTask.InvokeAsync(new AIFunctionArguments { ["agentName"] = "Research", ["input"] = "Task 1", ["description"] = "First task", }); - object? result2 = await startSubTask.InvokeAsync(new AIFunctionArguments + object? result2 = await startBackgroundTask.InvokeAsync(new AIFunctionArguments { ["agentName"] = "Research", ["input"] = "Task 2", @@ -253,11 +253,11 @@ public class SubAgentsProviderTests var tcs = new TaskCompletionSource(); var agent = CreateMockAgentWithRunResult("Research", tcs.Task); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); - AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion"); + AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask"); + AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion"); // Start one task - await startSubTask.InvokeAsync(new AIFunctionArguments + await startBackgroundTask.InvokeAsync(new AIFunctionArguments { ["agentName"] = "Research", ["input"] = "Task 1", @@ -288,7 +288,7 @@ public class SubAgentsProviderTests // Arrange var agent = CreateMockAgent("Research", "Research agent"); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion"); + AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion"); // Act object? result = await waitForFirst.InvokeAsync(new AIFunctionArguments @@ -302,24 +302,24 @@ public class SubAgentsProviderTests #endregion - #region GetSubTaskResults Tests + #region GetBackgroundTaskResults Tests /// - /// Verify that GetSubTaskResults returns the result text of a completed task. + /// Verify that GetBackgroundTaskResults returns the result text of a completed task. /// [Fact] - public async Task GetSubTaskResults_CompletedTask_ReturnsResultTextAsync() + public async Task GetBackgroundTaskResults_CompletedTask_ReturnsResultTextAsync() { // Arrange var tcs = new TaskCompletionSource(); var agent = CreateMockAgentWithRunResult("Research", tcs.Task); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); - AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion"); - AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults"); + AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask"); + AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion"); + AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults"); // Start a task - await startSubTask.InvokeAsync(new AIFunctionArguments + await startBackgroundTask.InvokeAsync(new AIFunctionArguments { ["agentName"] = "Research", ["input"] = "Research AI", @@ -346,20 +346,20 @@ public class SubAgentsProviderTests } /// - /// Verify that GetSubTaskResults for a still-running task returns status info. + /// Verify that GetBackgroundTaskResults for a still-running task returns status info. /// [Fact] - public async Task GetSubTaskResults_RunningTask_ReturnsStatusAsync() + public async Task GetBackgroundTaskResults_RunningTask_ReturnsStatusAsync() { // Arrange var tcs = new TaskCompletionSource(); var agent = CreateMockAgentWithRunResult("Research", tcs.Task); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); - AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults"); + AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask"); + AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults"); // Start a task (don't complete it) - await startSubTask.InvokeAsync(new AIFunctionArguments + await startBackgroundTask.InvokeAsync(new AIFunctionArguments { ["agentName"] = "Research", ["input"] = "Research AI", @@ -379,15 +379,15 @@ public class SubAgentsProviderTests } /// - /// Verify that GetSubTaskResults for a nonexistent task returns an error. + /// Verify that GetBackgroundTaskResults for a nonexistent task returns an error. /// [Fact] - public async Task GetSubTaskResults_NonexistentTask_ReturnsErrorAsync() + public async Task GetBackgroundTaskResults_NonexistentTask_ReturnsErrorAsync() { // Arrange var agent = CreateMockAgent("Research", "Research agent"); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults"); + AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults"); // Act object? result = await getResults.InvokeAsync(new AIFunctionArguments @@ -400,21 +400,21 @@ public class SubAgentsProviderTests } /// - /// Verify that GetSubTaskResults for a failed task returns the error. + /// Verify that GetBackgroundTaskResults for a failed task returns the error. /// [Fact] - public async Task GetSubTaskResults_FailedTask_ReturnsErrorTextAsync() + public async Task GetBackgroundTaskResults_FailedTask_ReturnsErrorTextAsync() { // Arrange var tcs = new TaskCompletionSource(); var agent = CreateMockAgentWithRunResult("Research", tcs.Task); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); - AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion"); - AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults"); + AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask"); + AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion"); + AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults"); // Start a task - await startSubTask.InvokeAsync(new AIFunctionArguments + await startBackgroundTask.InvokeAsync(new AIFunctionArguments { ["agentName"] = "Research", ["input"] = "Research AI", @@ -456,11 +456,11 @@ public class SubAgentsProviderTests var tcs = new TaskCompletionSource(); var agent = CreateMockAgentWithRunResult("Research", tcs.Task); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); - AIFunction getAllTasks = GetTool(tools, "SubAgents_GetAllTasks"); + AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask"); + AIFunction getAllTasks = GetTool(tools, "BackgroundAgents_GetAllTasks"); // Start a task - await startSubTask.InvokeAsync(new AIFunctionArguments + await startBackgroundTask.InvokeAsync(new AIFunctionArguments { ["agentName"] = "Research", ["input"] = "Research AI", @@ -490,12 +490,12 @@ public class SubAgentsProviderTests var tcs = new TaskCompletionSource(); var agent = CreateMockAgentWithRunResult("Research", tcs.Task); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); - AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion"); - AIFunction getAllTasks = GetTool(tools, "SubAgents_GetAllTasks"); + AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask"); + AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion"); + AIFunction getAllTasks = GetTool(tools, "BackgroundAgents_GetAllTasks"); // Start and complete a task - await startSubTask.InvokeAsync(new AIFunctionArguments + await startBackgroundTask.InvokeAsync(new AIFunctionArguments { ["agentName"] = "Research", ["input"] = "Research AI", @@ -525,7 +525,7 @@ public class SubAgentsProviderTests // Arrange var agent = CreateMockAgent("Research", "Research agent"); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction getAllTasks = GetTool(tools, "SubAgents_GetAllTasks"); + AIFunction getAllTasks = GetTool(tools, "BackgroundAgents_GetAllTasks"); // Act object? result = await getAllTasks.InvokeAsync(new AIFunctionArguments()); @@ -554,13 +554,13 @@ public class SubAgentsProviderTests return callCount == 1 ? tcs1.Task : tcs2.Task; }); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); - AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion"); - AIFunction continueTask = GetTool(tools, "SubAgents_ContinueTask"); - AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults"); + AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask"); + AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion"); + AIFunction continueTask = GetTool(tools, "BackgroundAgents_ContinueTask"); + AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults"); // Start and complete a task - await startSubTask.InvokeAsync(new AIFunctionArguments + await startBackgroundTask.InvokeAsync(new AIFunctionArguments { ["agentName"] = "Research", ["input"] = "Research AI", @@ -606,11 +606,11 @@ public class SubAgentsProviderTests var tcs = new TaskCompletionSource(); var agent = CreateMockAgentWithRunResult("Research", tcs.Task); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); - AIFunction continueTask = GetTool(tools, "SubAgents_ContinueTask"); + AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask"); + AIFunction continueTask = GetTool(tools, "BackgroundAgents_ContinueTask"); // Start a task (don't complete it) - await startSubTask.InvokeAsync(new AIFunctionArguments + await startBackgroundTask.InvokeAsync(new AIFunctionArguments { ["agentName"] = "Research", ["input"] = "Research AI", @@ -639,7 +639,7 @@ public class SubAgentsProviderTests // Arrange var agent = CreateMockAgent("Research", "Research agent"); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction continueTask = GetTool(tools, "SubAgents_ContinueTask"); + AIFunction continueTask = GetTool(tools, "BackgroundAgents_ContinueTask"); // Act object? result = await continueTask.InvokeAsync(new AIFunctionArguments @@ -666,13 +666,13 @@ public class SubAgentsProviderTests var tcs = new TaskCompletionSource(); var agent = CreateMockAgentWithRunResult("Research", tcs.Task); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); - AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion"); - AIFunction clearTask = GetTool(tools, "SubAgents_ClearCompletedTask"); - AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults"); + AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask"); + AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion"); + AIFunction clearTask = GetTool(tools, "BackgroundAgents_ClearCompletedTask"); + AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults"); // Start and complete a task - await startSubTask.InvokeAsync(new AIFunctionArguments + await startBackgroundTask.InvokeAsync(new AIFunctionArguments { ["agentName"] = "Research", ["input"] = "Research AI", @@ -711,11 +711,11 @@ public class SubAgentsProviderTests var tcs = new TaskCompletionSource(); var agent = CreateMockAgentWithRunResult("Research", tcs.Task); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); - AIFunction clearTask = GetTool(tools, "SubAgents_ClearCompletedTask"); + AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask"); + AIFunction clearTask = GetTool(tools, "BackgroundAgents_ClearCompletedTask"); // Start a task (don't complete it) - await startSubTask.InvokeAsync(new AIFunctionArguments + await startBackgroundTask.InvokeAsync(new AIFunctionArguments { ["agentName"] = "Research", ["input"] = "Research AI", @@ -743,7 +743,7 @@ public class SubAgentsProviderTests // Arrange var agent = CreateMockAgent("Research", "Research agent"); var (tools, _) = await CreateToolsWithProviderAsync(agent); - AIFunction clearTask = GetTool(tools, "SubAgents_ClearCompletedTask"); + AIFunction clearTask = GetTool(tools, "BackgroundAgents_ClearCompletedTask"); // Act object? result = await clearTask.InvokeAsync(new AIFunctionArguments @@ -767,7 +767,7 @@ public class SubAgentsProviderTests { // Arrange var agent = CreateMockAgent("Research", "Research agent"); - var provider = new SubAgentsProvider(new[] { agent }); + var provider = new BackgroundAgentsProvider(new[] { agent }); // Act var keys = provider.StateKeys; @@ -782,23 +782,23 @@ public class SubAgentsProviderTests #region CurrentRunContext Isolation Tests /// - /// Verify that StartSubTask does not corrupt CurrentRunContext of the calling agent. + /// Verify that StartBackgroundTask does not corrupt CurrentRunContext of the calling agent. /// Because RunAsync is a non-async method that synchronously sets the static AsyncLocal - /// CurrentRunContext, the provider must isolate the sub-agent call to prevent overwriting + /// CurrentRunContext, the provider must isolate the background agent call to prevent overwriting /// the outer agent's context. /// [Fact] - public async Task StartSubTask_DoesNotCorruptCurrentRunContextAsync() + public async Task StartBackgroundTask_DoesNotCorruptCurrentRunContextAsync() { // Arrange var tcs = new TaskCompletionSource(); var agent = CreateMockAgentWithRunResult("Research", tcs.Task); var (tools, _) = await CreateToolsWithProviderAsync(agent); - var startTool = GetTool(tools, "SubAgents_StartTask"); + var startTool = GetTool(tools, "BackgroundAgents_StartTask"); AgentRunContext? contextBefore = AIAgent.CurrentRunContext; - // Act — invoke StartSubTask; this calls agent.RunAsync internally. + // Act — invoke StartBackgroundTask; this calls agent.RunAsync internally. var args = new AIFunctionArguments(new Dictionary { ["agentName"] = "Research", @@ -826,16 +826,16 @@ public class SubAgentsProviderTests { // Arrange var agent = CreateMockAgent("Research", "Research agent"); - const string CustomInstructions = "These are custom sub-agent instructions.\n{sub_agents}"; - var options = new SubAgentsProviderOptions { Instructions = CustomInstructions }; - var provider = new SubAgentsProvider(new[] { agent }, options); + const string CustomInstructions = "These are custom background agent instructions.\n{background_agents}"; + var options = new BackgroundAgentsProviderOptions { Instructions = CustomInstructions }; + var provider = new BackgroundAgentsProvider(new[] { agent }, options); var context = CreateInvokingContext(); // Act AIContext result = await provider.InvokingAsync(context); // Assert — custom instructions replace default, agent list is injected via {sub_agents} placeholder - Assert.Contains("These are custom sub-agent instructions.", result.Instructions); + Assert.Contains("These are custom background agent instructions.", result.Instructions); Assert.Contains("Research", result.Instructions); } @@ -847,15 +847,15 @@ public class SubAgentsProviderTests { // Arrange var agent = CreateMockAgent("Research", "Research agent"); - var provider = new SubAgentsProvider(new[] { agent }); + var provider = new BackgroundAgentsProvider(new[] { agent }); var context = CreateInvokingContext(); // Act AIContext result = await provider.InvokingAsync(context); // Assert — instructions contain tool usage guidance and agent list - Assert.Contains("SubAgents_*", result.Instructions); - Assert.Contains("SubAgents_ClearCompletedTask", result.Instructions); + Assert.Contains("BackgroundAgents_*", result.Instructions); + Assert.Contains("BackgroundAgents_ClearCompletedTask", result.Instructions); Assert.Contains("Research", result.Instructions); Assert.Contains("Research agent", result.Instructions); } @@ -868,11 +868,11 @@ public class SubAgentsProviderTests { // Arrange var agent = CreateMockAgent("Research", "Research agent"); - var options = new SubAgentsProviderOptions + var options = new BackgroundAgentsProviderOptions { AgentListBuilder = agents => $"Custom list: {string.Join(", ", agents.Keys)}", }; - var provider = new SubAgentsProvider(new[] { agent }, options); + var provider = new BackgroundAgentsProvider(new[] { agent }, options); var context = CreateInvokingContext(); // Act @@ -880,7 +880,7 @@ public class SubAgentsProviderTests // Assert — custom agent list builder output is in instructions Assert.Contains("Custom list: Research", result.Instructions); - Assert.DoesNotContain("Available sub-agents:", result.Instructions); + Assert.DoesNotContain("Available background agents:", result.Instructions); } #endregion @@ -935,9 +935,9 @@ public class SubAgentsProviderTests return mock.Object; } - private static async Task<(IEnumerable Tools, SubAgentsProvider Provider)> CreateToolsWithProviderAsync(AIAgent agent) + private static async Task<(IEnumerable Tools, BackgroundAgentsProvider Provider)> CreateToolsWithProviderAsync(AIAgent agent) { - var provider = new SubAgentsProvider(new[] { agent }); + var provider = new BackgroundAgentsProvider(new[] { agent }); var context = CreateInvokingContext(); AIContext result = await provider.InvokingAsync(context); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs index d2724478d3..c733d8efe7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs @@ -116,7 +116,7 @@ public class TodoProviderTests await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List { new() { Title = "Test", Description = null } } }); // Act - object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1 } }); + object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List { new() { Id = 1, Reason = "Done" } } }); // Assert Assert.True(state.Items[0].IsComplete); @@ -139,7 +139,7 @@ public class TodoProviderTests }); // Act - object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1, 3 } }); + object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List { new() { Id = 1, Reason = "Done" }, new() { Id = 3, Reason = "Done" } } }); // Assert Assert.True(state.Items[0].IsComplete); @@ -159,12 +159,35 @@ public class TodoProviderTests AIFunction completeTodos = GetTool(tools, "TodoList_Complete"); // Act - object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 999 } }); + object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List { new() { Id = 999, Reason = "Done" } } }); // Assert Assert.Equal(0, GetIntResult(result)); } + /// + /// Verify that CompleteTodos accepts an optional reason parameter. + /// + [Fact] + public async Task CompleteTodos_AcceptsReasonParameterAsync() + { + // Arrange + var (tools, state) = await CreateToolsWithStateAsync(); + AIFunction addTodos = GetTool(tools, "TodoList_Add"); + AIFunction completeTodos = GetTool(tools, "TodoList_Complete"); + await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List { new() { Title = "Research topic" } } }); + + // Act + object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() + { + ["items"] = new List { new() { Id = 1, Reason = "Found the answer in the documentation." } }, + }); + + // Assert + Assert.True(state.Items[0].IsComplete); + Assert.Equal(1, GetIntResult(result)); + } + #endregion #region RemoveTodos Tests @@ -249,7 +272,7 @@ public class TodoProviderTests { ["todos"] = new List { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } }, }); - await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1 } }); + await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List { new() { Id = 1, Reason = "Done" } } }); // Act object? result = await getRemainingTodos.InvokeAsync(new AIFunctionArguments()); @@ -279,7 +302,7 @@ public class TodoProviderTests { ["todos"] = new List { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } }, }); - await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1 } }); + await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List { new() { Id = 1, Reason = "Done" } } }); // Act object? result = await getAllTodos.InvokeAsync(new AIFunctionArguments()); @@ -376,7 +399,7 @@ public class TodoProviderTests { ["todos"] = new List { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } }, }); - await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1 } }); + await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List { new() { Id = 1, Reason = "Done" } } }); // Act var remaining = await provider.GetRemainingTodosAsync(session); @@ -543,7 +566,7 @@ public class TodoProviderTests new() { Title = "Second", Description = "Has details" }, }, }); - await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1 } }); + await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List { new() { Id = 1, Reason = "Done" } } }); // Act — second invocation should see the updated list in messages AIContext result2 = await provider.InvokingAsync(context); @@ -762,7 +785,7 @@ public class TodoProviderTests { ["todos"] = new List { new() { Title = "New C" } }, }).AsTask(), - completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1, 2, 3 } }).AsTask()); + completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List { new() { Id = 1, Reason = "Done" }, new() { Id = 2, Reason = "Done" }, new() { Id = 3, Reason = "Done" } } }).AsTask()); // Assert object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments());