Compare commits

...
Author SHA1 Message Date
Evan MattsonandGitHub 4b0522d62d Python: Bump Python package versions for a release (#5964)
* Bump Python package versions to 1.5.0 for a release

* Promote orchestrations to 1.0.0rc1

* ci(python-setup): merge dynamic exclude into existing workspace exclude

The python-setup action injected exclude = [...] verbatim into
[tool.uv.workspace], producing a duplicate 'exclude' key when the
section already had a static exclude. Scope the rewrite to the
[tool.uv.workspace] section and append the package to the existing
array when present; idempotent if the package is already excluded.

* Address Copilot review feedback: raise inter-package floors to 1.5.0

- foundry, foundry-local: agent-framework-openai >=1.4.0 -> >=1.5.0
- azure-contentunderstanding: agent-framework-foundry >=1.4.0 -> >=1.5.0
- azurefunctions: pin agent-framework-durabletask to >=1.0.0b260519,<2

Keeps lockstep cohort consistent and avoids mixed 1.4.x / 1.5.0 installs.

* Re-include azurefunctions and durabletask in the uv workspace

The pinned durabletask>=1.4.0 floor is enough to make resolution succeed;
the workspace exclude was over-correction and broke CI samples and pyright
type-checking (re-exports in agent_framework/azure/__init__.pyi plus
samples/04-hosting/{azure_functions,durabletask}/ could not resolve their
imports). Dropping them from agent-framework-core[all] still stands so the
metapackage does not pull them.

* Restore azurefunctions and durabletask in agent-framework-core[all]

The durabletask floor pin keeps users on the safe 1.4.0, so they are once
again included in the metapackage. Update CHANGELOG to reflect the pin
rather than an [all] removal.

* Raise uvicorn ceiling in ag-ui and devui to allow 0.42+

The root override-dependencies pins uvicorn[standard]>=0.34.0 (no upper)
and the workspace lock resolves to 0.47.0. The package ceiling <0.42.0
meant the workspace was no longer testing the declared supported range.
Bump to <1 so the lock fits within the declared bounds.

Also picked up by validate-dependency-bounds: refresh stale orchestrations
RC pin in devui dev deps.
2026-05-20 09:20:53 +09:00
8636c70ddf ci(python-setup): drop -U upgrade flag from uv sync (#5961)
The shared composite action ran `uv sync --all-packages --all-extras
--dev -U` on every job, which upgrades every dependency to the latest
compatible version instead of using the pinned versions in `uv.lock`.

That is currently producing a hard resolver failure on every CI job:

    No solution found when resolving dependencies for split
    (markers: python_full_version >= '3.11' and sys_platform == 'darwin')
    Because there are no versions of durabletask and
    agent-framework-durabletask depends on durabletask>=1.3.0,<2,
    we can conclude that agent-framework-durabletask's requirements
    are unsatisfiable.

Dropping `-U` makes the install use the workspace lockfile, which is
what is reproducible locally and what we publish releases against.
Upgrades should be opt-in (via a scheduled job or a separate workflow)
rather than implicit on every CI run.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 19:33:11 +00:00
westeyandGitHub 61f636ffb8 .NET: Reduce re-rendering in harness console (#5953)
* Reduce re-rendering in harness console

* Address PR comments

* Fix broken merge
2026-05-19 19:10:57 +00:00
westeyandGitHub afcb6b1a00 .NET: Harness code act skill sample (#5930)
* Add sample that shows code execution and skills together

* Use nuget for python module path

* Update readme.

* Fix formatting.

* Reduce flashing in rendering.

* Improve screen clearing for Powershell

* Add a couple of small UX fixes
2026-05-19 15:49:03 +00:00
westeyandGitHub 8ccaf7fb82 Harness Console: Add a factory option for creating custom sessions (#5951) 2026-05-19 15:32:14 +00:00
Taisir HassanandGitHub 3f522a8246 Remove duplicate pop in InMemoryCacheProvider.remove (#5795)
The second self._cache.pop(key, None) call is a guaranteed no-op: the first pop has already removed the key (or returned None), and there is no await between the two statements that could allow another coroutine to re-add it. Removing the dead line clarifies intent without changing behavior.
2026-05-19 14:02:20 +00:00
66a09a76af Python: fix: hyperlight skips symlinks when staging sandbox input (#5919)
* Python: fix(hyperlight): skip symlinks when staging files into the sandbox

The helpers that populate the sandbox input tree (``_copy_path`` and the
``_path_tree_signature`` walker used for cache invalidation) relied on
``Path.is_file()``, ``Path.is_dir()`` and ``shutil.copy2`` - all of which
follow symlinks by default. When the source tree contains symlinks, that
let entries from outside the configured input source surface inside the
sandbox.

Harden both code paths to never follow symlinks:

- ``_copy_path`` now bails out via ``Path.is_symlink()`` before any
  ``is_dir()`` / ``is_file()`` check, skips non-regular files, and uses
  ``shutil.copy2(..., follow_symlinks=False)`` as defense in depth.
- New ``_iter_real_entries`` walker replaces the previous ``Path.rglob``
  call inside ``_path_tree_signature`` (rglob follows directory symlinks).
- ``_path_tree_signature`` switches to ``Path.lstat()`` so size/mtime are
  never read through a symlink target.

Added regression tests covering:

- A pre-placed file symlink in ``workspace_root`` (top level).
- A pre-placed directory symlink in ``workspace_root``.
- A nested file symlink inside a real subdirectory.
- ``_path_tree_signature`` ignoring symlinks so the cache key reflects only
  what is actually staged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python: fix(hyperlight): address PR #5919 review feedback

- _iter_real_entries now yields directories and regular files only,
  skipping non-regular entries (sockets/FIFOs/devices). Keeps the
  cache-key signature consistent with what _copy_path actually stages.
- The four new symlink regression tests skip when the platform does not
  support symlink creation (e.g. unprivileged Windows runners), via a
  local _symlinks_supported helper modelled on the one in
  packages/core/tests/core/test_skills.py. Prevents OSError /
  NotImplementedError from failing CI jobs that have nothing to do with
  the change under test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python: fix(hyperlight): address PR #5919 follow-up review feedback

- _copy_path docstring: narrow the scope to "symlink entries present in
  the source tree at rest" and explicitly call out that the copy is NOT
  atomic with respect to concurrent mutation of the source tree.
  Callers who need that stronger guarantee should snapshot their
  workspace before passing it in. Avoids overpromising on a TOCTOU
  window that pathlib cannot express; closing it properly would need
  fd-based traversal (O_NOFOLLOW | O_DIRECTORY + os.scandir(fd)) with
  a separate Windows story, which is out of scope for this targeted
  fix.

- _path_tree_signature: drop the `if path.is_symlink(): return ()`
  short-circuit. Resolve a symlink root to its real target before
  walking instead. The public construction flow already resolves
  workspace_root / file_mounts[].host_path up front so this never
  affected user-facing code, but the short-circuit was misleading and
  would have produced an empty, stable signature for any direct
  caller that builds a _RunConfig without going through the public
  constructor. Defense in depth: even if a future call site forgets
  to resolve the root, the cache key still reflects real contents.

- Added regression test
  test_path_tree_signature_walks_through_symlinked_root: a symlinked
  workspace root must produce a non-empty signature, AND the signature
  must change when the real target's contents change so the cache key
  actually invalidates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 11:41:53 +00:00
Tao ChenGitHubCopilotEduard van Valkenburgcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>eavanvalkenburg
1b6f7d80fd Python: Record actual served model from Azure OpenAI (#5910)
* Record actual served model as response model for Azure OpenAI

* Formatting

* Fix tests

* Fix pipeline error

* Comments

* Address review: surface served model via ChatResponse.model

Apply blocking review feedback from PR #5910:

- Use ChatResponse.model / ChatResponseUpdate.model as the source of truth
  for the Azure x-ms-served-model header value, instead of stashing it in
  additional_properties and overriding it again in observability.
  Observability already reads response.model; the chat client now overwrites
  it post-parse when the served-model header is present. Empirically the
  Azure Responses API returns the deployment alias in body.model and the
  actual snapshot (e.g. gpt-5-nano-2025-08-07) in this header.

- Move the AZURE_OPENAI_SERVED_MODEL_HEADER constant out of observability.py
  and into RawOpenAIChatClient (as the SERVED_MODEL_HEADER ClassVar). The
  header is Azure-OpenAI-Responses-API-specific so observability does not
  need to know about it.

- Revert the streaming text_format path to client.responses.stream(...) and
  drop the _pydantic_model_to_text_format_param helper. That helper imported
  from openai.lib._parsing._responses (a private SDK path) and the swap to
  responses.create(stream=True) dropped client-side output_parsed for
  structured-output streaming. The streaming-with-text_format path is the
  only one that does not surface the served-model header - documented inline.

- Wrap the raw streaming responses in async with so the underlying socket
  closes deterministically (continuation_token retrieve + create paths).

- Fix the empty-string / whitespace-only header at the source by stripping
  in _extract_served_model and returning None when nothing remains.

- Revert unrelated formatting-only churn in _skills.py and test_mcp.py.

- Update unit tests to assert against chat_response.model / update.model
  and add an aggregated streaming assertion plus a pin that the
  streaming-with-text_format path does not get the header.

Verified end-to-end against Azure OpenAI Responses API: deployment alias
gpt-5-nano now reports gpt-5-nano-2025-08-07 as ChatResponse.model in both
the non-streaming and streaming paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: preserve streaming structured output finalization

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/f62076ef-558d-49e8-8fe2-f38d527c9639

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* refactor: name streaming response finalizer

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/f62076ef-558d-49e8-8fe2-f38d527c9639

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* fix: capture streaming response format after prepare

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/f62076ef-558d-49e8-8fe2-f38d527c9639

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* refactor: clarify streaming response format capture

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/f62076ef-558d-49e8-8fe2-f38d527c9639

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* test: use public API for streaming structured output

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/f62076ef-558d-49e8-8fe2-f38d527c9639

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Inline the served-model header override at its two call sites

The `_apply_served_model_header` helper was a 1-line wrapper around
`_extract_served_model`. Inlining the `if served_model is not None: ...`
matches the pattern already used in the streaming paths and folds the
explanatory docstring onto `_extract_served_model` (which is now the
single place that knows about the header).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>
2026-05-19 06:38:53 +00:00
Evan MattsonandGitHub 3bbc81554b Python: Improve the handling of intermediate outputs for workflows and orchestrations (#5623)
* Improve the handling of intermediate outputs for workflows and orchestrations

* Address PR review feedback on intermediate output forwarding

- Switch workflow.as_agent() forwarding to an explicit allowlist of {output,
  intermediate, data, request_info} so orchestration-internal events
  (group_chat, handoff_sent, magentic_orchestrator) stay inside the workflow
  instead of leaking into agent responses via str(data) coercion.
- Stop raising on intermediate AgentResponseUpdate in non-streaming run();
  surface the partial as a Message with text_reasoning content. The defensive
  raise still applies to terminal output events, where Update payloads would
  corrupt message ordering.
- Extend the DevUI workflow-event mapper so intermediate yields wrapping
  plain strings, Messages, and list[Message] render as visible output items
  instead of generic completed-trace events.
- Add orchestration coverage for GroupChat, Handoff, and Magentic builders
  (default vs intermediate_outputs=True; structural where end-to-end is heavy).

* Lift output-designation policy into a value type

Replace the ``Workflow._output_executors`` list and the
``RunnerContext.should_label_as_intermediate`` Protocol method with a single
immutable ``OutputDesignation`` value type owned by ``Workflow``. Thread the
designation as a parameter through the existing call chain (Runner ->
EdgeRunner -> Executor -> WorkflowContext) so ``yield_output`` consults the
threaded snapshot directly rather than calling back into the runner context.

Removes the ``InProcRunnerContext._workflow`` back-reference and the
``WorkflowBuilder.build()`` assignment that wired it up. Adds the public
predicate ``Workflow.is_terminal_executor(executor_id)`` for external
observers; ``OutputDesignation`` itself stays package-internal.

Key decisions
- ``OutputDesignation.designated`` is ``frozenset[str] | None`` -- ``None``
  preserves legacy "every yield is type='output'" behavior, any frozenset
  (including empty) opts into strict mode. The ``DeprecationWarning`` for
  legacy mode at build time is unchanged.
- ``output_designation`` is an optional parameter on ``Runner``,
  ``EdgeRunner.send_message``, ``EdgeRunner._execute_on_target``,
  ``Executor.execute``, ``Executor._create_context_for_handler``, and
  ``WorkflowContext.__init__``. Each defaults to legacy ``OutputDesignation()``
  so direct callers (Azure Functions ``CapturingRunnerContext``,
  ``test_runner`` recording fixtures) keep working without ceremony.
- The workflow-level filter in ``_run_core`` reads ``self._output_designation``
  live, preserving today's semantics where mutating the designation after
  build still affects subsequent runs (used by two existing tests).
- ``Workflow.to_dict()`` continues to emit ``"output_executors":
  list[str] | None`` (sorted from the frozenset). Checkpoint format unchanged.

Files changed
- _workflow.py: add ``OutputDesignation`` dataclass; replace
  ``_output_executors`` with ``_output_designation``; add
  ``is_terminal_executor``; delete ``_should_yield_output_event``.
- _runner_context.py: drop ``should_label_as_intermediate`` Protocol method
  and ``InProcRunnerContext`` impl; drop ``_workflow`` back-reference.
- _workflow_builder.py: remove ``context._workflow = workflow`` assignment.
- _runner.py, _edge_runner.py, _executor.py, _workflow_context.py: thread
  ``output_designation`` parameter through the call chain.
- tests/workflow/test_output_designation.py (new): three-state coverage of
  the value type plus the public predicate delegation.
- tests/workflow/test_workflow_builder.py, test_validation.py,
  test_workflow.py, test_runner.py and
  orchestrations/tests/test_orchestration_intermediate_vs_terminal.py:
  switch probes from ``_output_executors`` set checks to
  ``get_output_executors`` / ``is_terminal_executor``; update two
  post-build mutation tests to set ``_output_designation`` instead.

Verification
- core/tests/workflow/, orchestrations/tests/, azurefunctions/tests/:
  1119 passed, 42 skipped, 2 xfailed.
- ``uv run poe lint``: clean.
- ``uv run poe typing``: only the pre-existing
  ``_AGENT_FORWARDED_EVENT_TYPES`` pyright warning from 394bcd607 remains.

Notes for next iteration
- The builder's own ``_output_executors`` attribute (``list[Executor |
  SupportsAgentRun]``) is intentionally untouched; the issue scoped the
  rename to the workflow attribute.
- Adjacent review candidates (twin ``WorkflowAgent`` translators,
  ``_AGENT_FORWARDED_EVENT_TYPES`` kind classifier,
  ``_event_origin_context`` ContextVar removal, ``WorkflowEvent`` ADT
  split, legacy-mode removal) remain out of scope.

* Add explicit workflow output designation

Key decisions

- Extend the internal OutputDesignation value type from terminal-only membership to output/intermediate/hidden classification. Legacy mode remains outputs=None, so workflows built without output_executors or intermediate_executors still label every yield_output as type='output'.

- WorkflowBuilder now accepts intermediate_executors. Providing either designation enters explicit mode; output executors emit output, intermediate executors emit intermediate, and unlisted yield_output payloads are hidden from caller-facing events while remaining in executor_completed data.

- Empty explicit designation, duplicate entries, overlaps, unknown executors, and designated executors without workflow output annotations fail build validation. Existing orchestration builders pass intermediate-capable participants through intermediate_executors to preserve current intermediate_outputs behavior until participant-oriented designation lands.

Files changed

- packages/core/agent_framework/_workflows/_workflow.py, _workflow_builder.py, _workflow_context.py, _validation.py, _events.py

- packages/core/tests/workflow/test_output_designation.py, test_output_executors_contract.py, test_strict_mode_event_labeling.py, test_validation.py, test_workflow.py, test_workflow_agent_intermediate.py

- packages/orchestrations/agent_framework_orchestrations/_sequential.py, _concurrent.py, _group_chat.py, _magentic.py

- packages/core/AGENTS.md

Verification

- uv run pytest packages/core/tests/workflow packages/orchestrations/tests packages/devui/tests/devui/test_mapper.py -q

- uv run pytest packages/azurefunctions/tests -q

- uv run poe lint

- uv run poe typing fails only on pre-existing packages/core/agent_framework/_workflows/_agent.py _AGENT_FORWARDED_EVENT_TYPES private-use pyright error.

Notes for next iteration

- issues/03-core-workflow-explicit-designation.md was moved to issues/done but issues/ remains untracked and intentionally excluded from this commit.

- Slice 4 should tighten workflow.as_agent() mapping for hidden emissions and streaming-only update payloads; Slice 5 should replace orchestration intermediate_outputs with participant-oriented designation.

* Tighten workflow-as-agent output mapping

Key decisions

- Treat AgentResponseUpdate as a streaming-only payload across the workflow.as_agent() adapter, so non-streaming agent runs now reject both terminal output and intermediate workflow events carrying updates.
- Keep streaming classification behavior explicit: terminal update payloads remain normal text content, while intermediate update payloads are rewritten to text_reasoning content.
- Add explicit-mode coverage proving hidden yield_output emissions do not appear in non-streaming AgentResponse messages or streaming AgentResponseUpdate chunks.

Files changed

- packages/core/agent_framework/_workflows/_agent.py
- packages/core/tests/workflow/test_workflow_agent_intermediate.py

Verification

- uv run pytest packages/core/tests/workflow/test_workflow_agent_intermediate.py -q
- uv run pytest packages/core/tests/workflow/test_workflow_agent.py packages/core/tests/workflow/test_workflow_agent_intermediate.py -q
- uv run pytest packages/core/tests/workflow packages/orchestrations/tests packages/devui/tests/devui/test_mapper.py -q
- uv run poe lint
- uv run poe typing fails only on the pre-existing packages/core/agent_framework/_workflows/_agent.py _AGENT_FORWARDED_EVENT_TYPES private-use pyright error.

Blockers or notes for next iteration

- issues/04-workflow-as-agent-output-mapping.md was moved to issues/done/ but issues/ remains untracked and intentionally excluded from this commit.
- Slice 5 should replace orchestration intermediate_outputs with participant-oriented designation.

* Add orchestration participant output designation

Key decisions

- Replace orchestration intermediate_outputs with participant-oriented output_participants and intermediate_participants across Sequential, Concurrent, GroupChat, Magentic, and Handoff builders.
- Keep synthetic final executors terminal by default for Concurrent, GroupChat, and Magentic; keep Sequential's final participant terminal by default; keep Handoff participants terminal by default.
- Centralize participant designation validation for empty explicit designation, duplicates, overlaps, and unknown participants, then map validated participants to workflow output/intermediate executors.

Files changed

- packages/orchestrations/agent_framework_orchestrations/_participant_designation.py
- packages/orchestrations/agent_framework_orchestrations/_sequential.py
- packages/orchestrations/agent_framework_orchestrations/_concurrent.py
- packages/orchestrations/agent_framework_orchestrations/_group_chat.py
- packages/orchestrations/agent_framework_orchestrations/_magentic.py
- packages/orchestrations/agent_framework_orchestrations/_handoff.py
- packages/orchestrations/tests/test_orchestration_intermediate_vs_terminal.py
- packages/orchestrations/tests/test_magentic.py

Blockers or notes for next iteration

- issues/05-orchestration-participant-designation.md was moved to issues/done/ but issues/ remains untracked and intentionally excluded from this commit.
- Slice 7 should migrate samples and docs away from intermediate_outputs to the new participant designation API.
- uv run poe typing still fails only on the pre-existing packages/core/agent_framework/_workflows/_agent.py _AGENT_FORWARDED_EVENT_TYPES private-use pyright error.

* Migrate samples to explicit output designation

Key decisions

- Replace sample usage of the removed orchestration intermediate_outputs boolean with participant-oriented intermediate_participants designation.
- Update raw workflow guidance to show output_executors together with intermediate_executors, and document that unlisted yields are hidden in explicit designation mode.
- Keep orchestration final outputs terminal while streaming designated participant responses as intermediate progress, including workflow.as_agent() samples where intermediates map to text_reasoning content.
- Refresh workflow and orchestration README guidance plus the changelog reference so public docs no longer point users at intermediate_outputs.

Files changed

- CHANGELOG.md
- packages/orchestrations/README.md
- samples/README.md
- samples/03-workflows/README.md
- samples/03-workflows/control-flow/intermediate_vs_terminal_outputs.py
- samples/03-workflows/orchestrations/README.md
- samples/03-workflows/orchestrations/group_chat_agent_manager.py
- samples/03-workflows/orchestrations/group_chat_philosophical_debate.py
- samples/03-workflows/orchestrations/group_chat_simple_selector.py
- samples/03-workflows/orchestrations/magentic.py
- samples/03-workflows/orchestrations/magentic_human_plan_review.py
- samples/03-workflows/orchestrations/sequential_chain_only_agent_responses.py
- samples/03-workflows/agents/group_chat_workflow_as_agent.py
- samples/03-workflows/agents/magentic_workflow_as_agent.py
- samples/03-workflows/agents/sequential_workflow_as_agent.py
- samples/semantic-kernel-migration/orchestrations/group_chat.py
- samples/semantic-kernel-migration/orchestrations/magentic.py

Blockers or notes for next iteration

- issues/07-samples-and-docs-explicit-output-designation.md was moved to issues/done/ but issues/ remains untracked and intentionally excluded from this commit.
- issues/06-devui-intermediate-event-rendering.md remains present and appears already satisfied by existing DevUI mapper/tests from the prior implementation slice.
- PRD-explicit-workflow-output-designation.md remains untracked and intentionally excluded from this commit.

* Render DevUI intermediate workflow outputs

Key decisions

- Preserve workflow output designation metadata on visible DevUI output messages and text deltas so intermediate/data emissions remain distinguishable from terminal output.
- Render intermediate workflow message items in the execution timeline using executor metadata, while excluding them from the final workflow result aggregation.
- Keep terminal output message rendering unchanged and retain legacy data events on the intermediate compatibility path.

Files changed

- packages/devui/agent_framework_devui/_mapper.py
- packages/devui/frontend/src/components/features/workflow/execution-timeline.tsx
- packages/devui/frontend/src/components/features/workflow/workflow-view.tsx
- packages/devui/frontend/src/types/openai.ts
- packages/devui/tests/devui/test_mapper.py

Blockers or notes for next iteration

- issues/06-devui-intermediate-event-rendering.md was moved to issues/done/ but issues/ remains untracked and intentionally excluded from this commit.
- PRD-explicit-workflow-output-designation.md remains untracked and intentionally excluded from this commit.
- uv run poe typing still fails only on the pre-existing packages/core/agent_framework/_workflows/_agent.py _AGENT_FORWARDED_EVENT_TYPES private-use pyright error.

* Fix mypy

* Clarify orchestration participant output config

* Rename participant output kwargs for clarity

output_participants -> final_output_from, intermediate_participants ->
intermediate_output_from. The old names read like categories of
participant; the new names make it clear the kwarg designates which
participants' outputs surface as final vs. intermediate events.

* Rename core workflow output kwargs with deprecation shim

Adds final_output_from / intermediate_output_from as canonical kwargs on
Workflow and WorkflowBuilder. Old output_executors / intermediate_executors
kwargs continue to work but emit DeprecationWarning via a shared coalesce
helper that also rejects supplying both. Wire-format keys in to_dict()
stay as output_executors / intermediate_executors so checkpoint
compatibility is preserved.

Internal call sites in orchestrations and samples updated to the new
names so users following sample code learn the canonical vocabulary;
legacy callers still work with a one-shot warning.

* Suppress pyright reportPrivateUsage on cross-module sentinel import

* Update docstrings

* Propagate sub-workflow intermediate outputs, fix handoff/sequential intermediate-only designation, and shore up tests, sample, and docstrings around the intermediate output contract.

* Add canonical workflow output_from selection

Key decisions:\n- Make output_from the canonical workflow-output allow-list and keep output_executors/final_output_from as deprecated compatibility aliases.\n- Treat empty output_from/intermediate_output_from lists as explicit selections and keep validation responsible for empty, duplicate, overlap, and unknown selections.\n- Remove the branch-only public intermediate_executors WorkflowBuilder kwarg while preserving legacy wire keys in to_dict().\n\nFiles changed:\n- packages/core/agent_framework/_workflows/_workflow.py\n- packages/core/agent_framework/_workflows/_workflow_builder.py\n- packages/core/agent_framework/_workflows/_workflow_context.py\n- packages/core/agent_framework/_workflows/_agent.py\n- packages/core/agent_framework/_workflows/_agent_executor.py\n- packages/core/tests/workflow/* output-selection coverage updates\n- packages/core/AGENTS.md\n- issues/done/001-canonical-list-based-output-selection.md\n\nBlockers/notes:\n- Orchestration builders still pass final_output_from internally; follow-up issue 004 should migrate them to output_from.\n- Legacy omitted-selection behavior and explicit all/all_other literals are left for issues 002 and 003.

* Add explicit all workflow output selection

Key decisions:
- Treat output_from='all' as an explicit workflow-output selection sentinel and expand it at build time to executors with declared workflow output types.
- Keep omitted output selections in legacy all-output mode with a deprecation warning that names output_from and intermediate_output_from and points to output_from='all'.
- Reject intermediate_output_from='all' at construction because the all-output literal is output-only for this issue.

Files changed:
- packages/core/agent_framework/_workflows/_workflow_builder.py
- packages/core/tests/workflow/test_output_executors_contract.py
- issues/done/002-explicit-all-output-and-legacy-migration.md

Blockers/notes:
- all_other intermediate-output selection remains for issue 003.
- Workflow-as-agent/orchestration parity remains for issue 004.

* Add all-other intermediate output selection

Key decisions:
- Treat intermediate_output_from='all_other' as an explicit intermediate-output selection sentinel and expand it at build time after the workflow graph is complete.
- Expand all_other to output-capable executors not selected by output_from; omitted or empty output_from selects no workflow outputs, while output_from='all' leaves an empty intermediate selection.
- Keep output_from='all_other' invalid so all_other remains intermediate-output-only and runtime classification still receives concrete executor-id sets.

Files changed:
- packages/core/agent_framework/_workflows/_workflow_builder.py
- packages/core/tests/workflow/test_output_executors_contract.py
- issues/done/003-all-other-intermediate-output-selection.md

Blockers/notes:
- Workflow-as-agent and orchestration parity remains for issue 004.
- Full documentation updates remain for issue 005.

* Add orchestration output selection parity

Key decisions:
- Expose output_from on sequential, concurrent, group chat, handoff, and magentic builders while keeping final_output_from as a deprecated compatibility alias.
- Resolve orchestration participant selections through the same explicit rules as workflows: output_from='all', intermediate_output_from='all_other', hidden unselected participant payloads, and overlap/duplicate/unknown/invalid-literal validation.
- Continue preserving documented orchestration defaults by always designating each pattern's terminal internal executor where applicable.

Files changed:
- packages/orchestrations/agent_framework_orchestrations/_participant_output_config.py
- packages/orchestrations/agent_framework_orchestrations/_sequential.py
- packages/orchestrations/agent_framework_orchestrations/_concurrent.py
- packages/orchestrations/agent_framework_orchestrations/_group_chat.py
- packages/orchestrations/agent_framework_orchestrations/_handoff.py
- packages/orchestrations/agent_framework_orchestrations/_magentic.py
- packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py
- packages/orchestrations/tests/test_orchestration_intermediate_vs_terminal.py
- issues/done/004-workflow-as-agent-and-orchestration-parity.md

Blockers/notes:
- Full documentation and sample migration wording remains for issue 005.
- Existing tests that intentionally use final_output_from now emit the new deprecation warning.

* Document workflow output selection contract

Key decisions:
- Use Workflow Output and Intermediate Output as the developer-facing terms for selected caller-facing emissions.
- Document output_from and intermediate_output_from as the canonical API, with output_from as an allow-list and unselected payloads hidden unless explicitly selected as intermediate.
- Add scenario and invalid-selection tables for workflow and orchestration docs, including legacy omission warnings, output_from='all', intermediate_output_from='all_other', list selections, invalid literals, overlap, duplicates, unknown selections, and empty explicit selections.
- Migrate samples away from final_output_from and output_executors except where compatibility aliases are explicitly documented.

Files changed:
- packages/core/AGENTS.md
- packages/orchestrations/README.md
- packages/orchestrations/agent_framework_orchestrations/_handoff.py
- packages/orchestrations/agent_framework_orchestrations/_sequential.py
- samples/03-workflows/README.md
- samples/03-workflows/control-flow/intermediate_vs_terminal_outputs.py
- samples/03-workflows/human-in-the-loop/agents_with_approval_requests.py
- samples/03-workflows/orchestrations/README.md
- samples/04-hosting/foundry-hosted-agents/responses/05_workflows/main.py
- scripts/sample_validation/create_dynamic_workflow_executor.py
- issues/done/005-document-output-selection-contract.md

Blockers/notes:
- Direct full Ruff on scripts/sample_validation/create_dynamic_workflow_executor.py still reports pre-existing docstring/print/line-length issues outside this docs migration; syntax-focused checks for changed files pass.
- No remaining AFK issue files are present under issues/.

* Latest updates

* Typing fixes

* Cleanup
2026-05-19 00:15:25 +00:00
Peter IbekweandGitHub 3ebbdb01b4 .NET: Delegate MCP ContentBlock to AIContent conversion to the MCP SDK (#5903)
* Add sample for invoking Foundry Toolbox tools from declarative workflows

* Addressed initial PR comments.

* Delegate MCP ContentBlock to AIContent conversion to the MCP SDK

* Addressed additional properties metadata in the conversion fallback.
2026-05-18 20:39:56 +00:00
Roger BarretoandGitHub aad20c2b33 .NET: Bump Azure.AI.Projects to 2.1.0-beta.2 and add agent-endpoint AsAIAgent path (#5899)
* .NET: Bump Azure.AI.Projects to 2.1.0-beta.2 and add agent-endpoint AsAIAgent path

Bumps Azure.AI.Projects to 2.1.0-beta.2 with the matching transitive pins (Azure.Core 1.55.0, System.ClientModel 1.11.0).

Foundry agent endpoint plumbing:
* FoundryAgent now routes the agent-endpoint constructor through the new GetProjectResponsesClientForAgentEndpoint helper.
* Adds an internal FoundryAgent ctor that takes an existing AIProjectClient plus a parsed agent endpoint so the public extension does not need to construct a second project client.
* Adds public AIProjectClient.AsAIAgent(Uri agentEndpoint, ...) extension. This is the path consumer samples are expected to use for hosted agents because version selection happens server-side.
* Trims the dangling "If you want to construct a FoundryAgent against a project endpoint..." sentence from ParseAgentEndpoint.

Unit tests:
* Four new tests in AzureAIProjectChatClientExtensionsTests cover the AIProjectClient.AsAIAgent(Uri agentEndpoint, ...) overload. 263/263 pass.

Consumer samples (Using-Samples):
* SimpleAgent and SessionFilesClient now read AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_AGENT_NAME (both required, throw on missing), derive the agent endpoint with new Uri($"{projectEndpoint}/agents/{agentName}/endpoint/protocols/openai"), then call aiProjectClient.AsAIAgent(agentEndpoint, ...).
* SessionFilesClient README updated.

Contributor samples (responses/*):
* New HostedContributorRouteExtensions.MapDevTemporaryLocalAgentEndpoint() wildcard route extension so localhost contributor servers accept the per-agent OpenAI endpoint shape the production Hosted runtime exposes.
* All 11 contributor Program.cs files call MapDevTemporaryLocalAgentEndpoint() with a contributor-only warning comment.
* Hosted-Files and Hosted-AzureSearchRag were importing Hosted_Shared_Contributor_Setup but never calling AddDevTemporaryLocalContributorSetup(). Both now call it so HostedSessionIsolationKeyProvider resolves correctly in dev.
* Hosted-AzureSearchRag, Hosted-Files, Hosted-MemoryAgent csprojs drop stale VersionOverride="2.1.0-beta.1" pins.
* Hosted-AzureSearchRag and Hosted-Files csprojs add ProjectReference to Hosted_Shared_Contributor_Setup.
* Hosted-Observability/.dockerignore removed the out/ exclusion that was blocking COPY out/ . in Dockerfile.contributor.

Verified:
* Full solution-scoped build of changed projects: green.
* Scoped CI-parity dotnet format via WSL2 + Docker (mcr.microsoft.com/dotnet/sdk:10.0) over every changed csproj: clean.
* Foundry unit tests: 263/263.
* Contributor docker smoke for 8 hosted samples (publish + docker build + docker run + curl POST to the wildcard route): HTTP 200 / 500 with route matched.
* End-to-end smoke against the real Azure Foundry project with a fresh bearer token: Hosted-Files contributor container served HTTP 200, the agent invoked ListBundledFiles, and returned the expected file name.

* Address PR review: forward pipeline settings; add UTs

- CreateProjectClientOptions also carries RetryPolicy, NetworkTimeout, ClientLoggingOptions, MessageLoggingPolicy (was Transport+UserAgentApplicationId only).

- Make CreateProjectClientOptions internal so tests can verify the copy directly.

- Add AsAIAgent(Uri) UTs covering tools forwarding to inner ChatOptions and null tools handling.

- Add CreateProjectClientOptions UTs covering null caller and full pipeline-settings copy.
2026-05-18 20:20:56 +00:00
westeyandGitHub dff23a9413 .NET: Add ability to export/import sessions in harness console (#5920)
* Add ability to export/import sessions in harness console

* Address PR comments
2026-05-18 18:44:50 +00:00
westeyandGitHub eff36b504e .NET: Add otel file logging and switch samples to projects client with store=true (#5924)
* Add otel file logging and switch samples to projects client with store=true

* Fix formatting and remove rogue file
2026-05-18 17:39:29 +00:00
westeyandGitHub 7cea5e162a .NET: Require TODO finish reason and rename SubAgents to BackgroundAgents (#5902)
* Require TODO finish reason and rename SubAgents to BackgroundAgents

* Address PR comments
2026-05-18 15:37:25 +00:00
ddc0fcf81f .NET: Adding default providers and tools to HarnessAgent (#5896)
* Adding default providers and tools to HarnessAgent

* Address PR comments

* Add further comments to clarify certain setings.

* Apply suggestion from @SergeyMenshykh

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

---------

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
2026-05-18 10:07:16 +00:00
191 changed files with 8902 additions and 2798 deletions
+8 -2
View File
@@ -32,7 +32,13 @@ runs:
if grep -q "name = \"$pkg\"" "$f"; then
pkg_dir=$(dirname "$f" | sed 's|python/||')
echo "Excluding workspace package: $pkg ($pkg_dir)"
sed -i.bak '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
if awk '/^\[tool\.uv\.workspace\]/{f=1;next} /^\[/{f=0} f && /^exclude = \[/{found=1} END{exit !found}' python/pyproject.toml; then
if ! awk '/^\[tool\.uv\.workspace\]/{f=1;next} /^\[/{f=0} f && /^exclude = \[/ && index($0, "\"'"$pkg_dir"'\"")' python/pyproject.toml | grep -q .; then
sed -i.bak '/\[tool\.uv\.workspace\]/,/^\[/ { /^exclude = \[/ s|\]|, "'"$pkg_dir"'"]| }' python/pyproject.toml
fi
else
sed -i.bak '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
fi
sed -i.bak '/'"$pkg"' = { workspace = true }/d' python/pyproject.toml
fi
done
@@ -40,4 +46,4 @@ runs:
- name: Install the project
shell: bash
run: |
cd python && uv sync --all-packages --all-extras --dev -U --prerelease=if-necessary-or-explicit
cd python && uv sync --all-packages --all-extras --dev --prerelease=if-necessary-or-explicit
+4 -3
View File
@@ -26,10 +26,10 @@
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
<PackageVersion Include="Azure.Search.Documents" Version="12.0.0" />
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.1" />
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.2" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
<PackageVersion Include="Azure.Core" Version="1.53.0" />
<PackageVersion Include="Azure.Core" Version="1.55.0" />
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.5.0" />
@@ -44,7 +44,7 @@
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
<PackageVersion Include="System.ClientModel" Version="1.10.0" />
<PackageVersion Include="System.ClientModel" Version="1.11.0" />
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
@@ -112,6 +112,7 @@
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
<!-- Hyperlight -->
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
<!-- Inference SDKs -->
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
+2 -1
View File
@@ -122,8 +122,9 @@
<File Path="samples/02-agents/Harness/README.md" />
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Harness_Step02_Research_WithBackgroundAgents.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step04_CodeExecution/Harness_Step04_CodeExecution.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
</Folder>
@@ -24,6 +24,11 @@ public static class AnsiEscapes
/// </summary>
public static string MoveCursor(int row, int column) => $"\x1b[{row};{column}H";
/// <summary>
/// Erases the current line from the cursor position to the end of the line (EL 0).
/// </summary>
public static string EraseToEndOfLine => "\x1b[0K";
/// <summary>
/// Erases the entire current line (EL 2).
/// </summary>
@@ -39,7 +39,7 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
{
foreach (string line in props.Title.Split('\n'))
{
Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X));
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
Console.Write(AnsiEscapes.EraseEntireLine);
Console.Write(line);
row++;
@@ -51,7 +51,7 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
for (int i = 0; i < totalItems; i++)
{
Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X));
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
Console.Write(AnsiEscapes.EraseEntireLine);
bool isSelected = i == props.SelectedIndex;
@@ -58,11 +58,11 @@ public class TextInput : ConsoleReactiveComponent<TextInputProps, ConsoleReactiv
public override void RenderCore(TextInputProps props, ConsoleReactiveState state)
{
int promptLength = props.Prompt.Length;
int textWidth = this.Width - promptLength;
int textWidth = props.Width - promptLength;
string indent = new(' ', promptLength);
// First line: prompt + start of text
Console.Write(AnsiEscapes.MoveCursor(this.Y, this.X));
Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
Console.Write(AnsiEscapes.EraseEntireLine);
Console.Write(props.Prompt);
@@ -90,7 +90,7 @@ public class TextInput : ConsoleReactiveComponent<TextInputProps, ConsoleReactiv
while (offset < props.Text.Length)
{
int chunk = Math.Min(textWidth, props.Text.Length - offset);
Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X));
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
Console.Write(AnsiEscapes.EraseEntireLine);
Console.Write(indent);
Console.Write(props.Text[offset..(offset + chunk)]);
@@ -17,7 +17,7 @@ public record TextPanelProps : ConsoleReactiveProps
/// <summary>
/// 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 <see cref="ConsoleReactiveComponent.Height"/>
/// re-rendered on each update. If the component's <see cref="ConsoleReactiveProps.Height"/>
/// exceeds the number of output lines, leftover lines are erased.
/// </summary>
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
@@ -51,18 +51,18 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
for (int j = 0; j < lineCount; j++)
{
Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y + currentRow));
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow));
Console.Write(lines[j]);
currentRow++;
}
}
// If the component height exceeds the output, erase leftover lines
if (this.Height > currentRow)
if (props.Height > currentRow)
{
for (int i = currentRow; i < this.Height; i++)
for (int i = currentRow; i < props.Height; i++)
{
Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y + i));
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + i));
}
}
}
@@ -52,7 +52,7 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
}
// Move cursor to the bottom of the scroll area
Console.Write(AnsiEscapes.MoveCursor(this.Y + this.Height - 1, this.X));
Console.Write(AnsiEscapes.MoveCursor(props.Y + props.Height - 1, props.X));
// Output only new items since last rendered
for (int i = state.RenderedCount; i < props.Items.Count; i++)
@@ -9,9 +9,6 @@ namespace Harness.ConsoleReactiveComponents;
/// </summary>
public record TopBottomRuleProps : ConsoleReactiveProps
{
/// <summary>Gets the width of the horizontal rules in characters.</summary>
public int Width { get; init; }
/// <summary>Gets the foreground color of the horizontal rules. If <c>null</c>, the default terminal color is used.</summary>
public ConsoleColor? Color { get; init; }
}
@@ -32,7 +29,7 @@ public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, Consol
int childrenHeight = 0;
foreach (var child in props.Children)
{
childrenHeight += child.Height;
childrenHeight += child.BaseProps?.Height ?? 0;
}
// Top rule + children + bottom rule
@@ -51,11 +48,11 @@ public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, Consol
}
// Top rule
Console.Write(AnsiEscapes.MoveCursor(this.Y, this.X));
Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
Console.Write(rule);
// Render children stacked below the top rule
int currentY = this.Y + 1;
int currentY = props.Y + 1;
if (props.Color.HasValue)
{
@@ -64,10 +61,9 @@ public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, Consol
foreach (var child in props.Children)
{
child.X = this.X;
child.Y = currentY;
child.BaseProps = child.BaseProps! with { X = props.X, Y = currentY };
child.Render();
currentY += child.Height;
currentY += child.BaseProps.Height;
}
if (props.Color.HasValue)
@@ -76,7 +72,7 @@ public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, Consol
}
// Bottom rule
Console.Write(AnsiEscapes.MoveCursor(currentY, this.X));
Console.Write(AnsiEscapes.MoveCursor(currentY, props.X));
Console.Write(rule);
if (props.Color.HasValue)
@@ -3,8 +3,8 @@
namespace Harness.ConsoleReactiveFramework;
/// <summary>
/// Abstract base class for all console UI components. Provides layout properties
/// (position and size) and a <see cref="Render"/> method for drawing to the console.
/// Abstract base class for all console UI components. Provides access to layout
/// through <see cref="BaseProps"/> and a <see cref="Render"/> method for drawing to the console.
/// Derive from <see cref="ConsoleReactiveComponent{TProps, TState}"/> instead of this class directly.
/// </summary>
public abstract class ConsoleReactiveComponent
@@ -13,20 +13,21 @@ public abstract class ConsoleReactiveComponent
{
}
/// <summary>Gets or sets the 1-based column position of the component.</summary>
public int X { get; set; }
/// <summary>Gets or sets the 1-based row position of the component.</summary>
public int Y { get; set; }
/// <summary>Gets or sets the width of the component in columns.</summary>
public int Width { get; set; }
/// <summary>Gets or sets the height of the component in rows.</summary>
public int Height { get; set; }
/// <summary>
/// Gets or sets the component's props as the base <see cref="ConsoleReactiveProps"/> type.
/// Used by parent components to set layout (X, Y, Width, Height) on children without
/// knowing the concrete props type.
/// </summary>
public abstract ConsoleReactiveProps? BaseProps { get; set; }
/// <summary>Renders the component to the console at its current position.</summary>
public abstract void Render();
/// <summary>
/// Invalidates the component's cached render state, causing the next <see cref="Render"/> call
/// to proceed even if props and state have not changed. Use after a screen erase to force repaint.
/// </summary>
public abstract void Invalidate();
}
/// <summary>
@@ -46,6 +47,13 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
/// <summary>Gets or sets the component's props (external configuration).</summary>
public TProps? Props { get; set; }
/// <inheritdoc/>
public override ConsoleReactiveProps? BaseProps
{
get => this.Props;
set => this.Props = (TProps?)value;
}
/// <summary>Gets or sets the component's internal state.</summary>
protected TState? State { get; set; }
@@ -73,8 +81,8 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
return;
}
if (ReferenceEquals(this.Props, this._lastRenderedProps)
&& ReferenceEquals(this.State, this._lastRenderedState))
if (EqualityComparer<TProps>.Default.Equals(this.Props, this._lastRenderedProps)
&& EqualityComparer<TState>.Default.Equals(this.State, this._lastRenderedState))
{
return;
}
@@ -86,6 +94,16 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
}
}
/// <inheritdoc/>
public override void Invalidate()
{
lock (this._renderLock)
{
this._lastRenderedProps = default;
this._lastRenderedState = default;
}
}
/// <summary>
/// Called by <see cref="Render"/> to perform the actual rendering. Override this in derived classes.
/// </summary>
@@ -95,11 +113,23 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
}
/// <summary>
/// Base record for component props. Provides an optional <see cref="Children"/> collection
/// for composing child components.
/// Base record for component props. Provides layout properties (position and size)
/// and an optional <see cref="Children"/> collection for composing child components.
/// </summary>
public record ConsoleReactiveProps
{
/// <summary>Gets the 1-based column position of the component.</summary>
public int X { get; init; }
/// <summary>Gets the 1-based row position of the component.</summary>
public int Y { get; init; }
/// <summary>Gets the width of the component in columns.</summary>
public int Width { get; init; }
/// <summary>Gets the height of the component in rows.</summary>
public int Height { get; init; }
/// <summary>Gets the child components to render within this component.</summary>
public IReadOnlyList<ConsoleReactiveComponent> Children { get; init; } = [];
}
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Agents.AI;
namespace Harness.Shared.Console.Commands;
/// <summary>
/// Handles <c>/session-export &lt;filename&gt;</c> and <c>/session-import &lt;filename&gt;</c>
/// commands for serializing the current session to a file and restoring a session from a file.
/// </summary>
public sealed class SessionCommandHandler : CommandHandler
{
private readonly AIAgent _agent;
/// <summary>
/// Initializes a new instance of the <see cref="SessionCommandHandler"/> class.
/// </summary>
/// <param name="agent">The agent used for session serialization and deserialization.</param>
public SessionCommandHandler(AIAgent agent)
{
this._agent = agent;
}
/// <inheritdoc/>
public override string? GetHelpText() => "/session-export <file> | /session-import <file>";
/// <inheritdoc/>
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
{
string command = input.Split(' ', 2)[0];
if (command.Equals("/session-export", StringComparison.OrdinalIgnoreCase))
{
await this.HandleExportAsync(input, session, ux).ConfigureAwait(false);
return true;
}
if (command.Equals("/session-import", StringComparison.OrdinalIgnoreCase))
{
await this.HandleImportAsync(input, ux).ConfigureAwait(false);
return true;
}
return false;
}
private async Task HandleExportAsync(string input, AgentSession session, IUXStateDriver ux)
{
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2)
{
await ux.WriteInfoLineAsync("Usage: /session-export <filename>").ConfigureAwait(false);
return;
}
string filename = parts[1];
try
{
JsonElement serialized = await this._agent.SerializeSessionAsync(session).ConfigureAwait(false);
string json = JsonSerializer.Serialize(serialized);
await File.WriteAllTextAsync(filename, json).ConfigureAwait(false);
await ux.WriteInfoLineAsync($"Session exported to {filename}").ConfigureAwait(false);
}
catch (Exception ex)
{
await ux.WriteInfoLineAsync($"Failed to export session to {filename}: {ex.Message}").ConfigureAwait(false);
}
}
private async Task HandleImportAsync(string input, IUXStateDriver ux)
{
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2)
{
await ux.WriteInfoLineAsync("Usage: /session-import <filename>").ConfigureAwait(false);
return;
}
string filename = parts[1];
try
{
string json = await File.ReadAllTextAsync(filename).ConfigureAwait(false);
JsonElement element = JsonSerializer.Deserialize<JsonElement>(json);
AgentSession newSession = await this._agent.DeserializeSessionAsync(element).ConfigureAwait(false);
await ux.ReplaceSessionAsync(newSession).ConfigureAwait(false);
await ux.WriteInfoLineAsync($"Session imported from {filename}").ConfigureAwait(false);
}
catch (FileNotFoundException)
{
await ux.WriteInfoLineAsync($"File not found: {filename}").ConfigureAwait(false);
}
catch (Exception ex)
{
await ux.WriteInfoLineAsync($"Failed to import session from {filename}: {ex.Message}").ConfigureAwait(false);
}
}
}
@@ -43,7 +43,7 @@ public class AgentModeAndHelp : ConsoleReactiveComponent<AgentModeAndHelpProps,
}
System.Console.Write(AnsiEscapes.SaveCursor);
System.Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y));
System.Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y));
bool hasMode = props.Mode is not null;
@@ -35,6 +35,7 @@ public class AgentStatus : ConsoleReactiveComponent<AgentStatusProps, AgentStatu
];
private readonly Timer _timer;
private AgentStatusProps? _previousProps;
/// <summary>
/// Initializes a new instance of the <see cref="AgentStatus"/> class.
@@ -85,7 +86,12 @@ public class AgentStatus : ConsoleReactiveComponent<AgentStatusProps, AgentStatu
}
System.Console.Write(AnsiEscapes.SaveCursor);
System.Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y));
System.Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
if (props != this._previousProps)
{
System.Console.Write(AnsiEscapes.EraseToEndOfLine);
this._previousProps = props;
}
if (props.ShowSpinner)
{
@@ -0,0 +1,67 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Globalization;
using OpenTelemetry;
namespace Harness.Shared.Console;
/// <summary>
/// A simple OpenTelemetry span exporter that writes completed activities (spans) to a text file.
/// Each span is formatted as a human-readable block with timestamps, operation name, duration,
/// status, and any tags/events.
/// </summary>
public sealed class FileSpanExporter : BaseExporter<Activity>
{
private readonly string _filePath;
private readonly object _lock = new();
public FileSpanExporter(string filePath)
{
this._filePath = filePath;
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
}
public override ExportResult Export(in Batch<Activity> batch)
{
lock (this._lock)
{
using var writer = new StreamWriter(this._filePath, append: true);
foreach (var activity in batch)
{
WriteActivity(writer, activity);
}
}
return ExportResult.Success;
}
private static void WriteActivity(StreamWriter writer, Activity activity)
{
var start = activity.StartTimeUtc.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
var duration = activity.Duration.TotalMilliseconds.ToString("F1", CultureInfo.InvariantCulture);
writer.WriteLine($"[{start}] {activity.OperationName} ({duration}ms) [{activity.Status}]");
if (!string.IsNullOrEmpty(activity.DisplayName) && activity.DisplayName != activity.OperationName)
{
writer.WriteLine($" DisplayName: {activity.DisplayName}");
}
foreach (var tag in activity.Tags)
{
writer.WriteLine($" {tag.Key}: {tag.Value}");
}
foreach (var ev in activity.Events)
{
writer.WriteLine($" Event: {ev.Name} @ {ev.Timestamp:HH:mm:ss.fff}");
foreach (var tag in ev.Tags)
{
writer.WriteLine($" {tag.Key}: {tag.Value}");
}
}
writer.WriteLine();
}
}
@@ -19,15 +19,15 @@ namespace Harness.Shared.Console;
public sealed class HarnessAgentRunner : IDisposable
{
private readonly AIAgent _agent;
private readonly AgentSession _session;
private readonly AgentModeProvider? _modeProvider;
private readonly MessageInjectingChatClient? _messageInjector;
private readonly IReadOnlyList<CommandHandler> _commandHandlers;
private readonly IReadOnlyList<ConsoleObserver> _observers;
private readonly IUXStateDriver _ux;
private readonly SemaphoreSlim _inputGate = new(1, 1);
private AgentSession _session;
/// <summary>
/// Initializes a new instance of the <see cref="HarnessAgentRunner"/> class.
/// </summary>
@@ -62,6 +62,25 @@ public sealed class HarnessAgentRunner : IDisposable
/// </summary>
public string HelpText { get; }
/// <summary>
/// Replaces the current session with the specified session. Used by the UX driver
/// when importing a serialized session. Acquires the input gate to ensure no
/// concurrent agent turn is reading the session.
/// </summary>
/// <param name="newSession">The new session to use.</param>
internal async Task ReplaceSessionAsync(AgentSession newSession)
{
await this._inputGate.WaitAsync().ConfigureAwait(false);
try
{
this._session = newSession;
}
finally
{
this._inputGate.Release();
}
}
/// <inheritdoc/>
public void Dispose() => this._inputGate.Dispose();
@@ -63,6 +63,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
getState: () => this.State!,
setState: s => this.SetState(s),
requestShutdown: () => this._shutdownTcs.TrySetResult(true),
replaceSession: s => this.Runner!.ReplaceSessionAsync(s),
modeColors: modeColors);
this.Runner = runnerFactory(this._uxDriver);
@@ -370,7 +371,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
};
bottomChildHeight = ListSelection.CalculateHeight(listProps);
this._listSelection.Height = bottomChildHeight;
listProps = listProps with { Height = bottomChildHeight };
this._listSelection.Props = listProps;
bottomChild = this._listSelection;
}
@@ -397,8 +398,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
}
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
this._textInput.Width = state.ConsoleWidth;
this._textInput.Height = bottomChildHeight;
textInputProps = textInputProps with { Width = state.ConsoleWidth, Height = bottomChildHeight };
this._textInput.Props = textInputProps;
bottomChild = this._textInput;
}
@@ -412,8 +412,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
};
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
this._textInput.Width = state.ConsoleWidth;
this._textInput.Height = bottomChildHeight;
textInputProps = textInputProps with { Width = state.ConsoleWidth, Height = bottomChildHeight };
this._textInput.Props = textInputProps;
bottomChild = this._textInput;
}
@@ -458,6 +457,16 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
this._textScrollPanel.Reset();
this._resizedSinceLastRender = false;
// Invalidate all children so they re-render even if props haven't changed
this._rule.Invalidate();
this._textScrollPanel.Invalidate();
this._textPanel.Invalidate();
this._queuedPanel.Invalidate();
this._agentStatus.Invalidate();
this._modeAndHelp.Invalidate();
this._textInput.Invalidate();
this._listSelection.Invalidate();
}
this._scrollRegionBottom = scrollBottom;
@@ -469,35 +478,35 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
? state.ScrollAreaContentItems.Take(state.ScrollAreaContentItems.Count - 1).ToList()
: [];
this._textScrollPanel.X = 1;
this._textScrollPanel.Y = 1;
this._textScrollPanel.Width = state.ConsoleWidth;
this._textScrollPanel.Height = scrollBottom;
this._textScrollPanel.Props = new TextScrollPanelProps
{
X = 1,
Y = 1,
Width = state.ConsoleWidth,
Height = scrollBottom,
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 = state.ConsoleWidth;
this._textPanel.Height = textPanelHeight;
this._textPanel.Props = new TextPanelProps
{
X = 1,
Y = scrollBottom + 1,
Width = state.ConsoleWidth,
Height = textPanelHeight,
Items = lastItems,
};
this._textPanel.Render();
// Render queued input items between text panel and agent status
int queuedPanelY = scrollBottom + textPanelHeight + 1;
this._queuedPanel.X = 1;
this._queuedPanel.Y = queuedPanelY;
this._queuedPanel.Width = state.ConsoleWidth;
this._queuedPanel.Height = queuedPanelHeight;
this._queuedPanel.Props = new TextPanelProps
{
X = 1,
Y = queuedPanelY,
Width = state.ConsoleWidth,
Height = queuedPanelHeight,
Items = state.QueuedItems,
};
this._queuedPanel.Render();
@@ -506,32 +515,41 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
int agentStatusY = queuedPanelY + queuedPanelHeight;
if (showStatusAndHelp)
{
this._agentStatus.X = 1;
this._agentStatus.Y = agentStatusY;
this._agentStatus.Width = state.ConsoleWidth;
this._agentStatus.Height = agentStatusHeight;
this._agentStatus.Props = agentStatusProps;
this._agentStatus.Props = agentStatusProps with
{
X = 1,
Y = agentStatusY,
Width = state.ConsoleWidth,
Height = agentStatusHeight,
};
this._agentStatus.Render();
}
// Render the bottom rule + child below the agent status
this._rule.X = 1;
this._rule.Y = agentStatusY + agentStatusHeight;
this._rule.Props = ruleProps;
this._rule.Props = ruleProps with
{
X = 1,
Y = agentStatusY + agentStatusHeight,
};
this._rule.Render();
// Render the mode-and-help line below the bottom rule
if (showStatusAndHelp)
{
int modeAndHelpY = this._rule.Y + ruleHeight;
this._modeAndHelp.X = 1;
this._modeAndHelp.Y = modeAndHelpY;
this._modeAndHelp.Width = state.ConsoleWidth;
this._modeAndHelp.Height = modeAndHelpHeight;
this._modeAndHelp.Props = modeAndHelpProps;
int modeAndHelpY = agentStatusY + agentStatusHeight + ruleHeight;
this._modeAndHelp.Props = modeAndHelpProps with
{
X = 1,
Y = modeAndHelpY,
Width = state.ConsoleWidth,
Height = modeAndHelpHeight,
};
this._modeAndHelp.Render();
}
// Clear the bottom padding line
System.Console.Write(AnsiEscapes.MoveAndEraseLine(state.ConsoleHeight));
// Position cursor for natural typing appearance
this.PositionCursor(state);
}
@@ -545,7 +563,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
int textWidth = state.ConsoleWidth - promptLength;
int textLength = state.InputText.Length;
int textInputY = this._rule.Y + 1;
int textInputY = (this._rule.Props?.Y ?? 0) + 1;
if (textWidth <= 0 || textLength == 0)
{
@@ -563,7 +581,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
&& state.ListSelectionIndex == state.ListSelectionOptions.Count)
{
int titleLines = state.ListSelectionTitle?.Split('\n').Length ?? 0;
int customOptionY = this._rule.Y + 1 + titleLines + state.ListSelectionOptions.Count;
int customOptionY = (this._rule.Props?.Y ?? 0) + 1 + titleLines + state.ListSelectionOptions.Count;
int cursorCol = 2 + state.ListSelectionCustomInputText.Length + 1;
System.Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol));
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text;
using Harness.ConsoleReactiveComponents;
using Microsoft.Agents.AI;
@@ -24,6 +25,8 @@ public static class HarnessConsole
{
options ??= new();
System.Console.OutputEncoding = Encoding.UTF8;
// Null means use defaults; an explicit (possibly empty) list means use exactly what was provided.
var observers = options.Observers
?? HarnessConsoleOptions.BuildDefaultObservers();
@@ -33,7 +36,9 @@ public static class HarnessConsole
var modeProvider = agent.GetService<AgentModeProvider>();
var messageInjector = agent.GetService<MessageInjectingChatClient>();
AgentSession session = await agent.CreateSessionAsync();
AgentSession session = options.SessionFactory is not null
? await options.SessionFactory(agent)
: await agent.CreateSessionAsync();
using var component = new HarnessAppComponent(
placeholder: userPrompt,
@@ -63,6 +68,7 @@ public static class HarnessConsole
System.Console.ResetColor();
System.Console.Write(AnsiEscapes.ResetScrollRegion);
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
System.Console.Write(AnsiEscapes.EraseEntireScreen);
System.Console.Write(AnsiEscapes.MoveCursor(1, 1));
System.Console.WriteLine("Goodbye!");
@@ -45,6 +45,12 @@ public class HarnessConsoleOptions
/// </summary>
public Dictionary<string, ConsoleColor> ModeColors { get; set; } = new(DefaultModeColors, StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets or sets an optional factory for creating the <see cref="AgentSession"/>.
/// When <see langword="null"/> (the default), <see cref="AIAgent.CreateSessionAsync"/> is used.
/// </summary>
public Func<AIAgent, Task<AgentSession>>? SessionFactory { get; set; }
/// <summary>
/// Creates the default set of observers without planning support.
/// Includes tool call display, tool approval, error display, reasoning display,
@@ -128,6 +134,7 @@ public class HarnessConsoleOptions
new ExitCommandHandler(),
new TodoCommandHandler(todoProvider),
new ModeCommandHandler(modeProvider, modeColors ?? DefaultModeColors),
new SessionCommandHandler(agent),
];
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveComponents;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
@@ -16,6 +17,7 @@ internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver
private readonly Func<HarnessAppComponentState> _getState;
private readonly Action<HarnessAppComponentState> _setState;
private readonly Action _requestShutdown;
private readonly Func<AgentSession, Task> _replaceSession;
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
private readonly List<string> _outputItems = [];
private readonly object _stateLock = new();
@@ -32,16 +34,19 @@ internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver
/// <param name="getState">Returns the component's current state.</param>
/// <param name="setState">Replaces the component's state and triggers a re-render.</param>
/// <param name="requestShutdown">Callback invoked when a command handler requests application shutdown.</param>
/// <param name="replaceSession">Callback invoked to replace the current agent session (e.g., on import).</param>
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
public HarnessConsoleUXStateDriver(
Func<HarnessAppComponentState> getState,
Action<HarnessAppComponentState> setState,
Action requestShutdown,
Func<AgentSession, Task> replaceSession,
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
{
this._getState = getState;
this._setState = setState;
this._requestShutdown = requestShutdown;
this._replaceSession = replaceSession;
this._modeColors = modeColors;
this._currentMode = getState().ModeText;
}
@@ -405,4 +410,7 @@ internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver
/// <inheritdoc/>
public void RequestShutdown() => this._requestShutdown();
/// <inheritdoc/>
public Task ReplaceSessionAsync(AgentSession newSession) => this._replaceSession(newSession);
}
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable VSTHRD002 // Synchronous waits are required by OpenTelemetry enrichment callbacks.
using OpenTelemetry;
using OpenTelemetry.Trace;
namespace Harness.Shared.Console;
/// <summary>
/// Provides factory methods for creating pre-configured OpenTelemetry tracing for harness samples.
/// </summary>
public static class HarnessTracing
{
/// <summary>
/// Creates a <see cref="TracerProvider"/> that captures spans from the specified source and HTTP client activity,
/// enriching HTTP spans with full request/response headers and bodies, and exports all spans to a timestamped
/// text file in the application base directory.
/// </summary>
/// <param name="sourceName">The activity source name to subscribe to (e.g., "Harness.Research").</param>
/// <returns>A configured <see cref="TracerProvider"/>, or <see langword="null"/> if the builder returns null.</returns>
public static TracerProvider? CreateFileTracerProvider(string sourceName)
{
var traceLogPath = Path.Combine(AppContext.BaseDirectory, $"traces_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{Guid.NewGuid()}.log");
return Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddHttpClientInstrumentation((options) =>
{
options.EnrichWithHttpRequestMessage = (activity, request) =>
{
activity.SetTag("http.request.headers", request.Headers.ToString());
if (request.Content != null)
{
activity.SetTag("http.request.content.headers", request.Content.Headers.ToString());
var content = request.Content.ReadAsStringAsync().GetAwaiter().GetResult();
activity.SetTag("http.request.content.body", content);
}
};
options.EnrichWithHttpResponseMessage = (activity, response) =>
{
activity.SetTag("http.response.headers", response.Headers.ToString());
if (response.Content != null)
{
activity.SetTag("http.response.content.headers", response.Content.Headers.ToString());
var content = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
activity.SetTag("http.response.content.body", content);
}
};
})
.AddProcessor(new SimpleActivityExportProcessor(new FileSpanExporter(traceLogPath)))
.Build();
}
}
@@ -7,6 +7,11 @@
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\ConsoleReactiveFramework\ConsoleReactiveFramework.csproj" />
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
@@ -117,4 +118,11 @@ public interface IUXStateDriver
/// on the owning component.
/// </summary>
void RequestShutdown();
/// <summary>
/// Replaces the current agent session with the specified session (e.g., after importing
/// a serialized session from a file).
/// </summary>
/// <param name="newSession">The new session to use.</param>
Task ReplaceSessionAsync(AgentSession newSession);
}
@@ -6,26 +6,26 @@ using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>SubAgents_*</c> tool calls with human-readable details
/// Formats <c>BackgroundAgents_*</c> tool calls with human-readable details
/// for task start, continue, wait, and result retrieval operations.
/// </summary>
public sealed class SubAgentToolFormatter : ToolCallFormatter
public sealed class BackgroundAgentToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("SubAgents_", StringComparison.Ordinal);
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("BackgroundAgents_", StringComparison.Ordinal);
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"SubAgents_StartTask" => FormatStartSubTask(call),
"SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
"SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
"SubAgents_ContinueTask" => FormatContinueTask(call),
"SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
"BackgroundAgents_StartTask" => FormatStartBackgroundTask(call),
"BackgroundAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
"BackgroundAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
"BackgroundAgents_ContinueTask" => FormatContinueTask(call),
"BackgroundAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
_ => null,
};
private static string? FormatStartSubTask(FunctionCallContent call)
private static string? FormatStartBackgroundTask(FunctionCallContent call)
{
string? agentName = GetStringArgumentValue(call, "agentName");
string? description = GetStringArgumentValue(call, "description");
@@ -19,7 +19,7 @@ public sealed class TodoToolFormatter : ToolCallFormatter
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"TodoList_Add" => FormatAddTodos(call),
"TodoList_Complete" => FormatIdList(call, "ids", "Complete"),
"TodoList_Complete" => FormatCompleteTodos(call),
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
_ => null,
};
@@ -64,6 +64,50 @@ public sealed class TodoToolFormatter : ToolCallFormatter
return sb.ToString();
}
private static string? FormatCompleteTodos(FunctionCallContent call)
{
if (call.Arguments?.TryGetValue("items", out object? itemsObj) != true || itemsObj is null)
{
return null;
}
var entries = new List<(int Id, string? Reason)>();
if (itemsObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in jsonArray.EnumerateArray())
{
if (!item.TryGetProperty("id", out JsonElement idElement) || !idElement.TryGetInt32(out int id))
{
continue;
}
string? reason = item.TryGetProperty("reason", out JsonElement reasonElement)
? reasonElement.GetString()
: null;
entries.Add((id, reason));
}
}
if (entries.Count == 0)
{
return null;
}
var sb = new StringBuilder();
for (int i = 0; i < entries.Count; i++)
{
string connector = i < entries.Count - 1 ? "├─" : "└─";
sb.Append($"\n {connector} Complete #{entries[i].Id}");
if (!string.IsNullOrEmpty(entries[i].Reason))
{
sb.Append($" — {Truncate(entries[i].Reason!, 80)}");
}
}
return sb.ToString();
}
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
{
List<int>? ids = GetIntListArgumentValue(call, paramName);
@@ -56,7 +56,7 @@ public abstract class ToolCallFormatter
[
new TodoToolFormatter(),
new ModeToolFormatter(),
new SubAgentToolFormatter(),
new BackgroundAgentToolFormatter(),
new FileMemoryToolFormatter(),
new WebSearchToolFormatter(),
new FallbackToolFormatter(),
@@ -13,8 +13,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
</ItemGroup>
@@ -1,8 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use a HarnessAgent with the Harness AIContextProviders
// (TodoProvider and AgentModeProvider) for interactive research tasks with web search
// capabilities powered by Azure AI Foundry.
// This sample demonstrates how to use a HarnessAgent for interactive research tasks.
// The HarnessAgent comes pre-configured with TodoProvider, AgentModeProvider, FileMemoryProvider,
// ToolApproval, WebSearch, and OpenTelemetry — so this sample only needs custom instructions
// and a WebBrowsingTool.
// The agent plans research tasks, creates a todo list, gets user approval,
// and then executes each step — all within an interactive conversation loop.
//
@@ -15,147 +16,88 @@
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
using System.ClientModel.Primitives;
using Azure.AI.Projects;
using Azure.Identity;
using Harness.Shared.Console;
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
using SampleApp;
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
const string TracingSourceName = "Harness.Research";
// Set up OpenTelemetry tracing that writes spans to a text file.
// This captures all agent activity (tool calls, model invocations, compaction, etc.)
// as well as HTTP requests made by the underlying HttpClient transport.
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
// Create a HarnessAgent with the Harness providers (TodoProvider and AgentModeProvider)
// and research-focused instructions including the mandatory planning workflow.
var instructions =
"""
## Research Assistant Instructions
You are a research assistant. When given a research topic, research it thoroughly using web search and web browsing.
Use your knowledge to form good search queries and hypotheses, but always verify claims with the tools available to you rather than relying on memory alone.
## Mandatory planning workflow
For every new substantive user request, including short factual questions, your behavior is determined by the mode you are in.
If you are in plan mode, start with the *Plan Mode* steps, and if you are in execute mode, skip directly to the *Execute Mode* steps below.
*Plan Mode*
1. Analyze the request with the purpose of building a research plan.
2. Create a list of todo items.
3. If needed, use the provided tools to do some exploratory checks to help build a plan and determine what clarifying questions you may need from the user.
4. Ask for clarifications from the user where needed.
1. Ask each clarification one by one.
2. When asking for clarification and you have specific options in mind, present them to the user, so they can choose the option instead of having to retype the entire response.
3. Do not proceed until you have received all the needed clarifications.
4. Do short exploratory research if it helps with being able to ask sensible clarifications from the user.
5. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes.
6. Present the plan to the user and ask for approval to switch to execute mode and process the plan.
7. When approval is granted, always switch to execute mode (using the `AgentMode_Set` tool), and follow the steps for *Execute mode*.
*Execute Mode*
1. If you don't have a plan or tasks yet, analyse the user request and create tasks and a plan. (**Skip this step if you came from plan mode**)
2. Work autonomously use your best judgement to make decisions and keep progressing without asking the user questions. The goal is to have a complete, useful result ready when the user returns.
3. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable option, note your choice, and keep going.
4. Mark tasks as completed as you finish them.
5. Continue working, thinking and calling tools until you have the research result for the user.
## General Instructions
- You must check the current mode after any user input, since the user may have changed the mode themselves,
e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, meaning they want to review a plan first before execution.
- Explain your reasoning and thought process as you work through tasks.
- Explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process.
- Avoid making more than 4 tool calls in a row without explaining what you are doing.
- Do not answer the underlying question before the plan has been presented and approved.
- This rule applies even when the answer seems obvious or the task seems small.
- For short requests, use a brief micro-plan rather than skipping planning. The only exceptions are:
- greetings,
- pure acknowledgments,
- clarification questions needed to form the plan,
- follow-up questions about results you have already presented,
- meta-discussion about the workflow itself.
**Todo management**
Mark each todo complete as you finish it so the list stays current.
If a todo turns out to be unnecessary or is blocked, remove it and briefly explain why.
Once the user finishes with a topic and moves onto a new one, clean up old completed todos by deleting them.
**Research quality**
### Research quality
Consult multiple sources when possible and cross-reference key claims.
When sources disagree, note the discrepancy and explain which source you consider more reliable and why.
If a web page fails to load or a search returns irrelevant results, try alternative search queries or sources before moving on.
Track your sources you will need them when presenting results.
**Presenting results**
### Presenting results
When presenting your final findings:
- Use Markdown formatting for clarity.
- Use clear sections with headings for each major topic or sub-question.
- Cite your sources inline (e.g., "According to [source name](URL), ...").
- End with a brief summary of key takeaways.
- Save the final research report to file memory so it survives compaction and can be referenced later.
**File memory**
Use the FileMemory_* tools to:
- Store downloaded search results or web pages.
- Store plans.
- Read the current plan to make sure tasks were done according to plan.
- Store findings.
- Check for relevant previously downloaded data / findings before starting new research.
- In addition to returning the results to the user, save the final research report to file memory so it survives compaction and can be referenced later.
""";
// Create the agent using AsHarnessAgent, which pre-configures function invocation,
// per-service-call chat history persistence, and in-loop compaction.
// Then wrap with UseToolApproval to allow auto-approving tools once confirmed.
// per-service-call chat history persistence, in-loop compaction, TodoProvider, AgentModeProvider,
// FileMemoryProvider, ToolApproval, WebSearch, AgentSkillsProvider, and OpenTelemetry.
// Only custom instructions, a WebBrowsingTool, and FileAccess opt-out are needed.
AIAgent agent =
// Create an OpenAIClient that communicates with the Foundry responses service.
new OpenAIClient(
new AIProjectClient(
new Uri(endpoint),
// 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.
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
new OpenAIClientOptions()
{
Endpoint = new Uri(endpoint),
RetryPolicy = new ClientRetryPolicy(3) // Enable retries to improve resiliency.
})
new DefaultAzureCredential(),
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) }) // Enable retries to improve resiliency.
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName) // We want to manage chat history locally (not stored in the responses service), so that we can manage compaction ourselves.
.AsIChatClient(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "ResearchAgent",
Description = "A research assistant that plans and executes research tasks.",
AIContextProviders =
[
new TodoProvider(), // Add an AIContextProvider to allow the agent to create a TODO list, which is stored in the session.
new AgentModeProvider(), // Add an AIContextProvider that tracks the agent mode and allows switching mode. Current mode is stored in the session.
new FileMemoryProvider( // Add an AIContextProvider that can store memories in files under a session specific working folder.
new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
(_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() })
],
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
OpenTelemetrySourceName = TracingSourceName, // Use our custom source name so spans are captured by the TracerProvider above.
FileMemoryStore = new FileSystemAgentFileStore( // Configure the file memory provider to store files in a local folder called "agent-files".
Path.Combine(AppContext.BaseDirectory, "agent-files")),
ChatOptions = new ChatOptions
{
Instructions = instructions,
Tools =
[
ResponseTool.CreateWebSearchTool().AsAITool(), // Add the foundry hosted web search tool that runs in the service.
new WebBrowsingTool( // Add a local web browsing tool that converts html to markdown.
new WebBrowsingTool( // Add a local web browsing tool that converts html to markdown.
new WebBrowsingToolOptions { AllowPublicNetworks = true }),
],
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
Reasoning = new() { Effort = ReasoningEffort.Medium },
},
})
.AsBuilder()
.UseToolApproval() // Add the ability to auto approve tools once a user has said they don't want to be asked again. Approval rules are tied to the session.
.Build();
});
// Run the interactive console session using the shared HarnessConsole helper.
await HarnessConsole.RunAgentAsync(
@@ -13,8 +13,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
</ItemGroup>
@@ -0,0 +1,119 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use the BackgroundAgentsProvider to delegate work to background agents.
// A parent agent is given a list of stock tickers and instructed to find the closing price
// for each ticker on December 31, 2025. It delegates the web searches to a background agent.
// The HarnessAgent provides built-in WebSearch (HostedWebSearchTool) so no manual web search
// tool configuration is needed on the background agent.
//
// Special commands:
// /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.
using System.ClientModel.Primitives;
using Azure.AI.Projects;
using Azure.Identity;
using Harness.Shared.Console;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
const string TracingSourceName = "Harness.SubAgents";
// Set up OpenTelemetry tracing that writes spans to a text file.
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
// Create the AIProjectClient for communicating with the Foundry responses service.
var projectClient = new AIProjectClient(
new Uri(endpoint),
new DefaultAzureCredential(),
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) });
// --- Background agent: Web Search Agent ---
// This agent uses the HarnessAgent's built-in HostedWebSearchTool to search the web.
// Features not needed by this sub-agent are disabled.
AIAgent webSearchAgent =
projectClient
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClient(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "WebSearchAgent",
Description = "An agent that can search the web to find information.",
OpenTelemetrySourceName = TracingSourceName,
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
DisableToolApproval = true, // If enabled, this allows don't-ask-again approval functionality.
ChatOptions = new ChatOptions
{
Instructions = "You are a web search assistant. When asked to find information, use the web search tool to look it up and return a concise, factual answer.",
},
});
// --- Parent agent: Stock Price Researcher ---
// This agent orchestrates the background agent to look up stock prices in parallel.
var parentInstructions =
"""
You are a stock price research assistant. You have access to a web search background agent that can look up information on the web.
When given a list of stock tickers, your job is to find the closing price for each ticker on December 31, 2025.
## Workflow
1. For each ticker, start a background task on the WebSearchAgent asking it to find the closing price on December 31, 2025.
- Start all background tasks before waiting for any of them to complete, so they run concurrently.
2. Wait for all background tasks to complete.
3. Retrieve the results from each background task.
4. Present a summary table with the ticker symbol and closing price for each stock.
5. Clear all completed tasks to free memory.
## Important
- Always delegate web searches to the WebSearchAgent background agent. Do not try to answer from memory.
- If a background task fails or returns unclear results, continue the task with a more specific query.
- Present results in a clean markdown table format.
""";
// --- Parent agent: Stock Price Researcher ---
// This agent orchestrates the sub-agent to look up stock prices in parallel.
// Most features are disabled since the parent only needs SubAgentsProvider.
AIAgent parentAgent =
projectClient
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClient(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "StockPriceResearcher",
Description = "An agent that researches stock prices using background agents.",
OpenTelemetrySourceName = TracingSourceName,
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
DisableToolApproval = true, // If enabled, this allows don't-ask-again approval functionality.
DisableWebSearch = true,
AIContextProviders =
[
new BackgroundAgentsProvider([webSearchAgent]),
],
ChatOptions = new ChatOptions
{
Instructions = parentInstructions,
MaxOutputTokens = 16_000,
},
});
// Run the interactive console session.
await HarnessConsole.RunAgentAsync(
parentAgent,
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):");
@@ -1,24 +1,24 @@
# Harness Step 02 — SubAgents (Stock Price Research)
# Harness Step 02 — BackgroundAgents (Stock Price Research)
This sample demonstrates how to use the **SubAgentsProvider** to delegate work from a parent agent to sub-agents. Both agents use `HarnessAgent` for pre-configured function invocation, per-service-call persistence, and context-window compaction.
This sample demonstrates how to use the **BackgroundAgentsProvider** to delegate work from a parent agent to background agents. Both agents use `HarnessAgent` for pre-configured function invocation, per-service-call persistence, and context-window compaction.
## What It Does
A parent agent receives a list of stock tickers and uses a web-search sub-agent to find the closing price for each ticker on December 31, 2025. The sub-tasks run concurrently, and results are presented in a summary table.
A parent agent receives a list of stock tickers and uses a web-search background agent to find the closing price for each ticker on December 31, 2025. The background tasks run concurrently, and results are presented in a summary table.
### Architecture
```
┌─────────────────────────────────┐
│ StockPriceResearcher │
│ (Parent Agent) │
│ │
SubAgentsProvider │
│ ├─ SubAgents_StartTask │
│ ├─ SubAgents_WaitFor... │
│ ├─ SubAgents_GetTaskResults │
│ └─ ... │
└────────────┬────────────────────┘
┌────────────────────────────────────────
│ StockPriceResearcher
│ (Parent Agent)
BackgroundAgentsProvider │
│ ├─ BackgroundAgents_StartTask │
│ ├─ BackgroundAgents_WaitFor... │
│ ├─ BackgroundAgents_GetTaskResults │
│ └─ ...
└────────────┬───────────────────────────
│ delegates to
┌─────────────────────────────────┐
@@ -40,7 +40,7 @@ A parent agent receives a list of stock tickers and uses a web-search sub-agent
## Running the Sample
```bash
cd dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents
cd dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents
dotnet run
```
@@ -50,4 +50,4 @@ When prompted, enter a list of stock tickers such as:
BAC, MSFT, BA
```
The parent agent will delegate each ticker lookup to the web search sub-agent concurrently and present the results in a table.
The parent agent will delegate each ticker lookup to the web search background agent concurrently and present the results in a table.
@@ -1,106 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use the SubAgentsProvider to delegate work to sub-agents.
// A parent agent is given a list of stock tickers and instructed to find the closing price
// for each ticker on December 31, 2025. It delegates the web searches to a sub-agent
// equipped with Foundry's hosted web search tool.
//
// Special commands:
// /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.
using System.ClientModel.Primitives;
using Azure.Identity;
using Harness.Shared.Console;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
// --- Sub-agent: Web Search Agent ---
// This agent can search the web and is used by the parent agent to look up stock prices.
AIAgent webSearchAgent =
new OpenAIClient(
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
new OpenAIClientOptions()
{
Endpoint = new Uri(endpoint),
RetryPolicy = new ClientRetryPolicy(3)
})
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "WebSearchAgent",
Description = "An agent that can search the web to find information.",
ChatOptions = new ChatOptions
{
Instructions = "You are a web search assistant. When asked to find information, use the web search tool to look it up and return a concise, factual answer.",
Tools =
[
ResponseTool.CreateWebSearchTool().AsAITool(),
],
},
});
// --- Parent agent: Stock Price Researcher ---
// This agent orchestrates the sub-agent to look up stock prices in parallel.
var parentInstructions =
"""
You are a stock price research assistant. You have access to a web search sub-agent that can look up information on the web.
When given a list of stock tickers, your job is to find the closing price for each ticker on December 31, 2025.
## Workflow
1. For each ticker, start a sub-task on the WebSearchAgent asking it to find the closing price on December 31, 2025.
- Start all sub-tasks before waiting for any of them to complete, so they run concurrently.
2. Wait for all sub-tasks to complete.
3. Retrieve the results from each sub-task.
4. Present a summary table with the ticker symbol and closing price for each stock.
5. Clear all completed tasks to free memory.
## Important
- Always delegate web searches to the WebSearchAgent sub-agent. Do not try to answer from memory.
- If a sub-task fails or returns unclear results, continue the task with a more specific query.
- Present results in a clean markdown table format.
""";
AIAgent parentAgent =
new OpenAIClient(
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
new OpenAIClientOptions()
{
Endpoint = new Uri(endpoint),
RetryPolicy = new ClientRetryPolicy(3)
})
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "StockPriceResearcher",
Description = "An agent that researches stock prices using sub-agents.",
AIContextProviders =
[
new SubAgentsProvider([webSearchAgent]),
],
ChatOptions = new ChatOptions
{
Instructions = parentInstructions,
MaxOutputTokens = 16_000,
},
});
// Run the interactive console session.
await HarnessConsole.RunAgentAsync(
parentAgent,
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):");
@@ -13,13 +13,13 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="data\**\*" CopyToOutputDirectory="PreserveNewest" />
<Content Include="working\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -1,10 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use a HarnessAgent with the FileAccessProvider
// This sample demonstrates how to use a HarnessAgent with the default FileAccessProvider
// to give an agent access to a folder of CSV data files. The agent can read, analyze,
// and extract information from the data, then write results back as new files.
//
// The sample includes a pre-populated `data/` folder with sales transaction data.
// The sample includes a pre-populated `working/` folder with sales transaction data.
// The HarnessAgent's default FileAccessProvider uses `{cwd}/working` as its working directory,
// which matches this sample's folder layout.
// Ask the agent to analyze the data, produce summaries, or create new output files.
//
// Special commands:
@@ -14,22 +16,21 @@
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
using System.ClientModel.Primitives;
using Azure.AI.Projects;
using Azure.Identity;
using Harness.Shared.Console;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
const string TracingSourceName = "Harness.DataProcessing";
// Point the file store at the data/ folder that ships with the sample.
var dataFolder = Path.Combine(AppContext.BaseDirectory, "data");
var fileStore = new FileSystemAgentFileStore(dataFolder);
// Set up OpenTelemetry tracing that writes spans to a text file.
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
var instructions =
"""
@@ -56,25 +57,27 @@ var instructions =
- Always explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process.
""";
// Create the chat client from the OpenAI provider.
// Create the agent using AsHarnessAgent. The FileAccessStore is explicitly set to the
// sample's working/ folder (copied to the output directory) so it works regardless of cwd.
// Unused features are disabled.
AIAgent agent =
new OpenAIClient(
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
new OpenAIClientOptions()
{
Endpoint = new Uri(endpoint),
RetryPolicy = new ClientRetryPolicy(3)
})
new AIProjectClient(
new Uri(endpoint),
new DefaultAzureCredential(),
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) })
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName)
.AsIChatClient(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "DataAnalyst",
Description = "A data analyst assistant that reads, analyzes, and processes data files.",
AIContextProviders =
[
new FileAccessProvider(fileStore),
],
OpenTelemetrySourceName = TracingSourceName,
FileAccessStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "working")),
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
DisableWebSearch = true,
ChatOptions = new ChatOptions
{
Instructions = instructions,
@@ -1,11 +1,11 @@
# What this sample demonstrates
This sample demonstrates how to use a `HarnessAgent` with the `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, and in-loop compaction — so the sample only needs to supply the chat client, token limits, and application-specific options.
This sample demonstrates how to use a `HarnessAgent` with the default `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, in-loop compaction, tool approval, and OpenTelemetry — so the sample only needs to supply the chat client, token limits, custom instructions, and opt out of unused features.
Key features showcased:
- **HarnessAgent** — a pre-configured agent that wraps a `ChatClientAgent` with function invocation, per-service-call persistence, and context-window compaction
- **FileAccessProvider** — gives the agent tools to read, write, list, search, and delete files in a shared data folder
- **FileAccessProvider** — the HarnessAgent's default file access provider uses `{cwd}/working` as its working directory, matching this sample's `working/` folder
- **CSV data processing** — the agent reads sales transaction data and performs analysis on demand
- **Output file creation** — the agent can write summaries, filtered data, or reports back to the data folder
- **Streaming output** — responses are streamed token-by-token for a natural experience
@@ -39,7 +39,7 @@ dotnet run --project samples/02-agents/Harness/Harness_Step03_DataProcessing
## What to Expect
The sample starts an interactive conversation with a data analyst agent. The `data/` folder contains a `sales.csv` file with ~50 rows of sales transaction data (date, product, category, quantity, unit price, region, salesperson).
The sample starts an interactive conversation with a data analyst agent. The `working/` folder contains a `sales.csv` file with ~50 rows of sales transaction data (date, product, category, quantity, unit price, region, salesperson).
You can ask the agent to:
@@ -53,7 +53,7 @@ E.g. try the following prompt `Please process the sales.csv file by first filter
## Sample Data
The included `data/sales.csv` contains sales transactions from January to March 2025 with the following columns:
The included `working/sales.csv` contains sales transactions from January to March 2025 with the following columns:
| Column | Description |
| --- | --- |
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Hyperlight.HyperlightSandbox.Guest.Python" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="skills\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,122 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates a HarnessAgent with ALL features enabled, plus:
// - Hyperlight CodeAct (HyperlightCodeActProvider) for sandboxed Python code execution
// - Skills (AgentSkillsProvider) discovering a local "regex-tester" skill
//
// The agent can plan tasks with todos, manage modes, store memories, read/write files,
// search the web, approve sensitive tools, discover and use skills, and execute arbitrary
// Python code in a Hyperlight sandbox — all pre-configured by the HarnessAgent.
//
// Try asking: "Help me write a regex that matches valid email addresses, then test it."
//
// Special commands:
// /todos — Display the current todo list without invoking the agent.
// /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.
using System.ClientModel.Primitives;
using Azure.AI.Projects;
using Azure.Identity;
using Harness.Shared.Console;
using HyperlightSandbox.Guest.Python;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hyperlight;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
const string TracingSourceName = "Harness.CodeExecution";
// Set up OpenTelemetry tracing that writes spans to a text file.
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
// Create the HyperlightCodeActProvider with the Python/Wasm backend.
// The guest module path is resolved automatically from the Hyperlight.HyperlightSandbox.Guest.Python NuGet package.
using var codeAct = new HyperlightCodeActProvider(
HyperlightCodeActProviderOptions.CreateForWasm(PythonGuestModule.GetModulePath()));
var instructions =
"""
## Technical Assistant Instructions
You are a code-powered technical assistant. You can execute Python code in a sandboxed environment
to solve problems precisely rather than guessing. You also have access to skills that provide
structured workflows for specific technical tasks.
### Code Execution
When a problem requires computation, validation, or testing:
- Write Python code and use `execute_code` to run it in the sandbox.
- Always verify results by running the code rather than reasoning about what would happen.
- If code fails, read the error message carefully, fix the issue, and retry.
### Skills
You have access to discoverable skills. When a task matches a skill's description:
- Follow the skill's instructions carefully.
- Use the skill's reference materials for context.
- Combine the skill's workflow with code execution when appropriate.
### Planning and Research
For complex tasks:
- Break the problem into steps using your todo list.
- Research background information using web search when needed.
- Save important findings to file memory for later reference.
### Presenting Results
- Show your work: include the code you ran and its output.
- Explain what each part of your solution does.
- If applicable, save final results to file memory.
""";
// Create the agent with ALL HarnessAgent features enabled plus Hyperlight CodeAct.
// No Disable* flags are set — TodoProvider, AgentModeProvider, FileMemory, FileAccess,
// ToolApproval, WebSearch, and AgentSkillsProvider are all active.
AIAgent agent =
new AIProjectClient(
new Uri(endpoint),
new DefaultAzureCredential(),
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) })
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClient(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "CodeExecutionAgent",
Description = "A technical assistant with sandboxed code execution and skill-based workflows.",
OpenTelemetrySourceName = TracingSourceName,
// Point the file memory at a local folder for persistent memory across sessions.
FileMemoryStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
// Add the HyperlightCodeActProvider so the agent can execute Python code in a sandbox.
AIContextProviders = [codeAct],
ChatOptions = new ChatOptions
{
Instructions = instructions,
MaxOutputTokens = MaxOutputTokens,
Reasoning = new() { Effort = ReasoningEffort.Medium },
},
});
// Run the interactive console session using the shared HarnessConsole helper.
await HarnessConsole.RunAgentAsync(
agent,
userPrompt: "Ask me a technical question, or try: \"Help me write a regex that matches valid email addresses.\"",
new HarnessConsoleOptions
{
Observers = HarnessConsoleOptions.BuildObserversWithPlanning(
agent,
planModeName: "plan",
executionModeName: "execute",
maxContextWindowTokens: MaxContextWindowTokens,
maxOutputTokens: MaxOutputTokens),
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
});
@@ -0,0 +1,51 @@
# Harness Step 04 — Code Execution (Hyperlight + Skills)
This sample demonstrates a HarnessAgent with **all features enabled**, plus:
- **Hyperlight CodeAct** — sandboxed Python code execution via `execute_code` (requires KVM)
- **Skills** — file-based skill discovery (a `regex-tester` skill is included)
The agent can plan tasks, manage modes, store memories, read/write files, search the web, approve sensitive operations, discover and use skills, and execute arbitrary Python code — all pre-configured by the HarnessAgent.
## Prerequisites
- .NET 10 SDK
- An Azure AI Foundry project endpoint
- KVM-capable host (the Hyperlight sandbox runs code in micro-VMs)
## Environment Variables
| Variable | Description |
|----------|-------------|
| `AZURE_AI_PROJECT_ENDPOINT` | Your Azure AI Foundry project endpoint |
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Model deployment name (default: `gpt-5.4`) |
## Running
```bash
dotnet run
```
## What to Try
- **Regex testing**: "Help me write a regex that matches valid email addresses, then test it against some examples."
- **Code execution**: "Calculate the first 20 prime numbers using the Sieve of Eratosthenes."
- **Skill + code combo**: "I need a regex for ISO 8601 dates — test it thoroughly with edge cases."
## Included Skill
The `skills/regex-tester/` skill instructs the agent to validate regex patterns by executing Python test code in the Hyperlight sandbox. It includes a regex cheatsheet as reference material.
## Features Enabled
| Feature | Description |
|---------|-------------|
| TodoProvider | Task planning and tracking (`/todos` command) |
| AgentModeProvider | Mode switching (`/mode` command) |
| FileMemoryProvider | Persistent memory stored as files |
| FileAccessProvider | Read/write files in a working directory |
| ToolApproval | Don't-ask-again approval for sensitive tools |
| WebSearch | Built-in hosted web search |
| AgentSkillsProvider | Discovers and uses skills from the `skills/` folder |
| HyperlightCodeActProvider | Sandboxed Python execution via `execute_code` |
| OpenTelemetry | Trace logging to a text file |
@@ -0,0 +1,36 @@
---
name: regex-tester
description: Validate, test, and debug regular expressions by executing them against sample inputs. Use when asked to build, verify, or explain a regex pattern.
---
## Usage
When the user asks you to create, validate, or debug a regular expression:
1. **Understand the requirement** — clarify what the pattern should match and what it should reject.
2. **Consult the cheatsheet** — review `references/regex-cheatsheet.md` for syntax reminders if needed.
3. **Write and execute test code** — use the `execute_code` tool to run Python code that:
- Compiles the regex with `re.compile()`
- Tests it against a set of positive examples (should match) and negative examples (should not match)
- Extracts and displays any capturing groups
- Reports pass/fail for each test case
4. **Iterate** — if any test fails, refine the pattern and re-run until all cases pass.
5. **Present the result** — give the user the final pattern, explain what each part does, and show the test results.
## Example Test Script
```python
import re
pattern = re.compile(r'^[\w.+-]+@[\w-]+\.[\w.-]+$')
positives = ["user@example.com", "first.last+tag@sub.domain.org"]
negatives = ["@missing.com", "no-at-sign", "spaces in@address.com"]
for s in positives:
assert pattern.match(s), f"FAIL: expected match for '{s}'"
for s in negatives:
assert not pattern.match(s), f"FAIL: expected no match for '{s}'"
print("All tests passed!")
```
@@ -0,0 +1,97 @@
# Regex Quick Reference (Python `re` module)
## Character Classes
| Pattern | Matches |
|---------|---------|
| `.` | Any character except newline |
| `\d` | Digit `[0-9]` |
| `\D` | Non-digit |
| `\w` | Word character `[a-zA-Z0-9_]` |
| `\W` | Non-word character |
| `\s` | Whitespace `[ \t\n\r\f\v]` |
| `\S` | Non-whitespace |
| `[abc]` | Any of a, b, or c |
| `[^abc]`| Any character except a, b, c |
| `[a-z]` | Range: a through z |
## Quantifiers
| Pattern | Meaning |
|---------|---------|
| `*` | 0 or more (greedy) |
| `+` | 1 or more (greedy) |
| `?` | 0 or 1 (greedy) |
| `{n}` | Exactly n |
| `{n,}` | n or more |
| `{n,m}` | Between n and m |
| `*?`, `+?`, `??` | Non-greedy versions |
## Anchors
| Pattern | Meaning |
|---------|---------|
| `^` | Start of string (or line with `re.MULTILINE`) |
| `$` | End of string (or line with `re.MULTILINE`) |
| `\b` | Word boundary |
| `\B` | Non-word boundary |
## Groups and Backreferences
| Pattern | Meaning |
|---------|---------|
| `(...)` | Capturing group |
| `(?:...)`| Non-capturing group |
| `(?P<name>...)` | Named group |
| `\1` | Backreference to group 1 |
| `(?=...)` | Positive lookahead |
| `(?!...)` | Negative lookahead |
| `(?<=...)` | Positive lookbehind |
| `(?<!...)` | Negative lookbehind |
## Flags
| Flag | Effect |
|------|--------|
| `re.IGNORECASE` / `re.I` | Case-insensitive matching |
| `re.MULTILINE` / `re.M` | `^`/`$` match line boundaries |
| `re.DOTALL` / `re.S` | `.` matches newline |
| `re.VERBOSE` / `re.X` | Allow comments and whitespace |
## Common Patterns
| Use Case | Pattern |
|----------|---------|
| Email (simple) | `^[\w.+-]+@[\w-]+\.[\w.-]+$` |
| IPv4 address | `^\d{1,3}(\.\d{1,3}){3}$` |
| ISO date | `^\d{4}-\d{2}-\d{2}$` |
| URL (http/https) | `^https?://[^\s/$.?#].[^\s]*$` |
| Phone (US) | `^(\+1)?[-.\s]?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}$` |
## Python API
```python
import re
# Test if a string matches
re.match(r'pattern', "string") # match at start
re.search(r'pattern', "string") # match anywhere
re.fullmatch(r'pattern', "string") # match entire string
# Find all matches
re.findall(r'\d+', "abc 123 def 456") # ['123', '456']
# Named groups
m = re.match(r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})', "2025-01-15")
m.group('year') # '2025'
# Replace
re.sub(r'\d+', 'X', "abc 123 def") # 'abc X def'
# Split
re.split(r',+', "a,b,,c") # ['a', 'b', 'c']
# Compile for reuse
pattern = re.compile(r'^\d{4}-\d{2}-\d{2}$')
pattern.match("2025-01-15") # Match object
```
+1 -1
View File
@@ -7,5 +7,5 @@ Samples demonstrating the [Harness AIContextProviders](../../../src/Microsoft.Ag
| Sample | Description |
| --- | --- |
| [Harness_Step01_Research](./Harness_Step01_Research/README.md) | Using a ChatClientAgent with TodoProvider and AgentModeProvider for research, showcasing planning mode and todo management |
| [Harness_Step02_Research_WithSubAgents](./Harness_Step02_Research_WithSubAgents/README.md) | Using SubAgentsProvider to delegate stock price lookups to a web-search sub-agent concurrently |
| [Harness_Step02_Research_WithBackgroundAgents](./Harness_Step02_Research_WithBackgroundAgents/README.md) | Using BackgroundAgentsProvider to delegate stock price lookups to a web-search background agent concurrently |
| [Harness_Step03_DataProcessing](./Harness_Step03_DataProcessing/README.md) | Using FileAccessProvider to give an agent access to CSV data files for reading, analysis, and output generation |
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="DotNetEnv" />
@@ -22,6 +22,7 @@
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
@@ -14,6 +14,7 @@ using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
@@ -66,14 +67,15 @@ AIAgent agent = new AIProjectClient(new Uri(projectEndpoint), credential)
// Host the agent as a Foundry Hosted Agent using the Responses API.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
var app = builder.Build();
app.MapFoundryResponses();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
app.Run();
@@ -46,10 +46,9 @@ builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debuggi
var app = builder.Build();
app.MapFoundryResponses();
// In Development, also map the OpenAI-compatible route that AIProjectClient uses.
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
app.Run();
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -28,6 +28,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
@@ -35,6 +35,7 @@ using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
@@ -175,14 +176,15 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
var app = builder.Build();
app.MapFoundryResponses();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
app.Run();
@@ -39,10 +39,9 @@ builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debuggi
var app = builder.Build();
app.MapFoundryResponses();
// In Development, also map the OpenAI-compatible route that AIProjectClient uses.
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
app.Run();
@@ -118,10 +118,10 @@ builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debuggi
var app = builder.Build();
app.MapFoundryResponses();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
app.Run();
@@ -87,10 +87,9 @@ builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debuggi
var app = builder.Build();
app.MapFoundryResponses();
// In Development, also map the OpenAI-compatible route that AIProjectClient uses.
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
app.Run();
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -79,10 +79,9 @@ builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debuggi
var app = builder.Build();
app.MapFoundryResponses();
// In Development, also map the OpenAI-compatible route that AIProjectClient uses.
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
app.Run();
@@ -1,7 +1,6 @@
.env
bin/
obj/
out/
.vs/
.vscode/
*.user
@@ -66,9 +66,9 @@ builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debuggi
var app = builder.Build();
app.MapFoundryResponses();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
app.Run();
@@ -53,10 +53,10 @@ builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debuggi
var app = builder.Build();
app.MapFoundryResponses();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
app.Run();
@@ -69,10 +69,10 @@ builder.Services.AddFoundryToolboxes(toolboxName);
var app = builder.Build();
app.MapFoundryResponses();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
app.Run();
@@ -55,9 +55,9 @@ builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debuggi
var app = builder.Build();
app.MapFoundryResponses();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
app.Run();
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Hosting;
namespace Hosted_Shared_Contributor_Setup;
/// <summary>
/// Routing helpers for contributor samples that host a Foundry-managed agent locally.
/// </summary>
public static class HostedContributorRouteExtensions
{
/// <summary>
/// In Development, maps the per-agent OpenAI route shape that live Foundry uses
/// (<c>/api/projects/{project}/agents/{agentName}/endpoint/protocols/openai/responses</c>) on top
/// of the default <c>MapFoundryResponses()</c> so a local REPL client can reach the agent through
/// <c>AIProjectClient.AsAIAgent(Uri agentEndpoint)</c>, which is the only supported consumption path
/// for Foundry-hosted agents.
///
/// <para>
/// The <c>{project}</c> and <c>{agentName}</c> segments are route-parameter wildcards on the server
/// side; the handler does not consume them, so any value sent by the client is accepted.
/// </para>
///
/// <para><b>For local contributor debugging only and should not be used in production.</b></para>
/// </summary>
/// <param name="app">The <see cref="WebApplication"/> to attach the routes to.</param>
/// <returns>The same <see cref="WebApplication"/> for chaining.</returns>
public static WebApplication MapDevTemporaryLocalAgentEndpoint(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("api/projects/{project}/agents/{agentName}/endpoint/protocols/openai");
}
return app;
}
}
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel.Primitives;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Azure.Identity;
using DotNetEnv;
@@ -11,28 +10,34 @@ using Microsoft.Agents.AI.Foundry;
// Load .env file if present (for local development)
Env.TraversePath().Load();
Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT")
?? "http://localhost:8088");
// AZURE_AI_PROJECT_ENDPOINT is the Foundry project endpoint. Shape:
// https://<host>/api/projects/<project>
Uri projectEndpoint = new(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."));
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
?? throw new InvalidOperationException("AGENT_NAME is not set.");
// AZURE_AI_AGENT_NAME is the registered server-side agent name.
string agentName = Environment.GetEnvironmentVariable("AZURE_AI_AGENT_NAME")
?? throw new InvalidOperationException("AZURE_AI_AGENT_NAME is not set.");
// ── Create an agent-framework agent backed by the remote Hosted-Files agent ──
// Derive the per-agent OpenAI endpoint that hosted Foundry agents require.
Uri agentEndpoint = new($"{projectEndpoint}/agents/{agentName}/endpoint/protocols/openai");
// ── Create an agent-framework agent backed by the remote agent endpoint ──────
var options = new AIProjectClientOptions();
if (agentEndpoint.Scheme == "http")
if (projectEndpoint.Scheme == "http")
{
// For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy
// BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right
// before the request hits the wire.
projectEndpoint = new UriBuilder(projectEndpoint) { Scheme = "https" }.Uri;
agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri;
options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport);
}
var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options);
FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName));
var aiProjectClient = new AIProjectClient(projectEndpoint, new AzureCliCredential(), options);
FoundryAgent agent = aiProjectClient.AsAIAgent(agentEndpoint);
AgentSession session = await agent.CreateSessionAsync();
@@ -41,10 +46,10 @@ AgentSession session = await agent.CreateSessionAsync();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"""
══════════════════════════════════════════════════════════
Session Files Client
Session Files Client
Connected to: {agentEndpoint}
Try: "Give me the total revenue in the contoso file."
Type a message or 'quit' to exit
Type a message or 'quit' to exit
""");
Console.ResetColor();
@@ -13,18 +13,18 @@ The agent's container-side `ListFiles` and `ReadFile` tools surface the bundled
## Configuration
```env
AGENT_ENDPOINT=http://localhost:8088
AGENT_NAME=hosted-files
AZURE_AI_PROJECT_ENDPOINT=https://<host>/api/projects/<project>
AZURE_AI_AGENT_NAME=hosted-files
```
`AGENT_ENDPOINT` defaults to `http://localhost:8088`. Override with the deployed agent endpoint when chatting against Foundry.
Both are required. `AZURE_AI_PROJECT_ENDPOINT` is the Foundry project endpoint URL and `AZURE_AI_AGENT_NAME` is the registered server-side agent name. The sample builds the per-agent OpenAI endpoint URL from these.
## Run
```bash
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient
$env:AGENT_ENDPOINT = "http://localhost:8088"
$env:AGENT_NAME = "hosted-files"
$env:AZURE_AI_PROJECT_ENDPOINT = "http://localhost:8088/api/projects/local"
$env:AZURE_AI_AGENT_NAME = "hosted-files"
dotnet run
```
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel.Primitives;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Azure.Identity;
using DotNetEnv;
@@ -11,28 +10,34 @@ using Microsoft.Agents.AI.Foundry;
// Load .env file if present (for local development)
Env.TraversePath().Load();
Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT")
?? "http://localhost:8088");
// AZURE_AI_PROJECT_ENDPOINT is the Foundry project endpoint. Shape:
// https://<host>/api/projects/<project>
Uri projectEndpoint = new(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."));
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
?? throw new InvalidOperationException("AGENT_NAME is not set.");
// AZURE_AI_AGENT_NAME is the registered server-side agent name.
string agentName = Environment.GetEnvironmentVariable("AZURE_AI_AGENT_NAME")
?? throw new InvalidOperationException("AZURE_AI_AGENT_NAME is not set.");
// Derive the per-agent OpenAI endpoint that hosted Foundry agents require.
Uri agentEndpoint = new($"{projectEndpoint}/agents/{agentName}/endpoint/protocols/openai");
// ── Create an agent-framework agent backed by the remote agent endpoint ──────
var options = new AIProjectClientOptions();
if (agentEndpoint.Scheme == "http")
if (projectEndpoint.Scheme == "http")
{
// For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy
// BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right
// before the request hits the wire.
projectEndpoint = new UriBuilder(projectEndpoint) { Scheme = "https" }.Uri;
agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri;
options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport);
}
var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options);
FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName));
var aiProjectClient = new AIProjectClient(projectEndpoint, new AzureCliCredential(), options);
FoundryAgent agent = aiProjectClient.AsAIAgent(agentEndpoint);
AgentSession session = await agent.CreateSessionAsync();
@@ -41,9 +46,9 @@ AgentSession session = await agent.CreateSessionAsync();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"""
══════════════════════════════════════════════════════════
Simple Agent Sample
Simple Agent Sample
Connected to: {agentEndpoint}
Type a message or 'quit' to exit
Type a message or 'quit' to exit
══════════════════════════════════════════════════════════
""");
Console.ResetColor();
@@ -66,6 +66,42 @@ public static partial class AzureAIProjectChatClientExtensions
return new FoundryAgent(aiProjectClient, innerAgent);
}
/// <summary>
/// Wraps an existing server side hosted agent as a <see cref="FoundryAgent"/> using the provided
/// <see cref="AIProjectClient"/> and an agent-specific endpoint URI.
/// </summary>
/// <param name="aiProjectClient">The <see cref="AIProjectClient"/> to use for project-level operations. Cannot be <see langword="null"/>.</param>
/// <param name="agentEndpoint">
/// The agent-specific endpoint URI of shape
/// <c>https://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/endpoint/protocols/openai</c>.
/// The agent name is parsed from this URI and the active agent version is resolved server side
/// from the endpoint's administrator-controlled version selector. Cannot be <see langword="null"/>.
/// </param>
/// <param name="tools">The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
/// <returns>A <see cref="FoundryAgent"/> instance that routes calls through the supplied agent endpoint.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="aiProjectClient"/> or <paramref name="agentEndpoint"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="agentEndpoint"/> does not match the expected agent-endpoint shape.</exception>
/// <remarks>
/// Agent version selection is controlled by the Foundry administrator through the endpoint's
/// version selector and cannot be overridden by the caller. Use the
/// <see cref="AsAIAgent(AIProjectClient, AgentReference, IList{AITool}?, Func{IChatClient, IChatClient}?, IServiceProvider?)"/>
/// overload when an explicit agent version pin is required.
/// </remarks>
public static FoundryAgent AsAIAgent(
this AIProjectClient aiProjectClient,
Uri agentEndpoint,
IList<AITool>? tools = null,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null)
{
Throw.IfNull(aiProjectClient);
Throw.IfNull(agentEndpoint);
return new FoundryAgent(aiProjectClient, agentEndpoint, tools, clientFactory, services);
}
/// <summary>
/// Uses an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/> and <see cref="ProjectsAgentRecord"/>.
/// </summary>
@@ -40,27 +40,9 @@ namespace Microsoft.Agents.AI.Foundry;
public sealed class FoundryAgent : DelegatingAIAgent
{
/// <summary>
/// Default OAuth scope for the Azure AI resource. Matches the scope used by
/// <c>Azure.AI.Extensions.OpenAI</c>'s internal authentication helper so the bearer token is
/// accepted by the Foundry control plane.
/// The cached <see cref="AIProjectClient"/> supplied to or constructed by the active constructor.
/// </summary>
private const string AzureAiResourceScope = "https://ai.azure.com/.default";
/// <summary>
/// The cached <see cref="AIProjectClient"/> when one was supplied or constructed by the active
/// constructor. Null when the agent was constructed via the agent-endpoint constructor, which
/// does not build a full <see cref="AIProjectClient"/>.
/// </summary>
private readonly AIProjectClient? _aiProjectClient;
/// <summary>
/// Project-scoped <see cref="ProjectOpenAIClient"/>. Always non-null. Used for project-level
/// operations such as <see cref="CreateConversationSessionAsync(CancellationToken)"/>.
/// In agent-endpoint mode this is built directly from the project root derived from the
/// supplied agent endpoint; in project-endpoint mode it is the cached client returned by
/// <see cref="AIProjectClient"/>.
/// </summary>
private readonly ProjectOpenAIClient _projectOpenAIClient;
private readonly AIProjectClient _aiProjectClient;
/// <summary>
/// Initializes a new instance of the <see cref="FoundryAgent"/> class using the direct Responses API path.
@@ -94,7 +76,6 @@ public sealed class FoundryAgent : DelegatingAIAgent
out var aiProjectClient))
{
this._aiProjectClient = aiProjectClient;
this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
}
/// <summary>
@@ -106,11 +87,9 @@ public sealed class FoundryAgent : DelegatingAIAgent
/// </param>
/// <param name="credential">The authentication credential.</param>
/// <param name="clientOptions">
/// Optional configuration for the underlying <see cref="ProjectOpenAIClient"/>. When supplied:
/// Optional configuration for the underlying <see cref="ProjectResponsesClient"/>. When supplied:
/// <list type="bullet">
/// <item><description>The instance is passed through to the per-agent client; pipeline policies added via <c>AddPolicy(...)</c> on it execute on the per-agent traffic.</description></item>
/// <item><description><c>Endpoint</c> and <see cref="ProjectOpenAIClientOptions.AgentName"/> are owned by this constructor and are overwritten with values derived from <paramref name="agentEndpoint"/>; any caller value is replaced.</description></item>
/// <item><description>For the project-level conversations client a separate fresh options bag is built that copies only <see cref="ClientPipelineOptions.RetryPolicy"/>, <see cref="ClientPipelineOptions.NetworkTimeout"/>, <see cref="ClientPipelineOptions.Transport"/>, and <c>UserAgentApplicationId</c>; pipeline policies added via <c>AddPolicy(...)</c> do <strong>not</strong> propagate to the conversations pipeline.</description></item>
/// </list>
/// </param>
/// <param name="tools">Optional tools to use when interacting with the agent.</param>
@@ -134,9 +113,34 @@ public sealed class FoundryAgent : DelegatingAIAgent
IList<AITool>? tools = null,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null)
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services))
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services, out var aiProjectClient))
{
this._projectOpenAIClient = CreateProjectLevelOpenAIClientFromAgentEndpoint(agentEndpoint, credential, clientOptions);
this._aiProjectClient = aiProjectClient;
}
/// <summary>
/// Initializes a new instance of the <see cref="FoundryAgent"/> class from an agent-specific
/// endpoint while reusing an existing <see cref="AIProjectClient"/>.
/// </summary>
/// <param name="aiProjectClient">An existing <see cref="AIProjectClient"/> rooted at the same project as <paramref name="agentEndpoint"/>.</param>
/// <param name="agentEndpoint">
/// The agent-specific endpoint URI. Must be of the shape
/// <c>https://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/endpoint/protocols/openai</c>.
/// </param>
/// <param name="tools">Optional tools to use when interacting with the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/>.</param>
/// <param name="services">Optional service provider for resolving dependencies required by AI functions.</param>
/// <exception cref="ArgumentNullException"><paramref name="aiProjectClient"/> or <paramref name="agentEndpoint"/> is null.</exception>
/// <exception cref="ArgumentException"><paramref name="agentEndpoint"/> does not match the expected agent-endpoint shape.</exception>
internal FoundryAgent(
AIProjectClient aiProjectClient,
Uri agentEndpoint,
IList<AITool>? tools = null,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null)
: base(BuildAgentEndpointInnerAgent(aiProjectClient, agentEndpoint, clientOptions: null, tools, clientFactory, services))
{
this._aiProjectClient = Throw.IfNull(aiProjectClient);
}
/// <summary>
@@ -146,7 +150,6 @@ public sealed class FoundryAgent : DelegatingAIAgent
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
{
this._aiProjectClient = Throw.IfNull(aiProjectClient);
this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
}
#region Convenience methods
@@ -179,7 +182,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
/// <returns>A <see cref="ChatClientAgentSession"/> linked to the newly created server-side conversation.</returns>
public async Task<ChatClientAgentSession> CreateConversationSessionAsync(CancellationToken cancellationToken = default)
{
var conversationsClient = this._projectOpenAIClient.GetProjectConversationsClient();
var conversationsClient = this._aiProjectClient.ProjectOpenAIClient.GetProjectConversationsClient();
var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value;
@@ -201,11 +204,6 @@ public sealed class FoundryAgent : DelegatingAIAgent
return this._aiProjectClient;
}
if (serviceKey is null && serviceType == typeof(ProjectOpenAIClient))
{
return this._projectOpenAIClient;
}
return base.GetService(serviceType, serviceKey);
}
@@ -291,11 +289,10 @@ public sealed class FoundryAgent : DelegatingAIAgent
/// <summary>
/// Builds the inner <see cref="ChatClientAgent"/> for the agent-endpoint constructor by
/// constructing a per-agent <see cref="ProjectOpenAIClient"/> via the
/// <c>ProjectOpenAIClient(AuthenticationPolicy, ProjectOpenAIClientOptions)</c>
/// constructor with <see cref="ProjectOpenAIClientOptions.AgentName"/> set. This routes the
/// outbound URL through the per-agent endpoint shape that the Foundry service expects for
/// hosted agents and lets the SDK auto-append the <c>api-version</c> query string.
/// constructing a project-scoped <see cref="ProjectOpenAIClient"/> and using
/// <see cref="ProjectOpenAIClient.GetProjectResponsesClientForAgentEndpoint(string, string?, ProjectOpenAIClientOptions?)"/>.
/// This routes the outbound URL through the per-agent endpoint shape that the Foundry service
/// expects for hosted agents and lets the SDK auto-append the <c>api-version</c> query string.
/// Caller-supplied <paramref name="clientOptions"/> are passed through to the per-agent
/// client with <c>Endpoint</c> and
/// <see cref="ProjectOpenAIClientOptions.AgentName"/> overridden by values derived from
@@ -308,22 +305,44 @@ public sealed class FoundryAgent : DelegatingAIAgent
ProjectOpenAIClientOptions? clientOptions,
IList<AITool>? tools,
Func<IChatClient, IChatClient>? clientFactory,
IServiceProvider? services)
IServiceProvider? services,
out AIProjectClient outClient)
{
Throw.IfNull(agentEndpoint);
Throw.IfNull(credential);
var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint);
outClient = CreateProjectClient(projectRoot, credential, CreateProjectClientOptions(clientOptions));
return BuildAgentEndpointInnerAgent(outClient, agentEndpoint, clientOptions, tools, clientFactory, services);
}
/// <summary>
/// Builds the inner <see cref="ChatClientAgent"/> for an agent endpoint against a pre-built
/// <see cref="AIProjectClient"/>. The caller is responsible for ensuring the supplied client
/// is rooted at the same project as <paramref name="agentEndpoint"/>; the agent name is
/// parsed from the endpoint URI and passed to
/// <see cref="ProjectOpenAIClient.GetProjectResponsesClientForAgentEndpoint(string, string?, ProjectOpenAIClientOptions?)"/>.
/// </summary>
private static AIAgent BuildAgentEndpointInnerAgent(
AIProjectClient aiProjectClient,
Uri agentEndpoint,
ProjectOpenAIClientOptions? clientOptions,
IList<AITool>? tools,
Func<IChatClient, IChatClient>? clientFactory,
IServiceProvider? services)
{
Throw.IfNull(aiProjectClient);
Throw.IfNull(agentEndpoint);
var (agentName, _) = ParseAgentEndpoint(agentEndpoint);
var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
perAgentOptions.Endpoint = agentEndpoint;
perAgentOptions.AgentName = agentName;
perAgentOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
var authPolicy = new BearerTokenPolicy(credential, AzureAiResourceScope);
var perAgentClient = new ProjectOpenAIClient(authPolicy, perAgentOptions);
IChatClient chatClient = perAgentClient.GetProjectResponsesClient().AsIChatClient();
IChatClient chatClient = aiProjectClient.ProjectOpenAIClient
.GetProjectResponsesClientForAgentEndpoint(agentName, options: perAgentOptions)
.AsIChatClient();
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
@@ -339,57 +358,6 @@ public sealed class FoundryAgent : DelegatingAIAgent
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
}
/// <summary>
/// Builds the project-scoped <see cref="ProjectOpenAIClient"/> for the agent-endpoint
/// constructor by deriving the project root from the supplied agent endpoint and constructing
/// a fresh client without <see cref="ProjectOpenAIClientOptions.AgentName"/> so the SDK
/// appends the standard <c>/openai/v1</c> suffix expected for project-level surfaces such as
/// conversations.
/// </summary>
/// <remarks>
/// Only the four observable primitive properties (<see cref="ClientPipelineOptions.RetryPolicy"/>,
/// <see cref="ClientPipelineOptions.NetworkTimeout"/>, <see cref="ClientPipelineOptions.Transport"/>,
/// and <c>UserAgentApplicationId</c>) are copied from the caller's options bag. Pipeline
/// policies added via <c>AddPolicy</c> on the caller bag do not propagate because
/// <see cref="ClientPipelineOptions"/> does not publicly enumerate its policies. The MEAI
/// user-agent policy is appended last.
/// </remarks>
private static ProjectOpenAIClient CreateProjectLevelOpenAIClientFromAgentEndpoint(
Uri agentEndpoint,
AuthenticationTokenProvider credential,
ProjectOpenAIClientOptions? clientOptions)
{
var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint);
var projectOptions = new ProjectOpenAIClientOptions();
if (clientOptions is not null)
{
if (clientOptions.RetryPolicy is not null)
{
projectOptions.RetryPolicy = clientOptions.RetryPolicy;
}
if (clientOptions.NetworkTimeout is not null)
{
projectOptions.NetworkTimeout = clientOptions.NetworkTimeout;
}
if (clientOptions.Transport is not null)
{
projectOptions.Transport = clientOptions.Transport;
}
if (!string.IsNullOrEmpty(clientOptions.UserAgentApplicationId))
{
projectOptions.UserAgentApplicationId = clientOptions.UserAgentApplicationId;
}
}
projectOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
return new ProjectOpenAIClient(projectRoot, credential, projectOptions);
}
/// <summary>
/// Parses an agent endpoint URI of shape
/// <c>https://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/endpoint/protocols/openai</c>
@@ -417,8 +385,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
if (idx < 0)
{
throw new ArgumentException(
$"Expected an agent endpoint of shape 'https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai' but got '{agentEndpoint}'. " +
"If you want to construct a FoundryAgent against a project endpoint, use the (Uri projectEndpoint, AuthenticationTokenProvider credential, string model, string instructions, ...) constructor instead.",
$"Expected an agent endpoint of shape 'https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai' but got '{agentEndpoint}'.",
nameof(agentEndpoint));
}
@@ -461,5 +428,32 @@ public sealed class FoundryAgent : DelegatingAIAgent
return new AIProjectClient(endpoint, credential, clientOptions);
}
internal static AIProjectClientOptions? CreateProjectClientOptions(ProjectOpenAIClientOptions? clientOptions)
{
if (clientOptions is null)
{
return null;
}
// Copy pipeline behavior the caller configured on the per-agent options bag onto the
// project-level options bag so the agent endpoint client honors it. UserAgentApplicationId
// is project-level (not derived from the agent endpoint), so it must be carried through too.
var projectOptions = new AIProjectClientOptions
{
Transport = clientOptions.Transport,
RetryPolicy = clientOptions.RetryPolicy,
NetworkTimeout = clientOptions.NetworkTimeout,
MessageLoggingPolicy = clientOptions.MessageLoggingPolicy,
UserAgentApplicationId = clientOptions.UserAgentApplicationId,
};
if (clientOptions.ClientLoggingOptions is not null)
{
projectOptions.ClientLoggingOptions = clientOptions.ClientLoggingOptions;
}
return projectOptions;
}
#endregion
}
@@ -1,6 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
@@ -10,7 +13,8 @@ namespace Microsoft.Agents.AI;
/// <summary>
/// A pre-configured <see cref="DelegatingAIAgent"/> that wraps a <see cref="ChatClientAgent"/> with
/// function invocation, per-service-call chat history persistence, and in-loop compaction.
/// function invocation, per-service-call chat history persistence, in-loop compaction, and a rich set
/// of default context providers and agent decorators.
/// </summary>
/// <remarks>
/// <para>
@@ -23,6 +27,27 @@ namespace Microsoft.Agents.AI;
/// </list>
/// </para>
/// <para>
/// By default, the following context providers are included (each can be disabled via <see cref="HarnessAgentOptions"/>):
/// <list type="bullet">
/// <item><description><see cref="TodoProvider"/> — todo list management.</description></item>
/// <item><description><see cref="AgentModeProvider"/> — agent mode tracking (plan/execute).</description></item>
/// <item><description><see cref="FileMemoryProvider"/> — file-based session memory.</description></item>
/// <item><description><see cref="FileAccessProvider"/> — shared file access.</description></item>
/// <item><description><see cref="AgentSkillsProvider"/> — skill discovery and loading.</description></item>
/// </list>
/// </para>
/// <para>
/// The agent is also wrapped with the following decorators by default (each can be disabled):
/// <list type="bullet">
/// <item><description><see cref="ToolApprovalAgent"/> — "don't ask again" tool approval rules.</description></item>
/// <item><description><see cref="OpenTelemetryAgent"/> — OpenTelemetry instrumentation.</description></item>
/// </list>
/// </para>
/// <para>
/// A <see cref="HostedWebSearchTool"/> is added to the chat options by default (can be disabled via
/// <see cref="HarnessAgentOptions.DisableWebSearch"/>).
/// </para>
/// <para>
/// The underlying <see cref="ChatClientAgent"/> is configured with
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> and
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> set to <see langword="true"/>
@@ -48,7 +73,9 @@ public sealed class HarnessAgent : DelegatingAIAgent
- Think through the task before acting. Break complex work into clear steps.
- Use the tools available to you to gather information, perform actions, and verify results.
- Explain your reasoning between tool calls so the user can follow your progress.
- Explain your reasoning and thought process as you work through tasks.
- Explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process.
- Avoid making more than 4 tool calls in a row without explaining what you are doing.
- If a tool call fails or returns unexpected results, adapt your approach rather than repeating the same call.
- When you have completed the task, present a clear and concise summary of what you did and what you found.
""";
@@ -74,15 +101,15 @@ public sealed class HarnessAgent : DelegatingAIAgent
/// additional context providers, and chat history provider.
/// When <see langword="null"/>, the agent uses built-in default settings.
/// </param>
/// <exception cref="System.ArgumentNullException">
/// <exception cref="ArgumentNullException">
/// <paramref name="chatClient"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="System.ArgumentOutOfRangeException">
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="maxContextWindowTokens"/> is not positive, or
/// <paramref name="maxOutputTokens"/> is negative or greater than or equal to <paramref name="maxContextWindowTokens"/>.
/// </exception>
public HarnessAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options = null)
: base(BuildInnerAgent(
: base(BuildAgent(
Throw.IfNull(chatClient),
maxContextWindowTokens,
maxOutputTokens,
@@ -90,6 +117,25 @@ public sealed class HarnessAgent : DelegatingAIAgent
{
}
private static AIAgent BuildAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
{
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, maxContextWindowTokens, maxOutputTokens, options);
AIAgentBuilder builder = innerAgent.AsBuilder();
if (options?.DisableToolApproval is not true)
{
builder.UseToolApproval();
}
if (options?.DisableOpenTelemetry is not true)
{
builder.UseOpenTelemetry(sourceName: options?.OpenTelemetrySourceName);
}
return builder.Build();
}
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
{
var compactionStrategy = new ContextWindowCompactionStrategy(
@@ -102,15 +148,28 @@ public sealed class HarnessAgent : DelegatingAIAgent
ChatReducer = compactionStrategy.AsChatReducer(),
});
string instructions = options?.ChatOptions?.Instructions ?? DefaultInstructions;
string harnessInstructions = options?.HarnessInstructions ?? DefaultInstructions;
string? agentInstructions = options?.ChatOptions?.Instructions;
ChatOptions chatOptions = BuildChatOptions(options?.ChatOptions, instructions, maxOutputTokens);
string instructions = (string.IsNullOrWhiteSpace(harnessInstructions), string.IsNullOrWhiteSpace(agentInstructions)) switch
{
(true, true) => harnessInstructions,
(true, false) => agentInstructions!,
(false, true) => harnessInstructions,
(false, false) => $"{harnessInstructions}\n\n{agentInstructions}",
};
ChatOptions chatOptions = BuildChatOptions(options, instructions, maxOutputTokens);
var compactionProvider = new CompactionProvider(compactionStrategy);
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options);
return chatClient
.AsBuilder()
.UseFunctionInvocation()
.UseFunctionInvocation(configure: options?.MaximumIterationsPerRequest is int maxIterations
? ficc => ficc.MaximumIterationsPerRequest = maxIterations
: null)
.UseMessageInjection()
.UsePerServiceCallChatHistoryPersistence()
.UseAIContextProviders(compactionProvider)
@@ -121,17 +180,80 @@ public sealed class HarnessAgent : DelegatingAIAgent
Description = options?.Description,
ChatOptions = chatOptions,
ChatHistoryProvider = chatHistoryProvider,
AIContextProviders = options?.AIContextProviders,
AIContextProviders = contextProviders,
UseProvidedChatClientAsIs = true,
RequirePerServiceCallChatHistoryPersistence = true,
WarnOnChatHistoryProviderConflict = false,
ThrowOnChatHistoryProviderConflict = false,
});
}
private static ChatOptions BuildChatOptions(ChatOptions? source, string instructions, int maxOutputTokens)
private static ChatOptions BuildChatOptions(HarnessAgentOptions? options, string instructions, int maxOutputTokens)
{
ChatOptions result = source?.Clone() ?? new ChatOptions();
ChatOptions result = options?.ChatOptions?.Clone() ?? new ChatOptions();
result.Instructions = instructions;
result.MaxOutputTokens ??= maxOutputTokens;
if (options?.DisableWebSearch is not true)
{
result.Tools ??= [];
result.Tools.Add(new HostedWebSearchTool());
}
return result;
}
private static List<AIContextProvider> BuildContextProviders(HarnessAgentOptions? options)
{
var providers = new List<AIContextProvider>();
if (options?.DisableTodoProvider is not true)
{
providers.Add(new TodoProvider());
}
if (options?.DisableAgentModeProvider is not true)
{
providers.Add(new AgentModeProvider(options?.AgentModeProviderOptions));
}
if (options?.DisableFileMemory is not true)
{
AgentFileStore fileMemoryStore = options?.FileMemoryStore
?? new FileSystemAgentFileStore(
Path.Combine(Directory.GetCurrentDirectory(), "agent-file-memory"));
providers.Add(new FileMemoryProvider(
fileMemoryStore,
_ => new FileMemoryState
{
WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString(),
}));
}
if (options?.DisableFileAccess is not true)
{
AgentFileStore fileAccessStore = options?.FileAccessStore
?? new FileSystemAgentFileStore(
Path.Combine(Directory.GetCurrentDirectory(), "working"));
providers.Add(new FileAccessProvider(fileAccessStore));
}
if (options?.DisableAgentSkillsProvider is not true)
{
AgentSkillsProvider skillsProvider = options?.AgentSkillsSource is AgentSkillsSource source
? new AgentSkillsProvider(source)
: new AgentSkillsProvider(Directory.GetCurrentDirectory());
providers.Add(skillsProvider);
}
if (options?.AIContextProviders is IEnumerable<AIContextProvider> userProviders)
{
providers.AddRange(userProviders);
}
return providers;
}
}
@@ -36,13 +36,31 @@ public sealed class HarnessAgentOptions
/// Use <see cref="ChatOptions.Tools"/> to supply additional tools the agent can invoke.
/// </para>
/// <para>
/// Use <see cref="ChatOptions.Instructions"/> to override the <see cref="HarnessAgent"/>'s built-in
/// default instructions. When <see cref="ChatOptions.Instructions"/> is <see langword="null"/> or not set,
/// the default instructions are used.
/// Use <see cref="ChatOptions.Instructions"/> to provide agent-specific instructions (e.g., research methodology,
/// data analysis workflow). These are combined with <see cref="HarnessInstructions"/> to form the final instructions
/// sent to the model: harness instructions appear first, followed by agent-specific instructions.
/// When <see cref="ChatOptions.Instructions"/> is <see langword="null"/>, only <see cref="HarnessInstructions"/>
/// (or the default) is used.
/// </para>
/// </remarks>
public ChatOptions? ChatOptions { get; set; }
/// <summary>
/// Gets or sets the harness-level instructions that control general tool usage and behavior patterns.
/// </summary>
/// <remarks>
/// <para>
/// Harness instructions provide guidance on how to use tools, explain reasoning, and structure work.
/// They are combined with <see cref="ChatOptions"/>.<see cref="ChatOptions.Instructions"/> (agent-specific instructions)
/// to produce the final instructions sent to the model: harness instructions first, then agent-specific instructions.
/// </para>
/// <para>
/// When <see langword="null"/> (the default), <see cref="HarnessAgent.DefaultInstructions"/> is used.
/// Set to <see cref="string.Empty"/> to omit harness instructions entirely.
/// </para>
/// </remarks>
public string? HarnessInstructions { get; set; }
/// <summary>
/// Gets or sets the <see cref="ChatHistoryProvider"/> to use for storing chat history.
/// </summary>
@@ -61,4 +79,143 @@ public sealed class HarnessAgentOptions
/// <see cref="ChatClientAgentOptions.AIContextProviders"/>.
/// </remarks>
public IEnumerable<AIContextProvider>? AIContextProviders { get; set; }
/// <summary>
/// Gets or sets the maximum number of function-invocation loop iterations per request.
/// </summary>
/// <remarks>
/// When set, this value is passed to <see cref="FunctionInvokingChatClient.MaximumIterationsPerRequest"/>.
/// When <see langword="null"/>, the <see cref="FunctionInvokingChatClient"/> default is used.
/// </remarks>
public int? MaximumIterationsPerRequest { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="ToolApprovalAgent"/> wrapper is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), the agent is wrapped with tool approval middleware
/// that supports "don't ask again" auto-approval rules.
/// </remarks>
public bool DisableToolApproval { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="FileMemoryProvider"/> is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), a <see cref="FileMemoryProvider"/> is included in the
/// agent's context providers, using either <see cref="FileMemoryStore"/> or a default
/// <see cref="FileSystemAgentFileStore"/> rooted at <c>{cwd}/agent-file-memory/{timestamp}_{guid}</c>.
/// </remarks>
public bool DisableFileMemory { get; set; }
/// <summary>
/// Gets or sets a custom <see cref="AgentFileStore"/> for the <see cref="FileMemoryProvider"/>.
/// </summary>
/// <remarks>
/// When <see langword="null"/> and <see cref="DisableFileMemory"/> is <see langword="false"/>,
/// a default <see cref="FileSystemAgentFileStore"/> is created.
/// This property is ignored when <see cref="DisableFileMemory"/> is <see langword="true"/>.
/// </remarks>
public AgentFileStore? FileMemoryStore { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="FileAccessProvider"/> is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), a <see cref="FileAccessProvider"/> is included in the
/// agent's context providers, using either <see cref="FileAccessStore"/> or a default
/// <see cref="FileSystemAgentFileStore"/> rooted at <c>{cwd}/working</c>.
/// </remarks>
public bool DisableFileAccess { get; set; }
/// <summary>
/// Gets or sets a custom <see cref="AgentFileStore"/> for the <see cref="FileAccessProvider"/>.
/// </summary>
/// <remarks>
/// When <see langword="null"/> and <see cref="DisableFileAccess"/> is <see langword="false"/>,
/// a default <see cref="FileSystemAgentFileStore"/> is created.
/// This property is ignored when <see cref="DisableFileAccess"/> is <see langword="true"/>.
/// </remarks>
public AgentFileStore? FileAccessStore { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="HostedWebSearchTool"/> is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), a <see cref="HostedWebSearchTool"/> is added
/// to <see cref="ChatOptions"/>.<see cref="ChatOptions.Tools"/>.
/// </remarks>
public bool DisableWebSearch { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="TodoProvider"/> is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), a <see cref="TodoProvider"/> is included
/// in the agent's context providers for tracking work items.
/// </remarks>
public bool DisableTodoProvider { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="AgentModeProvider"/> is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), an <see cref="AgentModeProvider"/> is included
/// in the agent's context providers. Use <see cref="AgentModeProviderOptions"/> to configure
/// custom modes.
/// </remarks>
public bool DisableAgentModeProvider { get; set; }
/// <summary>
/// Gets or sets custom options for the <see cref="AgentModeProvider"/>.
/// </summary>
/// <remarks>
/// When <see langword="null"/>, the <see cref="AgentModeProvider"/> uses its built-in default
/// modes ("plan" and "execute"). This property is ignored when
/// <see cref="DisableAgentModeProvider"/> is <see langword="true"/>.
/// </remarks>
public AgentModeProviderOptions? AgentModeProviderOptions { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="AgentSkillsProvider"/> is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), an <see cref="AgentSkillsProvider"/> is included
/// in the agent's context providers. Use <see cref="AgentSkillsSource"/> to provide a custom
/// skills source; otherwise, the provider defaults to file-based skill discovery from the current
/// working directory.
/// </remarks>
public bool DisableAgentSkillsProvider { get; set; }
/// <summary>
/// Gets or sets a custom <see cref="AI.AgentSkillsSource"/> for the <see cref="AgentSkillsProvider"/>.
/// </summary>
/// <remarks>
/// When <see langword="null"/> and <see cref="DisableAgentSkillsProvider"/> is <see langword="false"/>,
/// the provider defaults to file-based skill discovery from the current working directory.
/// This property is ignored when <see cref="DisableAgentSkillsProvider"/> is <see langword="true"/>.
/// </remarks>
public AgentSkillsSource? AgentSkillsSource { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="OpenTelemetryAgent"/> wrapper is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), the agent is wrapped with an
/// <see cref="OpenTelemetryAgent"/> that provides OpenTelemetry instrumentation
/// following the Semantic Conventions for Generative AI systems.
/// </remarks>
public bool DisableOpenTelemetry { get; set; }
/// <summary>
/// Gets or sets the OpenTelemetry source name used by the <see cref="OpenTelemetryAgent"/> wrapper.
/// </summary>
/// <remarks>
/// When <see langword="null"/> (the default), the framework's default source name
/// (<c>"Experimental.Microsoft.Agents.AI"</c>) is used.
/// Set this to a custom value to enable filtering spans from a specific <see cref="System.Diagnostics.ActivitySource"/>
/// in your <c>TracerProvider</c> configuration.
/// This property is ignored when <see cref="DisableOpenTelemetry"/> is <see langword="true"/>.
/// </remarks>
public string? OpenTelemetrySourceName { get; set; }
}
@@ -12,6 +12,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
@@ -27,6 +28,8 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp;
/// </remarks>
public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
{
private const string FilenameAdditionalPropertyName = "filename";
/// <summary>
/// Reserved <c>toolName</c> value that maps an <see cref="IMcpToolHandler.InvokeToolAsync"/> request
/// to the MCP protocol <c>tools/list</c> discovery operation.
@@ -272,46 +275,46 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
internal static AIContent ConvertContentBlock(ContentBlock block)
{
return block switch
// Delegate to the MCP SDK's canonical converter. It maps every known
// ContentBlock subtype (Text/Image/Audio/EmbeddedResource/ToolUse/ToolResult)
// and sets RawRepresentation + AdditionalProperties from block.Meta.
// It intentionally returns null for ResourceLinkBlock — map that to
// UriContent here so callers always receive a usable AIContent.
return block.ToAIContent() ?? block switch
{
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),
ResourceLinkBlock link => new UriContent(link.Uri, link.MimeType ?? "application/octet-stream")
{
RawRepresentation = link,
AdditionalProperties = CreateAdditionalProperties(link),
},
_ => new TextContent(block.ToString() ?? string.Empty)
{
RawRepresentation = block,
AdditionalProperties = CreateAdditionalProperties(block),
},
};
}
private static AIContent ConvertEmbeddedResource(EmbeddedResourceBlock block)
private static AdditionalPropertiesDictionary? CreateAdditionalProperties(ContentBlock 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),
};
}
AdditionalPropertiesDictionary? properties = null;
private static DataContent CreateDataContent(ReadOnlyMemory<byte> base64Utf8Data, string mediaType)
{
if (base64Utf8Data.IsEmpty)
if (block.Meta is not null)
{
return new DataContent($"data:{mediaType};base64,", mediaType);
foreach (var property in block.Meta)
{
properties ??= new AdditionalPropertiesDictionary();
properties.Add(property.Key, property.Value);
}
}
#if NET8_0_OR_GREATER
string base64 = Encoding.UTF8.GetString(base64Utf8Data.Span);
#else
string base64 = Encoding.UTF8.GetString(base64Utf8Data.ToArray());
#endif
// If it's already a data URI, use it directly
if (base64.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
if (block is ResourceLinkBlock { Name: { Length: > 0 } name })
{
return new DataContent(base64, mediaType);
properties ??= new AdditionalPropertiesDictionary();
properties.TryAdd(FilenameAdditionalPropertyName, name);
}
return new DataContent($"data:{mediaType};base64,{base64}", mediaType);
return properties;
}
private static string SerializeToolsList(IEnumerable<Tool> tools)
@@ -74,9 +74,11 @@ internal static partial class AgentJsonUtilities
[JsonSerializable(typeof(TodoState))]
[JsonSerializable(typeof(TodoItem))]
[JsonSerializable(typeof(TodoItemInput))]
[JsonSerializable(typeof(TodoCompleteInput))]
[JsonSerializable(typeof(List<int>), TypeInfoPropertyName = "IntList")]
[JsonSerializable(typeof(List<TodoItem>), TypeInfoPropertyName = "TodoItemList")]
[JsonSerializable(typeof(List<TodoItemInput>), TypeInfoPropertyName = "TodoItemInputList")]
[JsonSerializable(typeof(List<TodoCompleteInput>), TypeInfoPropertyName = "TodoCompleteInputList")]
// AgentModeProvider types
[JsonSerializable(typeof(AgentModeState))]
@@ -95,12 +97,12 @@ internal static partial class AgentJsonUtilities
[JsonSerializable(typeof(FileListEntry))]
[JsonSerializable(typeof(List<FileListEntry>), TypeInfoPropertyName = "FileListEntryList")]
// SubAgentsProvider types
[JsonSerializable(typeof(SubAgentState))]
[JsonSerializable(typeof(SubAgentRuntimeState))]
[JsonSerializable(typeof(SubTaskInfo))]
[JsonSerializable(typeof(SubTaskStatus))]
[JsonSerializable(typeof(List<SubTaskInfo>), TypeInfoPropertyName = "SubTaskInfoList")]
// BackgroundAgentsProvider types
[JsonSerializable(typeof(BackgroundAgentState))]
[JsonSerializable(typeof(BackgroundAgentRuntimeState))]
[JsonSerializable(typeof(BackgroundTaskInfo))]
[JsonSerializable(typeof(BackgroundTaskStatus))]
[JsonSerializable(typeof(List<BackgroundTaskInfo>), TypeInfoPropertyName = "BackgroundTaskInfoList")]
[ExcludeFromCodeCoverage]
internal sealed partial class JsonContext : JsonSerializerContext;
@@ -45,20 +45,54 @@ public sealed class AgentModeProvider : AIContextProvider
"""
## Agent Mode
You can operate in different modes. Depending on the mode you are in, you will be required to follow different processes.
- You can operate in different modes. Depending on the mode you are in, you will be required to follow different processes.
- You must check the current mode after any user input, since the user may have changed the mode themselves,
e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, meaning they want to review a plan first before execution.
Use the AgentMode_Get tool to check your current operating mode.
Use the AgentMode_Set tool to switch between modes as your work progresses. Only use AgentMode_Set if the user explicitly instructs/allows you to change modes.
{available_modes}
You are currently operating in the {current_mode} mode.
### Mandatory Mode based Workflow
For every new substantive user request, including short factual questions, your behavior is determined by the mode you are in.
{available_modes}
""";
private static readonly IReadOnlyList<AgentModeProviderOptions.AgentMode> s_defaultModes =
[
new("plan", "Use this mode when analyzing requirements, breaking down tasks, and creating plans. This is the interactive mode — ask clarifying questions, discuss options, and get user approval before proceeding."),
new("execute", "Use this mode when carrying out approved plans. Work autonomously using your best judgement — do not ask the user questions or wait for feedback. Make reasonable decisions on your own so that there is a complete, useful result when the user returns. If you encounter ambiguity, choose the most reasonable option and note your choice."),
new(
"plan",
"""
Use this mode when analyzing requirements, breaking down tasks, and creating plans. This is the interactive mode — ask clarifying questions, discuss options, and get user approval before proceeding.
Process to follow when in plan mode:
1. Analyze the request with the purpose of building a research plan.
2. Create a list of todo items.
3. If needed, use the provided tools to do some exploratory checks to help build a plan and determine what clarifying questions you may need from the user.
4. Ask for clarifications from the user where needed.
1. Ask each clarification one by one.
2. When asking for clarification and you have specific options in mind, present them to the user, so they can choose the option instead of having to retype the entire response.
3. Do not proceed until you have received all the needed clarifications.
4. Do short exploratory research if it helps with being able to ask sensible clarifications from the user.
5. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes.
6. Present the plan to the user and ask for approval to switch to execute mode and process the plan.
7. When approval is granted, always switch to execute mode (using the `AgentMode_Set` tool), and follow the steps for *Execute mode*.
"""),
new(
"execute",
"""
Use this mode when carrying out approved plans. Work autonomously using your best judgment — do not ask the user questions or wait for feedback.
Process to follow when in execute mode:
1. If you don't have a plan or tasks yet, analyze the user request and create tasks and a plan. (**Skip this step if you came from plan mode**)
2. Work autonomously — use your best judgment to make decisions and keep progressing without asking the user questions. The goal is to have a complete, useful result ready when the user returns.
3. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable option, note your choice, and keep going.
4. Mark tasks as completed as you finish them.
5. Continue working, thinking and calling tools until you have the research result for the user.
"""),
];
private readonly ProviderSessionState<AgentModeState> _sessionState;
@@ -187,12 +221,15 @@ public sealed class AgentModeProvider : AIContextProvider
private string BuildInstructions(string currentMode)
{
// Build list of modes text:
var modesListBuilder = new StringBuilder();
foreach (var mode in this._modes)
{
modesListBuilder.AppendLine($"- \"{mode.Name}\": {mode.Description}");
modesListBuilder.AppendLine($"#### {mode.Name}");
modesListBuilder.AppendLine();
modesListBuilder.AppendLine(mode.Description.TrimEnd());
modesListBuilder.AppendLine();
}
var modesListText = modesListBuilder.ToString();
return new StringBuilder(this._instructions)
@@ -7,15 +7,15 @@ using System.Threading.Tasks;
namespace Microsoft.Agents.AI;
/// <summary>
/// Holds non-serializable runtime references for in-flight sub-tasks within a single parent session.
/// Holds non-serializable runtime references for in-flight background tasks within a single parent session.
/// </summary>
/// <remarks>
/// Properties are marked with <see cref="JsonIgnoreAttribute"/> because <see cref="Task{TResult}"/>
/// and <see cref="AgentSession"/> are not JSON-serializable. After deserialization (e.g., after a restart),
/// a fresh empty instance is created and any previously-running tasks are marked as
/// <see cref="SubTaskStatus.Lost"/> by <see cref="SubAgentsProvider"/>.
/// <see cref="BackgroundTaskStatus.Lost"/> by <see cref="BackgroundAgentsProvider"/>.
/// </remarks>
internal sealed class SubAgentRuntimeState
internal sealed class BackgroundAgentRuntimeState
{
/// <summary>
/// Gets the mapping of task IDs to their in-flight <see cref="Task{AgentResponse}"/> instances.
@@ -24,9 +24,9 @@ internal sealed class SubAgentRuntimeState
public Dictionary<int, Task<AgentResponse>> InFlightTasks { get; } = [];
/// <summary>
/// Gets the mapping of task IDs to their sub-agent <see cref="AgentSession"/> instances,
/// Gets the mapping of task IDs to their background agent <see cref="AgentSession"/> instances,
/// needed for <c>ContinueTask</c>.
/// </summary>
[JsonIgnore]
public Dictionary<int, AgentSession> SubTaskSessions { get; } = [];
public Dictionary<int, AgentSession> BackgroundTaskSessions { get; } = [];
}
@@ -8,21 +8,21 @@ using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the serializable state of sub-tasks managed by the <see cref="SubAgentsProvider"/>,
/// Represents the serializable state of background tasks managed by the <see cref="BackgroundAgentsProvider"/>,
/// stored in the session's <see cref="AgentSessionStateBag"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class SubAgentState
internal sealed class BackgroundAgentState
{
/// <summary>
/// Gets or sets the next ID to assign to a new sub-task.
/// Gets or sets the next ID to assign to a new background task.
/// </summary>
[JsonPropertyName("nextTaskId")]
public int NextTaskId { get; set; } = 1;
/// <summary>
/// Gets the list of sub-task metadata entries.
/// Gets the list of background task metadata entries.
/// </summary>
[JsonPropertyName("tasks")]
public List<SubTaskInfo> Tasks { get; set; } = [];
public List<BackgroundTaskInfo> Tasks { get; set; } = [];
}
@@ -15,56 +15,56 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// An <see cref="AIContextProvider"/> that enables an agent to delegate work to sub-agents asynchronously.
/// An <see cref="AIContextProvider"/> that enables an agent to delegate work to background agents asynchronously.
/// </summary>
/// <remarks>
/// <para>
/// The <see cref="SubAgentsProvider"/> allows a parent agent to start sub-tasks on child agents,
/// wait for their completion, and retrieve results. Each sub-task runs in its own session and
/// The <see cref="BackgroundAgentsProvider"/> allows a parent agent to start background tasks on child agents,
/// wait for their completion, and retrieve results. Each background task runs in its own session and
/// executes concurrently.
/// </para>
/// <para>
/// This provider exposes the following tools to the agent:
/// <list type="bullet">
/// <item><description><c>SubAgents_StartTask</c> — Start a sub-task on a named agent with text input. Returns the task ID.</description></item>
/// <item><description><c>SubAgents_WaitForFirstCompletion</c> — Block until the first of the specified tasks completes. Returns the completed task's ID.</description></item>
/// <item><description><c>SubAgents_GetTaskResults</c> — Retrieve the text output of a completed sub-task.</description></item>
/// <item><description><c>SubAgents_GetAllTasks</c> — List all sub-tasks with their IDs, statuses, descriptions, and agent names.</description></item>
/// <item><description><c>SubAgents_ContinueTask</c> — Send follow-up input to a completed sub-task's session to resume work.</description></item>
/// <item><description><c>SubAgents_ClearCompletedTask</c> — Remove a completed sub-task and release its session to free memory.</description></item>
/// <item><description><c>BackgroundAgents_StartTask</c> — Start a background task on a named agent with text input. Returns the task ID.</description></item>
/// <item><description><c>BackgroundAgents_WaitForFirstCompletion</c> — Block until the first of the specified tasks completes. Returns the completed task's ID.</description></item>
/// <item><description><c>BackgroundAgents_GetTaskResults</c> — Retrieve the text output of a completed background task.</description></item>
/// <item><description><c>BackgroundAgents_GetAllTasks</c> — List all background tasks with their IDs, statuses, descriptions, and agent names.</description></item>
/// <item><description><c>BackgroundAgents_ContinueTask</c> — Send follow-up input to a completed background task's session to resume work.</description></item>
/// <item><description><c>BackgroundAgents_ClearCompletedTask</c> — Remove a completed background task and release its session to free memory.</description></item>
/// </list>
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class SubAgentsProvider : AIContextProvider
public sealed class BackgroundAgentsProvider : AIContextProvider
{
private const string DefaultInstructions =
"""
## SubAgents
You have access to sub-agents that can perform work on your behalf.
## BackgroundAgents
You have access to background agents that can perform work on your behalf.
- Use the `SubAgents_*` list of tools to start tasks on sub agents and check their results.
- Creating a sub task does not block, and sub-tasks run concurrently.
- Use the `BackgroundAgents_*` list of tools to start tasks on background agents and check their results.
- Creating a background task does not block, and background tasks run concurrently.
- Important: Always wait for outstanding tasks to finish before you finish processing.
- Important: After retrieving results from a completed task, clear it with SubAgents_ClearCompletedTask to free memory, unless you plan to continue it with SubAgents_ContinueTask.
- Important: After retrieving results from a completed task, clear it with BackgroundAgents_ClearCompletedTask to free memory, unless you plan to continue it with BackgroundAgents_ContinueTask.
{sub_agents}
{background_agents}
""";
private readonly Dictionary<string, AIAgent> _agents;
private readonly ProviderSessionState<SubAgentState> _sessionState;
private readonly ProviderSessionState<SubAgentRuntimeState> _runtimeSessionState;
private readonly ProviderSessionState<BackgroundAgentState> _sessionState;
private readonly ProviderSessionState<BackgroundAgentRuntimeState> _runtimeSessionState;
private readonly string _instructions;
private IReadOnlyList<string>? _stateKeys;
/// <summary>
/// Initializes a new instance of the <see cref="SubAgentsProvider"/> class.
/// Initializes a new instance of the <see cref="BackgroundAgentsProvider"/> class.
/// </summary>
/// <param name="agents">The collection of sub-agents available for delegation.</param>
/// <param name="agents">The collection of background agents available for delegation.</param>
/// <param name="options">Optional settings controlling the provider behavior.</param>
/// <exception cref="ArgumentNullException"><paramref name="agents"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">An agent has a null or empty name, or agent names are not unique.</exception>
public SubAgentsProvider(IEnumerable<AIAgent> agents, SubAgentsProviderOptions? options = null)
public BackgroundAgentsProvider(IEnumerable<AIAgent> agents, BackgroundAgentsProviderOptions? options = null)
{
_ = Throw.IfNull(agents);
@@ -74,15 +74,15 @@ public sealed class SubAgentsProvider : AIContextProvider
string agentListText = options?.AgentListBuilder is not null
? options.AgentListBuilder(this._agents)
: BuildDefaultAgentListText(this._agents);
this._instructions = baseInstructions.Replace("{sub_agents}", agentListText);
this._instructions = baseInstructions.Replace("{background_agents}", agentListText);
this._sessionState = new ProviderSessionState<SubAgentState>(
_ => new SubAgentState(),
this._sessionState = new ProviderSessionState<BackgroundAgentState>(
_ => new BackgroundAgentState(),
this.GetType().Name,
AgentJsonUtilities.DefaultOptions);
this._runtimeSessionState = new ProviderSessionState<SubAgentRuntimeState>(
_ => new SubAgentRuntimeState(),
this._runtimeSessionState = new ProviderSessionState<BackgroundAgentRuntimeState>(
_ => new BackgroundAgentRuntimeState(),
this.GetType().Name + "_Runtime",
AgentJsonUtilities.DefaultOptions);
}
@@ -93,8 +93,8 @@ public sealed class SubAgentsProvider : AIContextProvider
/// <inheritdoc />
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
SubAgentState state = this._sessionState.GetOrInitializeState(context.Session);
SubAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(context.Session);
BackgroundAgentState state = this._sessionState.GetOrInitializeState(context.Session);
BackgroundAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(context.Session);
return new ValueTask<AIContext>(new AIContext
{
@@ -113,12 +113,12 @@ public sealed class SubAgentsProvider : AIContextProvider
{
if (string.IsNullOrWhiteSpace(agent.Name))
{
throw new ArgumentException("All sub-agents must have a non-empty Name.", nameof(agents));
throw new ArgumentException("All background agents must have a non-empty Name.", nameof(agents));
}
if (dict.ContainsKey(agent.Name))
{
throw new ArgumentException($"Duplicate sub-agent name: '{agent.Name}'. Agent names must be unique (case-insensitive).", nameof(agents));
throw new ArgumentException($"Duplicate background agent name: '{agent.Name}'. Agent names must be unique (case-insensitive).", nameof(agents));
}
dict[agent.Name] = agent;
@@ -126,19 +126,19 @@ public sealed class SubAgentsProvider : AIContextProvider
if (dict.Count == 0)
{
throw new ArgumentException("At least one sub-agent must be provided.", nameof(agents));
throw new ArgumentException("At least one background agent must be provided.", nameof(agents));
}
return dict;
}
/// <summary>
/// Builds the default text listing available sub-agents and their descriptions.
/// Builds the default text listing available background agents and their descriptions.
/// </summary>
private static string BuildDefaultAgentListText(IReadOnlyDictionary<string, AIAgent> agents)
{
var sb = new StringBuilder();
sb.AppendLine("Available sub-agents:");
sb.AppendLine("Available background agents:");
foreach (var kvp in agents)
{
sb.Append("- ").Append(kvp.Key);
@@ -156,12 +156,12 @@ public sealed class SubAgentsProvider : AIContextProvider
/// <summary>
/// Refreshes the status of in-flight tasks in the given state for the specified session.
/// </summary>
private void TryRefreshTaskState(SubAgentState state, SubAgentRuntimeState runtimeState, AgentSession? session)
private void TryRefreshTaskState(BackgroundAgentState state, BackgroundAgentRuntimeState runtimeState, AgentSession? session)
{
bool changed = false;
foreach (SubTaskInfo task in state.Tasks)
foreach (BackgroundTaskInfo task in state.Tasks)
{
if (task.Status != SubTaskStatus.Running)
if (task.Status != BackgroundTaskStatus.Running)
{
continue;
}
@@ -169,7 +169,7 @@ public sealed class SubAgentsProvider : AIContextProvider
if (!runtimeState.InFlightTasks.TryGetValue(task.Id, out Task<AgentResponse>? inFlight))
{
// In-flight reference lost (e.g., after restart/deserialization).
task.Status = SubTaskStatus.Lost;
task.Status = BackgroundTaskStatus.Lost;
changed = true;
continue;
}
@@ -188,32 +188,32 @@ public sealed class SubAgentsProvider : AIContextProvider
}
/// <summary>
/// Finalizes a task by extracting results from the completed Task and updating the SubTaskInfo.
/// Finalizes a task by extracting results from the completed Task and updating the BackgroundTaskInfo.
/// </summary>
private static void FinalizeTask(SubTaskInfo taskInfo, Task<AgentResponse> completedTask, SubAgentRuntimeState runtimeState)
private static void FinalizeTask(BackgroundTaskInfo taskInfo, Task<AgentResponse> completedTask, BackgroundAgentRuntimeState runtimeState)
{
if (completedTask.Status == TaskStatus.RanToCompletion)
{
taskInfo.Status = SubTaskStatus.Completed;
taskInfo.Status = BackgroundTaskStatus.Completed;
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits — task is already completed
taskInfo.ResultText = completedTask.Result.Text;
#pragma warning restore VSTHRD002
}
else if (completedTask.IsFaulted)
{
taskInfo.Status = SubTaskStatus.Failed;
taskInfo.Status = BackgroundTaskStatus.Failed;
taskInfo.ErrorText = completedTask.Exception?.InnerException?.Message ?? completedTask.Exception?.Message ?? "Unknown error";
}
else if (completedTask.IsCanceled)
{
taskInfo.Status = SubTaskStatus.Failed;
taskInfo.Status = BackgroundTaskStatus.Failed;
taskInfo.ErrorText = "Task was canceled.";
}
runtimeState.InFlightTasks.Remove(taskInfo.Id);
}
private AITool[] CreateTools(SubAgentState state, SubAgentRuntimeState runtimeState, AgentSession? session)
private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeState runtimeState, AgentSession? session)
{
var serializerOptions = AgentJsonUtilities.DefaultOptions;
@@ -221,43 +221,43 @@ public sealed class SubAgentsProvider : AIContextProvider
[
AIFunctionFactory.Create(
async (
[Description("The name of the sub agent to delegate the task to.")] string agentName,
[Description("The request to pass to the sub agent.")] string input,
[Description("The name of the background agent to delegate the task to.")] string agentName,
[Description("The request to pass to the background agent.")] string input,
[Description("A description of the task used to identify the task later.")] string description) =>
{
if (!this._agents.TryGetValue(agentName, out AIAgent? agent))
{
return $"Error: No sub-agent found with name '{agentName}'. Available agents: {string.Join(", ", this._agents.Keys)}";
return $"Error: No background agent found with name '{agentName}'. Available agents: {string.Join(", ", this._agents.Keys)}";
}
int taskId = state.NextTaskId++;
var taskInfo = new SubTaskInfo
var taskInfo = new BackgroundTaskInfo
{
Id = taskId,
AgentName = agentName,
Description = description,
Status = SubTaskStatus.Running,
Status = BackgroundTaskStatus.Running,
};
state.Tasks.Add(taskInfo);
// Create a dedicated session for this sub-task so it can be continued later.
// Create a dedicated session for this background task so it can be continued later.
AgentSession subSession = await agent.CreateSessionAsync().ConfigureAwait(false);
// Wrap in Task.Run to fork the ExecutionContext. AIAgent.RunAsync is a non-async
// method that synchronously sets the static AsyncLocal CurrentRunContext. Without
// this isolation, the sub-agent's RunAsync would overwrite the outer (calling)
// this isolation, the background agent's RunAsync would overwrite the outer (calling)
// agent's CurrentRunContext, corrupting all subsequent tool invocations in the
// same FICC batch.
runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(input, subSession));
runtimeState.SubTaskSessions[taskId] = subSession;
runtimeState.BackgroundTaskSessions[taskId] = subSession;
this._sessionState.SaveState(session, state);
return $"Sub-task {taskId} started on agent '{agentName}'.";
return $"Background task {taskId} started on agent '{agentName}'.";
},
new AIFunctionFactoryOptions
{
Name = "SubAgents_StartTask",
Description = "Start a sub-task on a named sub-agent. Returns a confirmation message containing the task ID.",
Name = "BackgroundAgents_StartTask",
Description = "Start a background task on a named background agent. Returns a confirmation message containing the task ID.",
SerializerOptions = serializerOptions,
}),
@@ -287,7 +287,7 @@ public sealed class SubAgentsProvider : AIContextProvider
this._sessionState.SaveState(session, state);
// Check if any of the requested IDs are already complete.
SubTaskInfo? alreadyComplete = state.Tasks.FirstOrDefault(t => taskIds.Contains(t.Id) && t.Status != SubTaskStatus.Running);
BackgroundTaskInfo? alreadyComplete = state.Tasks.FirstOrDefault(t => taskIds.Contains(t.Id) && t.Status != BackgroundTaskStatus.Running);
if (alreadyComplete is not null)
{
return $"Task {alreadyComplete.Id} is not running; current status: {alreadyComplete.Status}.";
@@ -303,7 +303,7 @@ public sealed class SubAgentsProvider : AIContextProvider
var completedEntry = waitableTasks.First(t => t.Task == completedTask);
// Finalize the completed task.
SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == completedEntry.Id);
BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == completedEntry.Id);
if (taskInfo is not null)
{
FinalizeTask(taskInfo, completedEntry.Task, runtimeState);
@@ -314,8 +314,8 @@ public sealed class SubAgentsProvider : AIContextProvider
},
new AIFunctionFactoryOptions
{
Name = "SubAgents_WaitForFirstCompletion",
Description = "Block until the first of the specified sub-tasks completes. Provide one or more task IDs. Returns a status message containing the ID of the task that completed first.",
Name = "BackgroundAgents_WaitForFirstCompletion",
Description = "Block until the first of the specified background tasks completes. Provide one or more task IDs. Returns a status message containing the ID of the task that completed first.",
SerializerOptions = serializerOptions,
}),
@@ -324,7 +324,7 @@ public sealed class SubAgentsProvider : AIContextProvider
{
this.TryRefreshTaskState(state, runtimeState, session);
SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
if (taskInfo is null)
{
return $"Error: No task found with ID {taskId}.";
@@ -332,17 +332,17 @@ public sealed class SubAgentsProvider : AIContextProvider
return taskInfo.Status switch
{
SubTaskStatus.Completed => taskInfo.ResultText ?? "(no output)",
SubTaskStatus.Failed => $"Task failed: {taskInfo.ErrorText ?? "Unknown error"}",
SubTaskStatus.Lost => "Task state was lost (reference unavailable).",
SubTaskStatus.Running => $"Task {taskId} is still running.",
BackgroundTaskStatus.Completed => taskInfo.ResultText ?? "(no output)",
BackgroundTaskStatus.Failed => $"Task failed: {taskInfo.ErrorText ?? "Unknown error"}",
BackgroundTaskStatus.Lost => "Task state was lost (reference unavailable).",
BackgroundTaskStatus.Running => $"Task {taskId} is still running.",
_ => $"Task {taskId} has status: {taskInfo.Status}.",
};
},
new AIFunctionFactoryOptions
{
Name = "SubAgents_GetTaskResults",
Description = "Get the text output of a sub-task by its ID. Returns the result text if complete, or status information if still running or failed.",
Name = "BackgroundAgents_GetTaskResults",
Description = "Get the text output of a background task by its ID. Returns the result text if complete, or status information if still running or failed.",
SerializerOptions = serializerOptions,
}),
@@ -358,7 +358,7 @@ public sealed class SubAgentsProvider : AIContextProvider
var sb = new StringBuilder();
sb.AppendLine("Tasks:");
foreach (SubTaskInfo task in state.Tasks)
foreach (BackgroundTaskInfo task in state.Tasks)
{
sb.Append("- Task ").Append(task.Id).Append(" [").Append(task.Status).Append("] (").Append(task.AgentName).Append("): ").AppendLine(task.Description);
}
@@ -367,8 +367,8 @@ public sealed class SubAgentsProvider : AIContextProvider
},
new AIFunctionFactoryOptions
{
Name = "SubAgents_GetAllTasks",
Description = "List all sub-tasks with their IDs, statuses, agent names, and descriptions.",
Name = "BackgroundAgents_GetAllTasks",
Description = "List all background tasks with their IDs, statuses, agent names, and descriptions.",
SerializerOptions = serializerOptions,
}),
@@ -377,18 +377,18 @@ public sealed class SubAgentsProvider : AIContextProvider
{
this.TryRefreshTaskState(state, runtimeState, session);
SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
if (taskInfo is null)
{
return $"Error: No task found with ID {taskId}.";
}
if (taskInfo.Status == SubTaskStatus.Lost)
if (taskInfo.Status == BackgroundTaskStatus.Lost)
{
return $"Error: Task {taskId} cannot be continued because its session was lost (e.g., after a session restore). Start a new task instead.";
}
if (taskInfo.Status == SubTaskStatus.Running)
if (taskInfo.Status == BackgroundTaskStatus.Running)
{
return $"Error: Task {taskId} is still running. Wait for it to complete before continuing.";
}
@@ -398,17 +398,17 @@ public sealed class SubAgentsProvider : AIContextProvider
return $"Error: Agent '{taskInfo.AgentName}' is no longer available.";
}
if (!runtimeState.SubTaskSessions.TryGetValue(taskId, out AgentSession? subSession))
if (!runtimeState.BackgroundTaskSessions.TryGetValue(taskId, out AgentSession? subSession))
{
return $"Error: Session for task {taskId} is no longer available.";
}
// Reset task state and start a new run on the existing session.
taskInfo.Status = SubTaskStatus.Running;
taskInfo.Status = BackgroundTaskStatus.Running;
taskInfo.ResultText = null;
taskInfo.ErrorText = null;
// Wrap in Task.Run to isolate the ExecutionContext (see StartSubTask comment).
// Wrap in Task.Run to isolate the ExecutionContext (see StartBackgroundTask comment).
runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(text, subSession));
this._sessionState.SaveState(session, state);
@@ -416,8 +416,8 @@ public sealed class SubAgentsProvider : AIContextProvider
},
new AIFunctionFactoryOptions
{
Name = "SubAgents_ContinueTask",
Description = "Send follow-up input to a completed or failed sub-task to resume its work. The sub-task's session is preserved, so the agent retains conversational context.",
Name = "BackgroundAgents_ContinueTask",
Description = "Send follow-up input to a completed or failed background task to resume its work. The background task's session is preserved, so the agent retains conversational context.",
SerializerOptions = serializerOptions,
}),
@@ -426,13 +426,13 @@ public sealed class SubAgentsProvider : AIContextProvider
{
this.TryRefreshTaskState(state, runtimeState, session);
SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
if (taskInfo is null)
{
return $"Error: No task found with ID {taskId}.";
}
if (taskInfo.Status == SubTaskStatus.Running)
if (taskInfo.Status == BackgroundTaskStatus.Running)
{
return $"Error: Task {taskId} is still running. Wait for it to complete before clearing.";
}
@@ -442,15 +442,15 @@ public sealed class SubAgentsProvider : AIContextProvider
// Clean up runtime references.
runtimeState.InFlightTasks.Remove(taskId);
runtimeState.SubTaskSessions.Remove(taskId);
runtimeState.BackgroundTaskSessions.Remove(taskId);
this._sessionState.SaveState(session, state);
return $"Task {taskId} cleared.";
},
new AIFunctionFactoryOptions
{
Name = "SubAgents_ClearCompletedTask",
Description = "Remove a completed or failed sub-task and release its session to free memory. Use this after retrieving results when you no longer need to continue the task.",
Name = "BackgroundAgents_ClearCompletedTask",
Description = "Remove a completed or failed background task and release its session to free memory. Use this after retrieving results when you no longer need to continue the task.",
SerializerOptions = serializerOptions,
}),
];
@@ -8,21 +8,21 @@ using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Options controlling the behavior of <see cref="SubAgentsProvider"/>.
/// Options controlling the behavior of <see cref="BackgroundAgentsProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class SubAgentsProviderOptions
public sealed class BackgroundAgentsProviderOptions
{
/// <summary>
/// Gets or sets custom instructions provided to the agent for using the sub-agent tools.
/// Gets or sets custom instructions provided to the agent for using the background agent tools.
/// </summary>
/// <remarks>
/// Use the <c>{sub_agents}</c> placeholder to allow the provider to inject
/// the formatted list of available sub agents.
/// Use the <c>{background_agents}</c> placeholder to allow the provider to inject
/// the formatted list of available background agents.
/// </remarks>
/// <value>
/// When <see langword="null"/> (the default), the provider uses built-in instructions
/// that guide the agent on how to use the sub-agent tools.
/// that guide the agent on how to use the background agent tools.
/// The agent list is always appended after the instructions regardless of this setting.
/// </value>
public string? Instructions { get; set; }
@@ -33,7 +33,7 @@ public sealed class SubAgentsProviderOptions
/// <value>
/// When <see langword="null"/> (the default), the provider generates a standard list of agent names and descriptions.
/// When set, this function receives the dictionary of available agents (keyed by name) and should return
/// a formatted string describing the available sub-agents.
/// a formatted string describing the available background agents.
/// </value>
public Func<IReadOnlyDictionary<string, AIAgent>, string>? AgentListBuilder { get; set; }
}
@@ -7,43 +7,43 @@ using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the metadata and result of a sub-task managed by the <see cref="SubAgentsProvider"/>.
/// Represents the metadata and result of a background task managed by the <see cref="BackgroundAgentsProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class SubTaskInfo
public sealed class BackgroundTaskInfo
{
/// <summary>
/// Gets or sets the unique identifier for this sub-task.
/// Gets or sets the unique identifier for this background task.
/// </summary>
[JsonPropertyName("id")]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the agent that is executing this sub-task.
/// Gets or sets the name of the agent that is executing this background task.
/// </summary>
[JsonPropertyName("agentName")]
public string AgentName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a description of what this sub-task is doing.
/// Gets or sets a description of what this background task is doing.
/// </summary>
[JsonPropertyName("description")]
public string Description { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the current status of this sub-task.
/// Gets or sets the current status of this background task.
/// </summary>
[JsonPropertyName("status")]
public SubTaskStatus Status { get; set; }
public BackgroundTaskStatus Status { get; set; }
/// <summary>
/// Gets or sets the text result of the sub-task, populated when the task completes successfully.
/// Gets or sets the text result of the background task, populated when the task completes successfully.
/// </summary>
[JsonPropertyName("resultText")]
public string? ResultText { get; set; }
/// <summary>
/// Gets or sets the error message if the sub-task failed.
/// Gets or sets the error message if the background task failed.
/// </summary>
[JsonPropertyName("errorText")]
public string? ErrorText { get; set; }
@@ -6,28 +6,28 @@ using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the status of a sub-task managed by the <see cref="SubAgentsProvider"/>.
/// Represents the status of a background task managed by the <see cref="BackgroundAgentsProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public enum SubTaskStatus
public enum BackgroundTaskStatus
{
/// <summary>
/// The sub-task is currently running.
/// The background task is currently running.
/// </summary>
Running,
/// <summary>
/// The sub-task completed successfully.
/// The background task completed successfully.
/// </summary>
Completed,
/// <summary>
/// The sub-task failed with an error.
/// The background task failed with an error.
/// </summary>
Failed,
/// <summary>
/// The sub-task's in-flight reference was lost (e.g., after a restart),
/// The background task's in-flight reference was lost (e.g., after a restart),
/// and its final state cannot be determined.
/// </summary>
Lost,
@@ -55,7 +55,7 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
- Use descriptive file names (e.g., "projectarchitecture.md", "userpreferences.md").
- Include a description when saving a file to help with future discovery.
- Before starting new tasks, use FileMemory_ListFiles and FileMemory_SearchFiles to check for relevant existing memories.
- Before starting new tasks, use FileMemory_ListFiles and FileMemory_SearchFiles to check for relevant existing memories to avoid duplicate work.
- Keep memories up-to-date by overwriting files when information changes.
- When you receive large amounts of data (e.g., downloaded web pages, API responses, research results),
save them to files if they will be required later, so that they are not lost when older context is compacted or truncated.
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the input for completing a single todo item via the <see cref="TodoProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class TodoCompleteInput
{
/// <summary>
/// Gets or sets the ID of the todo item to mark as complete.
/// </summary>
[JsonPropertyName("id")]
public int Id { get; set; }
/// <summary>
/// Gets or sets the reason describing how or why the item was completed.
/// </summary>
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
@@ -48,13 +48,13 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
You have access to a todo list for tracking work items.
While planning, make sure that you break down complex tasks into manageable todo items and add them to the list.
Ask questions from the user where clarification is needed to create effective todos.
If the user provides feedback on your plan, adjust your todos accordingly by adding new items or removing irrelevant ones.
If the user provides feedback on your plan, adjust your todos accordingly by adding new items or removing irrelevant/old ones.
During execution, use the todo list to keep track of what needs to be done, mark items as complete when finished, and remove any items that are no longer needed.
When a user changes the topic or changes their mind, ensure that you update the todo list accordingly by removing irrelevant items or adding new ones as needed.
When a user changes the topic or changes their mind, ensure that you update the todo list accordingly by removing irrelevant/old items or adding new ones as needed.
Use these tools to manage your tasks:
- Use TodoList_Add to break down complex work into trackable items (supports adding one or many at once).
- Use TodoList_Complete to mark items as done when finished (supports one or many at once).
- Use TodoList_Complete to mark items as done when finished (supports one or many at once). Include a reason describing how the items were completed.
- Use TodoList_GetRemaining to check what work is still pending.
- Use TodoList_GetAll to review the full list including completed items.
- Use TodoList_Remove to remove items that are no longer needed (supports one or many at once).
@@ -235,14 +235,14 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
}),
AIFunctionFactory.Create(
async (List<int> ids) =>
async (List<TodoCompleteInput> items) =>
{
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync().ConfigureAwait(false);
try
{
TodoState state = this._sessionState.GetOrInitializeState(session);
var idSet = new HashSet<int>(ids);
var idSet = new HashSet<int>(items.Select(i => i.Id));
int completed = 0;
foreach (TodoItem item in state.Items)
{
@@ -268,7 +268,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
new AIFunctionFactoryOptions
{
Name = "TodoList_Complete",
Description = "Mark one or more todo items as complete by their IDs. Returns the number of items that were found and marked complete.",
Description = "Mark one or more todo items as complete. Each entry has an ID and a reason describing how/why the item was completed. Returns the number of items that were found and marked complete.",
SerializerOptions = serializerOptions,
}),
@@ -20,7 +20,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="Microsoft.Extensions.AI" />
@@ -1380,6 +1380,133 @@ public sealed class AzureAIProjectChatClientExtensionsTests
#endregion
#region AsAIAgent(AIProjectClient, Uri agentEndpoint) Tests
private const string TestAgentEndpointUrl = "https://test.services.ai.azure.com/api/projects/test-project/agents/it-happy-path/endpoint/protocols/openai";
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) throws ArgumentNullException when AIProjectClient is null.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_WithNullClient_ThrowsArgumentNullException()
{
// Arrange
AIProjectClient? client = null;
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
client!.AsAIAgent(new Uri(TestAgentEndpointUrl)));
Assert.Equal("aiProjectClient", exception.ParamName);
}
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) throws ArgumentNullException when agentEndpoint is null.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_WithNullEndpoint_ThrowsArgumentNullException()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClient();
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
client.AsAIAgent((Uri)null!));
Assert.Equal("agentEndpoint", exception.ParamName);
}
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) populates Name/Id from the parsed endpoint slug
/// and exposes the supplied <see cref="AIProjectClient"/> via <see cref="AIAgent.GetService{TService}(object?)"/>.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_PopulatesNameAndIdFromSlugAndReusesProjectClient()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClient();
// Act
var agent = client.AsAIAgent(new Uri(TestAgentEndpointUrl));
// Assert
Assert.NotNull(agent);
Assert.IsType<FoundryAgent>(agent);
Assert.Equal("it-happy-path", agent.Name);
Assert.Equal("it-happy-path", agent.Id);
Assert.Same(client, agent.GetService<AIProjectClient>());
}
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) applies the supplied client factory exactly once.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_WithClientFactory_AppliesFactoryCorrectly()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClient();
TestChatClient? testChatClient = null;
// Act
var agent = client.AsAIAgent(
new Uri(TestAgentEndpointUrl),
clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient));
// Assert
Assert.NotNull(agent);
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.NotNull(retrievedTestClient);
Assert.Same(testChatClient, retrievedTestClient);
}
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) forwards the supplied tools to the inner
/// <see cref="ChatClientAgent"/>'s <see cref="ChatOptions.Tools"/>.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_ForwardsToolsToInnerChatOptions()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClient();
var tool1 = AIFunctionFactory.Create(() => "result-1", "tool_1", "First test tool.");
var tool2 = AIFunctionFactory.Create(() => "result-2", "tool_2", "Second test tool.");
List<AITool> tools = [tool1, tool2];
// Act
var agent = client.AsAIAgent(new Uri(TestAgentEndpointUrl), tools: tools);
// Assert
Assert.NotNull(agent);
ChatOptions? chatOptions = GetAgentChatOptions(agent);
Assert.NotNull(chatOptions);
Assert.NotNull(chatOptions!.Tools);
Assert.Equal(2, chatOptions.Tools!.Count);
Assert.Same(tool1, chatOptions.Tools[0]);
Assert.Same(tool2, chatOptions.Tools[1]);
}
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) accepts a null tools argument without throwing
/// and produces an agent whose inner <see cref="ChatOptions.Tools"/> is null.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_WithNullTools_DoesNotThrow()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClient();
// Act
var agent = client.AsAIAgent(new Uri(TestAgentEndpointUrl), tools: null);
// Assert
Assert.NotNull(agent);
ChatOptions? chatOptions = GetAgentChatOptions(agent);
Assert.NotNull(chatOptions);
Assert.Null(chatOptions!.Tools);
}
#endregion
#region Helper Methods
/// <summary>
@@ -2,6 +2,7 @@
using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
@@ -356,7 +357,7 @@ public class FoundryAgentTests
bool userAgentFound = false;
using HttpHandlerAssert httpHandler = new(request =>
{
if (request.Headers.TryGetValues("User-Agent", out System.Collections.Generic.IEnumerable<string>? values))
if (request.Headers.TryGetValues("User-Agent", out IEnumerable<string>? values))
{
foreach (string value in values)
{
@@ -431,23 +432,23 @@ public class FoundryAgentTests
}
[Fact]
public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull()
{
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
Assert.NotNull(agent.GetService<ProjectOpenAIClient>());
Assert.Null(agent.GetService<ProjectOpenAIClient>());
}
[Fact]
public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNull()
public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNonNull()
{
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
Assert.Null(agent.GetService<AIProjectClient>());
Assert.NotNull(agent.GetService<AIProjectClient>());
}
[Fact]
public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull()
{
FoundryAgent agent = new(
s_testEndpoint,
@@ -455,7 +456,7 @@ public class FoundryAgentTests
model: "gpt-4o-mini",
instructions: "Test");
Assert.NotNull(agent.GetService<ProjectOpenAIClient>());
Assert.Null(agent.GetService<ProjectOpenAIClient>());
}
[Fact]
@@ -665,21 +666,82 @@ public class FoundryAgentTests
}
[Fact]
public void AgentEndpointConstructor_PropagatesUserAgentApplicationId_ToProjectLevelClient()
public void AgentEndpointConstructor_PreservesUserAgentApplicationId()
{
// The MEAI policy adds its own User-Agent header so we cannot reliably observe the OpenAI SDK's
// application-id stamp in the outbound request. Verify the value is propagated onto the
// project-level client's options via the public ProjectOpenAIClient surface.
ProjectOpenAIClientOptions opts = new() { UserAgentApplicationId = "my-app-id" };
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
ProjectOpenAIClient? projectClient = agent.GetService<ProjectOpenAIClient>();
Assert.NotNull(projectClient);
// Caller's UserAgentApplicationId is preserved on the per-agent options bag verbatim.
Assert.NotNull(agent);
Assert.Equal("my-app-id", opts.UserAgentApplicationId);
}
[Fact]
public void CreateProjectClientOptions_NullCallerOptions_ReturnsNull()
{
Assert.Null(FoundryAgent.CreateProjectClientOptions(null));
}
[Fact]
public void CreateProjectClientOptions_CarriesPipelineSettingsAndUserAgent()
{
// Arrange
var transport = new FakePipelineTransport();
var retryPolicy = new FakeRetryPolicy();
var messageLoggingPolicy = new FakeMessageLoggingPolicy();
var clientLoggingOptions = new ClientLoggingOptions { EnableLogging = false };
var networkTimeout = TimeSpan.FromSeconds(42);
ProjectOpenAIClientOptions callerOptions = new()
{
UserAgentApplicationId = "my-app-id",
Transport = transport,
RetryPolicy = retryPolicy,
MessageLoggingPolicy = messageLoggingPolicy,
ClientLoggingOptions = clientLoggingOptions,
NetworkTimeout = networkTimeout,
};
// Act
AIProjectClientOptions? projectOptions = FoundryAgent.CreateProjectClientOptions(callerOptions);
// Assert: every settable pipeline behavior the caller configured is forwarded
// onto the project-level options bag, not silently dropped.
Assert.NotNull(projectOptions);
Assert.Equal("my-app-id", projectOptions!.UserAgentApplicationId);
Assert.Same(transport, projectOptions.Transport);
Assert.Same(retryPolicy, projectOptions.RetryPolicy);
Assert.Same(messageLoggingPolicy, projectOptions.MessageLoggingPolicy);
Assert.Same(clientLoggingOptions, projectOptions.ClientLoggingOptions);
Assert.Equal(networkTimeout, projectOptions.NetworkTimeout);
}
private sealed class FakeRetryPolicy : PipelinePolicy
{
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
=> ProcessNext(message, pipeline, currentIndex);
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
=> ProcessNextAsync(message, pipeline, currentIndex);
}
private sealed class FakeMessageLoggingPolicy : PipelinePolicy
{
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
=> ProcessNext(message, pipeline, currentIndex);
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
=> ProcessNextAsync(message, pipeline, currentIndex);
}
private sealed class FakePipelineTransport : PipelineTransport
{
protected override PipelineMessage CreateMessageCore() => throw new NotSupportedException();
protected override void ProcessCore(PipelineMessage message) => throw new NotSupportedException();
protected override ValueTask ProcessCoreAsync(PipelineMessage message) => throw new NotSupportedException();
}
#endregion
#region ParseAgentEndpoint tests
@@ -762,13 +824,13 @@ public class FoundryAgentTests
private readonly string _value;
public HeaderStampPolicy(string name, string value) { this._name = name; this._value = value; }
public override void Process(PipelineMessage message, System.Collections.Generic.IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Set(this._name, this._value);
ProcessNext(message, pipeline, currentIndex);
}
public override ValueTask ProcessAsync(PipelineMessage message, System.Collections.Generic.IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Set(this._name, this._value);
return ProcessNextAsync(message, pipeline, currentIndex);
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
public class HarnessAgentOptionsTests
@@ -18,8 +20,23 @@ public class HarnessAgentOptionsTests
Assert.Null(options.Name);
Assert.Null(options.Description);
Assert.Null(options.ChatOptions);
Assert.Null(options.HarnessInstructions);
Assert.Null(options.ChatHistoryProvider);
Assert.Null(options.AIContextProviders);
Assert.False(options.DisableToolApproval);
Assert.False(options.DisableFileMemory);
Assert.False(options.DisableFileAccess);
Assert.False(options.DisableWebSearch);
Assert.False(options.DisableTodoProvider);
Assert.False(options.DisableAgentModeProvider);
Assert.False(options.DisableAgentSkillsProvider);
Assert.False(options.DisableOpenTelemetry);
Assert.Null(options.OpenTelemetrySourceName);
Assert.Null(options.MaximumIterationsPerRequest);
Assert.Null(options.FileMemoryStore);
Assert.Null(options.FileAccessStore);
Assert.Null(options.AgentModeProviderOptions);
Assert.Null(options.AgentSkillsSource);
}
/// <summary>
@@ -31,6 +48,10 @@ public class HarnessAgentOptionsTests
// Arrange
var chatHistoryProvider = new InMemoryChatHistoryProvider();
var contextProviders = new AIContextProvider[] { new TodoProvider() };
var fileMemoryStore = new Mock<AgentFileStore>().Object;
var fileAccessStore = new Mock<AgentFileStore>().Object;
var agentModeOptions = new AgentModeProviderOptions();
var skillsSource = new Mock<AgentSkillsSource>().Object;
// Act
var options = new HarnessAgentOptions
@@ -39,8 +60,23 @@ public class HarnessAgentOptionsTests
Name = "test-name",
Description = "test-description",
ChatOptions = new() { Temperature = 0.5f, Instructions = "custom instructions" },
HarnessInstructions = "custom harness instructions",
ChatHistoryProvider = chatHistoryProvider,
AIContextProviders = contextProviders,
MaximumIterationsPerRequest = 42,
DisableToolApproval = true,
DisableFileMemory = true,
FileMemoryStore = fileMemoryStore,
DisableFileAccess = true,
FileAccessStore = fileAccessStore,
DisableWebSearch = true,
DisableTodoProvider = true,
DisableAgentModeProvider = true,
AgentModeProviderOptions = agentModeOptions,
DisableAgentSkillsProvider = true,
AgentSkillsSource = skillsSource,
DisableOpenTelemetry = true,
OpenTelemetrySourceName = "custom-source",
};
// Assert
@@ -50,7 +86,22 @@ public class HarnessAgentOptionsTests
Assert.NotNull(options.ChatOptions);
Assert.Equal(0.5f, options.ChatOptions!.Temperature);
Assert.Equal("custom instructions", options.ChatOptions.Instructions);
Assert.Equal("custom harness instructions", options.HarnessInstructions);
Assert.Same(chatHistoryProvider, options.ChatHistoryProvider);
Assert.Same(contextProviders, options.AIContextProviders);
Assert.Equal(42, options.MaximumIterationsPerRequest);
Assert.True(options.DisableToolApproval);
Assert.True(options.DisableFileMemory);
Assert.Same(fileMemoryStore, options.FileMemoryStore);
Assert.True(options.DisableFileAccess);
Assert.Same(fileAccessStore, options.FileAccessStore);
Assert.True(options.DisableWebSearch);
Assert.True(options.DisableTodoProvider);
Assert.True(options.DisableAgentModeProvider);
Assert.Same(agentModeOptions, options.AgentModeProviderOptions);
Assert.True(options.DisableAgentSkillsProvider);
Assert.Same(skillsSource, options.AgentSkillsSource);
Assert.True(options.DisableOpenTelemetry);
Assert.Equal("custom-source", options.OpenTelemetrySourceName);
}
}
@@ -15,6 +15,21 @@ public class HarnessAgentTests
private const int TestMaxContextWindowTokens = 100_000;
private const int TestMaxOutputTokens = 10_000;
/// <summary>
/// Creates a HarnessAgent with all default features disabled to isolate tests for specific behaviors.
/// </summary>
private static HarnessAgentOptions CreateAllDisabledOptions() => new()
{
DisableToolApproval = true,
DisableOpenTelemetry = true,
DisableFileMemory = true,
DisableFileAccess = true,
DisableWebSearch = true,
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableAgentSkillsProvider = true,
};
#region Constructor Validation
/// <summary>
@@ -81,13 +96,12 @@ public class HarnessAgentTests
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.Name = "TestAgent";
options.Description = "A test agent";
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
{
Name = "TestAgent",
Description = "A test agent",
});
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
// Assert
Assert.Equal("TestAgent", agent.Name);
@@ -102,12 +116,11 @@ public class HarnessAgentTests
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.Id = "my-agent-id";
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
{
Id = "my-agent-id",
});
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
// Assert
Assert.Equal("my-agent-id", agent.Id);
@@ -127,7 +140,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -136,19 +149,18 @@ public class HarnessAgentTests
}
/// <summary>
/// Verify that default instructions are used when options is provided but ChatOptions.Instructions is null.
/// Verify that default instructions are used when options is provided but neither HarnessInstructions nor ChatOptions.Instructions is set.
/// </summary>
[Fact]
public void Instructions_DefaultsWhenChatOptionsInstructionsIsNull()
public void Instructions_DefaultsWhenBothNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.ChatOptions = new ChatOptions { Temperature = 0.5f };
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
{
ChatOptions = new ChatOptions { Temperature = 0.5f },
});
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -157,24 +169,106 @@ public class HarnessAgentTests
}
/// <summary>
/// Verify that ChatOptions.Instructions overrides the defaults.
/// Verify that ChatOptions.Instructions is appended to the default HarnessInstructions.
/// </summary>
[Fact]
public void Instructions_CanBeOverriddenViaChatOptions()
public void Instructions_CombinesDefaultHarnessWithAgentInstructions()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.ChatOptions = new ChatOptions { Instructions = "You are a custom assistant." };
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
{
ChatOptions = new ChatOptions { Instructions = "You are a custom assistant." },
});
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Equal("You are a custom assistant.", innerAgent!.Instructions);
var expected = $"{HarnessAgent.DefaultInstructions}\n\nYou are a custom assistant.";
Assert.Equal(expected, innerAgent!.Instructions);
}
/// <summary>
/// Verify that custom HarnessInstructions replaces the default.
/// </summary>
[Fact]
public void Instructions_CustomHarnessInstructionsReplacesDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.HarnessInstructions = "Custom harness rules.";
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Equal("Custom harness rules.", innerAgent!.Instructions);
}
/// <summary>
/// Verify that custom HarnessInstructions and ChatOptions.Instructions are combined.
/// </summary>
[Fact]
public void Instructions_CombinesCustomHarnessWithAgentInstructions()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.HarnessInstructions = "Custom harness rules.";
options.ChatOptions = new ChatOptions { Instructions = "You are a research agent." };
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Equal("Custom harness rules.\n\nYou are a research agent.", innerAgent!.Instructions);
}
/// <summary>
/// Verify that empty HarnessInstructions omits harness portion, using only agent instructions.
/// </summary>
[Fact]
public void Instructions_EmptyHarnessInstructionsUsesOnlyAgentInstructions()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.HarnessInstructions = string.Empty;
options.ChatOptions = new ChatOptions { Instructions = "Agent only instructions." };
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Equal("Agent only instructions.", innerAgent!.Instructions);
}
/// <summary>
/// Verify that empty HarnessInstructions with no agent instructions results in empty string.
/// </summary>
[Fact]
public void Instructions_EmptyHarnessInstructionsWithNoAgentInstructions()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.HarnessInstructions = string.Empty;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Equal(string.Empty, innerAgent!.Instructions);
}
#endregion
@@ -191,7 +285,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -208,12 +302,11 @@ public class HarnessAgentTests
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var customProvider = new InMemoryChatHistoryProvider();
var options = CreateAllDisabledOptions();
options.ChatHistoryProvider = customProvider;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
{
ChatHistoryProvider = customProvider,
});
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -235,7 +328,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -256,7 +349,7 @@ public class HarnessAgentTests
var rawClient = mockClient.Object;
// Act
var agent = new HarnessAgent(rawClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
var agent = new HarnessAgent(rawClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — the pipeline wraps the raw client, so the outer client is not the same object.
@@ -269,45 +362,45 @@ public class HarnessAgentTests
#region AIContextProviders
/// <summary>
/// Verify that additional AIContextProviders from options are passed to the inner ChatClientAgent,
/// not merged into the chat client builder pipeline.
/// Verify that additional AIContextProviders from options are passed to the inner ChatClientAgent.
/// </summary>
[Fact]
public void AIContextProviders_ArePassedToInnerAgent()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var todoProvider = new TodoProvider();
var customProvider = new TodoProvider();
var options = CreateAllDisabledOptions();
options.AIContextProviders = [customProvider];
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
{
AIContextProviders = [todoProvider],
});
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — the TodoProvider should appear in the inner agent's AIContextProviders.
// Assert — the custom provider should appear in the inner agent's AIContextProviders.
Assert.NotNull(innerAgent);
Assert.NotNull(innerAgent!.AIContextProviders);
Assert.Contains(todoProvider, innerAgent.AIContextProviders!);
Assert.Contains(customProvider, innerAgent.AIContextProviders!);
}
/// <summary>
/// Verify that when no AIContextProviders are specified, the inner agent has no additional providers.
/// Verify that when all default providers are disabled and no user AIContextProviders are specified,
/// the inner agent has an empty providers list.
/// </summary>
[Fact]
public void AIContextProviders_IsNullWhenNoneSpecified()
public void AIContextProviders_IsEmptyWhenAllDisabledAndNoneSpecified()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Null(innerAgent!.AIContextProviders);
Assert.NotNull(innerAgent!.AIContextProviders);
Assert.Empty(innerAgent.AIContextProviders!);
}
#endregion
@@ -332,13 +425,10 @@ public class HarnessAgentTests
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
{
ChatOptions = new ChatOptions
{
Tools = [tool],
},
});
var options = CreateAllDisabledOptions();
options.ChatOptions = new ChatOptions { Tools = [tool] };
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var session = await agent.CreateSessionAsync();
// Act
@@ -389,7 +479,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
// Assert
Assert.Same(agent, agent.GetService<HarnessAgent>());
@@ -405,7 +495,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
// Assert
Assert.NotNull(agent.GetService<ChatClientAgent>());
@@ -430,7 +520,7 @@ public class HarnessAgentTests
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello!")));
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens);
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var session = await agent.CreateSessionAsync();
// Act
@@ -487,19 +577,19 @@ public class HarnessAgentTests
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.Name = "ExtensionAgent";
options.ChatOptions = new ChatOptions { Instructions = "Custom instructions" };
// Act
var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
{
Name = "ExtensionAgent",
ChatOptions = new ChatOptions { Instructions = "Custom instructions" },
});
var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.Equal("ExtensionAgent", agent.Name);
Assert.NotNull(innerAgent);
Assert.Equal("Custom instructions", innerAgent!.Instructions);
var expected = $"{HarnessAgent.DefaultInstructions}\n\nCustom instructions";
Assert.Equal(expected, innerAgent!.Instructions);
}
/// <summary>
@@ -513,4 +603,598 @@ public class HarnessAgentTests
}
#endregion
#region Feature: ToolApproval
/// <summary>
/// Verify that ToolApprovalAgent is included in the pipeline by default.
/// </summary>
[Fact]
public void ToolApproval_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableToolApproval = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
// Assert
Assert.NotNull(agent.GetService<ToolApprovalAgent>());
}
/// <summary>
/// Verify that ToolApprovalAgent is excluded when disabled.
/// </summary>
[Fact]
public void ToolApproval_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
// Assert
Assert.Null(agent.GetService<ToolApprovalAgent>());
}
#endregion
#region Feature: OpenTelemetry
/// <summary>
/// Verify that OpenTelemetryAgent is included in the pipeline by default.
/// </summary>
[Fact]
public void OpenTelemetry_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableOpenTelemetry = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
// Assert
Assert.NotNull(agent.GetService<OpenTelemetryAgent>());
}
/// <summary>
/// Verify that OpenTelemetryAgent is excluded when disabled.
/// </summary>
[Fact]
public void OpenTelemetry_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
// Assert
Assert.Null(agent.GetService<OpenTelemetryAgent>());
}
/// <summary>
/// Verify that a custom OpenTelemetrySourceName is accepted without error.
/// </summary>
[Fact]
public void OpenTelemetry_CustomSourceNameIsAccepted()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableOpenTelemetry = false;
options.OpenTelemetrySourceName = "MyApp.AgentTracing";
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
// Assert
Assert.NotNull(agent.GetService<OpenTelemetryAgent>());
}
#endregion
#region Feature: WebSearch
/// <summary>
/// Verify that HostedWebSearchTool is added to ChatOptions.Tools by default.
/// </summary>
[Fact]
public async Task WebSearch_IncludedByDefaultAsync()
{
// Arrange
var mockClient = new Mock<IChatClient>();
ChatOptions? capturedOptions = null;
mockClient
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
var options = CreateAllDisabledOptions();
options.DisableWebSearch = false;
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var session = await agent.CreateSessionAsync();
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert
Assert.NotNull(capturedOptions?.Tools);
Assert.Contains(capturedOptions!.Tools!, t => t is HostedWebSearchTool);
}
/// <summary>
/// Verify that HostedWebSearchTool is not added when disabled.
/// </summary>
[Fact]
public async Task WebSearch_ExcludedWhenDisabledAsync()
{
// Arrange
var mockClient = new Mock<IChatClient>();
ChatOptions? capturedOptions = null;
mockClient
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var session = await agent.CreateSessionAsync();
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert
Assert.NotNull(capturedOptions);
if (capturedOptions!.Tools != null)
{
Assert.DoesNotContain(capturedOptions.Tools, t => t is HostedWebSearchTool);
}
}
/// <summary>
/// Verify that user-provided tools are preserved alongside the default HostedWebSearchTool.
/// </summary>
[Fact]
public async Task WebSearch_CoexistsWithUserToolsAsync()
{
// Arrange
var mockClient = new Mock<IChatClient>();
ChatOptions? capturedOptions = null;
mockClient
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
var userTool = AIFunctionFactory.Create(() => "test", "UserTool");
var options = CreateAllDisabledOptions();
options.DisableWebSearch = false;
options.ChatOptions = new ChatOptions { Tools = [userTool] };
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var session = await agent.CreateSessionAsync();
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert
Assert.NotNull(capturedOptions?.Tools);
Assert.Contains(capturedOptions!.Tools!, t => t is HostedWebSearchTool);
Assert.Contains(capturedOptions.Tools!, t => t == userTool);
}
#endregion
#region Feature: TodoProvider
/// <summary>
/// Verify that TodoProvider is included in AIContextProviders by default.
/// </summary>
[Fact]
public void TodoProvider_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableTodoProvider = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is TodoProvider);
}
/// <summary>
/// Verify that TodoProvider is excluded when disabled.
/// </summary>
[Fact]
public void TodoProvider_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is TodoProvider);
}
}
#endregion
#region Feature: AgentModeProvider
/// <summary>
/// Verify that AgentModeProvider is included in AIContextProviders by default.
/// </summary>
[Fact]
public void AgentModeProvider_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableAgentModeProvider = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is AgentModeProvider);
}
/// <summary>
/// Verify that AgentModeProvider is excluded when disabled.
/// </summary>
[Fact]
public void AgentModeProvider_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is AgentModeProvider);
}
}
/// <summary>
/// Verify that custom AgentModeProviderOptions are passed through.
/// </summary>
[Fact]
public void AgentModeProvider_UsesCustomOptions()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableAgentModeProvider = false;
options.AgentModeProviderOptions = new AgentModeProviderOptions
{
Modes =
[
new AgentModeProviderOptions.AgentMode("custom-mode", "A custom mode for testing"),
],
DefaultMode = "custom-mode",
};
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — AgentModeProvider should be present (we can't easily inspect its internal options,
// but we verify it is created and present).
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is AgentModeProvider);
}
#endregion
#region Feature: FileMemoryProvider
/// <summary>
/// Verify that FileMemoryProvider is included in AIContextProviders by default.
/// </summary>
[Fact]
public void FileMemoryProvider_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableFileMemory = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is FileMemoryProvider);
}
/// <summary>
/// Verify that FileMemoryProvider is excluded when disabled.
/// </summary>
[Fact]
public void FileMemoryProvider_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is FileMemoryProvider);
}
}
/// <summary>
/// Verify that a custom FileMemoryStore is used when provided.
/// </summary>
[Fact]
public void FileMemoryProvider_UsesCustomStore()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var customStore = new Mock<AgentFileStore>().Object;
var options = CreateAllDisabledOptions();
options.DisableFileMemory = false;
options.FileMemoryStore = customStore;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — FileMemoryProvider should be present with the custom store.
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is FileMemoryProvider);
}
#endregion
#region Feature: FileAccessProvider
/// <summary>
/// Verify that FileAccessProvider is included in AIContextProviders by default.
/// </summary>
[Fact]
public void FileAccessProvider_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableFileAccess = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is FileAccessProvider);
}
/// <summary>
/// Verify that FileAccessProvider is excluded when disabled.
/// </summary>
[Fact]
public void FileAccessProvider_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is FileAccessProvider);
}
}
/// <summary>
/// Verify that a custom FileAccessStore is used when provided.
/// </summary>
[Fact]
public void FileAccessProvider_UsesCustomStore()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var customStore = new Mock<AgentFileStore>().Object;
var options = CreateAllDisabledOptions();
options.DisableFileAccess = false;
options.FileAccessStore = customStore;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — FileAccessProvider should be present with the custom store.
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is FileAccessProvider);
}
#endregion
#region Feature: AgentSkillsProvider
/// <summary>
/// Verify that AgentSkillsProvider is included in AIContextProviders by default.
/// </summary>
[Fact]
public void AgentSkillsProvider_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableAgentSkillsProvider = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is AgentSkillsProvider);
}
/// <summary>
/// Verify that AgentSkillsProvider is excluded when disabled.
/// </summary>
[Fact]
public void AgentSkillsProvider_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is AgentSkillsProvider);
}
}
/// <summary>
/// Verify that a custom AgentSkillsSource is used when provided.
/// </summary>
[Fact]
public void AgentSkillsProvider_UsesCustomSource()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var customSource = new Mock<AgentSkillsSource>().Object;
var options = CreateAllDisabledOptions();
options.DisableAgentSkillsProvider = false;
options.AgentSkillsSource = customSource;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — AgentSkillsProvider should be present.
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is AgentSkillsProvider);
}
#endregion
#region Feature: MaximumIterationsPerRequest
/// <summary>
/// Verify that MaximumIterationsPerRequest configures the FunctionInvokingChatClient.
/// </summary>
[Fact]
public void MaximumIterationsPerRequest_ConfiguresFunctionInvokingChatClient()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.MaximumIterationsPerRequest = 42;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
var ficc = innerAgent!.ChatClient.GetService<FunctionInvokingChatClient>();
// Assert
Assert.NotNull(ficc);
Assert.Equal(42, ficc!.MaximumIterationsPerRequest);
}
/// <summary>
/// Verify that the default MaximumIterationsPerRequest is used when not set.
/// </summary>
[Fact]
public void MaximumIterationsPerRequest_UsesDefaultWhenNotSet()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
var ficc = innerAgent!.ChatClient.GetService<FunctionInvokingChatClient>();
// Assert — default is not 0 and not our custom value.
Assert.NotNull(ficc);
Assert.NotEqual(0, ficc!.MaximumIterationsPerRequest);
}
#endregion
#region Feature: All Defaults Enabled
/// <summary>
/// Verify that when no options are provided, all default features are enabled.
/// </summary>
[Fact]
public async Task AllDefaults_AllFeaturesEnabledAsync()
{
// Arrange
var mockClient = new Mock<IChatClient>();
ChatOptions? capturedOptions = null;
mockClient
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
// Act
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — agent wrappers
Assert.NotNull(agent.GetService<ToolApprovalAgent>());
Assert.NotNull(agent.GetService<OpenTelemetryAgent>());
// Assert — default context providers
Assert.NotNull(innerAgent);
Assert.NotNull(innerAgent!.AIContextProviders);
var providers = innerAgent.AIContextProviders!.ToList();
Assert.Contains(providers, p => p is TodoProvider);
Assert.Contains(providers, p => p is AgentModeProvider);
Assert.Contains(providers, p => p is FileMemoryProvider);
Assert.Contains(providers, p => p is FileAccessProvider);
Assert.Contains(providers, p => p is AgentSkillsProvider);
// Assert — HostedWebSearchTool is present in the tools sent to the model
var session = await agent.CreateSessionAsync();
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
Assert.NotNull(capturedOptions?.Tools);
Assert.Contains(capturedOptions!.Tools!, t => t is HostedWebSearchTool);
}
#endregion
}
@@ -13,9 +13,9 @@ using Moq.Protected;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="SubAgentsProvider"/> class.
/// Unit tests for the <see cref="BackgroundAgentsProvider"/> class.
/// </summary>
public class SubAgentsProviderTests
public class BackgroundAgentsProviderTests
{
#region Constructor Tests
@@ -26,7 +26,7 @@ public class SubAgentsProviderTests
public void Constructor_NullAgents_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new SubAgentsProvider(null!));
Assert.Throws<ArgumentNullException>(() => new BackgroundAgentsProvider(null!));
}
/// <summary>
@@ -36,7 +36,7 @@ public class SubAgentsProviderTests
public void Constructor_EmptyAgents_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => new SubAgentsProvider(Array.Empty<AIAgent>()));
Assert.Throws<ArgumentException>(() => new BackgroundAgentsProvider(Array.Empty<AIAgent>()));
}
/// <summary>
@@ -49,7 +49,7 @@ public class SubAgentsProviderTests
var agent = CreateMockAgent(null!, "desc");
// Act & Assert
Assert.Throws<ArgumentException>(() => new SubAgentsProvider(new[] { agent }));
Assert.Throws<ArgumentException>(() => new BackgroundAgentsProvider(new[] { agent }));
}
/// <summary>
@@ -62,7 +62,7 @@ public class SubAgentsProviderTests
var agent = CreateMockAgent("", "desc");
// Act & Assert
Assert.Throws<ArgumentException>(() => new SubAgentsProvider(new[] { agent }));
Assert.Throws<ArgumentException>(() => new BackgroundAgentsProvider(new[] { agent }));
}
/// <summary>
@@ -76,7 +76,7 @@ public class SubAgentsProviderTests
var agent2 = CreateMockAgent("research", "Agent 2");
// Act & Assert
Assert.Throws<ArgumentException>(() => new SubAgentsProvider(new[] { agent1, agent2 }));
Assert.Throws<ArgumentException>(() => new BackgroundAgentsProvider(new[] { agent1, agent2 }));
}
/// <summary>
@@ -90,7 +90,7 @@ public class SubAgentsProviderTests
var agent2 = CreateMockAgent("Writer", "Writer agent");
// Act
var provider = new SubAgentsProvider(new[] { agent1, agent2 });
var provider = new BackgroundAgentsProvider(new[] { agent1, agent2 });
// Assert
Assert.NotNull(provider);
@@ -108,7 +108,7 @@ public class SubAgentsProviderTests
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var provider = new SubAgentsProvider(new[] { agent });
var provider = new BackgroundAgentsProvider(new[] { agent });
var context = CreateInvokingContext();
// Act
@@ -129,7 +129,7 @@ public class SubAgentsProviderTests
// Arrange
var agent1 = CreateMockAgent("Research", "Performs research");
var agent2 = CreateMockAgent("Writer", "Writes content");
var provider = new SubAgentsProvider(new[] { agent1, agent2 });
var provider = new BackgroundAgentsProvider(new[] { agent1, agent2 });
var context = CreateInvokingContext();
// Act
@@ -144,22 +144,22 @@ public class SubAgentsProviderTests
#endregion
#region StartSubTask Tests
#region StartBackgroundTask Tests
/// <summary>
/// Verify that StartSubTask returns a task ID.
/// Verify that StartBackgroundTask returns a task ID.
/// </summary>
[Fact]
public async Task StartSubTask_ReturnsTaskIdAsync()
public async Task StartBackgroundTask_ReturnsTaskIdAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
// Act
object? result = await startSubTask.InvokeAsync(new AIFunctionArguments
object? result = await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Find information about AI",
@@ -175,18 +175,18 @@ public class SubAgentsProviderTests
}
/// <summary>
/// Verify that StartSubTask with invalid agent name returns an error.
/// Verify that StartBackgroundTask with invalid agent name returns an error.
/// </summary>
[Fact]
public async Task StartSubTask_InvalidAgentName_ReturnsErrorAsync()
public async Task StartBackgroundTask_InvalidAgentName_ReturnsErrorAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
// Act
object? result = await startSubTask.InvokeAsync(new AIFunctionArguments
object? result = await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "NonExistent",
["input"] = "Some input",
@@ -200,10 +200,10 @@ public class SubAgentsProviderTests
}
/// <summary>
/// Verify that StartSubTask assigns sequential IDs.
/// Verify that StartBackgroundTask assigns sequential IDs.
/// </summary>
[Fact]
public async Task StartSubTask_AssignsSequentialIdsAsync()
public async Task StartBackgroundTask_AssignsSequentialIdsAsync()
{
// Arrange
var tcs1 = new TaskCompletionSource<AgentResponse>();
@@ -215,16 +215,16 @@ public class SubAgentsProviderTests
return callCount == 1 ? tcs1.Task : tcs2.Task;
});
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
// Act
object? result1 = await startSubTask.InvokeAsync(new AIFunctionArguments
object? result1 = await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Task 1",
["description"] = "First task",
});
object? result2 = await startSubTask.InvokeAsync(new AIFunctionArguments
object? result2 = await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Task 2",
@@ -253,11 +253,11 @@ public class SubAgentsProviderTests
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
// Start one task
await startSubTask.InvokeAsync(new AIFunctionArguments
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Task 1",
@@ -288,7 +288,7 @@ public class SubAgentsProviderTests
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
// Act
object? result = await waitForFirst.InvokeAsync(new AIFunctionArguments
@@ -302,24 +302,24 @@ public class SubAgentsProviderTests
#endregion
#region GetSubTaskResults Tests
#region GetBackgroundTaskResults Tests
/// <summary>
/// Verify that GetSubTaskResults returns the result text of a completed task.
/// Verify that GetBackgroundTaskResults returns the result text of a completed task.
/// </summary>
[Fact]
public async Task GetSubTaskResults_CompletedTask_ReturnsResultTextAsync()
public async Task GetBackgroundTaskResults_CompletedTask_ReturnsResultTextAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
// Start a task
await startSubTask.InvokeAsync(new AIFunctionArguments
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -346,20 +346,20 @@ public class SubAgentsProviderTests
}
/// <summary>
/// Verify that GetSubTaskResults for a still-running task returns status info.
/// Verify that GetBackgroundTaskResults for a still-running task returns status info.
/// </summary>
[Fact]
public async Task GetSubTaskResults_RunningTask_ReturnsStatusAsync()
public async Task GetBackgroundTaskResults_RunningTask_ReturnsStatusAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
// Start a task (don't complete it)
await startSubTask.InvokeAsync(new AIFunctionArguments
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -379,15 +379,15 @@ public class SubAgentsProviderTests
}
/// <summary>
/// Verify that GetSubTaskResults for a nonexistent task returns an error.
/// Verify that GetBackgroundTaskResults for a nonexistent task returns an error.
/// </summary>
[Fact]
public async Task GetSubTaskResults_NonexistentTask_ReturnsErrorAsync()
public async Task GetBackgroundTaskResults_NonexistentTask_ReturnsErrorAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
// Act
object? result = await getResults.InvokeAsync(new AIFunctionArguments
@@ -400,21 +400,21 @@ public class SubAgentsProviderTests
}
/// <summary>
/// Verify that GetSubTaskResults for a failed task returns the error.
/// Verify that GetBackgroundTaskResults for a failed task returns the error.
/// </summary>
[Fact]
public async Task GetSubTaskResults_FailedTask_ReturnsErrorTextAsync()
public async Task GetBackgroundTaskResults_FailedTask_ReturnsErrorTextAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
// Start a task
await startSubTask.InvokeAsync(new AIFunctionArguments
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -456,11 +456,11 @@ public class SubAgentsProviderTests
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction getAllTasks = GetTool(tools, "SubAgents_GetAllTasks");
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction getAllTasks = GetTool(tools, "BackgroundAgents_GetAllTasks");
// Start a task
await startSubTask.InvokeAsync(new AIFunctionArguments
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -490,12 +490,12 @@ public class SubAgentsProviderTests
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
AIFunction getAllTasks = GetTool(tools, "SubAgents_GetAllTasks");
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
AIFunction getAllTasks = GetTool(tools, "BackgroundAgents_GetAllTasks");
// Start and complete a task
await startSubTask.InvokeAsync(new AIFunctionArguments
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -525,7 +525,7 @@ public class SubAgentsProviderTests
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction getAllTasks = GetTool(tools, "SubAgents_GetAllTasks");
AIFunction getAllTasks = GetTool(tools, "BackgroundAgents_GetAllTasks");
// Act
object? result = await getAllTasks.InvokeAsync(new AIFunctionArguments());
@@ -554,13 +554,13 @@ public class SubAgentsProviderTests
return callCount == 1 ? tcs1.Task : tcs2.Task;
});
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
AIFunction continueTask = GetTool(tools, "SubAgents_ContinueTask");
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
AIFunction continueTask = GetTool(tools, "BackgroundAgents_ContinueTask");
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
// Start and complete a task
await startSubTask.InvokeAsync(new AIFunctionArguments
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -606,11 +606,11 @@ public class SubAgentsProviderTests
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction continueTask = GetTool(tools, "SubAgents_ContinueTask");
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction continueTask = GetTool(tools, "BackgroundAgents_ContinueTask");
// Start a task (don't complete it)
await startSubTask.InvokeAsync(new AIFunctionArguments
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -639,7 +639,7 @@ public class SubAgentsProviderTests
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction continueTask = GetTool(tools, "SubAgents_ContinueTask");
AIFunction continueTask = GetTool(tools, "BackgroundAgents_ContinueTask");
// Act
object? result = await continueTask.InvokeAsync(new AIFunctionArguments
@@ -666,13 +666,13 @@ public class SubAgentsProviderTests
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion");
AIFunction clearTask = GetTool(tools, "SubAgents_ClearCompletedTask");
AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults");
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction waitForFirst = GetTool(tools, "BackgroundAgents_WaitForFirstCompletion");
AIFunction clearTask = GetTool(tools, "BackgroundAgents_ClearCompletedTask");
AIFunction getResults = GetTool(tools, "BackgroundAgents_GetTaskResults");
// Start and complete a task
await startSubTask.InvokeAsync(new AIFunctionArguments
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -711,11 +711,11 @@ public class SubAgentsProviderTests
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask");
AIFunction clearTask = GetTool(tools, "SubAgents_ClearCompletedTask");
AIFunction startBackgroundTask = GetTool(tools, "BackgroundAgents_StartTask");
AIFunction clearTask = GetTool(tools, "BackgroundAgents_ClearCompletedTask");
// Start a task (don't complete it)
await startSubTask.InvokeAsync(new AIFunctionArguments
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
@@ -743,7 +743,7 @@ public class SubAgentsProviderTests
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction clearTask = GetTool(tools, "SubAgents_ClearCompletedTask");
AIFunction clearTask = GetTool(tools, "BackgroundAgents_ClearCompletedTask");
// Act
object? result = await clearTask.InvokeAsync(new AIFunctionArguments
@@ -767,7 +767,7 @@ public class SubAgentsProviderTests
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var provider = new SubAgentsProvider(new[] { agent });
var provider = new BackgroundAgentsProvider(new[] { agent });
// Act
var keys = provider.StateKeys;
@@ -782,23 +782,23 @@ public class SubAgentsProviderTests
#region CurrentRunContext Isolation Tests
/// <summary>
/// Verify that StartSubTask does not corrupt CurrentRunContext of the calling agent.
/// Verify that StartBackgroundTask does not corrupt CurrentRunContext of the calling agent.
/// Because RunAsync is a non-async method that synchronously sets the static AsyncLocal
/// CurrentRunContext, the provider must isolate the sub-agent call to prevent overwriting
/// CurrentRunContext, the provider must isolate the background agent call to prevent overwriting
/// the outer agent's context.
/// </summary>
[Fact]
public async Task StartSubTask_DoesNotCorruptCurrentRunContextAsync()
public async Task StartBackgroundTask_DoesNotCorruptCurrentRunContextAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
var startTool = GetTool(tools, "SubAgents_StartTask");
var startTool = GetTool(tools, "BackgroundAgents_StartTask");
AgentRunContext? contextBefore = AIAgent.CurrentRunContext;
// Act — invoke StartSubTask; this calls agent.RunAsync internally.
// Act — invoke StartBackgroundTask; this calls agent.RunAsync internally.
var args = new AIFunctionArguments(new Dictionary<string, object?>
{
["agentName"] = "Research",
@@ -826,16 +826,16 @@ public class SubAgentsProviderTests
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
const string CustomInstructions = "These are custom sub-agent instructions.\n{sub_agents}";
var options = new SubAgentsProviderOptions { Instructions = CustomInstructions };
var provider = new SubAgentsProvider(new[] { agent }, options);
const string CustomInstructions = "These are custom background agent instructions.\n{background_agents}";
var options = new BackgroundAgentsProviderOptions { Instructions = CustomInstructions };
var provider = new BackgroundAgentsProvider(new[] { agent }, options);
var context = CreateInvokingContext();
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — custom instructions replace default, agent list is injected via {sub_agents} placeholder
Assert.Contains("These are custom sub-agent instructions.", result.Instructions);
Assert.Contains("These are custom background agent instructions.", result.Instructions);
Assert.Contains("Research", result.Instructions);
}
@@ -847,15 +847,15 @@ public class SubAgentsProviderTests
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var provider = new SubAgentsProvider(new[] { agent });
var provider = new BackgroundAgentsProvider(new[] { agent });
var context = CreateInvokingContext();
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — instructions contain tool usage guidance and agent list
Assert.Contains("SubAgents_*", result.Instructions);
Assert.Contains("SubAgents_ClearCompletedTask", result.Instructions);
Assert.Contains("BackgroundAgents_*", result.Instructions);
Assert.Contains("BackgroundAgents_ClearCompletedTask", result.Instructions);
Assert.Contains("Research", result.Instructions);
Assert.Contains("Research agent", result.Instructions);
}
@@ -868,11 +868,11 @@ public class SubAgentsProviderTests
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var options = new SubAgentsProviderOptions
var options = new BackgroundAgentsProviderOptions
{
AgentListBuilder = agents => $"Custom list: {string.Join(", ", agents.Keys)}",
};
var provider = new SubAgentsProvider(new[] { agent }, options);
var provider = new BackgroundAgentsProvider(new[] { agent }, options);
var context = CreateInvokingContext();
// Act
@@ -880,7 +880,7 @@ public class SubAgentsProviderTests
// Assert — custom agent list builder output is in instructions
Assert.Contains("Custom list: Research", result.Instructions);
Assert.DoesNotContain("Available sub-agents:", result.Instructions);
Assert.DoesNotContain("Available background agents:", result.Instructions);
}
#endregion
@@ -935,9 +935,9 @@ public class SubAgentsProviderTests
return mock.Object;
}
private static async Task<(IEnumerable<AITool> Tools, SubAgentsProvider Provider)> CreateToolsWithProviderAsync(AIAgent agent)
private static async Task<(IEnumerable<AITool> Tools, BackgroundAgentsProvider Provider)> CreateToolsWithProviderAsync(AIAgent agent)
{
var provider = new SubAgentsProvider(new[] { agent });
var provider = new BackgroundAgentsProvider(new[] { agent });
var context = CreateInvokingContext();
AIContext result = await provider.InvokingAsync(context);
@@ -116,7 +116,7 @@ public class TodoProviderTests
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Test", Description = null } } });
// Act
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1 } });
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" } } });
// Assert
Assert.True(state.Items[0].IsComplete);
@@ -139,7 +139,7 @@ public class TodoProviderTests
});
// Act
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1, 3 } });
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" }, new() { Id = 3, Reason = "Done" } } });
// Assert
Assert.True(state.Items[0].IsComplete);
@@ -159,12 +159,35 @@ public class TodoProviderTests
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
// Act
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 999 } });
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 999, Reason = "Done" } } });
// Assert
Assert.Equal(0, GetIntResult(result));
}
/// <summary>
/// Verify that CompleteTodos accepts an optional reason parameter.
/// </summary>
[Fact]
public async Task CompleteTodos_AcceptsReasonParameterAsync()
{
// Arrange
var (tools, state) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "TodoList_Add");
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Research topic" } } });
// Act
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments()
{
["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Found the answer in the documentation." } },
});
// Assert
Assert.True(state.Items[0].IsComplete);
Assert.Equal(1, GetIntResult(result));
}
#endregion
#region RemoveTodos Tests
@@ -249,7 +272,7 @@ public class TodoProviderTests
{
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
});
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1 } });
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" } } });
// Act
object? result = await getRemainingTodos.InvokeAsync(new AIFunctionArguments());
@@ -279,7 +302,7 @@ public class TodoProviderTests
{
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
});
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1 } });
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" } } });
// Act
object? result = await getAllTodos.InvokeAsync(new AIFunctionArguments());
@@ -376,7 +399,7 @@ public class TodoProviderTests
{
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
});
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1 } });
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" } } });
// Act
var remaining = await provider.GetRemainingTodosAsync(session);
@@ -543,7 +566,7 @@ public class TodoProviderTests
new() { Title = "Second", Description = "Has details" },
},
});
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1 } });
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" } } });
// Act — second invocation should see the updated list in messages
AIContext result2 = await provider.InvokingAsync(context);
@@ -762,7 +785,7 @@ public class TodoProviderTests
{
["todos"] = new List<TodoItemInput> { new() { Title = "New C" } },
}).AsTask(),
completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1, 2, 3 } }).AsTask());
completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" }, new() { Id = 2, Reason = "Done" }, new() { Id = 3, Reason = "Done" } } }).AsTask());
// Assert
object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments());
@@ -445,8 +445,9 @@ public sealed class DefaultMcpToolHandlerTests
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
result.Should().BeOfType<TextContent>()
.Which.Text.Should().Be("hello world");
TextContent textContent = result.Should().BeOfType<TextContent>().Subject;
textContent.Text.Should().Be("hello world");
textContent.RawRepresentation.Should().BeSameAs(block);
}
[Fact]
@@ -462,13 +463,17 @@ public sealed class DefaultMcpToolHandlerTests
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("image/png");
dataContent.Uri.Should().Be("data:image/png;base64,");
dataContent.Data.IsEmpty.Should().BeTrue();
dataContent.RawRepresentation.Should().BeSameAs(block);
}
[Fact]
public void ConvertContentBlock_ImageContentBlock_WithBase64Payload_ShouldReturnDataContent()
{
// Arrange
byte[] base64Bytes = Encoding.UTF8.GetBytes("iVBORw0KGgo=");
const string Base64Payload = "iVBORw0KGgo=";
byte[] base64Bytes = Encoding.UTF8.GetBytes(Base64Payload);
byte[] expectedDecoded = Convert.FromBase64String(Base64Payload);
ImageContentBlock block = new() { Data = new ReadOnlyMemory<byte>(base64Bytes), MimeType = "image/png" };
// Act
@@ -477,39 +482,9 @@ public sealed class DefaultMcpToolHandlerTests
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("image/png");
dataContent.Uri.Should().Be("data:image/png;base64,iVBORw0KGgo=");
}
[Fact]
public void ConvertContentBlock_ImageContentBlock_WithDataUri_ShouldReturnDataContentDirectly()
{
// Arrange
const string DataUri = "data:image/jpeg;base64,/9j/4AAQ";
byte[] dataUriBytes = Encoding.UTF8.GetBytes(DataUri);
ImageContentBlock block = new() { Data = new ReadOnlyMemory<byte>(dataUriBytes), MimeType = "image/jpeg" };
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("image/jpeg");
dataContent.Uri.Should().Be(DataUri);
}
[Fact]
public void ConvertContentBlock_ImageContentBlock_WithNullMimeType_ShouldDefaultToImageWildcard()
{
// Arrange
byte[] base64Bytes = Encoding.UTF8.GetBytes("iVBORw0KGgo=");
ImageContentBlock block = new() { Data = new ReadOnlyMemory<byte>(base64Bytes), MimeType = null! };
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("image/*");
dataContent.Data.ToArray().Should().BeEquivalentTo(expectedDecoded);
dataContent.Uri.Should().Be($"data:image/png;base64,{Base64Payload}");
dataContent.RawRepresentation.Should().BeSameAs(block);
}
[Fact]
@@ -525,13 +500,17 @@ public sealed class DefaultMcpToolHandlerTests
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("audio/wav");
dataContent.Uri.Should().Be("data:audio/wav;base64,");
dataContent.Data.IsEmpty.Should().BeTrue();
dataContent.RawRepresentation.Should().BeSameAs(block);
}
[Fact]
public void ConvertContentBlock_AudioContentBlock_WithBase64Payload_ShouldReturnDataContent()
{
// Arrange
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
const string Base64Payload = "UklGRiQA";
byte[] base64Bytes = Encoding.UTF8.GetBytes(Base64Payload);
byte[] expectedDecoded = Convert.FromBase64String(Base64Payload);
AudioContentBlock block = new() { Data = new ReadOnlyMemory<byte>(base64Bytes), MimeType = "audio/wav" };
// Act
@@ -540,39 +519,9 @@ public sealed class DefaultMcpToolHandlerTests
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("audio/wav");
dataContent.Uri.Should().Be("data:audio/wav;base64,UklGRiQA");
}
[Fact]
public void ConvertContentBlock_AudioContentBlock_WithDataUri_ShouldReturnDataContentDirectly()
{
// Arrange
const string DataUri = "data:audio/mp3;base64,//uQxAAA";
byte[] dataUriBytes = Encoding.UTF8.GetBytes(DataUri);
AudioContentBlock block = new() { Data = new ReadOnlyMemory<byte>(dataUriBytes), MimeType = "audio/mp3" };
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("audio/mp3");
dataContent.Uri.Should().Be(DataUri);
}
[Fact]
public void ConvertContentBlock_AudioContentBlock_WithNullMimeType_ShouldDefaultToAudioWildcard()
{
// Arrange
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
AudioContentBlock block = new() { Data = new ReadOnlyMemory<byte>(base64Bytes), MimeType = null! };
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("audio/*");
dataContent.Data.ToArray().Should().BeEquivalentTo(expectedDecoded);
dataContent.Uri.Should().Be($"data:audio/wav;base64,{Base64Payload}");
dataContent.RawRepresentation.Should().BeSameAs(block);
}
[Fact]
@@ -593,15 +542,18 @@ public sealed class DefaultMcpToolHandlerTests
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
result.Should().BeOfType<TextContent>()
.Which.Text.Should().Be("embedded text payload");
TextContent textContent = result.Should().BeOfType<TextContent>().Subject;
textContent.Text.Should().Be("embedded text payload");
textContent.RawRepresentation.Should().BeSameAs(block);
}
[Fact]
public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_ShouldReturnDataContent()
{
// Arrange
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
const string Base64Payload = "UklGRiQA";
byte[] base64Bytes = Encoding.UTF8.GetBytes(Base64Payload);
byte[] expectedDecoded = Convert.FromBase64String(Base64Payload);
EmbeddedResourceBlock block = new()
{
Resource = new BlobResourceContents
@@ -618,21 +570,65 @@ public sealed class DefaultMcpToolHandlerTests
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("application/zip");
dataContent.Uri.Should().Be("data:application/zip;base64,UklGRiQA");
dataContent.Data.ToArray().Should().BeEquivalentTo(expectedDecoded);
dataContent.Uri.Should().Be($"data:application/zip;base64,{Base64Payload}");
dataContent.RawRepresentation.Should().BeSameAs(block);
}
[Fact]
public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_NullMimeType_DefaultsToOctetStream()
public void ConvertContentBlock_ResourceLinkBlock_WithUri_ShouldReturnUriContent()
{
// Arrange
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
EmbeddedResourceBlock block = new()
ResourceLinkBlock block = new()
{
Resource = new BlobResourceContents
Uri = "https://example.com/resource.bin",
Name = "resource.bin",
MimeType = "application/zip",
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
UriContent uriContent = result.Should().BeOfType<UriContent>().Subject;
uriContent.Uri.ToString().Should().Be("https://example.com/resource.bin");
uriContent.MediaType.Should().Be("application/zip");
uriContent.RawRepresentation.Should().BeSameAs(block);
}
[Fact]
public void ConvertContentBlock_ResourceLinkBlock_WithNullMimeType_ShouldDefaultToOctetStream()
{
// Arrange
ResourceLinkBlock block = new()
{
Uri = "https://example.com/resource",
Name = "resource",
MimeType = null,
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
UriContent uriContent = result.Should().BeOfType<UriContent>().Subject;
uriContent.Uri.ToString().Should().Be("https://example.com/resource");
uriContent.MediaType.Should().Be("application/octet-stream");
}
[Fact]
public void ConvertContentBlock_ResourceLinkBlock_WithMeta_ShouldPropagateToAdditionalProperties()
{
// Arrange
ResourceLinkBlock block = new()
{
Uri = "https://example.com/resource.bin",
Name = string.Empty,
MimeType = "application/zip",
Meta = new System.Text.Json.Nodes.JsonObject
{
Blob = new ReadOnlyMemory<byte>(base64Bytes),
Uri = "resource://example.bin",
MimeType = null!,
["traceId"] = "abc-123",
["priority"] = 7,
},
};
@@ -640,9 +636,120 @@ public sealed class DefaultMcpToolHandlerTests
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("application/octet-stream");
dataContent.Uri.Should().Be("data:application/octet-stream;base64,UklGRiQA");
UriContent uriContent = result.Should().BeOfType<UriContent>().Subject;
uriContent.AdditionalProperties.Should().NotBeNull();
uriContent.AdditionalProperties!.Should().HaveCount(2);
uriContent.AdditionalProperties["traceId"].Should().BeSameAs(block.Meta!["traceId"]);
uriContent.AdditionalProperties["priority"].Should().BeSameAs(block.Meta["priority"]);
}
[Fact]
public void ConvertContentBlock_ResourceLinkBlock_WithName_ShouldMapNameToFilenameAdditionalProperty()
{
// Arrange
ResourceLinkBlock block = new()
{
Uri = "https://example.com/resource.bin",
Name = "resource.bin",
MimeType = "application/zip",
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
UriContent uriContent = result.Should().BeOfType<UriContent>().Subject;
uriContent.AdditionalProperties.Should().NotBeNull();
uriContent.AdditionalProperties!["filename"].Should().Be("resource.bin");
}
[Fact]
public void ConvertContentBlock_ToolUseContentBlock_ShouldReturnFunctionCallContent()
{
// Arrange
using JsonDocument input = JsonDocument.Parse("{\"city\":\"Seattle\",\"unit\":\"celsius\"}");
ToolUseContentBlock block = new()
{
Id = "call-1",
Name = "get_weather",
Input = input.RootElement.Clone(),
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
FunctionCallContent call = result.Should().BeOfType<FunctionCallContent>().Subject;
call.CallId.Should().Be("call-1");
call.Name.Should().Be("get_weather");
call.Arguments.Should().NotBeNull();
call.Arguments!.Should().ContainKey("city");
call.RawRepresentation.Should().BeSameAs(block);
}
[Fact]
public void ConvertContentBlock_ToolResultContentBlock_NotError_ShouldReturnFunctionResultContent()
{
// Arrange
ToolResultContentBlock block = new()
{
ToolUseId = "call-1",
Content = [new TextContentBlock { Text = "ok" }],
IsError = false,
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
FunctionResultContent functionResult = result.Should().BeOfType<FunctionResultContent>().Subject;
functionResult.CallId.Should().Be("call-1");
functionResult.Exception.Should().BeNull();
functionResult.RawRepresentation.Should().BeSameAs(block);
}
[Fact]
public void ConvertContentBlock_ToolResultContentBlock_WithIsError_ShouldSetException()
{
// Arrange
ToolResultContentBlock block = new()
{
ToolUseId = "call-2",
Content = [new TextContentBlock { Text = "boom" }],
IsError = true,
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
FunctionResultContent functionResult = result.Should().BeOfType<FunctionResultContent>().Subject;
functionResult.CallId.Should().Be("call-2");
functionResult.Exception.Should().NotBeNull();
functionResult.RawRepresentation.Should().BeSameAs(block);
}
[Fact]
public void ConvertContentBlock_BlockWithMeta_ShouldPropagateToAdditionalProperties()
{
// Arrange
TextContentBlock block = new()
{
Text = "hello",
Meta = new System.Text.Json.Nodes.JsonObject
{
["traceId"] = "abc-123",
["priority"] = 7,
},
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
result.AdditionalProperties.Should().NotBeNull();
result.AdditionalProperties!.Should().ContainKey("traceId");
result.AdditionalProperties.Should().ContainKey("priority");
}
#endregion
+20 -2
View File
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.5.0] - 2026-05-19
### Added
- **agent-framework-core**, **agent-framework-foundry**, **agent-framework-openai**: Record actual served model from Azure OpenAI ([#5910](https://github.com/microsoft/agent-framework/pull/5910))
- **samples**: New Foundry Hosted Agents samples for RAG, Skills, and Memory ([#5822](https://github.com/microsoft/agent-framework/pull/5822))
### Changed
- **agent-framework-core**, **agent-framework-azurefunctions**, **agent-framework-devui**, **agent-framework-foundry**, **agent-framework-orchestrations**: Improve handling of intermediate outputs for workflows and orchestrations ([#5623](https://github.com/microsoft/agent-framework/pull/5623))
- **agent-framework-durabletask**: Pin `durabletask` and `durabletask-azuremanaged` floors to `>=1.4.0` and exclude upstream `durabletask` 1.4.1, 1.4.2, and 1.4.3 from the supported version range.
- **agent-framework-orchestrations**: Bumped package to release candidate stage.
### Fixed
- **agent-framework-core**: Parse YAML block scalars in SKILL.md frontmatter ([#5863](https://github.com/microsoft/agent-framework/pull/5863))
- **agent-framework-github-copilot**: Include tools added by `ContextProvider.before_run` in session creation ([#5780](https://github.com/microsoft/agent-framework/pull/5780))
- **agent-framework-hyperlight**: Skip symlinks when staging sandbox input ([#5919](https://github.com/microsoft/agent-framework/pull/5919))
- **agent-framework-purview**: Remove duplicate pop in `InMemoryCacheProvider.remove` ([#5795](https://github.com/microsoft/agent-framework/pull/5795))
## [1.4.0] - 2026-05-14
### Added
@@ -67,7 +84,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **agent-framework-foundry-hosting**: Add hosted Durable Workflow support — propagate full conversation history to workflow agents and wire `Workflow.as_agent()` end-to-end via the foundry hosting layer ([#5531](https://github.com/microsoft/agent-framework/pull/5531))
### Changed
- **agent-framework-orchestrations**: [BREAKING] Standardize orchestration terminal outputs as `AgentResponse` so `Workflow.as_agent()` returns the final answer only; aligns sequential-approval (`with_request_info`) and concurrent (`intermediate_outputs=True`) flows on the same output contract ([#5301](https://github.com/microsoft/agent-framework/pull/5301))
- **agent-framework-orchestrations**: [BREAKING] Standardize orchestration terminal outputs as `AgentResponse` so `Workflow.as_agent()` returns the final answer only; aligns sequential-approval (`with_request_info`) and concurrent participant output designation flows on the same output contract ([#5301](https://github.com/microsoft/agent-framework/pull/5301))
- **agent-framework-core**, **agent-framework-declarative**: Preserve `Workflow.run()` shared state across calls so multi-turn `WorkflowAgent` invocations retain context, accept `list[Message]` input in the declarative start executor, and coerce `Enum` values when serializing PowerFx symbols ([#5531](https://github.com/microsoft/agent-framework/pull/5531))
- **dependencies**: Update workspace package dependencies and preserve `mcp[ws]` / `uvicorn[standard]` extras through override-dependencies in `/python` ([#5555](https://github.com/microsoft/agent-framework/pull/5555))
@@ -1071,7 +1088,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.4.0...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...HEAD
[1.5.0]: https://github.com/microsoft/agent-framework/compare/python-1.4.0...python-1.5.0
[1.4.0]: https://github.com/microsoft/agent-framework/compare/python-1.3.0...python-1.4.0
[1.3.0]: https://github.com/microsoft/agent-framework/compare/python-1.2.2...python-1.3.0
[1.2.2]: https://github.com/microsoft/agent-framework/compare/python-1.2.1...python-1.2.2
+2 -2
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260514"
version = "1.0.0b260519"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.4.0,<2",
"agent-framework-core>=1.5.0,<2",
"a2a-sdk>=1.0.0,<2",
]
+2 -2
View File
@@ -22,10 +22,10 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.4.0,<2",
"agent-framework-core>=1.5.0,<2",
"ag-ui-protocol>=0.1.16,<0.2",
"fastapi>=0.115.0,<0.133.1",
"uvicorn[standard]>=0.30.0,<0.42.0"
"uvicorn[standard]>=0.30.0,<1"
]
[project.optional-dependencies]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260514"
version = "1.0.0b260519"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.4.0,<2",
"agent-framework-core>=1.5.0,<2",
"anthropic>=0.80.0,<0.80.1",
]
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260514"
version = "1.0.0b260519"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.4.0,<2",
"agent-framework-core>=1.5.0,<2",
"azure-search-documents>=11.7.0b2,<11.7.0b3",
]
@@ -4,7 +4,7 @@ description = "Azure Content Understanding integration for Microsoft Agent Frame
authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260514"
version = "1.0.0a260519"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.4.0,<2",
"agent-framework-foundry>=1.4.0,<2",
"agent-framework-core>=1.5.0,<2",
"agent-framework-foundry>=1.5.0,<2",
"azure-ai-contentunderstanding>=1.0.1,<1.1",
"aiohttp>=3.9,<4",
"filetype>=1.2,<2",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260514"
version = "1.0.0b260519"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.4.0,<2",
"agent-framework-core>=1.5.0,<2",
"azure-cosmos>=4.3.0,<5",
]
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any, TypeVar, cast
import azure.durable_functions as df
import azure.functions as func
from agent_framework import AgentExecutor, SupportsAgentRun, Workflow, WorkflowEvent
from agent_framework._workflows._runner_context import YieldOutputEventType
from agent_framework_durabletask import (
DEFAULT_MAX_POLL_RETRIES,
DEFAULT_POLL_INTERVAL_SECONDS,
@@ -307,6 +308,18 @@ class AgentFunctionApp(DFAppBase):
async def run() -> dict[str, Any]:
# Create runner context and shared state
runner_context = CapturingRunnerContext()
workflow = self.workflow
def classify_yielded_output(executor_id: str) -> YieldOutputEventType | None:
if workflow is None:
return "output"
if workflow.is_terminal_executor(executor_id):
return "output"
if workflow.is_intermediate_executor(executor_id):
return "intermediate"
return None
runner_context.set_yield_output_classifier(classify_yielded_output)
shared_state = State()
# Deserialize shared state values to reconstruct dataclasses/Pydantic models
@@ -19,6 +19,7 @@ from agent_framework import (
WorkflowEvent,
WorkflowMessage,
)
from agent_framework._workflows._runner_context import YieldOutputClassifier, YieldOutputEventType
from agent_framework._workflows._state import State
@@ -41,6 +42,7 @@ class CapturingRunnerContext(RunnerContext):
self._pending_request_info_events: dict[str, WorkflowEvent[Any]] = {}
self._workflow_id: str | None = None
self._streaming: bool = False
self._yield_output_classifier: YieldOutputClassifier = lambda _executor_id: "output"
# region Messaging
@@ -144,6 +146,14 @@ class CapturingRunnerContext(RunnerContext):
"""Check if streaming mode is enabled (always False in activity context)."""
return self._streaming
def set_yield_output_classifier(self, classifier: YieldOutputClassifier) -> None:
"""Set the classifier used by WorkflowContext.yield_output()."""
self._yield_output_classifier = classifier
def classify_yielded_output(self, executor_id: str) -> YieldOutputEventType | None:
"""Classify an executor's yield_output payload as output, intermediate, or hidden."""
return self._yield_output_classifier(executor_id)
# endregion Workflow Configuration
# region Request Info Events
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260514"
version = "1.0.0b260519"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,8 +22,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.4.0,<2",
"agent-framework-durabletask",
"agent-framework-core>=1.5.0,<2",
"agent-framework-durabletask>=1.0.0b260519,<2",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
]
@@ -107,7 +107,7 @@ class TestCapturingRunnerContext:
@pytest.mark.asyncio
async def test_add_event_queues_event(self, context: CapturingRunnerContext) -> None:
"""Test that add_event queues events correctly."""
event = WorkflowEvent.output(executor_id="exec_1", data="output")
event = WorkflowEvent("output", executor_id="exec_1", data="output")
await context.add_event(event)
@@ -120,7 +120,7 @@ class TestCapturingRunnerContext:
@pytest.mark.asyncio
async def test_drain_events_clears_queue(self, context: CapturingRunnerContext) -> None:
"""Test that drain_events clears the event queue."""
await context.add_event(WorkflowEvent.output(executor_id="e", data="test"))
await context.add_event(WorkflowEvent("output", executor_id="e", data="test"))
await context.drain_events() # First drain
events = await context.drain_events() # Second drain
@@ -132,14 +132,14 @@ class TestCapturingRunnerContext:
"""Test has_events returns correct boolean."""
assert await context.has_events() is False
await context.add_event(WorkflowEvent.output(executor_id="e", data="test"))
await context.add_event(WorkflowEvent("output", executor_id="e", data="test"))
assert await context.has_events() is True
@pytest.mark.asyncio
async def test_next_event_waits_for_event(self, context: CapturingRunnerContext) -> None:
"""Test that next_event returns queued events."""
event = WorkflowEvent.output(executor_id="e", data="waited")
event = WorkflowEvent("output", executor_id="e", data="waited")
await context.add_event(event)
result = await context.next_event()
@@ -171,7 +171,7 @@ class TestCapturingRunnerContext:
async def test_reset_for_new_run_clears_state(self, context: CapturingRunnerContext) -> None:
"""Test that reset_for_new_run clears all state."""
await context.send_message(WorkflowMessage(data="test", target_id="t", source_id="s"))
await context.add_event(WorkflowEvent.output(executor_id="e", data="event"))
await context.add_event(WorkflowEvent("output", executor_id="e", data="event"))
context.set_streaming(True)
context.reset_for_new_run()
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260514"
version = "1.0.0b260519"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.4.0,<2",
"agent-framework-core>=1.5.0,<2",
"boto3>=1.35.0,<2.0.0",
"botocore>=1.35.0,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260514"
version = "1.0.0b260519"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.4.0,<2",
"agent-framework-core>=1.5.0,<2",
"openai-chatkit>=1.4.1,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260514"
version = "1.0.0b260519"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.4.0,<2",
"agent-framework-core>=1.5.0,<2",
"claude-agent-sdk>=0.1.36,<0.1.49",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260514"
version = "1.0.0b260519"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.4.0,<2",
"agent-framework-core>=1.5.0,<2",
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
]
+8 -1
View File
@@ -79,7 +79,14 @@ agent_framework/
### Workflows (`_workflows/`)
- **`Workflow`** - Graph-based workflow definition
- **`WorkflowBuilder`** - Fluent API for building workflows
- **`WorkflowBuilder`** - Fluent API for building workflows, including explicit
`output_from` / `intermediate_output_from` selection for caller-facing emissions. `output_from`
is an allow-list for **Workflow Output**; unselected executor payloads are hidden unless
`intermediate_output_from` selects them as **Intermediate Output**. Use `output_from="all"` for
explicit all-output behavior and `intermediate_output_from="all_other"` for visible progress from
every output-capable executor not selected by `output_from`.
- **`WorkflowRunResult`** - Non-streaming workflow result with Workflow Output `get_outputs()`
and Intermediate Output `get_intermediate_outputs()` accessors
- **Orchestrators**: `SequentialOrchestrator`, `ConcurrentOrchestrator`, `GroupChatOrchestrator`, `MagenticOrchestrator`, `HandoffOrchestrator`
## Built-in Providers

Some files were not shown because too many files have changed in this diff Show More