@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,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"]
|