Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.venv
.mypy_cache
__pycache__
/build
/dist
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ to include examples, links to docs, or any other relevant information.
workflow sandbox now also restricts the non-deterministic `uuid.uuid7()`
added to the standard library in Python 3.14, matching the existing
`uuid.uuid1()`/`uuid.uuid4()` restrictions.
- `temporalio.contrib.openai_agents.TemporalWorkerEnvValue` supplies a sandbox
environment variable from the worker's own environment, so the secret value
is never stored in workflow history.
- **Experimental**: `TemporalOperationHandler` can now use Standalone Activities as asynchronous
Nexus Operation backing executions through `TemporalNexusClient.start_activity`.

Expand All @@ -50,6 +53,15 @@ to include examples, links to docs, or any other relevant information.

### :boom: Breaking Changes

- `temporalio.contrib.openai_agents` now rejects sandbox path grants that
are bound to a host path. A `SandboxPathGrant` with `host_path` set wrote
that path into workflow history in plaintext on every sandbox operation,
so such a grant is refused before the manifest is serialized and the
workflow fails with an error naming the sandbox-side paths. Remove
`host_path` from those grants. `SandboxPathGrant.host_path` was added in
`openai-agents` 0.19.2, so this affects only workflows already running
against 0.19.2 or later.

### Fixed

- The `google-adk` extra now depends on `mcp`, so fresh installs of
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.11.1,<2", "opentelemetry-sdk>=1.11.1,<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
42 changes: 42 additions & 0 deletions temporalio/contrib/openai_agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,48 @@ result = await Runner.run(
)
```

### Environment Variables

Use `TemporalWorkerEnvValue` for a sandbox environment value that should come from the worker's environment rather than being written into the manifest — a secret especially. It carries the name of a variable, and the worker reads that variable when the sandbox environment is needed:

```python
from agents import RunConfig, Runner
from agents.sandbox import Manifest, SandboxRunConfig
from agents.sandbox.manifest import Environment
from agents.extensions.sandbox.daytona import DaytonaSandboxClientOptions

from temporalio.contrib.openai_agents import TemporalWorkerEnvValue
from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client

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

result = await Runner.run(
agent, prompt,
run_config=RunConfig(sandbox=SandboxRunConfig(
client=temporal_sandbox_client("daytona"),
options=DaytonaSandboxClientOptions(pause_on_exit=False),
manifest=manifest,
)),
)
```

This reads `PROD_OPENAI_KEY` on the worker and sets `OPENAI_API_KEY` inside the sandbox; the two names need not match. Set the variable on every worker that runs sandbox activities — if it is missing or empty there, the run fails with a non-retryable error naming it.

Keep secrets out of inline file contents (`File(content=...)`), environment fields on client options such as `DaytonaSandboxClientOptions.env_vars`, and credential fields on client options such as `CloudflareSandboxClientOptions.api_key`.

### Unsupported Inputs

Path grants that bind a host path are rejected. Grant paths inside the sandbox instead.

A live sandbox session passed to `SandboxRunConfig(session=...)` is also rejected — the plugin needs to create the session itself so its work runs in activities.

## Streaming

⚠️ **Experimental** - This functionality is subject to change prior to General Availability.
Expand Down
4 changes: 4 additions & 0 deletions temporalio/contrib/openai_agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
from temporalio.contrib.openai_agents.sandbox._sandbox_client_provider import (
SandboxClientProvider,
)
from temporalio.contrib.openai_agents.sandbox._temporal_worker_env_value import (
TemporalWorkerEnvValue,
)
from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError

from . import testing, workflow
Expand All @@ -28,6 +31,7 @@
"SandboxClientProvider",
"StatelessMCPServerProvider",
"StatefulMCPServerProvider",
"TemporalWorkerEnvValue",
"testing",
"workflow",
]
9 changes: 9 additions & 0 deletions temporalio/contrib/openai_agents/_openai_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,15 @@ 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')))"
)
# Checked before the client, which upstream never resolves for a live
# session: it configures the session and returns, so no activity --
# and no manifest check -- ever runs.
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
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ async def create(
options: BaseSandboxClientOptions,
) -> SandboxSession:
"""Create a new sandbox session via activity."""
_reject_host_path_grants(manifest)
result: SessionResult = await workflow.execute_activity(
f"{self._name}-sandbox_client_create",
arg=CreateSessionArgs(
Expand All @@ -93,6 +94,7 @@ async def create(

async def resume(self, state: SandboxSessionState) -> SandboxSession:
"""Resume an existing sandbox session via activity."""
_reject_host_path_grants(state.manifest)
result: SessionResult = await workflow.execute_activity(
f"{self._name}-sandbox_client_resume",
arg=ResumeSessionArgs(state=state),
Expand Down Expand Up @@ -122,3 +124,21 @@ async def delete(self, session: TemporalSandboxSession) -> TemporalSandboxSessio
def deserialize_session_state(self, payload: dict[str, Any]) -> SandboxSessionState:
"""Deserialize a session state from a dict."""
return SandboxSessionState.parse(payload)


def _reject_host_path_grants(manifest: Manifest | None) -> None:
"""Reject path grants bound to a host path before the manifest reaches a payload."""
# Imported here because workflow.py imports this module.
from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError

if manifest is None:
return
# Names the sandbox-side path, never host_path: this message is recorded in
# the WorkflowExecutionFailed event.
bound = [g.path for g in manifest.extra_path_grants if g.host_path is not None]
if bound:
raise AgentsWorkflowError(
Comment thread
brianstrauch marked this conversation as resolved.
"Sandbox path grants with a host_path are not supported by the Temporal OpenAI "
f"Agents plugin (found: {', '.join(bound)}). The host path is recorded in "
"workflow history in plaintext. Remove host_path from these grants."
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Sandbox environment value resolved from the Temporal Worker's environment."""

from __future__ import annotations

import os
from typing import Literal

from agents.sandbox.manifest import EnvValue

from temporalio import workflow
from temporalio.exceptions import ApplicationError


class TemporalWorkerEnvValue(EnvValue):
"""A sandbox environment variable whose value is read on the Temporal Worker.

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

Use it wherever a sandbox environment value should come from the worker's
environment rather than being written into the manifest. It carries the name
of the variable, and the worker reads the value when the sandbox environment
is needed. Only the name is recorded in workflow history.

::

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(key="OPENAI_API_KEY"),
"REGION": "us-west-2",
}
)
)

Set the variable on every worker that runs sandbox activities. The two
names need not match: ``{"OPENAI_API_KEY": TemporalWorkerEnvValue(key="PROD_KEY")}``
reads ``PROD_KEY`` on the worker and sets ``OPENAI_API_KEY`` inside the
sandbox.
"""

type: Literal["temporal.worker_env_value"] = "temporal.worker_env_value" # type: ignore[assignment]
"""Discriminator for this environment value type."""

key: str
"""Name of the environment variable to read on the worker."""

async def resolve(self) -> str:
"""Return the value read from the worker's environment.

Raises:
ApplicationError: If :py:attr:`key` is unset or empty, or if called
from workflow code. Non-retryable, with
``type="TemporalWorkerEnvValueUnresolved"``.
"""
if workflow.in_workflow():
raise ApplicationError(
"TemporalWorkerEnvValue.resolve() must run on a worker, not in workflow "
"code: it reads the process environment, which is non-deterministic on "
"replay and would pull the value into workflow state.",
type="TemporalWorkerEnvValueUnresolved",
non_retryable=True,
)
value = os.environ.get(self.key)
if not value:
raise ApplicationError(
f"TemporalWorkerEnvValue environment variable {self.key!r} is not set, "
"or is empty, in the worker process environment.",
type="TemporalWorkerEnvValueUnresolved",
non_retryable=True,
)
return value
Loading
Loading