react-agent-harness 0.5.0__tar.gz → 0.5.2__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 (77) hide show
  1. {react_agent_harness-0.5.0/react_agent_harness.egg-info → react_agent_harness-0.5.2}/PKG-INFO +1 -1
  2. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/README.md +57 -25
  3. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/cli.py +9 -2
  4. react_agent_harness-0.5.2/harness/oauth_browser.py +150 -0
  5. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/pyproject.toml +1 -1
  6. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2/react_agent_harness.egg-info}/PKG-INFO +1 -1
  7. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/react_agent_harness.egg-info/SOURCES.txt +2 -0
  8. react_agent_harness-0.5.2/tests/test_mcp_auth.py +368 -0
  9. react_agent_harness-0.5.2/tests/test_oauth_browser.py +111 -0
  10. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_steering.py +43 -14
  11. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tools/mcp/adapter.py +70 -15
  12. react_agent_harness-0.5.2/tools/mcp/auth.py +437 -0
  13. react_agent_harness-0.5.0/tests/test_mcp_auth.py +0 -104
  14. react_agent_harness-0.5.0/tools/mcp/auth.py +0 -129
  15. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/LICENSE +0 -0
  16. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/agents/__init__.py +0 -0
  17. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/agents/base.py +0 -0
  18. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/__init__.py +0 -0
  19. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/annotation.py +0 -0
  20. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/checkpoint.py +0 -0
  21. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/console.py +0 -0
  22. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/events.py +0 -0
  23. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/executor_bridge.py +0 -0
  24. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/hitl.py +0 -0
  25. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/llm/__init__.py +0 -0
  26. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/llm/_streaming.py +0 -0
  27. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/llm/anthropic.py +0 -0
  28. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/llm/auth.py +0 -0
  29. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/llm/claude_code.py +0 -0
  30. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/llm/openai.py +0 -0
  31. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/llm/openai_codex.py +0 -0
  32. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/otel.py +0 -0
  33. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/runtime.py +0 -0
  34. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/steering.py +0 -0
  35. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/tool_policy.py +0 -0
  36. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/harness/utils.py +0 -0
  37. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/memory/__init__.py +0 -0
  38. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/memory/episodic_lance.py +0 -0
  39. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/memory/manager.py +0 -0
  40. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/memory/redis_store.py +0 -0
  41. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/memory/stores.py +0 -0
  42. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/memory/working.py +0 -0
  43. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/orchestrator/__init__.py +0 -0
  44. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/orchestrator/planner.py +0 -0
  45. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/react_agent_harness.egg-info/dependency_links.txt +0 -0
  46. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/react_agent_harness.egg-info/entry_points.txt +0 -0
  47. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/react_agent_harness.egg-info/requires.txt +0 -0
  48. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/react_agent_harness.egg-info/top_level.txt +0 -0
  49. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/setup.cfg +0 -0
  50. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_agents_base.py +0 -0
  51. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_annotation.py +0 -0
  52. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_anthropic_llm.py +0 -0
  53. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_checkpoint_resume.py +0 -0
  54. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_claude_code_llm.py +0 -0
  55. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_cli.py +0 -0
  56. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_console_renderer.py +0 -0
  57. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_executor_bridge.py +0 -0
  58. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_http_fetch.py +0 -0
  59. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_llm_auth.py +0 -0
  60. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_mcp_adapter.py +0 -0
  61. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_memory.py +0 -0
  62. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_openai_codex_llm.py +0 -0
  63. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_openai_llm.py +0 -0
  64. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_orchestrator.py +0 -0
  65. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_otel.py +0 -0
  66. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_parse_action_json.py +0 -0
  67. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_redis_store.py +0 -0
  68. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_streaming.py +0 -0
  69. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_tool_policy.py +0 -0
  70. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_utils.py +0 -0
  71. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_vision.py +0 -0
  72. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tests/test_working_memory.py +0 -0
  73. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tools/__init__.py +0 -0
  74. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tools/builtin/__init__.py +0 -0
  75. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tools/builtin/fetch_image.py +0 -0
  76. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tools/builtin/http_fetch.py +0 -0
  77. {react_agent_harness-0.5.0 → react_agent_harness-0.5.2}/tools/mcp/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: react-agent-harness
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Multi-agent LLM orchestration: hybrid DAG planning, two-tier memory, streaming
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -44,6 +44,7 @@ harness/steering.py Async steering — agent.steer(text), StdinRouter pu
44
44
  harness/checkpoint.py CheckpointStore + _ResumeHint + maybe_resume_key — pluggable run-state persistence (file + Redis); auto-resume built into dispatch_stream / run_stream
45
45
  harness/otel.py OTELHook — OpenTelemetry span exporter (opt-in)
46
46
  harness/executor_bridge.py ExecutorBridge + ExecutorTool — controlled subprocess launcher with optional Docker sandboxing
47
+ harness/oauth_browser.py Localhost OAuth callback server + open_or_print_url — shared by MCP browser-OAuth and LLM login flows
47
48
  orchestrator/planner.py Hybrid DAG orchestrator — plan, replan, synthesize
48
49
  agents/base.py Generic BaseAgent — ReAct loop, no subclassing needed
49
50
  memory/manager.py MemoryManager — semantic KV + episodic vector
@@ -592,47 +593,78 @@ async with MCPServerConnection(params, server_name="filesystem") as conn:
592
593
  result = await runtime.run("list files in /tmp")
593
594
  ```
594
595
 
595
- Supports **stdio** and **SSE** transports. The `MCPServerConnection` context
596
- manager handles the full lifecycle — connect, discover, and cleanup.
596
+ Supports **stdio**, **SSE**, and **streamable-HTTP** transports. The
597
+ `MCPServerConnection` context manager handles the full lifecycle —
598
+ connect, discover, and cleanup.
597
599
 
598
- Remote MCP servers can receive static headers or bearer tokens through an auth
599
- provider:
600
+ ### Auth options
601
+
602
+ Pick the provider that matches how your MCP server authenticates:
603
+
604
+ | Provider | When to use |
605
+ |---|---|
606
+ | `StaticMCPAuth` | Literal header/env values you have in hand |
607
+ | `BearerMCPAuth` | A single bearer token string |
608
+ | `ApiKeyMCPAuth` | API-key headers backed by environment variables |
609
+ | `OAuthMCPAuth` | Bearer token cached in the shared `auth.json` file |
610
+ | `BrowserOAuthMCPAuth` | Full OAuth 2.0 + PKCE flow with browser login |
611
+
612
+ **API keys backed by env vars** — generic, no vendor coupling:
600
613
 
601
614
  ```python
602
615
  import os
603
- from tools.mcp import MCPServerConnection, StaticMCPAuth
616
+ from tools.mcp.auth import ApiKeyMCPAuth, StreamableHttpServerParams
617
+ from tools.mcp import MCPServerConnection
618
+
619
+ auth = ApiKeyMCPAuth({
620
+ "DD-Api-Key": "DD_API_KEY",
621
+ "DD-Application-Key": "DD_APP_KEY",
622
+ })
623
+ params = StreamableHttpServerParams(url="https://mcp.datadoghq.com/")
624
+
625
+ async with MCPServerConnection(params, server_name="datadog", auth=auth) as conn:
626
+ conn.register_tools(tool_registry)
627
+ ```
604
628
 
605
- auth = StaticMCPAuth(
606
- headers={
607
- "DD_API_KEY": os.environ["DD_API_KEY"],
608
- "DD_APPLICATION_KEY": os.environ["DD_APPLICATION_KEY"],
609
- }
629
+ **Browser-based OAuth (PKCE) for hosted MCP servers**:
630
+
631
+ ```python
632
+ from tools.mcp.auth import BrowserOAuthMCPAuth, StreamableHttpServerParams
633
+ from tools.mcp import MCPServerConnection
634
+
635
+ auth = BrowserOAuthMCPAuth(
636
+ server_url="https://mcp.example.com/",
637
+ provider_name="mcp:example",
638
+ client_id="abc123", # from the provider's developer console
639
+ client_secret="shh", # optional (PKCE-only flows omit)
640
+ scopes=["read", "write"],
610
641
  )
642
+ params = StreamableHttpServerParams(url="https://mcp.example.com/")
611
643
 
612
- async with MCPServerConnection(
613
- {"url": "https://mcp.datadoghq.com/api/unstable/mcp-server/mcp"},
614
- server_name="datadog",
615
- auth=auth,
616
- ) as conn:
644
+ async with MCPServerConnection(params, auth=auth) as conn:
617
645
  conn.register_tools(tool_registry)
618
646
  ```
619
647
 
620
- OAuth-style auth files can be reused for MCP bearer auth:
648
+ First connect opens the browser, captures the redirect on
649
+ `http://127.0.0.1:8765/callback`, persists tokens to
650
+ `~/.agent-harness/auth/auth.json` (chmod 0600), and refreshes them
651
+ transparently on every subsequent run. Register your OAuth app with that
652
+ redirect URI.
653
+
654
+ Servers that support RFC 7591 dynamic client registration work without
655
+ supplying `client_id` — the MCP SDK registers a fresh client on first
656
+ connect.
657
+
658
+ **Cached OAuth from the auth.json file** (for tokens you already minted
659
+ elsewhere):
621
660
 
622
661
  ```python
623
- from tools.mcp import MCPServerConnection, OAuthMCPAuth
662
+ from tools.mcp import OAuthMCPAuth, MCPServerConnection
624
663
 
625
664
  auth = OAuthMCPAuth.from_auth_file(
626
665
  "~/.agent-harness/auth/auth.json",
627
- provider="datadog-mcp",
666
+ provider="my-service",
628
667
  )
629
-
630
- async with MCPServerConnection(
631
- {"url": "https://mcp.datadoghq.com/api/unstable/mcp-server/mcp"},
632
- server_name="datadog",
633
- auth=auth,
634
- ) as conn:
635
- conn.register_tools(tool_registry)
636
668
  ```
637
669
 
638
670
  See `examples/mcp_demo.py` for local stdio MCP and `examples/mcp_auth_demo.py`
@@ -79,11 +79,13 @@ def main() -> int:
79
79
 
80
80
 
81
81
  async def _login_openai_codex(path: Path) -> int:
82
+ from harness.oauth_browser import open_or_print_url
83
+
82
84
  client = OpenAICodexOAuthClient()
83
85
  try:
84
86
  device = await client.request_device_code()
85
87
  print("OpenAI Codex login")
86
- print(f"Open: {device.verification_uri}")
88
+ open_or_print_url(device.verification_uri, prefix="Open:")
87
89
  print(f"Code: {device.user_code}")
88
90
  print("Waiting for authorization...")
89
91
  cred = await client.poll_device_code(device)
@@ -95,11 +97,16 @@ async def _login_openai_codex(path: Path) -> int:
95
97
 
96
98
 
97
99
  async def _login_claude_code(path: Path) -> int:
100
+ from harness.oauth_browser import open_or_print_url
101
+
98
102
  client = AnthropicClaudeCodeOAuthClient()
99
103
  try:
100
104
  login = client.begin_login()
101
105
  print("Claude Code login")
102
- print(f"Open: {login.url}")
106
+ # Anthropic owns the redirect URI (console.anthropic.com), so we
107
+ # can't auto-capture the callback here. Best we can do is open the
108
+ # browser for the user and let them paste the result.
109
+ open_or_print_url(login.url, prefix="Open:")
103
110
  print("Paste the final callback URL, or the code#state value.")
104
111
  callback_input = input("Callback: ")
105
112
  cred = await client.finish_login(login, callback_input)
@@ -0,0 +1,150 @@
1
+ """Browser-based OAuth helpers shared across providers.
2
+
3
+ Two utilities here:
4
+
5
+ - ``open_or_print_url(url)`` — try to open a URL in the user's default
6
+ browser; fall back to printing it so headless / SSH sessions still work.
7
+
8
+ - ``wait_for_oauth_callback(port, path, timeout)`` — spin up a one-shot
9
+ localhost HTTP server, block until the OAuth provider redirects the
10
+ browser back, and return the ``(code, state)`` pair from the query
11
+ string. Used by ``BrowserOAuthMCPAuth`` and any future browser-based
12
+ login flow whose redirect URI we control.
13
+
14
+ Stdlib only — no new dependencies. The callback server uses
15
+ ``http.server.HTTPServer`` in a background thread so the asyncio caller can
16
+ ``await`` on a future that resolves when the request arrives.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import logging
23
+ import threading
24
+ import urllib.parse
25
+ import webbrowser
26
+ from http.server import BaseHTTPRequestHandler, HTTPServer
27
+ from typing import Any
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ _HTML_OK = b"""<!doctype html>
33
+ <html><body style="font-family:sans-serif;text-align:center;padding:3em">
34
+ <h2>Authorization complete</h2>
35
+ <p>You can close this tab and return to the terminal.</p>
36
+ </body></html>
37
+ """
38
+
39
+ _HTML_ERROR = b"""<!doctype html>
40
+ <html><body style="font-family:sans-serif;text-align:center;padding:3em">
41
+ <h2>Authorization failed</h2>
42
+ <p>%s</p>
43
+ <p>Check the terminal for details.</p>
44
+ </body></html>
45
+ """
46
+
47
+
48
+ def open_or_print_url(url: str, *, prefix: str = "Open in browser:") -> None:
49
+ """Try to open ``url`` in the default browser; always print it as a fallback."""
50
+ print(f"{prefix} {url}")
51
+ try:
52
+ webbrowser.open(url, new=2)
53
+ except Exception as e: # noqa: BLE001 — best-effort UX nicety
54
+ logger.debug("webbrowser.open failed: %s", e)
55
+
56
+
57
+ async def wait_for_oauth_callback(
58
+ *,
59
+ port: int = 0,
60
+ path: str = "/callback",
61
+ timeout: float = 300.0,
62
+ bind_host: str = "127.0.0.1",
63
+ ) -> tuple[str, str | None]:
64
+ """Run a localhost HTTP server until a redirect with ``code`` arrives.
65
+
66
+ Args:
67
+ port: Port to bind. ``0`` lets the OS pick a free port — read it back
68
+ from ``actual_port`` after construction if you need it (this
69
+ helper does not return the bound port; callers that need it
70
+ should use :func:`bind_callback_server` instead).
71
+ path: Expected redirect path. Other paths return 404.
72
+ timeout: Seconds to wait before raising :class:`TimeoutError`.
73
+ bind_host: Address to bind on. Keep ``127.0.0.1`` for security —
74
+ anything else makes the auth code observable on the LAN.
75
+
76
+ Returns:
77
+ ``(code, state)`` from the query string. ``state`` is ``None`` when
78
+ the provider does not echo it back.
79
+
80
+ Raises:
81
+ TimeoutError: No callback arrived within ``timeout`` seconds.
82
+ RuntimeError: The redirect carried an ``error`` query parameter.
83
+ """
84
+ server, actual_port, future = bind_callback_server(port=port, path=path, bind_host=bind_host)
85
+ try:
86
+ return await asyncio.wait_for(future, timeout=timeout)
87
+ finally:
88
+ server.shutdown()
89
+
90
+
91
+ def bind_callback_server(
92
+ *,
93
+ port: int = 0,
94
+ path: str = "/callback",
95
+ bind_host: str = "127.0.0.1",
96
+ ) -> tuple[HTTPServer, int, asyncio.Future[tuple[str, str | None]]]:
97
+ """Start the callback server and return (server, port, future).
98
+
99
+ Callers that need the bound port up front (to construct the redirect URI
100
+ before opening the browser) use this and then ``await future``. Callers
101
+ that already know the port should prefer :func:`wait_for_oauth_callback`.
102
+
103
+ The server runs in a daemon thread and shuts down on the first valid
104
+ callback or when ``server.shutdown()`` is called.
105
+ """
106
+ loop = asyncio.get_running_loop()
107
+ future: asyncio.Future[tuple[str, str | None]] = loop.create_future()
108
+
109
+ class _Handler(BaseHTTPRequestHandler):
110
+ def do_GET(self) -> None: # noqa: N802 — stdlib API
111
+ parsed = urllib.parse.urlparse(self.path)
112
+ if parsed.path != path:
113
+ self.send_response(404)
114
+ self.end_headers()
115
+ return
116
+ qs = urllib.parse.parse_qs(parsed.query)
117
+ err = qs.get("error", [None])[0]
118
+ if err:
119
+ desc = qs.get("error_description", [""])[0]
120
+ msg = f"{err}: {desc}".strip(": ")
121
+ self.send_response(400)
122
+ self.send_header("Content-Type", "text/html")
123
+ self.end_headers()
124
+ self.wfile.write(_HTML_ERROR % msg.encode("utf-8", "replace"))
125
+ if not future.done():
126
+ loop.call_soon_threadsafe(
127
+ future.set_exception, RuntimeError(f"OAuth callback error: {msg}")
128
+ )
129
+ return
130
+ code = qs.get("code", [None])[0]
131
+ state = qs.get("state", [None])[0]
132
+ if not code:
133
+ self.send_response(400)
134
+ self.end_headers()
135
+ return
136
+ self.send_response(200)
137
+ self.send_header("Content-Type", "text/html")
138
+ self.end_headers()
139
+ self.wfile.write(_HTML_OK)
140
+ if not future.done():
141
+ loop.call_soon_threadsafe(future.set_result, (code, state))
142
+
143
+ def log_message(self, *_args: Any) -> None: # silence stdlib's stderr noise
144
+ return
145
+
146
+ server = HTTPServer((bind_host, port), _Handler)
147
+ actual_port = server.server_address[1]
148
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
149
+ thread.start()
150
+ return server, actual_port, future
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "react-agent-harness"
7
- version = "0.5.0"
7
+ version = "0.5.2"
8
8
  description = "Multi-agent LLM orchestration: hybrid DAG planning, two-tier memory, streaming"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: react-agent-harness
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Multi-agent LLM orchestration: hybrid DAG planning, two-tier memory, streaming
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -11,6 +11,7 @@ harness/console.py
11
11
  harness/events.py
12
12
  harness/executor_bridge.py
13
13
  harness/hitl.py
14
+ harness/oauth_browser.py
14
15
  harness/otel.py
15
16
  harness/runtime.py
16
17
  harness/steering.py
@@ -50,6 +51,7 @@ tests/test_llm_auth.py
50
51
  tests/test_mcp_adapter.py
51
52
  tests/test_mcp_auth.py
52
53
  tests/test_memory.py
54
+ tests/test_oauth_browser.py
53
55
  tests/test_openai_codex_llm.py
54
56
  tests/test_openai_llm.py
55
57
  tests/test_orchestrator.py