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.
- locals/__init__.py +69 -0
- locals/agents.py +485 -0
- locals/api.py +333 -0
- locals/apis.py +483 -0
- locals/app.py +2505 -0
- locals/attention.py +224 -0
- locals/bench.py +112 -0
- locals/blocks.py +485 -0
- locals/budget.py +111 -0
- locals/chat.py +227 -0
- locals/cli.py +84 -0
- locals/client.py +297 -0
- locals/constraint.py +165 -0
- locals/core.py +430 -0
- locals/defaults.py +274 -0
- locals/engine.py +1300 -0
- locals/fields.py +48 -0
- locals/forced.py +249 -0
- locals/formats.py +969 -0
- locals/fuse.py +339 -0
- locals/gdn_kernel.py +205 -0
- locals/grammar.py +594 -0
- locals/harness.py +1239 -0
- locals/hybrid_batch.py +1879 -0
- locals/kernels.py +1441 -0
- locals/lazy.py +224 -0
- locals/llg.py +24 -0
- locals/lm.py +75 -0
- locals/lookup.py +167 -0
- locals/menubar.py +100 -0
- locals/mixed.py +69 -0
- locals/mma_kernel.py +171 -0
- locals/nax_kernel.py +471 -0
- locals/norm_kernel.py +82 -0
- locals/oai.py +811 -0
- locals/packed.py +495 -0
- locals/pda.py +558 -0
- locals/pdactl.py +338 -0
- locals/plan.py +1146 -0
- locals/presets/tune_v6_applegpu_g14s.json +1 -0
- locals/presets/tune_v6_applegpu_g17s.json +1 -0
- locals/qmv_frag.py +344 -0
- locals/qmv_multi.py +248 -0
- locals/regex.py +585 -0
- locals/regions.py +126 -0
- locals/runtime.py +499 -0
- locals/server.py +2300 -0
- locals/service.py +680 -0
- locals/siblings.py +208 -0
- locals/solve.py +909 -0
- locals/tree.py +211 -0
- locals/treedec.py +133 -0
- locals/vocab.py +172 -0
- locals/walk.py +219 -0
- locals-0.2.0.dist-info/METADATA +534 -0
- locals-0.2.0.dist-info/RECORD +61 -0
- locals-0.2.0.dist-info/WHEEL +5 -0
- locals-0.2.0.dist-info/entry_points.txt +4 -0
- locals-0.2.0.dist-info/licenses/LICENSE +21 -0
- locals-0.2.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +76 -0
- 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()
|