react-agent-harness 0.2.0__tar.gz → 0.3.0__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 (67) hide show
  1. {react_agent_harness-0.2.0/react_agent_harness.egg-info → react_agent_harness-0.3.0}/PKG-INFO +1 -1
  2. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/README.md +97 -0
  3. react_agent_harness-0.3.0/harness/cli.py +137 -0
  4. react_agent_harness-0.3.0/harness/llm/__init__.py +19 -0
  5. react_agent_harness-0.3.0/harness/llm/_streaming.py +56 -0
  6. react_agent_harness-0.3.0/harness/llm/auth.py +610 -0
  7. react_agent_harness-0.3.0/harness/llm/claude_code.py +312 -0
  8. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/llm/openai.py +11 -5
  9. react_agent_harness-0.3.0/harness/llm/openai_codex.py +283 -0
  10. react_agent_harness-0.3.0/harness/utils.py +102 -0
  11. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/pyproject.toml +4 -1
  12. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0/react_agent_harness.egg-info}/PKG-INFO +1 -1
  13. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/react_agent_harness.egg-info/SOURCES.txt +11 -0
  14. react_agent_harness-0.3.0/react_agent_harness.egg-info/entry_points.txt +2 -0
  15. react_agent_harness-0.3.0/tests/test_claude_code_llm.py +265 -0
  16. react_agent_harness-0.3.0/tests/test_cli.py +69 -0
  17. react_agent_harness-0.3.0/tests/test_llm_auth.py +297 -0
  18. react_agent_harness-0.3.0/tests/test_openai_codex_llm.py +204 -0
  19. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_openai_llm.py +3 -2
  20. react_agent_harness-0.3.0/tests/test_utils.py +96 -0
  21. react_agent_harness-0.2.0/harness/utils.py +0 -46
  22. react_agent_harness-0.2.0/tools/builtin/__init__.py +0 -0
  23. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/LICENSE +0 -0
  24. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/agents/__init__.py +0 -0
  25. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/agents/base.py +0 -0
  26. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/__init__.py +0 -0
  27. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/annotation.py +0 -0
  28. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/checkpoint.py +0 -0
  29. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/events.py +0 -0
  30. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/executor_bridge.py +0 -0
  31. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/hitl.py +0 -0
  32. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/otel.py +0 -0
  33. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/runtime.py +0 -0
  34. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/steering.py +0 -0
  35. {react_agent_harness-0.2.0/harness/llm → react_agent_harness-0.3.0/memory}/__init__.py +0 -0
  36. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/memory/episodic_lance.py +0 -0
  37. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/memory/manager.py +0 -0
  38. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/memory/redis_store.py +0 -0
  39. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/memory/stores.py +0 -0
  40. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/memory/working.py +0 -0
  41. {react_agent_harness-0.2.0/memory → react_agent_harness-0.3.0/orchestrator}/__init__.py +0 -0
  42. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/orchestrator/planner.py +0 -0
  43. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/react_agent_harness.egg-info/dependency_links.txt +0 -0
  44. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/react_agent_harness.egg-info/requires.txt +0 -0
  45. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/react_agent_harness.egg-info/top_level.txt +0 -0
  46. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/setup.cfg +0 -0
  47. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_agents_base.py +0 -0
  48. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_annotation.py +0 -0
  49. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_checkpoint_resume.py +0 -0
  50. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_executor_bridge.py +0 -0
  51. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_http_fetch.py +0 -0
  52. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_mcp_adapter.py +0 -0
  53. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_memory.py +0 -0
  54. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_orchestrator.py +0 -0
  55. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_otel.py +0 -0
  56. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_parse_action_json.py +0 -0
  57. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_redis_store.py +0 -0
  58. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_steering.py +0 -0
  59. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_streaming.py +0 -0
  60. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_vision.py +0 -0
  61. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_working_memory.py +0 -0
  62. {react_agent_harness-0.2.0/orchestrator → react_agent_harness-0.3.0/tools}/__init__.py +0 -0
  63. {react_agent_harness-0.2.0/tools → react_agent_harness-0.3.0/tools/builtin}/__init__.py +0 -0
  64. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tools/builtin/fetch_image.py +0 -0
  65. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tools/builtin/http_fetch.py +0 -0
  66. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tools/mcp/__init__.py +0 -0
  67. {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tools/mcp/adapter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: react-agent-harness
3
- Version: 0.2.0
3
+ Version: 0.3.0
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
@@ -74,6 +74,7 @@ explicit control.
74
74
  | `examples/executor_bridge_demo.py` | `ExecutorBridge` backends side-by-side: allowlist, env scrubbing, Docker network/fs isolation, timeout, positional-arg tools. | `ah-executor` and/or Docker |
75
75
  | `examples/durable_memory_demo.py` | Redis (semantic) + LanceDB (episodic) memory persistence across two related goals. | `OPENAI_API_KEY`, `[openai,redis,lance]`, Redis reachable |
76
76
  | `examples/mcp_demo.py` | Connects to an MCP filesystem server and gives the agent its tools. | `OPENAI_API_KEY`, `[openai,mcp]`, `npx` |
77
+ | `examples/subscription_auth_demo.py` | Runs an agent through subscription-backed providers: direct `openai-codex` OAuth or direct `claude-code` OAuth. | `agent-harness login openai-codex` or `agent-harness login claude-code` |
77
78
 
78
79
  ## Adding a new domain (3 steps)
79
80
 
@@ -109,6 +110,102 @@ llm = OpenAILLM(model="gpt-4o-mini") # reads OPENAI_API_KEY from
109
110
  runtime = AgentRuntime(..., llm=llm)
110
111
  ```
111
112
 
113
+ Credential-backed adapters can also plug into the same contract. This is the
114
+ shape used for provider-specific subscription or OAuth flows without teaching
115
+ agents about auth:
116
+
117
+ ```bash
118
+ agent-harness login openai-codex
119
+ agent-harness auth status openai-codex
120
+ agent-harness login claude-code
121
+ agent-harness auth status claude-code
122
+ ```
123
+
124
+ > **⚠️ Subscription adapters are experimental — use the metered API in production.**
125
+ >
126
+ > `OpenAICodexLLM` and `ClaudeCodeLLM` bridge **ChatGPT / Claude
127
+ > subscription OAuth credentials** into the harness by talking to
128
+ > internal CLI endpoints with CLI-shaped User-Agent and billing headers.
129
+ > This route:
130
+ >
131
+ > - **May violate OpenAI's and Anthropic's Terms of Service.** Both
132
+ > providers prohibit using subscription accounts (ChatGPT Plus/Pro,
133
+ > Claude Pro/Max) for arbitrary programmatic access — subscriptions
134
+ > price for the official CLI's intended use only.
135
+ > - **May result in account suspension** if abuse detection classifies
136
+ > harness traffic as misuse.
137
+ > - **Depends on undocumented internal endpoints**
138
+ > (`/backend-api/codex/responses`, the Anthropic Messages API with
139
+ > `claude-code-*` beta flags) that providers can change or revoke at
140
+ > any time.
141
+ >
142
+ > **Use these adapters only for personal research on accounts you own.**
143
+ > Do not use them to serve other users. For anything else, prefer the
144
+ > metered API path:
145
+ >
146
+ > - `OpenAILLM` with `OPENAI_API_KEY` (optionally routed through a
147
+ > gateway like LiteLLM/Helicone for cost headers).
148
+ > - The standard Anthropic Messages API with an Anthropic API key.
149
+
150
+ Direct `openai-codex` OAuth follows the Codex/Pi-style ChatGPT
151
+ subscription route rather than the stable OpenAI Platform API. The
152
+ Codex OAuth client id can be overridden with
153
+ `AGENT_HARNESS_OPENAI_CODEX_CLIENT_ID`.
154
+
155
+ ```python
156
+ from harness.llm.openai_codex import OpenAICodexLLM
157
+
158
+ llm = OpenAICodexLLM(
159
+ model="gpt-5.5",
160
+ auth_file="~/.agent-harness/auth/auth.json", # Pi-shaped openai-codex OAuth entry
161
+ )
162
+ runtime = AgentRuntime(..., llm=llm)
163
+ ```
164
+
165
+ `OpenAICodexLLM` calls the Codex backend directly
166
+ (`https://chatgpt.com/backend-api/codex/responses`) with OAuth credentials.
167
+ The stable fallback remains `OpenAILLM` with `OPENAI_API_KEY`.
168
+
169
+ For Claude Code-style setups, use `ClaudeCodeLLM` with Claude Pro/Max OAuth
170
+ credentials stored in the same auth file. It calls the Anthropic Messages API
171
+ directly with Claude-Code-compatible OAuth headers:
172
+
173
+ ```bash
174
+ agent-harness login claude-code
175
+ python examples/subscription_auth_demo.py claude-code
176
+ ```
177
+
178
+ ```python
179
+ from harness.llm.claude_code import ClaudeCodeLLM
180
+
181
+ llm = ClaudeCodeLLM(
182
+ model="claude-sonnet-4-6",
183
+ auth_file="~/.agent-harness/auth/auth.json",
184
+ )
185
+ ```
186
+
187
+ `ClaudeCodeLLM` reads a `claude-code` OAuth entry, refreshes it automatically
188
+ when expired, and retries once after `401`/`403`. This mirrors Pi's Claude
189
+ Pro/Max extension approach rather than shelling out to the Claude CLI. The
190
+ default model is the current canonical Sonnet release ID, `claude-sonnet-4-6`;
191
+ set `CLAUDE_CODE_MODEL` or pass `model="claude-opus-4-7"` to choose another
192
+ model.
193
+
194
+ Both adapters stream incrementally — `stream_complete()` yields each
195
+ SSE delta token as it arrives, and `complete()` consumes the same
196
+ stream and returns the concatenated text once finished. Cost / token
197
+ usage is captured from the final stream event into `last_usage`.
198
+
199
+ The Claude billing header's `cc_version` is read from
200
+ `CLAUDE_CODE_VERSION` (env) or from `claude --version` if the CLI is
201
+ installed; falls back to `unknown` otherwise. Pinning a specific
202
+ version with `CLAUDE_CODE_VERSION=2.1.150` is recommended if you want
203
+ stable behavior across CLI upgrades.
204
+
205
+ Do not copy browser/app refresh tokens into repo files. Store OAuth auth files
206
+ under `~/.agent-harness/auth` or reuse an existing Pi auth file with private
207
+ file permissions (`0600`).
208
+
112
209
  To use Anthropic / Gemini / Ollama / a local SGLang or vLLM server / anything
113
210
  else — write a 30-line adapter implementing those two methods. See
114
211
  `harness/llm/openai.py` for the reference shape; the harness never imports a
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from harness.llm.auth import (
11
+ AnthropicClaudeCodeOAuthClient,
12
+ AuthFileOAuthProvider,
13
+ OAuthCredential,
14
+ OpenAICodexOAuthClient,
15
+ default_auth_file,
16
+ )
17
+
18
+ PROVIDERS = ["openai-codex", "claude-code"]
19
+
20
+
21
+ def main() -> int:
22
+ parser = argparse.ArgumentParser(prog="agent-harness", description="agent-harness utilities")
23
+ sub = parser.add_subparsers(dest="command", required=True)
24
+
25
+ login = sub.add_parser("login", help="log in to a provider")
26
+ login.add_argument("provider", choices=PROVIDERS)
27
+ login.add_argument("--auth-file", default=str(default_auth_file()))
28
+
29
+ status = sub.add_parser("auth", help="inspect or clear provider auth")
30
+ status_sub = status.add_subparsers(dest="auth_command", required=True)
31
+ status_cmd = status_sub.add_parser("status", help="show auth status")
32
+ status_cmd.add_argument("provider", choices=PROVIDERS)
33
+ status_cmd.add_argument("--auth-file", default=str(default_auth_file()))
34
+ logout_cmd = status_sub.add_parser("logout", help="remove auth credentials")
35
+ logout_cmd.add_argument("provider", choices=PROVIDERS)
36
+ logout_cmd.add_argument("--auth-file", default=str(default_auth_file()))
37
+
38
+ args = parser.parse_args()
39
+ try:
40
+ if args.command == "login":
41
+ if args.provider == "openai-codex":
42
+ return asyncio.run(_login_openai_codex(Path(args.auth_file).expanduser()))
43
+ if args.provider == "claude-code":
44
+ return asyncio.run(_login_claude_code(Path(args.auth_file).expanduser()))
45
+ if args.command == "auth" and args.auth_command == "status":
46
+ if args.provider == "openai-codex":
47
+ return _status_oauth_provider(Path(args.auth_file).expanduser(), "openai-codex")
48
+ if args.provider == "claude-code":
49
+ return _status_oauth_provider(Path(args.auth_file).expanduser(), "claude-code")
50
+ if args.command == "auth" and args.auth_command == "logout":
51
+ if args.provider == "openai-codex":
52
+ return _logout_oauth_provider(Path(args.auth_file).expanduser(), "openai-codex")
53
+ if args.provider == "claude-code":
54
+ return _logout_oauth_provider(Path(args.auth_file).expanduser(), "claude-code")
55
+ except Exception as e:
56
+ print(f"agent-harness: {e}", file=sys.stderr)
57
+ return 1
58
+ parser.error("unsupported command")
59
+ return 2
60
+
61
+
62
+ async def _login_openai_codex(path: Path) -> int:
63
+ client = OpenAICodexOAuthClient()
64
+ try:
65
+ device = await client.request_device_code()
66
+ print("OpenAI Codex login")
67
+ print(f"Open: {device.verification_uri}")
68
+ print(f"Code: {device.user_code}")
69
+ print("Waiting for authorization...")
70
+ cred = await client.poll_device_code(device)
71
+ finally:
72
+ await client.aclose()
73
+ _write_oauth_credential(path, cred)
74
+ print(f"Logged in to openai-codex. Credentials saved to {path}")
75
+ return 0
76
+
77
+
78
+ async def _login_claude_code(path: Path) -> int:
79
+ client = AnthropicClaudeCodeOAuthClient()
80
+ try:
81
+ login = client.begin_login()
82
+ print("Claude Code login")
83
+ print(f"Open: {login.url}")
84
+ print("Paste the final callback URL, or the code#state value.")
85
+ callback_input = input("Callback: ")
86
+ cred = await client.finish_login(login, callback_input)
87
+ finally:
88
+ await client.aclose()
89
+ _write_oauth_credential(path, cred)
90
+ print(f"Logged in to claude-code. Credentials saved to {path}")
91
+ return 0
92
+
93
+
94
+ def _status_oauth_provider(path: Path, provider_name: str) -> int:
95
+ provider = AuthFileOAuthProvider(path, provider=provider_name)
96
+ try:
97
+ cred = provider._read_credential()
98
+ except FileNotFoundError:
99
+ print(f"Not logged in: {path} does not exist")
100
+ return 1
101
+ except Exception as e:
102
+ print(f"Not logged in: {e}")
103
+ return 1
104
+ status = {
105
+ "provider": provider_name,
106
+ "auth_file": str(path),
107
+ "account_id": cred.account_id,
108
+ "expires_at": cred.expires_at.isoformat() if cred.expires_at else None,
109
+ "expired": cred.is_expired(),
110
+ }
111
+ print(json.dumps(status, indent=2))
112
+ return 0
113
+
114
+
115
+ def _logout_oauth_provider(path: Path, provider_name: str) -> int:
116
+ provider = AuthFileOAuthProvider(
117
+ path, provider=provider_name, require_private_permissions=False
118
+ )
119
+ provider.clear()
120
+ print(f"Removed {provider_name} credentials from {path}")
121
+ return 0
122
+
123
+
124
+ def _write_oauth_credential(path: Path, cred: OAuthCredential) -> None:
125
+ provider = AuthFileOAuthProvider(
126
+ path, provider=cred.provider, require_private_permissions=False
127
+ )
128
+ path.parent.mkdir(parents=True, exist_ok=True)
129
+ if not path.exists():
130
+ path.write_text("{}")
131
+ if os.name != "nt":
132
+ path.chmod(0o600)
133
+ provider._write_credential(cred)
134
+
135
+
136
+ if __name__ == "__main__":
137
+ raise SystemExit(main())
@@ -0,0 +1,19 @@
1
+ """LLM adapter helpers."""
2
+
3
+ from harness.llm.auth import (
4
+ AnthropicClaudeCodeOAuthClient,
5
+ AuthFileOAuthProvider,
6
+ OAuthCredential,
7
+ OpenAICodexOAuthClient,
8
+ )
9
+ from harness.llm.claude_code import ClaudeCodeLLM
10
+ from harness.llm.openai_codex import OpenAICodexLLM
11
+
12
+ __all__ = [
13
+ "AnthropicClaudeCodeOAuthClient",
14
+ "AuthFileOAuthProvider",
15
+ "ClaudeCodeLLM",
16
+ "OAuthCredential",
17
+ "OpenAICodexLLM",
18
+ "OpenAICodexOAuthClient",
19
+ ]
@@ -0,0 +1,56 @@
1
+ """Shared SSE helpers for streaming-capable LLM adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncGenerator
6
+ from typing import Any
7
+
8
+
9
+ async def aiter_sse_events(response: Any) -> AsyncGenerator[tuple[str, str], None]:
10
+ """Yield (event_type, data) pairs from an SSE response.
11
+
12
+ Parses the standard `event:` / `data:` line format. Blank lines
13
+ terminate events. The default event type for unlabelled events is
14
+ `"message"`. Trailing buffered data (no terminating blank line) is
15
+ flushed when the stream ends.
16
+ """
17
+ current_event = "message"
18
+ data_lines: list[str] = []
19
+ async for raw_line in response.aiter_lines():
20
+ line = raw_line.rstrip("\r")
21
+ if not line:
22
+ if data_lines:
23
+ yield current_event, "\n".join(data_lines)
24
+ current_event = "message"
25
+ data_lines = []
26
+ continue
27
+ if line.startswith("event:"):
28
+ current_event = line[len("event:") :].strip()
29
+ elif line.startswith("data:"):
30
+ data_lines.append(line[len("data:") :].strip())
31
+ if data_lines:
32
+ yield current_event, "\n".join(data_lines)
33
+
34
+
35
+ async def read_error_body(response: Any) -> bytes:
36
+ """Drain the body of an error response, returning at most 4 KiB."""
37
+ out: list[bytes] = []
38
+ total = 0
39
+ async for chunk in response.aiter_bytes():
40
+ if total >= 4096:
41
+ break
42
+ out.append(chunk)
43
+ total += len(chunk)
44
+ return b"".join(out)[:4096]
45
+
46
+
47
+ def format_streaming_error(status_code: int, body: bytes, *, provider: str) -> str:
48
+ """Build a user-facing error message from an error response body.
49
+
50
+ Truncates aggressively because error bodies sometimes echo request
51
+ payloads — we don't want bearer tokens or full prompts in tracebacks.
52
+ """
53
+ text = body.decode(errors="replace").strip()
54
+ if not text:
55
+ return f"{provider} backend returned HTTP {status_code}"
56
+ return f"{provider} backend returned {status_code}: {text[:500]}"