File size: 24,083 Bytes
10f26af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
"""
agent.py -- Tool-using agent core for the GLM-5.2 personal workspace Space.

This module is deliberately self-contained and network-free (the only
network call in the whole app -- the actual chat completion -- is made by
app.py using the caller's own OpenAI-compatible client). Everything here is
pure local logic:

  - a system prompt that teaches the model a strict, easy-to-parse
    tool-call protocol
  - a small set of real tools: python execution, shell execution, file
    read/write/list/delete, zip packaging, pip install
  - a per-user sandboxed workspace directory with path-traversal protection
  - simple JSON-file backed conversation memory (persists to /data if the
    Space has persistent storage attached, otherwise to a local folder
    that lives only as long as the current container)
  - a parser that pulls a tool call out of the model's raw reply text

app.py drives the actual multi-step loop; this file only exposes the
building blocks so that loop stays short and readable.
"""

import os
import re
import sys
import json
import uuid
import shutil
import zipfile
import subprocess
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple


# --------------------------------------------------------------------------
# Tunables -- safe to adjust these constants as your needs change
# --------------------------------------------------------------------------

MAX_AGENT_STEPS = 12                       # hard cap on tool round-trips per user message
MAX_TOOL_OUTPUT_CHARS = 8000               # truncate stdout/stderr fed back to the model
MAX_READ_FILE_CHARS = 20000                # truncate read_file output
MAX_FILE_WRITE_BYTES = 8 * 1024 * 1024     # 8MB per write_file call
MAX_UPLOAD_BYTES = 32 * 1024 * 1024        # 32MB per user upload
MAX_HISTORY_ITEMS_KEPT_ON_DISK = 400       # trim the on-disk transcript beyond this
MAX_HISTORY_ITEMS_SENT_TO_MODEL = 40       # trim what's replayed into the next API call
DEFAULT_TOOL_TIMEOUT = 60
MAX_TOOL_TIMEOUT = 240
ULIMIT_V_KB = 3_000_000                    # ~2.9GB address-space cap per tool execution


# --------------------------------------------------------------------------
# Storage roots
# --------------------------------------------------------------------------

def _pick_storage_root() -> Path:
    """Prefer Hugging Face Spaces persistent storage (/data) when it exists
    and is writable, otherwise fall back to a folder next to the app that
    only lives as long as the current container (reset on rebuild/restart).
    """
    data_dir = Path("/data")
    try:
        if data_dir.is_dir() and os.access(str(data_dir), os.W_OK):
            root = data_dir / "glm_workspace"
            root.mkdir(parents=True, exist_ok=True)
            return root
    except OSError:
        pass
    root = Path(__file__).resolve().parent / "workspace_store"
    root.mkdir(parents=True, exist_ok=True)
    return root


STORAGE_ROOT = _pick_storage_root()
IS_PERSISTENT = str(STORAGE_ROOT).startswith("/data")


def _slugify(raw: str) -> str:
    raw = (raw or "").strip().lower()
    slug = re.sub(r"[^a-z0-9_-]+", "-", raw).strip("-")
    return slug or "user"


def user_key_from_userinfo(user_info: Optional[dict]) -> str:
    """Stable, filesystem-safe key identifying this person's private
    workspace, derived from their HF OAuth profile."""
    if not user_info:
        return "user"
    raw = (
        user_info.get("preferred_username")
        or user_info.get("sub")
        or user_info.get("name")
        or "user"
    )
    return _slugify(str(raw))


def get_user_dir(user_key: str) -> Path:
    d = STORAGE_ROOT / _slugify(user_key)
    (d / "files").mkdir(parents=True, exist_ok=True)
    return d


def get_files_dir(user_key: str) -> Path:
    return get_user_dir(user_key) / "files"


def get_history_path(user_key: str) -> Path:
    return get_user_dir(user_key) / "chat_history.json"


# --------------------------------------------------------------------------
# History persistence
# --------------------------------------------------------------------------

def load_history(user_key: str) -> List[Dict[str, Any]]:
    p = get_history_path(user_key)
    if not p.exists():
        return []
    try:
        data = json.loads(p.read_text(encoding="utf-8"))
        return data if isinstance(data, list) else []
    except (json.JSONDecodeError, OSError):
        return []


def save_history(user_key: str, history: List[Dict[str, Any]]) -> None:
    trimmed = history[-MAX_HISTORY_ITEMS_KEPT_ON_DISK:]
    p = get_history_path(user_key)
    try:
        p.write_text(json.dumps(trimmed, ensure_ascii=False), encoding="utf-8")
    except OSError:
        pass


def clear_history(user_key: str) -> None:
    p = get_history_path(user_key)
    try:
        if p.exists():
            p.unlink()
    except OSError:
        pass


def history_to_api_messages(history: List[Dict[str, Any]]) -> List[Dict[str, str]]:
    """Collapse stored history into the plain role/content pairs the chat
    completion API expects, trimmed to a recent window so context doesn't
    grow without bound."""
    recent = history[-MAX_HISTORY_ITEMS_SENT_TO_MODEL:]
    return [{"role": item["role"], "content": item["content"]} for item in recent]


# --------------------------------------------------------------------------
# Path safety
# --------------------------------------------------------------------------

class UnsafePathError(ValueError):
    pass


def safe_join(base: Path, rel_path: str) -> Path:
    """Resolve rel_path against base, refusing anything that would escape
    the workspace. A leading "/" is treated as "root of the workspace"
    (stripped, not rejected) since every tool path is meant to be
    workspace-relative anyway; ".." is still blocked below."""
    rel_path = (rel_path or "").strip().lstrip("/")
    if not rel_path or rel_path == ".":
        return base.resolve()
    candidate = (base / rel_path).resolve()
    base_resolved = base.resolve()
    if candidate != base_resolved and base_resolved not in candidate.parents:
        raise UnsafePathError(f"Path '{rel_path}' escapes the workspace")
    return candidate


def safe_upload_name(filename: str, target_dir: Path) -> str:
    base = Path(filename or "upload.bin").name
    stem = Path(base).stem
    suffix = re.sub(r"[^A-Za-z0-9.]+", "", Path(base).suffix)[:12]
    stem_slug = re.sub(r"[^A-Za-z0-9_.-]+", "_", stem).strip("_") or "upload"
    candidate = f"{stem_slug}{suffix}"
    if not (target_dir / candidate).exists():
        return candidate
    return f"{stem_slug}_{uuid.uuid4().hex[:6]}{suffix}"


def truncate(text: str, limit: int) -> str:
    if not text:
        return text or ""
    if len(text) <= limit:
        return text
    cut = len(text) - limit
    return text[:limit] + f"\n...[truncated {cut} more characters]..."


# --------------------------------------------------------------------------
# Sandboxed subprocess execution
# --------------------------------------------------------------------------

_SECRET_NAME_RE = re.compile(r"(TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL)", re.IGNORECASE)


def _sanitized_env() -> Dict[str, str]:
    """Strip anything that looks like a credential so a tool call can never
    accidentally echo the Space's own secrets (e.g. HF_TOKEN) back into the
    conversation, where they'd be sent to the inference provider as plain
    text."""
    return {k: v for k, v in os.environ.items() if not _SECRET_NAME_RE.search(k)}


def _ulimit_prefix(cpu_cap: int) -> str:
    # Applied via the shell's own `ulimit` builtin *after* bash has already
    # exec'd (not via subprocess's preexec_fn, which runs arbitrary Python
    # between fork() and exec() and can deadlock in a multi-threaded async
    # server). Limits are inherited across the following exec.
    return f"ulimit -v {ULIMIT_V_KB} 2>/dev/null; ulimit -t {cpu_cap} 2>/dev/null; "


def _run_subprocess(cmd: List[str], cwd: Path, timeout: int) -> Dict[str, Any]:
    try:
        proc = subprocess.run(
            cmd,
            cwd=str(cwd),
            capture_output=True,
            text=True,
            timeout=timeout,
            env=_sanitized_env(),
        )
        return {
            "success": proc.returncode == 0,
            "exit_code": proc.returncode,
            "stdout": truncate(proc.stdout or "", MAX_TOOL_OUTPUT_CHARS),
            "stderr": truncate(proc.stderr or "", MAX_TOOL_OUTPUT_CHARS),
        }
    except subprocess.TimeoutExpired:
        return {
            "success": False, "exit_code": None, "stdout": "",
            "stderr": f"Timed out after {timeout}s.",
        }
    except FileNotFoundError as e:
        return {"success": False, "exit_code": None, "stdout": "", "stderr": str(e)}


def _run_argv(argv: List[str], cwd: Path, timeout: int) -> Dict[str, Any]:
    cpu_cap = max(int(timeout) + 10, 10)
    bash_cmd = ["/bin/bash", "-c", _ulimit_prefix(cpu_cap) + 'exec "$@"', "--", *argv]
    return _run_subprocess(bash_cmd, cwd, timeout)


def _run_shell_str(command: str, cwd: Path, timeout: int) -> Dict[str, Any]:
    cpu_cap = max(int(timeout) + 10, 10)
    bash_cmd = ["/bin/bash", "-lc", _ulimit_prefix(cpu_cap) + command]
    return _run_subprocess(bash_cmd, cwd, timeout)


_SHELL_DENYLIST = [
    r"rm\s+-rf\s+/(?!\S)",                  # rm -rf /
    r"rm\s+-rf\s+/\*",                      # rm -rf /*
    r":\(\)\s*\{\s*:\|\s*:&\s*\}\s*;\s*:",  # classic fork bomb
    r"\bmkfs\b",
    r"\bdd\s+.*of=/dev/",
    r"\bshutdown\b",
    r"\breboot\b",
    r">\s*/dev/sd[a-z]",
]
_SHELL_DENYLIST_RE = re.compile("|".join(_SHELL_DENYLIST), re.IGNORECASE)


def _clean_timeout(args: dict) -> int:
    try:
        t = int(args.get("timeout") or DEFAULT_TOOL_TIMEOUT)
    except (TypeError, ValueError):
        t = DEFAULT_TOOL_TIMEOUT
    return max(1, min(t, MAX_TOOL_TIMEOUT))


# --------------------------------------------------------------------------
# Tool implementations -- each takes (args, files_dir) and returns a dict
# with at least {"success": bool, "stdout": str, "stderr": str}
# --------------------------------------------------------------------------

def tool_run_python(args: dict, files_dir: Path) -> Dict[str, Any]:
    code = args.get("code")
    if not code or not isinstance(code, str):
        return {"success": False, "stdout": "", "stderr": "Missing 'code' string."}
    timeout = _clean_timeout(args)
    script_path = files_dir / f".tmp_{uuid.uuid4().hex}.py"
    try:
        script_path.write_text(code, encoding="utf-8")
        return _run_argv([sys.executable, "-u", script_path.name], files_dir, timeout)
    finally:
        try:
            script_path.unlink(missing_ok=True)
        except OSError:
            pass


def tool_run_shell(args: dict, files_dir: Path) -> Dict[str, Any]:
    command = args.get("command")
    if not command or not isinstance(command, str):
        return {"success": False, "stdout": "", "stderr": "Missing 'command' string."}
    if _SHELL_DENYLIST_RE.search(command):
        return {
            "success": False, "stdout": "",
            "stderr": "Blocked: this command matches a destructive pattern disabled in this workspace.",
        }
    timeout = _clean_timeout(args)
    return _run_shell_str(command, files_dir, timeout)


def tool_write_file(args: dict, files_dir: Path) -> Dict[str, Any]:
    path = args.get("path")
    content = args.get("content", "")
    if not path or not isinstance(path, str):
        return {"success": False, "stdout": "", "stderr": "Missing 'path'."}
    if isinstance(content, (dict, list)):
        content = json.dumps(content, ensure_ascii=False, indent=2)
    content = "" if content is None else str(content)
    if len(content.encode("utf-8", errors="ignore")) > MAX_FILE_WRITE_BYTES:
        return {"success": False, "stdout": "", "stderr": f"File too large (max {MAX_FILE_WRITE_BYTES} bytes)."}
    try:
        target = safe_join(files_dir, path)
    except UnsafePathError as e:
        return {"success": False, "stdout": "", "stderr": str(e)}
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(content, encoding="utf-8")
    return {"success": True, "stdout": f"Wrote {len(content)} chars to {path}", "stderr": "", "file": path}


def tool_read_file(args: dict, files_dir: Path) -> Dict[str, Any]:
    path = args.get("path")
    if not path or not isinstance(path, str):
        return {"success": False, "stdout": "", "stderr": "Missing 'path'."}
    try:
        target = safe_join(files_dir, path)
    except UnsafePathError as e:
        return {"success": False, "stdout": "", "stderr": str(e)}
    if not target.exists() or not target.is_file():
        return {"success": False, "stdout": "", "stderr": f"No such file: {path}"}
    try:
        text = target.read_text(encoding="utf-8", errors="replace")
    except OSError as e:
        return {"success": False, "stdout": "", "stderr": str(e)}
    return {"success": True, "stdout": truncate(text, MAX_READ_FILE_CHARS), "stderr": ""}


def tool_list_files(args: dict, files_dir: Path) -> Dict[str, Any]:
    path = args.get("path", ".") or "."
    try:
        target = safe_join(files_dir, path)
    except UnsafePathError as e:
        return {"success": False, "stdout": "", "stderr": str(e)}
    if not target.exists() or not target.is_dir():
        return {"success": False, "stdout": "", "stderr": f"No such directory: {path}"}
    entries = []
    for p in sorted(target.rglob("*")):
        if p.name.startswith(".tmp_") or p.name == "chat_history.json":
            continue
        rel = p.relative_to(files_dir)
        kind = "dir" if p.is_dir() else "file"
        size = str(p.stat().st_size) if p.is_file() else ""
        entries.append(f"{kind:4}  {size:>10}  {rel}")
        if len(entries) >= 500:
            entries.append("... [truncated: more than 500 entries]")
            break
    return {"success": True, "stdout": "\n".join(entries) or "(empty)", "stderr": ""}


def tool_delete_file(args: dict, files_dir: Path) -> Dict[str, Any]:
    path = args.get("path")
    if not path or not isinstance(path, str):
        return {"success": False, "stdout": "", "stderr": "Missing 'path'."}
    try:
        target = safe_join(files_dir, path)
    except UnsafePathError as e:
        return {"success": False, "stdout": "", "stderr": str(e)}
    if target == files_dir.resolve():
        return {"success": False, "stdout": "", "stderr": "Refusing to delete the whole workspace."}
    if not target.exists():
        return {"success": False, "stdout": "", "stderr": f"No such path: {path}"}
    try:
        if target.is_dir():
            shutil.rmtree(target)
        else:
            target.unlink()
    except OSError as e:
        return {"success": False, "stdout": "", "stderr": str(e)}
    return {"success": True, "stdout": f"Deleted {path}", "stderr": ""}


def tool_make_zip(args: dict, files_dir: Path) -> Dict[str, Any]:
    output = (args.get("output") or f"archive_{uuid.uuid4().hex[:8]}.zip").strip()
    if not output.lower().endswith(".zip"):
        output += ".zip"
    try:
        out_target = safe_join(files_dir, output)
    except UnsafePathError as e:
        return {"success": False, "stdout": "", "stderr": str(e)}

    paths = args.get("paths") or None
    try:
        if paths:
            resolved = [safe_join(files_dir, p) for p in paths]
        else:
            resolved = [
                p for p in files_dir.iterdir()
                if p.name != Path(output).name
                and not p.name.startswith(".tmp_")
                and p.name != "chat_history.json"
            ]
    except UnsafePathError as e:
        return {"success": False, "stdout": "", "stderr": str(e)}

    out_target.parent.mkdir(parents=True, exist_ok=True)
    count = 0
    with zipfile.ZipFile(out_target, "w", zipfile.ZIP_DEFLATED) as zf:
        for item in resolved:
            if not item.exists():
                continue
            if item.is_file():
                zf.write(item, item.relative_to(files_dir))
                count += 1
            else:
                for sub in item.rglob("*"):
                    if sub.is_file():
                        zf.write(sub, sub.relative_to(files_dir))
                        count += 1
    if count == 0:
        out_target.unlink(missing_ok=True)
        return {"success": False, "stdout": "", "stderr": "Nothing to zip (no matching files found)."}
    size = out_target.stat().st_size
    return {"success": True, "stdout": f"Created {output} ({count} files, {size} bytes)", "stderr": "", "file": output}


def tool_pip_install(args: dict, files_dir: Path) -> Dict[str, Any]:
    package = args.get("package")
    if not package or not isinstance(package, str):
        return {"success": False, "stdout": "", "stderr": "Missing 'package'."}
    if re.search(r"[;&|`$\n]", package):
        return {"success": False, "stdout": "", "stderr": "Invalid characters in package spec."}
    return _run_argv(
        [sys.executable, "-m", "pip", "install", "--quiet",
         "--disable-pip-version-check", "--break-system-packages", package],
        files_dir, 180,
    )


TOOLS = {
    "run_python": tool_run_python,
    "run_shell": tool_run_shell,
    "write_file": tool_write_file,
    "read_file": tool_read_file,
    "list_files": tool_list_files,
    "delete_file": tool_delete_file,
    "make_zip": tool_make_zip,
    "pip_install": tool_pip_install,
}


def execute_tool(name: str, args: dict, files_dir: Path) -> Dict[str, Any]:
    fn = TOOLS.get(name)
    if not fn:
        return {"success": False, "stdout": "", "stderr": f"Unknown tool '{name}'. Available: {', '.join(TOOLS)}"}
    if not isinstance(args, dict):
        return {"success": False, "stdout": "", "stderr": "Tool 'args' must be a JSON object."}
    try:
        return fn(args, files_dir)
    except Exception as e:  # noqa: BLE001 -- surface tool bugs to the model instead of crashing the turn
        return {"success": False, "stdout": "", "stderr": f"Tool crashed: {e}"}


# --------------------------------------------------------------------------
# Tool-call parsing
# --------------------------------------------------------------------------

_TAGGED_BLOCK_RE = re.compile(r"```tool_call\s*\n(.*?)```", re.DOTALL | re.IGNORECASE)
_ANY_BLOCK_RE = re.compile(r"```[a-zA-Z_-]*\s*\n(.*?)```", re.DOTALL)


def find_tool_call(text: str) -> Tuple[Optional[dict], Optional[str], str]:
    """Look for a tool call in the model's raw turn text.

    Returns (call, parse_error, display_text):
      - call: {"tool": str, "args": dict} if a valid call was found, else None
      - parse_error: a message to feed back to the model if a tool_call-shaped
        block was found but was malformed, else None
      - display_text: the turn text with the raw tool-call block removed, i.e.
        whatever visible reasoning the model wrote for this turn
    """
    m = _TAGGED_BLOCK_RE.search(text)
    if m:
        raw = m.group(1).strip()
        display = (text[: m.start()] + text[m.end():]).strip()
        try:
            data = json.loads(raw)
        except json.JSONDecodeError as e:
            return (
                None,
                f"Your tool_call block wasn't valid JSON ({e}). Send a single "
                f'```tool_call block with valid JSON: {{"tool": "...", "args": {{...}}}}.',
                display,
            )
        if not isinstance(data, dict) or "tool" not in data:
            return None, "Your tool_call JSON must be an object with a 'tool' key and an 'args' object.", display
        return {"tool": data.get("tool"), "args": data.get("args") or {}}, None, display

    # Fallback: tolerate a mis-tagged fenced block (e.g. ```json) as long as
    # it parses into the same shape -- models sometimes drift on the tag.
    for bm in _ANY_BLOCK_RE.finditer(text):
        raw = bm.group(1).strip()
        try:
            data = json.loads(raw)
        except json.JSONDecodeError:
            continue
        if isinstance(data, dict) and data.get("tool") in TOOLS:
            display = (text[: bm.start()] + text[bm.end():]).strip()
            return {"tool": data.get("tool"), "args": data.get("args") or {}}, None, display

    return None, None, text.strip()


# --------------------------------------------------------------------------
# System prompt
# --------------------------------------------------------------------------

BASE_SYSTEM_PROMPT = """You are a personal autonomous coding assistant running inside the user's own private, isolated Linux workspace (a Hugging Face Space container only this user can reach). You reason, write code, run it for real, read the results, fix bugs, and hand back finished files -- the same kind of workflow Claude Code or ChatGPT's code interpreter use.

WORKSPACE
All tool paths are relative to your private working directory. Anything you write there can be downloaded by the user directly from this chat. Nothing you do here is visible to anyone else.

TOOL PROTOCOL
To take an action, reply with ONLY one fenced block, exactly like this, and nothing else in that message:

```tool_call
{"tool": "<name>", "args": {...}}
```

Wait for the result before deciding what to do next. Only ONE tool call per message -- never bundle several. If you ever want to show what a tool call looks like as an example without actually running it, do not use a real ```tool_call fence for that -- describe it in words instead, since a real fenced block will actually execute.

AVAILABLE TOOLS
- run_python -- {"code": "...", "timeout": optional seconds (default 60)}. Runs Python 3 in your workspace.
- run_shell -- {"command": "...", "timeout": optional seconds (default 60)}. Runs a bash command in your workspace.
- write_file -- {"path": "relative/path.ext", "content": "..."}. Creates or overwrites a file.
- read_file -- {"path": "relative/path.ext"}.
- list_files -- {"path": optional relative dir, default "."}.
- delete_file -- {"path": "relative/path.ext"}.
- make_zip -- {"output": "name.zip", "paths": optional list of relative paths -- omit to zip everything}.
- pip_install -- {"package": "name"}. Installs a Python package for use in run_python.

WORKING STYLE
1. Before telling the user a piece of code or a file is finished, actually run it with run_python or run_shell and check the output. If it errors, read the error, fix it, and run it again. Keep going until it genuinely works, or you've made a real effort and can explain what's blocking it.
2. When the deliverable is more than a one-liner, or is a set of files, package it with make_zip and name the file in your final answer so the user can download it.
3. When there is nothing left to run, just answer normally in plain text/Markdown with NO tool_call block. That ends your turn and is what the user sees as your reply.
4. You have a limited number of tool calls for this message, so work efficiently rather than exploring aimlessly.
5. Never try to damage or escape the container; stay inside your own workspace, and treat destructive system-wide commands as off-limits.
6. For ordinary conversation that needs no code or files, just answer directly -- you don't have to use a tool just because you can.
"""


def build_system_prompt(user_extra: Optional[str]) -> str:
    if user_extra and user_extra.strip():
        return BASE_SYSTEM_PROMPT + "\n\nADDITIONAL INSTRUCTIONS FROM THE USER:\n" + user_extra.strip()
    return BASE_SYSTEM_PROMPT


def format_tool_result_message(tool: str, result: Dict[str, Any]) -> str:
    lines = [f"[TOOL RESULT: {tool}]", f"success: {result.get('success')}"]
    if result.get("exit_code") is not None:
        lines.append(f"exit_code: {result['exit_code']}")
    stdout = result.get("stdout") or ""
    stderr = result.get("stderr") or ""
    if stdout:
        lines.append("stdout:\n" + stdout)
    if stderr:
        lines.append("stderr:\n" + stderr)
    if not stdout and not stderr:
        lines.append("(no output)")
    return "\n".join(lines)