@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,841 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import queue
6
+ import shlex
7
+ import sys
8
+ import threading
9
+ import time
10
+ from typing import Any
11
+
12
+ from kagent.cli.commands import (
13
+ is_runtime_interactive_command,
14
+ runtime_interactive_command_suggestions,
15
+ runtime_interactive_command_usage,
16
+ runtime_interactive_completion_words,
17
+ )
18
+ from kagent.cli.conversation import (
19
+ RUNTIME_MEMORY_MAX_TURNS,
20
+ compact_runtime_conversation_memory,
21
+ remember_runtime_turn,
22
+ runtime_goal_with_memory,
23
+ )
24
+ from kagent.cli.memory import (
25
+ RuntimeSessionMemory,
26
+ clear_runtime_history,
27
+ default_runtime_history_path,
28
+ load_runtime_session_memory,
29
+ runtime_prompt_history,
30
+ save_runtime_session_memory,
31
+ )
32
+ from kagent.cli.trace import (
33
+ persist_runtime_cli_trace_or_raise,
34
+ save_runtime_trace_snapshot_or_raise,
35
+ )
36
+ from kagent.cli.ui import (
37
+ approval_prompt,
38
+ format_runtime_interactive_doctor,
39
+ format_runtime_interactive_status,
40
+ format_runtime_interactive_summary,
41
+ format_runtime_interactive_tools,
42
+ format_runtime_notice,
43
+ format_runtime_pending_approval_detail,
44
+ format_runtime_progress_event,
45
+ format_runtime_provider_config,
46
+ format_runtime_session_memory,
47
+ runtime_interactive_help,
48
+ runtime_prompt,
49
+ runtime_prompt_reset,
50
+ runtime_ready_message,
51
+ runtime_ui_color_enabled,
52
+ runtime_user_message_block,
53
+ )
54
+ from kagent.utils.json_output import format_and_write_json, json_ready
55
+
56
+
57
+ def run_runtime_interactive(
58
+ *,
59
+ provider: Any,
60
+ run_runtime_agent: Any,
61
+ max_iterations: int,
62
+ fail_on_agent_failure: bool,
63
+ full_trace_output: bool = False,
64
+ metadata: dict[str, str] | None = None,
65
+ tags: list[str] | None = None,
66
+ trace_dir: str = "",
67
+ persist_trace: Any = None,
68
+ session_memory_path: str = "",
69
+ ) -> None:
70
+ interactive_tty = sys.stdin.isatty()
71
+ prompt_stream = sys.__stderr__ or sys.stderr
72
+ full_json_mode = full_trace_output
73
+ session_memory = load_runtime_session_memory(
74
+ session_memory_path,
75
+ max_turns=RUNTIME_MEMORY_MAX_TURNS,
76
+ )
77
+ last_payload: Any = None
78
+ line_reader: Any = None
79
+ state_lock = threading.RLock()
80
+ if interactive_tty:
81
+ line_reader = _runtime_interactive_line_reader(prompt_stream)
82
+ print(runtime_ready_message(color=runtime_ui_color_enabled()), file=prompt_stream)
83
+
84
+ def run_goal_once(
85
+ goal: str,
86
+ approval_reader: Any = None,
87
+ full_json_override: bool | None = None,
88
+ ) -> None:
89
+ nonlocal last_payload
90
+ with state_lock:
91
+ runtime_goal = runtime_goal_with_memory(goal, session_memory)
92
+ run_full_json_mode = (
93
+ full_json_mode if full_json_override is None else full_json_override
94
+ )
95
+ progress_sink = _runtime_interactive_progress_sink(
96
+ enabled=interactive_tty and not run_full_json_mode,
97
+ )
98
+ try:
99
+ payload = json_ready(
100
+ run_runtime_agent(
101
+ runtime_goal,
102
+ provider=provider,
103
+ max_iterations=max_iterations,
104
+ metadata=metadata,
105
+ tags=tags,
106
+ event_sink=progress_sink,
107
+ stream_answers=interactive_tty and not run_full_json_mode,
108
+ )
109
+ )
110
+ finally:
111
+ _close_runtime_progress_sink(progress_sink)
112
+ if trace_dir and persist_trace is not None:
113
+ persist_runtime_cli_trace_or_raise(payload, trace_dir, persist_trace)
114
+ _print_runtime_interactive_payload(
115
+ payload,
116
+ full_json=run_full_json_mode or not interactive_tty,
117
+ )
118
+ if payload.get("status") == "requires_approval" and interactive_tty:
119
+ payload = _maybe_run_approved_runtime_action(
120
+ payload=payload,
121
+ goal=runtime_goal,
122
+ run_runtime_agent=run_runtime_agent,
123
+ metadata=metadata,
124
+ tags=tags,
125
+ progress_enabled=not run_full_json_mode,
126
+ response_reader=approval_reader,
127
+ )
128
+ if payload is not None:
129
+ if trace_dir and persist_trace is not None:
130
+ persist_runtime_cli_trace_or_raise(payload, trace_dir, persist_trace)
131
+ _print_runtime_interactive_payload(
132
+ payload,
133
+ full_json=run_full_json_mode,
134
+ )
135
+ with state_lock:
136
+ last_payload = payload
137
+ remember_runtime_turn(session_memory, goal, payload)
138
+ save_runtime_session_memory(session_memory_path, session_memory)
139
+ if (
140
+ fail_on_agent_failure
141
+ and isinstance(payload, dict)
142
+ and payload.get("status") == "failed"
143
+ ):
144
+ raise SystemExit(1)
145
+
146
+ if interactive_tty:
147
+ work_queue: queue.Queue[tuple[str, bool] | None] = queue.Queue()
148
+ approval_broker = _RuntimeInteractiveApprovalBroker()
149
+ worker_errors: list[BaseException] = []
150
+
151
+ def worker() -> None:
152
+ while True:
153
+ queued_item = work_queue.get()
154
+ try:
155
+ if queued_item is None:
156
+ return
157
+ queued_goal, queued_full_json_mode = queued_item
158
+ run_goal_once(
159
+ queued_goal,
160
+ approval_reader=approval_broker.read,
161
+ full_json_override=queued_full_json_mode,
162
+ )
163
+ except BaseException as exc:
164
+ worker_errors.append(exc)
165
+ finally:
166
+ work_queue.task_done()
167
+
168
+ worker_thread = threading.Thread(target=worker, daemon=True)
169
+ worker_thread.start()
170
+
171
+ def shutdown_worker() -> None:
172
+ work_queue.join()
173
+ work_queue.put(None)
174
+ worker_thread.join()
175
+ if worker_errors:
176
+ raise worker_errors[0]
177
+
178
+ while True:
179
+ try:
180
+ line = line_reader.read(color=runtime_ui_color_enabled())
181
+ except EOFError:
182
+ shutdown_worker()
183
+ return
184
+ goal = line.strip()
185
+ if not goal:
186
+ if line_reader.should_erase_empty_line():
187
+ _erase_empty_runtime_prompt_line()
188
+ continue
189
+ if approval_broker.submit_if_waiting(goal):
190
+ continue
191
+ if _is_runtime_approval_reply(goal):
192
+ if approval_broker.submit_when_waiting(goal, timeout=0.15):
193
+ continue
194
+ if goal.lower() in {"exit", "quit", ":q"}:
195
+ shutdown_worker()
196
+ return
197
+ if goal.startswith("/"):
198
+ work_queue.join()
199
+ if not is_runtime_interactive_command(goal):
200
+ _print_unknown_runtime_interactive_command(goal)
201
+ continue
202
+ with state_lock:
203
+ handled, full_json_mode = _handle_runtime_interactive_command(
204
+ goal,
205
+ full_json_mode,
206
+ session_memory,
207
+ last_payload,
208
+ session_memory_path=session_memory_path,
209
+ trace_dir=trace_dir,
210
+ provider=provider,
211
+ line_reader=line_reader,
212
+ )
213
+ if handled:
214
+ continue
215
+ _print_invalid_runtime_interactive_command(goal)
216
+ continue
217
+ _replace_runtime_prompt_with_user_message(goal)
218
+ with state_lock:
219
+ queued_full_json_mode = full_json_mode
220
+ work_queue.put((goal, queued_full_json_mode))
221
+
222
+ while True:
223
+ try:
224
+ line = sys.stdin.readline()
225
+ except EOFError:
226
+ return
227
+ if line == "":
228
+ return
229
+ goal = line.strip()
230
+ if not goal:
231
+ continue
232
+ if goal.lower() in {"exit", "quit", ":q"}:
233
+ return
234
+ run_goal_once(goal)
235
+
236
+
237
+ def _enable_interactive_line_editing() -> None:
238
+ try:
239
+ import readline # noqa: F401
240
+ except ImportError:
241
+ return
242
+
243
+
244
+ class _RuntimeLineReader:
245
+ def read(self, *, color: bool) -> str:
246
+ raise NotImplementedError
247
+
248
+ def should_erase_empty_line(self) -> bool:
249
+ return True
250
+
251
+ def uses_prompt_status(self) -> bool:
252
+ return False
253
+
254
+ def clear_history(self) -> None:
255
+ return
256
+
257
+ def line_editor_name(self) -> str:
258
+ return "input"
259
+
260
+
261
+ class _InputLineReader(_RuntimeLineReader):
262
+ def read(self, *, color: bool) -> str:
263
+ try:
264
+ return input(runtime_prompt(color=color))
265
+ finally:
266
+ reset = runtime_prompt_reset(color=color)
267
+ if reset:
268
+ sys.stdout.write(reset)
269
+ sys.stdout.flush()
270
+
271
+ def line_editor_name(self) -> str:
272
+ return "readline/input"
273
+
274
+
275
+ class _PromptToolkitLineReader(_RuntimeLineReader):
276
+ def __init__(self, session: Any):
277
+ self._session = session
278
+
279
+ def read(self, *, color: bool) -> str:
280
+ message = _runtime_prompt_fragments(color=color)
281
+ try:
282
+ from prompt_toolkit.patch_stdout import patch_stdout
283
+ except ImportError:
284
+ return self._session.prompt(
285
+ message,
286
+ wrap_lines=True,
287
+ multiline=False,
288
+ prompt_continuation=_runtime_prompt_continuation,
289
+ reserve_space_for_menu=0,
290
+ refresh_interval=0.12,
291
+ )
292
+ with patch_stdout(raw=True):
293
+ return self._session.prompt(
294
+ message,
295
+ wrap_lines=True,
296
+ multiline=False,
297
+ prompt_continuation=_runtime_prompt_continuation,
298
+ reserve_space_for_menu=0,
299
+ refresh_interval=0.12,
300
+ )
301
+
302
+ def clear_history(self) -> None:
303
+ history = getattr(self._session, "history", None)
304
+ loaded_strings = getattr(history, "_loaded_strings", None)
305
+ if isinstance(loaded_strings, list):
306
+ loaded_strings.clear()
307
+
308
+ def should_erase_empty_line(self) -> bool:
309
+ return False
310
+
311
+ def uses_prompt_status(self) -> bool:
312
+ return False
313
+
314
+ def line_editor_name(self) -> str:
315
+ return "prompt_toolkit"
316
+
317
+
318
+ def _runtime_prompt_fragments(*, color: bool) -> Any:
319
+ if not color:
320
+ return "› "
321
+ return [
322
+ ("class:input-bar.prompt", "› "),
323
+ ]
324
+
325
+
326
+ def _runtime_prompt_continuation(
327
+ _width: int,
328
+ _line_number: int,
329
+ _is_soft_wrap: bool,
330
+ ) -> list[tuple[str, str]]:
331
+ return [("class:input-bar.continuation", " ")]
332
+
333
+
334
+ def _runtime_interactive_line_reader(
335
+ prompt_stream: Any,
336
+ ) -> _RuntimeLineReader:
337
+ prompt_toolkit_session = _prompt_toolkit_session_for_tty(prompt_stream)
338
+ if prompt_toolkit_session is not None:
339
+ return _PromptToolkitLineReader(prompt_toolkit_session)
340
+ _enable_interactive_line_editing()
341
+ return _InputLineReader()
342
+
343
+
344
+ def _prompt_toolkit_session_for_tty(prompt_stream: Any) -> Any:
345
+ if sys.stdin is not getattr(sys, "__stdin__", None):
346
+ return None
347
+ if not _stream_is_tty(sys.stdin):
348
+ return None
349
+ if not _stream_is_tty(prompt_stream):
350
+ return None
351
+ try:
352
+ from prompt_toolkit import PromptSession
353
+ from prompt_toolkit.completion import WordCompleter
354
+ from prompt_toolkit.styles import Style
355
+ except ImportError:
356
+ return None
357
+ completer = WordCompleter(
358
+ runtime_interactive_completion_words(),
359
+ ignore_case=True,
360
+ sentence=True,
361
+ )
362
+ return PromptSession(
363
+ complete_while_typing=True,
364
+ completer=completer,
365
+ enable_history_search=True,
366
+ history=runtime_prompt_history(default_runtime_history_path()),
367
+ style=Style.from_dict(
368
+ {
369
+ "": "#ffffff",
370
+ "input-bar": "#ffffff",
371
+ "input-bar.prompt": "ansicyan bold",
372
+ "input-bar.continuation": "#6b7280",
373
+ }
374
+ ),
375
+ )
376
+
377
+
378
+ def _stream_is_tty(stream: Any) -> bool:
379
+ isatty = getattr(stream, "isatty", None)
380
+ return bool(callable(isatty) and isatty())
381
+
382
+
383
+ def _print_runtime_interactive_payload(payload: Any, *, full_json: bool) -> None:
384
+ if full_json:
385
+ print(format_and_write_json(payload, ""))
386
+ return
387
+ if sys.stdin.isatty():
388
+ print()
389
+ print(
390
+ format_runtime_interactive_summary(
391
+ payload,
392
+ color=runtime_ui_color_enabled(),
393
+ )
394
+ )
395
+ if sys.stdin.isatty():
396
+ print()
397
+
398
+
399
+ def _runtime_interactive_progress_sink(
400
+ *,
401
+ enabled: bool,
402
+ ) -> Any:
403
+ if not enabled:
404
+ return None
405
+ return _RuntimeInteractiveProgress()
406
+
407
+
408
+ def _close_runtime_progress_sink(progress_sink: Any) -> None:
409
+ close = getattr(progress_sink, "close", None)
410
+ if callable(close):
411
+ close()
412
+
413
+
414
+ class _RuntimeInteractiveApprovalBroker:
415
+ def __init__(self) -> None:
416
+ self._condition = threading.Condition()
417
+ self._responses: queue.Queue[str] = queue.Queue()
418
+ self._waiting = False
419
+
420
+ def read(self, prompt: str) -> str:
421
+ with self._condition:
422
+ self._waiting = True
423
+ self._condition.notify_all()
424
+ try:
425
+ sys.stdout.write(prompt)
426
+ sys.stdout.flush()
427
+ return self._responses.get()
428
+ finally:
429
+ with self._condition:
430
+ self._waiting = False
431
+ self._condition.notify_all()
432
+
433
+ def submit_if_waiting(self, line: str) -> bool:
434
+ with self._condition:
435
+ if not self._waiting:
436
+ return False
437
+ self._responses.put(line)
438
+ return True
439
+
440
+ def submit_when_waiting(self, line: str, *, timeout: float) -> bool:
441
+ deadline = time.monotonic() + timeout
442
+ with self._condition:
443
+ while not self._waiting:
444
+ remaining = deadline - time.monotonic()
445
+ if remaining <= 0:
446
+ return False
447
+ self._condition.wait(timeout=remaining)
448
+ self._responses.put(line)
449
+ return True
450
+
451
+
452
+ def _is_runtime_approval_reply(goal: str) -> bool:
453
+ return goal.strip().lower() in {
454
+ "y",
455
+ "yes",
456
+ "approve",
457
+ "n",
458
+ "no",
459
+ "d",
460
+ "detail",
461
+ "details",
462
+ "view",
463
+ }
464
+
465
+
466
+ def _erase_empty_runtime_prompt_line() -> None:
467
+ sys.stdout.write("\x1b[1A\r\x1b[2K")
468
+ sys.stdout.flush()
469
+
470
+
471
+ def _replace_runtime_prompt_with_user_message(goal: str) -> None:
472
+ if not runtime_ui_color_enabled():
473
+ return
474
+ sys.stdout.write("\x1b[1A\r\x1b[2K")
475
+ sys.stdout.write(
476
+ runtime_user_message_block(
477
+ goal,
478
+ color=True,
479
+ )
480
+ )
481
+ sys.stdout.write("\n\n")
482
+ sys.stdout.flush()
483
+
484
+
485
+ class _RuntimeInteractiveProgress:
486
+ _FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
487
+
488
+ def __init__(self) -> None:
489
+ self._message = ""
490
+ self._frame_index = 0
491
+ self._last_width = 0
492
+ self._started = False
493
+ self._closed = False
494
+ self._active = False
495
+ self._streaming_answer = False
496
+ self._lock = threading.Lock()
497
+ self._thread: threading.Thread | None = None
498
+
499
+ def __call__(self, event: Any) -> None:
500
+ if isinstance(event, dict):
501
+ event_type = str(event.get("type", "")).strip()
502
+ if event_type == "answer_started":
503
+ self._start_answer_stream()
504
+ return
505
+ if event_type == "answer_delta":
506
+ self._write_answer_delta(str(event.get("delta", "")))
507
+ return
508
+ if event_type == "answer_completed":
509
+ self._finish_answer_stream()
510
+ return
511
+ message = format_runtime_progress_event(
512
+ event,
513
+ color=runtime_ui_color_enabled(),
514
+ )
515
+ if not message or not isinstance(event, dict):
516
+ return
517
+ event_type = str(event.get("type", "")).strip()
518
+ if event_type in {"planner_started", "planner_completed", "tool_started"}:
519
+ self._start_or_update(message)
520
+ return
521
+ self._finish_active(clear=True)
522
+ self._write_line(message)
523
+
524
+ def close(self) -> None:
525
+ with self._lock:
526
+ self._closed = True
527
+ self._finish_active(clear=True)
528
+
529
+ def _start_or_update(self, message: str) -> None:
530
+ with self._lock:
531
+ if self._closed:
532
+ return
533
+ if self._message == message and self._active:
534
+ return
535
+ self._message = message
536
+ if not self._started:
537
+ sys.stdout.write("\n")
538
+ self._started = True
539
+ if not _stream_is_tty(sys.stdout):
540
+ sys.stdout.write(f" {message}\n")
541
+ sys.stdout.flush()
542
+ self._active = False
543
+ self._last_width = 0
544
+ return
545
+ self._active = True
546
+ self._render_locked()
547
+ if self._thread is None or not self._thread.is_alive():
548
+ self._thread = threading.Thread(target=self._spin, daemon=True)
549
+ self._thread.start()
550
+
551
+ def _finish_active(self, *, clear: bool) -> None:
552
+ thread: threading.Thread | None
553
+ with self._lock:
554
+ self._active = False
555
+ thread = self._thread
556
+ if thread is not None and thread.is_alive():
557
+ thread.join(timeout=0.25)
558
+ with self._lock:
559
+ if clear:
560
+ self._clear_locked()
561
+
562
+ def _write_line(self, message: str) -> None:
563
+ with self._lock:
564
+ if not self._started:
565
+ sys.stdout.write("\n")
566
+ self._started = True
567
+ sys.stdout.write(f"{message}\n")
568
+ sys.stdout.flush()
569
+ self._last_width = 0
570
+
571
+ def _start_answer_stream(self) -> None:
572
+ self._finish_active(clear=True)
573
+ with self._lock:
574
+ if not self._started:
575
+ sys.stdout.write("\n")
576
+ self._started = True
577
+ if not self._streaming_answer:
578
+ sys.stdout.write("Answer\n ")
579
+ self._streaming_answer = True
580
+ self._last_width = 0
581
+ sys.stdout.flush()
582
+
583
+ def _write_answer_delta(self, delta: str) -> None:
584
+ if not delta:
585
+ return
586
+ with self._lock:
587
+ if not self._streaming_answer:
588
+ if not self._started:
589
+ sys.stdout.write("\n")
590
+ self._started = True
591
+ sys.stdout.write("Answer\n ")
592
+ self._streaming_answer = True
593
+ sys.stdout.write(delta)
594
+ sys.stdout.flush()
595
+
596
+ def _finish_answer_stream(self) -> None:
597
+ with self._lock:
598
+ if self._streaming_answer:
599
+ sys.stdout.write("\n")
600
+ sys.stdout.flush()
601
+ self._streaming_answer = False
602
+ self._last_width = 0
603
+
604
+ def _spin(self) -> None:
605
+ while True:
606
+ time.sleep(0.12)
607
+ with self._lock:
608
+ if self._closed or not self._active:
609
+ return
610
+ self._frame_index += 1
611
+ self._render_locked()
612
+
613
+ def _render_locked(self) -> None:
614
+ frame = self._FRAMES[self._frame_index % len(self._FRAMES)]
615
+ line = f" {frame} {self._message}"
616
+ padding = " " * max(0, self._last_width - len(line))
617
+ sys.stdout.write(f"\r{line}{padding}")
618
+ sys.stdout.flush()
619
+ self._last_width = len(line)
620
+
621
+ def _clear_locked(self) -> None:
622
+ if self._last_width:
623
+ sys.stdout.write("\r" + (" " * self._last_width) + "\r")
624
+ sys.stdout.flush()
625
+ self._last_width = 0
626
+
627
+
628
+ def _handle_runtime_interactive_command(
629
+ command: str,
630
+ full_json_mode: bool,
631
+ session_memory: RuntimeSessionMemory,
632
+ last_payload: Any,
633
+ *,
634
+ session_memory_path: str = "",
635
+ trace_dir: str = "",
636
+ provider: Any = None,
637
+ line_reader: Any = None,
638
+ ) -> tuple[bool, bool]:
639
+ normalized = command.strip().lower()
640
+ if normalized in {"/json", "/full", "/debug"}:
641
+ print(format_runtime_notice("Output mode", "full JSON traces"))
642
+ return True, True
643
+ if normalized in {"/compact", "/summary"}:
644
+ print(format_runtime_notice("Output mode", "compact transcript"))
645
+ return True, False
646
+ if normalized in {"/help", "/?"}:
647
+ print(runtime_interactive_help())
648
+ return True, full_json_mode
649
+ if normalized in {"/pwd", "/cwd"}:
650
+ print(format_runtime_notice("Working directory", os.getcwd()))
651
+ return True, full_json_mode
652
+ if normalized == "/cd" or normalized.startswith("/cd "):
653
+ _change_runtime_interactive_directory(command)
654
+ return True, full_json_mode
655
+ if normalized in {"/status", "/stat"}:
656
+ print(
657
+ format_runtime_interactive_status(
658
+ cwd=os.getcwd(),
659
+ full_json_mode=full_json_mode,
660
+ session_memory=session_memory,
661
+ last_payload=last_payload,
662
+ trace_dir=trace_dir,
663
+ )
664
+ )
665
+ return True, full_json_mode
666
+ if normalized in {"/doctor", "/diagnostics"}:
667
+ line_editor = ""
668
+ if line_reader is not None:
669
+ line_editor_name = getattr(line_reader, "line_editor_name", None)
670
+ if callable(line_editor_name):
671
+ line_editor = str(line_editor_name())
672
+ print(
673
+ format_runtime_interactive_doctor(
674
+ cwd=os.getcwd(),
675
+ provider=provider,
676
+ session_memory_path=session_memory_path,
677
+ history_path=default_runtime_history_path(),
678
+ trace_dir=trace_dir,
679
+ line_editor=line_editor,
680
+ )
681
+ )
682
+ return True, full_json_mode
683
+ if normalized in {"/config", "/provider"}:
684
+ print(format_runtime_provider_config(provider))
685
+ return True, full_json_mode
686
+ if normalized in {"/tools", "/actions"}:
687
+ from kagent.runtime.tools import registered_runtime_tool_metadata
688
+
689
+ print(format_runtime_interactive_tools(registered_runtime_tool_metadata()))
690
+ return True, full_json_mode
691
+ if normalized in {"/memory", "/mem"}:
692
+ print(format_runtime_session_memory(session_memory))
693
+ return True, full_json_mode
694
+ if normalized in {"/compact-memory", "/compress-memory"}:
695
+ compacted_now = compact_runtime_conversation_memory(session_memory)
696
+ save_runtime_session_memory(session_memory_path, session_memory)
697
+ detail = (
698
+ f"{compacted_now} turn{'s' if compacted_now != 1 else ''} compacted"
699
+ if compacted_now
700
+ else "already compact"
701
+ )
702
+ print(format_runtime_notice("Memory compacted", detail))
703
+ return True, full_json_mode
704
+ if normalized in {"/last", "/last-run"}:
705
+ if last_payload is None:
706
+ print(format_runtime_notice("Last run", "no previous run"))
707
+ else:
708
+ _print_runtime_interactive_payload(last_payload, full_json=False)
709
+ return True, full_json_mode
710
+ if normalized in {"/trace", "/last-json"}:
711
+ if last_payload is None:
712
+ print(format_runtime_notice("Last run", "no previous run"))
713
+ else:
714
+ _print_runtime_interactive_payload(last_payload, full_json=True)
715
+ return True, full_json_mode
716
+ if normalized == "/save-trace" or normalized.startswith("/save-trace "):
717
+ _save_last_runtime_trace(command, last_payload)
718
+ return True, full_json_mode
719
+ if normalized == "/export-trace" or normalized.startswith("/export-trace "):
720
+ _save_last_runtime_trace(command, last_payload)
721
+ return True, full_json_mode
722
+ if normalized in {"/clear", "/clear-memory"}:
723
+ session_memory.clear()
724
+ save_runtime_session_memory(session_memory_path, session_memory)
725
+ print(format_runtime_notice("Memory", "cleared"))
726
+ return True, full_json_mode
727
+ if normalized in {"/reset", "/reset-session"}:
728
+ session_memory.clear()
729
+ save_runtime_session_memory(session_memory_path, session_memory)
730
+ clear_runtime_history(default_runtime_history_path())
731
+ if line_reader is not None:
732
+ clear_history = getattr(line_reader, "clear_history", None)
733
+ if callable(clear_history):
734
+ clear_history()
735
+ print(format_runtime_notice("Reset", "memory and prompt history cleared"))
736
+ return True, full_json_mode
737
+ return False, full_json_mode
738
+
739
+
740
+ def _print_unknown_runtime_interactive_command(command: str) -> None:
741
+ suggestions = runtime_interactive_command_suggestions(command)
742
+ detail = "try /help"
743
+ if suggestions:
744
+ detail = "try " + ", ".join(suggestions)
745
+ print(format_runtime_notice("Unknown command", detail))
746
+
747
+
748
+ def _print_invalid_runtime_interactive_command(command: str) -> None:
749
+ usage = runtime_interactive_command_usage(command)
750
+ detail = f"usage: {usage}" if usage else "try /help"
751
+ print(format_runtime_notice("Invalid command", detail))
752
+
753
+
754
+ def _save_last_runtime_trace(command: str, last_payload: Any) -> None:
755
+ if last_payload is None:
756
+ print(format_runtime_notice("Last run", "no previous run"))
757
+ return
758
+ try:
759
+ parts = shlex.split(command.strip())
760
+ except ValueError as exc:
761
+ print(format_runtime_notice("Save trace failed", str(exc)))
762
+ return
763
+ if len(parts) < 2 or not parts[1].strip():
764
+ print(format_runtime_notice("Save trace", "path required: /save-trace PATH"))
765
+ return
766
+ try:
767
+ saved_path = save_runtime_trace_snapshot_or_raise(
768
+ last_payload,
769
+ parts[1].strip(),
770
+ )
771
+ except (OSError, ValueError) as exc:
772
+ print(format_runtime_notice("Save trace failed", str(exc)))
773
+ return
774
+ print(format_runtime_notice("Trace saved", saved_path))
775
+
776
+
777
+ def _change_runtime_interactive_directory(command: str) -> None:
778
+ raw_path = command.strip()[3:].strip()
779
+ target = os.path.expanduser(raw_path or "~")
780
+ if not os.path.isabs(target):
781
+ target = os.path.abspath(target)
782
+ if not os.path.isdir(target):
783
+ print(format_runtime_notice("Directory not found", target))
784
+ return
785
+ os.chdir(target)
786
+ print(format_runtime_notice("Working directory", os.getcwd()))
787
+
788
+
789
+ def _maybe_run_approved_runtime_action(
790
+ *,
791
+ payload: Any,
792
+ goal: str,
793
+ run_runtime_agent: Any,
794
+ metadata: dict[str, str] | None = None,
795
+ tags: list[str] | None = None,
796
+ progress_enabled: bool = True,
797
+ response_reader: Any = None,
798
+ ) -> Any:
799
+ pending = payload.get("pending_approval") if isinstance(payload, dict) else None
800
+ if not isinstance(pending, dict):
801
+ return None
802
+ action_id = str(pending.get("id", "")).strip()
803
+ tool = str(pending.get("tool", "")).strip()
804
+ if not action_id or not tool:
805
+ return None
806
+ while True:
807
+ prompt = approval_prompt(action_id, tool, color=runtime_ui_color_enabled())
808
+ if response_reader is None:
809
+ answer = input(prompt).strip().lower()
810
+ else:
811
+ answer = str(response_reader(prompt)).strip().lower()
812
+ if answer in {"d", "detail", "details", "view"}:
813
+ print(format_runtime_pending_approval_detail(pending))
814
+ continue
815
+ break
816
+ if answer not in {"y", "yes", "approve"}:
817
+ print(format_runtime_notice("Approval skipped", "action not approved"))
818
+ return None
819
+ progress_sink = _runtime_interactive_progress_sink(enabled=progress_enabled)
820
+ try:
821
+ return json_ready(
822
+ run_runtime_agent(
823
+ goal,
824
+ provider=_InlineRuntimePlanProvider({"actions": [pending]}),
825
+ max_iterations=1,
826
+ approved_action_ids={action_id},
827
+ metadata=metadata,
828
+ tags=tags,
829
+ event_sink=progress_sink,
830
+ )
831
+ )
832
+ finally:
833
+ _close_runtime_progress_sink(progress_sink)
834
+
835
+
836
+ class _InlineRuntimePlanProvider:
837
+ def __init__(self, plan: dict) -> None:
838
+ self.plan = plan
839
+
840
+ def complete(self, _system: str, _user: str) -> str:
841
+ return json.dumps(self.plan, ensure_ascii=False, sort_keys=True)