ZENLLC commited on
Commit
95788f4
·
verified ·
1 Parent(s): 05fc799

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +689 -298
app.py CHANGED
@@ -5,26 +5,28 @@ import csv
5
  import io
6
  import json
7
  import math
 
8
  import random
 
9
  import time
 
10
  from dataclasses import dataclass, field
11
  from typing import Dict, List, Any, Optional, Tuple
12
 
13
  import numpy as np
 
14
  import gradio as gr
15
 
16
  from core.kpi import compute_kpis
17
 
18
  # ============================================================
19
- # ZEN Orchestrator Arena — Visual Agent Orchestra + Business KPIs
20
- # ============================================================
21
- # What you get:
22
- # - Animated SVG environment (smooth movement)
23
- # - Optional pseudo-3D POV panel (lightweight raycaster)
24
- # - Business backlog: tasks distributed across the map as "task nodes"
25
- # - Live logs: action + thought + tokens + cost + wall + sim time
26
- # - Incidents: inject blockers, regress progress, force reroutes
27
- # - Exports: JSONL run log + CSV finance ledger
28
  # ============================================================
29
 
30
  # -----------------------------
@@ -36,6 +38,7 @@ DEFAULT_MODEL_PROFILES = {
36
  "Sim-Claude": {"in_per_1m": 3.00, "out_per_1m": 15.00, "tps": 90.0},
37
  "Sim-Gemini": {"in_per_1m": 1.50, "out_per_1m": 6.00, "tps": 200.0},
38
  "Sim-Local": {"in_per_1m": 0.20, "out_per_1m": 0.20, "tps": 300.0},
 
39
  }
40
 
41
  SIM_TIME_PRESETS = {
@@ -78,7 +81,7 @@ TILE_NAMES = {
78
  RESOURCE: "Resource",
79
  }
80
 
81
- # Palette (readable, “ops dashboard night mode”)
82
  COL_BG = "#0b1020"
83
  COL_PANEL = "#0f1733"
84
  COL_GRIDLINE = "#121a3b"
@@ -115,8 +118,15 @@ def fmt_duration(seconds: float) -> str:
115
  s = float(max(0.0, seconds))
116
  if s < 1:
117
  return f"{s:.3f}s"
118
- # pick a scale
119
- units = [("seconds", 1), ("minutes", 60), ("hours", 3600), ("days", 86400), ("weeks", 604800), ("months(30d)", 2592000), ("years(365d)", 31536000)]
 
 
 
 
 
 
 
120
  unit, scale = "seconds", 1
121
  for name, sc in units:
122
  if s >= sc:
@@ -137,8 +147,8 @@ def is_blocking(tile: int) -> bool:
137
  return tile == WALL
138
 
139
 
140
- def manhattan(a: Tuple[int, int], b: Tuple[int, int]) -> int:
141
- return abs(a[0]-b[0]) + abs(a[1]-b[1])
142
 
143
 
144
  def make_rng(seed: int) -> random.Random:
@@ -147,10 +157,6 @@ def make_rng(seed: int) -> random.Random:
147
  return r
148
 
149
 
150
- def neighbors4(x: int, y: int) -> List[Tuple[int, int]]:
151
- return [(x+1,y), (x,y+1), (x-1,y), (x,y-1)]
152
-
153
-
154
  def bfs_next_step(grid: List[List[int]], start: Tuple[int, int], goal: Tuple[int, int]) -> Optional[Tuple[int, int]]:
155
  if start == goal:
156
  return None
@@ -201,6 +207,19 @@ class Task:
201
  notes: str = ""
202
 
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  @dataclass
205
  class Agent:
206
  name: str
@@ -214,6 +233,9 @@ class Agent:
214
  fatigue: float = 0.0
215
  skill: Dict[str, float] = field(default_factory=dict)
216
  target_task_id: Optional[str] = None
 
 
 
217
 
218
 
219
  @dataclass
@@ -235,24 +257,29 @@ class World:
235
  overlay: bool = True
236
  auto_camera: bool = True
237
 
 
 
238
  model_profiles: Dict[str, Dict[str, float]] = field(default_factory=dict)
239
 
240
- # logs
 
241
  run_log: List[Dict[str, Any]] = field(default_factory=list)
242
  events: List[str] = field(default_factory=list)
243
 
244
  done: bool = False
245
 
246
 
247
- def default_agents_positions() -> Dict[str, Tuple[int,int]]:
248
- # spawn points
 
 
249
  return {
250
  "PM": (2, 2),
251
- "ENG": (GRID_W-3, 2),
252
- "DATA": (2, GRID_H-3),
253
- "OPS": (GRID_W-3, GRID_H-3),
254
- "SEC": (GRID_W//2, 2),
255
- "DES": (GRID_W//2, GRID_H-3),
256
  }
257
 
258
 
@@ -260,54 +287,92 @@ def default_agents() -> Dict[str, Agent]:
260
  sp = default_agents_positions()
261
  return {
262
  "PM": Agent("PM", "Product Manager", "Sim-GPT-4o", *sp["PM"], focus=0.80, reliability=0.90,
263
- skill={"product": 0.95, "ops": 0.70, "design": 0.55, "engineering": 0.35}),
 
264
  "ENG": Agent("ENG", "Engineer", "Sim-GPT-5", *sp["ENG"], focus=0.82, reliability=0.86,
265
- skill={"engineering": 0.95, "data": 0.70, "security": 0.55, "ops": 0.55}),
 
266
  "DATA": Agent("DATA", "Data Scientist", "Sim-Gemini", *sp["DATA"], focus=0.78, reliability=0.84,
267
- skill={"data": 0.92, "engineering": 0.55, "product": 0.45}),
 
268
  "OPS": Agent("OPS", "Ops Lead", "Sim-Claude", *sp["OPS"], focus=0.76, reliability=0.88,
269
- skill={"ops": 0.92, "security": 0.65, "product": 0.55, "engineering": 0.45}),
 
270
  "SEC": Agent("SEC", "Security", "Sim-Local", *sp["SEC"], focus=0.70, reliability=0.93,
271
- skill={"security": 0.92, "ops": 0.62, "engineering": 0.55}),
 
272
  "DES": Agent("DES", "Designer", "Sim-GPT-4o", *sp["DES"], focus=0.74, reliability=0.87,
273
- skill={"design": 0.92, "product": 0.65}),
 
274
  }
275
 
276
 
277
  # -----------------------------
278
- # Map builder (office / maze hybrid)
279
  # -----------------------------
280
- def build_office_map(seed: int) -> List[List[int]]:
281
  r = make_rng(seed)
 
 
 
282
  g = [[EMPTY for _ in range(GRID_W)] for _ in range(GRID_H)]
283
 
284
  # borders
285
  for x in range(GRID_W):
286
  g[0][x] = WALL
287
- g[GRID_H-1][x] = WALL
288
  for y in range(GRID_H):
289
  g[y][0] = WALL
290
- g[y][GRID_W-1] = WALL
291
-
292
- # add internal walls (corridors)
293
- for x in range(3, GRID_W-3):
294
- if x % 3 == 0:
295
- for y in range(2, GRID_H-2):
296
- if r.random() < 0.65:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  g[y][x] = WALL
 
 
 
 
298
 
299
- # carve some openings
300
- for _ in range(28):
301
- x = r.randint(2, GRID_W-3)
302
- y = r.randint(2, GRID_H-3)
303
- g[y][x] = EMPTY
304
-
305
- # resource patches (like “data sources / infra nodes”)
306
- for _ in range(10):
307
- x = r.randint(2, GRID_W-3)
308
- y = r.randint(2, GRID_H-3)
309
- if g[y][x] == EMPTY:
310
- g[y][x] = RESOURCE
311
 
312
  return g
313
 
@@ -325,14 +390,23 @@ def tile_color(tile: int) -> str:
325
  # -----------------------------
326
  # Backlog -> task nodes on map
327
  # -----------------------------
328
- def generate_backlog(seed: int, n: int, difficulty: float, grid: List[List[int]]) -> List[Task]:
329
  r = make_rng(seed + 999)
330
- skill_buckets = ["product", "engineering", "data", "ops", "security", "design"]
 
 
 
 
 
 
 
 
 
 
331
  verbs = ["Ship", "Audit", "Refactor", "Launch", "Scale", "Integrate", "Automate", "Harden", "Migrate", "Validate"]
332
  nouns = ["pipeline", "dashboard", "agent", "workflow", "integration", "report", "system", "KB", "API", "ledger"]
333
 
334
- # choose empty cells to place task nodes
335
- empties = [(x,y) for y in range(1, GRID_H-1) for x in range(1, GRID_W-1) if grid[y][x] == EMPTY]
336
  r.shuffle(empties)
337
  nodes = empties[: max(n, 1)]
338
 
@@ -342,8 +416,8 @@ def generate_backlog(seed: int, n: int, difficulty: float, grid: List[List[int]]
342
  title = f"{r.choice(verbs)} {r.choice(nouns)} ({skill})"
343
  complexity = clamp(r.uniform(0.25, 1.25) * (0.85 + 0.6 * difficulty), 0.15, 2.0)
344
  risk = clamp(r.uniform(0.05, 0.35) * (0.7 + 0.8 * difficulty), 0.03, 0.75)
345
-
346
  xy = nodes[i] if i < len(nodes) else (2, 2)
 
347
  tasks.append(Task(
348
  id=f"T{i+1:03d}",
349
  title=title,
@@ -356,20 +430,26 @@ def generate_backlog(seed: int, n: int, difficulty: float, grid: List[List[int]]
356
 
357
 
358
  def place_task_nodes(grid: List[List[int]], tasks: List[Task]):
 
 
 
 
 
 
359
  for t in tasks:
360
- x,y = t.node_xy
361
- if in_bounds(x,y) and grid[y][x] == EMPTY:
362
  grid[y][x] = TASKNODE
363
 
364
 
365
  # -----------------------------
366
- # Economics: tokens/cost/time
367
  # -----------------------------
368
  def agent_skill_score(a: Agent, skill: str) -> float:
369
  return float(a.skill.get(skill, 0.15))
370
 
371
 
372
- def estimate_tokens(task: Task, agent: Agent, difficulty: float, r: random.Random) -> Tuple[int,int]:
373
  base = 900 + 2200 * task.complexity * (0.8 + 0.6 * difficulty)
374
  fit = agent_skill_score(agent, task.required_skill)
375
  efficiency = clamp(1.15 - 0.55 * fit, 0.55, 1.25)
@@ -377,31 +457,29 @@ def estimate_tokens(task: Task, agent: Agent, difficulty: float, r: random.Rando
377
  total = base * efficiency * noise
378
  tin = int(total * r.uniform(0.40, 0.55))
379
  tout = int(total - tin)
380
- return max(1,tin), max(1,tout)
381
 
382
 
383
- def cost_from_tokens(model_profiles: Dict[str, Dict[str,float]], model: str, tin: int, tout: int) -> float:
384
  prof = model_profiles.get(model, {"in_per_1m": 2.0, "out_per_1m": 6.0, "tps": 100.0})
385
- return (tin/1_000_000.0)*float(prof["in_per_1m"]) + (tout/1_000_000.0)*float(prof["out_per_1m"])
386
 
387
 
388
- def wall_seconds_from_tokens(model_profiles: Dict[str, Dict[str,float]], model: str, tin: int, tout: int) -> float:
389
  prof = model_profiles.get(model, {"tps": 100.0})
390
  tps = float(prof.get("tps", 100.0))
391
- return float((tin+tout)/max(tps,1.0))
392
 
393
 
394
  # -----------------------------
395
  # Task selection & progress
396
  # -----------------------------
397
  def pick_task_for_agent(w: World, agent: Agent) -> Optional[Task]:
398
- # Keep current target if still relevant
399
  if agent.target_task_id:
400
  t = next((x for x in w.tasks if x.id == agent.target_task_id), None)
401
- if t and t.status in ("todo","doing","blocked"):
402
  return t
403
 
404
- # Prefer doing owned
405
  doing_owned = [t for t in w.tasks if t.status == "doing" and t.owner == agent.name]
406
  if doing_owned:
407
  agent.target_task_id = doing_owned[0].id
@@ -425,12 +503,12 @@ def progress_delta(task: Task, agent: Agent, difficulty: float, r: random.Random
425
  base *= (0.75 + 0.5 * agent.focus)
426
  base *= (0.95 - 0.35 * clamp(agent.fatigue, 0.0, 1.0))
427
  base *= (1.05 - 0.55 * difficulty)
428
- return clamp(base * r.uniform(0.85,1.15), 0.01, 0.28)
429
 
430
 
431
  def maybe_incident(w: World, task: Task, agent: Agent, r: random.Random) -> Optional[str]:
432
- p = task.risk * (0.6 + 0.9*w.incident_rate) * (0.75 + 0.9*(1.0-agent.reliability))
433
- p *= (0.85 + 0.5*w.difficulty)
434
  if r.random() < clamp(p, 0.01, 0.85):
435
  return r.choice([
436
  "Scope creep discovered",
@@ -448,20 +526,19 @@ def maybe_incident(w: World, task: Task, agent: Agent, r: random.Random) -> Opti
448
  # -----------------------------
449
  # Movement + environment effects
450
  # -----------------------------
451
- def move_step(w: World, a: Agent, target_xy: Tuple[int,int]):
452
- tx,ty = target_xy
453
- nxt = bfs_next_step(w.grid, (a.x,a.y), (tx,ty))
454
  if nxt is None:
455
  return
456
- nx,ny = nxt
457
- a.ori = face_towards(a.x,a.y,a.ori,nx,ny)
458
- if in_bounds(nx,ny) and not is_blocking(w.grid[ny][nx]):
459
- a.x,a.y = nx,ny
460
 
461
 
462
  def apply_env_tile_effects(w: World, a: Agent):
463
  tile = w.grid[a.y][a.x]
464
- # stepping on BLOCKER tile increases fatigue slightly (it’s “friction”)
465
  if tile == BLOCKER:
466
  a.fatigue = clamp(a.fatigue + 0.04, 0.0, 1.0)
467
  w.events.append(f"t={w.step}: {a.name} hit a blocker zone (+fatigue).")
@@ -504,7 +581,10 @@ def within_fov(ax: int, ay: int, ori: int, tx: int, ty: int, fov_deg: float = FO
504
 
505
 
506
  def raycast_pov(w: World, who: str) -> np.ndarray:
 
 
507
  a = w.agents[who]
 
508
  img = np.zeros((VIEW_H, VIEW_W, 3), dtype=np.uint8)
509
  img[:, :] = SKY
510
  for y in range(VIEW_H // 2, VIEW_H):
@@ -548,7 +628,6 @@ def raycast_pov(w: World, who: str) -> np.ndarray:
548
 
549
  depth *= math.cos(ang - base)
550
  depth = max(depth, 0.001)
551
-
552
  h = int((VIEW_H * 0.92) / depth)
553
  y0 = max(0, VIEW_H // 2 - h // 2)
554
  y1 = min(VIEW_H - 1, VIEW_H // 2 + h // 2)
@@ -564,7 +643,7 @@ def raycast_pov(w: World, who: str) -> np.ndarray:
564
  col = (col * dim).astype(np.uint8)
565
  img[y0:y1, rx:rx + 1] = col
566
 
567
- # show other agents as blocks if visible
568
  for nm, other in w.agents.items():
569
  if nm == who:
570
  continue
@@ -590,7 +669,7 @@ def raycast_pov(w: World, who: str) -> np.ndarray:
590
  y1 = int(clamp(ymid + size // 2, 0, VIEW_H - 1))
591
 
592
  hexcol = AGENT_COLORS.get(nm, "#ffd17a").lstrip("#")
593
- rgb = np.array([int(hexcol[i:i+2], 16) for i in (0,2,4)], dtype=np.uint8)
594
  img[y0:y1, x0:x1] = rgb
595
 
596
  if w.overlay:
@@ -602,16 +681,14 @@ def raycast_pov(w: World, who: str) -> np.ndarray:
602
 
603
 
604
  # -----------------------------
605
- # SVG renderer (animated, smooth)
606
  # -----------------------------
607
- def svg_render(w: World, highlight: Optional[Tuple[int,int]] = None) -> str:
608
- # HUD summary
609
  k = compute_kpis(w.run_log)
610
  done = sum(1 for t in w.tasks if t.status == "done")
611
- doing = sum(1 for t in w.tasks if t.status == "doing")
612
  blocked = sum(1 for t in w.tasks if t.status == "blocked")
613
  headline = f"ZEN Orchestrator Arena • step={w.step} • sim={fmt_duration(w.sim_elapsed_seconds)} • done={done}/{len(w.tasks)} • blocked={blocked} • cost=${k.total_cost_usd:,.2f}"
614
- detail = f"time/tick={fmt_duration(w.sim_seconds_per_tick)} • difficulty={w.difficulty:.2f} • incident_rate={w.incident_rate:.2f} • pov={w.pov_agent}"
615
 
616
  css = f"""
617
  <style>
@@ -650,7 +727,6 @@ def svg_render(w: World, highlight: Optional[Tuple[int,int]] = None) -> str:
650
  stroke: rgba(170,195,255,0.16);
651
  stroke-width: 1;
652
  }}
653
- .dead {{ opacity: 0.22; filter: none; }}
654
  .banner {{ fill: rgba(255,255,255,0.08); }}
655
  </style>
656
  """
@@ -665,7 +741,6 @@ def svg_render(w: World, highlight: Optional[Tuple[int,int]] = None) -> str:
665
  <text class="hud hudSmall" x="18" y="50" font-size="12">{detail}</text>
666
  """]
667
 
668
- # tiles
669
  for y in range(GRID_H):
670
  for x in range(GRID_W):
671
  t = w.grid[y][x]
@@ -673,23 +748,21 @@ def svg_render(w: World, highlight: Optional[Tuple[int,int]] = None) -> str:
673
  py = HUD_H + y * TILE
674
  svg.append(f'<rect class="tile" x="{px}" y="{py}" width="{TILE}" height="{TILE}" fill="{tile_color(t)}"/>')
675
 
676
- # overlay glyphs
677
  if t == TASKNODE:
678
- cx = px + TILE*0.5
679
- cy = py + TILE*0.5
680
  svg.append(f'<circle cx="{cx}" cy="{cy}" r="6" fill="rgba(0,0,0,0.35)"/>')
681
  svg.append(f'<circle cx="{cx}" cy="{cy}" r="4" fill="{COL_TASK}"/>')
682
  elif t == RESOURCE:
683
- cx = px + TILE*0.5
684
- cy = py + TILE*0.5
685
  svg.append(f'<rect x="{cx-5}" y="{cy-5}" width="10" height="10" rx="3" fill="{COL_RES}" opacity="0.95"/>')
686
  elif t == BLOCKER:
687
- cx = px + TILE*0.5
688
- cy = py + TILE*0.5
689
  svg.append(f'<line x1="{cx-6}" y1="{cy-6}" x2="{cx+6}" y2="{cy+6}" stroke="rgba(0,0,0,0.45)" stroke-width="3"/>')
690
  svg.append(f'<line x1="{cx-6}" y1="{cy+6}" x2="{cx+6}" y2="{cy-6}" stroke="rgba(0,0,0,0.45)" stroke-width="3"/>')
691
 
692
- # gridlines
693
  for x in range(GRID_W + 1):
694
  px = x * TILE
695
  svg.append(f'<line class="gridline" x1="{px}" y1="{HUD_H}" x2="{px}" y2="{SVG_H}"/>')
@@ -697,7 +770,6 @@ def svg_render(w: World, highlight: Optional[Tuple[int,int]] = None) -> str:
697
  py = HUD_H + y * TILE
698
  svg.append(f'<line class="gridline" x1="0" y1="{py}" x2="{SVG_W}" y2="{py}"/>')
699
 
700
- # highlight
701
  if highlight:
702
  hx, hy = highlight
703
  if in_bounds(hx, hy):
@@ -718,16 +790,18 @@ def svg_render(w: World, highlight: Optional[Tuple[int,int]] = None) -> str:
718
  """)
719
 
720
  dx, dy = DIRS[a.ori]
721
- x2 = TILE/2 + dx*(TILE*0.32)
722
- y2 = TILE/2 + dy*(TILE*0.32)
723
  svg.append(f'<line x1="{TILE/2}" y1="{TILE/2}" x2="{x2}" y2="{y2}" stroke="rgba(10,10,14,0.85)" stroke-width="4" stroke-linecap="round"/>')
724
 
725
- badge_w = max(46, 10 * len(nm) * 0.62)
 
726
  svg.append(f'<rect class="badge" x="{TILE/2 - badge_w/2}" y="{TILE*0.05}" rx="10" width="{badge_w}" height="16"/>')
727
- svg.append(f'<text x="{TILE/2}" y="{TILE*0.05 + 12}" text-anchor="middle" font-size="10" fill="rgba(235,240,255,0.92)" font-family="ui-sans-serif, system-ui">{nm}</text>')
728
 
729
- # tiny task indicator
730
- if a.target_task_id:
 
731
  svg.append(f'<circle cx="{TILE*0.86}" cy="{TILE*0.18}" r="5" fill="rgba(110,180,255,0.95)"/>')
732
 
733
  svg.append("</g>")
@@ -737,7 +811,7 @@ def svg_render(w: World, highlight: Optional[Tuple[int,int]] = None) -> str:
737
 
738
 
739
  # -----------------------------
740
- # Business render (text panels)
741
  # -----------------------------
742
  def status_summary(w: World) -> str:
743
  k = compute_kpis(w.run_log)
@@ -748,7 +822,7 @@ def status_summary(w: World) -> str:
748
  return (
749
  f"step={w.step} | sim_elapsed={fmt_duration(w.sim_elapsed_seconds)} | tick={fmt_duration(w.sim_seconds_per_tick)}\n"
750
  f"tasks: done={done}/{len(w.tasks)} | doing={doing} | blocked={blocked} | todo={todo}\n"
751
- f"ledger: actions={k.total_actions} | cost=${k.total_cost_usd:,.2f} | in={k.total_tokens_in:,} | out={k.total_tokens_out:,}"
752
  )
753
 
754
 
@@ -773,22 +847,22 @@ def kpi_text(w: World) -> str:
773
 
774
 
775
  def agents_table(w: World) -> str:
776
- cols = ["name", "role", "model", "fatigue", "target"]
777
  rows = [cols]
778
  for a in w.agents.values():
779
- rows.append([a.name, a.role, a.model, f"{a.fatigue:.2f}", a.target_task_id or "-"])
780
  widths = [max(len(str(r[i])) for r in rows) for i in range(len(cols))]
781
  out = []
782
  for i, r in enumerate(rows):
783
  out.append(" | ".join(str(r[j]).ljust(widths[j]) for j in range(len(cols))))
784
  if i == 0:
785
- out.append("-+-".join("-"*w for w in widths))
786
  return "\n".join(out)
787
 
788
 
789
  def tasks_table(w: World, limit: int = 24) -> str:
790
  order = {"blocked": 0, "doing": 1, "todo": 2, "done": 3}
791
- ts = sorted(w.tasks, key=lambda t: (order.get(t.status,9), t.id))
792
  cols = ["id", "status", "owner", "progress", "skill", "xy", "title"]
793
  rows = [cols]
794
  for t in ts[:limit]:
@@ -796,7 +870,7 @@ def tasks_table(w: World, limit: int = 24) -> str:
796
  t.id,
797
  t.status,
798
  t.owner or "-",
799
- f"{t.progress*100:5.1f}%",
800
  t.required_skill,
801
  f"({t.node_xy[0]},{t.node_xy[1]})",
802
  (t.title[:55] + "…") if len(t.title) > 56 else t.title,
@@ -806,55 +880,175 @@ def tasks_table(w: World, limit: int = 24) -> str:
806
  for i, r in enumerate(rows):
807
  out.append(" | ".join(str(r[j]).ljust(widths[j]) for j in range(len(cols))))
808
  if i == 0:
809
- out.append("-+-".join("-"*w for w in widths))
810
  return "\n".join(out)
811
 
812
 
813
  # -----------------------------
814
- # Exports
815
  # -----------------------------
816
- def export_jsonl(w: World) -> Tuple[str, bytes]:
817
- buf = io.StringIO()
818
- for e in w.run_log:
819
- buf.write(json.dumps(e, ensure_ascii=False) + "\n")
820
- return "zen_orchestrator_runlog.jsonl", buf.getvalue().encode("utf-8")
821
 
822
 
823
- def export_csv(w: World) -> Tuple[str, bytes]:
824
  if not w.run_log:
825
- return "zen_orchestrator_ledger.csv", b""
826
- fields = ["t","agent","role","model","action","task_id","task_title","tokens_in","tokens_out","cost_usd","wall_seconds","sim_seconds","difficulty"]
827
- buf = io.StringIO()
828
- wri = csv.DictWriter(buf, fieldnames=fields)
829
- wri.writeheader()
830
- for e in w.run_log:
831
- wri.writerow({k: e.get(k) for k in fields})
832
- return "zen_orchestrator_ledger.csv", buf.getvalue().encode("utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
833
 
834
 
835
  # -----------------------------
836
- # Core sim tick (movement + work)
837
  # -----------------------------
838
  def tick(w: World, r: random.Random):
839
  if w.done:
840
  return
841
 
842
- # Stop condition
843
  if all(t.status == "done" for t in w.tasks):
844
  w.done = True
845
  w.events.append(f"t={w.step}: DONE — all tasks completed.")
846
  return
847
 
848
- t0 = time.perf_counter()
849
-
850
- # Each agent: move toward task node; if on node, work / unblock / incident
851
- for name, a in w.agents.items():
852
  task = pick_task_for_agent(w, a)
853
 
854
  if task is None:
855
- # idle
856
  w.run_log.append({
857
- "t": w.step, "agent": name, "role": a.role, "model": a.model,
858
  "action": "idle",
859
  "thought": "No task available; monitoring and waiting.",
860
  "task_id": None, "task_title": None,
@@ -866,23 +1060,45 @@ def tick(w: World, r: random.Random):
866
  })
867
  continue
868
 
869
- # Start
870
  if task.status == "todo":
871
  task.status = "doing"
872
  task.owner = name
873
  w.events.append(f"t={w.step}: {name} started {task.id} — {task.title}")
874
 
875
- # Move toward the task node
876
  tx, ty = task.node_xy
 
 
 
 
 
 
 
 
 
 
 
877
  if (a.x, a.y) != (tx, ty):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
878
  move_step(w, a, (tx, ty))
879
  apply_env_tile_effects(w, a)
880
-
881
- # movement log (cheap)
882
  w.run_log.append({
883
- "t": w.step, "agent": name, "role": a.role, "model": a.model,
884
  "action": "move",
885
- "thought": f"Navigating toward {task.id} node at ({tx},{ty}).",
886
  "task_id": task.id, "task_title": task.title,
887
  "tokens_in": 10, "tokens_out": 5,
888
  "cost_usd": cost_from_tokens(w.model_profiles, a.model, 10, 5),
@@ -892,61 +1108,66 @@ def tick(w: World, r: random.Random):
892
  })
893
  continue
894
 
895
- # On node: do work
896
  incident = maybe_incident(w, task, a, r)
 
 
897
  tin, tout = estimate_tokens(task, a, w.difficulty, r)
898
  wall_s = wall_seconds_from_tokens(w.model_profiles, a.model, tin, tout)
899
  cost = cost_from_tokens(w.model_profiles, a.model, tin, tout)
900
 
901
- if task.status == "blocked":
902
- # unblock attempt
903
- unblock = clamp(0.03 + 0.10*agent_skill_score(a, task.required_skill), 0.02, 0.18) * r.uniform(0.85, 1.1)
 
 
 
904
  task.progress = clamp(task.progress + unblock, 0.0, 1.0)
905
  if task.progress >= 0.35:
906
  task.status = "doing"
907
  task.notes = ""
908
- # clear blocker tile visually
909
- x,y = task.node_xy
910
  if w.grid[y][x] == BLOCKER:
911
  w.grid[y][x] = TASKNODE
912
  w.events.append(f"t={w.step}: {name} unblocked {task.id}")
913
 
914
  w.run_log.append({
915
- "t": w.step, "agent": name, "role": a.role, "model": a.model,
916
  "action": "unblock",
917
- "thought": f"Resolving blocker on {task.id}: clarify deps, reduce ambiguity, reroute plan.",
918
  "task_id": task.id, "task_title": task.title,
919
  "tokens_in": tin, "tokens_out": tout,
920
  "cost_usd": cost,
921
  "wall_seconds": wall_s,
922
  "sim_seconds": w.sim_seconds_per_tick,
923
  "difficulty": w.difficulty,
 
924
  })
925
  a.fatigue = clamp(a.fatigue + 0.03, 0.0, 1.0)
926
  continue
927
 
928
  if incident is not None:
929
- regress = clamp(r.uniform(0.04, 0.16) * (0.7 + 0.8*w.difficulty), 0.02, 0.22)
930
  task.progress = clamp(task.progress - regress, 0.0, 1.0)
931
  if r.random() < 0.50:
932
  task.status = "blocked"
933
  task.notes = incident
934
- # mark blocker visually at node
935
- x,y = task.node_xy
936
  w.grid[y][x] = BLOCKER
937
 
938
  w.events.append(f"t={w.step}: INCIDENT on {task.id} ({name}) — {incident}")
939
  w.run_log.append({
940
- "t": w.step, "agent": name, "role": a.role, "model": a.model,
941
  "action": "incident_response",
942
- "thought": f"Incident '{incident}' detected. Triaging, mitigating, and adjusting plan.",
943
  "task_id": task.id, "task_title": task.title,
944
- "tokens_in": int(tin*1.05), "tokens_out": int(tout*1.05),
945
- "cost_usd": float(cost*1.08),
946
- "wall_seconds": float(wall_s*1.10),
947
  "sim_seconds": w.sim_seconds_per_tick,
948
  "difficulty": w.difficulty,
949
  "incident": incident,
 
950
  })
951
  a.fatigue = clamp(a.fatigue + 0.06, 0.0, 1.0)
952
  continue
@@ -957,14 +1178,13 @@ def tick(w: World, r: random.Random):
957
  if task.progress >= 1.0:
958
  task.status = "done"
959
  w.events.append(f"t={w.step}: ✅ {task.id} completed by {name}")
960
- # remove node visually after completion for “clearing the board”
961
- x,y = task.node_xy
962
  w.grid[y][x] = EMPTY
963
 
964
  w.run_log.append({
965
- "t": w.step, "agent": name, "role": a.role, "model": a.model,
966
  "action": "work",
967
- "thought": f"Advancing {task.id}: execute, validate, document.",
968
  "task_id": task.id, "task_title": task.title,
969
  "tokens_in": tin, "tokens_out": tout,
970
  "cost_usd": cost,
@@ -973,46 +1193,37 @@ def tick(w: World, r: random.Random):
973
  "difficulty": w.difficulty,
974
  "task_progress": task.progress,
975
  })
976
- a.fatigue = clamp(a.fatigue + 0.02*(0.7 + 0.6*w.difficulty), 0.0, 1.0)
977
 
978
  # camera cuts
979
- if w.auto_camera:
980
- # choose “most interesting” = highest fatigue or currently blocked task owner
981
  best, best_score = w.pov_agent, -1e9
982
  blocked_owners = set(t.owner for t in w.tasks if t.status == "blocked" and t.owner)
983
  for nm, a in w.agents.items():
984
- score = 0.0
985
- score += a.fatigue * 10.0
986
- if nm in blocked_owners:
987
- score += 4.0
988
  if score > best_score:
989
  best, best_score = nm, score
990
  w.pov_agent = best
991
 
992
- # Recovery drift
993
  for a in w.agents.values():
994
  a.fatigue = clamp(a.fatigue - 0.01, 0.0, 1.0)
995
 
996
- # advance time
997
  w.sim_elapsed_seconds += w.sim_seconds_per_tick
998
  w.step += 1
999
 
1000
- # event cadence
1001
- wall_total = time.perf_counter() - t0
1002
- if w.step % 10 == 0:
1003
- w.events.append(f"t={w.step}: tick wall={wall_total:.3f}s | sim_elapsed={fmt_duration(w.sim_elapsed_seconds)}")
1004
-
1005
- if len(w.events) > 260:
1006
- w.events = w.events[-260:]
1007
 
1008
 
1009
  # -----------------------------
1010
  # Init world
1011
  # -----------------------------
1012
- def init_world(seed: int, sim_seconds_per_tick: float, difficulty: float, incident_rate: float, max_parallel: int, backlog_size: int) -> World:
1013
- grid = build_office_map(seed)
 
1014
  agents = default_agents()
1015
- tasks = generate_backlog(seed, backlog_size, difficulty, grid)
1016
  place_task_nodes(grid, tasks)
1017
 
1018
  w = World(
@@ -1029,147 +1240,234 @@ def init_world(seed: int, sim_seconds_per_tick: float, difficulty: float, incide
1029
  pov_agent="ENG",
1030
  overlay=True,
1031
  auto_camera=True,
 
1032
  model_profiles=json.loads(json.dumps(DEFAULT_MODEL_PROFILES)),
 
 
 
 
1033
  events=[f"Initialized: seed={seed} | time/tick={fmt_duration(sim_seconds_per_tick)} | difficulty={difficulty:.2f}"],
1034
  )
1035
  return w
1036
 
1037
 
1038
  # -----------------------------
1039
- # UI update + actions
1040
  # -----------------------------
1041
- def ui_refresh(w: World, highlight: Optional[Tuple[int,int]] = None):
1042
  arena = svg_render(w, highlight)
1043
  pov = raycast_pov(w, w.pov_agent)
1044
  status = status_summary(w)
1045
  agents_txt = agents_table(w)
1046
  tasks_txt = tasks_table(w)
1047
- events_txt = "\n".join(w.events[-24:])
1048
  kpis_txt = kpi_text(w)
1049
- return arena, pov, status, agents_txt, tasks_txt, events_txt, kpis_txt
 
1050
 
1051
 
1052
- def ui_reset(seed: int, sim_preset: str, difficulty: float, incident_rate: float, max_parallel: int, backlog_size: int):
1053
- sim_seconds = SIM_TIME_PRESETS.get(sim_preset, 7*24*3600)
1054
- w = init_world(int(seed), float(sim_seconds), float(difficulty), float(incident_rate), int(max_parallel), int(backlog_size))
1055
- return (*ui_refresh(w, None), w, None)
 
 
 
 
 
1056
 
1057
 
1058
- def ui_run(w: World, highlight, n: int):
1059
- r = make_rng(w.seed + w.step*31)
1060
  for _ in range(max(1, int(n))):
1061
  if w.done:
1062
  break
1063
  tick(w, r)
1064
- return (*ui_refresh(w, highlight), w, highlight)
1065
 
1066
 
1067
- def ui_inject_incident(w: World, highlight, task_id: str, note: str):
1068
  t = next((x for x in w.tasks if x.id == task_id.strip()), None)
1069
  if not t:
1070
  w.events.append(f"t={w.step}: inject failed — {task_id} not found.")
1071
- return (*ui_refresh(w, highlight), w, highlight)
1072
 
1073
  t.status = "blocked"
1074
  t.notes = note.strip() or "Injected incident"
1075
- x,y = t.node_xy
1076
  if w.grid[y][x] in (TASKNODE, EMPTY):
1077
  w.grid[y][x] = BLOCKER
1078
 
1079
  w.events.append(f"t={w.step}: 🔥 INJECTED INCIDENT on {t.id} — {t.notes}")
1080
  w.run_log.append({
1081
- "t": w.step, "agent": "SYSTEM", "role": "Simulator", "model": "n/a",
1082
  "action": "inject_incident",
1083
  "thought": "User injected an incident to stress-test orchestration.",
1084
  "task_id": t.id, "task_title": t.title,
1085
  "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "wall_seconds": 0.0,
1086
  "sim_seconds": 0.0, "difficulty": w.difficulty,
1087
  "incident": t.notes,
 
1088
  })
1089
- # highlight the node
1090
  highlight = t.node_xy
1091
- return (*ui_refresh(w, highlight), w, highlight)
1092
 
1093
 
1094
- def ui_set_overlay(w: World, highlight, v: bool):
1095
  w.overlay = bool(v)
1096
- return (*ui_refresh(w, highlight), w, highlight)
1097
 
1098
 
1099
- def ui_set_autocam(w: World, highlight, v: bool):
1100
  w.auto_camera = bool(v)
1101
  w.events.append(f"t={w.step}: auto_camera={w.auto_camera}")
1102
- return (*ui_refresh(w, highlight), w, highlight)
1103
 
1104
 
1105
- def ui_set_pov(w: World, highlight, who: str):
1106
  if who in w.agents:
1107
  w.pov_agent = who
1108
  w.events.append(f"t={w.step}: POV -> {who}")
1109
- return (*ui_refresh(w, highlight), w, highlight)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1110
 
 
 
1111
 
1112
- def ui_set_agent_model(w: World, highlight, who: str, model: str):
1113
- if who in w.agents and model in w.model_profiles:
1114
- w.agents[who].model = model
1115
- w.events.append(f"t={w.step}: {who} model -> {model}")
1116
- return (*ui_refresh(w, highlight), w, highlight)
1117
 
 
1118
 
1119
- def ui_update_model_price(w: World, highlight, model: str, in_per_1m: float, out_per_1m: float, tps: float):
1120
- if model not in w.model_profiles:
1121
- w.model_profiles[model] = {"in_per_1m": 2.0, "out_per_1m": 6.0, "tps": 100.0}
1122
- w.model_profiles[model]["in_per_1m"] = float(max(0.0, in_per_1m))
1123
- w.model_profiles[model]["out_per_1m"] = float(max(0.0, out_per_1m))
1124
- w.model_profiles[model]["tps"] = float(max(1.0, tps))
1125
- w.events.append(f"t={w.step}: pricing updated for {model}")
1126
- return (*ui_refresh(w, highlight), w, highlight)
1127
 
 
 
 
 
 
 
 
 
 
1128
 
1129
- def ui_download_jsonl(w: World):
1130
- name, data = export_jsonl(w)
1131
- return gr.File.update(value=(name, data))
 
 
 
1132
 
 
 
 
1133
 
1134
- def ui_download_csv(w: World):
1135
- name, data = export_csv(w)
1136
- return gr.File.update(value=(name, data))
1137
 
1138
 
1139
- # -----------------------------
1140
- # Exports helpers
1141
- # -----------------------------
1142
- def export_jsonl(w: World) -> Tuple[str, bytes]:
1143
- buf = io.StringIO()
1144
- for e in w.run_log:
1145
- buf.write(json.dumps(e, ensure_ascii=False) + "\n")
1146
- return "zen_orchestrator_runlog.jsonl", buf.getvalue().encode("utf-8")
1147
 
1148
 
1149
- def export_csv(w: World) -> Tuple[str, bytes]:
1150
- if not w.run_log:
1151
- return "zen_orchestrator_ledger.csv", b""
1152
- fields = ["t","agent","role","model","action","task_id","task_title","tokens_in","tokens_out","cost_usd","wall_seconds","sim_seconds","difficulty"]
1153
- buf = io.StringIO()
1154
- wri = csv.DictWriter(buf, fieldnames=fields)
1155
- wri.writeheader()
1156
- for e in w.run_log:
1157
- wri.writerow({k: e.get(k) for k in fields})
1158
- return "zen_orchestrator_ledger.csv", buf.getvalue().encode("utf-8")
1159
 
1160
 
1161
  # -----------------------------
1162
- # Gradio UI
1163
  # -----------------------------
1164
- TITLE = "ZEN Orchestrator Arena — Visual Agent Orchestra + KPIs"
1165
 
1166
  with gr.Blocks(title=TITLE) as demo:
1167
  gr.Markdown(
1168
  f"## {TITLE}\n"
1169
- "This is the hybrid you wanted: **visual world + business telemetry**.\n"
1170
- "- Agents move through the environment to task nodes\n"
1171
- "- Actions generate **logs, costs, tokens, time**, and KPI rollups\n"
1172
- "- Inject incidents to watch the orchestra reroute and recover\n"
1173
  )
1174
 
1175
  w0 = init_world(
@@ -1179,11 +1477,14 @@ with gr.Blocks(title=TITLE) as demo:
1179
  incident_rate=0.35,
1180
  max_parallel=3,
1181
  backlog_size=24,
 
 
 
 
1182
  )
1183
 
1184
  w_state = gr.State(w0)
1185
  highlight_state = gr.State(None)
1186
-
1187
  autoplay_on = gr.State(False)
1188
  timer = gr.Timer(value=0.18, active=False)
1189
 
@@ -1207,10 +1508,32 @@ with gr.Blocks(title=TITLE) as demo:
1207
  difficulty = gr.Slider(0.0, 1.0, value=0.55, step=0.01, label="Difficulty")
1208
  incident_rate = gr.Slider(0.0, 1.0, value=0.35, step=0.01, label="Incident Rate")
1209
  with gr.Row():
1210
- max_parallel = gr.Slider(1, 8, value=3, step=1, label="Max parallel tasks")
1211
- backlog_size = gr.Slider(8, 80, value=24, step=1, label="Backlog size")
 
1212
  btn_reset = gr.Button("Reset Scenario")
1213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1214
  with gr.Accordion("Autoplay / Run", open=True):
1215
  autoplay_speed = gr.Slider(0.05, 0.8, value=0.18, step=0.01, label="Autoplay tick interval (sec)")
1216
  with gr.Row():
@@ -1225,99 +1548,167 @@ with gr.Blocks(title=TITLE) as demo:
1225
  with gr.Accordion("Camera & Visuals", open=False):
1226
  overlay = gr.Checkbox(value=True, label="POV Overlay Reticle")
1227
  auto_camera = gr.Checkbox(value=True, label="Auto Camera Cuts")
1228
- pov_pick = gr.Dropdown(choices=list(default_agents().keys()), value="ENG", label="POV Agent")
1229
 
1230
  with gr.Accordion("Incidents", open=False):
1231
  task_id = gr.Textbox(value="T001", label="Task ID (e.g., T001)")
1232
  incident_note = gr.Textbox(value="Vendor outage", label="Incident note")
1233
  btn_inject = gr.Button("Inject Incident (force block + highlight)")
1234
 
1235
- with gr.Accordion("Models & Pricing", open=False):
1236
  with gr.Row():
1237
- agent_pick = gr.Dropdown(choices=list(default_agents().keys()), value="ENG", label="Agent")
1238
- model_pick = gr.Dropdown(choices=list(DEFAULT_MODEL_PROFILES.keys()), value="Sim-GPT-5", label="Model")
1239
- btn_set_model = gr.Button("Set Agent Model")
1240
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1241
  with gr.Row():
1242
- price_model = gr.Dropdown(choices=list(DEFAULT_MODEL_PROFILES.keys()), value="Sim-GPT-5", label="Model to edit")
1243
- in_per_1m = gr.Number(value=DEFAULT_MODEL_PROFILES["Sim-GPT-5"]["in_per_1m"], label="Input $/1M")
1244
- out_per_1m = gr.Number(value=DEFAULT_MODEL_PROFILES["Sim-GPT-5"]["out_per_1m"], label="Output $/1M")
1245
- tps = gr.Number(value=DEFAULT_MODEL_PROFILES["Sim-GPT-5"]["tps"], label="Tokens/sec (TPS)")
1246
- btn_price = gr.Button("Update Pricing")
1247
 
1248
  with gr.Accordion("Exports", open=True):
1249
  with gr.Row():
1250
- btn_dl_jsonl = gr.Button("Download Run Log (JSONL)")
1251
- btn_dl_csv = gr.Button("Download Ledger (CSV)")
1252
- file_out = gr.File(label="Download")
 
 
 
1253
 
1254
  # initial load
1255
  demo.load(
1256
- lambda w, h: (*ui_refresh(w, h), w, h),
1257
- inputs=[w_state, highlight_state],
1258
- outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, w_state, highlight_state],
1259
  queue=True,
1260
  )
1261
 
1262
  # reset
1263
  btn_reset.click(
1264
  ui_reset,
1265
- inputs=[seed, sim_preset, difficulty, incident_rate, max_parallel, backlog_size],
1266
- outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, w_state, highlight_state],
1267
  queue=True,
1268
  )
1269
 
1270
  # run
1271
- btn_run.click(ui_run, inputs=[w_state, highlight_state, run_n], outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, w_state, highlight_state], queue=True)
1272
- btn_run50.click(lambda w,h: ui_run(w,h,50), inputs=[w_state, highlight_state], outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, w_state, highlight_state], queue=True)
1273
- btn_run200.click(lambda w,h: ui_run(w,h,200), inputs=[w_state, highlight_state], outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, w_state, highlight_state], queue=True)
 
 
 
1274
 
1275
  # incidents
1276
  btn_inject.click(
1277
  ui_inject_incident,
1278
- inputs=[w_state, highlight_state, task_id, incident_note],
1279
- outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, w_state, highlight_state],
1280
  queue=True,
1281
  )
1282
 
1283
  # visuals
1284
- overlay.change(ui_set_overlay, inputs=[w_state, highlight_state, overlay], outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, w_state, highlight_state], queue=True)
1285
- auto_camera.change(ui_set_autocam, inputs=[w_state, highlight_state, auto_camera], outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, w_state, highlight_state], queue=True)
1286
- pov_pick.change(ui_set_pov, inputs=[w_state, highlight_state, pov_pick], outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, w_state, highlight_state], queue=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1287
 
1288
- # models/pricing
1289
- btn_set_model.click(ui_set_agent_model, inputs=[w_state, highlight_state, agent_pick, model_pick], outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, w_state, highlight_state], queue=True)
1290
- btn_price.click(ui_update_model_price, inputs=[w_state, highlight_state, price_model, in_per_1m, out_per_1m, tps], outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, w_state, highlight_state], queue=True)
 
 
 
 
1291
 
1292
- # exports
1293
- btn_dl_jsonl.click(lambda w: ui_download_jsonl(w), inputs=[w_state], outputs=[file_out], queue=True)
1294
- btn_dl_csv.click(lambda w: ui_download_csv(w), inputs=[w_state], outputs=[file_out], queue=True)
 
 
 
 
1295
 
1296
  # autoplay
1297
- def autoplay_start(w: World, h, interval: float):
1298
  interval = float(interval)
1299
- return gr.update(value=interval, active=True), True, w, h
1300
 
1301
- def autoplay_stop(w: World, h):
1302
- return gr.update(active=False), False, w, h
1303
 
1304
- btn_play.click(autoplay_start, inputs=[w_state, highlight_state, autoplay_speed], outputs=[timer, autoplay_on, w_state, highlight_state], queue=True)
1305
- btn_pause.click(autoplay_stop, inputs=[w_state, highlight_state], outputs=[timer, autoplay_on, w_state, highlight_state], queue=True)
 
 
 
 
 
 
 
 
 
 
1306
 
1307
- def autoplay_tick(w: World, h, is_on: bool):
1308
  if not is_on:
1309
- return (*ui_refresh(w, h), w, h, is_on, gr.update())
1310
- r = make_rng(w.seed + w.step*31)
1311
  if not w.done:
1312
  tick(w, r)
1313
  if w.done:
1314
- return (*ui_refresh(w, h), w, h, False, gr.update(active=False))
1315
- return (*ui_refresh(w, h), w, h, True, gr.update())
1316
 
1317
  timer.tick(
1318
  autoplay_tick,
1319
- inputs=[w_state, highlight_state, autoplay_on],
1320
- outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, w_state, highlight_state, autoplay_on, timer],
1321
  queue=True,
1322
  )
1323
 
 
5
  import io
6
  import json
7
  import math
8
+ import os
9
  import random
10
+ import tempfile
11
  import time
12
+ import uuid
13
  from dataclasses import dataclass, field
14
  from typing import Dict, List, Any, Optional, Tuple
15
 
16
  import numpy as np
17
+ import pandas as pd
18
  import gradio as gr
19
 
20
  from core.kpi import compute_kpis
21
 
22
  # ============================================================
23
+ # ZEN Orchestrator Arena — Visual Agent Orchestra + Run Data Table
24
+ # - Run log is always visible + scrollable in-app
25
+ # - Downloads use real files on disk via DownloadButton
26
+ # - Add unlimited agents (Sim or API-driven)
27
+ # - 1 API key slot can power up to 10 agents
28
+ # - Scenario prompt + per-agent behavior prompt
29
+ # - Regenerate map + regenerate tasks with controls
 
 
30
  # ============================================================
31
 
32
  # -----------------------------
 
38
  "Sim-Claude": {"in_per_1m": 3.00, "out_per_1m": 15.00, "tps": 90.0},
39
  "Sim-Gemini": {"in_per_1m": 1.50, "out_per_1m": 6.00, "tps": 200.0},
40
  "Sim-Local": {"in_per_1m": 0.20, "out_per_1m": 0.20, "tps": 300.0},
41
+ "API-LLM": {"in_per_1m": 5.00, "out_per_1m": 15.00, "tps": 120.0}, # default placeholder
42
  }
43
 
44
  SIM_TIME_PRESETS = {
 
81
  RESOURCE: "Resource",
82
  }
83
 
84
+ # Palette
85
  COL_BG = "#0b1020"
86
  COL_PANEL = "#0f1733"
87
  COL_GRIDLINE = "#121a3b"
 
118
  s = float(max(0.0, seconds))
119
  if s < 1:
120
  return f"{s:.3f}s"
121
+ units = [
122
+ ("seconds", 1),
123
+ ("minutes", 60),
124
+ ("hours", 3600),
125
+ ("days", 86400),
126
+ ("weeks", 604800),
127
+ ("months(30d)", 2592000),
128
+ ("years(365d)", 31536000),
129
+ ]
130
  unit, scale = "seconds", 1
131
  for name, sc in units:
132
  if s >= sc:
 
147
  return tile == WALL
148
 
149
 
150
+ def neighbors4(x: int, y: int) -> List[Tuple[int, int]]:
151
+ return [(x + 1, y), (x, y + 1), (x - 1, y), (x, y - 1)]
152
 
153
 
154
  def make_rng(seed: int) -> random.Random:
 
157
  return r
158
 
159
 
 
 
 
 
160
  def bfs_next_step(grid: List[List[int]], start: Tuple[int, int], goal: Tuple[int, int]) -> Optional[Tuple[int, int]]:
161
  if start == goal:
162
  return None
 
207
  notes: str = ""
208
 
209
 
210
+ @dataclass
211
+ class ApiKeySlot:
212
+ slot_name: str
213
+ provider: str = "OpenAI-compatible"
214
+ base_url: str = "https://api.openai.com/v1"
215
+ api_key: str = "" # keep in memory/state only
216
+ model: str = "gpt-4o-mini"
217
+ temperature: float = 0.3
218
+ max_output_tokens: int = 220
219
+ system_prompt: str = "You are an agent in a multi-agent office simulation. Be concise, action-oriented, and realistic."
220
+ agents_using: int = 0 # enforced <= 10
221
+
222
+
223
  @dataclass
224
  class Agent:
225
  name: str
 
233
  fatigue: float = 0.0
234
  skill: Dict[str, float] = field(default_factory=dict)
235
  target_task_id: Optional[str] = None
236
+ behavior_prompt: str = ""
237
+ engine: str = "sim" # "sim" or "api"
238
+ api_slot: Optional[str] = None # slot_name
239
 
240
 
241
  @dataclass
 
257
  overlay: bool = True
258
  auto_camera: bool = True
259
 
260
+ scenario_prompt: str = "Run a realistic office execution cycle: prioritize critical work, mitigate incidents, and finish tasks."
261
+
262
  model_profiles: Dict[str, Dict[str, float]] = field(default_factory=dict)
263
 
264
+ api_slots: Dict[str, ApiKeySlot] = field(default_factory=dict)
265
+
266
  run_log: List[Dict[str, Any]] = field(default_factory=list)
267
  events: List[str] = field(default_factory=list)
268
 
269
  done: bool = False
270
 
271
 
272
+ # -----------------------------
273
+ # Defaults
274
+ # -----------------------------
275
+ def default_agents_positions() -> Dict[str, Tuple[int, int]]:
276
  return {
277
  "PM": (2, 2),
278
+ "ENG": (GRID_W - 3, 2),
279
+ "DATA": (2, GRID_H - 3),
280
+ "OPS": (GRID_W - 3, GRID_H - 3),
281
+ "SEC": (GRID_W // 2, 2),
282
+ "DES": (GRID_W // 2, GRID_H - 3),
283
  }
284
 
285
 
 
287
  sp = default_agents_positions()
288
  return {
289
  "PM": Agent("PM", "Product Manager", "Sim-GPT-4o", *sp["PM"], focus=0.80, reliability=0.90,
290
+ skill={"product": 0.95, "ops": 0.70, "design": 0.55, "engineering": 0.35},
291
+ behavior_prompt="Drive priority clarity, reduce scope creep, keep tasks unblocked."),
292
  "ENG": Agent("ENG", "Engineer", "Sim-GPT-5", *sp["ENG"], focus=0.82, reliability=0.86,
293
+ skill={"engineering": 0.95, "data": 0.70, "security": 0.55, "ops": 0.55},
294
+ behavior_prompt="Build pragmatic solutions, unblock dependencies, keep shipping."),
295
  "DATA": Agent("DATA", "Data Scientist", "Sim-Gemini", *sp["DATA"], focus=0.78, reliability=0.84,
296
+ skill={"data": 0.92, "engineering": 0.55, "product": 0.45},
297
+ behavior_prompt="Measure outcomes, validate metrics, improve decision quality."),
298
  "OPS": Agent("OPS", "Ops Lead", "Sim-Claude", *sp["OPS"], focus=0.76, reliability=0.88,
299
+ skill={"ops": 0.92, "security": 0.65, "product": 0.55, "engineering": 0.45},
300
+ behavior_prompt="Keep systems stable, respond to incidents, reduce friction."),
301
  "SEC": Agent("SEC", "Security", "Sim-Local", *sp["SEC"], focus=0.70, reliability=0.93,
302
+ skill={"security": 0.92, "ops": 0.62, "engineering": 0.55},
303
+ behavior_prompt="Minimize risk, enforce secure defaults, fail safely."),
304
  "DES": Agent("DES", "Designer", "Sim-GPT-4o", *sp["DES"], focus=0.74, reliability=0.87,
305
+ skill={"design": 0.92, "product": 0.65},
306
+ behavior_prompt="Increase usability, reduce cognitive load, ship coherent UX."),
307
  }
308
 
309
 
310
  # -----------------------------
311
+ # Map builder (customizable)
312
  # -----------------------------
313
+ def build_map(seed: int, preset: str, wall_density: float, resource_density: float) -> List[List[int]]:
314
  r = make_rng(seed)
315
+ wall_density = clamp(float(wall_density), 0.05, 0.85)
316
+ resource_density = clamp(float(resource_density), 0.0, 0.25)
317
+
318
  g = [[EMPTY for _ in range(GRID_W)] for _ in range(GRID_H)]
319
 
320
  # borders
321
  for x in range(GRID_W):
322
  g[0][x] = WALL
323
+ g[GRID_H - 1][x] = WALL
324
  for y in range(GRID_H):
325
  g[y][0] = WALL
326
+ g[y][GRID_W - 1] = WALL
327
+
328
+ if preset == "Office Corridors":
329
+ for x in range(3, GRID_W - 3):
330
+ if x % 3 == 0:
331
+ for y in range(2, GRID_H - 2):
332
+ if r.random() < (0.45 + 0.4 * wall_density):
333
+ g[y][x] = WALL
334
+ # carve openings
335
+ for _ in range(int(22 + 30 * (1 - wall_density))):
336
+ x = r.randint(2, GRID_W - 3)
337
+ y = r.randint(2, GRID_H - 3)
338
+ g[y][x] = EMPTY
339
+
340
+ elif preset == "Open Office":
341
+ # sparse walls
342
+ for _ in range(int((GRID_W * GRID_H) * (0.08 + 0.35 * wall_density))):
343
+ x = r.randint(2, GRID_W - 3)
344
+ y = r.randint(2, GRID_H - 3)
345
+ g[y][x] = WALL
346
+ # carve a few rooms
347
+ for _ in range(3):
348
+ x0 = r.randint(2, GRID_W - 10)
349
+ y0 = r.randint(2, GRID_H - 7)
350
+ w = r.randint(5, 9)
351
+ h = r.randint(4, 6)
352
+ for y in range(y0, y0 + h):
353
+ for x in range(x0, x0 + w):
354
+ if x in (x0, x0 + w - 1) or y in (y0, y0 + h - 1):
355
+ g[y][x] = WALL
356
+ else:
357
+ g[y][x] = EMPTY
358
+ # door
359
+ g[y0 + h // 2][x0] = EMPTY
360
+
361
+ else: # "Warehouse Grid"
362
+ for y in range(2, GRID_H - 2):
363
+ for x in range(2, GRID_W - 2):
364
+ if (x % 4 == 0 and y % 2 == 0) and r.random() < (0.55 + 0.35 * wall_density):
365
  g[y][x] = WALL
366
+ for _ in range(30):
367
+ x = r.randint(2, GRID_W - 3)
368
+ y = r.randint(2, GRID_H - 3)
369
+ g[y][x] = EMPTY
370
 
371
+ # resources
372
+ for y in range(2, GRID_H - 2):
373
+ for x in range(2, GRID_W - 2):
374
+ if g[y][x] == EMPTY and r.random() < resource_density:
375
+ g[y][x] = RESOURCE
 
 
 
 
 
 
 
376
 
377
  return g
378
 
 
390
  # -----------------------------
391
  # Backlog -> task nodes on map
392
  # -----------------------------
393
+ def generate_backlog(seed: int, n: int, difficulty: float, grid: List[List[int]], skill_mix: str) -> List[Task]:
394
  r = make_rng(seed + 999)
395
+
396
+ all_skills = ["product", "engineering", "data", "ops", "security", "design"]
397
+ if skill_mix == "Engineering-heavy":
398
+ skill_buckets = ["engineering", "engineering", "engineering", "data", "ops", "security"]
399
+ elif skill_mix == "Ops-heavy":
400
+ skill_buckets = ["ops", "ops", "ops", "security", "engineering", "product"]
401
+ elif skill_mix == "Balanced":
402
+ skill_buckets = all_skills
403
+ else: # "Product-heavy"
404
+ skill_buckets = ["product", "product", "product", "design", "engineering", "ops"]
405
+
406
  verbs = ["Ship", "Audit", "Refactor", "Launch", "Scale", "Integrate", "Automate", "Harden", "Migrate", "Validate"]
407
  nouns = ["pipeline", "dashboard", "agent", "workflow", "integration", "report", "system", "KB", "API", "ledger"]
408
 
409
+ empties = [(x, y) for y in range(1, GRID_H - 1) for x in range(1, GRID_W - 1) if grid[y][x] == EMPTY]
 
410
  r.shuffle(empties)
411
  nodes = empties[: max(n, 1)]
412
 
 
416
  title = f"{r.choice(verbs)} {r.choice(nouns)} ({skill})"
417
  complexity = clamp(r.uniform(0.25, 1.25) * (0.85 + 0.6 * difficulty), 0.15, 2.0)
418
  risk = clamp(r.uniform(0.05, 0.35) * (0.7 + 0.8 * difficulty), 0.03, 0.75)
 
419
  xy = nodes[i] if i < len(nodes) else (2, 2)
420
+
421
  tasks.append(Task(
422
  id=f"T{i+1:03d}",
423
  title=title,
 
430
 
431
 
432
  def place_task_nodes(grid: List[List[int]], tasks: List[Task]):
433
+ # clear old nodes/blockers
434
+ for y in range(GRID_H):
435
+ for x in range(GRID_W):
436
+ if grid[y][x] in (TASKNODE, BLOCKER):
437
+ grid[y][x] = EMPTY
438
+
439
  for t in tasks:
440
+ x, y = t.node_xy
441
+ if in_bounds(x, y) and grid[y][x] == EMPTY:
442
  grid[y][x] = TASKNODE
443
 
444
 
445
  # -----------------------------
446
+ # Economics
447
  # -----------------------------
448
  def agent_skill_score(a: Agent, skill: str) -> float:
449
  return float(a.skill.get(skill, 0.15))
450
 
451
 
452
+ def estimate_tokens(task: Task, agent: Agent, difficulty: float, r: random.Random) -> Tuple[int, int]:
453
  base = 900 + 2200 * task.complexity * (0.8 + 0.6 * difficulty)
454
  fit = agent_skill_score(agent, task.required_skill)
455
  efficiency = clamp(1.15 - 0.55 * fit, 0.55, 1.25)
 
457
  total = base * efficiency * noise
458
  tin = int(total * r.uniform(0.40, 0.55))
459
  tout = int(total - tin)
460
+ return max(1, tin), max(1, tout)
461
 
462
 
463
+ def cost_from_tokens(model_profiles: Dict[str, Dict[str, float]], model: str, tin: int, tout: int) -> float:
464
  prof = model_profiles.get(model, {"in_per_1m": 2.0, "out_per_1m": 6.0, "tps": 100.0})
465
+ return (tin / 1_000_000.0) * float(prof["in_per_1m"]) + (tout / 1_000_000.0) * float(prof["out_per_1m"])
466
 
467
 
468
+ def wall_seconds_from_tokens(model_profiles: Dict[str, Dict[str, float]], model: str, tin: int, tout: int) -> float:
469
  prof = model_profiles.get(model, {"tps": 100.0})
470
  tps = float(prof.get("tps", 100.0))
471
+ return float((tin + tout) / max(tps, 1.0))
472
 
473
 
474
  # -----------------------------
475
  # Task selection & progress
476
  # -----------------------------
477
  def pick_task_for_agent(w: World, agent: Agent) -> Optional[Task]:
 
478
  if agent.target_task_id:
479
  t = next((x for x in w.tasks if x.id == agent.target_task_id), None)
480
+ if t and t.status in ("todo", "doing", "blocked"):
481
  return t
482
 
 
483
  doing_owned = [t for t in w.tasks if t.status == "doing" and t.owner == agent.name]
484
  if doing_owned:
485
  agent.target_task_id = doing_owned[0].id
 
503
  base *= (0.75 + 0.5 * agent.focus)
504
  base *= (0.95 - 0.35 * clamp(agent.fatigue, 0.0, 1.0))
505
  base *= (1.05 - 0.55 * difficulty)
506
+ return clamp(base * r.uniform(0.85, 1.15), 0.01, 0.28)
507
 
508
 
509
  def maybe_incident(w: World, task: Task, agent: Agent, r: random.Random) -> Optional[str]:
510
+ p = task.risk * (0.6 + 0.9 * w.incident_rate) * (0.75 + 0.9 * (1.0 - agent.reliability))
511
+ p *= (0.85 + 0.5 * w.difficulty)
512
  if r.random() < clamp(p, 0.01, 0.85):
513
  return r.choice([
514
  "Scope creep discovered",
 
526
  # -----------------------------
527
  # Movement + environment effects
528
  # -----------------------------
529
+ def move_step(w: World, a: Agent, target_xy: Tuple[int, int]):
530
+ tx, ty = target_xy
531
+ nxt = bfs_next_step(w.grid, (a.x, a.y), (tx, ty))
532
  if nxt is None:
533
  return
534
+ nx, ny = nxt
535
+ a.ori = face_towards(a.x, a.y, a.ori, nx, ny)
536
+ if in_bounds(nx, ny) and not is_blocking(w.grid[ny][nx]):
537
+ a.x, a.y = nx, ny
538
 
539
 
540
  def apply_env_tile_effects(w: World, a: Agent):
541
  tile = w.grid[a.y][a.x]
 
542
  if tile == BLOCKER:
543
  a.fatigue = clamp(a.fatigue + 0.04, 0.0, 1.0)
544
  w.events.append(f"t={w.step}: {a.name} hit a blocker zone (+fatigue).")
 
581
 
582
 
583
  def raycast_pov(w: World, who: str) -> np.ndarray:
584
+ if who not in w.agents:
585
+ who = next(iter(w.agents.keys()))
586
  a = w.agents[who]
587
+
588
  img = np.zeros((VIEW_H, VIEW_W, 3), dtype=np.uint8)
589
  img[:, :] = SKY
590
  for y in range(VIEW_H // 2, VIEW_H):
 
628
 
629
  depth *= math.cos(ang - base)
630
  depth = max(depth, 0.001)
 
631
  h = int((VIEW_H * 0.92) / depth)
632
  y0 = max(0, VIEW_H // 2 - h // 2)
633
  y1 = min(VIEW_H - 1, VIEW_H // 2 + h // 2)
 
643
  col = (col * dim).astype(np.uint8)
644
  img[y0:y1, rx:rx + 1] = col
645
 
646
+ # other agents as blocks
647
  for nm, other in w.agents.items():
648
  if nm == who:
649
  continue
 
669
  y1 = int(clamp(ymid + size // 2, 0, VIEW_H - 1))
670
 
671
  hexcol = AGENT_COLORS.get(nm, "#ffd17a").lstrip("#")
672
+ rgb = np.array([int(hexcol[i:i + 2], 16) for i in (0, 2, 4)], dtype=np.uint8)
673
  img[y0:y1, x0:x1] = rgb
674
 
675
  if w.overlay:
 
681
 
682
 
683
  # -----------------------------
684
+ # SVG renderer
685
  # -----------------------------
686
+ def svg_render(w: World, highlight: Optional[Tuple[int, int]] = None) -> str:
 
687
  k = compute_kpis(w.run_log)
688
  done = sum(1 for t in w.tasks if t.status == "done")
 
689
  blocked = sum(1 for t in w.tasks if t.status == "blocked")
690
  headline = f"ZEN Orchestrator Arena • step={w.step} • sim={fmt_duration(w.sim_elapsed_seconds)} • done={done}/{len(w.tasks)} • blocked={blocked} • cost=${k.total_cost_usd:,.2f}"
691
+ detail = f"time/tick={fmt_duration(w.sim_seconds_per_tick)} • difficulty={w.difficulty:.2f} • incident_rate={w.incident_rate:.2f} • agents={len(w.agents)} • pov={w.pov_agent}"
692
 
693
  css = f"""
694
  <style>
 
727
  stroke: rgba(170,195,255,0.16);
728
  stroke-width: 1;
729
  }}
 
730
  .banner {{ fill: rgba(255,255,255,0.08); }}
731
  </style>
732
  """
 
741
  <text class="hud hudSmall" x="18" y="50" font-size="12">{detail}</text>
742
  """]
743
 
 
744
  for y in range(GRID_H):
745
  for x in range(GRID_W):
746
  t = w.grid[y][x]
 
748
  py = HUD_H + y * TILE
749
  svg.append(f'<rect class="tile" x="{px}" y="{py}" width="{TILE}" height="{TILE}" fill="{tile_color(t)}"/>')
750
 
 
751
  if t == TASKNODE:
752
+ cx = px + TILE * 0.5
753
+ cy = py + TILE * 0.5
754
  svg.append(f'<circle cx="{cx}" cy="{cy}" r="6" fill="rgba(0,0,0,0.35)"/>')
755
  svg.append(f'<circle cx="{cx}" cy="{cy}" r="4" fill="{COL_TASK}"/>')
756
  elif t == RESOURCE:
757
+ cx = px + TILE * 0.5
758
+ cy = py + TILE * 0.5
759
  svg.append(f'<rect x="{cx-5}" y="{cy-5}" width="10" height="10" rx="3" fill="{COL_RES}" opacity="0.95"/>')
760
  elif t == BLOCKER:
761
+ cx = px + TILE * 0.5
762
+ cy = py + TILE * 0.5
763
  svg.append(f'<line x1="{cx-6}" y1="{cy-6}" x2="{cx+6}" y2="{cy+6}" stroke="rgba(0,0,0,0.45)" stroke-width="3"/>')
764
  svg.append(f'<line x1="{cx-6}" y1="{cy+6}" x2="{cx+6}" y2="{cy-6}" stroke="rgba(0,0,0,0.45)" stroke-width="3"/>')
765
 
 
766
  for x in range(GRID_W + 1):
767
  px = x * TILE
768
  svg.append(f'<line class="gridline" x1="{px}" y1="{HUD_H}" x2="{px}" y2="{SVG_H}"/>')
 
770
  py = HUD_H + y * TILE
771
  svg.append(f'<line class="gridline" x1="0" y1="{py}" x2="{SVG_W}" y2="{py}"/>')
772
 
 
773
  if highlight:
774
  hx, hy = highlight
775
  if in_bounds(hx, hy):
 
790
  """)
791
 
792
  dx, dy = DIRS[a.ori]
793
+ x2 = TILE / 2 + dx * (TILE * 0.32)
794
+ y2 = TILE / 2 + dy * (TILE * 0.32)
795
  svg.append(f'<line x1="{TILE/2}" y1="{TILE/2}" x2="{x2}" y2="{y2}" stroke="rgba(10,10,14,0.85)" stroke-width="4" stroke-linecap="round"/>')
796
 
797
+ label = nm
798
+ badge_w = max(56, 8 * len(label) + 24)
799
  svg.append(f'<rect class="badge" x="{TILE/2 - badge_w/2}" y="{TILE*0.05}" rx="10" width="{badge_w}" height="16"/>')
800
+ svg.append(f'<text x="{TILE/2}" y="{TILE*0.05 + 12}" text-anchor="middle" font-size="10" fill="rgba(235,240,255,0.92)" font-family="ui-sans-serif, system-ui">{label}</text>')
801
 
802
+ if a.engine == "api":
803
+ svg.append(f'<circle cx="{TILE*0.86}" cy="{TILE*0.18}" r="5" fill="rgba(255,110,110,0.95)"/>') # red dot = API agent
804
+ elif a.target_task_id:
805
  svg.append(f'<circle cx="{TILE*0.86}" cy="{TILE*0.18}" r="5" fill="rgba(110,180,255,0.95)"/>')
806
 
807
  svg.append("</g>")
 
811
 
812
 
813
  # -----------------------------
814
+ # Business render
815
  # -----------------------------
816
  def status_summary(w: World) -> str:
817
  k = compute_kpis(w.run_log)
 
822
  return (
823
  f"step={w.step} | sim_elapsed={fmt_duration(w.sim_elapsed_seconds)} | tick={fmt_duration(w.sim_seconds_per_tick)}\n"
824
  f"tasks: done={done}/{len(w.tasks)} | doing={doing} | blocked={blocked} | todo={todo}\n"
825
+ f"agents={len(w.agents)} | actions={k.total_actions} | cost=${k.total_cost_usd:,.2f} | in={k.total_tokens_in:,} | out={k.total_tokens_out:,}"
826
  )
827
 
828
 
 
847
 
848
 
849
  def agents_table(w: World) -> str:
850
+ cols = ["name", "engine", "role", "model", "fatigue", "target", "api_slot"]
851
  rows = [cols]
852
  for a in w.agents.values():
853
+ rows.append([a.name, a.engine, a.role, a.model, f"{a.fatigue:.2f}", a.target_task_id or "-", a.api_slot or "-"])
854
  widths = [max(len(str(r[i])) for r in rows) for i in range(len(cols))]
855
  out = []
856
  for i, r in enumerate(rows):
857
  out.append(" | ".join(str(r[j]).ljust(widths[j]) for j in range(len(cols))))
858
  if i == 0:
859
+ out.append("-+-".join("-" * w for w in widths))
860
  return "\n".join(out)
861
 
862
 
863
  def tasks_table(w: World, limit: int = 24) -> str:
864
  order = {"blocked": 0, "doing": 1, "todo": 2, "done": 3}
865
+ ts = sorted(w.tasks, key=lambda t: (order.get(t.status, 9), t.id))
866
  cols = ["id", "status", "owner", "progress", "skill", "xy", "title"]
867
  rows = [cols]
868
  for t in ts[:limit]:
 
870
  t.id,
871
  t.status,
872
  t.owner or "-",
873
+ f"{t.progress * 100:5.1f}%",
874
  t.required_skill,
875
  f"({t.node_xy[0]},{t.node_xy[1]})",
876
  (t.title[:55] + "…") if len(t.title) > 56 else t.title,
 
880
  for i, r in enumerate(rows):
881
  out.append(" | ".join(str(r[j]).ljust(widths[j]) for j in range(len(cols))))
882
  if i == 0:
883
+ out.append("-+-".join("-" * w for w in widths))
884
  return "\n".join(out)
885
 
886
 
887
  # -----------------------------
888
+ # RUN DATA TABLE (in-app)
889
  # -----------------------------
890
+ RUNLOG_COLUMNS = [
891
+ "t", "agent", "engine", "role", "model", "action", "task_id",
892
+ "tokens_in", "tokens_out", "cost_usd", "wall_seconds", "sim_seconds",
893
+ "thought", "incident", "task_progress"
894
+ ]
895
 
896
 
897
+ def runlog_df(w: World, last_n: int = 400) -> pd.DataFrame:
898
  if not w.run_log:
899
+ return pd.DataFrame(columns=RUNLOG_COLUMNS)
900
+
901
+ tail = w.run_log[-int(max(1, last_n)):]
902
+ rows = []
903
+ for e in tail:
904
+ row = {c: e.get(c) for c in RUNLOG_COLUMNS}
905
+ # cost format
906
+ if row.get("cost_usd") is not None:
907
+ try:
908
+ row["cost_usd"] = float(row["cost_usd"])
909
+ except Exception:
910
+ pass
911
+ rows.append(row)
912
+
913
+ df = pd.DataFrame(rows, columns=RUNLOG_COLUMNS)
914
+ if not df.empty:
915
+ df["cost_usd"] = df["cost_usd"].fillna(0.0).astype(float).round(6)
916
+ return df
917
+
918
+
919
+ # -----------------------------
920
+ # File exports (disk files -> DownloadButton)
921
+ # -----------------------------
922
+ def write_runlog_jsonl_file(w: World) -> str:
923
+ fd, path = tempfile.mkstemp(prefix="zen_runlog_", suffix=".jsonl")
924
+ os.close(fd)
925
+ with open(path, "w", encoding="utf-8") as f:
926
+ for e in w.run_log:
927
+ f.write(json.dumps(e, ensure_ascii=False) + "\n")
928
+ return path
929
+
930
+
931
+ def write_ledger_csv_file(w: World) -> str:
932
+ fd, path = tempfile.mkstemp(prefix="zen_ledger_", suffix=".csv")
933
+ os.close(fd)
934
+ fields = ["t", "agent", "engine", "role", "model", "action", "task_id", "task_title",
935
+ "tokens_in", "tokens_out", "cost_usd", "wall_seconds", "sim_seconds", "difficulty"]
936
+ with open(path, "w", newline="", encoding="utf-8") as f:
937
+ wri = csv.DictWriter(f, fieldnames=fields)
938
+ wri.writeheader()
939
+ for e in w.run_log:
940
+ wri.writerow({k: e.get(k) for k in fields})
941
+ return path
942
+
943
+
944
+ # -----------------------------
945
+ # API Agent support (OpenAI-compatible, stdlib only)
946
+ # Note: Uses urllib to avoid adding dependencies.
947
+ # -----------------------------
948
+ def openai_compat_chat(slot: ApiKeySlot, messages: List[Dict[str, str]]) -> Tuple[str, int, int]:
949
+ """
950
+ Returns: (assistant_text, tokens_in_est, tokens_out_est)
951
+ Token counts are approximate (we don't have tiktoken); good enough for sim.
952
+ """
953
+ import urllib.request
954
+
955
+ url = slot.base_url.rstrip("/") + "/chat/completions"
956
+ payload = {
957
+ "model": slot.model,
958
+ "messages": messages,
959
+ "temperature": float(slot.temperature),
960
+ "max_tokens": int(slot.max_output_tokens),
961
+ }
962
+ data = json.dumps(payload).encode("utf-8")
963
+
964
+ req = urllib.request.Request(url, data=data, method="POST")
965
+ req.add_header("Content-Type", "application/json")
966
+ if slot.api_key:
967
+ req.add_header("Authorization", f"Bearer {slot.api_key}")
968
+
969
+ try:
970
+ with urllib.request.urlopen(req, timeout=25) as resp:
971
+ raw = resp.read().decode("utf-8", errors="replace")
972
+ j = json.loads(raw)
973
+ text = j["choices"][0]["message"]["content"]
974
+ except Exception as ex:
975
+ text = f"[API_ERROR] {type(ex).__name__}: {ex}"
976
+
977
+ # crude token est: ~4 chars/token (varies by language); good enough for billing estimates
978
+ joined_in = " ".join(m.get("content", "") for m in messages)
979
+ tin = max(1, int(len(joined_in) / 4))
980
+ tout = max(1, int(len(text) / 4))
981
+ return text, tin, tout
982
+
983
+
984
+ def make_agent_decision_with_api(w: World, a: Agent, task: Task) -> Tuple[str, str]:
985
+ """
986
+ Uses scenario_prompt + agent.behavior_prompt + task details.
987
+ Returns: (action, thought)
988
+ """
989
+ slot = w.api_slots.get(a.api_slot or "")
990
+ if not slot:
991
+ return "work", "No API slot configured; falling back."
992
+
993
+ sys = slot.system_prompt.strip() or "You are an agent in a simulation."
994
+ scenario = w.scenario_prompt.strip()
995
+ beh = a.behavior_prompt.strip()
996
+ task_desc = (
997
+ f"Task: {task.id} | {task.title}\n"
998
+ f"Required skill: {task.required_skill}\n"
999
+ f"Status: {task.status} | Progress: {task.progress:.2f}\n"
1000
+ f"Notes: {task.notes or 'None'}\n"
1001
+ f"Difficulty: {w.difficulty:.2f} | IncidentRate: {w.incident_rate:.2f}\n"
1002
+ )
1003
+
1004
+ user = (
1005
+ f"Scenario Context:\n{scenario}\n\n"
1006
+ f"Agent Role: {a.role} ({a.name})\n"
1007
+ f"Agent Behavior:\n{beh or '(none)'}\n\n"
1008
+ f"{task_desc}\n"
1009
+ "Return JSON with keys: action (move|work|unblock|idle), thought (short).\n"
1010
+ "Be realistic and concise."
1011
+ )
1012
+
1013
+ messages = [
1014
+ {"role": "system", "content": sys},
1015
+ {"role": "user", "content": user},
1016
+ ]
1017
+ txt, tin, tout = openai_compat_chat(slot, messages)
1018
+
1019
+ # parse JSON if possible
1020
+ action = "work"
1021
+ thought = txt.strip()
1022
+ try:
1023
+ j = json.loads(txt)
1024
+ action = str(j.get("action", "work")).strip()
1025
+ thought = str(j.get("thought", "")).strip() or thought
1026
+ except Exception:
1027
+ # keep raw
1028
+ pass
1029
+
1030
+ # store token estimates to world event? we’ll return them via caller’s log fields
1031
+ return action, thought
1032
 
1033
 
1034
  # -----------------------------
1035
+ # Core sim tick
1036
  # -----------------------------
1037
  def tick(w: World, r: random.Random):
1038
  if w.done:
1039
  return
1040
 
 
1041
  if all(t.status == "done" for t in w.tasks):
1042
  w.done = True
1043
  w.events.append(f"t={w.step}: DONE — all tasks completed.")
1044
  return
1045
 
1046
+ for name, a in list(w.agents.items()):
 
 
 
1047
  task = pick_task_for_agent(w, a)
1048
 
1049
  if task is None:
 
1050
  w.run_log.append({
1051
+ "t": w.step, "agent": name, "engine": a.engine, "role": a.role, "model": a.model,
1052
  "action": "idle",
1053
  "thought": "No task available; monitoring and waiting.",
1054
  "task_id": None, "task_title": None,
 
1060
  })
1061
  continue
1062
 
1063
+ # start
1064
  if task.status == "todo":
1065
  task.status = "doing"
1066
  task.owner = name
1067
  w.events.append(f"t={w.step}: {name} started {task.id} — {task.title}")
1068
 
 
1069
  tx, ty = task.node_xy
1070
+
1071
+ # decide (api agents can suggest intent; sim agents follow deterministic logic)
1072
+ desired = None
1073
+ api_thought = None
1074
+ api_tokens = (0, 0)
1075
+ if a.engine == "api" and a.api_slot:
1076
+ action, thought = make_agent_decision_with_api(w, a, task)
1077
+ api_thought = thought
1078
+ desired = action
1079
+
1080
+ # movement: if not on node, always move (unless API insists idle)
1081
  if (a.x, a.y) != (tx, ty):
1082
+ if desired == "idle":
1083
+ w.run_log.append({
1084
+ "t": w.step, "agent": name, "engine": a.engine, "role": a.role, "model": a.model,
1085
+ "action": "idle",
1086
+ "thought": api_thought or f"Waiting before moving toward {task.id}.",
1087
+ "task_id": task.id, "task_title": task.title,
1088
+ "tokens_in": 12, "tokens_out": 8,
1089
+ "cost_usd": cost_from_tokens(w.model_profiles, a.model, 12, 8),
1090
+ "wall_seconds": 0.01,
1091
+ "sim_seconds": w.sim_seconds_per_tick,
1092
+ "difficulty": w.difficulty,
1093
+ })
1094
+ continue
1095
+
1096
  move_step(w, a, (tx, ty))
1097
  apply_env_tile_effects(w, a)
 
 
1098
  w.run_log.append({
1099
+ "t": w.step, "agent": name, "engine": a.engine, "role": a.role, "model": a.model,
1100
  "action": "move",
1101
+ "thought": api_thought or f"Navigating toward {task.id} at ({tx},{ty}).",
1102
  "task_id": task.id, "task_title": task.title,
1103
  "tokens_in": 10, "tokens_out": 5,
1104
  "cost_usd": cost_from_tokens(w.model_profiles, a.model, 10, 5),
 
1108
  })
1109
  continue
1110
 
1111
+ # on node: handle blocked/unblock, incidents, work
1112
  incident = maybe_incident(w, task, a, r)
1113
+
1114
+ # token/cost estimate
1115
  tin, tout = estimate_tokens(task, a, w.difficulty, r)
1116
  wall_s = wall_seconds_from_tokens(w.model_profiles, a.model, tin, tout)
1117
  cost = cost_from_tokens(w.model_profiles, a.model, tin, tout)
1118
 
1119
+ # if api agent, treat its response as “more expensive” by default (still uses our pricing profile)
1120
+ thought = api_thought or f"Advancing {task.id}: execute, validate, document."
1121
+ action = desired or "work"
1122
+
1123
+ if task.status == "blocked" or action == "unblock":
1124
+ unblock = clamp(0.03 + 0.10 * agent_skill_score(a, task.required_skill), 0.02, 0.18) * r.uniform(0.85, 1.1)
1125
  task.progress = clamp(task.progress + unblock, 0.0, 1.0)
1126
  if task.progress >= 0.35:
1127
  task.status = "doing"
1128
  task.notes = ""
1129
+ x, y = task.node_xy
 
1130
  if w.grid[y][x] == BLOCKER:
1131
  w.grid[y][x] = TASKNODE
1132
  w.events.append(f"t={w.step}: {name} unblocked {task.id}")
1133
 
1134
  w.run_log.append({
1135
+ "t": w.step, "agent": name, "engine": a.engine, "role": a.role, "model": a.model,
1136
  "action": "unblock",
1137
+ "thought": thought,
1138
  "task_id": task.id, "task_title": task.title,
1139
  "tokens_in": tin, "tokens_out": tout,
1140
  "cost_usd": cost,
1141
  "wall_seconds": wall_s,
1142
  "sim_seconds": w.sim_seconds_per_tick,
1143
  "difficulty": w.difficulty,
1144
+ "task_progress": task.progress,
1145
  })
1146
  a.fatigue = clamp(a.fatigue + 0.03, 0.0, 1.0)
1147
  continue
1148
 
1149
  if incident is not None:
1150
+ regress = clamp(r.uniform(0.04, 0.16) * (0.7 + 0.8 * w.difficulty), 0.02, 0.22)
1151
  task.progress = clamp(task.progress - regress, 0.0, 1.0)
1152
  if r.random() < 0.50:
1153
  task.status = "blocked"
1154
  task.notes = incident
1155
+ x, y = task.node_xy
 
1156
  w.grid[y][x] = BLOCKER
1157
 
1158
  w.events.append(f"t={w.step}: INCIDENT on {task.id} ({name}) — {incident}")
1159
  w.run_log.append({
1160
+ "t": w.step, "agent": name, "engine": a.engine, "role": a.role, "model": a.model,
1161
  "action": "incident_response",
1162
+ "thought": thought if api_thought else f"Incident '{incident}' detected. Triaging + mitigating.",
1163
  "task_id": task.id, "task_title": task.title,
1164
+ "tokens_in": int(tin * 1.05), "tokens_out": int(tout * 1.05),
1165
+ "cost_usd": float(cost * 1.08),
1166
+ "wall_seconds": float(wall_s * 1.10),
1167
  "sim_seconds": w.sim_seconds_per_tick,
1168
  "difficulty": w.difficulty,
1169
  "incident": incident,
1170
+ "task_progress": task.progress,
1171
  })
1172
  a.fatigue = clamp(a.fatigue + 0.06, 0.0, 1.0)
1173
  continue
 
1178
  if task.progress >= 1.0:
1179
  task.status = "done"
1180
  w.events.append(f"t={w.step}: ✅ {task.id} completed by {name}")
1181
+ x, y = task.node_xy
 
1182
  w.grid[y][x] = EMPTY
1183
 
1184
  w.run_log.append({
1185
+ "t": w.step, "agent": name, "engine": a.engine, "role": a.role, "model": a.model,
1186
  "action": "work",
1187
+ "thought": thought,
1188
  "task_id": task.id, "task_title": task.title,
1189
  "tokens_in": tin, "tokens_out": tout,
1190
  "cost_usd": cost,
 
1193
  "difficulty": w.difficulty,
1194
  "task_progress": task.progress,
1195
  })
1196
+ a.fatigue = clamp(a.fatigue + 0.02 * (0.7 + 0.6 * w.difficulty), 0.0, 1.0)
1197
 
1198
  # camera cuts
1199
+ if w.auto_camera and w.agents:
 
1200
  best, best_score = w.pov_agent, -1e9
1201
  blocked_owners = set(t.owner for t in w.tasks if t.status == "blocked" and t.owner)
1202
  for nm, a in w.agents.items():
1203
+ score = a.fatigue * 10.0 + (4.0 if nm in blocked_owners else 0.0)
 
 
 
1204
  if score > best_score:
1205
  best, best_score = nm, score
1206
  w.pov_agent = best
1207
 
1208
+ # recovery drift
1209
  for a in w.agents.values():
1210
  a.fatigue = clamp(a.fatigue - 0.01, 0.0, 1.0)
1211
 
 
1212
  w.sim_elapsed_seconds += w.sim_seconds_per_tick
1213
  w.step += 1
1214
 
1215
+ if len(w.events) > 320:
1216
+ w.events = w.events[-320:]
 
 
 
 
 
1217
 
1218
 
1219
  # -----------------------------
1220
  # Init world
1221
  # -----------------------------
1222
+ def init_world(seed: int, sim_seconds_per_tick: float, difficulty: float, incident_rate: float, max_parallel: int,
1223
+ backlog_size: int, map_preset: str, wall_density: float, resource_density: float, skill_mix: str) -> World:
1224
+ grid = build_map(seed, map_preset, wall_density, resource_density)
1225
  agents = default_agents()
1226
+ tasks = generate_backlog(seed, backlog_size, difficulty, grid, skill_mix)
1227
  place_task_nodes(grid, tasks)
1228
 
1229
  w = World(
 
1240
  pov_agent="ENG",
1241
  overlay=True,
1242
  auto_camera=True,
1243
+ scenario_prompt="Run a realistic office execution cycle: prioritize critical work, mitigate incidents, and finish tasks.",
1244
  model_profiles=json.loads(json.dumps(DEFAULT_MODEL_PROFILES)),
1245
+ api_slots={
1246
+ "KEYSLOT_A": ApiKeySlot(slot_name="KEYSLOT_A"),
1247
+ "KEYSLOT_B": ApiKeySlot(slot_name="KEYSLOT_B"),
1248
+ },
1249
  events=[f"Initialized: seed={seed} | time/tick={fmt_duration(sim_seconds_per_tick)} | difficulty={difficulty:.2f}"],
1250
  )
1251
  return w
1252
 
1253
 
1254
  # -----------------------------
1255
+ # UI refresh
1256
  # -----------------------------
1257
+ def ui_refresh(w: World, highlight: Optional[Tuple[int, int]], runlog_rows: int):
1258
  arena = svg_render(w, highlight)
1259
  pov = raycast_pov(w, w.pov_agent)
1260
  status = status_summary(w)
1261
  agents_txt = agents_table(w)
1262
  tasks_txt = tasks_table(w)
1263
+ events_txt = "\n".join(w.events[-30:])
1264
  kpis_txt = kpi_text(w)
1265
+ df = runlog_df(w, last_n=int(runlog_rows))
1266
+ return arena, pov, status, agents_txt, tasks_txt, events_txt, kpis_txt, df
1267
 
1268
 
1269
+ # -----------------------------
1270
+ # Actions
1271
+ # -----------------------------
1272
+ def ui_reset(seed: int, sim_preset: str, difficulty: float, incident_rate: float, max_parallel: int, backlog_size: int,
1273
+ map_preset: str, wall_density: float, resource_density: float, skill_mix: str, runlog_rows: int):
1274
+ sim_seconds = SIM_TIME_PRESETS.get(sim_preset, 7 * 24 * 3600)
1275
+ w = init_world(int(seed), float(sim_seconds), float(difficulty), float(incident_rate), int(max_parallel), int(backlog_size),
1276
+ map_preset, float(wall_density), float(resource_density), skill_mix)
1277
+ return (*ui_refresh(w, None, runlog_rows), w, None)
1278
 
1279
 
1280
+ def ui_run(w: World, highlight, n: int, runlog_rows: int):
1281
+ r = make_rng(w.seed + w.step * 31)
1282
  for _ in range(max(1, int(n))):
1283
  if w.done:
1284
  break
1285
  tick(w, r)
1286
+ return (*ui_refresh(w, highlight, runlog_rows), w, highlight)
1287
 
1288
 
1289
+ def ui_inject_incident(w: World, highlight, task_id: str, note: str, runlog_rows: int):
1290
  t = next((x for x in w.tasks if x.id == task_id.strip()), None)
1291
  if not t:
1292
  w.events.append(f"t={w.step}: inject failed — {task_id} not found.")
1293
+ return (*ui_refresh(w, highlight, runlog_rows), w, highlight)
1294
 
1295
  t.status = "blocked"
1296
  t.notes = note.strip() or "Injected incident"
1297
+ x, y = t.node_xy
1298
  if w.grid[y][x] in (TASKNODE, EMPTY):
1299
  w.grid[y][x] = BLOCKER
1300
 
1301
  w.events.append(f"t={w.step}: 🔥 INJECTED INCIDENT on {t.id} — {t.notes}")
1302
  w.run_log.append({
1303
+ "t": w.step, "agent": "SYSTEM", "engine": "sim", "role": "Simulator", "model": "n/a",
1304
  "action": "inject_incident",
1305
  "thought": "User injected an incident to stress-test orchestration.",
1306
  "task_id": t.id, "task_title": t.title,
1307
  "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "wall_seconds": 0.0,
1308
  "sim_seconds": 0.0, "difficulty": w.difficulty,
1309
  "incident": t.notes,
1310
+ "task_progress": t.progress,
1311
  })
 
1312
  highlight = t.node_xy
1313
+ return (*ui_refresh(w, highlight, runlog_rows), w, highlight)
1314
 
1315
 
1316
+ def ui_set_overlay(w: World, highlight, v: bool, runlog_rows: int):
1317
  w.overlay = bool(v)
1318
+ return (*ui_refresh(w, highlight, runlog_rows), w, highlight)
1319
 
1320
 
1321
+ def ui_set_autocam(w: World, highlight, v: bool, runlog_rows: int):
1322
  w.auto_camera = bool(v)
1323
  w.events.append(f"t={w.step}: auto_camera={w.auto_camera}")
1324
+ return (*ui_refresh(w, highlight, runlog_rows), w, highlight)
1325
 
1326
 
1327
+ def ui_set_pov(w: World, highlight, who: str, runlog_rows: int):
1328
  if who in w.agents:
1329
  w.pov_agent = who
1330
  w.events.append(f"t={w.step}: POV -> {who}")
1331
+ return (*ui_refresh(w, highlight, runlog_rows), w, highlight)
1332
+
1333
+
1334
+ def ui_update_scenario_prompt(w: World, highlight, scenario: str, runlog_rows: int):
1335
+ w.scenario_prompt = (scenario or "").strip()
1336
+ w.events.append(f"t={w.step}: scenario_prompt updated.")
1337
+ return (*ui_refresh(w, highlight, runlog_rows), w, highlight)
1338
+
1339
+
1340
+ # ---- Map & task regeneration (without full reset)
1341
+ def ui_regen_map(w: World, highlight, map_preset: str, wall_density: float, resource_density: float, runlog_rows: int):
1342
+ w.grid = build_map(w.seed + w.step, map_preset, wall_density, resource_density)
1343
+ # re-place task nodes where tasks want to be; if invalid, keep tasks but drop nodes
1344
+ place_task_nodes(w.grid, w.tasks)
1345
+ w.events.append(f"t={w.step}: regenerated map preset={map_preset} wall_density={wall_density:.2f} resource_density={resource_density:.2f}")
1346
+ return (*ui_refresh(w, highlight, runlog_rows), w, highlight)
1347
+
1348
+
1349
+ def ui_regen_tasks(w: World, highlight, backlog_size: int, skill_mix: str, runlog_rows: int):
1350
+ w.tasks = generate_backlog(w.seed + w.step, int(backlog_size), w.difficulty, w.grid, skill_mix)
1351
+ place_task_nodes(w.grid, w.tasks)
1352
+ w.events.append(f"t={w.step}: regenerated tasks size={backlog_size} mix={skill_mix}")
1353
+ return (*ui_refresh(w, highlight, runlog_rows), w, highlight)
1354
+
1355
+
1356
+ # ---- Add agents
1357
+ def _unique_agent_name(existing: set, base: str) -> str:
1358
+ base = base.strip() or "A"
1359
+ if base not in existing:
1360
+ return base
1361
+ i = 2
1362
+ while f"{base}{i}" in existing:
1363
+ i += 1
1364
+ return f"{base}{i}"
1365
+
1366
+
1367
+ def ui_add_agents(w: World, highlight, count: int, base_name: str, role: str, engine: str,
1368
+ api_slot: str, model_name: str, behavior_prompt: str,
1369
+ runlog_rows: int):
1370
+ count = int(max(1, count))
1371
+ existing = set(w.agents.keys())
1372
+
1373
+ # enforce API slot cap (10)
1374
+ if engine == "api":
1375
+ slot = w.api_slots.get(api_slot)
1376
+ if not slot:
1377
+ w.events.append(f"t={w.step}: add_agents failed — unknown api_slot {api_slot}")
1378
+ return (*ui_refresh(w, highlight, runlog_rows), w, highlight)
1379
+
1380
+ available = max(0, 10 - slot.agents_using)
1381
+ if available <= 0:
1382
+ w.events.append(f"t={w.step}: add_agents blocked — {api_slot} already has 10 agents.")
1383
+ return (*ui_refresh(w, highlight, runlog_rows), w, highlight)
1384
+
1385
+ count = min(count, available)
1386
+
1387
+ # spawn near a corner but not on walls
1388
+ spx, spy = 2, 2
1389
+ if len(w.agents) % 2 == 1:
1390
+ spx, spy = GRID_W - 3, GRID_H - 3
1391
+
1392
+ for _ in range(count):
1393
+ nm = _unique_agent_name(existing, base_name)
1394
+ existing.add(nm)
1395
+
1396
+ a = Agent(
1397
+ name=nm,
1398
+ role=role.strip() or "Custom Agent",
1399
+ model=("API-LLM" if engine == "api" else (model_name.strip() or "Sim-GPT-4o")),
1400
+ x=spx,
1401
+ y=spy,
1402
+ focus=0.75,
1403
+ reliability=0.86,
1404
+ skill={
1405
+ "engineering": 0.55,
1406
+ "data": 0.55,
1407
+ "product": 0.55,
1408
+ "ops": 0.55,
1409
+ "security": 0.55,
1410
+ "design": 0.55,
1411
+ },
1412
+ behavior_prompt=(behavior_prompt or "").strip(),
1413
+ engine=engine,
1414
+ api_slot=(api_slot if engine == "api" else None),
1415
+ )
1416
+ w.agents[nm] = a
1417
 
1418
+ if engine == "api":
1419
+ w.api_slots[api_slot].agents_using += 1
1420
 
1421
+ w.events.append(f"t={w.step}: added agent {nm} (engine={engine}, role={a.role})")
 
 
 
 
1422
 
1423
+ return (*ui_refresh(w, highlight, runlog_rows), w, highlight)
1424
 
 
 
 
 
 
 
 
 
1425
 
1426
+ # ---- Configure API slot
1427
+ def ui_save_api_slot(w: World, highlight,
1428
+ slot_name: str, base_url: str, api_key: str, model: str,
1429
+ temperature: float, max_output_tokens: int, system_prompt: str,
1430
+ runlog_rows: int):
1431
+ slot = w.api_slots.get(slot_name)
1432
+ if not slot:
1433
+ w.api_slots[slot_name] = ApiKeySlot(slot_name=slot_name)
1434
+ slot = w.api_slots[slot_name]
1435
 
1436
+ slot.base_url = (base_url or "").strip() or "https://api.openai.com/v1"
1437
+ slot.api_key = (api_key or "").strip() # stored only in state
1438
+ slot.model = (model or "").strip() or "gpt-4o-mini"
1439
+ slot.temperature = float(clamp(float(temperature), 0.0, 2.0))
1440
+ slot.max_output_tokens = int(clamp(int(max_output_tokens), 16, 1200))
1441
+ slot.system_prompt = (system_prompt or "").strip() or slot.system_prompt
1442
 
1443
+ # pricing profile for API agents (optional)
1444
+ if "API-LLM" not in w.model_profiles:
1445
+ w.model_profiles["API-LLM"] = {"in_per_1m": 5.00, "out_per_1m": 15.00, "tps": 120.0}
1446
 
1447
+ masked = ("(set)" if slot.api_key else "(empty)")
1448
+ w.events.append(f"t={w.step}: saved {slot_name} base_url={slot.base_url} model={slot.model} api_key={masked}")
1449
+ return (*ui_refresh(w, highlight, runlog_rows), w, highlight)
1450
 
1451
 
1452
+ # ---- Downloads (these now work reliably)
1453
+ def ui_download_jsonl(w: World) -> str:
1454
+ return write_runlog_jsonl_file(w)
 
 
 
 
 
1455
 
1456
 
1457
+ def ui_download_csv(w: World) -> str:
1458
+ return write_ledger_csv_file(w)
 
 
 
 
 
 
 
 
1459
 
1460
 
1461
  # -----------------------------
1462
+ # UI
1463
  # -----------------------------
1464
+ TITLE = "ZEN Orchestrator Arena — Visual Agents + Run Data"
1465
 
1466
  with gr.Blocks(title=TITLE) as demo:
1467
  gr.Markdown(
1468
  f"## {TITLE}\n"
1469
+ "Now the **Run Data is always visible in-app** (scrollable table), and downloads are done via **real files**.\n"
1470
+ "You can also add unlimited agents, including API-driven agents (OpenAI-compatible) with **1 key slot powering up to 10 agents**.\n"
 
 
1471
  )
1472
 
1473
  w0 = init_world(
 
1477
  incident_rate=0.35,
1478
  max_parallel=3,
1479
  backlog_size=24,
1480
+ map_preset="Office Corridors",
1481
+ wall_density=0.55,
1482
+ resource_density=0.06,
1483
+ skill_mix="Balanced",
1484
  )
1485
 
1486
  w_state = gr.State(w0)
1487
  highlight_state = gr.State(None)
 
1488
  autoplay_on = gr.State(False)
1489
  timer = gr.Timer(value=0.18, active=False)
1490
 
 
1508
  difficulty = gr.Slider(0.0, 1.0, value=0.55, step=0.01, label="Difficulty")
1509
  incident_rate = gr.Slider(0.0, 1.0, value=0.35, step=0.01, label="Incident Rate")
1510
  with gr.Row():
1511
+ max_parallel = gr.Slider(1, 12, value=3, step=1, label="Max parallel tasks")
1512
+ backlog_size = gr.Slider(8, 120, value=24, step=1, label="Backlog size")
1513
+ skill_mix = gr.Dropdown(["Balanced", "Engineering-heavy", "Ops-heavy", "Product-heavy"], value="Balanced", label="Task skill mix")
1514
  btn_reset = gr.Button("Reset Scenario")
1515
 
1516
+ scenario_prompt = gr.Textbox(
1517
+ value=w0.scenario_prompt,
1518
+ label="Scenario Context Prompt (global)",
1519
+ lines=4,
1520
+ placeholder="Give context like: 'We are a startup racing to ship an agent sim product. Optimize for speed, realism, and clean logs.'",
1521
+ )
1522
+ btn_save_scenario = gr.Button("Save Scenario Prompt")
1523
+
1524
+ with gr.Accordion("Office Setup (Map)", open=False):
1525
+ with gr.Row():
1526
+ map_preset = gr.Dropdown(["Office Corridors", "Open Office", "Warehouse Grid"], value="Office Corridors", label="Map preset")
1527
+ wall_density = gr.Slider(0.05, 0.85, value=0.55, step=0.01, label="Wall density")
1528
+ resource_density = gr.Slider(0.0, 0.25, value=0.06, step=0.01, label="Resource density")
1529
+ btn_regen_map = gr.Button("Regenerate Map (keep run)")
1530
+
1531
+ with gr.Accordion("Tasks (Regenerate)", open=False):
1532
+ with gr.Row():
1533
+ regen_backlog_size = gr.Slider(8, 200, value=24, step=1, label="New backlog size")
1534
+ regen_skill_mix = gr.Dropdown(["Balanced", "Engineering-heavy", "Ops-heavy", "Product-heavy"], value="Balanced", label="New skill mix")
1535
+ btn_regen_tasks = gr.Button("Regenerate Tasks (keep run)")
1536
+
1537
  with gr.Accordion("Autoplay / Run", open=True):
1538
  autoplay_speed = gr.Slider(0.05, 0.8, value=0.18, step=0.01, label="Autoplay tick interval (sec)")
1539
  with gr.Row():
 
1548
  with gr.Accordion("Camera & Visuals", open=False):
1549
  overlay = gr.Checkbox(value=True, label="POV Overlay Reticle")
1550
  auto_camera = gr.Checkbox(value=True, label="Auto Camera Cuts")
1551
+ pov_pick = gr.Dropdown(choices=list(w0.agents.keys()), value="ENG", label="POV Agent")
1552
 
1553
  with gr.Accordion("Incidents", open=False):
1554
  task_id = gr.Textbox(value="T001", label="Task ID (e.g., T001)")
1555
  incident_note = gr.Textbox(value="Vendor outage", label="Incident note")
1556
  btn_inject = gr.Button("Inject Incident (force block + highlight)")
1557
 
1558
+ with gr.Accordion("Add Agents (Unlimited)", open=True):
1559
  with gr.Row():
1560
+ add_count = gr.Slider(1, 20, value=3, step=1, label="How many to add")
1561
+ base_name = gr.Textbox(value="A", label="Base name (auto increments)")
1562
+ role = gr.Textbox(value="Custom Agent", label="Role title")
1563
+ with gr.Row():
1564
+ engine = gr.Dropdown(["sim", "api"], value="sim", label="Engine")
1565
+ api_slot = gr.Dropdown(["KEYSLOT_A", "KEYSLOT_B"], value="KEYSLOT_A", label="API Key Slot (max 10 agents per slot)")
1566
+ sim_model = gr.Dropdown(list(DEFAULT_MODEL_PROFILES.keys()), value="Sim-GPT-4o", label="Sim model profile")
1567
+ behavior_prompt = gr.Textbox(
1568
+ value="",
1569
+ lines=3,
1570
+ label="Behavior Prompt (per new agent)",
1571
+ placeholder="Example: 'Act like a ruthless optimizations engineer. Prioritize unblocking and finishing tasks fast.'"
1572
+ )
1573
+ btn_add_agents = gr.Button("Add Agents")
1574
+
1575
+ with gr.Accordion("API Key Slots (Optional)", open=False):
1576
+ gr.Markdown(
1577
+ "This supports **OpenAI-compatible** chat completion endpoints.\n"
1578
+ "- Base URL: typically `https://api.openai.com/v1`\n"
1579
+ "- 1 slot can power **up to 10 agents** (enforced)\n"
1580
+ "- If you leave the key empty, API agents will still exist but will log `[API_ERROR]`.\n"
1581
+ )
1582
+ with gr.Row():
1583
+ slot_pick = gr.Dropdown(["KEYSLOT_A", "KEYSLOT_B"], value="KEYSLOT_A", label="Slot")
1584
+ base_url = gr.Textbox(value="https://api.openai.com/v1", label="Base URL")
1585
+ with gr.Row():
1586
+ api_key = gr.Textbox(value="", label="API Key", type="password")
1587
+ api_model = gr.Textbox(value="gpt-4o-mini", label="Model")
1588
  with gr.Row():
1589
+ temperature = gr.Slider(0.0, 2.0, value=0.3, step=0.01, label="Temperature")
1590
+ max_out = gr.Slider(16, 1200, value=220, step=1, label="Max output tokens")
1591
+ sys_prompt = gr.Textbox(value="You are an agent in a multi-agent office simulation. Be concise, action-oriented, and realistic.", lines=4, label="System Prompt")
1592
+ btn_save_slot = gr.Button("Save Slot")
 
1593
 
1594
  with gr.Accordion("Exports", open=True):
1595
  with gr.Row():
1596
+ dl_jsonl = gr.DownloadButton("Download Run Log (JSONL)", fn=ui_download_jsonl, inputs=[w_state])
1597
+ dl_csv = gr.DownloadButton("Download Ledger (CSV)", fn=ui_download_csv, inputs=[w_state])
1598
+
1599
+ with gr.Accordion("Run Data (Scroll + Download)", open=True):
1600
+ runlog_rows = gr.Slider(50, 2000, value=400, step=50, label="Rows shown in table (last N)")
1601
+ run_data = gr.Dataframe(label="Run Data", interactive=False, wrap=True, height=420)
1602
 
1603
  # initial load
1604
  demo.load(
1605
+ lambda w, h, rows: (*ui_refresh(w, h, rows), w, h),
1606
+ inputs=[w_state, highlight_state, runlog_rows],
1607
+ outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state],
1608
  queue=True,
1609
  )
1610
 
1611
  # reset
1612
  btn_reset.click(
1613
  ui_reset,
1614
+ inputs=[seed, sim_preset, difficulty, incident_rate, max_parallel, backlog_size, map_preset, wall_density, resource_density, skill_mix, runlog_rows],
1615
+ outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state],
1616
  queue=True,
1617
  )
1618
 
1619
  # run
1620
+ btn_run.click(ui_run, inputs=[w_state, highlight_state, run_n, runlog_rows],
1621
+ outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state], queue=True)
1622
+ btn_run50.click(lambda w, h, rows: ui_run(w, h, 50, rows), inputs=[w_state, highlight_state, runlog_rows],
1623
+ outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state], queue=True)
1624
+ btn_run200.click(lambda w, h, rows: ui_run(w, h, 200, rows), inputs=[w_state, highlight_state, runlog_rows],
1625
+ outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state], queue=True)
1626
 
1627
  # incidents
1628
  btn_inject.click(
1629
  ui_inject_incident,
1630
+ inputs=[w_state, highlight_state, task_id, incident_note, runlog_rows],
1631
+ outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state],
1632
  queue=True,
1633
  )
1634
 
1635
  # visuals
1636
+ overlay.change(ui_set_overlay, inputs=[w_state, highlight_state, overlay, runlog_rows],
1637
+ outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state], queue=True)
1638
+ auto_camera.change(ui_set_autocam, inputs=[w_state, highlight_state, auto_camera, runlog_rows],
1639
+ outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state], queue=True)
1640
+ pov_pick.change(ui_set_pov, inputs=[w_state, highlight_state, pov_pick, runlog_rows],
1641
+ outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state], queue=True)
1642
+
1643
+ # scenario prompt
1644
+ btn_save_scenario.click(ui_update_scenario_prompt, inputs=[w_state, highlight_state, scenario_prompt, runlog_rows],
1645
+ outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state], queue=True)
1646
+
1647
+ # map/tasks regen
1648
+ btn_regen_map.click(ui_regen_map, inputs=[w_state, highlight_state, map_preset, wall_density, resource_density, runlog_rows],
1649
+ outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state], queue=True)
1650
+ btn_regen_tasks.click(ui_regen_tasks, inputs=[w_state, highlight_state, regen_backlog_size, regen_skill_mix, runlog_rows],
1651
+ outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state], queue=True)
1652
+
1653
+ # add agents
1654
+ btn_add_agents.click(
1655
+ ui_add_agents,
1656
+ inputs=[w_state, highlight_state, add_count, base_name, role, engine, api_slot, sim_model, behavior_prompt, runlog_rows],
1657
+ outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state],
1658
+ queue=True,
1659
+ )
1660
 
1661
+ # save api slot
1662
+ btn_save_slot.click(
1663
+ ui_save_api_slot,
1664
+ inputs=[w_state, highlight_state, slot_pick, base_url, api_key, api_model, temperature, max_out, sys_prompt, runlog_rows],
1665
+ outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state],
1666
+ queue=True,
1667
+ )
1668
 
1669
+ # runlog row slider refresh
1670
+ runlog_rows.change(
1671
+ lambda w, h, rows: (*ui_refresh(w, h, rows), w, h),
1672
+ inputs=[w_state, highlight_state, runlog_rows],
1673
+ outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state],
1674
+ queue=True,
1675
+ )
1676
 
1677
  # autoplay
1678
+ def autoplay_start(w: World, h, interval: float, rows: int):
1679
  interval = float(interval)
1680
+ return gr.update(value=interval, active=True), True, (*ui_refresh(w, h, rows)), w, h
1681
 
1682
+ def autoplay_stop(w: World, h, rows: int):
1683
+ return gr.update(active=False), False, (*ui_refresh(w, h, rows)), w, h
1684
 
1685
+ btn_play.click(
1686
+ autoplay_start,
1687
+ inputs=[w_state, highlight_state, autoplay_speed, runlog_rows],
1688
+ outputs=[timer, autoplay_on, arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state],
1689
+ queue=True,
1690
+ )
1691
+ btn_pause.click(
1692
+ autoplay_stop,
1693
+ inputs=[w_state, highlight_state, runlog_rows],
1694
+ outputs=[timer, autoplay_on, arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state],
1695
+ queue=True,
1696
+ )
1697
 
1698
+ def autoplay_tick(w: World, h, is_on: bool, rows: int):
1699
  if not is_on:
1700
+ return (*ui_refresh(w, h, rows), w, h, is_on, gr.update())
1701
+ r = make_rng(w.seed + w.step * 31)
1702
  if not w.done:
1703
  tick(w, r)
1704
  if w.done:
1705
+ return (*ui_refresh(w, h, rows), w, h, False, gr.update(active=False))
1706
+ return (*ui_refresh(w, h, rows), w, h, True, gr.update())
1707
 
1708
  timer.tick(
1709
  autoplay_tick,
1710
+ inputs=[w_state, highlight_state, autoplay_on, runlog_rows],
1711
+ outputs=[arena, pov_img, status, agents_box, tasks_box, events, kpis, run_data, w_state, highlight_state, autoplay_on, timer],
1712
  queue=True,
1713
  )
1714