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.
- loom_threads-0.1.0/.gitignore +6 -0
- loom_threads-0.1.0/.python-version +1 -0
- loom_threads-0.1.0/AGENTS.md +11 -0
- loom_threads-0.1.0/LICENSE +7 -0
- loom_threads-0.1.0/PKG-INFO +23 -0
- loom_threads-0.1.0/README.md +13 -0
- loom_threads-0.1.0/pyproject.toml +39 -0
- loom_threads-0.1.0/src/loom/__init__.py +20 -0
- loom_threads-0.1.0/src/loom/agent.py +206 -0
- loom_threads-0.1.0/src/loom/cli.py +238 -0
- loom_threads-0.1.0/src/loom/coding.py +216 -0
- loom_threads-0.1.0/src/loom/dispatch.py +98 -0
- loom_threads-0.1.0/src/loom/engine.py +68 -0
- loom_threads-0.1.0/src/loom/episodes.py +135 -0
- loom_threads-0.1.0/src/loom/prompts.py +67 -0
- loom_threads-0.1.0/src/loom/py.typed +0 -0
- loom_threads-0.1.0/src/loom/sessions.py +105 -0
- loom_threads-0.1.0/src/loom/threads.py +314 -0
- loom_threads-0.1.0/tests/test_config.py +83 -0
- loom_threads-0.1.0/tests/test_dispatch.py +390 -0
- loom_threads-0.1.0/tests/test_sessions.py +105 -0
- loom_threads-0.1.0/uv.lock +578 -0
- loom_threads-0.1.0/wiki/architecture.md +80 -0
- loom_threads-0.1.0/wiki/operations.md +54 -0
- loom_threads-0.1.0/wiki/quickstart.md +71 -0
|
@@ -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()
|