@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,931 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import sys
7
+ import textwrap
8
+ import unicodedata
9
+ from typing import Any
10
+
11
+ from kagent.cli.commands import runtime_interactive_commands
12
+ from kagent.cli.memory import RuntimeSessionMemory, coerce_runtime_session_memory
13
+ from kagent.utils.json_output import json_ready
14
+
15
+
16
+ def runtime_ui_color_enabled() -> bool:
17
+ return (
18
+ sys.stdout.isatty()
19
+ and "NO_COLOR" not in os.environ
20
+ and os.environ.get("TERM", "") != "dumb"
21
+ )
22
+
23
+
24
+ def runtime_ready_message(*, color: bool = False) -> str:
25
+ subtitle = "local terminal agent"
26
+ product_line = "ask anything. approve actions when needed."
27
+ return "\n".join(
28
+ [
29
+ _color("kagent", "bold", enabled=color),
30
+ _dim(subtitle, enabled=color),
31
+ "",
32
+ _dim(product_line, enabled=color),
33
+ ]
34
+ )
35
+
36
+
37
+ def runtime_prompt(*, color: bool = False) -> str:
38
+ if not color:
39
+ return "› "
40
+ return (
41
+ "\001\033[36m\002"
42
+ "› "
43
+ "\001\033[97m\002"
44
+ )
45
+
46
+
47
+ def runtime_prompt_reset(*, color: bool = False) -> str:
48
+ return "\033[0m" if color else ""
49
+
50
+
51
+ def runtime_user_message_block(
52
+ message: str,
53
+ *,
54
+ color: bool = False,
55
+ width: int | None = None,
56
+ ) -> str:
57
+ text = " ".join(str(message).split())
58
+ line = f"› {text}" if text else "›"
59
+ if not color:
60
+ return line
61
+ return f"{_color('›', 'cyan', enabled=True)} {_color(text, 'white', enabled=True)}"
62
+
63
+
64
+ def runtime_setup_message(*, config_path: str, color: bool = False) -> str:
65
+ return "\n".join(
66
+ [
67
+ _color("kagent setup", "bold", enabled=color),
68
+ " Configure your provider once.",
69
+ _dim("Choose a provider once, then kagent opens directly next time.", enabled=color),
70
+ "",
71
+ f"Config {config_path}",
72
+ ]
73
+ )
74
+
75
+
76
+ def runtime_interactive_help() -> str:
77
+ commands = runtime_interactive_commands()
78
+ command_width = max(len(command.primary) for command in commands)
79
+ lines = ["kagent command palette"]
80
+ for section in ("Session", "Provider", "Output", "Debug"):
81
+ section_commands = [
82
+ command for command in commands if command.section == section
83
+ ]
84
+ if not section_commands:
85
+ continue
86
+ lines.append("")
87
+ lines.append(section)
88
+ for command in section_commands:
89
+ lines.append(
90
+ f" {command.primary.ljust(command_width)} {command.description}"
91
+ )
92
+ return "\n".join(lines)
93
+
94
+
95
+ def format_runtime_provider_config(provider: Any) -> str:
96
+ snapshot = _provider_redacted_snapshot(provider)
97
+ if not snapshot:
98
+ return "kagent provider\n provider inline/test\n api_key not configured"
99
+ provider_name = str(
100
+ snapshot.get("llm_provider_display_name")
101
+ or snapshot.get("llm_provider")
102
+ or "unknown"
103
+ )
104
+ base_url = str(snapshot.get("llm_base_url", "")).strip() or "-"
105
+ model = str(snapshot.get("llm_model", "")).strip() or "-"
106
+ api_key_state = (
107
+ "configured"
108
+ if str(snapshot.get("llm_api_key_configured", "")).lower() == "true"
109
+ else "not configured"
110
+ )
111
+ timeout = str(snapshot.get("llm_timeout_seconds", "")).strip()
112
+ retries = str(snapshot.get("llm_max_retries", "")).strip()
113
+ backoff = str(snapshot.get("llm_retry_backoff_seconds", "")).strip()
114
+ return "\n".join(
115
+ [
116
+ "kagent provider",
117
+ f" provider {provider_name}",
118
+ f" base_url {base_url}",
119
+ f" model {model}",
120
+ f" api_key {api_key_state}",
121
+ f" timeout {timeout}s" if timeout else " timeout -",
122
+ f" retries {retries}" if retries else " retries -",
123
+ f" backoff {backoff}s" if backoff else " backoff -",
124
+ ]
125
+ )
126
+
127
+
128
+ def _provider_redacted_snapshot(provider: Any) -> dict[str, Any]:
129
+ config = getattr(provider, "config", None)
130
+ snapshot_fn = getattr(config, "redacted_snapshot", None)
131
+ if not callable(snapshot_fn):
132
+ return {}
133
+ snapshot = snapshot_fn()
134
+ return snapshot if isinstance(snapshot, dict) else {}
135
+
136
+
137
+ def format_runtime_interactive_tools(tools: list[dict[str, Any]]) -> str:
138
+ rows = []
139
+ for tool in sorted(tools, key=lambda item: str(item.get("name", ""))):
140
+ name = str(tool.get("name", "")).strip()
141
+ if not name:
142
+ continue
143
+ if name in {"note"}:
144
+ continue
145
+ approval = str(tool.get("approval_required_by_default", "")).strip().lower()
146
+ access = "approval" if approval == "true" else "allowed"
147
+ description = _one_line_text(str(tool.get("description", "")).strip())
148
+ rows.append((name, access, description))
149
+ if not rows:
150
+ return "kagent actions\n no external actions registered"
151
+ name_width = max(len(name) for name, _access, _description in rows)
152
+ return "\n".join(
153
+ ["kagent actions"]
154
+ + [
155
+ f" {name.ljust(name_width)} {access.ljust(8)} {description}".rstrip()
156
+ for name, access, description in rows
157
+ ]
158
+ )
159
+
160
+
161
+ def format_runtime_interactive_status(
162
+ *,
163
+ cwd: str,
164
+ full_json_mode: bool,
165
+ session_memory: RuntimeSessionMemory | list[dict[str, str]],
166
+ last_payload: Any,
167
+ trace_dir: str = "",
168
+ ) -> str:
169
+ memory = coerce_runtime_session_memory(session_memory)
170
+ memory_count = len(memory.turns)
171
+ memory_label = "turn" if memory_count == 1 else "turns"
172
+ compacted = f", {memory.compacted_turn_count} compacted" if memory.compacted_turn_count else ""
173
+ last_status = "-"
174
+ if isinstance(last_payload, dict):
175
+ last_status = str(last_payload.get("status", "")).strip() or "-"
176
+ return "\n".join(
177
+ [
178
+ "kagent session",
179
+ f" cwd {cwd}",
180
+ f" output {'full JSON' if full_json_mode else 'compact'}",
181
+ f" memory {memory_count} recent {memory_label}{compacted}",
182
+ f" last {last_status}",
183
+ f" trace {trace_dir or 'off'}",
184
+ ]
185
+ )
186
+
187
+
188
+ def format_runtime_interactive_doctor(
189
+ *,
190
+ cwd: str,
191
+ provider: Any,
192
+ session_memory_path: str,
193
+ history_path: str,
194
+ trace_dir: str,
195
+ line_editor: str,
196
+ ) -> str:
197
+ provider_snapshot = _provider_redacted_snapshot(provider)
198
+ provider_name = str(
199
+ provider_snapshot.get("llm_provider_display_name")
200
+ or provider_snapshot.get("llm_provider")
201
+ or "inline/test"
202
+ )
203
+ model = str(provider_snapshot.get("llm_model", "")).strip() or "-"
204
+ base_url_state = (
205
+ "configured"
206
+ if str(provider_snapshot.get("llm_base_url", "")).strip()
207
+ else "not configured"
208
+ )
209
+ api_key_state = (
210
+ "configured"
211
+ if str(provider_snapshot.get("llm_api_key_configured", "")).lower() == "true"
212
+ else "not configured"
213
+ )
214
+ return "\n".join(
215
+ [
216
+ "kagent doctor",
217
+ f" cwd {cwd}",
218
+ f" provider {provider_name}",
219
+ f" model {model}",
220
+ f" base_url {base_url_state}",
221
+ f" api_key {api_key_state}",
222
+ f" memory {session_memory_path or 'off'}",
223
+ f" history {history_path or 'off'}",
224
+ f" line_editor {line_editor or '-'}",
225
+ f" trace {trace_dir or 'off'}",
226
+ ]
227
+ )
228
+
229
+
230
+ def format_runtime_session_memory(
231
+ session_memory: RuntimeSessionMemory | list[dict[str, str]],
232
+ ) -> str:
233
+ memory = coerce_runtime_session_memory(session_memory)
234
+ if not memory:
235
+ return "Memory is empty."
236
+ lines = ["Memory"]
237
+ if memory.summary:
238
+ lines.append(" summary")
239
+ lines.extend(_indented_lines(memory.summary, prefix=" "))
240
+ if memory.facts:
241
+ lines.append(" facts")
242
+ lines.extend(f" - {fact}" for fact in memory.facts)
243
+ if memory.open_items:
244
+ lines.append(" open items")
245
+ lines.extend(f" - {item}" for item in memory.open_items)
246
+ if memory.compacted_turn_count:
247
+ lines.append(f" compacted turns {memory.compacted_turn_count}")
248
+ if memory.turns:
249
+ lines.append(" recent turns")
250
+ for index, turn in enumerate(memory.turns, start=1):
251
+ user = turn.get("user", "")
252
+ assistant = turn.get("assistant", "")
253
+ lines.append(f" {index}. user {user}")
254
+ if assistant:
255
+ lines.append(f" agent {assistant}")
256
+ return "\n".join(lines)
257
+
258
+
259
+ def format_runtime_notice(title: str, detail: str = "") -> str:
260
+ lines = [str(title).strip()]
261
+ detail_text = str(detail).strip()
262
+ if detail_text:
263
+ lines.extend(_indented_lines(detail_text, prefix=" "))
264
+ return "\n".join(lines)
265
+
266
+
267
+ def format_runtime_pending_approval_detail(pending: dict) -> str:
268
+ return "\n".join(
269
+ [
270
+ "Approval detail",
271
+ *_indented_lines(_format_pending_approval(pending), prefix=" "),
272
+ " reply y approve, n skip",
273
+ ]
274
+ )
275
+
276
+
277
+ def format_runtime_interactive_summary(payload: Any, *, color: bool = False) -> str:
278
+ if not isinstance(payload, dict):
279
+ return str(payload)
280
+
281
+ status = str(payload.get("status", "")).strip()
282
+ pending = payload.get("pending_approval")
283
+ if status == "requires_approval" and isinstance(pending, dict):
284
+ return _format_runtime_approval_summary(pending, color=color)
285
+
286
+ lines = [_format_run_status(payload, status, color=color)]
287
+
288
+ answer = (
289
+ ""
290
+ if payload.get("answer_streamed") == "true"
291
+ else str(payload.get("answer", "")).strip()
292
+ )
293
+ if answer:
294
+ lines.append("")
295
+ lines.append(_dim("Answer", enabled=color))
296
+ lines.extend(_answer_lines(answer))
297
+
298
+ error_code = str(payload.get("error_code", "")).strip()
299
+ error = str(payload.get("error", "")).strip()
300
+ if error_code or error:
301
+ lines.append("")
302
+ lines.append(_color("Error", "red", enabled=color))
303
+ lines.extend(_indented_lines(join_non_empty([error_code, error], " "), prefix=" "))
304
+
305
+ visible_steps = visible_runtime_steps(payload.get("steps"))
306
+ if visible_steps:
307
+ lines.append("")
308
+ lines.append(_dim("Steps", enabled=color))
309
+ for step in visible_steps:
310
+ lines.extend(format_runtime_step_lines(step, color=color))
311
+
312
+ visible_observations = visible_runtime_observations(
313
+ payload.get("observations"),
314
+ successful_only=bool(answer),
315
+ )
316
+ if visible_observations:
317
+ lines.append("")
318
+ lines.append(_dim("Results", enabled=color))
319
+ for observation, repeat_count in visible_observations:
320
+ lines.extend(
321
+ format_runtime_result_lines(
322
+ observation, color=color, repeat_count=repeat_count
323
+ )
324
+ )
325
+
326
+ return "\n".join(lines)
327
+
328
+
329
+ def visible_runtime_steps(steps: Any) -> list[dict]:
330
+ if not isinstance(steps, list):
331
+ return []
332
+ visible = []
333
+ for step in steps:
334
+ if isinstance(step, dict):
335
+ visible.append(step)
336
+ return visible
337
+
338
+
339
+ def format_runtime_step_lines(step: dict, *, color: bool = False) -> list[str]:
340
+ state = str(step.get("state", "")).strip()
341
+ title = str(step.get("title", "")).strip()
342
+ detail = str(step.get("detail", "")).strip()
343
+ headline = join_non_empty([_status_icon(state, color=color), title], " ")
344
+ lines = _wrapped_block_lines(headline or _status_icon(state, color=color), prefix=" ")
345
+ if detail:
346
+ lines.extend(_indented_lines(detail, prefix=" "))
347
+ return lines
348
+
349
+
350
+ def format_runtime_progress_event(event: Any, *, color: bool = False) -> str:
351
+ if not isinstance(event, dict):
352
+ return ""
353
+ event_type = str(event.get("type", "")).strip()
354
+ if event_type == "planner_started":
355
+ return _dim("working...", enabled=color)
356
+ if event_type == "planner_completed":
357
+ return ""
358
+ if event_type == "tool_started":
359
+ tool = str(event.get("tool", "")).strip() or "tool"
360
+ if _is_internal_progress_tool(tool):
361
+ return ""
362
+ return _dim("working...", enabled=color)
363
+ if event_type == "tool_completed":
364
+ status = str(event.get("status", "")).strip()
365
+ tool = str(event.get("tool", "")).strip() or "tool"
366
+ if _is_internal_progress_tool(tool) and status in {"ok", "done"}:
367
+ return ""
368
+ icon = _status_icon(status, color=color)
369
+ return join_non_empty([f"{icon} Done", _progress_duration(event)], " · ")
370
+ if event_type == "approval_required":
371
+ return ""
372
+ if event_type == "planner_failed":
373
+ return ""
374
+ return ""
375
+
376
+
377
+ def visible_runtime_observations(
378
+ observations: Any,
379
+ *,
380
+ successful_only: bool = False,
381
+ ) -> list[tuple[dict, int]]:
382
+ if not isinstance(observations, list):
383
+ return []
384
+ visible = []
385
+ for observation, repeat_count in _collapse_runtime_observations(observations):
386
+ if _is_internal_note_observation(observation):
387
+ continue
388
+ if successful_only and not _is_successful_observation(observation):
389
+ continue
390
+ visible.append((observation, repeat_count))
391
+ return visible
392
+
393
+
394
+ def format_runtime_observation(
395
+ observation: dict,
396
+ *,
397
+ color: bool = False,
398
+ repeat_count: int = 1,
399
+ ) -> str:
400
+ return "\n".join(
401
+ format_runtime_observation_lines(
402
+ observation, color=color, repeat_count=repeat_count
403
+ )
404
+ )
405
+
406
+
407
+ def format_runtime_observation_lines(
408
+ observation: dict,
409
+ *,
410
+ color: bool = False,
411
+ repeat_count: int = 1,
412
+ ) -> list[str]:
413
+ status = str(observation.get("status", "")).strip() or "-"
414
+ tool = str(observation.get("tool", "")).strip() or "-"
415
+ duration = str(observation.get("duration_seconds", "")).strip()
416
+ summary = summarize_runtime_output(observation.get("output"), tool=tool)
417
+ suffix = f" x{repeat_count}" if repeat_count > 1 else ""
418
+ error_code = str(observation.get("error_code", "")).strip()
419
+ error = str(observation.get("error", "")).strip()
420
+
421
+ headline = [join_non_empty([_status_icon(status, color=color), tool + suffix], " ")]
422
+ if duration:
423
+ headline.append(_dim(f"{duration}s", enabled=color))
424
+ if summary:
425
+ headline.append(_dim(summary, enabled=color))
426
+ lines = [" " + " · ".join(headline)]
427
+ if error_code or error:
428
+ lines.extend(_indented_lines(join_non_empty([error_code, error], " "), prefix=" "))
429
+ return lines
430
+
431
+
432
+ def format_runtime_result_lines(
433
+ observation: dict,
434
+ *,
435
+ color: bool = False,
436
+ repeat_count: int = 1,
437
+ ) -> list[str]:
438
+ status = str(observation.get("status", "")).strip()
439
+ tool = str(observation.get("tool", "")).strip()
440
+ summary = summarize_runtime_user_result(observation.get("output"), tool=tool)
441
+ if not summary:
442
+ summary = _user_action_label(tool)
443
+ suffix = f" x{repeat_count}" if repeat_count > 1 else ""
444
+ headline = join_non_empty([_status_icon(status, color=color), summary + suffix], " ")
445
+ error = _user_runtime_error(observation)
446
+ lines = _wrapped_block_lines(headline, prefix=" ")
447
+ if error:
448
+ lines.extend(_indented_lines(error, prefix=" "))
449
+ return lines
450
+
451
+
452
+ def summarize_runtime_user_result(output: Any, *, tool: str = "") -> str:
453
+ if not isinstance(output, dict) or not output:
454
+ return ""
455
+ normalized_tool = tool.strip()
456
+ if normalized_tool == "open_url":
457
+ url = _short_runtime_value(output.get("url", ""))
458
+ application = _short_runtime_value(output.get("application", ""))
459
+ return join_non_empty(
460
+ [
461
+ f"Opened {url}" if url else "Opened URL",
462
+ f"in {application}" if application else "",
463
+ ],
464
+ " ",
465
+ )
466
+ if normalized_tool == "open_app":
467
+ application = _short_runtime_value(output.get("application", ""))
468
+ if output.get("opened") is True:
469
+ return f"Opened {application}" if application else "Opened app"
470
+ return application
471
+ if normalized_tool == "apply_patch":
472
+ changed = _summarize_changed_files(output.get("changed_files"))
473
+ return f"Updated files {changed}" if changed else "Updated files"
474
+ if normalized_tool == "revert_patch":
475
+ changed = _summarize_changed_files(output.get("changed_files"))
476
+ return f"Restored files {changed}" if changed else "Restored files"
477
+ if normalized_tool == "read_file":
478
+ path = _short_runtime_value(output.get("path", ""))
479
+ return f"Read {path}" if path else "Read file"
480
+ if normalized_tool == "list_files":
481
+ root = _short_runtime_value(output.get("root", ""))
482
+ count = output.get("file_count")
483
+ count_label = f"{count} files" if count is not None else ""
484
+ return join_non_empty(
485
+ [f"Listed {root}" if root else "Listed files", count_label],
486
+ " · ",
487
+ )
488
+ if normalized_tool == "artifact":
489
+ title = _short_runtime_value(output.get("title", ""))
490
+ return f"Created artifact {title}" if title else "Created artifact"
491
+ if normalized_tool == "http_request":
492
+ url = _short_runtime_value(output.get("url", ""))
493
+ status_code = str(output.get("status_code", "")).strip()
494
+ return join_non_empty(
495
+ [f"Fetched {url}" if url else "Fetched URL", status_code],
496
+ " · ",
497
+ )
498
+ return summarize_runtime_output(output, tool=tool)
499
+
500
+
501
+ def summarize_runtime_output(output: Any, *, tool: str = "") -> str:
502
+ if not isinstance(output, dict) or not output:
503
+ return ""
504
+ tool_summary = _summarize_runtime_output_for_tool(tool, output)
505
+ if tool_summary:
506
+ return tool_summary
507
+ preferred_keys = [
508
+ "url",
509
+ "path",
510
+ "changed_files",
511
+ "file_count",
512
+ "application",
513
+ "opened",
514
+ "status_code",
515
+ "content_type",
516
+ "artifact_id",
517
+ "title",
518
+ ]
519
+ items = []
520
+ for key in preferred_keys:
521
+ if key in output:
522
+ items.append(f"{key}={_short_runtime_value(output[key])}")
523
+ if not items:
524
+ for key in sorted(output)[:3]:
525
+ items.append(f"{key}={_short_runtime_value(output[key])}")
526
+ return ", ".join(items)
527
+
528
+
529
+ def approval_prompt(action_id: str, tool: str, *, color: bool = False) -> str:
530
+ return _color("Approve this action?", "yellow", enabled=color) + " [y/N/d] "
531
+
532
+
533
+ def join_non_empty(values: list[str], separator: str) -> str:
534
+ return separator.join(value for value in values if value)
535
+
536
+
537
+ def _format_run_status(payload: dict, status: str, *, color: bool) -> str:
538
+ parts = [_status_label(status, color=color)]
539
+ if status.strip() == "requires_approval":
540
+ parts.append("pending")
541
+ duration = str(payload.get("duration_seconds", "")).strip()
542
+ if duration:
543
+ parts.append(f"{duration}s")
544
+ return " · ".join(parts)
545
+
546
+
547
+ def _format_runtime_approval_summary(pending: dict, *, color: bool) -> str:
548
+ lines = [_color("Approval needed", "yellow", enabled=color)]
549
+ lines.extend(_indented_lines(_format_pending_approval(pending), prefix=" "))
550
+ return "\n".join(lines)
551
+
552
+
553
+ def _format_pending_approval(pending: dict) -> str:
554
+ tool = str(pending.get("tool", "")).strip()
555
+ lines = []
556
+ if tool:
557
+ lines.append(f"action {_approval_action_label(tool)}")
558
+ action_input = pending.get("input")
559
+ input_summary = _summarize_pending_input(action_input, tool=tool)
560
+ if input_summary:
561
+ lines.append(f"target {input_summary}")
562
+ reason = str(pending.get("reason", "")).strip()
563
+ if reason:
564
+ lines.append(f"reason {reason}")
565
+ return "\n".join(lines)
566
+
567
+
568
+ def _approval_action_label(tool: str) -> str:
569
+ labels = {
570
+ "apply_patch": "Edit files",
571
+ "http_request": "Fetch URL",
572
+ "open_app": "Open app",
573
+ "open_url": "Open URL",
574
+ "revert_patch": "Restore files",
575
+ "shell_command": "Run command",
576
+ }
577
+ return labels.get(tool.strip(), tool.strip() or "Run action")
578
+
579
+
580
+ def _user_action_label(tool: str) -> str:
581
+ labels = {
582
+ "apply_patch": "Updated files",
583
+ "http_request": "Fetched URL",
584
+ "open_app": "Opened app",
585
+ "open_url": "Opened URL",
586
+ "revert_patch": "Restored files",
587
+ "shell_command": "Ran command",
588
+ }
589
+ return labels.get(tool.strip(), "Completed action")
590
+
591
+
592
+ def _user_runtime_error(observation: dict) -> str:
593
+ status = str(observation.get("status", "")).strip()
594
+ if status in {"ok", "done"}:
595
+ return ""
596
+ error = str(observation.get("error", "")).strip()
597
+ if error:
598
+ return error
599
+ return "Action did not complete."
600
+
601
+
602
+ def _summarize_pending_input(action_input: Any, *, tool: str) -> str:
603
+ if not isinstance(action_input, dict):
604
+ return ""
605
+ if tool in {"open_url", "http_request"}:
606
+ return _short_runtime_value(action_input.get("url", ""))
607
+ if tool == "open_app":
608
+ return _short_runtime_value(action_input.get("application", ""))
609
+ if tool == "shell_command":
610
+ return _short_runtime_value(action_input.get("command", ""))
611
+ return summarize_runtime_output(action_input, tool=tool)
612
+
613
+
614
+ def _answer_lines(text: str) -> list[str]:
615
+ return _wrapped_block_lines(text, prefix=" ")
616
+
617
+
618
+ def _indented_lines(text: str, prefix: str = " ") -> list[str]:
619
+ return _wrapped_block_lines(text, prefix=prefix)
620
+
621
+
622
+ def _wrapped_block_lines(text: str, *, prefix: str) -> list[str]:
623
+ width = _ui_width()
624
+ wrap_width = max(20, width - len(prefix))
625
+ lines: list[str] = []
626
+ in_fence = False
627
+ for raw_line in text.splitlines():
628
+ line = raw_line.rstrip()
629
+ if line.strip().startswith("```"):
630
+ in_fence = not in_fence
631
+ lines.append(prefix + line)
632
+ continue
633
+ if in_fence or not line.strip():
634
+ lines.append(prefix + line)
635
+ continue
636
+ leading = line[: len(line) - len(line.lstrip())]
637
+ content = line.lstrip()
638
+ bullet_prefix = _markdown_continuation_prefix(content)
639
+ wrapped = _wrap_display_text(content, width=max(20, wrap_width - len(leading)))
640
+ if not wrapped:
641
+ lines.append(prefix + line)
642
+ continue
643
+ lines.append(prefix + leading + wrapped[0])
644
+ for continuation in wrapped[1:]:
645
+ lines.append(prefix + leading + bullet_prefix + continuation)
646
+ return lines
647
+
648
+
649
+ def _markdown_continuation_prefix(text: str) -> str:
650
+ stripped = text.lstrip()
651
+ if stripped.startswith(("- ", "* ")):
652
+ return " "
653
+ if len(stripped) > 3 and stripped[0].isdigit() and stripped[1:3] == ". ":
654
+ return " "
655
+ return ""
656
+
657
+
658
+ def _wrap_display_text(text: str, *, width: int) -> list[str]:
659
+ if _contains_wide_text(text):
660
+ return _hard_wrap_display_text(text, width=width)
661
+ wrapped = textwrap.wrap(
662
+ text,
663
+ width=width,
664
+ break_long_words=False,
665
+ break_on_hyphens=False,
666
+ )
667
+ if not wrapped:
668
+ return []
669
+ lines: list[str] = []
670
+ for line in wrapped:
671
+ lines.append(line)
672
+ return lines
673
+
674
+
675
+ def _hard_wrap_display_text(text: str, *, width: int) -> list[str]:
676
+ lines: list[str] = []
677
+ current = ""
678
+ current_width = 0
679
+ for char in text:
680
+ char_width = _display_width(char)
681
+ if current and current_width + char_width > width:
682
+ if _is_leading_punctuation(char):
683
+ previous = current[-1]
684
+ current = current[:-1].rstrip()
685
+ if current:
686
+ lines.append(current)
687
+ current = previous + char
688
+ current_width = _display_width(current)
689
+ continue
690
+ lines.append(current.rstrip())
691
+ current = ""
692
+ current_width = 0
693
+ current += char
694
+ current_width += char_width
695
+ if current:
696
+ lines.append(current.rstrip())
697
+ return lines
698
+
699
+
700
+ def _display_width(text: str) -> int:
701
+ width = 0
702
+ for char in text:
703
+ if unicodedata.combining(char):
704
+ continue
705
+ width += 2 if unicodedata.east_asian_width(char) in {"F", "W"} else 1
706
+ return width
707
+
708
+
709
+ def _contains_wide_text(text: str) -> bool:
710
+ return any(unicodedata.east_asian_width(char) in {"F", "W"} for char in text)
711
+
712
+
713
+ def _is_leading_punctuation(char: str) -> bool:
714
+ return char in "、。,!?;:,.!?;:)]})】》"
715
+
716
+
717
+ def _progress_duration(event: dict) -> str:
718
+ duration = str(event.get("duration_seconds", "")).strip()
719
+ return f"{duration}s" if duration else ""
720
+
721
+
722
+ def _collapse_runtime_observations(observations: list) -> list[tuple[dict, int]]:
723
+ collapsed: list[tuple[dict, int]] = []
724
+ last_signature = None
725
+ for observation in observations:
726
+ if not isinstance(observation, dict):
727
+ continue
728
+ signature = _runtime_observation_signature(observation)
729
+ if collapsed and signature == last_signature:
730
+ previous, count = collapsed[-1]
731
+ collapsed[-1] = (previous, count + 1)
732
+ continue
733
+ collapsed.append((observation, 1))
734
+ last_signature = signature
735
+ return collapsed
736
+
737
+
738
+ def _runtime_observation_signature(observation: dict) -> str:
739
+ stable_observation = {
740
+ "action_id": observation.get("action_id"),
741
+ "error": observation.get("error"),
742
+ "error_code": observation.get("error_code"),
743
+ "output": observation.get("output"),
744
+ "status": observation.get("status"),
745
+ "tool": observation.get("tool"),
746
+ }
747
+ return json.dumps(json_ready(stable_observation), ensure_ascii=False, sort_keys=True)
748
+
749
+
750
+ def _is_internal_note_observation(observation: dict) -> bool:
751
+ return (
752
+ str(observation.get("tool", "")).strip() == "note"
753
+ and str(observation.get("status", "")).strip() in {"ok", "done"}
754
+ and not str(observation.get("error_code", "")).strip()
755
+ and not str(observation.get("error", "")).strip()
756
+ )
757
+
758
+
759
+ def _is_successful_observation(observation: dict) -> bool:
760
+ return str(observation.get("status", "")).strip() in {"ok", "done"}
761
+
762
+
763
+ def _is_internal_progress_tool(tool: str) -> bool:
764
+ return tool.strip() == "note"
765
+
766
+
767
+ def _summarize_runtime_output_for_tool(tool: str, output: dict) -> str:
768
+ normalized_tool = tool.strip()
769
+ if normalized_tool in {"apply_patch", "revert_patch"}:
770
+ return _summarize_changed_files(output.get("changed_files"))
771
+ if normalized_tool == "open_url":
772
+ return join_non_empty(
773
+ [
774
+ _short_runtime_value(output.get("url", "")),
775
+ _short_runtime_value(output.get("application", "")),
776
+ "opened" if output.get("opened") is True else "",
777
+ ],
778
+ " · ",
779
+ )
780
+ if normalized_tool == "open_app":
781
+ return join_non_empty(
782
+ [
783
+ _short_runtime_value(output.get("application", "")),
784
+ "opened" if output.get("opened") is True else "",
785
+ ],
786
+ " · ",
787
+ )
788
+ if normalized_tool == "http_request":
789
+ return join_non_empty(
790
+ [
791
+ _short_runtime_value(output.get("url", "")),
792
+ str(output.get("status_code", "")).strip(),
793
+ _short_runtime_value(output.get("content_type", "")),
794
+ ],
795
+ " · ",
796
+ )
797
+ if normalized_tool == "read_file":
798
+ return join_non_empty(
799
+ [
800
+ _short_runtime_value(output.get("path", "")),
801
+ _bytes_label(output.get("bytes")),
802
+ "truncated" if output.get("truncated") is True else "",
803
+ ],
804
+ " · ",
805
+ )
806
+ if normalized_tool == "list_files":
807
+ return join_non_empty(
808
+ [
809
+ _short_runtime_value(output.get("root", "")),
810
+ f"{output.get('file_count')} files"
811
+ if output.get("file_count") is not None
812
+ else "",
813
+ "truncated" if output.get("truncated") is True else "",
814
+ ],
815
+ " · ",
816
+ )
817
+ if normalized_tool == "artifact":
818
+ return join_non_empty(
819
+ [
820
+ _short_runtime_value(output.get("title", "")),
821
+ _short_runtime_value(output.get("kind", "")),
822
+ _short_runtime_value(output.get("format", "")),
823
+ _bytes_label(output.get("bytes")),
824
+ ],
825
+ " · ",
826
+ )
827
+ return ""
828
+
829
+
830
+ def _summarize_changed_files(changed_files: Any) -> str:
831
+ if not isinstance(changed_files, list) or not changed_files:
832
+ return ""
833
+ parts = []
834
+ for item in changed_files[:3]:
835
+ if isinstance(item, dict):
836
+ operation = str(item.get("operation", "")).strip()
837
+ path = str(item.get("path", "")).strip()
838
+ bytes_label = _bytes_label(item.get("bytes"))
839
+ parts.append(join_non_empty([operation, path, bytes_label], " "))
840
+ else:
841
+ parts.append(_short_runtime_value(item))
842
+ if len(changed_files) > 3:
843
+ parts.append(f"+{len(changed_files) - 3} more")
844
+ return "; ".join(part for part in parts if part)
845
+
846
+
847
+ def _bytes_label(value: Any) -> str:
848
+ if value in {None, ""}:
849
+ return ""
850
+ try:
851
+ return f"{int(value)}B"
852
+ except (TypeError, ValueError):
853
+ return ""
854
+
855
+
856
+ def _status_label(status: str, *, color: bool = False) -> str:
857
+ normalized = status.strip() or "-"
858
+ color_name = {
859
+ "done": "green",
860
+ "ok": "green",
861
+ "failed": "red",
862
+ "requires_approval": "yellow",
863
+ "cancelled": "yellow",
864
+ }.get(normalized, "cyan")
865
+ labels = {
866
+ "done": "Done",
867
+ "ok": "Done",
868
+ "failed": "Failed",
869
+ "requires_approval": "Approval",
870
+ "cancelled": "Cancelled",
871
+ }
872
+ label = labels.get(normalized, normalized)
873
+ return _color(label, color_name, enabled=color)
874
+
875
+
876
+ def _status_icon(status: str, *, color: bool = False) -> str:
877
+ normalized = status.strip()
878
+ if normalized in {"done", "ok"}:
879
+ return _color("✓", "green", enabled=color)
880
+ if normalized == "failed":
881
+ return _color("✗", "red", enabled=color)
882
+ if normalized in {"requires_approval", "cancelled"}:
883
+ return _color("!", "yellow", enabled=color)
884
+ return _color("•", "cyan", enabled=color)
885
+
886
+
887
+ def _short_runtime_value(value: Any) -> str:
888
+ if isinstance(value, str):
889
+ text = str(value)
890
+ elif isinstance(value, bool):
891
+ text = json.dumps(value)
892
+ elif isinstance(value, (int, float)):
893
+ text = str(value)
894
+ else:
895
+ text = json.dumps(json_ready(value), ensure_ascii=False, sort_keys=True)
896
+ if len(text) > 96:
897
+ return text[:93] + "..."
898
+ return text
899
+
900
+
901
+ def _one_line_text(text: str) -> str:
902
+ compact = " ".join(text.split())
903
+ if len(compact) > 96:
904
+ return compact[:93] + "..."
905
+ return compact
906
+
907
+
908
+ def _ui_width() -> int:
909
+ return max(40, shutil.get_terminal_size((100, 24)).columns)
910
+
911
+
912
+ def _dim(text: str, *, enabled: bool) -> str:
913
+ return _color(text, "dim", enabled=enabled)
914
+
915
+
916
+ def _color(text: str, style: str, *, enabled: bool) -> str:
917
+ if not enabled:
918
+ return text
919
+ codes = {
920
+ "bold": "1",
921
+ "dim": "2",
922
+ "green": "32",
923
+ "red": "31",
924
+ "yellow": "33",
925
+ "cyan": "36",
926
+ "white": "97",
927
+ }
928
+ code = codes.get(style)
929
+ if not code:
930
+ return text
931
+ return f"\033[{code}m{text}\033[0m"