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.
- calc_terminal/__init__.py +14 -0
- calc_terminal/__main__.py +14 -0
- calc_terminal/activity.py +1334 -0
- calc_terminal/agent.py +3387 -0
- calc_terminal/agent_runtime.py +519 -0
- calc_terminal/ai_context.py +447 -0
- calc_terminal/ai_modes.py +752 -0
- calc_terminal/ai_personalization.py +286 -0
- calc_terminal/ai_preview_feedback.py +213 -0
- calc_terminal/aicore.py +2572 -0
- calc_terminal/anim.py +367 -0
- calc_terminal/app.py +3685 -0
- calc_terminal/art.py +639 -0
- calc_terminal/atomsim.py +368 -0
- calc_terminal/attachments.py +743 -0
- calc_terminal/benchmark_system.py +414 -0
- calc_terminal/browser/__init__.py +36 -0
- calc_terminal/browser/browser_state.py +346 -0
- calc_terminal/browser/devserver.py +176 -0
- calc_terminal/browser/engine.py +494 -0
- calc_terminal/browser/navigation.py +84 -0
- calc_terminal/browser/preview.py +429 -0
- calc_terminal/browser/preview_entry.py +95 -0
- calc_terminal/browser/project_detector.py +144 -0
- calc_terminal/browser/server.py +449 -0
- calc_terminal/browser/state.py +75 -0
- calc_terminal/browser/watcher.py +99 -0
- calc_terminal/browser_gui/__init__.py +1 -0
- calc_terminal/browser_gui/__main__.py +3 -0
- calc_terminal/browser_gui/launcher.py +173 -0
- calc_terminal/browser_gui/playwright_browser.py +117 -0
- calc_terminal/browser_gui/qt_browser.py +1501 -0
- calc_terminal/browser_gui/webview_browser.py +57 -0
- calc_terminal/capabilities/__init__.py +35 -0
- calc_terminal/capabilities/adapters/__init__.py +33 -0
- calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
- calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
- calc_terminal/capabilities/adapters/filesystem.py +206 -0
- calc_terminal/capabilities/adapters/git_adapter.py +202 -0
- calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
- calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
- calc_terminal/capabilities/adapters/platforms.py +200 -0
- calc_terminal/capabilities/adapters/python_exec.py +93 -0
- calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
- calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
- calc_terminal/capabilities/adapters/structural_bio.py +161 -0
- calc_terminal/capabilities/adapters/terminal.py +99 -0
- calc_terminal/capabilities/bus.py +178 -0
- calc_terminal/capabilities/discovery.py +207 -0
- calc_terminal/capabilities/schema.py +221 -0
- calc_terminal/cat.ico +0 -0
- calc_terminal/cat_browser.py +2018 -0
- calc_terminal/chat_store.py +703 -0
- calc_terminal/cli.py +1178 -0
- calc_terminal/code_editor.py +640 -0
- calc_terminal/collaboration.py +723 -0
- calc_terminal/commands_data.py +139 -0
- calc_terminal/compatibility_engine.py +352 -0
- calc_terminal/compute/__init__.py +31 -0
- calc_terminal/compute/fabric.py +350 -0
- calc_terminal/config.py +227 -0
- calc_terminal/core/__init__.py +41 -0
- calc_terminal/core/checkpoint.py +156 -0
- calc_terminal/core/input/__init__.py +45 -0
- calc_terminal/core/mode_registry.py +300 -0
- calc_terminal/core/project_graph.py +172 -0
- calc_terminal/core/recovery.py +129 -0
- calc_terminal/core/security_layer.py +112 -0
- calc_terminal/core/task_graph.py +202 -0
- calc_terminal/core/unified_runtime.py +184 -0
- calc_terminal/core/verification.py +257 -0
- calc_terminal/customization.py +1566 -0
- calc_terminal/derivations.py +153 -0
- calc_terminal/device_control.py +263 -0
- calc_terminal/diagnostics/__init__.py +27 -0
- calc_terminal/diagnostics/doctor_engine.py +382 -0
- calc_terminal/diagnostics/self_test.py +247 -0
- calc_terminal/doctor.py +519 -0
- calc_terminal/easter_eggs.py +274 -0
- calc_terminal/editor/__init__.py +1 -0
- calc_terminal/editor/actions.py +263 -0
- calc_terminal/editor/commands.py +160 -0
- calc_terminal/editor/shortcuts.py +226 -0
- calc_terminal/engine.py +259 -0
- calc_terminal/errors.py +120 -0
- calc_terminal/event_stream.py +146 -0
- calc_terminal/eventbus.py +133 -0
- calc_terminal/extensions.py +733 -0
- calc_terminal/fallback_cli.py +1321 -0
- calc_terminal/first_run.py +265 -0
- calc_terminal/fomoji_auth.py +1043 -0
- calc_terminal/formulas.py +82 -0
- calc_terminal/fs_cache.py +121 -0
- calc_terminal/fs_watcher.py +277 -0
- calc_terminal/game.py +193 -0
- calc_terminal/gen1.py +5 -0
- calc_terminal/generators.py +245 -0
- calc_terminal/gestures/__init__.py +42 -0
- calc_terminal/gestures/bindings.py +175 -0
- calc_terminal/gestures/manager.py +477 -0
- calc_terminal/goodbye.py +363 -0
- calc_terminal/gpu3d.py +290 -0
- calc_terminal/graphs.py +358 -0
- calc_terminal/hardware_analyzer.py +440 -0
- calc_terminal/host/__init__.py +30 -0
- calc_terminal/host/browser_manager.py +187 -0
- calc_terminal/host/desktop.py +1386 -0
- calc_terminal/host/launcher.py +395 -0
- calc_terminal/host/terminal.py +279 -0
- calc_terminal/identity.py +216 -0
- calc_terminal/input/__init__.py +54 -0
- calc_terminal/input/capabilities.py +258 -0
- calc_terminal/input/focus.py +87 -0
- calc_terminal/input/gestures.py +64 -0
- calc_terminal/input/pointer.py +114 -0
- calc_terminal/input/touch.py +345 -0
- calc_terminal/keys.py +84 -0
- calc_terminal/live_automation.py +165 -0
- calc_terminal/mathtext.py +433 -0
- calc_terminal/mcp.py +386 -0
- calc_terminal/memory.py +337 -0
- calc_terminal/memory_v2.py +479 -0
- calc_terminal/metrics.py +333 -0
- calc_terminal/mode_detection.py +146 -0
- calc_terminal/model.py +2431 -0
- calc_terminal/model_router.py +665 -0
- calc_terminal/models/__init__.py +0 -0
- calc_terminal/models/active_state.py +187 -0
- calc_terminal/models/dynamic_registry.py +584 -0
- calc_terminal/models/manager.py +781 -0
- calc_terminal/models/model_metadata.json +3526 -0
- calc_terminal/models/profiles.py +194 -0
- calc_terminal/models/registry.py +265 -0
- calc_terminal/models/schema.py +197 -0
- calc_terminal/models/validator.py +287 -0
- calc_terminal/models/verification_engine.py +368 -0
- calc_terminal/native_picker.py +215 -0
- calc_terminal/ollama_catalog.py +279 -0
- calc_terminal/ollama_download.py +233 -0
- calc_terminal/orchestrator.py +304 -0
- calc_terminal/package_research.py +322 -0
- calc_terminal/packages.py +1024 -0
- calc_terminal/pc_specs.py +116 -0
- calc_terminal/permissions.py +334 -0
- calc_terminal/pet.py +106 -0
- calc_terminal/pipeline.py +505 -0
- calc_terminal/platform/__init__.py +491 -0
- calc_terminal/platform/desktop.py +491 -0
- calc_terminal/platform/web.py +781 -0
- calc_terminal/preview/__init__.py +1 -0
- calc_terminal/preview/dev_server.py +303 -0
- calc_terminal/preview/diagnostics.py +131 -0
- calc_terminal/preview/live_reload.py +66 -0
- calc_terminal/preview/manager.py +129 -0
- calc_terminal/project_stats.py +209 -0
- calc_terminal/projects.py +328 -0
- calc_terminal/providers/__init__.py +0 -0
- calc_terminal/providers/adapters/__init__.py +80 -0
- calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
- calc_terminal/providers/adapters/base.py +106 -0
- calc_terminal/providers/adapters/chinese_adapters.py +420 -0
- calc_terminal/providers/adapters/gemini_adapter.py +101 -0
- calc_terminal/providers/adapters/ollama_adapter.py +83 -0
- calc_terminal/providers/adapters/openai_adapter.py +159 -0
- calc_terminal/providers/adapters/other_adapters.py +246 -0
- calc_terminal/providers/anthropic_provider.py +172 -0
- calc_terminal/providers/auto_update.py +416 -0
- calc_terminal/providers/base_provider.py +105 -0
- calc_terminal/providers/discovery_manager.py +207 -0
- calc_terminal/providers/gemini_provider.py +178 -0
- calc_terminal/providers/lifecycle.py +767 -0
- calc_terminal/providers/ollama_adapter.py +707 -0
- calc_terminal/providers/openai_provider.py +254 -0
- calc_terminal/providers/provider_manager.py +1827 -0
- calc_terminal/providers/providers.json +4075 -0
- calc_terminal/reactionsim.py +279 -0
- calc_terminal/registry.py +337 -0
- calc_terminal/report.py +162 -0
- calc_terminal/research/__init__.py +45 -0
- calc_terminal/research/artifact_intel.py +126 -0
- calc_terminal/research/data_lineage.py +123 -0
- calc_terminal/research/experiment_ledger.py +303 -0
- calc_terminal/research/reproducibility.py +131 -0
- calc_terminal/resilience/__init__.py +47 -0
- calc_terminal/resilience/agent_state.py +121 -0
- calc_terminal/resilience/capability_matcher.py +174 -0
- calc_terminal/resilience/circuit_breaker.py +158 -0
- calc_terminal/resilience/failover_engine.py +230 -0
- calc_terminal/resilience/health_monitor.py +192 -0
- calc_terminal/resilience/ollama_adapter.py +125 -0
- calc_terminal/resilience/orchestrator.py +312 -0
- calc_terminal/resilience/types.py +134 -0
- calc_terminal/sandbox.py +98 -0
- calc_terminal/scires.py +558 -0
- calc_terminal/security_scanner.py +126 -0
- calc_terminal/session.py +294 -0
- calc_terminal/sim3d.py +206 -0
- calc_terminal/solver.py +276 -0
- calc_terminal/sound.py +127 -0
- calc_terminal/task_reports.py +287 -0
- calc_terminal/terminal_host.py +201 -0
- calc_terminal/terminal_identity.py +411 -0
- calc_terminal/test_ai_mode_reliability.py +344 -0
- calc_terminal/test_browser.py +368 -0
- calc_terminal/test_code_editor_upgrade.py +485 -0
- calc_terminal/test_customization.py +1148 -0
- calc_terminal/test_customization_ui.py +612 -0
- calc_terminal/test_dynamic_registry.py +304 -0
- calc_terminal/test_extensions.py +436 -0
- calc_terminal/test_overhaul.py +557 -0
- calc_terminal/test_project_detect.py +255 -0
- calc_terminal/test_root_cause_fix.py +527 -0
- calc_terminal/test_stability.py +532 -0
- calc_terminal/test_terminal_identity.py +132 -0
- calc_terminal/test_v079_speed.py +460 -0
- calc_terminal/theme.py +1107 -0
- calc_terminal/timeline.py +139 -0
- calc_terminal/todos.py +246 -0
- calc_terminal/tool_call_normalizer.py +419 -0
- calc_terminal/tui.py +104 -0
- calc_terminal/ui/__init__.py +8 -0
- calc_terminal/ui/activity_panel.py +231 -0
- calc_terminal/ui/activity_stream_panel.py +238 -0
- calc_terminal/ui/animations.py +122 -0
- calc_terminal/ui/app.py +7271 -0
- calc_terminal/ui/attach_panel.py +597 -0
- calc_terminal/ui/attachments.py +424 -0
- calc_terminal/ui/backup_panel.py +810 -0
- calc_terminal/ui/browser_shell.py +887 -0
- calc_terminal/ui/cat_agent.py +357 -0
- calc_terminal/ui/chats_panel.py +899 -0
- calc_terminal/ui/command_palette.py +125 -0
- calc_terminal/ui/command_palette_modal.py +166 -0
- calc_terminal/ui/composer.py +1141 -0
- calc_terminal/ui/context_menu.py +197 -0
- calc_terminal/ui/conversation.py +1435 -0
- calc_terminal/ui/customization_panel.py +1229 -0
- calc_terminal/ui/dashboard.py +404 -0
- calc_terminal/ui/design_system.py +557 -0
- calc_terminal/ui/diff_panel.py +213 -0
- calc_terminal/ui/editor.py +2102 -0
- calc_terminal/ui/empty_state.py +302 -0
- calc_terminal/ui/events.py +487 -0
- calc_terminal/ui/extensions_panel.py +815 -0
- calc_terminal/ui/footer.py +166 -0
- calc_terminal/ui/gestures_panel.py +383 -0
- calc_terminal/ui/goodbye_screen.py +100 -0
- calc_terminal/ui/header.py +1034 -0
- calc_terminal/ui/help_panel.py +254 -0
- calc_terminal/ui/live_activities.py +914 -0
- calc_terminal/ui/mcp_panel.py +570 -0
- calc_terminal/ui/memory_center.py +524 -0
- calc_terminal/ui/mode_colors_panel.py +525 -0
- calc_terminal/ui/nav_screens.py +747 -0
- calc_terminal/ui/ollama_panel.py +536 -0
- calc_terminal/ui/palette.py +221 -0
- calc_terminal/ui/permission_panel.py +269 -0
- calc_terminal/ui/personalization_panel.py +517 -0
- calc_terminal/ui/personalize_center.py +1568 -0
- calc_terminal/ui/preview_panel.py +441 -0
- calc_terminal/ui/resizers.py +402 -0
- calc_terminal/ui/sidebar.py +1285 -0
- calc_terminal/ui/statusbar.py +168 -0
- calc_terminal/ui/theme_css.py +1396 -0
- calc_terminal/ui/thinking.py +226 -0
- calc_terminal/ui/timeline_panel.py +102 -0
- calc_terminal/ui/todo_panel.py +193 -0
- calc_terminal/ui/viewport.py +136 -0
- calc_terminal/ui/vision_panel.py +489 -0
- calc_terminal/ui/welcome_modal.py +343 -0
- calc_terminal/ui/widgets.py +160 -0
- calc_terminal/ui/workspace.py +831 -0
- calc_terminal/viewers/__init__.py +1 -0
- calc_terminal/viewers/document_viewer.py +252 -0
- calc_terminal/viewers/image_viewer.py +241 -0
- calc_terminal/viewers/pdf_viewer.py +203 -0
- calc_terminal/viewers/presentation_viewer.py +164 -0
- calc_terminal/viewers/registry.py +120 -0
- calc_terminal/viewers/spreadsheet_viewer.py +204 -0
- calc_terminal/vision/__init__.py +89 -0
- calc_terminal/vision/analysis.py +194 -0
- calc_terminal/vision/annotations.py +297 -0
- calc_terminal/vision/capture.py +171 -0
- calc_terminal/vision/context.py +231 -0
- calc_terminal/vision/correlation.py +169 -0
- calc_terminal/vision/cursor.py +258 -0
- calc_terminal/vision/events.py +66 -0
- calc_terminal/vision/frame_pipeline.py +259 -0
- calc_terminal/vision/priority.py +218 -0
- calc_terminal/vision/provider.py +180 -0
- calc_terminal/vision/safety.py +149 -0
- calc_terminal/vision/session.py +281 -0
- calc_terminal/vision/verify.py +162 -0
- calc_terminal/vision.py +514 -0
- calc_terminal/vscode_integration.py +113 -0
- calc_terminal/web/__init__.py +8 -0
- calc_terminal/web/cat_runtime.py +710 -0
- calc_terminal/web/server.py +2891 -0
- calc_terminal/web/static/css/app.css +3152 -0
- calc_terminal/web/static/icons/badge-72.png +0 -0
- calc_terminal/web/static/icons/cat.ico +0 -0
- calc_terminal/web/static/icons/icon-128.png +0 -0
- calc_terminal/web/static/icons/icon-144.png +0 -0
- calc_terminal/web/static/icons/icon-152.png +0 -0
- calc_terminal/web/static/icons/icon-192.png +0 -0
- calc_terminal/web/static/icons/icon-384.png +0 -0
- calc_terminal/web/static/icons/icon-512.png +0 -0
- calc_terminal/web/static/icons/icon-72.png +0 -0
- calc_terminal/web/static/icons/icon-96.png +0 -0
- calc_terminal/web/static/icons/icon.svg +34 -0
- calc_terminal/web/static/icons/new-project.png +0 -0
- calc_terminal/web/static/icons/open-project.png +0 -0
- calc_terminal/web/static/index.html +734 -0
- calc_terminal/web/static/js/app.js +2403 -0
- calc_terminal/web/static/manifest.json +88 -0
- calc_terminal/web/static/sw.js +230 -0
- calc_terminal/workflow_engine.py +769 -0
- calc_terminal/workspace.py +593 -0
- calc_terminal/workspace_index.py +385 -0
- cct_cli-0.7.9.0.dist-info/METADATA +210 -0
- cct_cli-0.7.9.0.dist-info/RECORD +325 -0
- cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
- cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
- cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
- 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
|