diff --git a/sdk/ai/azure-ai-projects/CHANGELOG.md b/sdk/ai/azure-ai-projects/CHANGELOG.md index 519ab8a6b5c8..e832d414beea 100644 --- a/sdk/ai/azure-ai-projects/CHANGELOG.md +++ b/sdk/ai/azure-ai-projects/CHANGELOG.md @@ -60,6 +60,7 @@ Breaking changes in beta classes: * Added Hosted Agent code-upload samples `sample_create_hosted_agent_from_code.py` and `sample_create_hosted_agent_from_code_async.py`, demonstrating uploading a code package (zip) as a new hosted agent version. * The Hosted Agent creation sample also demonstrates assigning the hosted agent managed identity the Azure AI User RBAC role on the backing Azure AI account. * Updated the other Hosted Agent samples to reuse an existing Hosted Agent as a prerequisite, instead of creating a new hosted agent version in each sample. + * Added Routines samples `sample_routines_crud.py` demonstring CRUD operations and `sample_routines_with_timer_trigger.py` trigger a routine by a timer. * Added Toolbox tool-search sample `sample_toolboxes_with_search_preview.py` and `sample_toolboxes_with_search_preview_async.py`, demonstrating creating a Toolbox version with `ToolboxSearchPreviewTool` and invoking `MCPTool`. * Added `.beta.models` samples under `samples/models/`: * `sample_models_basic.py` — synchronous end-to-end registration via the `create` helper (uses `azcopy`), followed by `get`, `list_versions`, `list`, `get_credentials`, `update`, and `delete`. diff --git a/sdk/ai/azure-ai-projects/assets.json b/sdk/ai/azure-ai-projects/assets.json index b8b603a2eb49..a3832e93b84d 100644 --- a/sdk/ai/azure-ai-projects/assets.json +++ b/sdk/ai/azure-ai-projects/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "python", "TagPrefix": "python/ai/azure-ai-projects", - "Tag": "python/ai/azure-ai-projects_449a9f8e06" + "Tag": "python/ai/azure-ai-projects_11a3786ca2" } diff --git a/sdk/ai/azure-ai-projects/samples/evaluations/sample_agent_trace_evaluation_smart_filter.py b/sdk/ai/azure-ai-projects/samples/evaluations/sample_agent_trace_evaluation_smart_filter.py index bb1e44c4898a..983e27916ef2 100644 --- a/sdk/ai/azure-ai-projects/samples/evaluations/sample_agent_trace_evaluation_smart_filter.py +++ b/sdk/ai/azure-ai-projects/samples/evaluations/sample_agent_trace_evaluation_smart_filter.py @@ -127,7 +127,7 @@ "start_time": start_time, "end_time": end_time, "max_traces": args.max_traces, - "filter_strategy": "smart_filtering" + "filter_strategy": "smart_filtering", } if args.agent_id: @@ -173,4 +173,4 @@ print(f"\nāœ— Evaluation run failed: {run.error}") client.evals.delete(eval_id=eval_object.id) - print("Evaluation deleted") \ No newline at end of file + print("Evaluation deleted") diff --git a/sdk/ai/azure-ai-projects/samples/evaluations/sample_scheduled_agent_traces_evaluation_smart_filter.py b/sdk/ai/azure-ai-projects/samples/evaluations/sample_scheduled_agent_traces_evaluation_smart_filter.py index 4e39ea2eb539..b3f337f6c522 100644 --- a/sdk/ai/azure-ai-projects/samples/evaluations/sample_scheduled_agent_traces_evaluation_smart_filter.py +++ b/sdk/ai/azure-ai-projects/samples/evaluations/sample_scheduled_agent_traces_evaluation_smart_filter.py @@ -43,7 +43,7 @@ Schedule, RecurrenceTrigger, DailyRecurrenceSchedule, - EvaluationScheduleTask + EvaluationScheduleTask, ) import time @@ -198,6 +198,7 @@ def assign_rbac(): # pylint: disable=too-many-statements print("An unexpected error occurred. Please check the error details above.") raise + def schedule_trace_evaluation(): endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] model_deployment_name = os.environ["FOUNDRY_MODEL_NAME"] @@ -284,7 +285,7 @@ def schedule_trace_evaluation(): "start_time": start_time, "end_time": end_time, "max_traces": args.max_traces, - "filter_strategy": "smart_filtering" + "filter_strategy": "smart_filtering", } if args.agent_id: @@ -304,7 +305,7 @@ def schedule_trace_evaluation(): eval_run_object = { "eval_id": eval_object.id, "name": "trace_eval_with_smart_filter", - "data_source": data_source + "data_source": data_source, } print("Eval Run:") @@ -335,5 +336,6 @@ def schedule_trace_evaluation(): client.evals.delete(eval_id=eval_object.id) print("Evaluation deleted") + if __name__ == "__main__": main() diff --git a/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_routines_crud.py b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_routines_crud.py new file mode 100644 index 000000000000..f71a74b33b48 --- /dev/null +++ b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_routines_crud.py @@ -0,0 +1,111 @@ +# pylint: disable=line-too-long,useless-suppression +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ + +""" +DESCRIPTION: + This sample demonstrates how to perform CRUD operations on Routines + using the synchronous AIProjectClient. + + It creates a routine bound to an existing hosted agent, retrieves it, + toggles its `enabled` state via `disable` / `enable`, lists routines, + and finally deletes it. A `CustomRoutineTrigger` is used to keep the + sample self-contained (no GitHub or schedule resources required). + + Routines are currently a preview feature. In the Python SDK, you access + these operations via `project_client.beta.routines`. + +USAGE: + python sample_routines_crud.py + + Before running the sample: + + pip install "azure-ai-projects>=2.2.0" python-dotenv + + Set these environment variables with your own values: + 1) FOUNDRY_PROJECT_ENDPOINT - The Azure AI Project endpoint, as found in the Overview + page of your Microsoft Foundry portal. + 2) FOUNDRY_HOSTED_AGENT_NAME - The name of an existing Hosted Agent to invoke + when the routine fires. +""" + +import os + +from dotenv import load_dotenv + +from azure.core.exceptions import ResourceNotFoundError +from azure.identity import DefaultAzureCredential + +from azure.ai.projects import AIProjectClient +from azure.ai.projects.models import ( + CustomRoutineTrigger, + InvokeAgentResponsesApiRoutineAction, + Routine, + RoutineTrigger, +) + +load_dotenv() + +endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] +agent_name = os.environ["FOUNDRY_HOSTED_AGENT_NAME"] + + +def print_routine_state(routine: Routine) -> None: + print(f" - routine `{routine.name}` enabled={routine.enabled} description={routine.description!r}") + + +with ( + DefaultAzureCredential() as credential, + AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project_client, +): + + routine_name = "sample-routine" + + try: + project_client.beta.routines.delete(routine_name) + print(f"Routine `{routine_name}` deleted") + except ResourceNotFoundError: + pass + + triggers: dict[str, RoutineTrigger] = { + "manual": CustomRoutineTrigger( + provider="sample-provider", + event_name="sample-event", + parameters={"source": "sample_routines_crud"}, + ), + } + + action = InvokeAgentResponsesApiRoutineAction(agent_name=agent_name) + + created = project_client.beta.routines.create_or_update( + routine_name, + description="Routine created by the azure-ai-projects sample.", + enabled=True, + triggers=triggers, + action=action, + ) + print(f"Created routine: {created.name} enabled={created.enabled}") + + disabled = project_client.beta.routines.disable(routine_name) + print(f"Disabled routine: {disabled.name} enabled={disabled.enabled}") + + fetched = project_client.beta.routines.get(routine_name) + print("Retrieved routine after disable:") + print_routine_state(fetched) + + enabled = project_client.beta.routines.enable(routine_name) + print(f"Enabled routine: {enabled.name} enabled={enabled.enabled}") + + fetched = project_client.beta.routines.get(routine_name) + print("Retrieved routine after enable:") + print_routine_state(fetched) + + routines = list(project_client.beta.routines.list()) + print(f"Found {len(routines)} routine(s):") + for item in routines: + print(f" - {item.name} enabled={item.enabled}") + + project_client.beta.routines.delete(routine_name) + print("Routine deleted") diff --git a/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_routines_with_timer_trigger.py b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_routines_with_timer_trigger.py new file mode 100644 index 000000000000..74b12ab4e775 --- /dev/null +++ b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_routines_with_timer_trigger.py @@ -0,0 +1,136 @@ +# pylint: disable=line-too-long,useless-suppression +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ + +""" +DESCRIPTION: + This sample demonstrates how to create a Routine that fires automatically + from a one-shot timer trigger, then record the resulting runs by polling + `list_runs(...)` using the synchronous AIProjectClient. + + The routine is bound to an existing hosted agent and scheduled to fire a + short time in the future. The sample then polls the run history until a + terminal phase is reached (or a deadline elapses), printing each observed + transition. The routine is deleted at the end of the sample. + + Routines are currently a preview feature. In the Python SDK, you access + these operations via `project_client.beta.routines`. + +USAGE: + python sample_routines_with_timer_trigger.py + + Before running the sample: + + pip install "azure-ai-projects>=2.2.0" python-dotenv + + Set these environment variables with your own values: + 1) FOUNDRY_PROJECT_ENDPOINT - The Azure AI Project endpoint, as found in the Overview + page of your Microsoft Foundry portal. + 2) FOUNDRY_HOSTED_AGENT_NAME - The name of an existing Hosted Agent to invoke + when the routine timer fires. +""" + +import datetime +import json +import os +import time + +from dotenv import load_dotenv + +from azure.core.exceptions import ResourceNotFoundError +from azure.core.settings import settings + +settings.tracing_implementation = "opentelemetry" +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor, ConsoleSpanExporter +from azure.monitor.opentelemetry import configure_azure_monitor +from azure.ai.projects.telemetry import AIProjectInstrumentor + +from azure.identity import DefaultAzureCredential + +from azure.ai.projects import AIProjectClient +from azure.ai.projects.models import ( + InvokeAgentResponsesApiRoutineAction, + RoutineRun, + RoutineRunPhase, + TimerRoutineTrigger, +) + +load_dotenv() + +# Console exporter: spans printed to stdout as they finish. +tracer_provider = TracerProvider() +tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) +trace.set_tracer_provider(tracer_provider) +tracer = trace.get_tracer(__name__) +AIProjectInstrumentor().instrument() + +endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] +agent_name = os.environ["FOUNDRY_HOSTED_AGENT_NAME"] + + +with ( + DefaultAzureCredential() as credential, + AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project_client, +): + # Azure Monitor exporter: same spans also sent to the Application Insights + # resource attached to the Foundry project, viewable in the "Tracing" tab + # on ai.azure.com. + configure_azure_monitor(connection_string=project_client.telemetry.get_application_insights_connection_string()) + + routine_name = "sample-routine-timer" + + try: + project_client.beta.routines.delete(routine_name) + print(f"Routine `{routine_name}` deleted") + except ResourceNotFoundError: + pass + + fire_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=20) + created = project_client.beta.routines.create_or_update( + routine_name, + description="Routine used by the timer-trigger sample.", + enabled=True, + triggers={"once": TimerRoutineTrigger(at=fire_at)}, + action=InvokeAgentResponsesApiRoutineAction(agent_name=agent_name), + ) + print(f"Created routine: {created.name} enabled={created.enabled} fire_at={fire_at.isoformat()}") + + terminal_phases = {RoutineRunPhase.COMPLETED, RoutineRunPhase.FAILED} + seen_phases: dict[str, RoutineRunPhase] = {} + final_run: RoutineRun | None = None + + deadline = time.monotonic() + 180 + while time.monotonic() < deadline: + runs = list(project_client.beta.routines.list_runs(routine_name, limit=20, order="desc")) + for run in runs: + if run.id is None: + continue + if seen_phases.get(run.id) == run.phase: + continue + seen_phases[run.id] = run.phase # type: ignore[assignment] + print( + f" - run_id={run.id} phase={run.phase} status={run.status} " + f"trigger_type={run.trigger_type} triggered_at={run.triggered_at} ended_at={run.ended_at}" + ) + if str(run.status).lower() == "finished": + final_run = run + + if final_run is not None: + break + time.sleep(5) + + if final_run: + print("Final run:") + print(json.dumps(final_run.as_dict(), indent=2, default=str)) + # Note: retrieving the response body produced by a routine-dispatched + # run via `openai_client.responses.retrieve(final_run.response_id)` is + # not yet supported by the service for this scenario. + else: + print("Timer did not produce a terminal run within the deadline.") + + project_client.beta.routines.delete(routine_name) + print("Routine deleted") diff --git a/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_skill_in_toolbox.py b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_skill_in_toolbox.py new file mode 100644 index 000000000000..af07a47bae37 --- /dev/null +++ b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_skill_in_toolbox.py @@ -0,0 +1,155 @@ +# pylint: disable=line-too-long,useless-suppression +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ + +""" +DESCRIPTION: + This sample demonstrates how to expose a Skill to a Prompt Agent via a + Toolbox, using the synchronous AIProjectClient and the OpenAI-compatible + client. + + It creates a Skill with inline content describing how to compute shipping + cost, then creates a Toolbox version that references the skill. A Prompt + Agent is created with an `MCPTool` pointed at the toolbox's versioned + `/mcp` endpoint. The skill's instructions are injected into the agent's + context, so when asked a shipping-cost question the agent answers directly + using the skill's formula. + + Skills and Toolboxes are currently preview features. In the Python SDK, + you access these operations via `project_client.beta.skills` and + `project_client.beta.toolboxes`. + +USAGE: + python sample_skill_in_toolbox.py + + Before running the sample: + + pip install "azure-ai-projects>=2.2.0" python-dotenv openai + + Set these environment variables with your own values: + 1) FOUNDRY_PROJECT_ENDPOINT - The Azure AI Project endpoint, as found in the + Overview page of your Microsoft Foundry portal. + 2) FOUNDRY_MODEL_NAME - The deployment name of the AI model, as found under + the "Name" column in the "Models + endpoints" tab in your Microsoft + Foundry project. +""" + +import os + +from dotenv import load_dotenv + +from azure.core.exceptions import ResourceNotFoundError +from azure.identity import DefaultAzureCredential + +from azure.ai.projects import AIProjectClient +from azure.ai.projects.models import ( + MCPTool, + PromptAgentDefinition, + SkillInlineContent, + ToolboxSearchPreviewTool, + ToolboxSkillReference, +) + +load_dotenv() + +endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + +SKILL_NAME = "shipping-cost-skill" +TOOLBOX_NAME = "toolbox_with_skill" +AGENT_NAME = "SkillToolboxAgent" + + +with ( + DefaultAzureCredential() as credential, + AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project_client, + project_client.get_openai_client() as openai_client, +): + + try: + project_client.beta.toolboxes.delete(TOOLBOX_NAME) + except ResourceNotFoundError: + pass + + try: + project_client.beta.skills.delete(SKILL_NAME) + except ResourceNotFoundError: + pass + + skill_version = project_client.beta.skills.create( + name=SKILL_NAME, + inline_content=SkillInlineContent( + description="Compute shipping cost for a package given weight and destination.", + instructions=( + "You are a shipping cost calculator. When asked to compute " + "shipping cost, use this formula: cost (USD) = 5 + 2 * weight_kg " + "for domestic destinations, and cost (USD) = 15 + 4 * weight_kg " + "for international destinations. Always state the formula you used." + ), + metadata={"revision": "1"}, + ), + ) + print(f"Created skill: {skill_version.name} version={skill_version.version}") + + toolbox_version = project_client.beta.toolboxes.create_version( + name=TOOLBOX_NAME, + description="Toolbox exposing a shipping-cost skill.", + tools=[ToolboxSearchPreviewTool()], + skills=[ToolboxSkillReference(name=skill_version.name, version=skill_version.version)], + ) + print(f"Created toolbox: {toolbox_version.name} version={toolbox_version.version}") + + toolbox_mcp_url = f"{endpoint}/toolboxes/{TOOLBOX_NAME}/versions/{toolbox_version.version}/mcp?api-version=v1" + token = credential.get_token("https://ai.azure.com/.default").token + + toolbox_mcp_tool = MCPTool( + server_label="skill-toolbox", + server_url=toolbox_mcp_url, + authorization=token, + headers={"Foundry-Features": "Toolboxes=V1Preview"}, + require_approval="never", + ) + + agent = project_client.agents.create_version( + agent_name=AGENT_NAME, + definition=PromptAgentDefinition( + model=os.environ["FOUNDRY_MODEL_NAME"], + instructions=( + "Answer the user using the `shipping-cost-skill` instructions " + "available in your context. Do not call `tool_search`; the " + "skill rules are already part of your knowledge. Apply the " + "skill's formula exactly as given and state the formula in " + "your answer." + ), + temperature=0, + tools=[toolbox_mcp_tool], + ), + ) + print(f"Agent created (id={agent.id}, name={agent.name}, version={agent.version})") + + user_input = "Compute the shipping cost for a 3 kg package shipped domestically." + print(f"User: {user_input}") + response = openai_client.responses.create( + input=user_input, + extra_body={"agent_reference": {"name": agent.name, "type": "agent_reference"}}, + ) + + for item in response.output: + if item.type == "mcp_list_tools": + print(f"mcp_list_tools server_label={item.server_label} tools={[t.name for t in (item.tools or [])]}") + elif item.type == "mcp_call": + print(f"mcp_call server_label={item.server_label} name={item.name} error={item.error}") + if getattr(item, "output", None): + print(f" output: {item.output}") + elif item.type == "mcp_approval_request": + print(f"mcp_approval_request server_label={item.server_label} name={item.name}") + else: + print(f"output item type={item.type}") + + print(f"Response: {response.output_text}") + + project_client.beta.toolboxes.delete(TOOLBOX_NAME) + print("Toolbox deleted") + project_client.beta.skills.delete(SKILL_NAME) + print("Skill deleted") diff --git a/sdk/ai/azure-ai-projects/tests/samples/test_samples.py b/sdk/ai/azure-ai-projects/tests/samples/test_samples.py index 598e9e3e564e..5f5504bba6f4 100644 --- a/sdk/ai/azure-ai-projects/tests/samples/test_samples.py +++ b/sdk/ai/azure-ai-projects/tests/samples/test_samples.py @@ -185,17 +185,17 @@ def test_models_samples(self, sample_path: str, **kwargs) -> None: # fails the test). @servicePreparer() - # @additionalSampleTests( - # [ - # AdditionalSampleTestDetail( - # test_id="sample_dataset_generation_job_simpleqna_with_prompt_source", - # sample_filename="sample_dataset_generation_job_simpleqna_with_prompt_source.py", - # env_vars={ - # "POLL_INTERVAL_SECONDS": "60", - # }, - # ), - # ] - # ) + @additionalSampleTests( + [ + AdditionalSampleTestDetail( + test_id="sample_dataset_generation_job_simpleqna_with_prompt_source", + sample_filename="sample_dataset_generation_job_simpleqna_with_prompt_source.py", + env_vars={ + "POLL_INTERVAL_SECONDS": "60", + }, + ), + ] + ) @pytest.mark.parametrize( "sample_path", get_sample_paths( @@ -250,7 +250,10 @@ def test_chat_completions_samples(self, sample_path: str, **kwargs) -> None: "sample_path", get_sample_paths( "hosted_agents", - samples_to_skip=[], + samples_to_skip=[ + "sample_routines_crud.py", # Skipped due to service serialization issues + "sample_routines_with_timer_trigger.py", # Skipped due to service serialization issues + ], ), ) @SamplePathPasser()