loom-code 0.1.1__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_code/__init__.py +22 -0
- loom_code/_post_commit.py +119 -0
- loom_code/agent.py +544 -0
- loom_code/approval.py +616 -0
- loom_code/browse/__init__.py +291 -0
- loom_code/browse/act.py +467 -0
- loom_code/browse/observe.py +249 -0
- loom_code/browse/session.py +96 -0
- loom_code/browse/verify.py +194 -0
- loom_code/checkpoint.py +283 -0
- loom_code/cli.py +495 -0
- loom_code/code_index.py +703 -0
- loom_code/compact.py +143 -0
- loom_code/consent.py +47 -0
- loom_code/credentials.py +527 -0
- loom_code/edit_tool.py +635 -0
- loom_code/extensions.py +522 -0
- loom_code/file_history.py +322 -0
- loom_code/file_tools.py +93 -0
- loom_code/git_hook.py +200 -0
- loom_code/grep_tool.py +430 -0
- loom_code/hooks.py +297 -0
- loom_code/loominit/__init__.py +23 -0
- loom_code/loominit/_ast_walk.py +429 -0
- loom_code/loominit/_files.py +284 -0
- loom_code/loominit/_graph.py +141 -0
- loom_code/loominit/_resolve.py +392 -0
- loom_code/loominit/_tests_map.py +108 -0
- loom_code/loominit/extractor.py +332 -0
- loom_code/loominit/repomap.py +225 -0
- loom_code/loominit/schema.py +242 -0
- loom_code/lsp_tools.py +396 -0
- loom_code/mcp_host.py +79 -0
- loom_code/operator.py +449 -0
- loom_code/paste.py +97 -0
- loom_code/paths.py +52 -0
- loom_code/permissions.py +177 -0
- loom_code/project.py +104 -0
- loom_code/prompts.py +451 -0
- loom_code/render.py +783 -0
- loom_code/repl.py +4080 -0
- loom_code/rules.py +267 -0
- loom_code/sandboxed_bash.py +176 -0
- loom_code/scribe.py +88 -0
- loom_code/skills/__init__.py +16 -0
- loom_code/skills/graphify/SKILL.md +97 -0
- loom_code/skills/graphify/tools.py +570 -0
- loom_code/trust.py +216 -0
- loom_code/turn.py +169 -0
- loom_code/web_fetch.py +370 -0
- loom_code/workers.py +758 -0
- loom_code/worktree.py +134 -0
- loom_code-0.1.1.dist-info/METADATA +224 -0
- loom_code-0.1.1.dist-info/RECORD +58 -0
- loom_code-0.1.1.dist-info/WHEEL +5 -0
- loom_code-0.1.1.dist-info/entry_points.txt +2 -0
- loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
- loom_code-0.1.1.dist-info/top_level.txt +1 -0
loom_code/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""loom-code — a loomflow-native terminal coding agent.
|
|
2
|
+
|
|
3
|
+
The entire agent brain is loomflow:
|
|
4
|
+
|
|
5
|
+
* ``Agent`` + ``ReAct`` — the agent loop
|
|
6
|
+
* ``living_plan=True`` — the task tracker (Claude Code's TodoWrite)
|
|
7
|
+
* ``LocalDiskWorkspace`` — per-project memory + the self-improvement
|
|
8
|
+
loop (citation tracking + relevance-aware recall)
|
|
9
|
+
* ``read`` / ``write`` / ``edit`` / ``bash`` / ``grep`` / ``find``
|
|
10
|
+
/ ``ls`` builtin tools — the file-and-shell kernel
|
|
11
|
+
* ``StandardPermissions`` + ``approval_handler`` — the safety gate
|
|
12
|
+
* ``Agent.stream()`` — streaming output
|
|
13
|
+
|
|
14
|
+
This package is ONLY the terminal shell: REPL, ``rich`` rendering,
|
|
15
|
+
slash commands, project detection, the diff-approval prompt. If
|
|
16
|
+
agent-loop / memory / tool-dispatch logic ever shows up here, that
|
|
17
|
+
is a bug — it means loomflow is missing something and the fix
|
|
18
|
+
belongs in the framework, not here. loom-code is the dogfood test
|
|
19
|
+
that keeps loomflow honest.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Post-commit hook runner — debounced indexer refresh.
|
|
2
|
+
|
|
3
|
+
Invoked by ``.git/hooks/post-commit`` (installed by
|
|
4
|
+
``loom_code.git_hook.install``). Counts commits since the last
|
|
5
|
+
refresh per indexer; when the threshold is hit (5 by default),
|
|
6
|
+
runs the indexer's incremental update.
|
|
7
|
+
|
|
8
|
+
Designed to FAIL SILENT. A git hook crashing has the same UX
|
|
9
|
+
cost as a broken commit — we'd rather skip a refresh than make
|
|
10
|
+
``git commit`` look broken. All exception handling is broad and
|
|
11
|
+
mute; the worst case is "graph stayed stale one more commit."
|
|
12
|
+
|
|
13
|
+
Why debounce: graphify rebuilds + loominit structural rebuilds
|
|
14
|
+
are fast (5-15s on typical projects) but not free. Running them
|
|
15
|
+
on every single commit during a heavy dev session (10+ commits/
|
|
16
|
+
hour) is wasteful. Every-5-commits keeps the indexes "close
|
|
17
|
+
enough" without burning cycles.
|
|
18
|
+
|
|
19
|
+
Invoked as::
|
|
20
|
+
|
|
21
|
+
python -m loom_code._post_commit <project_root>
|
|
22
|
+
|
|
23
|
+
Backgrounded by the shell hook so it doesn't delay the commit.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import sys
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
# Refresh threshold — commits since last refresh before triggering
|
|
33
|
+
# the indexer's incremental rebuild. Empirically tuned: every
|
|
34
|
+
# commit is wasteful, every 20 is too stale to be useful, 5 sits
|
|
35
|
+
# in the goldilocks zone for typical dev pace.
|
|
36
|
+
_THRESHOLD = 5
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main() -> int:
|
|
40
|
+
if len(sys.argv) < 2:
|
|
41
|
+
return 0
|
|
42
|
+
project_root = Path(sys.argv[1])
|
|
43
|
+
loom_dir = project_root / ".loom"
|
|
44
|
+
if not loom_dir.is_dir():
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
# Graphify: incremental rebuild via the package's own
|
|
48
|
+
# ``--update`` flag (re-extracts only changed files, merges
|
|
49
|
+
# into the existing graph). Only runs if graphify has been
|
|
50
|
+
# set up at least once for this project.
|
|
51
|
+
graphify_dir = loom_dir / "graphify"
|
|
52
|
+
if (graphify_dir / "graph.json").is_file():
|
|
53
|
+
_maybe_refresh(
|
|
54
|
+
counter_file=graphify_dir / "_commits_since_refresh.txt",
|
|
55
|
+
refresh_fn=lambda: _refresh_graphify(project_root, graphify_dir),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _maybe_refresh(
|
|
62
|
+
*, counter_file: Path, refresh_fn: Callable[[], None]
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Increment the counter; if it crosses the threshold, run
|
|
65
|
+
the refresh and reset. Errors are swallowed — better to skip
|
|
66
|
+
a refresh than break the commit."""
|
|
67
|
+
try:
|
|
68
|
+
count = (
|
|
69
|
+
int(counter_file.read_text())
|
|
70
|
+
if counter_file.is_file()
|
|
71
|
+
else 0
|
|
72
|
+
)
|
|
73
|
+
except (ValueError, OSError):
|
|
74
|
+
count = 0
|
|
75
|
+
count += 1
|
|
76
|
+
if count >= _THRESHOLD:
|
|
77
|
+
try:
|
|
78
|
+
refresh_fn()
|
|
79
|
+
counter_file.write_text("0")
|
|
80
|
+
except Exception: # noqa: BLE001 — never break a commit
|
|
81
|
+
# Leave counter at threshold; next commit will retry.
|
|
82
|
+
pass
|
|
83
|
+
else:
|
|
84
|
+
try:
|
|
85
|
+
counter_file.write_text(str(count))
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _refresh_graphify(project_root: Path, graphify_dir: Path) -> None:
|
|
91
|
+
"""Re-run the graphify extract → build → cluster → persist
|
|
92
|
+
pipeline in-process via the shared ``graphify_build_impl``
|
|
93
|
+
helper that the ``@tool`` wrapper + ``/loominit`` already use.
|
|
94
|
+
|
|
95
|
+
Single source of truth means three things stay in sync: the
|
|
96
|
+
submodule-import shim that dodges graphify's ``__getattr__``
|
|
97
|
+
namespace shadowing, the git-ls-files fast path that skips
|
|
98
|
+
walking ``.venv`` / ``node_modules``, and the exact tree-sitter
|
|
99
|
+
/ Leiden / JSON pipeline. Bypassing it here is what caused this
|
|
100
|
+
function to silently fail on every commit before: it called
|
|
101
|
+
``graphify.extract(files)`` (a submodule, not a function),
|
|
102
|
+
passed ``[extraction]`` (build_from_json wants a dict), and
|
|
103
|
+
dropped the ``communities`` arg to ``to_json``.
|
|
104
|
+
|
|
105
|
+
Capped via subprocess from the shell hook (5 min, see the hook
|
|
106
|
+
wrapper) so a hung extraction can't block git indefinitely."""
|
|
107
|
+
# graphify_dir kept in the signature for the caller's existing
|
|
108
|
+
# path math; the impl writes to the same `.loom/graphify/graph.json`
|
|
109
|
+
# via its own ``_graph_path`` helper, so we don't need to use it.
|
|
110
|
+
_ = graphify_dir
|
|
111
|
+
import anyio
|
|
112
|
+
|
|
113
|
+
from .skills.graphify.tools import graphify_build_impl
|
|
114
|
+
|
|
115
|
+
anyio.run(graphify_build_impl, project_root)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
sys.exit(main())
|
loom_code/agent.py
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""Builds the loomflow team that powers loom-code.
|
|
2
|
+
|
|
3
|
+
This is the one place loom-code wires loomflow primitives
|
|
4
|
+
together. Everything here is configuration — no agent-loop logic,
|
|
5
|
+
no tool implementations, no memory logic. If this file ever grows
|
|
6
|
+
real behaviour, that behaviour belongs in loomflow.
|
|
7
|
+
|
|
8
|
+
loom-code is a single ``Team.supervisor``. The coordinator is a
|
|
9
|
+
READ-ONLY tech lead: it has ``read``/``grep``/``ls``/``find``/
|
|
10
|
+
``web_fetch`` to understand the code and answer questions, plus a
|
|
11
|
+
``delegate`` tool — but NO writer/exec tools. So it plans, tracks,
|
|
12
|
+
and manages, and hands every change (writes/edits) to ``coder`` and
|
|
13
|
+
every test-run to ``reviewer``, with ``explorer``/``auditor`` for
|
|
14
|
+
investigation (see :mod:`loom_code.workers`). Removing the writer
|
|
15
|
+
kernel from the coordinator is deliberate: with it, the model just
|
|
16
|
+
grinds edits itself and leaves the workers idle. ``Team.supervisor``
|
|
17
|
+
returns a plain ``Agent``, so the rest of loom-code (REPL, CLI,
|
|
18
|
+
renderer) treats it exactly like any agent.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections.abc import Awaitable, Callable
|
|
24
|
+
from importlib.resources import files as _pkg_files
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from loomflow import Agent
|
|
29
|
+
from loomflow.team import Team
|
|
30
|
+
from loomflow.tools import find_tool, ls_tool, read_tool
|
|
31
|
+
from loomflow.workspace import LocalDiskWorkspace
|
|
32
|
+
|
|
33
|
+
from .code_index import codebase_search_tool
|
|
34
|
+
from .credentials import patient_retry_policy_for
|
|
35
|
+
from .extensions import Extensions, safe_role_name
|
|
36
|
+
from .file_tools import loom_read_tool
|
|
37
|
+
from .grep_tool import enhanced_grep_tool as grep_tool
|
|
38
|
+
from .hooks import attach_tool_hooks
|
|
39
|
+
from .lsp_tools import lsp_tools
|
|
40
|
+
from .project import Project
|
|
41
|
+
from .prompts import build_unified_coordinator_instructions
|
|
42
|
+
from .rules import remember_rule_tool
|
|
43
|
+
from .trust import discover_trusted
|
|
44
|
+
from .web_fetch import web_fetch_tool
|
|
45
|
+
from .workers import (
|
|
46
|
+
BUILTIN_WORKER_NAMES,
|
|
47
|
+
SUMMARY_THRESHOLD_CHARS,
|
|
48
|
+
_build_coder,
|
|
49
|
+
build_custom_worker,
|
|
50
|
+
build_workers,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Bundled skills shipped with loom-code. Each entry is a directory
|
|
55
|
+
# under ``loom_code/skills/`` with a ``SKILL.md`` + optional
|
|
56
|
+
# ``tools.py``. The framework's SkillRegistry discovers them on
|
|
57
|
+
# Agent construction; the agent sees a 50-token (name + description)
|
|
58
|
+
# entry for each, and calls ``load_skill(name)`` to materialise the
|
|
59
|
+
# body + tools when relevant. Cheap baseline — no LLM cost unless
|
|
60
|
+
# the agent actually loads a skill.
|
|
61
|
+
def _bundled_skill_paths() -> list[Path]:
|
|
62
|
+
"""Return absolute Paths to every shipped skill directory.
|
|
63
|
+
Uses ``importlib.resources`` so the lookup works whether
|
|
64
|
+
loom-code is installed editable, as a wheel, or zipped."""
|
|
65
|
+
root = _pkg_files("loom_code.skills")
|
|
66
|
+
out: list[Path] = []
|
|
67
|
+
for entry in root.iterdir(): # type: ignore[attr-defined]
|
|
68
|
+
if entry.is_dir() and (entry / "SKILL.md").is_file():
|
|
69
|
+
out.append(Path(str(entry)))
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
# loom-code keeps its per-project state under <root>/.loom/ —
|
|
73
|
+
# the workspace notebook and the sqlite memory db both live here.
|
|
74
|
+
# Mirrors how Claude Code uses .claude/ and Pi uses .pi/.
|
|
75
|
+
LOOM_DIR = ".loom"
|
|
76
|
+
|
|
77
|
+
# Default model. Overridable via --model / the /model command.
|
|
78
|
+
DEFAULT_MODEL = "gpt-4.1-mini"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_openai_model(model: str) -> bool:
|
|
82
|
+
"""True when ``model`` is served by OpenAI (so its embeddings are
|
|
83
|
+
paid for by the same key). Used to pin the memory embedder to the
|
|
84
|
+
selected chat model's provider — OpenAI models get OpenAI
|
|
85
|
+
embeddings, everything else (Claude, Gemini, local) uses the
|
|
86
|
+
zero-key ``hash`` embedder so recall never makes a cross-provider
|
|
87
|
+
OpenAI call. Anthropic / Gemini / Ollama have no embeddings API we
|
|
88
|
+
use, so the test is simply 'is this an OpenAI chat model'."""
|
|
89
|
+
m = model.lower()
|
|
90
|
+
# OpenAI chat models: gpt-*, the o-series (o1/o3/o4...), and the
|
|
91
|
+
# ``openai/`` litellm prefix. Anthropic/Gemini/etc. never match.
|
|
92
|
+
return (
|
|
93
|
+
m.startswith(("gpt-", "gpt", "o1", "o3", "o4", "openai/", "chatgpt"))
|
|
94
|
+
or m in {"o1", "o3", "o4"}
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_agent(
|
|
99
|
+
project: Project,
|
|
100
|
+
*,
|
|
101
|
+
model: str = DEFAULT_MODEL,
|
|
102
|
+
approval_handler: Callable[..., Awaitable[bool]] | None = None,
|
|
103
|
+
max_turns: int = 100,
|
|
104
|
+
web_backend: str | None = None,
|
|
105
|
+
max_stop_hook_iterations: int = 2,
|
|
106
|
+
snip_window: int = 8,
|
|
107
|
+
auto_compact: bool = True,
|
|
108
|
+
tool_result_summarizer: str | None = None,
|
|
109
|
+
extensions: Extensions | None = None,
|
|
110
|
+
effort: str | None = None,
|
|
111
|
+
sandbox: bool = False,
|
|
112
|
+
sandbox_allow_network: bool = False,
|
|
113
|
+
operator: bool = False,
|
|
114
|
+
run_until: str | dict[str, Any] | None = None,
|
|
115
|
+
) -> tuple[Agent, LocalDiskWorkspace]:
|
|
116
|
+
"""Wire the loom-code agent for a given project.
|
|
117
|
+
|
|
118
|
+
Returns ``(coordinator, workspace)`` — the coordinator is a
|
|
119
|
+
single ``Team.supervisor`` Agent; the caller needs the workspace
|
|
120
|
+
handle to drive the self-improvement loop
|
|
121
|
+
(``attribute_outcome`` after a run, ``prune`` for retention).
|
|
122
|
+
|
|
123
|
+
The coordinator is READ-ONLY: it holds ``read``/``grep``/``ls``/
|
|
124
|
+
``find``/``web_fetch`` (+ ``delegate``) to understand the code
|
|
125
|
+
and answer questions, but has NO ``write``/``edit``/``bash`` —
|
|
126
|
+
so it CANNOT make changes itself and MUST delegate every
|
|
127
|
+
mutation to a worker. It rides loomflow's ``delegate`` →
|
|
128
|
+
``SubagentInvocation`` path, which streams worker events to the
|
|
129
|
+
parent and rolls up their token cost. (Giving the coordinator
|
|
130
|
+
the writer kernel was tried and reverted: the model just ground
|
|
131
|
+
edits itself and never delegated, leaving the roster idle.)
|
|
132
|
+
|
|
133
|
+
The whole brain in one builder call:
|
|
134
|
+
|
|
135
|
+
* **workers** — the delegate roster (:func:`build_workers`):
|
|
136
|
+
``coder`` (the ONLY writer — full file-and-shell kernel), plus
|
|
137
|
+
read-only ``explorer`` / ``auditor`` / ``reviewer``. Custom
|
|
138
|
+
``.loom/agents/*.md`` join as additional delegate workers.
|
|
139
|
+
* **coordinator** — ``Team.supervisor`` with read-only ``tools=``;
|
|
140
|
+
owns the living plan; plans, tracks, and delegates all writes
|
|
141
|
+
to ``coder`` and verification to ``reviewer``.
|
|
142
|
+
* **living_plan** — on the coordinator; mirrors to the
|
|
143
|
+
workspace so plans persist across sessions.
|
|
144
|
+
* **workspace** — ``<root>/.loom/notebook`` — shared notebook,
|
|
145
|
+
wired onto the coordinator AND every worker.
|
|
146
|
+
* **memory** — ``sqlite:<root>/.loom/memory.db`` — episodes +
|
|
147
|
+
auto-extracted facts, persisted across sessions.
|
|
148
|
+
|
|
149
|
+
Because the coordinator now executes destructive tools itself,
|
|
150
|
+
it carries the permission gate + ``approval_handler`` (the
|
|
151
|
+
workers do too); tool hooks attach to the coordinator AND every
|
|
152
|
+
worker. Prompt caching, persistent tool transcripts, snip
|
|
153
|
+
window and auto-compaction are all on, threaded through
|
|
154
|
+
``Team.supervisor``'s forwarded Agent kwargs.
|
|
155
|
+
"""
|
|
156
|
+
loom_dir = project.root / LOOM_DIR
|
|
157
|
+
loom_dir.mkdir(exist_ok=True)
|
|
158
|
+
|
|
159
|
+
workspace = LocalDiskWorkspace(str(loom_dir / "notebook"))
|
|
160
|
+
# Embedder follows the SELECTED chat model's provider. An OpenAI
|
|
161
|
+
# model uses OpenAI embeddings (the key is already present + funded
|
|
162
|
+
# for OpenAI users); every other provider (Claude, Gemini, local)
|
|
163
|
+
# uses the zero-key ``hash`` embedder so memory recall NEVER makes a
|
|
164
|
+
# cross-provider OpenAI call. Without this, loomflow's default
|
|
165
|
+
# embedder auto-picks OpenAI whenever OPENAI_API_KEY happens to be
|
|
166
|
+
# set — which crashed Claude-only runs with an OpenAI 429 during
|
|
167
|
+
# fact recall. Passing memory as a dict (not the ``sqlite:`` string)
|
|
168
|
+
# is what lets us pin the embedder.
|
|
169
|
+
embedder = "openai" if _is_openai_model(model) else "hash"
|
|
170
|
+
memory_cfg: dict[str, str] = {
|
|
171
|
+
"backend": "sqlite",
|
|
172
|
+
"path": str(loom_dir / "memory.db"),
|
|
173
|
+
"embedder": embedder,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
# Bundled skills (graphify today, more later) computed before the
|
|
177
|
+
# workers + coordinator so the SAME list lands on every agent.
|
|
178
|
+
# Without skills on a worker, the coordinator delegating "build
|
|
179
|
+
# the graph" to coder fails: coder's tool host lacks the skill's
|
|
180
|
+
# tools. ``skill_paths`` append AFTER bundled so last-source-wins
|
|
181
|
+
# gives project > user > bundled.
|
|
182
|
+
bundled_skills = _bundled_skill_paths()
|
|
183
|
+
# User + project extensions (the ``.loom`` folder — skills,
|
|
184
|
+
# subagents, hooks). When NOT supplied (desktop sidecar, scripts,
|
|
185
|
+
# tests) we self-discover with a deny-by-default trust gate so an
|
|
186
|
+
# untrusted project's hooks aren't auto-run.
|
|
187
|
+
if extensions is None:
|
|
188
|
+
extensions = discover_trusted(project.root)
|
|
189
|
+
all_skills = bundled_skills + extensions.skill_paths
|
|
190
|
+
|
|
191
|
+
# Auto-compact threshold — 80% of the model's context window,
|
|
192
|
+
# computed before the workers so it lands on EVERY worker too
|
|
193
|
+
# (a long delegation otherwise grows past the window and 400s
|
|
194
|
+
# with context_length_exceeded). ``None`` disables compaction.
|
|
195
|
+
auto_compact_at_tokens: int | None = None
|
|
196
|
+
if auto_compact:
|
|
197
|
+
from loomflow.agent.auto_compact import context_window_for
|
|
198
|
+
|
|
199
|
+
from .credentials import context_window_override
|
|
200
|
+
# Prefer a known window for litellm-routed models (NVIDIA
|
|
201
|
+
# Nemotron, Groq Llama, ...) that context_window_for doesn't
|
|
202
|
+
# recognise — otherwise it returns a conservative 8192 and
|
|
203
|
+
# compaction fires far too early.
|
|
204
|
+
window = context_window_override(model) or context_window_for(model)
|
|
205
|
+
auto_compact_at_tokens = int(window * 0.8)
|
|
206
|
+
|
|
207
|
+
# Cheap same-provider sibling for low-stakes utility LLM calls —
|
|
208
|
+
# auto-compact summaries and per-result tool-output compression.
|
|
209
|
+
# Running those on the main coding model (Opus / GPT-4-class)
|
|
210
|
+
# wastes real money on summarisation; Haiku / gpt-4.1-mini do
|
|
211
|
+
# the job. ``None`` (no usable cheap sibling) falls back to the
|
|
212
|
+
# main model inside the framework.
|
|
213
|
+
from .credentials import cheap_model_for
|
|
214
|
+
cheap_model = cheap_model_for(model)
|
|
215
|
+
# Per-result tool-output compression for the read-only WORKERS
|
|
216
|
+
# only. The framework replaces the result IN-TURN (the agent sees
|
|
217
|
+
# the digest, never the verbatim output), so this is a last-resort
|
|
218
|
+
# bound against a single huge dump 400-ing a worker's run — snip
|
|
219
|
+
# is turn-count-based and auto-compact never fires inside a
|
|
220
|
+
# worker's single run. Excluded on purpose:
|
|
221
|
+
# * the CODER — needs verbatim ``read`` output to build
|
|
222
|
+
# exact-match ``edit`` old_strings;
|
|
223
|
+
# * the COORDINATOR — its ``delegate`` results ARE the worker
|
|
224
|
+
# briefings (digesting them loses the findings), and in
|
|
225
|
+
# operator mode it holds writer tools, hitting the same
|
|
226
|
+
# exact-match problem as the coder.
|
|
227
|
+
worker_summarizer = (
|
|
228
|
+
tool_result_summarizer
|
|
229
|
+
if tool_result_summarizer is not None
|
|
230
|
+
else cheap_model
|
|
231
|
+
)
|
|
232
|
+
summary_threshold = SUMMARY_THRESHOLD_CHARS
|
|
233
|
+
|
|
234
|
+
# MCP servers (trust-gated above, so only servers from a trusted
|
|
235
|
+
# repo or the user's own config survive). Built into one registry
|
|
236
|
+
# and handed to the coder — the sole writer/executor — so its tools
|
|
237
|
+
# join the coder's kernel. The registry connects lazily (on first
|
|
238
|
+
# tool use), so an unreachable server costs nothing until called.
|
|
239
|
+
# Stashed on the coordinator (``_mcp_registry``) so the REPL/sidecar
|
|
240
|
+
# can ``await registry.aclose()`` on exit.
|
|
241
|
+
mcp_registry: Any | None = None
|
|
242
|
+
if extensions.mcp_specs:
|
|
243
|
+
try:
|
|
244
|
+
from loomflow.mcp import MCPRegistry
|
|
245
|
+
|
|
246
|
+
mcp_registry = MCPRegistry(
|
|
247
|
+
[entry.spec for entry in extensions.mcp_specs]
|
|
248
|
+
)
|
|
249
|
+
except ImportError:
|
|
250
|
+
# ``mcp`` extra not installed — skip MCP rather than fail the
|
|
251
|
+
# build. (Discovery already degrades, but a user could pass
|
|
252
|
+
# pre-built Extensions; belt-and-suspenders.)
|
|
253
|
+
mcp_registry = None
|
|
254
|
+
|
|
255
|
+
workers = build_workers(
|
|
256
|
+
project,
|
|
257
|
+
model=model,
|
|
258
|
+
approval_handler=approval_handler,
|
|
259
|
+
web_backend=web_backend,
|
|
260
|
+
skills=all_skills,
|
|
261
|
+
auto_compact_at_tokens=auto_compact_at_tokens,
|
|
262
|
+
snip_window=snip_window,
|
|
263
|
+
tool_result_summarizer=worker_summarizer,
|
|
264
|
+
effort=effort,
|
|
265
|
+
mcp_registry=mcp_registry,
|
|
266
|
+
sandbox=sandbox,
|
|
267
|
+
sandbox_allow_network=sandbox_allow_network,
|
|
268
|
+
# Same embedder name memory uses (resolved above) so every
|
|
269
|
+
# worker's codebase_search hits the one shared index; the
|
|
270
|
+
# workspace handle fuses learned notes into results (Phase 1b).
|
|
271
|
+
embedder=embedder,
|
|
272
|
+
workspace=workspace,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Custom .loom subagents join as delegate WORKERS. The coordinator
|
|
276
|
+
# reaches them through `delegate`, which keeps streaming + cost
|
|
277
|
+
# rollup (a raw agent-as-tool would lose both). A custom agent
|
|
278
|
+
# whose name collides with a builtin role is skipped — never let a
|
|
279
|
+
# dropped-in spec shadow the known roster, above all ``coder``.
|
|
280
|
+
for spec in extensions.agent_specs:
|
|
281
|
+
# loomflow worker names must be Python identifiers; Claude-
|
|
282
|
+
# Code-style names use hyphens (security-auditor -> ...).
|
|
283
|
+
role = safe_role_name(spec.name)
|
|
284
|
+
if role in BUILTIN_WORKER_NAMES or role in workers:
|
|
285
|
+
continue
|
|
286
|
+
workers[role] = build_custom_worker(
|
|
287
|
+
project,
|
|
288
|
+
spec,
|
|
289
|
+
model=model,
|
|
290
|
+
approval_handler=approval_handler,
|
|
291
|
+
skills=all_skills,
|
|
292
|
+
auto_compact_at_tokens=auto_compact_at_tokens,
|
|
293
|
+
snip_window=snip_window,
|
|
294
|
+
effort=effort,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# The coordinator is READ-ONLY: it reads to understand + answers
|
|
298
|
+
# questions, but has NO writer/exec tools — so it CANNOT grind
|
|
299
|
+
# edits itself and MUST delegate every change to ``coder`` (and
|
|
300
|
+
# test-runs to ``reviewer``). Removing the writer kernel is what
|
|
301
|
+
# stops the coordinator doing everything itself and leaving the
|
|
302
|
+
# worker roster idle — a prompt nudge alone didn't hold.
|
|
303
|
+
root = project.root
|
|
304
|
+
coordinator_tools: list[object] = [
|
|
305
|
+
# Policy-bounded read — reaches user-referenced files outside
|
|
306
|
+
# the project (grep/find/ls stay project-scoped).
|
|
307
|
+
loom_read_tool(root),
|
|
308
|
+
grep_tool(root),
|
|
309
|
+
# Semantic search — finds code by MEANING where grep needs the
|
|
310
|
+
# literal string. Embeds in the SAME space (``embedder``) as
|
|
311
|
+
# memory, so the index and the note store fuse (Phase 1b): the
|
|
312
|
+
# ``workspace`` handle makes every search blend code symbols
|
|
313
|
+
# with what we've LEARNED about them. The coordinator gets it
|
|
314
|
+
# to locate the right subsystem before delegating.
|
|
315
|
+
codebase_search_tool(root, embedder, workspace=workspace),
|
|
316
|
+
# LSP navigation (jedi) — go_to_definition / find_references /
|
|
317
|
+
# hover resolve symbols through imports + scope like an IDE,
|
|
318
|
+
# where grep only matches strings. Read-only; Python only.
|
|
319
|
+
*lsp_tools(root),
|
|
320
|
+
find_tool(root),
|
|
321
|
+
ls_tool(root),
|
|
322
|
+
web_fetch_tool(),
|
|
323
|
+
# Lets the coordinator persist a durable, user-stated rule to
|
|
324
|
+
# AGENTS.md (always-in-prompt next session) instead of trusting
|
|
325
|
+
# probabilistic recall. See loom_code.rules.
|
|
326
|
+
remember_rule_tool(root),
|
|
327
|
+
]
|
|
328
|
+
if web_backend is not None:
|
|
329
|
+
from loomflow.tools import web_tool
|
|
330
|
+
coordinator_tools.append(web_tool(backend=web_backend))
|
|
331
|
+
|
|
332
|
+
# COMPUTER OPERATOR mode (/computer): the coordinator is the agent the
|
|
333
|
+
# user talks to, so in operator mode it must hold the ACTION tools
|
|
334
|
+
# DIRECTLY — not delegate to a worker (the bug that made browser tools
|
|
335
|
+
# unreachable). Add write/edit/bash (act on files + system) + native
|
|
336
|
+
# media/app tools + loom-code's OWN browser engine (page_open/observe/
|
|
337
|
+
# act/check — stable data-loom-id handles + overlay-safe acting +
|
|
338
|
+
# vision verify, replacing the Playwright MCP browser).
|
|
339
|
+
coordinator_instructions = build_unified_coordinator_instructions(project)
|
|
340
|
+
coordinator_tool_host: Any = coordinator_tools
|
|
341
|
+
if operator:
|
|
342
|
+
from pathlib import Path as _Path
|
|
343
|
+
|
|
344
|
+
from loomflow.tools import bash_tool, edit_tool, write_tool
|
|
345
|
+
|
|
346
|
+
from .browse import browse_tools
|
|
347
|
+
from .operator import build_operator_prompt, media_app_tools
|
|
348
|
+
|
|
349
|
+
# OPERATOR mode = "operate my whole computer". The default coding
|
|
350
|
+
# tools are rooted at the PROJECT and reject paths outside it
|
|
351
|
+
# (".. escapes workdir"), which breaks "create a file in
|
|
352
|
+
# Downloads" / "read my Documents". So in operator mode, root the
|
|
353
|
+
# file + shell tools at HOME — the user's actual machine, like a
|
|
354
|
+
# human at the keyboard. The approval gate still confirms every
|
|
355
|
+
# write/destructive action. Coding mode stays project-scoped.
|
|
356
|
+
home = str(_Path.home())
|
|
357
|
+
coordinator_tools.extend(
|
|
358
|
+
[
|
|
359
|
+
read_tool(home),
|
|
360
|
+
ls_tool(home),
|
|
361
|
+
find_tool(home),
|
|
362
|
+
write_tool(home),
|
|
363
|
+
edit_tool(home),
|
|
364
|
+
bash_tool(home, timeout=300.0),
|
|
365
|
+
*media_app_tools(),
|
|
366
|
+
*browse_tools(model=model),
|
|
367
|
+
]
|
|
368
|
+
)
|
|
369
|
+
# build_operator_prompt() injects today's date so relative dates
|
|
370
|
+
# ("tomorrow") resolve correctly.
|
|
371
|
+
coordinator_instructions = build_operator_prompt()
|
|
372
|
+
# Compose the coordinator's tools with the MCP registry so the
|
|
373
|
+
# coordinator itself can call browser_* (and any other MCP) tools.
|
|
374
|
+
# In operator mode this is what makes browser control reachable by
|
|
375
|
+
# the agent the user talks to.
|
|
376
|
+
if mcp_registry is not None:
|
|
377
|
+
from loomflow.tools.registry import InProcessToolHost
|
|
378
|
+
|
|
379
|
+
from .mcp_host import McpAugmentedHost
|
|
380
|
+
|
|
381
|
+
coordinator_tool_host = McpAugmentedHost(
|
|
382
|
+
InProcessToolHost(coordinator_tools), mcp_registry
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# ``max_stop_hook_iterations`` bounds the framework Ralph loop:
|
|
386
|
+
# while the LivingPlan still has todo/doing steps after the model
|
|
387
|
+
# stops, the StopHook re-prompts it to continue — up to this many
|
|
388
|
+
# times. The cap only bites when the agent is STUCK (a converging
|
|
389
|
+
# task drains its plan before hitting it), so keep it LOW: a high
|
|
390
|
+
# value just re-prompts a confused model into a re-planning spin
|
|
391
|
+
# (observed: 8 → the model re-plans + re-asks for input it already
|
|
392
|
+
# has, 650k tokens). Default 2 — one or two continuation nudges,
|
|
393
|
+
# then stop. In-turn persistence (the "own the run" prompt rule)
|
|
394
|
+
# is the primary mechanism; this is just a small safety net.
|
|
395
|
+
# ``/set_continue_cap`` tunes it per session.
|
|
396
|
+
# In operator mode the coordinator runs destructive/real-world tools
|
|
397
|
+
# itself (write/edit/bash/browser), so it needs the approval gate +
|
|
398
|
+
# permissions — exactly as the coder does in coding mode. In coding
|
|
399
|
+
# mode the coordinator stays read-only (gate lives on the workers).
|
|
400
|
+
_coord_extra: dict[str, Any] = {}
|
|
401
|
+
if operator:
|
|
402
|
+
from loomflow import StandardPermissions
|
|
403
|
+
|
|
404
|
+
_coord_extra["permissions"] = StandardPermissions()
|
|
405
|
+
_coord_extra["approval_handler"] = approval_handler
|
|
406
|
+
# /goal run-until-done loop. Passed ONLY when armed: ``run_until=``
|
|
407
|
+
# needs a loomflow newer than any released 0.10.x, and passing the
|
|
408
|
+
# kwarg unconditionally (even as None) would TypeError at startup
|
|
409
|
+
# on a PyPI install — /goal degrades to an error on old framework
|
|
410
|
+
# versions instead of bricking the whole CLI.
|
|
411
|
+
if run_until is not None:
|
|
412
|
+
_coord_extra["run_until"] = run_until
|
|
413
|
+
coordinator = Team.supervisor(
|
|
414
|
+
workers=workers,
|
|
415
|
+
instructions=coordinator_instructions,
|
|
416
|
+
tools=coordinator_tool_host,
|
|
417
|
+
model=model,
|
|
418
|
+
memory=memory_cfg,
|
|
419
|
+
workspace=workspace,
|
|
420
|
+
living_plan=True,
|
|
421
|
+
skills=all_skills,
|
|
422
|
+
max_turns=max_turns,
|
|
423
|
+
max_stop_hook_iterations=max_stop_hook_iterations,
|
|
424
|
+
prompt_caching=True,
|
|
425
|
+
tool_result_summarizer=tool_result_summarizer,
|
|
426
|
+
tool_result_summary_threshold=summary_threshold,
|
|
427
|
+
snip_window=snip_window,
|
|
428
|
+
auto_compact_at_tokens=auto_compact_at_tokens,
|
|
429
|
+
# Auto-compact summaries are low-stakes — run them on the
|
|
430
|
+
# cheap same-provider sibling instead of the coding model.
|
|
431
|
+
# ``None`` falls back to the main model inside the framework.
|
|
432
|
+
auto_compact_summariser=cheap_model,
|
|
433
|
+
effort=effort,
|
|
434
|
+
persist_tool_transcripts=True,
|
|
435
|
+
# Patient retry schedule on free-tier/litellm providers —
|
|
436
|
+
# None elsewhere keeps loomflow's default 3 attempts.
|
|
437
|
+
retry_policy=patient_retry_policy_for(model),
|
|
438
|
+
**_coord_extra,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
# Tool hooks attach to every agent that touches the codebase. The
|
|
442
|
+
# coordinator only reads, but a user PreToolUse hook can match
|
|
443
|
+
# ``read``/``grep`` too, so keep it in the set; the workers (which
|
|
444
|
+
# write + run bash) are the main target. No-op when none declared.
|
|
445
|
+
for tool_agent in (coordinator, *workers.values()):
|
|
446
|
+
attach_tool_hooks(
|
|
447
|
+
tool_agent, extensions.hook_specs, cwd=project.root
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# Stash the MCP registry on the coordinator so the REPL / sidecar can
|
|
451
|
+
# tear it down (``await coordinator._mcp_registry.aclose()``) on exit
|
|
452
|
+
# — mirrors how the worker registry is carried on the coordinator.
|
|
453
|
+
# ``None`` when no MCP servers were discovered (the common case).
|
|
454
|
+
coordinator._mcp_registry = mcp_registry # type: ignore[attr-defined]
|
|
455
|
+
|
|
456
|
+
return coordinator, workspace
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def build_solo_agent(
|
|
460
|
+
project: Project,
|
|
461
|
+
*,
|
|
462
|
+
model: str = DEFAULT_MODEL,
|
|
463
|
+
approval_handler: Callable[..., Awaitable[bool]] | None = None,
|
|
464
|
+
web_backend: str | None = None,
|
|
465
|
+
snip_window: int = 8,
|
|
466
|
+
auto_compact: bool = True,
|
|
467
|
+
effort: str | None = None,
|
|
468
|
+
sandbox: bool = False,
|
|
469
|
+
sandbox_allow_network: bool = False,
|
|
470
|
+
extensions: Extensions | None = None,
|
|
471
|
+
) -> Agent:
|
|
472
|
+
"""The trivial-task FAST PATH: one coder-kernel agent, no team.
|
|
473
|
+
|
|
474
|
+
The supervisor topology earns its keep on multi-file features and
|
|
475
|
+
verification-worthy work — but it taxes a one-line fix with a full
|
|
476
|
+
delegation round-trip (coordinator reads → delegates → coder
|
|
477
|
+
re-reads → coordinator integrates): 2-3x the turns and model calls
|
|
478
|
+
of just doing it. The REPL routes obviously-small tasks here
|
|
479
|
+
instead (see ``Repl._route_turn``); everything real still goes
|
|
480
|
+
through the team.
|
|
481
|
+
|
|
482
|
+
Context is CONTINUOUS across routes: the solo agent shares the
|
|
483
|
+
team's memory (same ``.loom/memory.db``, same embedder pivot) and
|
|
484
|
+
notebook workspace, and the REPL runs it under the same
|
|
485
|
+
``session_id`` — so a solo fix shows up in the team's history next
|
|
486
|
+
turn and vice versa. Approval gate + tool hooks apply exactly as
|
|
487
|
+
they do for the team's coder; permissions are identical. MCP
|
|
488
|
+
servers are NOT attached (an external-integration task isn't a
|
|
489
|
+
trivial fix — the router sends those to the team).
|
|
490
|
+
"""
|
|
491
|
+
loom_dir = project.root / LOOM_DIR
|
|
492
|
+
loom_dir.mkdir(exist_ok=True)
|
|
493
|
+
workspace = LocalDiskWorkspace(str(loom_dir / "notebook"))
|
|
494
|
+
embedder = "openai" if _is_openai_model(model) else "hash"
|
|
495
|
+
memory_cfg: dict[str, str] = {
|
|
496
|
+
"backend": "sqlite",
|
|
497
|
+
"path": str(loom_dir / "memory.db"),
|
|
498
|
+
"embedder": embedder,
|
|
499
|
+
}
|
|
500
|
+
if extensions is None:
|
|
501
|
+
extensions = discover_trusted(project.root)
|
|
502
|
+
all_skills = _bundled_skill_paths() + extensions.skill_paths
|
|
503
|
+
|
|
504
|
+
auto_compact_at_tokens: int | None = None
|
|
505
|
+
if auto_compact:
|
|
506
|
+
from loomflow.agent.auto_compact import context_window_for
|
|
507
|
+
|
|
508
|
+
from .credentials import context_window_override
|
|
509
|
+
window = context_window_override(model) or context_window_for(model)
|
|
510
|
+
auto_compact_at_tokens = int(window * 0.8)
|
|
511
|
+
|
|
512
|
+
agent = _build_coder(
|
|
513
|
+
project,
|
|
514
|
+
model=model,
|
|
515
|
+
approval_handler=approval_handler,
|
|
516
|
+
has_web=web_backend is not None,
|
|
517
|
+
skills=all_skills,
|
|
518
|
+
auto_compact_at_tokens=auto_compact_at_tokens,
|
|
519
|
+
snip_window=snip_window,
|
|
520
|
+
effort=effort,
|
|
521
|
+
sandbox=sandbox,
|
|
522
|
+
sandbox_allow_network=sandbox_allow_network,
|
|
523
|
+
embedder=embedder,
|
|
524
|
+
workspace=workspace,
|
|
525
|
+
# Standalone — no parent to inherit memory/workspace from.
|
|
526
|
+
memory=memory_cfg,
|
|
527
|
+
attach_workspace=True,
|
|
528
|
+
# Shares the REPL session_id with the read-only coordinator —
|
|
529
|
+
# persisting writer transcripts would make the coordinator
|
|
530
|
+
# rehydrate history of "itself" editing (the grind failure).
|
|
531
|
+
persist_tool_transcripts=False,
|
|
532
|
+
)
|
|
533
|
+
if web_backend is not None:
|
|
534
|
+
from loomflow.tools import web_tool
|
|
535
|
+
agent.add_tool(web_tool(backend=web_backend)) # type: ignore[arg-type]
|
|
536
|
+
attach_tool_hooks(agent, extensions.hook_specs, cwd=project.root)
|
|
537
|
+
return agent
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def loom_dir_for(root: Path) -> Path:
|
|
541
|
+
"""Return (and create) the ``.loom`` dir for a project root."""
|
|
542
|
+
d = root / LOOM_DIR
|
|
543
|
+
d.mkdir(exist_ok=True)
|
|
544
|
+
return d
|