@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.
- package/README.md +353 -0
- package/npm/bin/kagent-serve.js +6 -0
- package/npm/bin/kagent.js +6 -0
- package/npm/lib/App.js +524 -0
- package/npm/lib/app-state.js +224 -0
- package/npm/lib/approval-choice.js +25 -0
- package/npm/lib/commands.js +59 -0
- package/npm/lib/editor.js +188 -0
- package/npm/lib/ink-runner.js +41 -0
- package/npm/lib/kagent-home.js +39 -0
- package/npm/lib/launcher.js +221 -0
- package/npm/lib/protocol.js +33 -0
- package/npm/lib/provider-setup.js +139 -0
- package/npm/lib/python-runner.js +892 -0
- package/npm/lib/runtime-client.js +390 -0
- package/npm/lib/terminal-input.js +127 -0
- package/npm/lib/terminal-text.js +19 -0
- package/npm/lib/terminal-width.js +14 -0
- package/npm/lib/transcript.js +227 -0
- package/npm/lib/ui-components.js +247 -0
- package/npm/lib/update-manager.js +334 -0
- package/package.json +39 -0
- package/pyproject.toml +55 -0
- package/src/kagent/__init__.py +90 -0
- package/src/kagent/cli/__init__.py +5 -0
- package/src/kagent/cli/__main__.py +6 -0
- package/src/kagent/cli/commands.py +112 -0
- package/src/kagent/cli/conversation.py +127 -0
- package/src/kagent/cli/interactive.py +841 -0
- package/src/kagent/cli/main.py +685 -0
- package/src/kagent/cli/memory.py +460 -0
- package/src/kagent/cli/pending_approval.py +169 -0
- package/src/kagent/cli/provider.py +27 -0
- package/src/kagent/cli/session_commands.py +401 -0
- package/src/kagent/cli/stdio_runtime.py +784 -0
- package/src/kagent/cli/trace.py +67 -0
- package/src/kagent/cli/ui.py +931 -0
- package/src/kagent/core/__init__.py +0 -0
- package/src/kagent/core/agent.py +296 -0
- package/src/kagent/core/faults.py +11 -0
- package/src/kagent/core/invariants.py +47 -0
- package/src/kagent/core/normalization.py +81 -0
- package/src/kagent/core/planning.py +48 -0
- package/src/kagent/core/state.py +73 -0
- package/src/kagent/core/summary.py +64 -0
- package/src/kagent/core/tools.py +196 -0
- package/src/kagent/core/trace.py +57 -0
- package/src/kagent/eval/__init__.py +11 -0
- package/src/kagent/eval/cases.py +229 -0
- package/src/kagent/eval/evaluator.py +216 -0
- package/src/kagent/integrations/__init__.py +3 -0
- package/src/kagent/integrations/audit.py +95 -0
- package/src/kagent/integrations/backends.py +131 -0
- package/src/kagent/integrations/memory.py +301 -0
- package/src/kagent/ops/__init__.py +0 -0
- package/src/kagent/ops/batch.py +156 -0
- package/src/kagent/ops/doctor.py +255 -0
- package/src/kagent/ops/metrics.py +214 -0
- package/src/kagent/ops/release_evidence.py +877 -0
- package/src/kagent/ops/release_manifest.py +142 -0
- package/src/kagent/ops/trace_replay.py +285 -0
- package/src/kagent/providers/__init__.py +7 -0
- package/src/kagent/providers/embeddings.py +187 -0
- package/src/kagent/providers/llm.py +770 -0
- package/src/kagent/py.typed +1 -0
- package/src/kagent/runtime/__init__.py +28 -0
- package/src/kagent/runtime/action_graph.py +543 -0
- package/src/kagent/runtime/agent.py +2089 -0
- package/src/kagent/runtime/approval.py +64 -0
- package/src/kagent/runtime/cancellation.py +64 -0
- package/src/kagent/runtime/checkpoint_state.py +146 -0
- package/src/kagent/runtime/checkpoint_storage.py +270 -0
- package/src/kagent/runtime/context.py +65 -0
- package/src/kagent/runtime/file_transaction.py +195 -0
- package/src/kagent/runtime/hooks.py +74 -0
- package/src/kagent/runtime/metadata.py +116 -0
- package/src/kagent/runtime/patch_checkpoints.py +385 -0
- package/src/kagent/runtime/policy.py +50 -0
- package/src/kagent/runtime/presentation.py +205 -0
- package/src/kagent/runtime/redaction.py +130 -0
- package/src/kagent/runtime/sandbox.py +331 -0
- package/src/kagent/runtime/skills.py +66 -0
- package/src/kagent/runtime/steering.py +56 -0
- package/src/kagent/runtime/steps.py +255 -0
- package/src/kagent/runtime/task_state.py +40 -0
- package/src/kagent/runtime/tools.py +3532 -0
- package/src/kagent/runtime/types.py +240 -0
- package/src/kagent/runtime/workspace.py +597 -0
- package/src/kagent/service/__init__.py +26 -0
- package/src/kagent/service/__main__.py +6 -0
- package/src/kagent/service/active_runs.py +178 -0
- package/src/kagent/service/cli.py +737 -0
- package/src/kagent/service/contract.py +2571 -0
- package/src/kagent/service/errors.py +71 -0
- package/src/kagent/service/idempotency.py +584 -0
- package/src/kagent/service/router.py +884 -0
- package/src/kagent/service/run.py +150 -0
- package/src/kagent/service/runtime.py +1915 -0
- package/src/kagent/service/runtime_approval.py +20 -0
- package/src/kagent/service/runtime_cancel.py +192 -0
- package/src/kagent/service/runtime_lifecycle.py +229 -0
- package/src/kagent/service/runtime_metadata.py +21 -0
- package/src/kagent/service/runtime_policy.py +115 -0
- package/src/kagent/service/runtime_recovery.py +573 -0
- package/src/kagent/service/runtime_resume.py +382 -0
- package/src/kagent/service/runtime_resume_claim.py +116 -0
- package/src/kagent/service/runtime_run.py +350 -0
- package/src/kagent/service/runtime_status.py +2007 -0
- package/src/kagent/service/safety.py +139 -0
- package/src/kagent/service/server.py +114 -0
- package/src/kagent/service/status.py +233 -0
- package/src/kagent/service/trace_store.py +322 -0
- package/src/kagent/service/transport.py +24 -0
- package/src/kagent/utils/__init__.py +0 -0
- package/src/kagent/utils/config_validation.py +21 -0
- package/src/kagent/utils/json_output.py +23 -0
- 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,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))
|