locals 0.2.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.
Files changed (61) hide show
  1. locals/__init__.py +69 -0
  2. locals/agents.py +485 -0
  3. locals/api.py +333 -0
  4. locals/apis.py +483 -0
  5. locals/app.py +2505 -0
  6. locals/attention.py +224 -0
  7. locals/bench.py +112 -0
  8. locals/blocks.py +485 -0
  9. locals/budget.py +111 -0
  10. locals/chat.py +227 -0
  11. locals/cli.py +84 -0
  12. locals/client.py +297 -0
  13. locals/constraint.py +165 -0
  14. locals/core.py +430 -0
  15. locals/defaults.py +274 -0
  16. locals/engine.py +1300 -0
  17. locals/fields.py +48 -0
  18. locals/forced.py +249 -0
  19. locals/formats.py +969 -0
  20. locals/fuse.py +339 -0
  21. locals/gdn_kernel.py +205 -0
  22. locals/grammar.py +594 -0
  23. locals/harness.py +1239 -0
  24. locals/hybrid_batch.py +1879 -0
  25. locals/kernels.py +1441 -0
  26. locals/lazy.py +224 -0
  27. locals/llg.py +24 -0
  28. locals/lm.py +75 -0
  29. locals/lookup.py +167 -0
  30. locals/menubar.py +100 -0
  31. locals/mixed.py +69 -0
  32. locals/mma_kernel.py +171 -0
  33. locals/nax_kernel.py +471 -0
  34. locals/norm_kernel.py +82 -0
  35. locals/oai.py +811 -0
  36. locals/packed.py +495 -0
  37. locals/pda.py +558 -0
  38. locals/pdactl.py +338 -0
  39. locals/plan.py +1146 -0
  40. locals/presets/tune_v6_applegpu_g14s.json +1 -0
  41. locals/presets/tune_v6_applegpu_g17s.json +1 -0
  42. locals/qmv_frag.py +344 -0
  43. locals/qmv_multi.py +248 -0
  44. locals/regex.py +585 -0
  45. locals/regions.py +126 -0
  46. locals/runtime.py +499 -0
  47. locals/server.py +2300 -0
  48. locals/service.py +680 -0
  49. locals/siblings.py +208 -0
  50. locals/solve.py +909 -0
  51. locals/tree.py +211 -0
  52. locals/treedec.py +133 -0
  53. locals/vocab.py +172 -0
  54. locals/walk.py +219 -0
  55. locals-0.2.0.dist-info/METADATA +534 -0
  56. locals-0.2.0.dist-info/RECORD +61 -0
  57. locals-0.2.0.dist-info/WHEEL +5 -0
  58. locals-0.2.0.dist-info/entry_points.txt +4 -0
  59. locals-0.2.0.dist-info/licenses/LICENSE +21 -0
  60. locals-0.2.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +76 -0
  61. locals-0.2.0.dist-info/top_level.txt +1 -0
locals/__init__.py ADDED
@@ -0,0 +1,69 @@
1
+ """locals: exact constrained decoding for MLX on Apple silicon.
2
+
3
+ Compile a JSON schema, a regular expression or a tool-call format into a
4
+ grammar for a tokenizer, then decode under it: forced text is written without
5
+ running the model, the mask lives on the GPU, recursive schemas run on a small
6
+ stack, outputs stay valid under a token limit, and many requests decode
7
+ together in one batch.
8
+
9
+ import locals
10
+
11
+ rt = locals.load("mlx-community/Qwen3.5-4B-MLX-4bit") # or the short names "qwen", "gemma"
12
+ print(rt.generate("Extract the person from: ...", schema=SCHEMA).text)
13
+
14
+ # or as a grammar library, in any decode loop
15
+ grammar = locals.GrammarCompiler(tokenizer).compile_json_schema(SCHEMA)
16
+ matcher = grammar.matcher()
17
+
18
+ # or as a logits processor for mlx-lm
19
+ from mlx_lm import generate
20
+ generate(model, tokenizer, prompt, logits_processors=[locals.logits_processor(grammar)])
21
+
22
+ # or as mlx-lm, faster: the same functions on a patched model
23
+ from locals.lm import load, generate, batch_generate
24
+ model, tokenizer = load("mlx-community/gemma-4-e4b-it-4bit") # any mlx-lm model; or: locals.patch(model)
25
+
26
+ Serving (OpenAI-compatible, continuous batching for concurrent agents):
27
+ ``locals code DIR`` opens OpenCode on a project with everything set up;
28
+ ``locals serve`` runs the server alone; see ``locals --help``.
29
+ """
30
+
31
+ __version__ = "0.2.0"
32
+
33
+ # Every name is imported on first use: the command-line app (locals status, locals stop, the menu bar)
34
+ # imports this package, and MLX and mlx-lm alone took 2.4 of the 2.9 s `locals --version` needed.
35
+ _LAZY = {"CompiledGrammar": "api", "GrammarCompiler": "api", "Matcher": "api", "Vocabulary": "api", "load": "api",
36
+ "logits_processor": "api", "ConstraintExhausted": "engine", "Engine": "engine", "GenResult": "engine",
37
+ "Runtime": "runtime", "patch": "lm", "BatchService": "service", "Server": "server"}
38
+
39
+ __all__ = [
40
+ "__version__",
41
+ "load",
42
+ "Runtime",
43
+ "Engine",
44
+ "GenResult",
45
+ "ConstraintExhausted",
46
+ "Vocabulary",
47
+ "GrammarCompiler",
48
+ "CompiledGrammar",
49
+ "Matcher",
50
+ "logits_processor",
51
+ "patch",
52
+ "BatchService",
53
+ "Server",
54
+ ]
55
+
56
+
57
+ def __getattr__(name):
58
+ module = _LAZY.get(name)
59
+ if module is None:
60
+ raise AttributeError(f"module 'locals' has no attribute {name!r}")
61
+ import importlib
62
+
63
+ value = getattr(importlib.import_module(f".{module}", __name__), name)
64
+ globals()[name] = value
65
+ return value
66
+
67
+
68
+ def __dir__():
69
+ return sorted(list(globals()) + list(_LAZY))
locals/agents.py ADDED
@@ -0,0 +1,485 @@
1
+ """Run many tool-using agents in parallel on one local model.
2
+
3
+ python -m locals.agents --engine locals --agents 8 # built-in task suite
4
+ python -m locals.agents --engine stock --agents 8 # classical stack
5
+ python -m locals.agents --engine both --agents 8 # both, then compare
6
+ python -m locals.agents --task "Write hello.py and run it" --agents 4
7
+
8
+ Every agent is a thread with its own sandbox directory and its own conversation.
9
+ Each turn the model must answer with one JSON action (think / tool / path /
10
+ content), enforced by constrained decoding, so actions always parse. All agents'
11
+ model calls go to one BatchService: whichever agents are waiting for the model
12
+ at a given moment decode together in one batch, and each agent's conversation
13
+ prefix is served from the prompt cache, so a turn only prefills the new tool
14
+ output.
15
+
16
+ Engines: ``locals`` (our kernels, fused layers, pipelined drip batching, our
17
+ constraint engine) or ``stock`` (plain mlx-lm BatchGenerator + llguidance).
18
+
19
+ Warning: ``run_python`` executes model-written code on this machine (in the
20
+ agent's sandbox directory, isolated mode, with a timeout). It is not a security
21
+ sandbox. Use --no-exec to disable it.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import subprocess
30
+ import sys
31
+ import threading
32
+ import time
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+ from typing import Callable, List, Optional
36
+ from .defaults import DEFAULT_MODEL, MODEL_HELP, model_id
37
+
38
+ # Tools in the model's native (Qwen3.5) function-calling format. Parameter
39
+ # values are raw text between XML tags, so code needs no escaping.
40
+ TOOL_SPECS = {
41
+ "list_files": ("List the files in your sandbox directory.", []),
42
+ "read_file": ("Show the contents of a file.", [("path", "file path relative to your sandbox, e.g. main.py")]),
43
+ "write_file": ("Save text as a file (creates or overwrites it).", [("path", "file path relative to your sandbox, e.g. main.py"),
44
+ ("content", "the complete file text")]),
45
+ "run_file": ("Run a Python file from your sandbox and show its output.", [("path", "the Python file to run, e.g. main.py")]),
46
+ "run_python": ("Run a snippet of Python code in your sandbox and show its output.", [("code", "Python code")]),
47
+ "finish": ("Call this when the task is done.", [("answer", "the final answer or a short summary")]),
48
+ }
49
+ TOOLS = list(TOOL_SPECS)
50
+
51
+
52
+ def tool_schemas() -> list:
53
+ return [{"type": "function", "function": {
54
+ "name": name, "description": desc,
55
+ "parameters": {"type": "object", "properties": {p: {"type": "string", "description": d} for p, d in params},
56
+ "required": [p for p, _ in params]}}} for name, (desc, params) in TOOL_SPECS.items()]
57
+
58
+
59
+ # One reply = exactly one call of a known tool (no free-text preamble: a 2B model
60
+ # otherwise writes the whole solution as prose) with its required parameters.
61
+ # Enforced by constrained decoding, so every reply parses; a value is any text without "</".
62
+ _VAL = r"([^<]|<[^/])*"
63
+ ACTION_REGEX = (r"<tool_call>\n(" + "|".join(
64
+ rf"<function={name}>\n" + "".join(rf"<parameter={p}>\n{_VAL}\n</parameter>\n" for p, _ in params)
65
+ for name, (_, params) in TOOL_SPECS.items()) + r")</function>\n</tool_call>")
66
+
67
+
68
+ def parse_action(text: str) -> Optional[dict]:
69
+ import re
70
+
71
+ m = re.search(r"<tool_call>\n<function=(\w+)>\n(.*)</function>\n</tool_call>$", text, re.S)
72
+ if not m:
73
+ return None
74
+ act = {"tool": m.group(1), "thought": text[: m.start()].strip()}
75
+ for k, v in re.findall(r"<parameter=(\w+)>\n(.*?)\n</parameter>\n", m.group(2), re.S):
76
+ act[k] = v
77
+ return act
78
+
79
+
80
+ SYSTEM = """You are agent {i}, a careful software agent. You work alone in your own sandbox directory and solve the task with the tools, one tool call per reply.
81
+ Use file paths relative to your sandbox, like main.py. Write complete, correct Python. Check the output of your code before you call finish."""
82
+
83
+
84
+ # --- built-in task suite ---------------------------------------------------------------
85
+
86
+ def _run_file(d: Path, name: str) -> str:
87
+ if not (d / name).exists():
88
+ return ""
89
+ try:
90
+ return subprocess.run([sys.executable, "-I", name], cwd=d, capture_output=True, text=True, timeout=10).stdout
91
+ except Exception:
92
+ return ""
93
+
94
+
95
+ def _fizz(n):
96
+ return "FizzBuzz" if n % 15 == 0 else "Fizz" if n % 3 == 0 else "Buzz" if n % 5 == 0 else str(n)
97
+
98
+
99
+ def _fibs(n):
100
+ a, b, out = 0, 1, []
101
+ for _ in range(n):
102
+ out.append(a)
103
+ a, b = b, a + b
104
+ return out
105
+
106
+
107
+ @dataclass
108
+ class Task:
109
+ name: str
110
+ prompt: str
111
+ check: Optional[Callable[[Path, str], bool]] = None # (sandbox, final answer) -> passed
112
+
113
+
114
+ SUITE = [
115
+ Task("fizzbuzz", "Write fizzbuzz.py that prints FizzBuzz for the numbers 1 to 15, one per line (Fizz for multiples of 3, "
116
+ "Buzz for multiples of 5, FizzBuzz for both). Run it to check.",
117
+ lambda d, a: _run_file(d, "fizzbuzz.py").split() == [_fizz(i) for i in range(1, 16)]),
118
+ Task("primes", "Create primes.py with a function is_prime(n) and make it print the list of primes below 50 as a Python list. Run it.",
119
+ lambda d, a: str([p for p in range(2, 50) if all(p % k for k in range(2, p))]) in _run_file(d, "primes.py")),
120
+ Task("reverse", "Write reverse.py that prints the string 'locals agents' reversed. Run it.",
121
+ lambda d, a: _run_file(d, "reverse.py").strip() == "stnega nocts"),
122
+ Task("squares", "Compute the sum of the squares of the integers 1 to 100 with Python, then finish with the number as the answer.",
123
+ lambda d, a: "338350" in a),
124
+ Task("fib", "Write fib.py that prints the first 15 Fibonacci numbers separated by spaces, starting with 0 1. Run it.",
125
+ lambda d, a: _run_file(d, "fib.py").split() == [str(x) for x in _fibs(15)]),
126
+ Task("vowels", "Count the vowels (a, e, i, o, u) in the sentence 'Parallel agents share one batch' using Python, then finish "
127
+ "with the count as the answer.",
128
+ lambda d, a: a.strip().rstrip(".").split()[-1:] == ["10"] or a.strip() == "10"),
129
+ Task("csv", "Create data.csv with a header line name,age and 4 rows of made-up people. Then compute the average age with "
130
+ "Python and finish with it as the answer.",
131
+ lambda d, a: (d / "data.csv").exists() and len((d / "data.csv").read_text().strip().splitlines()) == 5),
132
+ Task("wordcount", "Write word_count.py that finds the most common word in the text 'the quick brown fox jumps over the lazy "
133
+ "dog the end' and prints only that word. Run it.",
134
+ lambda d, a: _run_file(d, "word_count.py").strip() == "the"),
135
+ ]
136
+
137
+
138
+ # --- one agent ---------------------------------------------------------------------------
139
+
140
+ @dataclass
141
+ class AgentState:
142
+ i: int
143
+ task: Task
144
+ dir: Path
145
+ step: int = 0
146
+ status: str = "waiting"
147
+ last_tool: str = ""
148
+ tokens_out: int = 0
149
+ prompt_tokens: int = 0
150
+ cached_tokens: int = 0
151
+ answer: str = ""
152
+ finished: bool = False
153
+ passed: Optional[bool] = None
154
+ t_model: float = 0.0
155
+ t_tools: float = 0.0
156
+ transcript: List[dict] = field(default_factory=list)
157
+
158
+
159
+ def _run(base: Path, argv: list) -> str:
160
+ """The model's program, in its sandbox folder, with its whole process group ended after it (or after 10 s)."""
161
+ from .solve import run_shell
162
+ import shlex
163
+
164
+ code, out = run_shell(" ".join(shlex.quote(a) for a in [sys.executable, "-I"] + argv), base, 10)
165
+ if code is None:
166
+ return "error: timed out after 10 s"
167
+ return out.strip()[:1500] or "(no output)"
168
+
169
+
170
+ def _tool(st: AgentState, act: dict, allow_exec: bool) -> str:
171
+ tool, path = act.get("tool"), (act.get("path") or "").strip()
172
+ base = st.dir.resolve()
173
+ if path.startswith(("/", "~")) or ":" in path[:3]:
174
+ path = Path(path.replace("\\", "/")).name # absolute paths: keep the file name, inside the sandbox
175
+ target = (base / path).resolve() if path else base
176
+ if path and base not in target.parents and target != base:
177
+ return "error: path must stay inside the sandbox"
178
+ if tool == "list_files":
179
+ files = sorted(p.relative_to(base).as_posix() for p in base.rglob("*") if p.is_file())
180
+ return "\n".join(files) or "(no files)"
181
+ if tool == "read_file":
182
+ if not target.is_file():
183
+ return f"error: no such file {path!r}"
184
+ return target.read_text(errors="replace")[:2000]
185
+ if tool == "write_file":
186
+ if not path or target == base or target.is_dir():
187
+ return "error: write_file needs a file's path"
188
+ content = act.get("content", "")
189
+ target.parent.mkdir(parents=True, exist_ok=True)
190
+ target.write_text(content + ("\n" if content and not content.endswith("\n") else ""))
191
+ return f"wrote {len(content)} characters to {path}"
192
+ if tool in ("run_file", "run_python"):
193
+ if not allow_exec:
194
+ return "error: code execution is disabled"
195
+ if tool == "run_file":
196
+ if not target.is_file():
197
+ return f"error: no such file {path!r}"
198
+ return _run(base, [target.name if target.parent == base else str(target)])
199
+ return _run(base, ["-c", act.get("code", "")])
200
+ if tool == "finish":
201
+ return "done"
202
+ return f"error: unknown tool {tool!r}"
203
+
204
+
205
+ def first_prompt(tok, st: AgentState) -> List[int]:
206
+ msgs = [{"role": "system", "content": SYSTEM.format(i=st.i)}, {"role": "user", "content": "Task: " + st.task.prompt}]
207
+ return tok.apply_chat_template(msgs, tools=tool_schemas(), add_generation_prompt=True, enable_thinking=False, tokenize=True)
208
+
209
+
210
+ class ModelFormat:
211
+ """The tool-call format of the model's chat template (locals.formats). Qwen3.5's
212
+ XML calls keep the typed one-call grammar and the incremental prompt above;
213
+ any other model re-renders the conversation with its own template each step
214
+ (the prompt cache reuses the unchanged prefix) and decodes exactly one call in
215
+ its own format."""
216
+
217
+ def __init__(self, tok):
218
+ from . import formats
219
+
220
+ self.tok = tok
221
+ self.fmt = formats.detect(tok)
222
+ self.native = self.fmt.name == "qwen_xml"
223
+ self.kw = {} if formats.thinking_style(tok) == "none" else {"enable_thinking": False}
224
+ self.regex = ACTION_REGEX if self.native else self.fmt.calls(self.fmt.call(TOOLS), many=False, at_least_one=True)
225
+
226
+ def render(self, msgs: List[dict]) -> List[int]:
227
+ m, tools = self.fmt.prepare([dict(x) for x in msgs], tool_schemas())
228
+ return self.tok.apply_chat_template(m, tools=tools, add_generation_prompt=True, tokenize=True, **self.kw)
229
+
230
+ def parse(self, text: str) -> Optional[dict]:
231
+ if self.native:
232
+ return parse_action(text)
233
+ calls = self.fmt.parse_calls(self.fmt.split(text)[1])
234
+ if not calls:
235
+ return None
236
+ name, args = calls[0]
237
+ act = {"tool": name, "thought": ""}
238
+ act.update({k: v if isinstance(v, str) else json.dumps(v) for k, v in args.items()})
239
+ return act
240
+
241
+
242
+ def run_agent(svc, st: AgentState, max_steps: int, max_tokens: int, allow_exec: bool, mf: Optional["ModelFormat"] = None) -> None:
243
+ tok = svc.tokenizer
244
+ mf = mf or ModelFormat(tok)
245
+ msgs = [{"role": "system", "content": SYSTEM.format(i=st.i)}, {"role": "user", "content": "Task: " + st.task.prompt}]
246
+ history = first_prompt(tok, st) if mf.native else mf.render(msgs)
247
+ st.dir.mkdir(parents=True, exist_ok=True)
248
+ while st.step < max_steps:
249
+ st.step += 1
250
+ st.status = "model"
251
+ t = time.perf_counter()
252
+ job = svc.submit(history, regex=mf.regex, max_tokens=max_tokens).wait()
253
+ st.t_model += time.perf_counter() - t
254
+ st.tokens_out += len(job.tokens)
255
+ st.prompt_tokens += len(history)
256
+ st.cached_tokens += job.cached
257
+ if job.finish.startswith(("error", "invalid")):
258
+ st.status = job.finish
259
+ return
260
+ act = mf.parse(job.text)
261
+ if act is None:
262
+ act = {"tool": "?", "thought": job.text[-200:]}
263
+ result = f"error: your reply was cut off after {max_tokens} tokens; keep it shorter"
264
+ else:
265
+ st.status = "tool"
266
+ st.last_tool = act.get("tool", "?")
267
+ t = time.perf_counter()
268
+ try:
269
+ result = _tool(st, act, allow_exec)
270
+ except (OSError, ValueError) as e: # a folder for a file, a NUL in a path: the model's to see, not the agent's end
271
+ result = f"error: {e}"
272
+ st.t_tools += time.perf_counter() - t
273
+ prev = st.transcript[-1] if st.transcript else None
274
+ if prev is not None and prev["action"] == act and prev["result"] == result:
275
+ result += "\n(note: this repeats your previous step exactly; change your approach)"
276
+ st.transcript.append({"step": st.step, "action": act, "result": result})
277
+ if act.get("tool") == "finish":
278
+ st.answer = act.get("answer") or ""
279
+ st.finished = True
280
+ break
281
+ if mf.native:
282
+ delta = ("<|im_end|>\n<|im_start|>user\n<tool_response>\n" + result
283
+ + "\n</tool_response><|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n")
284
+ history = history + job.tokens + tok.encode(delta, add_special_tokens=False)
285
+ else:
286
+ cid = mf.fmt.new_id()
287
+ args = {k: v for k, v in act.items() if k not in ("tool", "thought")}
288
+ msgs += [{"role": "assistant", "content": "", "tool_calls": [{"id": cid, "type": "function",
289
+ "function": {"name": act["tool"], "arguments": args}}]},
290
+ {"role": "tool", "tool_call_id": cid, "content": result}]
291
+ history = mf.render(msgs)
292
+ st.status = "finished" if st.finished else "out of steps"
293
+ if st.task.check is not None:
294
+ try:
295
+ st.passed = bool(st.task.check(st.dir, st.answer))
296
+ except Exception:
297
+ st.passed = False
298
+
299
+
300
+ # --- dashboard ---------------------------------------------------------------------------
301
+
302
+ def _render(svc, states, t0, engine):
303
+ from rich.table import Table
304
+
305
+ el = time.perf_counter() - t0
306
+ s = svc.stats()
307
+ tab = Table(title=f"locals agents · engine: {engine} · {el:6.1f}s", title_justify="left", expand=False)
308
+ for col in ["agent", "task", "step", "tool", "status", "tokens", "check"]:
309
+ tab.add_column(col, justify="right" if col in ("step", "tokens") else "left")
310
+ for st in states:
311
+ chk = "" if st.passed is None else ("pass" if st.passed else "fail")
312
+ tab.add_row(str(st.i), st.task.name, str(st.step), st.last_tool, st.status, str(st.tokens_out), chk)
313
+ gen = sum(st.tokens_out for st in states)
314
+ cached = s["cached_tokens"] / max(1, s["prompt_tokens"])
315
+ tab.caption = (f"batch {s['active']:2d} rows · {gen} tokens generated · {gen / max(el, 1e-9):6.1f} tok/s · "
316
+ f"prompt cache {cached:4.0%}")
317
+ return tab
318
+
319
+
320
+ def run(args) -> dict:
321
+ from rich.console import Console
322
+ from rich.live import Live
323
+
324
+ from .service import BatchService
325
+
326
+ console = Console(stderr=True)
327
+ tasks = [Task("custom", args.task)] * args.agents if args.task else [SUITE[i % len(SUITE)] for i in range(args.agents)]
328
+ out_dir = Path(args.out) / f"{time.strftime('%Y%m%d-%H%M%S')}-{args.engine}"
329
+ from .app import check_local_load
330
+
331
+ check_local_load(args.model, "agents")
332
+ console.print(f"loading {args.model} on engine [bold]{args.engine}[/] ...")
333
+ t = time.perf_counter()
334
+ svc = BatchService(args.engine, args.model, slots=args.slots, temp=args.temp, seed=args.seed)
335
+ load_s = time.perf_counter() - t
336
+ # Warm the batch (kernel compilation, first-token paths) so timings are steady-state.
337
+ warm = svc.tokenizer.apply_chat_template([{"role": "user", "content": "hi"}], add_generation_prompt=True,
338
+ enable_thinking=False, tokenize=True)
339
+ mf = ModelFormat(svc.tokenizer)
340
+ for j in [svc.submit(warm, regex=mf.regex, max_tokens=24) for _ in range(min(4, args.agents))]:
341
+ j.wait()
342
+ states = [AgentState(i, task, out_dir / f"agent_{i:02d}") for i, task in enumerate(tasks)]
343
+ # Every agent's first prompt starts with the same system prompt and tool
344
+ # definitions: prefill that prefix once, for both engines alike.
345
+ firsts = [first_prompt(svc.tokenizer, st) if mf.native else
346
+ mf.render([{"role": "system", "content": SYSTEM.format(i=st.i)}, {"role": "user", "content": "Task: " + st.task.prompt}])
347
+ for st in states]
348
+ lcp = firsts[0]
349
+ for f in firsts[1:]:
350
+ n = next((k for k, (a, b) in enumerate(zip(lcp, f)) if a != b), min(len(lcp), len(f)))
351
+ lcp = lcp[:n]
352
+ if len(lcp) > 32:
353
+ svc.prime(lcp)
354
+ base = svc.stats()
355
+ threads = [threading.Thread(target=run_agent, args=(svc, st, args.max_steps, args.max_tokens, not args.no_exec, mf), daemon=True)
356
+ for st in states]
357
+ t0 = time.perf_counter()
358
+ for th in threads:
359
+ th.start()
360
+ if args.quiet:
361
+ for th in threads:
362
+ th.join()
363
+ else:
364
+ with Live(_render(svc, states, t0, args.engine), console=console, refresh_per_second=4) as live:
365
+ while any(th.is_alive() for th in threads):
366
+ time.sleep(0.25)
367
+ live.update(_render(svc, states, t0, args.engine))
368
+ live.update(_render(svc, states, t0, args.engine))
369
+ wall = time.perf_counter() - t0
370
+ s = svc.stats()
371
+ svc.close()
372
+ gen = sum(st.tokens_out for st in states)
373
+ engine_counters = {k: s[k] - base.get(k, 0) for k in ("steps", "rows", "prefill_s", "prefill_tokens", "admissions", "idle_s") if k in s}
374
+ summary = {
375
+ "engine_counters": engine_counters,
376
+ "engine": args.engine, "model": args.model, "agents": args.agents, "wall_s": wall, "load_s": load_s,
377
+ "tokens_out": gen, "tok_per_s": gen / wall, "steps": sum(st.step for st in states),
378
+ "finished": sum(st.finished for st in states), "passed": sum(bool(st.passed) for st in states),
379
+ "errors": sum(st.status.startswith("error") for st in states),
380
+ "checked": sum(st.passed is not None for st in states),
381
+ "prompt_tokens": s["prompt_tokens"] - base["prompt_tokens"], "cached_tokens": s["cached_tokens"] - base["cached_tokens"],
382
+ "agents_detail": [{"agent": st.i, "task": st.task.name, "steps": st.step, "status": st.status, "tokens": st.tokens_out,
383
+ "passed": st.passed, "answer": st.answer[:200], "model_s": st.t_model, "tools_s": st.t_tools}
384
+ for st in states],
385
+ "out_dir": str(out_dir),
386
+ }
387
+ out_dir.mkdir(parents=True, exist_ok=True)
388
+ (out_dir / "summary.json").write_text(json.dumps(summary, indent=1))
389
+ (out_dir / "transcripts.json").write_text(json.dumps([{"agent": st.i, "task": st.task.prompt, "steps": st.transcript}
390
+ for st in states], indent=1))
391
+ console.print(f"[bold]{args.engine}[/]: {args.agents} agents, {summary['steps']} steps, {gen} tokens in {wall:.1f}s "
392
+ f"({gen / wall:.1f} tok/s) · finished {summary['finished']}/{args.agents}"
393
+ + (f" · checks passed {summary['passed']}/{summary['checked']}" if summary["checked"] else "")
394
+ + f" · prompt cache {summary['cached_tokens'] / max(1, summary['prompt_tokens']):.0%}"
395
+ + f"\nrun saved to {out_dir}")
396
+ return summary
397
+
398
+
399
+ def _without(argv: list, opt: str) -> list:
400
+ """``argv`` without ``opt VALUE`` and ``opt=VALUE``."""
401
+ out, skip = [], False
402
+ for a in argv:
403
+ if skip:
404
+ skip = False
405
+ elif a == opt:
406
+ skip = True
407
+ elif not a.startswith(opt + "="):
408
+ out.append(a)
409
+ return out
410
+
411
+
412
+ def compare(args, argv=None) -> None:
413
+ """Run both engines in fresh processes (one model in memory at a time) and compare."""
414
+ from rich.console import Console
415
+ from rich.table import Table
416
+
417
+ console = Console()
418
+ results = {}
419
+ for engine in ("stock", "locals"):
420
+ given = list(sys.argv[1:] if argv is None else argv) # this command's own arguments (python -m locals.agents ...)
421
+ # (the user's --summary-json came after ours and won: the runs printed nothing, and the comparison crashed)
422
+ given = _without(_without(given, "--engine"), "--summary-json")
423
+ cmd = [sys.executable, "-m", "locals.agents", "--engine", engine, "--summary-json", "-"] + given
424
+ p = subprocess.run(cmd, stdout=subprocess.PIPE, text=True)
425
+ if p.returncode != 0:
426
+ console.print(f"[red]{engine} run failed[/]")
427
+ sys.exit(p.returncode or 1)
428
+ results[engine] = json.loads(p.stdout.strip().splitlines()[-1])
429
+ if results[engine].get("errors"):
430
+ console.print(f"[red]{engine}: {results[engine]['errors']} agents hit engine errors; not comparing[/]")
431
+ return
432
+ a, b = results["stock"], results["locals"]
433
+ tab = Table(title=f"{args.agents} agents on {args.model}")
434
+ tab.add_column("")
435
+ tab.add_column("stock (mlx-lm + llguidance)", justify="right")
436
+ tab.add_column("locals", justify="right")
437
+ rows = [("wall time", f"{a['wall_s']:.1f} s", f"{b['wall_s']:.1f} s"),
438
+ ("tokens generated", str(a["tokens_out"]), str(b["tokens_out"])),
439
+ ("throughput", f"{a['tok_per_s']:.1f} tok/s", f"{b['tok_per_s']:.1f} tok/s"),
440
+ ("agent steps", str(a["steps"]), str(b["steps"])),
441
+ ("finished", f"{a['finished']}/{a['agents']}", f"{b['finished']}/{b['agents']}")]
442
+ if a["checked"]:
443
+ rows.append(("checks passed", f"{a['passed']}/{a['checked']}", f"{b['passed']}/{b['checked']}"))
444
+ rows.append(("prompt cache hit", f"{a['cached_tokens'] / max(1, a['prompt_tokens']):.0%}",
445
+ f"{b['cached_tokens'] / max(1, b['prompt_tokens']):.0%}"))
446
+ for r in rows:
447
+ tab.add_row(*r)
448
+ tab.caption = f"locals: {a['wall_s'] / b['wall_s']:.2f}x less wall time, {b['tok_per_s'] / a['tok_per_s']:.2f}x the throughput"
449
+ console.print(tab)
450
+ if args.summary_json == "-":
451
+ print(json.dumps(results))
452
+ elif args.summary_json:
453
+ Path(args.summary_json).write_text(json.dumps(results, indent=1))
454
+
455
+
456
+ def main(argv=None):
457
+ ap = argparse.ArgumentParser(prog="locals agents", description="Run many tool-using agents in parallel on one local model.")
458
+ ap.add_argument("--engine", choices=["locals", "stock", "both"], default="locals")
459
+ ap.add_argument("--model", default=DEFAULT_MODEL, type=model_id, help=MODEL_HELP)
460
+ ap.add_argument("--agents", type=int, default=8)
461
+ ap.add_argument("--task", default=None, help="one task for every agent (default: the built-in suite)")
462
+ ap.add_argument("--max-steps", type=int, default=8)
463
+ ap.add_argument("--max-tokens", type=int, default=700, help="per model call")
464
+ ap.add_argument("--slots", type=int, default=16, help="maximum batch size")
465
+ ap.add_argument("--temp", type=float, default=0.3)
466
+ ap.add_argument("--seed", type=int, default=0)
467
+ ap.add_argument("--out", default="agent_runs")
468
+ ap.add_argument("--no-exec", action="store_true", help="disable run_python")
469
+ ap.add_argument("--quiet", action="store_true", help="no live dashboard")
470
+ ap.add_argument("--summary-json", default=None, help="write the summary as JSON ('-' for stdout)")
471
+ args = ap.parse_args(argv)
472
+ if not args.no_exec:
473
+ print("locals agents: the agents' run_python and run_file tools run the code the model writes, as you, in "
474
+ f"{args.out}/ (--no-exec turns them off)", file=sys.stderr, flush=True)
475
+ if args.engine == "both":
476
+ return compare(args, argv)
477
+ summary = run(args)
478
+ if args.summary_json == "-":
479
+ print(json.dumps({k: v for k, v in summary.items() if k != "agents_detail"}))
480
+ elif args.summary_json:
481
+ Path(args.summary_json).write_text(json.dumps(summary, indent=1))
482
+
483
+
484
+ if __name__ == "__main__":
485
+ main()