inspect-robots-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ """inspect-robots-agent — frontier LLMs as first-class Inspect Robots policies.
2
+
3
+ An LLM behind any OpenAI-compatible API (OpenRouter, OpenAI, local
4
+ vLLM/Ollama, Anthropic's compat endpoint) drives whatever embodiment it is
5
+ paired with: each tool call becomes one smooth, approver-checked action
6
+ chunk. Registered as the policy ``agent``::
7
+
8
+ inspect-robots "pick up the cube" --policy agent \
9
+ -P model=anthropic/claude-fable-5 --embodiment cubepick
10
+
11
+ or programmatically::
12
+
13
+ from inspect_robots import eval
14
+ from inspect_robots_agent import LLMAgentPolicy
15
+
16
+ eval("my-task", LLMAgentPolicy(model="openai/gpt-5.2"), "cubepick")
17
+
18
+ The policy is discovered via the ``inspect_robots.policies`` entry point, so
19
+ it shows up in ``inspect-robots list policies`` without being imported first.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from inspect_robots_agent.policy import AgentPolicyConfig, LLMAgentPolicy, agent_policy
25
+
26
+ __all__ = ["AgentPolicyConfig", "LLMAgentPolicy", "agent_policy"]
27
+
28
+ __version__ = "0.1.0"
@@ -0,0 +1,186 @@
1
+ """OpenAI-compatible chat client + provider resolution (plan 0008 §4a).
2
+
3
+ No provider SDKs: one ``httpx`` client speaking the chat-completions wire
4
+ format covers OpenRouter, OpenAI, local vLLM/Ollama, and Anthropic's
5
+ OpenAI-compat endpoint — the same "speak the protocol, don't import the
6
+ package" doctrine as the xpolicylab plugin.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from inspect_robots.errors import ConfigError
19
+
20
+ ENV_MODEL = "INSPECT_ROBOTS_MODEL"
21
+
22
+ _ANTHROPIC_BASE = "https://api.anthropic.com/v1"
23
+ _OPENAI_BASE = "https://api.openai.com/v1"
24
+ _OPENROUTER_BASE = "https://openrouter.ai/api/v1"
25
+ _OPENROUTER_KEY = "OPENROUTER_API_KEY"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Provider:
30
+ """A resolved OpenAI-compatible endpoint: where, with which key, which model."""
31
+
32
+ base_url: str
33
+ api_key: str
34
+ model: str
35
+
36
+
37
+ def resolve_provider(
38
+ model: str | None,
39
+ base_url: str | None,
40
+ api_key_env: str | None,
41
+ env: Mapping[str, str],
42
+ ) -> Provider:
43
+ """The key/base-url ladder (plan 0008 §4a); first match wins.
44
+
45
+ 1. Explicit ``base_url`` — any OpenAI-compatible endpoint; the key comes
46
+ from ``api_key_env`` (default ``OPENROUTER_API_KEY``), and a missing
47
+ key is allowed (local vLLM/Ollama endpoints are typically keyless).
48
+ 2. ``anthropic/*`` model + ``ANTHROPIC_API_KEY`` — the Anthropic compat
49
+ endpoint (provider prefix stripped from the model id).
50
+ 3. ``openai/*`` model + ``OPENAI_API_KEY`` — OpenAI (prefix stripped).
51
+ 4. ``OPENROUTER_API_KEY`` — OpenRouter, which takes the full
52
+ ``provider/model`` string.
53
+
54
+ Anything else is a guided [`ConfigError`][inspect_robots.errors.ConfigError]
55
+ naming the fixes — never a traceback at the user.
56
+ """
57
+ if not model:
58
+ raise ConfigError(
59
+ "no model configured for the agent policy.\n"
60
+ f"fix: pass -P model=provider/model (e.g. anthropic/claude-fable-5) "
61
+ f"or set ${ENV_MODEL}"
62
+ )
63
+ if base_url:
64
+ key_env = api_key_env or _OPENROUTER_KEY
65
+ return Provider(base_url=base_url.rstrip("/"), api_key=env.get(key_env, ""), model=model)
66
+ provider_prefix, _, bare_model = model.partition("/")
67
+ if provider_prefix == "anthropic" and (key := env.get("ANTHROPIC_API_KEY")):
68
+ return Provider(base_url=_ANTHROPIC_BASE, api_key=key, model=bare_model)
69
+ if provider_prefix == "openai" and (key := env.get("OPENAI_API_KEY")):
70
+ return Provider(base_url=_OPENAI_BASE, api_key=key, model=bare_model)
71
+ if key := env.get(_OPENROUTER_KEY):
72
+ return Provider(base_url=_OPENROUTER_BASE, api_key=key, model=model)
73
+ raise ConfigError(
74
+ f"no API key found for model {model!r}.\n"
75
+ f"fix: set ${_OPENROUTER_KEY} (works for any model), or the provider's "
76
+ "key ($ANTHROPIC_API_KEY for anthropic/*, $OPENAI_API_KEY for openai/*), "
77
+ "or pass -P base_url=... (+ -P api_key_env=NAME) for a custom endpoint"
78
+ )
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class ToolCall:
83
+ """One tool invocation the model asked for; ``arguments`` is raw JSON text."""
84
+
85
+ id: str
86
+ name: str
87
+ arguments: str
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class AssistantMessage:
92
+ """The parsed ``choices[0].message`` of a chat completion."""
93
+
94
+ content: str | None
95
+ tool_calls: tuple[ToolCall, ...]
96
+
97
+ def raw(self) -> dict[str, Any]:
98
+ """The wire-format dict to append back onto the conversation."""
99
+ message: dict[str, Any] = {"role": "assistant", "content": self.content}
100
+ if self.tool_calls:
101
+ message["tool_calls"] = [
102
+ {
103
+ "id": c.id,
104
+ "type": "function",
105
+ "function": {"name": c.name, "arguments": c.arguments},
106
+ }
107
+ for c in self.tool_calls
108
+ ]
109
+ return message
110
+
111
+
112
+ class ChatClient:
113
+ """Blocking chat-completions client with bounded retry on transient failures.
114
+
115
+ Retries 429/5xx and transport errors with exponential backoff; a 4xx is
116
+ our request's fault and fails immediately. Persistent failure raises
117
+ ``RuntimeError``, which the rollout wraps as ``PolicyError``.
118
+ """
119
+
120
+ def __init__(
121
+ self,
122
+ provider: Provider,
123
+ *,
124
+ timeout_s: float = 120.0,
125
+ max_retries: int = 3,
126
+ backoff_s: float = 1.0,
127
+ transport: httpx.BaseTransport | None = None,
128
+ ):
129
+ self._provider = provider
130
+ self._max_retries = max_retries
131
+ self._backoff_s = backoff_s
132
+ headers = {}
133
+ if provider.api_key:
134
+ headers["Authorization"] = f"Bearer {provider.api_key}"
135
+ self._http = httpx.Client(
136
+ base_url=provider.base_url,
137
+ headers=headers,
138
+ timeout=timeout_s,
139
+ transport=transport,
140
+ )
141
+
142
+ def complete(
143
+ self,
144
+ messages: list[dict[str, Any]],
145
+ tools: list[dict[str, Any]],
146
+ temperature: float | None = None,
147
+ ) -> AssistantMessage:
148
+ body: dict[str, Any] = {"model": self._provider.model, "messages": messages}
149
+ if tools:
150
+ body["tools"] = tools
151
+ if temperature is not None:
152
+ body["temperature"] = temperature
153
+
154
+ last_error = "unknown error"
155
+ for attempt in range(self._max_retries):
156
+ try:
157
+ response = self._http.post("/chat/completions", json=body)
158
+ except httpx.TransportError as exc:
159
+ last_error = str(exc)
160
+ else:
161
+ if response.status_code == 200:
162
+ return _parse_message(response.json())
163
+ last_error = f"HTTP {response.status_code}: {response.text[:500]}"
164
+ if response.status_code not in (429,) and response.status_code < 500:
165
+ # A 4xx is our request's fault; retrying cannot help.
166
+ raise RuntimeError(f"LLM request rejected — {last_error}")
167
+ if attempt + 1 < self._max_retries:
168
+ time.sleep(self._backoff_s * 2**attempt)
169
+ raise RuntimeError(f"LLM request failed after {self._max_retries} attempts — {last_error}")
170
+
171
+ def close(self) -> None:
172
+ self._http.close()
173
+
174
+
175
+ def _parse_message(payload: dict[str, Any]) -> AssistantMessage:
176
+ message = payload["choices"][0]["message"]
177
+ calls = tuple(
178
+ ToolCall(
179
+ id=str(c["id"]),
180
+ name=str(c["function"]["name"]),
181
+ arguments=str(c["function"]["arguments"]),
182
+ )
183
+ for c in message.get("tool_calls") or []
184
+ )
185
+ content = message.get("content")
186
+ return AssistantMessage(content=content if content is None else str(content), tool_calls=calls)
@@ -0,0 +1,50 @@
1
+ """Minimal stdlib PNG encoder for camera frames (no Pillow dependency).
2
+
3
+ LLM APIs take images as base64 data URLs; this encodes an ``(H, W, C)``
4
+ uint8/float array as an uncompressed-filter PNG using only zlib + struct.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import struct
11
+ import zlib
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+ import numpy.typing as npt
16
+
17
+ _COLOR_TYPE_BY_CHANNELS = {1: 0, 3: 2, 4: 6}
18
+
19
+
20
+ def _chunk(tag: bytes, data: bytes) -> bytes:
21
+ return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data))
22
+
23
+
24
+ def encode_png(image: npt.NDArray[Any]) -> bytes:
25
+ """Encode an ``(H, W)`` or ``(H, W, {1,3,4})`` array as PNG bytes.
26
+
27
+ Float arrays are assumed normalized to [0, 1] and scaled; everything else
28
+ is cast to uint8.
29
+ """
30
+ arr = np.asarray(image)
31
+ if np.issubdtype(arr.dtype, np.floating):
32
+ arr = (np.clip(arr, 0.0, 1.0) * 255.0).round()
33
+ arr = np.ascontiguousarray(arr.astype(np.uint8))
34
+ if arr.ndim == 2:
35
+ arr = arr[:, :, np.newaxis]
36
+ height, width, channels = arr.shape
37
+ color_type = _COLOR_TYPE_BY_CHANNELS[channels]
38
+ header = struct.pack(">IIBBBBB", width, height, 8, color_type, 0, 0, 0)
39
+ raw = b"".join(b"\x00" + arr[row].tobytes() for row in range(height))
40
+ return (
41
+ b"\x89PNG\r\n\x1a\n"
42
+ + _chunk(b"IHDR", header)
43
+ + _chunk(b"IDAT", zlib.compress(raw))
44
+ + _chunk(b"IEND", b"")
45
+ )
46
+
47
+
48
+ def png_data_url(image: npt.NDArray[Any]) -> str:
49
+ """The ``data:image/png;base64,...`` form LLM APIs accept inline."""
50
+ return "data:image/png;base64," + base64.b64encode(encode_png(image)).decode("ascii")
@@ -0,0 +1,282 @@
1
+ """The agent's tool surface over a bound action space (plan 0008 §4b/§4c).
2
+
3
+ Tools are generated from the embodiment's spaces — the plugin never knows
4
+ what a "YAM" or an "arm" is. The control mode picks the motion tool:
5
+
6
+ - Absolute-target modes (``joint_pos``, ``eef_abs_pose``): ``move_joints``
7
+ with named partial targets, linearly interpolated from the current
8
+ observed state; hold-still repeats the current state.
9
+ - Displacement modes (``eef_delta_pos``, ``eef_delta_pose``,
10
+ ``joint_delta``): ``move_by`` splits the requested displacement evenly
11
+ across the chunk's steps; hold-still is all zeros.
12
+
13
+ Tool mistakes (unknown label, non-finite value, bad duration, malformed
14
+ JSON) come back as structured error strings the LLM sees and can correct —
15
+ never exceptions. Unsupported configurations fail at build (= ``bind``)
16
+ time with a clear message.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from dataclasses import dataclass
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+ import numpy.typing as npt
27
+
28
+ from inspect_robots.spaces import Box, ObservationSpace
29
+ from inspect_robots.types import Action, ActionChunk, Observation
30
+
31
+ _ABSOLUTE_MODES = frozenset({"joint_pos", "eef_abs_pose"})
32
+ _DISPLACEMENT_MODES = frozenset({"eef_delta_pos", "eef_delta_pose", "joint_delta"})
33
+ _POSE_MODES = frozenset({"eef_abs_pose", "eef_delta_pose"})
34
+ _SAFE_ROT = frozenset({"none", "rot6d"})
35
+
36
+ _FALLBACK_HZ = 10.0
37
+ _MAX_DURATION_S = 10.0
38
+
39
+
40
+ class ToolsetError(Exception):
41
+ """An action space / observation space this toolset cannot drive."""
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ToolResult:
46
+ """One executed tool call: an action chunk to play, or an error for the LLM.
47
+
48
+ Exactly one of ``chunk``/``error`` is set. ``note`` is the human/LLM-facing
49
+ confirmation text for successful calls.
50
+ """
51
+
52
+ chunk: ActionChunk | None = None
53
+ error: str | None = None
54
+ note: str = ""
55
+
56
+
57
+ class Toolset:
58
+ """Schemas + execution for one bound embodiment. Built via ``build_toolset``."""
59
+
60
+ def __init__(
61
+ self,
62
+ *,
63
+ absolute: bool,
64
+ labels: tuple[str, ...],
65
+ state_key: str | None,
66
+ control_hz: float | None,
67
+ bounds_text: str,
68
+ ):
69
+ self._absolute = absolute
70
+ self._labels = labels
71
+ self._index_by_label = {label: i for i, label in enumerate(labels)}
72
+ self._state_key = state_key
73
+ self._hz = control_hz
74
+ self._move_tool = "move_joints" if absolute else "move_by"
75
+ self._bounds_text = bounds_text
76
+
77
+ # -- schemas ---------------------------------------------------------------
78
+
79
+ def schemas(self) -> list[dict[str, Any]]:
80
+ """OpenAI-format tool definitions for this embodiment."""
81
+ if self._absolute:
82
+ move_description = (
83
+ "Move to absolute joint/dimension targets, smoothly interpolated "
84
+ "from the current pose over duration_s. Unnamed dimensions hold "
85
+ "their current value. " + self._bounds_text
86
+ )
87
+ values_key = "targets"
88
+ else:
89
+ move_description = (
90
+ "Move BY the given displacement per dimension, split evenly over "
91
+ "duration_s. Unnamed dimensions do not move. " + self._bounds_text
92
+ )
93
+ values_key = "deltas"
94
+ move = {
95
+ "type": "function",
96
+ "function": {
97
+ "name": self._move_tool,
98
+ "description": move_description,
99
+ "parameters": {
100
+ "type": "object",
101
+ "properties": {
102
+ values_key: {
103
+ "type": "object",
104
+ "description": (
105
+ "Map of dimension name to value. Valid names: "
106
+ + ", ".join(self._labels)
107
+ ),
108
+ },
109
+ "duration_s": {
110
+ "type": "number",
111
+ "description": f"Motion duration in seconds (0 < d <= {_MAX_DURATION_S})",
112
+ },
113
+ },
114
+ "required": [values_key, "duration_s"],
115
+ },
116
+ },
117
+ }
118
+ done = {
119
+ "type": "function",
120
+ "function": {
121
+ "name": "done",
122
+ "description": "Declare the task finished. The trial ends; a scorer judges success.",
123
+ "parameters": {
124
+ "type": "object",
125
+ "properties": {"summary": {"type": "string"}},
126
+ "required": ["summary"],
127
+ },
128
+ },
129
+ }
130
+ give_up = {
131
+ "type": "function",
132
+ "function": {
133
+ "name": "give_up",
134
+ "description": "Stop trying; the task cannot be completed. The trial ends.",
135
+ "parameters": {
136
+ "type": "object",
137
+ "properties": {"reason": {"type": "string"}},
138
+ "required": ["reason"],
139
+ },
140
+ },
141
+ }
142
+ return [move, done, give_up]
143
+
144
+ # -- execution ---------------------------------------------------------------
145
+
146
+ def execute(self, call: Any, observation: Observation) -> ToolResult:
147
+ """Turn one tool call into an ActionChunk, or an error string for the LLM."""
148
+ try:
149
+ arguments = json.loads(call.arguments)
150
+ except (TypeError, ValueError):
151
+ return ToolResult(error=f"arguments for {call.name} are not valid JSON")
152
+ if not isinstance(arguments, dict):
153
+ return ToolResult(error=f"arguments for {call.name} must be a JSON object")
154
+ if call.name in ("done", "give_up"):
155
+ return self._stop(call.name, arguments, observation)
156
+ if call.name != self._move_tool:
157
+ return ToolResult(
158
+ error=f"unknown tool {call.name!r}; available: {self._move_tool}, done, give_up"
159
+ )
160
+ return self._move(arguments, observation)
161
+
162
+ def _current_state(self, observation: Observation) -> npt.NDArray[np.float64]:
163
+ if self._state_key is None:
164
+ return np.zeros(len(self._labels))
165
+ return np.asarray(observation.state[self._state_key], dtype=np.float64)
166
+
167
+ def _stop(self, name: str, arguments: dict[str, Any], observation: Observation) -> ToolResult:
168
+ # Hold still per control mode: repeat the pose (absolute) or move by
169
+ # nothing (displacement), flagged for rollout's policy-stop channel.
170
+ data = self._current_state(observation) if self._absolute else np.zeros(len(self._labels))
171
+ detail = str(arguments.get("summary") or arguments.get("reason") or "")
172
+ action = Action(
173
+ data=data,
174
+ meta={"request_stop": True, "stop_reason": name, "stop_detail": detail},
175
+ )
176
+ return ToolResult(
177
+ chunk=ActionChunk(actions=[action], control_hz=self._hz),
178
+ note=f"{name}: {detail}",
179
+ )
180
+
181
+ def _move(self, arguments: dict[str, Any], observation: Observation) -> ToolResult:
182
+ values_key = "targets" if self._absolute else "deltas"
183
+ values = arguments.get(values_key)
184
+ duration = arguments.get("duration_s")
185
+ if not isinstance(values, dict) or not values:
186
+ return ToolResult(error=f"{values_key} must be a non-empty object of name: value")
187
+ if not isinstance(duration, (int, float)) or isinstance(duration, bool):
188
+ return ToolResult(error="duration_s must be a number")
189
+ if not 0 < float(duration) <= _MAX_DURATION_S:
190
+ return ToolResult(
191
+ error=f"duration_s must be in (0, {_MAX_DURATION_S}] seconds, got {duration}"
192
+ )
193
+
194
+ vector = np.zeros(len(self._labels))
195
+ for label, raw in values.items():
196
+ index = self._index_by_label.get(str(label))
197
+ if index is None:
198
+ return ToolResult(
199
+ error=f"unknown dimension {label!r}; valid names: {', '.join(self._labels)}"
200
+ )
201
+ if isinstance(raw, bool) or not isinstance(raw, (int, float)) or not np.isfinite(raw):
202
+ return ToolResult(error=f"value for {label!r} must be a finite number, got {raw!r}")
203
+ vector[index] = float(raw)
204
+
205
+ hz = self._hz if self._hz is not None else _FALLBACK_HZ
206
+ steps = max(1, round(float(duration) * hz))
207
+ if self._absolute:
208
+ current = self._current_state(observation)
209
+ target = current.copy()
210
+ for label in values:
211
+ target[self._index_by_label[str(label)]] = vector[self._index_by_label[str(label)]]
212
+ fractions = np.linspace(1.0 / steps, 1.0, steps)
213
+ actions = [Action(data=current + (target - current) * f) for f in fractions]
214
+ else:
215
+ per_step = vector / steps
216
+ actions = [Action(data=per_step.copy()) for _ in range(steps)]
217
+ return ToolResult(
218
+ chunk=ActionChunk(actions=actions, control_hz=self._hz),
219
+ note=f"executing {self._move_tool} over {steps} steps ({duration}s)",
220
+ )
221
+
222
+
223
+ def build_toolset(
224
+ action_space: Box, observation_space: ObservationSpace, control_hz: float | None
225
+ ) -> Toolset:
226
+ """Validate a (action space, observation space) pairing and build its tools.
227
+
228
+ Raises [`ToolsetError`][inspect_robots_agent._tools.ToolsetError] with a
229
+ clear message for configurations the motion layer cannot drive — at bind
230
+ time, never mid-trial.
231
+ """
232
+ semantics = action_space.semantics
233
+ if semantics is None:
234
+ raise ToolsetError(
235
+ "the embodiment's action space declares no semantics; the agent cannot "
236
+ "tell absolute targets from displacements"
237
+ )
238
+ mode = semantics.control_mode
239
+ if mode in _POSE_MODES and semantics.rotation_repr not in _SAFE_ROT:
240
+ raise ToolsetError(
241
+ f"rotation_repr {semantics.rotation_repr!r} cannot be driven per-dimension; "
242
+ f"only {sorted(_SAFE_ROT)} are supported"
243
+ )
244
+ if mode not in _ABSOLUTE_MODES | _DISPLACEMENT_MODES:
245
+ raise ToolsetError(f"control_mode {mode!r} is not supported by the agent policy yet")
246
+
247
+ absolute = mode in _ABSOLUTE_MODES
248
+ dim = action_space.dim
249
+ state_key: str | None = None
250
+ if absolute:
251
+ spec = observation_space.state
252
+ if spec is None:
253
+ raise ToolsetError(
254
+ "absolute-target control needs a StateSpec on the embodiment's "
255
+ "observation space to locate the proprioceptive reference"
256
+ )
257
+ matching = [f.key for f in spec.fields if f.shape == (dim,)]
258
+ if len(matching) != 1:
259
+ raise ToolsetError(
260
+ f"absolute-target control needs exactly one state field with shape "
261
+ f"({dim},); found {matching or 'none'}"
262
+ )
263
+ state_key = matching[0]
264
+
265
+ labels = semantics.dim_labels or tuple(str(i) for i in range(dim))
266
+ low, high = action_space.low, action_space.high
267
+ if low is not None and high is not None:
268
+ pairs = ", ".join(
269
+ f"{label}: [{lo:.4g}, {hi:.4g}]"
270
+ for label, lo, hi in zip(labels, low.tolist(), high.tolist())
271
+ )
272
+ bounds_text = f"Per-dimension bounds: {pairs}."
273
+ else:
274
+ bounds_text = "The action space declares no bounds; move conservatively."
275
+
276
+ return Toolset(
277
+ absolute=absolute,
278
+ labels=labels,
279
+ state_key=state_key,
280
+ control_hz=control_hz,
281
+ bounds_text=bounds_text,
282
+ )
@@ -0,0 +1,211 @@
1
+ """LLMAgentPolicy — a frontier LLM as a first-class Inspect Robots policy.
2
+
3
+ The conversation loop lives inside ``act()``: observation in (labeled state
4
+ text + camera frames), one validated tool call out, synthesized into an
5
+ open-loop ``ActionChunk`` by the motion layer. The LLM never sees raw
6
+ actuation, and every emitted action still passes the rollout's approver
7
+ chain — this module contains no safety-critical code path of its own
8
+ (plan 0008 §4b).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ import httpx
19
+ import numpy as np
20
+
21
+ from inspect_robots.embodiment import EmbodimentInfo
22
+ from inspect_robots.policy import PolicyBase, PolicyConfig, PolicyInfo
23
+ from inspect_robots.scene import Scene
24
+ from inspect_robots.spaces import Box
25
+ from inspect_robots.types import ActionChunk, Observation
26
+ from inspect_robots_agent._llm import ENV_MODEL, ChatClient, ToolCall, resolve_provider
27
+ from inspect_robots_agent._png import png_data_url
28
+ from inspect_robots_agent._tools import Toolset, build_toolset
29
+
30
+ _MAX_CONSECUTIVE_FAILURES = 3
31
+
32
+ _SYSTEM_TEMPLATE = """You are controlling a real robot embodiment named {name!r} \
33
+ through tool calls. Each observation message gives you the current \
34
+ proprioceptive state and camera images. Work toward the user's goal in \
35
+ small, deliberate motions; re-check the observation after every motion. \
36
+ Safety approvers clamp out-of-bounds and too-fast actions below you. \
37
+ Respond with exactly one tool call per turn. When the goal is achieved call \
38
+ done; if it cannot be achieved call give_up. You have a budget of \
39
+ {budget} LLM calls for the whole trial."""
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class AgentPolicyConfig(PolicyConfig):
44
+ """Inference-time configuration recorded in the eval log.
45
+
46
+ Extends the core ``PolicyConfig``; ``eval()`` serializes configs with
47
+ ``dataclasses.asdict``, so these fields land in ``EvalSpec.policy_config``
48
+ for free.
49
+ """
50
+
51
+ model: str | None = None
52
+ base_url: str | None = None
53
+ api_key_env: str | None = None
54
+ max_llm_calls: int = 50
55
+
56
+
57
+ class LLMAgentPolicy(PolicyBase):
58
+ """Drives whatever embodiment it is bound to via LLM tool calls.
59
+
60
+ Embodiment-adaptive: ``bind()`` (called by ``eval()`` before the
61
+ compatibility check) adopts the embodiment's spaces and builds the tool
62
+ surface from them. Conversation state is per-trial (``reset``).
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ model: str | None = None,
68
+ base_url: str | None = None,
69
+ api_key_env: str | None = None,
70
+ max_llm_calls: int = 50,
71
+ temperature: float | None = None,
72
+ transport: httpx.BaseTransport | None = None,
73
+ env: dict[str, str] | None = None,
74
+ ):
75
+ environ = dict(os.environ) if env is None else env
76
+ provider = resolve_provider(
77
+ model=model or environ.get(ENV_MODEL),
78
+ base_url=base_url,
79
+ api_key_env=api_key_env,
80
+ env=environ,
81
+ )
82
+ if max_llm_calls < 1:
83
+ raise ValueError("max_llm_calls must be >= 1")
84
+ self._client = ChatClient(provider, transport=transport)
85
+ self._max_llm_calls = max_llm_calls
86
+ self._temperature = temperature
87
+ self.config = AgentPolicyConfig(
88
+ temperature=temperature,
89
+ model=provider.model,
90
+ base_url=provider.base_url,
91
+ api_key_env=api_key_env,
92
+ max_llm_calls=max_llm_calls,
93
+ )
94
+ # Placeholder until bind(); eval() always binds before compat/rollout.
95
+ self.info = PolicyInfo(name="agent", action_space=Box(shape=(1,)))
96
+ self._toolset: Toolset | None = None
97
+ self._embodiment_name = "(unbound)"
98
+ self._messages: list[dict[str, Any]] = []
99
+ self._calls_used = 0
100
+
101
+ # -- lifecycle ---------------------------------------------------------------
102
+
103
+ def bind(self, embodiment_info: EmbodimentInfo) -> None:
104
+ """Adopt the embodiment's spaces and build the tool surface from them."""
105
+ self._toolset = build_toolset(
106
+ embodiment_info.action_space,
107
+ embodiment_info.observation_space,
108
+ embodiment_info.control_hz,
109
+ )
110
+ self._embodiment_name = embodiment_info.name
111
+ self.info = PolicyInfo(
112
+ name="agent",
113
+ action_space=embodiment_info.action_space,
114
+ observation_space=embodiment_info.observation_space,
115
+ control_hz=embodiment_info.control_hz,
116
+ )
117
+
118
+ def reset(self, scene: Scene) -> None:
119
+ self._messages = [
120
+ {
121
+ "role": "system",
122
+ "content": _SYSTEM_TEMPLATE.format(
123
+ name=self._embodiment_name, budget=self._max_llm_calls
124
+ ),
125
+ },
126
+ {"role": "user", "content": f"Goal: {scene.instruction}"},
127
+ ]
128
+ self._calls_used = 0
129
+
130
+ # -- the loop ------------------------------------------------------------------
131
+
132
+ def act(self, observation: Observation) -> ActionChunk:
133
+ toolset = self._toolset
134
+ if toolset is None:
135
+ raise RuntimeError(
136
+ "LLMAgentPolicy.act() before bind(); run it through eval() or call "
137
+ "policy.bind(embodiment.info) first"
138
+ )
139
+ self._messages.append({"role": "user", "content": _observation_content(observation)})
140
+ failures = 0
141
+ while True:
142
+ if self._calls_used >= self._max_llm_calls:
143
+ return self._forced_give_up(toolset, observation, "LLM call budget exhausted")
144
+ message = self._client.complete(
145
+ self._messages, toolset.schemas(), temperature=self._temperature
146
+ )
147
+ self._calls_used += 1
148
+ self._messages.append(message.raw())
149
+
150
+ if not message.tool_calls:
151
+ failures += 1
152
+ if failures >= _MAX_CONSECUTIVE_FAILURES:
153
+ raise RuntimeError(f"LLM produced no tool call in {failures} consecutive turns")
154
+ self._messages.append(
155
+ {"role": "user", "content": "Respond with exactly one tool call."}
156
+ )
157
+ continue
158
+
159
+ call, *extras = message.tool_calls
160
+ for extra in extras:
161
+ # Every tool_call id needs an answer on the wire; only the
162
+ # first call per turn is executed.
163
+ self._messages.append(
164
+ {
165
+ "role": "tool",
166
+ "tool_call_id": extra.id,
167
+ "content": "ignored: one tool call per turn",
168
+ }
169
+ )
170
+ result = toolset.execute(call, observation)
171
+ self._messages.append(
172
+ {"role": "tool", "tool_call_id": call.id, "content": result.error or result.note}
173
+ )
174
+ if result.error:
175
+ failures += 1
176
+ if failures >= _MAX_CONSECUTIVE_FAILURES:
177
+ raise RuntimeError(f"LLM tool calls kept failing; last error: {result.error}")
178
+ continue
179
+ assert result.chunk is not None # execute() sets exactly one of chunk/error
180
+ return result.chunk
181
+
182
+ def _forced_give_up(self, toolset: Toolset, observation: Observation, why: str) -> ActionChunk:
183
+ synthetic = ToolCall(id="budget", name="give_up", arguments=json.dumps({"reason": why}))
184
+ result = toolset.execute(synthetic, observation)
185
+ assert result.chunk is not None
186
+ return result.chunk
187
+
188
+
189
+ def _observation_content(observation: Observation) -> list[dict[str, Any]]:
190
+ """State as readable text plus camera frames as inline PNG data URLs."""
191
+ lines = ["Current observation."]
192
+ if observation.instruction:
193
+ lines.append(f"Instruction: {observation.instruction}")
194
+ for key, value in observation.state.items():
195
+ rounded = np.round(np.asarray(value, dtype=np.float64), 4).tolist()
196
+ lines.append(f"state[{key}]: {rounded}")
197
+ parts: list[dict[str, Any]] = [{"type": "text", "text": "\n".join(lines)}]
198
+ for name, image in observation.images.items():
199
+ parts.append({"type": "text", "text": f"camera {name!r}:"})
200
+ parts.append({"type": "image_url", "image_url": {"url": png_data_url(image)}})
201
+ return parts
202
+
203
+
204
+ def agent_policy(**kwargs: Any) -> LLMAgentPolicy:
205
+ """Factory the Inspect Robots registry calls (entry point ``agent``).
206
+
207
+ Accepts the same keyword arguments as
208
+ [`LLMAgentPolicy`][inspect_robots_agent.policy.LLMAgentPolicy]; the CLI
209
+ forwards ``-P key=value`` pairs here.
210
+ """
211
+ return LLMAgentPolicy(**kwargs)
File without changes
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: inspect-robots-agent
3
+ Version: 0.1.0
4
+ Summary: LLM agent policy for Inspect Robots: frontier LLMs (Claude, GPT, anything OpenAI-compatible) drive any registered embodiment through tool calls.
5
+ Project-URL: Homepage, https://github.com/robocurve/inspect-robots/tree/main/plugins/inspect-robots-agent
6
+ Project-URL: Repository, https://github.com/robocurve/inspect-robots
7
+ Author: Inspect Robots contributors
8
+ License-Expression: MIT
9
+ Keywords: agent,evaluation,inspect_robots,llm,robotics,vla
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: httpx>=0.27
18
+ Requires-Dist: inspect-robots>=0.4
19
+ Requires-Dist: numpy>=1.24
20
+ Provides-Extra: dev
21
+ Requires-Dist: mypy>=1.11; extra == 'dev'
22
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
23
+ Requires-Dist: pytest>=8.0; extra == 'dev'
24
+ Requires-Dist: ruff>=0.6; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # inspect-robots-agent
28
+
29
+ LLM agent policy for [Inspect Robots](https://github.com/robocurve/inspect-robots):
30
+ frontier LLMs (Claude, GPT, anything behind an OpenAI-compatible API) drive any
31
+ registered embodiment through tool calls, as a first-class `Policy` named
32
+ `agent`. The same policy runs ad-hoc instructions and scores on registered
33
+ tasks next to fine-tuned VLAs.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install inspect-robots inspect-robots-agent
39
+ ```
40
+
41
+ ## Quickstart (no hardware)
42
+
43
+ ```bash
44
+ export ANTHROPIC_API_KEY=sk-ant-...
45
+
46
+ inspect-robots "pick up the cube" --policy agent \
47
+ -P model=anthropic/claude-fable-5 --embodiment cubepick
48
+ ```
49
+
50
+ Model strings are OpenRouter-style `provider/model`, resolved from
51
+ `-P model=...` or `$INSPECT_ROBOTS_MODEL`. API keys come from the environment:
52
+
53
+ 1. `-P base_url=...` (with `-P api_key_env=NAME`): any OpenAI-compatible endpoint
54
+ 2. `anthropic/*` model with `ANTHROPIC_API_KEY`: the Anthropic compat endpoint
55
+ 3. `openai/*` model with `OPENAI_API_KEY`: OpenAI
56
+ 4. `OPENROUTER_API_KEY`: OpenRouter, any model string
57
+
58
+ ## How it works
59
+
60
+ Each LLM tool call becomes one smooth, open-loop action chunk: named partial
61
+ joint targets are interpolated at the embodiment's control rate
62
+ (`move_joints`), displacements are split across steps (`move_by`), and
63
+ `done`/`give_up` end the trial through the core's policy-stop channel. Every
64
+ action still passes the CLI's default safety approvers (bounds clamp plus
65
+ per-step delta limit); the plugin contains no safety-critical code path of
66
+ its own.
67
+
68
+ > **Warning:**
69
+ > Guardrails are on by default at the CLI. **Never pass `--disable-guardrails`
70
+ > on real hardware** unless you fully trust the policy and the rig.
71
+
72
+ Configuration knobs (all `-P key=value`): `model`, `base_url`, `api_key_env`,
73
+ `max_llm_calls`, `temperature`.
@@ -0,0 +1,10 @@
1
+ inspect_robots_agent/__init__.py,sha256=VXmY2Xr1USMOve2eqp3EhvOwV1ObhedCW_COezgg-oo,1021
2
+ inspect_robots_agent/_llm.py,sha256=Vm7yWbrCtJ9uQlGFiHQ-d6FfyuaNvBPJ1_VyiaVIJYM,6741
3
+ inspect_robots_agent/_png.py,sha256=ESKxe8iRc982L8Jv_ScZvT5iWNLwQEjE6Sp1sV6_gSs,1648
4
+ inspect_robots_agent/_tools.py,sha256=uoyuF_UgmWfsQid3IQY_rjYSd5BC-psLm6NOEruJ_sM,11555
5
+ inspect_robots_agent/policy.py,sha256=oFUAF4jjPFUd_C0t0-cMwRgN-7b-TRS-DJ19BkcoaWU,8629
6
+ inspect_robots_agent/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ inspect_robots_agent-0.1.0.dist-info/METADATA,sha256=UsVAfnd2g670v9L78c5D89PjQL_QH8QB9jehvRlyhJ4,2934
8
+ inspect_robots_agent-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
+ inspect_robots_agent-0.1.0.dist-info/entry_points.txt,sha256=o8w6RAsKhdQlvxFprv2PF3tJeLcFg2kOuEMd2rPBp4Y,75
10
+ inspect_robots_agent-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [inspect_robots.policies]
2
+ agent = inspect_robots_agent.policy:agent_policy