feat(evals): passive/interactive agent eval framework over memory2 - #3411
feat(evals): passive/interactive agent eval framework over memory2#3411spomichter wants to merge 13 commits into
Conversation
EvalCase/PassiveEval/InteractiveEval with EvalRig protocol dispatch, EvalRunner implementing the rig (model call / mcp skill / agent loop / live-store sampling), scorers as plain functions wrapping openevals, generated + hand VQA suites over go2 replays, dimsim go-to-bed interactive suite, dimos evals CLI + EvalModule MCP skills. extracts _init_model to dimos/agents/model.py for shared use.
…e-store sampling)
…ackages, no __all__)
Codecov Report❌ Patch coverage is @@ Coverage Diff @@
## main #3411 +/- ##
==========================================
+ Coverage 76.05% 76.07% +0.02%
==========================================
Files 1201 1212 +11
Lines 117016 117709 +693
Branches 10530 10578 +48
==========================================
+ Hits 88996 89548 +552
- Misses 24974 25098 +124
- Partials 3046 3063 +17
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 5 files with indirect coverage changes 🚀 New features to boost your workflow:
|
…anges reuse mcp_client._init_model lazily instead of extracting it — keeps this PR scoped to dimos/evals (+ cli registration). extraction can be its own PR if we want it shared properly.
|
@greptile review |
|
We need to design runtime and scorer information access more carefully. The current scheme is wrong IMO because it evaluates against Memory2 state produced by the runtime itself. To simulate real operation as faithfully as possible, the agent should have access to all information exposed by the running blueprint, including RPCs, streams, and Memory2. Scoring should instead use independent, privileged simulator state, such as ground-truth robot and object poses and object types. |
| @@ -0,0 +1,202 @@ | |||
| # Evals Intro | |||
There was a problem hiding this comment.
I think this should be in /docs/ as dimos evals is a command that's shipped to everyone.
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
| # House convention (StoreConfig): pass an instance to inject, e.g. a fake | ||
| # chat model in tests. None -> built from `model` like McpClient does. | ||
| chat_model: Any | None = None | ||
| mcp_url: str = "http://localhost:9990/mcp" |
There was a problem hiding this comment.
mcp_port is in global_config. You should probably use that.
| context_budget: int = 8 # max observations encoded per context Select | ||
| attach: bool = False # True: drive an already-running dimos | ||
| launch_timeout_s: float = 1200.0 # blueprint + MCP readiness (e2e parity) | ||
| out_dir: Path = Path("~/.local/state/dimos/evals").expanduser() |
There was a problem hiding this comment.
This should use STATE_DIR / "evals" with STATE_DIR from constants.py
| store.stop() | ||
| return series | ||
|
|
||
| def _wait_live_store(self, deadline: float) -> Store: |
There was a problem hiding this comment.
I don't like the way this works. It assumes that a store is on disk and also that it's a file. That's very specific to Sqlite. Other DBs don't create a single file for a DB.
There was a problem hiding this comment.
I think what should happen here is that class Store should be changed to have something like Store.wait_for_creation (or a better name) and this sort of internal stuff should be performed in SqliteStore.wait_for_creation instead of in unrelated places like here.
| def instruct(self, text: str) -> None: | ||
| from dimos.core.transport import pLCMTransport | ||
|
|
||
| transport: pLCMTransport[str] = pLCMTransport("/human_input") |
There was a problem hiding this comment.
You should use transport = make_transport("/human_input") since we shouldn't be strictly tied to LCM now.
| transport.lcm.start() | ||
| try: | ||
| transport.publish(text) | ||
| time.sleep(0.5) # let LCM flush before teardown |
There was a problem hiding this comment.
Are you sure this is an issue? I don't think I've encountered it. If it is, we should have a transport.flush() rather than a random wait as it would be a problem with transports in general, not just here.
| transport.publish(text) | ||
| time.sleep(0.5) # let LCM flush before teardown | ||
| finally: | ||
| transport.lcm.stop() |
There was a problem hiding this comment.
No need to call transport.lcm.start()/.stop() anymore. Just use transport.start()/.stop()
| from dimos.evals.types import _no_setup | ||
|
|
||
| if case.simulator and not self.config.attach: | ||
| from dimos.e2e_tests.dimos_cli_call import DimosCliCall |
There was a problem hiding this comment.
Since dimos_cli_call is used in dimos/evals now too, it should be moved out of dimos/e2e_tests as it's no longer just for end to end testing. Maybe somewhere in dimos/cli?
There was a problem hiding this comment.
True good call -- ill do a following PR just to keep this PR clean and consoludated
| proc.global_args = ["--dimsim-scene", case.scene] | ||
| proc.demo_args = ["run", *case.blueprint.split()] | ||
| proc.start() | ||
| self._proc = proc |
There was a problem hiding this comment.
The architecture here seems a bit unclear to me.
If I understand this correctly, InteractiveEval gets an EvalRunner and sets itself up on it by calling eval_runner.setup_env(self)
I see EvalRunner as a parent object of multiple InteractiveEval objects. It's generally bad for children to control their parent object instead of the other way arround.
For example, EvalRunner.setup_env sets self._proc = DimosCliCall(). This creates a new process for every eval. When EvalRunner.stop is called, only the last self._proc is ever stopped.
This issue is obscured by the fact that an eval runs itself on the eval runner instead of the eval runner being in charge of running the evals. That is, the fact that EvalRunner._proc is leaked is hard to see because it's ultimately set from InteractiveEval.evaluate.
But also, _proc probably doesn't belong on EvalRunner. It should be on InteractiveEval. Evals should probably have a life cycle where they can be created and destroyed.
Edit: So EvalRunner.run does in fact run the evals, but also the evals know too much about the eval runner and store state on it because they can't store state on themselves.
Problem
Eval framework, supports both InteractiveEvals and PassiveEvals, @paul-nechifor old dimsim spatial memory eval replicated via the new framework here pretty clean:
Uses langchain openevals for VQA formatting so its compatible with all public benchmarks we'd want to run. Scoring is generic and done via lambda one-liners. Agent loop runs LLM queries with a light agent_loop in the EvalRunner -- kept minimal and using langchain client since just doing dumb QA.
Closes DIM-1392, DIM-1390
Solution
dimos/evals/package. one word (evals) everywhere, no benchmark vs eval splitPassiveEval(frozen mem2 recording, any replay window, non-autoregressive) andInteractiveEval(live sim/robot, actions mutate state, scored by sampling teh live recorder store)Streams, no copies, no parallel data structreswithin,ramp, Vector3 subtraction etc), graded [0,1] credit not only pass/failinputs/reference_outputs, evaluators wrapped not subclassed) so external benchmarks map on natively and we can test against themPassiveEval[T]tiesexpected/parse/scoretogehter so mypy catches a mismatched case at suite definition time, not mid-rundimos evals run ...), mcp (EvalModuleskills so coding agents can iterate), and pytest--blindablation built in — same suite with observations withheld, alredy caught two guessable MCQsMoondreamChatadapter so evals run keyless on any gpu boxdefining evals
passive one-off over a replay — the whole thing is one literal:
interactive — the case names its environment, score is sampled from the live mem2 store every
interval_sand reduced byaggregate:running
or
dimos evals run dimos.evals.suites.go2_smoke --blind. every run writes results.jsonl + summary.json + per-case transcripts to~/.local/state/dimos/evals/run-*/.first real numbers
luna sighted: examples 1.00 / smoke 1.00 / vqa 0.86, blind = refusals. interactive rig scored 0.856 on a scripted go-to-bed in dimsim. the full agentic go-to-bed scores 0.0 right now —
unitree-go2-agenticpublishes no /odom in dimsim on current main (upstream test_dimsim_spatial_memory fails teh same way, suspect the control coordinator refactor). separate ticket coming.Breaking Changes
None
How to Test
interactive (needs deno + display):
dimos evals run dimos.evals.suites.dimsim_house --live-db recording_go2.dbContributor License Agreement