opal-agent-sdk 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. opal_agent_sdk-0.1.1/LICENSE +21 -0
  2. opal_agent_sdk-0.1.1/PKG-INFO +86 -0
  3. opal_agent_sdk-0.1.1/README.md +49 -0
  4. opal_agent_sdk-0.1.1/pyproject.toml +105 -0
  5. opal_agent_sdk-0.1.1/setup.cfg +4 -0
  6. opal_agent_sdk-0.1.1/src/opal_agent_sdk/__init__.py +72 -0
  7. opal_agent_sdk-0.1.1/src/opal_agent_sdk/_auth.py +53 -0
  8. opal_agent_sdk-0.1.1/src/opal_agent_sdk/_config.py +64 -0
  9. opal_agent_sdk-0.1.1/src/opal_agent_sdk/_convenience.py +118 -0
  10. opal_agent_sdk-0.1.1/src/opal_agent_sdk/_http.py +320 -0
  11. opal_agent_sdk-0.1.1/src/opal_agent_sdk/_sse.py +116 -0
  12. opal_agent_sdk-0.1.1/src/opal_agent_sdk/_version.py +3 -0
  13. opal_agent_sdk-0.1.1/src/opal_agent_sdk/_ws.py +169 -0
  14. opal_agent_sdk-0.1.1/src/opal_agent_sdk/agents/__init__.py +32 -0
  15. opal_agent_sdk-0.1.1/src/opal_agent_sdk/agents/_specialized.py +437 -0
  16. opal_agent_sdk-0.1.1/src/opal_agent_sdk/agents/_workflow.py +129 -0
  17. opal_agent_sdk-0.1.1/src/opal_agent_sdk/canvas.py +187 -0
  18. opal_agent_sdk-0.1.1/src/opal_agent_sdk/cli/__init__.py +13 -0
  19. opal_agent_sdk-0.1.1/src/opal_agent_sdk/cli/main.py +224 -0
  20. opal_agent_sdk-0.1.1/src/opal_agent_sdk/client.py +109 -0
  21. opal_agent_sdk-0.1.1/src/opal_agent_sdk/errors.py +100 -0
  22. opal_agent_sdk-0.1.1/src/opal_agent_sdk/executions.py +43 -0
  23. opal_agent_sdk-0.1.1/src/opal_agent_sdk/pats.py +58 -0
  24. opal_agent_sdk-0.1.1/src/opal_agent_sdk/py.typed +0 -0
  25. opal_agent_sdk-0.1.1/src/opal_agent_sdk/types.py +160 -0
  26. opal_agent_sdk-0.1.1/src/opal_agent_sdk.egg-info/PKG-INFO +86 -0
  27. opal_agent_sdk-0.1.1/src/opal_agent_sdk.egg-info/SOURCES.txt +29 -0
  28. opal_agent_sdk-0.1.1/src/opal_agent_sdk.egg-info/dependency_links.txt +1 -0
  29. opal_agent_sdk-0.1.1/src/opal_agent_sdk.egg-info/entry_points.txt +2 -0
  30. opal_agent_sdk-0.1.1/src/opal_agent_sdk.egg-info/requires.txt +15 -0
  31. opal_agent_sdk-0.1.1/src/opal_agent_sdk.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Optimizely, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: opal-agent-sdk
3
+ Version: 0.1.1
4
+ Summary: Async Python SDK for invoking Opal agents over PAT-authenticated REST and socket.io.
5
+ Author-email: Optimizely <opal-team@optimizely.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/optimizely/opal-app
8
+ Project-URL: Documentation, https://github.com/optimizely/opal-app/blob/main/docs/tech-spec/agent-framework/agent-sdk/python-sdk.md
9
+ Project-URL: Source, https://github.com/optimizely/opal-app/tree/main/sdks/agent-sdk/python
10
+ Keywords: opal,agent,sdk,ai,llm,async
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Framework :: AsyncIO
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: httpx>=0.27
24
+ Requires-Dist: python-socketio[asyncio_client]>=5.10
25
+ Requires-Dist: pydantic>=2.6
26
+ Provides-Extra: cli
27
+ Requires-Dist: typer>=0.12; extra == "cli"
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
30
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
31
+ Requires-Dist: pytest-httpx>=0.30.0; extra == "dev"
32
+ Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
33
+ Requires-Dist: ruff>=0.6.0; extra == "dev"
34
+ Requires-Dist: mypy>=1.10.0; extra == "dev"
35
+ Requires-Dist: typer>=0.12; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+ # opal-agent-sdk (Python)
39
+
40
+ Async Python SDK for invoking Opal agents from external programs over a PAT.
41
+
42
+ **Tech spec:** [`docs/tech-spec/agent-framework/agent-sdk/python-sdk.md`](../../../docs/tech-spec/agent-framework/agent-sdk/python-sdk.md) in this repo.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install opal-agent-sdk # core
48
+ pip install "opal-agent-sdk[cli]" # adds the `opal` CLI
49
+ ```
50
+
51
+ Python 3.10+. Async-only.
52
+
53
+ ## Quickstart
54
+
55
+ ```python
56
+ import asyncio
57
+ import os
58
+ from opal_agent_sdk import OpalClient, PATAuth
59
+
60
+ async def main() -> None:
61
+ async with OpalClient(auth=PATAuth(os.environ["OPAL_PAT"])) as client:
62
+ result = await client.agents.specialized.run(
63
+ agent_id="customer-service-bot",
64
+ parameters={"query": "Where is order #1234?"},
65
+ )
66
+ print(result.output_text)
67
+
68
+ asyncio.run(main())
69
+ ```
70
+
71
+ See [`examples/`](./examples) for more (one runnable snippet per spec Appendix A entry).
72
+
73
+ ## Status
74
+
75
+ v0.1 — under active development on branch `opal_app_agent_sdk`. Wire contract pinned to Hypatia's `SdkEventEnvelope` + space-element responses via fixtures in [`tests/contract/`](./tests/contract).
76
+
77
+ ## Development
78
+
79
+ ```bash
80
+ make install # uv pip install -e ".[dev,cli]"
81
+ make test # pytest
82
+ make lint # ruff check
83
+ make format # ruff format
84
+ make typecheck # mypy --strict
85
+ make check # all of the above
86
+ ```
@@ -0,0 +1,49 @@
1
+ # opal-agent-sdk (Python)
2
+
3
+ Async Python SDK for invoking Opal agents from external programs over a PAT.
4
+
5
+ **Tech spec:** [`docs/tech-spec/agent-framework/agent-sdk/python-sdk.md`](../../../docs/tech-spec/agent-framework/agent-sdk/python-sdk.md) in this repo.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install opal-agent-sdk # core
11
+ pip install "opal-agent-sdk[cli]" # adds the `opal` CLI
12
+ ```
13
+
14
+ Python 3.10+. Async-only.
15
+
16
+ ## Quickstart
17
+
18
+ ```python
19
+ import asyncio
20
+ import os
21
+ from opal_agent_sdk import OpalClient, PATAuth
22
+
23
+ async def main() -> None:
24
+ async with OpalClient(auth=PATAuth(os.environ["OPAL_PAT"])) as client:
25
+ result = await client.agents.specialized.run(
26
+ agent_id="customer-service-bot",
27
+ parameters={"query": "Where is order #1234?"},
28
+ )
29
+ print(result.output_text)
30
+
31
+ asyncio.run(main())
32
+ ```
33
+
34
+ See [`examples/`](./examples) for more (one runnable snippet per spec Appendix A entry).
35
+
36
+ ## Status
37
+
38
+ v0.1 — under active development on branch `opal_app_agent_sdk`. Wire contract pinned to Hypatia's `SdkEventEnvelope` + space-element responses via fixtures in [`tests/contract/`](./tests/contract).
39
+
40
+ ## Development
41
+
42
+ ```bash
43
+ make install # uv pip install -e ".[dev,cli]"
44
+ make test # pytest
45
+ make lint # ruff check
46
+ make format # ruff format
47
+ make typecheck # mypy --strict
48
+ make check # all of the above
49
+ ```
@@ -0,0 +1,105 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "opal-agent-sdk"
7
+ version = "0.1.1"
8
+ description = "Async Python SDK for invoking Opal agents over PAT-authenticated REST and socket.io."
9
+ authors = [{ name = "Optimizely", email = "opal-team@optimizely.com" }]
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ keywords = ["opal", "agent", "sdk", "ai", "llm", "async"]
13
+ license = { text = "MIT" }
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Framework :: AsyncIO",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "httpx>=0.27",
27
+ "python-socketio[asyncio_client]>=5.10",
28
+ "pydantic>=2.6",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ cli = ["typer>=0.12"]
33
+ dev = [
34
+ "pytest>=8.0.0",
35
+ "pytest-asyncio>=0.23.0",
36
+ "pytest-httpx>=0.30.0",
37
+ "pytest-cov>=5.0.0",
38
+ "ruff>=0.6.0",
39
+ "mypy>=1.10.0",
40
+ "typer>=0.12",
41
+ ]
42
+
43
+ [project.scripts]
44
+ opal = "opal_agent_sdk.cli.main:app"
45
+
46
+ [project.urls]
47
+ "Homepage" = "https://github.com/optimizely/opal-app"
48
+ "Documentation" = "https://github.com/optimizely/opal-app/blob/main/docs/tech-spec/agent-framework/agent-sdk/python-sdk.md"
49
+ "Source" = "https://github.com/optimizely/opal-app/tree/main/sdks/agent-sdk/python"
50
+
51
+ [tool.setuptools.packages.find]
52
+ where = ["src"]
53
+
54
+ [tool.setuptools.package-data]
55
+ opal_agent_sdk = ["py.typed"]
56
+
57
+ [tool.ruff]
58
+ line-length = 100
59
+ target-version = "py310"
60
+ src = ["src", "tests"]
61
+
62
+ [tool.ruff.lint]
63
+ select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "RUF"]
64
+ ignore = [
65
+ "B008", # function call in argument defaults — common in typer commands
66
+ ]
67
+
68
+ [tool.ruff.lint.per-file-ignores]
69
+ "tests/**" = ["E501"]
70
+
71
+ [tool.mypy]
72
+ python_version = "3.10"
73
+ strict = true
74
+ warn_unused_configs = true
75
+ warn_redundant_casts = true
76
+ warn_unused_ignores = true
77
+ # Tests are mypy-light on purpose — assertions in tests aren't worth strict types.
78
+ exclude = ["tests/", "build/"]
79
+
80
+ [[tool.mypy.overrides]]
81
+ module = ["socketio.*", "engineio.*"]
82
+ ignore_missing_imports = true
83
+
84
+ [tool.pytest.ini_options]
85
+ testpaths = ["tests"]
86
+ python_files = ["test_*.py"]
87
+ python_classes = ["Test*"]
88
+ python_functions = ["test_*"]
89
+ addopts = "-v --strict-markers"
90
+ asyncio_mode = "auto"
91
+ markers = [
92
+ "integration: opt-in tests that hit localdev (require OPAL_INTEGRATION=1)",
93
+ ]
94
+
95
+ [tool.coverage.run]
96
+ source = ["src/opal_agent_sdk"]
97
+ branch = true
98
+
99
+ [tool.coverage.report]
100
+ exclude_lines = [
101
+ "pragma: no cover",
102
+ "if TYPE_CHECKING:",
103
+ "raise NotImplementedError",
104
+ "\\.\\.\\.",
105
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,72 @@
1
+ """Opal Agent SDK — async Python client for invoking Opal agents.
2
+
3
+ Public symbols:
4
+ - ``OpalClient`` — the async client
5
+ - ``PATAuth`` — Personal Access Token auth strategy
6
+ - ``OpalEvent`` — discriminated-union event for streams
7
+ - ``OpalError`` (+ typed subclasses) — exception hierarchy
8
+ - ``run`` / ``stream`` — module-level convenience helpers (Phase 6)
9
+
10
+ See ``docs/tech-spec/agent-framework/agent-sdk/python-sdk.md`` in the opal-app repo.
11
+ """
12
+
13
+ from opal_agent_sdk._auth import OpalAuth, PATAuth
14
+ from opal_agent_sdk._config import OpalConfig
15
+ from opal_agent_sdk._convenience import aclose, run, stream
16
+ from opal_agent_sdk._version import __version__
17
+ from opal_agent_sdk.client import OpalClient
18
+ from opal_agent_sdk.errors import (
19
+ OpalAuthError,
20
+ OpalConcurrencyError,
21
+ OpalConnectionError,
22
+ OpalError,
23
+ OpalNotFoundError,
24
+ OpalRateLimitError,
25
+ OpalReplayMissedError,
26
+ OpalServerError,
27
+ OpalUnsupportedError,
28
+ )
29
+ from opal_agent_sdk.types import (
30
+ PAT,
31
+ Artifact,
32
+ CommitInfo,
33
+ FileAttachment,
34
+ OpalChatTurn,
35
+ OpalEvent,
36
+ OpalRunResult,
37
+ OpalSpace,
38
+ OpalStepExecution,
39
+ OpalWorkflowExecution,
40
+ TokenUsage,
41
+ )
42
+
43
+ __all__ = [
44
+ "PAT",
45
+ "Artifact",
46
+ "CommitInfo",
47
+ "FileAttachment",
48
+ "OpalAuth",
49
+ "OpalAuthError",
50
+ "OpalChatTurn",
51
+ "OpalClient",
52
+ "OpalConcurrencyError",
53
+ "OpalConfig",
54
+ "OpalConnectionError",
55
+ "OpalError",
56
+ "OpalEvent",
57
+ "OpalNotFoundError",
58
+ "OpalRateLimitError",
59
+ "OpalReplayMissedError",
60
+ "OpalRunResult",
61
+ "OpalServerError",
62
+ "OpalSpace",
63
+ "OpalStepExecution",
64
+ "OpalUnsupportedError",
65
+ "OpalWorkflowExecution",
66
+ "PATAuth",
67
+ "TokenUsage",
68
+ "__version__",
69
+ "aclose",
70
+ "run",
71
+ "stream",
72
+ ]
@@ -0,0 +1,53 @@
1
+ """Authentication strategies.
2
+
3
+ v1 ships `PATAuth` only. `ClientCredentialsAuth` and `AuthorizationCodeAuth`
4
+ follow in v1.1 with the same `auth=` constructor slot on `OpalClient`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import MutableMapping
10
+ from typing import Protocol, runtime_checkable
11
+
12
+
13
+ @runtime_checkable
14
+ class OpalAuth(Protocol):
15
+ """Protocol every auth strategy implements.
16
+
17
+ `apply()` mutates the headers mapping to add whatever the wire needs
18
+ (today: an `Authorization: Bearer <token>` header). `bearer_token`
19
+ exposes the raw token for socket.io handshakes that need it in the
20
+ `auth` payload rather than a header.
21
+ """
22
+
23
+ @property
24
+ def bearer_token(self) -> str: ...
25
+
26
+ def apply(self, headers: MutableMapping[str, str]) -> None: ...
27
+
28
+
29
+ class PATAuth:
30
+ """Personal Access Token auth — the v1 default.
31
+
32
+ The token is opaque to the SDK; identity is resolved per-request by the
33
+ API Gateway via authz-server introspect. No caching, no refresh — when
34
+ the PAT is revoked, the next call raises ``OpalAuthError``.
35
+ """
36
+
37
+ __slots__ = ("_token",)
38
+
39
+ def __init__(self, token: str) -> None:
40
+ if not token or not isinstance(token, str):
41
+ raise ValueError("PATAuth requires a non-empty string token")
42
+ self._token = token
43
+
44
+ @property
45
+ def bearer_token(self) -> str:
46
+ return self._token
47
+
48
+ def apply(self, headers: MutableMapping[str, str]) -> None:
49
+ headers["Authorization"] = f"Bearer {self._token}"
50
+
51
+ def __repr__(self) -> str:
52
+ # Never log the token, even via repr().
53
+ return "PATAuth(token=<redacted>)"
@@ -0,0 +1,64 @@
1
+ """Client configuration — endpoints, timeouts, retry policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from urllib.parse import urlparse, urlunparse
8
+
9
+ _DEFAULT_BASE_URL = "https://api.opal.optimizely.com"
10
+
11
+
12
+ def _derive_ws_url(base_url: str) -> str:
13
+ """Default WS URL: same host as base_url, http(s) → ws(s)."""
14
+ parsed = urlparse(base_url)
15
+ scheme = "wss" if parsed.scheme == "https" else "ws"
16
+ return urlunparse((scheme, parsed.netloc, "", "", "", ""))
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class OpalConfig:
21
+ """Endpoint + timeout + retry configuration for an `OpalClient`.
22
+
23
+ `instance_id` is part of the URL path on hypatia endpoints
24
+ (``/api/v1/{instance_id}/agents/...``). The gateway routes ``/hypatia/api``
25
+ → ``hypatia:/api`` without rewriting the path, so the SDK is responsible
26
+ for filling in instance_id. A future improvement (not in v1) is for the
27
+ SDK to extract it lazily from the PAT JWT's ``instance_id`` claim.
28
+ """
29
+
30
+ base_url: str = _DEFAULT_BASE_URL
31
+ ws_url: str | None = None
32
+ instance_id: str | None = None
33
+ timeout_s: float = 30.0
34
+ retry_max_attempts: int = 3
35
+ verify_ssl: bool = True # Localdev only — do not disable in production
36
+
37
+ @property
38
+ def effective_ws_url(self) -> str:
39
+ """Resolved WS URL — explicit `ws_url`, else derived from `base_url`."""
40
+ return self.ws_url if self.ws_url else _derive_ws_url(self.base_url)
41
+
42
+ def require_instance_id(self) -> str:
43
+ """Return ``instance_id`` or raise a clear error if it wasn't supplied."""
44
+ if not self.instance_id:
45
+ raise ValueError(
46
+ "instance_id is required for this call. Either pass "
47
+ "OpalConfig(instance_id=...) or set the OPAL_INSTANCE_ID "
48
+ "environment variable."
49
+ )
50
+ return self.instance_id
51
+
52
+ @classmethod
53
+ def from_env(cls) -> OpalConfig:
54
+ """Build a config from `OPAL_BASE_URL` / `OPAL_WS_URL` / `OPAL_INSTANCE_ID` env vars.
55
+
56
+ Endpoint vars have defaults; instance_id does not — it stays None and
57
+ ``require_instance_id()`` raises at call time if the caller never
58
+ supplied one.
59
+ """
60
+ return cls(
61
+ base_url=os.environ.get("OPAL_BASE_URL", _DEFAULT_BASE_URL),
62
+ ws_url=os.environ.get("OPAL_WS_URL") or None,
63
+ instance_id=os.environ.get("OPAL_INSTANCE_ID") or None,
64
+ )
@@ -0,0 +1,118 @@
1
+ """Module-level convenience: ``from opal_agent_sdk import run, stream``.
2
+
3
+ Wraps a process-global default :class:`OpalClient` built from environment
4
+ variables on first call. The client is closed at interpreter exit via
5
+ ``atexit``. Anthropic-style convenience surface — fine for scripts, not
6
+ recommended for long-lived services (use an explicit client there).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import atexit
13
+ import logging
14
+ import threading
15
+ from collections.abc import AsyncIterator, Mapping
16
+ from typing import Any
17
+
18
+ from opal_agent_sdk._auth import PATAuth
19
+ from opal_agent_sdk._config import OpalConfig
20
+ from opal_agent_sdk.client import OpalClient
21
+ from opal_agent_sdk.types import OpalEvent, OpalRunResult
22
+
23
+ logger = logging.getLogger("opal_agent_sdk.convenience")
24
+
25
+ _default_client: OpalClient | None = None
26
+ _default_client_lock = threading.Lock()
27
+ _atexit_registered = False
28
+
29
+
30
+ def _build_default_client() -> OpalClient:
31
+ """Construct the singleton client from env vars.
32
+
33
+ Requires ``OPAL_PAT``; raises ``RuntimeError`` if missing so callers
34
+ aren't surprised by a delayed auth error on the first request.
35
+ """
36
+ import os
37
+
38
+ pat = os.environ.get("OPAL_PAT")
39
+ if not pat:
40
+ raise RuntimeError(
41
+ "OPAL_PAT environment variable is not set. The top-level run()/stream() "
42
+ "helpers require it. For explicit control, construct an OpalClient yourself."
43
+ )
44
+ return OpalClient(auth=PATAuth(pat), config=OpalConfig.from_env())
45
+
46
+
47
+ def _get_default_client() -> OpalClient:
48
+ """Thread-safely fetch (or create) the process-global default client."""
49
+ global _default_client, _atexit_registered
50
+ with _default_client_lock:
51
+ if _default_client is None:
52
+ _default_client = _build_default_client()
53
+ if not _atexit_registered:
54
+ atexit.register(_close_default_client_at_exit)
55
+ _atexit_registered = True
56
+ return _default_client
57
+
58
+
59
+ def _close_default_client_at_exit() -> None:
60
+ """Best-effort cleanup at interpreter shutdown.
61
+
62
+ If the user has their own event loop running (e.g. asyncio.run already
63
+ returned and the loop is gone, but they didn't aclose() us), call
64
+ ``asyncio.run(client.aclose())``. If that fails because a loop is still
65
+ running on this thread, log and bail — the OS will reclaim the
66
+ connection. Best-effort hygiene, not correctness.
67
+ """
68
+ global _default_client
69
+ client = _default_client
70
+ if client is None:
71
+ return
72
+ try:
73
+ asyncio.run(client.aclose())
74
+ except RuntimeError as exc:
75
+ logger.debug("default client atexit cleanup skipped: %s", exc)
76
+ _default_client = None
77
+
78
+
79
+ async def aclose() -> None:
80
+ """Explicitly close the process-global default client.
81
+
82
+ Long-lived services that built one via ``run()``/``stream()`` should
83
+ call this at shutdown so resources are released deterministically.
84
+ """
85
+ global _default_client
86
+ with _default_client_lock:
87
+ client = _default_client
88
+ _default_client = None
89
+ if client is not None:
90
+ await client.aclose()
91
+
92
+
93
+ async def run(
94
+ agent_id: str,
95
+ *,
96
+ parameters: Mapping[str, Any] | None = None,
97
+ ) -> OpalRunResult:
98
+ """Top-level convenience: run a specialized agent once using a default client.
99
+
100
+ Equivalent to::
101
+
102
+ async with OpalClient.from_env() as client:
103
+ return await client.agents.specialized.run(agent_id=..., parameters=...)
104
+
105
+ but the client is reused across calls within the process.
106
+ """
107
+ client = _get_default_client()
108
+ return await client.agents.specialized.run(agent_id, parameters=parameters)
109
+
110
+
111
+ def stream(
112
+ agent_id: str,
113
+ *,
114
+ parameters: Mapping[str, Any] | None = None,
115
+ ) -> AsyncIterator[OpalEvent]:
116
+ """Top-level convenience: stream events from a specialized agent execution."""
117
+ client = _get_default_client()
118
+ return client.agents.specialized.stream(agent_id, parameters=parameters)