@trac3er/oh-my-god 2.0.0-beta.2 → 2.1.0-alpha
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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/OMG-setup.sh +33 -0
- package/README.md +43 -0
- package/claude_experimental/__init__.py +27 -0
- package/claude_experimental/_compat.py +12 -0
- package/claude_experimental/_degradation.py +210 -0
- package/claude_experimental/_flags.py +35 -0
- package/claude_experimental/_lifecycle.py +122 -0
- package/claude_experimental/integration/__init__.py +16 -0
- package/claude_experimental/integration/_placeholder.py +7 -0
- package/claude_experimental/integration/autotuner.py +182 -0
- package/claude_experimental/integration/checkpoints.py +327 -0
- package/claude_experimental/integration/experiments.py +302 -0
- package/claude_experimental/integration/openapi_gen.py +428 -0
- package/claude_experimental/integration/streaming.py +131 -0
- package/claude_experimental/integration/telemetry.py +287 -0
- package/claude_experimental/memory/__init__.py +16 -0
- package/claude_experimental/memory/_placeholder.py +7 -0
- package/claude_experimental/memory/api.py +434 -0
- package/claude_experimental/memory/augmented_generation.py +142 -0
- package/claude_experimental/memory/episodic.py +190 -0
- package/claude_experimental/memory/failure_learning.py +183 -0
- package/claude_experimental/memory/migrate.py +169 -0
- package/claude_experimental/memory/procedural.py +212 -0
- package/claude_experimental/memory/semantic.py +376 -0
- package/claude_experimental/memory/store.py +310 -0
- package/claude_experimental/parallel/__init__.py +17 -0
- package/claude_experimental/parallel/_placeholder.py +7 -0
- package/claude_experimental/parallel/aggregation.py +131 -0
- package/claude_experimental/parallel/api.py +244 -0
- package/claude_experimental/parallel/executor.py +110 -0
- package/claude_experimental/parallel/ralph_bridge.py +164 -0
- package/claude_experimental/parallel/sandbox.py +314 -0
- package/claude_experimental/parallel/scaling.py +171 -0
- package/claude_experimental/parallel/ultraworker.py +285 -0
- package/claude_experimental/patterns/__init__.py +16 -0
- package/claude_experimental/patterns/_placeholder.py +7 -0
- package/claude_experimental/patterns/antipatterns.py +454 -0
- package/claude_experimental/patterns/api.py +196 -0
- package/claude_experimental/patterns/extractor.py +163 -0
- package/claude_experimental/patterns/mining.py +219 -0
- package/claude_experimental/patterns/refactoring.py +234 -0
- package/hooks/_common.py +3 -1
- package/package.json +1 -1
- package/runtime/subagent_dispatcher.py +109 -9
- package/settings.json +6 -1
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from importlib import import_module
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable, Protocol, TypeAlias, cast
|
|
10
|
+
from urllib import parse, request
|
|
11
|
+
from urllib.error import HTTPError
|
|
12
|
+
|
|
13
|
+
JSONScalar: TypeAlias = str | int | float | bool | None
|
|
14
|
+
JSONValue: TypeAlias = JSONScalar | dict[str, "JSONValue"] | list["JSONValue"]
|
|
15
|
+
JSONDict: TypeAlias = dict[str, JSONValue]
|
|
16
|
+
|
|
17
|
+
_CONTAINER_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ToolGenerator:
|
|
21
|
+
def generate(self, spec_path: str) -> dict[str, Callable[..., JSONDict]]:
|
|
22
|
+
integration_mod = import_module("claude_experimental.integration")
|
|
23
|
+
require_enabled_obj = integration_mod.__dict__.get("_require_enabled")
|
|
24
|
+
if not callable(require_enabled_obj):
|
|
25
|
+
raise RuntimeError("integration feature gate is unavailable")
|
|
26
|
+
require_enabled = cast(Callable[[], None], require_enabled_obj)
|
|
27
|
+
_ = require_enabled()
|
|
28
|
+
|
|
29
|
+
spec = self._load_spec(spec_path)
|
|
30
|
+
operations = self._iter_operations(spec)
|
|
31
|
+
return {name: self._build_callable(meta) for name, meta in operations.items()}
|
|
32
|
+
|
|
33
|
+
def generate_module(self, spec_path: str) -> str:
|
|
34
|
+
integration_mod = import_module("claude_experimental.integration")
|
|
35
|
+
require_enabled_obj = integration_mod.__dict__.get("_require_enabled")
|
|
36
|
+
if not callable(require_enabled_obj):
|
|
37
|
+
raise RuntimeError("integration feature gate is unavailable")
|
|
38
|
+
require_enabled = cast(Callable[[], None], require_enabled_obj)
|
|
39
|
+
_ = require_enabled()
|
|
40
|
+
|
|
41
|
+
spec = self._load_spec(spec_path)
|
|
42
|
+
operations = self._iter_operations(spec)
|
|
43
|
+
|
|
44
|
+
lines: list[str] = [
|
|
45
|
+
"from __future__ import annotations",
|
|
46
|
+
"",
|
|
47
|
+
"import json",
|
|
48
|
+
"from urllib import parse, request",
|
|
49
|
+
"from urllib.error import HTTPError",
|
|
50
|
+
"",
|
|
51
|
+
"",
|
|
52
|
+
"def _map_http_error(err: HTTPError) -> Exception:",
|
|
53
|
+
" if err.code == 404:",
|
|
54
|
+
" return FileNotFoundError(f'HTTP 404: {err.reason}')",
|
|
55
|
+
" if err.code == 400:",
|
|
56
|
+
" return ValueError(f'HTTP 400: {err.reason}')",
|
|
57
|
+
" if err.code >= 500:",
|
|
58
|
+
" return RuntimeError(f'HTTP {err.code}: {err.reason}')",
|
|
59
|
+
" return RuntimeError(f'HTTP {err.code}: {err.reason}')",
|
|
60
|
+
"",
|
|
61
|
+
"",
|
|
62
|
+
"def _call_endpoint(base_url: str, path: str, method: str, query_params: dict, body):",
|
|
63
|
+
" query = {k: v for k, v in query_params.items() if v is not None}",
|
|
64
|
+
" query_string = parse.urlencode(query, doseq=True)",
|
|
65
|
+
" url = f\"{base_url.rstrip('/')}\" + path",
|
|
66
|
+
" if query_string:",
|
|
67
|
+
" url = f\"{url}?{query_string}\"",
|
|
68
|
+
" payload = None",
|
|
69
|
+
" headers = {}",
|
|
70
|
+
" if body is not None:",
|
|
71
|
+
" payload = json.dumps(body).encode('utf-8')",
|
|
72
|
+
" headers['Content-Type'] = 'application/json'",
|
|
73
|
+
" req = request.Request(url, data=payload, method=method.upper(), headers=headers)",
|
|
74
|
+
" try:",
|
|
75
|
+
" with request.urlopen(req) as response:",
|
|
76
|
+
" response_bytes = response.read()",
|
|
77
|
+
" if not response_bytes:",
|
|
78
|
+
" return {}",
|
|
79
|
+
" return json.loads(response_bytes.decode('utf-8'))",
|
|
80
|
+
" except HTTPError as err:",
|
|
81
|
+
" raise _map_http_error(err) from err",
|
|
82
|
+
"",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
for endpoint_name, meta in operations.items():
|
|
86
|
+
path_params = meta.path_params
|
|
87
|
+
signature_items = ["base_url", *path_params, "body=None", "**query_params"]
|
|
88
|
+
signature = ", ".join(signature_items)
|
|
89
|
+
lines.append(f"def {endpoint_name}({signature}):")
|
|
90
|
+
lines.append(f" path = {meta.path_template!r}")
|
|
91
|
+
for param_name in path_params:
|
|
92
|
+
lines.append(
|
|
93
|
+
f" path = path.replace('{{{param_name}}}', parse.quote(str({param_name}), safe=''))"
|
|
94
|
+
)
|
|
95
|
+
lines.append(
|
|
96
|
+
f" return _call_endpoint(base_url=base_url, path=path, method={meta.method!r}, query_params=query_params, body=body)"
|
|
97
|
+
)
|
|
98
|
+
lines.append("")
|
|
99
|
+
|
|
100
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
101
|
+
|
|
102
|
+
def _build_callable(self, meta: _OperationMeta) -> Callable[..., JSONDict]:
|
|
103
|
+
method = meta.method.upper()
|
|
104
|
+
path_template = meta.path_template
|
|
105
|
+
path_params = meta.path_params
|
|
106
|
+
|
|
107
|
+
def endpoint(
|
|
108
|
+
base_url: str,
|
|
109
|
+
*args: object,
|
|
110
|
+
body: JSONDict | None = None,
|
|
111
|
+
**query_params: object,
|
|
112
|
+
) -> JSONDict:
|
|
113
|
+
if len(args) < len(path_params):
|
|
114
|
+
missing = path_params[len(args) :]
|
|
115
|
+
raise ValueError(f"Missing path parameters: {', '.join(missing)}")
|
|
116
|
+
|
|
117
|
+
path = path_template
|
|
118
|
+
for name, value in zip(path_params, args):
|
|
119
|
+
path = path.replace("{" + name + "}", parse.quote(str(value), safe=""))
|
|
120
|
+
|
|
121
|
+
query = {key: value for key, value in query_params.items() if value is not None}
|
|
122
|
+
query_string = parse.urlencode(query, doseq=True)
|
|
123
|
+
url = f"{base_url.rstrip('/')}" + path
|
|
124
|
+
if query_string:
|
|
125
|
+
url = f"{url}?{query_string}"
|
|
126
|
+
|
|
127
|
+
payload: bytes | None = None
|
|
128
|
+
headers: dict[str, str] = {}
|
|
129
|
+
if body is not None:
|
|
130
|
+
payload = json.dumps(body).encode("utf-8")
|
|
131
|
+
headers["Content-Type"] = "application/json"
|
|
132
|
+
|
|
133
|
+
req = request.Request(url=url, data=payload, method=method, headers=headers)
|
|
134
|
+
try:
|
|
135
|
+
with cast(_HTTPResponse, request.urlopen(req)) as response:
|
|
136
|
+
data = response.read()
|
|
137
|
+
if not data:
|
|
138
|
+
return {}
|
|
139
|
+
decoded: str = data.decode("utf-8")
|
|
140
|
+
loaded = cast(object, json.loads(decoded))
|
|
141
|
+
if isinstance(loaded, dict):
|
|
142
|
+
return cast(JSONDict, loaded)
|
|
143
|
+
return {"data": cast(JSONValue, loaded)}
|
|
144
|
+
except HTTPError as err:
|
|
145
|
+
raise self._map_http_error(err) from err
|
|
146
|
+
|
|
147
|
+
endpoint.__name__ = meta.endpoint_name
|
|
148
|
+
setattr(endpoint, "__signature__", self._build_signature(path_params))
|
|
149
|
+
return endpoint
|
|
150
|
+
|
|
151
|
+
def _build_signature(self, path_params: list[str]) -> inspect.Signature:
|
|
152
|
+
parameters = [inspect.Parameter("base_url", inspect.Parameter.POSITIONAL_OR_KEYWORD)]
|
|
153
|
+
for param_name in path_params:
|
|
154
|
+
parameters.append(inspect.Parameter(param_name, inspect.Parameter.POSITIONAL_OR_KEYWORD))
|
|
155
|
+
parameters.append(inspect.Parameter("body", inspect.Parameter.KEYWORD_ONLY, default=None))
|
|
156
|
+
parameters.append(inspect.Parameter("query_params", inspect.Parameter.VAR_KEYWORD))
|
|
157
|
+
return inspect.Signature(parameters, return_annotation=dict[str, JSONValue])
|
|
158
|
+
|
|
159
|
+
def _iter_operations(self, spec: JSONDict) -> dict[str, _OperationMeta]:
|
|
160
|
+
paths_value = spec.get("paths")
|
|
161
|
+
if not isinstance(paths_value, dict):
|
|
162
|
+
return {}
|
|
163
|
+
|
|
164
|
+
operations: dict[str, _OperationMeta] = {}
|
|
165
|
+
for path_key, path_item_value in paths_value.items():
|
|
166
|
+
if not isinstance(path_item_value, dict):
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
path_item = cast(JSONDict, path_item_value)
|
|
170
|
+
path_level_params = self._extract_params(path_item.get("parameters"))
|
|
171
|
+
for method_key, op_value in path_item.items():
|
|
172
|
+
method = method_key.lower()
|
|
173
|
+
if method not in _CONTAINER_METHODS or not isinstance(op_value, dict):
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
operation = cast(JSONDict, op_value)
|
|
177
|
+
op_params = self._extract_params(operation.get("parameters"))
|
|
178
|
+
merged_params = self._merge_params(path_level_params, op_params)
|
|
179
|
+
path_params = [param.name for param in merged_params if param.location == "path"]
|
|
180
|
+
endpoint_name = self._endpoint_name(method, path_key, operation)
|
|
181
|
+
operations[endpoint_name] = _OperationMeta(
|
|
182
|
+
endpoint_name=endpoint_name,
|
|
183
|
+
method=method,
|
|
184
|
+
path_template=path_key,
|
|
185
|
+
path_params=path_params,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return operations
|
|
189
|
+
|
|
190
|
+
def _merge_params(self, base: list[_ParamMeta], extra: list[_ParamMeta]) -> list[_ParamMeta]:
|
|
191
|
+
result: list[_ParamMeta] = []
|
|
192
|
+
seen: set[tuple[str, str]] = set()
|
|
193
|
+
for param in base + extra:
|
|
194
|
+
key = (param.name, param.location)
|
|
195
|
+
if key in seen:
|
|
196
|
+
continue
|
|
197
|
+
seen.add(key)
|
|
198
|
+
result.append(param)
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
def _extract_params(self, raw_params: JSONValue) -> list[_ParamMeta]:
|
|
202
|
+
if not isinstance(raw_params, list):
|
|
203
|
+
return []
|
|
204
|
+
|
|
205
|
+
result: list[_ParamMeta] = []
|
|
206
|
+
for item in raw_params:
|
|
207
|
+
if not isinstance(item, dict):
|
|
208
|
+
continue
|
|
209
|
+
name = item.get("name")
|
|
210
|
+
location = item.get("in")
|
|
211
|
+
if isinstance(name, str) and isinstance(location, str):
|
|
212
|
+
result.append(_ParamMeta(name=name, location=location))
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
def _endpoint_name(self, method: str, path_value: str, operation: JSONDict) -> str:
|
|
216
|
+
operation_id = operation.get("operationId")
|
|
217
|
+
if isinstance(operation_id, str) and operation_id.strip():
|
|
218
|
+
return self._sanitize_identifier(operation_id)
|
|
219
|
+
|
|
220
|
+
parts = [method]
|
|
221
|
+
for part in path_value.strip("/").split("/"):
|
|
222
|
+
if not part:
|
|
223
|
+
continue
|
|
224
|
+
if part.startswith("{") and part.endswith("}"):
|
|
225
|
+
parts.append(f"by_{part[1:-1]}")
|
|
226
|
+
else:
|
|
227
|
+
parts.append(part.replace("-", "_"))
|
|
228
|
+
return self._sanitize_identifier("_".join(parts))
|
|
229
|
+
|
|
230
|
+
def _sanitize_identifier(self, value: str) -> str:
|
|
231
|
+
name = re.sub(r"[^0-9a-zA-Z_]", "_", value)
|
|
232
|
+
name = re.sub(r"_+", "_", name).strip("_")
|
|
233
|
+
if not name:
|
|
234
|
+
return "endpoint"
|
|
235
|
+
if name[0].isdigit():
|
|
236
|
+
return f"endpoint_{name}"
|
|
237
|
+
return name
|
|
238
|
+
|
|
239
|
+
def _load_spec(self, spec_path: str) -> JSONDict:
|
|
240
|
+
raw_text = Path(spec_path).read_text(encoding="utf-8")
|
|
241
|
+
suffix = Path(spec_path).suffix.lower()
|
|
242
|
+
if suffix == ".json":
|
|
243
|
+
parsed_obj = cast(object, json.loads(raw_text))
|
|
244
|
+
else:
|
|
245
|
+
parsed_obj = self._parse_yaml(raw_text)
|
|
246
|
+
if not isinstance(parsed_obj, dict):
|
|
247
|
+
raise ValueError("OpenAPI spec root must be an object")
|
|
248
|
+
return cast(JSONDict, parsed_obj)
|
|
249
|
+
|
|
250
|
+
def _parse_yaml(self, raw_text: str) -> JSONDict:
|
|
251
|
+
lines = raw_text.splitlines()
|
|
252
|
+
root: JSONDict = {}
|
|
253
|
+
stack: list[tuple[int, _Container]] = [(-1, root)]
|
|
254
|
+
index = 0
|
|
255
|
+
|
|
256
|
+
while index < len(lines):
|
|
257
|
+
raw_line = lines[index]
|
|
258
|
+
index += 1
|
|
259
|
+
if not raw_line.strip() or raw_line.lstrip().startswith("#"):
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
indent = len(raw_line) - len(raw_line.lstrip(" "))
|
|
263
|
+
line = raw_line.strip()
|
|
264
|
+
while len(stack) > 1 and indent <= stack[-1][0]:
|
|
265
|
+
_ = stack.pop()
|
|
266
|
+
parent = stack[-1][1]
|
|
267
|
+
|
|
268
|
+
if line.startswith("- "):
|
|
269
|
+
if not isinstance(parent, list):
|
|
270
|
+
raise ValueError(f"Invalid YAML list placement: {line}")
|
|
271
|
+
item_text = line[2:].strip()
|
|
272
|
+
if not item_text:
|
|
273
|
+
new_item: JSONDict = {}
|
|
274
|
+
parent.append(new_item)
|
|
275
|
+
stack.append((indent, new_item))
|
|
276
|
+
continue
|
|
277
|
+
if ":" in item_text and not item_text.startswith("{"):
|
|
278
|
+
key, value = self._split_key_value(item_text)
|
|
279
|
+
entry: JSONDict = {key: self._parse_scalar(value)}
|
|
280
|
+
parent.append(entry)
|
|
281
|
+
stack.append((indent, entry))
|
|
282
|
+
continue
|
|
283
|
+
parent.append(self._parse_scalar(item_text))
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
key, value = self._split_key_value(line)
|
|
287
|
+
target: JSONDict
|
|
288
|
+
if isinstance(parent, list):
|
|
289
|
+
if not parent or not isinstance(parent[-1], dict):
|
|
290
|
+
parent.append({})
|
|
291
|
+
last_item = parent[-1]
|
|
292
|
+
if not isinstance(last_item, dict):
|
|
293
|
+
raise ValueError("Expected mapping item inside YAML list")
|
|
294
|
+
target = cast(JSONDict, last_item)
|
|
295
|
+
else:
|
|
296
|
+
target = parent
|
|
297
|
+
|
|
298
|
+
if value == "":
|
|
299
|
+
container = self._detect_container(lines, index, indent)
|
|
300
|
+
target[key] = container
|
|
301
|
+
stack.append((indent, container))
|
|
302
|
+
else:
|
|
303
|
+
target[key] = self._parse_scalar(value)
|
|
304
|
+
|
|
305
|
+
return root
|
|
306
|
+
|
|
307
|
+
def _split_key_value(self, line: str) -> tuple[str, str]:
|
|
308
|
+
if ":" not in line:
|
|
309
|
+
raise ValueError(f"Invalid YAML line: {line}")
|
|
310
|
+
key, value = line.split(":", 1)
|
|
311
|
+
return key.strip(), value.strip()
|
|
312
|
+
|
|
313
|
+
def _detect_container(self, lines: list[str], start_idx: int, current_indent: int) -> _Container:
|
|
314
|
+
idx = start_idx
|
|
315
|
+
while idx < len(lines):
|
|
316
|
+
candidate = lines[idx]
|
|
317
|
+
idx += 1
|
|
318
|
+
if not candidate.strip() or candidate.lstrip().startswith("#"):
|
|
319
|
+
continue
|
|
320
|
+
indent = len(candidate) - len(candidate.lstrip(" "))
|
|
321
|
+
if indent <= current_indent:
|
|
322
|
+
return {}
|
|
323
|
+
if candidate.strip().startswith("- "):
|
|
324
|
+
return []
|
|
325
|
+
return {}
|
|
326
|
+
return {}
|
|
327
|
+
|
|
328
|
+
def _parse_scalar(self, value: str) -> JSONValue:
|
|
329
|
+
if value in {"", "null", "~"}:
|
|
330
|
+
return None
|
|
331
|
+
if value in {"true", "True"}:
|
|
332
|
+
return True
|
|
333
|
+
if value in {"false", "False"}:
|
|
334
|
+
return False
|
|
335
|
+
if value.startswith("{") and value.endswith("}"):
|
|
336
|
+
return self._parse_inline_map(value[1:-1].strip())
|
|
337
|
+
if value.startswith("[") and value.endswith("]"):
|
|
338
|
+
return self._parse_inline_list(value[1:-1].strip())
|
|
339
|
+
if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
|
|
340
|
+
return value[1:-1]
|
|
341
|
+
if re.fullmatch(r"-?\d+", value):
|
|
342
|
+
return int(value)
|
|
343
|
+
if re.fullmatch(r"-?\d+\.\d+", value):
|
|
344
|
+
return float(value)
|
|
345
|
+
return value
|
|
346
|
+
|
|
347
|
+
def _parse_inline_map(self, content: str) -> JSONDict:
|
|
348
|
+
if not content:
|
|
349
|
+
return {}
|
|
350
|
+
result: JSONDict = {}
|
|
351
|
+
for item in self._split_inline_items(content):
|
|
352
|
+
key, value = self._split_key_value(item)
|
|
353
|
+
result[key] = self._parse_scalar(value)
|
|
354
|
+
return result
|
|
355
|
+
|
|
356
|
+
def _parse_inline_list(self, content: str) -> list[JSONValue]:
|
|
357
|
+
if not content:
|
|
358
|
+
return []
|
|
359
|
+
return [self._parse_scalar(item) for item in self._split_inline_items(content)]
|
|
360
|
+
|
|
361
|
+
def _split_inline_items(self, text: str) -> list[str]:
|
|
362
|
+
items: list[str] = []
|
|
363
|
+
current: list[str] = []
|
|
364
|
+
depth = 0
|
|
365
|
+
quote: str | None = None
|
|
366
|
+
|
|
367
|
+
for char in text:
|
|
368
|
+
if quote is not None:
|
|
369
|
+
current.append(char)
|
|
370
|
+
if char == quote:
|
|
371
|
+
quote = None
|
|
372
|
+
continue
|
|
373
|
+
if char in {'"', "'"}:
|
|
374
|
+
quote = char
|
|
375
|
+
current.append(char)
|
|
376
|
+
continue
|
|
377
|
+
if char in "[{":
|
|
378
|
+
depth += 1
|
|
379
|
+
current.append(char)
|
|
380
|
+
continue
|
|
381
|
+
if char in "]}":
|
|
382
|
+
depth -= 1
|
|
383
|
+
current.append(char)
|
|
384
|
+
continue
|
|
385
|
+
if char == "," and depth == 0:
|
|
386
|
+
item = "".join(current).strip()
|
|
387
|
+
if item:
|
|
388
|
+
items.append(item)
|
|
389
|
+
current = []
|
|
390
|
+
continue
|
|
391
|
+
current.append(char)
|
|
392
|
+
|
|
393
|
+
tail = "".join(current).strip()
|
|
394
|
+
if tail:
|
|
395
|
+
items.append(tail)
|
|
396
|
+
return items
|
|
397
|
+
|
|
398
|
+
def _map_http_error(self, err: HTTPError) -> Exception:
|
|
399
|
+
if err.code == 404:
|
|
400
|
+
return FileNotFoundError(f"HTTP 404: {err.reason}")
|
|
401
|
+
if err.code == 400:
|
|
402
|
+
return ValueError(f"HTTP 400: {err.reason}")
|
|
403
|
+
if err.code >= 500:
|
|
404
|
+
return RuntimeError(f"HTTP {err.code}: {err.reason}")
|
|
405
|
+
return RuntimeError(f"HTTP {err.code}: {err.reason}")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@dataclass
|
|
409
|
+
class _ParamMeta:
|
|
410
|
+
name: str
|
|
411
|
+
location: str
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@dataclass
|
|
415
|
+
class _OperationMeta:
|
|
416
|
+
endpoint_name: str
|
|
417
|
+
method: str
|
|
418
|
+
path_template: str
|
|
419
|
+
path_params: list[str]
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class _HTTPResponse(Protocol):
|
|
423
|
+
def __enter__(self) -> "_HTTPResponse": ...
|
|
424
|
+
def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> bool | None: ...
|
|
425
|
+
def read(self) -> bytes: ...
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
_Container: TypeAlias = JSONDict | list[JSONValue]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""claude_experimental.integration.streaming — Server-Sent Events (SSE) streaming for agent output."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
import uuid
|
|
6
|
+
from collections import deque
|
|
7
|
+
from typing import Generator, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SSEStream:
|
|
11
|
+
"""
|
|
12
|
+
Server-Sent Events (SSE) streaming buffer for agent output.
|
|
13
|
+
|
|
14
|
+
Provides in-memory buffering with backpressure handling and thread-safe access.
|
|
15
|
+
Events are formatted as SSE-compliant strings and can be read with optional
|
|
16
|
+
last_event_id filtering for resumable streams.
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
>>> stream = SSEStream(max_buffer=100)
|
|
20
|
+
>>> stream.emit('content', 'Hello, world!')
|
|
21
|
+
>>> for sse_line in stream.read():
|
|
22
|
+
... print(sse_line)
|
|
23
|
+
id: <uuid>
|
|
24
|
+
event: content
|
|
25
|
+
data: Hello, world!
|
|
26
|
+
<BLANKLINE>
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, max_buffer: int = 100) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Initialize SSE stream with bounded buffer.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
max_buffer: Maximum number of events to buffer. Oldest events are
|
|
35
|
+
dropped when buffer overflows (FIFO backpressure).
|
|
36
|
+
"""
|
|
37
|
+
from claude_experimental.integration import _require_enabled
|
|
38
|
+
|
|
39
|
+
_require_enabled()
|
|
40
|
+
|
|
41
|
+
self._max_buffer = max_buffer
|
|
42
|
+
self._buffer: deque[dict[str, str]] = deque(maxlen=max_buffer)
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
self._closed = False
|
|
45
|
+
|
|
46
|
+
def emit(self, event_type: str, data: str, event_id: Optional[str] = None) -> None:
|
|
47
|
+
"""
|
|
48
|
+
Emit an event to the stream buffer.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
event_type: Event type (e.g., 'content', 'progress', 'error', 'complete').
|
|
52
|
+
data: Event payload as string.
|
|
53
|
+
event_id: Optional event ID. If not provided, a UUID is generated.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
RuntimeError: If stream is closed or feature flag is disabled.
|
|
57
|
+
"""
|
|
58
|
+
from claude_experimental.integration import _require_enabled
|
|
59
|
+
|
|
60
|
+
_require_enabled()
|
|
61
|
+
|
|
62
|
+
if self._closed:
|
|
63
|
+
raise RuntimeError("Cannot emit to closed stream")
|
|
64
|
+
|
|
65
|
+
if event_id is None:
|
|
66
|
+
event_id = str(uuid.uuid4())
|
|
67
|
+
|
|
68
|
+
event = {"id": event_id, "event": event_type, "data": data}
|
|
69
|
+
|
|
70
|
+
with self._lock:
|
|
71
|
+
self._buffer.append(event)
|
|
72
|
+
|
|
73
|
+
def read(self, last_event_id: Optional[str] = None) -> Generator[str, None, None]:
|
|
74
|
+
"""
|
|
75
|
+
Read events from the stream as SSE-formatted strings.
|
|
76
|
+
|
|
77
|
+
Yields events in FIFO order, optionally skipping events before a given
|
|
78
|
+
last_event_id for resumable streams.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
last_event_id: Optional event ID to resume from. Events with IDs
|
|
82
|
+
before this one are skipped.
|
|
83
|
+
|
|
84
|
+
Yields:
|
|
85
|
+
SSE-formatted event strings (including trailing blank line).
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
RuntimeError: If feature flag is disabled.
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
>>> stream = SSEStream()
|
|
92
|
+
>>> stream.emit('content', 'test')
|
|
93
|
+
>>> for line in stream.read():
|
|
94
|
+
... print(repr(line))
|
|
95
|
+
'id: ...'
|
|
96
|
+
'event: content'
|
|
97
|
+
'data: test'
|
|
98
|
+
''
|
|
99
|
+
"""
|
|
100
|
+
from claude_experimental.integration import _require_enabled
|
|
101
|
+
|
|
102
|
+
_require_enabled()
|
|
103
|
+
|
|
104
|
+
with self._lock:
|
|
105
|
+
events = list(self._buffer)
|
|
106
|
+
|
|
107
|
+
# Filter events if resuming from last_event_id
|
|
108
|
+
if last_event_id is not None:
|
|
109
|
+
found_index = -1
|
|
110
|
+
for i, event in enumerate(events):
|
|
111
|
+
if event["id"] == last_event_id:
|
|
112
|
+
found_index = i
|
|
113
|
+
break
|
|
114
|
+
if found_index >= 0:
|
|
115
|
+
events = events[found_index + 1 :]
|
|
116
|
+
|
|
117
|
+
# Yield SSE-formatted events
|
|
118
|
+
for event in events:
|
|
119
|
+
yield f"id: {event['id']}"
|
|
120
|
+
yield f"event: {event['event']}"
|
|
121
|
+
yield f"data: {event['data']}"
|
|
122
|
+
yield "" # Blank line terminates event
|
|
123
|
+
|
|
124
|
+
def close(self) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Signal that the stream is closed.
|
|
127
|
+
|
|
128
|
+
Subsequent calls to emit() will raise RuntimeError.
|
|
129
|
+
"""
|
|
130
|
+
with self._lock:
|
|
131
|
+
self._closed = True
|