@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,385 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
from kagent.runtime.checkpoint_storage import (
|
|
11
|
+
FILE_MODE,
|
|
12
|
+
MANIFEST_VERSION,
|
|
13
|
+
atomic_write_text,
|
|
14
|
+
ensure_store_layout,
|
|
15
|
+
new_checkpoint_id,
|
|
16
|
+
read_signed_manifest,
|
|
17
|
+
reject_existing_symlink_chain,
|
|
18
|
+
reject_symlink,
|
|
19
|
+
remove_checkpoint_directory,
|
|
20
|
+
resolve_checkpoint_target,
|
|
21
|
+
validate_checkpoint_id,
|
|
22
|
+
write_signed_manifest,
|
|
23
|
+
)
|
|
24
|
+
from kagent.runtime.file_transaction import (
|
|
25
|
+
TextFileState,
|
|
26
|
+
capture_text_states,
|
|
27
|
+
commit_text_changes,
|
|
28
|
+
validate_workspace_targets,
|
|
29
|
+
workspace_transaction,
|
|
30
|
+
)
|
|
31
|
+
from kagent.utils.paths import kagent_state_dir, migrate_legacy_kagent_state
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class PatchRevert:
|
|
36
|
+
checkpoint_id: str
|
|
37
|
+
expected_current: dict[Path, str | None]
|
|
38
|
+
staged_contents: dict[Path, str | None]
|
|
39
|
+
target_modes: dict[Path, int]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PatchCheckpointStore:
|
|
43
|
+
def __init__(self, state_root: str | Path) -> None:
|
|
44
|
+
self.state_root = Path(state_root).expanduser()
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_environment(
|
|
48
|
+
cls,
|
|
49
|
+
env: Mapping[str, str] | None = None,
|
|
50
|
+
) -> "PatchCheckpointStore":
|
|
51
|
+
source = os.environ if env is None else env
|
|
52
|
+
configured = source.get("KAGENT_PATCH_STATE_DIR", "").strip()
|
|
53
|
+
if configured:
|
|
54
|
+
return cls(configured)
|
|
55
|
+
migrate_legacy_kagent_state(source)
|
|
56
|
+
return cls(kagent_state_dir(source) / "patches")
|
|
57
|
+
|
|
58
|
+
def prepare(
|
|
59
|
+
self,
|
|
60
|
+
workspace_root: Path,
|
|
61
|
+
before: dict[Path, TextFileState],
|
|
62
|
+
after: dict[Path, str | None],
|
|
63
|
+
) -> str:
|
|
64
|
+
if set(before) != set(after):
|
|
65
|
+
raise ValueError("checkpoint before and after paths must match")
|
|
66
|
+
workspace_root = workspace_root.resolve()
|
|
67
|
+
checkpoint_id = new_checkpoint_id()
|
|
68
|
+
checkpoint_directory = self._checkpoint_directory(
|
|
69
|
+
workspace_root,
|
|
70
|
+
checkpoint_id,
|
|
71
|
+
)
|
|
72
|
+
ensure_store_layout(
|
|
73
|
+
self.state_root,
|
|
74
|
+
self._workspace_directory(workspace_root),
|
|
75
|
+
checkpoint_directory,
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
entries = []
|
|
79
|
+
for index, target in enumerate(after):
|
|
80
|
+
relative_path = target.relative_to(workspace_root).as_posix()
|
|
81
|
+
before_state = before[target]
|
|
82
|
+
after_content = after[target]
|
|
83
|
+
entries.append(
|
|
84
|
+
{
|
|
85
|
+
"path": relative_path,
|
|
86
|
+
"before": self._write_state(
|
|
87
|
+
checkpoint_directory,
|
|
88
|
+
f"before-{index:04d}.txt",
|
|
89
|
+
before_state.content,
|
|
90
|
+
before_state.mode,
|
|
91
|
+
),
|
|
92
|
+
"after": self._write_state(
|
|
93
|
+
checkpoint_directory,
|
|
94
|
+
f"after-{index:04d}.txt",
|
|
95
|
+
after_content,
|
|
96
|
+
before_state.mode
|
|
97
|
+
if before_state.content is not None
|
|
98
|
+
else FILE_MODE,
|
|
99
|
+
),
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
manifest = {
|
|
103
|
+
"version": MANIFEST_VERSION,
|
|
104
|
+
"checkpoint_id": checkpoint_id,
|
|
105
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
106
|
+
"status": "prepared",
|
|
107
|
+
"entries": entries,
|
|
108
|
+
}
|
|
109
|
+
self._write_manifest(checkpoint_directory, manifest)
|
|
110
|
+
except Exception:
|
|
111
|
+
remove_checkpoint_directory(checkpoint_directory)
|
|
112
|
+
raise
|
|
113
|
+
return checkpoint_id
|
|
114
|
+
|
|
115
|
+
def mark_committed(self, workspace_root: Path, checkpoint_id: str) -> None:
|
|
116
|
+
checkpoint_directory, manifest = self._load_manifest(
|
|
117
|
+
workspace_root,
|
|
118
|
+
checkpoint_id,
|
|
119
|
+
)
|
|
120
|
+
if manifest.get("status") != "prepared":
|
|
121
|
+
raise ValueError("checkpoint is not prepared")
|
|
122
|
+
manifest["status"] = "committed"
|
|
123
|
+
self._write_manifest(checkpoint_directory, manifest)
|
|
124
|
+
|
|
125
|
+
def discard(self, workspace_root: Path, checkpoint_id: str) -> None:
|
|
126
|
+
checkpoint_directory = self._checkpoint_directory(
|
|
127
|
+
workspace_root.resolve(),
|
|
128
|
+
checkpoint_id,
|
|
129
|
+
)
|
|
130
|
+
if not checkpoint_directory.exists():
|
|
131
|
+
return
|
|
132
|
+
remove_checkpoint_directory(checkpoint_directory)
|
|
133
|
+
|
|
134
|
+
def history(self, workspace_root: Path, *, limit: int = 20) -> dict[str, Any]:
|
|
135
|
+
if limit < 1:
|
|
136
|
+
raise ValueError("limit must be positive")
|
|
137
|
+
reject_existing_symlink_chain(self.state_root)
|
|
138
|
+
checkpoints_directory = (
|
|
139
|
+
self._workspace_directory(workspace_root.resolve()) / "checkpoints"
|
|
140
|
+
)
|
|
141
|
+
if not checkpoints_directory.exists():
|
|
142
|
+
return {"checkpoints": [], "checkpoint_count": 0}
|
|
143
|
+
reject_symlink(checkpoints_directory)
|
|
144
|
+
checkpoints = []
|
|
145
|
+
for directory in sorted(checkpoints_directory.iterdir(), reverse=True):
|
|
146
|
+
if len(checkpoints) >= limit:
|
|
147
|
+
break
|
|
148
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
149
|
+
continue
|
|
150
|
+
try:
|
|
151
|
+
_, manifest = self._load_manifest(workspace_root, directory.name)
|
|
152
|
+
except ValueError:
|
|
153
|
+
continue
|
|
154
|
+
if manifest.get("status") != "committed":
|
|
155
|
+
continue
|
|
156
|
+
entries = manifest["entries"]
|
|
157
|
+
checkpoints.append(
|
|
158
|
+
{
|
|
159
|
+
"checkpoint_id": manifest["checkpoint_id"],
|
|
160
|
+
"created_at": manifest["created_at"],
|
|
161
|
+
"file_count": len(entries),
|
|
162
|
+
"paths": [entry["path"] for entry in entries],
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
return {"checkpoints": checkpoints, "checkpoint_count": len(checkpoints)}
|
|
166
|
+
|
|
167
|
+
def load_revert(self, workspace_root: Path, checkpoint_id: str) -> PatchRevert:
|
|
168
|
+
workspace_root = workspace_root.resolve()
|
|
169
|
+
checkpoint_directory, manifest = self._load_manifest(
|
|
170
|
+
workspace_root,
|
|
171
|
+
checkpoint_id,
|
|
172
|
+
)
|
|
173
|
+
if manifest.get("status") != "committed":
|
|
174
|
+
raise ValueError("checkpoint is not committed")
|
|
175
|
+
return self._revert_from_manifest(
|
|
176
|
+
workspace_root,
|
|
177
|
+
checkpoint_directory,
|
|
178
|
+
manifest,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def recover_prepared(self, workspace_root: Path) -> int:
|
|
182
|
+
workspace_root = workspace_root.resolve()
|
|
183
|
+
checkpoints_directory = self._workspace_directory(workspace_root) / "checkpoints"
|
|
184
|
+
if not checkpoints_directory.exists():
|
|
185
|
+
return 0
|
|
186
|
+
recovered = 0
|
|
187
|
+
with workspace_transaction(workspace_root):
|
|
188
|
+
for directory in sorted(checkpoints_directory.iterdir()):
|
|
189
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
190
|
+
continue
|
|
191
|
+
_, manifest = self._load_manifest(workspace_root, directory.name)
|
|
192
|
+
if manifest.get("status") != "prepared":
|
|
193
|
+
continue
|
|
194
|
+
revert = self._revert_from_manifest(
|
|
195
|
+
workspace_root,
|
|
196
|
+
directory,
|
|
197
|
+
manifest,
|
|
198
|
+
)
|
|
199
|
+
validate_workspace_targets(workspace_root, revert.staged_contents)
|
|
200
|
+
current_states = capture_text_states(revert.staged_contents)
|
|
201
|
+
for target, current_state in current_states.items():
|
|
202
|
+
allowed_contents = {
|
|
203
|
+
revert.expected_current[target],
|
|
204
|
+
revert.staged_contents[target],
|
|
205
|
+
}
|
|
206
|
+
if current_state.content not in allowed_contents:
|
|
207
|
+
relative_path = target.relative_to(workspace_root).as_posix()
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"checkpoint recovery SHA-256 conflict: {relative_path}"
|
|
210
|
+
)
|
|
211
|
+
if any(
|
|
212
|
+
current_states[target].content != revert.staged_contents[target]
|
|
213
|
+
for target in revert.staged_contents
|
|
214
|
+
):
|
|
215
|
+
commit_text_changes(
|
|
216
|
+
workspace_root,
|
|
217
|
+
revert.staged_contents,
|
|
218
|
+
original_states=current_states,
|
|
219
|
+
target_modes=revert.target_modes,
|
|
220
|
+
)
|
|
221
|
+
self.discard(workspace_root, directory.name)
|
|
222
|
+
recovered += 1
|
|
223
|
+
return recovered
|
|
224
|
+
|
|
225
|
+
def _revert_from_manifest(
|
|
226
|
+
self,
|
|
227
|
+
workspace_root: Path,
|
|
228
|
+
checkpoint_directory: Path,
|
|
229
|
+
manifest: dict[str, Any],
|
|
230
|
+
) -> PatchRevert:
|
|
231
|
+
expected_current = {}
|
|
232
|
+
staged_contents = {}
|
|
233
|
+
target_modes = {}
|
|
234
|
+
for entry in manifest["entries"]:
|
|
235
|
+
target = resolve_checkpoint_target(workspace_root, entry["path"])
|
|
236
|
+
expected_current[target] = self._read_state(
|
|
237
|
+
checkpoint_directory,
|
|
238
|
+
entry["after"],
|
|
239
|
+
)
|
|
240
|
+
staged_contents[target] = self._read_state(
|
|
241
|
+
checkpoint_directory,
|
|
242
|
+
entry["before"],
|
|
243
|
+
)
|
|
244
|
+
target_modes[target] = int(entry["before"]["mode"])
|
|
245
|
+
return PatchRevert(
|
|
246
|
+
checkpoint_id=manifest["checkpoint_id"],
|
|
247
|
+
expected_current=expected_current,
|
|
248
|
+
staged_contents=staged_contents,
|
|
249
|
+
target_modes=target_modes,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def _workspace_directory(self, workspace_root: Path) -> Path:
|
|
253
|
+
identity = hashlib.sha256(str(workspace_root).encode("utf-8")).hexdigest()
|
|
254
|
+
return self.state_root / identity
|
|
255
|
+
|
|
256
|
+
def _checkpoint_directory(
|
|
257
|
+
self,
|
|
258
|
+
workspace_root: Path,
|
|
259
|
+
checkpoint_id: str,
|
|
260
|
+
) -> Path:
|
|
261
|
+
validate_checkpoint_id(checkpoint_id)
|
|
262
|
+
return self._workspace_directory(workspace_root) / "checkpoints" / checkpoint_id
|
|
263
|
+
|
|
264
|
+
def _load_manifest(
|
|
265
|
+
self,
|
|
266
|
+
workspace_root: Path,
|
|
267
|
+
checkpoint_id: str,
|
|
268
|
+
) -> tuple[Path, dict[str, Any]]:
|
|
269
|
+
checkpoint_directory = self._checkpoint_directory(
|
|
270
|
+
workspace_root.resolve(),
|
|
271
|
+
checkpoint_id,
|
|
272
|
+
)
|
|
273
|
+
reject_existing_symlink_chain(self.state_root)
|
|
274
|
+
manifest_path = checkpoint_directory / "manifest.json"
|
|
275
|
+
reject_symlink(checkpoint_directory)
|
|
276
|
+
reject_symlink(manifest_path)
|
|
277
|
+
if not manifest_path.is_file():
|
|
278
|
+
raise ValueError("checkpoint does not exist")
|
|
279
|
+
manifest = read_signed_manifest(
|
|
280
|
+
manifest_path,
|
|
281
|
+
self.state_root,
|
|
282
|
+
checkpoint_id,
|
|
283
|
+
)
|
|
284
|
+
return checkpoint_directory, manifest
|
|
285
|
+
|
|
286
|
+
def _write_manifest(
|
|
287
|
+
self,
|
|
288
|
+
checkpoint_directory: Path,
|
|
289
|
+
manifest: dict[str, Any],
|
|
290
|
+
) -> None:
|
|
291
|
+
write_signed_manifest(
|
|
292
|
+
checkpoint_directory / "manifest.json",
|
|
293
|
+
manifest,
|
|
294
|
+
self.state_root,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def _write_state(
|
|
298
|
+
self,
|
|
299
|
+
checkpoint_directory: Path,
|
|
300
|
+
blob_name: str,
|
|
301
|
+
content: str | None,
|
|
302
|
+
mode: int,
|
|
303
|
+
) -> dict[str, Any]:
|
|
304
|
+
if content is None:
|
|
305
|
+
return {"exists": False, "sha256": "", "mode": mode, "blob": ""}
|
|
306
|
+
encoded = content.encode("utf-8")
|
|
307
|
+
atomic_write_text(checkpoint_directory / blob_name, content)
|
|
308
|
+
return {
|
|
309
|
+
"exists": True,
|
|
310
|
+
"sha256": hashlib.sha256(encoded).hexdigest(),
|
|
311
|
+
"mode": mode,
|
|
312
|
+
"blob": blob_name,
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
def _read_state(
|
|
316
|
+
self,
|
|
317
|
+
checkpoint_directory: Path,
|
|
318
|
+
state: Any,
|
|
319
|
+
) -> str | None:
|
|
320
|
+
if not isinstance(state, dict) or not isinstance(state.get("exists"), bool):
|
|
321
|
+
raise ValueError("checkpoint manifest is invalid")
|
|
322
|
+
if state["exists"] is False:
|
|
323
|
+
return None
|
|
324
|
+
blob_name = state.get("blob")
|
|
325
|
+
expected_sha256 = state.get("sha256")
|
|
326
|
+
if not isinstance(blob_name, str) or Path(blob_name).name != blob_name:
|
|
327
|
+
raise ValueError("checkpoint manifest is invalid")
|
|
328
|
+
blob_path = checkpoint_directory / blob_name
|
|
329
|
+
reject_symlink(blob_path)
|
|
330
|
+
if not blob_path.is_file():
|
|
331
|
+
raise ValueError("checkpoint content is missing")
|
|
332
|
+
content = blob_path.read_text(encoding="utf-8")
|
|
333
|
+
actual_sha256 = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
334
|
+
if actual_sha256 != expected_sha256:
|
|
335
|
+
raise ValueError("checkpoint content SHA-256 mismatch")
|
|
336
|
+
return content
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def commit_checkpointed_text_changes(
|
|
340
|
+
store: PatchCheckpointStore,
|
|
341
|
+
workspace_root: Path,
|
|
342
|
+
staged_contents: dict[Path, str | None],
|
|
343
|
+
*,
|
|
344
|
+
target_modes: dict[Path, int] | None = None,
|
|
345
|
+
) -> str:
|
|
346
|
+
original_states = capture_text_states(staged_contents)
|
|
347
|
+
checkpoint_id = store.prepare(workspace_root, original_states, staged_contents)
|
|
348
|
+
files_committed = False
|
|
349
|
+
try:
|
|
350
|
+
commit_text_changes(
|
|
351
|
+
workspace_root,
|
|
352
|
+
staged_contents,
|
|
353
|
+
original_states=original_states,
|
|
354
|
+
target_modes=target_modes,
|
|
355
|
+
)
|
|
356
|
+
files_committed = True
|
|
357
|
+
store.mark_committed(workspace_root, checkpoint_id)
|
|
358
|
+
except Exception as commit_error:
|
|
359
|
+
rollback_error = None
|
|
360
|
+
if files_committed:
|
|
361
|
+
try:
|
|
362
|
+
commit_text_changes(
|
|
363
|
+
workspace_root,
|
|
364
|
+
{
|
|
365
|
+
target: state.content
|
|
366
|
+
for target, state in original_states.items()
|
|
367
|
+
},
|
|
368
|
+
target_modes={
|
|
369
|
+
target: state.mode for target, state in original_states.items()
|
|
370
|
+
},
|
|
371
|
+
)
|
|
372
|
+
except Exception as exc:
|
|
373
|
+
rollback_error = exc
|
|
374
|
+
try:
|
|
375
|
+
store.discard(workspace_root, checkpoint_id)
|
|
376
|
+
except Exception as discard_error:
|
|
377
|
+
if rollback_error is None:
|
|
378
|
+
rollback_error = discard_error
|
|
379
|
+
if rollback_error is not None:
|
|
380
|
+
raise RuntimeError(
|
|
381
|
+
f"checkpoint commit failed: {commit_error}; "
|
|
382
|
+
f"rollback failed: {rollback_error}"
|
|
383
|
+
) from commit_error
|
|
384
|
+
raise
|
|
385
|
+
return checkpoint_id
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, Optional, Set
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class PolicyDecision:
|
|
9
|
+
status: str
|
|
10
|
+
reason: str = ""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RuntimePolicy:
|
|
14
|
+
def __init__(self, *, allowed_tools: Optional[Set[str]] = None) -> None:
|
|
15
|
+
self.allowed_tools = set(
|
|
16
|
+
allowed_tools
|
|
17
|
+
or {
|
|
18
|
+
"apply_patch",
|
|
19
|
+
"artifact",
|
|
20
|
+
"delegate_task",
|
|
21
|
+
"decision_matrix",
|
|
22
|
+
"list_files",
|
|
23
|
+
"memory_get",
|
|
24
|
+
"memory_put",
|
|
25
|
+
"memory_recall",
|
|
26
|
+
"memory_remember",
|
|
27
|
+
"memory_search",
|
|
28
|
+
"memory_upsert",
|
|
29
|
+
"note",
|
|
30
|
+
"patch_history",
|
|
31
|
+
"read_file",
|
|
32
|
+
"rubric_score",
|
|
33
|
+
"skill_get",
|
|
34
|
+
"skill_list",
|
|
35
|
+
"task_list",
|
|
36
|
+
"task_transition",
|
|
37
|
+
"transform_text",
|
|
38
|
+
"workspace_diff",
|
|
39
|
+
"workspace_history",
|
|
40
|
+
"workspace_list",
|
|
41
|
+
"workspace_read",
|
|
42
|
+
"workspace_search",
|
|
43
|
+
"workspace_write",
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def authorize(self, tool: str, _input_payload: Dict[str, Any]) -> PolicyDecision:
|
|
48
|
+
if tool not in self.allowed_tools:
|
|
49
|
+
return PolicyDecision(status="denied", reason="tool_not_allowed")
|
|
50
|
+
return PolicyDecision(status="allowed")
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
from kagent.runtime.redaction import redact_runtime_text
|
|
6
|
+
|
|
7
|
+
MAX_PRESENTATION_CONTENT_CHARS = 4000
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def project_runtime_presentation(
|
|
11
|
+
tool: str,
|
|
12
|
+
status: str,
|
|
13
|
+
output: Dict[str, Any],
|
|
14
|
+
) -> Dict[str, Any]:
|
|
15
|
+
if status != "ok" or not isinstance(output, dict):
|
|
16
|
+
return {}
|
|
17
|
+
|
|
18
|
+
projectors = {
|
|
19
|
+
"artifact": _project_artifact,
|
|
20
|
+
"apply_patch": _project_apply_patch,
|
|
21
|
+
"revert_patch": _project_revert_patch,
|
|
22
|
+
"workspace_diff": _project_workspace_diff,
|
|
23
|
+
"workspace_restore": _project_workspace_restore,
|
|
24
|
+
"open_url": _project_open_url,
|
|
25
|
+
"open_app": _project_open_app,
|
|
26
|
+
"http_request": _project_http_request,
|
|
27
|
+
"shell_command": _project_shell_command,
|
|
28
|
+
}
|
|
29
|
+
projector = projectors.get(tool)
|
|
30
|
+
if projector is None:
|
|
31
|
+
return {}
|
|
32
|
+
return projector(output)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _project_artifact(output: Dict[str, Any]) -> Dict[str, Any]:
|
|
36
|
+
title = _string(output.get("title"))
|
|
37
|
+
content = _string(output.get("content"))
|
|
38
|
+
if not title or not content:
|
|
39
|
+
return {}
|
|
40
|
+
kind = _display_label(output.get("kind"))
|
|
41
|
+
artifact_format = _display_label(output.get("format"))
|
|
42
|
+
byte_count = output.get("bytes")
|
|
43
|
+
bytes_label = f"{byte_count} bytes" if isinstance(byte_count, int) else ""
|
|
44
|
+
detail = " · ".join(
|
|
45
|
+
part for part in (kind, artifact_format, bytes_label) if part
|
|
46
|
+
)
|
|
47
|
+
visible, truncated = _bounded_content(content)
|
|
48
|
+
return _presentation_with_content(
|
|
49
|
+
f"Created {title}",
|
|
50
|
+
detail,
|
|
51
|
+
visible,
|
|
52
|
+
truncated,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _project_apply_patch(output: Dict[str, Any]) -> Dict[str, Any]:
|
|
57
|
+
changed_files = output.get("changed_files")
|
|
58
|
+
if not isinstance(changed_files, list) or not changed_files:
|
|
59
|
+
return {}
|
|
60
|
+
paths = []
|
|
61
|
+
for item in changed_files:
|
|
62
|
+
if not isinstance(item, dict):
|
|
63
|
+
continue
|
|
64
|
+
path = _string(item.get("path"))
|
|
65
|
+
if path:
|
|
66
|
+
paths.append(path)
|
|
67
|
+
if not paths:
|
|
68
|
+
return {}
|
|
69
|
+
count = len(paths)
|
|
70
|
+
visible_paths = paths[:3]
|
|
71
|
+
path_detail = ", ".join(visible_paths)
|
|
72
|
+
if count > len(visible_paths):
|
|
73
|
+
path_detail += f", +{count - len(visible_paths)} more"
|
|
74
|
+
label = "file" if count == 1 else "files"
|
|
75
|
+
return _presentation("Updated files", f"{count} {label}: {path_detail}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _project_revert_patch(output: Dict[str, Any]) -> Dict[str, Any]:
|
|
79
|
+
projected = _project_apply_patch(output)
|
|
80
|
+
if not projected:
|
|
81
|
+
return {}
|
|
82
|
+
projected["title"] = "Restored files"
|
|
83
|
+
return projected
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _project_workspace_diff(output: Dict[str, Any]) -> Dict[str, Any]:
|
|
87
|
+
path = _string(output.get("path"))
|
|
88
|
+
diff = _string(output.get("diff"))
|
|
89
|
+
if not path or not diff:
|
|
90
|
+
return {}
|
|
91
|
+
kind = _string(output.get("kind"))
|
|
92
|
+
detail = f"{kind}/{path}" if kind else path
|
|
93
|
+
visible, truncated = _bounded_content(
|
|
94
|
+
diff,
|
|
95
|
+
already_truncated=output.get("truncated") is True,
|
|
96
|
+
)
|
|
97
|
+
return _presentation_with_content(
|
|
98
|
+
"Workspace changes",
|
|
99
|
+
detail,
|
|
100
|
+
visible,
|
|
101
|
+
truncated,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _project_workspace_restore(output: Dict[str, Any]) -> Dict[str, Any]:
|
|
106
|
+
path = _string(output.get("path"))
|
|
107
|
+
revision_id = _string(output.get("restored_revision_id"))
|
|
108
|
+
if not path or not revision_id:
|
|
109
|
+
return {}
|
|
110
|
+
kind = _string(output.get("kind"))
|
|
111
|
+
detail = f"{kind}/{path}" if kind else path
|
|
112
|
+
return _presentation("Restored workspace asset", detail)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _project_open_url(output: Dict[str, Any]) -> Dict[str, Any]:
|
|
116
|
+
url = _string(output.get("url"))
|
|
117
|
+
if output.get("opened") is not True or not url:
|
|
118
|
+
return {}
|
|
119
|
+
return _presentation("Opened URL", url)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _project_open_app(output: Dict[str, Any]) -> Dict[str, Any]:
|
|
123
|
+
application = _string(output.get("application"))
|
|
124
|
+
if output.get("opened") is not True or not application:
|
|
125
|
+
return {}
|
|
126
|
+
return _presentation("Opened application", application)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _project_http_request(output: Dict[str, Any]) -> Dict[str, Any]:
|
|
130
|
+
url = _string(output.get("url"))
|
|
131
|
+
status_code = output.get("status_code")
|
|
132
|
+
if not url or not isinstance(status_code, int):
|
|
133
|
+
return {}
|
|
134
|
+
content_type = _string(output.get("content_type"))
|
|
135
|
+
detail = " · ".join(
|
|
136
|
+
part for part in (str(status_code), content_type, url) if part
|
|
137
|
+
)
|
|
138
|
+
return _presentation("Fetched URL", detail)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _project_shell_command(output: Dict[str, Any]) -> Dict[str, Any]:
|
|
142
|
+
exit_code = output.get("exit_code")
|
|
143
|
+
if not isinstance(exit_code, int):
|
|
144
|
+
return {}
|
|
145
|
+
duration = output.get("duration_seconds")
|
|
146
|
+
detail_parts = [f"Exit {exit_code}"]
|
|
147
|
+
if isinstance(duration, (int, float)):
|
|
148
|
+
detail_parts.append(f"{duration}s")
|
|
149
|
+
stdout = _string(output.get("stdout"))
|
|
150
|
+
stderr = _string(output.get("stderr"))
|
|
151
|
+
content = "\n".join(part for part in (stdout, stderr) if part)
|
|
152
|
+
visible, truncated = _bounded_content(
|
|
153
|
+
content,
|
|
154
|
+
already_truncated=output.get("truncated") is True,
|
|
155
|
+
)
|
|
156
|
+
return _presentation_with_content(
|
|
157
|
+
"Command completed",
|
|
158
|
+
" · ".join(detail_parts),
|
|
159
|
+
visible,
|
|
160
|
+
truncated,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _presentation(title: str, detail: str) -> Dict[str, Any]:
|
|
165
|
+
return {
|
|
166
|
+
"title": redact_runtime_text(title),
|
|
167
|
+
"detail": redact_runtime_text(detail),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _presentation_with_content(
|
|
172
|
+
title: str,
|
|
173
|
+
detail: str,
|
|
174
|
+
content: str,
|
|
175
|
+
truncated: bool,
|
|
176
|
+
) -> Dict[str, Any]:
|
|
177
|
+
return {
|
|
178
|
+
**_presentation(title, detail),
|
|
179
|
+
"content": content,
|
|
180
|
+
"truncated": truncated,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _bounded_content(
|
|
185
|
+
content: str,
|
|
186
|
+
*,
|
|
187
|
+
already_truncated: bool = False,
|
|
188
|
+
) -> tuple[str, bool]:
|
|
189
|
+
redacted = redact_runtime_text(content)
|
|
190
|
+
was_bounded = len(redacted) > MAX_PRESENTATION_CONTENT_CHARS
|
|
191
|
+
return (
|
|
192
|
+
redacted[:MAX_PRESENTATION_CONTENT_CHARS],
|
|
193
|
+
already_truncated or was_bounded,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _display_label(value: Any) -> str:
|
|
198
|
+
text = _string(value)
|
|
199
|
+
if not text:
|
|
200
|
+
return ""
|
|
201
|
+
return " ".join(part.capitalize() for part in text.replace("_", " ").split())
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _string(value: Any) -> str:
|
|
205
|
+
return value if isinstance(value, str) else ""
|