loom-threads 0.1.0__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .loom/
5
+ .coverage
6
+ .env
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,11 @@
1
+ ## Wiki
2
+
3
+ This repository has documentation located in the /wiki directory.
4
+
5
+ Start here:
6
+
7
+ - [Wiki quickstart](wiki/quickstart.md)
8
+
9
+ Wiki includes repository overview, architecture notes, workflows, domain concepts, operations, integrations, testing guidance, and source maps.
10
+
11
+ When working in this repository, read the Wiki quickstart first, then follow its links to the relevant architecture, workflow, domain, operation, and testing notes.
@@ -0,0 +1,7 @@
1
+ Copyright 2026 Dheerapat Tookkane
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.5
2
+ Name: loom-threads
3
+ Version: 0.1.0
4
+ Summary: Thread-and-episode orchestration on its own any-llm engine.
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: any-llm-sdk
8
+ Requires-Dist: rich>=13.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Loom
12
+
13
+ Thread-and-episode orchestration on its own minimal engine (any-llm providers).
14
+
15
+ Orchestrator dispatches **threads**; each thread runs a bounded action in a worker context and returns an **episode**. Episodes are the only thing that crosses between threads.
16
+
17
+ ```sh
18
+ uv sync
19
+ uv run loom "add rate limiting to the API"
20
+ uv run loom setup # provider config
21
+ ```
22
+
23
+ Docs: [wiki/quickstart.md](wiki/quickstart.md)
@@ -0,0 +1,13 @@
1
+ # Loom
2
+
3
+ Thread-and-episode orchestration on its own minimal engine (any-llm providers).
4
+
5
+ Orchestrator dispatches **threads**; each thread runs a bounded action in a worker context and returns an **episode**. Episodes are the only thing that crosses between threads.
6
+
7
+ ```sh
8
+ uv sync
9
+ uv run loom "add rate limiting to the API"
10
+ uv run loom setup # provider config
11
+ ```
12
+
13
+ Docs: [wiki/quickstart.md](wiki/quickstart.md)
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "loom-threads"
3
+ version = "0.1.0"
4
+ description = "Thread-and-episode orchestration on its own any-llm engine."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = ["any-llm-sdk", "rich>=13.0"]
8
+
9
+ [project.scripts]
10
+ loom = "loom.cli:main"
11
+
12
+ [build-system]
13
+ requires = ["hatchling>=1.26"]
14
+ build-backend = "hatchling.build"
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["src/loom"]
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "ty",
22
+ "pytest>=8.0",
23
+ "ruff>=0.5",
24
+ ]
25
+
26
+ [tool.ruff]
27
+ line-length = 100
28
+ target-version = "py312"
29
+ src = ["src", "tests"]
30
+
31
+ [tool.ruff.lint]
32
+ select = ["E", "F", "I", "UP", "B", "SIM"]
33
+
34
+ [tool.ruff.lint.per-file-ignores]
35
+ # Prompt prose is wrapped by the model, not the formatter.
36
+ "src/loom/prompts.py" = ["E501"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -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
+ ]
@@ -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
+ ]
@@ -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()