localcode 0.4.0__py3-none-macosx_13_0_arm64.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.
- localcode/__init__.py +11 -0
- localcode/__main__.py +5 -0
- localcode/_subproc_env.py +54 -0
- localcode/agent/__init__.py +177 -0
- localcode/agent/app_tasks.py +157 -0
- localcode/agent/constants.py +275 -0
- localcode/agent/context.py +1152 -0
- localcode/agent/goal.py +244 -0
- localcode/agent/helpers.py +1036 -0
- localcode/agent/hollow_module.py +230 -0
- localcode/agent/hooks.py +414 -0
- localcode/agent/loop.py +2855 -0
- localcode/agent/plateau.py +297 -0
- localcode/agent/project_check_gate.py +91 -0
- localcode/agent/prompt_context.py +264 -0
- localcode/agent/prompts.py +368 -0
- localcode/agent/reasoning_loop.py +79 -0
- localcode/agent/recovery.py +606 -0
- localcode/agent/recovery_controller.py +25 -0
- localcode/agent/round_policy.py +23 -0
- localcode/agent/sections.py +241 -0
- localcode/agent/state_machine.py +100 -0
- localcode/agent/streaming.py +324 -0
- localcode/agent/tool_execution.py +368 -0
- localcode/agent/tool_orchestration.py +54 -0
- localcode/agent/turn_finalization.py +135 -0
- localcode/app.py +1595 -0
- localcode/approvals.py +17 -0
- localcode/auto_compact.py +176 -0
- localcode/autonomy.py +103 -0
- localcode/bin/__init__.py +0 -0
- localcode/bin/llama-server +0 -0
- localcode/bin/localcode-ui +4 -0
- localcode/bootstrap.py +1191 -0
- localcode/cache.py +194 -0
- localcode/checkpoint.py +375 -0
- localcode/compact.py +73 -0
- localcode/compaction.py +341 -0
- localcode/composer.py +72 -0
- localcode/config.py +521 -0
- localcode/context.py +161 -0
- localcode/display.py +58 -0
- localcode/embeddings.py +480 -0
- localcode/entrypoint.py +536 -0
- localcode/errors.py +350 -0
- localcode/events.py +409 -0
- localcode/evidence.py +102 -0
- localcode/execution_policy.py +48 -0
- localcode/features.py +308 -0
- localcode/formatting.py +91 -0
- localcode/headless_json.py +265 -0
- localcode/health.py +288 -0
- localcode/hf_quants.py +203 -0
- localcode/history.py +594 -0
- localcode/indexer.py +144 -0
- localcode/injection_defense.py +204 -0
- localcode/launcher.py +373 -0
- localcode/logging_utils.py +28 -0
- localcode/lsp.py +153 -0
- localcode/mcp/__init__.py +88 -0
- localcode/mcp/_bridge.py +61 -0
- localcode/mcp/_config.py +33 -0
- localcode/mcp/_transports.py +128 -0
- localcode/mcp/client.py +395 -0
- localcode/memory_guard.py +291 -0
- localcode/model_config.py +288 -0
- localcode/model_delete.py +444 -0
- localcode/model_families.py +279 -0
- localcode/models.py +236 -0
- localcode/models_catalog.py +901 -0
- localcode/notebook.py +123 -0
- localcode/output.py +403 -0
- localcode/patching.py +65 -0
- localcode/paths.py +303 -0
- localcode/performance.py +623 -0
- localcode/permissions.py +79 -0
- localcode/permissions_v2.py +310 -0
- localcode/plans.py +198 -0
- localcode/process_registry.py +271 -0
- localcode/protocol/__init__.py +56 -0
- localcode/protocol/events.py +116 -0
- localcode/protocol/jsonl.py +153 -0
- localcode/protocol/outcomes.py +231 -0
- localcode/reasoning_capabilities.py +59 -0
- localcode/recommendations.py +87 -0
- localcode/recovery.py +134 -0
- localcode/redaction.py +164 -0
- localcode/runtime.py +2331 -0
- localcode/server_manager.py +1069 -0
- localcode/session.py +296 -0
- localcode/shell.py +83 -0
- localcode/skills/debug.md +21 -0
- localcode/skills/edit-verified.md +30 -0
- localcode/skills/explain.md +19 -0
- localcode/skills/finish-verified.md +11 -0
- localcode/skills/git-commit-safely.md +30 -0
- localcode/skills/locate.md +21 -0
- localcode/skills/plan-task.md +51 -0
- localcode/skills/review.md +22 -0
- localcode/skills/run-tests.md +24 -0
- localcode/skills.py +728 -0
- localcode/snapshots.py +195 -0
- localcode/sounds.py +50 -0
- localcode/telemetry.py +273 -0
- localcode/theme.py +137 -0
- localcode/thermal.py +160 -0
- localcode/thinking.py +114 -0
- localcode/tool_router.py +246 -0
- localcode/toolkit.py +1241 -0
- localcode/tools/__init__.py +400 -0
- localcode/tools/agent.py +259 -0
- localcode/tools/append_file.py +55 -0
- localcode/tools/background_process.py +115 -0
- localcode/tools/base.py +168 -0
- localcode/tools/bash.py +1160 -0
- localcode/tools/code_navigation.py +97 -0
- localcode/tools/edit_diff.py +80 -0
- localcode/tools/edit_file.py +477 -0
- localcode/tools/facts.py +149 -0
- localcode/tools/glob_tool.py +60 -0
- localcode/tools/grep.py +65 -0
- localcode/tools/inspect_symbol.py +266 -0
- localcode/tools/launch_app.py +93 -0
- localcode/tools/list_files.py +42 -0
- localcode/tools/multi_edit.py +167 -0
- localcode/tools/plan_mode.py +86 -0
- localcode/tools/project_check.py +685 -0
- localcode/tools/read_file.py +261 -0
- localcode/tools/read_state.py +223 -0
- localcode/tools/skill_tool.py +41 -0
- localcode/tools/syntax_check.py +216 -0
- localcode/tools/todo_write.py +200 -0
- localcode/tools/tool_call_repair.py +157 -0
- localcode/tools/web_fetch.py +182 -0
- localcode/tools/web_search.py +45 -0
- localcode/tools/write_file.py +281 -0
- localcode/tui/__init__.py +1 -0
- localcode/tui/app.py +485 -0
- localcode/tui/bridge.py +78 -0
- localcode/tui/clipboard_image.py +153 -0
- localcode/tui/paste_collapse.py +95 -0
- localcode/tui/screens/__init__.py +0 -0
- localcode/tui/screens/chat.py +5052 -0
- localcode/tui/screens/confirm.py +123 -0
- localcode/tui/screens/mode_picker.py +79 -0
- localcode/tui/screens/model_picker.py +962 -0
- localcode/tui/screens/setup.py +891 -0
- localcode/tui/styles/__init__.py +0 -0
- localcode/tui/styles/app.tcss +197 -0
- localcode/tui/widgets/__init__.py +0 -0
- localcode/tui/widgets/approval.py +2 -0
- localcode/tui/widgets/chat_log.py +2063 -0
- localcode/tui/widgets/messages/__init__.py +21 -0
- localcode/tui/widgets/messages/diff.py +287 -0
- localcode/tui/widgets/voice_visualizer.py +122 -0
- localcode/turn_diff.py +180 -0
- localcode/ui/FORK_COMMIT +1 -0
- localcode/ui/__init__.py +58 -0
- localcode/ui/launch.py +204 -0
- localcode/ui/picker_cli.py +165 -0
- localcode/ui/plugin/localcode.ts +626 -0
- localcode/ui/ports.py +42 -0
- localcode/ui/server_cmd.py +89 -0
- localcode/ui/supervisor.py +736 -0
- localcode/undo.py +121 -0
- localcode/verification.py +87 -0
- localcode/voice.py +948 -0
- localcode-0.4.0.dist-info/METADATA +160 -0
- localcode-0.4.0.dist-info/RECORD +173 -0
- localcode-0.4.0.dist-info/WHEEL +5 -0
- localcode-0.4.0.dist-info/entry_points.txt +3 -0
- localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
- localcode-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""append_file - append content to a text file, creating it when needed."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .base import ToolContext
|
|
5
|
+
from .write_file import _detect_stub_code
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SCHEMA = {
|
|
9
|
+
"type": "function",
|
|
10
|
+
"function": {
|
|
11
|
+
"name": "append_file",
|
|
12
|
+
"description": (
|
|
13
|
+
"Append content to a text file, creating the file and parent "
|
|
14
|
+
"directories if needed. Use this for section-by-section file "
|
|
15
|
+
"creation or when adding a later section without an edit anchor."
|
|
16
|
+
),
|
|
17
|
+
"parameters": {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"properties": {
|
|
20
|
+
"path": {"type": "string"},
|
|
21
|
+
"content": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Content to append to the end of the file",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
"required": ["path", "content"],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def execute(ctx: ToolContext, args: dict) -> str:
|
|
33
|
+
if "path" not in args:
|
|
34
|
+
return "Error: 'path' argument is required for append_file."
|
|
35
|
+
if "content" not in args:
|
|
36
|
+
return "Error: 'content' argument is required for append_file."
|
|
37
|
+
|
|
38
|
+
rel_path = str(args["path"])
|
|
39
|
+
path = ctx.resolve_write_path(rel_path)
|
|
40
|
+
if path.is_dir():
|
|
41
|
+
return f"Error: append_file cannot append to directory: {rel_path}"
|
|
42
|
+
content = str(args.get("content", ""))
|
|
43
|
+
stub = _detect_stub_code(content, rel_path)
|
|
44
|
+
if stub:
|
|
45
|
+
return stub
|
|
46
|
+
|
|
47
|
+
created = not path.exists()
|
|
48
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
existing = "" if created else path.read_text(errors="replace")
|
|
50
|
+
separator = "" if not existing or existing.endswith("\n") or content.startswith("\n") else "\n"
|
|
51
|
+
path.write_text(existing + separator + content)
|
|
52
|
+
appended_lines = content.count("\n") + (1 if content else 0)
|
|
53
|
+
total_lines = (existing + separator + content).count("\n") + 1
|
|
54
|
+
verb = "Created" if created else "Appended"
|
|
55
|
+
return f"{verb} {rel_path}; added {appended_lines} lines ({total_lines} total lines)"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Explicit lifecycle tool for supervised background commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .base import ToolContext
|
|
11
|
+
from .._subproc_env import clean_env
|
|
12
|
+
from ..process_registry import (
|
|
13
|
+
ProcessRecord,
|
|
14
|
+
find_record,
|
|
15
|
+
load_records,
|
|
16
|
+
mark_spawned,
|
|
17
|
+
record_process,
|
|
18
|
+
refresh_record,
|
|
19
|
+
save_records,
|
|
20
|
+
spawned_this_session,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
SCHEMA = {
|
|
24
|
+
"type": "function",
|
|
25
|
+
"function": {
|
|
26
|
+
"name": "background_process",
|
|
27
|
+
"description": "Start, poll, list, or stop a supervised background command. Returns a stable process ID and incremental output.",
|
|
28
|
+
"parameters": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"properties": {
|
|
31
|
+
"action": {"type": "string", "enum": ["start", "poll", "list", "stop"]},
|
|
32
|
+
"command": {"type": "string", "description": "Required for start."},
|
|
33
|
+
"process_id": {"type": "string", "description": "Required for poll/stop."},
|
|
34
|
+
"offset": {"type": "integer", "description": "Byte offset for incremental poll output.", "default": 0},
|
|
35
|
+
"owner": {"type": "string", "description": "Logical owner label.", "default": "agent"},
|
|
36
|
+
},
|
|
37
|
+
"required": ["action"],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def _status(record: ProcessRecord) -> str:
|
|
43
|
+
return "stopped" if record.stopped_at else "running"
|
|
44
|
+
|
|
45
|
+
def execute(ctx: ToolContext, args: dict) -> str:
|
|
46
|
+
action = str(args.get("action", ""))
|
|
47
|
+
if action == "list":
|
|
48
|
+
records = load_records(ctx.repo)
|
|
49
|
+
if not records:
|
|
50
|
+
return "No supervised background processes."
|
|
51
|
+
return "\n".join(f"{r.process_id} {_status(refresh_record(ctx.repo, r.process_id) or r)} owner={r.owner} pid={r.pid} command={r.command}" for r in records[-50:])
|
|
52
|
+
process_id = str(args.get("process_id", ""))
|
|
53
|
+
if action in {"poll", "stop"} and not process_id:
|
|
54
|
+
return f"Error: process_id is required for {action}."
|
|
55
|
+
if action == "start":
|
|
56
|
+
command = str(args.get("command", "")).strip()
|
|
57
|
+
if not command:
|
|
58
|
+
return "Error: command is required for start."
|
|
59
|
+
log_dir = ctx.repo / ".localcode" / "process-logs"
|
|
60
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
provisional = ProcessRecord(pid=0, pgid=0, port=0, url="", cwd=str(ctx.repo), kind="background", command=command, log_path="", verified=False, started_at=time.time(), owner=str(args.get("owner", "agent")))
|
|
62
|
+
log_path = log_dir / f"{provisional.process_id}.log"
|
|
63
|
+
stream = log_path.open("ab", buffering=0)
|
|
64
|
+
try:
|
|
65
|
+
proc = subprocess.Popen(command, cwd=ctx.repo, shell=True, executable="/bin/sh", stdout=stream, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, env=clean_env(), start_new_session=True)
|
|
66
|
+
finally:
|
|
67
|
+
stream.close()
|
|
68
|
+
mark_spawned(proc.pid)
|
|
69
|
+
provisional.pid = proc.pid
|
|
70
|
+
provisional.pgid = proc.pid
|
|
71
|
+
provisional.log_path = str(log_path)
|
|
72
|
+
record_process(ctx.repo, provisional)
|
|
73
|
+
return f"Started {provisional.process_id}: status=running pid={proc.pid} owner={provisional.owner} output_offset=0"
|
|
74
|
+
record = refresh_record(ctx.repo, process_id)
|
|
75
|
+
if record is None:
|
|
76
|
+
return f"Error: unknown process_id: {process_id}"
|
|
77
|
+
if action == "stop":
|
|
78
|
+
if not record.stopped_at:
|
|
79
|
+
target = record.pgid or record.pid
|
|
80
|
+
# Registry records are durable on-disk state. Refuse to signal
|
|
81
|
+
# any pid this LocalCode process did not spawn — a crafted or
|
|
82
|
+
# stale record must never drive killpg at an arbitrary pid.
|
|
83
|
+
if target <= 1 or not (spawned_this_session(record.pid) or spawned_this_session(target)):
|
|
84
|
+
return (
|
|
85
|
+
f"Error: refusing to stop {process_id}: its pid was not "
|
|
86
|
+
"started by this session (stop it manually if needed)."
|
|
87
|
+
)
|
|
88
|
+
try:
|
|
89
|
+
os.killpg(target, signal.SIGTERM)
|
|
90
|
+
except ProcessLookupError:
|
|
91
|
+
pass
|
|
92
|
+
except OSError as exc:
|
|
93
|
+
return f"Error: could not stop {process_id}: {exc}"
|
|
94
|
+
records = load_records(ctx.repo)
|
|
95
|
+
for item in records:
|
|
96
|
+
if item.process_id == process_id:
|
|
97
|
+
item.stopped_at = time.time()
|
|
98
|
+
save_records(ctx.repo, records)
|
|
99
|
+
return f"Stopped {process_id}."
|
|
100
|
+
if action == "poll":
|
|
101
|
+
offset = max(0, int(args.get("offset", 0)))
|
|
102
|
+
data = b""
|
|
103
|
+
try:
|
|
104
|
+
with Path(record.log_path).open("rb") as handle:
|
|
105
|
+
handle.seek(offset)
|
|
106
|
+
data = handle.read(64_000)
|
|
107
|
+
next_offset = handle.tell()
|
|
108
|
+
except OSError:
|
|
109
|
+
next_offset = offset
|
|
110
|
+
output = data.decode(errors="replace")
|
|
111
|
+
return f"id={process_id} status={_status(record)} pid={record.pid} next_offset={next_offset}\n{output}".rstrip()
|
|
112
|
+
return f"Error: unsupported action: {action}"
|
|
113
|
+
|
|
114
|
+
def is_concurrency_safe(args: dict) -> bool:
|
|
115
|
+
return str(args.get("action")) in {"poll", "list"}
|
localcode/tools/base.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Shared types for tool modules.
|
|
2
|
+
|
|
3
|
+
Each tool lives in its own file under src/localcode/tools/ and exports two
|
|
4
|
+
symbols:
|
|
5
|
+
|
|
6
|
+
SCHEMA: dict — the OpenAI-compatible function schema handed to the
|
|
7
|
+
model. See TOOL_SCHEMAS usage in src/localcode/agent.py.
|
|
8
|
+
|
|
9
|
+
execute: callable (ctx: ToolContext, args: dict) -> str — invoked
|
|
10
|
+
when the model picks this tool. Returns the result string
|
|
11
|
+
that's fed back as the `tool` message.
|
|
12
|
+
|
|
13
|
+
Per-tool files + co-located schema+exec is the minimal-agent / terminal coding tools Agent
|
|
14
|
+
SDK convention. Makes adding a tool a one-file change, keeps description
|
|
15
|
+
next to the code that implements it, and enables per-tool unit tests.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Protocol
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from ..app import LocalCodeApp
|
|
25
|
+
from ..output import OutputManager
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ToolContext:
|
|
30
|
+
"""Everything a tool might need to do its job.
|
|
31
|
+
|
|
32
|
+
Keep this deliberately small — if a tool can be done with just
|
|
33
|
+
`repo` (Path) and `args` (dict), prefer that. Reach for `app` or
|
|
34
|
+
`out` only when you need the full agent state or the TUI output
|
|
35
|
+
manager.
|
|
36
|
+
"""
|
|
37
|
+
app: "LocalCodeApp"
|
|
38
|
+
out: "OutputManager"
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def repo(self) -> Path:
|
|
42
|
+
return self.app.repo_root
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def check_root(self) -> Path:
|
|
46
|
+
"""Where project checks (ruff/tsc/...) may scan — see app.check_root."""
|
|
47
|
+
return getattr(self.app, "check_root", None) or self.app.repo_root
|
|
48
|
+
|
|
49
|
+
def resolve_path(self, raw: str) -> Path:
|
|
50
|
+
"""Resolve a tool `path` argument, healing corrupted repo prefixes.
|
|
51
|
+
|
|
52
|
+
Small quantized models corrupt long absolute paths when they copy
|
|
53
|
+
them between calls. Observed live (2026-07-13): the model turned the
|
|
54
|
+
workdir `…/localcode-evals/20260713-024502/tempconv` into
|
|
55
|
+
`…/localcode-evals/2026-0713-0245-02/tempconv` — hyphens
|
|
56
|
+
hallucinated into the timestamp — and every subsequent write landed
|
|
57
|
+
in a phantom tree while the real project stayed empty. Same class of
|
|
58
|
+
fix as fuzzy old_string matching in edit_file: if an absolute path
|
|
59
|
+
is NOT under repo_root but its leading components are a near-match
|
|
60
|
+
of repo_root's (difflib ratio ≥ 0.8 per component), remap that
|
|
61
|
+
prefix onto the real repo_root. Anything else passes through
|
|
62
|
+
untouched — reads intentionally have full filesystem access.
|
|
63
|
+
|
|
64
|
+
NOTE: this method does NOT contain the path. Write tools must call
|
|
65
|
+
`resolve_write_path()` instead, which applies the same healing and
|
|
66
|
+
then refuses anything that lands outside the project root.
|
|
67
|
+
"""
|
|
68
|
+
p = self.repo / raw
|
|
69
|
+
try:
|
|
70
|
+
root = self.repo.resolve()
|
|
71
|
+
rp = p.resolve()
|
|
72
|
+
if root == rp or root in rp.parents:
|
|
73
|
+
return p # already inside the project — nothing to heal
|
|
74
|
+
root_parts = root.parts
|
|
75
|
+
path_parts = rp.parts
|
|
76
|
+
if len(path_parts) <= len(root_parts):
|
|
77
|
+
return p
|
|
78
|
+
import difflib
|
|
79
|
+
for a, b in zip(root_parts, path_parts):
|
|
80
|
+
if a == b:
|
|
81
|
+
continue
|
|
82
|
+
if difflib.SequenceMatcher(None, a, b).ratio() < 0.8:
|
|
83
|
+
return p # genuinely different location — respect it
|
|
84
|
+
healed = root.joinpath(*path_parts[len(root_parts):])
|
|
85
|
+
try:
|
|
86
|
+
from ..events import emit as _emit
|
|
87
|
+
_emit("path_remap", raw=str(raw)[:200], healed=str(healed)[:200])
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
return healed
|
|
91
|
+
except Exception:
|
|
92
|
+
return p
|
|
93
|
+
|
|
94
|
+
def resolve_write_path(self, raw: str) -> Path:
|
|
95
|
+
"""Resolve a WRITE target: heal first, then enforce containment.
|
|
96
|
+
|
|
97
|
+
Healing runs exactly as it does for reads (small quants mangle long
|
|
98
|
+
absolute paths, and a mangled path that near-matches repo_root is
|
|
99
|
+
remapped onto it). Containment is applied to the healed result, so
|
|
100
|
+
the heal feature keeps working while `/Users/victim/.zshrc`,
|
|
101
|
+
`../../../etc/hosts`, and a repo-internal symlink pointing outside
|
|
102
|
+
all raise `PathContainmentError`.
|
|
103
|
+
|
|
104
|
+
Escape hatches, in order:
|
|
105
|
+
* the agent's notebook scratch dir (sanctioned, may live outside
|
|
106
|
+
the project root), and
|
|
107
|
+
* paths the USER has explicitly approved this session, recorded on
|
|
108
|
+
`app._approved_write_paths` — approval is a human decision, so an
|
|
109
|
+
approved path is never re-blocked here.
|
|
110
|
+
"""
|
|
111
|
+
from ..paths import PathContainmentError, contain_write_path
|
|
112
|
+
|
|
113
|
+
healed = self.resolve_path(raw)
|
|
114
|
+
|
|
115
|
+
approved = getattr(self.app, "_approved_write_paths", None)
|
|
116
|
+
if approved:
|
|
117
|
+
try:
|
|
118
|
+
if str(Path(healed).resolve()) in {str(Path(a).resolve()) for a in approved}:
|
|
119
|
+
return Path(healed).resolve()
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
return contain_write_path(healed, self.repo)
|
|
125
|
+
except PathContainmentError:
|
|
126
|
+
# Outside the project — the notebook scratch dir is the one
|
|
127
|
+
# sanctioned exception. Checked lazily so the common (contained)
|
|
128
|
+
# case never touches the notebook module.
|
|
129
|
+
try:
|
|
130
|
+
from ..notebook import is_within_notebook
|
|
131
|
+
if is_within_notebook(Path(healed)):
|
|
132
|
+
return Path(healed).resolve()
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
try:
|
|
136
|
+
from ..events import emit as _emit
|
|
137
|
+
_emit("write_containment_block", raw=str(raw)[:200],
|
|
138
|
+
resolved=str(healed)[:200])
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
141
|
+
raise
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class ToolResult:
|
|
146
|
+
"""Internal typed tool result.
|
|
147
|
+
|
|
148
|
+
Tools may continue returning plain strings. The dispatcher normalizes
|
|
149
|
+
both forms to this type so the agent loop can reason over facts while
|
|
150
|
+
preserving the exact text sent back to the model/UI.
|
|
151
|
+
"""
|
|
152
|
+
text: str
|
|
153
|
+
ok: bool = True
|
|
154
|
+
facts: dict = field(default_factory=dict)
|
|
155
|
+
|
|
156
|
+
def __str__(self) -> str:
|
|
157
|
+
return self.text
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class ToolModule(Protocol):
|
|
161
|
+
"""Structural type every tool module satisfies."""
|
|
162
|
+
SCHEMA: dict
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def execute(ctx: ToolContext, args: dict) -> str: ... # noqa: E704
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def is_concurrency_safe(args: dict) -> bool: ... # noqa: E704
|