agents-city 0.5.11 → 0.5.12
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/benchmarks/committee/run.py +2 -2
- package/benchmarks/latency/live.py +8 -4
- package/benchmarks/stress/run.py +10 -5
- package/bin/hall +1 -0
- package/bin/test-contracts.py +36 -3
- package/bin/test-navegador.py +59 -1
- package/city/oven/collect.py +11 -4
- package/city/web/sella.py +4 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
|
@@ -34,12 +34,12 @@ def table(rows):
|
|
|
34
34
|
widths = [max(len(label), *(len(str(row[key])) for row in rows))
|
|
35
35
|
for label, key in columns]
|
|
36
36
|
lines = [
|
|
37
|
-
' '.join(label.ljust(width) for (label, _), width in zip(columns, widths)),
|
|
37
|
+
' '.join(label.ljust(width) for (label, _), width in zip(columns, widths, strict=False)),
|
|
38
38
|
' '.join('-' * width for width in widths),
|
|
39
39
|
]
|
|
40
40
|
for row in rows:
|
|
41
41
|
lines.append(' '.join(str(row[key]).ljust(width)
|
|
42
|
-
for (_, key), width in zip(columns, widths)))
|
|
42
|
+
for (_, key), width in zip(columns, widths, strict=False)))
|
|
43
43
|
return '\n'.join(lines)
|
|
44
44
|
|
|
45
45
|
|
|
@@ -42,7 +42,8 @@ def parse_args():
|
|
|
42
42
|
parser.add_argument('--timeout', type=int, default=180,
|
|
43
43
|
help='seconds to wait for model positions (default: 180)')
|
|
44
44
|
parser.add_argument('--json', action='store_true', help='print only the JSON report')
|
|
45
|
-
parser.add_argument('--no-save', action='store_true',
|
|
45
|
+
parser.add_argument('--no-save', action='store_true',
|
|
46
|
+
help='do not append the local baseline ledger')
|
|
46
47
|
parser.add_argument('--keep', action='store_true', help='keep the temporary city and logs')
|
|
47
48
|
return parser.parse_args()
|
|
48
49
|
|
|
@@ -81,7 +82,7 @@ def main():
|
|
|
81
82
|
os.symlink(os.path.join(ROOT, 'bin', 'agents-city.js'), os.path.join(tools, 'agents-city'))
|
|
82
83
|
marker = f'CITY_NATIVE_{int(time.time())}_{os.getpid()}'
|
|
83
84
|
actors = {runtime: f'{runtime}-agent' for runtime in selected}
|
|
84
|
-
for
|
|
85
|
+
for _runtime, actor in actors.items():
|
|
85
86
|
repo = os.path.join(repos, actor)
|
|
86
87
|
os.makedirs(repo)
|
|
87
88
|
with open(os.path.join(repo, 'README.md'), 'w', encoding='utf-8') as stream:
|
|
@@ -227,7 +228,9 @@ def wait_ready(runtimes, actors, processes, streams, app, timeout):
|
|
|
227
228
|
for runtime in runtimes:
|
|
228
229
|
process = processes[runtime]
|
|
229
230
|
if process.poll() is not None:
|
|
230
|
-
raise RuntimeError(
|
|
231
|
+
raise RuntimeError(
|
|
232
|
+
f'{runtime} exited during startup: '
|
|
233
|
+
f'{tail(read_stream(streams[runtime]), 12)}')
|
|
231
234
|
ready = ready and os.path.exists(
|
|
232
235
|
os.path.join(status_dir, f'{actors[runtime]}.json'))
|
|
233
236
|
if ready:
|
|
@@ -256,7 +259,8 @@ def report_for(results, unavailable, thread, duration):
|
|
|
256
259
|
return {
|
|
257
260
|
'schema': 'agents-city/native-e2e-latency@1',
|
|
258
261
|
'runAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
259
|
-
'scope': 'live task completion through bus, native provider protocol
|
|
262
|
+
'scope': ('live task completion through bus, native provider protocol '
|
|
263
|
+
'and committee position'),
|
|
260
264
|
'thread': thread,
|
|
261
265
|
'durationMs': duration,
|
|
262
266
|
'results': results + unavailable,
|
package/benchmarks/stress/run.py
CHANGED
|
@@ -140,7 +140,8 @@ def run(args):
|
|
|
140
140
|
raise RuntimeError('a Codex gateway exited during startup')
|
|
141
141
|
startup_hubs = hub_pids(city)
|
|
142
142
|
if len(startup_hubs) != 1:
|
|
143
|
-
raise RuntimeError(
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
f'concurrent startup created {len(startup_hubs)} hubs: {startup_hubs}')
|
|
144
145
|
report['startupMs'] = round((time.monotonic() - started) * 1000)
|
|
145
146
|
report['startupHubProcesses'] = len(startup_hubs)
|
|
146
147
|
|
|
@@ -156,8 +157,11 @@ def run(args):
|
|
|
156
157
|
# recovery, not a stale endpoint. What must disappear is the
|
|
157
158
|
# stopped PID's ownership; the endpoint may validly already name
|
|
158
159
|
# the one new hub.
|
|
160
|
+
# Bound as a default: the lambda is called in this same
|
|
161
|
+
# iteration, so late binding does not bite — but correct by
|
|
162
|
+
# accident is not correct.
|
|
159
163
|
if not wait_for(
|
|
160
|
-
lambda: endpoint_released_or_replaced(endpoint_path,
|
|
164
|
+
lambda pid=stopped_pid: endpoint_released_or_replaced(endpoint_path, pid), 4
|
|
161
165
|
):
|
|
162
166
|
raise RuntimeError('the stopped hub still owns the endpoint')
|
|
163
167
|
round_started = time.monotonic()
|
|
@@ -178,8 +182,8 @@ def run(args):
|
|
|
178
182
|
f'round {round_number} spectator feed did not represent all '
|
|
179
183
|
f'{args.agents} selected actors exactly once')
|
|
180
184
|
if not wait_for(
|
|
181
|
-
lambda: round_complete(
|
|
182
|
-
|
|
185
|
+
lambda t=thread: round_complete(t, claude_capture, codex_capture,
|
|
186
|
+
metrics_path, args.agents // 2, args.agents),
|
|
183
187
|
args.timeout,
|
|
184
188
|
):
|
|
185
189
|
raise RuntimeError(
|
|
@@ -197,7 +201,8 @@ def run(args):
|
|
|
197
201
|
f'Claude={claude_count}, Codex={codex_count}')
|
|
198
202
|
if len(metrics) != args.agents:
|
|
199
203
|
raise RuntimeError(
|
|
200
|
-
f'round {round_number} recorded {len(metrics)} acceptances,
|
|
204
|
+
f'round {round_number} recorded {len(metrics)} acceptances, '
|
|
205
|
+
f'expected {args.agents}')
|
|
201
206
|
latencies = sorted(int(row.get('totalToNativeAcceptMs', 0)) for row in metrics)
|
|
202
207
|
round_report = {
|
|
203
208
|
'round': round_number,
|
package/bin/hall
CHANGED
|
@@ -28,6 +28,7 @@ sys.path.insert(0, AQUI)
|
|
|
28
28
|
sys.path.insert(0, os.path.join(os.path.dirname(AQUI), 'plugin', 'scripts'))
|
|
29
29
|
import serve # noqa: E402
|
|
30
30
|
import cities # noqa: E402
|
|
31
|
+
import runtime_processes # noqa: E402 how a child is cut loose from a terminal
|
|
31
32
|
|
|
32
33
|
def asegura_build():
|
|
33
34
|
"""hall.js, built if missing or older than its source. Same npm the map already
|
package/bin/test-contracts.py
CHANGED
|
@@ -358,7 +358,40 @@ def la_puerta_de_windows():
|
|
|
358
358
|
afirma('· and the town hall is not one of them',
|
|
359
359
|
'hall' not in conShell, str(conShell))
|
|
360
360
|
|
|
361
|
-
# 3. Every file this
|
|
361
|
+
# 3. Every Python file this repo ships is one the linter actually reads.
|
|
362
|
+
#
|
|
363
|
+
# `bin/hall` is Python with no `.py` on it, and ruff selects by a glob on
|
|
364
|
+
# the extension — so the town hall was never linted, and shipped a call to
|
|
365
|
+
# a module it had forgotten to import. `F821 Undefined name` would have
|
|
366
|
+
# caught it before a tag did.
|
|
367
|
+
#
|
|
368
|
+
# Third time in this repo that a tool chose files by their NAME instead of
|
|
369
|
+
# by what they are: shellcheck's glob broke when a folder emptied and had
|
|
370
|
+
# been missing seven scripts; the front door called the town hall a shell
|
|
371
|
+
# program because it is long; and this.
|
|
372
|
+
import fnmatch # noqa: PLC0415
|
|
373
|
+
import tomllib # noqa: PLC0415
|
|
374
|
+
|
|
375
|
+
incluidos = tomllib.load(open(os.path.join(RAIZ, 'pyproject.toml'), 'rb'))
|
|
376
|
+
patrones = incluidos['tool']['ruff']['include']
|
|
377
|
+
seguidos = subprocess.run(['git', 'ls-files'], cwd=RAIZ, capture_output=True,
|
|
378
|
+
text=True).stdout.split()
|
|
379
|
+
fuera = []
|
|
380
|
+
for rel in seguidos:
|
|
381
|
+
ruta = os.path.join(RAIZ, rel)
|
|
382
|
+
if not os.path.isfile(ruta) or rel.startswith('bin/test'):
|
|
383
|
+
continue
|
|
384
|
+
if not rel.endswith('.py'):
|
|
385
|
+
with open(ruta, 'rb') as f:
|
|
386
|
+
cabeza = f.readline()
|
|
387
|
+
if not (cabeza.startswith(b'#!') and b'python' in cabeza.lower()):
|
|
388
|
+
continue
|
|
389
|
+
if not any(fnmatch.fnmatch(rel, p) for p in patrones):
|
|
390
|
+
fuera.append(rel)
|
|
391
|
+
afirma('· every Python file this repo ships is one the linter reads',
|
|
392
|
+
not fuera, 'not covered by ruff include: ' + ', '.join(fuera))
|
|
393
|
+
|
|
394
|
+
# 4. Every file this product reads or writes is UTF-8, said out loud. A
|
|
362
395
|
# bare `open()` takes the console's code page on Windows, which is how a
|
|
363
396
|
# card with an em dash was written in cp1252 and read back as UTF-8 by
|
|
364
397
|
# the next door along — the first thing a Windows runner found.
|
|
@@ -380,14 +413,14 @@ def la_puerta_de_windows():
|
|
|
380
413
|
not sinDecir, 'a bare open() takes the console code page: '
|
|
381
414
|
+ ', '.join(sinDecir[:8]))
|
|
382
415
|
|
|
383
|
-
#
|
|
416
|
+
# 5. The launcher every window runs. Written in the language of the machine
|
|
384
417
|
# it will run on — and what it carries is a plan, not a shell sentence.
|
|
385
418
|
lanzador = open(os.path.join(RAIZ, 'plugin', 'scripts', 'launch.py'),
|
|
386
419
|
encoding='utf-8').read()
|
|
387
420
|
afirma('· the launcher is written for the machine it will run on',
|
|
388
421
|
"'#!/usr/bin/env bash'" in lanzador and '@echo off' in lanzador, '')
|
|
389
422
|
|
|
390
|
-
#
|
|
423
|
+
# 6. And the promise on the tin matches all of that. Windows is claimed
|
|
391
424
|
# because a machine says so, not because the code looks portable: the
|
|
392
425
|
# runner opens a city there every time this is pushed.
|
|
393
426
|
afirma('· package.json says where this runs, and a runner agrees',
|
package/bin/test-navegador.py
CHANGED
|
@@ -31,7 +31,7 @@ sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
|
|
|
31
31
|
sys.path.insert(0, AQUI)
|
|
32
32
|
|
|
33
33
|
import serve # noqa: E402
|
|
34
|
-
from testlib import afirma, resumen # noqa: E402
|
|
34
|
+
from testlib import afirma, detiene_hubs_de_ciudad, resumen # noqa: E402
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
def disco_de_prueba():
|
|
@@ -145,6 +145,63 @@ def bundle_al_dia():
|
|
|
145
145
|
return "rebuilt from src/hall.ts"
|
|
146
146
|
|
|
147
147
|
|
|
148
|
+
def la_puerta_que_abre_el_ayuntamiento():
|
|
149
|
+
"""`bin/hall`, run as a person runs it — detached, and answering.
|
|
150
|
+
|
|
151
|
+
This is the check that was missing. The hall shipped calling a module it had
|
|
152
|
+
forgotten to import, and nothing here noticed, because every suite started
|
|
153
|
+
`serve.py` directly and none of them ever came through the door. A
|
|
154
|
+
`NameError` in `despega` is only reachable by launching one.
|
|
155
|
+
|
|
156
|
+
The linter would have caught it too — and now does, because `bin/hall` is
|
|
157
|
+
Python with no `.py` on it and ruff selects by a glob on the extension. Two
|
|
158
|
+
different holes, one bug through both.
|
|
159
|
+
"""
|
|
160
|
+
print(" the door that opens the town hall")
|
|
161
|
+
casa = tempfile.mkdtemp(prefix="agents-city-hall-")
|
|
162
|
+
datos = os.path.join(casa, "app", "navtest", "home")
|
|
163
|
+
os.makedirs(datos)
|
|
164
|
+
ciudad_de_prueba(datos)
|
|
165
|
+
entorno = dict(os.environ, HOME=casa, AGENTS_CITY_HOME=os.path.join(casa, "app"),
|
|
166
|
+
AGENTS_CITY_DATA=datos, AGENTS_CITY_USER="navtest",
|
|
167
|
+
CITY_DIR=os.path.join(casa, "canal"))
|
|
168
|
+
entorno.pop("CITY_BUS_URL", None)
|
|
169
|
+
salida = ""
|
|
170
|
+
try:
|
|
171
|
+
r = subprocess.run([sys.executable, os.path.join(RAIZ, "bin", "hall"),
|
|
172
|
+
"--no-browser"], capture_output=True, text=True,
|
|
173
|
+
timeout=90, env=entorno, cwd=casa)
|
|
174
|
+
salida = r.stdout + r.stderr
|
|
175
|
+
afirma("· happy: the door starts a hall and hands back an address",
|
|
176
|
+
"http://127.0.0.1:" in salida and "Traceback" not in salida,
|
|
177
|
+
salida[-500:])
|
|
178
|
+
direccion = ""
|
|
179
|
+
for trozo in salida.split():
|
|
180
|
+
if trozo.startswith("http://127.0.0.1:"):
|
|
181
|
+
direccion = trozo
|
|
182
|
+
break
|
|
183
|
+
contesta = False
|
|
184
|
+
if direccion:
|
|
185
|
+
import urllib.error # noqa: PLC0415
|
|
186
|
+
import urllib.request # noqa: PLC0415
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
with urllib.request.urlopen(direccion, timeout=5) as resp:
|
|
190
|
+
contesta = resp.status == 200
|
|
191
|
+
except (OSError, urllib.error.URLError):
|
|
192
|
+
contesta = False
|
|
193
|
+
afirma("· happy: and that address really serves the page",
|
|
194
|
+
contesta, direccion or salida[-300:])
|
|
195
|
+
afirma("· non-happy: nothing it prints is a stack trace",
|
|
196
|
+
"NameError" not in salida and "Traceback" not in salida,
|
|
197
|
+
salida[-500:])
|
|
198
|
+
finally:
|
|
199
|
+
subprocess.run([sys.executable, os.path.join(RAIZ, "plugin", "scripts", "apaga.py"),
|
|
200
|
+
datos, "--yes"], capture_output=True, timeout=60, env=entorno)
|
|
201
|
+
detiene_hubs_de_ciudad(datos)
|
|
202
|
+
shutil.rmtree(casa, ignore_errors=True)
|
|
203
|
+
|
|
204
|
+
|
|
148
205
|
def un_chrome_que_no_arranca():
|
|
149
206
|
"""What the driver says when the browser never answers.
|
|
150
207
|
|
|
@@ -181,6 +238,7 @@ def un_chrome_que_no_arranca():
|
|
|
181
238
|
def main():
|
|
182
239
|
print()
|
|
183
240
|
print(" the hall, in a browser that clicks")
|
|
241
|
+
la_puerta_que_abre_el_ayuntamiento()
|
|
184
242
|
un_chrome_que_no_arranca()
|
|
185
243
|
print(f" · {bundle_al_dia()}")
|
|
186
244
|
datos = tempfile.mkdtemp()
|
package/city/oven/collect.py
CHANGED
|
@@ -5,7 +5,10 @@ It exists because the PNGs come out of the browser in base64, and there is no
|
|
|
5
5
|
point routing them through anything in between: the browser posts them here and
|
|
6
6
|
here they are saved.
|
|
7
7
|
"""
|
|
8
|
-
import base64
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
9
12
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
10
13
|
|
|
11
14
|
DESTINO = os.path.join(os.path.dirname(__file__), '..', 'web', 'assets', 'sprites')
|
|
@@ -13,7 +16,9 @@ os.makedirs(DESTINO, exist_ok=True)
|
|
|
13
16
|
|
|
14
17
|
class Manos(BaseHTTPRequestHandler):
|
|
15
18
|
def do_OPTIONS(self):
|
|
16
|
-
self.send_response(204)
|
|
19
|
+
self.send_response(204)
|
|
20
|
+
self.cors()
|
|
21
|
+
self.end_headers()
|
|
17
22
|
def do_POST(self):
|
|
18
23
|
n = int(self.headers.get('content-length', 0))
|
|
19
24
|
cuerpo = self.rfile.read(n)
|
|
@@ -33,8 +38,10 @@ class Manos(BaseHTTPRequestHandler):
|
|
|
33
38
|
json.dump(todas, open(metas, 'w'), indent=1, sort_keys=True)
|
|
34
39
|
self.send_response(200)
|
|
35
40
|
except Exception as e:
|
|
36
|
-
print('fallo:', e)
|
|
37
|
-
|
|
41
|
+
print('fallo:', e)
|
|
42
|
+
self.send_response(500)
|
|
43
|
+
self.cors()
|
|
44
|
+
self.end_headers()
|
|
38
45
|
def cors(self):
|
|
39
46
|
self.send_header('access-control-allow-origin', '*')
|
|
40
47
|
self.send_header('access-control-allow-headers', '*')
|
package/city/web/sella.py
CHANGED
|
@@ -8,7 +8,10 @@ dist/index.html to /city.js?v=<hash of the bundle>, so a changed bundle is a
|
|
|
8
8
|
changed URL and the browser fetches it. An unchanged bundle keeps its hash and
|
|
9
9
|
stays cached, which is the whole point of the cache.
|
|
10
10
|
"""
|
|
11
|
-
import hashlib
|
|
11
|
+
import hashlib
|
|
12
|
+
import pathlib
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
12
15
|
|
|
13
16
|
dist = pathlib.Path(__file__).parent / 'dist'
|
|
14
17
|
for pagina, guion in (('index.html', 'city.js'),):
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "city",
|
|
3
3
|
"displayName": "Agents City",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.12",
|
|
5
5
|
"description": "One autonomous city seat with a work domain, role, goal, repo support agents, recognised skills and explicit roads to other cities.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "jlcases",
|