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 +20 -0
- loom/agent.py +206 -0
- loom/cli.py +238 -0
- loom/coding.py +216 -0
- loom/dispatch.py +98 -0
- loom/engine.py +68 -0
- loom/episodes.py +135 -0
- loom/prompts.py +67 -0
- loom/py.typed +0 -0
- loom/sessions.py +105 -0
- loom/threads.py +314 -0
- loom_threads-0.1.0.dist-info/METADATA +23 -0
- loom_threads-0.1.0.dist-info/RECORD +16 -0
- loom_threads-0.1.0.dist-info/WHEEL +4 -0
- loom_threads-0.1.0.dist-info/entry_points.txt +2 -0
- loom_threads-0.1.0.dist-info/licenses/LICENSE +7 -0
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"]
|