Smestern commited on
Commit
2d2d367
·
verified ·
1 Parent(s): 8f12f84

Deploy Labrats live Space (free-tier, remote embeddings)

Browse files
app.py CHANGED
@@ -633,13 +633,83 @@ with gr.Blocks(
633
  A small (\u226432B) LLM agent inside the DiscoveryWorld simulator, with a
634
  two-tier memory layer (private episodic + shared lab notebook).
635
 
636
- This Space replays recorded episodes. **Phase B** is a vanilla ReAct
637
- loop; **Phase C** adds the memory layer. The comparison tab shows the
638
- behavioural delta from adding memory on the same scenario and seed.
639
  """
640
  )
641
 
642
  with gr.Tabs():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
643
  with gr.Tab("Episode replay"):
644
  with gr.Row():
645
  ep_picker = gr.Dropdown(
@@ -802,77 +872,7 @@ behavioural delta from adding memory on the same scenario and seed.
802
  title="Right score over time", height=260, y_lim=[0, 1],
803
  )
804
 
805
- with gr.Tab("Run live"):
806
- _token_ok = has_hf_token()
807
- _local_ok = has_local_model()
808
- _default_backend = "local" if _local_ok else "hf"
809
- _any_backend = _token_ok or _local_ok
810
- _status_bits = []
811
- _status_bits.append(
812
- "`HF_TOKEN` detected" if _token_ok else "no `HF_TOKEN`"
813
- )
814
- _status_bits.append(
815
- "local model configured" if _local_ok else "no local model"
816
- )
817
- gr.Markdown(
818
- "Run a **live episode** — spin up N ReAct agents on a fresh "
819
- "DiscoveryWorld scenario. Choose the **HF** backend (Hugging "
820
- "Face Inference Providers, needs an `HF_TOKEN`) or the **local** "
821
- "backend (in-process llama-cpp, needs the `LLAMA_*` env vars). "
822
- "The run streams into a new trace that appears in the "
823
- "**Episode replay** tab when it finishes.\n\n"
824
- + (
825
- "**Status:** " + ", ".join(_status_bits) + "."
826
- if _any_backend
827
- else "**Status:** no backend is configured (no `HF_TOKEN` and "
828
- "no local model), so live runs are disabled. Recorded "
829
- "episodes still work in the other tabs."
830
- )
831
- )
832
- with gr.Row():
833
- live_backend = gr.Radio(
834
- choices=["local", "hf"],
835
- value=_default_backend,
836
- label="Backend",
837
- scale=2,
838
- )
839
- live_scenario = gr.Dropdown(
840
- choices=LIVE_SCENARIOS,
841
- value="Archaeology Dating",
842
- label="Scenario",
843
- allow_custom_value=True,
844
- scale=3,
845
- )
846
- live_difficulty = gr.Dropdown(
847
- choices=["Easy", "Normal", "Challenge"],
848
- value="Normal",
849
- label="Difficulty",
850
- scale=2,
851
- )
852
- live_seed = gr.Number(value=0, precision=0, label="Seed", scale=1)
853
- with gr.Row():
854
- live_agents = gr.Slider(
855
- minimum=1, maximum=LiveRunner.MAX_AGENTS_CAP, value=2, step=1,
856
- label="Agents", scale=2,
857
- )
858
- live_steps = gr.Slider(
859
- minimum=1, maximum=LiveRunner.MAX_STEPS_CAP, value=15, step=1,
860
- label="Max steps", scale=3,
861
- )
862
- live_memory = gr.Checkbox(value=True, label="Memory", scale=1)
863
- live_dialogue = gr.Checkbox(value=True, label="Dialogue", scale=1)
864
- live_start_btn = gr.Button(
865
- "\u25b6 Start live episode", variant="primary", interactive=_any_backend
866
- )
867
- live_status_md = gr.Markdown(_live_status_md())
868
- # Live viewport: the latest rendered frame, streamed in as the
869
- # episode runs. Mirrors the playback view on the replay tab.
870
- live_frame_caption = gr.Markdown("_(frames appear here once a run starts)_")
871
- live_frame_view = gr.HTML(
872
- "<div style='padding:1em;color:#888'>(no frames yet)</div>"
873
- )
874
- # Polls the background runner while an episode is in flight.
875
- live_timer = gr.Timer(value=1.0, active=False)
876
 
877
  # ---- wiring --------------------------------------------------
878
 
 
633
  A small (\u226432B) LLM agent inside the DiscoveryWorld simulator, with a
634
  two-tier memory layer (private episodic + shared lab notebook).
635
 
 
 
 
636
  """
637
  )
638
 
639
  with gr.Tabs():
640
+ with gr.Tab("Run live"):
641
+ _token_ok = has_hf_token()
642
+ _local_ok = has_local_model()
643
+ _default_backend = "local" if _local_ok else "hf"
644
+ _any_backend = _token_ok or _local_ok
645
+ _status_bits = []
646
+ _status_bits.append(
647
+ "`HF_TOKEN` detected" if _token_ok else "no `HF_TOKEN`"
648
+ )
649
+ _status_bits.append(
650
+ "local model configured" if _local_ok else "no local model"
651
+ )
652
+ gr.Markdown(
653
+ "Run a **live episode** — spin up N ReAct agents on a fresh "
654
+ "DiscoveryWorld scenario. Choose the **HF** backend (Hugging "
655
+ "Face Inference Providers, needs an `HF_TOKEN`) or the **local** "
656
+ "backend (in-process llama-cpp, needs the `LLAMA_*` env vars). "
657
+ "The run streams into a new trace that appears in the "
658
+ "**Episode replay** tab when it finishes.\n\n"
659
+ + (
660
+ "**Status:** " + ", ".join(_status_bits) + "."
661
+ if _any_backend
662
+ else "**Status:** no backend is configured (no `HF_TOKEN` and "
663
+ "no local model), so live runs are disabled. Recorded "
664
+ "episodes still work in the other tabs."
665
+ )
666
+ )
667
+ with gr.Row():
668
+ live_backend = gr.Radio(
669
+ choices=["local", "hf"],
670
+ value=_default_backend,
671
+ label="Backend",
672
+ scale=2,
673
+ )
674
+ live_scenario = gr.Dropdown(
675
+ choices=LIVE_SCENARIOS,
676
+ value="Archaeology Dating",
677
+ label="Scenario",
678
+ allow_custom_value=True,
679
+ scale=3,
680
+ )
681
+ live_difficulty = gr.Dropdown(
682
+ choices=["Easy", "Normal", "Challenge"],
683
+ value="Normal",
684
+ label="Difficulty",
685
+ scale=2,
686
+ )
687
+ live_seed = gr.Number(value=0, precision=0, label="Seed", scale=1)
688
+ with gr.Row():
689
+ live_agents = gr.Slider(
690
+ minimum=1, maximum=LiveRunner.MAX_AGENTS_CAP, value=2, step=1,
691
+ label="Agents", scale=2,
692
+ )
693
+ live_steps = gr.Slider(
694
+ minimum=1, maximum=LiveRunner.MAX_STEPS_CAP, value=15, step=1,
695
+ label="Max steps", scale=3,
696
+ )
697
+ live_memory = gr.Checkbox(value=True, label="Memory", scale=1)
698
+ live_dialogue = gr.Checkbox(value=True, label="Dialogue", scale=1)
699
+ live_start_btn = gr.Button(
700
+ "\u25b6 Start live episode", variant="primary", interactive=_any_backend
701
+ )
702
+ live_status_md = gr.Markdown(_live_status_md())
703
+ # Live viewport: the latest rendered frame, streamed in as the
704
+ # episode runs. Mirrors the playback view on the replay tab.
705
+ live_frame_caption = gr.Markdown("_(frames appear here once a run starts)_")
706
+ live_frame_view = gr.HTML(
707
+ "<div style='padding:1em;color:#888'>(no frames yet)</div>"
708
+ )
709
+ # Polls the background runner while an episode is in flight.
710
+ live_timer = gr.Timer(value=1.0, active=False)
711
+
712
+
713
  with gr.Tab("Episode replay"):
714
  with gr.Row():
715
  ep_picker = gr.Dropdown(
 
872
  title="Right score over time", height=260, y_lim=[0, 1],
873
  )
874
 
875
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
876
 
877
  # ---- wiring --------------------------------------------------
878
 
src/labrats/agents/react_agent.py CHANGED
@@ -17,8 +17,10 @@ No memory retrieval yet — Stage 2 plugs it in via `StepContext.extras`.
17
 
18
  from __future__ import annotations
19
 
 
20
  import json
21
  import re
 
22
  from collections import deque
23
  from collections.abc import Mapping
24
  from dataclasses import dataclass, field
@@ -212,6 +214,10 @@ class ReActAgent:
212
  self._pending: PreviousStep | None = None
213
  # Last retrieval result, exposed for trace/UI.
214
  self.last_retrieval: dict[str, list[MemoryHit]] = {}
 
 
 
 
215
 
216
  def _build_system_prompt(self, peers: list[str]) -> str:
217
  base = _SYSTEM_TEMPLATE.format(
@@ -233,10 +239,16 @@ class ReActAgent:
233
  def step(
234
  self, observation: Mapping[str, Any], ctx: StepContext
235
  ) -> StepDecision:
 
 
 
 
236
  view = compact(observation)
237
  # Flush memory writes for the previously-executed action — this
238
  # is the first turn we can see its `last_action_message`.
 
239
  self._flush_pending(view, ctx)
 
240
  # Anything currently accessible counts as "reached" for the
241
  # loop-break explorer so it stops steering us back to it.
242
  for obj in view.accessible:
@@ -244,10 +256,13 @@ class ReActAgent:
244
  if isinstance(uid, int):
245
  self._visited.add(uid)
246
  # Retrieve memories before deciding.
 
247
  self.last_retrieval = self._retrieve(view, ctx)
 
248
  # Inbox + peer list from the scheduler (dialogue path).
249
  inbox: list[Any] = list(ctx.extras.get("inbox") or [])
250
  peers: list[str] = list(ctx.extras.get("peer_names") or self._static_peers)
 
251
  decision = self._decide(view, ctx, inbox=inbox, peers=peers)
252
  # Binding loop-break: if the chosen action is a verbatim repeat of a
253
  # recently-failed action, override it with a different move so the
@@ -275,6 +290,24 @@ class ReActAgent:
275
  errors=[],
276
  prev_view=view,
277
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  return decision
279
 
280
  def on_action_result(
@@ -501,12 +534,14 @@ class ReActAgent:
501
 
502
  for attempt in range(2):
503
  try:
 
504
  raw_text = self._client.complete(
505
  messages,
506
  response_format=response_format,
507
  temperature=self._temperature,
508
  max_tokens=self._max_tokens,
509
  )
 
510
  except Exception as exc:
511
  # Provider blew up: fall back to a safe MOVE rather than crash.
512
  attempts.append(
 
17
 
18
  from __future__ import annotations
19
 
20
+ import contextlib
21
  import json
22
  import re
23
+ import time
24
  from collections import deque
25
  from collections.abc import Mapping
26
  from dataclasses import dataclass, field
 
214
  self._pending: PreviousStep | None = None
215
  # Last retrieval result, exposed for trace/UI.
216
  self.last_retrieval: dict[str, list[MemoryHit]] = {}
217
+ # Per-step wall-time breakdown (perf diagnosis), exposed for trace.
218
+ self.last_timings: dict[str, float] = {}
219
+ # LLM wall time accumulated within the current step's `_decide`.
220
+ self._llm_seconds = 0.0
221
 
222
  def _build_system_prompt(self, peers: list[str]) -> str:
223
  base = _SYSTEM_TEMPLATE.format(
 
239
  def step(
240
  self, observation: Mapping[str, Any], ctx: StepContext
241
  ) -> StepDecision:
242
+ step_t0 = time.perf_counter()
243
+ store = self._store
244
+ embed0 = getattr(store, "embed_seconds", 0.0)
245
+ vstore0 = getattr(store, "vstore_seconds", 0.0)
246
  view = compact(observation)
247
  # Flush memory writes for the previously-executed action — this
248
  # is the first turn we can see its `last_action_message`.
249
+ flush_t0 = time.perf_counter()
250
  self._flush_pending(view, ctx)
251
+ t_flush = time.perf_counter() - flush_t0
252
  # Anything currently accessible counts as "reached" for the
253
  # loop-break explorer so it stops steering us back to it.
254
  for obj in view.accessible:
 
256
  if isinstance(uid, int):
257
  self._visited.add(uid)
258
  # Retrieve memories before deciding.
259
+ retrieve_t0 = time.perf_counter()
260
  self.last_retrieval = self._retrieve(view, ctx)
261
+ t_retrieve = time.perf_counter() - retrieve_t0
262
  # Inbox + peer list from the scheduler (dialogue path).
263
  inbox: list[Any] = list(ctx.extras.get("inbox") or [])
264
  peers: list[str] = list(ctx.extras.get("peer_names") or self._static_peers)
265
+ self._llm_seconds = 0.0
266
  decision = self._decide(view, ctx, inbox=inbox, peers=peers)
267
  # Binding loop-break: if the chosen action is a verbatim repeat of a
268
  # recently-failed action, override it with a different move so the
 
290
  errors=[],
291
  prev_view=view,
292
  )
293
+ # Record the per-step wall-time breakdown for the trace. embed/vstore
294
+ # are store-wide deltas (this agent's flush+retrieve), llm is the sum
295
+ # of provider calls inside `_decide`.
296
+ t_step = time.perf_counter() - step_t0
297
+ store_size = 0
298
+ if store is not None:
299
+ with contextlib.suppress(Exception):
300
+ store_size = store.count("private") + store.count("notebook")
301
+ self.last_timings = {
302
+ "t_step": round(t_step, 4),
303
+ "t_flush": round(t_flush, 4),
304
+ "t_retrieve": round(t_retrieve, 4),
305
+ "t_llm": round(self._llm_seconds, 4),
306
+ "t_embed": round(getattr(store, "embed_seconds", 0.0) - embed0, 4),
307
+ "t_vstore": round(getattr(store, "vstore_seconds", 0.0) - vstore0, 4),
308
+ "prompt_chars": len(decision.user_prompt or ""),
309
+ "store_size": store_size,
310
+ }
311
  return decision
312
 
313
  def on_action_result(
 
534
 
535
  for attempt in range(2):
536
  try:
537
+ llm_t0 = time.perf_counter()
538
  raw_text = self._client.complete(
539
  messages,
540
  response_format=response_format,
541
  temperature=self._temperature,
542
  max_tokens=self._max_tokens,
543
  )
544
+ self._llm_seconds += time.perf_counter() - llm_t0
545
  except Exception as exc:
546
  # Provider blew up: fall back to a safe MOVE rather than crash.
547
  attempts.append(
src/labrats/env/actions.py CHANGED
@@ -14,7 +14,7 @@ We mirror that schema here so we can:
14
  from __future__ import annotations
15
 
16
  from collections.abc import Mapping
17
- from typing import Any, Literal
18
 
19
  from pydantic import BaseModel, Field, ValidationError
20
 
@@ -34,6 +34,10 @@ ActionName = Literal[
34
  "DISCOVERY_FEED_GET_UPDATES", "DISCOVERY_FEED_GET_POST_BY_ID",
35
  ]
36
 
 
 
 
 
37
  DIRECTIONS = ("north", "east", "south", "west")
38
 
39
  UUID_1_ACTIONS = {
@@ -75,6 +79,68 @@ class ActionValidationError(Exception):
75
  """Raised when an action cannot be executed against the current obs."""
76
 
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  def parse(raw: Any) -> ActionPacket:
79
  """Coerce raw model output into an `ActionPacket`. Raises on failure."""
80
  if isinstance(raw, ActionPacket):
@@ -84,7 +150,7 @@ def parse(raw: Any) -> ActionPacket:
84
  f"action must be a JSON object, got {type(raw).__name__}"
85
  )
86
  try:
87
- return ActionPacket.model_validate(dict(raw))
88
  except ValidationError as e:
89
  raise ActionValidationError(str(e)) from e
90
 
@@ -381,6 +447,11 @@ def schema_summary_for_prompt() -> str:
381
  "\"action\".\n"
382
  "Action shape (the value of \"action\"): {\"action\": NAME, \"arg1\": "
383
  "..., \"arg2\": ...}\n"
 
 
 
 
 
384
  "Argument rules (omit arg2 when not listed):\n"
385
  " PICKUP/DROP/OPEN/CLOSE/ACTIVATE/DEACTIVATE/EAT/READ/TALK/"
386
  "TELEPORT_TO_OBJECT: arg1 = visible UUID (integer)\n"
 
14
  from __future__ import annotations
15
 
16
  from collections.abc import Mapping
17
+ from typing import Any, Literal, get_args
18
 
19
  from pydantic import BaseModel, Field, ValidationError
20
 
 
34
  "DISCOVERY_FEED_GET_UPDATES", "DISCOVERY_FEED_GET_POST_BY_ID",
35
  ]
36
 
37
+ # Case-insensitive lookup from any spelling the model emits to the canonical
38
+ # enum value (e.g. "teleport_to_object" -> "TELEPORT_TO_OBJECT").
39
+ _ACTION_NAME_LOOKUP = {name.lower(): name for name in get_args(ActionName)}
40
+
41
  DIRECTIONS = ("north", "east", "south", "west")
42
 
43
  UUID_1_ACTIONS = {
 
79
  """Raised when an action cannot be executed against the current obs."""
80
 
81
 
82
+ def _unwrap_named_action(d: Mapping[str, Any]) -> dict[str, Any] | None:
83
+ """Normalize the action-name-as-key shapes the model emits without a grammar.
84
+
85
+ Grammar-free decoding lets Gemma invent encodings the JSON schema would
86
+ have forbidden, e.g.:
87
+ {"teleport_to_object": 47477} -> arg1 = scalar
88
+ {"PUT": {"arg1": 1, "arg2": 2}} -> nested arg dict
89
+ {"USE": [1, 2]} -> positional list
90
+ Returns the canonical `{"action": NAME, "arg1": .., "arg2": ..}` dict, or
91
+ None if exactly one action-name key isn't found (so canonical packets and
92
+ genuinely malformed ones fall through to Pydantic for a real error).
93
+ """
94
+ named = [
95
+ (key, _ACTION_NAME_LOOKUP[key.strip().lower()])
96
+ for key in d
97
+ if isinstance(key, str) and key.strip().lower() in _ACTION_NAME_LOOKUP
98
+ ]
99
+ if len(named) != 1:
100
+ return None
101
+ key, canon = named[0]
102
+ value = d[key]
103
+ out: dict[str, Any] = {"action": canon}
104
+ if isinstance(value, Mapping):
105
+ for slot in ("arg1", "arg2"):
106
+ if slot in value:
107
+ out[slot] = value[slot]
108
+ elif isinstance(value, (list, tuple)):
109
+ if len(value) >= 1:
110
+ out["arg1"] = value[0]
111
+ if len(value) >= 2:
112
+ out["arg2"] = value[1]
113
+ elif value is not None:
114
+ out["arg1"] = value
115
+ # Carry over any arg1/arg2 the model placed beside the named key.
116
+ for slot in ("arg1", "arg2"):
117
+ if slot in d and slot not in out:
118
+ out[slot] = d[slot]
119
+ return out
120
+
121
+
122
+ def _coerce_action_shape(raw: Mapping[str, Any]) -> dict[str, Any]:
123
+ """Map common model deviations onto the canonical ActionPacket shape."""
124
+ data = dict(raw)
125
+ action = data.get("action")
126
+ # Canonical-ish: a string action name (normalize casing only).
127
+ if isinstance(action, str):
128
+ canon = _ACTION_NAME_LOOKUP.get(action.strip().lower())
129
+ if canon:
130
+ data["action"] = canon
131
+ return data
132
+ # {"action": {"PUT": {...}}} / {"action": {"teleport_to_object": id}}
133
+ if isinstance(action, Mapping):
134
+ unwrapped = _unwrap_named_action(action)
135
+ if unwrapped is not None:
136
+ return unwrapped
137
+ # Action name used directly as the top-level key, no "action" field.
138
+ unwrapped = _unwrap_named_action(data)
139
+ if unwrapped is not None:
140
+ return unwrapped
141
+ return data
142
+
143
+
144
  def parse(raw: Any) -> ActionPacket:
145
  """Coerce raw model output into an `ActionPacket`. Raises on failure."""
146
  if isinstance(raw, ActionPacket):
 
150
  f"action must be a JSON object, got {type(raw).__name__}"
151
  )
152
  try:
153
+ return ActionPacket.model_validate(_coerce_action_shape(raw))
154
  except ValidationError as e:
155
  raise ActionValidationError(str(e)) from e
156
 
 
447
  "\"action\".\n"
448
  "Action shape (the value of \"action\"): {\"action\": NAME, \"arg1\": "
449
  "..., \"arg2\": ...}\n"
450
+ "NAME is a string in the \"action\" field. Never use the action name "
451
+ "as a key or nest arguments — write {\"action\": \"PUT\", \"arg1\": 1, "
452
+ "\"arg2\": 2}, NOT {\"PUT\": {...}} or {\"action\": {\"put\": ...}}.\n"
453
+ "Example: {\"reason\": \"Pick up the shovel to start digging.\", "
454
+ "\"action\": {\"action\": \"PICKUP\", \"arg1\": 47477}}\n"
455
  "Argument rules (omit arg2 when not listed):\n"
456
  " PICKUP/DROP/OPEN/CLOSE/ACTIVATE/DEACTIVATE/EAT/READ/TALK/"
457
  "TELEPORT_TO_OBJECT: arg1 = visible UUID (integer)\n"
src/labrats/live.py CHANGED
@@ -73,6 +73,13 @@ def has_local_model() -> bool:
73
  or (os.environ.get("LLAMA_REPO_ID") and os.environ.get("LLAMA_FILENAME"))
74
  )
75
 
 
 
 
 
 
 
 
76
 
77
  def backend_available(backend: str) -> bool:
78
  """Whether the given backend has its prerequisites configured."""
@@ -114,10 +121,10 @@ class LiveRunner:
114
 
115
  #: Hard ceiling on steps regardless of what the UI requests, to bound the
116
  #: cost of any single run against the shared token.
117
- MAX_STEPS_CAP = 40
118
  #: Hard ceiling on agents for the same reason (each agent is an LLM call
119
  #: per tick).
120
- MAX_AGENTS_CAP = 4
121
 
122
  def __init__(
123
  self,
 
73
  or (os.environ.get("LLAMA_REPO_ID") and os.environ.get("LLAMA_FILENAME"))
74
  )
75
 
76
+ def max_agents_cap() -> int:
77
+ """The max agents cap for live episodes."""
78
+ return int(os.environ.get("LIVE_MAX_AGENTS_CAP", 4))
79
+
80
+ def max_steps_cap() -> int:
81
+ """The max steps cap for live episodes."""
82
+ return int(os.environ.get("LIVE_MAX_STEPS_CAP", 40))
83
 
84
  def backend_available(backend: str) -> bool:
85
  """Whether the given backend has its prerequisites configured."""
 
121
 
122
  #: Hard ceiling on steps regardless of what the UI requests, to bound the
123
  #: cost of any single run against the shared token.
124
+ MAX_STEPS_CAP = max_steps_cap()
125
  #: Hard ceiling on agents for the same reason (each agent is an LLM call
126
  #: per tick).
127
+ MAX_AGENTS_CAP = max_agents_cap()
128
 
129
  def __init__(
130
  self,
src/labrats/memory/embedder.py CHANGED
@@ -6,6 +6,7 @@ without pulling sentence-transformers / downloading bge-small.
6
 
7
  from __future__ import annotations
8
 
 
9
  import hashlib
10
  import time
11
  from typing import Protocol
@@ -35,12 +36,18 @@ class BGEEmbedder:
35
  def __init__(self, device: str | None = None) -> None:
36
  self._device = device
37
  self._model = None # type: ignore[assignment]
 
 
38
 
39
  def _ensure_model(self):
40
  if self._model is None:
41
  from sentence_transformers import SentenceTransformer
42
 
43
  self._model = SentenceTransformer(self.model_name, device=self._device)
 
 
 
 
44
  return self._model
45
 
46
  def embed(self, texts: list[str]) -> np.ndarray:
 
6
 
7
  from __future__ import annotations
8
 
9
+ import contextlib
10
  import hashlib
11
  import time
12
  from typing import Protocol
 
36
  def __init__(self, device: str | None = None) -> None:
37
  self._device = device
38
  self._model = None # type: ignore[assignment]
39
+ # Filled in on first embed; exposed for the perf trace.
40
+ self.resolved_device: str | None = None
41
 
42
  def _ensure_model(self):
43
  if self._model is None:
44
  from sentence_transformers import SentenceTransformer
45
 
46
  self._model = SentenceTransformer(self.model_name, device=self._device)
47
+ # Surface the device actually chosen (CUDA vs CPU) — a CPU-bound
48
+ # BGE is a prime suspect for the local memory-step slowdown.
49
+ with contextlib.suppress(Exception):
50
+ self.resolved_device = str(self._model.device)
51
  return self._model
52
 
53
  def embed(self, texts: list[str]) -> np.ndarray:
src/labrats/memory/store.py CHANGED
@@ -28,6 +28,7 @@ Chroma backend choices:
28
  from __future__ import annotations
29
 
30
  import contextlib
 
31
  from collections.abc import Iterable
32
  from pathlib import Path
33
  from typing import Any, Protocol
@@ -82,6 +83,14 @@ class ChromaMemoryStore:
82
  import chromadb
83
 
84
  self._embedder = embedder
 
 
 
 
 
 
 
 
85
  self._decay = decay
86
  self._weights = weights
87
  self._dedup = {
@@ -139,20 +148,30 @@ class ChromaMemoryStore:
139
 
140
  # ---- write -------------------------------------------------------
141
 
 
 
 
 
 
 
 
142
  def write(self, record: MemoryRecord) -> bool:
143
  """Embed + insert. Returns False if dedup suppressed the write."""
144
  coll = self._collections[record.tier]
145
- vec = self._embedder.embed([record.content])[0]
146
 
147
  # Dedup: query same tier (author-scoped on private; cross-author on
148
  # notebook), require same type, and require cosine ≥ threshold.
149
  where = self._dedup_where(record)
 
150
  existing = coll.query(
151
  query_embeddings=[vec.tolist()],
152
  n_results=1,
153
  where=where,
154
  include=["metadatas", "distances", "documents"],
155
  )
 
 
156
  ids = (existing.get("ids") or [[]])[0]
157
  if ids:
158
  dist = (existing.get("distances") or [[1.0]])[0][0]
@@ -161,12 +180,15 @@ class ChromaMemoryStore:
161
  if sim >= self._dedup[record.tier]:
162
  return False
163
 
 
164
  coll.add(
165
  ids=[record.id],
166
  documents=[record.content],
167
  embeddings=[vec.tolist()],
168
  metadatas=[_to_chroma_metadata(record)],
169
  )
 
 
170
  return True
171
 
172
  def _dedup_where(self, record: MemoryRecord) -> dict[str, Any]:
@@ -193,7 +215,7 @@ class ChromaMemoryStore:
193
  coll = self._collections[tier]
194
  if coll.count() == 0:
195
  return []
196
- q_vec = self._embedder.embed([query_text])[0]
197
  where = _build_where(tier=tier, author=author, type_filter=type_filter)
198
  kwargs: dict[str, Any] = {
199
  "query_embeddings": [q_vec.tolist()],
@@ -202,7 +224,10 @@ class ChromaMemoryStore:
202
  }
203
  if where is not None:
204
  kwargs["where"] = where
 
205
  res = coll.query(**kwargs)
 
 
206
  ids = (res.get("ids") or [[]])[0]
207
  if not ids:
208
  return []
 
28
  from __future__ import annotations
29
 
30
  import contextlib
31
+ import time
32
  from collections.abc import Iterable
33
  from pathlib import Path
34
  from typing import Any, Protocol
 
83
  import chromadb
84
 
85
  self._embedder = embedder
86
+ # Cumulative perf counters (diagnosing memory-driven slowdown). All
87
+ # embedding goes through this store, so deltas around an agent step
88
+ # attribute time to embed vs vector-store (HNSW) cleanly. Agents run
89
+ # sequentially per tick, so monotonic counters split correctly.
90
+ self.embed_seconds = 0.0
91
+ self.embed_calls = 0
92
+ self.vstore_seconds = 0.0
93
+ self.vstore_calls = 0
94
  self._decay = decay
95
  self._weights = weights
96
  self._dedup = {
 
148
 
149
  # ---- write -------------------------------------------------------
150
 
151
+ def _timed_embed(self, texts: list[str]) -> np.ndarray:
152
+ t0 = time.perf_counter()
153
+ out = self._embedder.embed(texts)
154
+ self.embed_seconds += time.perf_counter() - t0
155
+ self.embed_calls += 1
156
+ return out
157
+
158
  def write(self, record: MemoryRecord) -> bool:
159
  """Embed + insert. Returns False if dedup suppressed the write."""
160
  coll = self._collections[record.tier]
161
+ vec = self._timed_embed([record.content])[0]
162
 
163
  # Dedup: query same tier (author-scoped on private; cross-author on
164
  # notebook), require same type, and require cosine ≥ threshold.
165
  where = self._dedup_where(record)
166
+ t0 = time.perf_counter()
167
  existing = coll.query(
168
  query_embeddings=[vec.tolist()],
169
  n_results=1,
170
  where=where,
171
  include=["metadatas", "distances", "documents"],
172
  )
173
+ self.vstore_seconds += time.perf_counter() - t0
174
+ self.vstore_calls += 1
175
  ids = (existing.get("ids") or [[]])[0]
176
  if ids:
177
  dist = (existing.get("distances") or [[1.0]])[0][0]
 
180
  if sim >= self._dedup[record.tier]:
181
  return False
182
 
183
+ t0 = time.perf_counter()
184
  coll.add(
185
  ids=[record.id],
186
  documents=[record.content],
187
  embeddings=[vec.tolist()],
188
  metadatas=[_to_chroma_metadata(record)],
189
  )
190
+ self.vstore_seconds += time.perf_counter() - t0
191
+ self.vstore_calls += 1
192
  return True
193
 
194
  def _dedup_where(self, record: MemoryRecord) -> dict[str, Any]:
 
215
  coll = self._collections[tier]
216
  if coll.count() == 0:
217
  return []
218
+ q_vec = self._timed_embed([query_text])[0]
219
  where = _build_where(tier=tier, author=author, type_filter=type_filter)
220
  kwargs: dict[str, Any] = {
221
  "query_embeddings": [q_vec.tolist()],
 
224
  }
225
  if where is not None:
226
  kwargs["where"] = where
227
+ t0 = time.perf_counter()
228
  res = coll.query(**kwargs)
229
+ self.vstore_seconds += time.perf_counter() - t0
230
+ self.vstore_calls += 1
231
  ids = (res.get("ids") or [[]])[0]
232
  if not ids:
233
  return []
src/labrats/models/hf_provider.py CHANGED
@@ -47,6 +47,7 @@ class HFProviderClient:
47
  provider: str | None = None,
48
  token: str | None = None,
49
  timeout: float = 60.0,
 
50
  ) -> None:
51
  self.model = model or os.environ.get("HF_MODEL", DEFAULT_MODEL)
52
  self.provider = provider or os.environ.get("HF_INFERENCE_PROVIDER", DEFAULT_PROVIDER)
@@ -61,6 +62,12 @@ class HFProviderClient:
61
  token=self._token,
62
  timeout=timeout,
63
  )
 
 
 
 
 
 
64
  # Flips to False on the first 422 grammar rejection so we stop
65
  # paying the round-trip cost on every subsequent call.
66
  self._response_format_supported = True
@@ -74,7 +81,11 @@ class HFProviderClient:
74
  max_tokens: int = 512,
75
  ) -> str:
76
  payload = [m.to_dict() for m in messages]
77
- rf = response_format if self._response_format_supported else None
 
 
 
 
78
  last_exc: Exception | None = None
79
  for attempt in range(2):
80
  try:
@@ -111,6 +122,19 @@ def _is_transient(exc: BaseException) -> bool:
111
  return any(h in msg for h in _TRANSIENT_HINTS)
112
 
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  def _is_response_format_rejection(exc: BaseException) -> bool:
115
  msg = str(exc).lower()
116
  # Providers signal "I can't honor this response_format" with different
 
47
  provider: str | None = None,
48
  token: str | None = None,
49
  timeout: float = 60.0,
50
+ grammar: bool | None = None,
51
  ) -> None:
52
  self.model = model or os.environ.get("HF_MODEL", DEFAULT_MODEL)
53
  self.provider = provider or os.environ.get("HF_INFERENCE_PROVIDER", DEFAULT_PROVIDER)
 
62
  token=self._token,
63
  timeout=timeout,
64
  )
65
+ # `response_format` (json_schema grammar) is 8-31x slower on HF: with
66
+ # `provider="auto"` it routes to grammar-capable but slow providers
67
+ # (measured 8-31s/call vs ~0.7-1.3s free). We rely on the prompt + the
68
+ # agent's JSON extractor/coercion/validator/retry instead. Default OFF;
69
+ # set grammar=True (or HF_GRAMMAR=1) to re-enable constrained decoding.
70
+ self._grammar_enabled = _bool_env("HF_GRAMMAR", grammar, default=False)
71
  # Flips to False on the first 422 grammar rejection so we stop
72
  # paying the round-trip cost on every subsequent call.
73
  self._response_format_supported = True
 
81
  max_tokens: int = 512,
82
  ) -> str:
83
  payload = [m.to_dict() for m in messages]
84
+ rf = (
85
+ response_format
86
+ if self._grammar_enabled and self._response_format_supported
87
+ else None
88
+ )
89
  last_exc: Exception | None = None
90
  for attempt in range(2):
91
  try:
 
122
  return any(h in msg for h in _TRANSIENT_HINTS)
123
 
124
 
125
+ def _bool_env(name: str, override: bool | None, *, default: bool) -> bool:
126
+ """Resolve a tri-state bool: explicit override > env var > default.
127
+
128
+ Accepts 1/0, true/false, yes/no, on/off (case-insensitive) for the env.
129
+ """
130
+ if override is not None:
131
+ return override
132
+ raw = os.environ.get(name)
133
+ if raw is None or raw == "":
134
+ return default
135
+ return raw.strip().lower() in ("1", "true", "yes", "on")
136
+
137
+
138
  def _is_response_format_rejection(exc: BaseException) -> bool:
139
  msg = str(exc).lower()
140
  # Providers signal "I can't honor this response_format" with different
src/labrats/models/local_provider.py CHANGED
@@ -46,6 +46,7 @@ class LocalLlamaClient:
46
  n_threads: int | None = None,
47
  seed: int | None = None,
48
  chat_format: str | None = None,
 
49
  verbose: bool = False,
50
  **llama_kwargs: Any,
51
  ) -> None:
@@ -104,6 +105,15 @@ class LocalLlamaClient:
104
  self.model = Path(path).name
105
  self._llama = Llama(model_path=path, **common_kwargs)
106
 
 
 
 
 
 
 
 
 
 
107
  # Flips to False on the first grammar compile failure so we stop
108
  # paying the round-trip cost on every subsequent call.
109
  self._response_format_supported = True
@@ -117,7 +127,11 @@ class LocalLlamaClient:
117
  max_tokens: int = 512,
118
  ) -> str:
119
  payload = [m.to_dict() for m in messages]
120
- rf = _normalize_response_format(response_format) if self._response_format_supported else None
 
 
 
 
121
  try:
122
  resp = self._llama.create_chat_completion(
123
  messages=payload,
@@ -170,6 +184,19 @@ def _int_env_optional(name: str, override: int | None) -> int | None:
170
  return int(raw) if raw is not None and raw != "" else None
171
 
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  def _is_grammar_rejection(exc: BaseException) -> bool:
174
  msg = str(exc).lower()
175
  return any(h in msg for h in _GRAMMAR_REJECT_HINTS)
 
46
  n_threads: int | None = None,
47
  seed: int | None = None,
48
  chat_format: str | None = None,
49
+ grammar: bool | None = None,
50
  verbose: bool = False,
51
  **llama_kwargs: Any,
52
  ) -> None:
 
105
  self.model = Path(path).name
106
  self._llama = Llama(model_path=path, **common_kwargs)
107
 
108
+ # Grammar-constrained decoding (JSON schema/object) is ~8-10x slower
109
+ # on large-vocab models like Gemma-4 (262k vocab): llama-cpp filters
110
+ # the grammar against candidate tokens every step. When disabled we
111
+ # drop `response_format` and rely on the prompt + the agent's JSON
112
+ # extractor/validator/retry instead. Default OFF (a controlled A/B
113
+ # showed identical task score at ~3.8x the speed); set grammar=True
114
+ # (or LLAMA_GRAMMAR=1) to re-enable constrained decoding.
115
+ self._grammar_enabled = _bool_env("LLAMA_GRAMMAR", grammar, default=False)
116
+
117
  # Flips to False on the first grammar compile failure so we stop
118
  # paying the round-trip cost on every subsequent call.
119
  self._response_format_supported = True
 
127
  max_tokens: int = 512,
128
  ) -> str:
129
  payload = [m.to_dict() for m in messages]
130
+ rf = (
131
+ _normalize_response_format(response_format)
132
+ if self._grammar_enabled and self._response_format_supported
133
+ else None
134
+ )
135
  try:
136
  resp = self._llama.create_chat_completion(
137
  messages=payload,
 
184
  return int(raw) if raw is not None and raw != "" else None
185
 
186
 
187
+ def _bool_env(name: str, override: bool | None, *, default: bool) -> bool:
188
+ """Resolve a tri-state bool: explicit override > env var > default.
189
+
190
+ Accepts 1/0, true/false, yes/no, on/off (case-insensitive) for the env.
191
+ """
192
+ if override is not None:
193
+ return override
194
+ raw = os.environ.get(name)
195
+ if raw is None or raw == "":
196
+ return default
197
+ return raw.strip().lower() in ("1", "true", "yes", "on")
198
+
199
+
200
  def _is_grammar_rejection(exc: BaseException) -> bool:
201
  msg = str(exc).lower()
202
  return any(h in msg for h in _GRAMMAR_REJECT_HINTS)
src/labrats/orchestrator/episode.py CHANGED
@@ -4,6 +4,7 @@ from __future__ import annotations
4
 
5
  import contextlib
6
  import hashlib
 
7
  from collections.abc import Iterator, Mapping
8
  from pathlib import Path
9
  from typing import Any
@@ -12,6 +13,7 @@ from ..agents.base import Agent
12
  from ..env.world import DiscoveryWorldEnv
13
  from ..memory.store import MemoryStore
14
  from ..telemetry.agents_log import AgentsLogWriter, _fmt_action
 
15
  from ..telemetry.trace import TraceWriter
16
  from .bus import MessageBus
17
  from .scheduler import SimultaneousScheduler, TickOutcome
@@ -114,7 +116,18 @@ def run_episode(
114
  )
115
 
116
  try:
117
- for outcome in _iter_ticks(scheduler, max_steps):
 
 
 
 
 
 
 
 
 
 
 
118
  if trace is not None:
119
  for idx, decision in outcome.decisions.items():
120
  res = outcome.results[idx]
@@ -139,6 +152,9 @@ def run_episode(
139
  "memory": memory_summary,
140
  "utterance": _utterance_summary(decision),
141
  }
 
 
 
142
  if verbose_agents:
143
  cur_sp = getattr(agent, "_last_system_prompt", "")
144
  sp_hash = _sys_hash(cur_sp) if cur_sp else ""
@@ -213,9 +229,17 @@ def run_episode(
213
  f"successes={successes}/{len(outcome.results)} "
214
  f"progress={_progress_summary(env.scorecard())}"
215
  )
 
 
 
 
 
216
  if outcome.tasks_complete:
217
  break
218
 
 
 
 
219
  final_scorecard = env.scorecard()
220
  summary = {
221
  "tasks_complete": env.tasks_complete(),
 
4
 
5
  import contextlib
6
  import hashlib
7
+ import time
8
  from collections.abc import Iterator, Mapping
9
  from pathlib import Path
10
  from typing import Any
 
13
  from ..env.world import DiscoveryWorldEnv
14
  from ..memory.store import MemoryStore
15
  from ..telemetry.agents_log import AgentsLogWriter, _fmt_action
16
+ from ..telemetry.perf import PerfMonitor
17
  from ..telemetry.trace import TraceWriter
18
  from .bus import MessageBus
19
  from .scheduler import SimultaneousScheduler, TickOutcome
 
116
  )
117
 
118
  try:
119
+ monitor = PerfMonitor(enabled=progress)
120
+ tick_iter = _iter_ticks(scheduler, max_steps)
121
+ while True:
122
+ tick_t0 = time.perf_counter()
123
+ try:
124
+ outcome = next(tick_iter)
125
+ except StopIteration:
126
+ break
127
+ tick_wall = time.perf_counter() - tick_t0
128
+ monitor.start_tick()
129
+ for idx in outcome.decisions:
130
+ monitor.record_step(getattr(agents[idx], "last_timings", None))
131
  if trace is not None:
132
  for idx, decision in outcome.decisions.items():
133
  res = outcome.results[idx]
 
152
  "memory": memory_summary,
153
  "utterance": _utterance_summary(decision),
154
  }
155
+ timings = getattr(agent, "last_timings", None)
156
+ if timings:
157
+ step_fields["timings"] = timings
158
  if verbose_agents:
159
  cur_sp = getattr(agent, "_last_system_prompt", "")
160
  sp_hash = _sys_hash(cur_sp) if cur_sp else ""
 
229
  f"successes={successes}/{len(outcome.results)} "
230
  f"progress={_progress_summary(env.scorecard())}"
231
  )
232
+ perf_line = monitor.end_tick(
233
+ outcome.tick, outcome.step_counter, tick_wall
234
+ )
235
+ if perf_line:
236
+ print(perf_line)
237
  if outcome.tasks_complete:
238
  break
239
 
240
+ perf_summary = monitor.summary()
241
+ if perf_summary:
242
+ print(perf_summary)
243
  final_scorecard = env.scorecard()
244
  summary = {
245
  "tasks_complete": env.tasks_complete(),
src/labrats/telemetry/perf.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Live performance monitor for episode runs.
2
+
3
+ Aggregates the per-step `timings` block each agent exposes (see
4
+ `ReActAgent.last_timings`) and turns it into a compact per-tick line plus an
5
+ end-of-episode breakdown. This makes the "where does the 30s/tick go?" answer
6
+ visible *during* a run instead of only post-hoc from the trace.
7
+
8
+ Buckets tracked per step (seconds): t_llm, t_embed, t_vstore, t_flush,
9
+ t_retrieve, plus t_step (measured agent-step wall) and the raw tick wall time.
10
+ All buckets are summed across the agents that acted in a tick, because the
11
+ scheduler runs them sequentially — so their times add up to the tick wall.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections import defaultdict
17
+ from dataclasses import dataclass, field
18
+
19
+ # Buckets we attribute, in print order. t_llm first because it dominates.
20
+ BUCKETS = ("t_llm", "t_embed", "t_vstore", "t_flush", "t_retrieve")
21
+ _SHORT = {
22
+ "t_llm": "llm",
23
+ "t_embed": "embed",
24
+ "t_vstore": "vstore",
25
+ "t_flush": "flush",
26
+ "t_retrieve": "retr",
27
+ }
28
+
29
+
30
+ @dataclass
31
+ class PerfMonitor:
32
+ """Accumulates per-step timings and prints per-tick + summary lines.
33
+
34
+ `enabled=False` makes every method a cheap no-op so callers can wire it
35
+ in unconditionally.
36
+ """
37
+
38
+ enabled: bool = True
39
+ _totals: dict[str, float] = field(default_factory=lambda: defaultdict(float))
40
+ _tick_buf: dict[str, float] = field(default_factory=lambda: defaultdict(float))
41
+ _tick_agents: int = 0
42
+ _tick_max_store: int = 0
43
+ _n_ticks: int = 0
44
+ _n_steps: int = 0
45
+ _wall_total: float = 0.0
46
+
47
+ def start_tick(self) -> None:
48
+ """Reset the per-tick accumulators (call before agents act)."""
49
+ if not self.enabled:
50
+ return
51
+ self._tick_buf = defaultdict(float)
52
+ self._tick_agents = 0
53
+ self._tick_max_store = 0
54
+
55
+ def record_step(self, timings: dict[str, float] | None) -> None:
56
+ """Fold one agent's `last_timings` into the current tick + totals."""
57
+ if not self.enabled or not timings:
58
+ return
59
+ self._n_steps += 1
60
+ self._tick_agents += 1
61
+ for key in (*BUCKETS, "t_step"):
62
+ val = float(timings.get(key, 0.0))
63
+ self._tick_buf[key] += val
64
+ self._totals[key] += val
65
+ store = int(timings.get("store_size", 0))
66
+ self._tick_max_store = max(self._tick_max_store, store)
67
+
68
+ def end_tick(self, tick: int, step: int, wall: float) -> str | None:
69
+ """Close the tick, update wall totals, and return a one-line summary."""
70
+ if not self.enabled:
71
+ return None
72
+ self._wall_total += wall
73
+ self._n_ticks += 1
74
+ accounted = sum(self._tick_buf.get(b, 0.0) for b in BUCKETS)
75
+ parts = " ".join(
76
+ f"{_SHORT[b]}={self._tick_buf.get(b, 0.0):.2f}" for b in BUCKETS
77
+ )
78
+ other = max(0.0, wall - accounted)
79
+ return (
80
+ f" [perf] tick={tick:03d} wall={wall:5.2f}s {parts} "
81
+ f"other={other:.2f} store={self._tick_max_store}"
82
+ )
83
+
84
+ def summary(self) -> str | None:
85
+ """Episode-level attribution: per-bucket totals, share, per-tick mean."""
86
+ if not self.enabled or self._n_ticks == 0:
87
+ return None
88
+ accounted = sum(self._totals.get(b, 0.0) for b in BUCKETS)
89
+ denom = max(self._wall_total, accounted) or 1.0
90
+ lines = [
91
+ " [perf] episode summary "
92
+ f"(ticks={self._n_ticks} steps={self._n_steps} "
93
+ f"wall={self._wall_total:.1f}s):"
94
+ ]
95
+ for b in BUCKETS:
96
+ tot = self._totals.get(b, 0.0)
97
+ lines.append(
98
+ f" {_SHORT[b]:>7}: {tot:7.2f}s ({100 * tot / denom:5.1f}%) "
99
+ f"{tot / self._n_ticks:.2f}s/tick"
100
+ )
101
+ other = max(0.0, self._wall_total - accounted)
102
+ lines.append(
103
+ f" {'other':>7}: {other:7.2f}s ({100 * other / denom:5.1f}%) "
104
+ f"{other / self._n_ticks:.2f}s/tick"
105
+ )
106
+ return "\n".join(lines)