@openlucaskaka/kagent 0.1.7

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 (117) hide show
  1. package/README.md +353 -0
  2. package/npm/bin/kagent-serve.js +6 -0
  3. package/npm/bin/kagent.js +6 -0
  4. package/npm/lib/App.js +524 -0
  5. package/npm/lib/app-state.js +224 -0
  6. package/npm/lib/approval-choice.js +25 -0
  7. package/npm/lib/commands.js +59 -0
  8. package/npm/lib/editor.js +188 -0
  9. package/npm/lib/ink-runner.js +41 -0
  10. package/npm/lib/kagent-home.js +39 -0
  11. package/npm/lib/launcher.js +221 -0
  12. package/npm/lib/protocol.js +33 -0
  13. package/npm/lib/provider-setup.js +139 -0
  14. package/npm/lib/python-runner.js +892 -0
  15. package/npm/lib/runtime-client.js +390 -0
  16. package/npm/lib/terminal-input.js +127 -0
  17. package/npm/lib/terminal-text.js +19 -0
  18. package/npm/lib/terminal-width.js +14 -0
  19. package/npm/lib/transcript.js +227 -0
  20. package/npm/lib/ui-components.js +247 -0
  21. package/npm/lib/update-manager.js +334 -0
  22. package/package.json +39 -0
  23. package/pyproject.toml +55 -0
  24. package/src/kagent/__init__.py +90 -0
  25. package/src/kagent/cli/__init__.py +5 -0
  26. package/src/kagent/cli/__main__.py +6 -0
  27. package/src/kagent/cli/commands.py +112 -0
  28. package/src/kagent/cli/conversation.py +127 -0
  29. package/src/kagent/cli/interactive.py +841 -0
  30. package/src/kagent/cli/main.py +685 -0
  31. package/src/kagent/cli/memory.py +460 -0
  32. package/src/kagent/cli/pending_approval.py +169 -0
  33. package/src/kagent/cli/provider.py +27 -0
  34. package/src/kagent/cli/session_commands.py +401 -0
  35. package/src/kagent/cli/stdio_runtime.py +784 -0
  36. package/src/kagent/cli/trace.py +67 -0
  37. package/src/kagent/cli/ui.py +931 -0
  38. package/src/kagent/core/__init__.py +0 -0
  39. package/src/kagent/core/agent.py +296 -0
  40. package/src/kagent/core/faults.py +11 -0
  41. package/src/kagent/core/invariants.py +47 -0
  42. package/src/kagent/core/normalization.py +81 -0
  43. package/src/kagent/core/planning.py +48 -0
  44. package/src/kagent/core/state.py +73 -0
  45. package/src/kagent/core/summary.py +64 -0
  46. package/src/kagent/core/tools.py +196 -0
  47. package/src/kagent/core/trace.py +57 -0
  48. package/src/kagent/eval/__init__.py +11 -0
  49. package/src/kagent/eval/cases.py +229 -0
  50. package/src/kagent/eval/evaluator.py +216 -0
  51. package/src/kagent/integrations/__init__.py +3 -0
  52. package/src/kagent/integrations/audit.py +95 -0
  53. package/src/kagent/integrations/backends.py +131 -0
  54. package/src/kagent/integrations/memory.py +301 -0
  55. package/src/kagent/ops/__init__.py +0 -0
  56. package/src/kagent/ops/batch.py +156 -0
  57. package/src/kagent/ops/doctor.py +255 -0
  58. package/src/kagent/ops/metrics.py +214 -0
  59. package/src/kagent/ops/release_evidence.py +877 -0
  60. package/src/kagent/ops/release_manifest.py +142 -0
  61. package/src/kagent/ops/trace_replay.py +285 -0
  62. package/src/kagent/providers/__init__.py +7 -0
  63. package/src/kagent/providers/embeddings.py +187 -0
  64. package/src/kagent/providers/llm.py +770 -0
  65. package/src/kagent/py.typed +1 -0
  66. package/src/kagent/runtime/__init__.py +28 -0
  67. package/src/kagent/runtime/action_graph.py +543 -0
  68. package/src/kagent/runtime/agent.py +2089 -0
  69. package/src/kagent/runtime/approval.py +64 -0
  70. package/src/kagent/runtime/cancellation.py +64 -0
  71. package/src/kagent/runtime/checkpoint_state.py +146 -0
  72. package/src/kagent/runtime/checkpoint_storage.py +270 -0
  73. package/src/kagent/runtime/context.py +65 -0
  74. package/src/kagent/runtime/file_transaction.py +195 -0
  75. package/src/kagent/runtime/hooks.py +74 -0
  76. package/src/kagent/runtime/metadata.py +116 -0
  77. package/src/kagent/runtime/patch_checkpoints.py +385 -0
  78. package/src/kagent/runtime/policy.py +50 -0
  79. package/src/kagent/runtime/presentation.py +205 -0
  80. package/src/kagent/runtime/redaction.py +130 -0
  81. package/src/kagent/runtime/sandbox.py +331 -0
  82. package/src/kagent/runtime/skills.py +66 -0
  83. package/src/kagent/runtime/steering.py +56 -0
  84. package/src/kagent/runtime/steps.py +255 -0
  85. package/src/kagent/runtime/task_state.py +40 -0
  86. package/src/kagent/runtime/tools.py +3532 -0
  87. package/src/kagent/runtime/types.py +240 -0
  88. package/src/kagent/runtime/workspace.py +597 -0
  89. package/src/kagent/service/__init__.py +26 -0
  90. package/src/kagent/service/__main__.py +6 -0
  91. package/src/kagent/service/active_runs.py +178 -0
  92. package/src/kagent/service/cli.py +737 -0
  93. package/src/kagent/service/contract.py +2571 -0
  94. package/src/kagent/service/errors.py +71 -0
  95. package/src/kagent/service/idempotency.py +584 -0
  96. package/src/kagent/service/router.py +884 -0
  97. package/src/kagent/service/run.py +150 -0
  98. package/src/kagent/service/runtime.py +1915 -0
  99. package/src/kagent/service/runtime_approval.py +20 -0
  100. package/src/kagent/service/runtime_cancel.py +192 -0
  101. package/src/kagent/service/runtime_lifecycle.py +229 -0
  102. package/src/kagent/service/runtime_metadata.py +21 -0
  103. package/src/kagent/service/runtime_policy.py +115 -0
  104. package/src/kagent/service/runtime_recovery.py +573 -0
  105. package/src/kagent/service/runtime_resume.py +382 -0
  106. package/src/kagent/service/runtime_resume_claim.py +116 -0
  107. package/src/kagent/service/runtime_run.py +350 -0
  108. package/src/kagent/service/runtime_status.py +2007 -0
  109. package/src/kagent/service/safety.py +139 -0
  110. package/src/kagent/service/server.py +114 -0
  111. package/src/kagent/service/status.py +233 -0
  112. package/src/kagent/service/trace_store.py +322 -0
  113. package/src/kagent/service/transport.py +24 -0
  114. package/src/kagent/utils/__init__.py +0 -0
  115. package/src/kagent/utils/config_validation.py +21 -0
  116. package/src/kagent/utils/json_output.py +23 -0
  117. package/src/kagent/utils/paths.py +473 -0
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import hashlib
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Iterable, List, Optional
8
+
9
+ from kagent import __version__
10
+
11
+ PACKAGE_NAME = "kagent"
12
+
13
+
14
+ def build_release_manifest(artifact_paths: Iterable[Path]) -> Dict[str, Any]:
15
+ artifacts = [_artifact_record(Path(path)) for path in artifact_paths]
16
+ return {
17
+ "package": PACKAGE_NAME,
18
+ "version": __version__,
19
+ "artifact_count": str(len(artifacts)),
20
+ "artifacts": artifacts,
21
+ }
22
+
23
+
24
+ def verify_release_manifest(manifest_path: Path) -> Dict[str, Any]:
25
+ manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
26
+ artifacts = manifest.get("artifacts", [])
27
+ failures = []
28
+ checked = 0
29
+ if not isinstance(artifacts, list):
30
+ declared_artifact_count = str(manifest.get("artifact_count", "0"))
31
+ return {
32
+ "status": "failed",
33
+ "artifact_count": declared_artifact_count,
34
+ "checked": "0",
35
+ "failures": [
36
+ {
37
+ "path": str(manifest_path),
38
+ "error": "artifacts must be a list",
39
+ }
40
+ ],
41
+ }
42
+ if str(manifest.get("package", "")) != PACKAGE_NAME:
43
+ failures.append({"path": str(manifest_path), "error": "package mismatch"})
44
+ if str(manifest.get("version", "")) != __version__:
45
+ failures.append({"path": str(manifest_path), "error": "version mismatch"})
46
+ declared_artifact_count = str(manifest.get("artifact_count", len(artifacts)))
47
+ if declared_artifact_count != str(len(artifacts)):
48
+ failures.append(
49
+ {
50
+ "path": str(manifest_path),
51
+ "error": "artifact_count mismatch",
52
+ }
53
+ )
54
+ for artifact in artifacts:
55
+ if not isinstance(artifact, dict):
56
+ failures.append(
57
+ {
58
+ "path": str(manifest_path),
59
+ "error": "artifact entry must be an object",
60
+ }
61
+ )
62
+ continue
63
+ raw_path = artifact.get("path")
64
+ if raw_path is None or str(raw_path).strip() == "":
65
+ failures.append(
66
+ {
67
+ "path": str(manifest_path),
68
+ "error": "artifact path missing",
69
+ }
70
+ )
71
+ continue
72
+ path_text = str(raw_path)
73
+ if "\x00" in path_text:
74
+ failures.append(
75
+ {
76
+ "path": str(manifest_path),
77
+ "error": "artifact path invalid",
78
+ }
79
+ )
80
+ continue
81
+ path = Path(path_text)
82
+ checked += 1
83
+ if not path.exists():
84
+ failures.append({"path": str(path), "error": "artifact missing"})
85
+ continue
86
+ if not path.is_file():
87
+ failures.append({"path": str(path), "error": "artifact is not a file"})
88
+ continue
89
+ actual = _artifact_record(path)
90
+ if actual["sha256"] != str(artifact.get("sha256", "")):
91
+ failures.append({"path": str(path), "error": "sha256 mismatch"})
92
+ continue
93
+ if actual["size_bytes"] != str(artifact.get("size_bytes", "")):
94
+ failures.append({"path": str(path), "error": "size mismatch"})
95
+ return {
96
+ "status": "failed" if failures else "verified",
97
+ "artifact_count": declared_artifact_count,
98
+ "checked": str(checked),
99
+ "failures": failures,
100
+ }
101
+
102
+
103
+ def main(argv: Optional[List[str]] = None) -> None:
104
+ parser = argparse.ArgumentParser(
105
+ description="Generate a JSON release manifest with artifact sha256 hashes."
106
+ )
107
+ parser.add_argument("artifacts", nargs="*", help="Artifact file paths to include.")
108
+ parser.add_argument("--output", default="", metavar="PATH", help="Write manifest JSON to PATH.")
109
+ parser.add_argument("--verify", default="", metavar="PATH", help="Verify an existing manifest.")
110
+ args = parser.parse_args(argv)
111
+
112
+ try:
113
+ if args.verify:
114
+ manifest = verify_release_manifest(Path(args.verify))
115
+ else:
116
+ if not args.artifacts:
117
+ parser.error("at least one artifact path is required")
118
+ manifest = build_release_manifest([Path(path) for path in args.artifacts])
119
+ payload = json.dumps(manifest, indent=2, sort_keys=True) + "\n"
120
+ if args.output:
121
+ Path(args.output).write_text(payload, encoding="utf-8")
122
+ except json.JSONDecodeError as exc:
123
+ parser.error(f"invalid release manifest JSON: {exc}")
124
+ except OSError as exc:
125
+ parser.error(str(exc))
126
+ print(payload, end="")
127
+ if args.verify and manifest["status"] != "verified":
128
+ raise SystemExit(1)
129
+
130
+
131
+ def _artifact_record(path: Path) -> Dict[str, str]:
132
+ data = path.read_bytes()
133
+ return {
134
+ "path": str(path),
135
+ "file_name": path.name,
136
+ "size_bytes": str(len(data)),
137
+ "sha256": hashlib.sha256(data).hexdigest(),
138
+ }
139
+
140
+
141
+ if __name__ == "__main__":
142
+ main()
@@ -0,0 +1,285 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List
7
+
8
+ from kagent.runtime.steps import derive_runtime_steps
9
+ from kagent.utils.json_output import format_and_write_json
10
+
11
+
12
+ def summarize_runtime_trace(trace: Dict[str, Any], *, trace_path: str = "") -> Dict[str, Any]:
13
+ if not isinstance(trace, dict):
14
+ raise ValueError("trace payload must be a JSON object")
15
+ observations = trace.get("observations")
16
+ if not isinstance(observations, list):
17
+ observations = []
18
+ events = trace.get("events")
19
+ if not isinstance(events, list):
20
+ events = []
21
+ progress_events = trace.get("progress_events")
22
+ if not isinstance(progress_events, list):
23
+ progress_events = []
24
+ steps = _steps(trace)
25
+ return {
26
+ "trace_path": trace_path,
27
+ "trace_type": str(trace.get("trace_type", "")),
28
+ "run_id": str(trace.get("run_id", "")),
29
+ "status": str(trace.get("status", "")),
30
+ "goal": str(trace.get("goal", "")),
31
+ "started_at": str(trace.get("started_at", "")),
32
+ "completed_at": str(trace.get("completed_at", "")),
33
+ "duration_seconds": str(trace.get("duration_seconds", "")),
34
+ "iterations": _iterations_label(trace),
35
+ "event_count": str(len(events)),
36
+ "progress_event_count": str(len(progress_events)),
37
+ "observation_count": str(len(observations)),
38
+ "step_count": str(len(steps)),
39
+ "approved_action_count": str(trace.get("approved_action_count", "0")),
40
+ "pending_approval": _pending_approval_summary(trace.get("pending_approval")),
41
+ "steps": steps,
42
+ "tool_counts": _count_by_key(observations, "tool"),
43
+ "observation_status_counts": _count_by_key(observations, "status"),
44
+ "failed_observations": _failed_observations(observations),
45
+ "changed_files": _changed_files(observations),
46
+ "artifacts": _artifacts(observations),
47
+ "progress_timeline": _progress_timeline(progress_events),
48
+ "timeline": _timeline(observations),
49
+ }
50
+
51
+
52
+ def main() -> None:
53
+ parser = argparse.ArgumentParser(
54
+ description="Build a redacted replay summary for a persisted runtime trace."
55
+ )
56
+ parser.add_argument("trace_json", help="Path to a persisted runtime trace JSON file.")
57
+ parser.add_argument(
58
+ "--output",
59
+ default="",
60
+ metavar="PATH",
61
+ help="Write the JSON summary to PATH as well as stdout.",
62
+ )
63
+ args = parser.parse_args()
64
+ try:
65
+ trace = _read_trace(Path(args.trace_json))
66
+ summary = summarize_runtime_trace(trace, trace_path=args.trace_json)
67
+ json_payload = format_and_write_json(summary, args.output)
68
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
69
+ parser.error(str(exc))
70
+ print(json_payload)
71
+
72
+
73
+ def _read_trace(path: Path) -> Dict[str, Any]:
74
+ payload = json.loads(path.read_text(encoding="utf-8"))
75
+ if not isinstance(payload, dict):
76
+ raise ValueError("trace payload must be a JSON object")
77
+ return payload
78
+
79
+
80
+ def _iterations_label(trace: Dict[str, Any]) -> str:
81
+ iteration_count = str(trace.get("iteration_count", "")).strip()
82
+ max_iterations = str(trace.get("max_iterations", "")).strip()
83
+ if iteration_count and max_iterations:
84
+ return f"{iteration_count}/{max_iterations}"
85
+ return iteration_count
86
+
87
+
88
+ def _pending_approval_summary(value: Any) -> Dict[str, str]:
89
+ if not isinstance(value, dict):
90
+ return {}
91
+ return {
92
+ "id": str(value.get("id", "")),
93
+ "tool": str(value.get("tool", "")),
94
+ "reason": str(value.get("reason", "")),
95
+ }
96
+
97
+
98
+ def _steps(trace: Dict[str, Any]) -> List[Dict[str, str]]:
99
+ raw_steps = trace.get("steps")
100
+ if isinstance(raw_steps, list):
101
+ steps = _sanitize_steps(raw_steps)
102
+ if steps:
103
+ return steps
104
+ return _sanitize_steps(derive_runtime_steps(_trace_with_latest_plan(trace)))
105
+
106
+
107
+ def _trace_with_latest_plan(trace: Dict[str, Any]) -> Dict[str, Any]:
108
+ if isinstance(trace.get("plan"), dict):
109
+ return trace
110
+ plans = trace.get("plans")
111
+ if not isinstance(plans, list):
112
+ return trace
113
+ for item in reversed(plans):
114
+ if isinstance(item, dict):
115
+ return {**trace, "plan": item}
116
+ return trace
117
+
118
+
119
+ def _sanitize_steps(value: Any) -> List[Dict[str, str]]:
120
+ if not isinstance(value, list):
121
+ return []
122
+ steps = []
123
+ for item in value:
124
+ if not isinstance(item, dict):
125
+ continue
126
+ index = _scalar(item.get("index"))
127
+ state = _scalar(item.get("state"))
128
+ title = _scalar(item.get("title"))
129
+ if state not in {"done", "failed", "pending", "waiting_approval"}:
130
+ continue
131
+ if not index or not title:
132
+ continue
133
+ step = {
134
+ "index": index,
135
+ "state": state,
136
+ "title": title,
137
+ }
138
+ detail = _scalar(item.get("detail"))
139
+ if detail:
140
+ step["detail"] = detail
141
+ steps.append(step)
142
+ return steps
143
+
144
+
145
+ def _scalar(value: Any) -> str:
146
+ if isinstance(value, bool):
147
+ return ""
148
+ if isinstance(value, str):
149
+ return value.strip()
150
+ if isinstance(value, int):
151
+ return str(value)
152
+ if isinstance(value, float):
153
+ return str(value)
154
+ return ""
155
+
156
+
157
+ def _count_by_key(observations: List[Any], key: str) -> Dict[str, str]:
158
+ counts: Dict[str, int] = {}
159
+ for observation in observations:
160
+ if not isinstance(observation, dict):
161
+ continue
162
+ value = str(observation.get(key, "")).strip()
163
+ if not value:
164
+ continue
165
+ counts[value] = counts.get(value, 0) + 1
166
+ return {key: str(counts[key]) for key in sorted(counts)}
167
+
168
+
169
+ def _failed_observations(observations: List[Any]) -> List[Dict[str, str]]:
170
+ failed = []
171
+ for observation in observations:
172
+ if not isinstance(observation, dict):
173
+ continue
174
+ if str(observation.get("status", "")) not in {"failed", "requires_approval"}:
175
+ continue
176
+ failed.append(
177
+ {
178
+ "action_id": str(observation.get("action_id", "")),
179
+ "tool": str(observation.get("tool", "")),
180
+ "status": str(observation.get("status", "")),
181
+ "error_code": str(observation.get("error_code", "")),
182
+ "error": str(observation.get("error", "")),
183
+ }
184
+ )
185
+ return failed
186
+
187
+
188
+ def _changed_files(observations: List[Any]) -> List[Dict[str, str]]:
189
+ changed = []
190
+ for observation in observations:
191
+ if not isinstance(observation, dict):
192
+ continue
193
+ output = observation.get("output")
194
+ if not isinstance(output, dict):
195
+ continue
196
+ files = output.get("changed_files")
197
+ if not isinstance(files, list):
198
+ continue
199
+ for item in files:
200
+ if not isinstance(item, dict):
201
+ continue
202
+ changed.append(
203
+ {
204
+ "action_id": str(observation.get("action_id", "")),
205
+ "path": str(item.get("path", "")),
206
+ "previous_path": str(item.get("previous_path", "")),
207
+ "operation": str(item.get("operation", "")),
208
+ "bytes": str(item.get("bytes", "")),
209
+ "sha256": str(item.get("sha256", "")),
210
+ }
211
+ )
212
+ return changed
213
+
214
+
215
+ def _artifacts(observations: List[Any]) -> List[Dict[str, str]]:
216
+ artifacts = []
217
+ for observation in observations:
218
+ if not isinstance(observation, dict):
219
+ continue
220
+ output = observation.get("output")
221
+ if not isinstance(output, dict):
222
+ continue
223
+ artifact_id = str(output.get("artifact_id", "")).strip()
224
+ if not artifact_id:
225
+ continue
226
+ artifacts.append(
227
+ {
228
+ "action_id": str(observation.get("action_id", "")),
229
+ "artifact_id": artifact_id,
230
+ "title": str(output.get("title", "")),
231
+ "kind": str(output.get("kind", "")),
232
+ "format": str(output.get("format", "")),
233
+ "bytes": str(output.get("bytes", "")),
234
+ }
235
+ )
236
+ return artifacts
237
+
238
+
239
+ def _timeline(observations: List[Any]) -> List[Dict[str, str]]:
240
+ timeline = []
241
+ for observation in observations:
242
+ if not isinstance(observation, dict):
243
+ continue
244
+ timeline.append(
245
+ {
246
+ "action_id": str(observation.get("action_id", "")),
247
+ "tool": str(observation.get("tool", "")),
248
+ "status": str(observation.get("status", "")),
249
+ "error_code": str(observation.get("error_code", "")),
250
+ "duration_seconds": str(observation.get("duration_seconds", "")),
251
+ }
252
+ )
253
+ return timeline
254
+
255
+
256
+ def _progress_timeline(progress_events: List[Any]) -> List[Dict[str, str]]:
257
+ timeline = []
258
+ fields = [
259
+ "type",
260
+ "node",
261
+ "status",
262
+ "iteration",
263
+ "action_id",
264
+ "tool",
265
+ "reason",
266
+ "error_code",
267
+ "action_count",
268
+ "iteration_count",
269
+ "duration_seconds",
270
+ ]
271
+ for item in progress_events:
272
+ if not isinstance(item, dict):
273
+ continue
274
+ event = {
275
+ field: str(item[field])
276
+ for field in fields
277
+ if field in item and str(item[field]).strip()
278
+ }
279
+ if event:
280
+ timeline.append(event)
281
+ return timeline
282
+
283
+
284
+ if __name__ == "__main__":
285
+ main()
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from kagent.providers import llm
4
+
5
+ llm_provider = llm
6
+
7
+ __all__ = ["llm", "llm_provider"]
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ import urllib.error
6
+ import urllib.request
7
+ from dataclasses import dataclass
8
+ from os import environ
9
+ from typing import Any, Callable, Dict, List, Mapping, Optional
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class EmbeddingProviderConfig:
14
+ base_url: str = ""
15
+ api_key: str = ""
16
+ model: str = ""
17
+ timeout_seconds: float = 30.0
18
+ max_retries: int = 2
19
+ retry_backoff_seconds: float = 0.25
20
+
21
+ @classmethod
22
+ def from_env(
23
+ cls,
24
+ env: Optional[Mapping[str, str]] = None,
25
+ ) -> "EmbeddingProviderConfig":
26
+ source = env if env is not None else environ
27
+ return cls(
28
+ base_url=source.get(
29
+ "KAGENT_EMBEDDING_BASE_URL",
30
+ source.get("KAGENT_LLM_BASE_URL", cls.base_url),
31
+ ),
32
+ api_key=source.get(
33
+ "KAGENT_EMBEDDING_API_KEY",
34
+ source.get("KAGENT_LLM_API_KEY", cls.api_key),
35
+ ),
36
+ model=source.get("KAGENT_EMBEDDING_MODEL", cls.model),
37
+ timeout_seconds=_env_float(
38
+ source,
39
+ "KAGENT_EMBEDDING_TIMEOUT_SECONDS",
40
+ cls.timeout_seconds,
41
+ ),
42
+ max_retries=_env_int(
43
+ source,
44
+ "KAGENT_EMBEDDING_MAX_RETRIES",
45
+ cls.max_retries,
46
+ ),
47
+ retry_backoff_seconds=_env_float(
48
+ source,
49
+ "KAGENT_EMBEDDING_RETRY_BACKOFF_SECONDS",
50
+ cls.retry_backoff_seconds,
51
+ ),
52
+ )
53
+
54
+ def redacted_snapshot(self) -> Dict[str, str]:
55
+ provider = "openai_compatible" if self.base_url and self.model else "unconfigured"
56
+ base_url_configured = bool(self.base_url)
57
+ return {
58
+ "embedding_provider": provider,
59
+ "embedding_base_url": "configured" if base_url_configured else "",
60
+ "embedding_base_url_configured": str(base_url_configured).lower(),
61
+ "embedding_model": self.model,
62
+ "embedding_api_key_configured": str(bool(self.api_key)).lower(),
63
+ "embedding_timeout_seconds": str(self.timeout_seconds),
64
+ "embedding_max_retries": str(self.max_retries),
65
+ "embedding_retry_backoff_seconds": str(self.retry_backoff_seconds),
66
+ }
67
+
68
+ def __post_init__(self) -> None:
69
+ if self.timeout_seconds <= 0:
70
+ raise ValueError("embedding timeout_seconds must be positive")
71
+ if self.max_retries < 0:
72
+ raise ValueError("embedding max_retries must be non-negative")
73
+ if self.retry_backoff_seconds < 0:
74
+ raise ValueError("embedding retry_backoff_seconds must be non-negative")
75
+
76
+
77
+ class OpenAICompatibleEmbeddingProvider:
78
+ def __init__(
79
+ self,
80
+ config: EmbeddingProviderConfig,
81
+ *,
82
+ urlopen: Callable[..., Any] = urllib.request.urlopen,
83
+ sleep: Callable[[float], None] = time.sleep,
84
+ ) -> None:
85
+ if not config.base_url:
86
+ raise ValueError("embedding base_url is required")
87
+ if not config.model:
88
+ raise ValueError("embedding model is required")
89
+ self.config = config
90
+ self._urlopen = urlopen
91
+ self._sleep = sleep
92
+
93
+ def embed(self, text: str) -> List[float]:
94
+ normalized_text = str(text).strip()
95
+ if not normalized_text:
96
+ raise ValueError("embedding text is required")
97
+ request = self._embedding_request(normalized_text)
98
+ body = self._request_json_with_retries(request)
99
+ try:
100
+ vector = body["data"][0]["embedding"]
101
+ except (KeyError, IndexError, TypeError) as exc:
102
+ raise RuntimeError("embedding provider response missing vector") from exc
103
+ if not isinstance(vector, list) or not vector:
104
+ raise RuntimeError("embedding provider response missing vector")
105
+ normalized_vector = []
106
+ for item in vector:
107
+ if isinstance(item, bool) or not isinstance(item, (int, float)):
108
+ raise RuntimeError("embedding provider vector must contain numbers")
109
+ normalized_vector.append(float(item))
110
+ return normalized_vector
111
+
112
+ def _embedding_request(self, text: str) -> urllib.request.Request:
113
+ endpoint = self.config.base_url.rstrip("/") + "/embeddings"
114
+ payload = {
115
+ "input": text,
116
+ "model": self.config.model,
117
+ }
118
+ headers = {"Content-Type": "application/json"}
119
+ if self.config.api_key:
120
+ headers["Authorization"] = f"Bearer {self.config.api_key}"
121
+ return urllib.request.Request(
122
+ endpoint,
123
+ data=json.dumps(payload).encode("utf-8"),
124
+ headers=headers,
125
+ method="POST",
126
+ )
127
+
128
+ def _request_json_with_retries(
129
+ self,
130
+ request: urllib.request.Request,
131
+ ) -> Dict[str, Any]:
132
+ max_attempts = self.config.max_retries + 1
133
+ for attempt in range(max_attempts):
134
+ try:
135
+ with self._urlopen(
136
+ request,
137
+ timeout=self.config.timeout_seconds,
138
+ ) as response:
139
+ body = json.loads(response.read().decode("utf-8"))
140
+ if int(response.status) < 200 or int(response.status) >= 300:
141
+ raise RuntimeError("embedding provider request failed")
142
+ if not isinstance(body, dict):
143
+ raise RuntimeError("embedding provider response must be an object")
144
+ return body
145
+ except urllib.error.HTTPError as exc:
146
+ if attempt >= max_attempts - 1 or not _is_retryable_embedding_error(
147
+ exc
148
+ ):
149
+ raise RuntimeError(
150
+ f"embedding provider request failed: http_status={exc.code}"
151
+ ) from exc
152
+ if self.config.retry_backoff_seconds:
153
+ self._sleep(self.config.retry_backoff_seconds)
154
+ except (urllib.error.URLError, TimeoutError) as exc:
155
+ if attempt >= max_attempts - 1:
156
+ raise RuntimeError(
157
+ "embedding provider request failed: network_error"
158
+ ) from exc
159
+ if self.config.retry_backoff_seconds:
160
+ self._sleep(self.config.retry_backoff_seconds)
161
+ raise RuntimeError("embedding provider request failed")
162
+
163
+
164
+ def _env_float(env: Mapping[str, str], name: str, default: float) -> float:
165
+ value = env.get(name)
166
+ if value in {None, ""}:
167
+ return default
168
+ try:
169
+ return float(value)
170
+ except ValueError as exc:
171
+ raise ValueError(f"{name} must be a number") from exc
172
+
173
+
174
+ def _env_int(env: Mapping[str, str], name: str, default: int) -> int:
175
+ value = env.get(name)
176
+ if value in {None, ""}:
177
+ return default
178
+ try:
179
+ return int(value)
180
+ except ValueError as exc:
181
+ raise ValueError(f"{name} must be an integer") from exc
182
+
183
+
184
+ def _is_retryable_embedding_error(exc: BaseException) -> bool:
185
+ if isinstance(exc, urllib.error.HTTPError):
186
+ return exc.code == 429 or 500 <= exc.code <= 599
187
+ return isinstance(exc, (urllib.error.URLError, TimeoutError))