Deploy Labrats live Space (free-tier, remote embeddings)
Browse files- app.py +74 -74
- src/labrats/agents/react_agent.py +35 -0
- src/labrats/env/actions.py +73 -2
- src/labrats/live.py +9 -2
- src/labrats/memory/embedder.py +7 -0
- src/labrats/memory/store.py +27 -2
- src/labrats/models/hf_provider.py +25 -1
- src/labrats/models/local_provider.py +28 -1
- src/labrats/orchestrator/episode.py +25 -1
- src/labrats/telemetry/perf.py +106 -0
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 |
-
|
| 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(
|
| 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 =
|
| 118 |
#: Hard ceiling on agents for the same reason (each agent is an LLM call
|
| 119 |
#: per tick).
|
| 120 |
-
MAX_AGENTS_CAP =
|
| 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.
|
| 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.
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|