cct-cli 0.7.9.0__py3-none-any.whl

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 (325) hide show
  1. calc_terminal/__init__.py +14 -0
  2. calc_terminal/__main__.py +14 -0
  3. calc_terminal/activity.py +1334 -0
  4. calc_terminal/agent.py +3387 -0
  5. calc_terminal/agent_runtime.py +519 -0
  6. calc_terminal/ai_context.py +447 -0
  7. calc_terminal/ai_modes.py +752 -0
  8. calc_terminal/ai_personalization.py +286 -0
  9. calc_terminal/ai_preview_feedback.py +213 -0
  10. calc_terminal/aicore.py +2572 -0
  11. calc_terminal/anim.py +367 -0
  12. calc_terminal/app.py +3685 -0
  13. calc_terminal/art.py +639 -0
  14. calc_terminal/atomsim.py +368 -0
  15. calc_terminal/attachments.py +743 -0
  16. calc_terminal/benchmark_system.py +414 -0
  17. calc_terminal/browser/__init__.py +36 -0
  18. calc_terminal/browser/browser_state.py +346 -0
  19. calc_terminal/browser/devserver.py +176 -0
  20. calc_terminal/browser/engine.py +494 -0
  21. calc_terminal/browser/navigation.py +84 -0
  22. calc_terminal/browser/preview.py +429 -0
  23. calc_terminal/browser/preview_entry.py +95 -0
  24. calc_terminal/browser/project_detector.py +144 -0
  25. calc_terminal/browser/server.py +449 -0
  26. calc_terminal/browser/state.py +75 -0
  27. calc_terminal/browser/watcher.py +99 -0
  28. calc_terminal/browser_gui/__init__.py +1 -0
  29. calc_terminal/browser_gui/__main__.py +3 -0
  30. calc_terminal/browser_gui/launcher.py +173 -0
  31. calc_terminal/browser_gui/playwright_browser.py +117 -0
  32. calc_terminal/browser_gui/qt_browser.py +1501 -0
  33. calc_terminal/browser_gui/webview_browser.py +57 -0
  34. calc_terminal/capabilities/__init__.py +35 -0
  35. calc_terminal/capabilities/adapters/__init__.py +33 -0
  36. calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
  37. calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
  38. calc_terminal/capabilities/adapters/filesystem.py +206 -0
  39. calc_terminal/capabilities/adapters/git_adapter.py +202 -0
  40. calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
  41. calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
  42. calc_terminal/capabilities/adapters/platforms.py +200 -0
  43. calc_terminal/capabilities/adapters/python_exec.py +93 -0
  44. calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
  45. calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
  46. calc_terminal/capabilities/adapters/structural_bio.py +161 -0
  47. calc_terminal/capabilities/adapters/terminal.py +99 -0
  48. calc_terminal/capabilities/bus.py +178 -0
  49. calc_terminal/capabilities/discovery.py +207 -0
  50. calc_terminal/capabilities/schema.py +221 -0
  51. calc_terminal/cat.ico +0 -0
  52. calc_terminal/cat_browser.py +2018 -0
  53. calc_terminal/chat_store.py +703 -0
  54. calc_terminal/cli.py +1178 -0
  55. calc_terminal/code_editor.py +640 -0
  56. calc_terminal/collaboration.py +723 -0
  57. calc_terminal/commands_data.py +139 -0
  58. calc_terminal/compatibility_engine.py +352 -0
  59. calc_terminal/compute/__init__.py +31 -0
  60. calc_terminal/compute/fabric.py +350 -0
  61. calc_terminal/config.py +227 -0
  62. calc_terminal/core/__init__.py +41 -0
  63. calc_terminal/core/checkpoint.py +156 -0
  64. calc_terminal/core/input/__init__.py +45 -0
  65. calc_terminal/core/mode_registry.py +300 -0
  66. calc_terminal/core/project_graph.py +172 -0
  67. calc_terminal/core/recovery.py +129 -0
  68. calc_terminal/core/security_layer.py +112 -0
  69. calc_terminal/core/task_graph.py +202 -0
  70. calc_terminal/core/unified_runtime.py +184 -0
  71. calc_terminal/core/verification.py +257 -0
  72. calc_terminal/customization.py +1566 -0
  73. calc_terminal/derivations.py +153 -0
  74. calc_terminal/device_control.py +263 -0
  75. calc_terminal/diagnostics/__init__.py +27 -0
  76. calc_terminal/diagnostics/doctor_engine.py +382 -0
  77. calc_terminal/diagnostics/self_test.py +247 -0
  78. calc_terminal/doctor.py +519 -0
  79. calc_terminal/easter_eggs.py +274 -0
  80. calc_terminal/editor/__init__.py +1 -0
  81. calc_terminal/editor/actions.py +263 -0
  82. calc_terminal/editor/commands.py +160 -0
  83. calc_terminal/editor/shortcuts.py +226 -0
  84. calc_terminal/engine.py +259 -0
  85. calc_terminal/errors.py +120 -0
  86. calc_terminal/event_stream.py +146 -0
  87. calc_terminal/eventbus.py +133 -0
  88. calc_terminal/extensions.py +733 -0
  89. calc_terminal/fallback_cli.py +1321 -0
  90. calc_terminal/first_run.py +265 -0
  91. calc_terminal/fomoji_auth.py +1043 -0
  92. calc_terminal/formulas.py +82 -0
  93. calc_terminal/fs_cache.py +121 -0
  94. calc_terminal/fs_watcher.py +277 -0
  95. calc_terminal/game.py +193 -0
  96. calc_terminal/gen1.py +5 -0
  97. calc_terminal/generators.py +245 -0
  98. calc_terminal/gestures/__init__.py +42 -0
  99. calc_terminal/gestures/bindings.py +175 -0
  100. calc_terminal/gestures/manager.py +477 -0
  101. calc_terminal/goodbye.py +363 -0
  102. calc_terminal/gpu3d.py +290 -0
  103. calc_terminal/graphs.py +358 -0
  104. calc_terminal/hardware_analyzer.py +440 -0
  105. calc_terminal/host/__init__.py +30 -0
  106. calc_terminal/host/browser_manager.py +187 -0
  107. calc_terminal/host/desktop.py +1386 -0
  108. calc_terminal/host/launcher.py +395 -0
  109. calc_terminal/host/terminal.py +279 -0
  110. calc_terminal/identity.py +216 -0
  111. calc_terminal/input/__init__.py +54 -0
  112. calc_terminal/input/capabilities.py +258 -0
  113. calc_terminal/input/focus.py +87 -0
  114. calc_terminal/input/gestures.py +64 -0
  115. calc_terminal/input/pointer.py +114 -0
  116. calc_terminal/input/touch.py +345 -0
  117. calc_terminal/keys.py +84 -0
  118. calc_terminal/live_automation.py +165 -0
  119. calc_terminal/mathtext.py +433 -0
  120. calc_terminal/mcp.py +386 -0
  121. calc_terminal/memory.py +337 -0
  122. calc_terminal/memory_v2.py +479 -0
  123. calc_terminal/metrics.py +333 -0
  124. calc_terminal/mode_detection.py +146 -0
  125. calc_terminal/model.py +2431 -0
  126. calc_terminal/model_router.py +665 -0
  127. calc_terminal/models/__init__.py +0 -0
  128. calc_terminal/models/active_state.py +187 -0
  129. calc_terminal/models/dynamic_registry.py +584 -0
  130. calc_terminal/models/manager.py +781 -0
  131. calc_terminal/models/model_metadata.json +3526 -0
  132. calc_terminal/models/profiles.py +194 -0
  133. calc_terminal/models/registry.py +265 -0
  134. calc_terminal/models/schema.py +197 -0
  135. calc_terminal/models/validator.py +287 -0
  136. calc_terminal/models/verification_engine.py +368 -0
  137. calc_terminal/native_picker.py +215 -0
  138. calc_terminal/ollama_catalog.py +279 -0
  139. calc_terminal/ollama_download.py +233 -0
  140. calc_terminal/orchestrator.py +304 -0
  141. calc_terminal/package_research.py +322 -0
  142. calc_terminal/packages.py +1024 -0
  143. calc_terminal/pc_specs.py +116 -0
  144. calc_terminal/permissions.py +334 -0
  145. calc_terminal/pet.py +106 -0
  146. calc_terminal/pipeline.py +505 -0
  147. calc_terminal/platform/__init__.py +491 -0
  148. calc_terminal/platform/desktop.py +491 -0
  149. calc_terminal/platform/web.py +781 -0
  150. calc_terminal/preview/__init__.py +1 -0
  151. calc_terminal/preview/dev_server.py +303 -0
  152. calc_terminal/preview/diagnostics.py +131 -0
  153. calc_terminal/preview/live_reload.py +66 -0
  154. calc_terminal/preview/manager.py +129 -0
  155. calc_terminal/project_stats.py +209 -0
  156. calc_terminal/projects.py +328 -0
  157. calc_terminal/providers/__init__.py +0 -0
  158. calc_terminal/providers/adapters/__init__.py +80 -0
  159. calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
  160. calc_terminal/providers/adapters/base.py +106 -0
  161. calc_terminal/providers/adapters/chinese_adapters.py +420 -0
  162. calc_terminal/providers/adapters/gemini_adapter.py +101 -0
  163. calc_terminal/providers/adapters/ollama_adapter.py +83 -0
  164. calc_terminal/providers/adapters/openai_adapter.py +159 -0
  165. calc_terminal/providers/adapters/other_adapters.py +246 -0
  166. calc_terminal/providers/anthropic_provider.py +172 -0
  167. calc_terminal/providers/auto_update.py +416 -0
  168. calc_terminal/providers/base_provider.py +105 -0
  169. calc_terminal/providers/discovery_manager.py +207 -0
  170. calc_terminal/providers/gemini_provider.py +178 -0
  171. calc_terminal/providers/lifecycle.py +767 -0
  172. calc_terminal/providers/ollama_adapter.py +707 -0
  173. calc_terminal/providers/openai_provider.py +254 -0
  174. calc_terminal/providers/provider_manager.py +1827 -0
  175. calc_terminal/providers/providers.json +4075 -0
  176. calc_terminal/reactionsim.py +279 -0
  177. calc_terminal/registry.py +337 -0
  178. calc_terminal/report.py +162 -0
  179. calc_terminal/research/__init__.py +45 -0
  180. calc_terminal/research/artifact_intel.py +126 -0
  181. calc_terminal/research/data_lineage.py +123 -0
  182. calc_terminal/research/experiment_ledger.py +303 -0
  183. calc_terminal/research/reproducibility.py +131 -0
  184. calc_terminal/resilience/__init__.py +47 -0
  185. calc_terminal/resilience/agent_state.py +121 -0
  186. calc_terminal/resilience/capability_matcher.py +174 -0
  187. calc_terminal/resilience/circuit_breaker.py +158 -0
  188. calc_terminal/resilience/failover_engine.py +230 -0
  189. calc_terminal/resilience/health_monitor.py +192 -0
  190. calc_terminal/resilience/ollama_adapter.py +125 -0
  191. calc_terminal/resilience/orchestrator.py +312 -0
  192. calc_terminal/resilience/types.py +134 -0
  193. calc_terminal/sandbox.py +98 -0
  194. calc_terminal/scires.py +558 -0
  195. calc_terminal/security_scanner.py +126 -0
  196. calc_terminal/session.py +294 -0
  197. calc_terminal/sim3d.py +206 -0
  198. calc_terminal/solver.py +276 -0
  199. calc_terminal/sound.py +127 -0
  200. calc_terminal/task_reports.py +287 -0
  201. calc_terminal/terminal_host.py +201 -0
  202. calc_terminal/terminal_identity.py +411 -0
  203. calc_terminal/test_ai_mode_reliability.py +344 -0
  204. calc_terminal/test_browser.py +368 -0
  205. calc_terminal/test_code_editor_upgrade.py +485 -0
  206. calc_terminal/test_customization.py +1148 -0
  207. calc_terminal/test_customization_ui.py +612 -0
  208. calc_terminal/test_dynamic_registry.py +304 -0
  209. calc_terminal/test_extensions.py +436 -0
  210. calc_terminal/test_overhaul.py +557 -0
  211. calc_terminal/test_project_detect.py +255 -0
  212. calc_terminal/test_root_cause_fix.py +527 -0
  213. calc_terminal/test_stability.py +532 -0
  214. calc_terminal/test_terminal_identity.py +132 -0
  215. calc_terminal/test_v079_speed.py +460 -0
  216. calc_terminal/theme.py +1107 -0
  217. calc_terminal/timeline.py +139 -0
  218. calc_terminal/todos.py +246 -0
  219. calc_terminal/tool_call_normalizer.py +419 -0
  220. calc_terminal/tui.py +104 -0
  221. calc_terminal/ui/__init__.py +8 -0
  222. calc_terminal/ui/activity_panel.py +231 -0
  223. calc_terminal/ui/activity_stream_panel.py +238 -0
  224. calc_terminal/ui/animations.py +122 -0
  225. calc_terminal/ui/app.py +7271 -0
  226. calc_terminal/ui/attach_panel.py +597 -0
  227. calc_terminal/ui/attachments.py +424 -0
  228. calc_terminal/ui/backup_panel.py +810 -0
  229. calc_terminal/ui/browser_shell.py +887 -0
  230. calc_terminal/ui/cat_agent.py +357 -0
  231. calc_terminal/ui/chats_panel.py +899 -0
  232. calc_terminal/ui/command_palette.py +125 -0
  233. calc_terminal/ui/command_palette_modal.py +166 -0
  234. calc_terminal/ui/composer.py +1141 -0
  235. calc_terminal/ui/context_menu.py +197 -0
  236. calc_terminal/ui/conversation.py +1435 -0
  237. calc_terminal/ui/customization_panel.py +1229 -0
  238. calc_terminal/ui/dashboard.py +404 -0
  239. calc_terminal/ui/design_system.py +557 -0
  240. calc_terminal/ui/diff_panel.py +213 -0
  241. calc_terminal/ui/editor.py +2102 -0
  242. calc_terminal/ui/empty_state.py +302 -0
  243. calc_terminal/ui/events.py +487 -0
  244. calc_terminal/ui/extensions_panel.py +815 -0
  245. calc_terminal/ui/footer.py +166 -0
  246. calc_terminal/ui/gestures_panel.py +383 -0
  247. calc_terminal/ui/goodbye_screen.py +100 -0
  248. calc_terminal/ui/header.py +1034 -0
  249. calc_terminal/ui/help_panel.py +254 -0
  250. calc_terminal/ui/live_activities.py +914 -0
  251. calc_terminal/ui/mcp_panel.py +570 -0
  252. calc_terminal/ui/memory_center.py +524 -0
  253. calc_terminal/ui/mode_colors_panel.py +525 -0
  254. calc_terminal/ui/nav_screens.py +747 -0
  255. calc_terminal/ui/ollama_panel.py +536 -0
  256. calc_terminal/ui/palette.py +221 -0
  257. calc_terminal/ui/permission_panel.py +269 -0
  258. calc_terminal/ui/personalization_panel.py +517 -0
  259. calc_terminal/ui/personalize_center.py +1568 -0
  260. calc_terminal/ui/preview_panel.py +441 -0
  261. calc_terminal/ui/resizers.py +402 -0
  262. calc_terminal/ui/sidebar.py +1285 -0
  263. calc_terminal/ui/statusbar.py +168 -0
  264. calc_terminal/ui/theme_css.py +1396 -0
  265. calc_terminal/ui/thinking.py +226 -0
  266. calc_terminal/ui/timeline_panel.py +102 -0
  267. calc_terminal/ui/todo_panel.py +193 -0
  268. calc_terminal/ui/viewport.py +136 -0
  269. calc_terminal/ui/vision_panel.py +489 -0
  270. calc_terminal/ui/welcome_modal.py +343 -0
  271. calc_terminal/ui/widgets.py +160 -0
  272. calc_terminal/ui/workspace.py +831 -0
  273. calc_terminal/viewers/__init__.py +1 -0
  274. calc_terminal/viewers/document_viewer.py +252 -0
  275. calc_terminal/viewers/image_viewer.py +241 -0
  276. calc_terminal/viewers/pdf_viewer.py +203 -0
  277. calc_terminal/viewers/presentation_viewer.py +164 -0
  278. calc_terminal/viewers/registry.py +120 -0
  279. calc_terminal/viewers/spreadsheet_viewer.py +204 -0
  280. calc_terminal/vision/__init__.py +89 -0
  281. calc_terminal/vision/analysis.py +194 -0
  282. calc_terminal/vision/annotations.py +297 -0
  283. calc_terminal/vision/capture.py +171 -0
  284. calc_terminal/vision/context.py +231 -0
  285. calc_terminal/vision/correlation.py +169 -0
  286. calc_terminal/vision/cursor.py +258 -0
  287. calc_terminal/vision/events.py +66 -0
  288. calc_terminal/vision/frame_pipeline.py +259 -0
  289. calc_terminal/vision/priority.py +218 -0
  290. calc_terminal/vision/provider.py +180 -0
  291. calc_terminal/vision/safety.py +149 -0
  292. calc_terminal/vision/session.py +281 -0
  293. calc_terminal/vision/verify.py +162 -0
  294. calc_terminal/vision.py +514 -0
  295. calc_terminal/vscode_integration.py +113 -0
  296. calc_terminal/web/__init__.py +8 -0
  297. calc_terminal/web/cat_runtime.py +710 -0
  298. calc_terminal/web/server.py +2891 -0
  299. calc_terminal/web/static/css/app.css +3152 -0
  300. calc_terminal/web/static/icons/badge-72.png +0 -0
  301. calc_terminal/web/static/icons/cat.ico +0 -0
  302. calc_terminal/web/static/icons/icon-128.png +0 -0
  303. calc_terminal/web/static/icons/icon-144.png +0 -0
  304. calc_terminal/web/static/icons/icon-152.png +0 -0
  305. calc_terminal/web/static/icons/icon-192.png +0 -0
  306. calc_terminal/web/static/icons/icon-384.png +0 -0
  307. calc_terminal/web/static/icons/icon-512.png +0 -0
  308. calc_terminal/web/static/icons/icon-72.png +0 -0
  309. calc_terminal/web/static/icons/icon-96.png +0 -0
  310. calc_terminal/web/static/icons/icon.svg +34 -0
  311. calc_terminal/web/static/icons/new-project.png +0 -0
  312. calc_terminal/web/static/icons/open-project.png +0 -0
  313. calc_terminal/web/static/index.html +734 -0
  314. calc_terminal/web/static/js/app.js +2403 -0
  315. calc_terminal/web/static/manifest.json +88 -0
  316. calc_terminal/web/static/sw.js +230 -0
  317. calc_terminal/workflow_engine.py +769 -0
  318. calc_terminal/workspace.py +593 -0
  319. calc_terminal/workspace_index.py +385 -0
  320. cct_cli-0.7.9.0.dist-info/METADATA +210 -0
  321. cct_cli-0.7.9.0.dist-info/RECORD +325 -0
  322. cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
  323. cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
  324. cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
  325. cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
calc_terminal/agent.py ADDED
@@ -0,0 +1,3387 @@
1
+ """
2
+ CCT Agent — Chemistry Agentic AI [BETA].
3
+
4
+ Layers a real tool-using agent loop on top of aicore's provider access
5
+ (API or Ollama) so the model doesn't just *talk* about chemistry — it can
6
+ actually drive the app: solve exact formulas symbolically, run the safe
7
+ calculator, plot any 2D curve or 3D surface (a built-in chemistry preset,
8
+ OR a completely custom function/curve the model names itself), and start
9
+ live 2D/3D atom, electron/proton, and quantum-orbital simulations.
10
+
11
+ Protocol: the model is asked to reply with exactly one JSON object per
12
+ turn — either {"action": "tool", "tool": ..., "args": {...}} to use a
13
+ tool, or {"action": "final", "text": ...} to answer. Results of each
14
+ tool call are fed back in as the next turn so the model can chain
15
+ several tools (e.g. solve a value, then plot it) before finishing. This
16
+ works uniformly across every provider in aicore.PROVIDERS (OpenAI,
17
+ Anthropic, Gemini, Groq, OpenRouter, Ollama, or a custom endpoint)
18
+ because it only relies on plain text in/out, not provider-specific
19
+ function-calling schemas.
20
+ """
21
+
22
+ import fnmatch
23
+ import json
24
+ import math
25
+ import os
26
+ import random
27
+ import re
28
+ import subprocess
29
+ import time
30
+
31
+ from . import theme
32
+ from . import aicore
33
+ from . import solver
34
+ from . import atomsim
35
+ from . import identity
36
+ from . import sim3d
37
+ from . import graphs
38
+ from . import sound
39
+ from . import memory
40
+ from . import security_scanner
41
+ from . import sandbox
42
+ from . import permissions as perm
43
+ from . import workspace
44
+ from .tool_call_normalizer import normalize_tool_calls, extract_final_answer, is_tool_call_response
45
+
46
+ # ------------------------------------------------------------- attachments --
47
+ # Feature 6: files/images imported via /import (or the code pad's :import)
48
+ # are tracked here so the agent can be told about them and fetch their
49
+ # content/analysis on demand via the read_attachment tool, without having
50
+ # to paste the whole file into every prompt up front.
51
+ #
52
+ # v0.7.8.1: entries are normalized Attachment objects
53
+ # (calc_terminal/attachments.py) — never bare path strings — so the
54
+ # read_attachment tool serves real extracted content, and the composer's
55
+ # attached files (set_attachments) are visible to the same tool.
56
+ _ATTACHMENTS = []
57
+
58
+
59
+ def add_attachment(path):
60
+ """Register one attachment (path, or an Attachment object).
61
+ Kept for the legacy /import and code-pad :import paths; the primary
62
+ UI uses set_attachments() instead."""
63
+ from . import attachments as _att
64
+ if isinstance(path, _att.Attachment):
65
+ _ATTACHMENTS.append(path)
66
+ return
67
+ _ATTACHMENTS.append(_att.AttachmentManager.create(path))
68
+
69
+
70
+ def set_attachments(attachments):
71
+ """v0.7.8.1: replace the session attachment list wholesale — the UI
72
+ worker calls this before an agent/build turn so the model can read
73
+ the files attached via the composer through read_attachment."""
74
+ _ATTACHMENTS.clear()
75
+ for a in (attachments or []):
76
+ if a is not None:
77
+ _ATTACHMENTS.append(a)
78
+
79
+
80
+ def get_attachments():
81
+ return list(_ATTACHMENTS)
82
+
83
+
84
+ def clear_attachments():
85
+ _ATTACHMENTS.clear()
86
+
87
+
88
+ def _attachments_context():
89
+ if not _ATTACHMENTS:
90
+ return ""
91
+ lines = ["Files/images attached this session (real, addressable context — "
92
+ "use read_attachment to see content, archive_list for archives, "
93
+ "or operate on their real paths directly with file tools):"]
94
+ for a in _ATTACHMENTS:
95
+ if isinstance(a, dict): # legacy shape
96
+ path, kind = a.get("path"), a.get("kind", "file")
97
+ lines.append(f"- {path} ({kind})")
98
+ continue
99
+ status = getattr(a, "extraction_status", "ready")
100
+ suffix = "" if status == "ready" else f", status: {status}"
101
+ size = getattr(a, "size", 0) or 0
102
+ lines.append(f"- id: {a.id} | name: {a.name} | type: {a.kind} | "
103
+ f"size: {size}B | path: {a.path}{suffix}")
104
+ return "\n".join(lines)
105
+
106
+
107
+ MAX_STEPS = 18
108
+ _CALC_NS = {
109
+ "sqrt": math.sqrt, "log10": math.log10, "log": math.log, "sin": math.sin,
110
+ "cos": math.cos, "tan": math.tan, "pi": math.pi, "e": math.e,
111
+ "abs": abs, "round": round, "exp": math.exp,
112
+ }
113
+ _CALC_ALLOWED = re.compile(r"^[0-9+\-*/().\s,a-zA-Z_]*$")
114
+
115
+
116
+ # ------------------------------------------------------------------ tools --
117
+ def _floats(d):
118
+ out = {}
119
+ for k, v in (d or {}).items():
120
+ try:
121
+ out[k] = float(v)
122
+ except (TypeError, ValueError):
123
+ pass
124
+ return out
125
+
126
+
127
+ def _tool_list_formulas(args):
128
+ lines = [f"{k} = {v[2]} [{v[1]}] \u2014 {v[0]}" for k, v in solver.FORMULA_LIBRARY.items()]
129
+ return "Formula library keys (use with solve_formula):\n" + "\n".join(lines)
130
+
131
+
132
+ def _tool_solve_formula(args):
133
+ key = str(args.get("key", "")).strip()
134
+ known = _floats(args.get("values"))
135
+ if key not in solver.FORMULA_LIBRARY:
136
+ matches = [k for k in solver.FORMULA_LIBRARY if key and key.lower() in k.lower()]
137
+ if not matches:
138
+ return (f"Unknown formula key '{key}'. Call list_formulas first to see valid "
139
+ f"keys such as kin_first, ideal_gas, nernst, arrhenius, half_life_1.")
140
+ key = matches[0]
141
+ try:
142
+ solve_for, result, name, cat, eq = solver.solve_library_formula(key, known)
143
+ except solver.SolveError as e:
144
+ sound.play("error")
145
+ return f"Could not solve '{key}': {e}"
146
+ sound.play("success")
147
+ return f"Solved '{name}' [{cat}]: {eq} -> {solve_for} = {result:.6g}"
148
+
149
+
150
+ def _tool_solve_custom(args):
151
+ formula = str(args.get("formula", "")).strip()
152
+ known = _floats(args.get("values"))
153
+ solve_for = str(args.get("solve_for", "")).strip()
154
+ if not formula or "=" not in formula or not solve_for:
155
+ return "Need a formula containing '=', known values, and a solve_for variable name."
156
+ try:
157
+ result, eq = solver.solve_formula(formula, known, solve_for)
158
+ except solver.SolveError as e:
159
+ sound.play("error")
160
+ return f"Could not solve: {e}"
161
+ sound.play("success")
162
+ return f"Solved {eq} -> {solve_for} = {result:.6g}"
163
+
164
+
165
+ def _tool_calculate(args):
166
+ expr = str(args.get("expression", "")).strip().replace("^", "**")
167
+ if not expr or not _CALC_ALLOWED.match(expr):
168
+ return "Invalid or unsafe expression."
169
+ try:
170
+ result = eval(expr, {"__builtins__": {}}, _CALC_NS)
171
+ except Exception as e:
172
+ sound.play("error")
173
+ return f"Calculation error: {e}"
174
+ sound.play("success")
175
+ return f"{args.get('expression')} = {result}"
176
+
177
+
178
+ def _tool_generate_numerical(args):
179
+ from .generators import GENERATORS
180
+ from .engine import render_notebook
181
+ topic = str(args.get("topic", "first")).lower()
182
+ keys = list(GENERATORS.keys())
183
+ matches = [k for k in keys if topic in k or k in topic]
184
+ key = matches[0] if matches else random.choice(keys)
185
+ nb = GENERATORS[key]()
186
+ print()
187
+ render_notebook(nb)
188
+ sound.play("success")
189
+ return f"Generated and rendered a full {nb['topic']} notebook above. Final answer: {nb['final_answer']}"
190
+
191
+
192
+ def _tool_plot_preset(args):
193
+ name = str(args.get("preset", "")).strip()
194
+ valid = [k for k, _ in graphs.PRESETS]
195
+ if name not in valid:
196
+ matches = [k for k in valid if name and name in k]
197
+ if not matches:
198
+ return f"Unknown preset '{name}'. Valid presets: {', '.join(valid)}"
199
+ name = matches[0]
200
+ xs, ys, title, xl, yl = graphs.preset_curve(name)
201
+ print()
202
+ graphs.ascii_plot(xs, ys, title, xl, yl)
203
+ sound.play("success")
204
+ result = f"Plotted built-in preset '{name}' ({title}) as an animated terminal graph."
205
+ if args.get("export"):
206
+ try:
207
+ path = graphs.export_2d(xs, ys, title, xl, yl)
208
+ result += f" Exported PNG: {path}"
209
+ except Exception as e:
210
+ result += f" (PNG export failed: {e})"
211
+ return result
212
+
213
+
214
+ def _tool_plot_function(args):
215
+ """Plot / graph ANY named 2D function — this is the 'name anything to
216
+ the model' hook: the AI can invent a title and formula on the spot."""
217
+ expr = str(args.get("expression", "x")).strip()
218
+ xmin = args.get("xmin", 0)
219
+ xmax = args.get("xmax", 10)
220
+ title = args.get("title") or f"y = {expr}"
221
+ try:
222
+ xs, ys, t, xl, yl = graphs.custom_curve(
223
+ expr, xmin, xmax, 60, title,
224
+ str(args.get("xlabel", "x")), str(args.get("ylabel", "y")))
225
+ except graphs.ExpressionError as e:
226
+ sound.play("error")
227
+ return f"Could not plot '{expr}': {e}"
228
+ print()
229
+ graphs.ascii_plot(xs, ys, t, xl, yl)
230
+ sound.play("success")
231
+ result = f"Plotted custom function '{expr}' over x in [{xmin}, {xmax}] as \"{title}\"."
232
+ if args.get("export"):
233
+ try:
234
+ path = graphs.export_custom_2d(expr, xmin, xmax, 300, title,
235
+ str(args.get("xlabel", "x")), str(args.get("ylabel", "y")))
236
+ result += f" Exported PNG: {path}"
237
+ except Exception as e:
238
+ result += f" (PNG export failed: {e})"
239
+ return result
240
+
241
+
242
+ def _tool_plot_surface(args):
243
+ """Export a 3D surface PNG — a built-in preset ('orbital_3d' or 'pvt')
244
+ or any AI-named surface z = f(x, y)."""
245
+ kind = str(args.get("kind", "")).strip().lower()
246
+ try:
247
+ if kind in ("orbital_3d", "pvt"):
248
+ path = graphs.export_3d_surface(kind)
249
+ sound.play("success")
250
+ return f"Exported the built-in 3D surface '{kind}': {path}"
251
+ expr = str(args.get("expression", "x**2 - y**2")).strip()
252
+ title = args.get("title") or f"z = {expr}"
253
+ path = graphs.export_custom_3d(
254
+ expr,
255
+ args.get("xmin", -5), args.get("xmax", 5),
256
+ args.get("ymin", -5), args.get("ymax", 5),
257
+ 45, title)
258
+ sound.play("success")
259
+ return f"Exported custom 3D surface \"{title}\" (z = {expr}): {path}"
260
+ except graphs.ExpressionError as e:
261
+ sound.play("error")
262
+ return f"Could not export surface: {e}"
263
+ except Exception as e:
264
+ sound.play("error")
265
+ return f"3D export failed (is matplotlib/numpy installed?): {e}"
266
+
267
+
268
+ def _tool_atom_2d(args):
269
+ z = atomsim.resolve_element(str(args.get("element", "C")))
270
+ if z is None:
271
+ sound.play("error")
272
+ return f"Unknown element '{args.get('element')}'. Use a symbol (C, Fe, Na...) or Z=1-36."
273
+ frames = int(args.get("frames", 220))
274
+ print()
275
+ sound.play("sim_start")
276
+ atomsim.atom_simulation(z, max_frames=max(20, min(frames, 2000)))
277
+ sym, name, shells, mass = atomsim.ELEMENTS[z]
278
+ return f"Ran the live 2D Bohr atom/electron/proton simulation for {name} ({sym}, Z={z})."
279
+
280
+
281
+ def _tool_atom_3d(args):
282
+ z = atomsim.resolve_element(str(args.get("element", "C")))
283
+ if z is None:
284
+ sound.play("error")
285
+ return f"Unknown element '{args.get('element')}'. Use a symbol (C, Fe, Na...) or Z=1-36."
286
+ frames = int(args.get("frames", 220))
287
+ print()
288
+ sound.play("sim_start")
289
+ sim3d.atom_simulation_3d(z, max_frames=max(20, min(frames, 2000)))
290
+ sym, name, shells, mass = atomsim.ELEMENTS[z]
291
+ return f"Ran the live real-time 3D atom/electron/proton simulation for {name} ({sym}, Z={z})."
292
+
293
+
294
+ def _tool_orbital(args):
295
+ key = str(args.get("orbital", "1s")).lower().strip()
296
+ if key not in atomsim.ORBITALS:
297
+ sound.play("error")
298
+ return f"Unknown orbital '{key}'. Valid: {', '.join(atomsim.ORBITALS)}"
299
+ points = int(args.get("points", 1200))
300
+ print()
301
+ sound.play("sim_start")
302
+ atomsim.orbital_simulation(key, max_points=max(200, min(points, 5000)))
303
+ return f"Rendered the live |\u03c8|\u00b2 Monte-Carlo probability cloud for the {key} orbital."
304
+
305
+
306
+ def _tool_orbital_grid(args):
307
+ try:
308
+ path = atomsim.snapshot_orbital_grid()
309
+ except Exception as e:
310
+ sound.play("error")
311
+ return f"Orbital grid export failed (is matplotlib/numpy/scipy installed?): {e}"
312
+ if not path:
313
+ sound.play("error")
314
+ return "matplotlib/numpy/scipy not installed \u2014 orbital grid export skipped."
315
+ sound.play("success")
316
+ return f"Exported the hydrogen orbital reference chart (multiple n,l,m panels): {path}"
317
+
318
+
319
+ def _tool_bloch(args):
320
+ from . import scires
321
+ import math as _math
322
+ try:
323
+ theta_deg = float(args.get("theta_deg", 90.0))
324
+ phi_deg = float(args.get("phi_deg", 0.0))
325
+ except (TypeError, ValueError):
326
+ sound.play("error")
327
+ return "theta_deg/phi_deg must be numbers (degrees)."
328
+ try:
329
+ path = scires.render_bloch_sphere(theta_deg, phi_deg)
330
+ except Exception as e:
331
+ sound.play("error")
332
+ return f"Bloch sphere export failed (is matplotlib/numpy/scipy installed?): {e}"
333
+ if not path:
334
+ sound.play("error")
335
+ return "matplotlib/numpy/scipy not installed \u2014 Bloch sphere export skipped."
336
+ sound.play("success")
337
+ probs = scires.bloch_state_probs(_math.radians(theta_deg), _math.radians(phi_deg))
338
+ p_summary = ", ".join(f"{b}: {p[0]*100:.1f}%/{p[1]*100:.1f}%" for b, p in probs.items())
339
+ return (f"Exported a Bloch sphere for theta={theta_deg}\u00b0, phi={phi_deg}\u00b0 to {path}. "
340
+ f"Exact measurement probabilities ({p_summary}).")
341
+
342
+
343
+ def _tool_bonding(args):
344
+ from . import scires
345
+ key = str(args.get("molecule", "furan")).strip().lower()
346
+ if key not in scires.MOLECULES:
347
+ sound.play("error")
348
+ return (f"Unknown molecule '{key}'. Available: "
349
+ f"{', '.join(sorted(scires.MOLECULES.keys()))}")
350
+ try:
351
+ path = scires.render_bonding(key)
352
+ except Exception as e:
353
+ sound.play("error")
354
+ return f"Bonding map export failed (is matplotlib/numpy/scipy installed?): {e}"
355
+ if not path:
356
+ sound.play("error")
357
+ return "matplotlib/numpy/scipy not installed \u2014 bonding map export skipped."
358
+ sound.play("success")
359
+ return f"Exported a stylized electron-localization / bonding density map for {key}: {path}"
360
+
361
+
362
+ def _tool_web_search(args):
363
+ query = str(args.get("query", "")).strip()
364
+ if not query:
365
+ return "No query given."
366
+ results = aicore.web_search(query, max_results=int(args.get("max_results", 5) or 5))
367
+ if not results:
368
+ return f"No web results found for '{query}' (search unreachable or blocked)."
369
+ lines = [f"[{i+1}] {r['title']} — {r['url']}\n {r['snippet']}" for i, r in enumerate(results)]
370
+ return "Web search results:\n" + "\n".join(lines)
371
+
372
+
373
+ def _tool_deep_research(args):
374
+ topic = str(args.get("topic", "")).strip()
375
+ if not topic:
376
+ return "No research topic given."
377
+ summary, sources = aicore.deep_research(topic, num_queries=int(args.get("num_queries", 3) or 3))
378
+ src_lines = "\n".join(f"[{i+1}] {s['title']} — {s['url']}" for i, s in enumerate(sources))
379
+ return f"Research summary for '{topic}':\n{summary}\n\nSources:\n{src_lines}"
380
+
381
+
382
+ def _tool_read_attachment(args):
383
+ if not _ATTACHMENTS:
384
+ return "No files/images have been imported this session."
385
+ # Accept path fragments, attachment ids, or bare filenames so the
386
+ # model can address an attached file however it was described in
387
+ # context (requirement #22: attachments are real, addressable
388
+ # context: attached_file_id / filename / type / size / path).
389
+ ident = str(args.get("path", args.get("file_id", args.get("filename", "")))).strip()
390
+ match = None
391
+ if ident:
392
+ ident_low = ident.lower()
393
+ for a in _ATTACHMENTS:
394
+ path = a["path"] if isinstance(a, dict) else a.path
395
+ aid = "" if isinstance(a, dict) else getattr(a, "id", "")
396
+ aname = os.path.basename(path).lower()
397
+ if (ident in path) or (aid and ident_low == aid.lower()) \
398
+ or aname == ident_low or aname.endswith(ident_low):
399
+ match = a
400
+ break
401
+ if match is None:
402
+ names = ", ".join(
403
+ os.path.basename((x["path"] if isinstance(x, dict) else x.path))
404
+ for x in _ATTACHMENTS)
405
+ return (f"No attachment matches '{ident}'. Attached this session: {names}.")
406
+ match = match or _ATTACHMENTS[-1]
407
+ if isinstance(match, dict): # legacy shape
408
+ path, kind = match["path"], match["kind"]
409
+ else:
410
+ path, kind = match.path, match.kind
411
+ if kind == "image" or aicore.is_image_file(path):
412
+ result = aicore.query_ai_with_image(
413
+ "Describe this image precisely; transcribe any code, chemistry, "
414
+ "or math it contains.", path)
415
+ return f"Image '{path}' analysis:\n{result}"
416
+ # v0.7.6 Patch 1, Fix 7: non-image attachments go through the same
417
+ # per-type context builder the streamed UI uses (real text for
418
+ # text/code/md/CSV, real listings for zip/tar, PDF metadata + text
419
+ # layer, honest notes for audio/video/binary) instead of a raw
420
+ # text read that fails on every non-text file.
421
+ # v0.7.8.1: Attachment objects serve their already-extracted content
422
+ # (re-extracted on demand if still pending).
423
+ from . import attachments as _att
424
+ if not isinstance(match, dict):
425
+ ctx = _att.AttachmentManager.build_context([match])
426
+ return f"Contents of '{path}':\n" + ctx
427
+ return (f"Contents of '{path}':\n"
428
+ + aicore.attachment_context_for(path))
429
+
430
+
431
+ # ------------------------------------------------------- filesystem --
432
+ # Real file-system tools for the agent (spec v0.7.1 sections 1/10/20).
433
+ # Every mutating one below goes through workspace.resolve_writable_path
434
+ # first (raises ValueError instead of silently touching something
435
+ # unintended) and is gated by run_agent's permission dispatch — see
436
+ # MUTATING_TOOLS / TOOL_PERM_KEY / TOOL_DESCRIBE below. None of these
437
+ # functions check permissions themselves; that's the caller's job, so
438
+ # there's exactly one place (run_agent) that can possibly forget to.
439
+
440
+ _last_change: dict = {}
441
+ """One-slot side channel for structured file-change info. The mutating
442
+ file tools put their before/after snapshot here on success; run_agent
443
+ reads (and clears) it right after spec['run'], so each executed step can
444
+ carry a 4th 'change' element without changing what tools return."""
445
+
446
+
447
+ def _mark_change(**fields):
448
+ _last_change.clear()
449
+ _last_change.update(fields)
450
+ # v0.7.9.0: any known mutation instantly invalidates cached directory
451
+ # listings (requirement: never serve stale filesystem information).
452
+ try:
453
+ from . import fs_cache
454
+ for key in ("path", "new_path"):
455
+ p = fields.get(key)
456
+ if p:
457
+ fs_cache.invalidate_path(p)
458
+ fs_cache.invalidate_path(os.path.dirname(str(p)))
459
+ except Exception:
460
+ pass
461
+
462
+
463
+ def _tool_write_file(args):
464
+ raw_path = str(args.get("path", "")).strip()
465
+ content = args.get("content", "")
466
+ overwrite = bool(args.get("overwrite", False))
467
+ if not raw_path:
468
+ return "No path given."
469
+ try:
470
+ path = workspace.resolve_writable_path(raw_path)
471
+ except ValueError as e:
472
+ return f"Refused: {e}"
473
+ if os.path.exists(path) and not overwrite:
474
+ return (f"'{path}' already exists. Call write_file again with "
475
+ f"\"overwrite\": true if you really mean to replace it.")
476
+ old_text = None
477
+ if os.path.exists(path):
478
+ # Snapshot the pre-write content so the chat can show exactly
479
+ # what changed instead of just "Done." (spec v0.7.4: structured
480
+ # OLD/NEW summaries for every AI code edit).
481
+ try:
482
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
483
+ old_text = f.read()
484
+ except OSError:
485
+ old_text = None
486
+ try:
487
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
488
+ with open(path, "w", encoding="utf-8") as f:
489
+ f.write(str(content))
490
+ except OSError as e:
491
+ _mark_change()
492
+ return f"Could not write '{path}': {e}"
493
+ _mark_change(kind="create" if old_text is None else "modify",
494
+ path=path, old=old_text, new=str(content))
495
+ sound.play("success")
496
+ return f"Wrote {len(str(content))} characters to '{path}'."
497
+
498
+
499
+ def _tool_create_folder(args):
500
+ raw_path = str(args.get("path", "")).strip()
501
+ if not raw_path:
502
+ return "No path given."
503
+ try:
504
+ path = workspace.resolve_writable_path(raw_path)
505
+ except ValueError as e:
506
+ return f"Refused: {e}"
507
+ try:
508
+ os.makedirs(path, exist_ok=True)
509
+ except OSError as e:
510
+ return f"Could not create '{path}': {e}"
511
+ try:
512
+ from . import fs_cache
513
+ fs_cache.invalidate_path(os.path.dirname(str(path)))
514
+ fs_cache.invalidate_path(str(path))
515
+ except Exception:
516
+ pass
517
+ return f"Created folder '{path}'."
518
+
519
+
520
+ def _tool_delete_file(args):
521
+ raw_path = str(args.get("path", "")).strip()
522
+ if not raw_path:
523
+ return "No path given."
524
+ try:
525
+ path = workspace.resolve_writable_path(raw_path)
526
+ except ValueError as e:
527
+ return f"Refused: {e}"
528
+ if not os.path.exists(path):
529
+ return f"'{path}' does not exist — nothing to delete."
530
+ if os.path.isdir(path):
531
+ return (f"'{path}' is a folder — delete_file only removes single files "
532
+ f"(to avoid a one-typo wiping a whole directory tree).")
533
+ try:
534
+ os.remove(path)
535
+ except OSError as e:
536
+ return f"Could not delete '{path}': {e}"
537
+ _mark_change(kind="delete", path=path)
538
+ return f"Deleted '{path}'."
539
+
540
+
541
+ def _tool_rename_file(args):
542
+ raw_src = str(args.get("path", "")).strip()
543
+ raw_dst = str(args.get("new_path", "")).strip()
544
+ if not raw_src or not raw_dst:
545
+ return "Need both 'path' (source) and 'new_path' (destination)."
546
+ try:
547
+ src = workspace.resolve_writable_path(raw_src)
548
+ dst = workspace.resolve_writable_path(raw_dst)
549
+ except ValueError as e:
550
+ return f"Refused: {e}"
551
+ if not os.path.exists(src):
552
+ return f"'{src}' does not exist."
553
+ if os.path.exists(dst):
554
+ return f"'{dst}' already exists — refusing to overwrite it via rename."
555
+ try:
556
+ os.makedirs(os.path.dirname(dst) or ".", exist_ok=True)
557
+ os.rename(src, dst)
558
+ except OSError as e:
559
+ return f"Could not rename '{src}' to '{dst}': {e}"
560
+ _mark_change(kind="rename", path=src, new_path=dst)
561
+ return f"Renamed '{src}' to '{dst}'."
562
+
563
+
564
+ def _tool_edit_file(args):
565
+ """Edit a file by replacing old_text with new_text. Supports find/replace
566
+ or full content replacement. Requires path, old_text and new_text."""
567
+ raw_path = str(args.get("path", "")).strip()
568
+ old_text = str(args.get("old_text", "") or args.get("old", "") or "")
569
+ new_text = str(args.get("new_text") or args.get("new") or args.get("content") or args.get("code") or "")
570
+ reason = str(args.get("reason", "")).strip()
571
+ if not raw_path:
572
+ return "No path given."
573
+ try:
574
+ path = workspace.resolve_writable_path(raw_path)
575
+ except ValueError as e:
576
+ return f"Refused: {e}"
577
+ if not os.path.exists(path):
578
+ return f"'{path}' does not exist - use write_file to create it."
579
+ try:
580
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
581
+ content = f.read()
582
+ except OSError as e:
583
+ return f"Could not read '{path}': {e}"
584
+ old_snapshot = content
585
+
586
+ norm_content = content.replace("\r\n", "\n")
587
+ norm_old = old_text.replace("\r\n", "\n")
588
+ norm_new = new_text.replace("\r\n", "\n")
589
+
590
+ # If old_text is empty, '*', 'all', or matches normalized content, replace entire file
591
+ if not old_text.strip() or old_text.strip() in ("*", "all", "whole", "full") or norm_old.strip() == norm_content.strip():
592
+ content = norm_new
593
+ elif norm_old in norm_content:
594
+ content = norm_content.replace(norm_old, norm_new, 1)
595
+ else:
596
+ # Try line-by-line whitespace-tolerant match
597
+ old_lines = [l.strip() for l in norm_old.split("\n") if l.strip()]
598
+ if old_lines and old_lines[0] in norm_content and old_lines[-1] in norm_content:
599
+ start_idx = norm_content.find(old_lines[0])
600
+ end_idx = norm_content.find(old_lines[-1], start_idx) + len(old_lines[-1])
601
+ content = norm_content[:start_idx] + norm_new + norm_content[end_idx:]
602
+ else:
603
+ return (f"The specified old_text was not found in '{path}'. "
604
+ f"Check the exact text. Tip: pass old_text: 'all' to overwrite the whole file.")
605
+
606
+ try:
607
+ with open(path, "w", encoding="utf-8") as f:
608
+ f.write(content)
609
+ except OSError as e:
610
+ return f"Could not write '{path}': {e}"
611
+ _mark_change(kind="modify", path=path, old=old_snapshot, new=content)
612
+ sound.play("success")
613
+ return f"Edited '{path}' ({len(norm_new)} chars inserted)."
614
+
615
+
616
+ def _tool_search_workspace(args):
617
+ """Search for files or content in the workspace."""
618
+ query = str(args.get("query", "") or args.get("pattern", "")).strip()
619
+ path = str(args.get("path", "")).strip() or "."
620
+ file_pattern = str(args.get("file_pattern", "")).strip()
621
+ max_results = int(args.get("max_results", 20))
622
+ if not query:
623
+ return "No search query given."
624
+ try:
625
+ base = workspace.resolve_writable_path(path) if path != "." else os.getcwd()
626
+ except ValueError:
627
+ base = os.getcwd()
628
+ results = []
629
+ try:
630
+ for root, dirs, files in os.walk(base):
631
+ dirs[:] = [d for d in dirs if not d.startswith(".") and d not in (
632
+ "node_modules", "__pycache__", ".git", ".venv", "venv")]
633
+ for fname in files:
634
+ if file_pattern and not fnmatch.fnmatch(fname, file_pattern):
635
+ continue
636
+ fpath = os.path.join(root, fname)
637
+ rel = os.path.relpath(fpath, base)
638
+ try:
639
+ with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
640
+ for i, line in enumerate(f, 1):
641
+ if query.lower() in line.lower():
642
+ results.append(f"{rel}:{i}: {line.rstrip()[:120]}")
643
+ if len(results) >= max_results:
644
+ break
645
+ except (OSError, UnicodeDecodeError):
646
+ pass
647
+ if len(results) >= max_results:
648
+ break
649
+ if len(results) >= max_results:
650
+ break
651
+ except Exception as e:
652
+ return f"Search error: {e}"
653
+ if not results:
654
+ return f"No matches for '{query}' in workspace."
655
+ return f"Search results for '{query}' ({len(results)} matches):\n" + "\n".join(results)
656
+
657
+
658
+ def _resolve_tool_path(raw_path, for_write=False):
659
+ """Resolve a model-supplied path for a tool, with the attachment
660
+ exception: a path that IS an actively attached file is always in
661
+ scope (the user explicitly handed CCT that file), even when it sits
662
+ outside the workspace/home roots — e.g. an attached zip on another
663
+ drive can still be inspected and modified."""
664
+ raw = str(raw_path or "").strip()
665
+ if not raw:
666
+ raise ValueError("No path given.")
667
+ expanded = os.path.expanduser(raw)
668
+ norm = os.path.normpath(os.path.abspath(expanded))
669
+ for a in _ATTACHMENTS:
670
+ apath = getattr(a, "path", None) or (a.get("path") if isinstance(a, dict) else None)
671
+ if apath and os.path.normpath(os.path.abspath(apath)) == norm:
672
+ return norm
673
+ return workspace.resolve_tool_path(expanded, for_write=for_write)
674
+
675
+
676
+ def _zip_entry_is_dir(info):
677
+ return info.is_dir() or info.filename.endswith("/")
678
+
679
+
680
+ def _tool_archive_list(args):
681
+ """List the real contents of an archive (requirement #9). Read-only,
682
+ so any non-blocked location resolves."""
683
+ raw_path = str(args.get("path", "")).strip()
684
+ if not raw_path:
685
+ return "No archive path given."
686
+ try:
687
+ path = _resolve_tool_path(raw_path)
688
+ except ValueError as e:
689
+ return f"Refused: {e}"
690
+ if not os.path.exists(path):
691
+ # Help the model recover: point at what IS attached.
692
+ att = ", ".join(getattr(a, "name", "") for a in _ATTACHMENTS) or "none"
693
+ return (f"Archive '{raw_path}' not found. Attached files this session: {att}. "
694
+ f"Use read_attachment to see them, then retry with an exact path.")
695
+ ext = os.path.splitext(path)[1].lower()
696
+ dirs = files = 0
697
+ if ext == ".zip":
698
+ import zipfile
699
+ try:
700
+ with zipfile.ZipFile(path, "r") as zf:
701
+ infos = zf.infolist()
702
+ entries = [i.filename for i in infos]
703
+ sizes = {i.filename: i.file_size for i in infos}
704
+ dirs = sum(1 for i in infos if _zip_entry_is_dir(i))
705
+ files = len(infos) - dirs
706
+ except Exception as e:
707
+ return f"Could not read '{path}': {e}"
708
+ elif ext in (".tar", ".gz", ".tgz", ".bz2"):
709
+ import tarfile
710
+ try:
711
+ with tarfile.open(path, "r:*") as tf:
712
+ members = tf.getmembers()
713
+ entries = [m.name for m in members]
714
+ sizes = {m.name: m.size for m in members}
715
+ dirs = sum(1 for m in members if m.isdir())
716
+ files = len(members) - dirs
717
+ except Exception as e:
718
+ return f"Could not read '{path}': {e}"
719
+ else:
720
+ return f"Unsupported archive format: {ext}"
721
+ lines = [f"Archive: {path}",
722
+ f"Entries: {len(entries)} ({dirs} folders, {files} files)"]
723
+ for name in sorted(entries):
724
+ sz = sizes.get(name, 0)
725
+ kind = "/" if name.endswith("/") else ""
726
+ lines.append(f" {name}{kind} ({sz} bytes)")
727
+ if dirs:
728
+ lines.append(f"\nThis archive contains {dirs} folder entr{'y' if dirs == 1 else 'ies'}; "
729
+ f"use archive_delete_entries to remove them.")
730
+ return "\n".join(lines)
731
+
732
+
733
+ def _coerce_str_list(value):
734
+ """Model-supplied list arguments arrive as a real JSON list, a
735
+ JSON-encoded string ('[\\"a\\", \\"b\\"]'), or a comma/newline
736
+ separated string depending on how the call was formatted. Coerce all
737
+ of them into a clean python list of stripped strings."""
738
+ if value is None:
739
+ return []
740
+ if isinstance(value, (list, tuple)):
741
+ return [str(v).strip() for v in value if str(v).strip()]
742
+ s = str(value).strip()
743
+ if not s:
744
+ return []
745
+ if s.startswith("["):
746
+ try:
747
+ parsed = json.loads(s)
748
+ if isinstance(parsed, list):
749
+ return [str(v).strip() for v in parsed if str(v).strip()]
750
+ except Exception:
751
+ pass
752
+ parts = re.split(r"[,;\n]", s)
753
+ out = []
754
+ for part in parts:
755
+ p = part.strip().strip('"').strip("'").strip("[]").strip()
756
+ if p:
757
+ out.append(p)
758
+ return out
759
+
760
+
761
+ def _rewrite_zip_safely(path, keep_predicate):
762
+ """Requirement #10 safety chain: original archive → temporary working
763
+ copy → modify → validate → replace original ONLY after success.
764
+ Returns (True, kept_count) or (False, error_message). The original
765
+ file is never touched unless the new copy validates cleanly; a
766
+ post-replace validation failure rolls back from a backup."""
767
+ import shutil
768
+ import tempfile
769
+ import zipfile
770
+ backup_path = path + ".cct-bak"
771
+ tmp_fd, tmp_path = tempfile.mkstemp(suffix=".zip",
772
+ dir=os.path.dirname(path) or None)
773
+ os.close(tmp_fd)
774
+ try:
775
+ kept = 0
776
+ with zipfile.ZipFile(path, "r") as zf_in:
777
+ names = zf_in.namelist()
778
+ with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf_out:
779
+ for item in zf_in.infolist():
780
+ if keep_predicate(item):
781
+ data = zf_in.read(item.filename)
782
+ zf_out.writestr(item, data)
783
+ kept += 1
784
+ # validate the working copy BEFORE touching the original
785
+ with zipfile.ZipFile(tmp_path, "r") as zf_chk:
786
+ bad = zf_chk.testzip()
787
+ if bad is not None:
788
+ os.unlink(tmp_path)
789
+ return False, f"modified copy failed validation (bad entry: {bad}) — original untouched"
790
+ shutil.copy2(path, backup_path)
791
+ shutil.move(tmp_path, path)
792
+ # post-replace validation; roll back on any failure
793
+ try:
794
+ with zipfile.ZipFile(path, "r") as zf_chk2:
795
+ if zf_chk2.testzip() is not None:
796
+ raise OSError("post-replace validation failed")
797
+ except Exception:
798
+ shutil.move(backup_path, path)
799
+ return False, "replaced archive failed validation — original restored"
800
+ try:
801
+ os.unlink(backup_path)
802
+ except OSError:
803
+ pass
804
+ return True, kept
805
+ except Exception as e:
806
+ for p in (tmp_path,):
807
+ try:
808
+ os.unlink(p)
809
+ except OSError:
810
+ pass
811
+ return False, str(e)
812
+
813
+
814
+ def _tool_archive_delete_entries(args):
815
+ """Delete entries (files and/or whole folders) from a ZIP by exact
816
+ name or glob pattern. Folders are matched prefix-wise, so deleting
817
+ 'folders/' removes everything under it. Safe-replace + validated."""
818
+ raw_path = str(args.get("path", "")).strip()
819
+ entries_to_delete = args.get("entries") or args.get("folders") or []
820
+ pattern = str(args.get("pattern", "")).strip()
821
+ if not raw_path:
822
+ return "No archive path given."
823
+ entries_to_delete = _coerce_str_list(entries_to_delete)
824
+ try:
825
+ path = _resolve_tool_path(raw_path, for_write=True)
826
+ except ValueError as e:
827
+ return f"Refused: {e}"
828
+ if not os.path.exists(path):
829
+ return f"Archive '{path}' not found."
830
+ ext = os.path.splitext(path)[1].lower()
831
+ if ext != ".zip":
832
+ return f"Archive deletion currently supports .zip files (got {ext})."
833
+ import zipfile
834
+ try:
835
+ with zipfile.ZipFile(path, "r") as zf:
836
+ infos = zf.infolist()
837
+ all_entries = [i.filename for i in infos]
838
+ except Exception as e:
839
+ return f"Could not read '{path}': {e}"
840
+
841
+ to_delete = set()
842
+
843
+ def _add_match(name):
844
+ n = str(name).strip().replace("\\", "/").strip('"\'').rstrip("/")
845
+ if not n:
846
+ return
847
+ for entry in all_entries:
848
+ e_norm = entry.rstrip("/")
849
+ if e_norm == n or entry.startswith(n + "/"):
850
+ to_delete.add(entry)
851
+
852
+ for name in entries_to_delete:
853
+ _add_match(str(name))
854
+ if pattern:
855
+ for entry in all_entries:
856
+ if (fnmatch.fnmatch(entry, pattern)
857
+ or fnmatch.fnmatch(entry.rstrip("/"), pattern)
858
+ or fnmatch.fnmatch(os.path.basename(entry), pattern)):
859
+ to_delete.add(entry)
860
+
861
+ # never delete EVERYTHING — an empty result would silently destroy data
862
+ if not to_delete:
863
+ listing = "\n".join(" " + e for e in all_entries[:25])
864
+ more = f"\n ...and {len(all_entries) - 25} more" if len(all_entries) > 25 else ""
865
+ return ("No matching entries found to delete. Archive contains:\n"
866
+ + listing + more
867
+ + "\nPass exact entry/folder names from this list (or a glob pattern).")
868
+ if len(to_delete) >= len([e for e in all_entries if not e.endswith('/')]) \
869
+ and not [e for e in all_entries if e not in to_delete]:
870
+ return ("Refusing to remove every entry from the archive — that would "
871
+ "destroy it. Leave at least one file.")
872
+
873
+ ok, result = _rewrite_zip_safely(
874
+ path, lambda item: item.filename not in to_delete)
875
+ if not ok:
876
+ return f"Archive modification FAILED — nothing was lost: {result}"
877
+ deleted_dirs = len({e for e in to_delete if e.endswith("/")})
878
+ deleted_files = len(to_delete) - deleted_dirs
879
+ sample = ", ".join(sorted(to_delete)[:8])
880
+ more = f" (+{len(to_delete) - 8} more)" if len(to_delete) > 8 else ""
881
+ _mark_change(kind="modify", path=path,
882
+ old=f"<archive:{len(all_entries)} entries>",
883
+ new=f"<archive:{result} entries>")
884
+ return (f"Deleted {len(to_delete)} entries ({deleted_dirs} folders, "
885
+ f"{deleted_files} files) from '{path}': {sample}{more}. "
886
+ f"{result} entries remain and the archive validated OK.")
887
+
888
+
889
+ def _tool_archive_extract(args):
890
+ """Extract an archive into a destination folder (default: a sibling
891
+ folder named after the archive)."""
892
+ raw_path = str(args.get("path", "")).strip()
893
+ dest_raw = str(args.get("dest", args.get("destination", ""))).strip()
894
+ if not raw_path:
895
+ return "No archive path given."
896
+ try:
897
+ path = _resolve_tool_path(raw_path)
898
+ except ValueError as e:
899
+ return f"Refused: {e}"
900
+ if not os.path.exists(path):
901
+ return f"Archive '{path}' not found."
902
+ try:
903
+ if dest_raw:
904
+ dest = workspace.resolve_writable_path(dest_raw)
905
+ else:
906
+ dest = workspace.resolve_writable_path(
907
+ os.path.join(workspace.root_dir(),
908
+ os.path.splitext(os.path.basename(path))[0] or "extracted"))
909
+ except ValueError as e:
910
+ return f"Refused: {e}"
911
+ ext = os.path.splitext(path)[1].lower()
912
+ try:
913
+ if ext == ".zip":
914
+ import zipfile
915
+ with zipfile.ZipFile(path, "r") as zf:
916
+ if zf.testzip() is not None:
917
+ return f"Archive '{path}' is corrupt — refusing to extract."
918
+ zf.extractall(dest)
919
+ count = len(zf.namelist())
920
+ elif ext in (".tar", ".gz", ".tgz", ".bz2"):
921
+ import tarfile
922
+ with tarfile.open(path, "r:*") as tf:
923
+ tf.extractall(dest)
924
+ count = len(tf.getmembers())
925
+ else:
926
+ return f"Unsupported archive format: {ext}"
927
+ except Exception as e:
928
+ return f"Extraction failed: {e}"
929
+ return f"Extracted {count} entries from '{path}' into '{dest}'."
930
+
931
+
932
+ def _tool_archive_add_entries(args):
933
+ """Add files (from disk or text content) into an existing ZIP.
934
+ args: path, files=[paths], or name+content pairs via entries=[{name,content}]."""
935
+ raw_path = str(args.get("path", "")).strip()
936
+ if not raw_path:
937
+ return "No archive path given."
938
+ try:
939
+ path = _resolve_tool_path(raw_path, for_write=True)
940
+ except ValueError as e:
941
+ return f"Refused: {e}"
942
+ if not os.path.exists(path):
943
+ return f"Archive '{path}' not found."
944
+ ext = os.path.splitext(path)[1].lower()
945
+ if ext != ".zip":
946
+ return f"Adding entries currently supports .zip files (got {ext})."
947
+ import shutil
948
+ import tempfile
949
+ import zipfile
950
+
951
+ file_paths = _coerce_str_list(args.get("files") or [])
952
+ content_entries = args.get("entries") or []
953
+ if not file_paths and not content_entries:
954
+ return "Nothing to add: pass files=[...] (disk paths) and/or entries=[{name, content}]."
955
+
956
+ resolved_files = []
957
+ for fp in file_paths:
958
+ try:
959
+ rp = _resolve_tool_path(str(fp))
960
+ except ValueError as e:
961
+ return f"Refused adding '{fp}': {e}"
962
+ if not os.path.isfile(rp):
963
+ return f"'{rp}' is not a file — only files can be added."
964
+ resolved_files.append(rp)
965
+
966
+ backup_path = path + ".cct-bak"
967
+ tmp_fd, tmp_path = tempfile.mkstemp(suffix=".zip",
968
+ dir=os.path.dirname(path) or None)
969
+ os.close(tmp_fd)
970
+ try:
971
+ added = []
972
+ with zipfile.ZipFile(path, "r") as zin:
973
+ existing = set(zin.namelist())
974
+ with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zout:
975
+ for item in zin.infolist():
976
+ zout.writestr(item, zin.read(item.filename))
977
+ for rp in resolved_files:
978
+ arcname = os.path.basename(rp)
979
+ zout.write(rp, arcname=arcname)
980
+ added.append(arcname + (" (overwrote)" if arcname in existing else ""))
981
+ for ent in content_entries:
982
+ if not isinstance(ent, dict):
983
+ continue
984
+ nm = str(ent.get("name", "")).strip().replace("\\", "/")
985
+ ct = str(ent.get("content", ""))
986
+ if not nm:
987
+ continue
988
+ zout.writestr(nm, ct)
989
+ added.append(nm + (" (overwrote)" if nm in existing else ""))
990
+ with zipfile.ZipFile(tmp_path, "r") as chk:
991
+ if chk.testzip() is not None:
992
+ os.unlink(tmp_path)
993
+ return "Modified copy failed validation — original untouched."
994
+ shutil.copy2(path, backup_path)
995
+ shutil.move(tmp_path, path)
996
+ try:
997
+ os.unlink(backup_path)
998
+ except OSError:
999
+ pass
1000
+ _mark_change(kind="modify", path=path, old="<archive>", new="<archive+additions>")
1001
+ return f"Added {len(added)} entr{'y' if len(added) == 1 else 'ies'} to '{path}': " + ", ".join(added)
1002
+ except Exception as e:
1003
+ try:
1004
+ os.unlink(tmp_path)
1005
+ except OSError:
1006
+ pass
1007
+ return f"Archive modification FAILED — nothing was lost: {e}"
1008
+
1009
+
1010
+ def _tool_archive_repack(args):
1011
+ """Rebuild an archive fresh (recompress / dedupe / drop dangling
1012
+ directory entries). args: path, optional out (new path),
1013
+ optional drop_dirs (bool, default False)."""
1014
+ raw_path = str(args.get("path", "")).strip()
1015
+ out_raw = str(args.get("out", "")).strip()
1016
+ drop_dirs = bool(args.get("drop_dirs", False))
1017
+ if not raw_path:
1018
+ return "No archive path given."
1019
+ try:
1020
+ path = _resolve_tool_path(raw_path, for_write=not out_raw)
1021
+ except ValueError as e:
1022
+ return f"Refused: {e}"
1023
+ if not os.path.exists(path):
1024
+ return f"Archive '{path}' not found."
1025
+ ext = os.path.splitext(path)[1].lower()
1026
+ if ext != ".zip":
1027
+ return f"Repack currently supports .zip files (got {ext})."
1028
+ if out_raw:
1029
+ try:
1030
+ out = workspace.resolve_writable_path(out_raw)
1031
+ except ValueError as e:
1032
+ return f"Refused: {e}"
1033
+ else:
1034
+ out = path
1035
+ import zipfile
1036
+ try:
1037
+ with zipfile.ZipFile(path, "r") as zin:
1038
+ names = zin.namelist()
1039
+ seen = set()
1040
+ payload = []
1041
+ for item in zin.infolist():
1042
+ if drop_dirs and _zip_entry_is_dir(item):
1043
+ continue
1044
+ if item.filename in seen:
1045
+ continue
1046
+ seen.add(item.filename)
1047
+ payload.append((item, zin.read(item.filename)))
1048
+ except Exception as e:
1049
+ return f"Could not read '{path}': {e}"
1050
+ import shutil
1051
+ import tempfile
1052
+ tmp_fd, tmp_path = tempfile.mkstemp(suffix=".zip",
1053
+ dir=os.path.dirname(out) or None)
1054
+ os.close(tmp_fd)
1055
+ try:
1056
+ with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zout:
1057
+ for item, data in payload:
1058
+ zout.writestr(item.filename, data)
1059
+ with zipfile.ZipFile(tmp_path, "r") as chk:
1060
+ if chk.testzip() is not None:
1061
+ os.unlink(tmp_path)
1062
+ return "Repacked copy failed validation — original untouched."
1063
+ if out == path:
1064
+ shutil.copy2(path, path + ".cct-bak")
1065
+ shutil.move(tmp_path, path)
1066
+ try:
1067
+ os.unlink(path + ".cct-bak")
1068
+ except OSError:
1069
+ pass
1070
+ else:
1071
+ shutil.move(tmp_path, out)
1072
+ _mark_change(kind="modify", path=out, old="<archive>", new="<archive repacked>")
1073
+ return (f"Repacked '{path}' → '{out}': {len(payload)} entries written"
1074
+ + (" (directory entries dropped)" if drop_dirs else "")
1075
+ + ", validated OK.")
1076
+ except Exception as e:
1077
+ try:
1078
+ os.unlink(tmp_path)
1079
+ except OSError:
1080
+ pass
1081
+ return f"Repack FAILED — original untouched: {e}"
1082
+
1083
+
1084
+ def _tool_archive_validate(args):
1085
+ """Validate that an archive file is intact and readable."""
1086
+ raw_path = str(args.get("path", "")).strip()
1087
+ if not raw_path:
1088
+ return "No archive path given."
1089
+ try:
1090
+ path = _resolve_tool_path(raw_path)
1091
+ except ValueError as e:
1092
+ return f"Refused: {e}"
1093
+ if not os.path.exists(path):
1094
+ return f"Archive '{path}' not found."
1095
+ ext = os.path.splitext(path)[1].lower()
1096
+ if ext == ".zip":
1097
+ import zipfile
1098
+ try:
1099
+ with zipfile.ZipFile(path, "r") as zf:
1100
+ bad = zf.testzip()
1101
+ count = len(zf.namelist())
1102
+ if bad:
1103
+ return f"Archive '{path}' is CORRUPT - bad file: {bad}"
1104
+ return f"Archive '{path}' is VALID ({count} entries, CRC check passed)."
1105
+ except Exception as e:
1106
+ return f"Archive '{path}' is INVALID: {e}"
1107
+ elif ext in (".tar", ".gz", ".tgz", ".bz2"):
1108
+ import tarfile
1109
+ try:
1110
+ with tarfile.open(path, "r:*") as tf:
1111
+ count = len(tf.getmembers())
1112
+ tf.close()
1113
+ return f"Archive '{path}' is valid ({count} entries, no errors)."
1114
+ except Exception as e:
1115
+ return f"Archive '{path}' is INVALID: {e}"
1116
+ return f"Cannot validate format {ext}."
1117
+
1118
+
1119
+ def _tool_run_terminal(args):
1120
+ """Execute a terminal command on the user's machine (spec v0.7.4:
1121
+ every AI-triggered terminal action must be transparent in the chat).
1122
+ Permission-gated via the shell_commands key; runs in the active
1123
+ workspace root; output, exit code, and duration are captured and
1124
+ recorded as a 'terminal' change so the summary shows them."""
1125
+ cmd = str(args.get("command", "")).strip()
1126
+ if not cmd:
1127
+ return "No command given."
1128
+ try:
1129
+ timeout = min(max(float(args.get("timeout", 60) or 60), 1.0), 300.0)
1130
+ except (TypeError, ValueError):
1131
+ timeout = 60.0
1132
+ cwd = workspace.root_dir() or os.getcwd()
1133
+ t0 = time.time()
1134
+ try:
1135
+ proc = subprocess.run(
1136
+ cmd, shell=True, cwd=cwd, capture_output=True, text=True,
1137
+ timeout=timeout, encoding="utf-8", errors="replace",
1138
+ env={**os.environ, "PYTHONIOENCODING": "utf-8"})
1139
+ except subprocess.TimeoutExpired:
1140
+ return (f"$ {cmd}\nCommand timed out after {timeout:.0f}s "
1141
+ f"(exit 124).")
1142
+ except OSError as e:
1143
+ return f"$ {cmd}\nCould not run command: {e}"
1144
+ finally:
1145
+ try:
1146
+ from . import terminal_identity
1147
+ terminal_identity.set_terminal_title()
1148
+ except Exception:
1149
+ pass
1150
+ duration = round(time.time() - t0, 2)
1151
+ output = ((proc.stdout or "") + (proc.stderr or "")).strip()
1152
+ if len(output) > 100_000:
1153
+ output = output[:100_000] + "\n... [truncated for memory safety]"
1154
+ _mark_change(kind="terminal", command=cmd, output=output,
1155
+ exit_code=proc.returncode, duration=duration)
1156
+ tail = output.splitlines()[:40]
1157
+ lines = [f"$ {cmd}"]
1158
+ lines.extend(tail)
1159
+ if output and len(output.splitlines()) > 40:
1160
+ lines.append(f"... {len(output.splitlines()) - 40} more line(s) omitted")
1161
+ status = "success" if proc.returncode == 0 else f"exit code {proc.returncode}"
1162
+ lines.append(f"\u2713 {status} ({duration}s)")
1163
+ return "\n".join(lines)
1164
+
1165
+
1166
+ def _run_project_command(args, default_cmd, label):
1167
+ """Shared executor for run_build / run_tests: pick a real command
1168
+ (explicit arg, or auto-detected from the project's manifest files)
1169
+ and run it in the workspace root via the same subprocess path as
1170
+ run_terminal — real execution, real output, real exit codes."""
1171
+ cmd = str(args.get("command", "")).strip()
1172
+ if not cmd:
1173
+ root = workspace.root_dir()
1174
+ # detect from what actually exists on disk
1175
+ has = lambda n: os.path.isfile(os.path.join(root, n))
1176
+ if label == "build":
1177
+ if has("package.json"):
1178
+ cmd = "npm run build"
1179
+ elif has("pyproject.toml"):
1180
+ cmd = "python -m build"
1181
+ elif has("setup.py"):
1182
+ cmd = "python setup.py build"
1183
+ elif has("Cargo.toml"):
1184
+ cmd = "cargo build"
1185
+ elif has("Makefile"):
1186
+ cmd = "make"
1187
+ elif has("CMakeLists.txt"):
1188
+ cmd = "cmake --build ."
1189
+ else:
1190
+ if has("package.json"):
1191
+ cmd = "npm test"
1192
+ elif has("pyproject.toml") or has("setup.cfg") or has("pytest.ini"):
1193
+ cmd = "python -m pytest"
1194
+ elif has("Cargo.toml"):
1195
+ cmd = "cargo test"
1196
+ elif has("go.mod"):
1197
+ cmd = "go test ./..."
1198
+ if not cmd:
1199
+ return (f"No {label} command found and none given. Pass "
1200
+ f'{{"command": "..."}} explicitly, or add a package.json / '
1201
+ f"pyproject.toml to the workspace.")
1202
+ return _tool_run_terminal({"command": cmd,
1203
+ "timeout": args.get("timeout", 120)})
1204
+
1205
+
1206
+ def _tool_run_build(args):
1207
+ """Build the project in the active workspace for real."""
1208
+ return _run_project_command(args, None, "build")
1209
+
1210
+
1211
+ def _tool_run_tests(args):
1212
+ """Run the project's test suite for real and report the result."""
1213
+ return _run_project_command(args, None, "test")
1214
+
1215
+
1216
+ def _tool_inspect_project(args):
1217
+ """Inspect the active workspace/project: layout, key manifests, and
1218
+ entry points — so the agent grounds itself before acting instead of
1219
+ guessing or asking the user which project it is."""
1220
+ path_raw = str(args.get("path", "")).strip() or "."
1221
+ try:
1222
+ base = (_resolve_tool_path(path_raw) if path_raw != "."
1223
+ else workspace.root_dir())
1224
+ except ValueError as e:
1225
+ return f"Refused: {e}"
1226
+ if not os.path.isdir(base):
1227
+ return f"'{base}' is not a folder."
1228
+ entries = sorted(os.listdir(base))
1229
+ dirs = [e for e in entries if os.path.isdir(os.path.join(base, e))]
1230
+ files = [e for e in entries if os.path.isfile(os.path.join(base, e))]
1231
+ manifests = [f for f in files if f.lower() in (
1232
+ "package.json", "pyproject.toml", "requirements.txt", "setup.py",
1233
+ "setup.cfg", "cargo.toml", "go.mod", "makefile", "cmakelists.txt",
1234
+ "readme.md", "pipfile", "poetry.lock")]
1235
+ code_exts = {".py", ".js", ".ts", ".tsx", ".jsx", ".html", ".css", ".java",
1236
+ ".c", ".cpp", ".rs", ".go", ".rb", ".php"}
1237
+ code_files = [f for f in files
1238
+ if os.path.splitext(f)[1].lower() in code_exts]
1239
+ lines = [f"Project root: {base}",
1240
+ f"Folders ({len(dirs)}): " + (", ".join(dirs[:25]) or "(none)"),
1241
+ f"Files ({len(files)}):"]
1242
+ for f in files[:60]:
1243
+ try:
1244
+ size = os.path.getsize(os.path.join(base, f))
1245
+ except OSError:
1246
+ size = 0
1247
+ lines.append(f" {f} ({size}B)")
1248
+ if len(files) > 60:
1249
+ lines.append(f" ...and {len(files) - 60} more")
1250
+ if manifests:
1251
+ lines.append("Key manifests: " + ", ".join(manifests))
1252
+ pm = next((m for m in ("package.json", "pyproject.toml",
1253
+ "requirements.txt") if m in manifests), None)
1254
+ if pm:
1255
+ try:
1256
+ with open(os.path.join(base, pm), "r", encoding="utf-8",
1257
+ errors="replace") as fh:
1258
+ head = "".join(fh.readline() for _ in range(40))
1259
+ lines.append(f"\nHead of {pm}:\n{head}")
1260
+ except OSError:
1261
+ pass
1262
+ if code_files:
1263
+ lines.append("Code files at root: " + ", ".join(code_files[:30]))
1264
+ return "\n".join(lines)
1265
+
1266
+
1267
+ def _tool_install_packages(args):
1268
+ """Autonomous Package Manager tool — installs real packages through
1269
+ packages.py (pip/npm/cargo/brew/winget/...). Governed by the SAME
1270
+ permission engine as every other tool (requirement #7): ASK EACH
1271
+ TIME prompts via the caller's permission_callback, FULL ACCESS
1272
+ executes automatically, RESTRICTED refuses with an honest error.
1273
+ No mode-based lockout and no broken approval side-path."""
1274
+ from . import packages
1275
+ raw = str(args.get("packages") or args.get("package") or "").strip()
1276
+ if not raw:
1277
+ return "No package given. Pass {\"packages\": \"requests\"} or {\"packages\": [\"numpy\", \"pandas\"]}."
1278
+ if re.match(r"^(pip|npm|pnpm|yarn|bun|cargo|conda|brew|choco|winget|apt|dnf)\s+install\s+", raw, re.I):
1279
+ # the model passed a whole shell command — extract the package spec(s)
1280
+ raw = re.sub(r"^(pip3?|npm|pnpm|yarn|bun|cargo|conda|brew|choco|winget|apt|dnf)(\s+install|\s+add)\s+", "", raw, flags=re.I).strip()
1281
+ raw = re.sub(r"^-[\w-]+\s*", "", raw).strip() or raw
1282
+ names = [p.strip() for p in re.split(r"[,\s]+", raw) if p.strip()]
1283
+ if len(names) > 1:
1284
+ names = names[:3]
1285
+ results = [_install_one_package(n, args) for n in names]
1286
+ return "\n\n".join(results)
1287
+ return _install_one_package(names[0], args)
1288
+
1289
+
1290
+ def _install_one_package(name, args):
1291
+ """Shared single-package install used by _tool_install_packages.
1292
+ Runs the full permission-narrowed install flow; returns the
1293
+ observation text."""
1294
+ from . import packages
1295
+ from . import permissions as perm
1296
+ from . import package_research
1297
+
1298
+ # Parse a possibly-versioned name like "numpy==1.26" or "requests>=2".
1299
+ import re as _re
1300
+ m = _re.match(r"^([A-Za-z0-9_.@/-]+?)([<>=~!].*)?$", name)
1301
+ pkg = m.group(1) if m else name
1302
+ version = (m.group(2) or "latest").lstrip("=<>~!")
1303
+ req = packages.PackageRequest(name=pkg, version=version or "latest",
1304
+ raw=name)
1305
+
1306
+ manager = packages.detect_manager(req)
1307
+ if manager is None:
1308
+ card = package_research.research_card(req)
1309
+ card_lines = package_research.render_card(req, card)
1310
+ research_note = ("Unknown package — researched it:\n"
1311
+ + "\n".join(line.strip() for line in card_lines)
1312
+ + "\n\nRecommendation: " + card.get("recommendation", ""))
1313
+ manager = "pip" # sensible default for a Python terminal, still approval-gated
1314
+ req.manager = manager
1315
+ research_note += f"\n\nProceeding with {packages.MANAGERS[manager]['label']} (approval required below)."
1316
+ else:
1317
+ req.manager = manager
1318
+ research_note = f"Detected {packages.MANAGERS[manager]['label']} as the right manager."
1319
+
1320
+ flow = perm.install_flow_state()
1321
+ try:
1322
+ if not flow["allow"]:
1323
+ return (f"{research_note}\n\nPermission denied: Restricted mode "
1324
+ "— package installs are disabled, nothing was installed.")
1325
+ remembered = packages.remembered_decision(manager, req.name)
1326
+ if remembered == "deny":
1327
+ return (f"{research_note}\n\nInstall of {req.label()} was previously "
1328
+ f"denied (remembered choice) — nothing installed.")
1329
+ events = []
1330
+ if flow["prompt"] and remembered is None:
1331
+ # ASK EACH TIME mode: the caller's permission dispatch
1332
+ # already showed the approval card for this exact install
1333
+ # before we got here (perm_key install_packages) — reaching
1334
+ # this point means the user approved. Surface the stage so
1335
+ # the activity log shows the approval happened.
1336
+ events.append({"stage": packages.STAGE_PERMISSION,
1337
+ "message": f"Approved by the user — installing {req.label()} "
1338
+ f"via {packages.MANAGERS[manager]['label']}."})
1339
+ out_lines = [research_note]
1340
+ result = packages.run_install(req, on_event=lambda ev: events.append(ev))
1341
+ out_lines.append(packages.summarize(result, req))
1342
+ tail = (result.get("output_tail") or "").strip()
1343
+ if tail:
1344
+ out_lines.append("")
1345
+ out_lines.append("Last output:")
1346
+ out_lines.extend(" " + l for l in tail.splitlines()[-6:])
1347
+ return "\n".join(out_lines)
1348
+ finally:
1349
+ perm.restore_mode()
1350
+
1351
+
1352
+ # --------------------------------------------------- change summaries --
1353
+ # Git-style change reports (spec v0.7.4: every AI code modification must
1354
+ # be visible in the chat as a real diff, never a silent edit). Built
1355
+ # from the `change` snapshots the file tools record via _mark_change,
1356
+ # rendered line-by-line with +/- markers and line numbers, capped so a
1357
+ # few-line edit never floods the chat with the whole file.
1358
+ #
1359
+ # `markdown=True` wraps each diff in a ```diff fence so the chat's Rich
1360
+ # Markdown renderer (pygments is installed) colors + green / - red;
1361
+ # `markdown=False` produces plain lines for the classic terminal.
1362
+
1363
+ _MAX_CHANGE_ROWS = 120
1364
+ _MAX_OUTPUT_LINES = 25
1365
+
1366
+
1367
+ def _git_diff_rows(old_text, new_text, max_rows=_MAX_CHANGE_ROWS):
1368
+ """Git-style per-line diff of a rewrite. Returns rows of
1369
+ (marker, lineno, line): '+' = added (NEW line number), '-' = removed
1370
+ (OLD line number), marker '...' = truncation notice."""
1371
+ from itertools import zip_longest
1372
+ import difflib
1373
+ old_lines = old_text.splitlines()
1374
+ new_lines = new_text.splitlines()
1375
+ matcher = difflib.SequenceMatcher(a=old_lines, b=new_lines, autojunk=False)
1376
+ rows = []
1377
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
1378
+ if tag == "equal":
1379
+ continue
1380
+ if tag == "replace":
1381
+ for i, j in zip_longest(range(i1, i2), range(j1, j2), fillvalue=None):
1382
+ if i is not None:
1383
+ rows.append(("-", i + 1, old_lines[i]))
1384
+ if j is not None:
1385
+ rows.append(("+", j + 1, new_lines[j]))
1386
+ elif tag == "delete":
1387
+ for i in range(i1, i2):
1388
+ rows.append(("-", i + 1, old_lines[i]))
1389
+ elif tag == "insert":
1390
+ for j in range(j1, j2):
1391
+ rows.append(("+", j + 1, new_lines[j]))
1392
+ if len(rows) > max_rows:
1393
+ rows = rows[:max_rows] + [("...", "", f"{len(rows) - max_rows} more changed line(s) omitted")]
1394
+ return rows
1395
+
1396
+
1397
+ def _change_line_stats(change):
1398
+ """(added, removed, modified) line counts for one change snapshot:
1399
+ added = new lines in insert/replace hunks; removed = deleted lines;
1400
+ modified = lines whose content changed in replace hunks (old side)."""
1401
+ kind = change.get("kind")
1402
+ if kind == "create":
1403
+ return len(str(change.get("new") or "").splitlines()), 0, 0
1404
+ if kind == "modify":
1405
+ import difflib
1406
+ old = str(change.get("old") or "").splitlines()
1407
+ new = str(change.get("new") or "").splitlines()
1408
+ matcher = difflib.SequenceMatcher(a=old, b=new, autojunk=False)
1409
+ added = removed = modified = 0
1410
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
1411
+ if tag == "replace":
1412
+ added += j2 - j1
1413
+ modified += i2 - i1
1414
+ elif tag == "delete":
1415
+ removed += i2 - i1
1416
+ elif tag == "insert":
1417
+ added += j2 - j1
1418
+ return added, removed, modified
1419
+ return 0, 0, 0
1420
+
1421
+
1422
+ def _append_diff_block(out, rows, fence):
1423
+ if fence:
1424
+ out.append(fence)
1425
+ for marker, lineno, line in rows:
1426
+ if marker == "...":
1427
+ out.append(f" {line}")
1428
+ else:
1429
+ out.append(f"{marker} {lineno} | {line}")
1430
+ if fence:
1431
+ out.append("```")
1432
+
1433
+
1434
+ def summarize_file_changes(steps, markdown=False, max_rows=_MAX_CHANGE_ROWS,
1435
+ max_output_lines=_MAX_OUTPUT_LINES):
1436
+ """Build the chat-ready change report for a completed agent run:
1437
+
1438
+ āœ“ Changes Applied
1439
+
1440
+ Files modified: 3
1441
+ Lines added: 18
1442
+ Lines removed: 5
1443
+ Lines modified: 11
1444
+
1445
+ ------------------
1446
+
1447
+ āœ“ Created: utils.py (24 lines added)
1448
+
1449
+ ```diff
1450
+ + 1 | def calculate_mass():
1451
+ + 2 | return moles * molar_mass
1452
+ ```
1453
+
1454
+ ------------------
1455
+
1456
+ āœŽ Modified: main.py (+2 āˆ’1)
1457
+
1458
+ ```diff
1459
+ - 62 | total = a+b
1460
+ + 62 | total = a + b
1461
+ ```
1462
+
1463
+ ... then terminal actions, then the action timeline.
1464
+
1465
+ Returns '' when nothing on disk actually changed.
1466
+ """
1467
+ changes = []
1468
+ for _name, _args, _obs, change in (steps or []):
1469
+ if isinstance(change, dict) and change:
1470
+ changes.append(change)
1471
+ if not changes:
1472
+ return ""
1473
+ fence = "```diff" if markdown else ""
1474
+ sep = "------------------"
1475
+
1476
+ files = 0
1477
+ tot_added = tot_removed = tot_modified = 0
1478
+ for c in changes:
1479
+ if c.get("kind") in ("create", "modify", "delete", "rename"):
1480
+ files += 1
1481
+ a, r, m = _change_line_stats(c)
1482
+ tot_added += a
1483
+ tot_removed += r
1484
+ tot_modified += m
1485
+
1486
+ out = ["āœ“ Changes Applied", "",
1487
+ f"Files modified: {files}",
1488
+ f"Lines added: {tot_added}",
1489
+ f"Lines removed: {tot_removed}",
1490
+ f"Lines modified: {tot_modified}"]
1491
+
1492
+ for c in changes:
1493
+ kind = c.get("kind")
1494
+ path = c.get("path", "?")
1495
+ out.append("")
1496
+ out.append(sep)
1497
+ out.append("")
1498
+ if kind == "create":
1499
+ new = str(c.get("new") or "")
1500
+ count = len(new.splitlines())
1501
+ out.append(f"āœ“ Created: {path} ({count} line{'s' if count != 1 else ''} added)")
1502
+ rows = [("+", j + 1, line) for j, line in enumerate(new.splitlines()[:max_rows])]
1503
+ _append_diff_block(out, rows, fence)
1504
+ elif kind == "modify":
1505
+ rows = _git_diff_rows(str(c.get("old") or ""), str(c.get("new") or ""), max_rows)
1506
+ if not rows:
1507
+ continue
1508
+ a, r, _m = _change_line_stats(c)
1509
+ out.append(f"āœŽ Modified: {path} (+{a} \u2212{r})")
1510
+ _append_diff_block(out, rows, fence)
1511
+ elif kind == "delete":
1512
+ out.append(f"šŸ—‘ Deleted: {path}")
1513
+ elif kind == "rename":
1514
+ out.append(f"šŸ“„ Renamed: {path} \u2192 {c.get('new_path', '?')}")
1515
+ elif kind == "terminal":
1516
+ out.append(f"› {c.get('command', '')}")
1517
+ output = str(c.get("output") or "").strip().splitlines()
1518
+ for line in output[:max_output_lines]:
1519
+ out.append(" " + line)
1520
+ if len(output) > max_output_lines:
1521
+ out.append(f" ... {len(output) - max_output_lines} more line(s) omitted")
1522
+ code = c.get("exit_code")
1523
+ dur = c.get("duration")
1524
+ status = "\u2713" if code == 0 else "\u2717"
1525
+ out.append(f"{status} exit {code}" + (f" ({dur}s)" if dur is not None else ""))
1526
+
1527
+ out.append("")
1528
+ out.append(sep)
1529
+ out.append("")
1530
+ out.append("Timeline")
1531
+ for _name, _args, _obs, change in (steps or []):
1532
+ if not isinstance(change, dict) or not change:
1533
+ continue
1534
+ kind = change.get("kind")
1535
+ if kind in ("create", "modify", "delete", "rename"):
1536
+ action = {"create": "Created", "modify": "Edited",
1537
+ "delete": "Deleted", "rename": "Renamed"}[kind]
1538
+ base = os.path.basename(str(change.get("path", "")))
1539
+ a, r, _m = _change_line_stats(change)
1540
+ extra = f" (+{a} \u2212{r})" if kind == "modify" and (a or r) else ""
1541
+ out.append(f"\u2713 {action} {base}{extra}")
1542
+ elif kind == "terminal":
1543
+ out.append(f"\u2713 Ran {change.get('command', '')}")
1544
+ out.append("\u2713 Finished")
1545
+ return "\n".join(out)
1546
+
1547
+
1548
+ def _tool_read_file(args):
1549
+ raw_path = str(args.get("path", "")).strip()
1550
+ if not raw_path:
1551
+ return "No path given."
1552
+ try:
1553
+ path = _resolve_tool_path(raw_path)
1554
+ except ValueError as e:
1555
+ return f"Refused: {e}"
1556
+ if not os.path.isfile(path):
1557
+ return f"'{path}' is not a file (or doesn't exist)."
1558
+ content, truncated = aicore.read_text_file_for_context(path)
1559
+ if content is None:
1560
+ return f"Could not read '{path}' (binary or unreadable)."
1561
+ note = " (truncated)" if truncated else ""
1562
+ return f"Contents of '{path}'{note}:\n{content}"
1563
+
1564
+
1565
+ def _tool_device_action(args):
1566
+ """v0.7.7 Responsible Device Control (spec section 14): plans an
1567
+ action on an authorized device through device_control.py, and
1568
+ executes it only when the provider reports it can really do the
1569
+ thing. The permission dispatch (perm_key device_control) is the
1570
+ user's explicit approval gate; every action is logged."""
1571
+ from . import device_control as dc
1572
+ provider_key = str(args.get("provider", "")).strip().lower()
1573
+ action = str(args.get("action", "run")).strip() or "run"
1574
+ target = str(args.get("target", "")).strip()
1575
+ if not provider_key:
1576
+ return ("No provider given. Available: "
1577
+ + ", ".join(f"{p['key']}" for p in dc.available_providers()) + ".")
1578
+ resolved = dc.plan_action(provider_key, action, {"target": target,
1579
+ "command": args.get("command")})
1580
+ if not resolved["ok"]:
1581
+ dc.log_action(provider_key, action, target, "denied",
1582
+ resolved.get("reason", "unavailable"))
1583
+ return resolved["reason"]
1584
+ plan = resolved["plan"]
1585
+ if plan.get("command") is None and plan.get("effect", "").startswith(
1586
+ "Runs in your terminal"):
1587
+ return "Planned: " + plan["effect"]
1588
+ result = dc.execute_approved(provider_key, plan, {"target": target,
1589
+ "command": args.get("command")})
1590
+ if result.get("ok"):
1591
+ return (f"Device action approved and executed ({provider_key}/{action}): "
1592
+ + str(result.get("note") or result.get("exit_code") or "done"))
1593
+ return (f"Device action planned but execution failed: "
1594
+ + str(result.get("note", "unknown error")))
1595
+
1596
+
1597
+ def _tool_list_directory(args):
1598
+ raw_path = str(args.get("path", "") or ".").strip()
1599
+ try:
1600
+ path = workspace.resolve_writable_path(raw_path)
1601
+ except ValueError as e:
1602
+ return f"Refused: {e}"
1603
+ if not os.path.isdir(path):
1604
+ return f"'{path}' is not a folder (or doesn't exist)."
1605
+
1606
+ def _scan():
1607
+ try:
1608
+ entries = sorted(os.listdir(path))
1609
+ except OSError as e:
1610
+ return None
1611
+ lines = [f"Contents of '{path}':"]
1612
+ for name in entries[:200]:
1613
+ full = os.path.join(path, name)
1614
+ kind = "dir" if os.path.isdir(full) else "file"
1615
+ size = "" if kind == "dir" else f", {os.path.getsize(full)}B"
1616
+ lines.append(f" [{kind}] {name}{size}")
1617
+ if len(entries) > 200:
1618
+ lines.append(f" ...and {len(entries) - 200} more entries.")
1619
+ return "\n".join(lines)
1620
+
1621
+ # v0.7.9.0: repeated listings within one agent run are served from a
1622
+ # TTL+mtime-guarded cache (fs_cache) — invalidated by any CAT write/
1623
+ # delete/rename and by external directory changes (mtime probe).
1624
+ from . import fs_cache
1625
+ cached = fs_cache.cached_dir_listing(path, _scan)
1626
+ return cached or f"Could not list '{path}'."
1627
+
1628
+
1629
+ TOOLS = {
1630
+ "list_formulas": {
1631
+ "run": _tool_list_formulas,
1632
+ "desc": "List every formula key in the library (call this if unsure of a key).",
1633
+ "args": "{}",
1634
+ },
1635
+ "solve_formula": {
1636
+ "run": _tool_solve_formula,
1637
+ "desc": "Solve a named library formula for its one unknown variable.",
1638
+ "args": '{"key": "ideal_gas", "values": {"P": 1, "n": 2, "T": 300}} (leave exactly one variable out of values)',
1639
+ },
1640
+ "solve_custom": {
1641
+ "run": _tool_solve_custom,
1642
+ "desc": "Solve ANY formula you write, for any named variable — not limited to the library.",
1643
+ "args": '{"formula": "P*V = n*R*T", "values": {"P": 1, "n": 2, "T": 300}, "solve_for": "V"}',
1644
+ },
1645
+ "calculate": {
1646
+ "run": _tool_calculate,
1647
+ "desc": "Evaluate a plain arithmetic/scientific expression exactly (+ - * / ** sqrt log sin cos ...).",
1648
+ "args": '{"expression": "2.303/50 * log10(1.0/0.25)"}',
1649
+ },
1650
+ "generate_numerical": {
1651
+ "run": _tool_generate_numerical,
1652
+ "desc": "Generate and render a full randomized notebook-style numerical for a topic (kinetics/mole/etc).",
1653
+ "args": '{"topic": "first order half life"}',
1654
+ },
1655
+ "plot_preset": {
1656
+ "run": _tool_plot_preset,
1657
+ "desc": "Plot one of the 10 built-in animated chemistry curves (kinetics, Arrhenius, Boyle's, titration...).",
1658
+ "args": '{"preset": "arrhenius", "export": false}',
1659
+ },
1660
+ "plot_function": {
1661
+ "run": _tool_plot_function,
1662
+ "desc": "Plot/animate ANY 2D function you name yourself, e.g. a curve not in the presets.",
1663
+ "args": '{"expression": "sin(x)*exp(-x/5)", "xmin": 0, "xmax": 20, "title": "Damped oscillation", "export": false}',
1664
+ },
1665
+ "plot_surface": {
1666
+ "run": _tool_plot_surface,
1667
+ "desc": "Export a high-quality 3D surface PNG: built-in ('orbital_3d','pvt') or any z=f(x,y) you name.",
1668
+ "args": '{"kind": "custom", "expression": "exp(-(x**2+y**2)/4)", "title": "Gaussian electron density"}',
1669
+ },
1670
+ "simulate_atom_2d": {
1671
+ "run": _tool_atom_2d,
1672
+ "desc": "Run the live 2D animated Bohr atom / electron / proton / neutron simulation for an element.",
1673
+ "args": '{"element": "Fe", "frames": 220}',
1674
+ },
1675
+ "simulate_atom_3d": {
1676
+ "run": _tool_atom_3d,
1677
+ "desc": "Run the live real-time 3D atom / electron / proton simulation (rotatable) for an element.",
1678
+ "args": '{"element": "Na", "frames": 220}',
1679
+ },
1680
+ "simulate_orbital": {
1681
+ "run": _tool_orbital,
1682
+ "desc": "Run the live quantum orbital electron-cloud Monte-Carlo simulation (1s,2s,2p,3s,3p,3d).",
1683
+ "args": '{"orbital": "2p", "points": 1500}',
1684
+ },
1685
+ "orbital_grid": {
1686
+ "run": _tool_orbital_grid,
1687
+ "desc": "Export a reference-chart PNG of many hydrogen orbitals' |psi|^2 side by side (n up to 4).",
1688
+ "args": "{}",
1689
+ },
1690
+ "bonding_map": {
1691
+ "run": _tool_bonding,
1692
+ "desc": "Export a stylized chemical-bonding electron-density map (ELF-style, rainbow colormap) for a molecule.",
1693
+ "args": '{"molecule": "furan"} (available: furan, water, methane, co2, ethanol, benzene)',
1694
+ },
1695
+ "bloch_sphere": {
1696
+ "run": _tool_bloch,
1697
+ "desc": "Export a Bloch sphere for a single qubit state, with exact Z/X/Y measurement probabilities.",
1698
+ "args": '{"theta_deg": 90, "phi_deg": 0} (90,0 = |+>; 0,0 = |0>; 90,90 = |+i>)',
1699
+ },
1700
+ "web_search": {
1701
+ "run": _tool_web_search,
1702
+ "desc": "Search the live web for current information (no API key needed).",
1703
+ "args": '{"query": "latest IUPAC atomic weight of lithium", "max_results": 5}',
1704
+ },
1705
+ "deep_research": {
1706
+ "run": _tool_deep_research,
1707
+ "desc": "Run several web searches on different angles of a topic and synthesize a cited summary.",
1708
+ "args": '{"topic": "green hydrogen production methods", "num_queries": 3}',
1709
+ },
1710
+ "read_attachment": {
1711
+ "run": _tool_read_attachment,
1712
+ "desc": "Read/analyze a file or image the user imported with /import this session.",
1713
+ "args": '{"path": "notes.txt"} (path can be a partial match; omit to use the most recent import)',
1714
+ },
1715
+ "read_file": {
1716
+ "run": _tool_read_file,
1717
+ "desc": "Read a real file from disk by path (workspace-relative or absolute).",
1718
+ "args": '{"path": "src/main.py"}',
1719
+ "perm_key": "read_files",
1720
+ },
1721
+ "list_directory": {
1722
+ "run": _tool_list_directory,
1723
+ "desc": "List a real folder's contents by path (workspace-relative or absolute).",
1724
+ "args": '{"path": "src"} (omit path to list the workspace root)',
1725
+ "perm_key": "read_files",
1726
+ },
1727
+ "write_file": {
1728
+ "run": _tool_write_file,
1729
+ "desc": "Create or overwrite a real file on disk with the given text content.",
1730
+ "args": '{"path": "src/main.py", "content": "print(1)", "overwrite": false, '
1731
+ '"reason": "Generate the requested script."}',
1732
+ "perm_key": "write_files",
1733
+ "describe": lambda a: ("Write file", str(a.get("path", "")),
1734
+ str(a.get("reason") or "Generate/update requested file content.")),
1735
+ },
1736
+ "create_folder": {
1737
+ "run": _tool_create_folder,
1738
+ "desc": "Create a real folder on disk (and any missing parent folders).",
1739
+ "args": '{"path": "src/models", "reason": "Organize the new module."}',
1740
+ "perm_key": "write_files",
1741
+ "describe": lambda a: ("Create folder", str(a.get("path", "")),
1742
+ str(a.get("reason") or "Create requested folder.")),
1743
+ },
1744
+ "delete_file": {
1745
+ "run": _tool_delete_file,
1746
+ "desc": "Permanently delete a single real file on disk (folders are refused).",
1747
+ "args": '{"path": "old_notes.txt", "reason": "No longer needed."}',
1748
+ "perm_key": "write_files",
1749
+ "describe": lambda a: ("Delete file", str(a.get("path", "")),
1750
+ str(a.get("reason") or "Remove requested file.")),
1751
+ },
1752
+ "rename_file": {
1753
+ "run": _tool_rename_file,
1754
+ "desc": "Rename/move a real file or folder on disk.",
1755
+ "args": '{"path": "old.py", "new_path": "new.py", "reason": "Clearer name."}',
1756
+ "perm_key": "write_files",
1757
+ "describe": lambda a: (f"Rename '{a.get('path', '')}' → '{a.get('new_path', '')}'",
1758
+ str(a.get("path", "")),
1759
+ str(a.get("reason") or "Rename/move requested file.")),
1760
+ },
1761
+ "edit_file": {
1762
+ "run": _tool_edit_file,
1763
+ "desc": "Edit a file by replacing old_text with new_text (find/replace). "
1764
+ "The old_text must appear exactly in the file.",
1765
+ "args": '{"path": "main.py", "old_text": "old code", "new_text": "new code", "reason": "Fix bug."}',
1766
+ "perm_key": "write_files",
1767
+ "describe": lambda a: (f"Edit '{a.get('path', '')}'",
1768
+ str(a.get("old_text", "")[:60]),
1769
+ str(a.get("reason") or "Edit file content.")),
1770
+ },
1771
+ "search_workspace": {
1772
+ "run": _tool_search_workspace,
1773
+ "desc": "Search for files or content in the workspace. Returns matching lines with file:line.",
1774
+ "args": '{"query": "def main", "file_pattern": "*.py", "max_results": 10}',
1775
+ "describe": lambda a: (f"Search for '{a.get('query', '')}'",
1776
+ str(a.get("query", "")),
1777
+ "Search workspace files."),
1778
+ },
1779
+ "archive_list": {
1780
+ "run": _tool_archive_list,
1781
+ "desc": "List all entries in an archive (ZIP, tar.gz, tar.bz2) — folders and files, with sizes. Use this FIRST on any attached/compressed file.",
1782
+ "args": '{"path": "backup.zip"}',
1783
+ "describe": lambda a: (f"Inspect archive: {a.get('path', '')}",
1784
+ str(a.get("path", "")),
1785
+ "List archive contents."),
1786
+ },
1787
+ "archive_delete_entries": {
1788
+ "run": _tool_archive_delete_entries,
1789
+ "desc": "Delete entries from a ZIP archive by exact name or glob pattern; folder names remove the whole folder tree. Safe-replace: original preserved until the modified copy validates.",
1790
+ "args": '{"path": "backup.zip", "entries": ["folders/", "old_file.txt"]} or {"pattern": "*.tmp"}',
1791
+ "perm_key": "write_files",
1792
+ "describe": lambda a: (f"Delete entries from archive: {a.get('path', '')}",
1793
+ str(a.get("path", "")),
1794
+ "Remove selected entries from the archive."),
1795
+ },
1796
+ "archive_extract": {
1797
+ "run": _tool_archive_extract,
1798
+ "desc": "Extract an archive into a real destination folder (default: a folder named after the archive in the workspace).",
1799
+ "args": '{"path": "project.zip", "dest": "extracted_project"}',
1800
+ "perm_key": "write_files",
1801
+ "describe": lambda a: (f"Extract archive: {a.get('path', '')}",
1802
+ str(a.get("dest", "")),
1803
+ "Extract the archive to disk."),
1804
+ },
1805
+ "archive_add_entries": {
1806
+ "run": _tool_archive_add_entries,
1807
+ "desc": "Add files (from disk) and/or new text entries into an existing ZIP archive.",
1808
+ "args": '{"path": "project.zip", "files": ["notes.txt"]} or {"entries": [{"name": "docs/readme.md", "content": "..."}]}',
1809
+ "perm_key": "write_files",
1810
+ "describe": lambda a: (f"Add entries to archive: {a.get('path', '')}",
1811
+ str(a.get("path", "")),
1812
+ "Add entries into the archive."),
1813
+ },
1814
+ "archive_repack": {
1815
+ "run": _tool_archive_repack,
1816
+ "desc": "Rebuild a ZIP fresh: recompress, dedupe repeated names, optionally drop directory placeholder entries (drop_dirs).",
1817
+ "args": '{"path": "project.zip", "drop_dirs": false}',
1818
+ "perm_key": "write_files",
1819
+ "describe": lambda a: (f"Repack archive: {a.get('path', '')}",
1820
+ str(a.get("path", "")),
1821
+ "Rebuild/recompress the archive."),
1822
+ },
1823
+ "archive_validate": {
1824
+ "run": _tool_archive_validate,
1825
+ "desc": "Validate that an archive file is intact and readable (zip CRC test / tar read-through). Run this after any modification.",
1826
+ "args": '{"path": "backup.zip"}',
1827
+ "describe": lambda a: (f"Validate archive: {a.get('path', '')}",
1828
+ str(a.get("path", "")),
1829
+ "Check archive integrity."),
1830
+ },
1831
+ "run_build": {
1832
+ "run": _tool_run_build,
1833
+ "desc": "Build the project in the active workspace (auto-detects npm/pyproject/setup.py/cargo/Makefile/CMake, or pass an explicit command). Real build output and exit code.",
1834
+ "args": '{"command": "npm run build"} (or {} to auto-detect)',
1835
+ "perm_key": "shell_commands",
1836
+ "describe": lambda a: ("Run project build", str(a.get("command", "") or "(auto-detected)"),
1837
+ "Build the project so its result can be verified."),
1838
+ },
1839
+ "run_tests": {
1840
+ "run": _tool_run_tests,
1841
+ "desc": "Run the project's test suite (auto-detects pytest/npm test/cargo/go test, or pass an explicit command). Real results.",
1842
+ "args": '{"command": "python -m pytest"} (or {} to auto-detect)',
1843
+ "perm_key": "shell_commands",
1844
+ "describe": lambda a: ("Run tests", str(a.get("command", "") or "(auto-detected)"),
1845
+ "Execute the project's test suite."),
1846
+ },
1847
+ "inspect_project": {
1848
+ "run": _tool_inspect_project,
1849
+ "desc": "Inspect the active workspace/project: layout, folders, files, manifests (package.json / pyproject.toml / ...). Use this to ground yourself before acting.",
1850
+ "args": '{"path": "."} (omit for the workspace root)',
1851
+ "describe": lambda a: ("Inspect project", str(a.get("path", "") or "."),
1852
+ "Survey the project layout and manifests."),
1853
+ },
1854
+ "run_terminal": {
1855
+ "run": _tool_run_terminal,
1856
+ "desc": "Run a terminal command on the user's machine in the active "
1857
+ "workspace folder (e.g. 'python main.py', 'pip install ...'). "
1858
+ "The user sees the command, its output, and the exit code.",
1859
+ "args": '{"command": "python main.py", "reason": "Run the generated script."}',
1860
+ "perm_key": "shell_commands",
1861
+ "describe": lambda a: ("Run terminal command", str(a.get("command", "")),
1862
+ str(a.get("reason") or "Execute requested terminal command.")),
1863
+ },
1864
+ "install_packages": {
1865
+ "run": _tool_install_packages,
1866
+ "desc": "Install real packages with the Autonomous Package Manager "
1867
+ "(pip/npm/cargo/brew/winget...). Governed by the active "
1868
+ "permission mode: asks in Ask-Each-Time, automatic in Full "
1869
+ "Access, refused in Restricted. Unknown packages are "
1870
+ "researched first.",
1871
+ "args": '{"packages": "requests"} or {"packages": ["numpy", "pandas"]}',
1872
+ "perm_key": "install_packages",
1873
+ "describe": lambda a: ("Install package(s)", str(a.get("packages", a.get("package", ""))),
1874
+ "Install the requested package(s)."),
1875
+ },
1876
+ "device_action": {
1877
+ "run": _tool_device_action,
1878
+ "desc": "Request an action on the user's authorized device via the "
1879
+ "Responsible Device Control framework (terminal, simulation; "
1880
+ "desktop/android/ios/browser are extension points).",
1881
+ "args": '{"provider": "simulation", "action": "run", "target": "atom Fe"}',
1882
+ "perm_key": "device_control",
1883
+ "describe": lambda a: (f"Device action: {a.get('provider', '?')}/{a.get('action', '?')}",
1884
+ str(a.get("target", "")),
1885
+ "Perform an action on your device (must be explicitly approved)."),
1886
+ },
1887
+ }
1888
+
1889
+ _TOOL_DOCS = "\n".join(f"- {name}({spec['args']}) \u2014 {spec['desc']}" for name, spec in TOOLS.items())
1890
+
1891
+ _PROTOCOL = f"""Reply with EXACTLY ONE JSON object per turn and nothing else (no markdown fences, no
1892
+ commentary outside the JSON). The "action" field must be literally the
1893
+ string "tool" or "final" — NEVER the tool's name itself — and every tool
1894
+ argument MUST be nested inside "args", never placed at the top level:
1895
+ To use a tool: {{"action": "tool", "tool": "<name>", "args": {{...}}, "thought": "<why>"}}
1896
+ To finish: {{"action": "final", "text": "<answer for the user>"}}
1897
+
1898
+ Correct example for the calculate tool:
1899
+ {{"action": "tool", "tool": "calculate", "args": {{"expression": "2+2"}}, "thought": "..."}}
1900
+ The following format is also understood, but the above is preferred:
1901
+ {{"action": "calculate", "expression": "2+2", "thought": "..."}}
1902
+
1903
+ Available tools:
1904
+ {_TOOL_DOCS}
1905
+
1906
+ Rules:
1907
+ - ACT, DON'T DESCRIBE. When the user asks for an operation you have a tool
1908
+ for (create/edit/delete/move files, run commands, install packages,
1909
+ inspect or modify archives, build, test), EXECUTE it with the tools.
1910
+ NEVER reply with a tutorial, a list of commands for the user to run, or a
1911
+ description of what you "would do" — you are the executor. Never emit
1912
+ fake tool-call markup (<invoke>, <parameter>, ...) as prose: real calls
1913
+ happen only through this JSON protocol, and CCT executes them for real.
1914
+ - NEVER claim a file was created/edited/deleted unless a tool result in
1915
+ this conversation confirms it. Only the TOOL RESULT lines count as proof.
1916
+ - INSPECT FIRST. Before touching anything, ground yourself: inspect_project
1917
+ and/or list_directory for workspace tasks; read_attachment / archive_list
1918
+ for attached files. Use the REAL paths from those results.
1919
+ - ASK ONLY WHEN TRULY AMBIGUOUS. If exactly one file is attached, that IS
1920
+ the file the user means. If there is one active workspace, that IS the
1921
+ project. Do not ask "which file?" or "which project?" when context already
1922
+ answers it — pick the obvious target, act, and say what you chose.
1923
+ - FILE CREATION POLICY — EXPLICIT_ONLY (STRICT v0.7.9.6). Never create a
1924
+ permanent file or directory inside a user-provided workspace/project path
1925
+ unless the user explicitly requested that file/directory OR the requested
1926
+ operation necessarily and unavoidably requires that exact file. A path
1927
+ argument means "work with the existing location and its existing contents"
1928
+ — NOT "populate it with a template". Do NOT automatically scaffold default
1929
+ folders (calculations, graphs, images, projects, reports, research,
1930
+ scripts, simulations) or placeholder/README/analysis/report files. Do NOT
1931
+ invent a project structure. Existing projects must remain structurally
1932
+ unchanged unless the task explicitly requires a change. Suggestions may be
1933
+ offered in chat, but no filesystem change occurs without explicit
1934
+ authorization. Temporary artifacts belong in the system temp directory.
1935
+ - After modifying an archive, always archive_validate before reporting done.
1936
+ - Prefer a tool over guessing whenever something can be computed, plotted,
1937
+ simulated, built, or tested exactly. Never invent a numeric result a tool
1938
+ could produce.
1939
+ - You may call several tools across turns (one step per turn) before
1940
+ finishing. Keep going until the task is genuinely complete — inspect,
1941
+ change, verify, fix errors from real output, retry — then return "final".
1942
+ - If a question requires a formula you don't have in your library, derive it
1943
+ or formulate it from first principles and use `solve_custom`.
1944
+ - Always end with a "final" action summarizing what you actually did, with
1945
+ concrete results (paths, sizes, exit codes).
1946
+ - If the request needs no tool (pure explanation/definition), return "final"
1947
+ immediately.
1948
+ - Never give up on a hard problem — break it into a chain of small tool
1949
+ calls (one intermediate quantity per call) and keep going across turns.
1950
+ - If a sub-question genuinely cannot be pinned to an exact tool result,
1951
+ reason it out yourself and state the assumption plainly, but still give a
1952
+ concrete final answer for every part of the question that was asked.
1953
+ """
1954
+
1955
+ AGENT_SYSTEM_PROMPT = f"""You are CCT Agent, the autonomous IDE agent inside the Chemistry Calc Terminal
1956
+ (CCT): a first-principles thinker AND a hands-on operator. Unlike a plain
1957
+ chatbot, you directly OPERATE real tools: solve exact formulas symbolically,
1958
+ run the safe calculator, plot any 2D curve or 3D surface, drive live atom /
1959
+ electron / quantum-orbital simulations — and just as importantly: create,
1960
+ edit, rewrite, move and delete real files, create folders, search the
1961
+ workspace, read attachments, inspect/modify/validate ZIP archives, run
1962
+ terminal commands, build projects, run tests, and install packages.
1963
+
1964
+ Your primary strength is your ability to invent, solve, AND EXECUTE. When a
1965
+ request implies work on the workspace or an attached file, your job is to do
1966
+ the work end-to-end with tools — not to explain how the user could do it.
1967
+
1968
+ {_PROTOCOL}
1969
+ - When the user's message contains several distinct questions (e.g. "(a)
1970
+ ... (b) ... (c) ..." or multiple sentences each asking for a value),
1971
+ solve every one of them before returning "final", and structure the
1972
+ final text so each part's answer is clearly labeled.
1973
+
1974
+ **HOW TO WRITE YOUR "final" TEXT — NOTEBOOK FORMAT, ALWAYS LONGER AND
1975
+ THOROUGH** (this is your signature style, distinct from the quick, casual
1976
+ CCT AI chat mode):
1977
+ - Never answer in one bare line. Explain fully, the way a careful teacher
1978
+ writing out a notebook page would, even if the user's question was short.
1979
+ - Structure the answer with short, ALL-CAPS section headers on their own
1980
+ line (no markdown symbols needed, plain text is fine), each followed by
1981
+ one or more lines of explanation:
1982
+ * For a numerical/calculation question use headers in this order:
1983
+ GIVEN DATA, FIND, FORMULA, SUBSTITUTION, CALCULATION, VERIFICATION,
1984
+ FINAL ANSWER.
1985
+ * For a conceptual/definition/"explain X" question use: OVERVIEW,
1986
+ EXPLANATION, EXAMPLE, KEY POINTS, FINAL ANSWER (a one-line takeaway).
1987
+ * For a plot/simulation request, describe what was rendered under
1988
+ OVERVIEW / WHAT WAS RUN / OBSERVATIONS / FINAL ANSWER.
1989
+ * For a build/file/archive task use: WHAT WAS FOUND, WHAT WAS DONE,
1990
+ VERIFICATION, FINAL ANSWER — with real paths and real results.
1991
+ - Under CALCULATION or EXPLANATION, use several short lines/steps rather
1992
+ than one dense paragraph — one idea per line, numbered where it helps.
1993
+ - Always finish with a FINAL ANSWER section giving the concrete result or
1994
+ one-sentence takeaway.
1995
+ - Whenever you created or changed files this turn (write_file /
1996
+ create_folder / delete_file / rename_file / edit_file / archive_* /
1997
+ run_terminal), end your reply with a short "Why?" section of 2-5 bullet
1998
+ lines ("• ...") explaining what you changed and why. The app appends the
1999
+ exact diff separately, so keep this to the reasons, not the code itself.
2000
+
2001
+ {identity.IDENTITY_BLOCK}
2002
+ """
2003
+
2004
+ AI_SYSTEM_PROMPT = f"""You are CCT AI, the friendly quick-chat assistant embedded inside the Chemistry
2005
+ Calc Terminal (CCT). You share the same real tools as CCT Agent — solve,
2006
+ plot, simulate, AND create/edit/delete real files, inspect and modify
2007
+ archives, run commands, build, test, install packages. Use them whenever an
2008
+ exact number/plot/real action is needed; never guess a computable result and
2009
+ never merely describe an operation you could just perform.
2010
+
2011
+ {_PROTOCOL}
2012
+
2013
+ **HOW TO WRITE YOUR "final" TEXT — TALK NORMAL, KEEP IT SHORT:**
2014
+ - You are a conversation, not a notebook. Answer the way a knowledgeable
2015
+ friend would text back: a few natural sentences, plain language, no
2016
+ ALL-CAPS section headers, no forced GIVEN/FIND/FORMULA structure.
2017
+ - Give the key number or idea straight away, then one or two sentences of
2018
+ context if useful. Skip filler and long preambles.
2019
+ - It's fine to run tools and report the result conversationally, e.g.
2020
+ "That works out to k = 4.6e-3 s^-1 — nearly first order here."
2021
+ - If the question is genuinely huge (a long multi-part numerical, a full
2022
+ derivation, "explain in detail/step by step", several sub-questions at
2023
+ once), you may still solve it with tools, but keep your reply here brief
2024
+ (the headline results, plainly stated) rather than writing the whole
2025
+ notebook out — the app will separately point the user to `/agent` for
2026
+ the complete step-by-step notebook version, so you don't need to.
2027
+ - Whenever you created or changed files this turn (write_file /
2028
+ create_folder / delete_file / rename_file / edit_file / archive_* /
2029
+ run_terminal), end your reply with a short "Why?" section of 2-5 bullet
2030
+ lines ("• ...") explaining what you changed and why. The app appends the
2031
+ exact diff separately, so keep this to the reasons, not the code itself.
2032
+
2033
+ {identity.IDENTITY_BLOCK}
2034
+ """
2035
+
2036
+ # Backward-compatible alias — existing code/imports that referenced the old
2037
+ # single SYSTEM_PROMPT constant keep working (defaults to the Agent style).
2038
+ SYSTEM_PROMPT = AGENT_SYSTEM_PROMPT
2039
+
2040
+
2041
+ # --------------------------------------------------------- memory helpers --
2042
+ def _guess_topic(text):
2043
+ """Cheap keyword-based topic label for the memory 'topics you ask about
2044
+ most' counter — good enough to power recall, no NLP needed."""
2045
+ t = (text or "").lower()
2046
+ keywords = [
2047
+ "first order", "second order", "third order", "zero order", "half life",
2048
+ "half-life", "arrhenius", "activation energy", "mole concept", "molar mass",
2049
+ "molarity", "nernst", "faraday", "electrochemistry", "ideal gas", "boyle",
2050
+ "orbital", "atomsim", "bohr", "titration", "equilibrium", "thermodynamics",
2051
+ "kinetics", "redox", "stoichiometry", "ph", "buffer",
2052
+ ]
2053
+ for k in keywords:
2054
+ if k in t:
2055
+ return k
2056
+ words = [w.strip(".,?!") for w in t.split() if len(w.strip(".,?!")) >= 5]
2057
+ return words[0] if words else None
2058
+
2059
+
2060
+ _LONG_ANSWER_HINTS = re.compile(
2061
+ r"\bstep[- ]?by[- ]?step\b|\bderiv(e|ation)\b|\bexplain in detail\b|\bfull solution\b|"
2062
+ r"\bdetailed\b|\bin depth\b|\(a\)|\(b\)|\(c\)|\bevery part\b|\bmulti.?step\b|\blong answer\b",
2063
+ re.IGNORECASE,
2064
+ )
2065
+
2066
+
2067
+ def _looks_like_long_answer(user_text, final_text, steps):
2068
+ """Heuristic used by the CCT AI (chat) mode to decide whether to nudge
2069
+ the user toward /agent for the full notebook-style breakdown, instead
2070
+ of the short conversational answer /ai just gave."""
2071
+ if steps and len(steps) >= 3:
2072
+ return True
2073
+ if final_text and len(final_text) > 600:
2074
+ return True
2075
+ if user_text and _LONG_ANSWER_HINTS.search(user_text):
2076
+ return True
2077
+ return False
2078
+
2079
+
2080
+ def _extract_json(text):
2081
+ if not text:
2082
+ return None
2083
+ text = text.strip()
2084
+ # Strip DeepSeek-R1 / Qwen reasoning thought blocks before parsing JSON
2085
+ text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
2086
+ text = re.sub(r">\s*\*Thinking:\*.*?(?=\n\n|\Z)", "", text, flags=re.DOTALL)
2087
+ # Strip common markdown code-fence wrapping some models add anyway.
2088
+ text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip(), flags=re.MULTILINE)
2089
+ m = re.search(r"\{.*\}", text, re.S)
2090
+ if not m:
2091
+ return None
2092
+ candidate = m.group(0)
2093
+ try:
2094
+ return json.loads(candidate)
2095
+ except Exception:
2096
+ # try trimming to the first balanced-looking object
2097
+ depth = 0
2098
+ for i, ch in enumerate(candidate):
2099
+ if ch == "{":
2100
+ depth += 1
2101
+ elif ch == "}":
2102
+ depth -= 1
2103
+ if depth == 0:
2104
+ try:
2105
+ return json.loads(candidate[:i + 1])
2106
+ except Exception:
2107
+ return None
2108
+ return None
2109
+
2110
+
2111
+ def _build_prompt(convo):
2112
+ lines = []
2113
+ for turn in convo:
2114
+ lines.append(f"{turn['role'].upper()}: {turn['text']}")
2115
+ lines.append("ASSISTANT:")
2116
+ return "\n\n".join(lines)
2117
+
2118
+
2119
+ def _normalize_action(action):
2120
+ """Coerce the model's parsed JSON into the strict
2121
+ {"action": "tool"/"final", "tool": ..., "args": {...}} shape.
2122
+
2123
+ Models frequently drift from the exact protocol while still meaning
2124
+ the same thing — e.g. putting the tool name directly in "action"
2125
+ instead of wrapping it (`{"action": "calculate", "expression": ...}`
2126
+ instead of `{"action": "tool", "tool": "calculate", "args": {...}}`),
2127
+ or using a top-level "tool" key with no "action" at all, or leaving
2128
+ tool arguments unwrapped at the top level instead of nesting them
2129
+ under "args". Previously any of these caused the whole JSON blob to
2130
+ be dumped to the user verbatim as if it were the final answer,
2131
+ which looked like the agent "not solving anything" (this was the
2132
+ root cause of the reported bug). Accepting these common variants
2133
+ makes the agent robust to how a given provider/model actually talks,
2134
+ instead of requiring byte-perfect formatting.
2135
+ """
2136
+ if not isinstance(action, dict):
2137
+ return None
2138
+
2139
+ # NEW: Check for implicit tool call where a tool name is a key.
2140
+ # e.g. {"solve_custom": {"formula": "...", "values": ...}, "thought": "..."}
2141
+ tool_keys = [k for k in action.keys() if k in TOOLS]
2142
+ if len(tool_keys) == 1 and "action" not in action and "tool" not in action:
2143
+ tool_name = tool_keys[0]
2144
+ tool_args = action[tool_name]
2145
+ if isinstance(tool_args, dict):
2146
+ return {
2147
+ "action": "tool",
2148
+ "tool": tool_name,
2149
+ "args": tool_args,
2150
+ "thought": action.get("thought") or "Corrected from implicit tool call format."
2151
+ }
2152
+
2153
+ act = action.get("action")
2154
+ reserved = {"action", "tool", "args", "thought"}
2155
+
2156
+ # Canonical shape already — nothing to do.
2157
+ if act == "final" or act == "tool":
2158
+ return action
2159
+
2160
+ # {"tool": "calculate", "args": {...}, ...} with no "action" key.
2161
+ if act is None and isinstance(action.get("tool"), str) and action["tool"] in TOOLS:
2162
+ args = action.get("args")
2163
+ if not isinstance(args, dict):
2164
+ args = {k: v for k, v in action.items() if k not in reserved}
2165
+ return {"action": "tool", "tool": action["tool"], "args": args,
2166
+ "thought": action.get("thought")}
2167
+
2168
+ # {"action": "calculate", "expression": ..., "thought": ...} — the
2169
+ # tool name was put straight into "action" and its arguments left
2170
+ # at the top level instead of nested under "args". This is exactly
2171
+ # the shape shown in the bug report screenshots.
2172
+ if isinstance(act, str) and act in TOOLS:
2173
+ args = action.get("args")
2174
+ if not isinstance(args, dict):
2175
+ args = {k: v for k, v in action.items() if k not in reserved}
2176
+ return {"action": "tool", "tool": act, "args": args,
2177
+ "thought": action.get("thought")}
2178
+
2179
+ # A bare {"text": "..."} / {"answer": "..."} reply with no "action".
2180
+ for key in ("text", "answer", "response", "message"):
2181
+ if isinstance(action.get(key), str):
2182
+ return {"action": "final", "text": action[key]}
2183
+
2184
+ return action
2185
+
2186
+
2187
+ # v0.7.8 BONUS FIX (Section 4): users must NEVER see the internal tool
2188
+ # protocol. Models occasionally drift and hand back a JSON action blob
2189
+ # as if it were the answer — e.g. a raw {"action":"final","text":"..."}
2190
+ # line, or prose with a JSON object glued onto it. Every path that
2191
+ # hands text to the user runs through clean_final_text() so only the
2192
+ # clean payload is ever rendered.
2193
+
2194
+
2195
+ def _balanced_json_spans(text):
2196
+ """Yield (start, end) spans of brace-balanced JSON-looking objects
2197
+ in `text` (handles nested braces like {"args": {"expression": ..}}).
2198
+ Not a full JSON parser — spans are validated with json.loads later."""
2199
+ spans = []
2200
+ depth = 0
2201
+ start = -1
2202
+ for i, ch in enumerate(text):
2203
+ if ch == "{":
2204
+ if depth == 0:
2205
+ start = i
2206
+ depth += 1
2207
+ elif ch == "}":
2208
+ if depth > 0:
2209
+ depth -= 1
2210
+ if depth == 0 and start >= 0:
2211
+ spans.append((start, i + 1))
2212
+ start = -1
2213
+ return spans
2214
+
2215
+
2216
+ def _extract_protocol_text(data):
2217
+ """If a parsed dict is a protocol object (has an 'action' key, or is
2218
+ just a bare text/answer/response wrapper), return its text payload —
2219
+ else None."""
2220
+ if not isinstance(data, dict):
2221
+ return None
2222
+ if isinstance(data.get("action"), str):
2223
+ payload = data.get("text") or data.get("answer") \
2224
+ or data.get("response") or data.get("message")
2225
+ return payload if isinstance(payload, str) else None
2226
+ for key in ("text", "answer", "response", "message"):
2227
+ if isinstance(data.get(key), str):
2228
+ return data[key]
2229
+ return None
2230
+
2231
+
2232
+ def _is_protocol_blob(data):
2233
+ """True when a parsed dict is internal tool protocol (carries an
2234
+ 'action' key) rather than ordinary quoted JSON the user wrote."""
2235
+ return isinstance(data, dict) and isinstance(data.get("action"), str)
2236
+
2237
+
2238
+ def clean_final_text(text):
2239
+ """Parse any leaked tool-protocol JSON/XML out of `text` and return
2240
+ only the clean user-facing content. Pure function, never raises.
2241
+
2242
+ Handles four shapes:
2243
+ 1. The whole reply is one JSON object (optionally fence-wrapped)
2244
+ with a "text"/"answer"/"response" payload -> returns payload.
2245
+ 2. A JSON protocol object is glued before/after real prose
2246
+ (e.g. a tool echo) -> strips the blob.
2247
+ 3. XML-like tool call tags (<minimax:toolcall>, <invoke>, etc.)
2248
+ are stripped from displayed text.
2249
+ 4. Plain prose (no JSON/XML) -> returned unchanged.
2250
+ """
2251
+ if not text or not isinstance(text, str):
2252
+ return text or ""
2253
+ stripped = text.strip()
2254
+ if not stripped:
2255
+ return ""
2256
+
2257
+ # Shape 1: whole-reply JSON with a text payload (also fence-wrapped).
2258
+ m = re.search(r"\{.*\}", stripped, re.S)
2259
+ if m and len(m.group(0).replace(" ", "")) >= len(stripped.replace(" ", "")) * 0.7:
2260
+ try:
2261
+ data = json.loads(m.group(0))
2262
+ payload = _extract_protocol_text(data)
2263
+ if payload and payload.strip():
2264
+ return payload.strip()
2265
+ except Exception:
2266
+ pass
2267
+
2268
+ # Shape 2: strip protocol blobs wherever they're embedded, keep prose.
2269
+ out = []
2270
+ cursor = 0
2271
+ changed = False
2272
+ for start, end in _balanced_json_spans(text):
2273
+ chunk = text[start:end]
2274
+ try:
2275
+ data = json.loads(chunk)
2276
+ except Exception:
2277
+ continue
2278
+ if not _is_protocol_blob(data):
2279
+ continue
2280
+ out.append(text[cursor:start])
2281
+ cursor = end
2282
+ changed = True
2283
+ if changed:
2284
+ out.append(text[cursor:])
2285
+ cleaned = "".join(out)
2286
+ cleaned = re.sub(r"[ \t]+\n", "\n", cleaned)
2287
+ cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
2288
+ text = cleaned or text.strip()
2289
+
2290
+ # v0.8.0: Strip XML-like tool call tags that some providers emit
2291
+ # (e.g. <minimax:toolcall><invoke name="...">...</invoke></minimax:toolcall>)
2292
+ # These are internal protocol and must never reach the user.
2293
+ text = strip_tool_markup(text)
2294
+ # Strip <think>...</think> from final output if prose follows
2295
+ if "<think>" in text and "</think>" in text:
2296
+ post_think = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
2297
+ if post_think:
2298
+ text = post_think
2299
+ # Clean up any leftover empty lines from removed tags
2300
+ text = re.sub(r"\n{3,}", "\n\n", text).strip()
2301
+ return text
2302
+
2303
+
2304
+ # Compiled regex patterns for streaming chunk sanitization (shared,
2305
+ # avoids recompilation on every chunk).
2306
+ _XML_TOOLCALL_RE = re.compile(
2307
+ r"<(?:minimax|anthropic|openai|gemini|provider)?\s*:?\s*toolcall\s*>"
2308
+ r".*?"
2309
+ r"</(?:minimax|anthropic|openai|gemini|provider)?\s*:?\s*toolcall\s*>",
2310
+ re.DOTALL | re.IGNORECASE,
2311
+ )
2312
+ _XML_INVOKE_RE = re.compile(
2313
+ r"<invoke\s+[^>]*>.*?</invoke>", re.DOTALL | re.IGNORECASE
2314
+ )
2315
+ _XML_BARE_INVOKE_RE = re.compile(
2316
+ r"<invoke\s+[^>]*>.*", re.DOTALL | re.IGNORECASE # unclosed <invoke> — strip to EOF
2317
+ )
2318
+ _XML_PARAM_RE = re.compile(r"<parameter\s+[^>]*>.*?</parameter>", re.DOTALL | re.IGNORECASE)
2319
+ _XML_TOOLCALL_TAG_RE = re.compile(
2320
+ r"</?tool_call>", re.IGNORECASE
2321
+ )
2322
+ _XML_TOOLCALL_BLOCK_RE = re.compile(
2323
+ r"<tool_call>.*?</tool_call>", re.DOTALL | re.IGNORECASE # full block incl. contents
2324
+ )
2325
+ _XML_NAME_ARGS_RE = re.compile(
2326
+ r"<name>[^<]*</name>\s*<arguments>.*?</arguments>", re.DOTALL | re.IGNORECASE
2327
+ )
2328
+ _XML_FUNCTION_RE = re.compile(
2329
+ r"<function=\w+>.*?</function>|<function_calls>.*?</function_calls>",
2330
+ re.DOTALL | re.IGNORECASE,
2331
+ )
2332
+ _JSON_PROTOCOL_RE = re.compile(r"\{[^{}]*\"action\"\s*:\s*\"(?:tool|final)\"[^{}]*\}", re.DOTALL)
2333
+
2334
+
2335
+ def strip_tool_markup(text):
2336
+ """Remove EVERY form of internal tool-call protocol from `text`
2337
+ (requirement #23/#24): wrapped and bare <invoke> blocks (including an
2338
+ unclosed trailing <invoke>), <parameter ...> blocks, <tool_call>
2339
+ wrappers, <function=...>/<function_calls> blocks, minimax-style
2340
+ toolcall envelopes, and JSON action blobs. Used by clean_final_text,
2341
+ the clipboard copy path, and history/export so raw protocol never
2342
+ leaves the app."""
2343
+ if not text or not isinstance(text, str):
2344
+ return text or ""
2345
+ text = _XML_TOOLCALL_RE.sub("", text)
2346
+ text = _XML_INVOKE_RE.sub("", text)
2347
+ text = _XML_BARE_INVOKE_RE.sub("", text) if "<invoke" in text.lower() else text
2348
+ text = _XML_PARAM_RE.sub("", text)
2349
+ text = _XML_TOOLCALL_BLOCK_RE.sub("", text)
2350
+ text = _XML_NAME_ARGS_RE.sub("", text)
2351
+ text = _XML_TOOLCALL_TAG_RE.sub("", text)
2352
+ text = _XML_FUNCTION_RE.sub("", text)
2353
+ # a leftover JSON protocol blob on its own line(s)
2354
+ text = _JSON_PROTOCOL_RE.sub("", text)
2355
+ return re.sub(r"\n{3,}", "\n\n", text).strip()
2356
+
2357
+
2358
+ def sanitize_stream_chunk(chunk):
2359
+ """Strip internal tool-call protocol from a single streaming chunk.
2360
+
2361
+ During streaming, models may emit XML-like tool call tags or
2362
+ JSON protocol objects token-by-token. This function removes them
2363
+ so the user never sees raw protocol markup. Returns the cleaned
2364
+ chunk, or an empty string if the entire chunk was protocol.
2365
+
2366
+ This is a lightweight per-chunk filter — the full clean_final_text()
2367
+ is still applied to the assembled complete response.
2368
+ """
2369
+ if not chunk or not isinstance(chunk, str):
2370
+ return chunk or ""
2371
+ chunk = _XML_TOOLCALL_RE.sub("", chunk)
2372
+ if "<invoke" in chunk.lower():
2373
+ # a partial <invoke> in one chunk can't be matched by the closed
2374
+ # pattern; drop from the tag onward — clean_final_text re-runs on
2375
+ # the assembled text as the final authority.
2376
+ idx = chunk.lower().find("<invoke")
2377
+ chunk = chunk[:idx]
2378
+ chunk = _XML_PARAM_RE.sub("", chunk)
2379
+ chunk = _XML_TOOLCALL_TAG_RE.sub("", chunk)
2380
+ chunk = _XML_FUNCTION_RE.sub("", chunk)
2381
+ chunk = _JSON_PROTOCOL_RE.sub("", chunk)
2382
+ return chunk
2383
+
2384
+
2385
+ def _remember_turn(user_text, final_text, steps, mode):
2386
+ """Best-effort: log this exchange + 'every movement' into persistent
2387
+ memory so both /ai and /agent recall it (and each other's turns, plus
2388
+ facts/topics/activity) on the next question or the next session.
2389
+ Never allowed to raise — memory is a nice-to-have, not a dependency.
2390
+
2391
+ v0.7.9.0: per-tool-step activity lines are written in ONE batched
2392
+ save instead of one full-file rewrite per step, and the layered
2393
+ memory uses the process-wide singleton manager (no re-init per turn).
2394
+ """
2395
+ try:
2396
+ t0 = time.perf_counter()
2397
+ activity = [("ai_question" if mode == "ai" else "agent_question",
2398
+ user_text.strip().replace("\n", " ")[:120])]
2399
+ activity.extend(
2400
+ ("tool:" + name,
2401
+ f"ran {name} \u2014 {json.dumps(args, default=str)[:100]}")
2402
+ for name, args, _obs, _change in steps)
2403
+ memory.add_turn("user", user_text, mode=mode)
2404
+ memory.add_turn("assistant", final_text, mode=mode)
2405
+ memory.bump_topic(_guess_topic(user_text))
2406
+ fact = memory.maybe_extract_fact(user_text)
2407
+ if fact:
2408
+ memory.add_fact(fact)
2409
+ memory.add_activities(activity)
2410
+ try:
2411
+ from . import metrics
2412
+ m = metrics.current()
2413
+ if m is not None:
2414
+ m.mark_stage("memory_retrieval", t0)
2415
+ except Exception:
2416
+ pass
2417
+ except Exception:
2418
+ pass
2419
+ # v0.8.0: Also persist to the new layered memory system
2420
+ try:
2421
+ from . import memory_v2
2422
+ mm = memory_v2.get_manager()
2423
+ mm.add_session_turn("user", user_text, mode=mode)
2424
+ mm.add_session_turn("assistant", final_text, mode=mode)
2425
+ # Emit event for the event stream
2426
+ try:
2427
+ from .event_stream import stream
2428
+ stream.emit(AGENT_COMPLETED, source="agent",
2429
+ user_text=user_text[:200], steps_count=len(steps), mode=mode)
2430
+ except Exception:
2431
+ pass
2432
+ except Exception:
2433
+ pass
2434
+
2435
+
2436
+ def _cli_permission_prompt(key, action_label, path, reason):
2437
+ """Default permission prompt for every run_agent() caller that
2438
+ doesn't hand it a UI-specific callback (i.e. the classic REPL and
2439
+ /agent command in calc_terminal/app.py) — same card shape/colors as
2440
+ code_editor.py's _scan_and_confirm, so a mutating tool call looks
2441
+ like every other permission moment in the app instead of a special
2442
+ case. Blocks on a real input(), which is safe here because every
2443
+ caller that can't afford to block (the Textual UI's threaded
2444
+ worker) is required to pass its own non-blocking-from-the-UI's-
2445
+ perspective callback instead — see ui/app.py's
2446
+ _tool_permission_callback.
2447
+ """
2448
+ restricted = perm.manager.restricted_review(key)
2449
+ title = "Suggested action \u2014 review required" if restricted else "Permission required"
2450
+ lines = [theme.orange(title, bold=True)]
2451
+ lines.append(theme.orange("ACTION", bold=True) + f" {action_label}")
2452
+ if path:
2453
+ lines.append(theme.orange("PATH", bold=True) + f" {path}")
2454
+ lines.append(theme.orange("REASON", bold=True) + f" {reason}")
2455
+ lines.append("")
2456
+ if restricted:
2457
+ lines.append(theme.dim(" [Enter] Accept [n] Reject"))
2458
+ else:
2459
+ lines.append(theme.dim(" [Enter] Allow once [a] Always allow [n] Deny"))
2460
+ print()
2461
+ print(theme.panel(lines, title="permission", color=theme.ORANGE))
2462
+ choice = input(theme.dim(" \u25b8 ")).strip().lower()
2463
+ if choice == "n":
2464
+ return "deny"
2465
+ if choice == "a" and not restricted:
2466
+ return "always_allow"
2467
+ return "allow_once"
2468
+
2469
+
2470
+ def _check_permission(name, args, permission_callback):
2471
+ """Returns (proceed: bool, observation_or_None). Looks up the
2472
+ tool's perm_key/describe metadata, asks perm.manager whether this
2473
+ call needs a prompt under the current mode, and if so resolves it
2474
+ via permission_callback (or the CLI default above) before ever
2475
+ calling the tool's real run() function.
2476
+
2477
+ v0.7.7 spec section 3: package installs ALWAYS prompt, even in Full
2478
+ Access (the mode is temporarily narrowed to Ask Every Time for the
2479
+ duration of the install and restored afterwards — see
2480
+ permissions.install_flow_state / restore_mode, applied here for the
2481
+ agent-tool entry point, and in the app.py / ui/app.py install flows
2482
+ for the direct-entry point)."""
2483
+ spec = TOOLS[name]
2484
+ perm_key = spec.get("perm_key")
2485
+ if not perm_key:
2486
+ return True, None
2487
+ _trace("PERMISSION_CHECK", tool=name, key=perm_key,
2488
+ mode=perm.manager.mode)
2489
+
2490
+ if perm_key == "install_packages":
2491
+ from . import packages as _pkgs
2492
+ from . import permissions as _perm
2493
+ flow = _perm.install_flow_state()
2494
+ if not flow["allow"]:
2495
+ _trace("PERMISSION_RESULT", tool=name, allowed=False,
2496
+ reason="restricted mode")
2497
+ return False, ("Permission denied: Restricted mode — package "
2498
+ "installs are disabled, nothing was installed.")
2499
+ raw = str(args.get("packages") or args.get("package") or "").strip()
2500
+ if "," in raw:
2501
+ raw = raw.split(",")[0].strip()
2502
+ req, _mgr = _pkgs.resolve_request(raw or "package")
2503
+ remembered = _pkgs.remembered_decision(req.manager, req.name) if req and req.manager else None
2504
+ if remembered == "deny":
2505
+ return False, (f"Install of {req.name} was previously denied (remembered "
2506
+ f"choice) — nothing installed.")
2507
+ if remembered == "allow":
2508
+ return True, None # user permanently approved this exact package+manager
2509
+ if perm.manager.refused_by_always_deny(perm_key):
2510
+ return False, "Blocked by permission setting — previously denied for this session."
2511
+ needs = perm.manager.needs_prompt(perm_key)
2512
+ else:
2513
+ if perm.manager.refused_by_always_deny(perm_key):
2514
+ return False, "Blocked by permission setting — previously denied for this session."
2515
+ needs = perm.manager.needs_prompt(perm_key)
2516
+ if not needs:
2517
+ return True, None
2518
+ describe = spec.get("describe")
2519
+ if describe:
2520
+ action_label, path, reason = describe(args)
2521
+ else:
2522
+ action_label, path, reason = name, str(args.get("path", "")), "Requested by the AI."
2523
+ callback = permission_callback or _cli_permission_prompt
2524
+ decision = callback(perm_key, action_label, path, reason)
2525
+ proceed = perm.manager.decide(perm_key, decision, action_label, reason)
2526
+ if proceed:
2527
+ return True, None
2528
+ verb = "Rejected" if perm.manager.restricted_review(perm_key) else "Denied"
2529
+ return False, (f"{verb} by the user \u2014 did not {action_label.lower()}. "
2530
+ f"Do not retry this exact action; tell the user it was declined "
2531
+ f"and continue only if there's another way to help.")
2532
+
2533
+
2534
+ def _trace(event, **kw):
2535
+ """Requirement #29 diagnostic trace. Writes one structured line per
2536
+ pipeline stage (MODEL_RESPONSE / TOOL_DETECTED / TOOL_PARSED /
2537
+ PERMISSION_CHECK / PERMISSION_RESULT / TOOL_STARTED / TOOL_FINISHED /
2538
+ TOOL_FAILED / MODEL_CONTINUED) to stderr — never to the chat — so a
2539
+ broken model→tool→execution handoff is immediately visible in dev
2540
+ (`textual run --dev`, or any console run) without touching the UI."""
2541
+ try:
2542
+ import sys as _sys
2543
+ parts = [f"[CAT:AGENT] {event}"]
2544
+ for k, v in kw.items():
2545
+ v = str(v).replace("\n", "\\n")
2546
+ if len(v) > 160:
2547
+ v = v[:157] + "..."
2548
+ parts.append(f"{k}={v}")
2549
+ line = " | ".join(parts)
2550
+ # Legacy-console safe: never let diagnostics crash the pipeline.
2551
+ try:
2552
+ print(line, file=_sys.stderr)
2553
+ except UnicodeEncodeError:
2554
+ print(line.encode("ascii", "replace").decode(), file=_sys.stderr)
2555
+ except Exception:
2556
+ pass
2557
+
2558
+
2559
+ def run_agent(user_text, max_steps=MAX_STEPS, verbose=True, on_step=None, mode="agent",
2560
+ permission_callback=None, should_cancel=None, on_tool_result=None,
2561
+ fast=False, memory_query=None, turn_id=""):
2562
+ """Run the agent loop for one user request.
2563
+
2564
+ `mode` selects the persona/voice: "agent" (CCT Agent — long, thorough,
2565
+ notebook-formatted answers) or "ai" (CCT AI — short, conversational
2566
+ chat). Both share the exact same tools and memory.
2567
+
2568
+ v0.7.8.1: `should_cancel` is an optional zero-arg callable checked
2569
+ between loop iterations and before tool dispatch; when it returns
2570
+ truthy the loop stops immediately and hands back everything
2571
+ computed so far (the caller decides how to mark the interruption —
2572
+ the UI appends its own 'interrupted' note).
2573
+
2574
+ v0.7.9.0 (speed requirements): `fast=True` skips memory retrieval and
2575
+ other per-turn overhead entirely (simple requests must not pay for
2576
+ what they don't use); `memory_query` enables relevance-filtered
2577
+ memory injection instead of a fixed recent-window dump. Every model
2578
+ call / tool call is counted into metrics.current(), and real agent
2579
+ lifecycle events are published to event_stream so the UI shows live,
2580
+ truthful activity.
2581
+
2582
+ Returns (final_text, steps, meta) where steps is a list of
2583
+ (tool_name, args, observation) tuples that were actually executed, and
2584
+ meta is a dict with at least:
2585
+ - "mode": the mode this ran in
2586
+ - "suggest_agent": True if (in "ai" mode) this looked like a big
2587
+ enough question that the user should be pointed to /agent for the
2588
+ complete step-by-step notebook version.
2589
+ """
2590
+ def _cancelled():
2591
+ try:
2592
+ return bool(should_cancel and should_cancel())
2593
+ except Exception:
2594
+ return False
2595
+
2596
+ from . import metrics as _metrics
2597
+ _mreq = _metrics.current()
2598
+ if _mreq is not None:
2599
+ _mreq.stage_start("agent_loop")
2600
+
2601
+ def _emit(event_type, **data):
2602
+ try:
2603
+ from .event_stream import stream as _es
2604
+ _es.emit(event_type, source="agent", **data)
2605
+ except Exception:
2606
+ pass
2607
+
2608
+ system_prompt = AGENT_SYSTEM_PROMPT if mode == "agent" else AI_SYSTEM_PROMPT
2609
+ try:
2610
+ # v0.7.8 AI Personalization: the active profile's style
2611
+ # directive rides along inside the same system prompt the tool
2612
+ # loop already uses — tools, memory and permissions untouched.
2613
+ from . import ai_personalization as _ap
2614
+ system_prompt = _ap.personalize_system_prompt(system_prompt)
2615
+ except Exception:
2616
+ pass
2617
+ mem_ctx = ""
2618
+ # Memory toggle: if user disabled memory, skip all retrieval
2619
+ _mem_enabled = True
2620
+ try:
2621
+ from . import ai_personalization as _ap2
2622
+ _mem_enabled = _ap2.is_memory_enabled()
2623
+ except Exception:
2624
+ pass
2625
+ if not fast and _mem_enabled:
2626
+ t_mem = time.perf_counter()
2627
+ try:
2628
+ mem_ctx = memory.context_block(mode=mode, query=memory_query or user_text)
2629
+ except Exception:
2630
+ mem_ctx = ""
2631
+ try:
2632
+ from . import memory_v2
2633
+ mm = memory_v2.get_manager()
2634
+ v2_ctx = mm.context_block(query=memory_query or user_text)
2635
+ if v2_ctx:
2636
+ mem_ctx = (mem_ctx + "\n\n" + v2_ctx) if mem_ctx else v2_ctx
2637
+ except Exception:
2638
+ pass
2639
+ if _mreq is not None:
2640
+ _mreq.mark_stage("memory_retrieval", t_mem)
2641
+ if mem_ctx:
2642
+ system_prompt = system_prompt + "\n\n" + mem_ctx
2643
+ attach_ctx = _attachments_context()
2644
+ if attach_ctx:
2645
+ system_prompt = system_prompt + "\n\n" + attach_ctx
2646
+ # v0.8.0: Inject REAL workspace root and attachment paths so the model
2647
+ # never invents /workspace or other fake paths. The model MUST use
2648
+ # these real paths in tool arguments.
2649
+ try:
2650
+ _ws_root = workspace.root_dir()
2651
+ _ws_info = (
2652
+ f"\n\n## ACTIVE WORKSPACE (use this for all file operations)\n"
2653
+ f"Workspace root: {_ws_root}\n"
2654
+ f"When calling file tools (read_file, write_file, list_directory, etc.), "
2655
+ f"use workspace-relative paths (e.g. 'src/main.py') or the full absolute "
2656
+ f"path shown above. NEVER invent paths like '/workspace/...' — that is "
2657
+ f"not a real directory on this system.\n"
2658
+ )
2659
+ _found_files = []
2660
+ if os.path.isdir(_ws_root):
2661
+ for r, d_list, f_list in os.walk(_ws_root):
2662
+ d_list[:] = [d for d in d_list if not d.startswith(".") and d not in ("node_modules", "__pycache__", ".venv", "venv", ".pytest_cache", "dist", "build")]
2663
+ for f in f_list:
2664
+ rel_p = os.path.relpath(os.path.join(r, f), _ws_root).replace("\\", "/")
2665
+ _found_files.append(rel_p)
2666
+ if len(_found_files) >= 40:
2667
+ break
2668
+ if len(_found_files) >= 40:
2669
+ break
2670
+ if _found_files:
2671
+ _ws_info += "Existing files in active workspace (use these exact relative paths):\n"
2672
+ for fp in _found_files:
2673
+ _ws_info += f" - {fp}\n"
2674
+
2675
+ if _ATTACHMENTS:
2676
+ _ws_info += "Attached files (use their REAL paths in tool calls):\n"
2677
+ for a in _ATTACHMENTS:
2678
+ if hasattr(a, 'path'):
2679
+ _ws_info += f" - {a.path}\n"
2680
+ system_prompt = system_prompt + _ws_info
2681
+ except Exception:
2682
+ pass
2683
+
2684
+ convo = [{"role": "user", "text": user_text}]
2685
+ steps = []
2686
+ last_call = None
2687
+ stall_count = 0
2688
+
2689
+ # CAT Resilience: Decoupled Agent Task State
2690
+ _agent_state = None
2691
+ _task_req = None
2692
+ try:
2693
+ from .resilience.agent_state import AgentTaskState
2694
+ from .resilience.types import TaskRequirements
2695
+ _agent_state = AgentTaskState(task_id=turn_id or "", objective=user_text, convo=convo)
2696
+ _task_req = TaskRequirements(tools=True, coding=(mode in ("agent", "build")))
2697
+ except Exception:
2698
+ pass
2699
+
2700
+ # v0.8.0: Emit agent started event
2701
+ try:
2702
+ from .event_stream import stream, AGENT_STARTED
2703
+ stream.emit(AGENT_STARTED, source="agent", user_text=user_text[:200], mode=mode,
2704
+ fast=fast)
2705
+ except Exception:
2706
+ pass
2707
+ try:
2708
+ config = aicore.load_config()
2709
+ from .event_stream import stream as _es, MODEL_SELECTED
2710
+ _es.emit(MODEL_SELECTED, source="agent",
2711
+ provider=config.get("provider"), model=config.get("model"),
2712
+ mode=mode)
2713
+ except Exception:
2714
+ pass
2715
+ _provider_act = None
2716
+
2717
+ def _finish(text):
2718
+ if _mreq is not None:
2719
+ _mreq.stage_end("agent_loop")
2720
+ _remember_turn(user_text, text, steps, mode)
2721
+ # ── v0.7.9 Live Activity: finalize turn activities if cancelled ──
2722
+ try:
2723
+ from . import activity as _act2
2724
+ if turn_id:
2725
+ if _cancelled():
2726
+ _act2.manager.cancel_turn(turn_id)
2727
+ else:
2728
+ for _a in _act2.manager.get_for_turn(turn_id):
2729
+ if _a.status in (_act2.STATUS_WAITING, _act2.STATUS_WAITING_PERMISSION):
2730
+ _act2.manager.update(_a.id, status=_act2.STATUS_CANCELLED, result="Cancelled")
2731
+ except Exception:
2732
+ pass
2733
+ meta = {
2734
+ "mode": mode,
2735
+ "suggest_agent": mode == "ai" and _looks_like_long_answer(user_text, text, steps),
2736
+ "file_changes": summarize_file_changes(steps),
2737
+ "model_calls": (_mreq.model_calls if _mreq else 0),
2738
+ "tool_calls_executed": len(steps),
2739
+ }
2740
+ _emit("final_response", mode=mode,
2741
+ steps=len(steps), text=text)
2742
+ if _mreq is not None:
2743
+ try:
2744
+ _mreq.finish(response=text, meta=meta)
2745
+ except Exception:
2746
+ try:
2747
+ _mreq.finish()
2748
+ except Exception:
2749
+ pass
2750
+ cleaned = clean_final_text(text)
2751
+ if not cleaned or not cleaned.strip():
2752
+ if steps:
2753
+ parts = []
2754
+ for name, args, obs, _c in steps:
2755
+ target = (args.get("path") or args.get("command")
2756
+ or args.get("expression") or "")
2757
+ parts.append(f"- {name}({target}): {obs[:200]}")
2758
+ cleaned = "Here's what I did:\n" + "\n".join(parts)
2759
+ else:
2760
+ cleaned = (text or "").strip() or "I processed your request but have no summary to show."
2761
+ return cleaned, steps, meta
2762
+
2763
+ def _execute_tool_call(name, args, raw):
2764
+ """The ONE place a decided tool call becomes a REAL executed tool
2765
+ (requirement #3): permission gate → spec['run'] → observation.
2766
+ Returns the observation string fed back to the model."""
2767
+ spec = TOOLS.get(name)
2768
+ args = args if isinstance(args, dict) else {}
2769
+
2770
+ fingerprint = (name, json.dumps(args, sort_keys=True, default=str))
2771
+ nonlocal stall_count, last_call
2772
+ repeat = fingerprint == last_call
2773
+ stall_count = stall_count + 1 if repeat else 0
2774
+ last_call = fingerprint
2775
+
2776
+ if not spec:
2777
+ _trace("TOOL_FAILED", tool=name, reason="unknown tool")
2778
+ return f"Tool '{name}' does not exist. Valid tools: {', '.join(TOOLS)}"
2779
+ if stall_count >= 1:
2780
+ return ("You already ran this exact tool call and got a result — repeating it "
2781
+ "won't help. Use that result and move on to the NEXT step, or if every "
2782
+ "part of the question is now answered, reply with the final JSON action.")
2783
+
2784
+ _emit("tool_detected", tool=name, args=args)
2785
+ # ── v0.7.9 Live Activity: create RUNNING activity with real canonical metadata ──
2786
+ _act_obj = None
2787
+ _act_id = None
2788
+ _t_tool_start = time.perf_counter()
2789
+ try:
2790
+ from . import activity as _act
2791
+ meta = _act.meta_for_tool(name)
2792
+ ttype, taction, ttitle = meta
2793
+ category = getattr(meta, "category", _act.CAT_TERMINAL)
2794
+ phase = getattr(meta, "phase", _act.PHASE_IMPLEMENTATION)
2795
+ # human-readable title with target
2796
+ target = (args.get("path") or args.get("query") or args.get("command")
2797
+ or args.get("packages") or args.get("expression") or args.get("preset")
2798
+ or args.get("element") or args.get("orbital") or "")
2799
+ if target:
2800
+ ttitle = f"{ttitle} — {str(target)[:50]}"
2801
+ if name == "search_workspace" and args.get("query"):
2802
+ ttitle = f"Searching for \"{args.get('query')}\""
2803
+ elif name == "read_file":
2804
+ ttitle = f"Reading {target}"
2805
+ elif name == "write_file":
2806
+ ttitle = f"Writing {target}"
2807
+ elif name == "edit_file":
2808
+ ttitle = f"Editing {target}"
2809
+ elif name == "delete_file":
2810
+ ttitle = f"Deleting {target}"
2811
+ elif name in ("run_terminal", "run_build", "run_tests"):
2812
+ ttitle = f"Running {target[:40]}" if target and target != "(auto-detected)" else ttitle
2813
+ elif name == "web_search":
2814
+ ttitle = f"Searching web for \"{args.get('query','')[:30]}\""
2815
+
2816
+ target_path_val = str(args.get("path", "") or args.get("new_path", ""))
2817
+ _act_obj = _act.manager.create(
2818
+ type=ttype, action=taction, title=ttitle,
2819
+ status=_act.STATUS_RUNNING, turn_id=turn_id or "",
2820
+ tool=name, file=target_path_val, target_path=target_path_val,
2821
+ command=str(args.get("command", "")), details=str(target)[:120],
2822
+ category=category, phase=phase, query=str(args.get("query", ""))
2823
+ )
2824
+ _act_id = _act_obj.id
2825
+ try:
2826
+ from .workflow_engine import event_bus, ExecutionEvent, EVENT_TOOL_STARTED, STATE_RUNNING
2827
+ event_bus.emit(ExecutionEvent(
2828
+ event_id=f"evt-{uuid.uuid4().hex[:8]}",
2829
+ trace_id=turn_id or "",
2830
+ parent_id="",
2831
+ event_type=EVENT_TOOL_STARTED,
2832
+ status=STATE_RUNNING,
2833
+ source="agent.tools",
2834
+ tool=name,
2835
+ description=ttitle,
2836
+ metadata={"args": args}
2837
+ ))
2838
+ except Exception:
2839
+ pass
2840
+ except Exception:
2841
+ pass
2842
+
2843
+ if verbose:
2844
+ # errors='replace': on legacy cp1252 Windows consoles a plain
2845
+ # print() of tool args containing emoji/Unicode would raise
2846
+ # UnicodeEncodeError INSIDE the agent loop and kill the whole
2847
+ # reply (surfaced by the v0.7.9.0 headless E2E probe).
2848
+ try:
2849
+ print(theme.dim(f" \u2699 agent \u2192 {name}({args})"))
2850
+ except UnicodeEncodeError:
2851
+ safe = f" agent -> {name}({args})".encode("ascii", "replace").decode()
2852
+ print(safe)
2853
+ if on_step:
2854
+ try:
2855
+ on_step(name, args)
2856
+ except Exception:
2857
+ pass
2858
+ _trace("TOOL_STARTED", tool=name, args=json.dumps(args, default=str)[:160])
2859
+
2860
+ # permission may become WAITING
2861
+ _perm_act_id = None
2862
+ try:
2863
+ from . import activity as _actp
2864
+ _spec = TOOLS.get(name, {})
2865
+ _pk = _spec.get("perm_key")
2866
+ if _pk and perm.manager.needs_prompt(_pk):
2867
+ _perm_act = _actp.manager.create(
2868
+ type=_actp.TYPE_PERMISSION, action="permission",
2869
+ title=f"Permission: {name}",
2870
+ status=_actp.STATUS_WAITING_PERMISSION, turn_id=turn_id or "",
2871
+ tool=name, details=f"Awaiting user decision for {name}",
2872
+ category=_actp.CAT_SYSTEM, phase=_actp.PHASE_IMPLEMENTATION,
2873
+ permission_req={"key": _pk, "tool": name, "args": args}
2874
+ )
2875
+ _perm_act_id = _perm_act.id
2876
+ except Exception:
2877
+ pass
2878
+
2879
+ proceed, denial_obs = _check_permission(name, args, permission_callback)
2880
+ _trace("PERMISSION_RESULT", tool=name, allowed=proceed)
2881
+ # resolve permission waiting activity
2882
+ if _perm_act_id:
2883
+ try:
2884
+ from . import activity as _actp2
2885
+ if proceed:
2886
+ _actp2.manager.update(_perm_act_id, status=_actp2.STATUS_COMPLETED, result="Allowed")
2887
+ else:
2888
+ _actp2.manager.update(_perm_act_id, status=_actp2.STATUS_FAILED, result="Denied by user")
2889
+ except Exception:
2890
+ pass
2891
+ if not proceed:
2892
+ sound.play("error")
2893
+ obs = denial_obs
2894
+ # update tool activity to failed due permission
2895
+ if _act_id:
2896
+ try:
2897
+ from . import activity as _act3
2898
+ _act3.manager.update(_act_id, status=_act3.STATUS_FAILED, result="Denied — " + (denial_obs[:80] if denial_obs else "permission denied"))
2899
+ except Exception:
2900
+ pass
2901
+ else:
2902
+ sound.play("tool")
2903
+ try:
2904
+ from .event_stream import stream as _stream, TOOL_STARTED
2905
+ _stream.emit(TOOL_STARTED, source="agent", tool=name, args=args)
2906
+ except Exception:
2907
+ pass
2908
+ try:
2909
+ _last_change.clear()
2910
+ _t_tool = time.perf_counter()
2911
+ obs = spec["run"](args)
2912
+ if _mreq is not None:
2913
+ _mreq.count_tool_call()
2914
+ _mreq.mark_stage("tool_execution", _t_tool)
2915
+ _trace("TOOL_FINISHED", tool=name,
2916
+ result=(obs or "")[:160].replace("\n", "\\n"))
2917
+ # success activity update with full canonical metrics
2918
+ if _act_id:
2919
+ try:
2920
+ from . import activity as _act4
2921
+ res = str(obs or "")
2922
+ summary = res.splitlines()[0][:100] if res else "Done"
2923
+ match_cnt = None
2924
+ line_cnt = None
2925
+ diff_sum = ""
2926
+ exit_c = 0
2927
+ if name == "search_workspace" and "matches" in res.lower():
2928
+ import re as _re
2929
+ m = _re.search(r'(\d+)\s*matches', res, _re.I)
2930
+ if m:
2931
+ match_cnt = int(m.group(1))
2932
+ summary = f"{match_cnt} matches"
2933
+ else:
2934
+ match_cnt = len(res.splitlines()) if len(res.splitlines()) > 1 else 1
2935
+ summary = f"{match_cnt} results"
2936
+ elif name == "read_file" and res:
2937
+ lines = res.splitlines()
2938
+ line_cnt = len(lines)
2939
+ summary = f"{line_cnt} lines" if line_cnt > 1 else summary
2940
+ elif name in ("write_file", "edit_file"):
2941
+ summary = "File saved" if ("Wrote" in res or "Edited" in res) else summary
2942
+ diff_sum = summary
2943
+ elif name in ("run_tests", "run_build", "run_terminal"):
2944
+ if "failed" in res.lower():
2945
+ summary = "Failed"
2946
+ exit_c = 1
2947
+ elif "exit code 0" in res.lower() or "success" in res.lower()[:200]:
2948
+ summary = "Passed" if name == "run_tests" else "Succeeded"
2949
+ exit_c = 0
2950
+ dur_ms = int((time.perf_counter() - _t_tool) * 1000)
2951
+ _act4.manager.update(
2952
+ _act_id, status=_act4.STATUS_COMPLETED, result=summary,
2953
+ details=res[:200], duration_ms=dur_ms,
2954
+ stdout=res[:2000], match_count=match_cnt,
2955
+ line_count=line_cnt, diff_summary=diff_sum,
2956
+ exit_code=exit_c
2957
+ )
2958
+ for line in res.splitlines()[:10]:
2959
+ if line.strip():
2960
+ _act4.manager.append_output(_act_id, line[:120])
2961
+ try:
2962
+ from .workflow_engine import event_bus, ExecutionEvent, EVENT_TOOL_COMPLETED, STATE_SUCCEEDED
2963
+ dur_ms = int((time.perf_counter() - _t_tool_start) * 1000)
2964
+ event_bus.emit(ExecutionEvent(
2965
+ event_id=f"evt-{uuid.uuid4().hex[:8]}",
2966
+ trace_id=turn_id or "",
2967
+ parent_id="",
2968
+ event_type=EVENT_TOOL_COMPLETED,
2969
+ status=STATE_SUCCEEDED,
2970
+ source="agent.tools",
2971
+ tool=name,
2972
+ description=f"{name}: {summary}",
2973
+ duration_ms=dur_ms,
2974
+ metadata={"result": summary}
2975
+ ))
2976
+ except Exception:
2977
+ pass
2978
+ except Exception:
2979
+ pass
2980
+ except Exception as e:
2981
+ obs = f"Tool '{name}' failed with a real error: {e}"
2982
+ _trace("TOOL_FAILED", tool=name, error=str(e))
2983
+ try:
2984
+ from .event_stream import stream as _stream, TOOL_FINISHED, ERROR
2985
+ _stream.emit(ERROR, source="agent", tool=name, error=str(e)[:200])
2986
+ except Exception:
2987
+ pass
2988
+ if _act_id:
2989
+ try:
2990
+ from . import activity as _act5
2991
+ dur_ms = int((time.perf_counter() - _t_tool_start) * 1000)
2992
+ _act5.manager.update(
2993
+ _act_id, status=_act5.STATUS_FAILED, result=str(e)[:100],
2994
+ details=str(e)[:200], error=str(e)[:500],
2995
+ duration_ms=dur_ms, stderr=str(e)[:1000],
2996
+ exit_code=1
2997
+ )
2998
+ try:
2999
+ from .workflow_engine import event_bus, ExecutionEvent, EVENT_TOOL_COMPLETED, STATE_FAILED
3000
+ event_bus.emit(ExecutionEvent(
3001
+ event_id=f"evt-{uuid.uuid4().hex[:8]}",
3002
+ trace_id=turn_id or "",
3003
+ parent_id="",
3004
+ event_type=EVENT_TOOL_COMPLETED,
3005
+ status=STATE_FAILED,
3006
+ source="agent.tools",
3007
+ tool=name,
3008
+ description=f"{name} failed: {e}",
3009
+ duration_ms=dur_ms,
3010
+ metadata={"error": str(e)}
3011
+ ))
3012
+ except Exception:
3013
+ pass
3014
+ except Exception:
3015
+ pass
3016
+ if on_tool_result:
3017
+ try:
3018
+ on_tool_result(name, args, str(obs))
3019
+ except Exception:
3020
+ pass
3021
+ try:
3022
+ from .event_stream import stream as _stream, TOOL_FINISHED
3023
+ _stream.emit(TOOL_FINISHED, source="agent",
3024
+ tool=name, args=args, result_preview=str(obs)[:200],
3025
+ has_change=bool(_last_change))
3026
+ _stream.emit("tool_result", source="agent",
3027
+ tool=name, args=args, result_preview=str(obs)[:200],
3028
+ has_change=bool(_last_change))
3029
+ except Exception:
3030
+ pass
3031
+ # if tool failed (detected via observation prefix), ensure activity reflects
3032
+ if _act_id and obs:
3033
+ low = str(obs).lower()
3034
+ if low.startswith("could not") or "failed with a real error" in low or "refused:" in low:
3035
+ try:
3036
+ from . import activity as _act6
3037
+ # only override if still running
3038
+ cur = _act6.manager.get(_act_id)
3039
+ if cur and cur.status == _act6.RUNNING:
3040
+ _act6.manager.update(_act_id, status=_act6.FAILED, result=str(obs).splitlines()[0][:100])
3041
+ except Exception:
3042
+ pass
3043
+ change = dict(_last_change)
3044
+ _last_change.clear()
3045
+ steps.append((name, args, obs, change))
3046
+ if _agent_state is not None:
3047
+ try:
3048
+ _agent_state.record_step(name, args, obs, change)
3049
+ except Exception:
3050
+ pass
3051
+ return obs
3052
+
3053
+ for i in range(max_steps):
3054
+ if _cancelled():
3055
+ return _finish(
3056
+ "*(interrupted \u2014 here is everything completed so far)*\n"
3057
+ + ("\n".join(f"- {n}: {o}" for n, _, o, _c in steps)
3058
+ if steps else "No tools had run yet."))
3059
+ _emit("agent_thinking", step=i + 1, tools_so_far=len(steps))
3060
+ prompt = _build_prompt(convo)
3061
+ raw = aicore.query_ai(prompt, system_prompt=system_prompt, size_class="large", requirements=_task_req)
3062
+ if (not raw or not raw.strip()) and not aicore.is_error_response(raw):
3063
+ # A blank response is almost always a transient hiccup, not a
3064
+ # real "the model has nothing to say" — retry once before
3065
+ # treating it as a hard failure. v0.7.9.0: an ERROR SIGNATURE
3066
+ # ("AI not configured", quota exhausted, ...) is NOT transient —
3067
+ # retrying it just duplicated a doomed network round trip.
3068
+ raw = aicore.query_ai(prompt, system_prompt=system_prompt, size_class="large", requirements=_task_req)
3069
+ if not raw or not raw.strip():
3070
+ # Still blank after retry — if we have tool steps, summarize
3071
+ # them. Otherwise report the failure honestly.
3072
+ if steps:
3073
+ return _finish(None)
3074
+ return _finish("I wasn't able to get a response from the AI provider. "
3075
+ "Please check your connection and try again.")
3076
+ _trace("MODEL_RESPONSE", mode=mode, step=i + 1,
3077
+ text=(raw or "")[:160].replace("\n", "\\n"))
3078
+
3079
+ # ---- Tool-call detector → parser → normalizer (requirements #1/#2).
3080
+ # The provider-independent normalizer runs FIRST: it recognizes the
3081
+ # JSON protocol AND every XML dialect models actually emit
3082
+ # (<invoke name=...>, <minimax:toolcall>, <tool_call>, bare
3083
+ # <invoke>, function-call syntax). Only when it finds nothing do we
3084
+ # fall back to the strict JSON action protocol below.
3085
+ tool_calls = normalize_tool_calls(raw, list(TOOLS.keys()))
3086
+ if tool_calls:
3087
+ _trace("TOOL_PARSED", count=len(tool_calls),
3088
+ names=",".join(tc.name for tc in tool_calls))
3089
+ convo.append({"role": "assistant", "text": raw})
3090
+ observations = []
3091
+ for tc in tool_calls[:3]:
3092
+ if _cancelled():
3093
+ break
3094
+ _trace("TOOL_DETECTED", tool=tc.name, fmt=tc.source_format,
3095
+ args=json.dumps(tc.arguments, default=str)[:160])
3096
+ obs = _execute_tool_call(tc.name, tc.arguments, raw)
3097
+ observations.append(f"[{tc.name}] {obs}")
3098
+ steps_left = max_steps - i - 1
3099
+ nudge = ""
3100
+ if steps_left <= 3:
3101
+ nudge = (f"\n\n(You have about {steps_left} step(s) left — start wrapping up: "
3102
+ "finish any remaining sub-calculations now and prepare the final answer.")
3103
+ convo.append({"role": "user", "text": (
3104
+ f"TOOL RESULT: {' | '.join(observations)}{nudge}\n\n"
3105
+ "Continue: call another tool if needed, or reply with the final JSON action now."
3106
+ )})
3107
+ _trace("MODEL_CONTINUED", fed_back=True, tools_so_far=len(steps))
3108
+ _emit("agent_continuing", step=i + 1, tools_so_far=len(steps))
3109
+ continue
3110
+
3111
+ action = _normalize_action(_extract_json(raw))
3112
+
3113
+ if not action or "action" not in action:
3114
+ # Model didn't follow the JSON protocol at all and no parser
3115
+ # recognized a tool call. If it at least produced real prose,
3116
+ # that's a usable answer — only bail with the "couldn't reach"
3117
+ # message when there's truly nothing.
3118
+ _trace("NO_TOOL_IN_RESPONSE", snippet=(raw or "")[:120])
3119
+ return _finish(raw or "I couldn't reach the AI provider. Try /verify.")
3120
+
3121
+ if action.get("action") == "final":
3122
+ text = action.get("text") or raw
3123
+ return _finish(text)
3124
+
3125
+ if action.get("action") == "tool":
3126
+ name = action.get("tool")
3127
+ args = action.get("args") or {}
3128
+ _trace("TOOL_DETECTED", tool=name, fmt="json_protocol",
3129
+ args=json.dumps(args, default=str)[:160])
3130
+ obs = _execute_tool_call(name, args, raw)
3131
+
3132
+ convo.append({"role": "assistant", "text": raw})
3133
+ steps_left = max_steps - i - 1
3134
+ nudge = ""
3135
+ if steps_left <= 3:
3136
+ nudge = (f"\n\n(You have about {steps_left} step(s) left — start wrapping up: "
3137
+ "finish any remaining sub-calculations now and prepare the final answer.")
3138
+ convo.append({"role": "user", "text": (
3139
+ f"TOOL RESULT [{name}]: {obs}{nudge}\n\n"
3140
+ "Continue: call another tool if needed, or reply with the final JSON action now."
3141
+ )})
3142
+ _trace("MODEL_CONTINUED", fed_back=True, tools_so_far=len(steps))
3143
+ _emit("agent_continuing", step=i + 1, tools_so_far=len(steps))
3144
+ continue
3145
+
3146
+ # Unrecognised action type — fall back to treating it as final text.
3147
+ return _finish(raw)
3148
+
3149
+ # Step budget exhausted — force one last, direct request for the best
3150
+ # possible final answer using everything computed so far, instead of
3151
+ # handing back an apologetic "I ran out of steps" placeholder.
3152
+ convo.append({"role": "user", "text": (
3153
+ "You're out of tool-call steps. Using every result computed above, give your single "
3154
+ "best complete numeric answer to the ORIGINAL question now — reply with the final "
3155
+ 'JSON action: {"action": "final", "text": "<complete answer, every part labeled>"}.'
3156
+ )})
3157
+ raw = aicore.query_ai(_build_prompt(convo), system_prompt=system_prompt, size_class="large", requirements=_task_req)
3158
+ action = _normalize_action(_extract_json(raw))
3159
+ if action and action.get("action") == "final" and action.get("text"):
3160
+ return _finish(action["text"])
3161
+ if raw and raw.strip():
3162
+ return _finish(raw)
3163
+ summary = "\n".join(f"- {n}: {o}" for n, _, o, _c in steps) or "No tools were run."
3164
+ return _finish("Here's everything computed so far while working on that:\n" + summary)
3165
+
3166
+
3167
+ # =============================================================================
3168
+ # Multi-agent collaboration [BETA]
3169
+ #
3170
+ # CCT talks to exactly one configured AI provider (whatever the user set up
3171
+ # in /model), so "multiple agents" here means multiple *specialized passes*
3172
+ # over that same provider — a Planner pass, then one or more Specialist
3173
+ # passes that actually run tools (reusing run_agent's real tool-execution
3174
+ # loop above), then a Tester/Verifier pass — each with its own system
3175
+ # prompt, each reported to the UI as it starts/finishes via on_agent(). This
3176
+ # is honestly a sequential pipeline, not literal parallel execution (a
3177
+ # single terminal session showing one live status line at a time wouldn't
3178
+ # be able to show true concurrency anyway) — but each stage genuinely
3179
+ # reasons with a different persona/objective and can be watched working.
3180
+ # =============================================================================
3181
+
3182
+ AGENT_TEAM = [
3183
+ ("researcher", "Researcher", "gathers outside context via web search before the plan is made (only when the question needs it)"),
3184
+ ("planner", "Planner", "breaks the question into an ordered list of concrete sub-tasks"),
3185
+ ("specialist", "Specialist", "runs the real tools (solve/plot/simulate/search) to execute the plan"),
3186
+ ("programmer", "Programmer", "writes/refines any code the answer needs (only for coding requests)"),
3187
+ ("debugger", "Debugger", "statically scans generated code for risky patterns and sandbox-tests it (only for coding requests)"),
3188
+ ("tester", "Tester", "checks the final numbers against the given data before anything is shown"),
3189
+ ]
3190
+
3191
+ # Roles that only join the pipeline when the request actually calls for
3192
+ # them — always running all six for a plain "what is molarity?" question
3193
+ # would be theatre, not a real team, so membership is content-gated.
3194
+ _RESEARCH_HINTS = re.compile(
3195
+ r"\b(latest|recent|current|news|today|this year|search|look up|find out|"
3196
+ r"who is|what happened|price of|update on)\b", re.I)
3197
+ _CODE_HINTS = re.compile(
3198
+ r"\b(code|python|script|program|function|write a .*(class|module)|"
3199
+ r"generate.*(code|script))\b", re.I)
3200
+
3201
+ _PLANNER_PROMPT = """You are the PLANNER on a small AI team working inside CCT (Chemistry Calc
3202
+ Terminal). You do not solve anything and you do not call tools. Read the
3203
+ user's question and reply with ONLY a JSON object of the form:
3204
+ {"plan": ["short step 1", "short step 2", ...]}
3205
+ Keep it to 2-5 concrete, concrete steps (e.g. "solve for k using the
3206
+ first-order rate formula", "plot the resulting curve"). No prose outside
3207
+ the JSON."""
3208
+
3209
+ _TESTER_PROMPT = """You are the TESTER on a small AI team working inside CCT (Chemistry Calc
3210
+ Terminal). You are handed the ORIGINAL question, the PLAN the team made,
3211
+ and the SPECIALIST's final answer. Check the arithmetic and units are
3212
+ internally consistent with the given data. Reply with ONLY a JSON object:
3213
+ {"ok": true, "note": ""} if it checks out, or
3214
+ {"ok": false, "note": "<what looks wrong, one sentence>"} if not.
3215
+ Do not redo the whole calculation from scratch — spot-check it."""
3216
+
3217
+
3218
+ def _plan_steps(user_text, system_prompt):
3219
+ """Planner pass: ask for a short JSON step list. Falls back to a
3220
+ single-step plan if the model doesn't cooperate, so the pipeline
3221
+ never blocks on this stage."""
3222
+ raw = aicore.query_ai(
3223
+ f"User's question:\n{user_text}",
3224
+ system_prompt=_PLANNER_PROMPT + "\n\n" + system_prompt,
3225
+ size_class="large",
3226
+ )
3227
+ data = _extract_json(raw) or {}
3228
+ plan = data.get("plan")
3229
+ if isinstance(plan, list) and plan:
3230
+ return [str(s) for s in plan][:6]
3231
+ return ["Work out and answer the question directly."]
3232
+
3233
+
3234
+ def _verify_answer(user_text, plan, final_text, system_prompt):
3235
+ """Tester pass: sanity-check the specialist's answer. Never raises —
3236
+ a failed/garbled check is treated as 'ok' rather than blocking the
3237
+ user from seeing their answer."""
3238
+ raw = aicore.query_ai(
3239
+ "ORIGINAL QUESTION:\n" + user_text +
3240
+ "\n\nPLAN:\n" + "\n".join(f"- {p}" for p in plan) +
3241
+ "\n\nSPECIALIST'S FINAL ANSWER:\n" + final_text,
3242
+ system_prompt=_TESTER_PROMPT + "\n\n" + system_prompt,
3243
+ size_class="normal",
3244
+ )
3245
+ data = _extract_json(raw) or {}
3246
+ if data.get("ok") is False and data.get("note"):
3247
+ return False, str(data["note"])
3248
+ return True, ""
3249
+
3250
+
3251
+ _PROGRAMMER_PROMPT = """You are the PROGRAMMER on a small AI team working inside CCT (Chemistry
3252
+ Calc Terminal). The user wants runnable code. Write clean, correct
3253
+ Python (or the requested language) in a single fenced code block, with
3254
+ a one-line explanation before it. No filler, no apologies."""
3255
+
3256
+ _DEBUGGER_NOTE_TEMPLATE = (
3257
+ "\n\n⚠ Debugger static scan of the generated code found: {summary}\n"
3258
+ "{details}\nReview before running this yourself."
3259
+ )
3260
+
3261
+
3262
+ def _research_pass(user_text, system_prompt):
3263
+ """Researcher pass: only fires when the question has a recency/lookup
3264
+ flavor (regex-gated, see _RESEARCH_HINTS). Runs a real web search and
3265
+ folds a short synthesized brief into the context handed to the
3266
+ Planner, so downstream stages aren't reasoning from stale training
3267
+ data on a fast-moving fact."""
3268
+ if not _RESEARCH_HINTS.search(user_text):
3269
+ return ""
3270
+ try:
3271
+ results = aicore.web_search(user_text, max_results=4)
3272
+ except Exception:
3273
+ results = None
3274
+ if not results:
3275
+ return ""
3276
+ lines = [f"- {r['title']}: {r['snippet']}" for r in results[:4]]
3277
+ return "RESEARCHER'S BRIEF (live web search, use if relevant):\n" + "\n".join(lines)
3278
+
3279
+
3280
+ def _extract_code_block(text):
3281
+ m = re.search(r"```(?:python)?\s*\n(.*?)```", text or "", re.S)
3282
+ return m.group(1) if m else None
3283
+
3284
+
3285
+ def _debug_pass(final_text):
3286
+ """Debugger pass: only fires when the specialist's answer actually
3287
+ contains a Python code block. Runs the real static scanner
3288
+ (security_scanner.scan_code) and, for HIGH-risk-free code, a real
3289
+ sandboxed execution (sandbox.run_sandboxed) to confirm it at least
3290
+ runs, then appends genuine findings — never fabricated ones."""
3291
+ code = _extract_code_block(final_text)
3292
+ if not code:
3293
+ return final_text, None
3294
+ findings = security_scanner.scan_code(code)
3295
+ risk = security_scanner.risk_level(findings)
3296
+ note = {"risk": risk, "findings": [repr(f) for f in findings]}
3297
+ if findings:
3298
+ details = "\n".join(f" {repr(f)}" for f in findings[:5])
3299
+ final_text += _DEBUGGER_NOTE_TEMPLATE.format(
3300
+ summary=security_scanner.summarize(findings), details=details)
3301
+ if risk != security_scanner.HIGH:
3302
+ result = sandbox.run_sandboxed(code, timeout=8)
3303
+ note["sandbox_test"] = {
3304
+ "returncode": result["returncode"],
3305
+ "timed_out": result["timed_out"],
3306
+ "stderr_tail": (result["stderr"] or "")[-300:],
3307
+ }
3308
+ if result["returncode"] != 0 and not result["timed_out"]:
3309
+ err_tail = (result["stderr"] or "").strip().splitlines()
3310
+ err_tail = err_tail[-1] if err_tail else "unknown error"
3311
+ final_text += f"\n\nšŸž Debugger ran this in the sandbox and it raised: {err_tail}"
3312
+ else:
3313
+ note["sandbox_test"] = "skipped (high-risk code is not auto-executed)"
3314
+ return final_text, note
3315
+
3316
+
3317
+ def run_multi_agent(user_text, max_steps=MAX_STEPS, on_agent=None, mode="agent"):
3318
+ """Runs the Planner -> Specialist -> Tester pipeline for one user
3319
+ request. `on_agent(role_key, role_label, status)` is called with
3320
+ status in {"start", "done"} so the caller can render live per-agent
3321
+ status lines. Returns (final_text, steps, meta, plan, verify_note) —
3322
+ the extra `plan` and `verify_note` let the UI show the team's work,
3323
+ not just the final answer.
3324
+
3325
+ Falls back cleanly to the plain single-agent run_agent() output if
3326
+ no AI provider is configured (query_ai already returns a clear
3327
+ message in that case instead of raising).
3328
+ """
3329
+ system_prompt = AGENT_SYSTEM_PROMPT if mode == "agent" else AI_SYSTEM_PROMPT
3330
+ wants_code = bool(_CODE_HINTS.search(user_text))
3331
+ active_roles = ["planner", "specialist"] + (["programmer", "debugger"] if wants_code else []) + ["tester"]
3332
+
3333
+ research_brief = ""
3334
+ if _RESEARCH_HINTS.search(user_text):
3335
+ active_roles.insert(0, "researcher")
3336
+ if on_agent:
3337
+ on_agent("researcher", "Researcher", "start")
3338
+ research_brief = _research_pass(user_text, system_prompt)
3339
+ if on_agent:
3340
+ on_agent("researcher", "Researcher", "done")
3341
+
3342
+ if on_agent:
3343
+ on_agent("planner", "Planner", "start")
3344
+ plan = _plan_steps(user_text + ("\n\n" + research_brief if research_brief else ""), system_prompt)
3345
+ if on_agent:
3346
+ on_agent("planner", "Planner", "done")
3347
+
3348
+ if on_agent:
3349
+ on_agent("specialist", "Specialist", "start")
3350
+ plan_note = "\n\nTEAM PLAN (from the Planner — follow it, adapt if needed):\n" + \
3351
+ "\n".join(f"{i+1}. {p}" for i, p in enumerate(plan))
3352
+ if research_brief:
3353
+ plan_note += "\n\n" + research_brief
3354
+ if wants_code:
3355
+ plan_note += "\n\n" + _PROGRAMMER_PROMPT
3356
+ final_text, steps, meta = run_agent(
3357
+ user_text + plan_note, max_steps=max_steps, verbose=False, mode=mode
3358
+ )
3359
+ if on_agent:
3360
+ on_agent("specialist", "Specialist", "done")
3361
+
3362
+ debug_note = None
3363
+ if wants_code:
3364
+ if on_agent:
3365
+ on_agent("programmer", "Programmer", "start")
3366
+ on_agent("programmer", "Programmer", "done")
3367
+ on_agent("debugger", "Debugger", "start")
3368
+ final_text, debug_note = _debug_pass(final_text)
3369
+ if on_agent:
3370
+ on_agent("debugger", "Debugger", "done")
3371
+
3372
+ if on_agent:
3373
+ on_agent("tester", "Tester", "start")
3374
+ ok, note = _verify_answer(user_text, plan, final_text, system_prompt)
3375
+ if on_agent:
3376
+ on_agent("tester", "Tester", "done")
3377
+
3378
+ if not ok and note:
3379
+ final_text = final_text + f"\n\n⚠ Tester flagged this for review: {note}"
3380
+
3381
+ meta = dict(meta)
3382
+ meta["team_plan"] = plan
3383
+ meta["active_roles"] = active_roles
3384
+ meta["debug_note"] = debug_note
3385
+ meta["tester_ok"] = ok
3386
+ meta["tester_note"] = note
3387
+ return final_text, steps, meta