mirror of
https://github.com/huggingface/lerobot.git
synced 2026-08-08 17:39:44 +00:00
feat(g05): add language runtime adapter
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""G0.5 integration for the policy-agnostic language runtime."""
|
||||
|
||||
from .g05_adapter import G05PolicyAdapter
|
||||
|
||||
__all__ = ["G05PolicyAdapter"]
|
||||
@@ -0,0 +1,255 @@
|
||||
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""OpenGalaxea G0.5 bridge for the generic language runtime.
|
||||
|
||||
G0.5 generates optional reasoning and an action from one shared inference
|
||||
stream. This adapter deliberately does not run a second planner: System 2 is
|
||||
the ``cot_text`` produced by the same call whose action chunk System 1
|
||||
executes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from lerobot.runtime import RuntimeState
|
||||
from lerobot.runtime.adapter import BaseLanguageAdapter, GenerationConfig
|
||||
|
||||
_SUBTASK_RE = re.compile(
|
||||
r"(?:^|[\n|])\s*Subtask\s*:\s*(.+?)(?=(?:[\n|]\s*(?:Updated\s+Memory|Memory|Action|Plan)\s*:)|$)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_MEMORY_RE = re.compile(
|
||||
r"(?:^|[\n|])\s*(?:Updated\s+Memory|Memory)\s*:\s*(.+?)(?=(?:[\n|]\s*(?:Subtask|Action|Plan)\s*:)|$)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_MODE_ALIASES = {
|
||||
"auto": "auto",
|
||||
"system1": "system1",
|
||||
"system_1": "system1",
|
||||
"direct": "system1",
|
||||
"system2": "system2",
|
||||
"system_2": "system2",
|
||||
"hierarchical": "system2",
|
||||
}
|
||||
_TEXT_KEYS = ("cot_text", "generated_cot", "reasoning")
|
||||
|
||||
|
||||
class G05PolicyAdapter(BaseLanguageAdapter):
|
||||
"""Execute G0.5's unified reasoning/action stream in the language runtime.
|
||||
|
||||
The preferred policy hook is::
|
||||
|
||||
predict_action_chunk_with_runtime(observation, *, task) ->
|
||||
(action_chunk, {"cot_text": ...})
|
||||
|
||||
``task`` is exactly :attr:`RuntimeState.task`, including whitespace and
|
||||
Unicode. The policy must pass it to the author prompt builder unchanged
|
||||
before applying checkpoint-specific formatting. A structured return is
|
||||
required for batch-safe CoT handling; ``predict_action_chunk`` remains a
|
||||
compatibility fallback for System 1.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
policy: Any,
|
||||
gen: GenerationConfig | None = None,
|
||||
*,
|
||||
system_mode: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(policy, gen)
|
||||
self.system_mode = self._resolve_system_mode(system_mode)
|
||||
self._validate_checkpoint_capabilities()
|
||||
|
||||
def _resolve_system_mode(self, requested: str | None) -> str:
|
||||
config = getattr(self.policy, "config", None)
|
||||
raw_mode = requested
|
||||
if raw_mode is None:
|
||||
raw_mode = _read_config(config, "runtime_system_mode")
|
||||
if raw_mode is None:
|
||||
raw_mode = "auto"
|
||||
normalized = _MODE_ALIASES.get(str(raw_mode).strip().lower())
|
||||
if normalized is None:
|
||||
raise ValueError(
|
||||
f"Unknown G0.5 runtime system mode {raw_mode!r}; expected one of {sorted(_MODE_ALIASES)}."
|
||||
)
|
||||
if normalized == "auto":
|
||||
return "system2" if _predict_cot(self.policy) is True else "system1"
|
||||
return normalized
|
||||
|
||||
def _validate_checkpoint_capabilities(self) -> None:
|
||||
config = getattr(self.policy, "config", None)
|
||||
discrete = _read_config(config, "discrete_action")
|
||||
continuous = _read_config(config, "continuous_action")
|
||||
if discrete is False and continuous is False:
|
||||
raise ValueError(
|
||||
"G0.5 System 1 requires an enabled action head, but this checkpoint/config "
|
||||
"has both discrete_action=False and continuous_action=False."
|
||||
)
|
||||
if self.system_mode == "system2" and _predict_cot(self.policy) is not True:
|
||||
raise ValueError(
|
||||
"G0.5 System 2 requires a checkpoint/config with predict_cot=True; "
|
||||
"use runtime_system_mode='system1' for direct task execution."
|
||||
)
|
||||
|
||||
def update_language_state(self, observation: dict[str, Any] | None, state: RuntimeState) -> None:
|
||||
"""Do not invent an independent planner.
|
||||
|
||||
G0.5 System 2 reasoning is collected by :meth:`select_action` from the
|
||||
same inference pass as the action.
|
||||
"""
|
||||
|
||||
def select_action(self, observation: dict[str, Any], state: RuntimeState) -> Any:
|
||||
# A new mapping avoids mutating the provider's cached/preprocessed
|
||||
# observation. The raw operator task is authoritative for G0.5; a
|
||||
# previously generated subtask remains runtime state, not a silent task
|
||||
# replacement before the author prompt path.
|
||||
batch = dict(observation)
|
||||
batch["task"] = state.task
|
||||
|
||||
runtime_hook = getattr(self.policy, "predict_action_chunk_with_runtime", None)
|
||||
if callable(runtime_hook):
|
||||
output = runtime_hook(batch, task=state.task)
|
||||
else:
|
||||
if self.system_mode == "system2":
|
||||
raise RuntimeError(
|
||||
"G0.5 System 2 requires policy.predict_action_chunk_with_runtime() "
|
||||
"so cot_text and the matching action chunk come from one structured inference result."
|
||||
)
|
||||
output = self.policy.predict_action_chunk(batch)
|
||||
|
||||
action_chunk, metadata = _split_runtime_output(output)
|
||||
if self.system_mode == "system2":
|
||||
self._publish_reasoning(metadata, state)
|
||||
return action_chunk
|
||||
|
||||
def generate_text(
|
||||
self,
|
||||
kind: str,
|
||||
observation: dict[str, Any] | None,
|
||||
state: RuntimeState,
|
||||
user_text: str | None = None,
|
||||
) -> str:
|
||||
# The author implementation does not expose a separate policy-agnostic
|
||||
# planner. CoT is emitted in-stream by select_action.
|
||||
return ""
|
||||
|
||||
def handle_interjection(
|
||||
self, user_text: str, observation: dict[str, Any] | None, state: RuntimeState
|
||||
) -> None:
|
||||
# Preserve the operator update verbatim and invalidate the displayed
|
||||
# generated subtask. The runtime will use set_task for task replacement;
|
||||
# this method only records a mid-rollout plan/update.
|
||||
if user_text:
|
||||
state.set_context("plan", user_text, label="plan")
|
||||
state.set_context("subtask", None)
|
||||
|
||||
def _publish_reasoning(self, metadata: Mapping[str, Any], state: RuntimeState) -> None:
|
||||
text = _first_text(metadata, _TEXT_KEYS)
|
||||
if text:
|
||||
_set_generated_context(state, "cot_text", text, label="reasoning")
|
||||
|
||||
explicit_subtask = _as_text(metadata.get("subtask"))
|
||||
subtask = explicit_subtask or _extract_labeled_text(_SUBTASK_RE, text)
|
||||
if subtask:
|
||||
previous = state.language_context.get("subtask")
|
||||
if _set_generated_context(state, "subtask", subtask, label="subtask") and previous:
|
||||
state.extra["prior_subtask"] = previous
|
||||
|
||||
explicit_memory = _as_text(metadata.get("memory"))
|
||||
memory = explicit_memory or _extract_labeled_text(_MEMORY_RE, text)
|
||||
if memory:
|
||||
_set_generated_context(state, "memory", memory, label="memory")
|
||||
|
||||
plan = _as_text(metadata.get("plan"))
|
||||
if plan:
|
||||
_set_generated_context(state, "plan", plan, label="plan")
|
||||
|
||||
|
||||
def _read_config(config: Any, key: str) -> Any:
|
||||
if isinstance(config, Mapping):
|
||||
return config.get(key)
|
||||
return getattr(config, key, None)
|
||||
|
||||
|
||||
def _predict_cot(policy: Any) -> bool | None:
|
||||
config_value = _read_config(getattr(policy, "config", None), "predict_cot")
|
||||
if config_value is not None:
|
||||
return bool(config_value)
|
||||
policy_value = getattr(policy, "predict_cot", None)
|
||||
return None if policy_value is None else bool(policy_value)
|
||||
|
||||
|
||||
def _split_runtime_output(output: Any) -> tuple[Any, Mapping[str, Any]]:
|
||||
if isinstance(output, tuple) and len(output) == 2:
|
||||
action_chunk, raw_metadata = output
|
||||
if isinstance(raw_metadata, Mapping):
|
||||
return action_chunk, raw_metadata
|
||||
if isinstance(raw_metadata, str):
|
||||
return action_chunk, {"cot_text": raw_metadata}
|
||||
raise TypeError(
|
||||
"G0.5 runtime tuple output must contain metadata mapping or cot_text string as its second item."
|
||||
)
|
||||
|
||||
if isinstance(output, Mapping) and (
|
||||
"action_chunk" in output or any(key in output for key in (*_TEXT_KEYS, "subtask", "memory", "plan"))
|
||||
):
|
||||
action_key = "action_chunk" if "action_chunk" in output else "action"
|
||||
if action_key not in output:
|
||||
raise ValueError("G0.5 structured runtime output is missing 'action_chunk' (or 'action').")
|
||||
return output[action_key], output
|
||||
|
||||
return output, {}
|
||||
|
||||
|
||||
def _as_text(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
if isinstance(value, list | tuple) and len(value) == 1 and isinstance(value[0], str):
|
||||
return value[0].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _first_text(metadata: Mapping[str, Any], keys: tuple[str, ...]) -> str:
|
||||
for key in keys:
|
||||
text = _as_text(metadata.get(key))
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_labeled_text(pattern: re.Pattern[str], text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
match = pattern.search(text)
|
||||
return match.group(1).strip() if match else ""
|
||||
|
||||
|
||||
def _set_generated_context(state: RuntimeState, key: str, value: str, *, label: str) -> bool:
|
||||
"""Publish metadata from the current inference without invalidating its action.
|
||||
|
||||
``RuntimeState.revision`` guards against operator changes while inference is
|
||||
in flight. G0.5 reasoning belongs to that same inference result, so bumping
|
||||
the revision here would make the generic runtime discard the matching
|
||||
System 1 action chunk.
|
||||
"""
|
||||
with state.lock:
|
||||
if state.language_context.get(key) == value:
|
||||
return False
|
||||
state.language_context[key] = value
|
||||
state.log(f" {label}: {value}")
|
||||
return True
|
||||
@@ -21,6 +21,7 @@ from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
_ADAPTERS: dict[str, str] = {
|
||||
"g05": "lerobot.policies.g05.inference.g05_adapter:G05PolicyAdapter",
|
||||
"pi052": "lerobot.policies.pi052.inference.pi052_adapter:PI052PolicyAdapter",
|
||||
"pi05": "lerobot.runtime.adapter:DirectTaskPolicyAdapter",
|
||||
"molmoact2": "lerobot.runtime.adapter:DirectTaskPolicyAdapter",
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from lerobot.runtime import LanguageConditionedRuntime, RuntimeState
|
||||
|
||||
|
||||
class FakeG05Policy:
|
||||
def __init__(self, *, predict_cot=False, discrete_action=True, continuous_action=False):
|
||||
self.config = SimpleNamespace(
|
||||
predict_cot=predict_cot,
|
||||
discrete_action=discrete_action,
|
||||
continuous_action=continuous_action,
|
||||
)
|
||||
self.calls = []
|
||||
|
||||
def predict_action_chunk(self, observation):
|
||||
self.calls.append(observation)
|
||||
return ["a0", "a1"]
|
||||
|
||||
|
||||
def test_registry_lazily_resolves_g05_adapter():
|
||||
from lerobot.policies.g05.inference.g05_adapter import G05PolicyAdapter
|
||||
from lerobot.runtime.registry import get_language_adapter_factory
|
||||
|
||||
assert get_language_adapter_factory("g05") is G05PolicyAdapter
|
||||
|
||||
|
||||
def test_system1_passes_exact_runtime_task_without_mutating_observation():
|
||||
from lerobot.policies.g05.inference.g05_adapter import G05PolicyAdapter
|
||||
|
||||
policy = FakeG05Policy()
|
||||
adapter = G05PolicyAdapter(policy)
|
||||
original = {"task": "stale generated subtask", "observation.state": "state"}
|
||||
raw_task = " 把 red cup 放到左边\nexactly as written "
|
||||
|
||||
chunk = adapter.select_action(original, RuntimeState(task=raw_task))
|
||||
|
||||
assert chunk == ["a0", "a1"]
|
||||
assert policy.calls[0]["task"] == raw_task
|
||||
assert original["task"] == "stale generated subtask"
|
||||
|
||||
|
||||
def test_system2_surfaces_same_pass_cot_and_action():
|
||||
from lerobot.policies.g05.inference.g05_adapter import G05PolicyAdapter
|
||||
|
||||
class ReasoningPolicy(FakeG05Policy):
|
||||
def __init__(self):
|
||||
super().__init__(predict_cot=True, continuous_action=True)
|
||||
|
||||
def predict_action_chunk_with_runtime(self, observation, *, task):
|
||||
self.calls.append((observation, task))
|
||||
return {
|
||||
"action_chunk": ["fm0", "fm1"],
|
||||
"cot_text": "BBox: cup [1,2,3,4]|\nSubtask: grasp the cup|Updated Memory: cup located",
|
||||
}
|
||||
|
||||
policy = ReasoningPolicy()
|
||||
adapter = G05PolicyAdapter(policy)
|
||||
state = RuntimeState(task=" clear the table ")
|
||||
|
||||
chunk = adapter.select_action({"task": "wrong"}, state)
|
||||
|
||||
assert chunk == ["fm0", "fm1"]
|
||||
assert policy.calls[0][1] == " clear the table "
|
||||
assert policy.calls[0][0]["task"] == " clear the table "
|
||||
assert (
|
||||
state.language_context["cot_text"]
|
||||
== "BBox: cup [1,2,3,4]|\nSubtask: grasp the cup|Updated Memory: cup located"
|
||||
)
|
||||
assert state.language_context["subtask"] == "grasp the cup"
|
||||
assert state.language_context["memory"] == "cup located"
|
||||
|
||||
|
||||
def test_system2_accepts_batch_safe_tuple_metadata():
|
||||
from lerobot.policies.g05.inference.g05_adapter import G05PolicyAdapter
|
||||
|
||||
class ReasoningPolicy(FakeG05Policy):
|
||||
def __init__(self):
|
||||
super().__init__(predict_cot=True)
|
||||
|
||||
def predict_action_chunk_with_runtime(self, observation, *, task):
|
||||
return ("chunk", {"cot_text": ["Subtask: move left"], "plan": "first move left"})
|
||||
|
||||
state = RuntimeState(task="move")
|
||||
chunk = G05PolicyAdapter(ReasoningPolicy()).select_action({}, state)
|
||||
|
||||
assert chunk == "chunk"
|
||||
assert state.language_context["subtask"] == "move left"
|
||||
assert state.language_context["plan"] == "first move left"
|
||||
|
||||
|
||||
def test_system2_reasoning_does_not_invalidate_same_pass_action_chunk():
|
||||
from lerobot.policies.g05.inference.g05_adapter import G05PolicyAdapter
|
||||
|
||||
class ReasoningPolicy(FakeG05Policy):
|
||||
def __init__(self):
|
||||
super().__init__(predict_cot=True)
|
||||
|
||||
def predict_action_chunk_with_runtime(self, observation, *, task):
|
||||
return (["a0", "a1"], {"cot_text": "Subtask: pick cup"})
|
||||
|
||||
executed = []
|
||||
runtime = LanguageConditionedRuntime(
|
||||
policy_adapter=G05PolicyAdapter(ReasoningPolicy()),
|
||||
observation_provider=lambda: {"task": "pick"},
|
||||
action_executor=executed.append,
|
||||
)
|
||||
runtime.set_task("pick")
|
||||
|
||||
runtime.step_once()
|
||||
|
||||
assert executed == ["a0"]
|
||||
assert list(runtime.state.action_queue) == ["a1"]
|
||||
assert runtime.state.language_context["subtask"] == "pick cup"
|
||||
|
||||
|
||||
def test_system2_rejects_checkpoint_without_predict_cot():
|
||||
from lerobot.policies.g05.inference.g05_adapter import G05PolicyAdapter
|
||||
|
||||
with pytest.raises(ValueError, match="predict_cot=True"):
|
||||
G05PolicyAdapter(FakeG05Policy(predict_cot=False), system_mode="system2")
|
||||
|
||||
|
||||
def test_system1_rejects_checkpoint_without_an_action_head():
|
||||
from lerobot.policies.g05.inference.g05_adapter import G05PolicyAdapter
|
||||
|
||||
with pytest.raises(ValueError, match="both discrete_action=False and continuous_action=False"):
|
||||
G05PolicyAdapter(FakeG05Policy(predict_cot=True, discrete_action=False, continuous_action=False))
|
||||
|
||||
|
||||
def test_system2_requires_structured_single_pass_hook():
|
||||
from lerobot.policies.g05.inference.g05_adapter import G05PolicyAdapter
|
||||
|
||||
adapter = G05PolicyAdapter(FakeG05Policy(predict_cot=True))
|
||||
with pytest.raises(RuntimeError, match="predict_action_chunk_with_runtime"):
|
||||
adapter.select_action({}, RuntimeState(task="pick"))
|
||||
Reference in New Issue
Block a user