loom-threads 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.
loom/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Loom: thread-and-episode orchestration on its own any-llm engine."""
2
+
3
+ from loom.agent import Tool, ToolResult, run_loop
4
+ from loom.coding import create_coding_tools
5
+ from loom.dispatch import DEFAULT_MAX_TURNS, DEFAULT_TIMEOUT_SECS, run_dispatch
6
+ from loom.episodes import Episode, EpisodeStore
7
+ from loom.threads import create_thread_tools
8
+
9
+ __all__ = [
10
+ "DEFAULT_MAX_TURNS",
11
+ "DEFAULT_TIMEOUT_SECS",
12
+ "Episode",
13
+ "EpisodeStore",
14
+ "Tool",
15
+ "ToolResult",
16
+ "create_coding_tools",
17
+ "create_thread_tools",
18
+ "run_dispatch",
19
+ "run_loop",
20
+ ]
loom/agent.py ADDED
@@ -0,0 +1,206 @@
1
+ """Agent loop on any-llm: sequential tool calls, OpenAI-dict messages,
2
+ tiny event set for the CLI.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ from collections.abc import AsyncIterator, Mapping, Sequence
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ToolResult:
16
+ text: str
17
+ is_error: bool = False
18
+ details: dict[str, Any] | None = None
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Tool:
23
+ name: str
24
+ description: str
25
+ parameters: dict[str, Any]
26
+ execute_fn: Any # async (args: dict) -> ToolResult
27
+
28
+ async def execute(self, args: Mapping[str, Any]) -> ToolResult:
29
+ try:
30
+ return await self.execute_fn(dict(args))
31
+ except Exception as exc: # tools are an isolation boundary
32
+ return ToolResult(text=f"Error: {exc}", is_error=True)
33
+
34
+
35
+ # Events: just enough for cli.py / dispatch.py to print + collect episodes.
36
+ @dataclass(frozen=True, slots=True)
37
+ class TextDelta:
38
+ delta: str
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class ToolStart:
43
+ tool_name: str
44
+ args: dict[str, Any]
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class ToolEnd:
49
+ tool_name: str
50
+ result: ToolResult
51
+ is_error: bool
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class AssistantEnd:
56
+ text: str
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class AgentError:
61
+ message: str
62
+
63
+
64
+ Event = TextDelta | ToolStart | ToolEnd | AssistantEnd | AgentError
65
+
66
+
67
+ def to_openai_tools(tools: Sequence[Tool]) -> list[dict[str, Any]]:
68
+ return [
69
+ {
70
+ "type": "function",
71
+ "function": {
72
+ "name": t.name,
73
+ "description": t.description,
74
+ "parameters": t.parameters,
75
+ },
76
+ }
77
+ for t in tools
78
+ ]
79
+
80
+
81
+ def split_model(model: str, default_provider: str | None = None) -> tuple[str, str]:
82
+ """Accept 'provider:model' or plain model + separate provider."""
83
+ if ":" in model:
84
+ provider, _, model_id = model.partition(":")
85
+ return provider or (default_provider or "openai"), model_id
86
+ return (default_provider or "openai"), model
87
+
88
+
89
+ async def run_loop(
90
+ *,
91
+ provider: str | None,
92
+ model: str,
93
+ system: str,
94
+ messages: list[dict[str, Any]],
95
+ tools: Sequence[Tool],
96
+ max_turns: int = 32,
97
+ api_key: str | None = None,
98
+ api_base: str | None = None,
99
+ ) -> AsyncIterator[Event]:
100
+ """One agent run: call model, execute tool calls sequentially, repeat.
101
+
102
+ `messages` uses OpenAI dict format. Mutated in place (assistant + tool
103
+ turns appended) so callers can inspect history; the final assistant text
104
+ arrives as AssistantEnd per turn, AgentError on failure.
105
+ """
106
+ from any_llm import AnyLLM # lazy: keeps import cheap for tests
107
+
108
+ provider_name, model_id = split_model(model, provider)
109
+ api_key = api_key or os.getenv("LOOM_LLM_PROVIDER_API_KEY")
110
+ api_base = api_base or os.getenv("LOOM_LLM_PROVIDER_BASE_URL")
111
+ llm = AnyLLM.create(provider_name, api_key=api_key, api_base=api_base)
112
+ by_name = {t.name: t for t in tools}
113
+ wire_tools = to_openai_tools(tools) if tools else None
114
+
115
+ history: list[Any] = [{"role": "system", "content": system}, *messages]
116
+
117
+ for _ in range(max(1, max_turns)):
118
+ try:
119
+ result = await llm.acompletion(
120
+ model=model_id,
121
+ messages=history,
122
+ tools=wire_tools,
123
+ )
124
+ except Exception as exc:
125
+ yield AgentError(message=str(exc))
126
+ return
127
+
128
+ msg = result.choices[0].message
129
+ text: str = msg.content or ""
130
+ raw_calls = getattr(msg, "tool_calls", None) or []
131
+
132
+ if text:
133
+ yield TextDelta(delta=text)
134
+
135
+ calls: list[tuple[str, str, dict[str, Any]]] = []
136
+ for call in raw_calls:
137
+ fn = getattr(call, "function", None)
138
+ if fn is None:
139
+ continue
140
+ try:
141
+ args = json.loads(fn.arguments or "{}")
142
+ except (json.JSONDecodeError, TypeError):
143
+ args = {}
144
+ if not isinstance(args, dict):
145
+ args = {}
146
+ calls.append((call.id, fn.name, args))
147
+
148
+ # Replay assistant turn so the next request sees tool calls.
149
+ history.append(
150
+ {
151
+ "role": "assistant",
152
+ "content": text,
153
+ **(
154
+ {
155
+ "tool_calls": [
156
+ {
157
+ "id": cid,
158
+ "type": "function",
159
+ "function": {"name": name, "arguments": json.dumps(args)},
160
+ }
161
+ for cid, name, args in calls
162
+ ]
163
+ }
164
+ if calls
165
+ else {}
166
+ ),
167
+ }
168
+ )
169
+ yield AssistantEnd(text=text)
170
+
171
+ if not calls:
172
+ return
173
+
174
+ for cid, name, args in calls:
175
+ tool = by_name.get(name)
176
+ yield ToolStart(tool_name=name, args=args)
177
+ if tool is None:
178
+ result_ = ToolResult(text=f"Error: tool {name} not found", is_error=True)
179
+ else:
180
+ result_ = await tool.execute(args)
181
+ history.append(
182
+ {
183
+ "role": "tool",
184
+ "tool_call_id": cid,
185
+ "name": name,
186
+ "content": result_.text,
187
+ }
188
+ )
189
+ yield ToolEnd(tool_name=name, result=result_, is_error=result_.is_error)
190
+
191
+ yield AgentError(message=f"Agent stopped after max_turns={max_turns}")
192
+
193
+
194
+ __all__ = [
195
+ "AgentError",
196
+ "AssistantEnd",
197
+ "Event",
198
+ "TextDelta",
199
+ "Tool",
200
+ "ToolEnd",
201
+ "ToolResult",
202
+ "ToolStart",
203
+ "run_loop",
204
+ "split_model",
205
+ "to_openai_tools",
206
+ ]
loom/cli.py ADDED
@@ -0,0 +1,238 @@
1
+ """Print-mode orchestrator: `loom "refactor the parser"`.
2
+
3
+ The orchestrator only holds thread tools; workers get coding tools.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import asyncio
10
+ import sys
11
+ from collections.abc import Mapping
12
+ from importlib.metadata import PackageNotFoundError, version
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from loom.agent import AgentError, AssistantEnd, TextDelta, Tool, ToolEnd, ToolStart, run_loop
17
+ from loom.engine import build_engine, credential_path, load_credentials, save_credentials
18
+ from loom.episodes import EpisodeStore
19
+ from loom.prompts import orchestrator_prompt
20
+ from loom.sessions import SessionStore
21
+ from loom.threads import create_thread_tools
22
+
23
+
24
+ def _get_version() -> str:
25
+ for dist in ("loom-threads", "loom"):
26
+ try:
27
+ return f"loom {version(dist)}"
28
+ except PackageNotFoundError:
29
+ continue
30
+ return "loom unknown"
31
+
32
+
33
+ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
34
+ parser = argparse.ArgumentParser(
35
+ prog="loom", description="Thread-and-episode orchestration (any-llm engine)."
36
+ )
37
+ parser.add_argument("prompt", help="What to work on.")
38
+ parser.add_argument("--provider", default=None, help="any-llm provider name.")
39
+ parser.add_argument("--model", default=None, help="Model id ('provider:model' or plain).")
40
+ parser.add_argument("--cwd", default=".", help="Working directory for workers.")
41
+ parser.add_argument(
42
+ "--store",
43
+ default=None,
44
+ help="Episode directory (default: <cwd>/.loom/episodes).",
45
+ )
46
+ parser.add_argument("--max-turns", type=int, default=32, help="Orchestrator turns.")
47
+ parser.add_argument(
48
+ "--resume",
49
+ action="append",
50
+ default=[],
51
+ metavar="SESSION_ID",
52
+ help="Resume a session (one ID appends in place; several start a new run).",
53
+ )
54
+ parser.add_argument(
55
+ "--version",
56
+ action="version",
57
+ version=_get_version(),
58
+ help="Show the loom version and exit.",
59
+ )
60
+ return parser.parse_args(argv)
61
+
62
+
63
+ def _preview(text: str, limit: int = 220) -> str:
64
+ flat = " ".join(text.split())
65
+ return flat if len(flat) <= limit else flat[: limit - 1] + "…"
66
+
67
+
68
+ EPISODE_PREVIEW = 300
69
+
70
+
71
+ def _episode_lines(result: Any) -> list[str]:
72
+ details = result.details
73
+ episodes = details.get("episodes") if isinstance(details, dict) else None
74
+ if not isinstance(episodes, list):
75
+ return [f"<< {_preview(result.text, EPISODE_PREVIEW)}"]
76
+ lines = []
77
+ for episode in episodes:
78
+ if not isinstance(episode, Mapping):
79
+ continue
80
+ name = str(episode.get("name", "?"))
81
+ text = str(episode.get("text", ""))
82
+ marker = "!! " if text.startswith("Error:") else ""
83
+ lines.append(f"<< {marker}{name} ({len(text):,} chars): {_preview(text, EPISODE_PREVIEW)}")
84
+ return lines
85
+
86
+
87
+ def _dispatch_label(arguments: Mapping[str, Any]) -> str:
88
+ items = arguments.get("items")
89
+ if isinstance(items, list):
90
+ names = [
91
+ str(item["name"]) for item in items if isinstance(item, Mapping) and item.get("name")
92
+ ]
93
+ return "batch: " + ", ".join(names)
94
+ return f"thread {_preview(str(arguments.get('name', '')), 40)}"
95
+
96
+
97
+ def _open_session(sessions: SessionStore, resume: list[str], prompt: str) -> str:
98
+ """One existing --resume id continues in place; otherwise start a new run."""
99
+ if len(resume) == 1 and sessions.path_of(resume[0]).exists():
100
+ sessions.log_input(resume[0], prompt)
101
+ return resume[0]
102
+ return sessions.start(prompt)
103
+
104
+
105
+ def _episode_entries(result: Any) -> list[tuple[str, str, str | None]] | None:
106
+ """Per-episode (name, text, id) triples in dispatch order, or None."""
107
+ details = result.details
108
+ episodes = details.get("episodes") if isinstance(details, dict) else None
109
+ if not isinstance(episodes, list):
110
+ return None
111
+ entries = []
112
+ for episode in episodes:
113
+ if not isinstance(episode, Mapping):
114
+ continue
115
+ raw_id = episode.get("id")
116
+ entries.append(
117
+ (
118
+ str(episode.get("name", "?")),
119
+ str(episode.get("text", "")),
120
+ raw_id if isinstance(raw_id, str) else None,
121
+ )
122
+ )
123
+ return entries
124
+
125
+
126
+ async def _run(args: argparse.Namespace) -> None:
127
+ cwd = Path(args.cwd).resolve()
128
+ store = EpisodeStore(args.store or cwd / ".loom" / "episodes")
129
+ provider, model, worker_tools = build_engine(
130
+ provider_name=args.provider, model=args.model, cwd=cwd
131
+ )
132
+ sessions = SessionStore(cwd / ".loom" / "sessions")
133
+
134
+ messages: list[dict[str, Any]] = []
135
+ for prior in args.resume:
136
+ rendered = sessions.render(prior, store)
137
+ if rendered is None:
138
+ print(f"warning: session '{prior}' not found", file=sys.stderr)
139
+ else:
140
+ messages.append({"role": "user", "content": rendered})
141
+ messages.append({"role": "user", "content": args.prompt})
142
+ session_id = _open_session(sessions, args.resume, args.prompt)
143
+
144
+ def on_worker_event(name: str, event: object) -> None:
145
+ if isinstance(event, ToolStart):
146
+ print(f" [{name}] {event.tool_name} {_preview(str(event.args))}")
147
+ elif isinstance(event, ToolEnd):
148
+ status = "error" if event.is_error else "ok"
149
+ print(f" [{name}] -> {status}: {_preview(event.result.text)}")
150
+
151
+ tools: list[Tool] = create_thread_tools(
152
+ provider=provider,
153
+ model=model,
154
+ worker_tools=worker_tools,
155
+ store=store,
156
+ working_directory=cwd,
157
+ on_event=on_worker_event,
158
+ session=session_id,
159
+ )
160
+
161
+ pending_label = ""
162
+ async for event in run_loop(
163
+ provider=provider,
164
+ model=model,
165
+ system=orchestrator_prompt(str(cwd)),
166
+ messages=messages,
167
+ tools=tools,
168
+ max_turns=args.max_turns,
169
+ ):
170
+ if isinstance(event, ToolStart):
171
+ pending_label = _dispatch_label(event.args)
172
+ print(f"\n>> {pending_label}")
173
+ elif isinstance(event, ToolEnd):
174
+ entries = _episode_entries(event.result)
175
+ if entries is None:
176
+ sessions.log_output(session_id, event.result.text, label=pending_label)
177
+ else:
178
+ for name, text, episode_id in entries:
179
+ if episode_id:
180
+ sessions.log_episode_ref(session_id, name, episode_id)
181
+ else:
182
+ sessions.log_output(session_id, text, label=name)
183
+ for line in _episode_lines(event.result):
184
+ print(line)
185
+ print()
186
+ elif isinstance(event, AssistantEnd):
187
+ if event.text.strip():
188
+ sessions.log_output(session_id, event.text.strip())
189
+ elif isinstance(event, TextDelta):
190
+ print(event.delta, end="", flush=True)
191
+ elif isinstance(event, AgentError):
192
+ print(f"error: {event.message}", file=sys.stderr)
193
+
194
+ print(f"\nsession: {sessions.path_of(session_id)}")
195
+
196
+
197
+ def _redact(value: str) -> str:
198
+ return f"****{value[-4:]}" if len(value) > 4 else "****" if value else "(not set)"
199
+
200
+
201
+ def _run_setup() -> None:
202
+ import getpass
203
+
204
+ current = load_credentials()
205
+ print(f"loom setup (saves to {credential_path()}, mode 600)\n")
206
+
207
+ def ask(label: str, key: str, *, secret: bool = False) -> str:
208
+ existing = current.get(key, "")
209
+ hint = _redact(existing) if secret else existing or "(not set)"
210
+ prompt = f"{label} [{hint}]: "
211
+ value = (getpass.getpass(prompt) if secret else input(prompt)).strip()
212
+ return value or existing
213
+
214
+ try:
215
+ data = {
216
+ "base_url": ask("Provider base URL", "base_url"),
217
+ "api_key": ask("Provider API key", "api_key", secret=True),
218
+ "model": ask("Model (provider:model)", "model"),
219
+ "provider": ask("Provider override (optional)", "provider"),
220
+ }
221
+ except (EOFError, KeyboardInterrupt):
222
+ print("\nsetup cancelled.")
223
+ return
224
+ path = save_credentials({k: v for k, v in data.items() if v})
225
+ print(f"saved to {path}")
226
+
227
+
228
+ def main(argv: list[str] | None = None) -> None:
229
+ argv = sys.argv[1:] if argv is None else argv
230
+ if argv and argv[0] == "setup":
231
+ _run_setup()
232
+ return
233
+ args = _parse_args(argv)
234
+ asyncio.run(_run(args))
235
+
236
+
237
+ if __name__ == "__main__":
238
+ main()
loom/coding.py ADDED
@@ -0,0 +1,216 @@
1
+ """Worker coding tools: `read`/`write`/`edit`/`bash`.
2
+ Text-only, cwd-jailed, truncated.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from loom.agent import Tool, ToolResult
12
+
13
+ MAX_BYTES = 50 * 1024
14
+ MAX_LINES = 2_000
15
+
16
+ _locks: dict[Path, asyncio.Lock] = {}
17
+
18
+
19
+ def _lock(path: Path) -> asyncio.Lock:
20
+ lock = _locks.get(path)
21
+ if lock is None:
22
+ lock = asyncio.Lock()
23
+ _locks[path] = lock
24
+ return lock
25
+
26
+
27
+ def _resolve(cwd: Path, raw: Any) -> Path:
28
+ if not isinstance(raw, str) or not raw:
29
+ raise ValueError("path must be a non-empty string")
30
+ p = Path(raw)
31
+ resolved = (cwd / p).resolve() if not p.is_absolute() else p.resolve()
32
+ # ponytail: cwd jail is security, never simplify away
33
+ if resolved != cwd.resolve() and cwd.resolve() not in resolved.parents:
34
+ raise ValueError(f"path escapes working directory: {raw}")
35
+ return resolved
36
+
37
+
38
+ def _truncate(text: str) -> str:
39
+ lines = text.splitlines()
40
+ if len(lines) > MAX_LINES:
41
+ lines = lines[-MAX_LINES:]
42
+ return (
43
+ "\n".join(lines)
44
+ + f"\n\n[{len(text.splitlines())} lines total, showing last {MAX_LINES}]"
45
+ )
46
+ encoded = text.encode("utf-8", errors="replace")
47
+ if len(encoded) > MAX_BYTES:
48
+ cut = text.encode("utf-8", errors="replace")[-MAX_BYTES:].decode("utf-8", errors="replace")
49
+ return cut + f"\n\n[output truncated to last {MAX_BYTES // 1024}KB]"
50
+ return text
51
+
52
+
53
+ def create_coding_tools(cwd: str | Path | None = None) -> list[Tool]:
54
+ root = Path(cwd).resolve() if cwd else Path.cwd().resolve()
55
+
56
+ async def read_fn(args: dict[str, Any]) -> ToolResult:
57
+ try:
58
+ path = _resolve(root, args.get("path"))
59
+ except ValueError as exc:
60
+ return ToolResult(text=f"Error: {exc}", is_error=True)
61
+ if not path.exists():
62
+ return ToolResult(text=f"Error: file not found: {path}", is_error=True)
63
+ if path.is_dir():
64
+ return ToolResult(text=f"Error: path is a directory: {path}", is_error=True)
65
+ try:
66
+ text = path.read_text(encoding="utf-8")
67
+ except UnicodeDecodeError:
68
+ return ToolResult(text=f"Error: not a UTF-8 text file: {path}", is_error=True)
69
+ lines = text.splitlines()
70
+ offset = args.get("offset")
71
+ limit = args.get("limit")
72
+ start = max(int(offset) - 1, 0) if isinstance(offset, int) and offset > 0 else 0
73
+ end = start + int(limit) if isinstance(limit, int) and limit > 0 else None
74
+ return ToolResult(text=_truncate("\n".join(lines[start:end])) or "(empty file)")
75
+
76
+ async def write_fn(args: dict[str, Any]) -> ToolResult:
77
+ content = args.get("content")
78
+ if not isinstance(content, str):
79
+ return ToolResult(text="Error: content must be a string", is_error=True)
80
+ try:
81
+ path = _resolve(root, args.get("path"))
82
+ except ValueError as exc:
83
+ return ToolResult(text=f"Error: {exc}", is_error=True)
84
+ async with _lock(path):
85
+ path.parent.mkdir(parents=True, exist_ok=True)
86
+ path.write_text(content, encoding="utf-8")
87
+ return ToolResult(text=f"Successfully wrote to {path}.")
88
+
89
+ async def edit_fn(args: dict[str, Any]) -> ToolResult:
90
+ edits = args.get("edits")
91
+ if not isinstance(edits, list) or not edits:
92
+ return ToolResult(text="Error: edits must be a non-empty list", is_error=True)
93
+ try:
94
+ path = _resolve(root, args.get("path"))
95
+ except ValueError as exc:
96
+ return ToolResult(text=f"Error: {exc}", is_error=True)
97
+ if not path.exists() or path.is_dir():
98
+ return ToolResult(text=f"Error: file not found: {path}", is_error=True)
99
+ async with _lock(path):
100
+ content = path.read_text(encoding="utf-8")
101
+ for i, edit in enumerate(edits):
102
+ e: Any = edit
103
+ if not isinstance(e, dict):
104
+ return ToolResult(text=f"Error: edit {i} must be an object", is_error=True)
105
+ old, new = e.get("oldText"), e.get("newText")
106
+ if not isinstance(old, str) or not old or not isinstance(new, str):
107
+ return ToolResult(
108
+ text=f"Error: edit {i} needs non-empty oldText + newText", is_error=True
109
+ )
110
+ n = content.count(old)
111
+ if n != 1:
112
+ return ToolResult(
113
+ text=f"Error: edit {i} matches {n}x (need 1)",
114
+ is_error=True,
115
+ )
116
+ content = content.replace(old, new, 1)
117
+ path.write_text(content, encoding="utf-8")
118
+ return ToolResult(text=f"Successfully edited {path} ({len(edits)} edits).")
119
+
120
+ async def bash_fn(args: dict[str, Any]) -> ToolResult:
121
+ command = args.get("command")
122
+ if not isinstance(command, str) or not command.strip():
123
+ return ToolResult(text="Error: command must be a non-empty string", is_error=True)
124
+ timeout = args.get("timeout")
125
+ if timeout is not None and (not isinstance(timeout, (int, float)) or timeout <= 0):
126
+ return ToolResult(text="Error: timeout must be > 0", is_error=True)
127
+ try:
128
+ proc = await asyncio.create_subprocess_shell(
129
+ command,
130
+ cwd=root,
131
+ stdin=asyncio.subprocess.DEVNULL,
132
+ stdout=asyncio.subprocess.PIPE,
133
+ stderr=asyncio.subprocess.STDOUT,
134
+ )
135
+ try:
136
+ out, _ = await asyncio.wait_for(
137
+ proc.communicate(), timeout=float(timeout) if timeout else 120.0
138
+ )
139
+ except TimeoutError:
140
+ proc.kill()
141
+ return ToolResult(text="Error: command timed out", is_error=True)
142
+ except Exception as exc:
143
+ return ToolResult(text=f"Error: {exc}", is_error=True)
144
+ text_out = _truncate(out.decode(errors="replace")) or "(no output)"
145
+ if proc.returncode != 0:
146
+ return ToolResult(text=f"{text_out}\n\n[exit {proc.returncode}]", is_error=True)
147
+ return ToolResult(text=text_out)
148
+
149
+ return [
150
+ Tool(
151
+ name="read",
152
+ description="Read a UTF-8 text file. Use offset/limit for large files.",
153
+ parameters={
154
+ "type": "object",
155
+ "properties": {
156
+ "path": {"type": "string"},
157
+ "offset": {"type": "integer"},
158
+ "limit": {"type": "integer"},
159
+ },
160
+ "required": ["path"],
161
+ },
162
+ execute_fn=read_fn,
163
+ ),
164
+ Tool(
165
+ name="write",
166
+ description="Write content to a file. Creates parents, overwrites existing.",
167
+ parameters={
168
+ "type": "object",
169
+ "properties": {
170
+ "path": {"type": "string"},
171
+ "content": {"type": "string"},
172
+ },
173
+ "required": ["path", "content"],
174
+ },
175
+ execute_fn=write_fn,
176
+ ),
177
+ Tool(
178
+ name="edit",
179
+ description="Exact oldText->newText replacement. Each oldText must match exactly once.",
180
+ parameters={
181
+ "type": "object",
182
+ "properties": {
183
+ "path": {"type": "string"},
184
+ "edits": {
185
+ "type": "array",
186
+ "items": {
187
+ "type": "object",
188
+ "properties": {
189
+ "oldText": {"type": "string"},
190
+ "newText": {"type": "string"},
191
+ },
192
+ "required": ["oldText", "newText"],
193
+ },
194
+ },
195
+ },
196
+ "required": ["path", "edits"],
197
+ },
198
+ execute_fn=edit_fn,
199
+ ),
200
+ Tool(
201
+ name="bash",
202
+ description="Run a shell command in the working directory.",
203
+ parameters={
204
+ "type": "object",
205
+ "properties": {
206
+ "command": {"type": "string"},
207
+ "timeout": {"type": "number"},
208
+ },
209
+ "required": ["command"],
210
+ },
211
+ execute_fn=bash_fn,
212
+ ),
213
+ ]
214
+
215
+
216
+ __all__ = ["create_coding_tools"]