Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ to include examples, links to docs, or any other relevant information.
`uuid.uuid1()`/`uuid.uuid4()` restrictions.
- **Experimental**: `TemporalOperationHandler` can now use Standalone Activities as asynchronous
Nexus Operation backing executions through `TemporalNexusClient.start_activity`.
- **Experimental**: `temporalio.contrib.openai_agents.temporal_worker_env_ref` names an environment
variable the worker reads for a hosted tool credential, keeping it out of workflow history.
- **Experimental**: `temporalio.contrib.openai_agents.TemporalWorkerEnvValue` names an environment
variable the worker reads for a sandbox environment value, keeping it out of workflow history.
- **Experimental**: `OpenAIAgentsPlugin(resolvable_worker_env_vars=...)` allowlists the environment
variable names a worker will read.
- **Experimental**: `temporalio.contrib.openai_agents.AllowAllWorkerEnvVars` allowlists every
environment variable name on the worker.

### Changed

Expand Down Expand Up @@ -70,6 +78,12 @@ to include examples, links to docs, or any other relevant information.

### :boom: Breaking Changes

- The `openai-agents` extra now requires `openai-agents>=0.19.2,<0.20`, up from `>=0.17.5` with no
upper bound.
- `temporalio.contrib.openai_agents` now rejects a sandbox `SandboxPathGrant` bound to a
`host_path`.
- `temporalio.contrib.openai_agents` now rejects `run_config.sandbox.session`.

### Fixed

- `temporalio.contrib.opentelemetry` replay-safe spans now delegate
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ classifiers = [
grpc = ["grpcio>=1.48.2,<2"]
opentelemetry = ["opentelemetry-api>=1.26,<2", "opentelemetry-sdk>=1.26,<2"]
pydantic = ["pydantic>=2.0.0,<3"]
openai-agents = ["openai-agents>=0.17.5", "mcp>=1.9.4, <2"]
openai-agents = ["openai-agents>=0.19.2,<0.20", "mcp>=1.9.4, <2"]
Comment thread
xumaple marked this conversation as resolved.
google-adk = ["google-adk>=2.2.0,<3", "mcp>=1.24,<2"]
langgraph = ["langgraph>=1.1.0"]
langsmith = ["langsmith>=0.7.34,<0.9"]
Expand Down Expand Up @@ -77,8 +77,8 @@ dev = [
"pytest-cov>=6.1.1",
"httpx>=0.28.1",
"pytest-pretty>=1.3.0",
"openai-agents>=0.14.0; python_version >= '3.14'",
"openai-agents[litellm]>=0.14.0; python_version < '3.14'",
"openai-agents>=0.19.2,<0.20; python_version >= '3.14'",
"openai-agents[litellm]>=0.19.2,<0.20; python_version < '3.14'",
"litellm>=1.83.0",
"openinference-instrumentation-google-adk>=0.1.11",
"googleapis-common-protos>=1.75.0,<2",
Expand Down
59 changes: 49 additions & 10 deletions temporalio/contrib/openai_agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,11 +487,36 @@ A stateless factory that declares no parameters — like the `lambda: MCPServerS

For network-accessible MCP servers, you can also use `HostedMCPTool` from the OpenAI Agents SDK, which uses an MCP client hosted by OpenAI.

## Secrets for Hosted Tools
## Secrets from the Worker's Environment

⚠️ **Experimental** - This functionality is subject to change prior to General Availability.

Use `temporal_worker_env_ref()` for a hosted tool credential that should come from the worker's environment rather than being written into your workflow. Pass it the *name of an environment variable*, in place of the credential itself:
A credential an agent needs can stay in the worker process's environment instead of being written into your workflow. Where the value would otherwise go, you name the environment variable that holds it, and the worker reads that variable when the value is actually needed.

There are two forms, and which one you use follows from where the value goes:

- For a hosted tool credential, use `temporal_worker_env_ref()`. It is substituted only in the fields listed under [Hosted Tool Credentials](#hosted-tool-credentials).
- For a sandbox environment variable, use `TemporalWorkerEnvValue`.

Both are gated by `resolvable_worker_env_vars`, an allowlist of the variable names a worker is willing to read. On every worker that runs model or sandbox activities, set the variable and add its name to that list:

```python
plugin = OpenAIAgentsPlugin(resolvable_worker_env_vars=["MY_MCP_TOKEN"])
```

Names are matched exactly, with no globbing. Passing `AllowAllWorkerEnvVars()` in place of the list makes every environment variable on the worker resolvable, so a workflow-authored sandbox manifest can name any variable on the worker and have its value land inside the container.

```python
from temporalio.contrib.openai_agents import AllowAllWorkerEnvVars

plugin = OpenAIAgentsPlugin(resolvable_worker_env_vars=AllowAllWorkerEnvVars())
```

The reference form never raises. A name the worker does not allow is sent on as the reference string, and a name it allows resolves to whatever the variable holds — an empty string when that variable is unset or empty.

### Hosted Tool Credentials

Pass `temporal_worker_env_ref()` the *name of an environment variable*, in place of the credential itself:

```python
from agents import HostedMCPTool
Expand All @@ -507,14 +532,6 @@ tool = HostedMCPTool(
)
```

Every worker that runs model activities must both set `MY_MCP_TOKEN` and name it as resolvable:

```python
plugin = OpenAIAgentsPlugin(resolvable_worker_env_vars=["MY_MCP_TOKEN"])
```

Names are matched exactly, with no globbing, and `"*"` anywhere in the list allows every environment variable on the worker.

A reference can sit inside a larger value: in `"Bearer " + temporal_worker_env_ref("MY_MCP_TOKEN")`, the reference is replaced in place and the rest of the string is sent unchanged.

The environment variable's value is substituted in these fields and no others:
Expand All @@ -523,6 +540,28 @@ The environment variable's value is substituted in these fields and no others:
- `value` in each entry of `network_policy.domain_secrets` under a hosted `ShellTool`'s `environment`
- `value` in each entry of `network_policy.domain_secrets` under the `container` in a `CodeInterpreterTool`'s `tool_config`

### Sandbox Environment Variables

Put a `TemporalWorkerEnvValue` in the environment of a [sandbox](#sandbox-support) manifest, in place of the value itself:

```python
from agents.sandbox import Manifest
from agents.sandbox.manifest import Environment

from temporalio.contrib.openai_agents import TemporalWorkerEnvValue

manifest = Manifest(
environment=Environment(
value={
"OPENAI_API_KEY": TemporalWorkerEnvValue(name="PROD_OPENAI_KEY"),
"REGION": "us-west-2",
}
)
)
```

Pass that manifest to `SandboxRunConfig(manifest=...)`. This reads `PROD_OPENAI_KEY` on the worker and sets `OPENAI_API_KEY` inside the sandbox, so the two names need not match.

## Sandbox Support

⚠️ **Pre-release** - This functionality is subject to change prior to General Availability.
Expand Down
8 changes: 7 additions & 1 deletion temporalio/contrib/openai_agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
`OpenAI Agents SDK <https://github.com/openai/openai-agents-python>`_ and Temporal workflows.
"""

from temporalio.contrib.openai_agents._errors import AgentsWorkflowError
from temporalio.contrib.openai_agents._mcp import (
StatefulMCPServerProvider,
StatelessMCPServerProvider,
Expand All @@ -14,23 +15,28 @@
OpenAIPayloadConverter,
)
from temporalio.contrib.openai_agents._temporal_worker_env_ref import (
AllowAllWorkerEnvVars,
temporal_worker_env_ref,
)
from temporalio.contrib.openai_agents.sandbox._sandbox_client_provider import (
SandboxClientProvider,
)
from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError
from temporalio.contrib.openai_agents.sandbox._temporal_worker_env_value import (
TemporalWorkerEnvValue,
)

from . import testing, workflow

__all__ = [
"AgentsWorkflowError",
"AllowAllWorkerEnvVars",
"ModelActivityParameters",
"OpenAIAgentsPlugin",
"OpenAIPayloadConverter",
"SandboxClientProvider",
"StatelessMCPServerProvider",
"StatefulMCPServerProvider",
"TemporalWorkerEnvValue",
"temporal_worker_env_ref",
"testing",
"workflow",
Expand Down
11 changes: 11 additions & 0 deletions temporalio/contrib/openai_agents/_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Error types for the OpenAI Agents SDK Temporal integration."""

from temporalio.exceptions import TemporalError


class AgentsWorkflowError(TemporalError):
"""Error that terminates the calling workflow or update.

Raised when the agents SDK raises an error which should terminate, or when
the plugin rejects an unsupported configuration.
"""
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
from temporalio import activity
from temporalio.contrib.openai_agents._heartbeat_decorator import auto_heartbeater
from temporalio.contrib.openai_agents._temporal_worker_env_ref import (
AllowAllWorkerEnvVars,
_WorkerEnvRefResolver,
)
from temporalio.contrib.workflow_streams import WorkflowStreamClient
Expand Down Expand Up @@ -342,7 +343,7 @@ class ModelActivity:
def __init__(
self,
model_provider: ModelProvider | None = None,
resolvable_worker_env_vars: Collection[str] = (),
resolvable_worker_env_vars: Collection[str] | AllowAllWorkerEnvVars = (),
):
"""Initialize the activity with a model provider."""
self._model_provider = model_provider or OpenAIProvider(
Expand Down
8 changes: 7 additions & 1 deletion temporalio/contrib/openai_agents/_openai_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@
from typing_extensions import Unpack

from temporalio import workflow
from temporalio.contrib.openai_agents._errors import AgentsWorkflowError
from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters
from temporalio.contrib.openai_agents._temporal_model_stub import _TemporalModelStub
from temporalio.contrib.openai_agents.sandbox._temporal_sandbox_client import (
TemporalSandboxClient,
)
from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError


# Recursively replace models in all agents
Expand Down Expand Up @@ -196,6 +196,12 @@ def _prepare_workflow_run(
" from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client\n"
" run_config = RunConfig(sandbox=SandboxRunConfig(client=temporal_sandbox_client('my-backend')))"
)
elif run_config.sandbox.session is not None:
raise AgentsWorkflowError(
"run_config.sandbox.session is not supported by the Temporal OpenAI Agents "
"plugin. A live sandbox session is not a durable construct in a workflow. "
"Pass run_config.sandbox.client=temporal_sandbox_client(name) instead."
)
elif run_config.sandbox.client is None:
raise ValueError(
"run_config.sandbox.client must be set to a temporal sandbox client. "
Expand Down
23 changes: 16 additions & 7 deletions temporalio/contrib/openai_agents/_temporal_openai_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from openai._models import construct_type

import temporalio.api.common.v1
from temporalio.contrib.openai_agents._errors import AgentsWorkflowError
from temporalio.contrib.openai_agents._invoke_model_activity import ModelActivity
from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters
from temporalio.contrib.openai_agents._openai_runner import (
Expand All @@ -28,10 +29,13 @@
from temporalio.contrib.openai_agents._temporal_trace_provider import (
TemporalTraceProvider,
)
from temporalio.contrib.openai_agents._temporal_worker_env_ref import (
AllowAllWorkerEnvVars,
_snapshot_resolvable_env_vars,
)
from temporalio.contrib.openai_agents._trace_interceptor import (
OpenAIAgentsContextPropagationInterceptor,
)
from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError
from temporalio.contrib.opentelemetry._tracer_provider import ReplaySafeTracerProvider
from temporalio.contrib.pydantic import (
PydanticJSONPlainPayloadConverter,
Expand Down Expand Up @@ -295,7 +299,7 @@ def __init__(
register_activities: bool = True,
add_temporal_spans: bool = True,
use_otel_instrumentation: bool = False,
resolvable_worker_env_vars: Collection[str] = (),
resolvable_worker_env_vars: Collection[str] | AllowAllWorkerEnvVars = (),
) -> None:
"""Initialize the OpenAI agents plugin.

Expand Down Expand Up @@ -324,9 +328,10 @@ def __init__(
Warning: use_otel_instrumentation is experimental and behavior may change in future versions.
Use with caution in production environments.
resolvable_worker_env_vars: Names of the environment variables that
``temporal_worker_env_ref()`` may read on this worker. Names are
matched exactly, with no globbing; ``"*"``
anywhere in the collection allows every name.
``temporal_worker_env_ref()`` and ``TemporalWorkerEnvValue`` may
read on this worker. Names are matched exactly, with no globbing;
pass ``AllowAllWorkerEnvVars()`` in place of the names to allow
every variable.
Warning: resolvable_worker_env_vars is experimental and behavior may change in future versions.
Use with caution in production environments.

Expand All @@ -349,6 +354,8 @@ def __init__(

self._use_otel_instrumentation = use_otel_instrumentation

resolvable_env_vars = _snapshot_resolvable_env_vars(resolvable_worker_env_vars)

# Delay activity construction until they are actually needed
def add_activities(
activities: Sequence[Callable] | None,
Expand All @@ -357,7 +364,7 @@ def add_activities(
return activities or []

model_activity = ModelActivity(
model_provider, resolvable_worker_env_vars=resolvable_worker_env_vars
model_provider, resolvable_worker_env_vars=resolvable_env_vars
)
new_activities = [
model_activity.invoke_model_activity,
Expand All @@ -380,7 +387,9 @@ def add_activities(
)

for sandbox_provider in sandbox_clients:
new_activities.extend(sandbox_provider._get_activities())
new_activities.extend(
sandbox_provider._get_activities(resolvable_env_vars)
)

return list(activities or []) + new_activities

Expand Down
63 changes: 52 additions & 11 deletions temporalio/contrib/openai_agents/_temporal_worker_env_ref.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""References to secrets held in the Temporal Worker's environment."""
"""Worker-environment secrets: the reference form, and the allowlist both forms share."""

from __future__ import annotations

import dataclasses
import os
import re
from collections.abc import Collection, Mapping, MutableMapping
Expand All @@ -14,7 +15,50 @@

_REF_PATTERN = re.compile(re.escape(_REF_PREFIX) + r"\{([^}{]*)\}")

_ANY_ENV_VAR = "*"

@dataclasses.dataclass(frozen=True)
class AllowAllWorkerEnvVars:
"""Make every environment variable on the worker resolvable.

.. warning::
This class is experimental and may change in future versions.
Use with caution in production environments.

Pass an instance in place of a list of names::

OpenAIAgentsPlugin(resolvable_worker_env_vars=AllowAllWorkerEnvVars())

This grants far more on the sandbox form than on the hosted tool form. A
sandbox manifest is written in workflow code, so allowing every name lets a
workflow name any variable on the worker and have its value set inside the
sandbox container, where a shell command the model composes can read it.
"""


def _snapshot_resolvable_env_vars(
resolvable_worker_env_vars: Collection[str] | AllowAllWorkerEnvVars,
) -> frozenset[str] | AllowAllWorkerEnvVars:
if isinstance(resolvable_worker_env_vars, AllowAllWorkerEnvVars):
return resolvable_worker_env_vars
if isinstance(resolvable_worker_env_vars, str):
raise TypeError(
"resolvable_worker_env_vars takes a collection of environment variable "
'names, such as ["MY_MCP_TOKEN"], or AllowAllWorkerEnvVars(). A single '
"string is read as the collection of its characters, so pass a list even "
"for one name."
)
if cast(object, resolvable_worker_env_vars) is AllowAllWorkerEnvVars:
raise TypeError(
"resolvable_worker_env_vars takes an AllowAllWorkerEnvVars instance, not "
"the class itself. Pass AllowAllWorkerEnvVars()."
)
return frozenset(resolvable_worker_env_vars)


def _is_resolvable(
resolvable: frozenset[str] | AllowAllWorkerEnvVars, name: str
) -> bool:
return isinstance(resolvable, AllowAllWorkerEnvVars) or name in resolvable


def temporal_worker_env_ref(name: str) -> str:
Expand Down Expand Up @@ -45,19 +89,16 @@ def temporal_worker_env_ref(name: str) -> str:


class _WorkerEnvRefResolver: # type:ignore[reportUnusedClass]
def __init__(self, resolvable_worker_env_vars: Collection[str]) -> None:
if isinstance(resolvable_worker_env_vars, str):
raise TypeError(
"resolvable_worker_env_vars takes a collection of environment variable "
'names, such as ["MY_MCP_TOKEN"]. A single string is read as the '
"collection of its characters, so pass a list even for one name."
)
self._allowed = frozenset(resolvable_worker_env_vars)
def __init__(
self,
resolvable_worker_env_vars: Collection[str] | AllowAllWorkerEnvVars,
) -> None:
self._allowed = _snapshot_resolvable_env_vars(resolvable_worker_env_vars)

def _resolve_ref(self, value: str) -> str:
def substitute(match: re.Match[str]) -> str:
name = match.group(1)
if _ANY_ENV_VAR not in self._allowed and name not in self._allowed:
if not _is_resolvable(self._allowed, name):
return match.group(0)
return os.environ.get(name, "")

Expand Down
Loading
Loading