caudate-cli 0.1.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.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- voice/tts.py +214 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Python execution tool — run Python code in a subprocess."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from core.schemas import ToolResult
|
|
11
|
+
from execution.tools.base import BaseTool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PythonExecTool(BaseTool):
|
|
15
|
+
mutates = True
|
|
16
|
+
name = "PythonExec"
|
|
17
|
+
description = "Execute Python code in an isolated subprocess and return stdout. Use for calculations, data processing, or testing code snippets."
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def input_schema(self) -> dict:
|
|
21
|
+
return {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {
|
|
24
|
+
"code": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "Python code to execute",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
"required": ["code"],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
33
|
+
code = kwargs.get("code", "")
|
|
34
|
+
if not code:
|
|
35
|
+
return self._error("No code provided")
|
|
36
|
+
|
|
37
|
+
# Write to temp file and execute in subprocess for isolation
|
|
38
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
|
39
|
+
f.write(code)
|
|
40
|
+
tmp_path = f.name
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
proc = await asyncio.create_subprocess_exec(
|
|
44
|
+
"python3", tmp_path,
|
|
45
|
+
stdout=asyncio.subprocess.PIPE,
|
|
46
|
+
stderr=asyncio.subprocess.PIPE,
|
|
47
|
+
)
|
|
48
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
|
|
49
|
+
output = stdout.decode().strip()
|
|
50
|
+
errors = stderr.decode().strip()
|
|
51
|
+
|
|
52
|
+
if proc.returncode == 0:
|
|
53
|
+
return self._success(output or "(no output)")
|
|
54
|
+
else:
|
|
55
|
+
return self._error(f"Exit code {proc.returncode}: {errors}")
|
|
56
|
+
except asyncio.TimeoutError:
|
|
57
|
+
return self._error("Execution timed out after 30s")
|
|
58
|
+
except Exception as e:
|
|
59
|
+
return self._error(str(e))
|
|
60
|
+
finally:
|
|
61
|
+
Path(tmp_path).unlink(missing_ok=True)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Respond tool — present information back to the user."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
|
|
10
|
+
from core.schemas import ToolResult
|
|
11
|
+
from execution.tools.base import BaseTool
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RespondTool(BaseTool):
|
|
17
|
+
name = "Respond"
|
|
18
|
+
description = "Present a response or answer to the user. Use this to communicate results, explanations, or any information."
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def input_schema(self) -> dict:
|
|
22
|
+
return {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"message": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "The message to show the user",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
"required": ["message"],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
34
|
+
message = kwargs.get("message", "")
|
|
35
|
+
if not message:
|
|
36
|
+
return self._error("No message provided")
|
|
37
|
+
|
|
38
|
+
console.print(Panel(message, title="Cognos", border_style="green"))
|
|
39
|
+
preview = message[:100] + "..." if len(message) > 100 else message
|
|
40
|
+
return self._success(f"Responded to user: {preview}")
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Sandbox — unified lifecycle + snapshot tool for the local sandbox.
|
|
2
|
+
|
|
3
|
+
Inspired by Vercel Sandbox's API (`Sandbox.create`, `runCommand`,
|
|
4
|
+
`snapshot`, `stop`, source-from-snapshot). Applied to our LOCAL 50GB
|
|
5
|
+
ext4 loopback at `/home/raveuk/cognos/sandbox/` — no cloud dependency,
|
|
6
|
+
no billing, no Vercel account.
|
|
7
|
+
|
|
8
|
+
Modes the LLM can use:
|
|
9
|
+
|
|
10
|
+
status — mount state, free space, snapshot count
|
|
11
|
+
run — execute a shell command with cwd locked to sandbox/
|
|
12
|
+
snapshot [label] — tar+zstd the current sandbox state into a snapshot
|
|
13
|
+
list_snapshots — show all snapshots with size + age + label
|
|
14
|
+
restore <id> — wipe sandbox + extract a snapshot (auto-checkpoints
|
|
15
|
+
the pre-restore state first, so restores are
|
|
16
|
+
reversible)
|
|
17
|
+
delete_snapshot <id> — remove a snapshot artifact
|
|
18
|
+
|
|
19
|
+
Snapshots live at `~/.cognos/sandbox-snapshots/<id>.tar.zst` with a
|
|
20
|
+
sidecar `.meta.json`. Each `restore` auto-creates an
|
|
21
|
+
`auto-pre-restore-<src>` snapshot first, so a bad restore is just
|
|
22
|
+
another restore away.
|
|
23
|
+
|
|
24
|
+
The `run` mode is the key isolation primitive: it locks `cwd` to the
|
|
25
|
+
sandbox dir and uses a constrained env, so the LLM can't accidentally
|
|
26
|
+
mutate the rest of the filesystem via this tool. (For full freedom
|
|
27
|
+
the LLM still has the `Bash` tool.)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
import logging
|
|
34
|
+
import os
|
|
35
|
+
import shutil
|
|
36
|
+
import subprocess
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from core.schemas import ToolResult
|
|
41
|
+
from execution.tools.base import BaseTool
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_SANDBOX_DIR = Path("/home/raveuk/cognos/sandbox")
|
|
47
|
+
_SNAPSHOT_DIR = Path.home() / ".cognos" / "sandbox-snapshots"
|
|
48
|
+
_BIN_DIR = Path("/home/raveuk/cognos/bin")
|
|
49
|
+
_RUN_TIMEOUT_S = 60.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _fmt_bytes(n: int) -> str:
|
|
53
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
54
|
+
if n < 1024 or unit == "TB":
|
|
55
|
+
return f"{n:.1f}{unit}" if unit != "B" else f"{n}B"
|
|
56
|
+
n /= 1024
|
|
57
|
+
return f"{n}TB"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_mounted() -> bool:
|
|
61
|
+
if not _SANDBOX_DIR.exists():
|
|
62
|
+
return False
|
|
63
|
+
# /proc/mounts is fastest + always accurate
|
|
64
|
+
try:
|
|
65
|
+
with open("/proc/mounts") as f:
|
|
66
|
+
return any(str(_SANDBOX_DIR) in line for line in f)
|
|
67
|
+
except OSError:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _disk_usage() -> tuple[int, int, int]:
|
|
72
|
+
"""Return (total, used, avail) bytes for the sandbox FS."""
|
|
73
|
+
s = shutil.disk_usage(_SANDBOX_DIR)
|
|
74
|
+
return s.total, s.used, s.free
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _list_snapshots() -> list[dict[str, Any]]:
|
|
78
|
+
if not _SNAPSHOT_DIR.exists():
|
|
79
|
+
return []
|
|
80
|
+
out: list[dict[str, Any]] = []
|
|
81
|
+
for archive in sorted(_SNAPSHOT_DIR.glob("*.tar.zst")):
|
|
82
|
+
sid = archive.stem
|
|
83
|
+
meta_path = _SNAPSHOT_DIR / f"{sid}.meta.json"
|
|
84
|
+
meta: dict[str, Any] = {}
|
|
85
|
+
if meta_path.exists():
|
|
86
|
+
try:
|
|
87
|
+
import json
|
|
88
|
+
meta = json.loads(meta_path.read_text())
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
out.append({
|
|
92
|
+
"id": sid,
|
|
93
|
+
"label": meta.get("label", "-"),
|
|
94
|
+
"created": meta.get("created_utc", ""),
|
|
95
|
+
"size_bytes": archive.stat().st_size,
|
|
96
|
+
"path": str(archive),
|
|
97
|
+
})
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class SandboxTool(BaseTool):
|
|
102
|
+
mutates = True
|
|
103
|
+
name = "Sandbox"
|
|
104
|
+
description = (
|
|
105
|
+
"Unified sandbox lifecycle + snapshot tool. The sandbox is a "
|
|
106
|
+
"50GB ext4 loopback at /home/raveuk/cognos/sandbox/. Modes: "
|
|
107
|
+
"'status' (mount + disk + snapshot summary), 'run' (execute a "
|
|
108
|
+
"shell command with cwd locked to the sandbox), 'snapshot' "
|
|
109
|
+
"(tar+zstd current state to ~/.cognos/sandbox-snapshots/), "
|
|
110
|
+
"'restore' (wipe + extract a snapshot — auto-checkpoints first "
|
|
111
|
+
"so it's reversible), 'list_snapshots', 'delete_snapshot'. "
|
|
112
|
+
"Use 'snapshot' before letting the LLM rewrite a project so "
|
|
113
|
+
"you can roll back. 'run' for sandbox-scoped commands; for "
|
|
114
|
+
"full-system commands use the Bash tool instead."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def input_schema(self) -> dict[str, Any]:
|
|
119
|
+
return {
|
|
120
|
+
"type": "object",
|
|
121
|
+
"properties": {
|
|
122
|
+
"action": {
|
|
123
|
+
"type": "string",
|
|
124
|
+
"enum": [
|
|
125
|
+
"status", "run", "snapshot",
|
|
126
|
+
"list_snapshots", "restore", "delete_snapshot",
|
|
127
|
+
],
|
|
128
|
+
"description": "What to do.",
|
|
129
|
+
},
|
|
130
|
+
"cmd": {
|
|
131
|
+
"type": "string",
|
|
132
|
+
"description": "For 'run' mode: shell command to execute (cwd locked to sandbox).",
|
|
133
|
+
},
|
|
134
|
+
"label": {
|
|
135
|
+
"type": "string",
|
|
136
|
+
"description": "For 'snapshot' mode: short label for the snapshot.",
|
|
137
|
+
},
|
|
138
|
+
"id": {
|
|
139
|
+
"type": "string",
|
|
140
|
+
"description": "For 'restore' / 'delete_snapshot' mode: snapshot id.",
|
|
141
|
+
},
|
|
142
|
+
"timeout_s": {
|
|
143
|
+
"type": "number",
|
|
144
|
+
"description": "For 'run' mode: timeout in seconds (default 60, max 300).",
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
"required": ["action"],
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# -- mode dispatch ---------------------------------------------------
|
|
151
|
+
|
|
152
|
+
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
153
|
+
action = (kwargs.get("action") or "").lower().strip()
|
|
154
|
+
try:
|
|
155
|
+
if action == "status":
|
|
156
|
+
return self._status()
|
|
157
|
+
if action == "run":
|
|
158
|
+
return await self._run(kwargs)
|
|
159
|
+
if action == "snapshot":
|
|
160
|
+
return self._snapshot(kwargs)
|
|
161
|
+
if action == "list_snapshots":
|
|
162
|
+
return self._list_snapshots()
|
|
163
|
+
if action == "restore":
|
|
164
|
+
return self._restore(kwargs)
|
|
165
|
+
if action == "delete_snapshot":
|
|
166
|
+
return self._delete_snapshot(kwargs)
|
|
167
|
+
return ToolResult(
|
|
168
|
+
tool_name=self.name, status="error",
|
|
169
|
+
error=f"unknown action {action!r}; expected one of: status, run, snapshot, list_snapshots, restore, delete_snapshot",
|
|
170
|
+
)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger.exception("Sandbox tool failed")
|
|
173
|
+
return ToolResult(
|
|
174
|
+
tool_name=self.name, status="error",
|
|
175
|
+
error=f"{type(e).__name__}: {e}",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# -- modes -----------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def _status(self) -> ToolResult:
|
|
181
|
+
mounted = _is_mounted()
|
|
182
|
+
snapshots = _list_snapshots()
|
|
183
|
+
out_lines = [f"sandbox path: {_SANDBOX_DIR}"]
|
|
184
|
+
out_lines.append(f" mounted: {mounted}")
|
|
185
|
+
if mounted:
|
|
186
|
+
total, used, avail = _disk_usage()
|
|
187
|
+
out_lines.append(
|
|
188
|
+
f" disk usage: {_fmt_bytes(used)} used / {_fmt_bytes(total)} total "
|
|
189
|
+
f"({_fmt_bytes(avail)} free)"
|
|
190
|
+
)
|
|
191
|
+
out_lines.append(f"snapshots: {len(snapshots)} stored at {_SNAPSHOT_DIR}")
|
|
192
|
+
if snapshots:
|
|
193
|
+
recent = snapshots[-3:]
|
|
194
|
+
for s in recent:
|
|
195
|
+
out_lines.append(
|
|
196
|
+
f" - {s['id']} ({_fmt_bytes(s['size_bytes'])}) {s['label']}"
|
|
197
|
+
)
|
|
198
|
+
if len(snapshots) > 3:
|
|
199
|
+
out_lines.append(f" ... and {len(snapshots) - 3} more (use list_snapshots)")
|
|
200
|
+
return ToolResult(
|
|
201
|
+
tool_name=self.name, status="success",
|
|
202
|
+
output="\n".join(out_lines),
|
|
203
|
+
metadata={
|
|
204
|
+
"mounted": mounted,
|
|
205
|
+
"snapshot_count": len(snapshots),
|
|
206
|
+
},
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
async def _run(self, kwargs: dict) -> ToolResult:
|
|
210
|
+
cmd = (kwargs.get("cmd") or "").strip()
|
|
211
|
+
if not cmd:
|
|
212
|
+
return ToolResult(
|
|
213
|
+
tool_name=self.name, status="error",
|
|
214
|
+
error="`cmd` is required for run mode",
|
|
215
|
+
)
|
|
216
|
+
if not _is_mounted():
|
|
217
|
+
return ToolResult(
|
|
218
|
+
tool_name=self.name, status="error",
|
|
219
|
+
error=f"sandbox not mounted at {_SANDBOX_DIR}",
|
|
220
|
+
)
|
|
221
|
+
timeout = max(1.0, min(300.0, float(kwargs.get("timeout_s") or _RUN_TIMEOUT_S)))
|
|
222
|
+
|
|
223
|
+
# cwd locked to the sandbox; env minimal so commands don't pick
|
|
224
|
+
# up the user's full shell setup
|
|
225
|
+
env = {
|
|
226
|
+
"HOME": str(_SANDBOX_DIR),
|
|
227
|
+
"PATH": "/usr/local/bin:/usr/bin:/bin",
|
|
228
|
+
"USER": os.environ.get("USER", "raveuk"),
|
|
229
|
+
"LANG": "C.UTF-8",
|
|
230
|
+
"TMPDIR": str(_SANDBOX_DIR / ".tmp"),
|
|
231
|
+
}
|
|
232
|
+
(_SANDBOX_DIR / ".tmp").mkdir(exist_ok=True)
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
proc = await asyncio.create_subprocess_shell(
|
|
236
|
+
cmd,
|
|
237
|
+
cwd=str(_SANDBOX_DIR),
|
|
238
|
+
env=env,
|
|
239
|
+
stdout=asyncio.subprocess.PIPE,
|
|
240
|
+
stderr=asyncio.subprocess.PIPE,
|
|
241
|
+
)
|
|
242
|
+
stdout_b, stderr_b = await asyncio.wait_for(
|
|
243
|
+
proc.communicate(), timeout=timeout
|
|
244
|
+
)
|
|
245
|
+
except asyncio.TimeoutError:
|
|
246
|
+
try: proc.kill()
|
|
247
|
+
except Exception: pass
|
|
248
|
+
return ToolResult(
|
|
249
|
+
tool_name=self.name, status="error",
|
|
250
|
+
error=f"command timed out after {timeout}s",
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
stdout = stdout_b.decode("utf-8", errors="replace")
|
|
254
|
+
stderr = stderr_b.decode("utf-8", errors="replace")
|
|
255
|
+
rc = proc.returncode if proc.returncode is not None else -1
|
|
256
|
+
|
|
257
|
+
out = (
|
|
258
|
+
f"$ {cmd}\n"
|
|
259
|
+
f" cwd: {_SANDBOX_DIR}\n"
|
|
260
|
+
f" exit: {rc}\n"
|
|
261
|
+
)
|
|
262
|
+
if stdout:
|
|
263
|
+
out += f"--- stdout ---\n{stdout[:4000]}"
|
|
264
|
+
if len(stdout) > 4000:
|
|
265
|
+
out += f"\n... ({len(stdout) - 4000} more chars)"
|
|
266
|
+
if stderr:
|
|
267
|
+
out += f"\n--- stderr ---\n{stderr[:2000]}"
|
|
268
|
+
return ToolResult(
|
|
269
|
+
tool_name=self.name,
|
|
270
|
+
status="success" if rc == 0 else "error",
|
|
271
|
+
output=out.rstrip(),
|
|
272
|
+
error=None if rc == 0 else f"exit code {rc}",
|
|
273
|
+
metadata={
|
|
274
|
+
"exit_code": rc,
|
|
275
|
+
"stdout": stdout,
|
|
276
|
+
"stderr": stderr,
|
|
277
|
+
},
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def _snapshot(self, kwargs: dict) -> ToolResult:
|
|
281
|
+
label = (kwargs.get("label") or "snapshot").strip()
|
|
282
|
+
script = _BIN_DIR / "cognos-sandbox-snapshot"
|
|
283
|
+
if not script.exists():
|
|
284
|
+
return ToolResult(
|
|
285
|
+
tool_name=self.name, status="error",
|
|
286
|
+
error=f"helper script missing: {script}",
|
|
287
|
+
)
|
|
288
|
+
try:
|
|
289
|
+
result = subprocess.run(
|
|
290
|
+
[str(script), label],
|
|
291
|
+
capture_output=True, text=True, timeout=300,
|
|
292
|
+
)
|
|
293
|
+
except subprocess.TimeoutExpired:
|
|
294
|
+
return ToolResult(
|
|
295
|
+
tool_name=self.name, status="error",
|
|
296
|
+
error="snapshot timed out (>5 min)",
|
|
297
|
+
)
|
|
298
|
+
if result.returncode != 0:
|
|
299
|
+
return ToolResult(
|
|
300
|
+
tool_name=self.name, status="error",
|
|
301
|
+
error=f"snapshot failed: {result.stderr or result.stdout}",
|
|
302
|
+
)
|
|
303
|
+
return ToolResult(
|
|
304
|
+
tool_name=self.name, status="success",
|
|
305
|
+
output=result.stdout.strip(),
|
|
306
|
+
metadata={"label": label},
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def _list_snapshots(self) -> ToolResult:
|
|
310
|
+
snapshots = _list_snapshots()
|
|
311
|
+
if not snapshots:
|
|
312
|
+
return ToolResult(
|
|
313
|
+
tool_name=self.name, status="success",
|
|
314
|
+
output="no snapshots yet — use action='snapshot' to create one",
|
|
315
|
+
metadata={"snapshots": []},
|
|
316
|
+
)
|
|
317
|
+
lines = [f"{len(snapshots)} snapshot(s):"]
|
|
318
|
+
for s in snapshots:
|
|
319
|
+
lines.append(
|
|
320
|
+
f" {s['id']} {_fmt_bytes(s['size_bytes']):>10} "
|
|
321
|
+
f"{s['created']} {s['label']}"
|
|
322
|
+
)
|
|
323
|
+
return ToolResult(
|
|
324
|
+
tool_name=self.name, status="success",
|
|
325
|
+
output="\n".join(lines),
|
|
326
|
+
metadata={"snapshots": snapshots},
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def _restore(self, kwargs: dict) -> ToolResult:
|
|
330
|
+
sid = (kwargs.get("id") or "").strip()
|
|
331
|
+
if not sid:
|
|
332
|
+
return ToolResult(
|
|
333
|
+
tool_name=self.name, status="error",
|
|
334
|
+
error="`id` is required for restore mode",
|
|
335
|
+
)
|
|
336
|
+
script = _BIN_DIR / "cognos-sandbox-restore"
|
|
337
|
+
try:
|
|
338
|
+
result = subprocess.run(
|
|
339
|
+
[str(script), sid, "-y"],
|
|
340
|
+
capture_output=True, text=True, timeout=300,
|
|
341
|
+
)
|
|
342
|
+
except subprocess.TimeoutExpired:
|
|
343
|
+
return ToolResult(
|
|
344
|
+
tool_name=self.name, status="error",
|
|
345
|
+
error="restore timed out (>5 min)",
|
|
346
|
+
)
|
|
347
|
+
if result.returncode != 0:
|
|
348
|
+
return ToolResult(
|
|
349
|
+
tool_name=self.name, status="error",
|
|
350
|
+
error=f"restore failed: {result.stderr or result.stdout}",
|
|
351
|
+
)
|
|
352
|
+
return ToolResult(
|
|
353
|
+
tool_name=self.name, status="success",
|
|
354
|
+
output=result.stdout.strip(),
|
|
355
|
+
metadata={"restored_id": sid},
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def _delete_snapshot(self, kwargs: dict) -> ToolResult:
|
|
359
|
+
sid = (kwargs.get("id") or "").strip()
|
|
360
|
+
if not sid:
|
|
361
|
+
return ToolResult(
|
|
362
|
+
tool_name=self.name, status="error",
|
|
363
|
+
error="`id` is required for delete_snapshot mode",
|
|
364
|
+
)
|
|
365
|
+
archive = _SNAPSHOT_DIR / f"{sid}.tar.zst"
|
|
366
|
+
meta = _SNAPSHOT_DIR / f"{sid}.meta.json"
|
|
367
|
+
if not archive.exists():
|
|
368
|
+
return ToolResult(
|
|
369
|
+
tool_name=self.name, status="error",
|
|
370
|
+
error=f"snapshot not found: {sid}",
|
|
371
|
+
)
|
|
372
|
+
archive.unlink()
|
|
373
|
+
meta.unlink(missing_ok=True)
|
|
374
|
+
return ToolResult(
|
|
375
|
+
tool_name=self.name, status="success",
|
|
376
|
+
output=f"deleted snapshot {sid}",
|
|
377
|
+
metadata={"deleted_id": sid},
|
|
378
|
+
)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""WebSearch tool — real web search via DuckDuckGo HTML.
|
|
2
|
+
|
|
3
|
+
Uses the no-JS HTML endpoint (`html.duckduckgo.com`) which returns a list
|
|
4
|
+
of titled results without needing an API key. Falls back to the instant-
|
|
5
|
+
answer JSON API if the HTML scrape fails (some networks block it).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import html as html_lib
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import urllib.parse
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from core.schemas import ToolResult
|
|
18
|
+
from execution.tools.base import BaseTool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Loose patterns over the rendered HTML. DuckDuckGo's HTML layout is
|
|
22
|
+
# stable enough that these have held for years; if they break, the JSON
|
|
23
|
+
# fallback below still returns *something*.
|
|
24
|
+
_RESULT_BLOCK_RE = re.compile(
|
|
25
|
+
r'<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)</a>'
|
|
26
|
+
r'(?:.*?<a[^>]*class="result__snippet"[^>]*>(.*?)</a>)?',
|
|
27
|
+
re.DOTALL,
|
|
28
|
+
)
|
|
29
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WebSearchTool(BaseTool):
|
|
33
|
+
name = "WebSearch"
|
|
34
|
+
description = (
|
|
35
|
+
"Search the web with DuckDuckGo and return ranked results "
|
|
36
|
+
"(title, URL, snippet). Use to find current information, docs, "
|
|
37
|
+
"or sources before fetching a specific page with WebFetch."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def input_schema(self) -> dict:
|
|
42
|
+
return {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"properties": {
|
|
45
|
+
"query": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "The search query",
|
|
48
|
+
},
|
|
49
|
+
"max_results": {
|
|
50
|
+
"type": "integer",
|
|
51
|
+
"description": "Max results to return (default 8)",
|
|
52
|
+
"default": 8,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
"required": ["query"],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
59
|
+
query = (kwargs.get("query") or "").strip()
|
|
60
|
+
if not query:
|
|
61
|
+
return self._error("No query provided")
|
|
62
|
+
max_results = max(1, int(kwargs.get("max_results", 8)))
|
|
63
|
+
|
|
64
|
+
# 1. Real HTML results
|
|
65
|
+
try:
|
|
66
|
+
html = await _curl(
|
|
67
|
+
f"https://html.duckduckgo.com/html/?q={urllib.parse.quote(query)}",
|
|
68
|
+
method="POST",
|
|
69
|
+
data=f"q={urllib.parse.quote(query)}",
|
|
70
|
+
)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
html = ""
|
|
73
|
+
|
|
74
|
+
results = _parse_html_results(html, max_results) if html else []
|
|
75
|
+
|
|
76
|
+
# 2. Fallback to instant-answer JSON if HTML scrape produced nothing
|
|
77
|
+
if not results:
|
|
78
|
+
try:
|
|
79
|
+
raw = await _curl(
|
|
80
|
+
f"https://api.duckduckgo.com/?q={urllib.parse.quote(query)}"
|
|
81
|
+
f"&format=json&no_html=1",
|
|
82
|
+
)
|
|
83
|
+
data = json.loads(raw) if raw else {}
|
|
84
|
+
except Exception:
|
|
85
|
+
data = {}
|
|
86
|
+
if data.get("Abstract"):
|
|
87
|
+
results.append({
|
|
88
|
+
"title": data.get("AbstractSource", "Summary"),
|
|
89
|
+
"url": data.get("AbstractURL", ""),
|
|
90
|
+
"snippet": data["Abstract"],
|
|
91
|
+
})
|
|
92
|
+
for topic in data.get("RelatedTopics", [])[:max_results]:
|
|
93
|
+
if isinstance(topic, dict) and topic.get("Text"):
|
|
94
|
+
results.append({
|
|
95
|
+
"title": topic.get("Text", "")[:80],
|
|
96
|
+
"url": topic.get("FirstURL", ""),
|
|
97
|
+
"snippet": topic.get("Text", ""),
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
if not results:
|
|
101
|
+
return self._success(f"No results found for: {query}")
|
|
102
|
+
|
|
103
|
+
lines = [f"# Search results for: {query}", ""]
|
|
104
|
+
for i, r in enumerate(results, 1):
|
|
105
|
+
lines.append(f"{i}. **{r['title']}**")
|
|
106
|
+
if r.get("url"):
|
|
107
|
+
lines.append(f" {r['url']}")
|
|
108
|
+
if r.get("snippet"):
|
|
109
|
+
lines.append(f" {r['snippet']}")
|
|
110
|
+
lines.append("")
|
|
111
|
+
return self._success("\n".join(lines))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def _curl(
|
|
115
|
+
url: str, method: str = "GET", data: str | None = None,
|
|
116
|
+
) -> str:
|
|
117
|
+
"""Run curl in a subprocess, return decoded body (best effort)."""
|
|
118
|
+
args = [
|
|
119
|
+
"curl", "-sSL", "--max-time", "20",
|
|
120
|
+
"-A", "Mozilla/5.0 (cognos)",
|
|
121
|
+
]
|
|
122
|
+
if method == "POST":
|
|
123
|
+
args += ["-X", "POST"]
|
|
124
|
+
if data:
|
|
125
|
+
args += ["--data", data]
|
|
126
|
+
args.append(url)
|
|
127
|
+
proc = await asyncio.create_subprocess_exec(
|
|
128
|
+
*args,
|
|
129
|
+
stdout=asyncio.subprocess.PIPE,
|
|
130
|
+
stderr=asyncio.subprocess.PIPE,
|
|
131
|
+
)
|
|
132
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=22)
|
|
133
|
+
return stdout.decode(errors="ignore")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_html_results(html: str, limit: int) -> list[dict[str, str]]:
|
|
137
|
+
out: list[dict[str, str]] = []
|
|
138
|
+
for match in _RESULT_BLOCK_RE.finditer(html):
|
|
139
|
+
url = match.group(1)
|
|
140
|
+
# DDG wraps real urls in /l/?uddg=<encoded>
|
|
141
|
+
if "uddg=" in url:
|
|
142
|
+
try:
|
|
143
|
+
qs = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
|
|
144
|
+
url = qs.get("uddg", [url])[0]
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
title = html_lib.unescape(_TAG_RE.sub("", match.group(2) or "")).strip()
|
|
148
|
+
snippet = html_lib.unescape(_TAG_RE.sub("", match.group(3) or "")).strip()
|
|
149
|
+
if title and url:
|
|
150
|
+
out.append({"title": title, "url": url, "snippet": snippet})
|
|
151
|
+
if len(out) >= limit:
|
|
152
|
+
break
|
|
153
|
+
return out
|