raysurfer 0.6.3__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.
raysurfer/client.py ADDED
@@ -0,0 +1,1009 @@
1
+ """RaySurfer SDK client"""
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from raysurfer._version import __version__
10
+ from raysurfer.exceptions import (
11
+ APIError,
12
+ AuthenticationError,
13
+ CacheUnavailableError,
14
+ RateLimitError,
15
+ )
16
+ from raysurfer.sdk_types import CodeFile, GetCodeFilesResponse
17
+ from raysurfer.types import (
18
+ AgentReview,
19
+ AgentVerdict,
20
+ AlternativeCandidate,
21
+ AutoReviewResponse,
22
+ BestMatch,
23
+ CodeBlock,
24
+ CodeBlockMatch,
25
+ ExecutionIO,
26
+ ExecutionRecord,
27
+ ExecutionState,
28
+ FewShotExample,
29
+ FileWritten,
30
+ RetrieveBestResponse,
31
+ RetrieveCodeBlockResponse,
32
+ RetrieveExecutionsResponse,
33
+ SnipsDesired,
34
+ StoreCodeBlockResponse,
35
+ StoreExecutionResponse,
36
+ SubmitExecutionResultResponse,
37
+ TaskPattern,
38
+ )
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ DEFAULT_BASE_URL = "https://api.raysurfer.com"
43
+
44
+ # Maximum number of retry attempts for transient failures
45
+ MAX_RETRIES = 3
46
+ # Base delay in seconds for exponential backoff
47
+ RETRY_BASE_DELAY = 0.5
48
+ # HTTP status codes that should trigger a retry
49
+ RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
50
+
51
+
52
+ class AsyncRaySurfer:
53
+ """Async client for RaySurfer API"""
54
+
55
+ def __init__(
56
+ self,
57
+ api_key: str | None = None,
58
+ base_url: str = DEFAULT_BASE_URL,
59
+ timeout: float = 60.0,
60
+ organization_id: str | None = None,
61
+ workspace_id: str | None = None,
62
+ snips_desired: SnipsDesired | str | None = None,
63
+ namespace: str | None = None,
64
+ ):
65
+ """
66
+ Initialize the RaySurfer async client.
67
+
68
+ Args:
69
+ api_key: RaySurfer API key (or set RAYSURFER_API_KEY env var)
70
+ base_url: API base URL
71
+ timeout: Request timeout in seconds
72
+ organization_id: Optional organization ID for dedicated namespace (team/enterprise)
73
+ workspace_id: Optional workspace ID for client-specific namespace (enterprise only)
74
+ snips_desired: Scope of private snippets - "company" (Team/Enterprise) or "client" (Enterprise only)
75
+ namespace: Custom namespace for code storage/retrieval (overrides org-based namespacing)
76
+ """
77
+ self.api_key = api_key
78
+ self.base_url = base_url.rstrip("/")
79
+ self.timeout = timeout
80
+ self.organization_id = organization_id
81
+ self.workspace_id = workspace_id
82
+ self.namespace = namespace
83
+ # Convert string to SnipsDesired if needed
84
+ if isinstance(snips_desired, str):
85
+ self.snips_desired = SnipsDesired(snips_desired) if snips_desired else None
86
+ else:
87
+ self.snips_desired = snips_desired
88
+ self._client: httpx.AsyncClient | None = None
89
+
90
+ async def _get_client(self) -> httpx.AsyncClient:
91
+ if self._client is None:
92
+ headers = {"Content-Type": "application/json"}
93
+ if self.api_key:
94
+ headers["Authorization"] = f"Bearer {self.api_key}"
95
+ # Add organization/workspace headers for namespace routing
96
+ if self.organization_id:
97
+ headers["X-Raysurfer-Org-Id"] = self.organization_id
98
+ if self.workspace_id:
99
+ headers["X-Raysurfer-Workspace-Id"] = self.workspace_id
100
+ # Add snippet retrieval scope headers
101
+ if self.snips_desired:
102
+ headers["X-Raysurfer-Snips-Desired"] = self.snips_desired.value
103
+ # Custom namespace override
104
+ if self.namespace:
105
+ headers["X-Raysurfer-Namespace"] = self.namespace
106
+ # SDK version for tracking
107
+ headers["X-Raysurfer-SDK-Version"] = f"python/{__version__}"
108
+ self._client = httpx.AsyncClient(
109
+ base_url=self.base_url,
110
+ headers=headers,
111
+ timeout=self.timeout,
112
+ )
113
+ return self._client
114
+
115
+ async def close(self) -> None:
116
+ if self._client:
117
+ await self._client.aclose()
118
+ self._client = None
119
+
120
+ async def __aenter__(self) -> "AsyncRaySurfer":
121
+ return self
122
+
123
+ async def __aexit__(self, *args: Any) -> None:
124
+ await self.close()
125
+
126
+ async def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
127
+ client = await self._get_client()
128
+ last_exception: Exception | None = None
129
+
130
+ for attempt in range(MAX_RETRIES):
131
+ try:
132
+ response = await client.request(method, path, **kwargs)
133
+
134
+ if response.status_code == 401:
135
+ raise AuthenticationError("Invalid API key")
136
+ if response.status_code == 429:
137
+ retry_after = response.headers.get("Retry-After")
138
+ delay = float(retry_after) if retry_after else RETRY_BASE_DELAY * (2**attempt)
139
+ if attempt < MAX_RETRIES - 1:
140
+ logger.warning(f"Rate limited, retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})")
141
+ await asyncio.sleep(delay)
142
+ continue
143
+ raise RateLimitError(retry_after=delay)
144
+ if response.status_code in RETRYABLE_STATUS_CODES:
145
+ if attempt < MAX_RETRIES - 1:
146
+ delay = RETRY_BASE_DELAY * (2**attempt)
147
+ logger.warning(
148
+ f"Server error {response.status_code}, retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})"
149
+ )
150
+ await asyncio.sleep(delay)
151
+ continue
152
+ if response.status_code >= 400:
153
+ raise APIError(response.text, status_code=response.status_code)
154
+
155
+ return response.json()
156
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
157
+ last_exception = e
158
+ if attempt < MAX_RETRIES - 1:
159
+ delay = RETRY_BASE_DELAY * (2**attempt)
160
+ logger.warning(
161
+ f"Network error: {e}, retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})"
162
+ )
163
+ await asyncio.sleep(delay)
164
+ continue
165
+ raise CacheUnavailableError(f"Failed to connect after {MAX_RETRIES} attempts: {e}") from e
166
+
167
+ # Should not reach here, but just in case
168
+ raise CacheUnavailableError(f"Request failed after {MAX_RETRIES} attempts") from last_exception
169
+
170
+ # =========================================================================
171
+ # Store API
172
+ # =========================================================================
173
+
174
+ async def store_code_block(
175
+ self,
176
+ name: str,
177
+ source: str,
178
+ entrypoint: str,
179
+ language: str,
180
+ description: str = "",
181
+ input_schema: dict[str, Any] | None = None,
182
+ output_schema: dict[str, Any] | None = None,
183
+ language_version: str | None = None,
184
+ dependencies: list[str] | None = None,
185
+ tags: list[str] | None = None,
186
+ capabilities: list[str] | None = None,
187
+ example_queries: list[str] | None = None,
188
+ ) -> StoreCodeBlockResponse:
189
+ """Store a new code block"""
190
+ data = {
191
+ "name": name,
192
+ "description": description,
193
+ "source": source,
194
+ "entrypoint": entrypoint,
195
+ "language": language,
196
+ "input_schema": input_schema or {},
197
+ "output_schema": output_schema or {},
198
+ "language_version": language_version,
199
+ "dependencies": dependencies or [],
200
+ "tags": tags or [],
201
+ "capabilities": capabilities or [],
202
+ "example_queries": example_queries,
203
+ }
204
+ result = await self._request("POST", "/api/store/code-block", json=data)
205
+ return StoreCodeBlockResponse(**result)
206
+
207
+ async def store_execution(
208
+ self,
209
+ code_block_id: str,
210
+ triggering_task: str,
211
+ input_data: dict[str, Any],
212
+ output_data: Any,
213
+ execution_state: ExecutionState = ExecutionState.COMPLETED,
214
+ duration_ms: int = 0,
215
+ error_message: str | None = None,
216
+ error_type: str | None = None,
217
+ verdict: AgentVerdict | None = None,
218
+ review: AgentReview | None = None,
219
+ ) -> StoreExecutionResponse:
220
+ """Store an execution record"""
221
+ io = ExecutionIO(
222
+ input_data=input_data,
223
+ output_data=output_data,
224
+ output_type=type(output_data).__name__,
225
+ )
226
+ data = {
227
+ "code_block_id": code_block_id,
228
+ "triggering_task": triggering_task,
229
+ "io": io.model_dump(),
230
+ "execution_state": execution_state.value,
231
+ "duration_ms": duration_ms,
232
+ "error_message": error_message,
233
+ "error_type": error_type,
234
+ "verdict": verdict.value if verdict else None,
235
+ "review": review.model_dump() if review else None,
236
+ }
237
+ result = await self._request("POST", "/api/store/execution", json=data)
238
+ return StoreExecutionResponse(**result)
239
+
240
+ async def upload_new_code_snips(
241
+ self,
242
+ task: str,
243
+ files_written: list[FileWritten],
244
+ succeeded: bool,
245
+ auto_vote: bool = True,
246
+ execution_logs: str | None = None,
247
+ ) -> SubmitExecutionResultResponse:
248
+ """
249
+ Upload new code snippets from an execution.
250
+
251
+ This is the simplified API for agent integrations. Just send:
252
+ - The task that was executed
253
+ - Files that were written during execution
254
+ - Whether the task succeeded
255
+ - Whether to auto-vote on stored blocks (default: True)
256
+ - Captured execution logs for vote context
257
+
258
+ Backend handles: entrypoint detection, tag extraction, language detection,
259
+ deduplication, quality checks, and storage.
260
+ """
261
+ data: dict[str, Any] = {
262
+ "task": task,
263
+ "files_written": [f.model_dump() for f in files_written],
264
+ "succeeded": succeeded,
265
+ "auto_vote": auto_vote,
266
+ }
267
+ if execution_logs is not None:
268
+ data["execution_logs"] = execution_logs
269
+ result = await self._request("POST", "/api/store/execution-result", json=data)
270
+ return SubmitExecutionResultResponse(**result)
271
+
272
+ # =========================================================================
273
+ # Retrieve API
274
+ # =========================================================================
275
+
276
+ async def get_code_snips(
277
+ self,
278
+ task: str,
279
+ top_k: int = 10,
280
+ min_verdict_score: float = 0.0,
281
+ ) -> RetrieveCodeBlockResponse:
282
+ """
283
+ Get cached code snippets for a task.
284
+
285
+ Searches for code blocks by task description using semantic search.
286
+ """
287
+ data = {
288
+ "task": task,
289
+ "top_k": top_k,
290
+ "min_verdict_score": min_verdict_score,
291
+ }
292
+ result = await self._request("POST", "/api/retrieve/code-blocks", json=data)
293
+
294
+ code_blocks = [
295
+ CodeBlockMatch(
296
+ code_block=CodeBlock(**cb["code_block"]),
297
+ score=cb["score"],
298
+ verdict_score=cb["verdict_score"],
299
+ thumbs_up=cb["thumbs_up"],
300
+ thumbs_down=cb["thumbs_down"],
301
+ recent_executions=cb.get("recent_executions", []),
302
+ )
303
+ for cb in result["code_blocks"]
304
+ ]
305
+ return RetrieveCodeBlockResponse(
306
+ code_blocks=code_blocks,
307
+ total_found=result["total_found"],
308
+ )
309
+
310
+ async def retrieve_best(
311
+ self,
312
+ task: str,
313
+ top_k: int = 10,
314
+ min_verdict_score: float = 0.0,
315
+ ) -> RetrieveBestResponse:
316
+ """Get the single best code block for a task using verdict-aware scoring"""
317
+ data = {
318
+ "task": task,
319
+ "top_k": top_k,
320
+ "min_verdict_score": min_verdict_score,
321
+ }
322
+ result = await self._request("POST", "/api/retrieve/best-for-task", json=data)
323
+
324
+ best_match = None
325
+ if result.get("best_match"):
326
+ bm = result["best_match"]
327
+ best_match = BestMatch(
328
+ code_block=CodeBlock(**bm["code_block"]),
329
+ combined_score=bm["combined_score"],
330
+ vector_score=bm["vector_score"],
331
+ verdict_score=bm["verdict_score"],
332
+ error_resilience=bm["error_resilience"],
333
+ thumbs_up=bm["thumbs_up"],
334
+ thumbs_down=bm["thumbs_down"],
335
+ )
336
+
337
+ alternatives = [AlternativeCandidate(**alt) for alt in result.get("alternative_candidates", [])]
338
+
339
+ return RetrieveBestResponse(
340
+ best_match=best_match,
341
+ alternative_candidates=alternatives,
342
+ retrieval_confidence=result["retrieval_confidence"],
343
+ )
344
+
345
+ async def get_few_shot_examples(
346
+ self,
347
+ task: str,
348
+ k: int = 3,
349
+ ) -> list[FewShotExample]:
350
+ """Retrieve few-shot examples for code generation"""
351
+ data = {"task": task, "k": k}
352
+ result = await self._request("POST", "/api/retrieve/few-shot-examples", json=data)
353
+ return [FewShotExample(**ex) for ex in result["examples"]]
354
+
355
+ async def get_task_patterns(
356
+ self,
357
+ task: str | None = None,
358
+ code_block_id: str | None = None,
359
+ min_thumbs_up: int = 0,
360
+ top_k: int = 20,
361
+ ) -> list[TaskPattern]:
362
+ """Retrieve proven task->code mappings"""
363
+ data = {
364
+ "task": task,
365
+ "code_block_id": code_block_id,
366
+ "min_thumbs_up": min_thumbs_up,
367
+ "top_k": top_k,
368
+ }
369
+ result = await self._request("POST", "/api/retrieve/task-patterns", json=data)
370
+ return [TaskPattern(**p) for p in result["patterns"]]
371
+
372
+ async def get_code_files(
373
+ self,
374
+ task: str,
375
+ top_k: int = 5,
376
+ min_verdict_score: float = 0.3,
377
+ prefer_complete: bool = True,
378
+ cache_dir: str = ".raysurfer_code",
379
+ ) -> GetCodeFilesResponse:
380
+ """
381
+ Get code files for a task, ready to download to sandbox.
382
+
383
+ Returns code blocks with full source code, optimized for:
384
+ - High verdict scores (proven to work)
385
+ - More complete implementations (prefer longer source)
386
+ - Task relevance (semantic similarity)
387
+
388
+ Args:
389
+ task: Task description for semantic search
390
+ top_k: Maximum number of files to return
391
+ min_verdict_score: Minimum quality score (0-1)
392
+ prefer_complete: Whether to prefer longer/more complete implementations
393
+ cache_dir: Directory path where files will be written (default: .raysurfer_code).
394
+ Used to generate full paths in add_to_llm_prompt.
395
+ """
396
+ data = {
397
+ "task": task,
398
+ "top_k": top_k,
399
+ "min_verdict_score": min_verdict_score,
400
+ "prefer_complete": prefer_complete,
401
+ }
402
+ result = await self._request("POST", "/api/retrieve/code-files", json=data)
403
+ files = [CodeFile(**f) for f in result["files"]]
404
+
405
+ # Generate the add_to_llm_prompt string
406
+ add_to_llm_prompt = self._format_llm_prompt(files, cache_dir)
407
+
408
+ return GetCodeFilesResponse(
409
+ files=files,
410
+ task=result["task"],
411
+ total_found=result["total_found"],
412
+ add_to_llm_prompt=add_to_llm_prompt,
413
+ )
414
+
415
+ def _format_llm_prompt(self, files: list[CodeFile], cache_dir: str | None = None) -> str:
416
+ """Format a prompt string listing all retrieved code files."""
417
+ if not files:
418
+ return ""
419
+
420
+ lines = [
421
+ "\n\n## IMPORTANT: Pre-validated Code Files Available\n",
422
+ "The following validated code has been retrieved from the cache. "
423
+ "Use these files directly instead of regenerating code.\n",
424
+ ]
425
+
426
+ for f in files:
427
+ if cache_dir:
428
+ import os
429
+
430
+ full_path = os.path.join(cache_dir, f.filename)
431
+ lines.append(f"\n### `{f.filename}` -> `{full_path}`")
432
+ else:
433
+ lines.append(f"\n### `{f.filename}`")
434
+ lines.append(f"- **Description**: {f.description}")
435
+ lines.append(f"- **Language**: {f.language}")
436
+ lines.append(f"- **Entrypoint**: `{f.entrypoint}`")
437
+ lines.append(f"- **Confidence**: {f.verdict_score:.0%}")
438
+ if f.dependencies:
439
+ lines.append(f"- **Dependencies**: {', '.join(f.dependencies)}")
440
+
441
+ lines.append("\n\n**Instructions**:")
442
+ lines.append("1. Read the cached file(s) before writing new code")
443
+ lines.append("2. Use the cached code as your starting point")
444
+ lines.append("3. Only modify if the task requires specific changes")
445
+ lines.append("4. Do not regenerate code that already exists\n")
446
+
447
+ return "\n".join(lines)
448
+
449
+ async def vote_code_snip(
450
+ self,
451
+ task: str,
452
+ code_block_id: str,
453
+ code_block_name: str,
454
+ code_block_description: str,
455
+ succeeded: bool,
456
+ ) -> dict[str, Any]:
457
+ """
458
+ Vote on whether a cached code snippet was useful.
459
+
460
+ This triggers background voting to assess whether the cached code
461
+ actually helped complete the task successfully.
462
+ """
463
+ data = {
464
+ "task": task,
465
+ "code_block_id": code_block_id,
466
+ "code_block_name": code_block_name,
467
+ "code_block_description": code_block_description,
468
+ "succeeded": succeeded,
469
+ }
470
+ return await self._request("POST", "/api/store/cache-usage", json=data)
471
+
472
+ # =========================================================================
473
+ # Auto Review API
474
+ # =========================================================================
475
+
476
+ async def auto_review(
477
+ self,
478
+ execution_id: str,
479
+ triggering_task: str,
480
+ execution_state: ExecutionState,
481
+ input_data: dict[str, Any],
482
+ output_data: Any,
483
+ code_block_name: str,
484
+ code_block_description: str,
485
+ error_message: str | None = None,
486
+ ) -> AutoReviewResponse:
487
+ """
488
+ Get an auto-generated review using Claude Opus 4.5.
489
+ Useful for programmatically reviewing execution results.
490
+ """
491
+ data = {
492
+ "execution_id": execution_id,
493
+ "triggering_task": triggering_task,
494
+ "execution_state": execution_state.value,
495
+ "input_data": input_data,
496
+ "output_data": output_data,
497
+ "code_block_name": code_block_name,
498
+ "code_block_description": code_block_description,
499
+ "error_message": error_message,
500
+ }
501
+ result = await self._request("POST", "/api/store/auto-review", json=data)
502
+ return AutoReviewResponse(
503
+ success=result["success"],
504
+ execution_id=result["execution_id"],
505
+ review=AgentReview(**result["review"]),
506
+ message=result["message"],
507
+ )
508
+
509
+ async def get_executions(
510
+ self,
511
+ code_block_id: str | None = None,
512
+ task: str | None = None,
513
+ verdict: AgentVerdict | None = None,
514
+ limit: int = 20,
515
+ ) -> RetrieveExecutionsResponse:
516
+ """Retrieve execution records by code block ID, task, or verdict."""
517
+ data = {
518
+ "code_block_id": code_block_id,
519
+ "task": task,
520
+ "verdict": verdict.value if verdict else None,
521
+ "limit": limit,
522
+ }
523
+ result = await self._request("POST", "/api/retrieve/executions", json=data)
524
+ executions = [ExecutionRecord(**ex) for ex in result["executions"]]
525
+ return RetrieveExecutionsResponse(
526
+ executions=executions,
527
+ total_found=result["total_found"],
528
+ )
529
+
530
+
531
+ class RaySurfer:
532
+ """Sync client for RaySurfer API"""
533
+
534
+ def __init__(
535
+ self,
536
+ api_key: str | None = None,
537
+ base_url: str = DEFAULT_BASE_URL,
538
+ timeout: float = 60.0,
539
+ organization_id: str | None = None,
540
+ workspace_id: str | None = None,
541
+ snips_desired: SnipsDesired | str | None = None,
542
+ namespace: str | None = None,
543
+ ):
544
+ """
545
+ Initialize the RaySurfer sync client.
546
+
547
+ Args:
548
+ api_key: RaySurfer API key (or set RAYSURFER_API_KEY env var)
549
+ base_url: API base URL
550
+ timeout: Request timeout in seconds
551
+ organization_id: Optional organization ID for dedicated namespace (team/enterprise)
552
+ workspace_id: Optional workspace ID for client-specific namespace (enterprise only)
553
+ snips_desired: Scope of private snippets - "company" (Team/Enterprise) or "client" (Enterprise only)
554
+ namespace: Custom namespace for code storage/retrieval (overrides org-based namespacing)
555
+ """
556
+ self.api_key = api_key
557
+ self.base_url = base_url.rstrip("/")
558
+ self.timeout = timeout
559
+ self.organization_id = organization_id
560
+ self.workspace_id = workspace_id
561
+ self.namespace = namespace
562
+ # Convert string to SnipsDesired if needed
563
+ if isinstance(snips_desired, str):
564
+ self.snips_desired = SnipsDesired(snips_desired) if snips_desired else None
565
+ else:
566
+ self.snips_desired = snips_desired
567
+ self._client: httpx.Client | None = None
568
+
569
+ def _get_client(self) -> httpx.Client:
570
+ if self._client is None:
571
+ headers = {"Content-Type": "application/json"}
572
+ if self.api_key:
573
+ headers["Authorization"] = f"Bearer {self.api_key}"
574
+ # Add organization/workspace headers for namespace routing
575
+ if self.organization_id:
576
+ headers["X-Raysurfer-Org-Id"] = self.organization_id
577
+ if self.workspace_id:
578
+ headers["X-Raysurfer-Workspace-Id"] = self.workspace_id
579
+ # Add snippet retrieval scope headers
580
+ if self.snips_desired:
581
+ headers["X-Raysurfer-Snips-Desired"] = self.snips_desired.value
582
+ # Custom namespace override
583
+ if self.namespace:
584
+ headers["X-Raysurfer-Namespace"] = self.namespace
585
+ # SDK version for tracking
586
+ headers["X-Raysurfer-SDK-Version"] = f"python/{__version__}"
587
+ self._client = httpx.Client(
588
+ base_url=self.base_url,
589
+ headers=headers,
590
+ timeout=self.timeout,
591
+ )
592
+ return self._client
593
+
594
+ def close(self) -> None:
595
+ if self._client:
596
+ self._client.close()
597
+ self._client = None
598
+
599
+ def __enter__(self) -> "RaySurfer":
600
+ return self
601
+
602
+ def __exit__(self, *args: Any) -> None:
603
+ self.close()
604
+
605
+ def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
606
+ import time as _time
607
+
608
+ client = self._get_client()
609
+ last_exception: Exception | None = None
610
+
611
+ for attempt in range(MAX_RETRIES):
612
+ try:
613
+ response = client.request(method, path, **kwargs)
614
+
615
+ if response.status_code == 401:
616
+ raise AuthenticationError("Invalid API key")
617
+ if response.status_code == 429:
618
+ retry_after = response.headers.get("Retry-After")
619
+ delay = float(retry_after) if retry_after else RETRY_BASE_DELAY * (2**attempt)
620
+ if attempt < MAX_RETRIES - 1:
621
+ logger.warning(f"Rate limited, retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})")
622
+ _time.sleep(delay)
623
+ continue
624
+ raise RateLimitError(retry_after=delay)
625
+ if response.status_code in RETRYABLE_STATUS_CODES:
626
+ if attempt < MAX_RETRIES - 1:
627
+ delay = RETRY_BASE_DELAY * (2**attempt)
628
+ logger.warning(
629
+ f"Server error {response.status_code}, retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})"
630
+ )
631
+ _time.sleep(delay)
632
+ continue
633
+ if response.status_code >= 400:
634
+ raise APIError(response.text, status_code=response.status_code)
635
+
636
+ return response.json()
637
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
638
+ last_exception = e
639
+ if attempt < MAX_RETRIES - 1:
640
+ delay = RETRY_BASE_DELAY * (2**attempt)
641
+ logger.warning(
642
+ f"Network error: {e}, retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})"
643
+ )
644
+ _time.sleep(delay)
645
+ continue
646
+ raise CacheUnavailableError(f"Failed to connect after {MAX_RETRIES} attempts: {e}") from e
647
+
648
+ # Should not reach here, but just in case
649
+ raise CacheUnavailableError(f"Request failed after {MAX_RETRIES} attempts") from last_exception
650
+
651
+ # =========================================================================
652
+ # Store API
653
+ # =========================================================================
654
+
655
+ def store_code_block(
656
+ self,
657
+ name: str,
658
+ source: str,
659
+ entrypoint: str,
660
+ language: str,
661
+ description: str = "",
662
+ input_schema: dict[str, Any] | None = None,
663
+ output_schema: dict[str, Any] | None = None,
664
+ language_version: str | None = None,
665
+ dependencies: list[str] | None = None,
666
+ tags: list[str] | None = None,
667
+ capabilities: list[str] | None = None,
668
+ example_queries: list[str] | None = None,
669
+ ) -> StoreCodeBlockResponse:
670
+ """Store a new code block"""
671
+ data = {
672
+ "name": name,
673
+ "description": description,
674
+ "source": source,
675
+ "entrypoint": entrypoint,
676
+ "language": language,
677
+ "input_schema": input_schema or {},
678
+ "output_schema": output_schema or {},
679
+ "language_version": language_version,
680
+ "dependencies": dependencies or [],
681
+ "tags": tags or [],
682
+ "capabilities": capabilities or [],
683
+ "example_queries": example_queries,
684
+ }
685
+ result = self._request("POST", "/api/store/code-block", json=data)
686
+ return StoreCodeBlockResponse(**result)
687
+
688
+ def store_execution(
689
+ self,
690
+ code_block_id: str,
691
+ triggering_task: str,
692
+ input_data: dict[str, Any],
693
+ output_data: Any,
694
+ execution_state: ExecutionState = ExecutionState.COMPLETED,
695
+ duration_ms: int = 0,
696
+ error_message: str | None = None,
697
+ error_type: str | None = None,
698
+ verdict: AgentVerdict | None = None,
699
+ review: AgentReview | None = None,
700
+ ) -> StoreExecutionResponse:
701
+ """Store an execution record"""
702
+ io = ExecutionIO(
703
+ input_data=input_data,
704
+ output_data=output_data,
705
+ output_type=type(output_data).__name__,
706
+ )
707
+ data = {
708
+ "code_block_id": code_block_id,
709
+ "triggering_task": triggering_task,
710
+ "io": io.model_dump(),
711
+ "execution_state": execution_state.value,
712
+ "duration_ms": duration_ms,
713
+ "error_message": error_message,
714
+ "error_type": error_type,
715
+ "verdict": verdict.value if verdict else None,
716
+ "review": review.model_dump() if review else None,
717
+ }
718
+ result = self._request("POST", "/api/store/execution", json=data)
719
+ return StoreExecutionResponse(**result)
720
+
721
+ def upload_new_code_snips(
722
+ self,
723
+ task: str,
724
+ files_written: list[FileWritten],
725
+ succeeded: bool,
726
+ auto_vote: bool = True,
727
+ execution_logs: str | None = None,
728
+ ) -> SubmitExecutionResultResponse:
729
+ """
730
+ Upload new code snippets from an execution.
731
+
732
+ This is the simplified API for agent integrations. Just send:
733
+ - The task that was executed
734
+ - Files that were written during execution
735
+ - Whether the task succeeded
736
+ - Whether to auto-vote on stored blocks (default: True)
737
+ - Captured execution logs for vote context
738
+
739
+ Backend handles: entrypoint detection, tag extraction, language detection,
740
+ deduplication, quality checks, and storage.
741
+ """
742
+ data: dict[str, Any] = {
743
+ "task": task,
744
+ "files_written": [f.model_dump() for f in files_written],
745
+ "succeeded": succeeded,
746
+ "auto_vote": auto_vote,
747
+ }
748
+ if execution_logs is not None:
749
+ data["execution_logs"] = execution_logs
750
+ result = self._request("POST", "/api/store/execution-result", json=data)
751
+ return SubmitExecutionResultResponse(**result)
752
+
753
+ # =========================================================================
754
+ # Retrieve API
755
+ # =========================================================================
756
+
757
+ def get_code_snips(
758
+ self,
759
+ task: str,
760
+ top_k: int = 10,
761
+ min_verdict_score: float = 0.0,
762
+ ) -> RetrieveCodeBlockResponse:
763
+ """
764
+ Get cached code snippets for a task.
765
+
766
+ Searches for code blocks by task description using semantic search.
767
+ """
768
+ data = {
769
+ "task": task,
770
+ "top_k": top_k,
771
+ "min_verdict_score": min_verdict_score,
772
+ }
773
+ result = self._request("POST", "/api/retrieve/code-blocks", json=data)
774
+
775
+ code_blocks = [
776
+ CodeBlockMatch(
777
+ code_block=CodeBlock(**cb["code_block"]),
778
+ score=cb["score"],
779
+ verdict_score=cb["verdict_score"],
780
+ thumbs_up=cb["thumbs_up"],
781
+ thumbs_down=cb["thumbs_down"],
782
+ recent_executions=cb.get("recent_executions", []),
783
+ )
784
+ for cb in result["code_blocks"]
785
+ ]
786
+ return RetrieveCodeBlockResponse(
787
+ code_blocks=code_blocks,
788
+ total_found=result["total_found"],
789
+ )
790
+
791
+ def retrieve_best(
792
+ self,
793
+ task: str,
794
+ top_k: int = 10,
795
+ min_verdict_score: float = 0.0,
796
+ ) -> RetrieveBestResponse:
797
+ """Get the single best code block for a task using verdict-aware scoring"""
798
+ data = {
799
+ "task": task,
800
+ "top_k": top_k,
801
+ "min_verdict_score": min_verdict_score,
802
+ }
803
+ result = self._request("POST", "/api/retrieve/best-for-task", json=data)
804
+
805
+ best_match = None
806
+ if result.get("best_match"):
807
+ bm = result["best_match"]
808
+ best_match = BestMatch(
809
+ code_block=CodeBlock(**bm["code_block"]),
810
+ combined_score=bm["combined_score"],
811
+ vector_score=bm["vector_score"],
812
+ verdict_score=bm["verdict_score"],
813
+ error_resilience=bm["error_resilience"],
814
+ thumbs_up=bm["thumbs_up"],
815
+ thumbs_down=bm["thumbs_down"],
816
+ )
817
+
818
+ alternatives = [AlternativeCandidate(**alt) for alt in result.get("alternative_candidates", [])]
819
+
820
+ return RetrieveBestResponse(
821
+ best_match=best_match,
822
+ alternative_candidates=alternatives,
823
+ retrieval_confidence=result["retrieval_confidence"],
824
+ )
825
+
826
+ def get_few_shot_examples(
827
+ self,
828
+ task: str,
829
+ k: int = 3,
830
+ ) -> list[FewShotExample]:
831
+ """Retrieve few-shot examples for code generation"""
832
+ data = {"task": task, "k": k}
833
+ result = self._request("POST", "/api/retrieve/few-shot-examples", json=data)
834
+ return [FewShotExample(**ex) for ex in result["examples"]]
835
+
836
+ def get_task_patterns(
837
+ self,
838
+ task: str | None = None,
839
+ code_block_id: str | None = None,
840
+ min_thumbs_up: int = 0,
841
+ top_k: int = 20,
842
+ ) -> list[TaskPattern]:
843
+ """Retrieve proven task->code mappings"""
844
+ data = {
845
+ "task": task,
846
+ "code_block_id": code_block_id,
847
+ "min_thumbs_up": min_thumbs_up,
848
+ "top_k": top_k,
849
+ }
850
+ result = self._request("POST", "/api/retrieve/task-patterns", json=data)
851
+ return [TaskPattern(**p) for p in result["patterns"]]
852
+
853
+ def get_code_files(
854
+ self,
855
+ task: str,
856
+ top_k: int = 5,
857
+ min_verdict_score: float = 0.3,
858
+ prefer_complete: bool = True,
859
+ cache_dir: str = ".raysurfer_code",
860
+ ) -> GetCodeFilesResponse:
861
+ """
862
+ Get code files for a task, ready to download to sandbox.
863
+
864
+ Returns code blocks with full source code, optimized for:
865
+ - High verdict scores (proven to work)
866
+ - More complete implementations (prefer longer source)
867
+ - Task relevance (semantic similarity)
868
+
869
+ Args:
870
+ task: Task description for semantic search
871
+ top_k: Maximum number of files to return
872
+ min_verdict_score: Minimum quality score (0-1)
873
+ prefer_complete: Whether to prefer longer/more complete implementations
874
+ cache_dir: Directory path where files will be written (default: .raysurfer_code).
875
+ Used to generate full paths in add_to_llm_prompt.
876
+ """
877
+ data = {
878
+ "task": task,
879
+ "top_k": top_k,
880
+ "min_verdict_score": min_verdict_score,
881
+ "prefer_complete": prefer_complete,
882
+ }
883
+ result = self._request("POST", "/api/retrieve/code-files", json=data)
884
+ files = [CodeFile(**f) for f in result["files"]]
885
+
886
+ # Generate the add_to_llm_prompt string
887
+ add_to_llm_prompt = self._format_llm_prompt(files, cache_dir)
888
+
889
+ return GetCodeFilesResponse(
890
+ files=files,
891
+ task=result["task"],
892
+ total_found=result["total_found"],
893
+ add_to_llm_prompt=add_to_llm_prompt,
894
+ )
895
+
896
+ def _format_llm_prompt(self, files: list[CodeFile], cache_dir: str | None = None) -> str:
897
+ """Format a prompt string listing all retrieved code files."""
898
+ if not files:
899
+ return ""
900
+
901
+ lines = [
902
+ "\n\n## IMPORTANT: Pre-validated Code Files Available\n",
903
+ "The following validated code has been retrieved from the cache. "
904
+ "Use these files directly instead of regenerating code.\n",
905
+ ]
906
+
907
+ for f in files:
908
+ if cache_dir:
909
+ import os
910
+
911
+ full_path = os.path.join(cache_dir, f.filename)
912
+ lines.append(f"\n### `{f.filename}` -> `{full_path}`")
913
+ else:
914
+ lines.append(f"\n### `{f.filename}`")
915
+ lines.append(f"- **Description**: {f.description}")
916
+ lines.append(f"- **Language**: {f.language}")
917
+ lines.append(f"- **Entrypoint**: `{f.entrypoint}`")
918
+ lines.append(f"- **Confidence**: {f.verdict_score:.0%}")
919
+ if f.dependencies:
920
+ lines.append(f"- **Dependencies**: {', '.join(f.dependencies)}")
921
+
922
+ lines.append("\n\n**Instructions**:")
923
+ lines.append("1. Read the cached file(s) before writing new code")
924
+ lines.append("2. Use the cached code as your starting point")
925
+ lines.append("3. Only modify if the task requires specific changes")
926
+ lines.append("4. Do not regenerate code that already exists\n")
927
+
928
+ return "\n".join(lines)
929
+
930
+ def vote_code_snip(
931
+ self,
932
+ task: str,
933
+ code_block_id: str,
934
+ code_block_name: str,
935
+ code_block_description: str,
936
+ succeeded: bool,
937
+ ) -> dict[str, Any]:
938
+ """
939
+ Vote on whether a cached code snippet was useful.
940
+
941
+ This triggers background voting to assess whether the cached code
942
+ actually helped complete the task successfully.
943
+ """
944
+ data = {
945
+ "task": task,
946
+ "code_block_id": code_block_id,
947
+ "code_block_name": code_block_name,
948
+ "code_block_description": code_block_description,
949
+ "succeeded": succeeded,
950
+ }
951
+ return self._request("POST", "/api/store/cache-usage", json=data)
952
+
953
+ # =========================================================================
954
+ # Auto Review API
955
+ # =========================================================================
956
+
957
+ def auto_review(
958
+ self,
959
+ execution_id: str,
960
+ triggering_task: str,
961
+ execution_state: ExecutionState,
962
+ input_data: dict[str, Any],
963
+ output_data: Any,
964
+ code_block_name: str,
965
+ code_block_description: str,
966
+ error_message: str | None = None,
967
+ ) -> AutoReviewResponse:
968
+ """
969
+ Get an auto-generated review using Claude Opus 4.5.
970
+ Useful for programmatically reviewing execution results.
971
+ """
972
+ data = {
973
+ "execution_id": execution_id,
974
+ "triggering_task": triggering_task,
975
+ "execution_state": execution_state.value,
976
+ "input_data": input_data,
977
+ "output_data": output_data,
978
+ "code_block_name": code_block_name,
979
+ "code_block_description": code_block_description,
980
+ "error_message": error_message,
981
+ }
982
+ result = self._request("POST", "/api/store/auto-review", json=data)
983
+ return AutoReviewResponse(
984
+ success=result["success"],
985
+ execution_id=result["execution_id"],
986
+ review=AgentReview(**result["review"]),
987
+ message=result["message"],
988
+ )
989
+
990
+ def get_executions(
991
+ self,
992
+ code_block_id: str | None = None,
993
+ task: str | None = None,
994
+ verdict: AgentVerdict | None = None,
995
+ limit: int = 20,
996
+ ) -> RetrieveExecutionsResponse:
997
+ """Retrieve execution records by code block ID, task, or verdict."""
998
+ data = {
999
+ "code_block_id": code_block_id,
1000
+ "task": task,
1001
+ "verdict": verdict.value if verdict else None,
1002
+ "limit": limit,
1003
+ }
1004
+ result = self._request("POST", "/api/retrieve/executions", json=data)
1005
+ executions = [ExecutionRecord(**ex) for ex in result["executions"]]
1006
+ return RetrieveExecutionsResponse(
1007
+ executions=executions,
1008
+ total_found=result["total_found"],
1009
+ )