Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
538c15b
Add the agent-framework dependency group and lock it
DABH Nov 13, 2026
d62d1ef
Keep Client.connect sites type-checking on temporalio 1.36
DABH Nov 13, 2026
6da7d77
Add Microsoft Agent Framework samples
DABH Nov 13, 2026
ca8a71b
Test the Agent Framework samples with a scripted chat client
DABH Nov 13, 2026
b7410ed
Document and register the Agent Framework samples
DABH Nov 13, 2026
53ece11
Record the human-in-the-loop decision in the Update handler
DABH Nov 13, 2026
ef55d93
Restore the Claude tests row in CODEOWNERS
DABH Nov 13, 2026
a7fbfcd
Key the human-in-the-loop decision to its request
DABH Nov 13, 2026
27f5157
Keep the middleware snippet self-contained
DABH Nov 13, 2026
fe521e2
Fail the Claude decision Update instead of the Workflow Task
DABH Nov 13, 2026
4124a04
Read the transcript through the provider's source id
DABH Nov 13, 2026
035d61d
Point the sample README at poe test
DABH Nov 13, 2026
440e36a
Pin the plugin to its round-1 revision
DABH Nov 13, 2026
16dbbfa
Pin the plugin to its current revision
DABH Nov 13, 2026
4002670
State the Python floor as the plugin's requires-python
DABH Nov 13, 2026
be77fc1
Use poe test in the mcp README and note the no-collector case
DABH Nov 13, 2026
83d46bf
Name the request id in the approval wait
DABH Nov 13, 2026
7c737ec
Rename the samples to microsoft_agent_framework_plugin
DABH Nov 13, 2026
cca8634
Pin the plugin to d374e26c
DABH Nov 13, 2026
0b94d38
Let the streaming topic route the model call
DABH Nov 13, 2026
45e76fe
Pin the plugin to 8b472d21
DABH Nov 13, 2026
e711f1d
Test turns and end_chat sent while a turn is running
DABH Nov 13, 2026
485c406
Pin the plugin to a453fa6c
DABH Nov 13, 2026
6fad07a
Pin the plugin to 54998df8
DABH Nov 14, 2026
9ac1b81
Pin the plugin to its merge commit on main
DABH Nov 14, 2026
df932db
Add plugins to the loaded client config on temporalio 1.36
DABH Nov 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
/langgraph_plugin/ @temporalio/sdk @temporalio/ai-sdk
/langsmith_tracing/ @temporalio/sdk @temporalio/ai-sdk
/litellm_activity/ @temporalio/sdk @temporalio/ai-sdk
/microsoft_agent_framework_plugin/ @temporalio/sdk @temporalio/ai-sdk
/openai_agents/ @temporalio/sdk @temporalio/ai-sdk
/strands_plugin/ @temporalio/sdk @temporalio/ai-sdk
/tests/claude_agent_sdk_plugin/ @temporalio/sdk @temporalio/ai-sdk
Expand All @@ -36,5 +37,6 @@
/tests/langgraph_plugin/ @temporalio/sdk @temporalio/ai-sdk
/tests/langsmith_tracing/ @temporalio/sdk @temporalio/ai-sdk
/tests/litellm_activity/ @temporalio/sdk @temporalio/ai-sdk
/tests/microsoft_agent_framework_plugin/ @temporalio/sdk @temporalio/ai-sdk
/tests/openai_agents/ @temporalio/sdk @temporalio/ai-sdk
/tests/strands_plugin/ @temporalio/sdk @temporalio/ai-sdk
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jobs:
for project in \
bedrock_agentcore/strands_agent \
lambda_worker \
microsoft_agent_framework_plugin/mcp \
openai_agents/mcp_v2
do
if grep -q "^${project}/" <<< "$changed_files"; then
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ Some examples require extra dependencies. See each sample's directory for specif
* [message_passing/introduction](message_passing/introduction/) - Introduction to queries, signals, and updates.
* [message_passing/safe_message_handlers](message_passing/safe_message_handlers/) - Safely handling updates and signals.
* [message_passing/update_with_start/lazy_initialization](message_passing/update_with_start/lazy_initialization/) - Use update-with-start to update a Shopping Cart, starting it if it does not exist.
* [microsoft_agent_framework_plugin](microsoft_agent_framework_plugin) - Run Microsoft Agent Framework agents as durable Temporal Workflows (model calls, tools, MCP, approvals, streaming, Continue-As-New).
* [nexus_cancel](nexus_cancel) - Fan out concurrent Nexus operations, take the first result, and cancel the rest.
* [nexus_messaging](nexus_messaging): Demonstrates how to send signal, update and query messages through Nexus.
This contains two samples, one sending messages to an existing workflow and a second that creates a workflow through Nexus
Expand Down
28 changes: 21 additions & 7 deletions claude_agent_sdk_plugin/human_in_the_loop/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
)
from temporalio import workflow
from temporalio.claude_agent_sdk import TemporalTransport
from temporalio.exceptions import ApplicationError


@dataclass
Expand All @@ -33,7 +34,6 @@ class ToolRequest:
class HumanInTheLoopWorkflow:
def __init__(self) -> None:
self.pending: ToolRequest | None = None
self.approved: bool | None = None
self.decisions: dict[str, bool] = {}

@workflow.run
Expand Down Expand Up @@ -94,19 +94,22 @@ async def ask(self, prompt: str) -> str:
pass

# @@@SNIPSTART python-claude-agent-sdk-human-in-the-loop-approval
@staticmethod
def call_key(tool_name: str, tool_input: dict[str, Any]) -> str:
"""The key a decision is remembered under: the same call asks the same question."""
return json.dumps([tool_name, tool_input], sort_keys=True)

async def ask_human(
self, tool_name: str, tool_input: dict[str, Any], context: ToolPermissionContext
) -> PermissionResult:
# A turn redone after a Worker restart asks again, so reuse the decision.
call = json.dumps([tool_name, tool_input], sort_keys=True)
call = self.call_key(tool_name, tool_input)
if call not in self.decisions:
self.pending = ToolRequest(context.tool_use_id or "", tool_name, tool_input)
try:
await workflow.wait_condition(lambda: self.approved is not None)
self.decisions[call] = self.approved is True
await workflow.wait_condition(lambda: call in self.decisions)
finally:
self.pending = None
self.approved = None
if self.decisions[call]:
return PermissionResultAllow()
return PermissionResultDeny(message="A person denied this tool call")
Expand All @@ -120,11 +123,22 @@ def pending_tool_calls(self) -> ToolRequest | None:
# @@@SNIPSTART python-claude-agent-sdk-human-in-the-loop-decision
@workflow.update
def decide(self, approved: bool) -> None:
self.approved = approved
if self.pending is None:
# Fails this Update only; an assertion would fail the Workflow Task.
raise ApplicationError("No tool call is waiting for a decision")
# Remember the decision here, not after the wait in ask_human. That wait
# is cancelled when Claude Code's connection is lost, and the turn
# redone after the restart must find the decision already recorded.
call = self.call_key(self.pending.tool_name, self.pending.tool_input)
self.decisions[call] = approved

# @@@SNIPEND

@decide.validator
def validate_decide(self, approved: bool) -> None:
if self.pending is None or self.approved is not None:
if (
self.pending is None
or self.call_key(self.pending.tool_name, self.pending.tool_input)
in self.decisions
):
raise ValueError("No tool call is waiting for a decision")
6 changes: 4 additions & 2 deletions deepagents_plugin/external_storage/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any

import aioboto3
from langchain_core.messages import AIMessage
Expand All @@ -24,7 +25,7 @@ async def connect_client(
s3_endpoint: str = S3_ENDPOINT,
) -> AsyncIterator[Client]:
"""Keep the S3 client alive while Temporal reads and writes payloads."""
config = ClientConfig.load_client_connect_config()
config: dict[str, Any] = dict(ClientConfig.load_client_connect_config())
config.setdefault("target_host", "localhost:7233")
if target_host is not None:
config["target_host"] = target_host
Expand Down Expand Up @@ -62,4 +63,5 @@ async def connect_client(
]
),
)
yield await Client.connect(**config, plugins=[plugin])
config["plugins"] = [*config.get("plugins", []), plugin]
yield await Client.connect(**config)
12 changes: 7 additions & 5 deletions langfuse_tracing/ticket_triage/starter.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import asyncio
import os
import uuid
from typing import Any

from opentelemetry import trace
from temporalio.client import Client
Expand Down Expand Up @@ -41,12 +42,13 @@ async def main() -> None:

setup_tracing("ticket-triage-starter")

config = ClientConfig.load_client_connect_config()
config: dict[str, Any] = dict(ClientConfig.load_client_connect_config())
config.setdefault("target_host", "localhost:7233")
client = await Client.connect(
**config,
plugins=[OpenTelemetryPlugin(add_temporal_spans=True)],
)
config["plugins"] = [
*config.get("plugins", []),
OpenTelemetryPlugin(add_temporal_spans=True),
]
client = await Client.connect(**config)

# A fresh workflow ID per run avoids Temporal workflow ID conflicts and
# gives each run its own Langfuse session (langfuse.session.id on the root
Expand Down
12 changes: 7 additions & 5 deletions langfuse_tracing/ticket_triage/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import argparse
import asyncio
import logging
from typing import Any

from temporalio.client import Client
from temporalio.contrib.opentelemetry import OpenTelemetryPlugin
Expand Down Expand Up @@ -38,16 +39,17 @@ async def main() -> None:
setup_tracing("ticket-triage-worker")
flavor = instrument_openai()

config = ClientConfig.load_client_connect_config()
config: dict[str, Any] = dict(ClientConfig.load_client_connect_config())
config.setdefault("target_host", "localhost:7233")

# add_temporal_spans=True emits spans for Temporal operations (StartWorkflow,
# RunWorkflow, RunActivity, HandleUpdate, ...) in addition to propagating
# trace context across the client/workflow/activity boundaries.
client = await Client.connect(
**config,
plugins=[OpenTelemetryPlugin(add_temporal_spans=True)],
)
config["plugins"] = [
*config.get("plugins", []),
OpenTelemetryPlugin(add_temporal_spans=True),
]
client = await Client.connect(**config)

worker = Worker(
client,
Expand Down
9 changes: 4 additions & 5 deletions langsmith_tracing/basic/starter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import sys
from typing import Any

from langsmith import traceable
from temporalio.client import Client
Expand All @@ -25,18 +26,16 @@
async def main() -> str:
add_temporal_runs = "--add-temporal-runs" in sys.argv

config = ClientConfig.load_client_connect_config()
config: dict[str, Any] = dict(ClientConfig.load_client_connect_config())
config.setdefault("target_host", "localhost:7233")

plugin = LangSmithPlugin(
project_name=PROJECT_NAME,
add_temporal_runs=add_temporal_runs,
)

client = await Client.connect(
**config,
plugins=[plugin],
)
config["plugins"] = [*config.get("plugins", []), plugin]
client = await Client.connect(**config)

result = await client.execute_workflow(
BasicLLMWorkflow.run,
Expand Down
10 changes: 4 additions & 6 deletions langsmith_tracing/chatbot/starter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import readline # noqa: F401 — enables input() line editing
import sys
import uuid
from typing import Any

from langsmith import traceable
from temporalio.client import Client
Expand All @@ -19,19 +20,16 @@
async def main():
add_temporal_runs = "--add-temporal-runs" in sys.argv

config = ClientConfig.load_client_connect_config()
config: dict[str, Any] = dict(ClientConfig.load_client_connect_config())
config.setdefault("target_host", "localhost:7233")

plugin = LangSmithPlugin(
project_name=PROJECT_NAME,
add_temporal_runs=add_temporal_runs,
)

client = await Client.connect(
**config,
data_converter=pydantic_data_converter,
plugins=[plugin],
)
config["plugins"] = [*config.get("plugins", []), plugin]
client = await Client.connect(**config, data_converter=pydantic_data_converter)

wf_id = f"langsmith-chatbot-{uuid.uuid4().hex[:8]}"

Expand Down
108 changes: 108 additions & 0 deletions microsoft_agent_framework_plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Microsoft Agent Framework samples

These samples show the [Temporal plugin for Microsoft Agent Framework](https://github.com/temporalio/ai-integrations/tree/main/python/microsoft_agent_framework), which runs an Agent Framework `Agent` inside a Temporal Workflow. You write the agent the way Agent Framework documents it and swap the chat client for `TemporalChatClient`. Each model call then runs as the `microsoft_agent_framework.invoke_model` Activity (`microsoft_agent_framework.invoke_model_streaming` when you stream), with its own timeout and retries. The agent's loop, tools, middleware and approvals run in the Workflow, where Temporal records every step and replays it after a crash or a Worker restart. The docs page is [Microsoft Agent Framework](https://docs.temporal.io/develop/python/integrations/microsoft-agent-framework).

> **Pre-release.** The `temporalio-microsoft-agent-framework` package and its interfaces may change before General Availability.

`MicrosoftAgentFrameworkPlugin` goes on the Client or on the Worker, not both. These samples put it on `Client.connect(...)`; the Worker built from that Client inherits it.

## Samples

| Sample | Description |
|--------|-------------|
| [hello_world](hello_world) | One `Agent` over `TemporalChatClient`, one prompt, one model Activity. Start here. |
| [tools](tools) | A pure `@tool` that runs in the Workflow next to an Activity wrapped with `activity_as_tool`. |
| [mcp](mcp) | Tools from an MCP server through `TemporalMCPToolProvider`; the listing and each call are Activities. Its own uv project, see below. |
| [human_in_the_loop](human_in_the_loop) | A tool with `approval_mode="always_require"`. The Workflow publishes the request through a Query, waits for a `decide` Update and answers on the same session. |
| [middleware](middleware) | Function and chat middleware running in the Workflow: one refuses a tool call, one edits the instructions. |
| [multi_agent](multi_agent) | A coordinator with two sub-agents as tools through `Agent.as_tool()`; every agent's model calls are Activities. |
| [structured_output](structured_output) | `response_format` with a Pydantic model; the Workflow returns the model through the plugin's data converter. |
| [streaming](streaming) | `streaming_topic` publishes each update to a Workflow Stream; the starter prints them as they arrive. |
| [continue_as_new](continue_as_new) | A chat driven by Updates that hands `session.to_dict()` to a fresh run when Temporal suggests Continue-As-New. |
| [open_telemetry](open_telemetry) | `OpenTelemetryPlugin` and `MicrosoftAgentFrameworkPlugin` on one Client: one trace from the starter through the Workflow to the model Activity. |

## Prerequisites

1. Python 3.11 or later. The plugin declares `requires-python = ">=3.11"`, as Temporal Python SDK 1.36 does, so on 3.10 the `microsoft-agent-framework` dependency group resolves to nothing and the tests are not collected.

2. Install the dependencies from the repository root:

```bash
uv sync --group microsoft-agent-framework
```

This installs `temporalio-microsoft-agent-framework`, `agent-framework-openai` and Temporal Python SDK 1.36.0 or later.

3. An OpenAI API key. Each Worker reads it when it builds the `OpenAIChatClient`; the Workflow only carries the model name `gpt-4o-mini`:

```bash
export OPENAI_API_KEY=...
```

4. A local Temporal Service from the [Temporal CLI](https://docs.temporal.io/cli):

```bash
temporal server start-dev
```

## Running a sample

Each sample has two scripts. Start the Worker first, then the starter in a second terminal, both from the repository root:

```bash
# Terminal 1: start the Worker
uv run microsoft_agent_framework_plugin/<sample>/run_worker.py

# Terminal 2: start the Workflow
uv run microsoft_agent_framework_plugin/<sample>/run_workflow.py
```

For example, for the tools sample:

```bash
# Terminal 1
uv run microsoft_agent_framework_plugin/tools/run_worker.py

# Terminal 2
uv run microsoft_agent_framework_plugin/tools/run_workflow.py
```

The `mcp` sample is its own uv project: the plugin's MCP support needs MCP Python SDK 2, and other samples in this repository still pin SDK 1, so they cannot share one lock file. Run it from its directory:

```bash
cd microsoft_agent_framework_plugin/mcp
uv sync

# Terminal 1
uv run run_worker.py

# Terminal 2
uv run run_workflow.py
```

## Key features

- One Activity per model round trip, `microsoft_agent_framework.invoke_model`, with a 60-second start-to-close timeout in these samples and the plugin's default 10-second heartbeat timeout. Temporal owns retries, so the Workers build the OpenAI client with `AsyncOpenAI(max_retries=0)`.
- Tools run where you put them: a plain `@tool` runs in the Workflow, `activity_as_tool` runs an Activity with its own `start_to_close_timeout`.
- MCP servers are registered on the Worker. `TemporalMCPToolProvider` lists and calls their tools through the `temporalio.mcp.<server>.list-tools` and `temporalio.mcp.<server>.call-tool` Activities.
- Approvals are Agent Framework's own `approval_mode`; the wait is a Workflow Update with a validator.
- Middleware and context providers run in the Workflow, so they must be deterministic; I/O in middleware goes through `workflow.execute_activity`.
- Streaming publishes `ChatResponseUpdate` dicts to a Workflow Stream topic, batched every 100 milliseconds.
- Long chats carry `session.to_dict()` across Continue-As-New and restore it with `AgentSession.from_dict`.
- `OpenTelemetryPlugin` composes on the same Client; `create_tracer_provider()` keeps the spans Agent Framework starts from Workflow code replay-safe.

## Tests

The tests run every sample against the plugin's scripted `FakeChatClient` and a local dev server, with no API key:

```bash
uv run poe test tests/microsoft_agent_framework_plugin
uv run poe test tests/microsoft_agent_framework_plugin --workflow-environment time-skipping
cd microsoft_agent_framework_plugin/mcp && uv run poe test
```

## Related

- [Temporal plugin for Microsoft Agent Framework](https://github.com/temporalio/ai-integrations/tree/main/python/microsoft_agent_framework)
- [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)
- [Microsoft Agent Framework docs page](https://docs.temporal.io/develop/python/integrations/microsoft-agent-framework)
Empty file.
31 changes: 31 additions & 0 deletions microsoft_agent_framework_plugin/continue_as_new/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Continue-as-new

A chat that runs as long as you like. Each user turn is an Update, so the caller gets the reply from the same call. History grows with every turn, and `workflow.info().is_continue_as_new_suggested()` turns `True` once the Temporal Service decides it is large enough. The Workflow waits on that or on the `end_chat` Signal, lets in-flight Updates finish with `workflow.all_handlers_finished`, and hands `session.to_dict()` to a fresh run, which restores it with `AgentSession.from_dict`.

What crosses the boundary: the serialized session, which holds the transcript, any pending approval state and the provider's conversation id. What does not: the `Agent` object, provider connections and spans; the new run builds the agent again. Nothing waits in Workflow fields at the handoff: a `turn` is an Update, so it completes in the run that accepted it and returns its reply from there, `workflow.all_handlers_finished` lets an accepted turn finish before the handoff, and an `end_chat` that arrives during a turn ends the chat after that turn instead of being lost.

## What this sample demonstrates

- The `turn` Update, the `end_chat` Signal and the `transcript` Query
- An `asyncio.Lock` so turns take the session one at a time
- `workflow.continue_as_new(ChatInput(session=session.to_dict()))`

## Running the sample

```bash
# Terminal 1
uv run microsoft_agent_framework_plugin/continue_as_new/run_worker.py

# Terminal 2
uv run microsoft_agent_framework_plugin/continue_as_new/run_workflow.py
```

The starter sends two turns, prints each reply, prints the number of messages in the session and ends the chat. Two turns do not reach the default threshold, so the Workflow `microsoft-agent-framework-chat` completes in one run; the test lowers the threshold to see the handoff. In a chat application a UI would drive the Updates and the Workflow would run on, continuing as new whenever history gets large.

## Files

| File | Description |
|------|-------------|
| `workflow.py` | `ChatInput` and `ChatWorkflow` with the Update, Signal and Query |
| `run_worker.py` | Registers the plugin, starts the Worker on `microsoft-agent-framework-chat` |
| `run_workflow.py` | Starts the chat, sends two turns, ends it |
Empty file.
Loading
Loading