@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,460 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import stat
7
+ import tempfile
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any, Mapping
11
+
12
+ from kagent.utils.json_output import json_ready
13
+ from kagent.utils.paths import kagent_state_dir, migrate_legacy_kagent_state
14
+
15
+ SESSION_MEMORY_SCHEMA_VERSION = "2"
16
+ SESSION_MEMORY_ENV_VAR = "KAGENT_SESSION_MEMORY_PATH"
17
+ HISTORY_ENV_VAR = "KAGENT_HISTORY_PATH"
18
+ _API_KEY_PATTERN = re.compile(r"\bsk-[A-Za-z0-9:_-]{8,}\b")
19
+ _BEARER_TOKEN_PATTERN = re.compile(
20
+ r"\b(Authorization:\s*Bearer\s+|Bearer\s+)([A-Za-z0-9._~+/:-]{8,})",
21
+ re.IGNORECASE,
22
+ )
23
+ _URL_CREDENTIAL_PATTERN = re.compile(r"\b(https?://)([^/\s:@]+):([^/\s@]+)@")
24
+ _FACT_HINT_PATTERN = re.compile(
25
+ r"(我是|我叫|叫我|记住|偏好|喜欢|不喜欢|my name is|call me|remember|prefer)",
26
+ re.IGNORECASE,
27
+ )
28
+ _OPEN_ITEM_HINT_PATTERN = re.compile(
29
+ r"(继续|接下来|待办|todo|需要|要求|帮我|创建|修复|优化|上线|部署|follow up|next)",
30
+ re.IGNORECASE,
31
+ )
32
+ _MAX_COMPACT_LINE_CHARS = 220
33
+
34
+
35
+ @dataclass
36
+ class RuntimeSessionMemory:
37
+ summary: str = ""
38
+ facts: list[str] = field(default_factory=list)
39
+ open_items: list[str] = field(default_factory=list)
40
+ turns: list[dict[str, str]] = field(default_factory=list)
41
+ compacted_turn_count: int = 0
42
+
43
+ def __bool__(self) -> bool:
44
+ return bool(self.summary or self.facts or self.open_items or self.turns)
45
+
46
+ def __len__(self) -> int:
47
+ return len(self.turns)
48
+
49
+ def __iter__(self):
50
+ return iter(self.turns)
51
+
52
+ def __getitem__(self, key):
53
+ return self.turns[key]
54
+
55
+ def __delitem__(self, key) -> None:
56
+ del self.turns[key]
57
+
58
+ def __eq__(self, other: object) -> bool:
59
+ if isinstance(other, list):
60
+ return self.turns == other
61
+ if isinstance(other, RuntimeSessionMemory):
62
+ return (
63
+ self.summary == other.summary
64
+ and self.facts == other.facts
65
+ and self.open_items == other.open_items
66
+ and self.turns == other.turns
67
+ and self.compacted_turn_count == other.compacted_turn_count
68
+ )
69
+ return False
70
+
71
+ def append(self, turn: dict[str, str]) -> None:
72
+ self.turns.append(turn)
73
+
74
+ def clear(self) -> None:
75
+ self.summary = ""
76
+ self.facts.clear()
77
+ self.open_items.clear()
78
+ self.turns.clear()
79
+ self.compacted_turn_count = 0
80
+
81
+
82
+ def default_runtime_session_memory_path(
83
+ env: Mapping[str, str] | None = None,
84
+ ) -> str:
85
+ source = os.environ if env is None else env
86
+ if SESSION_MEMORY_ENV_VAR in source:
87
+ return source[SESSION_MEMORY_ENV_VAR]
88
+ migrate_legacy_kagent_state(source)
89
+ return str(kagent_state_dir(source) / "session-memory.json")
90
+
91
+
92
+ def default_runtime_history_path(
93
+ env: Mapping[str, str] | None = None,
94
+ ) -> str:
95
+ source = os.environ if env is None else env
96
+ if HISTORY_ENV_VAR in source:
97
+ return source[HISTORY_ENV_VAR]
98
+ migrate_legacy_kagent_state(source)
99
+ return str(kagent_state_dir(source) / "history")
100
+
101
+
102
+ def runtime_prompt_history(path: str):
103
+ if not path:
104
+ return None
105
+ try:
106
+ from prompt_toolkit.history import FileHistory
107
+ except ImportError:
108
+ return None
109
+
110
+ history_path = Path(path)
111
+ _prepare_owner_only_history_file(history_path)
112
+
113
+ class _RedactingFileHistory(FileHistory):
114
+ def store_string(self, string: str) -> None:
115
+ super().store_string(redact_runtime_session_memory_text(string))
116
+
117
+ def load_history_strings(self):
118
+ for item in super().load_history_strings():
119
+ yield redact_runtime_session_memory_text(item)
120
+
121
+ return _RedactingFileHistory(str(history_path))
122
+
123
+
124
+ def clear_runtime_history(path: str) -> None:
125
+ if not path:
126
+ return
127
+ history_path = Path(path)
128
+ _prepare_owner_only_history_file(history_path)
129
+ with history_path.open("w", encoding="utf-8") as handle:
130
+ handle.write("")
131
+ history_path.chmod(0o600)
132
+
133
+
134
+ def _prepare_owner_only_history_file(path: Path) -> None:
135
+ _reject_symlink_memory_file(path)
136
+ _reject_symlink_memory_path_parts(path)
137
+ _ensure_owner_only_memory_dir(path.parent)
138
+ if path.exists():
139
+ _require_owner_only_memory_file(path)
140
+ return
141
+ flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
142
+ if hasattr(os, "O_NOFOLLOW"):
143
+ flags |= os.O_NOFOLLOW
144
+ fd = os.open(path, flags, 0o600)
145
+ os.close(fd)
146
+ path.chmod(0o600)
147
+
148
+
149
+ def load_runtime_session_memory(path: str, *, max_turns: int) -> RuntimeSessionMemory:
150
+ if not path:
151
+ return RuntimeSessionMemory()
152
+ memory_path = Path(path)
153
+ try:
154
+ _reject_symlink_memory_file(memory_path)
155
+ _reject_symlink_memory_path_parts(memory_path)
156
+ _tighten_existing_owner_only_memory_dir(memory_path.parent)
157
+ _require_owner_only_memory_file(memory_path)
158
+ payload = json.loads(memory_path.read_text(encoding="utf-8"))
159
+ except FileNotFoundError:
160
+ return RuntimeSessionMemory()
161
+ if not isinstance(payload, dict):
162
+ raise ValueError("session memory file must contain a JSON object")
163
+ turns = payload.get("turns")
164
+ if not isinstance(turns, list):
165
+ raise ValueError("session memory file must contain a turns array")
166
+ return RuntimeSessionMemory(
167
+ summary=_bounded_memory_text(str(payload.get("summary", "")).strip(), 2400),
168
+ facts=_normalize_memory_lines(payload.get("facts"), max_items=16),
169
+ open_items=_normalize_memory_lines(payload.get("open_items"), max_items=16),
170
+ turns=_normalize_session_memory_turns(turns, max_turns=max_turns),
171
+ compacted_turn_count=_non_negative_int(payload.get("compacted_turn_count")),
172
+ )
173
+
174
+
175
+ def save_runtime_session_memory(
176
+ path: str,
177
+ memory: RuntimeSessionMemory | list[dict[str, str]],
178
+ ) -> None:
179
+ if not path:
180
+ return
181
+ memory_path = Path(path)
182
+ _reject_symlink_memory_file(memory_path)
183
+ _reject_symlink_memory_path_parts(memory_path)
184
+ output_dir = memory_path.parent
185
+ _ensure_owner_only_memory_dir(output_dir)
186
+ session_memory = coerce_runtime_session_memory(memory)
187
+ payload = {
188
+ "schema_version": SESSION_MEMORY_SCHEMA_VERSION,
189
+ "summary": _redact_session_memory_text(session_memory.summary),
190
+ "facts": json_ready(_redact_memory_lines(session_memory.facts)),
191
+ "open_items": json_ready(_redact_memory_lines(session_memory.open_items)),
192
+ "turns": json_ready(_redact_session_memory_turns(session_memory.turns)),
193
+ "compacted_turn_count": session_memory.compacted_turn_count,
194
+ }
195
+ data = json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n"
196
+ fd, temporary_name = tempfile.mkstemp(
197
+ prefix=f".{memory_path.name}.",
198
+ suffix=".tmp",
199
+ dir=output_dir,
200
+ text=True,
201
+ )
202
+ temporary_path = Path(temporary_name)
203
+ try:
204
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
205
+ fd = -1
206
+ handle.write(data)
207
+ handle.flush()
208
+ os.fsync(handle.fileno())
209
+ temporary_path.chmod(0o600)
210
+ temporary_path.replace(memory_path)
211
+ memory_path.chmod(0o600)
212
+ except Exception:
213
+ if fd != -1:
214
+ os.close(fd)
215
+ temporary_path.unlink(missing_ok=True)
216
+ raise
217
+
218
+
219
+ def coerce_runtime_session_memory(
220
+ memory: RuntimeSessionMemory | list[dict[str, str]],
221
+ ) -> RuntimeSessionMemory:
222
+ if isinstance(memory, RuntimeSessionMemory):
223
+ return memory
224
+ return RuntimeSessionMemory(
225
+ turns=_normalize_session_memory_turns(memory, max_turns=len(memory)),
226
+ )
227
+
228
+
229
+ def compact_runtime_session_memory(
230
+ memory: RuntimeSessionMemory | list[dict[str, str]],
231
+ *,
232
+ max_recent_turns: int,
233
+ max_summary_chars: int,
234
+ max_facts: int,
235
+ max_open_items: int,
236
+ ) -> RuntimeSessionMemory:
237
+ session_memory = coerce_runtime_session_memory(memory)
238
+ overflow_count = max(0, len(session_memory.turns) - max_recent_turns)
239
+ if overflow_count <= 0:
240
+ session_memory.summary = _bounded_memory_text(
241
+ session_memory.summary,
242
+ max_summary_chars,
243
+ )
244
+ session_memory.facts = _dedupe_memory_lines(session_memory.facts)[-max_facts:]
245
+ session_memory.open_items = _dedupe_memory_lines(session_memory.open_items)[
246
+ -max_open_items:
247
+ ]
248
+ return session_memory
249
+
250
+ old_turns = session_memory.turns[:overflow_count]
251
+ session_memory.turns = session_memory.turns[overflow_count:]
252
+ session_memory.compacted_turn_count += len(old_turns)
253
+
254
+ summary_lines = [_turn_summary_line(turn) for turn in old_turns]
255
+ summary_lines = [line for line in summary_lines if line]
256
+ merged_summary = "\n".join(
257
+ part
258
+ for part in [session_memory.summary.strip(), *summary_lines]
259
+ if part
260
+ )
261
+ session_memory.summary = _bounded_memory_text(merged_summary, max_summary_chars)
262
+ session_memory.facts = _dedupe_memory_lines(
263
+ [
264
+ *session_memory.facts,
265
+ *[
266
+ line
267
+ for turn in old_turns
268
+ for line in _fact_lines_from_turn(turn)
269
+ ],
270
+ ]
271
+ )[-max_facts:]
272
+ session_memory.open_items = _dedupe_memory_lines(
273
+ [
274
+ *session_memory.open_items,
275
+ *[
276
+ line
277
+ for turn in old_turns
278
+ for line in _open_item_lines_from_turn(turn)
279
+ ],
280
+ ]
281
+ )[-max_open_items:]
282
+ return session_memory
283
+
284
+
285
+ def _normalize_session_memory_turns(
286
+ turns: list[Any],
287
+ *,
288
+ max_turns: int,
289
+ ) -> list[dict[str, str]]:
290
+ normalized = []
291
+ for item in turns:
292
+ if not isinstance(item, dict):
293
+ continue
294
+ user = _redact_session_memory_text(str(item.get("user", "")).strip())
295
+ assistant = _redact_session_memory_text(str(item.get("assistant", "")).strip())
296
+ if not user and not assistant:
297
+ continue
298
+ normalized.append({"user": user, "assistant": assistant})
299
+ return normalized[-max_turns:]
300
+
301
+
302
+ def _normalize_memory_lines(value: Any, *, max_items: int) -> list[str]:
303
+ if not isinstance(value, list):
304
+ return []
305
+ lines = []
306
+ for item in value:
307
+ text = _bounded_memory_text(str(item).strip(), _MAX_COMPACT_LINE_CHARS)
308
+ if text:
309
+ lines.append(text)
310
+ return _dedupe_memory_lines(lines)[-max_items:]
311
+
312
+
313
+ def _redact_session_memory_turns(turns: list[dict[str, str]]) -> list[dict[str, str]]:
314
+ return [
315
+ {
316
+ "user": _redact_session_memory_text(str(turn.get("user", ""))),
317
+ "assistant": _redact_session_memory_text(str(turn.get("assistant", ""))),
318
+ }
319
+ for turn in turns
320
+ if str(turn.get("user", "")).strip() or str(turn.get("assistant", "")).strip()
321
+ ]
322
+
323
+
324
+ def _redact_memory_lines(lines: list[str]) -> list[str]:
325
+ return [
326
+ _redact_session_memory_text(str(line))
327
+ for line in lines
328
+ if str(line).strip()
329
+ ]
330
+
331
+
332
+ def _redact_session_memory_text(text: str) -> str:
333
+ return redact_runtime_session_memory_text(text)
334
+
335
+
336
+ def redact_runtime_session_memory_text(text: str) -> str:
337
+ redacted = _API_KEY_PATTERN.sub("[REDACTED_API_KEY]", text)
338
+ redacted = _BEARER_TOKEN_PATTERN.sub(r"\1[REDACTED_TOKEN]", redacted)
339
+ return _URL_CREDENTIAL_PATTERN.sub(r"\1[REDACTED_CREDENTIALS]@", redacted)
340
+
341
+
342
+ def _turn_summary_line(turn: dict[str, str]) -> str:
343
+ user = _bounded_memory_text(str(turn.get("user", "")).strip(), 120)
344
+ assistant = _bounded_memory_text(str(turn.get("assistant", "")).strip(), 140)
345
+ if user and assistant:
346
+ return f"- User: {user} | Agent: {assistant}"
347
+ if user:
348
+ return f"- User: {user}"
349
+ if assistant:
350
+ return f"- Agent: {assistant}"
351
+ return ""
352
+
353
+
354
+ def _fact_lines_from_turn(turn: dict[str, str]) -> list[str]:
355
+ user = str(turn.get("user", "")).strip()
356
+ assistant = str(turn.get("assistant", "")).strip()
357
+ lines = []
358
+ for label, text in (("User said", user), ("Agent noted", assistant)):
359
+ if text and _FACT_HINT_PATTERN.search(text):
360
+ lines.append(f"{label}: {_bounded_memory_text(text, _MAX_COMPACT_LINE_CHARS)}")
361
+ return lines
362
+
363
+
364
+ def _open_item_lines_from_turn(turn: dict[str, str]) -> list[str]:
365
+ user = str(turn.get("user", "")).strip()
366
+ assistant = str(turn.get("assistant", "")).strip()
367
+ lines = []
368
+ if user and _OPEN_ITEM_HINT_PATTERN.search(user):
369
+ lines.append(f"Request: {_bounded_memory_text(user, _MAX_COMPACT_LINE_CHARS)}")
370
+ lowered_assistant = assistant.lower()
371
+ follow_up_markers = (
372
+ "failed",
373
+ "blocked",
374
+ "requires approval",
375
+ "待",
376
+ "失败",
377
+ "阻塞",
378
+ )
379
+ if assistant and any(word in lowered_assistant for word in follow_up_markers):
380
+ lines.append(
381
+ f"Follow-up: {_bounded_memory_text(assistant, _MAX_COMPACT_LINE_CHARS)}"
382
+ )
383
+ return lines
384
+
385
+
386
+ def _dedupe_memory_lines(lines: list[str]) -> list[str]:
387
+ seen = set()
388
+ deduped = []
389
+ for line in lines:
390
+ text = _bounded_memory_text(str(line).strip(), _MAX_COMPACT_LINE_CHARS)
391
+ if not text or text in seen:
392
+ continue
393
+ seen.add(text)
394
+ deduped.append(text)
395
+ return deduped
396
+
397
+
398
+ def _bounded_memory_text(text: str, max_chars: int) -> str:
399
+ compact = " ".join(redact_runtime_session_memory_text(str(text)).split())
400
+ if max_chars <= 0 or len(compact) <= max_chars:
401
+ return compact
402
+ return compact[: max_chars - 3] + "..."
403
+
404
+
405
+ def _non_negative_int(value: Any) -> int:
406
+ try:
407
+ parsed = int(value)
408
+ except (TypeError, ValueError):
409
+ return 0
410
+ return max(0, parsed)
411
+
412
+
413
+ def _require_owner_only_memory_file(path: Path) -> None:
414
+ mode = path.stat().st_mode & 0o777
415
+ if mode & 0o077:
416
+ raise ValueError("session memory file must be owner-only (0600)")
417
+
418
+
419
+ def _reject_symlink_memory_file(path: Path) -> None:
420
+ if path.is_symlink():
421
+ raise ValueError("session memory file must not be a symlink")
422
+
423
+
424
+ def _ensure_owner_only_memory_dir(path: Path) -> None:
425
+ path.mkdir(parents=True, exist_ok=True)
426
+ if _is_shared_system_temp_directory(path):
427
+ return
428
+ path.chmod(0o700)
429
+
430
+
431
+ def _tighten_existing_owner_only_memory_dir(path: Path) -> None:
432
+ if path.exists() and not _is_shared_system_temp_directory(path):
433
+ path.chmod(0o700)
434
+
435
+
436
+ def _reject_symlink_memory_path_parts(path: Path) -> None:
437
+ current = Path(path.anchor) if path.is_absolute() else Path(".")
438
+ parts = path.parent.parts[1:] if path.parent.is_absolute() else path.parent.parts
439
+ for part in parts:
440
+ current = current / part
441
+ if current.exists() and current.is_symlink() and not _is_platform_path_alias(current):
442
+ raise ValueError("session memory path must not contain symlinks")
443
+
444
+
445
+ def _is_platform_path_alias(path: Path) -> bool:
446
+ return str(path) in {"/tmp", "/var"}
447
+
448
+
449
+ def _is_shared_system_temp_directory(path: Path) -> bool:
450
+ try:
451
+ resolved_path = path.resolve(strict=True)
452
+ resolved_system_tmp = Path("/tmp").resolve(strict=True)
453
+ metadata = resolved_path.stat()
454
+ except OSError:
455
+ return False
456
+ return (
457
+ resolved_path == resolved_system_tmp
458
+ and metadata.st_uid == 0
459
+ and bool(metadata.st_mode & stat.S_ISVTX)
460
+ )
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import tempfile
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any, Mapping
10
+
11
+ from kagent.utils.paths import kagent_state_dir, migrate_legacy_kagent_state
12
+
13
+ PENDING_APPROVAL_PATH_ENV_VAR = "KAGENT_PENDING_APPROVAL_PATH"
14
+ _SCHEMA_VERSION = "1"
15
+ _MAX_SNAPSHOT_AGE_SECONDS = 24 * 60 * 60
16
+ _MAX_CLOCK_SKEW_SECONDS = 5 * 60
17
+ _VALID_PHASES = {"awaiting_approval", "approved_executing"}
18
+
19
+
20
+ def default_pending_approval_path(
21
+ env: Mapping[str, str] | None = None,
22
+ *,
23
+ workspace: str | Path | None = None,
24
+ ) -> str:
25
+ source = os.environ if env is None else env
26
+ configured = source.get(PENDING_APPROVAL_PATH_ENV_VAR, "").strip()
27
+ if configured:
28
+ return configured
29
+ migrate_legacy_kagent_state(source)
30
+ root = kagent_state_dir(source)
31
+ workspace_root = Path.cwd() if workspace is None else Path(workspace)
32
+ identity = hashlib.sha256(
33
+ str(workspace_root.resolve()).encode("utf-8")
34
+ ).hexdigest()
35
+ return str(root / "pending-approvals" / f"{identity}.json")
36
+
37
+
38
+ def load_pending_approval(path: str) -> dict[str, Any] | None:
39
+ if not path:
40
+ return None
41
+ target = Path(path)
42
+ _reject_symlink_chain(target)
43
+ if not target.exists():
44
+ return None
45
+ if not target.is_file():
46
+ raise ValueError("pending approval path is not a regular file")
47
+ _require_owner_only_file(target)
48
+ try:
49
+ payload = json.loads(target.read_text(encoding="utf-8"))
50
+ except (OSError, json.JSONDecodeError) as exc:
51
+ raise ValueError("pending approval state is invalid") from exc
52
+ if (
53
+ not isinstance(payload, dict)
54
+ or payload.get("schema_version") != _SCHEMA_VERSION
55
+ or not isinstance(payload.get("action"), dict)
56
+ or not isinstance(payload.get("goal"), str)
57
+ or not isinstance(payload.get("runtime_goal"), str)
58
+ or not isinstance(payload.get("plan"), dict)
59
+ or payload.get("phase") not in _VALID_PHASES
60
+ or isinstance(payload.get("saved_at"), bool)
61
+ or not isinstance(payload.get("saved_at"), (int, float))
62
+ or not str(payload["action"].get("id", "")).strip()
63
+ ):
64
+ raise ValueError("pending approval state is invalid")
65
+ age_seconds = time.time() - float(payload["saved_at"])
66
+ if age_seconds > _MAX_SNAPSHOT_AGE_SECONDS:
67
+ clear_pending_approval(path)
68
+ return None
69
+ if age_seconds < -_MAX_CLOCK_SKEW_SECONDS:
70
+ raise ValueError("pending approval state is invalid")
71
+ return {
72
+ "action": payload["action"],
73
+ "goal": payload["goal"],
74
+ "runtime_goal": payload["runtime_goal"],
75
+ "plan": payload["plan"],
76
+ "phase": payload["phase"],
77
+ }
78
+
79
+
80
+ def save_pending_approval(path: str, payload: dict[str, Any]) -> None:
81
+ if not path:
82
+ return
83
+ phase = payload.get("phase", "awaiting_approval")
84
+ if phase not in _VALID_PHASES:
85
+ raise ValueError("pending approval phase is invalid")
86
+ target = Path(path)
87
+ _reject_symlink_chain(target)
88
+ _ensure_owner_only_directory(target.parent)
89
+ body = {
90
+ "schema_version": _SCHEMA_VERSION,
91
+ "action": payload["action"],
92
+ "goal": payload["goal"],
93
+ "runtime_goal": payload["runtime_goal"],
94
+ "plan": payload["plan"],
95
+ "phase": phase,
96
+ "saved_at": time.time(),
97
+ }
98
+ descriptor, temporary_name = tempfile.mkstemp(
99
+ prefix=f".{target.name}.",
100
+ suffix=".tmp",
101
+ dir=target.parent,
102
+ )
103
+ temporary_path = Path(temporary_name)
104
+ try:
105
+ os.chmod(temporary_path, 0o600)
106
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
107
+ descriptor = -1
108
+ json.dump(body, handle, ensure_ascii=False, sort_keys=True)
109
+ handle.write("\n")
110
+ handle.flush()
111
+ os.fsync(handle.fileno())
112
+ temporary_path.replace(target)
113
+ target.chmod(0o600)
114
+ _fsync_directory(target.parent)
115
+ except Exception:
116
+ if descriptor != -1:
117
+ os.close(descriptor)
118
+ temporary_path.unlink(missing_ok=True)
119
+ raise
120
+
121
+
122
+ def clear_pending_approval(path: str) -> None:
123
+ if not path:
124
+ return
125
+ target = Path(path)
126
+ _reject_symlink_chain(target)
127
+ if target.exists():
128
+ if not target.is_file():
129
+ raise ValueError("pending approval path is not a regular file")
130
+ target.unlink()
131
+ _fsync_directory(target.parent)
132
+
133
+
134
+ def _reject_symlink_chain(path: Path) -> None:
135
+ absolute = path.absolute()
136
+ current = Path(absolute.anchor)
137
+ for part in absolute.parts[1:]:
138
+ current = current / part
139
+ if current.exists() and current.is_symlink():
140
+ raise ValueError("pending approval path must not contain symlinks")
141
+
142
+
143
+ def _ensure_owner_only_directory(directory: Path) -> None:
144
+ directory.mkdir(parents=True, exist_ok=True)
145
+ current = directory
146
+ while current != current.parent and current.name in {
147
+ "pending-approvals",
148
+ "kagent",
149
+ }:
150
+ current.chmod(0o700)
151
+ current = current.parent
152
+ directory.chmod(0o700)
153
+
154
+
155
+ def _require_owner_only_file(path: Path) -> None:
156
+ mode = path.stat().st_mode & 0o777
157
+ if mode & 0o077:
158
+ raise ValueError("pending approval file must be owner-only")
159
+
160
+
161
+ def _fsync_directory(directory: Path) -> None:
162
+ flags = os.O_RDONLY
163
+ if hasattr(os, "O_DIRECTORY"):
164
+ flags |= os.O_DIRECTORY
165
+ descriptor = os.open(directory, flags)
166
+ try:
167
+ os.fsync(descriptor)
168
+ finally:
169
+ os.close(descriptor)
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class RuntimeProviderConfigError(ValueError):
5
+ pass
6
+
7
+
8
+ def runtime_provider_config_message(missing: list[str]) -> str:
9
+ missing_list = ", ".join(missing)
10
+ return (
11
+ "kagent runtime provider is not configured.\n"
12
+ f"Missing: {missing_list}\n\n"
13
+ "Fastest setup:\n"
14
+ " kagent --configure\n\n"
15
+ "Or set the provider in your shell, then run kagent again:\n"
16
+ " export KAGENT_LLM_PROVIDER='openai_compatible'\n"
17
+ " export KAGENT_LLM_BASE_URL='https://your-openai-compatible-endpoint/v1'\n"
18
+ " export KAGENT_LLM_MODEL='qwen3.5-122b-a10b'\n"
19
+ " export KAGENT_LLM_API_KEY='your-api-key'\n\n"
20
+ "Provider can be openai_compatible, deepseek, qwen, or ollama; "
21
+ "kagent can usually infer it from Base URL and model.\n\n"
22
+ "For a local LLM-free smoke test, run:\n"
23
+ " kagent --deterministic 'calculate 2 + 3'"
24
+ )
25
+
26
+
27
+ __all__ = ["RuntimeProviderConfigError", "runtime_provider_config_message"]