From d40670748d251d1c20c848a5c16417c240b8d5fe Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 14 May 2026 11:28:22 +0100 Subject: [PATCH 01/65] [BREAKING] Python: Align file skill folder discovery with agentskills.io spec (#5807) * Align Python FileSkillsSource with agentskills.io spec Update FileSkillsSource to scan spec-defined subdirectories instead of recursive rglob for resource and script discovery: - Resources: scan 'references/' and 'assets/' (was: entire skill tree) - Scripts: scan 'scripts/' (was: entire skill tree) - Add resource_directories and script_directories parameters for customization, with '.' root indicator for skill root files - Add directory validation: reject '..' traversal, absolute paths, empty names; normalize separators and deduplicate directories - Non-recursive scanning within each configured directory (top-level only) - Containment check validates files against target directory, not just skill root, for stronger path-traversal defense - Case-insensitive directory deduplication via os.path.normcase() - Cross-platform absolute path rejection in directory validation - Sort discovery results for stable ordering - Update SkillsProvider.from_paths() to pass new parameters through - Update all tests for new subdirectory-scoped discovery behavior Resolves #5711. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review: tighten path validation and add containment guard - Narrow Windows absolute path check to proper drive-root pattern (re.match r'^[A-Za-z]:[/\\]') to avoid rejecting valid POSIX names - Add _is_path_within_directory guard before _has_symlink_in_path in both discovery methods to prevent ValueError on escaped paths Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Log warning on OSError during directory listing in skill discovery Address review comment: _discover_resource_files and _discover_script_files previously swallowed OSError silently when iterdir() failed. Now log a warning so permission errors and transient FS failures are visible instead of making resource/script directories silently disappear. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/_skills.py | 322 +++++++++-- .../packages/core/tests/core/test_skills.py | 523 ++++++++++++++---- 2 files changed, 685 insertions(+), 160 deletions(-) diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index 1128a938ea..91b583aaab 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -1430,6 +1430,13 @@ DEFAULT_RESOURCE_EXTENSIONS: Final[tuple[str, ...]] = ( ) DEFAULT_SCRIPT_EXTENSIONS: Final[tuple[str, ...]] = (".py",) +# "." means the skill directory root itself (files directly in the skill folder). +ROOT_DIRECTORY_INDICATOR: Final[str] = "." + +# Standard subdirectory names per https://agentskills.io/specification#directory-structure +DEFAULT_RESOURCE_DIRECTORIES: Final[tuple[str, ...]] = ("references", "assets") +DEFAULT_SCRIPT_DIRECTORIES: Final[tuple[str, ...]] = ("scripts",) + # region Patterns and prompt template # Matches YAML frontmatter delimited by "---" lines. @@ -1650,6 +1657,8 @@ class SkillsProvider(ContextProvider): script_runner: SkillScriptRunner | None = None, resource_extensions: tuple[str, ...] | None = None, script_extensions: tuple[str, ...] | None = None, + resource_directories: Sequence[str] | None = None, + script_directories: Sequence[str] | None = None, instruction_template: str | None = None, require_script_approval: bool = False, disable_caching: bool = False, @@ -1672,6 +1681,15 @@ class SkillsProvider(ContextProvider): ``(".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt")``. script_extensions: File extensions recognized as discoverable scripts. Defaults to ``(".py",)``. + resource_directories: Relative directory paths to scan for + resource files within each skill directory. Use ``"."`` + to include files at the skill root level. Defaults to + ``("references", "assets")`` per the agentskills.io + specification. + script_directories: Relative directory paths to scan for + script files within each skill directory. Use ``"."`` + to include files at the skill root level. Defaults to + ``("scripts",)`` per the agentskills.io specification. instruction_template: Custom system-prompt template for advertising skills. Must contain a ``{skills}`` placeholder. Uses a built-in template when ``None``. @@ -1701,6 +1719,8 @@ class SkillsProvider(ContextProvider): script_runner=script_runner, resource_extensions=resource_extensions, script_extensions=script_extensions, + resource_directories=resource_directories, + script_directories=script_directories, ) ) return cls( @@ -2186,7 +2206,15 @@ class FileSkillsSource(SkillsSource): Recursively scans the configured *skill_paths* directories for ``SKILL.md`` files (up to 2 levels deep), parses their YAML frontmatter, - and discovers associated resource and script files from subdirectories. + and discovers associated resource and script files from spec-defined + subdirectories. + + By default, resources are discovered from ``references/`` and ``assets/`` + subdirectories, and scripts from ``scripts/``, per the + `agentskills.io specification + `_. Use *resource_directories* + and *script_directories* to customize which subdirectories are scanned. + Pass ``"."`` to include files at the skill root level. Security: file-based metadata is XML-escaped before prompt injection, and resource reads are guarded against path traversal and symlink escape. @@ -2200,14 +2228,15 @@ class FileSkillsSource(SkillsSource): source = FileSkillsSource(skill_paths="./skills") skills = await source.get_skills() - With a script runner and custom extensions: + With a script runner and custom directories: .. code-block:: python source = FileSkillsSource( skill_paths=["./skills", "./more-skills"], script_runner=my_runner, - script_extensions=(".py", ".sh"), + resource_directories=[".", "references", "assets"], + script_directories=["scripts"], ) """ @@ -2218,6 +2247,8 @@ class FileSkillsSource(SkillsSource): script_runner: SkillScriptRunner | None = None, resource_extensions: tuple[str, ...] | None = None, script_extensions: tuple[str, ...] | None = None, + resource_directories: Sequence[str] | None = None, + script_directories: Sequence[str] | None = None, ) -> None: """Initialize a FileSkillsSource. @@ -2237,6 +2268,18 @@ class FileSkillsSource(SkillsSource): ``(".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt")``. script_extensions: File extensions recognized as discoverable scripts. Defaults to ``(".py",)``. + resource_directories: Relative directory paths to scan for + resource files within each skill directory. Use ``"."`` + to include files at the skill root level. Defaults to + ``("references", "assets")`` per the + `agentskills.io specification + `_. + script_directories: Relative directory paths to scan for + script files within each skill directory. Use ``"."`` + to include files at the skill root level. Defaults to + ``("scripts",)`` per the + `agentskills.io specification + `_. """ if isinstance(skill_paths, (str, Path)): self._skill_paths: list[str] = [str(skill_paths)] @@ -2247,6 +2290,17 @@ class FileSkillsSource(SkillsSource): self._resource_extensions = resource_extensions or DEFAULT_RESOURCE_EXTENSIONS self._script_extensions = script_extensions or DEFAULT_SCRIPT_EXTENSIONS + self._resource_directories: tuple[str, ...] = ( + tuple(FileSkillsSource._validate_and_normalize_directory_names(resource_directories)) + if resource_directories is not None + else DEFAULT_RESOURCE_DIRECTORIES + ) + self._script_directories: tuple[str, ...] = ( + tuple(FileSkillsSource._validate_and_normalize_directory_names(script_directories)) + if script_directories is not None + else DEFAULT_SCRIPT_DIRECTORIES + ) + async def get_skills(self) -> list[Skill]: """Discover and return all file-based skills from configured paths. @@ -2284,12 +2338,16 @@ class FileSkillsSource(SkillsSource): ) # Discover and attach file-based resources - for rn in FileSkillsSource._discover_resource_files(skill_path, self._resource_extensions): + for rn in FileSkillsSource._discover_resource_files( + skill_path, self._resource_extensions, self._resource_directories + ): resource_full_path = FileSkillsSource._get_validated_resource_path(skill_path, rn) file_skill.resources.append(_FileSkillResource(name=rn, full_path=resource_full_path)) # Discover and attach file-based scripts as SkillScript instances - for sn in FileSkillsSource._discover_script_files(skill_path, self._script_extensions): + for sn in FileSkillsSource._discover_script_files( + skill_path, self._script_extensions, self._script_directories + ): script_full_path = os.path.normpath(os.path.join(skill_path, sn)) # noqa: ASYNC240 file_skill.scripts.append( FileSkillScript(name=sn, full_path=script_full_path, runner=self._script_runner) @@ -2369,116 +2427,274 @@ class FileSkillsSource(SkillsSource): return True return False + @staticmethod + def _validate_and_normalize_directory_names( + directories: Sequence[str], + ) -> list[str]: + """Validate and normalize relative directory names. + + Ensures each entry is a safe relative path. The ``"."`` root indicator + is passed through unchanged. Entries containing ``..`` segments or + representing absolute paths are rejected with a warning and skipped. + Empty or whitespace-only entries raise :class:`ValueError`. + + Args: + directories: Sequence of relative directory names to validate. + + Returns: + A list of validated, normalized directory names. + + Raises: + ValueError: If any entry is empty or whitespace-only. + """ + result: list[str] = [] + for directory in directories: + if not directory or not directory.strip(): + raise ValueError("Directory names must not be empty or whitespace.") + + # Normalize separators: backslash → forward slash, strip leading ./ and trailing / + normalized = PurePosixPath(directory.replace("\\", "/")).as_posix() + + # "." and "./" both normalize to "." — treat as root indicator + if normalized == ROOT_DIRECTORY_INDICATOR: + result.append(ROOT_DIRECTORY_INDICATOR) + continue + + # Reject absolute paths (check both POSIX and Windows-style roots + # so validation is consistent regardless of the host OS) + if ( + os.path.isabs(directory) + or normalized.startswith("/") + or re.match(r"^[A-Za-z]:[/\\]", directory) + ): + logger.warning( + "Skipping directory '%s': absolute paths are not allowed.", + directory, + ) + continue + + # Reject paths containing ".." segments + if any(segment == ".." for segment in normalized.split("/")): + logger.warning( + "Skipping directory '%s': parent traversal ('..') is not allowed.", + directory, + ) + continue + + result.append(normalized) + return result + @staticmethod def _discover_resource_files( skill_dir_path: str, extensions: tuple[str, ...] = DEFAULT_RESOURCE_EXTENSIONS, + directories: tuple[str, ...] = DEFAULT_RESOURCE_DIRECTORIES, ) -> list[str]: - """Scan a skill directory for resource files matching *extensions*. + """Scan configured subdirectories for resource files matching *extensions*. - Recursively walks *skill_dir_path* and collects files whose extension - is in *extensions*, excluding ``SKILL.md`` itself. Each candidate is - validated against path-traversal and symlink-escape checks; unsafe - files are skipped with a warning. + Scans each directory in *directories* within *skill_dir_path* for files + whose extension is in *extensions*, excluding ``SKILL.md`` itself. + Use ``"."`` in *directories* to include files at the skill root level. + Each candidate is validated against path-traversal and symlink-escape + checks; unsafe files are skipped with a warning. Args: skill_dir_path: Absolute path to the skill directory to scan. extensions: Tuple of allowed file extensions (e.g. ``(".md", ".json")``). + directories: Relative subdirectory paths to scan for resources. Returns: - Relative resource paths (forward-slash-separated) for every + Sorted relative resource paths (forward-slash-separated) for every discovered file that passes security checks. """ skill_dir = Path(skill_dir_path).absolute() root_directory_path = str(skill_dir) resources: list[str] = [] normalized_extensions = {e.lower() for e in extensions} + seen_directories: set[str] = set() - for resource_file in skill_dir.rglob("*"): - if not resource_file.is_file(): + for directory in directories: + is_root = directory == ROOT_DIRECTORY_INDICATOR + target_dir = skill_dir if is_root else (skill_dir / directory) + + # Deduplicate after resolving to avoid scanning the same directory twice. + # Use normcase for case-insensitive dedup on case-insensitive filesystems. + resolved_target = str(Path(os.path.normpath(target_dir)).absolute()) + dedup_key = os.path.normcase(resolved_target) + if dedup_key in seen_directories: + continue + seen_directories.add(dedup_key) + + if not target_dir.is_dir(): continue - if resource_file.name.upper() == SKILL_FILE_NAME.upper(): - continue + # Directory-level containment and symlink checks for non-root directories + if not is_root: + if not FileSkillsSource._is_path_within_directory(resolved_target, root_directory_path): + logger.warning( + "Skipping resource directory '%s': resolves outside skill directory '%s'", + directory, + skill_dir_path, + ) + continue - if resource_file.suffix.lower() not in normalized_extensions: - continue + if FileSkillsSource._has_symlink_in_path(resolved_target, root_directory_path): + logger.warning( + "Skipping resource directory '%s': symlink detected in path under skill directory '%s'", + directory, + skill_dir_path, + ) + continue - resource_full_path = str(Path(os.path.normpath(resource_file)).absolute()) - - if not FileSkillsSource._is_path_within_directory(resource_full_path, root_directory_path): + # Scan top-level files only (non-recursive) within this directory + try: + entries = list(target_dir.iterdir()) + except OSError: logger.warning( - "Skipping resource '%s': resolves outside skill directory '%s'", - resource_file, + "Failed to list resource directory '%s' in skill directory '%s'; skipping.", + directory, skill_dir_path, ) continue - if FileSkillsSource._has_symlink_in_path(resource_full_path, root_directory_path): - logger.warning( - "Skipping resource '%s': symlink detected in path under skill directory '%s'", - resource_file, - skill_dir_path, - ) - continue + for resource_file in entries: + if not resource_file.is_file(): + continue - rel_path = resource_file.relative_to(skill_dir) - resources.append(FileSkillsSource._normalize_resource_path(str(rel_path))) + if resource_file.name.upper() == SKILL_FILE_NAME.upper(): + continue + if resource_file.suffix.lower() not in normalized_extensions: + continue + + resource_full_path = str(Path(os.path.normpath(resource_file)).absolute()) + + # Containment check: file must resolve within the target directory + if not FileSkillsSource._is_path_within_directory(resource_full_path, resolved_target): + logger.warning( + "Skipping resource '%s': resolves outside target directory '%s'", + resource_file, + directory, + ) + continue + + if FileSkillsSource._has_symlink_in_path(resource_full_path, root_directory_path): + logger.warning( + "Skipping resource '%s': symlink detected in path under skill directory '%s'", + resource_file, + skill_dir_path, + ) + continue + + rel_path = resource_file.relative_to(skill_dir) + resources.append(FileSkillsSource._normalize_resource_path(str(rel_path))) + + resources.sort() return resources @staticmethod def _discover_script_files( skill_dir_path: str, extensions: tuple[str, ...] = DEFAULT_SCRIPT_EXTENSIONS, + directories: tuple[str, ...] = DEFAULT_SCRIPT_DIRECTORIES, ) -> list[str]: - """Scan a skill directory for script files matching *extensions*. + """Scan configured subdirectories for script files matching *extensions*. - Recursively walks *skill_dir_path* and collects files whose extension - is in *extensions*. Each candidate is validated against path-traversal - and symlink-escape checks; unsafe files are skipped with a warning. + Scans each directory in *directories* within *skill_dir_path* for files + whose extension is in *extensions*. Use ``"."`` in *directories* to + include files at the skill root level. Each candidate is validated + against path-traversal and symlink-escape checks; unsafe files are + skipped with a warning. Args: skill_dir_path: Absolute path to the skill directory to scan. extensions: Tuple of allowed script extensions (e.g. ``(".py",)``). + directories: Relative subdirectory paths to scan for scripts. Returns: - Relative script paths (forward-slash-separated) for every + Sorted relative script paths (forward-slash-separated) for every discovered file that passes security checks. """ skill_dir = Path(skill_dir_path).absolute() root_directory_path = str(skill_dir) scripts: list[str] = [] normalized_extensions = {e.lower() for e in extensions} + seen_directories: set[str] = set() - for script_file in skill_dir.rglob("*"): - if not script_file.is_file(): + for directory in directories: + is_root = directory == ROOT_DIRECTORY_INDICATOR + target_dir = skill_dir if is_root else (skill_dir / directory) + + # Deduplicate after resolving to avoid scanning the same directory twice. + # Use normcase for case-insensitive dedup on case-insensitive filesystems. + resolved_target = str(Path(os.path.normpath(target_dir)).absolute()) + dedup_key = os.path.normcase(resolved_target) + if dedup_key in seen_directories: + continue + seen_directories.add(dedup_key) + + if not target_dir.is_dir(): continue - if script_file.suffix.lower() not in normalized_extensions: - continue + # Directory-level containment and symlink checks for non-root directories + if not is_root: + if not FileSkillsSource._is_path_within_directory(resolved_target, root_directory_path): + logger.warning( + "Skipping script directory '%s': resolves outside skill directory '%s'", + directory, + skill_dir_path, + ) + continue - script_full_path = str(Path(os.path.normpath(script_file)).absolute()) + if FileSkillsSource._has_symlink_in_path(resolved_target, root_directory_path): + logger.warning( + "Skipping script directory '%s': symlink detected in path under skill directory '%s'", + directory, + skill_dir_path, + ) + continue - if not FileSkillsSource._is_path_within_directory(script_full_path, root_directory_path): + # Scan top-level files only (non-recursive) within this directory + try: + entries = list(target_dir.iterdir()) + except OSError: logger.warning( - "Skipping script '%s': resolves outside skill directory '%s'", - script_file, + "Failed to list script directory '%s' in skill directory '%s'; skipping.", + directory, skill_dir_path, ) continue - if FileSkillsSource._has_symlink_in_path(script_full_path, root_directory_path): - logger.warning( - "Skipping script '%s': symlink detected in path under skill directory '%s'", - script_file, - skill_dir_path, - ) - continue + for script_file in entries: + if not script_file.is_file(): + continue - rel_path = script_file.relative_to(skill_dir) - scripts.append(FileSkillsSource._normalize_resource_path(str(rel_path))) + if script_file.suffix.lower() not in normalized_extensions: + continue + script_full_path = str(Path(os.path.normpath(script_file)).absolute()) + + # Containment check: file must resolve within the target directory + if not FileSkillsSource._is_path_within_directory(script_full_path, resolved_target): + logger.warning( + "Skipping script '%s': resolves outside target directory '%s'", + script_file, + directory, + ) + continue + + if FileSkillsSource._has_symlink_in_path(script_full_path, root_directory_path): + logger.warning( + "Skipping script '%s': symlink detected in path under skill directory '%s'", + script_file, + skill_dir_path, + ) + continue + + rel_path = script_file.relative_to(skill_dir) + scripts.append(FileSkillsSource._normalize_resource_path(str(rel_path))) + + scripts.sort() return scripts @staticmethod diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py index de39c58b2f..30eba73237 100644 --- a/python/packages/core/tests/core/test_skills.py +++ b/python/packages/core/tests/core/test_skills.py @@ -31,8 +31,11 @@ from agent_framework import ( SkillsProvider, ) from agent_framework._skills import ( + DEFAULT_RESOURCE_DIRECTORIES, DEFAULT_RESOURCE_EXTENSIONS, + DEFAULT_SCRIPT_DIRECTORIES, DEFAULT_SCRIPT_EXTENSIONS, + ROOT_DIRECTORY_INDICATOR, InlineSkillResource, InlineSkillScript, _create_resource_element, @@ -143,6 +146,8 @@ async def _discover_file_skills_for_test( *, resource_extensions: tuple[str, ...] | None = None, script_extensions: tuple[str, ...] | None = None, + resource_directories: Sequence[str] | None = None, + script_directories: Sequence[str] | None = None, script_runner: Any = None, ) -> dict[str, FileSkill]: """Test helper: discover file skills and return as a dict keyed by name. @@ -155,6 +160,10 @@ async def _discover_file_skills_for_test( kwargs["resource_extensions"] = resource_extensions if script_extensions is not None: kwargs["script_extensions"] = script_extensions + if resource_directories is not None: + kwargs["resource_directories"] = resource_directories + if script_directories is not None: + kwargs["script_directories"] = script_directories if script_runner is not None: kwargs["script_runner"] = script_runner @@ -191,59 +200,103 @@ class TestNormalizeResourcePath: class TestDiscoverResourceFiles: """Tests for _discover_resource_files (filesystem-based resource discovery).""" - def test_discovers_md_files(self, tmp_path: Path) -> None: + def test_discovers_md_files_in_references(self, tmp_path: Path) -> None: skill_dir = tmp_path / "my-skill" skill_dir.mkdir() (skill_dir / "SKILL.md").write_text("---\nname: s\ndescription: d\n---\n", encoding="utf-8") - refs = skill_dir / "refs" + refs = skill_dir / "references" refs.mkdir() (refs / "FAQ.md").write_text("FAQ content", encoding="utf-8") resources = FileSkillsSource._discover_resource_files(str(skill_dir)) - assert "refs/FAQ.md" in resources + assert "references/FAQ.md" in resources - def test_excludes_skill_md(self, tmp_path: Path) -> None: + def test_discovers_md_files_in_assets(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + assets = skill_dir / "assets" + assets.mkdir() + (assets / "guide.md").write_text("guide", encoding="utf-8") + resources = FileSkillsSource._discover_resource_files(str(skill_dir)) + assert "assets/guide.md" in resources + + def test_excludes_skill_md_at_root(self, tmp_path: Path) -> None: skill_dir = tmp_path / "my-skill" skill_dir.mkdir() (skill_dir / "SKILL.md").write_text("content", encoding="utf-8") - resources = FileSkillsSource._discover_resource_files(str(skill_dir)) + resources = FileSkillsSource._discover_resource_files(str(skill_dir), directories=(".",)) assert len(resources) == 0 def test_discovers_multiple_extensions(self, tmp_path: Path) -> None: skill_dir = tmp_path / "my-skill" - skill_dir.mkdir() - (skill_dir / "data.json").write_text("{}", encoding="utf-8") - (skill_dir / "config.yaml").write_text("key: val", encoding="utf-8") - (skill_dir / "notes.txt").write_text("notes", encoding="utf-8") + refs = skill_dir / "references" + refs.mkdir(parents=True) + (refs / "data.json").write_text("{}", encoding="utf-8") + (refs / "config.yaml").write_text("key: val", encoding="utf-8") + (refs / "notes.txt").write_text("notes", encoding="utf-8") resources = FileSkillsSource._discover_resource_files(str(skill_dir)) assert len(resources) == 3 names = set(resources) - assert "data.json" in names - assert "config.yaml" in names - assert "notes.txt" in names + assert "references/data.json" in names + assert "references/config.yaml" in names + assert "references/notes.txt" in names def test_ignores_unsupported_extensions(self, tmp_path: Path) -> None: skill_dir = tmp_path / "my-skill" - skill_dir.mkdir() - (skill_dir / "image.png").write_bytes(b"\x89PNG") - (skill_dir / "binary.exe").write_bytes(b"\x00") + refs = skill_dir / "references" + refs.mkdir(parents=True) + (refs / "image.png").write_bytes(b"\x89PNG") + (refs / "binary.exe").write_bytes(b"\x00") resources = FileSkillsSource._discover_resource_files(str(skill_dir)) assert len(resources) == 0 def test_custom_extensions(self, tmp_path: Path) -> None: skill_dir = tmp_path / "my-skill" - skill_dir.mkdir() - (skill_dir / "data.json").write_text("{}", encoding="utf-8") - (skill_dir / "notes.txt").write_text("notes", encoding="utf-8") + refs = skill_dir / "references" + refs.mkdir(parents=True) + (refs / "data.json").write_text("{}", encoding="utf-8") + (refs / "notes.txt").write_text("notes", encoding="utf-8") resources = FileSkillsSource._discover_resource_files(str(skill_dir), extensions=(".json",)) - assert resources == ["data.json"] + assert resources == ["references/data.json"] - def test_discovers_nested_files(self, tmp_path: Path) -> None: + def test_does_not_discover_nested_files(self, tmp_path: Path) -> None: + """Non-recursive: files inside subdirectories of configured dirs are not discovered.""" skill_dir = tmp_path / "my-skill" - sub = skill_dir / "refs" / "deep" + sub = skill_dir / "references" / "deep" sub.mkdir(parents=True) (sub / "doc.md").write_text("deep doc", encoding="utf-8") resources = FileSkillsSource._discover_resource_files(str(skill_dir)) - assert "refs/deep/doc.md" in resources + assert len(resources) == 0 + + def test_root_directory_discovers_root_files(self, tmp_path: Path) -> None: + """The '.' root indicator discovers files at the skill root level.""" + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "data.json").write_text("{}", encoding="utf-8") + resources = FileSkillsSource._discover_resource_files(str(skill_dir), directories=(".",)) + assert "data.json" in resources + + def test_root_does_not_discover_by_default(self, tmp_path: Path) -> None: + """Files at skill root are not discovered with default directories.""" + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "data.json").write_text("{}", encoding="utf-8") + resources = FileSkillsSource._discover_resource_files(str(skill_dir)) + assert len(resources) == 0 + + def test_custom_directories(self, tmp_path: Path) -> None: + """Custom directory names override defaults.""" + skill_dir = tmp_path / "my-skill" + custom = skill_dir / "docs" + custom.mkdir(parents=True) + (custom / "readme.md").write_text("readme", encoding="utf-8") + resources = FileSkillsSource._discover_resource_files(str(skill_dir), directories=("docs",)) + assert "docs/readme.md" in resources + + def test_nonexistent_directory_silently_skipped(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + resources = FileSkillsSource._discover_resource_files(str(skill_dir), directories=("nonexistent",)) + assert resources == [] def test_empty_directory(self, tmp_path: Path) -> None: skill_dir = tmp_path / "my-skill" @@ -260,6 +313,27 @@ class TestDiscoverResourceFiles: assert ".xml" in DEFAULT_RESOURCE_EXTENSIONS assert ".txt" in DEFAULT_RESOURCE_EXTENSIONS + def test_duplicate_directories_deduplicated(self, tmp_path: Path) -> None: + """Duplicate directory entries should not produce duplicate resources.""" + skill_dir = tmp_path / "my-skill" + 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") + ) + assert resources == ["references/doc.md"] + + def test_results_are_sorted(self, tmp_path: Path) -> None: + """Results should be sorted for stable ordering.""" + skill_dir = tmp_path / "my-skill" + refs = skill_dir / "references" + refs.mkdir(parents=True) + (refs / "zebra.md").write_text("z", encoding="utf-8") + (refs / "alpha.md").write_text("a", encoding="utf-8") + resources = FileSkillsSource._discover_resource_files(str(skill_dir)) + assert resources == ["references/alpha.md", "references/zebra.md"] + class TestTryParseSkillDocument: """Tests for _extract_frontmatter.""" @@ -413,11 +487,11 @@ class TestDiscoverAndLoadSkills: tmp_path, "my-skill", body="Instructions here.", - resources={"refs/FAQ.md": "FAQ content"}, + resources={"references/FAQ.md": "FAQ content"}, ) skills = await _discover_file_skills_for_test([str(tmp_path)]) assert "my-skill" in skills - assert [r.name for r in skills["my-skill"].resources] == ["refs/FAQ.md"] + assert [r.name for r in skills["my-skill"].resources] == ["references/FAQ.md"] async def test_skill_discovers_all_resource_files(self, tmp_path: Path) -> None: """Resources are discovered by filesystem scan, not by markdown links.""" @@ -425,13 +499,13 @@ class TestDiscoverAndLoadSkills: tmp_path, "my-skill", body="No links here.", - resources={"data.json": '{"key": "val"}', "refs/doc.md": "doc content"}, + resources={"references/data.json": '{"key": "val"}', "assets/doc.md": "doc content"}, ) skills = await _discover_file_skills_for_test([str(tmp_path)]) assert "my-skill" in skills resource_names = sorted(r.name for r in skills["my-skill"].resources) - assert "data.json" in resource_names - assert "refs/doc.md" in resource_names + assert "assets/doc.md" in resource_names + assert "references/data.json" in resource_names # --------------------------------------------------------------------------- @@ -446,12 +520,12 @@ class TestReadSkillResource: _write_skill( tmp_path, "my-skill", - body="See [doc](refs/FAQ.md).", - resources={"refs/FAQ.md": "FAQ content here"}, + body="See [doc](references/FAQ.md).", + resources={"references/FAQ.md": "FAQ content here"}, ) skill_dir = tmp_path / "my-skill" - full_path = str(skill_dir / "refs" / "FAQ.md") - resource = _FileSkillResource(name="refs/FAQ.md", full_path=full_path) + full_path = str(skill_dir / "references" / "FAQ.md") + resource = _FileSkillResource(name="references/FAQ.md", full_path=full_path) content = await resource.read() assert content == "FAQ content here" @@ -468,12 +542,12 @@ class TestReadSkillResource: _write_skill( tmp_path, "my-skill", - body="See [doc](refs/FAQ.md).", - resources={"refs/FAQ.md": "FAQ content"}, + body="See [doc](references/FAQ.md).", + resources={"references/FAQ.md": "FAQ content"}, ) skill_dir = tmp_path / "my-skill" - full_path = str(skill_dir / "refs" / "FAQ.md") - resource = _FileSkillResource(name="refs/FAQ.md", full_path=full_path) + full_path = str(skill_dir / "references" / "FAQ.md") + resource = _FileSkillResource(name="references/FAQ.md", full_path=full_path) content = await resource.read() assert content == "FAQ content" @@ -486,7 +560,7 @@ class TestReadSkillResource: skill_dir = tmp_path / "skill" skill_dir.mkdir() (tmp_path / "secret.md").write_text("secret", encoding="utf-8") - resources = FileSkillsSource._discover_resource_files(str(skill_dir)) + resources = FileSkillsSource._discover_resource_files(str(skill_dir), directories=(".",)) assert not any("secret" in r for r in resources) @@ -633,13 +707,13 @@ class TestSkillsProvider: _write_skill( tmp_path, "my-skill", - body="See [doc](refs/FAQ.md).", - resources={"refs/FAQ.md": "FAQ content"}, + body="See [doc](references/FAQ.md).", + resources={"references/FAQ.md": "FAQ content"}, ) provider = SkillsProvider.from_paths(str(tmp_path)) await _init_provider(provider) result = provider._load_skill(_raw_skills(provider), "my-skill") - assert "See [doc](refs/FAQ.md)." in result + assert "See [doc](references/FAQ.md)." in result async def test_load_skill_unknown_returns_error(self, tmp_path: Path) -> None: provider = SkillsProvider.from_paths(str(tmp_path)) @@ -657,12 +731,12 @@ class TestSkillsProvider: _write_skill( tmp_path, "my-skill", - body="See [doc](refs/FAQ.md).", - resources={"refs/FAQ.md": "FAQ content"}, + body="See [doc](references/FAQ.md).", + resources={"references/FAQ.md": "FAQ content"}, ) provider = SkillsProvider.from_paths(str(tmp_path)) await _init_provider(provider) - result = await provider._read_skill_resource(_raw_skills(provider), "my-skill", "refs/FAQ.md") + result = await provider._read_skill_resource(_raw_skills(provider), "my-skill", "references/FAQ.md") assert result == "FAQ content" async def test_read_skill_resource_unknown_skill_returns_error(self, tmp_path: Path) -> None: @@ -791,7 +865,7 @@ class TestSymlinkDetection: "---\nname: my-skill\ndescription: A test skill.\n---\nInstructions.\n", encoding="utf-8", ) - refs_dir = skill_dir / "refs" + refs_dir = skill_dir / "references" refs_dir.mkdir() (refs_dir / "leak.md").symlink_to(outside_file) # Also add a safe resource @@ -800,8 +874,8 @@ class TestSymlinkDetection: skills = await _discover_file_skills_for_test([str(tmp_path)]) assert "my-skill" in skills resource_names = [r.name for r in skills["my-skill"].resources] - assert "refs/leak.md" not in resource_names - assert "refs/safe.md" in resource_names + assert "references/leak.md" not in resource_names + assert "references/safe.md" in resource_names def test_discover_resource_files_rejects_symlinked_resource(self, tmp_path: Path) -> None: """_discover_resource_files should exclude a symlinked resource file.""" @@ -811,12 +885,12 @@ class TestSymlinkDetection: outside_file = tmp_path / "secret.md" outside_file.write_text("secret content", encoding="utf-8") - refs_dir = skill_dir / "refs" + refs_dir = skill_dir / "references" refs_dir.mkdir() (refs_dir / "leak.md").symlink_to(outside_file) resources = FileSkillsSource._discover_resource_files(str(skill_dir)) - assert "refs/leak.md" not in resources + assert "references/leak.md" not in resources def test_discover_skips_symlinked_script(self, tmp_path: Path) -> None: """_discover_script_files should skip scripts with symlinks in their path.""" @@ -1361,21 +1435,22 @@ class TestSkillsProviderCodeSkill: async def test_custom_resource_extensions(self, tmp_path: Path) -> None: """SkillsProvider accepts custom resource_extensions.""" skill_dir = tmp_path / "my-skill" - skill_dir.mkdir() + refs = skill_dir / "references" + refs.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "---\nname: my-skill\ndescription: A test skill.\n---\nBody.", encoding="utf-8", ) - (skill_dir / "data.json").write_text("{}", encoding="utf-8") - (skill_dir / "notes.txt").write_text("notes", encoding="utf-8") + (refs / "data.json").write_text("{}", encoding="utf-8") + (refs / "notes.txt").write_text("notes", encoding="utf-8") # Only discover .json files provider = SkillsProvider.from_paths(str(tmp_path), resource_extensions=(".json",)) await _init_provider(provider) skill = _ctx(provider)[0]["my-skill"] resource_names = [r.name for r in skill.resources] - assert "data.json" in resource_names - assert "notes.txt" not in resource_names + assert "references/data.json" in resource_names + assert "references/notes.txt" not in resource_names # --------------------------------------------------------------------------- @@ -1407,11 +1482,11 @@ class TestFileBasedSkillParsing: assert skill.path == str(tmp_path / "my-skill") async def test_resources_populated(self, tmp_path: Path) -> None: - _write_skill(tmp_path, "my-skill", resources={"refs/doc.md": "content"}) + _write_skill(tmp_path, "my-skill", resources={"references/doc.md": "content"}) skills = await _discover_file_skills_for_test([str(tmp_path)]) assert "my-skill" in skills resource_names = [r.name for r in skills["my-skill"].resources] - assert "refs/doc.md" in resource_names + assert "references/doc.md" in resource_names # --------------------------------------------------------------------------- @@ -1467,12 +1542,12 @@ class TestDiscoverResourceFilesEdgeCases: """Additional edge-case tests for filesystem resource discovery.""" def test_excludes_skill_md_case_insensitive(self, tmp_path: Path) -> None: - """SKILL.md in any casing is excluded.""" + """SKILL.md in any casing is excluded when scanning root.""" skill_dir = tmp_path / "my-skill" skill_dir.mkdir() (skill_dir / "skill.md").write_text("lowercase name", encoding="utf-8") (skill_dir / "other.md").write_text("keep me", encoding="utf-8") - resources = FileSkillsSource._discover_resource_files(str(skill_dir)) + resources = FileSkillsSource._discover_resource_files(str(skill_dir), directories=(".",)) names = [r.lower() for r in resources] assert "skill.md" not in names assert "other.md" in resources @@ -1480,19 +1555,221 @@ class TestDiscoverResourceFilesEdgeCases: def test_skips_directories(self, tmp_path: Path) -> None: """Directories are not included as resources even if their name matches an extension.""" skill_dir = tmp_path / "my-skill" - subdir = skill_dir / "data.json" - subdir.mkdir(parents=True) + refs = skill_dir / "references" + refs.mkdir(parents=True) + subdir = refs / "data.json" + subdir.mkdir() resources = FileSkillsSource._discover_resource_files(str(skill_dir)) assert resources == [] def test_extension_matching_is_case_insensitive(self, tmp_path: Path) -> None: skill_dir = tmp_path / "my-skill" - skill_dir.mkdir() - (skill_dir / "NOTES.TXT").write_text("caps", encoding="utf-8") + refs = skill_dir / "references" + refs.mkdir(parents=True) + (refs / "NOTES.TXT").write_text("caps", encoding="utf-8") resources = FileSkillsSource._discover_resource_files(str(skill_dir)) assert len(resources) == 1 +class TestDiscoverFilesOSErrorWarning: + """OSError during directory listing should log a warning, not fail silently.""" + + def test_resource_discovery_warns_on_oserror(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + """_discover_resource_files logs a warning when iterdir() raises OSError.""" + skill_dir = tmp_path / "my-skill" + refs = skill_dir / "references" + refs.mkdir(parents=True) + (refs / "guide.md").write_text("content", encoding="utf-8") + + original_iterdir = Path.iterdir + + def _patched_iterdir(self: Path) -> Any: + if self.name == "references": + raise PermissionError("access denied") + return original_iterdir(self) + + import unittest.mock + + with unittest.mock.patch.object(Path, "iterdir", _patched_iterdir): + resources = FileSkillsSource._discover_resource_files(str(skill_dir)) + + assert resources == [] + assert any("Failed to list resource directory" in r.message for r in caplog.records) + + def test_script_discovery_warns_on_oserror(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + """_discover_script_files logs a warning when iterdir() raises OSError.""" + skill_dir = tmp_path / "my-skill" + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir(parents=True) + (scripts_dir / "run.py").write_text("print('hi')", encoding="utf-8") + + original_iterdir = Path.iterdir + + def _patched_iterdir(self: Path) -> Any: + if self.name == "scripts": + raise PermissionError("access denied") + return original_iterdir(self) + + import unittest.mock + + with unittest.mock.patch.object(Path, "iterdir", _patched_iterdir): + scripts = FileSkillsSource._discover_script_files(str(skill_dir)) + + assert scripts == [] + assert any("Failed to list script directory" in r.message for r in caplog.records) + + +class TestValidateAndNormalizeDirectoryNames: + """Tests for _validate_and_normalize_directory_names.""" + + def test_simple_directory_name(self) -> None: + result = FileSkillsSource._validate_and_normalize_directory_names(["references"]) + assert result == ["references"] + + def test_root_indicator(self) -> None: + result = FileSkillsSource._validate_and_normalize_directory_names(["."]) + assert result == ["."] + + def test_dot_slash_normalizes_to_root(self) -> None: + result = FileSkillsSource._validate_and_normalize_directory_names(["./"]) + assert result == ["."] + + def test_backslash_dot_normalizes_to_root(self) -> None: + result = FileSkillsSource._validate_and_normalize_directory_names([".\\"]) + assert result == ["."] + + def test_backslashes_normalized(self) -> None: + result = FileSkillsSource._validate_and_normalize_directory_names(["sub\\scripts"]) + assert result == ["sub/scripts"] + + def test_trailing_slash_stripped(self) -> None: + result = FileSkillsSource._validate_and_normalize_directory_names(["scripts/"]) + assert result == ["scripts"] + + def test_leading_dot_slash_stripped(self) -> None: + result = FileSkillsSource._validate_and_normalize_directory_names(["./references"]) + assert result == ["references"] + + def test_rejects_parent_traversal(self) -> None: + result = FileSkillsSource._validate_and_normalize_directory_names(["../secrets"]) + assert result == [] + + def test_rejects_embedded_parent_traversal(self) -> None: + result = FileSkillsSource._validate_and_normalize_directory_names(["sub/../secrets"]) + assert result == [] + + def test_rejects_absolute_path(self) -> None: + result = FileSkillsSource._validate_and_normalize_directory_names(["/etc/passwd"]) + assert result == [] + + def test_rejects_windows_absolute_path(self) -> None: + result = FileSkillsSource._validate_and_normalize_directory_names(["C:\\Windows"]) + assert result == [] + + def test_empty_string_raises(self) -> None: + with pytest.raises(ValueError, match="empty or whitespace"): + FileSkillsSource._validate_and_normalize_directory_names([""]) + + def test_whitespace_only_raises(self) -> None: + with pytest.raises(ValueError, match="empty or whitespace"): + FileSkillsSource._validate_and_normalize_directory_names([" "]) + + def test_multiple_directories(self) -> None: + result = FileSkillsSource._validate_and_normalize_directory_names( + [".", "references", "assets", "scripts"] + ) + assert result == [".", "references", "assets", "scripts"] + + def test_default_resource_directories(self) -> None: + assert DEFAULT_RESOURCE_DIRECTORIES == ("references", "assets") + + def test_default_script_directories(self) -> None: + assert DEFAULT_SCRIPT_DIRECTORIES == ("scripts",) + + def test_root_directory_indicator_is_dot(self) -> None: + assert ROOT_DIRECTORY_INDICATOR == "." + + +class TestFileSkillsSourceDirectories: + """Tests for resource_directories and script_directories parameters.""" + + async def test_custom_resource_directories(self, tmp_path: Path) -> None: + """Custom resource_directories controls which dirs are scanned.""" + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: test\n---\nBody", + encoding="utf-8", + ) + # Put resource in a custom directory + docs = skill_dir / "docs" + docs.mkdir() + (docs / "guide.md").write_text("guide", encoding="utf-8") + # Also put one in default references/ — should not be found + refs = skill_dir / "references" + refs.mkdir() + (refs / "ref.md").write_text("ref", encoding="utf-8") + + source = FileSkillsSource(str(tmp_path), resource_directories=["docs"]) + skills = await source.get_skills() + resource_names = [r.name for r in skills[0].resources] + assert "docs/guide.md" in resource_names + assert "references/ref.md" not in resource_names + + async def test_custom_script_directories(self, tmp_path: Path) -> None: + """Custom script_directories controls which dirs are scanned.""" + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: test\n---\nBody", + encoding="utf-8", + ) + # Put script in a custom directory + tools = skill_dir / "tools" + tools.mkdir() + (tools / "run.py").write_text("print('run')", encoding="utf-8") + + source = FileSkillsSource(str(tmp_path), script_directories=["tools"]) + skills = await source.get_skills() + script_names = [s.name for s in skills[0].scripts] + assert "tools/run.py" in script_names + + async def test_root_indicator_discovers_root_files(self, tmp_path: Path) -> None: + """The '.' root indicator discovers files at the skill root.""" + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: test\n---\nBody", + encoding="utf-8", + ) + (skill_dir / "data.json").write_text("{}", encoding="utf-8") + + source = FileSkillsSource(str(tmp_path), resource_directories=[".", "references"]) + skills = await source.get_skills() + resource_names = [r.name for r in skills[0].resources] + assert "data.json" in resource_names + + async def test_from_paths_passes_directories(self, tmp_path: Path) -> None: + """from_paths passes resource_directories and script_directories through.""" + skill_dir = tmp_path / "my-skill" + docs = skill_dir / "docs" + docs.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: test\n---\nBody", + encoding="utf-8", + ) + (docs / "guide.md").write_text("guide", encoding="utf-8") + + provider = SkillsProvider.from_paths( + str(tmp_path), + resource_directories=["docs"], + ) + await _init_provider(provider) + skill = _ctx(provider)[0]["my-skill"] + resource_names = [r.name for r in skill.resources] + assert "docs/guide.md" in resource_names + + # --------------------------------------------------------------------------- # Tests: _is_path_within_directory # --------------------------------------------------------------------------- @@ -2928,12 +3205,13 @@ class TestSkillsProviderFactories: assert isinstance(_CustomRunner(), SkillScriptRunner) skill_dir = tmp_path / "my-skill" - skill_dir.mkdir() + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "---\nname: my-skill\ndescription: test\n---\nBody", encoding="utf-8", ) - (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8") + (scripts_dir / "run.py").write_text("print('hi')", encoding="utf-8") provider = SkillsProvider.from_paths( str(tmp_path), @@ -2949,12 +3227,13 @@ class TestSkillsProviderFactories: assert isinstance(sync_runner, SkillScriptRunner) skill_dir = tmp_path / "my-skill" - skill_dir.mkdir() + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "---\nname: my-skill\ndescription: test\n---\nBody", encoding="utf-8", ) - (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8") + (scripts_dir / "run.py").write_text("print('hi')", encoding="utf-8") provider = SkillsProvider.from_paths( str(tmp_path), @@ -2966,12 +3245,13 @@ class TestSkillsProviderFactories: async def test_file_script_with_sync_runner_executes(self, tmp_path: Path) -> None: """A sync script_runner is awaitable through the provider's run_skill_script.""" skill_dir = tmp_path / "my-skill" - skill_dir.mkdir() + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "---\nname: my-skill\ndescription: test\n---\nBody", encoding="utf-8", ) - (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8") + (scripts_dir / "run.py").write_text("print('hi')", encoding="utf-8") def sync_runner(skill, script, args=None): return f"sync: {script.name} args={args}" @@ -2982,17 +3262,18 @@ class TestSkillsProviderFactories: ) await _init_provider(provider) run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") - result = await run_tool.func(skill_name="my-skill", script_name="run.py", args={"key": "val"}) - assert result == "sync: run.py args={'key': 'val'}" + result = await run_tool.func(skill_name="my-skill", script_name="scripts/run.py", args={"key": "val"}) + assert result == "sync: scripts/run.py args={'key': 'val'}" async def test_file_skills_with_callback_runner(self, tmp_path: Path) -> None: skill_dir = tmp_path / "my-skill" - skill_dir.mkdir() + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "---\nname: my-skill\ndescription: test\n---\nBody", encoding="utf-8", ) - (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8") + (scripts_dir / "run.py").write_text("print('hi')", encoding="utf-8") provider = SkillsProvider.from_paths( str(tmp_path), @@ -3028,12 +3309,13 @@ class TestSkillsProviderFactories: async def test_file_scripts_without_runner_no_error_at_init(self, tmp_path: Path) -> None: skill_dir = tmp_path / "my-skill" - skill_dir.mkdir() + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "---\nname: my-skill\ndescription: test\n---\nBody", encoding="utf-8", ) - (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8") + (scripts_dir / "run.py").write_text("print('hi')", encoding="utf-8") provider = SkillsProvider.from_paths(str(tmp_path)) # Initialization succeeds; the error now surfaces at script.run() time @@ -3309,7 +3591,23 @@ class TestSkillsProviderFactories: class TestFileScriptDiscovery: """Tests for automatic .py script discovery in skill directories.""" - async def test_discovers_py_files(self, tmp_path: Path) -> None: + async def test_discovers_py_files_in_scripts_dir(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "my-skill" + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: test\n---\nBody", + encoding="utf-8", + ) + (scripts_dir / "analyze.py").write_text("print('hi')", encoding="utf-8") + + skills = await _discover_file_skills_for_test(str(tmp_path)) + assert "my-skill" in skills + assert len(skills["my-skill"].scripts) == 1 + assert skills["my-skill"].scripts[0].name == "scripts/analyze.py" + + async def test_root_py_files_not_discovered_by_default(self, tmp_path: Path) -> None: + """Scripts at the skill root are NOT discovered with default directories.""" skill_dir = tmp_path / "my-skill" skill_dir.mkdir() (skill_dir / "SKILL.md").write_text( @@ -3320,8 +3618,7 @@ class TestFileScriptDiscovery: skills = await _discover_file_skills_for_test(str(tmp_path)) assert "my-skill" in skills - assert len(skills["my-skill"].scripts) == 1 - assert skills["my-skill"].scripts[0].name == "analyze.py" + assert len(skills["my-skill"].scripts) == 0 async def test_discovered_script_has_absolute_full_path(self, tmp_path: Path) -> None: skill_dir = tmp_path / "my-skill" @@ -3340,7 +3637,8 @@ class TestFileScriptDiscovery: expected = str(Path(str(skill_dir), "scripts", "generate.py")) assert script.full_path == expected - async def test_discovers_nested_scripts(self, tmp_path: Path) -> None: + async def test_scripts_not_discovered_recursively(self, tmp_path: Path) -> None: + """Scripts inside subdirectories of scripts/ are NOT discovered (non-recursive).""" skill_dir = tmp_path / "my-skill" scripts_dir = skill_dir / "scripts" scripts_dir.mkdir(parents=True) @@ -3348,11 +3646,16 @@ class TestFileScriptDiscovery: "---\nname: my-skill\ndescription: test\n---\nBody", encoding="utf-8", ) - (scripts_dir / "generate.py").write_text("print('gen')", encoding="utf-8") + # File directly in scripts/ is discovered + (scripts_dir / "top.py").write_text("print('top')", encoding="utf-8") + # File in scripts/sub/ is NOT discovered + sub_dir = scripts_dir / "sub" + sub_dir.mkdir() + (sub_dir / "nested.py").write_text("print('nested')", encoding="utf-8") skills = await _discover_file_skills_for_test(str(tmp_path)) assert len(skills["my-skill"].scripts) == 1 - assert skills["my-skill"].scripts[0].name == "scripts/generate.py" + assert skills["my-skill"].scripts[0].name == "scripts/top.py" async def test_no_scripts_when_no_py_files(self, tmp_path: Path) -> None: skill_dir = tmp_path / "my-skill" @@ -3373,36 +3676,38 @@ class TestCustomScriptExtensions: async def test_custom_script_extensions_via_get_skills(self, tmp_path: Path) -> None: """get_skills() forwards script_extensions to _discover_script_files.""" skill_dir = tmp_path / "my-skill" - skill_dir.mkdir() + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "---\nname: my-skill\ndescription: test\n---\nBody", encoding="utf-8", ) - (skill_dir / "analyze.py").write_text("print('hi')", encoding="utf-8") - (skill_dir / "run.sh").write_text("#!/bin/bash", encoding="utf-8") + (scripts_dir / "analyze.py").write_text("print('hi')", encoding="utf-8") + (scripts_dir / "run.sh").write_text("#!/bin/bash", encoding="utf-8") # Default: only .py discovered skills_default = await _discover_file_skills_for_test(str(tmp_path)) script_names_default = [s.name for s in skills_default["my-skill"].scripts] - assert "analyze.py" in script_names_default - assert "run.sh" not in script_names_default + assert "scripts/analyze.py" in script_names_default + assert "scripts/run.sh" not in script_names_default # Custom: only .sh discovered skills_custom = await _discover_file_skills_for_test(str(tmp_path), script_extensions=(".sh",)) script_names_custom = [s.name for s in skills_custom["my-skill"].scripts] - assert "run.sh" in script_names_custom - assert "analyze.py" not in script_names_custom + assert "scripts/run.sh" in script_names_custom + assert "scripts/analyze.py" not in script_names_custom async def test_custom_script_extensions_via_provider(self, tmp_path: Path) -> None: """SkillsProvider accepts custom script_extensions.""" skill_dir = tmp_path / "my-skill" - skill_dir.mkdir() + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "---\nname: my-skill\ndescription: test\n---\nBody", encoding="utf-8", ) - (skill_dir / "analyze.py").write_text("print('hi')", encoding="utf-8") - (skill_dir / "run.sh").write_text("#!/bin/bash", encoding="utf-8") + (scripts_dir / "analyze.py").write_text("print('hi')", encoding="utf-8") + (scripts_dir / "run.sh").write_text("#!/bin/bash", encoding="utf-8") # Only discover .sh scripts provider = SkillsProvider.from_paths( @@ -3413,20 +3718,21 @@ class TestCustomScriptExtensions: await _init_provider(provider) skill = _ctx(provider)[0]["my-skill"] script_names = [s.name for s in skill.scripts] - assert "run.sh" in script_names - assert "analyze.py" not in script_names + assert "scripts/run.sh" in script_names + assert "scripts/analyze.py" not in script_names async def test_multiple_script_extensions(self, tmp_path: Path) -> None: """Multiple script extensions can be specified.""" skill_dir = tmp_path / "my-skill" - skill_dir.mkdir() + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "---\nname: my-skill\ndescription: test\n---\nBody", encoding="utf-8", ) - (skill_dir / "analyze.py").write_text("print('hi')", encoding="utf-8") - (skill_dir / "run.sh").write_text("#!/bin/bash", encoding="utf-8") - (skill_dir / "notes.txt").write_text("notes", encoding="utf-8") + (scripts_dir / "analyze.py").write_text("print('hi')", encoding="utf-8") + (scripts_dir / "run.sh").write_text("#!/bin/bash", encoding="utf-8") + (scripts_dir / "notes.txt").write_text("notes", encoding="utf-8") provider = SkillsProvider.from_paths( str(tmp_path), @@ -3436,9 +3742,9 @@ class TestCustomScriptExtensions: await _init_provider(provider) skill = _ctx(provider)[0]["my-skill"] script_names = [s.name for s in skill.scripts] - assert "analyze.py" in script_names - assert "run.sh" in script_names - assert "notes.txt" not in script_names + assert "scripts/analyze.py" in script_names + assert "scripts/run.sh" in script_names + assert "scripts/notes.txt" not in script_names def test_default_script_extensions_unchanged(self) -> None: """DEFAULT_SCRIPT_EXTENSIONS contains only .py.""" @@ -4597,21 +4903,22 @@ class TestSkillsSource: async def test_file_skills_source_with_extensions(self, tmp_path: Path) -> None: """FileSkillsSource resource_extensions controls extension filtering.""" skill_dir = tmp_path / "my-skill" - skill_dir.mkdir() + refs = skill_dir / "references" + refs.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "---\nname: my-skill\ndescription: Test skill.\n---\nBody.", encoding="utf-8", ) - (skill_dir / "data.json").write_text("{}", encoding="utf-8") - (skill_dir / "data.csv").write_text("a,b", encoding="utf-8") + (refs / "data.json").write_text("{}", encoding="utf-8") + (refs / "data.csv").write_text("a,b", encoding="utf-8") # Only allow .json resources source = FileSkillsSource(str(tmp_path), resource_extensions=(".json",)) skills = await source.get_skills() assert len(skills) == 1 resource_names = [r.name for r in skills[0].resources] - assert "data.json" in resource_names - assert "data.csv" not in resource_names + assert "references/data.json" in resource_names + assert "references/data.csv" not in resource_names async def test_in_memory_skills_source_returns_all_skills(self) -> None: """InMemorySkillsSource returns all provided skills.""" @@ -4840,12 +5147,13 @@ class TestSourceComposition: async def test_file_source_with_script_runner(self, tmp_path: Path) -> None: """FileSkillsSource with script_runner enables script execution.""" skill_dir = tmp_path / "my-skill" - skill_dir.mkdir() + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "---\nname: my-skill\ndescription: test\n---\nBody", encoding="utf-8", ) - (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8") + (scripts_dir / "run.py").write_text("print('hi')", encoding="utf-8") source = DeduplicatingSkillsSource(FileSkillsSource(str(tmp_path), script_runner=_noop_script_runner)) provider = SkillsProvider(source) @@ -4875,12 +5183,13 @@ class TestSourceComposition: async def test_per_source_runner(self, tmp_path: Path) -> None: """Per-source script runner is used when set on FileSkillsSource.""" skill_dir = tmp_path / "my-skill" - skill_dir.mkdir() + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "---\nname: my-skill\ndescription: test\n---\nBody", encoding="utf-8", ) - (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8") + (scripts_dir / "run.py").write_text("print('hi')", encoding="utf-8") call_log: list[str] = [] @@ -4894,7 +5203,7 @@ class TestSourceComposition: # The source-level runner should be discovered and used run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") - result = await run_tool.func(skill_name="my-skill", script_name="run.py") + result = await run_tool.func(skill_name="my-skill", script_name="scripts/run.py") assert result == "source" assert call_log == ["source"] From 4e65fabafcda7eb3207b1150e067fb74592dc2a6 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 14 May 2026 12:09:01 +0100 Subject: [PATCH 02/65] .NET: Filestore improvements (#5842) * Filestore improvements * Address PR comments --- .../FileStore/FileSystemAgentFileStore.cs | 55 +- .../FileSystemAgentFileStoreTests.cs | 471 ++++++++++++++++++ 2 files changed, 525 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs index b26d0eea5d..a704c9c9e1 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs @@ -122,6 +122,7 @@ public sealed class FileSystemAgentFileStore : AgentFileStore } var files = Directory.GetFiles(fullDir) + .Where(f => (File.GetAttributes(f) & FileAttributes.ReparsePoint) == 0) .Select(Path.GetFileName) .Where(name => name is not null) .ToList(); @@ -157,6 +158,12 @@ public sealed class FileSystemAgentFileStore : AgentFileStore foreach (string filePath in Directory.GetFiles(fullDir)) { + // Skip files that are symlinks/reparse points to prevent reading outside the root. + if ((File.GetAttributes(filePath) & FileAttributes.ReparsePoint) != 0) + { + continue; + } + string? fileName = Path.GetFileName(filePath); if (fileName is null) { @@ -231,7 +238,7 @@ public sealed class FileSystemAgentFileStore : AgentFileStore /// /// Resolves a relative file path to a safe absolute path under the root directory. - /// Rejects paths that would escape the root via traversal or rooted paths. + /// Rejects paths that would escape the root via traversal, rooted paths, or symbolic links. /// private string ResolveSafePath(string relativePath) { @@ -250,9 +257,55 @@ public sealed class FileSystemAgentFileStore : AgentFileStore nameof(relativePath)); } + // Reject symlinks/reparse points in any path segment to prevent escaping the root. + ThrowIfContainsSymlink(fullPath, this._rootPath); + return fullPath; } + /// + /// Checks each path segment between the trusted root and the resolved path for symbolic links + /// or reparse points. Throws if any segment is a symlink. + /// Stops checking at the first segment that does not exist on disk (for write scenarios). + /// Uses directly so that dangling symlinks (whose targets + /// do not exist) are still detected via their flag. + /// + private static void ThrowIfContainsSymlink(string fullPath, string rootPath) + { + string rootTrimmed = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string relative = fullPath.Substring(rootTrimmed.Length); + string[] segments = relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + + string current = rootTrimmed; + foreach (string segment in segments) + { + current = Path.Combine(current, segment); + + FileAttributes attributes; + try + { + attributes = File.GetAttributes(current); + } + catch (FileNotFoundException) + { + // Segment does not exist on disk (write scenario); stop checking. + break; + } + catch (DirectoryNotFoundException) + { + break; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new ArgumentException( + "Invalid path: the resolved path contains a symbolic link or reparse point."); + } + } + } + /// /// Resolves a relative directory path to a safe absolute path under the root directory. /// An empty string resolves to the root directory itself. diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs index 82341d9a47..32342f177a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs @@ -334,4 +334,475 @@ public sealed class FileSystemAgentFileStoreTests : IDisposable } #endregion + + #region Symlink Escape Rejection + +#if NET + /// + /// Attempts to create a file symlink. Returns false if the platform does not support + /// symlink creation (e.g., Windows without developer mode) or if creation fails. + /// + private static bool TryCreateFileSymbolicLink(string linkPath, string targetPath) + { + try + { + File.CreateSymbolicLink(linkPath, targetPath); + } + catch (IOException) + { + return false; + } + + // Verify the symlink was actually created as a reparse point. + return File.Exists(linkPath) + && (File.GetAttributes(linkPath) & FileAttributes.ReparsePoint) != 0; + } + + /// + /// Attempts to create a directory symlink. Returns false if the platform does not support + /// symlink creation (e.g., Windows without developer mode) or if creation fails. + /// + private static bool TryCreateDirectorySymbolicLink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + } + catch (IOException) + { + return false; + } + + // Verify the symlink was actually created as a reparse point. + return Directory.Exists(linkPath) + && (File.GetAttributes(linkPath) & FileAttributes.ReparsePoint) != 0; + } + + [Fact] + public async Task ReadFileAsync_SymlinkedFile_ThrowsAsync() + { + // Arrange — create a file outside the root and symlink to it from inside. + string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_target_read_" + Guid.NewGuid().ToString("N") + ".txt"); + File.WriteAllText(outsideFile, "SECRET_OUTSIDE_ROOT"); + + string linkPath = Path.Combine(this._rootDir, "leak.txt"); + + try + { + if (!TryCreateFileSymbolicLink(linkPath, outsideFile)) + { + return; // Cannot create symlinks in this environment; skip. + } + + // Act & Assert — reading through the symlink should be rejected. + await Assert.ThrowsAsync(() => this._store.ReadFileAsync("leak.txt")); + } + finally + { + if (File.Exists(linkPath)) + { + File.Delete(linkPath); + } + + File.Delete(outsideFile); + } + } + + [Fact] + public async Task WriteFileAsync_SymlinkedFile_ThrowsAsync() + { + // Arrange — create a file outside the root and symlink to it from inside. + string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_target_write_" + Guid.NewGuid().ToString("N") + ".txt"); + File.WriteAllText(outsideFile, "ORIGINAL_CONTENT"); + + string linkPath = Path.Combine(this._rootDir, "overwrite.txt"); + + try + { + if (!TryCreateFileSymbolicLink(linkPath, outsideFile)) + { + return; + } + + // Act & Assert — writing through the symlink should be rejected. + await Assert.ThrowsAsync(() => this._store.WriteFileAsync("overwrite.txt", "EVIL_CONTENT")); + + // Verify the outside file was NOT modified. + Assert.Equal("ORIGINAL_CONTENT", await File.ReadAllTextAsync(outsideFile)); + } + finally + { + if (File.Exists(linkPath)) + { + File.Delete(linkPath); + } + + File.Delete(outsideFile); + } + } + + [Fact] + public async Task DeleteFileAsync_SymlinkedFile_ThrowsAsync() + { + // Arrange + string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_target_delete_" + Guid.NewGuid().ToString("N") + ".txt"); + File.WriteAllText(outsideFile, "DO_NOT_DELETE"); + + string linkPath = Path.Combine(this._rootDir, "trap.txt"); + + try + { + if (!TryCreateFileSymbolicLink(linkPath, outsideFile)) + { + return; + } + + // Act & Assert + await Assert.ThrowsAsync(() => this._store.DeleteFileAsync("trap.txt")); + + // Verify the outside file still exists. + Assert.True(File.Exists(outsideFile)); + } + finally + { + if (File.Exists(linkPath)) + { + File.Delete(linkPath); + } + + File.Delete(outsideFile); + } + } + + [Fact] + public async Task FileExistsAsync_SymlinkedFile_ThrowsAsync() + { + // Arrange + string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_target_exists_" + Guid.NewGuid().ToString("N") + ".txt"); + File.WriteAllText(outsideFile, "EXISTS_OUTSIDE"); + + string linkPath = Path.Combine(this._rootDir, "phantom.txt"); + + try + { + if (!TryCreateFileSymbolicLink(linkPath, outsideFile)) + { + return; + } + + // Act & Assert + await Assert.ThrowsAsync(() => this._store.FileExistsAsync("phantom.txt")); + } + finally + { + if (File.Exists(linkPath)) + { + File.Delete(linkPath); + } + + File.Delete(outsideFile); + } + } + + [Fact] + public async Task WriteFileAsync_DanglingSymlink_ThrowsAsync() + { + // Arrange — create a symlink pointing to a non-existent target. + string nonExistentTarget = Path.Combine(Path.GetTempPath(), "dangling_target_" + Guid.NewGuid().ToString("N") + ".txt"); + string linkPath = Path.Combine(this._rootDir, "dangling.txt"); + + try + { + if (!TryCreateFileSymbolicLink(linkPath, nonExistentTarget)) + { + return; + } + + // Act & Assert — even a dangling symlink must be rejected. + await Assert.ThrowsAsync(() => this._store.WriteFileAsync("dangling.txt", "CONTENT")); + + // Verify the target was NOT created by following the dangling link. + Assert.False(File.Exists(nonExistentTarget)); + } + finally + { + // Dangling symlinks: File.Exists returns false, but the link entry still exists. + // Use FileInfo to delete the link itself. + var linkInfo = new FileInfo(linkPath); + if (linkInfo.Exists || (linkInfo.Attributes & FileAttributes.ReparsePoint) != 0) + { + linkInfo.Delete(); + } + } + } + + [Fact] + public async Task ListFilesAsync_SymlinkedDirectory_ThrowsAsync() + { + // Arrange — create a directory outside root and symlink a directory inside root to it. + string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_dir_target_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(outsideDir); + File.WriteAllText(Path.Combine(outsideDir, "secret.txt"), "SECRET"); + + string linkDir = Path.Combine(this._rootDir, "linked-dir"); + + try + { + if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir)) + { + return; + } + + // Act & Assert + await Assert.ThrowsAsync(() => this._store.ListFilesAsync("linked-dir")); + } + finally + { + if (Directory.Exists(linkDir)) + { + Directory.Delete(linkDir); + } + + Directory.Delete(outsideDir, recursive: true); + } + } + + [Fact] + public async Task SearchFilesAsync_SymlinkedDirectory_ThrowsAsync() + { + // Arrange + string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_search_target_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(outsideDir); + File.WriteAllText(Path.Combine(outsideDir, "data.txt"), "SENSITIVE_DATA"); + + string linkDir = Path.Combine(this._rootDir, "search-link"); + + try + { + if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir)) + { + return; + } + + // Act & Assert + await Assert.ThrowsAsync(() => this._store.SearchFilesAsync("search-link", "SENSITIVE")); + } + finally + { + if (Directory.Exists(linkDir)) + { + Directory.Delete(linkDir); + } + + Directory.Delete(outsideDir, recursive: true); + } + } + + [Fact] + public async Task ReadFileAsync_ThroughDirectorySymlink_ThrowsAsync() + { + // Arrange — directory symlink inside root pointing outside; read a file through it. + string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_dir_read_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(outsideDir); + File.WriteAllText(Path.Combine(outsideDir, "secret.txt"), "DIR_SYMLINK_SECRET"); + + string linkDir = Path.Combine(this._rootDir, "linked-output"); + + try + { + if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir)) + { + return; + } + + // Act & Assert — reading through a directory symlink should be rejected. + await Assert.ThrowsAsync(() => this._store.ReadFileAsync("linked-output/secret.txt")); + } + finally + { + if (Directory.Exists(linkDir)) + { + Directory.Delete(linkDir); + } + + Directory.Delete(outsideDir, recursive: true); + } + } + + [Fact] + public async Task WriteFileAsync_ThroughDirectorySymlink_ThrowsAsync() + { + // Arrange — directory symlink; attempt to create/overwrite a file through it. + string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_dir_write_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(outsideDir); + + string linkDir = Path.Combine(this._rootDir, "linked-output"); + + try + { + if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir)) + { + return; + } + + // Act & Assert + await Assert.ThrowsAsync(() => this._store.WriteFileAsync("linked-output/created-by-agent.txt", "CONTENT")); + + // Verify no file was created outside. + Assert.False(File.Exists(Path.Combine(outsideDir, "created-by-agent.txt"))); + } + finally + { + if (Directory.Exists(linkDir)) + { + Directory.Delete(linkDir); + } + + Directory.Delete(outsideDir, recursive: true); + } + } + + [Fact] + public async Task DeleteFileAsync_ThroughDirectorySymlink_ThrowsAsync() + { + // Arrange — directory symlink; attempt to delete a file through it. + string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_dir_delete_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(outsideDir); + string outsideFile = Path.Combine(outsideDir, "delete-me.txt"); + File.WriteAllText(outsideFile, "DO_NOT_DELETE"); + + string linkDir = Path.Combine(this._rootDir, "linked-output"); + + try + { + if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir)) + { + return; + } + + // Act & Assert + await Assert.ThrowsAsync(() => this._store.DeleteFileAsync("linked-output/delete-me.txt")); + + // Verify the outside file was NOT deleted. + Assert.True(File.Exists(outsideFile)); + } + finally + { + if (Directory.Exists(linkDir)) + { + Directory.Delete(linkDir); + } + + Directory.Delete(outsideDir, recursive: true); + } + } + + [Fact] + public async Task CreateDirectoryAsync_ThroughDirectorySymlink_ThrowsAsync() + { + // Arrange — directory symlink; attempt to create a subdirectory through it. + string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_dir_mkdir_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(outsideDir); + + string linkDir = Path.Combine(this._rootDir, "linked-output"); + + try + { + if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir)) + { + return; + } + + // Act & Assert + await Assert.ThrowsAsync(() => this._store.CreateDirectoryAsync("linked-output/created-directory")); + + // Verify no directory was created outside. + Assert.False(Directory.Exists(Path.Combine(outsideDir, "created-directory"))); + } + finally + { + if (Directory.Exists(linkDir)) + { + Directory.Delete(linkDir); + } + + Directory.Delete(outsideDir, recursive: true); + } + } + + [Fact] + public async Task SearchFilesAsync_RootWithSymlinkedFile_DoesNotLeakContentAsync() + { + // Arrange — symlinked file at root level; search should not return its content. + string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_search_root_" + Guid.NewGuid().ToString("N") + ".txt"); + File.WriteAllText(outsideFile, "ROOT_LEVEL_SECRET_CONTENT"); + + string linkPath = Path.Combine(this._rootDir, "env-link.txt"); + + try + { + if (!TryCreateFileSymbolicLink(linkPath, outsideFile)) + { + return; + } + + // Also add a normal file to confirm search still works for non-symlinks. + await this._store.WriteFileAsync("normal.txt", "NORMAL_CONTENT"); + + // Act — search at root should skip the symlinked file. + var results = await this._store.SearchFilesAsync("", "SECRET_CONTENT"); + + // Assert — no results from the symlinked file. + Assert.Empty(results); + } + finally + { + if (File.Exists(linkPath)) + { + File.Delete(linkPath); + } + + File.Delete(outsideFile); + } + } + + [Fact] + public async Task ListFilesAsync_RootWithSymlinkedFile_ExcludesSymlinkAsync() + { + // Arrange — symlinked file at root level; listing should not include it. + string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_list_root_" + Guid.NewGuid().ToString("N") + ".txt"); + File.WriteAllText(outsideFile, "OUTSIDE"); + + string linkPath = Path.Combine(this._rootDir, "hidden-link.txt"); + + try + { + if (!TryCreateFileSymbolicLink(linkPath, outsideFile)) + { + return; + } + + // Also add a normal file. + await this._store.WriteFileAsync("visible.txt", "VISIBLE"); + + // Act + var files = await this._store.ListFilesAsync(""); + + // Assert — symlinked file should not appear in listing. + Assert.DoesNotContain("hidden-link.txt", files); + Assert.Contains("visible.txt", files); + } + finally + { + if (File.Exists(linkPath)) + { + File.Delete(linkPath); + } + + File.Delete(outsideFile); + } + } +#endif + + #endregion } From 198761d3ba00a59e6e86f6f6b1f4c582fb7f76f8 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 14 May 2026 12:51:34 +0100 Subject: [PATCH 03/65] .NET: DevUI: quarantine flaky discovery integration test (#5845) (#5846) TestServerWithDevUI_ResolvesMixedAgentsAndWorkflows_AllRegistrationsAsync fails intermittently in the merge_group with NRE on the discovery response, blocking PRs unrelated to DevUI from merging. Skip via Fact(Skip=...) referencing #5845 while the underlying race is investigated. --- .../DevUIIntegrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs index 901058ee29..029a650785 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs @@ -218,7 +218,7 @@ public class DevUIIntegrationTests Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-workflow" && e.Type == "workflow"); } - [Fact] + [Fact(Skip = "Flaky in merge_group; see https://github.com/microsoft/agent-framework/issues/5845")] public async Task TestServerWithDevUI_ResolvesMixedAgentsAndWorkflows_AllRegistrationsAsync() { // Arrange From 2d83a9b10d30ac8f7ce91d5dc221efaf918397ae Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 14 May 2026 12:55:04 +0100 Subject: [PATCH 04/65] Update version to 1.6.1 for release (#5843) --- dotnet/nuget/nuget-package.props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index cb1713e5e9..f1377833a2 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -1,14 +1,14 @@ - 1.6.0 + 1.6.1 1 - 260512 + 260514 $(VersionPrefix)-rc$(RCNumber) $(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1 $(VersionPrefix)-preview.$(DateSuffix).1 $(VersionPrefix) - 1.6.0 + 1.6.1 Debug;Release;Publish true From eb4053543659fdc53c584e5e0f6eb2b60a75b81e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 09:36:38 -0400 Subject: [PATCH 05/65] .NET: Add Executor RouteBuilder Unit Tests (#5824) * Add RouteBuilder unit tests Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/012f3b3b-acb9-4869-9084-b767cbe1885b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Address RouteBuilder test review feedback Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/012f3b3b-acb9-4869-9084-b767cbe1885b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Fix RouteBuilder test nullability warning Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/012f3b3b-acb9-4869-9084-b767cbe1885b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Refine RouteBuilder test helpers Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/012f3b3b-acb9-4869-9084-b767cbe1885b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Refactor overload int constants to HandlerOverload enum Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/19397f58-a88a-41cf-bd85-588f520e0d0f Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Fix ValueTask compatibility with .NET Framework 4.7.2 Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/a8437809-0898-43a6-a950-09eb3417f58a Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Fix IDE0001 format errors - simplify generic type names Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/8573214e-ec42-4969-ba94-76bdc8ad3e59 Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> Co-authored-by: Jacob Alber --- .../RouteBuilderTests.cs | 546 ++++++++++++++++++ 1 file changed, 546 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RouteBuilderTests.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RouteBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RouteBuilderTests.cs new file mode 100644 index 0000000000..a734b82b66 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RouteBuilderTests.cs @@ -0,0 +1,546 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Execution; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public sealed class RouteBuilderTests +{ + public enum HandlerOverload + { + SyncWithCancellation = 0, + SyncWithoutCancellation = 1, + AsyncWithCancellation = 2, + AsyncWithoutCancellation = 3, + } + + private sealed record TestPayload(string Value); + + private sealed class HandlerInvocation + { + public object? Message { get; private set; } + + public IWorkflowContext? Context { get; private set; } + + public CancellationToken CancellationToken { get; private set; } + + public int InvocationCount { get; private set; } + + public void Capture(object? message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + this.Message = message; + this.Context = context; + this.CancellationToken = cancellationToken; + this.InvocationCount++; + } + } + + private sealed class TestExternalRequestContext : IExternalRequestContext, IExternalRequestSink + { + public List RegisteredPorts { get; } = []; + + public List PostedRequests { get; } = []; + + public IExternalRequestSink RegisterPort(RequestPort port) + { + this.RegisteredPorts.Add(port); + return this; + } + + public ValueTask PostAsync(ExternalRequest request) + { + this.PostedRequests.Add(request); + return default; + } + } + + [Theory] + [InlineData(HandlerOverload.SyncWithCancellation)] + [InlineData(HandlerOverload.SyncWithoutCancellation)] + [InlineData(HandlerOverload.AsyncWithCancellation)] + [InlineData(HandlerOverload.AsyncWithoutCancellation)] + public async Task AddHandler_VoidOverloads_RouteExpectedMessageAsync(HandlerOverload overload) + { + // Arrange + RouteBuilder routeBuilder = new(null); + HandlerInvocation invocation = new(); + CancellationToken cancellationToken = new CancellationTokenSource().Token; + RegisterVoidHandler(routeBuilder, invocation, overload); + MessageRouter router = routeBuilder.Build(); + TestWorkflowContext context = new("executor"); + + // Act + CallResult? result = await router.RouteMessageAsync("hello", context, cancellationToken: cancellationToken); + + // Assert + result.Should().NotBeNull(); + result!.IsSuccess.Should().BeTrue(); + result.IsVoid.Should().BeTrue(); + result.Result.Should().BeNull(); + invocation.InvocationCount.Should().Be(1); + invocation.Message.Should().Be("hello"); + invocation.Context.Should().BeSameAs(context); + + if (UsesCancellationToken(overload)) + { + invocation.CancellationToken.Should().Be(cancellationToken); + } + } + + [Theory] + [InlineData(HandlerOverload.SyncWithCancellation)] + [InlineData(HandlerOverload.SyncWithoutCancellation)] + [InlineData(HandlerOverload.AsyncWithCancellation)] + [InlineData(HandlerOverload.AsyncWithoutCancellation)] + public async Task AddHandler_ResultOverloads_RouteExpectedMessageAsync(HandlerOverload overload) + { + // Arrange + RouteBuilder routeBuilder = new(null); + HandlerInvocation invocation = new(); + CancellationToken cancellationToken = new CancellationTokenSource().Token; + RegisterResultHandler(routeBuilder, invocation, overload); + MessageRouter router = routeBuilder.Build(); + TestWorkflowContext context = new("executor"); + + // Act + CallResult? result = await router.RouteMessageAsync("hello", context, cancellationToken: cancellationToken); + + // Assert + result.Should().NotBeNull(); + result!.IsSuccess.Should().BeTrue(); + result.IsVoid.Should().BeFalse(); + result.Result.Should().Be("HELLO"); + router.DefaultOutputTypes.Should().Contain(typeof(string)); + invocation.InvocationCount.Should().Be(1); + invocation.Message.Should().Be("hello"); + invocation.Context.Should().BeSameAs(context); + + if (UsesCancellationToken(overload)) + { + invocation.CancellationToken.Should().Be(cancellationToken); + } + } + + [Theory] + [InlineData(HandlerOverload.SyncWithCancellation)] + [InlineData(HandlerOverload.SyncWithoutCancellation)] + [InlineData(HandlerOverload.AsyncWithCancellation)] + [InlineData(HandlerOverload.AsyncWithoutCancellation)] + public async Task AddCatchAll_VoidOverloads_RouteUnexpectedMessageAsync(HandlerOverload overload) + { + // Arrange + RouteBuilder routeBuilder = new(null); + HandlerInvocation invocation = new(); + CancellationToken cancellationToken = new CancellationTokenSource().Token; + TestPayload payload = new("hello"); + RegisterVoidCatchAll(routeBuilder, invocation, overload); + MessageRouter router = routeBuilder.Build(); + TestWorkflowContext context = new("executor"); + + // Act + CallResult? result = await router.RouteMessageAsync(payload, context, cancellationToken: cancellationToken); + + // Assert + result.Should().NotBeNull(); + result!.IsSuccess.Should().BeTrue(); + result.IsVoid.Should().BeTrue(); + result.Result.Should().BeNull(); + invocation.InvocationCount.Should().Be(1); + invocation.Message.Should().BeEquivalentTo(new PortableValue(payload)); + invocation.Context.Should().BeSameAs(context); + + if (UsesCancellationToken(overload)) + { + invocation.CancellationToken.Should().Be(cancellationToken); + } + } + + [Theory] + [InlineData(HandlerOverload.SyncWithCancellation)] + [InlineData(HandlerOverload.SyncWithoutCancellation)] + [InlineData(HandlerOverload.AsyncWithCancellation)] + [InlineData(HandlerOverload.AsyncWithoutCancellation)] + public async Task AddCatchAll_ResultOverloads_RouteUnexpectedMessageAsync(HandlerOverload overload) + { + // Arrange + RouteBuilder routeBuilder = new(null); + HandlerInvocation invocation = new(); + CancellationToken cancellationToken = new CancellationTokenSource().Token; + TestPayload payload = new("hello"); + RegisterResultCatchAll(routeBuilder, invocation, overload); + MessageRouter router = routeBuilder.Build(); + TestWorkflowContext context = new("executor"); + + // Act + CallResult? result = await router.RouteMessageAsync(payload, context, cancellationToken: cancellationToken); + + // Assert + result.Should().NotBeNull(); + result!.IsSuccess.Should().BeTrue(); + result.IsVoid.Should().BeFalse(); + result.Result.Should().Be("HELLO"); + invocation.InvocationCount.Should().Be(1); + invocation.Message.Should().BeEquivalentTo(new PortableValue(payload)); + invocation.Context.Should().BeSameAs(context); + + if (UsesCancellationToken(overload)) + { + invocation.CancellationToken.Should().Be(cancellationToken); + } + } + + [Fact] + public async Task AddHandlerUntyped_VoidAndResultOverloads_RouteExpectedMessageAsync() + { + // Arrange + RouteBuilder routeBuilder = new(null); + HandlerInvocation voidInvocation = new(); + HandlerInvocation resultInvocation = new(); + CancellationToken cancellationToken = new CancellationTokenSource().Token; + routeBuilder.AddHandlerUntyped(typeof(string), (message, context, token) => + { + voidInvocation.Capture(message, context, token); + return default; + }); + routeBuilder.AddHandlerUntyped(typeof(int), (message, context, token) => + { + resultInvocation.Capture(message, context, token); + return new((int)message + 1); + }); + MessageRouter router = routeBuilder.Build(); + TestWorkflowContext context = new("executor"); + + // Act + CallResult? voidResult = await router.RouteMessageAsync("hello", context, cancellationToken: cancellationToken); + CallResult? typedResult = await router.RouteMessageAsync(41, context, cancellationToken: cancellationToken); + + // Assert + voidResult.Should().NotBeNull(); + voidResult!.IsVoid.Should().BeTrue(); + voidInvocation.Message.Should().Be("hello"); + voidInvocation.Context.Should().BeSameAs(context); + voidInvocation.CancellationToken.Should().Be(cancellationToken); + + typedResult.Should().NotBeNull(); + typedResult!.Result.Should().Be(42); + router.DefaultOutputTypes.Should().Contain(typeof(int)); + resultInvocation.Message.Should().Be(41); + resultInvocation.Context.Should().BeSameAs(context); + resultInvocation.CancellationToken.Should().Be(cancellationToken); + } + + [Fact] + public void AddHandler_ForPortableValue_ThrowsInvalidOperationException() + { + // Arrange + RouteBuilder routeBuilder = new(null); + + // Act + Action act = () => routeBuilder.AddHandler((message, context) => { }); + + // Assert + act.Should().Throw() + .WithMessage("*Use AddCatchAll()*"); + } + + [Fact] + public void AddHandler_DuplicateRegistrationWithoutOverwrite_ThrowsArgumentException() + { + // Arrange + RouteBuilder routeBuilder = new(null); + routeBuilder.AddHandler((message, context) => { }); + + // Act + Action act = () => routeBuilder.AddHandler((message, context) => { }); + + // Assert + act.Should().Throw() + .WithMessage("*already registered*"); + } + + [Fact] + public void AddHandler_OverwriteWithoutExistingRegistration_ThrowsArgumentException() + { + // Arrange + RouteBuilder routeBuilder = new(null); + + // Act + Action act = () => routeBuilder.AddHandler((message, context) => { }, overwrite: true); + + // Assert + act.Should().Throw() + .WithMessage("*has not yet been registered*"); + } + + [Fact] + public async Task AddHandler_OverwriteExistingRegistration_RoutesUpdatedHandlerAsync() + { + // Arrange + RouteBuilder routeBuilder = new(null); + routeBuilder.AddHandler((message, context) => context.SendMessageAsync("first")); + routeBuilder.AddHandler((message, context) => context.SendMessageAsync("second"), overwrite: true); + MessageRouter router = routeBuilder.Build(); + TestWorkflowContext context = new("executor"); + + // Act + _ = await router.RouteMessageAsync("hello", context); + + // Assert + context.SentMessages.Should().ContainSingle().Which.Should().Be("second"); + } + + [Fact] + public void AddCatchAll_DuplicateRegistrationWithoutOverwrite_ThrowsInvalidOperationException() + { + // Arrange + RouteBuilder routeBuilder = new(null); + routeBuilder.AddCatchAll((message, context) => { }); + + // Act + Action act = () => routeBuilder.AddCatchAll((message, context) => { }); + + // Assert + act.Should().Throw() + .WithMessage("*already registered*"); + } + + [Fact] + public async Task AddCatchAll_OverwriteExistingRegistration_RoutesUpdatedHandlerAsync() + { + // Arrange + RouteBuilder routeBuilder = new(null); + routeBuilder.AddCatchAll((message, context) => context.SendMessageAsync("first")); + routeBuilder.AddCatchAll((message, context) => context.SendMessageAsync("second"), overwrite: true); + MessageRouter router = routeBuilder.Build(); + TestWorkflowContext context = new("executor"); + + // Act + _ = await router.RouteMessageAsync(new TestPayload("hello"), context); + + // Assert + context.SentMessages.Should().ContainSingle().Which.Should().Be("second"); + } + + [Fact] + public void AddPortHandler_WithoutExternalRequestContext_ThrowsInvalidOperationException() + { + // Arrange + RouteBuilder routeBuilder = new(null); + + // Act + Action act = () => routeBuilder.AddPortHandler("port", (response, context, cancellationToken) => default, out _); + + // Assert + act.Should().Throw() + .WithMessage("*external request context is required*"); + } + + [Fact] + public async Task AddPortHandler_RoutesMatchingExternalResponseAsync() + { + // Arrange + TestExternalRequestContext externalRequestContext = new(); + RouteBuilder routeBuilder = new(externalRequestContext); + HandlerInvocation invocation = new(); + routeBuilder.AddPortHandler("port", (response, context, cancellationToken) => + { + invocation.Capture(response, context, cancellationToken); + return default; + }, out PortBinding portBinding); + await portBinding.PostRequestAsync("request", requestId: "req-1"); + MessageRouter router = routeBuilder.Build(); + TestWorkflowContext context = new("executor"); + CancellationToken cancellationToken = new CancellationTokenSource().Token; + ExternalResponse response = externalRequestContext.PostedRequests.Single().CreateResponse(42); + + // Act + CallResult? result = await router.RouteMessageAsync(response, context, cancellationToken: cancellationToken); + + // Assert + externalRequestContext.RegisteredPorts.Should().ContainSingle(port => port.Id == "port"); + externalRequestContext.PostedRequests.Should().ContainSingle(request => request.RequestId == "req-1"); + result.Should().NotBeNull(); + result!.IsSuccess.Should().BeTrue(); + result.Result.Should().BeSameAs(response); + invocation.InvocationCount.Should().Be(1); + invocation.Message.Should().Be(42); + invocation.Context.Should().BeSameAs(context); + invocation.CancellationToken.Should().Be(cancellationToken); + } + + [Fact] + public async Task AddPortHandler_UnknownPort_ReturnsExceptionResultAsync() + { + // Arrange + TestExternalRequestContext externalRequestContext = new(); + RouteBuilder routeBuilder = new(externalRequestContext); + routeBuilder.AddPortHandler("port", (response, context, cancellationToken) => default, out _); + MessageRouter router = routeBuilder.Build(); + ExternalRequest request = ExternalRequest.Create(RequestPort.Create("other"), "request", requestId: "req-1"); + + // Act + CallResult? result = await router.RouteMessageAsync(request.CreateResponse(42), new TestWorkflowContext("executor")); + + // Assert + result.Should().NotBeNull(); + result!.IsSuccess.Should().BeFalse(); + result.Exception.Should().BeOfType(); + result.Exception!.Message.Should().Contain("Unknown port"); + } + + private static void RegisterVoidHandler(RouteBuilder routeBuilder, HandlerInvocation invocation, HandlerOverload overload) + { + switch (overload) + { + case HandlerOverload.SyncWithCancellation: + routeBuilder.AddHandler((message, context, cancellationToken) => invocation.Capture(message, context, cancellationToken)); + break; + case HandlerOverload.SyncWithoutCancellation: + routeBuilder.AddHandler((message, context) => invocation.Capture(message, context)); + break; + case HandlerOverload.AsyncWithCancellation: + routeBuilder.AddHandler((message, context, cancellationToken) => + { + invocation.Capture(message, context, cancellationToken); + return default; + }); + break; + case HandlerOverload.AsyncWithoutCancellation: + routeBuilder.AddHandler((message, context) => + { + invocation.Capture(message, context); + return default; + }); + break; + default: + throw new ArgumentOutOfRangeException(nameof(overload)); + } + } + + private static void RegisterResultHandler(RouteBuilder routeBuilder, HandlerInvocation invocation, HandlerOverload overload) + { + switch (overload) + { + case HandlerOverload.SyncWithCancellation: + routeBuilder.AddHandler((message, context, cancellationToken) => + { + invocation.Capture(message, context, cancellationToken); + return NormalizeHandlerResult(message); + }); + break; + case HandlerOverload.SyncWithoutCancellation: + routeBuilder.AddHandler((message, context) => + { + invocation.Capture(message, context); + return NormalizeHandlerResult(message); + }); + break; + case HandlerOverload.AsyncWithCancellation: + Func> asyncHandlerWithCancellation = (message, context, cancellationToken) => + { + invocation.Capture(message, context, cancellationToken); + return new ValueTask(NormalizeHandlerResult(message)); + }; + routeBuilder.AddHandler(asyncHandlerWithCancellation); + break; + case HandlerOverload.AsyncWithoutCancellation: + Func> asyncHandler = (message, context) => + { + invocation.Capture(message, context); + return new ValueTask(NormalizeHandlerResult(message)); + }; + routeBuilder.AddHandler(asyncHandler); + break; + default: + throw new ArgumentOutOfRangeException(nameof(overload)); + } + } + + private static void RegisterVoidCatchAll(RouteBuilder routeBuilder, HandlerInvocation invocation, HandlerOverload overload) + { + switch (overload) + { + case HandlerOverload.SyncWithCancellation: + routeBuilder.AddCatchAll((message, context, cancellationToken) => invocation.Capture(message, context, cancellationToken)); + break; + case HandlerOverload.SyncWithoutCancellation: + routeBuilder.AddCatchAll((message, context) => invocation.Capture(message, context)); + break; + case HandlerOverload.AsyncWithCancellation: + routeBuilder.AddCatchAll((message, context, cancellationToken) => + { + invocation.Capture(message, context, cancellationToken); + return default; + }); + break; + case HandlerOverload.AsyncWithoutCancellation: + routeBuilder.AddCatchAll((message, context) => + { + invocation.Capture(message, context); + return default; + }); + break; + default: + throw new ArgumentOutOfRangeException(nameof(overload)); + } + } + + private static void RegisterResultCatchAll(RouteBuilder routeBuilder, HandlerInvocation invocation, HandlerOverload overload) + { + switch (overload) + { + case HandlerOverload.SyncWithCancellation: + routeBuilder.AddCatchAll((message, context, cancellationToken) => + { + invocation.Capture(message, context, cancellationToken); + return NormalizeCatchAllResult(message); + }); + break; + case HandlerOverload.SyncWithoutCancellation: + routeBuilder.AddCatchAll((message, context) => + { + invocation.Capture(message, context); + return NormalizeCatchAllResult(message); + }); + break; + case HandlerOverload.AsyncWithCancellation: + Func> asyncCatchAllWithCancellation = (message, context, cancellationToken) => + { + invocation.Capture(message, context, cancellationToken); + return new ValueTask(NormalizeCatchAllResult(message)); + }; + routeBuilder.AddCatchAll(asyncCatchAllWithCancellation); + break; + case HandlerOverload.AsyncWithoutCancellation: + Func> asyncCatchAll = (message, context) => + { + invocation.Capture(message, context); + return new ValueTask(NormalizeCatchAllResult(message)); + }; + routeBuilder.AddCatchAll(asyncCatchAll); + break; + default: + throw new ArgumentOutOfRangeException(nameof(overload)); + } + } + + private static bool UsesCancellationToken(HandlerOverload overload) => + overload is HandlerOverload.SyncWithCancellation or HandlerOverload.AsyncWithCancellation; + + private static string NormalizeHandlerResult(string message) => message.ToUpperInvariant(); + + private static string NormalizeCatchAllResult(PortableValue message) => GetPayloadValue(message).ToUpperInvariant(); + + private static string GetPayloadValue(PortableValue message) + { + return message.As() is TestPayload payload + ? payload.Value + : throw new InvalidOperationException("Expected catch-all message payload to deserialize as TestPayload."); + } +} From ae666a48874d3708eecba5f684fc83c0cfa345d0 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 14 May 2026 23:56:34 +0900 Subject: [PATCH 06/65] Python: Bump agent-framework-ag-ui to release candidate stage (#5844) * Bump agent-framework-ag-ui to release candidate stage * Mark agent-framework-ag-ui as rc in PACKAGE_STATUS --- python/PACKAGE_STATUS.md | 2 +- python/packages/ag-ui/pyproject.toml | 2 +- python/uv.lock | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index ec996de657..2b9730a890 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -16,7 +16,7 @@ Status is grouped into these buckets: | --- | --- | --- | | `agent-framework` | `python/` | `released` | | `agent-framework-a2a` | `python/packages/a2a` | `beta` | -| `agent-framework-ag-ui` | `python/packages/ag-ui` | `beta` | +| `agent-framework-ag-ui` | `python/packages/ag-ui` | `rc` | | `agent-framework-anthropic` | `python/packages/anthropic` | `beta` | | `agent-framework-azure-contentunderstanding` | `python/packages/azure-contentunderstanding` | `alpha` | | `agent-framework-azure-ai-search` | `python/packages/azure-ai-search` | `beta` | diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index f26a08745a..6e16d0cecd 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-ag-ui" -version = "1.0.0b260507" +version = "1.0.0rc1" description = "AG-UI protocol integration for Agent Framework" readme = "README.md" license-files = ["LICENSE"] diff --git a/python/uv.lock b/python/uv.lock index 76d1c03fb0..cd8ee8a0ca 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -178,7 +178,7 @@ requires-dist = [ [[package]] name = "agent-framework-ag-ui" -version = "1.0.0b260507" +version = "1.0.0rc1" source = { editable = "packages/ag-ui" } dependencies = [ { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -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 0e12640c70e260f93cb592e79e125d91f5d77522 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 15 May 2026 00:05:27 +0900 Subject: [PATCH 07/65] Improvements for DevUI (#5840) --- python/packages/devui/AGENTS.md | 6 + python/packages/devui/README.md | 43 +++-- .../devui/agent_framework_devui/__init__.py | 23 --- .../devui/agent_framework_devui/_cli.py | 6 +- .../devui/agent_framework_devui/_server.py | 23 ++- python/packages/devui/dev.md | 7 + .../packages/devui/tests/devui/test_server.py | 181 +++++++++++++++++- 7 files changed, 244 insertions(+), 45 deletions(-) diff --git a/python/packages/devui/AGENTS.md b/python/packages/devui/AGENTS.md index 5213095244..a3febee047 100644 --- a/python/packages/devui/AGENTS.md +++ b/python/packages/devui/AGENTS.md @@ -34,6 +34,12 @@ devui ./agents devui --entities my_agent.py ``` +## Security Posture + +DevUI is a development-only sample app, not a production hosting surface. Authentication is enabled by default. +Unauthenticated mode is allowed only on `localhost` / `127.0.0.1`; `0.0.0.0`, LAN IPs, and hostnames require +`DEVUI_AUTH_TOKEN` or `--auth-token`. + ## Import Path ```python diff --git a/python/packages/devui/README.md b/python/packages/devui/README.md index 669e7cd4d4..f5acf2dad6 100644 --- a/python/packages/devui/README.md +++ b/python/packages/devui/README.md @@ -47,6 +47,9 @@ devui ./agents --port 8080 # → API: http://localhost:8080/v1/* ``` +DevUI is auth-enabled by default. Localhost starts with a generated development token logged at startup; pass it as +`Authorization: Bearer ` for direct API calls. + When DevUI starts with no discovered entities, it displays a **sample entity gallery** with curated examples from the Agent Framework repository. You can download these samples, review them, and run them locally to get started quickly. ## Using MCP Tools @@ -137,12 +140,14 @@ For convenience, DevUI provides an OpenAI Responses backend API. This means you ```bash # Simple - use your entity name as the entity_id in metadata curl -X POST http://localhost:8080/v1/responses \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d @- << 'EOF' { "metadata": {"entity_id": "weather_agent"}, "input": "Hello world" } +EOF ``` Or use the OpenAI Python SDK: @@ -152,7 +157,7 @@ from openai import OpenAI client = OpenAI( base_url="http://localhost:8080/v1", - api_key="not-needed" # API key not required for local DevUI + api_key="" ) response = client.responses.create( @@ -201,6 +206,7 @@ DevUI provides an **OpenAI Proxy** feature for testing OpenAI models directly th ```bash curl -X POST http://localhost:8080/v1/responses \ + -H "Authorization: Bearer " \ -H "X-Proxy-Backend: openai" \ -d '{"model": "gpt-4.1-mini", "input": "Hello"}' ``` @@ -214,14 +220,14 @@ devui [directory] [options] Options: --port, -p Port (default: 8080) - --host Host (default: 127.0.0.1) + --host Host (default: 127.0.0.1; non-loopback hosts require auth) --headless API only, no UI --no-open Don't automatically open browser --instrumentation Enable OpenTelemetry instrumentation --reload Enable auto-reload --mode developer|user (default: developer) - --auth Enable Bearer token authentication - --auth-token Custom authentication token + --no-auth Disable auth for loopback-only local development + --auth-token Custom authentication token (required for non-loopback hosts unless DEVUI_AUTH_TOKEN is set) ``` ### UI Modes @@ -233,8 +239,8 @@ Options: # Development devui ./agents -# Production (user-facing) -devui ./agents --mode user --auth +# Local-only no-auth development +devui ./agents --no-auth ``` ## Key Endpoints @@ -336,28 +342,39 @@ These custom extensions are clearly namespaced and can be safely ignored by stan ## Security -DevUI is designed as a **sample application for local development** and should not be exposed to untrusted networks without proper authentication. +DevUI is designed as a **sample application for local development** and is not intended for production use. For +production, or for features beyond this sample app, build a custom interface and API server using the Agent Framework SDK. -**For production deployments:** +Auth is enabled by default. Unauthenticated mode is allowed only when DevUI is bound to `localhost` or `127.0.0.1`. +Network-reachable binds such as `0.0.0.0`, LAN IPs, and hostnames require Bearer token authentication with an explicit +token. + +**For shared development hosts:** ```bash -# User mode with authentication (recommended) -devui ./agents --mode user --auth --host 0.0.0.0 +# Set a token explicitly before binding beyond loopback +DEVUI_AUTH_TOKEN="" devui ./agents --mode user --host 0.0.0.0 + +# Or pass the token on the command line +devui ./agents --mode user --host 0.0.0.0 --auth-token "" ``` -This restricts developer APIs (reload, deployment, entity details) and requires Bearer token authentication. +Do not use `--no-auth` with `0.0.0.0`, LAN IPs, or hostnames. That configuration fails closed before startup. **Security features:** - User mode restricts developer-facing APIs -- Optional Bearer token authentication via `--auth` +- Bearer token authentication is enabled by default +- Unauthenticated mode is loopback-only (`localhost` / `127.0.0.1`) +- Non-loopback binds require `DEVUI_AUTH_TOKEN` or `--auth-token` - Only loads entities from local directories or in-memory registration - No remote code execution capabilities - Binds to localhost (127.0.0.1) by default **Best practices:** -- Use `--mode user --auth` for any deployment exposed to end users +- Do not use DevUI as a production deployment surface +- Use `--mode user` plus `DEVUI_AUTH_TOKEN` or `--auth-token` for shared development hosts - Review all agent/workflow code before running - Only load entities from trusted sources - Use `.env` files for sensitive credentials (never commit them) diff --git a/python/packages/devui/agent_framework_devui/__init__.py b/python/packages/devui/agent_framework_devui/__init__.py index b647c60fed..470134cb09 100644 --- a/python/packages/devui/agent_framework_devui/__init__.py +++ b/python/packages/devui/agent_framework_devui/__init__.py @@ -126,29 +126,6 @@ def serve( if not isinstance(port, int) or not (1 <= port <= 65535): raise ValueError(f"Invalid port: {port}. Must be integer between 1 and 65535") - # Security check: warn loudly when network-exposed without authentication. - if host not in ("127.0.0.1", "localhost") and not auth_enabled: - logger.warning("WARNING: Exposing DevUI to the network with --no-auth.") - logger.warning("Anyone on your network can read agent metadata and trigger requests.") - logger.warning("Drop --no-auth and DevUI will require Bearer tokens.") - - # Refuse to auto-generate a token for network-exposed binds. Auto-generated tokens - # are fine for localhost convenience; for anything else, require an explicit token. - if auth_enabled and not auth_token: - import os - - env_token = os.environ.get("DEVUI_AUTH_TOKEN") - if not env_token: - is_production = ( - host not in ("127.0.0.1", "localhost") - or os.environ.get("CI") == "true" - or os.environ.get("KUBERNETES_SERVICE_HOST") - ) - if is_production: - logger.error("Authentication required but no token provided.") - logger.error("Set DEVUI_AUTH_TOKEN env var or pass auth_token='...' to serve().") - raise ValueError("DEVUI_AUTH_TOKEN required when host is not localhost") - # Enable instrumentation if requested if instrumentation_enabled: from agent_framework.observability import enable_instrumentation diff --git a/python/packages/devui/agent_framework_devui/_cli.py b/python/packages/devui/agent_framework_devui/_cli.py index e5e64b6fd4..209b982cba 100644 --- a/python/packages/devui/agent_framework_devui/_cli.py +++ b/python/packages/devui/agent_framework_devui/_cli.py @@ -81,13 +81,15 @@ Examples: parser.add_argument( "--no-auth", action="store_true", - help="Disable Bearer token authentication. DevUI is auth-enabled by default; use this to opt out.", + help=( + "Disable Bearer token authentication for loopback-only local development. Non-loopback hosts require auth." + ), ) parser.add_argument( "--auth-token", type=str, - help="Custom Bearer token. Auto-generated and logged at startup when omitted.", + help="Custom Bearer token. Required for non-loopback hosts when DEVUI_AUTH_TOKEN is not set.", ) parser.add_argument("--version", action="version", version=f"Agent Framework DevUI {get_version()}") diff --git a/python/packages/devui/agent_framework_devui/_server.py b/python/packages/devui/agent_framework_devui/_server.py index 416821f40e..08c454f94e 100644 --- a/python/packages/devui/agent_framework_devui/_server.py +++ b/python/packages/devui/agent_framework_devui/_server.py @@ -89,7 +89,7 @@ class DevServer: mode: Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors) auth_enabled: Whether to require Bearer token auth on /v1/* endpoints. Defaults to True. auth_token: Bearer token. If None and auth_enabled, falls back to the DEVUI_AUTH_TOKEN - environment variable, then to an auto-generated token (logged at startup). + environment variable. Loopback binds may use an auto-generated token logged at startup. """ self.entities_dir = entities_dir self.port = port @@ -106,7 +106,7 @@ class DevServer: self.ui_enabled = ui_enabled self.mode = mode self.auth_enabled = auth_enabled - self.auth_token = self._resolve_auth_token(auth_enabled, auth_token) + self.auth_token = self._resolve_auth_token(host, auth_enabled, auth_token) self.executor: AgentFrameworkExecutor | None = None self.openai_executor: OpenAIExecutor | None = None self.deployment_manager = DeploymentManager() @@ -118,8 +118,14 @@ class DevServer: """Set in-memory entities to register on startup.""" self._pending_entities = entities + _AUTH_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost"}) _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "[::1]", "::1"}) + @classmethod + def _is_auth_loopback_host(cls, host: str) -> bool: + """Return True when unauthenticated DevUI may be limited to local loopback.""" + return host.lower() in cls._AUTH_LOOPBACK_HOSTS + def _loopback_allowed_hosts(self) -> frozenset[str] | None: """Return the Host-header allowlist when bound to a loopback interface, else None. @@ -131,16 +137,25 @@ class DevServer: return None return self._LOOPBACK_HOSTS - @staticmethod - def _resolve_auth_token(auth_enabled: bool, auth_token: str | None) -> str | None: + @classmethod + def _resolve_auth_token(cls, host: str, auth_enabled: bool, auth_token: str | None) -> str | None: """Resolve the active Bearer token. Returns None when auth is disabled.""" + is_loopback = cls._is_auth_loopback_host(host) if not auth_enabled: + if not is_loopback: + raise ValueError( + "DevUI authentication cannot be disabled for non-loopback hosts. " + "Bind to 127.0.0.1/localhost for no-auth local development, or enable auth and provide " + "DEVUI_AUTH_TOKEN or auth_token for network-reachable binds." + ) return None if auth_token: return auth_token env_token = os.getenv("DEVUI_AUTH_TOKEN") if env_token: return env_token + if not is_loopback: + raise ValueError("DEVUI_AUTH_TOKEN or auth_token is required when DevUI is bound to a non-loopback host.") generated = secrets.token_urlsafe(32) logger.info("=" * 70) logger.info("DevUI authentication enabled with auto-generated token:") diff --git a/python/packages/devui/dev.md b/python/packages/devui/dev.md index d537c22ca7..0f7441b9bd 100644 --- a/python/packages/devui/dev.md +++ b/python/packages/devui/dev.md @@ -60,6 +60,9 @@ devui This launches the UI with all example agents/workflows at http://localhost:8080 +DevUI is auth-enabled by default. Copy the generated token from startup logs and pass it as +`Authorization: Bearer ` for direct API calls. Use `--no-auth` only for loopback-only local testing. + ## 5. What You'll See - A web interface for testing agents interactively @@ -74,6 +77,7 @@ You can also test via API calls: ```bash curl -X POST http://localhost:8080/v1/responses \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "weather_agent", @@ -86,6 +90,7 @@ curl -X POST http://localhost:8080/v1/responses \ ```bash # Create a conversation curl -X POST http://localhost:8080/v1/conversations \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"metadata": {"agent_id": "weather_agent"}}' @@ -93,6 +98,7 @@ curl -X POST http://localhost:8080/v1/conversations \ # Use conversation ID in requests curl -X POST http://localhost:8080/v1/responses \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "weather_agent", @@ -102,6 +108,7 @@ curl -X POST http://localhost:8080/v1/responses \ # Continue the conversation curl -X POST http://localhost:8080/v1/responses \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "weather_agent", diff --git a/python/packages/devui/tests/devui/test_server.py b/python/packages/devui/tests/devui/test_server.py index bcb21f4eee..76589216cb 100644 --- a/python/packages/devui/tests/devui/test_server.py +++ b/python/packages/devui/tests/devui/test_server.py @@ -4,8 +4,10 @@ import asyncio import inspect +import sys import tempfile from pathlib import Path +from typing import Any import pytest from conftest import MockAgent @@ -492,7 +494,7 @@ def test_devserver_requires_auth_by_default(monkeypatch): def test_devserver_auth_can_be_explicitly_disabled(monkeypatch): - """Callers can opt out of auth with auth_enabled=False (escape hatch for tests / trusted hosts).""" + """Callers can opt out of auth on loopback (escape hatch for tests / trusted local hosts).""" monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) server = _server_with_mock_agent(auth_enabled=False) @@ -504,6 +506,106 @@ def test_devserver_auth_can_be_explicitly_disabled(monkeypatch): assert response.status_code == 200 +def test_devserver_rejects_non_loopback_no_auth(monkeypatch): + """Non-loopback binds must not be network-reachable without authentication.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + with pytest.raises(ValueError, match="authentication cannot be disabled"): + DevServer(host="0.0.0.0", auth_enabled=False) + + with pytest.raises(ValueError, match="authentication cannot be disabled"): + DevServer(host="devui.example", auth_enabled=False) + + +def test_devserver_rejects_non_loopback_without_explicit_token(monkeypatch): + """Network-reachable auth requires an operator-provided token, not a generated token.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + with pytest.raises(ValueError, match="DEVUI_AUTH_TOKEN or auth_token"): + DevServer(host="0.0.0.0") + + +def test_devserver_allows_non_loopback_with_explicit_token(monkeypatch): + """A network-reachable bind is allowed when auth has an explicit token.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + server = DevServer(host="0.0.0.0", auth_token="s3cret") + + assert server.auth_enabled is True + assert server.auth_token == "s3cret" + + +def test_devserver_allows_non_loopback_with_env_token(monkeypatch): + """A network-reachable bind is allowed when auth uses DEVUI_AUTH_TOKEN.""" + monkeypatch.setenv("DEVUI_AUTH_TOKEN", "env-s3cret") + + server = DevServer(host="0.0.0.0") + + assert server.auth_enabled is True + assert server.auth_token == "env-s3cret" + + +def test_devserver_allows_loopback_no_auth(monkeypatch): + """Unauthenticated DevUI remains available for local-only development and tests.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + for host in ("127.0.0.1", "localhost"): + server = DevServer(host=host, auth_enabled=False) + assert server.auth_enabled is False + assert server.auth_token is None + + +def test_devserver_loopback_auth_auto_generates_token(monkeypatch): + """Loopback auth-enabled usage may still use a generated development token.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + server = DevServer(host="127.0.0.1") + + assert server.auth_enabled is True + assert server.auth_token + + +def test_serve_rejects_non_loopback_no_auth(monkeypatch): + """The public serve() helper must inherit the DevServer network-auth invariant.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + with pytest.raises(ValueError, match="authentication cannot be disabled"): + agent_framework_devui.serve(entities=[], host="0.0.0.0", auth_enabled=False, ui_enabled=False) + + +def test_serve_rejects_non_loopback_without_explicit_token(monkeypatch): + """serve() must not maintain a weaker generated-token path for network binds.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + with pytest.raises(ValueError, match="DEVUI_AUTH_TOKEN or auth_token"): + agent_framework_devui.serve(entities=[], host="0.0.0.0", ui_enabled=False) + + +def test_serve_allows_non_loopback_with_explicit_token(monkeypatch): + """serve() accepts a network bind when an explicit token is provided.""" + import uvicorn + + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + run_args = {} + + def fake_run(_app, *, host, port, **_kwargs): + run_args["host"] = host + run_args["port"] = port + + monkeypatch.setattr(uvicorn, "run", fake_run) + + agent_framework_devui.serve( + entities=[], + host="0.0.0.0", + port=9090, + auth_token="s3cret", + auto_open=False, + ui_enabled=False, + ) + + assert run_args == {"host": "0.0.0.0", "port": 9090} + + def test_devserver_accepts_request_with_valid_bearer_token(monkeypatch): """When auth is on, supplying the configured Bearer token grants access.""" monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) @@ -567,8 +669,8 @@ def test_serve_defaults_to_auth_enabled(): ) -def test_cli_enables_auth_by_default_and_supports_no_auth_optout(): - """`devui ./agents` must produce auth-enabled config; `--no-auth` is the explicit escape hatch.""" +def test_cli_enables_auth_by_default_and_supports_loopback_no_auth_optout(): + """`devui ./agents` must produce auth-enabled config; `--no-auth` is the loopback-only escape hatch.""" from agent_framework_devui._cli import create_cli_parser parser = create_cli_parser() @@ -578,3 +680,76 @@ def test_cli_enables_auth_by_default_and_supports_no_auth_optout(): optout_args = parser.parse_args(["--no-auth"]) assert optout_args.no_auth is True + + help_text = parser.format_help() + assert "loopback-only" in help_text + assert "Non-loopback hosts require auth" in help_text + + +def _run_cli_with_fake_uvicorn(monkeypatch, tmp_path: Path, *args: str) -> dict[str, Any]: + """Run the DevUI CLI without binding a socket.""" + import uvicorn + + from agent_framework_devui import _cli + + run_args: dict[str, Any] = {} + + def fake_run(_app, *, host, port, **_kwargs): + run_args["host"] = host + run_args["port"] = port + + monkeypatch.setattr(uvicorn, "run", fake_run) + monkeypatch.setattr(sys, "argv", ["devui", str(tmp_path), "--no-open", "--headless", *args]) + + _cli.main() + + return run_args + + +def test_cli_allows_loopback_no_auth_without_binding_socket(monkeypatch, tmp_path): + """`devui --no-auth` remains valid on the default loopback host.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + run_args = _run_cli_with_fake_uvicorn(monkeypatch, tmp_path, "--no-auth") + + assert run_args == {"host": "127.0.0.1", "port": 8080} + + +def test_cli_rejects_non_loopback_no_auth_before_binding_socket(monkeypatch, tmp_path, capsys): + """`devui --host 0.0.0.0 --no-auth` must fail through shared server validation.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + with pytest.raises(SystemExit) as exc_info: + _run_cli_with_fake_uvicorn(monkeypatch, tmp_path, "--host", "0.0.0.0", "--no-auth") + + assert exc_info.value.code == 1 + assert "authentication cannot be disabled" in capsys.readouterr().err + + +def test_cli_rejects_non_loopback_without_explicit_token_before_binding_socket(monkeypatch, tmp_path, capsys): + """`devui --host 0.0.0.0` must fail when neither --auth-token nor DEVUI_AUTH_TOKEN is set.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + with pytest.raises(SystemExit) as exc_info: + _run_cli_with_fake_uvicorn(monkeypatch, tmp_path, "--host", "0.0.0.0") + + assert exc_info.value.code == 1 + assert "DEVUI_AUTH_TOKEN or auth_token" in capsys.readouterr().err + + +def test_cli_allows_non_loopback_with_auth_token_without_binding_socket(monkeypatch, tmp_path): + """`devui --host 0.0.0.0 --auth-token ...` starts with token auth enabled.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + run_args = _run_cli_with_fake_uvicorn(monkeypatch, tmp_path, "--host", "0.0.0.0", "--auth-token", "s3cret") + + assert run_args == {"host": "0.0.0.0", "port": 8080} + + +def test_cli_allows_non_loopback_with_env_token_without_binding_socket(monkeypatch, tmp_path): + """`DEVUI_AUTH_TOKEN=... devui --host 0.0.0.0` starts with token auth enabled.""" + monkeypatch.setenv("DEVUI_AUTH_TOKEN", "env-s3cret") + + run_args = _run_cli_with_fake_uvicorn(monkeypatch, tmp_path, "--host", "0.0.0.0") + + assert run_args == {"host": "0.0.0.0", "port": 8080} From 3047ad3066cce37cac79c923570a169362cf8ae1 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 14 May 2026 16:22:11 +0100 Subject: [PATCH 08/65] .NET: Harness console refactoring (#5811) * Restructure harness console so that reactive app is the entry point * Further refactoring to split tool formatters, improve UX, make console configurable and fix bugs * Address PR comments. * UX tweak * Fix streaming text bug * Address PR comments. --- .../ConsoleReactiveComponents/TextPanel.cs | 26 +- .../TextScrollPanel.cs | 16 +- .../Harness_ConsoleSandbox/AppComponent.cs | 315 ------------ .../Commands/CommandHandler.cs | 4 +- .../Commands/ExitCommandHandler.cs | 26 + .../Commands/ModeCommandHandler.cs | 4 +- .../Commands/TodoCommandHandler.cs | 4 +- .../Harness_Shared_Console/FollowUpAction.cs | 59 +++ .../HarnessAgentRunner.cs | 279 ++++++++++ .../HarnessAppComponent.cs | 450 +++++++++-------- .../HarnessAppComponentState.cs | 125 +++++ .../Harness_Shared_Console/HarnessConsole.cs | 250 ++------- .../HarnessConsoleOptions.cs | 137 ++++- .../HarnessConsoleUXStateDriver.cs | 408 +++++++++++++++ .../HarnessUXContainer.cs | 478 ------------------ .../Harness_Shared_Console/IUXStateDriver.cs | 120 +++++ .../Observers/ConsoleObserver.cs | 39 +- .../Observers/ErrorDisplayObserver.cs | 5 +- .../Observers/PlanningOutputObserver.cs | 155 ++++-- .../Observers/ReasoningDisplayObserver.cs | 5 +- .../Observers/TextOutputObserver.cs | 6 +- .../Observers/ToolApprovalObserver.cs | 115 +++-- .../Observers/ToolCallDisplayObserver.cs | 20 +- .../Observers/ToolCallFormatter.cs | 288 ----------- .../Observers/UsageDisplayObserver.cs | 5 +- .../Harness_Shared_Console/OutputEntry.cs | 7 +- .../ToolFormatters/FallbackToolFormatter.cs | 51 ++ .../ToolFormatters/FileMemoryToolFormatter.cs | 61 +++ .../ToolFormatters/ModeToolFormatter.cs | 27 + .../ToolFormatters/SubAgentToolFormatter.cs | 101 ++++ .../ToolFormatters/TodoToolFormatter.cs | 84 +++ .../ToolFormatters/ToolCallFormatter.cs | 135 +++++ .../ToolFormatters/WebSearchToolFormatter.cs | 22 + .../DownloadUriToolFormatter.cs | 23 + .../Harness_Step01_Research/Program.cs | 18 +- .../Program.cs | 3 +- .../Harness_Step03_DataProcessing/Program.cs | 3 +- .../HarnessAgent.cs | 2 + 38 files changed, 2152 insertions(+), 1724 deletions(-) delete mode 100644 dotnet/samples/02-agents/Harness/Harness_ConsoleSandbox/AppComponent.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ExitCommandHandler.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/FollowUpAction.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAgentRunner.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponentState.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleUXStateDriver.cs delete mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessUXContainer.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/IUXStateDriver.cs delete mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallFormatter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/FallbackToolFormatter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/FileMemoryToolFormatter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ModeToolFormatter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/SubAgentToolFormatter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/TodoToolFormatter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ToolCallFormatter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/WebSearchToolFormatter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Step01_Research/DownloadUriToolFormatter.cs diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs index cfd2b8c5ee..a9651f7726 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs @@ -9,42 +9,30 @@ namespace Harness.ConsoleReactiveComponents; /// public record TextPanelProps : ConsoleReactiveProps { - /// Gets the items to render in the panel. - public IReadOnlyList Items { get; init; } = []; + /// Gets the items to render in the panel. Each item is a pre-rendered + /// console string (may include ANSI escape sequences and newlines). + public IReadOnlyList Items { get; init; } = []; } /// -/// A component that renders a list of items vertically using a custom render delegate. +/// A component that renders a list of pre-rendered string items vertically. /// Designed for rendering dynamic items in a non-scroll region that may be /// re-rendered on each update. If the component's /// exceeds the number of output lines, leftover lines are erased. /// public class TextPanel : ConsoleReactiveComponent { - private readonly Func _renderItem; - - /// - /// Initializes a new instance of the class. - /// - /// A delegate that renders an item and returns the text to display (may contain newlines). - public TextPanel(Func renderItem) - { - this._renderItem = renderItem; - } - /// /// Calculates the height (in lines) needed to render all items. /// /// The items to measure. - /// The render delegate to use for measuring. /// The total number of lines all items will occupy. - public static int CalculateHeight(IReadOnlyList items, Func renderItem) + public static int CalculateHeight(IReadOnlyList items) { int total = 0; for (int i = 0; i < items.Count; i++) { - string text = renderItem(items[i]); - total += CountLines(text); + total += CountLines(items[i]); } return total; @@ -57,7 +45,7 @@ public class TextPanel : ConsoleReactiveComponent public record TextScrollPanelProps : ConsoleReactiveProps { - /// Gets the items to render in the scroll panel. - public IReadOnlyList Items { get; init; } = []; + /// Gets the items to render in the scroll panel. Each item is a pre-rendered + /// console string (may include ANSI escape sequences and newlines). + public IReadOnlyList Items { get; init; } = []; } /// @@ -20,21 +21,17 @@ public record TextScrollPanelProps : ConsoleReactiveProps public record TextScrollPanelState(int RenderedCount = 0) : ConsoleReactiveState; /// -/// A component that renders items within a scroll area using a custom render delegate. +/// A component that renders pre-rendered string items within a scroll area. /// All items are considered finalized — only new items since the last render are output. /// Use to force a full re-render. /// public class TextScrollPanel : ConsoleReactiveComponent { - private readonly Func _renderItem; - /// /// Initializes a new instance of the class. /// - /// A delegate that renders a single item and returns the text to display (may contain newlines). - public TextScrollPanel(Func renderItem) + public TextScrollPanel() { - this._renderItem = renderItem; this.State = new TextScrollPanelState(); } @@ -60,8 +57,7 @@ public class TextScrollPanel : ConsoleReactiveComponent -/// Determines which component is shown in the bottom panel. -/// -public enum BottomPanelMode -{ - /// Show the list selection component. - ListSelection, - - /// Show the text input component. - TextInput -} - -public record AppComponentProps : ConsoleReactiveProps -{ - public IReadOnlyList Items { get; init; } = Array.Empty(); - public IReadOnlyList ScrollItems { get; init; } = []; - - /// Gets the bottom panel mode. - public BottomPanelMode Mode { get; init; } = BottomPanelMode.ListSelection; - - /// Gets the prompt string for text input mode. - public string Prompt { get; init; } = "> "; - - /// Gets the placeholder text shown when the input is empty. - public string Placeholder { get; init; } = ""; - - /// Gets the highlight color for the active list item. Defaults to . - public ConsoleColor ListHighlightColor { get; init; } = ConsoleColor.Cyan; - - /// Gets the placeholder text for the custom text input option in the list. If null, no custom option is shown. - public string? ListCustomTextPlaceholder { get; init; } - - /// Gets the foreground color for the rule borders. If null, uses the default terminal color. - public ConsoleColor? RuleColor { get; init; } -} - -/// -/// Internal state for the . -/// -public record AppComponentState : ConsoleReactiveState -{ - /// Gets the selected index in list selection mode. - public int SelectedIndex { get; init; } - - /// Gets the current input text being typed in text input mode. - public string InputText { get; init; } = ""; - - /// Gets the current text being typed into the list's custom text option. - public string ListInputText { get; init; } = ""; -} - -public class AppComponent : ConsoleReactiveComponent -{ - private readonly TopBottomRule _rule = new(); - private readonly ListSelection _listSelection = new(); - private readonly TextInput _textInput = new(); - private readonly TextScrollPanel _textScrollPanel; - private readonly TextPanel _textPanel; - private readonly Func _renderItem; - private readonly Action _onTextInputSubmit; - private readonly Action _onListInputSubmit; - private bool _resizedSinceLastRender; - private int _lastScrollBottom; - - /// - /// Initializes a new instance of the class. - /// - /// A delegate that renders a single scroll panel item and returns the text to display. - /// A callback invoked with the input text when the user presses Enter in text input mode. - /// A callback invoked with the selected or typed text when the user presses Enter in list selection mode. - public AppComponent(Func renderScrollItem, Action onTextInputSubmit, Action onListInputSubmit) - { - this._renderItem = renderScrollItem; - this._onTextInputSubmit = onTextInputSubmit; - this._onListInputSubmit = onListInputSubmit; - this._textScrollPanel = new TextScrollPanel(renderScrollItem); - this._textPanel = new TextPanel(renderScrollItem); - this.State = new AppComponentState(); - KeyEventListener.Instance.KeyPressed += this.OnKeyPressed; - ConsoleResizeListener.Instance.ConsoleResized += this.OnConsoleResized; - } - - private void OnKeyPressed(object? sender, KeyPressEventArgs e) - { - if (this.Props!.Mode == BottomPanelMode.TextInput) - { - this.HandleTextInputKey(e); - } - else - { - this.HandleListSelectionKey(e); - } - } - - private void HandleTextInputKey(KeyPressEventArgs e) - { - if (e.KeyInfo.Key == ConsoleKey.Enter) - { - string text = this.State!.InputText; - this.SetState(this.State with { InputText = "" }); - this._onTextInputSubmit(text); - } - else if (e.KeyInfo.Key == ConsoleKey.Backspace) - { - if (this.State!.InputText.Length > 0) - { - this.SetState(this.State with { InputText = this.State.InputText[..^1] }); - } - } - else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar)) - { - this.SetState(this.State! with { InputText = this.State.InputText + e.KeyInfo.KeyChar }); - } - } - - private void HandleListSelectionKey(KeyPressEventArgs e) - { - int maxIndex = this.Props!.Items.Count - 1; - if (this.Props.ListCustomTextPlaceholder != null) - { - maxIndex = this.Props.Items.Count; // extra option at the end - } - - bool isOnCustomTextOption = this.Props.ListCustomTextPlaceholder != null - && this.State!.SelectedIndex == this.Props.Items.Count; - - if (e.KeyInfo.Key == ConsoleKey.UpArrow) - { - this.SetState(this.State! with { SelectedIndex = Math.Max(0, this.State.SelectedIndex - 1) }); - } - else if (e.KeyInfo.Key == ConsoleKey.DownArrow) - { - this.SetState(this.State! with { SelectedIndex = Math.Min(maxIndex, this.State.SelectedIndex + 1) }); - } - else if (e.KeyInfo.Key == ConsoleKey.Enter) - { - if (isOnCustomTextOption) - { - string text = this.State!.ListInputText; - this.SetState(this.State with { ListInputText = "" }); - this._onListInputSubmit(text); - } - else - { - this._onListInputSubmit(this.Props.Items[this.State!.SelectedIndex]); - } - } - else if (isOnCustomTextOption) - { - // Typing only works when on the custom text option - if (e.KeyInfo.Key == ConsoleKey.Backspace) - { - if (this.State!.ListInputText.Length > 0) - { - this.SetState(this.State with { ListInputText = this.State.ListInputText[..^1] }); - } - } - else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar)) - { - this.SetState(this.State! with { ListInputText = this.State.ListInputText + e.KeyInfo.KeyChar }); - } - } - } - - private void OnConsoleResized(object? sender, ConsoleResizeEventArgs e) - { - this._resizedSinceLastRender = true; - this.Render(); - } - - public override void RenderCore(AppComponentProps props, AppComponentState state) - { - // Determine the text panel height for the last scroll item - object? lastItem = props.ScrollItems.Count > 0 ? props.ScrollItems[^1] : null; - IReadOnlyList lastItems = lastItem != null ? [lastItem] : []; - int textPanelHeight = TextPanel.CalculateHeight(lastItems, this._renderItem); - if (textPanelHeight > 0) - { - textPanelHeight++; // Extra line for spacing between text panel and rule - } - - // Build the bottom panel child based on mode - ConsoleReactiveComponent bottomChild; - int bottomChildHeight; - - if (props.Mode == BottomPanelMode.TextInput) - { - var textInputProps = new TextInputProps - { - Prompt = props.Prompt, - Text = state.InputText, - Placeholder = props.Placeholder - }; - - bottomChildHeight = TextInput.CalculateHeight(textInputProps, Console.WindowWidth); - this._textInput.Width = Console.WindowWidth; - this._textInput.Height = bottomChildHeight; - this._textInput.Props = textInputProps; - bottomChild = this._textInput; - } - else - { - var listProps = new ListSelectionProps - { - Items = props.Items, - SelectedIndex = state.SelectedIndex, - HighlightColor = props.ListHighlightColor, - CustomTextPlaceholder = props.ListCustomTextPlaceholder, - CustomText = state.ListInputText - }; - - bottomChildHeight = ListSelection.CalculateHeight(listProps); - this._listSelection.Height = bottomChildHeight; - this._listSelection.Props = listProps; - bottomChild = this._listSelection; - } - - var ruleProps = new TopBottomRuleProps - { - Width = Console.WindowWidth, - Color = props.RuleColor, - Children = [bottomChild] - }; - - int ruleHeight = TopBottomRule.CalculateHeight(ruleProps); - int scrollBottom = Console.WindowHeight - ruleHeight - textPanelHeight; - - // If scroll region changed or a clear is needed, reset everything - if (this._resizedSinceLastRender || (this._lastScrollBottom != 0 && scrollBottom != this._lastScrollBottom)) - { - Console.Write(AnsiEscapes.EraseEntireScreen); - Console.Write(AnsiEscapes.EraseScrollbackBuffer); - this._textScrollPanel.Reset(); - this._resizedSinceLastRender = false; - } - - this._lastScrollBottom = scrollBottom; - - Console.Write(AnsiEscapes.SetScrollRegion(scrollBottom)); - - // Render text scroll panel in the scroll area (all items except the last) - IReadOnlyList scrollItems = props.ScrollItems.Count > 1 - ? props.ScrollItems.Take(props.ScrollItems.Count - 1).ToList() - : []; - - this._textScrollPanel.X = 1; - this._textScrollPanel.Y = 1; - this._textScrollPanel.Width = Console.WindowWidth; - this._textScrollPanel.Height = scrollBottom; - this._textScrollPanel.Props = new TextScrollPanelProps - { - Items = scrollItems - }; - this._textScrollPanel.Render(); - - // Render the text panel for the last (dynamic) item just below the scroll region - this._textPanel.X = 1; - this._textPanel.Y = scrollBottom + 1; - this._textPanel.Width = Console.WindowWidth; - this._textPanel.Height = textPanelHeight; - this._textPanel.Props = new TextPanelProps - { - Items = lastItems, - }; - this._textPanel.Render(); - - // Render the bottom rule + child below the text panel - this._rule.X = 1; - this._rule.Y = scrollBottom + textPanelHeight + 1; - this._rule.Props = ruleProps; - this._rule.Render(); - - // Position cursor for natural typing appearance - if (props.Mode == BottomPanelMode.TextInput) - { - int promptLength = props.Prompt.Length; - int textWidth = Console.WindowWidth - promptLength; - int textLength = state.InputText.Length; - - // The TextInput starts at rule.Y + 1 (first row inside the rule) - int textInputY = this._rule.Y + 1; - - if (textWidth <= 0 || textLength == 0) - { - // Cursor right after the prompt - Console.Write(AnsiEscapes.MoveCursor(textInputY, promptLength + 1)); - } - else - { - // Calculate which row and column the cursor lands on - int cursorRow = textLength < textWidth ? 0 : 1 + ((textLength - textWidth) / textWidth); - int cursorCol = textLength < textWidth ? textLength : (textLength - textWidth) % textWidth; - Console.Write(AnsiEscapes.MoveCursor(textInputY + cursorRow, promptLength + cursorCol + 1)); - } - } - else if (props.Mode == BottomPanelMode.ListSelection - && props.ListCustomTextPlaceholder != null - && state.SelectedIndex == props.Items.Count) - { - // Cursor after the typed text in the custom text option - // The custom text option is at rule.Y + 1 + Items.Count (0-based row inside rule) - int customOptionY = this._rule.Y + 1 + props.Items.Count; - // "> " prefix is 2 chars, then the typed text - int cursorCol = 2 + state.ListInputText.Length + 1; - Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol)); - } - } -} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/CommandHandler.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/CommandHandler.cs index 702500e283..86e9241cf4 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/CommandHandler.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/CommandHandler.cs @@ -23,7 +23,7 @@ public abstract class CommandHandler /// /// The raw user input string. /// The current agent session. - /// The UX container for rendering output. + /// The UX state driver for rendering output. /// if this handler handled the input; otherwise. - public abstract ValueTask TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux); + public abstract ValueTask TryHandleAsync(string input, AgentSession session, IUXStateDriver ux); } diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ExitCommandHandler.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ExitCommandHandler.cs new file mode 100644 index 0000000000..dd9d4b75e1 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ExitCommandHandler.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; + +namespace Harness.Shared.Console.Commands; + +/// +/// Handles the /exit command to shut down the console application. +/// +public sealed class ExitCommandHandler : CommandHandler +{ + /// + public override string? GetHelpText() => "/exit (quit)"; + + /// + public override ValueTask TryHandleAsync(string input, AgentSession session, IUXStateDriver ux) + { + if (!input.Equals("/exit", StringComparison.OrdinalIgnoreCase)) + { + return new ValueTask(false); + } + + ux.RequestShutdown(); + return new ValueTask(true); + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ModeCommandHandler.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ModeCommandHandler.cs index 2fb58fc79c..09c2cd3cb5 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ModeCommandHandler.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ModeCommandHandler.cs @@ -7,7 +7,7 @@ namespace Harness.Shared.Console.Commands; /// /// Handles the /mode command to display or switch the current agent mode. /// -internal sealed class ModeCommandHandler : CommandHandler +public sealed class ModeCommandHandler : CommandHandler { private readonly AgentModeProvider? _modeProvider; private readonly IReadOnlyDictionary? _modeColors; @@ -27,7 +27,7 @@ internal sealed class ModeCommandHandler : CommandHandler public override string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null; /// - public override async ValueTask TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux) + public override async ValueTask TryHandleAsync(string input, AgentSession session, IUXStateDriver ux) { if (!input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase) && !input.Equals("/mode", StringComparison.OrdinalIgnoreCase)) { diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/TodoCommandHandler.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/TodoCommandHandler.cs index 506648dccc..b3f8b8588d 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/TodoCommandHandler.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/TodoCommandHandler.cs @@ -7,7 +7,7 @@ namespace Harness.Shared.Console.Commands; /// /// Handles the /todos command to display the current todo list. /// -internal sealed class TodoCommandHandler : CommandHandler +public sealed class TodoCommandHandler : CommandHandler { private readonly TodoProvider? _todoProvider; @@ -24,7 +24,7 @@ internal sealed class TodoCommandHandler : CommandHandler public override string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null; /// - public override async ValueTask TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux) + public override async ValueTask TryHandleAsync(string input, AgentSession session, IUXStateDriver ux) { if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase)) { diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/FollowUpAction.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/FollowUpAction.cs new file mode 100644 index 0000000000..c08554ec4a --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/FollowUpAction.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console; + +/// +/// Represents an action returned by an observer at the end of an agent turn. +/// Subtypes describe either a question to ask the user () +/// or a message to add directly to the next agent input (). +/// +public abstract record FollowUpAction; + +/// +/// Represents a question that should be presented to the user. The +/// delegate is invoked with the user's answer and the +/// UX state driver, and returns an optional to add to the +/// next agent invocation. +/// +/// The question text shown to the user. +/// +/// Invoked with the user's answer and the UX state driver. The driver lets the +/// continuation write output (e.g., an action label like "Approved") in addition +/// to producing an optional for the next agent invocation. +/// +public abstract record FollowUpQuestion( + string Prompt, + Func> Continuation) : FollowUpAction; + +/// +/// A free-form text question. The user may type any response. +/// +/// The question text shown to the user. +/// Continuation that builds the response message. +public sealed record TextFollowUpQuestion( + string Prompt, + Func> Continuation) + : FollowUpQuestion(Prompt, Continuation); + +/// +/// A choice question. The user picks from , optionally with +/// the ability to enter custom text when is true. +/// +/// The question text shown to the user. +/// The list of pre-defined choices. +/// If true, the user may type a custom response in addition to the listed choices. +/// Continuation that builds the response message. +public sealed record ChoiceFollowUpQuestion( + string Prompt, + IReadOnlyList Choices, + bool AllowCustomText, + Func> Continuation) + : FollowUpQuestion(Prompt, Continuation); + +/// +/// A message to add directly to the next agent invocation without prompting the user. +/// +/// The chat message to add. +public sealed record FollowUpMessage(ChatMessage Message) : FollowUpAction; diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAgentRunner.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAgentRunner.cs new file mode 100644 index 0000000000..d0affc5f77 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAgentRunner.cs @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Harness.Shared.Console.Commands; +using Harness.Shared.Console.Observers; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console; + +/// +/// Orchestrates agent invocations driven by user-input events from the UI. +/// The component invokes the runner's input handlers (, +/// , ) directly; +/// the runner mutates UI state through the supplied . +/// All per-turn follow-up state (pending questions and accumulated responses) lives +/// in the component's state record — the runner reads/writes it exclusively through +/// the driver and holds no per-turn fields itself. +/// +public sealed class HarnessAgentRunner : IDisposable +{ + private readonly AIAgent _agent; + private readonly AgentSession _session; + private readonly AgentModeProvider? _modeProvider; + private readonly MessageInjectingChatClient? _messageInjector; + private readonly IReadOnlyList _commandHandlers; + private readonly IReadOnlyList _observers; + private readonly IUXStateDriver _ux; + + private readonly SemaphoreSlim _inputGate = new(1, 1); + + /// + /// Initializes a new instance of the class. + /// + public HarnessAgentRunner( + AIAgent agent, + AgentSession session, + AgentModeProvider? modeProvider, + MessageInjectingChatClient? messageInjector, + IReadOnlyList commandHandlers, + IReadOnlyList observers, + IUXStateDriver ux) + { + this._agent = agent; + this._session = session; + this._modeProvider = modeProvider; + this._messageInjector = messageInjector; + this._commandHandlers = commandHandlers; + this._observers = observers; + this._ux = ux; + + this.HelpText = string.Join( + ", ", + commandHandlers + .Select(h => h.GetHelpText()) + .Where(t => t is not null)!); + } + + /// + /// Gets the help text describing all available commands (joined by ", "), suitable + /// for display in the mode-and-help bar. Computed from the supplied + /// commandHandlers. + /// + public string HelpText { get; } + + /// + public void Dispose() => this._inputGate.Dispose(); + + /// + /// Handles a top-level user input submission (TextInput mode, no pending question). + /// Dispatches to command handlers, or starts an agent turn. + /// + internal async Task OnUserInputAsync(string text) + { + await this._inputGate.WaitAsync().ConfigureAwait(false); + try + { + this._ux.WriteUserInputEcho(text); + + foreach (var handler in this._commandHandlers) + { + if (await handler.TryHandleAsync(text, this._session, this._ux).ConfigureAwait(false)) + { + this._ux.CurrentMode = this._modeProvider?.GetMode(this._session); + return; + } + } + + await this.RunAgentLoopAsync([new ChatMessage(ChatRole.User, text)]).ConfigureAwait(false); + } + finally + { + this._inputGate.Release(); + } + } + + /// + /// Handles a user input submission while an agent turn is streaming. The text is + /// enqueued via the so it can be picked up + /// by the agent on its next opportunity. + /// + internal Task OnStreamingInputAsync(string text) + { + if (this._messageInjector is null) + { + return Task.CompletedTask; + } + + this._messageInjector.EnqueueMessages(this._session, [new ChatMessage(ChatRole.User, text)]); + this._ux.SetQueuedMessages(this._messageInjector.GetPendingMessages(this._session)); + return Task.CompletedTask; + } + + /// + /// Resumes (or completes) a turn after the user has answered all pending follow-up + /// questions. The component invokes this with the messages drained from + /// ; an empty list simply ends + /// the streaming display state without invoking the agent. + /// + internal async Task StartAgentTurnAsync(IList messages) + { + await this._inputGate.WaitAsync().ConfigureAwait(false); + try + { + if (messages.Count == 0) + { + this.CompleteTurn(); + return; + } + + await this.RunAgentLoopAsync(messages).ConfigureAwait(false); + } + finally + { + this._inputGate.Release(); + } + } + + private async Task RunAgentLoopAsync(IList messages) + { + IList? nextMessages = messages; + IReadOnlyList lastPendingMessages = this._messageInjector?.GetPendingMessages(this._session) ?? []; + + while (nextMessages is not null) + { + var runOptions = new AgentRunOptions(); + foreach (var observer in this._observers) + { + observer.ConfigureRunOptions(runOptions, this._agent, this._session); + } + + this._ux.CurrentMode = this._modeProvider?.GetMode(this._session); + this._ux.BeginStreaming(); + this._ux.BeginStreamingOutput(); + + try + { + await foreach (var update in this._agent.RunStreamingAsync(nextMessages, this._session, runOptions)) + { + if (this._modeProvider is not null) + { + string currentMode = this._modeProvider.GetMode(this._session); + if (currentMode != this._ux.CurrentMode) + { + this._ux.CurrentMode = currentMode; + } + } + + foreach (var content in update.Contents) + { + foreach (var observer in this._observers) + { + await observer.OnContentAsync(this._ux, content, this._agent, this._session).ConfigureAwait(false); + } + } + + if (!string.IsNullOrEmpty(update.Text)) + { + foreach (var observer in this._observers) + { + await observer.OnTextAsync(this._ux, update.Text, this._agent, this._session).ConfigureAwait(false); + } + } + + this.SyncQueuedMessageDisplay(ref lastPendingMessages); + } + } + catch (Exception ex) + { + await this._ux.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red).ConfigureAwait(false); + } + + // Final sync after streaming. + this.SyncQueuedMessageDisplay(ref lastPendingMessages); + + this._ux.StopSpinner(); + await this._ux.EndStreamingOutputAsync().ConfigureAwait(false); + + // Collect FollowUpActions from each observer. + var directMessages = new List(); + var questions = new List(); + foreach (var observer in this._observers) + { + var actions = await observer.OnStreamCompleteAsync(this._ux, this._agent, this._session).ConfigureAwait(false); + if (actions is null) + { + continue; + } + + foreach (var action in actions) + { + switch (action) + { + case FollowUpMessage msg: + directMessages.Add(msg.Message); + break; + case FollowUpQuestion q: + questions.Add(q); + break; + } + } + } + + bool hasFollowUpActions = directMessages.Count > 0 || questions.Count > 0; + await this._ux.WriteNoTextWarningAsync(hasFollowUpActions).ConfigureAwait(false); + + // Add any direct messages to the accumulator regardless of whether questions follow — + // they're sent on the next agent invocation, either by us (if no questions) or by + // the component (after the user finishes answering, via StartAgentTurnAsync). + foreach (var msg in directMessages) + { + this._ux.AddFollowUpResponse(msg); + } + + if (questions.Count > 0) + { + // Pause: hand control back to the UX to collect answers. + this._ux.QueueFollowUpQuestions(questions); + return; + } + + // No questions to ask — drain anything we just accumulated and loop with it. + IReadOnlyList drained = this._ux.TakeFollowUpResponses(); + nextMessages = drained.Count > 0 ? [.. drained] : null; + } + + this.CompleteTurn(); + } + + private void CompleteTurn() + { + this._ux.EndStreaming(); + this._ux.CurrentMode = this._modeProvider?.GetMode(this._session); + } + + /// + /// Synchronizes the queued items display with the message injector's pending messages. + /// Messages that have been consumed (drained by the service) are echoed to the output + /// area as regular user-input entries. + /// + private void SyncQueuedMessageDisplay(ref IReadOnlyList lastPendingMessages) + { + if (this._messageInjector is null) + { + return; + } + + var pending = this._messageInjector.GetPendingMessages(this._session); + + int consumedCount = lastPendingMessages.Count - pending.Count; + for (int i = 0; i < consumedCount && i < lastPendingMessages.Count; i++) + { + string text = lastPendingMessages[i].Text ?? string.Empty; + this._ux.WriteUserInputEcho(text); + } + + lastPendingMessages = pending; + this._ux.SetQueuedMessages(pending); + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs index 034cbfb000..e120329f12 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs @@ -3,171 +3,89 @@ using Harness.ConsoleReactiveComponents; using Harness.ConsoleReactiveFramework; using Harness.Shared.Console.Components; +using Microsoft.Extensions.AI; namespace Harness.Shared.Console; -/// -/// Determines which component is shown in the bottom panel. -/// -public enum BottomPanelMode -{ - /// Show the text input component for user input. - TextInput, - - /// Show the list selection component for interactive prompts. - ListSelection, - - /// Show a disabled input indicator during agent streaming. - Streaming, -} - -/// -/// Event arguments for the event. -/// -public sealed class InputSubmittedEventArgs : EventArgs -{ - /// - /// Initializes a new instance of the class. - /// - /// The submitted text. - /// The bottom panel mode in which the input was submitted. - public InputSubmittedEventArgs(string text, BottomPanelMode mode) - { - this.Text = text; - this.Mode = mode; - } - - /// Gets the submitted text. - public string Text { get; } - - /// Gets the bottom panel mode in which the input was submitted. - public BottomPanelMode Mode { get; } -} - -/// -/// Props for . -/// -public record HarnessAppComponentProps : ConsoleReactiveProps -{ - /// Gets or sets the list selection choices (for ListSelection mode). - public IReadOnlyList Items { get; set; } = Array.Empty(); - - /// Gets or sets the scroll items (output entries) to render in the scroll panel. - public IReadOnlyList ScrollItems { get; set; } = []; - - /// Gets or sets the bottom panel mode. - public BottomPanelMode Mode { get; set; } = BottomPanelMode.TextInput; - - /// Gets or sets the prompt string for text input mode. - public string Prompt { get; set; } = "You: "; - - /// Gets or sets the placeholder text shown when the input is empty. - public string Placeholder { get; set; } = ""; - - /// Gets or sets the highlight color for the active list item. - public ConsoleColor ListHighlightColor { get; set; } = ConsoleColor.Cyan; - - /// Gets or sets the placeholder text for the custom text input option in the list. - public string? ListCustomTextPlaceholder { get; set; } - - /// Gets or sets the foreground color for the rule borders and mode label. - public ConsoleColor? ModeColor { get; set; } - - /// Gets or sets the current mode name displayed below the bottom rule (e.g. "plan"). - public string? ModeText { get; set; } - - /// Gets or sets the help text displayed below the bottom rule (available commands). - public string? HelpText { get; set; } - - /// Gets or sets the title text displayed above the list selection (for interactive prompts). - public string? ListTitle { get; set; } - - /// Gets or sets a value indicating whether input is enabled during streaming. - public bool InputEnabled { get; set; } - - /// Gets or sets the prompt to show during streaming when input is disabled. - public string StreamingPrompt { get; set; } = "(agent is running...)"; - - /// Gets or sets a value indicating whether the agent status spinner is visible. - public bool ShowSpinner { get; set; } - - /// Gets or sets the formatted token usage text to display in the status bar. - public string? UsageText { get; set; } - - /// Gets or sets the queued input items to display above the rule. - public IReadOnlyList QueuedItems { get; set; } = []; -} - -/// -/// Internal state for . -/// -public record HarnessAppComponentState : ConsoleReactiveState -{ - /// Gets the selected index in list selection mode. - public int SelectedIndex { get; init; } - - /// Gets the current input text being typed. - public string InputText { get; init; } = ""; - - /// Gets the current text being typed into the list's custom text option. - public string ListInputText { get; init; } = ""; - - /// Gets the current console width in columns. - public int ConsoleWidth { get; init; } - - /// Gets the current console height in rows. - public int ConsoleHeight { get; init; } -} - /// /// The main application component for the Harness console. Manages the scroll region -/// and bottom panel (text input, list selection, or streaming indicator), and emits -/// an event when the user submits text in any mode. +/// and bottom panel (text input, list selection, or streaming indicator). Owns the +/// and routes user input events to the +/// registered . /// -public class HarnessAppComponent : ConsoleReactiveComponent, IDisposable +public class HarnessAppComponent : ConsoleReactiveComponent, IDisposable { private readonly TopBottomRule _rule = new(); private readonly ListSelection _listSelection = new(); private readonly TextInput _textInput = new(); - private readonly TextScrollPanel _textScrollPanel; - private readonly TextPanel _textPanel; - private readonly TextPanel _queuedPanel; + private readonly TextScrollPanel _textScrollPanel = new(); + private readonly TextPanel _textPanel = new(); + private readonly TextPanel _queuedPanel = new(); private readonly AgentStatus _agentStatus = new(); private readonly AgentModeAndHelp _modeAndHelp = new(); - private readonly Func _renderItem; - private bool _resizedSinceLastRender; + private readonly HarnessConsoleUXStateDriver _uxDriver; + private readonly TaskCompletionSource _shutdownTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly SemaphoreSlim _followUpGate = new(1, 1); + private int _scrollRegionBottom; + private bool _resizedSinceLastRender = true; private bool _deactivated; /// /// Initializes a new instance of the class. /// - /// A delegate that renders a single output entry and returns the text to display. - public HarnessAppComponent(Func renderScrollItem) + /// Placeholder text shown when the input is empty. + /// The current agent mode, used to colour the rule and prompt. + /// Whether the bottom-panel input accepts keystrokes during streaming. + /// Factory invoked with the component's + /// to construct the that owns the agent loop. + /// Optional mapping of mode names to console colors. + public HarnessAppComponent( + string placeholder, + string? initialMode, + bool inputEnabled, + Func runnerFactory, + IReadOnlyDictionary? modeColors = null) { - this._renderItem = renderScrollItem; - this._textScrollPanel = new TextScrollPanel(renderScrollItem); - this._textPanel = new TextPanel(renderScrollItem); - this._queuedPanel = new TextPanel(renderScrollItem); + this.Props = new ConsoleReactiveProps(); this.State = new HarnessAppComponentState { + Mode = BottomPanelMode.TextInput, + Prompt = "> ", + Placeholder = placeholder, + ModeColor = ModeColors.Get(initialMode, modeColors), + ModeText = initialMode, + InputEnabled = inputEnabled, ConsoleWidth = System.Console.WindowWidth, ConsoleHeight = System.Console.WindowHeight, }; + + this._uxDriver = new HarnessConsoleUXStateDriver( + getState: () => this.State!, + setState: s => this.SetState(s), + requestShutdown: () => this._shutdownTcs.TrySetResult(true), + modeColors: modeColors); + + this.Runner = runnerFactory(this._uxDriver); + + // Seed help text now that the runner (which knows the registered command handlers) + // is available. Direct assignment — no Render is triggered until the caller invokes Render(). + this.State = this.State with { HelpText = this.Runner.HelpText }; + KeyEventListener.Instance.KeyPressed += this.OnKeyPressed; ConsoleResizeListener.Instance.ConsoleResized += this.OnConsoleResized; } /// - /// Gets the 1-based row number of the last row in the output scroll region. + /// Gets the agent runner that owns the agent loop. Constructed by the factory + /// passed to the component's constructor. /// - public int ScrollRegionBottom { get; private set; } + public HarnessAgentRunner Runner { get; } /// - /// Occurs when the user submits input via Enter, in any mode (text input, list selection, - /// or streaming injection). Consumers inspect - /// to decide how to handle the submission. + /// Completes when a command handler requests application shutdown (e.g. the user types /exit). + /// Awaited by . /// - public event EventHandler? InputSubmitted; + public Task ShutdownTask => this._shutdownTcs.Task; /// /// Deactivates the component, resetting the scroll region and unsubscribing from events. @@ -184,9 +102,6 @@ public class HarnessAppComponent : ConsoleReactiveComponent @@ -205,20 +120,23 @@ public class HarnessAppComponent : ConsoleReactiveComponent 0) + if (this.State.ListSelectionCustomInputText.Length > 0) { - this.SetState(this.State with { ListInputText = this.State.ListInputText[..^1] }); + this.SetState(this.State with { ListSelectionCustomInputText = this.State.ListSelectionCustomInputText[..^1] }); } } else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar)) { - this.SetState(this.State! with { ListInputText = this.State.ListInputText + e.KeyInfo.KeyChar }); + this.SetState(this.State with { ListSelectionCustomInputText = this.State.ListSelectionCustomInputText + e.KeyInfo.KeyChar }); } } } private void HandleStreamingInputKey(KeyPressEventArgs e) { - // During streaming with input enabled, capture text for message injection if (e.KeyInfo.Key == ConsoleKey.Enter) { string text = this.State!.InputText; @@ -306,7 +223,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent 0) + { + _ = this.HandleFollowUpAnswerAsync(text); + } + else + { + _ = this.Runner.OnUserInputAsync(text); + } + } + + private void DispatchListSelectionSubmission(string text) + { + // List selection is only used to answer FollowUpQuestions. + _ = this.HandleFollowUpAnswerAsync(text); + } + + /// + /// Handles a user answer to the head of the pending follow-up question queue: + /// awaits the question's continuation (which is responsible for echoing both the + /// question and answer to the scroll area as it sees fit), appends any returned + /// chat message to the response accumulator, advances the queue, and — when the + /// queue empties — drains the accumulator and resumes the runner. + /// + private async Task HandleFollowUpAnswerAsync(string text) + { + IReadOnlyList? messagesToSend = null; + + await this._followUpGate.WaitAsync().ConfigureAwait(false); + try + { + HarnessConsoleUXStateDriver ux = this._uxDriver; + IReadOnlyList queue = this.State!.PendingQuestions; + if (queue.Count == 0) + { + return; + } + + FollowUpQuestion head = queue[0]; + + ChatMessage? response; + try + { + response = await head.Continuation(text, ux).ConfigureAwait(false); + } + catch (Exception ex) + { + await ux.WriteInfoLineAsync($"❌ Follow-up handler error: {ex.GetType().Name}: {ex.Message}", ConsoleColor.Red).ConfigureAwait(false); + response = null; + } + + if (response is not null) + { + ux.AddFollowUpResponse(response); + } + + ux.AdvanceFollowUpQuestion(); + + if (this.State!.PendingQuestions.Count == 0) + { + messagesToSend = ux.TakeFollowUpResponses(); + } + } + finally + { + this._followUpGate.Release(); + } + + // Resume the agent outside the gate — StartAgentTurnAsync runs the full agent + // loop which may queue new follow-up questions (re-entering this method). + if (messagesToSend is not null) + { + try + { + await this.Runner.StartAgentTurnAsync([.. messagesToSend]).ConfigureAwait(false); + } + catch (Exception ex) + { + await this._uxDriver.WriteInfoLineAsync($"❌ Agent error: {ex.GetType().Name}: {ex.Message}", ConsoleColor.Red).ConfigureAwait(false); + } + } + } + private void OnConsoleResized(object? sender, ConsoleResizeEventArgs e) { this._resizedSinceLastRender = true; @@ -332,35 +333,40 @@ public class HarnessAppComponent : ConsoleReactiveComponent - public override void RenderCore(HarnessAppComponentProps props, HarnessAppComponentState state) + public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentState state) { + if (this._deactivated) + { + return; + } + // Determine the text panel height for the last scroll item - IReadOnlyList lastItems = props.ScrollItems.Count > 0 - ? [props.ScrollItems[^1]] + IReadOnlyList lastItems = state.ScrollAreaContentItems.Count > 0 + ? [state.ScrollAreaContentItems[^1]] : []; - int textPanelHeight = TextPanel.CalculateHeight(lastItems, this._renderItem); + int textPanelHeight = TextPanel.CalculateHeight(lastItems); if (textPanelHeight > 0) { textPanelHeight++; // Extra line for spacing between text panel and rule } // Calculate queued items panel height - int queuedPanelHeight = TextPanel.CalculateHeight(props.QueuedItems, this._renderItem); + int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems); // Build the bottom panel child based on mode ConsoleReactiveComponent bottomChild; int bottomChildHeight; - if (props.Mode == BottomPanelMode.ListSelection) + if (state.Mode == BottomPanelMode.ListSelection) { var listProps = new ListSelectionProps { - Title = props.ListTitle, - Items = props.Items, - SelectedIndex = state.SelectedIndex, - HighlightColor = props.ListHighlightColor, - CustomTextPlaceholder = props.ListCustomTextPlaceholder, - CustomText = state.ListInputText, + Title = state.ListSelectionTitle, + Items = state.ListSelectionOptions, + SelectedIndex = state.ListSelectionIndex, + HighlightColor = state.ListHighlightColor, + CustomTextPlaceholder = state.ListSelectionCustomTextPlaceholder, + CustomText = state.ListSelectionCustomInputText, }; bottomChildHeight = ListSelection.CalculateHeight(listProps); @@ -368,25 +374,25 @@ public class HarnessAppComponent : ConsoleReactiveComponent scrollItems = props.ScrollItems.Count > 1 - ? props.ScrollItems.Take(props.ScrollItems.Count - 1).ToList() + IReadOnlyList scrollItems = state.ScrollAreaContentItems.Count > 1 + ? state.ScrollAreaContentItems.Take(state.ScrollAreaContentItems.Count - 1).ToList() : []; this._textScrollPanel.X = 1; @@ -486,18 +498,21 @@ public class HarnessAppComponent : ConsoleReactiveComponent +/// Determines which component is shown in the bottom panel. +/// +public enum BottomPanelMode +{ + /// Show the text input component for user input. + TextInput, + + /// Show the list selection component for interactive prompts. + ListSelection, + + /// Show a disabled input indicator during agent streaming. + Streaming, +} + +/// +/// Internal state for . All UI fields that may +/// change after construction live here; they are mutated exclusively via +/// by the +/// owning . +/// +public record HarnessAppComponentState : ConsoleReactiveState +{ + // --- Console dimensions --- + + /// Gets the current console width in columns. + public int ConsoleWidth { get; init; } + + /// Gets the current console height in rows. + public int ConsoleHeight { get; init; } + + // --- Bottom panel mode --- + + /// Gets the bottom panel mode. + public BottomPanelMode Mode { get; init; } = BottomPanelMode.TextInput; + + /// + /// Gets the queue of follow-up questions waiting for user answers. The head + /// ([0]) is the question currently being displayed; subsequent items + /// are dispatched in order as each is answered. While this queue is non-empty, + /// the next user submission is treated as the answer to the head question + /// instead of going to the agent runner's normal input handler. + /// + public IReadOnlyList PendingQuestions { get; init; } = []; + + /// + /// Gets the accumulated follow-up response messages collected during the + /// current agent turn — both direct s emitted + /// by observers and continuation results from answered questions. Consumed + /// by the runner via + /// before the next agent invocation. + /// + public IReadOnlyList AccumulatedFollowUpResponses { get; init; } = []; + + // --- Text input (active in TextInput / Streaming modes) --- + + /// Gets the prompt string for text input mode. + public string Prompt { get; init; } = "> "; + + /// Gets the placeholder text shown when the input is empty. + public string Placeholder { get; init; } = ""; + + /// Gets the current input text being typed. + public string InputText { get; init; } = ""; + + /// Gets a value indicating whether input is enabled during streaming. + public bool InputEnabled { get; init; } + + /// Gets the prompt to show during streaming when input is disabled. + public string StreamingPrompt { get; init; } = "(agent is running...)"; + + // --- List selection (active in ListSelection mode) --- + + /// Gets the title text displayed above the list selection (for interactive prompts). + public string? ListSelectionTitle { get; init; } + + /// Gets the list selection options. + public IReadOnlyList ListSelectionOptions { get; init; } = []; + + /// Gets the highlighted option index in list selection mode. + public int ListSelectionIndex { get; init; } + + /// Gets the placeholder text for the custom text input option in the list. + public string? ListSelectionCustomTextPlaceholder { get; init; } + + /// Gets the current text being typed into the list's custom text option. + public string ListSelectionCustomInputText { get; init; } = ""; + + /// Gets the highlight color for the active list item. + public ConsoleColor ListHighlightColor { get; init; } = ConsoleColor.Cyan; + + // --- Scroll / output area --- + + /// Gets the items rendered in the scroll-area. Each item is a pre-rendered + /// console string (may include ANSI escape sequences and newlines). + public IReadOnlyList ScrollAreaContentItems { get; init; } = []; + + /// Gets the queued input items to display above the rule. Each item is a + /// pre-rendered console string (may include ANSI escape sequences and newlines). + public IReadOnlyList QueuedItems { get; init; } = []; + + // --- Agent mode + status display --- + + /// Gets the foreground color for the rule borders and mode label. + public ConsoleColor? ModeColor { get; init; } + + /// Gets the current mode name displayed below the bottom rule (e.g. "plan"). + public string? ModeText { get; init; } + + /// Gets the help text displayed below the bottom rule (available commands). + public string? HelpText { get; init; } + + /// Gets a value indicating whether the agent status spinner is visible. + public bool ShowSpinner { get; init; } + + /// Gets the formatted token usage text to display in the status bar. + public string? UsageText { get; init; } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs index 99a0580706..1f313d1008 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs @@ -1,9 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. -using Harness.Shared.Console.Commands; -using Harness.Shared.Console.Observers; +using Harness.ConsoleReactiveComponents; using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; namespace Harness.Shared.Console; @@ -15,244 +13,58 @@ public static class HarnessConsole { /// /// Runs an interactive console session with the specified agent. - /// Supports streaming output, tool call display, spinner animation, - /// optional planning UX with structured output, and the /todos command. + /// Constructs the reactive UI component and the , + /// wires them together, and awaits the component's + /// (which completes when the user types /exit). /// /// The agent to interact with. - /// The title displayed in the console header. - /// A short prompt to the user, displayed below the title. + /// A short prompt to the user, displayed as a placeholder in the input area. /// Optional configuration options for the console session. - public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt, HarnessConsoleOptions? options = null) + public static async Task RunAgentAsync(AIAgent agent, string userPrompt, HarnessConsoleOptions? options = null) { options ??= new(); - if (options.EnablePlanningUx - && (string.IsNullOrWhiteSpace(options.PlanningModeName) || string.IsNullOrWhiteSpace(options.ExecutionModeName))) - { - throw new ArgumentException( - "When EnablePlanningUx is true, both PlanningModeName and ExecutionModeName must be configured.", - nameof(options)); - } + // Null means use defaults; an explicit (possibly empty) list means use exactly what was provided. + var observers = options.Observers + ?? HarnessConsoleOptions.BuildDefaultObservers(); + var commandHandlers = options.CommandHandlers + ?? HarnessConsoleOptions.BuildDefaultCommandHandlers(agent, options.ModeColors); - var todoProvider = agent.GetService(); var modeProvider = agent.GetService(); var messageInjector = agent.GetService(); - var commandHandlers = new List - { - new TodoCommandHandler(todoProvider), - new ModeCommandHandler(modeProvider, options.ModeColors), - }; - AgentSession session = await agent.CreateSessionAsync(); - using var ux = new HarnessUXContainer( + using var component = new HarnessAppComponent( placeholder: userPrompt, initialMode: modeProvider?.GetMode(session), inputEnabled: messageInjector is not null, + runnerFactory: ux => new HarnessAgentRunner( + agent: agent, + session: session, + modeProvider: modeProvider, + messageInjector: messageInjector, + commandHandlers: commandHandlers, + observers: observers, + ux: ux), modeColors: options.ModeColors); - // Streaming-mode submissions are enqueued for injection; the queued display - // is then refreshed from the injector's current pending list. - ux.StreamingInputReceived += (sender, e) => + // Trigger the initial render of the component now that state is seeded. + component.Render(); + + try { - if (messageInjector is null) - { - return; - } - - messageInjector.EnqueueMessages(session, [new ChatMessage(ChatRole.User, e.Text)]); - ux.ShowQueuedMessages(messageInjector.GetPendingMessages(session)); - }; - - var commandHelp = commandHandlers - .Select(h => h.GetHelpText()) - .Where(t => t is not null) - .Append("exit (quit)")!; - - ux.Initialize(title, commandHelp!, messageInjector is not null); - - string userInput = await ux.WaitForInputAsync(); - - while (!string.IsNullOrWhiteSpace(userInput) && !userInput.Equals("exit", StringComparison.OrdinalIgnoreCase)) + await component.ShutdownTask.ConfigureAwait(false); + } + finally { - ux.WriteUserInputEcho(userInput); - - // Check command handlers first — first one to handle wins. - bool handled = false; - foreach (var handler in commandHandlers) - { - if (await handler.TryHandleAsync(userInput, session, ux).ConfigureAwait(false)) - { - handled = true; - break; - } - } - - if (!handled) - { - await RunAgentTurnAsync(agent, session, modeProvider, messageInjector, options, ux, userInput); - } - - ux.CurrentMode = modeProvider?.GetMode(session); - userInput = await ux.WaitForInputAsync(); + component.Deactivate(); } - ux.Deactivate(); System.Console.ResetColor(); + System.Console.Write(AnsiEscapes.ResetScrollRegion); + System.Console.Write(AnsiEscapes.EraseEntireScreen); + System.Console.Write(AnsiEscapes.MoveCursor(1, 1)); System.Console.WriteLine("Goodbye!"); } - - /// - /// Runs one or more agent invocations for a single user turn, using the current - /// observers. Re-invokes automatically for tool approvals and mode-driven follow-ups - /// (e.g., planning clarification loops). - /// - private static async Task RunAgentTurnAsync( - AIAgent agent, - AgentSession session, - AgentModeProvider? modeProvider, - MessageInjectingChatClient? messageInjector, - HarnessConsoleOptions options, - HarnessUXContainer ux, - string userInput) - { - IList? nextMessages = [new ChatMessage(ChatRole.User, userInput)]; - IReadOnlyList lastPendingMessages = messageInjector?.GetPendingMessages(session) ?? []; - - while (nextMessages is not null) - { - var observers = CreateObservers(options, modeProvider, session); - - var runOptions = new AgentRunOptions(); - foreach (var observer in observers) - { - observer.ConfigureRunOptions(runOptions); - } - - ux.CurrentMode = modeProvider?.GetMode(session); - ux.BeginStreaming(); - ux.BeginStreamingOutput(); - - try - { - await foreach (var update in agent.RunStreamingAsync(nextMessages, session, runOptions)) - { - // Update mode color if the mode changed during streaming. - if (modeProvider is not null) - { - string currentMode = modeProvider.GetMode(session); - if (currentMode != ux.CurrentMode) - { - ux.CurrentMode = currentMode; - } - } - - foreach (var content in update.Contents) - { - foreach (var observer in observers) - { - await observer.OnContentAsync(ux, content); - } - } - - if (!string.IsNullOrEmpty(update.Text)) - { - foreach (var observer in observers) - { - await observer.OnTextAsync(ux, update.Text); - } - } - - SyncQueuedMessageDisplay(messageInjector, session, ux, ref lastPendingMessages); - } - } - catch (Exception ex) - { - await ux.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red); - } - - // Final sync after streaming — messages may have been consumed during the last iteration. - SyncQueuedMessageDisplay(messageInjector, session, ux, ref lastPendingMessages); - - // Stop spinner before observer completions (which may prompt for input). - ux.StopSpinner(); - - // Close the streaming output to provide visual separation from observer output. - await ux.EndStreamingOutputAsync(); - - var combinedMessages = new List(); - bool hasObserverMessages = false; - foreach (var observer in observers) - { - var messages = await observer.OnStreamCompleteAsync(ux, agent, session, options); - if (messages is { Count: > 0 }) - { - combinedMessages.AddRange(messages); - hasObserverMessages = true; - } - } - - await ux.WriteNoTextWarningAsync(hasFollowUpMessages: hasObserverMessages); - - ux.EndStreaming(); - - nextMessages = combinedMessages.Count > 0 ? combinedMessages : null; - } - } - - /// - /// Synchronizes the queued items display with the message injector's pending messages. - /// Messages that have been consumed (drained by the service) are echoed to the output - /// area as regular user-input entries. - /// - private static void SyncQueuedMessageDisplay( - MessageInjectingChatClient? messageInjector, - AgentSession session, - HarnessUXContainer ux, - ref IReadOnlyList lastPendingMessages) - { - if (messageInjector is null) - { - return; - } - - var pending = messageInjector.GetPendingMessages(session); - - // If previously pending messages exceed current pending count, some were consumed. - int consumedCount = lastPendingMessages.Count - pending.Count; - for (int i = 0; i < consumedCount && i < lastPendingMessages.Count; i++) - { - string text = lastPendingMessages[i].Text ?? string.Empty; - ux.WriteUserInputEcho(text); - } - - lastPendingMessages = pending; - ux.ShowQueuedMessages(pending); - } - - private static List CreateObservers(HarnessConsoleOptions options, AgentModeProvider? modeProvider, AgentSession session) - { - var observers = new List - { - new ToolCallDisplayObserver(), - new ToolApprovalObserver(), - new ErrorDisplayObserver(), - new ReasoningDisplayObserver(), - new UsageDisplayObserver(options.MaxContextWindowTokens, options.MaxOutputTokens), - }; - - if (options.EnablePlanningUx - && modeProvider is not null - && string.Equals(modeProvider.GetMode(session), options.PlanningModeName, StringComparison.OrdinalIgnoreCase)) - { - observers.Add(new PlanningOutputObserver(modeProvider)); - } - else - { - observers.Add(new TextOutputObserver()); - } - - return observers; - } } diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleOptions.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleOptions.cs index 2a9b580c0e..2fd2139990 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleOptions.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleOptions.cs @@ -1,5 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.ObjectModel; +using Harness.Shared.Console.Commands; +using Harness.Shared.Console.Observers; +using Harness.Shared.Console.ToolFormatters; +using Microsoft.Agents.AI; + namespace Harness.Shared.Console; /// @@ -8,45 +14,120 @@ namespace Harness.Shared.Console; public class HarnessConsoleOptions { /// - /// Gets or sets the optional maximum context window size in tokens. - /// When set, token usage is displayed as a percentage of the budget. + /// Gets or sets the list of console observers that participate in the agent response + /// streaming lifecycle. Use the factory methods on this class to create common observer sets. + /// When (the default), a default set of observers is used. + /// Set to an empty list to disable all observers. /// - public int? MaxContextWindowTokens { get; set; } + public IReadOnlyList? Observers { get; set; } /// - /// Gets or sets the optional maximum output tokens. - /// Used with to show input/output budget breakdown. + /// Gets or sets the list of command handlers to check before sending user input to the agent. + /// Use to create the default set. + /// When (the default), a default set of handlers is used. + /// Set to an empty list to disable all command handlers. /// - public int? MaxOutputTokens { get; set; } + public IReadOnlyList? CommandHandlers { get; set; } /// - /// Gets or sets a value indicating whether the planning UX is enabled. - /// When and the agent is in the mode specified by , - /// the console uses structured output to present clarification questions and approval requests - /// instead of streaming free-form text. + /// The default mode-to-color mapping used when no custom are provided. /// - /// Defaults to . - public bool EnablePlanningUx { get; set; } - - /// - /// Gets or sets the name of the agent mode that activates the planning UX. - /// Must be set when is . - /// - public string? PlanningModeName { get; set; } - - /// - /// Gets or sets the name of the agent mode to switch to when the user approves a plan. - /// Must be set when is . - /// - public string? ExecutionModeName { get; set; } + public static readonly IReadOnlyDictionary DefaultModeColors = new ReadOnlyDictionary( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["plan"] = ConsoleColor.Cyan, + ["execute"] = ConsoleColor.Green, + }); /// /// Gets or sets a mapping of agent mode names to console colors. /// When a mode is not found in this dictionary, the default color () is used. /// - public Dictionary ModeColors { get; set; } = new(StringComparer.OrdinalIgnoreCase) + public Dictionary ModeColors { get; set; } = new(DefaultModeColors, StringComparer.OrdinalIgnoreCase); + + /// + /// Creates the default set of observers without planning support. + /// Includes tool call display, tool approval, error display, reasoning display, + /// usage display, and text output. + /// + /// Optional maximum context window size in tokens for usage display. + /// Optional maximum output tokens for usage display. + /// Optional tool call formatters. When , + /// each observer uses the default formatters from . + /// A list of observers for a standard (non-planning) console session. + public static List BuildDefaultObservers( + int? maxContextWindowTokens = null, + int? maxOutputTokens = null, + IReadOnlyList? toolFormatters = null) { - ["plan"] = ConsoleColor.Cyan, - ["execute"] = ConsoleColor.Green, - }; + return + [ + new ToolCallDisplayObserver(toolFormatters), + new ToolApprovalObserver(toolFormatters), + new ErrorDisplayObserver(), + new ReasoningDisplayObserver(), + new UsageDisplayObserver(maxContextWindowTokens, maxOutputTokens), + new TextOutputObserver(), + ]; + } + + /// + /// Creates the default set of observers with planning support. + /// Includes a instead of . + /// + /// The agent, used to resolve . + /// The mode name that represents the planning mode. + /// The mode name to switch to when the user approves a plan. + /// Optional mode-to-color mapping for display. + /// Defaults to when . + /// Optional maximum context window size in tokens for usage display. + /// Optional maximum output tokens for usage display. + /// Optional tool call formatters. When , + /// each observer uses the default formatters from . + /// A list of observers for a planning-enabled console session. + public static List BuildObserversWithPlanning( + AIAgent agent, + string planModeName, + string executionModeName, + IReadOnlyDictionary? modeColors = null, + int? maxContextWindowTokens = null, + int? maxOutputTokens = null, + IReadOnlyList? toolFormatters = null) + { + var modeProvider = agent.GetService() + ?? throw new InvalidOperationException("Planning requires an AgentModeProvider service on the agent."); + + return + [ + new ToolCallDisplayObserver(toolFormatters), + new ToolApprovalObserver(toolFormatters), + new ErrorDisplayObserver(), + new ReasoningDisplayObserver(), + new UsageDisplayObserver(maxContextWindowTokens, maxOutputTokens), + new PlanningOutputObserver(modeProvider, planModeName, executionModeName, modeColors ?? DefaultModeColors), + ]; + } + + /// + /// Creates the default set of command handlers. + /// Includes exit, todo, and mode command handlers. + /// + /// The agent, used to resolve and . + /// Optional mode-to-color mapping for the mode command display. + /// Defaults to when . + /// A list of command handlers for a standard console session. + public static List BuildDefaultCommandHandlers( + AIAgent agent, + IReadOnlyDictionary? modeColors = null) + { + var todoProvider = agent.GetService(); + var modeProvider = agent.GetService(); + + return + [ + new ExitCommandHandler(), + new TodoCommandHandler(todoProvider), + new ModeCommandHandler(modeProvider, modeColors ?? DefaultModeColors), + ]; + } } diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleUXStateDriver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleUXStateDriver.cs new file mode 100644 index 0000000000..4dbbedb9b5 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleUXStateDriver.cs @@ -0,0 +1,408 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Harness.ConsoleReactiveComponents; +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console; + +/// +/// Default implementation. Owned by +/// ; mutates the component's state via a +/// SetState-style callback. Each public operation updates state and lets +/// the component's render-skip optimization handle the actual draw. +/// +internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver +{ + private readonly Func _getState; + private readonly Action _setState; + private readonly Action _requestShutdown; + private readonly IReadOnlyDictionary? _modeColors; + private readonly List _outputItems = []; + private readonly object _stateLock = new(); + + private OutputEntryType? _lastEntryType; + private bool _hasReceivedAnyText; + private OutputEntry? _currentStreamingEntry; + private int _currentStreamingEntryIndex = -1; + private string? _currentMode; + + /// + /// Initializes a new instance of the class. + /// + /// Returns the component's current state. + /// Replaces the component's state and triggers a re-render. + /// Callback invoked when a command handler requests application shutdown. + /// Optional mapping of mode names to console colors. + public HarnessConsoleUXStateDriver( + Func getState, + Action setState, + Action requestShutdown, + IReadOnlyDictionary? modeColors = null) + { + this._getState = getState; + this._setState = setState; + this._requestShutdown = requestShutdown; + this._modeColors = modeColors; + this._currentMode = getState().ModeText; + } + + /// + public string? CurrentMode + { + get => this._currentMode; + set + { + this.UpdateState(s => + { + this._currentMode = value; + return s with + { + ModeColor = ModeColors.Get(value, this._modeColors), + ModeText = value, + }; + }); + } + } + + /// + public void BeginStreaming() => + this.UpdateState(s => s with + { + Mode = BottomPanelMode.Streaming, + ShowSpinner = true, + }); + + /// + public void StopSpinner() => + this.UpdateState(s => s with { ShowSpinner = false }); + + /// + public void EndStreaming() => + this.UpdateState(s => s with + { + Mode = BottomPanelMode.TextInput, + ShowSpinner = false, + }); + + /// + public void BeginStreamingOutput() + { + lock (this._stateLock) + { + this._hasReceivedAnyText = false; + this._currentStreamingEntry = null; + this._currentStreamingEntryIndex = -1; + } + } + + /// + public void SetUsageText(string usageText) => + this.UpdateState(s => s with { UsageText = usageText }); + + /// + public void SetQueuedMessages(IReadOnlyList pending) + { + var newQueued = new List(pending.Count); + foreach (var msg in pending) + { + string text = msg.Text ?? string.Empty; + newQueued.Add(RenderEntry($" 💬 {text}\n", ConsoleColor.DarkGray)); + } + + this.UpdateState(s => s with { QueuedItems = newQueued }); + } + + /// + public void QueueFollowUpQuestions(IReadOnlyList questions) + { + if (questions.Count == 0) + { + return; + } + + this.UpdateState(s => + { + bool wasEmpty = s.PendingQuestions.Count == 0; + + var combined = new List(s.PendingQuestions.Count + questions.Count); + combined.AddRange(s.PendingQuestions); + combined.AddRange(questions); + + HarnessAppComponentState next = s with { PendingQuestions = combined }; + + if (wasEmpty) + { + next = this.ConfigureForHeadQuestion(next, combined[0]); + } + + return next; + }); + } + + /// + public void AddFollowUpResponse(ChatMessage response) + { + this.UpdateState(s => + { + var combined = new List(s.AccumulatedFollowUpResponses.Count + 1); + combined.AddRange(s.AccumulatedFollowUpResponses); + combined.Add(response); + return s with { AccumulatedFollowUpResponses = combined }; + }); + } + + /// + public void AdvanceFollowUpQuestion() + { + this.UpdateState(s => + { + if (s.PendingQuestions.Count == 0) + { + return s; + } + + var remaining = s.PendingQuestions.Skip(1).ToList(); + HarnessAppComponentState next = s with { PendingQuestions = remaining }; + + if (remaining.Count > 0) + { + return this.ConfigureForHeadQuestion(next, remaining[0]); + } + + return next with + { + Mode = BottomPanelMode.TextInput, + ListSelectionOptions = [], + ListSelectionTitle = null, + ListSelectionCustomTextPlaceholder = null, + ListSelectionIndex = 0, + ListSelectionCustomInputText = "", + }; + }); + } + + /// + public IReadOnlyList TakeFollowUpResponses() + { + return this.UpdateState(s => + { + IReadOnlyList responses = s.AccumulatedFollowUpResponses; + if (responses.Count == 0) + { + return (s, responses); + } + + return (s with { AccumulatedFollowUpResponses = [] }, responses); + }); + } + + /// + /// Configures the bottom-panel display fields on the supplied state for the + /// given head question. For text questions, also writes the prompt as an + /// info line above the input row as a side effect. + /// + private HarnessAppComponentState ConfigureForHeadQuestion(HarnessAppComponentState state, FollowUpQuestion question) + { + if (question is ChoiceFollowUpQuestion choice) + { + return state with + { + Mode = BottomPanelMode.ListSelection, + ListSelectionOptions = choice.Choices.ToList(), + ListSelectionTitle = choice.Prompt, + ListSelectionCustomTextPlaceholder = choice.AllowCustomText ? "✏️ Type a custom response..." : null, + ListSelectionIndex = 0, + ListSelectionCustomInputText = "", + }; + } + + // Text question — prompt is rendered as an info line above the input row. + // We append entries and capture the scroll snapshot inline so the caller's + // single _setState picks up both the new output and the UI mode change. + ConsoleColor ruleColor = ModeColors.Get(this._currentMode, this._modeColors); + List scrollSnapshot = this.AppendOutputEntriesAndSnapshot( + new OutputEntry(OutputEntryType.InfoLine, "\n", ruleColor), + new OutputEntry(OutputEntryType.InfoLine, $" {question.Prompt}", ruleColor)); + + return state with + { + Mode = BottomPanelMode.TextInput, + ListSelectionOptions = [], + ListSelectionTitle = null, + ListSelectionCustomTextPlaceholder = null, + ListSelectionIndex = 0, + ListSelectionCustomInputText = "", + ScrollAreaContentItems = scrollSnapshot, + }; + } + + /// + public void WriteUserInputEcho(string text) + { + this.UpdateState(s => + { + List snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry( + OutputEntryType.UserInput, + $"\nYou: {text}\n\n", + ConsoleColor.Green)); + return s with { ScrollAreaContentItems = snapshot }; + }); + } + + /// + public Task WriteInfoAsync(string text, ConsoleColor? color = null) => + this.WriteInfoCoreAsync(text, color, newLine: false); + + /// + public Task WriteInfoLineAsync(string text, ConsoleColor? color = null) => + this.WriteInfoCoreAsync(text, color, newLine: true); + + private Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine) + { + this.UpdateState(s => + { + // Add a blank line separator when transitioning from streaming text or user input. + string prefix = this._lastEntryType is OutputEntryType.StreamingText or OutputEntryType.StreamFooter + ? "\n " + : " "; + + string fullText = newLine ? prefix + text + "\n\n" : prefix + text; + List snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry( + OutputEntryType.InfoLine, + fullText, + color ?? ModeColors.Get(this._currentMode, this._modeColors))); + return s with { ScrollAreaContentItems = snapshot }; + }); + return Task.CompletedTask; + } + + /// + public Task WriteTextAsync(string text, ConsoleColor? color = null) + { + this.UpdateState(s => + { + this._lastEntryType = OutputEntryType.StreamingText; + this._hasReceivedAnyText = true; + + ConsoleColor effectiveColor = color ?? ModeColors.Get(this._currentMode, this._modeColors); + + if (this._currentStreamingEntry is not null + && this._currentStreamingEntryIndex == this._outputItems.Count - 1) + { + // The streaming entry is still the last item — safe to replace in place. + this._currentStreamingEntry = this._currentStreamingEntry with + { + Text = this._currentStreamingEntry.Text + text, + }; + this._outputItems[^1] = RenderEntry(this._currentStreamingEntry.Text, this._currentStreamingEntry.Color); + } + else + { + // Either the first text delta or other entries (tool calls, info lines) + // were appended after the previous streaming entry — start a fresh one. + const string Prefix = "\n"; + this._currentStreamingEntry = new OutputEntry(OutputEntryType.StreamingText, Prefix + text, effectiveColor); + this._outputItems.Add(RenderEntry(this._currentStreamingEntry.Text, this._currentStreamingEntry.Color)); + this._currentStreamingEntryIndex = this._outputItems.Count - 1; + } + + return s with { ScrollAreaContentItems = new List(this._outputItems) }; + }); + + return Task.CompletedTask; + } + + /// + public Task EndStreamingOutputAsync() + { + this.UpdateState(s => + { + if (this._hasReceivedAnyText) + { + this._outputItems.Add(RenderEntry("\n", null)); + this._currentStreamingEntry = null; + this._lastEntryType = OutputEntryType.StreamFooter; + return s with { ScrollAreaContentItems = new List(this._outputItems) }; + } + + return s; + }); + + return Task.CompletedTask; + } + + /// + public Task WriteNoTextWarningAsync(bool hasFollowUpActions) + { + if (!this._hasReceivedAnyText && !hasFollowUpActions) + { + this.UpdateState(s => + { + List snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry( + OutputEntryType.StreamFooter, + " (no text response from agent)\n", + ConsoleColor.DarkYellow)); + return s with { ScrollAreaContentItems = snapshot }; + }); + } + + return Task.CompletedTask; + } + + /// + /// Wraps the supplied text with ANSI foreground color escape sequences (or returns + /// the text unchanged when no color is specified). Output is appended to + /// and consumed verbatim by + /// and . + /// + private static string RenderEntry(string text, ConsoleColor? color) => + color.HasValue + ? $"{AnsiEscapes.SetForegroundColor(color.Value)}{text}{AnsiEscapes.ResetAttributes}" + : text; + + private void UpdateState(Func update) + { + lock (this._stateLock) + { + this._setState(update(this._getState())); + } + } + + private T UpdateState(Func update) + { + lock (this._stateLock) + { + var (newState, result) = update(this._getState()); + this._setState(newState); + return result; + } + } + + /// + /// Appends one or more output entries to the output list, updates + /// to the last entry's type, and returns a + /// snapshot of . Must be called inside a locked + /// context (e.g. within an callback). + /// + private List AppendOutputEntriesAndSnapshot(params OutputEntry[] entries) + { + this.AppendOutputEntriesCore(entries); + return new List(this._outputItems); + } + + private void AppendOutputEntriesCore(OutputEntry[] entries) + { + foreach (OutputEntry entry in entries) + { + this._outputItems.Add(RenderEntry(entry.Text, entry.Color)); + } + + if (entries.Length > 0) + { + this._lastEntryType = entries[^1].Type; + } + } + + /// + public void RequestShutdown() => this._requestShutdown(); +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessUXContainer.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessUXContainer.cs deleted file mode 100644 index 99699267fa..0000000000 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessUXContainer.cs +++ /dev/null @@ -1,478 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Harness.ConsoleReactiveComponents; -using Microsoft.Extensions.AI; - -namespace Harness.Shared.Console; - -/// -/// Event arguments raised when the user submits text while the bottom panel is in -/// streaming mode (i.e. an agent turn is in progress). -/// -public sealed class StreamingInputReceivedEventArgs : EventArgs -{ - /// - /// Initializes a new instance of the class. - /// - /// The submitted text. - public StreamingInputReceivedEventArgs(string text) - { - this.Text = text; - } - - /// - /// Gets the submitted text. - /// - public string Text { get; } -} - -/// -/// Façade over the harness UI: owns the , manages -/// its props, dispatches input submissions, and provides the high-level read/write -/// operations used by observers, command handlers, and the harness loop. -/// -/// -/// All callers interact with the UI exclusively through this class. The underlying -/// and its props are an implementation detail and -/// must not be exposed. -/// -public sealed class HarnessUXContainer : IDisposable -{ - /// - /// The prompt displayed in the bottom-panel input area. - /// - private const string UserPrompt = "> "; - - private readonly IReadOnlyDictionary? _modeColors; - private readonly List _outputItems = []; - private readonly HarnessAppComponent _appComponent; - private readonly object _outputLock = new(); - - private TaskCompletionSource? _pendingInputTcs; - private OutputEntryType? _lastEntryType; - private bool _hasReceivedAnyText; - private OutputEntry? _currentStreamingEntry; - private string? _currentMode; - - /// - /// Initializes a new instance of the class. - /// - /// Placeholder text shown when the input is empty. - /// The current agent mode, used to colour the rule and prompt. - /// Whether the bottom-panel input accepts keystrokes during streaming. - /// Optional mapping of mode names to console colors. - public HarnessUXContainer( - string placeholder, - string? initialMode, - bool inputEnabled, - IReadOnlyDictionary? modeColors = null) - { - this._modeColors = modeColors; - this._currentMode = initialMode; - - this._appComponent = new HarnessAppComponent(RenderOutputEntry) - { - Props = new HarnessAppComponentProps - { - ScrollItems = this._outputItems, - Mode = BottomPanelMode.TextInput, - Prompt = UserPrompt, - Placeholder = placeholder, - ModeColor = ModeColors.Get(initialMode, modeColors), - ModeText = initialMode, - InputEnabled = inputEnabled, - }, - }; - - this._appComponent.InputSubmitted += this.OnInputSubmitted; - } - - /// - /// Raised when the user submits text while the bottom panel is in streaming mode. - /// Subscribers typically enqueue the text into a message-injecting chat client. - /// - public event EventHandler? StreamingInputReceived; - - /// - /// Gets or sets the current agent mode (e.g. "plan", "execute"). Updating this - /// also refreshes the rule colour and bottom-panel prompt to match the new mode. - /// - public string? CurrentMode - { - get => this._currentMode; - set - { - this._currentMode = value; - this._appComponent.Props = this._appComponent.Props! with - { - ModeColor = ModeColors.Get(value, this._modeColors), - ModeText = value, - }; - this._appComponent.Render(); - } - } - - /// - /// Performs the initial screen clear, sets the help text in the mode-and-help bar, - /// and adds the title to the output area. - /// - /// The title displayed in the console header. - /// The command help strings displayed in the mode-and-help bar. - /// Whether streaming-time message injection is enabled. - public void Initialize(string title, IEnumerable commandHelpTexts, bool messageInjectionActive) - { - // Set the help text on the mode-and-help bar (persists below the rule). - this._appComponent.Props = this._appComponent.Props! with - { - HelpText = string.Join(", ", commandHelpTexts), - ModeText = this._currentMode, - }; - - System.Console.Write(AnsiEscapes.EraseEntireScreen); - System.Console.Write(AnsiEscapes.EraseScrollbackBuffer); - this._appComponent.Render(); - - this.AppendOutputEntries( - new OutputEntry(OutputEntryType.InfoLine, $"=== {title} ===\n", ConsoleColor.White), - new OutputEntry(OutputEntryType.InfoLine, "\n")); - } - - /// - /// Restores the cursor and exits the alternate screen, ending the interactive UI. - /// - public void Deactivate() => this._appComponent.Deactivate(); - - /// - /// Switches the bottom panel to streaming mode and starts the spinner. - /// - public void BeginStreaming() - { - this._appComponent.Props = this._appComponent.Props! with - { - Mode = BottomPanelMode.Streaming, - ShowSpinner = true, - }; - this._appComponent.Render(); - } - - /// - /// Stops the spinner without leaving streaming mode. Use between the end of the - /// stream and any observer-driven prompts (e.g. tool approvals). - /// - public void StopSpinner() - { - this._appComponent.Props = this._appComponent.Props! with { ShowSpinner = false }; - this._appComponent.Render(); - } - - /// - /// Switches the bottom panel back to text-input mode and stops the spinner. - /// - public void EndStreaming() - { - this._appComponent.Props = this._appComponent.Props! with - { - Mode = BottomPanelMode.TextInput, - ShowSpinner = false, - }; - this._appComponent.Render(); - } - - /// - /// Resets per-turn streaming bookkeeping in preparation for a new agent turn. - /// - public void BeginStreamingOutput() - { - this._hasReceivedAnyText = false; - this._currentStreamingEntry = null; - } - - /// - /// Sets the formatted usage text shown on the agent status bar. - /// - public void SetUsageText(string usageText) - { - this._appComponent.Props = this._appComponent.Props! with { UsageText = usageText }; - this._appComponent.Render(); - } - - /// - /// Clears the usage text from the agent status bar. - /// - public void ClearUsageText() - { - this._appComponent.Props = this._appComponent.Props! with { UsageText = null }; - this._appComponent.Render(); - } - - /// - /// Replaces the queued-message display with one entry per pending message. - /// - public void ShowQueuedMessages(IReadOnlyList pending) - { - var newQueued = new List(pending.Count); - foreach (var msg in pending) - { - string text = msg.Text ?? string.Empty; - newQueued.Add(new OutputEntry(OutputEntryType.UserInput, $" 💬 {text}\n", ConsoleColor.DarkGray)); - } - - this._appComponent.Props = this._appComponent.Props! with { QueuedItems = newQueued }; - this._appComponent.Render(); - } - - /// - /// Echoes a submitted user input as a regular user-input entry in the output area, - /// using the current mode-aware prompt prefix. - /// - /// The user-entered text. - public void WriteUserInputEcho(string text) - { - this.AppendOutputEntries(new OutputEntry( - OutputEntryType.UserInput, - $"\nYou: {text}\n", - ConsoleColor.Green)); - } - - /// - /// Writes informational output as an output entry, without a trailing newline. - /// - public Task WriteInfoAsync(string text, ConsoleColor? color = null) => - this.WriteInfoCoreAsync(text, color, newLine: false); - - /// - /// Writes informational output as an output entry, followed by a newline. - /// - public Task WriteInfoLineAsync(string text, ConsoleColor? color = null) => - this.WriteInfoCoreAsync(text, color, newLine: true); - - private Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine) - { - // Add a blank line separator when transitioning from streaming text or user input. - string prefix = this._lastEntryType is OutputEntryType.StreamingText or OutputEntryType.StreamFooter - ? "\n\n " - : " "; - - string fullText = newLine ? prefix + text + "\n" : prefix + text; - this.AppendOutputEntries(new OutputEntry( - OutputEntryType.InfoLine, - fullText, - color ?? ModeColors.Get(this.CurrentMode, this._modeColors))); - return Task.CompletedTask; - } - - /// - /// Writes streaming text output from the agent. Successive calls accumulate into a - /// single streaming entry that is re-rendered by the text panel. - /// - public Task WriteTextAsync(string text, ConsoleColor? color = null) - { - lock (this._outputLock) - { - this._lastEntryType = OutputEntryType.StreamingText; - this._hasReceivedAnyText = true; - - ConsoleColor effectiveColor = color ?? ModeColors.Get(this.CurrentMode, this._modeColors); - - if (this._currentStreamingEntry is not null) - { - this._currentStreamingEntry = this._currentStreamingEntry with - { - Text = this._currentStreamingEntry.Text + text, - }; - this._outputItems[^1] = this._currentStreamingEntry; - } - else - { - const string Prefix = "\n"; - this._currentStreamingEntry = new OutputEntry(OutputEntryType.StreamingText, Prefix + text, effectiveColor); - this._outputItems.Add(this._currentStreamingEntry); - } - - this._appComponent.Props = this._appComponent.Props! with - { - ScrollItems = new List(this._outputItems), - }; - } - - this._appComponent.Render(); - return Task.CompletedTask; - } - - /// - /// Writes a blank-line separator to visually close the streaming output section. - /// Call before observer completions so their output is visually separated. - /// - public Task EndStreamingOutputAsync() - { - lock (this._outputLock) - { - this._outputItems.Add(new OutputEntry(OutputEntryType.StreamFooter, "\n")); - this._currentStreamingEntry = null; - this._lastEntryType = OutputEntryType.StreamFooter; - this._appComponent.Props = this._appComponent.Props! with - { - ScrollItems = new List(this._outputItems), - }; - } - - this._appComponent.Render(); - return Task.CompletedTask; - } - - /// - /// Shows a "(no text response from agent)" warning if no text was received - /// and no observer produced follow-up messages. Call after observer completions. - /// - /// Whether any observer produced follow-up messages. - public Task WriteNoTextWarningAsync(bool hasFollowUpMessages) - { - if (!this._hasReceivedAnyText && !hasFollowUpMessages) - { - this.AppendOutputEntries(new OutputEntry( - OutputEntryType.StreamFooter, - " (no text response from agent)\n", - ConsoleColor.DarkYellow)); - } - - return Task.CompletedTask; - } - - /// - /// Reads a line of input from the user. If is supplied - /// it is rendered as an info line above the input row before reading. - /// - public async Task ReadLineAsync(string? prompt = null, ConsoleColor? promptColor = null) - { - if (prompt is not null) - { - ConsoleColor ruleColor = ModeColors.Get(this.CurrentMode, this._modeColors); - this.AppendOutputEntries( - new OutputEntry(OutputEntryType.InfoLine, "\n", ruleColor), - new OutputEntry(OutputEntryType.InfoLine, $" {prompt}", promptColor ?? ruleColor)); - } - - this._appComponent.Props = this._appComponent.Props! with { Mode = BottomPanelMode.TextInput }; - this._appComponent.Render(); - - string input = await this.WaitForInputAsync(); - - this.AppendOutputEntries(new OutputEntry( - OutputEntryType.UserInput, - $"\nYou: {input}\n", - ConsoleColor.Green)); - - return input; - } - - /// - /// Presents a selection prompt with the given choices and waits for the user's - /// selection. The title is displayed above the list in the bottom panel. After - /// selection the bottom panel is restored to text-input mode and both the question - /// and selection are echoed in the output area. - /// - public async Task ReadSelectionAsync(string title, IList choices) - { - this._appComponent.Props = this._appComponent.Props! with - { - Mode = BottomPanelMode.ListSelection, - Items = choices.ToList(), - ListTitle = title, - ListCustomTextPlaceholder = "✏️ Type a custom response...", - }; - this._appComponent.Render(); - - string selection = await this.WaitForInputAsync(); - - this._appComponent.Props = this._appComponent.Props with { Mode = BottomPanelMode.TextInput }; - - this.AppendOutputEntries( - new OutputEntry( - OutputEntryType.InfoLine, - $"\n {title}\n", - ModeColors.Get(this.CurrentMode, this._modeColors)), - new OutputEntry( - OutputEntryType.UserInput, - $"\nYou: {selection}\n", - ConsoleColor.Green)); - - return selection; - } - - /// - /// Awaits the next non-streaming user input submission. - /// - public Task WaitForInputAsync() - { - this._pendingInputTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - return this._pendingInputTcs.Task; - } - - private void OnInputSubmitted(object? sender, InputSubmittedEventArgs e) - { - if (e.Mode == BottomPanelMode.Streaming) - { - this.StreamingInputReceived?.Invoke(this, new StreamingInputReceivedEventArgs(e.Text)); - } - else - { - var waiter = this._pendingInputTcs; - this._pendingInputTcs = null; - waiter?.TrySetResult(e.Text); - } - } - - /// - public void Dispose() - { - this._appComponent.InputSubmitted -= this.OnInputSubmitted; - this._appComponent.Deactivate(); - this._appComponent.Dispose(); - } - - /// - /// Renders an to a string with ANSI color codes. - /// Used as the render delegate for the . - /// - private static string RenderOutputEntry(object item) - { - if (item is not OutputEntry entry) - { - return item?.ToString() ?? string.Empty; - } - - if (entry.Color.HasValue) - { - return $"{AnsiEscapes.SetForegroundColor(entry.Color.Value)}{entry.Text}{AnsiEscapes.ResetAttributes}"; - } - - return entry.Text; - } - - /// - /// Appends one or more output entries to the output list under lock, - /// updates to the last entry's type, and renders. - /// - private void AppendOutputEntries(params OutputEntry[] entries) - { - lock (this._outputLock) - { - foreach (OutputEntry entry in entries) - { - this._outputItems.Add(entry); - } - - if (entries.Length > 0) - { - this._lastEntryType = entries[^1].Type; - } - - this._appComponent.Props = this._appComponent.Props! with - { - ScrollItems = new List(this._outputItems), - }; - } - - this._appComponent.Render(); - } -} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/IUXStateDriver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/IUXStateDriver.cs new file mode 100644 index 0000000000..7c01545b68 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/IUXStateDriver.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console; + +/// +/// Abstraction over the harness UI state. All callers (observers, command handlers, +/// the agent runner) interact with the UI exclusively through this interface, which +/// internally translates each operation into a SetState call on the underlying +/// reactive component. +/// +/// +/// This interface is intentionally narrow: it does not expose blocking input methods. +/// The agent runner orchestrates input flow via +/// objects returned from observers. +/// +public interface IUXStateDriver +{ + /// + /// Gets or sets the current agent mode (e.g. "plan", "execute"). Setting also + /// refreshes the rule colour and bottom-panel prompt to match the new mode. + /// + string? CurrentMode { get; set; } + + /// + /// Echoes a submitted user input as a regular user-input entry in the output area. + /// + void WriteUserInputEcho(string text); + + /// + /// Writes informational output as an output entry, without a trailing newline. + /// + Task WriteInfoAsync(string text, ConsoleColor? color = null); + + /// + /// Writes informational output as an output entry, followed by a newline. + /// + Task WriteInfoLineAsync(string text, ConsoleColor? color = null); + + /// + /// Writes streaming text output from the agent. Successive calls accumulate into a + /// single streaming entry that is re-rendered by the text panel. + /// + Task WriteTextAsync(string text, ConsoleColor? color = null); + + /// + /// Writes a blank-line separator to visually close the streaming output section. + /// + Task EndStreamingOutputAsync(); + + /// + /// Shows a "(no text response from agent)" warning if no text was received + /// and no observer produced follow-up actions. + /// + Task WriteNoTextWarningAsync(bool hasFollowUpActions); + + /// + /// Switches the bottom panel to streaming mode and starts the spinner. + /// + void BeginStreaming(); + + /// + /// Stops the spinner without leaving streaming mode. + /// + void StopSpinner(); + + /// + /// Switches the bottom panel back to text-input mode and stops the spinner. + /// + void EndStreaming(); + + /// + /// Resets per-turn streaming bookkeeping in preparation for a new agent turn. + /// + void BeginStreamingOutput(); + + /// + /// Sets the formatted usage text shown on the agent status bar. + /// + void SetUsageText(string usageText); + + /// + /// Replaces the queued-message display with one entry per pending message. + /// + void SetQueuedMessages(IReadOnlyList pending); + + /// + /// Appends the supplied questions to the pending follow-up question queue in + /// component state. If the queue was empty, the bottom-panel display is + /// reconfigured to present the new head question. + /// + void QueueFollowUpQuestions(IReadOnlyList questions); + + /// + /// Appends a message to the accumulated follow-up response list in component state. + /// Called by the runner for direct outputs and by + /// the component when a question's continuation produces a response. + /// + void AddFollowUpResponse(ChatMessage response); + + /// + /// Pops the head of the pending follow-up question queue. Reconfigures the + /// bottom-panel display for the new head, or restores the default text-input + /// mode if the queue is now empty. + /// + void AdvanceFollowUpQuestion(); + + /// + /// Returns the current accumulated follow-up responses and clears them in state. + /// Called by the runner immediately before invoking the next agent turn. + /// + IReadOnlyList TakeFollowUpResponses(); + + /// + /// Signals that the application should shut down. Completes the shutdown task + /// on the owning component. + /// + void RequestShutdown(); +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ConsoleObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ConsoleObserver.cs index a868e61bdf..0a0307f661 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ConsoleObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ConsoleObserver.cs @@ -18,36 +18,41 @@ public abstract class ConsoleObserver /// Override to set options such as . /// /// The run options to configure. - public virtual void ConfigureRunOptions(AgentRunOptions options) + /// The agent being interacted with. + /// The current agent session. + public virtual void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session) { } /// /// Called for each item in the response stream. /// - /// The harness UX container, used for rendering output and interacting with the user. + /// The UX state driver, used for rendering output. /// The content item from the stream. - public virtual Task OnContentAsync(HarnessUXContainer ux, AIContent content) => Task.CompletedTask; + /// The agent being interacted with. + /// The current agent session. + public virtual Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) => Task.CompletedTask; /// /// Called for each text update in the response stream. /// - /// The harness UX container, used for rendering output and interacting with the user. + /// The UX state driver, used for rendering output. /// The text from the update. - public virtual Task OnTextAsync(HarnessUXContainer ux, string text) => Task.CompletedTask; - - /// - /// Called after the response stream completes. Returns messages to include in the - /// next agent invocation, or if no re-invocation is needed. - /// - /// The harness UX container, used for rendering output and interacting with the user. /// The agent being interacted with. /// The current agent session. - /// The console options. - /// Messages to send to the agent, or if no action is needed. - public virtual Task?> OnStreamCompleteAsync( - HarnessUXContainer ux, + public virtual Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session) => Task.CompletedTask; + + /// + /// Called after the response stream completes. Returns a heterogeneous list of + /// follow-up actions (questions to ask the user, and/or messages to add directly to + /// the next agent invocation), or if no follow-up is needed. + /// + /// The UX state driver, used for rendering output. + /// The agent being interacted with. + /// The current agent session. + /// Follow-up actions to process after the stream completes, or . + public virtual Task?> OnStreamCompleteAsync( + IUXStateDriver ux, AIAgent agent, - AgentSession session, - HarnessConsoleOptions options) => Task.FromResult?>(null); + AgentSession session) => Task.FromResult?>(null); } diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ErrorDisplayObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ErrorDisplayObserver.cs index 5e7ddc567c..03af74970a 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ErrorDisplayObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ErrorDisplayObserver.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; namespace Harness.Shared.Console.Observers; @@ -7,10 +8,10 @@ namespace Harness.Shared.Console.Observers; /// /// Displays error content (❌) from the response stream. /// -internal sealed class ErrorDisplayObserver : ConsoleObserver +public sealed class ErrorDisplayObserver : ConsoleObserver { /// - public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content) + public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) { if (content is ErrorContent errorContent) { diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs index 45b00a8d4c..1e7a73df96 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs @@ -2,51 +2,77 @@ using System.Text; using System.Text.Json; +using Harness.ConsoleReactiveComponents; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; namespace Harness.Shared.Console.Observers; /// -/// Planning observer that configures structured output, collects streamed text, -/// and deserializes it as a . Renders clarification -/// questions and approval prompts, and manages mode switching when the user approves a plan. +/// Planning observer that is mode-aware: in planning mode it configures structured +/// JSON output, collects streamed text, and deserializes it as a ; +/// in execution mode it passes text straight through to +/// for live streaming display. /// -internal sealed class PlanningOutputObserver : ConsoleObserver +public sealed class PlanningOutputObserver : ConsoleObserver { private readonly StringBuilder _textCollector = new(); private readonly AgentModeProvider _modeProvider; + private readonly string _planModeName; + private readonly string _executionModeName; + private readonly IReadOnlyDictionary? _modeColors; /// /// Initializes a new instance of the class. /// /// The mode provider for switching modes on approval. - public PlanningOutputObserver(AgentModeProvider modeProvider) + /// The mode name that represents the planning mode. + /// The mode name to switch to when the user approves a plan. + /// Optional mode-to-color mapping for display. + public PlanningOutputObserver(AgentModeProvider modeProvider, string planModeName, string executionModeName, IReadOnlyDictionary? modeColors = null) { this._modeProvider = modeProvider; + this._planModeName = planModeName; + this._executionModeName = executionModeName; + this._modeColors = modeColors; } /// - public override void ConfigureRunOptions(AgentRunOptions options) + public override void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session) { - options.ResponseFormat = ChatResponseFormat.ForJsonSchema(); + if (this.IsPlanningMode(this._modeProvider.GetMode(session))) + { + options.ResponseFormat = ChatResponseFormat.ForJsonSchema(); + } } /// - public override Task OnTextAsync(HarnessUXContainer ux, string text) + public override Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session) { - // Collect text silently instead of displaying it. - this._textCollector.Append(text); - return Task.CompletedTask; + if (this.IsPlanningMode(ux.CurrentMode)) + { + // Planning mode: collect text silently for JSON parsing after the stream. + this._textCollector.Append(text); + return Task.CompletedTask; + } + + // Execution mode: stream text directly to the console. + return ux.WriteTextAsync(text); } /// - public override async Task?> OnStreamCompleteAsync( - HarnessUXContainer ux, + public override async Task?> OnStreamCompleteAsync( + IUXStateDriver ux, AIAgent agent, - AgentSession session, - HarnessConsoleOptions options) + AgentSession session) { + if (!this.IsPlanningMode(ux.CurrentMode)) + { + // Execution mode: text was already streamed live; nothing to parse. + this._textCollector.Clear(); + return null; + } + // Read collected text from our stream observation. string collectedText = this._textCollector.ToString(); this._textCollector.Clear(); @@ -75,10 +101,9 @@ internal sealed class PlanningOutputObserver : ConsoleObserver return null; } - // Render based on response type. if (planningResponse.Type == PlanningResponseType.Clarification) { - return AsUserMessages(await this.RenderClarificationsAndCollectResponsesAsync(ux, planningResponse)); + return BuildClarificationActions(planningResponse); } if (planningResponse.Type == PlanningResponseType.Approval) @@ -90,67 +115,87 @@ internal sealed class PlanningOutputObserver : ConsoleObserver return null; } - string response = await this.RenderApprovalAndCollectResponseAsync(ux, question, options); - if (response == "Approved") - { - this._modeProvider.SetMode(session, options.ExecutionModeName!); - - await ux.WriteInfoLineAsync($"✅ Switched to {options.ExecutionModeName} mode.", - ModeColors.Get(options.ExecutionModeName, options.ModeColors)); - } - - return AsUserMessages(response); + return new List { this.BuildApprovalAction(question, session) }; } await ux.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow); return null; } - private static IList? AsUserMessages(string? text) => - text is not null ? [new ChatMessage(ChatRole.User, text)] : null; - - private async Task RenderClarificationsAndCollectResponsesAsync(HarnessUXContainer ux, PlanningResponse response) + private static List BuildClarificationActions(PlanningResponse response) { - var answers = new List(); + var actions = new List(response.Questions.Count); foreach (var question in response.Questions) { - string? answer; + string prompt = question.Message; + + async Task Continuation(string answer, IUXStateDriver ux) + { + if (string.IsNullOrWhiteSpace(answer)) + { + string noAnswer = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray)}(no answer){AnsiEscapes.ResetAttributes}"; + await ux.WriteInfoLineAsync(noAnswer, ConsoleColor.Gray).ConfigureAwait(false); + return null; + } + + string formatted = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.Green)}{answer}{AnsiEscapes.ResetAttributes}"; + await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false); + + return new ChatMessage(ChatRole.User, $"Q: {prompt}\nA: {answer}"); + } + if (question.Choices is { Count: > 0 }) { - answer = await ux.ReadSelectionAsync( - question.Message, - question.Choices); + actions.Add(new ChoiceFollowUpQuestion( + Prompt: prompt, + Choices: question.Choices, + AllowCustomText: true, + Continuation: Continuation)); } else { - answer = (await ux.ReadLineAsync(question.Message))?.Trim(); - } - - if (!string.IsNullOrWhiteSpace(answer)) - { - answers.Add($"Q: {question.Message}\nA: {answer}"); + actions.Add(new TextFollowUpQuestion( + Prompt: prompt, + Continuation: Continuation)); } } - return answers.Count > 0 ? string.Join("\n\n", answers) : null; + return actions; } - private async Task RenderApprovalAndCollectResponseAsync(HarnessUXContainer ux, PlanningQuestion question, HarnessConsoleOptions options) + private ChoiceFollowUpQuestion BuildApprovalAction(PlanningQuestion question, AgentSession session) { - var choices = new List - { - "Approve and switch to execute mode", - }; + const string ApproveOption = "Approve and switch to execute mode"; + var choices = new List { ApproveOption }; - string selection = await ux.ReadSelectionAsync(question.Message, choices); + return new ChoiceFollowUpQuestion( + Prompt: question.Message, + Choices: choices, + AllowCustomText: true, + Continuation: async (selection, ux) => + { + string formatted = $"🔹 {question.Message}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.Green)}{selection}{AnsiEscapes.ResetAttributes}"; + await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false); - if (selection == choices[0]) - { - return "Approved"; - } + if (selection == ApproveOption) + { + this._modeProvider.SetMode(session, this._executionModeName); + await ux.WriteInfoLineAsync( + $"✅ Switched to {this._executionModeName} mode.", + ModeColors.Get(this._executionModeName, this._modeColors)).ConfigureAwait(false); + return new ChatMessage(ChatRole.User, "Approved"); + } - // Custom freeform input — treat as suggested changes. - return selection; + // Custom freeform input — treat as suggested changes. + return new ChatMessage(ChatRole.User, selection); + }); } + + /// + /// Returns when the current mode matches the configured plan mode name. + /// A mode (no mode provider) is also treated as planning mode. + /// + private bool IsPlanningMode(string? currentMode) => + currentMode is null || string.Equals(currentMode, this._planModeName, StringComparison.OrdinalIgnoreCase); } diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ReasoningDisplayObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ReasoningDisplayObserver.cs index 7cbaa56f58..4d7e95f754 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ReasoningDisplayObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ReasoningDisplayObserver.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; namespace Harness.Shared.Console.Observers; @@ -7,10 +8,10 @@ namespace Harness.Shared.Console.Observers; /// /// Displays reasoning content in dark magenta from the response stream. /// -internal sealed class ReasoningDisplayObserver : ConsoleObserver +public sealed class ReasoningDisplayObserver : ConsoleObserver { /// - public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content) + public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) { if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text)) { diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/TextOutputObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/TextOutputObserver.cs index 2c502aa361..a81d8e829d 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/TextOutputObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/TextOutputObserver.cs @@ -1,15 +1,17 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Agents.AI; + namespace Harness.Shared.Console.Observers; /// /// Streams agent text output directly to the console. /// Used in normal (non-planning) mode. /// -internal sealed class TextOutputObserver : ConsoleObserver +public sealed class TextOutputObserver : ConsoleObserver { /// - public override async Task OnTextAsync(HarnessUXContainer ux, string text) + public override async Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session) { await ux.WriteTextAsync(text); } diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolApprovalObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolApprovalObserver.cs index c75cbe6dbb..20889d61fa 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolApprovalObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolApprovalObserver.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using Harness.ConsoleReactiveComponents; +using Harness.Shared.Console.ToolFormatters; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -7,86 +9,103 @@ namespace Harness.Shared.Console.Observers; /// /// Collects items during the response stream, -/// displays approval-needed notifications inline, and prompts the user for approval -/// decisions after the stream completes. +/// displays approval-needed notifications inline, and after the stream completes returns +/// one per pending approval request. Each question's +/// continuation produces a separate carrying the approval +/// response content. /// -internal sealed class ToolApprovalObserver : ConsoleObserver +public sealed class ToolApprovalObserver : ConsoleObserver { private readonly List _approvalRequests = []; + private readonly IReadOnlyList _formatters; + + /// + /// Initializes a new instance of the class. + /// + /// Optional list of tool formatters. When , + /// the default formatters from are used. + public ToolApprovalObserver(IReadOnlyList? formatters = null) + { + this._formatters = formatters ?? ToolCallFormatter.BuildDefaultToolFormatters(); + } /// - public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content) + public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) { if (content is ToolApprovalRequestContent approvalRequest) { this._approvalRequests.Add(approvalRequest); string toolName = approvalRequest.ToolCall is FunctionCallContent fc - ? ToolCallFormatter.Format(fc) + ? ToolCallFormatter.Format(this._formatters, fc) : approvalRequest.ToolCall?.ToString() ?? "unknown"; await ux.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow); } } /// - public override async Task?> OnStreamCompleteAsync( - HarnessUXContainer ux, + public override Task?> OnStreamCompleteAsync( + IUXStateDriver ux, AIAgent agent, - AgentSession session, - HarnessConsoleOptions options) + AgentSession session) { if (this._approvalRequests.Count == 0) { - return null; + return Task.FromResult?>(null); + } + + var actions = new List(this._approvalRequests.Count); + foreach (var request in this._approvalRequests) + { + actions.Add(this.BuildApprovalQuestion(request)); } - var messages = await PromptForApprovalsAsync(ux, this._approvalRequests); this._approvalRequests.Clear(); - return messages; + return Task.FromResult?>(actions); } - private static async Task?> PromptForApprovalsAsync(HarnessUXContainer ux, List approvalRequests) + private ChoiceFollowUpQuestion BuildApprovalQuestion(ToolApprovalRequestContent request) { - if (approvalRequests.Count == 0) + string toolName = request.ToolCall is FunctionCallContent fc + ? ToolCallFormatter.Format(this._formatters, fc) + : request.ToolCall?.ToString() ?? "unknown"; + + var choices = new List { - return null; - } + "Approve this call", + "Always approve this tool (any arguments)", + "Always approve this tool with these arguments", + "Deny", + }; - var responses = new List(); - foreach (var request in approvalRequests) - { - string toolName = request.ToolCall is FunctionCallContent fc - ? ToolCallFormatter.Format(fc) - : request.ToolCall?.ToString() ?? "unknown"; + string prompt = $"🔐 Tool approval: {toolName}"; - var choices = new List + return new ChoiceFollowUpQuestion( + Prompt: prompt, + Choices: choices, + AllowCustomText: false, + Continuation: async (selection, ux) => { - "Approve this call", - "Always approve this tool (any arguments)", - "Always approve this tool with these arguments", - "Deny", - }; + AIContent response = selection switch + { + "Always approve this tool (any arguments)" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"), + "Always approve this tool with these arguments" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"), + "Deny" => request.CreateResponse(approved: false, reason: "User denied"), + _ => request.CreateResponse(approved: true, reason: "User approved"), + }; - string selection = await ux.ReadSelectionAsync($"🔐 Tool approval: {toolName}", choices); - AIContent response = selection switch - { - "Always approve this tool (any arguments)" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"), - "Always approve this tool with these arguments" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"), - "Deny" => request.CreateResponse(approved: false, reason: "User denied"), - _ => request.CreateResponse(approved: true, reason: "User approved"), - }; + string action = selection switch + { + "Always approve this tool (any arguments)" => "✅ Always approved (any args)", + "Always approve this tool with these arguments" => "✅ Always approved (these args)", + "Deny" => "❌ Denied", + _ => "✅ Approved", + }; - string action = selection switch - { - "Always approve this tool (any arguments)" => "✅ Always approved (any args)", - "Always approve this tool with these arguments" => "✅ Always approved (these args)", - "Deny" => "❌ Denied", - _ => "✅ Approved", - }; - await ux.WriteInfoLineAsync($" {action}", ConsoleColor.DarkGray); + ConsoleColor answerColor = selection == "Deny" ? ConsoleColor.Red : ConsoleColor.Green; + string formatted = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(answerColor)}{action}{AnsiEscapes.ResetAttributes}"; + await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false); - responses.Add(response); - } - - return [new ChatMessage(ChatRole.User, responses)]; + return new ChatMessage(ChatRole.User, [response]); + }); } } 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 0ca55edf36..d47ce4c636 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 @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using Harness.Shared.Console.ToolFormatters; +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; namespace Harness.Shared.Console.Observers; @@ -8,14 +10,26 @@ namespace Harness.Shared.Console.Observers; /// Displays tool call notifications (🔧) for /// and items in the response stream. /// -internal sealed class ToolCallDisplayObserver : ConsoleObserver +public sealed class ToolCallDisplayObserver : ConsoleObserver { + private readonly IReadOnlyList _formatters; + + /// + /// Initializes a new instance of the class. + /// + /// Optional list of tool formatters. When , + /// the default formatters from are used. + public ToolCallDisplayObserver(IReadOnlyList? formatters = null) + { + this._formatters = formatters ?? ToolCallFormatter.BuildDefaultToolFormatters(); + } + /// - public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content) + public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) { if (content is FunctionCallContent functionCall) { - await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(functionCall)}...", ConsoleColor.DarkYellow); + await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(this._formatters, functionCall)}...", ConsoleColor.DarkYellow); } else if (content is ToolCallContent toolCall) { diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallFormatter.cs deleted file mode 100644 index 09c1ea290b..0000000000 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallFormatter.cs +++ /dev/null @@ -1,288 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.AI; - -namespace Harness.Shared.Console.Observers; - -/// -/// Formats instances into human-readable strings -/// for console display. -/// -public static class ToolCallFormatter -{ - /// - /// Returns a formatted string for the given tool call, with human-readable - /// details for known tools (todos, mode, sub-agents, web tools). - /// - /// The function call content to format. - /// A formatted string describing the tool call. - public static string Format(FunctionCallContent call) - { - string? detail = call.Name switch - { - // Todo tools - "TodoList_Add" => FormatAddTodos(call), - "TodoList_Complete" => FormatIdList(call, "ids", "Complete"), - "TodoList_Remove" => FormatIdList(call, "ids", "Remove"), - "TodoList_GetRemaining" => null, - "TodoList_GetAll" => null, - - // Mode tools - "AgentMode_Set" => FormatStringArg(call, "mode"), - "AgentMode_Get" => null, - - // Sub-agent tools - "SubAgents_StartTask" => FormatStartSubTask(call), - "SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"), - "SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"), - "SubAgents_GetAllTasks" => null, - "SubAgents_ContinueTask" => FormatContinueTask(call), - "SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"), - - // File memory tools - "FileMemory_SaveFile" => FormatSaveFile(call), - "FileMemory_ReadFile" => FormatStringArg(call, "fileName"), - "FileMemory_DeleteFile" => FormatStringArg(call, "fileName"), - "FileMemory_ListFiles" => null, - "FileMemory_SearchFiles" => FormatSearchFiles(call), - - // External tools - "web_search" => FormatStringArg(call, "query"), - "DownloadUri" => FormatStringArg(call, "uri"), - - _ => FormatFallback(call), - }; - - return detail is not null ? $"{call.Name} {detail}" : call.Name; - } - - private static string? FormatAddTodos(FunctionCallContent call) - { - if (call.Arguments?.TryGetValue("todos", out object? todosObj) != true || todosObj is null) - { - return null; - } - - var titles = new List(); - - if (todosObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array) - { - foreach (JsonElement item in jsonArray.EnumerateArray()) - { - string? title = item.TryGetProperty("title", out JsonElement titleElement) - ? titleElement.GetString() - : null; - - if (!string.IsNullOrEmpty(title)) - { - titles.Add(title); - } - } - } - - if (titles.Count == 0) - { - return null; - } - - var sb = new StringBuilder(); - sb.Append($"({titles.Count} item{(titles.Count == 1 ? "" : "s")})"); - foreach (string title in titles) - { - sb.Append($"\n • {title}"); - } - - return sb.ToString(); - } - - private static string? FormatIdList(FunctionCallContent call, string paramName, string verb) - { - List? ids = GetIntList(call, paramName); - if (ids is null || ids.Count == 0) - { - return null; - } - - return $"({verb} #{string.Join(", #", ids)})"; - } - - private static string? FormatSingleId(FunctionCallContent call, string paramName) - { - int? id = GetInt(call, paramName); - return id.HasValue ? $"(task #{id.Value})" : null; - } - - private static string? FormatStartSubTask(FunctionCallContent call) - { - string? agentName = GetString(call, "agentName"); - string? description = GetString(call, "description"); - - if (agentName is null && description is null) - { - return null; - } - - var sb = new StringBuilder("("); - if (agentName is not null) - { - sb.Append($"agent: {agentName}"); - } - - if (description is not null) - { - if (agentName is not null) - { - sb.Append(", "); - } - - sb.Append($"\"{Truncate(description, 60)}\""); - } - - sb.Append(')'); - return sb.ToString(); - } - - private static string? FormatContinueTask(FunctionCallContent call) - { - int? taskId = GetInt(call, "taskId"); - string? text = GetString(call, "text"); - - if (!taskId.HasValue) - { - return null; - } - - return text is not null - ? $"(task #{taskId.Value}, \"{Truncate(text, 50)}\")" - : $"(task #{taskId.Value})"; - } - - private static string? FormatSaveFile(FunctionCallContent call) - { - string? fileName = GetString(call, "fileName"); - string? description = GetString(call, "description"); - - if (fileName is null) - { - return null; - } - - return string.IsNullOrEmpty(description) - ? $"({fileName})" - : $"({fileName}, with description)"; - } - - private static string? FormatSearchFiles(FunctionCallContent call) - { - string? pattern = GetString(call, "regexPattern"); - string? filePattern = GetString(call, "filePattern"); - - if (pattern is null) - { - return null; - } - - return string.IsNullOrEmpty(filePattern) - ? $"(/{pattern}/)" - : $"(/{pattern}/ in {filePattern})"; - } - - private static string? FormatStringArg(FunctionCallContent call, string paramName) - { - string? value = GetString(call, paramName); - return value is not null ? $"({value})" : null; - } - - private static string? FormatFallback(FunctionCallContent call) - { - if (call.Arguments is null || call.Arguments.Count == 0) - { - return null; - } - - var parts = new List(); - foreach (var kvp in call.Arguments) - { - string? stringValue = kvp.Value switch - { - JsonElement je => je.ValueKind switch - { - JsonValueKind.String => je.GetString(), - JsonValueKind.Number => je.GetRawText(), - JsonValueKind.True => "true", - JsonValueKind.False => "false", - _ => null, - }, - not null => kvp.Value.ToString(), - _ => null, - }; - - if (stringValue is not null) - { - parts.Add($"{kvp.Key}: {Truncate(stringValue, 40)}"); - } - } - - return parts.Count > 0 ? $"({string.Join(", ", parts)})" : null; - } - - private static string? GetString(FunctionCallContent call, string paramName) - { - if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null) - { - return null; - } - - return value switch - { - JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(), - string s => s, - _ => value.ToString(), - }; - } - - private static int? GetInt(FunctionCallContent call, string paramName) - { - if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null) - { - return null; - } - - return value switch - { - JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(), - int i => i, - _ => int.TryParse(value.ToString(), out int parsed) ? parsed : null, - }; - } - - private static List? GetIntList(FunctionCallContent call, string paramName) - { - if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null) - { - return null; - } - - var result = new List(); - - if (value is JsonElement je && je.ValueKind == JsonValueKind.Array) - { - foreach (JsonElement item in je.EnumerateArray()) - { - if (item.ValueKind == JsonValueKind.Number) - { - result.Add(item.GetInt32()); - } - } - } - - return result.Count > 0 ? result : null; - } - - private static string Truncate(string text, int maxLength) - { - return text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength), "…"); - } -} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/UsageDisplayObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/UsageDisplayObserver.cs index 7e845ff0ad..14241f6823 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/UsageDisplayObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/UsageDisplayObserver.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; namespace Harness.Shared.Console.Observers; @@ -7,7 +8,7 @@ namespace Harness.Shared.Console.Observers; /// /// Displays token usage statistics (📊) from the response stream. /// -internal sealed class UsageDisplayObserver : ConsoleObserver +public sealed class UsageDisplayObserver : ConsoleObserver { private readonly int? _maxContextWindowTokens; private readonly int? _maxOutputTokens; @@ -24,7 +25,7 @@ internal sealed class UsageDisplayObserver : ConsoleObserver } /// - public override Task OnContentAsync(HarnessUXContainer ux, AIContent content) + public override Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) { if (content is UsageContent usage) { diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/OutputEntry.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/OutputEntry.cs index a838e09007..a9f2956fbc 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/OutputEntry.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/OutputEntry.cs @@ -5,7 +5,7 @@ namespace Harness.Shared.Console; /// /// Represents the type of an output entry in the console conversation. /// -public enum OutputEntryType +internal enum OutputEntryType { /// User input echo (e.g. "You: hello"). UserInput, @@ -25,9 +25,10 @@ public enum OutputEntryType /// /// Represents a single output entry in the console conversation history. -/// These entries are rendered by the via its render delegate. +/// Used internally by to track +/// the in-progress streaming entry and last-entry type for spacing decisions. /// /// The type of output entry. /// The text content of the entry. /// Optional foreground color for rendering. -public record OutputEntry(OutputEntryType Type, string Text, ConsoleColor? Color = null); +internal sealed record OutputEntry(OutputEntryType Type, string Text, ConsoleColor? Color = null); diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/FallbackToolFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/FallbackToolFormatter.cs new file mode 100644 index 0000000000..4d5df2b5fd --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/FallbackToolFormatter.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.ToolFormatters; + +/// +/// Catch-all formatter that handles any tool not matched by a more specific formatter. +/// Displays a generic summary of the tool's arguments. This formatter should always be +/// placed last in the formatter list. +/// +public sealed class FallbackToolFormatter : ToolCallFormatter +{ + /// + public override bool CanFormat(FunctionCallContent call) => true; + + /// + public override string? FormatDetail(FunctionCallContent call) + { + if (call.Arguments is null || call.Arguments.Count == 0) + { + return null; + } + + var parts = new List(); + foreach (var kvp in call.Arguments) + { + string? stringValue = kvp.Value switch + { + JsonElement je => je.ValueKind switch + { + JsonValueKind.String => je.GetString(), + JsonValueKind.Number => je.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => null, + }, + not null => kvp.Value.ToString(), + _ => null, + }; + + if (stringValue is not null) + { + parts.Add($"{kvp.Key}: {Truncate(stringValue, 40)}"); + } + } + + return parts.Count > 0 ? $"({string.Join(", ", parts)})" : null; + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/FileMemoryToolFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/FileMemoryToolFormatter.cs new file mode 100644 index 0000000000..7240089e03 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/FileMemoryToolFormatter.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.ToolFormatters; + +/// +/// Formats FileMemory_* tool calls, showing file names and search patterns +/// with tree-view corners for save operations. +/// +public sealed class FileMemoryToolFormatter : ToolCallFormatter +{ + /// + public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("FileMemory_", StringComparison.Ordinal); + + /// + public override string? FormatDetail(FunctionCallContent call) => call.Name switch + { + "FileMemory_SaveFile" => FormatSaveFile(call), + "FileMemory_ReadFile" => FormatStringArg(call, "fileName"), + "FileMemory_DeleteFile" => FormatStringArg(call, "fileName"), + "FileMemory_SearchFiles" => FormatSearchFiles(call), + _ => null, + }; + + private static string? FormatSaveFile(FunctionCallContent call) + { + string? fileName = GetStringArgumentValue(call, "fileName"); + string? description = GetStringArgumentValue(call, "description"); + + if (fileName is null) + { + return null; + } + + return string.IsNullOrEmpty(description) + ? $"\n └─ {fileName}" + : $"\n └─ {fileName} (with description)"; + } + + private static string? FormatSearchFiles(FunctionCallContent call) + { + string? pattern = GetStringArgumentValue(call, "regexPattern"); + string? filePattern = GetStringArgumentValue(call, "filePattern"); + + if (pattern is null) + { + return null; + } + + return string.IsNullOrEmpty(filePattern) + ? $"(/{pattern}/)" + : $"(/{pattern}/ in {filePattern})"; + } + + private static string? FormatStringArg(FunctionCallContent call, string paramName) + { + string? value = GetStringArgumentValue(call, paramName); + return value is not null ? $"({value})" : null; + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ModeToolFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ModeToolFormatter.cs new file mode 100644 index 0000000000..940a810c59 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ModeToolFormatter.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.ToolFormatters; + +/// +/// Formats AgentMode_* tool calls, showing the target mode for Set operations. +/// +public sealed class ModeToolFormatter : ToolCallFormatter +{ + /// + public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("AgentMode_", StringComparison.Ordinal); + + /// + public override string? FormatDetail(FunctionCallContent call) => call.Name switch + { + "AgentMode_Set" => FormatStringArg(call, "mode"), + _ => null, + }; + + private static string? FormatStringArg(FunctionCallContent call, string paramName) + { + string? value = GetStringArgumentValue(call, paramName); + return value is not null ? $"({value})" : null; + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/SubAgentToolFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/SubAgentToolFormatter.cs new file mode 100644 index 0000000000..915491d354 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/SubAgentToolFormatter.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.ToolFormatters; + +/// +/// Formats SubAgents_* tool calls with human-readable details +/// for task start, continue, wait, and result retrieval operations. +/// +public sealed class SubAgentToolFormatter : ToolCallFormatter +{ + /// + public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("SubAgents_", 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"), + _ => null, + }; + + private static string? FormatStartSubTask(FunctionCallContent call) + { + string? agentName = GetStringArgumentValue(call, "agentName"); + string? description = GetStringArgumentValue(call, "description"); + + if (agentName is null && description is null) + { + return null; + } + + var sb = new StringBuilder(); + + if (agentName is not null && description is not null) + { + sb.Append($"\n ├─ Agent: {agentName}"); + sb.Append($"\n └─ \"{Truncate(description, 80)}\""); + } + else if (agentName is not null) + { + sb.Append($"\n └─ Agent: {agentName}"); + } + else + { + sb.Append($"\n └─ \"{Truncate(description!, 80)}\""); + } + + return sb.ToString(); + } + + private static string? FormatIdList(FunctionCallContent call, string paramName, string verb) + { + List? ids = GetIntListArgumentValue(call, paramName); + if (ids is null || ids.Count == 0) + { + return null; + } + + var sb = new StringBuilder(); + for (int i = 0; i < ids.Count; i++) + { + string connector = i < ids.Count - 1 ? "├─" : "└─"; + sb.Append($"\n {connector} {verb} #{ids[i]}"); + } + + return sb.ToString(); + } + + private static string? FormatSingleId(FunctionCallContent call, string paramName) + { + int? id = GetIntArgumentValue(call, paramName); + return id.HasValue ? $"(task #{id.Value})" : null; + } + + private static string? FormatContinueTask(FunctionCallContent call) + { + int? taskId = GetIntArgumentValue(call, "taskId"); + string? text = GetStringArgumentValue(call, "text"); + + if (!taskId.HasValue) + { + return null; + } + + if (text is not null) + { + var sb = new StringBuilder(); + sb.Append($"\n ├─ Task #{taskId.Value}"); + sb.Append($"\n └─ \"{Truncate(text, 80)}\""); + return sb.ToString(); + } + + return $"\n └─ Task #{taskId.Value}"; + } +} 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 new file mode 100644 index 0000000000..98e041ede7 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/TodoToolFormatter.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.ToolFormatters; + +/// +/// Formats TodoList_* tool calls with tree-view output for added items +/// and structured output for complete/remove operations. +/// +public sealed class TodoToolFormatter : ToolCallFormatter +{ + /// + public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("TodoList_", StringComparison.Ordinal); + + /// + public override string? FormatDetail(FunctionCallContent call) => call.Name switch + { + "TodoList_Add" => FormatAddTodos(call), + "TodoList_Complete" => FormatIdList(call, "ids", "Complete"), + "TodoList_Remove" => FormatIdList(call, "ids", "Remove"), + _ => null, + }; + + private static string? FormatAddTodos(FunctionCallContent call) + { + if (call.Arguments?.TryGetValue("todos", out object? todosObj) != true || todosObj is null) + { + return null; + } + + var titles = new List(); + + if (todosObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in jsonArray.EnumerateArray()) + { + string? title = item.TryGetProperty("title", out JsonElement titleElement) + ? titleElement.GetString() + : null; + + if (!string.IsNullOrEmpty(title)) + { + titles.Add(title); + } + } + } + + if (titles.Count == 0) + { + return null; + } + + var sb = new StringBuilder(); + sb.Append($"({titles.Count} item{(titles.Count == 1 ? "" : "s")})"); + for (int i = 0; i < titles.Count; i++) + { + string connector = i < titles.Count - 1 ? "├─" : "└─"; + sb.Append($"\n {connector} {titles[i]}"); + } + + return sb.ToString(); + } + + private static string? FormatIdList(FunctionCallContent call, string paramName, string verb) + { + List? ids = GetIntListArgumentValue(call, paramName); + if (ids is null || ids.Count == 0) + { + return null; + } + + var sb = new StringBuilder(); + for (int i = 0; i < ids.Count; i++) + { + string connector = i < ids.Count - 1 ? "├─" : "└─"; + sb.Append($"\n {connector} {verb} #{ids[i]}"); + } + + return sb.ToString(); + } +} 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 new file mode 100644 index 0000000000..f8a131dd74 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ToolCallFormatter.cs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.ToolFormatters; + +/// +/// Base class for tool call formatters that produce human-readable display strings +/// for items shown in the console. +/// +public abstract class ToolCallFormatter +{ + /// + /// Returns if this formatter can handle the given function call. + /// + /// The function call content to check. + /// if this formatter should be used; otherwise . + public abstract bool CanFormat(FunctionCallContent call); + + /// + /// Returns the detail portion of the formatted output for the given tool call, + /// or if only the tool name should be displayed. + /// + /// The function call content to format. + /// A detail string to append after the tool name, or . + public abstract string? FormatDetail(FunctionCallContent call); + + /// + /// Formats a tool call using the first matching formatter from the provided list. + /// Returns "{toolName} {detail}" when a formatter produces detail, + /// or just "{toolName}" otherwise. + /// + internal static string Format(IReadOnlyList formatters, FunctionCallContent call) + { + foreach (var formatter in formatters) + { + if (formatter.CanFormat(call)) + { + string? detail = formatter.FormatDetail(call); + return detail is not null ? $"{call.Name} {detail}" : call.Name; + } + } + + return call.Name; + } + + /// + /// Creates the default list of tool call formatters. The + /// is always last. Users can call this method and combine the result with their own formatters. + /// + /// A list of all built-in tool call formatters. + public static List BuildDefaultToolFormatters() + { + return + [ + new TodoToolFormatter(), + new ModeToolFormatter(), + new SubAgentToolFormatter(), + new FileMemoryToolFormatter(), + new WebSearchToolFormatter(), + new FallbackToolFormatter(), + ]; + } + + /// + /// Extracts a string argument value from a function call. + /// + protected static string? GetStringArgumentValue(FunctionCallContent call, string paramName) + { + if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null) + { + return null; + } + + return value switch + { + JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(), + string s => s, + _ => value.ToString(), + }; + } + + /// + /// Extracts an integer argument value from a function call. + /// + protected static int? GetIntArgumentValue(FunctionCallContent call, string paramName) + { + if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null) + { + return null; + } + + return value switch + { + JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(), + int i => i, + _ => int.TryParse(value.ToString(), out int parsed) ? parsed : null, + }; + } + + /// + /// Extracts a list of integer argument values from a function call. + /// + protected static List? GetIntListArgumentValue(FunctionCallContent call, string paramName) + { + if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null) + { + return null; + } + + var result = new List(); + + if (value is JsonElement je && je.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in je.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.Number) + { + result.Add(item.GetInt32()); + } + } + } + + return result.Count > 0 ? result : null; + } + + /// + /// Truncates a string to the specified maximum length, appending an ellipsis if truncated. + /// + protected static string Truncate(string text, int maxLength) + { + return text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength), "…"); + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/WebSearchToolFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/WebSearchToolFormatter.cs new file mode 100644 index 0000000000..b2c681306f --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/WebSearchToolFormatter.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.ToolFormatters; + +/// +/// Formats web_search tool calls, showing the search query. +/// +public sealed class WebSearchToolFormatter : ToolCallFormatter +{ + /// + public override bool CanFormat(FunctionCallContent call) => + call.Name is "web_search"; + + /// + public override string? FormatDetail(FunctionCallContent call) + { + string? value = GetStringArgumentValue(call, "query"); + return value is not null ? $"({value})" : null; + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/DownloadUriToolFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/DownloadUriToolFormatter.cs new file mode 100644 index 0000000000..4175f2b1f3 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/DownloadUriToolFormatter.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Harness.Shared.Console.ToolFormatters; +using Microsoft.Extensions.AI; + +namespace SampleApp; + +/// +/// Formats DownloadUri tool calls, showing the target URI. +/// +public sealed class DownloadUriToolFormatter : ToolCallFormatter +{ + /// + public override bool CanFormat(FunctionCallContent call) => + call.Name is "DownloadUri"; + + /// + public override string? FormatDetail(FunctionCallContent call) + { + string? value = GetStringArgumentValue(call, "uri"); + return value is not null ? $"({value})" : null; + } +} 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 2e79dd572b..1c9e93588c 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs @@ -8,7 +8,8 @@ // // Special commands: // /todos — Display the current todo list without invoking the agent. -// exit — End the session. +// /mode — Get or set the current agent mode. +// /exit — End the session. #pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage. #pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments. @@ -16,6 +17,7 @@ using System.ClientModel.Primitives; using Azure.Identity; using Harness.Shared.Console; +using Harness.Shared.Console.ToolFormatters; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI; @@ -158,13 +160,15 @@ AIAgent agent = // Run the interactive console session using the shared HarnessConsole helper. await HarnessConsole.RunAgentAsync( agent, - title: "Research Assistant", userPrompt: "Enter a research topic to get started.", new HarnessConsoleOptions { - MaxContextWindowTokens = MaxContextWindowTokens, - MaxOutputTokens = MaxOutputTokens, - EnablePlanningUx = true, - PlanningModeName = "plan", - ExecutionModeName = "execute" + Observers = HarnessConsoleOptions.BuildObserversWithPlanning( + agent, + planModeName: "plan", + executionModeName: "execute", + maxContextWindowTokens: MaxContextWindowTokens, + maxOutputTokens: MaxOutputTokens, + toolFormatters: [new DownloadUriToolFormatter(), .. ToolCallFormatter.BuildDefaultToolFormatters()]), + CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent), }); 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 d34ac786e0..721da3339c 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 @@ -6,7 +6,7 @@ // equipped with Foundry's hosted web search tool. // // Special commands: -// exit — End the session. +// /exit — End the session. #pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage. #pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments. @@ -103,5 +103,4 @@ AIAgent parentAgent = // Run the interactive console session. await HarnessConsole.RunAgentAsync( parentAgent, - title: "Stock Price Researcher (SubAgents Demo)", userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):"); 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 60505cbe7d..b1b5bc5f2d 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs @@ -8,7 +8,7 @@ // Ask the agent to analyze the data, produce summaries, or create new output files. // // Special commands: -// exit — End the session. +// /exit — End the session. #pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage. #pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments. @@ -85,5 +85,4 @@ AIAgent agent = // Run the interactive console session. await HarnessConsole.RunAgentAsync( agent, - title: "Data Processing Assistant", userPrompt: "Ask me to analyze the data files, produce summaries, or create output files."); diff --git a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs index 9839d2a157..c22adca090 100644 --- a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs @@ -17,6 +17,7 @@ namespace Microsoft.Agents.AI; /// assembles the following pipeline from a caller-supplied : /// /// — automatic function/tool invocation. +/// — allows external code to inject messages into the conversation mid-stream. /// — persists chat history after every individual service call within a function-invocation loop. /// with a — applies context-window compaction before each call so long function-invocation loops do not overflow the context window. /// @@ -110,6 +111,7 @@ public sealed class HarnessAgent : DelegatingAIAgent return chatClient .AsBuilder() .UseFunctionInvocation() + .UseMessageInjection() .UsePerServiceCallChatHistoryPersistence() .UseAIContextProviders(compactionProvider) .BuildAIAgent(new ChatClientAgentOptions From 189e64bfdd8b7eedf7087a31f5eeab5cac1a6a4c Mon Sep 17 00:00:00 2001 From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Date: Thu, 14 May 2026 08:30:48 -0700 Subject: [PATCH 09/65] .NET: Add sample for invoking Foundry Toolbox tools from declarative workflows (#5829) * Add sample for invoking Foundry Toolbox tools from declarative workflows * Addressed initial PR comments. --- dotnet/agent-framework-dotnet.slnx | 1 + dotnet/eng/verify-samples/WorkflowSamples.cs | 11 + .../InvokeFoundryToolboxMcp.csproj | 42 ++++ .../InvokeFoundryToolboxMcp.yaml | 87 +++++++ .../InvokeFoundryToolboxMcp/Program.cs | 218 ++++++++++++++++++ .../DefaultMcpToolHandler.cs | 95 +++++++- .../DefaultMcpToolHandlerTests.cs | 157 +++++++++++++ .../ObjectModel/InvokeMcpToolExecutorTest.cs | 38 +++ 8 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj create mode 100644 dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.yaml create mode 100644 dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 87e6d9d3c6..750af38d7a 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -242,6 +242,7 @@ + diff --git a/dotnet/eng/verify-samples/WorkflowSamples.cs b/dotnet/eng/verify-samples/WorkflowSamples.cs index 2842f4af89..2793dd04c5 100644 --- a/dotnet/eng/verify-samples/WorkflowSamples.cs +++ b/dotnet/eng/verify-samples/WorkflowSamples.cs @@ -478,6 +478,17 @@ internal static class WorkflowSamples ExpectedOutputDescription = ["The output should show a workflow invoking a function tool (e.g. a menu plugin) to answer a question about the soup of the day."], }, + new SampleDefinition + { + Name = "Workflow_Declarative_InvokeFoundryToolboxMcp", + ProjectPath = "samples/03-workflows/Declarative/InvokeFoundryToolboxMcp", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME", "FOUNDRY_TOOLBOX_NAME", "FOUNDRY_AGENT_TOOLSET_API_VERSION"], + Inputs = ["How do I use Azure OpenAI with my data?"], + InputDelayMs = 3000, + ExpectedOutputDescription = ["The output should show a workflow using Foundry Toolbox MCP tools to search Microsoft Learn documentation and web search to provide a summary of results."], + }, + new SampleDefinition { Name = "Workflow_Declarative_InvokeMcpTool", diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj new file mode 100644 index 0000000000..3e70c3f994 --- /dev/null +++ b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj @@ -0,0 +1,42 @@ + + + + Exe + net10.0 + enable + enable + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.yaml b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.yaml new file mode 100644 index 0000000000..b5f6f39316 --- /dev/null +++ b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.yaml @@ -0,0 +1,87 @@ +# +# This workflow demonstrates invoking MCP tools through a Foundry toolbox MCP proxy. +# +# The toolbox is provisioned with TWO different tool types: +# 1. A Foundry built-in web_search tool +# 2. A Microsoft Learn MCP server (microsoft_docs) +# Both are surfaced through the same MCP-compatible toolbox endpoint. +# +# The workflow: +# 1. Accepts a documentation/web search query as input +# 2. Lists the tools exposed by the Foundry toolbox using reserved toolName: tools/list +# 3. Invokes the microsoft_docs_search MCP tool +# 4. Invokes the built-in web_search tool against the same toolbox endpoint +# 5. Uses an agent to summarize and combine both result sets +# +# Example input: +# How do I use Azure OpenAI with my data? +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_invoke_foundry_toolbox_mcp + actions: + + # Set the search query from user input. + - kind: SetVariable + id: set_search_query + variable: Local.SearchQuery + value: =System.LastMessage.Text + + # List tools exposed by the Foundry toolbox MCP proxy. + - kind: InvokeMcpTool + id: list_toolbox_tools + serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL + serverLabel: foundry_toolbox + toolName: tools/list + conversationId: =System.ConversationId + headers: + Foundry-Features: Toolboxes=V1Preview + output: + autoSend: true + result: Local.ToolboxTools + + # Invoke a specific tool exposed through the toolbox and add the result to the conversation. + - kind: InvokeMcpTool + id: search_docs_with_toolbox + serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL + serverLabel: foundry_toolbox + toolName: =Env.FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL & "___microsoft_docs_search" + conversationId: =System.ConversationId + headers: + Foundry-Features: Toolboxes=V1Preview + arguments: + query: =Local.SearchQuery + output: + autoSend: true + result: Local.SearchResult + + # Invoke the web_search built-in tool through the same toolbox proxy. The toolbox surfaces + # built-in Foundry tools (like web_search) alongside MCP tools through one MCP-compatible + # endpoint. Note that web_search expects argument 'search_query' (not 'query'). + - kind: InvokeMcpTool + id: search_web_with_toolbox + serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL + serverLabel: foundry_toolbox + toolName: =Env.FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME + conversationId: =System.ConversationId + headers: + Foundry-Features: Toolboxes=V1Preview + arguments: + search_query: =Local.SearchQuery + output: + autoSend: true + result: Local.WebSearchResult + + # Use the agent to summarize what happened and answer from the toolbox result. + - kind: InvokeAzureAgent + id: summarize_toolbox_result + agent: + name: FoundryToolboxMcpAgent + conversationId: =System.ConversationId + input: + messages: =UserMessage("Combine the Microsoft Learn docs results and the Foundry web search results in the conversation to answer the query " & Local.SearchQuery) + output: + autoSend: true + messages: Local.Summary diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs new file mode 100644 index 0000000000..6636cb13a7 --- /dev/null +++ b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates using InvokeMcpTool to call MCP tools through a Foundry toolbox. +// It creates a sample toolbox that exposes Microsoft Learn MCP tools, lists the toolbox tools +// through the reserved tools/list operation, then calls microsoft_docs_search from the workflow. + +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Concurrent; +using System.Net.Http.Headers; +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Azure.Core; +using Azure.Identity; +using Microsoft.Agents.AI.Workflows.Declarative.Mcp; +using Microsoft.Extensions.Configuration; +using OpenAI.Responses; +using Shared.Foundry; +using Shared.Workflows; + +#pragma warning disable OPENAI001 // Experimental API +#pragma warning disable AAIP001 // AgentToolboxes is experimental + +namespace Demo.Workflows.Declarative.InvokeFoundryToolboxMcp; + +/// +/// Demonstrates a workflow that uses InvokeMcpTool to call MCP tools exposed through a Foundry toolbox. +/// +/// +/// This sample provisions a toolbox with Microsoft Learn MCP tools, uses the reserved +/// tools/list tool name to list the toolbox tools, calls one specific toolbox tool, +/// and has a Foundry agent summarize the results. +/// +internal sealed class Program +{ + private const string ToolboxNameSetting = "FOUNDRY_TOOLBOX_NAME"; + private const string ToolboxApiVersionSetting = "FOUNDRY_AGENT_TOOLSET_API_VERSION"; + private const string ToolboxMcpServerUrlSetting = "FOUNDRY_TOOLBOX_MCP_SERVER_URL"; + private const string DocsServerLabelSetting = "FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL"; + private const string WebSearchToolNameSetting = "FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME"; + private const string DefaultToolboxName = "declarative_foundry_toolbox_mcp"; + private const string DefaultToolboxApiVersion = "v1"; + private const string DefaultDocsServerLabel = "microsoft_docs"; + private const string DefaultWebSearchToolName = "web_search"; + + public static async Task Main(string[] args) + { + // Initialize configuration + IConfiguration configuration = Application.InitializeConfig(); + Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint)); + string toolboxName = configuration[ToolboxNameSetting] ?? DefaultToolboxName; + string toolboxApiVersion = configuration[ToolboxApiVersionSetting] ?? DefaultToolboxApiVersion; + string docsServerLabel = configuration[DocsServerLabelSetting] ?? DefaultDocsServerLabel; + string webSearchToolName = configuration[WebSearchToolNameSetting] ?? DefaultWebSearchToolName; + + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + DefaultAzureCredential credential = new(); + + // Ensure sample toolbox and agent exist in Foundry + string toolboxEndpoint = await CreateSampleToolboxAsync(toolboxName, docsServerLabel, foundryEndpoint, credential); + string toolboxMcpServerUrl = BuildToolboxMcpServerUrl(toolboxEndpoint, toolboxName, toolboxApiVersion); + IConfiguration workflowConfiguration = new ConfigurationBuilder() + .AddConfiguration(configuration) + .AddInMemoryCollection(new Dictionary + { + [ToolboxMcpServerUrlSetting] = toolboxMcpServerUrl, + [DocsServerLabelSetting] = docsServerLabel, + [WebSearchToolNameSetting] = webSearchToolName, + }) + .Build(); + + await CreateAgentAsync(foundryEndpoint, configuration, credential); + + // Get input from command line or console + string workflowInput = Application.GetInput(args); + + // Create the MCP tool handler for invoking the Foundry toolbox MCP proxy. + ConcurrentBag createdHttpClients = []; + DefaultMcpToolHandler mcpToolHandler = new( + httpClientProvider: async (serverUrl, _) => + { + await Task.CompletedTask.ConfigureAwait(false); + + if (!string.Equals(serverUrl, toolboxMcpServerUrl, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + FoundryToolboxBearerTokenHandler handler = new(credential) + { + InnerHandler = new HttpClientHandler() + }; + HttpClient httpClient = new(handler); + createdHttpClients.Add(httpClient); + return httpClient; + }); + + try + { + // Create the workflow factory with MCP tool provider + WorkflowFactory workflowFactory = new("InvokeFoundryToolboxMcp.yaml", foundryEndpoint) + { + Configuration = workflowConfiguration, + McpToolHandler = mcpToolHandler + }; + + // Execute the workflow + WorkflowRunner runner = new() { UseJsonCheckpoints = true }; + await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); + } + finally + { + // Clean up connections and dispose created HttpClients + await mcpToolHandler.DisposeAsync(); + + foreach (HttpClient httpClient in createdHttpClients) + { + httpClient.Dispose(); + } + } + } + + private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration, TokenCredential credential) + { + AIProjectClient aiProjectClient = new(foundryEndpoint, credential); + + await aiProjectClient.CreateAgentAsync( + agentName: "FoundryToolboxMcpAgent", + agentDefinition: DefineToolboxAgent(configuration), + agentDescription: "Summarizes Foundry toolbox MCP tool results"); + } + + private static DeclarativeAgentDefinition DefineToolboxAgent(IConfiguration configuration) + { + return new DeclarativeAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel)) + { + Instructions = + """ + You are a helpful assistant that explains results produced by tools exposed through a Foundry toolbox. + The conversation history contains output from BOTH a Microsoft Learn documentation search (MCP) and a Foundry web search. + Synthesize an answer that draws on both sources, calls out where they agree or differ, and notes which toolbox tool produced each fact when it is relevant. + Be concise. + """ + }; + } + + private static async Task CreateSampleToolboxAsync(string name, string serverLabel, Uri foundryEndpoint, TokenCredential credential) + { + AgentAdministrationClientOptions options = new(); + options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall); + AgentAdministrationClient adminClient = new(foundryEndpoint, credential, options); + AgentToolboxes toolboxClient = adminClient.GetAgentToolboxes(); + + try + { + await toolboxClient.DeleteToolboxAsync(name); + Console.WriteLine($"Deleted existing toolbox '{name}'"); + } + catch (ClientResultException ex) when (ex.Status == 404) + { + // Toolbox does not exist. + } + + ProjectsAgentTool webTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateWebSearchTool()); + + ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool( + serverLabel: serverLabel, + serverUri: new Uri("https://learn.microsoft.com/api/mcp"), + toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval))); + + ToolboxVersion created = (await toolboxClient.CreateToolboxVersionAsync( + name: name, + tools: [webTool, mcpTool], + description: "Sample toolbox combining Foundry web search with the Microsoft Learn MCP tools for the declarative InvokeFoundryToolboxMcp sample.")).Value; + + Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))"); + + return $"{foundryEndpoint.ToString().TrimEnd('/')}/toolboxes"; + } + + private static string BuildToolboxMcpServerUrl(string toolboxEndpoint, string toolboxName, string apiVersion) => + $"{toolboxEndpoint.TrimEnd('/')}/{toolboxName}/mcp?api-version={Uri.EscapeDataString(apiVersion)}"; + + private sealed class FoundryToolboxBearerTokenHandler(TokenCredential credential) : DelegatingHandler + { + private static readonly TokenRequestContext s_tokenContext = + new(["https://ai.azure.com/.default"]); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + AccessToken token = await credential.GetTokenAsync(s_tokenContext, cancellationToken); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + + return await base.SendAsync(request, cancellationToken); + } + } + + private sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy + { + private const string FeatureHeader = "Foundry-Features"; + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Add(FeatureHeader, feature); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Add(FeatureHeader, feature); + return ProcessNextAsync(message, pipeline, currentIndex); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs index 681cd5dc85..66da428cf6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs @@ -3,12 +3,15 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; using System.Net.Http; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -24,6 +27,14 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp; /// public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable { + /// + /// Reserved toolName value that maps an request + /// to the MCP protocol tools/list discovery operation. + /// + public const string ListToolsToolName = "tools/list"; + + private static readonly JsonWriterOptions s_toolListJsonWriterOptions = new() { Indented = true }; + private readonly Func>? _httpClientProvider; private readonly Dictionary _clients = []; private readonly Dictionary _ownedHttpClients = []; @@ -53,9 +64,18 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable CancellationToken cancellationToken = default) { // TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore - McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString()); + if (IsListToolsToolName(toolName)) + { + ThrowIfListToolsArgumentsSpecified(arguments); + McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false); + IList tools = await listToolsClient.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + return CreateListToolsResultContent(tools.Select(tool => tool.ProtocolTool)); + } + McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false); + McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString()); + // Convert IDictionary to IReadOnlyDictionary for CallToolAsync IReadOnlyDictionary? readOnlyArguments = arguments is null ? null @@ -72,6 +92,23 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable return resultContent; } + internal static bool IsListToolsToolName(string toolName) => + string.Equals(toolName, ListToolsToolName, StringComparison.Ordinal); + + internal static McpServerToolResultContent CreateListToolsResultContent(IEnumerable tools) + { + Throw.IfNull(tools); + + McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString()) + { + Outputs = [] + }; + + resultContent.Outputs.Add(new TextContent(SerializeToolsList(tools))); + + return resultContent; + } + /// public async ValueTask DisposeAsync() { @@ -183,6 +220,16 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable return hashCode.ToString(CultureInfo.InvariantCulture); } + private static void ThrowIfListToolsArgumentsSpecified(IDictionary? arguments) + { + if (arguments is { Count: > 0 }) + { + throw new ArgumentException( + $"The reserved MCP '{ListToolsToolName}' operation does not accept tool arguments.", + nameof(arguments)); + } + } + private static void PopulateResultContent(McpServerToolResultContent resultContent, CallToolResult result) { // Ensure Outputs list is initialized @@ -230,6 +277,17 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable TextContentBlock text => new TextContent(text.Text), ImageContentBlock image => CreateDataContent(image.Data, image.MimeType ?? "image/*"), AudioContentBlock audio => CreateDataContent(audio.Data, audio.MimeType ?? "audio/*"), + EmbeddedResourceBlock embedded => ConvertEmbeddedResource(embedded), + _ => new TextContent(block.ToString() ?? string.Empty), + }; + } + + private static AIContent ConvertEmbeddedResource(EmbeddedResourceBlock block) + { + return block.Resource switch + { + TextResourceContents text => new TextContent(text.Text), + BlobResourceContents blob => CreateDataContent(blob.Blob, blob.MimeType ?? "application/octet-stream"), _ => new TextContent(block.ToString() ?? string.Empty), }; } @@ -255,4 +313,39 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable return new DataContent($"data:{mediaType};base64,{base64}", mediaType); } + + private static string SerializeToolsList(IEnumerable tools) + { + using MemoryStream stream = new(); + using (Utf8JsonWriter writer = new(stream, s_toolListJsonWriterOptions)) + { + writer.WriteStartObject(); + writer.WriteStartArray("tools"); + + foreach (Tool tool in tools) + { + writer.WriteStartObject(); + writer.WriteString("name", tool.Name); + writer.WriteString("description", tool.Description); + writer.WritePropertyName("inputSchema"); + tool.InputSchema.WriteTo(writer); + writer.WritePropertyName("outputSchema"); + if (tool.OutputSchema is JsonElement outputSchema) + { + outputSchema.WriteTo(writer); + } + else + { + writer.WriteNullValue(); + } + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.GetBuffer(), 0, (int)stream.Length); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs index abfa95cc36..f9cb5cdb56 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Net.Http; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -320,6 +321,92 @@ public sealed class DefaultMcpToolHandlerTests #endregion + #region Reserved Tools/List Tests + + [Fact] + public void IsListToolsToolName_WithReservedName_ShouldReturnTrue() + { + // Act + bool result = DefaultMcpToolHandler.IsListToolsToolName(DefaultMcpToolHandler.ListToolsToolName); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void IsListToolsToolName_WithRegularToolName_ShouldReturnFalse() + { + // Act + bool result = DefaultMcpToolHandler.IsListToolsToolName("search"); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task InvokeToolAsync_WithListToolsArguments_ShouldThrowArgumentExceptionAsync() + { + // Arrange + DefaultMcpToolHandler handler = new(); + + try + { + // Act + Func act = async () => await handler.InvokeToolAsync( + serverUrl: "http://localhost:12345/mcp", + serverLabel: "test", + toolName: DefaultMcpToolHandler.ListToolsToolName, + arguments: new Dictionary { ["ignored"] = true }, + headers: null, + connectionName: null); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*does not accept tool arguments*"); + } + finally + { + await handler.DisposeAsync(); + } + } + + [Fact] + public async Task CreateListToolsResultContent_WithTools_ShouldSerializeToolMetadataAsync() + { + // Arrange + JsonElement inputSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "query": { + "type": "string" + } + }, + "required": [ "query" ] + } + """); + Tool tool = new() + { + Name = "search", + Description = "Searches documentation.", + InputSchema = inputSchema + }; + + // Act + McpServerToolResultContent result = DefaultMcpToolHandler.CreateListToolsResultContent([tool]); + + // Assert + TextContent text = result.Outputs.Should().ContainSingle().Subject.Should().BeOfType().Subject; + using JsonDocument document = JsonDocument.Parse(text.Text); + JsonElement listedTool = document.RootElement.GetProperty("tools")[0]; + listedTool.GetProperty("name").GetString().Should().Be("search"); + listedTool.GetProperty("description").GetString().Should().Be("Searches documentation."); + listedTool.GetProperty("inputSchema").GetProperty("properties").GetProperty("query").GetProperty("type").GetString().Should().Be("string"); + } + + #endregion + #region Interface Implementation Tests [Fact] @@ -488,5 +575,75 @@ public sealed class DefaultMcpToolHandlerTests dataContent.MediaType.Should().Be("audio/*"); } + [Fact] + public void ConvertContentBlock_EmbeddedResourceBlock_WithTextResource_ShouldReturnTextContent() + { + // Arrange + EmbeddedResourceBlock block = new() + { + Resource = new TextResourceContents + { + Text = "embedded text payload", + Uri = "resource://example", + MimeType = "text/plain", + }, + }; + + // Act + AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); + + // Assert + result.Should().BeOfType() + .Which.Text.Should().Be("embedded text payload"); + } + + [Fact] + public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_ShouldReturnDataContent() + { + // Arrange + byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA"); + EmbeddedResourceBlock block = new() + { + Resource = new BlobResourceContents + { + Blob = new ReadOnlyMemory(base64Bytes), + Uri = "resource://example.bin", + MimeType = "application/zip", + }, + }; + + // Act + AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); + + // Assert + DataContent dataContent = result.Should().BeOfType().Subject; + dataContent.MediaType.Should().Be("application/zip"); + dataContent.Uri.Should().Be("data:application/zip;base64,UklGRiQA"); + } + + [Fact] + public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_NullMimeType_DefaultsToOctetStream() + { + // Arrange + byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA"); + EmbeddedResourceBlock block = new() + { + Resource = new BlobResourceContents + { + Blob = new ReadOnlyMemory(base64Bytes), + Uri = "resource://example.bin", + MimeType = null!, + }, + }; + + // Act + AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); + + // Assert + DataContent dataContent = result.Should().BeOfType().Subject; + dataContent.MediaType.Should().Be("application/octet-stream"); + dataContent.Uri.Should().Be("data:application/octet-stream;base64,UklGRiQA"); + } + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs index a1337b3e2d..d047badaf7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs @@ -432,6 +432,44 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl VerifyInvocationEvent(events); } + [Fact] + public async Task InvokeMcpToolExecuteWithReservedListToolsNameAsync() + { + // Arrange + this.State.InitializeSystem(); + const string ListToolsToolName = "tools/list"; + string? capturedToolName = null; + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithReservedListToolsNameAsync), + serverUrl: TestServerUrl, + toolName: ListToolsToolName); + Mock mockProvider = new(); + mockProvider.Setup(provider => provider.InvokeToolAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, IDictionary?, string?, CancellationToken>( + (_, _, toolName, _, _, _, _) => capturedToolName = toolName) + .ReturnsAsync(new McpServerToolResultContent("list-tools-call-id") + { + Outputs = [new TextContent("{\"tools\":[]}")] + }); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false); + + // Assert + VerifyModel(model, action); + VerifyInvocationEvent(events); + Assert.Equal(ListToolsToolName, capturedToolName); + } + [Fact] public async Task InvokeMcpToolExecuteWithMultipleContentTypesAsync() { From 8058fb1c5b4ff6228e7f72893fa81a3fa7d2f952 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 11:36:42 -0400 Subject: [PATCH 10/65] .NET: Fix flaky InputWaiter_WaitForInputAsync_BlocksUntilSignaledAsync (#5835) * test: remove finite timeout in BlocksUntilSignaledAsync to fix race Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/962b7404-4266-4a16-906c-ba3e607c2764 Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * address review: clarify comment, add timeout test, cross-reference test names Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/e406a5f2-ad31-4d37-b090-69e10713f885 Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> Co-authored-by: Jacob Alber --- .../InputWaiterAndOutputFilterTests.cs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterAndOutputFilterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterAndOutputFilterTests.cs index dead5454b4..c7c231c63e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterAndOutputFilterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterAndOutputFilterTests.cs @@ -34,7 +34,13 @@ public sealed class InputWaiterTests : IDisposable [Fact] public async Task InputWaiter_WaitForInputAsync_BlocksUntilSignaledAsync() { - Task waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(5)); + // Use the no-timeout overload so that the wait can only be released by SignalInput. + // A finite timeout would make this test's logic racy: the component correctly + // honors the timeout, but if the test thread is starved of CPU time (CI load, + // GC pause) long enough for the timeout to fire, waitTask completes before + // SignalInput is called and the "should not complete before signaled" assertion + // flakes. Timeout behavior is covered separately below. + Task waitTask = this._waiter.WaitForInputAsync(CancellationToken.None); Task completedBeforeSignal = await Task.WhenAny(waitTask, Task.Delay(100)); completedBeforeSignal.Should().NotBeSameAs( @@ -100,6 +106,21 @@ public sealed class InputWaiterTests : IDisposable this._waiter.SignalInput(); await this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(1)); } + + [Fact] + public async Task InputWaiter_WaitForInputAsync_CompletesWhenTimeoutExpiresAsync() + { + // Verify that a finite timeout releases the block even without a signal. + // We only assert that it *does* complete (within a generous outer bound); + // we intentionally do not assert that it stays blocked until the timeout, + // because that would re-introduce the same wall-clock flakiness + // described in BlocksUntilSignaledAsync (see comment on that test). + Task waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromMilliseconds(300)); + + Task completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(5))); + completed.Should().BeSameAs(waitTask, "the wait task should complete once the timeout expires"); + await waitTask; + } } public class OutputFilterTests From 190ca75b6a7785dd7a3d0646056297f12c8664d5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 12:23:41 -0400 Subject: [PATCH 11/65] .NET: Add Workflow Builder Specialized Edge tests (#5826) * Add workflow builder edge tests Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/3c3d5324-cdcd-4a38-8c67-94e4e78e29c5 Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Strengthen workflow edge helper tests Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Normalize edge helper bad input validation Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Clarify edge helper target validation Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Use explicit target parameter names Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Document workflow edge test helpers Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Clarify null element validation messages Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Add repeated chain executor coverage Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Preserve Throw helper validation style Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Cover empty switch case targets Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Relax builder null assertion parameter checks Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Inline ValidateTargets into call sites Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/cb9a6a6a-02c7-41a8-a4b4-da16ad62ef86 Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Refactor ForwardExcept with TFM-specialized TryGetNonEnumeratedCount Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/b081f61f-93ce-45dc-abbd-82c465395470 Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Use TFM-specialized count check: TryGetNonEnumeratedCount for NET6+, ICollection pattern for NETFX Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/8ec28a43-e7b7-456e-8d8e-921511b4accc Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Apply TFM-specialized count check to ForwardMessage as well Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/9238ea32-a3e8-4b83-9683-484ad400071f Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Address review feedback: simplify Throw.IfNull in SwitchBuilder per westey-m suggestion Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/299950fd-4457-47f3-a373-f65d601b7ea5 Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Use indexed parameter name in SwitchBuilder Throw.IfNull: executors[index] Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/c5655707-5b0b-44f3-98a9-5f3961e32cfe Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Revert #if NET6_0_OR_GREATER back to #if NET; inline executorIndex++ Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/c5655707-5b0b-44f3-98a9-5f3961e32cfe Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> * Add comment explaining unusual Throw.IfNull use for null elements inside collection Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/c5655707-5b0b-44f3-98a9-5f3961e32cfe Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> Co-authored-by: Jacob Alber --- .../SwitchBuilder.cs | 9 + .../WorkflowBuilderExtensions.cs | 29 +- .../WorkflowBuilderSmokeTests.cs | 298 ++++++++++++++++++ 3 files changed, 330 insertions(+), 6 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs index 14e6ed4f7c..66ac3e6908 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs @@ -36,9 +36,13 @@ public sealed class SwitchBuilder Throw.IfNull(executors); HashSet indicies = []; + int executorIndex = 0; foreach (ExecutorBinding executor in executors) { + // Explicit name: null element inside the collection argument. + Throw.IfNull(executor, $"{nameof(executors)}[{executorIndex++}]"); + if (!this._executorIndicies.TryGetValue(executor.Id, out int index)) { index = this._executors.Count; @@ -64,8 +68,13 @@ public sealed class SwitchBuilder { Throw.IfNull(executors); + int executorIndex = 0; + foreach (ExecutorBinding executor in executors) { + // Explicit name: null element inside the collection argument. + Throw.IfNull(executor, $"{nameof(executors)}[{executorIndex++}]"); + if (!this._executorIndicies.TryGetValue(executor.Id, out int index)) { index = this._executors.Count; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs index c702cf9ece..a22aa8e722 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs @@ -25,7 +25,11 @@ public static class WorkflowBuilderExtensions /// The target executor to which messages will be forwarded. /// The updated instance. public static WorkflowBuilder ForwardMessage(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding target) - => builder.ForwardMessage(source, [target], condition: null); + { + Throw.IfNull(target, nameof(target)); + + return builder.ForwardMessage(source, [target], condition: null); + } /// /// Adds edges to the workflow that forward messages of the specified type from the source executor to @@ -52,6 +56,8 @@ public static class WorkflowBuilderExtensions /// The updated instance. public static WorkflowBuilder ForwardMessage(this WorkflowBuilder builder, ExecutorBinding source, IEnumerable targets, Func? condition = null) { + Throw.IfNull(builder); + Throw.IfNull(source); Throw.IfNull(targets); Func predicate = WorkflowBuilder.CreateConditionFunc(IsAllowedTypeAndMatchingCondition)!; @@ -62,14 +68,16 @@ public static class WorkflowBuilderExtensions if (targets is ICollection { Count: 1 }) #endif { - return builder.AddEdge(source, targets.First(), predicate); + return builder.AddEdge(source, Throw.IfNull(targets.First(), nameof(targets)), predicate); } - return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets)); + return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets.Select(ValidateTarget))); // The reason we can check for "not null" here is that CreateConditionFunc will do the correct unwrapping // logic for PortableValues. bool IsAllowedTypeAndMatchingCondition(TMessage? message) => message != null && (condition == null || condition(message)); + + ExecutorBinding ValidateTarget(ExecutorBinding target) => Throw.IfNull(target, nameof(targets)); } /// @@ -81,7 +89,11 @@ public static class WorkflowBuilderExtensions /// The target executor to which messages, except those of type , will be forwarded. /// The updated instance with the added edges. public static WorkflowBuilder ForwardExcept(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding target) - => builder.ForwardExcept(source, [target]); + { + Throw.IfNull(target, nameof(target)); + + return builder.ForwardExcept(source, [target]); + } /// /// Adds edges from the specified source to the provided executors, excluding messages of a specified type. @@ -93,6 +105,8 @@ public static class WorkflowBuilderExtensions /// The updated instance with the added edges. public static WorkflowBuilder ForwardExcept(this WorkflowBuilder builder, ExecutorBinding source, IEnumerable targets) { + Throw.IfNull(builder); + Throw.IfNull(source); Throw.IfNull(targets); Func predicate = WorkflowBuilder.CreateConditionFunc((Func)IsAllowedType)!; @@ -103,14 +117,16 @@ public static class WorkflowBuilderExtensions if (targets is ICollection { Count: 1 }) #endif { - return builder.AddEdge(source, targets.First(), predicate); + return builder.AddEdge(source, Throw.IfNull(targets.First(), nameof(targets)), predicate); } - return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets)); + return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets.Select(ValidateTarget))); // The reason we can check for "null" here is that CreateConditionFunc will do the correct unwrapping // logic for PortableValues. static bool IsAllowedType(object? message) => message is null; + + ExecutorBinding ValidateTarget(ExecutorBinding target) => Throw.IfNull(target, nameof(targets)); } /// @@ -129,6 +145,7 @@ public static class WorkflowBuilderExtensions { Throw.IfNull(builder); Throw.IfNull(source); + Throw.IfNull(executors); HashSet seenExecutors = [source.Id]; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs index 2b370de99e..c2b855b8bf 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using FluentAssertions; namespace Microsoft.Agents.AI.Workflows.UnitTests; @@ -157,4 +158,301 @@ public partial class WorkflowBuilderSmokeTests workflow3.Name.Should().Be("Named Only"); workflow3.Description.Should().BeNull(); } + + [Fact] + public void ForwardMessage_WithSingleTarget_CreatesDirectEdge() + { + // Arrange + NoOpExecutor source = new("start"); + NoOpExecutor target = new("target"); + + // Act + Workflow workflow = new WorkflowBuilder(source.Id) + .ForwardMessage(source, target) + .Build(); + + // Assert + Edge edge = GetSingleEdge(workflow, source.Id); + edge.Kind.Should().Be(EdgeKind.Direct); + edge.DirectEdgeData.Should().NotBeNull(); + edge.DirectEdgeData!.SourceId.Should().Be(source.Id); + edge.DirectEdgeData!.SinkId.Should().Be(target.Id); + edge.DirectEdgeData.Condition.Should().NotBeNull(); + edge.DirectEdgeData.Condition!("message").Should().BeTrue(); + edge.DirectEdgeData.Condition!(42).Should().BeFalse(); + edge.DirectEdgeData.Condition!(null).Should().BeFalse(); + } + + [Fact] + public void ForwardMessage_WithMultipleTargets_CreatesFanOutEdge() + { + // Arrange + NoOpExecutor source = new("start"); + NoOpExecutor target1 = new("target1"); + NoOpExecutor target2 = new("target2"); + + // Act + Workflow workflow = new WorkflowBuilder(source.Id) + .ForwardMessage(source, [target1, target2], message => message == "match") + .Build(); + + // Assert + Edge edge = GetSingleEdge(workflow, source.Id); + edge.Kind.Should().Be(EdgeKind.FanOut); + edge.FanOutEdgeData.Should().NotBeNull(); + edge.FanOutEdgeData!.SourceId.Should().Be(source.Id); + edge.FanOutEdgeData!.SinkIds.Should().Equal([target1.Id, target2.Id]); + edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull(); + edge.FanOutEdgeData.EdgeAssigner!("match", 2).Should().Equal([0, 1]); + edge.FanOutEdgeData.EdgeAssigner!("other", 2).Should().BeEmpty(); + edge.FanOutEdgeData.EdgeAssigner!(42, 2).Should().BeEmpty(); + } + + [Fact] + public void ForwardExcept_WithSingleTarget_CreatesDirectEdge() + { + // Arrange + NoOpExecutor source = new("start"); + NoOpExecutor target = new("target"); + + // Act + Workflow workflow = new WorkflowBuilder(source.Id) + .ForwardExcept(source, target) + .Build(); + + // Assert + Edge edge = GetSingleEdge(workflow, source.Id); + edge.Kind.Should().Be(EdgeKind.Direct); + edge.DirectEdgeData.Should().NotBeNull(); + edge.DirectEdgeData!.SourceId.Should().Be(source.Id); + edge.DirectEdgeData!.SinkId.Should().Be(target.Id); + edge.DirectEdgeData.Condition.Should().NotBeNull(); + edge.DirectEdgeData.Condition!("message").Should().BeFalse(); + edge.DirectEdgeData.Condition!(42).Should().BeTrue(); + edge.DirectEdgeData.Condition!(null).Should().BeTrue(); + } + + [Fact] + public void ForwardExcept_WithMultipleTargets_CreatesFanOutEdge() + { + // Arrange + NoOpExecutor source = new("start"); + NoOpExecutor target1 = new("target1"); + NoOpExecutor target2 = new("target2"); + + // Act + Workflow workflow = new WorkflowBuilder(source.Id) + .ForwardExcept(source, [target1, target2]) + .Build(); + + // Assert + Edge edge = GetSingleEdge(workflow, source.Id); + edge.Kind.Should().Be(EdgeKind.FanOut); + edge.FanOutEdgeData.Should().NotBeNull(); + edge.FanOutEdgeData!.SourceId.Should().Be(source.Id); + edge.FanOutEdgeData!.SinkIds.Should().Equal([target1.Id, target2.Id]); + edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull(); + edge.FanOutEdgeData.EdgeAssigner!(42, 2).Should().Equal([0, 1]); + edge.FanOutEdgeData.EdgeAssigner!("message", 2).Should().BeEmpty(); + } + + [Fact] + public void AddChain_CreatesSequentialDirectEdges() + { + // Arrange + NoOpExecutor source = new("start"); + NoOpExecutor middle = new("middle"); + NoOpExecutor end = new("end"); + + // Act + Workflow workflow = new WorkflowBuilder(source.Id) + .AddChain(source, [middle, end]) + .Build(); + + // Assert + Edge firstEdge = GetSingleEdge(workflow, source.Id); + firstEdge.Kind.Should().Be(EdgeKind.Direct); + firstEdge.DirectEdgeData!.SourceId.Should().Be(source.Id); + firstEdge.DirectEdgeData.SinkId.Should().Be(middle.Id); + + Edge secondEdge = GetSingleEdge(workflow, middle.Id); + secondEdge.Kind.Should().Be(EdgeKind.Direct); + secondEdge.DirectEdgeData!.SourceId.Should().Be(middle.Id); + secondEdge.DirectEdgeData.SinkId.Should().Be(end.Id); + } + + [Fact] + public void AddChain_WhenExecutorRepeats_Throws() + { + // Arrange + NoOpExecutor source = new("start"); + NoOpExecutor middle = new("middle"); + + // Act + Action act = () => new WorkflowBuilder(source.Id) + .AddChain(source, [middle, source]); + + // Assert + act.Should().Throw() + .WithParameterName("executors"); + } + + [Fact] + public void AddExternalCall_CreatesRequestPortAndRoundTripEdges() + { + // Arrange + const string PortId = "port1"; + NoOpExecutor source = new("start"); + + // Act + Workflow workflow = new WorkflowBuilder(source.Id) + .AddExternalCall(source, PortId) + .Build(); + + // Assert + workflow.Ports.Should().ContainKey(PortId); + workflow.Ports[PortId].Request.Should().Be(typeof(string)); + workflow.Ports[PortId].Response.Should().Be(typeof(int)); + workflow.ExecutorBindings.Should().ContainKey(PortId); + + Edge requestEdge = GetSingleEdge(workflow, source.Id); + requestEdge.Kind.Should().Be(EdgeKind.Direct); + requestEdge.DirectEdgeData!.SourceId.Should().Be(source.Id); + requestEdge.DirectEdgeData.SinkId.Should().Be(PortId); + + Edge responseEdge = GetSingleEdge(workflow, PortId); + responseEdge.Kind.Should().Be(EdgeKind.Direct); + responseEdge.DirectEdgeData!.SourceId.Should().Be(PortId); + responseEdge.DirectEdgeData.SinkId.Should().Be(source.Id); + } + + [Fact] + public void AddSwitch_CreatesFanOutEdgeWithCasesAndDefault() + { + // Arrange + NoOpExecutor source = new("start"); + NoOpExecutor stringTarget = new("string-target"); + NoOpExecutor intTarget = new("int-target"); + NoOpExecutor defaultTarget = new("default-target"); + + // Act + Workflow workflow = new WorkflowBuilder(source.Id) + .AddSwitch(source, switchBuilder => switchBuilder + .AddCase(message => message == "match", [stringTarget]) + .AddCase(message => message > 0, [intTarget]) + .WithDefault([defaultTarget])) + .Build(); + + // Assert + Edge edge = GetSingleEdge(workflow, source.Id); + edge.Kind.Should().Be(EdgeKind.FanOut); + edge.FanOutEdgeData.Should().NotBeNull(); + edge.FanOutEdgeData!.SourceId.Should().Be(source.Id); + edge.FanOutEdgeData!.SinkIds.Should().Equal([stringTarget.Id, intTarget.Id, defaultTarget.Id]); + edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull(); + edge.FanOutEdgeData.EdgeAssigner!("match", 3).Should().Equal([0]); + edge.FanOutEdgeData.EdgeAssigner!(2, 3).Should().Equal([1]); + edge.FanOutEdgeData.EdgeAssigner!("other", 3).Should().Equal([2]); + } + + [Fact] + public void ForwardMessage_InvalidArguments_Throw() + { + // Arrange + WorkflowBuilder builder = new("start"); + NoOpExecutor source = new("start"); + NoOpExecutor target = new("target"); + + // Act/Assert + Assert.Throws(() => ((WorkflowBuilder)null!).ForwardMessage(source, target)); + Assert.Throws("source", () => builder.ForwardMessage(null!, target)); + Assert.Throws("target", () => builder.ForwardMessage(source, (ExecutorBinding)null!)); + Assert.Throws("targets", () => builder.ForwardMessage(source, (IEnumerable)null!)); + Assert.Throws("targets", () => builder.ForwardMessage(source, [target, null!])); + Assert.Throws("targets", () => builder.ForwardMessage(source, [])); + } + + [Fact] + public void ForwardExcept_InvalidArguments_Throw() + { + // Arrange + WorkflowBuilder builder = new("start"); + NoOpExecutor source = new("start"); + NoOpExecutor target = new("target"); + + // Act/Assert + Assert.Throws(() => ((WorkflowBuilder)null!).ForwardExcept(source, target)); + Assert.Throws("source", () => builder.ForwardExcept(null!, target)); + Assert.Throws("target", () => builder.ForwardExcept(source, (ExecutorBinding)null!)); + Assert.Throws("targets", () => builder.ForwardExcept(source, (IEnumerable)null!)); + Assert.Throws("targets", () => builder.ForwardExcept(source, [target, null!])); + Assert.Throws("targets", () => builder.ForwardExcept(source, [])); + } + + [Fact] + public void AddChain_InvalidArguments_Throw() + { + // Arrange + WorkflowBuilder builder = new("start"); + NoOpExecutor source = new("start"); + NoOpExecutor target = new("target"); + NoOpExecutor otherTarget = new("other-target"); + + // Act/Assert + Assert.Throws(() => ((WorkflowBuilder)null!).AddChain(source, [target])); + Assert.Throws("source", () => builder.AddChain(null!, [target])); + Assert.Throws("executors", () => builder.AddChain(source, null!)); + Assert.Throws("executors", () => builder.AddChain(source, [target, null!])); + Assert.Throws("executors", () => builder.AddChain(source, [target, source])); + Assert.Throws("executors", () => builder.AddChain(source, [target, otherTarget, target])); + } + + [Fact] + public void AddExternalCall_InvalidArguments_Throw() + { + // Arrange + WorkflowBuilder builder = new("start"); + NoOpExecutor source = new("start"); + + // Act/Assert + Assert.Throws(() => ((WorkflowBuilder)null!).AddExternalCall(source, "port")); + Assert.Throws("source", () => builder.AddExternalCall(null!, "port")); + Assert.Throws("portId", () => builder.AddExternalCall(source, null!)); + } + + [Fact] + public void AddSwitch_InvalidArguments_Throw() + { + // Arrange + WorkflowBuilder builder = new("start"); + NoOpExecutor source = new("start"); + + // Act/Assert + Assert.Throws(() => ((WorkflowBuilder)null!).AddSwitch(source, _ => { })); + Assert.Throws("source", () => builder.AddSwitch(null!, _ => { })); + Assert.Throws("configureSwitch", () => builder.AddSwitch(source, null!)); + Assert.Throws("targets", () => builder.AddSwitch(source, _ => { })); + Assert.Throws("targets", () => builder.AddSwitch(source, switchBuilder => switchBuilder.AddCase(_ => true, []))); + } + + [Fact] + public void SwitchBuilder_InvalidArguments_Throw() + { + // Arrange + SwitchBuilder switchBuilder = new(); + NoOpExecutor target = new("target"); + + // Act/Assert + Assert.Throws("predicate", () => switchBuilder.AddCase(null!, [target])); + Assert.Throws("executors", () => switchBuilder.AddCase(_ => true, null!)); + Assert.Throws("executors[1]", () => switchBuilder.AddCase(_ => true, [target, null!])); + Assert.Throws("executors", () => switchBuilder.WithDefault(null!)); + Assert.Throws("executors[1]", () => switchBuilder.WithDefault([target, null!])); + } + + /// + /// Gets the only edge emitted by the specified workflow source. + /// + private static Edge GetSingleEdge(Workflow workflow, string sourceId) + => workflow.Edges[sourceId].Should().ContainSingle().Subject; } From 3256550c5503daef28510d9cbf3f6662f5c1c86c Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Fri, 15 May 2026 01:10:27 +0800 Subject: [PATCH 12/65] .NET: fix: allow naming handoff workflows (#5799) * fix: allow naming handoff workflows * Only set name/description if not NullOrWhitespace Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Jacob Alber Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jacob Alber --- .../HandoffWorkflowBuilder.cs | 27 ++++++++++++++++++- ...plicationBuilderWorkflowExtensionsTests.cs | 24 +++++++++++++++++ .../HandoffOrchestrationTests.cs | 17 ++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs index 00e030448f..7142faad0b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs @@ -54,6 +54,8 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl private bool _emitAgentResponseUpdateEvents; private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly; private bool _returnToPrevious; + private string? _name; + private string? _description; /// /// Initializes a new instance of the class with no handoff relationships. @@ -97,6 +99,20 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl return (TBuilder)this; } + /// + public TBuilder WithName(string name) + { + this._name = name; + return (TBuilder)this; + } + + /// + public TBuilder WithDescription(string description) + { + this._description = description; + return (TBuilder)this; + } + /// /// Sets a value indicating whether agent streaming update events should be emitted during execution. /// If , the value will be taken from the @@ -330,7 +346,16 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl builder.AddEdge(start, executors[this._initialAgent.Id]); } - // Build the workflow. + if (!string.IsNullOrWhiteSpace(this._name)) + { + builder.WithName(this._name); + } + + if (!string.IsNullOrWhiteSpace(this._description)) + { + builder.WithDescription(this._description); + } + return builder.WithOutputFrom(end).Build(); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs index 1c5649d17c..c17655bd29 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs @@ -103,6 +103,30 @@ public class HostApplicationBuilderWorkflowExtensionsTests Assert.Contains(workflowDescriptors, d => (string)d.ServiceKey! == "workflow3"); } + /// + /// Verifies that a handoff workflow can be named from the DI workflow key. + /// + [Fact] + public void AddWorkflow_HandoffWorkflowWithName_ResolvesWorkflow() + { + var builder = new HostApplicationBuilder(); + const string WorkflowName = "handoffWorkflow"; + + var mockAgent = new Mock(); + mockAgent.Setup(a => a.Name).Returns("handoffAgent"); + +#pragma warning disable MAAIW001 // This test covers hosting handoff workflows. + builder.AddWorkflow(WorkflowName, (sp, key) => + AgentWorkflowBuilder.CreateHandoffBuilderWith(mockAgent.Object) + .WithName(key) + .Build()); +#pragma warning restore MAAIW001 + + var workflow = builder.Build().Services.GetRequiredKeyedService(WorkflowName); + + Assert.Equal(WorkflowName, workflow.Name); + } + /// /// Verifies that AddWorkflow handles empty strings for name. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffOrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffOrchestrationTests.cs index deddeb0c79..c8abe4719c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffOrchestrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffOrchestrationTests.cs @@ -86,6 +86,23 @@ public class HandoffOrchestrationTests target.Reason.Should().Be("instructions"); } + [Fact] + public void BuildHandoffs_WithNameAndDescription_SetsWorkflowMetadata() + { + const string WorkflowName = "handoff-workflow"; + const string WorkflowDescription = "A handoff workflow"; + + DoubleEchoAgent agent = new("agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(agent) + .WithName(WorkflowName) + .WithDescription(WorkflowDescription) + .Build(); + + Assert.Equal(WorkflowName, workflow.Name); + Assert.Equal(WorkflowDescription, workflow.Description); + } + [Fact] public async Task Handoffs_NoTransfers_ResponseServedByOriginalAgentAsync() { From 7432105ebea76993a45da072e5c246f3f5f1ca8f Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 14 May 2026 18:58:10 +0100 Subject: [PATCH 13/65] Python: Support list[str] arguments for file-based skill scripts (#5850) Port of .NET PR #5475. Broadens the args type from dict[str, Any] | None to dict[str, Any] | list[str] | None across the skill script API surface, enabling CLI-style argv forwarding to subprocess scripts. Changes: - SkillScript.run(), InlineSkillScript.run(), FileSkillScript.run(): widen args type; InlineSkillScript rejects list with TypeError - FileSkillScript.parameters_schema: returns array-of-strings schema - FileSkill.content: appends block with parameters_schema - SkillScriptRunner protocol: widen args type - SkillsProvider._run_skill_script: widen args type - run_skill_script tool schema: accept object, array, or null - subprocess_script_runner sample: accept list[str], reject dict - class_based_skill sample: fix missing SkillFrontmatter wrapper - Standardize 'folder' to 'directory' in docstrings (#5712) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/_skills.py | 102 ++++++++-- .../packages/core/tests/core/test_skills.py | 183 +++++++++++++++++- .../class_based_skill/class_based_skill.py | 12 +- .../skills/subprocess_script_runner.py | 37 ++-- 4 files changed, 290 insertions(+), 44 deletions(-) diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index 91b583aaab..c1d0c77e45 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -289,13 +289,15 @@ class SkillScript(ABC): return None @abstractmethod - async def run(self, skill: Skill, args: dict[str, Any] | None = None, **kwargs: Any) -> Any: + async def run(self, skill: Skill, args: dict[str, Any] | list[str] | None = None, **kwargs: Any) -> Any: """Run this script. Args: skill: The skill that owns this script. - args: Optional keyword arguments for the script, provided by the - agent/LLM. + args: Optional arguments for the script, provided by the + agent/LLM. May be a ``dict`` (named keyword arguments + for inline scripts) or a ``list[str]`` (positional CLI + arguments for file-based scripts). **kwargs: Runtime keyword arguments forwarded only to script functions that accept ``**kwargs``. @@ -361,19 +363,31 @@ class InlineSkillScript(SkillScript): self._parameters_schema_resolved = True return self._parameters_schema - async def run(self, skill: Skill, args: dict[str, Any] | None = None, **kwargs: Any) -> Any: + async def run(self, skill: Skill, args: dict[str, Any] | list[str] | None = None, **kwargs: Any) -> Any: """Run the script by invoking the callable in-process. Args: skill: The skill that owns this script. args: Optional keyword arguments for the script, provided by the - agent/LLM. + agent/LLM. Must be a ``dict`` or ``None``; passing a + ``list`` raises :class:`TypeError` because inline scripts + bind arguments by keyword name. **kwargs: Runtime keyword arguments forwarded only to script functions that accept ``**kwargs``. Returns: The script execution result. + + Raises: + TypeError: If ``args`` is a ``list`` (array-style arguments + are only supported for file-based scripts). """ + if isinstance(args, list): + raise TypeError( + f"Inline script '{self.name}' requires keyword arguments (dict), " + f"but received a list. Array-style arguments are only supported " + f"for file-based scripts." + ) if self._accepts_kwargs: # noqa: SIM108 result = self.function(**(args or {}), **kwargs) else: @@ -431,13 +445,23 @@ class FileSkillScript(SkillScript): self.full_path = full_path self._runner = runner - async def run(self, skill: Skill, args: dict[str, Any] | None = None, **kwargs: Any) -> Any: + @property + def parameters_schema(self) -> dict[str, Any] | None: + """JSON Schema advertising that file scripts accept a string array. + + Returns a fixed schema ``{"type": "array", "items": {"type": "string"}}`` + so that the LLM knows to pass positional CLI arguments as a JSON array + of strings. + """ + return {"type": "array", "items": {"type": "string"}} + + async def run(self, skill: Skill, args: dict[str, Any] | list[str] | None = None, **kwargs: Any) -> Any: """Run the script by delegating to the configured runner. Args: skill: The skill that owns this script. Must be a :class:`FileSkill`. - args: Optional keyword arguments for the script. + args: Optional arguments for the script. **kwargs: Additional runtime keyword arguments (unused). Returns: @@ -1348,6 +1372,7 @@ class FileSkill(Skill): self.path = path self._resources: list[SkillResource] = list(resources) if resources is not None else [] self._scripts: list[SkillScript] = list(scripts) if scripts is not None else [] + self._cached_content: str | None = None @property def frontmatter(self) -> SkillFrontmatter: @@ -1356,8 +1381,23 @@ class FileSkill(Skill): @property def content(self) -> str: - """The skill content provided at construction time.""" - return self._content + """The skill content with appended scripts block. + + When scripts are present, a ```` XML block is appended + to the raw SKILL.md content so that the LLM can discover each + script's ````. + + The result is cached after the first access. Adding scripts + after the first access will not be reflected. + """ + if self._cached_content is not None: + return self._cached_content + if not self._scripts: + self._cached_content = self._content + else: + script_lines = "\n".join(_create_script_element(s) for s in self._scripts) + self._cached_content = f"{self._content}\n\n\n{script_lines}\n" + return self._cached_content @property def resources(self) -> list[SkillResource]: @@ -1392,7 +1432,9 @@ class SkillScriptRunner(Protocol): satisfies this protocol. """ - def __call__(self, skill: FileSkill, script: FileSkillScript, args: dict[str, Any] | None = None) -> Any: + def __call__( + self, skill: FileSkill, script: FileSkillScript, args: dict[str, Any] | list[str] | None = None + ) -> Any: """Run a skill script. The :class:`SkillsProvider` resolves skill and script names @@ -1402,7 +1444,7 @@ class SkillScriptRunner(Protocol): Args: skill: The file-based skill that owns the script. script: The file-based script to run. - args: Optional keyword arguments for the script. + args: Optional arguments for the script. Returns: The result. May be any type; the framework @@ -1982,7 +2024,7 @@ class SkillsProvider(ContextProvider): if include_script_runner_tool: async def _run_script( - skill_name: str, script_name: str, args: dict[str, Any] | None = None, **kwargs: Any + skill_name: str, script_name: str, args: dict[str, Any] | list[str] | None = None, **kwargs: Any ) -> Any: return await self._run_skill_script(skills, skill_name, script_name, args, **kwargs) @@ -2005,12 +2047,31 @@ class SkillsProvider(ContextProvider): ), }, "args": { - "type": ["object", "null"], - "additionalProperties": True, + "oneOf": [ + { + "type": "object", + "additionalProperties": True, + "description": ( + "Named arguments as key-value pairs " + '(e.g. {"length": 24, "uppercase": true}).' + ), + }, + { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Positional CLI arguments as a string array " + '(e.g. ["input.docx", "--output", "result.idx"]).' + ), + }, + {"type": "null"}, + ], "default": None, "description": ( - "Arguments to pass to the script as key-value pairs. " - "Use parameter names as keys without leading dashes " + "Arguments to pass to the script. " + "Use an array of strings for CLI-style positional arguments " + '(e.g. ["input.docx", "--output", "result.idx"]), ' + "or an object for named parameters " '(e.g. {"length": 24, "uppercase": true}). ' "How these values are mapped to the underlying script " "is determined by the script implementation or configured runner." @@ -2060,7 +2121,7 @@ class SkillsProvider(ContextProvider): skills: Sequence[Skill], skill_name: str, script_name: str, - args: dict[str, Any] | None = None, + args: dict[str, Any] | list[str] | None = None, **kwargs: Any, ) -> Any: """Run a named script from a skill. @@ -2072,9 +2133,8 @@ class SkillsProvider(ContextProvider): skills: The skills to look up the skill from. skill_name: The name of the owning skill. script_name: The script name to look up (case-insensitive). - args: Optional keyword arguments for the script, provided by the - agent/LLM. These are mapped to the function's declared - parameters. + args: Optional arguments for the script, provided by the + agent/LLM. **kwargs: Runtime keyword arguments forwarded only to script functions that accept ``**kwargs`` (e.g. arguments passed via ``agent.run(user_id="123")``). @@ -2254,7 +2314,7 @@ class FileSkillsSource(SkillsSource): Args: skill_paths: One or more directory paths to search for file-based - skills. Each path may point to an individual skill folder + skills. Each path may point to an individual skill directory (containing ``SKILL.md``) or to a parent that contains skill subdirectories. diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py index 30eba73237..c386da2ff3 100644 --- a/python/packages/core/tests/core/test_skills.py +++ b/python/packages/core/tests/core/test_skills.py @@ -3518,7 +3518,6 @@ class TestSkillsProviderFactories: await _init_provider(provider) run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") args_desc = run_tool.parameters()["properties"]["args"]["description"] - assert "without leading dashes" in args_desc assert "script implementation or configured runner" in args_desc async def test_require_script_approval_sets_approval_mode(self) -> None: @@ -4744,12 +4743,16 @@ class TestCreateScriptElement: def test_name_only(self) -> None: s = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/scripts/run.py") elem = _create_script_element(s) - assert elem == '