agentibridge 0.2.0__py3-none-any.whl
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.
- agentibridge/__init__.py +3 -0
- agentibridge/__main__.py +5 -0
- agentibridge/claude_runner.py +327 -0
- agentibridge/cli.py +1218 -0
- agentibridge/collector.py +148 -0
- agentibridge/config.py +105 -0
- agentibridge/data/docker-compose.yml +109 -0
- agentibridge/dispatch.py +278 -0
- agentibridge/dispatch_bridge.py +333 -0
- agentibridge/embeddings.py +368 -0
- agentibridge/llm_client.py +147 -0
- agentibridge/logging.py +51 -0
- agentibridge/oauth_provider.py +356 -0
- agentibridge/parser.py +484 -0
- agentibridge/pg_client.py +100 -0
- agentibridge/redis_client.py +68 -0
- agentibridge/server.py +632 -0
- agentibridge/store.py +436 -0
- agentibridge/transport.py +439 -0
- agentibridge-0.2.0.dist-info/METADATA +422 -0
- agentibridge-0.2.0.dist-info/RECORD +25 -0
- agentibridge-0.2.0.dist-info/WHEEL +5 -0
- agentibridge-0.2.0.dist-info/entry_points.txt +2 -0
- agentibridge-0.2.0.dist-info/licenses/LICENSE +21 -0
- agentibridge-0.2.0.dist-info/top_level.txt +1 -0
agentibridge/__init__.py
ADDED
agentibridge/__main__.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Run Claude CLI directly via subprocess, or proxy via HTTP bridge.
|
|
2
|
+
|
|
3
|
+
Replaces the old completions.py module that called an external agenticore
|
|
4
|
+
/completions API. Now AgentiBridge is fully standalone — it shells out to
|
|
5
|
+
the ``claude`` CLI binary which must be on PATH (or set via CLAUDE_BINARY).
|
|
6
|
+
|
|
7
|
+
When ``CLAUDE_DISPATCH_URL`` is set, requests are proxied to a host-side
|
|
8
|
+
dispatch bridge (see :mod:`agentibridge.dispatch_bridge`) instead of
|
|
9
|
+
spawning ``claude`` locally. This is used when running inside Docker.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
from agentibridge.claude_runner import run_claude_sync
|
|
13
|
+
|
|
14
|
+
result = run_claude_sync("Summarize this code")
|
|
15
|
+
if result["success"]:
|
|
16
|
+
print(result["result"])
|
|
17
|
+
|
|
18
|
+
Env vars:
|
|
19
|
+
CLAUDE_BINARY — path to claude CLI (default: "claude")
|
|
20
|
+
CLAUDE_DISPATCH_MODEL — model for dispatch (default: "sonnet")
|
|
21
|
+
CLAUDE_DISPATCH_TIMEOUT — timeout in seconds (default: 300)
|
|
22
|
+
CLAUDE_DISPATCH_URL — bridge URL (empty = local mode)
|
|
23
|
+
DISPATCH_SECRET — shared secret for bridge auth
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
from dataclasses import asdict, dataclass
|
|
30
|
+
from typing import Any, Dict, Optional
|
|
31
|
+
|
|
32
|
+
from agentibridge.logging import log
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Configuration
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _claude_binary() -> str:
|
|
41
|
+
return os.environ.get("CLAUDE_BINARY", "claude")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _default_model() -> str:
|
|
45
|
+
return os.environ.get("CLAUDE_DISPATCH_MODEL", "sonnet")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _default_timeout() -> int:
|
|
49
|
+
return int(os.environ.get("CLAUDE_DISPATCH_TIMEOUT", "300"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _dispatch_url() -> str:
|
|
53
|
+
return os.environ.get("CLAUDE_DISPATCH_URL", "")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _dispatch_secret() -> str:
|
|
57
|
+
return os.environ.get("DISPATCH_SECRET", "")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Result dataclass
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ClaudeResult:
|
|
67
|
+
"""Result from a Claude CLI invocation."""
|
|
68
|
+
|
|
69
|
+
success: bool
|
|
70
|
+
result: Optional[str] = None
|
|
71
|
+
session_id: Optional[str] = None
|
|
72
|
+
exit_code: Optional[int] = None
|
|
73
|
+
duration_ms: Optional[int] = None
|
|
74
|
+
timed_out: bool = False
|
|
75
|
+
error: Optional[str] = None
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
78
|
+
return asdict(self)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Output parser
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_claude_output(raw: str) -> Dict[str, Any]:
|
|
87
|
+
"""Parse JSON output from ``claude --output-format json``.
|
|
88
|
+
|
|
89
|
+
The CLI emits a JSON object with fields like:
|
|
90
|
+
- result (str) — the final text answer
|
|
91
|
+
- session_id (str) — Claude session UUID
|
|
92
|
+
- cost_usd (float) — cost in USD
|
|
93
|
+
- duration_ms (int) — wall-clock time
|
|
94
|
+
- duration_api_ms (int) — API time
|
|
95
|
+
- is_error (bool)
|
|
96
|
+
|
|
97
|
+
Returns a flat dict; callers pick the keys they need.
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
data = json.loads(raw)
|
|
101
|
+
except (json.JSONDecodeError, TypeError):
|
|
102
|
+
return {"result": raw, "parse_error": True}
|
|
103
|
+
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# HTTP transport (container → host bridge)
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _run_claude_http(
|
|
113
|
+
dispatch_url: str,
|
|
114
|
+
prompt: str,
|
|
115
|
+
model: str,
|
|
116
|
+
timeout: int,
|
|
117
|
+
output_format: str,
|
|
118
|
+
resume_session_id: Optional[str] = None,
|
|
119
|
+
) -> ClaudeResult:
|
|
120
|
+
"""Proxy a dispatch request to the host-side bridge via HTTP.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
dispatch_url: Base URL of the dispatch bridge (e.g. http://host.docker.internal:8101).
|
|
124
|
+
prompt: The prompt/task text.
|
|
125
|
+
model: Model name.
|
|
126
|
+
timeout: Timeout in seconds for the Claude CLI execution.
|
|
127
|
+
output_format: CLI output format.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
ClaudeResult with parsed output.
|
|
131
|
+
"""
|
|
132
|
+
import httpx
|
|
133
|
+
|
|
134
|
+
secret = _dispatch_secret()
|
|
135
|
+
url = f"{dispatch_url.rstrip('/')}/dispatch"
|
|
136
|
+
# Give the bridge time to timeout first, then add buffer for HTTP overhead
|
|
137
|
+
http_timeout = timeout + 30
|
|
138
|
+
|
|
139
|
+
log("claude_runner: HTTP dispatch", {"url": url, "model": model, "prompt_len": len(prompt)})
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
async with httpx.AsyncClient(timeout=http_timeout) as client:
|
|
143
|
+
resp = await client.post(
|
|
144
|
+
url,
|
|
145
|
+
json={
|
|
146
|
+
"prompt": prompt,
|
|
147
|
+
"model": model,
|
|
148
|
+
"timeout": timeout,
|
|
149
|
+
"output_format": output_format,
|
|
150
|
+
"resume_session_id": resume_session_id or "",
|
|
151
|
+
},
|
|
152
|
+
headers={"X-Dispatch-Secret": secret},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if resp.status_code == 401:
|
|
156
|
+
return ClaudeResult(success=False, error="Dispatch bridge auth failed (401)")
|
|
157
|
+
|
|
158
|
+
if resp.status_code != 200:
|
|
159
|
+
return ClaudeResult(
|
|
160
|
+
success=False,
|
|
161
|
+
error=f"Dispatch bridge returned HTTP {resp.status_code}: {resp.text[:500]}",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
data = resp.json()
|
|
165
|
+
return ClaudeResult(
|
|
166
|
+
success=data.get("success", False),
|
|
167
|
+
result=data.get("result"),
|
|
168
|
+
session_id=data.get("session_id"),
|
|
169
|
+
exit_code=data.get("exit_code"),
|
|
170
|
+
duration_ms=data.get("duration_ms"),
|
|
171
|
+
timed_out=data.get("timed_out", False),
|
|
172
|
+
error=data.get("error"),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
except httpx.ConnectError as e:
|
|
176
|
+
msg = f"Cannot connect to dispatch bridge at {dispatch_url}: {e}"
|
|
177
|
+
log("claude_runner: bridge connect error", {"url": dispatch_url, "error": str(e)})
|
|
178
|
+
return ClaudeResult(success=False, error=msg)
|
|
179
|
+
|
|
180
|
+
except httpx.TimeoutException:
|
|
181
|
+
log("claude_runner: bridge timeout", {"url": dispatch_url, "timeout": http_timeout})
|
|
182
|
+
return ClaudeResult(success=False, timed_out=True, error=f"Dispatch bridge timed out after {http_timeout}s")
|
|
183
|
+
|
|
184
|
+
except Exception as e:
|
|
185
|
+
log("claude_runner: bridge unexpected error", {"error": str(e)})
|
|
186
|
+
return ClaudeResult(success=False, error=f"Dispatch bridge error: {e}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# Async runner
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def run_claude(
|
|
195
|
+
prompt: str,
|
|
196
|
+
model: Optional[str] = None,
|
|
197
|
+
timeout: Optional[int] = None,
|
|
198
|
+
cwd: Optional[str] = None,
|
|
199
|
+
output_format: str = "json",
|
|
200
|
+
resume_session_id: Optional[str] = None,
|
|
201
|
+
) -> ClaudeResult:
|
|
202
|
+
"""Run the ``claude`` CLI and return the parsed result.
|
|
203
|
+
|
|
204
|
+
If ``CLAUDE_DISPATCH_URL`` is set, proxies the request to the host-side
|
|
205
|
+
dispatch bridge via HTTP. Otherwise, runs the CLI as a local subprocess.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
prompt: The prompt/task text.
|
|
209
|
+
model: Model name (default: CLAUDE_DISPATCH_MODEL or "sonnet").
|
|
210
|
+
timeout: Timeout in seconds (default: CLAUDE_DISPATCH_TIMEOUT or 300).
|
|
211
|
+
cwd: Working directory for the subprocess.
|
|
212
|
+
output_format: CLI output format (default: "json").
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
ClaudeResult with parsed output.
|
|
216
|
+
"""
|
|
217
|
+
model = model or _default_model()
|
|
218
|
+
timeout = timeout or _default_timeout()
|
|
219
|
+
|
|
220
|
+
# Route to HTTP bridge if configured
|
|
221
|
+
dispatch_url = _dispatch_url()
|
|
222
|
+
if dispatch_url:
|
|
223
|
+
return await _run_claude_http(dispatch_url, prompt, model, timeout, output_format, resume_session_id)
|
|
224
|
+
|
|
225
|
+
# Local subprocess mode
|
|
226
|
+
binary = _claude_binary()
|
|
227
|
+
if resume_session_id:
|
|
228
|
+
cmd = [
|
|
229
|
+
binary,
|
|
230
|
+
"--dangerously-skip-permissions",
|
|
231
|
+
"--model",
|
|
232
|
+
model,
|
|
233
|
+
"--output-format",
|
|
234
|
+
output_format,
|
|
235
|
+
"--resume",
|
|
236
|
+
resume_session_id,
|
|
237
|
+
"--print",
|
|
238
|
+
prompt,
|
|
239
|
+
]
|
|
240
|
+
else:
|
|
241
|
+
cmd = [
|
|
242
|
+
binary,
|
|
243
|
+
"--dangerously-skip-permissions",
|
|
244
|
+
"--model",
|
|
245
|
+
model,
|
|
246
|
+
"--output-format",
|
|
247
|
+
output_format,
|
|
248
|
+
"-p",
|
|
249
|
+
prompt,
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
log("claude_runner: starting", {"model": model, "prompt_len": len(prompt)})
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
proc = await asyncio.create_subprocess_exec(
|
|
256
|
+
*cmd,
|
|
257
|
+
stdin=asyncio.subprocess.PIPE,
|
|
258
|
+
stdout=asyncio.subprocess.PIPE,
|
|
259
|
+
stderr=asyncio.subprocess.PIPE,
|
|
260
|
+
cwd=cwd,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
264
|
+
stdout_text = stdout.decode("utf-8", errors="replace") if stdout else ""
|
|
265
|
+
stderr_text = stderr.decode("utf-8", errors="replace") if stderr else ""
|
|
266
|
+
|
|
267
|
+
if proc.returncode != 0:
|
|
268
|
+
log("claude_runner: non-zero exit", {"exit_code": proc.returncode, "stderr": stderr_text[:500]})
|
|
269
|
+
return ClaudeResult(
|
|
270
|
+
success=False,
|
|
271
|
+
exit_code=proc.returncode,
|
|
272
|
+
error=stderr_text[:2000] or f"Exit code {proc.returncode}",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
parsed = parse_claude_output(stdout_text)
|
|
276
|
+
|
|
277
|
+
return ClaudeResult(
|
|
278
|
+
success=not parsed.get("is_error", False),
|
|
279
|
+
result=parsed.get("result", stdout_text),
|
|
280
|
+
session_id=parsed.get("session_id"),
|
|
281
|
+
duration_ms=parsed.get("duration_ms"),
|
|
282
|
+
exit_code=proc.returncode,
|
|
283
|
+
error=parsed.get("result") if parsed.get("is_error") else None,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
except asyncio.TimeoutError:
|
|
287
|
+
log("claude_runner: timeout", {"timeout": timeout})
|
|
288
|
+
return ClaudeResult(success=False, timed_out=True, error=f"Timed out after {timeout}s")
|
|
289
|
+
|
|
290
|
+
except FileNotFoundError:
|
|
291
|
+
msg = f"Claude CLI binary not found: {binary}"
|
|
292
|
+
log("claude_runner: binary not found", {"binary": binary})
|
|
293
|
+
return ClaudeResult(success=False, error=msg)
|
|
294
|
+
|
|
295
|
+
except Exception as e:
|
|
296
|
+
log("claude_runner: unexpected error", {"error": str(e)})
|
|
297
|
+
return ClaudeResult(success=False, error=str(e))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
# Sync wrapper
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def run_claude_sync(prompt: str, **kwargs) -> ClaudeResult:
|
|
306
|
+
"""Synchronous wrapper around :func:`run_claude`.
|
|
307
|
+
|
|
308
|
+
If called from within a running event loop (e.g. MCP server context),
|
|
309
|
+
runs the coroutine in a separate thread to avoid the
|
|
310
|
+
"Cannot run the event loop while another loop is running" error.
|
|
311
|
+
Prefer calling :func:`run_claude` directly with ``await`` when possible.
|
|
312
|
+
"""
|
|
313
|
+
import concurrent.futures
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
asyncio.get_running_loop()
|
|
317
|
+
# Already inside an event loop — run in a thread with its own loop
|
|
318
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
319
|
+
future = pool.submit(asyncio.run, run_claude(prompt, **kwargs))
|
|
320
|
+
return future.result()
|
|
321
|
+
except RuntimeError:
|
|
322
|
+
# No running loop — safe to create one
|
|
323
|
+
loop = asyncio.new_event_loop()
|
|
324
|
+
try:
|
|
325
|
+
return loop.run_until_complete(run_claude(prompt, **kwargs))
|
|
326
|
+
finally:
|
|
327
|
+
loop.close()
|