creature-lab 0.3.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.
Files changed (182) hide show
  1. creature_lab/__init__.py +18 -0
  2. creature_lab/agents/__init__.py +5 -0
  3. creature_lab/agents/baseline.py +53 -0
  4. creature_lab/agents/llm.py +33 -0
  5. creature_lab/agents/loop.py +112 -0
  6. creature_lab/agents/prompts.py +63 -0
  7. creature_lab/agents/tools.py +101 -0
  8. creature_lab/autopsy.py +376 -0
  9. creature_lab/backends/__init__.py +1 -0
  10. creature_lab/backends/base.py +25 -0
  11. creature_lab/backends/mujoco_backend.py +253 -0
  12. creature_lab/backends/pybullet_backend.py +573 -0
  13. creature_lab/cli.py +3317 -0
  14. creature_lab/controllers/__init__.py +1 -0
  15. creature_lab/controllers/cpg.py +83 -0
  16. creature_lab/controllers/factory.py +208 -0
  17. creature_lab/controllers/pid.py +33 -0
  18. creature_lab/controllers/policy.py +91 -0
  19. creature_lab/controllers/pose_seq.py +38 -0
  20. creature_lab/controllers/posture.py +125 -0
  21. creature_lab/controllers/sinusoid.py +20 -0
  22. creature_lab/controllers/target_seek.py +128 -0
  23. creature_lab/diagnosis.py +435 -0
  24. creature_lab/diagnostics.py +261 -0
  25. creature_lab/editor/__init__.py +5 -0
  26. creature_lab/editor/controls.py +2495 -0
  27. creature_lab/editor/history.py +85 -0
  28. creature_lab/editor/jobs.py +176 -0
  29. creature_lab/editor/labels.py +91 -0
  30. creature_lab/editor/live.py +672 -0
  31. creature_lab/editor/playback.py +98 -0
  32. creature_lab/editor/presets.py +282 -0
  33. creature_lab/editor/robot_panel.py +288 -0
  34. creature_lab/editor/robot_stage.py +181 -0
  35. creature_lab/editor/session.py +1413 -0
  36. creature_lab/env.py +274 -0
  37. creature_lab/evolve.py +394 -0
  38. creature_lab/export/__init__.py +9 -0
  39. creature_lab/export/mjcf.py +199 -0
  40. creature_lab/export/urdf.py +152 -0
  41. creature_lab/export/urdf_import.py +142 -0
  42. creature_lab/exporting.py +360 -0
  43. creature_lab/failure_zoo.py +98 -0
  44. creature_lab/hashing.py +19 -0
  45. creature_lab/io_utils.py +37 -0
  46. creature_lab/library.py +311 -0
  47. creature_lab/qualification.py +299 -0
  48. creature_lab/reports.py +428 -0
  49. creature_lab/reports_html.py +549 -0
  50. creature_lab/rl/__init__.py +9 -0
  51. creature_lab/rl/gym_env.py +74 -0
  52. creature_lab/rl/train.py +148 -0
  53. creature_lab/robots/__init__.py +6 -0
  54. creature_lab/robots/actuator.py +115 -0
  55. creature_lab/robots/analysis.py +282 -0
  56. creature_lab/robots/api.py +30 -0
  57. creature_lab/robots/card.py +182 -0
  58. creature_lab/robots/catalog.py +153 -0
  59. creature_lab/robots/checkup.py +216 -0
  60. creature_lab/robots/cli.py +275 -0
  61. creature_lab/robots/compare.py +229 -0
  62. creature_lab/robots/data/UNITREE_RL_GYM_LICENSE.txt +23 -0
  63. creature_lab/robots/data/microduck.json +253 -0
  64. creature_lab/robots/data/unitree_g1.json +174 -0
  65. creature_lab/robots/data/unitree_g1_policy.npz +0 -0
  66. creature_lab/robots/fetch.py +115 -0
  67. creature_lab/robots/film.py +78 -0
  68. creature_lab/robots/g1.py +103 -0
  69. creature_lab/robots/microduck.py +153 -0
  70. creature_lab/robots/overlay.py +384 -0
  71. creature_lab/robots/policies.py +81 -0
  72. creature_lab/robots/render.py +236 -0
  73. creature_lab/robots/report.py +307 -0
  74. creature_lab/robots/rollout.py +185 -0
  75. creature_lab/robots/scene.py +53 -0
  76. creature_lab/robots/story.py +485 -0
  77. creature_lab/robots/types.py +120 -0
  78. creature_lab/robots/video.py +277 -0
  79. creature_lab/robots/visual.py +41 -0
  80. creature_lab/robustness.py +165 -0
  81. creature_lab/runs.py +242 -0
  82. creature_lab/scaffold/__init__.py +29 -0
  83. creature_lab/scaffold/humanoid.py +227 -0
  84. creature_lab/scaffold/legged.py +144 -0
  85. creature_lab/scaffold/mirror.py +90 -0
  86. creature_lab/scaffold/worm.py +80 -0
  87. creature_lab/schema/__init__.py +79 -0
  88. creature_lab/schema/agent.py +28 -0
  89. creature_lab/schema/base.py +33 -0
  90. creature_lab/schema/control.py +49 -0
  91. creature_lab/schema/controller.py +208 -0
  92. creature_lab/schema/creature.py +235 -0
  93. creature_lab/schema/summary.py +30 -0
  94. creature_lab/schema/task.py +137 -0
  95. creature_lab/schema/trace.py +110 -0
  96. creature_lab/scoring.py +56 -0
  97. creature_lab/terrain.py +172 -0
  98. creature_lab/validation.py +103 -0
  99. creature_lab/viewers/__init__.py +1 -0
  100. creature_lab/viewers/overlays.py +71 -0
  101. creature_lab/viewers/plotting.py +43 -0
  102. creature_lab/viewers/video_exporter.py +26 -0
  103. creature_lab/viewers/viser_viewer.py +489 -0
  104. creature_lab/web/__init__.py +6 -0
  105. creature_lab/web/builder.py +91 -0
  106. creature_lab/web/creatures.py +445 -0
  107. creature_lab/web/jobs.py +172 -0
  108. creature_lab/web/library.py +254 -0
  109. creature_lab/web/scene.py +112 -0
  110. creature_lab/web/server.py +394 -0
  111. creature_lab/web/static/assets/index-CVJ_iiu_.css +1 -0
  112. creature_lab/web/static/assets/index-D-2y8anQ.js +4233 -0
  113. creature_lab/web/static/clips/unitree_g1.webp +0 -0
  114. creature_lab/web/static/index.html +16 -0
  115. creature_lab/web/thumbs.py +120 -0
  116. creature_lab/web/whatif.py +149 -0
  117. creature_lab/zoo/damaged_quadruped/baselines/recover.json +14 -0
  118. creature_lab/zoo/damaged_quadruped/baselines/recover.mujoco.json +14 -0
  119. creature_lab/zoo/damaged_quadruped/creature.json +205 -0
  120. creature_lab/zoo/damaged_quadruped/tasks/recover.json +8 -0
  121. creature_lab/zoo/hexapod/baselines/crawl_forward.json +14 -0
  122. creature_lab/zoo/hexapod/baselines/crawl_forward.mujoco.json +14 -0
  123. creature_lab/zoo/hexapod/controller.json +42 -0
  124. creature_lab/zoo/hexapod/creature.json +295 -0
  125. creature_lab/zoo/hexapod/tasks/crawl_forward.json +7 -0
  126. creature_lab/zoo/humanoid_12dof/baselines/reach.json +14 -0
  127. creature_lab/zoo/humanoid_12dof/baselines/reach.mujoco.json +14 -0
  128. creature_lab/zoo/humanoid_12dof/baselines/walk.json +14 -0
  129. creature_lab/zoo/humanoid_12dof/baselines/walk.mujoco.json +14 -0
  130. creature_lab/zoo/humanoid_12dof/controller.json +18 -0
  131. creature_lab/zoo/humanoid_12dof/creature.json +628 -0
  132. creature_lab/zoo/humanoid_12dof/tasks/reach.json +8 -0
  133. creature_lab/zoo/humanoid_12dof/tasks/walk.json +7 -0
  134. creature_lab/zoo/humanoid_minimal/baselines/balance.json +14 -0
  135. creature_lab/zoo/humanoid_minimal/baselines/balance.mujoco.json +14 -0
  136. creature_lab/zoo/humanoid_minimal/baselines/push_recovery.json +14 -0
  137. creature_lab/zoo/humanoid_minimal/baselines/push_recovery.mujoco.json +14 -0
  138. creature_lab/zoo/humanoid_minimal/baselines/walk.json +14 -0
  139. creature_lab/zoo/humanoid_minimal/baselines/walk.mujoco.json +14 -0
  140. creature_lab/zoo/humanoid_minimal/creature.json +434 -0
  141. creature_lab/zoo/humanoid_minimal/tasks/balance.json +7 -0
  142. creature_lab/zoo/humanoid_minimal/tasks/push_recovery.json +8 -0
  143. creature_lab/zoo/humanoid_minimal/tasks/walk.json +7 -0
  144. creature_lab/zoo/quadruped/baselines/crawl_forward.json +14 -0
  145. creature_lab/zoo/quadruped/baselines/crawl_forward.mujoco.json +14 -0
  146. creature_lab/zoo/quadruped/baselines/gap_cross.json +14 -0
  147. creature_lab/zoo/quadruped/baselines/gap_cross.mujoco.json +14 -0
  148. creature_lab/zoo/quadruped/baselines/low_friction_crawl.mujoco.json +14 -0
  149. creature_lab/zoo/quadruped/baselines/push_recovery.mujoco.json +14 -0
  150. creature_lab/zoo/quadruped/baselines/reach_target.json +14 -0
  151. creature_lab/zoo/quadruped/baselines/reach_target.mujoco.json +14 -0
  152. creature_lab/zoo/quadruped/baselines/slope_climb.json +14 -0
  153. creature_lab/zoo/quadruped/baselines/slope_climb.mujoco.json +14 -0
  154. creature_lab/zoo/quadruped/baselines/stability_hold.mujoco.json +14 -0
  155. creature_lab/zoo/quadruped/baselines/step_over.json +14 -0
  156. creature_lab/zoo/quadruped/baselines/step_over.mujoco.json +14 -0
  157. creature_lab/zoo/quadruped/controller.json +30 -0
  158. creature_lab/zoo/quadruped/creature.json +205 -0
  159. creature_lab/zoo/quadruped/tasks/crawl_forward.json +7 -0
  160. creature_lab/zoo/quadruped/tasks/gap_cross.json +7 -0
  161. creature_lab/zoo/quadruped/tasks/low_friction_crawl.json +7 -0
  162. creature_lab/zoo/quadruped/tasks/push_recovery.json +8 -0
  163. creature_lab/zoo/quadruped/tasks/reach_target.json +8 -0
  164. creature_lab/zoo/quadruped/tasks/slope_climb.json +7 -0
  165. creature_lab/zoo/quadruped/tasks/stability_hold.json +7 -0
  166. creature_lab/zoo/quadruped/tasks/step_over.json +7 -0
  167. creature_lab/zoo/tripod/baselines/crawl_forward.json +14 -0
  168. creature_lab/zoo/tripod/baselines/crawl_forward.mujoco.json +14 -0
  169. creature_lab/zoo/tripod/controller.json +24 -0
  170. creature_lab/zoo/tripod/creature.json +145 -0
  171. creature_lab/zoo/tripod/tasks/crawl_forward.json +7 -0
  172. creature_lab/zoo/worm/baselines/crawl_forward.json +14 -0
  173. creature_lab/zoo/worm/baselines/crawl_forward.mujoco.json +14 -0
  174. creature_lab/zoo/worm/controller.json +36 -0
  175. creature_lab/zoo/worm/creature.json +247 -0
  176. creature_lab/zoo/worm/tasks/crawl_forward.json +7 -0
  177. creature_lab/zoo.py +171 -0
  178. creature_lab-0.3.0.dist-info/METADATA +370 -0
  179. creature_lab-0.3.0.dist-info/RECORD +182 -0
  180. creature_lab-0.3.0.dist-info/WHEEL +4 -0
  181. creature_lab-0.3.0.dist-info/entry_points.txt +2 -0
  182. creature_lab-0.3.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,18 @@
1
+ """Creature Lab: a minimal, visual, backend-agnostic creature simulation lab."""
2
+
3
+ from creature_lab.schema import (
4
+ CreatureSpec,
5
+ EpisodeTrace,
6
+ FrameState,
7
+ TaskSpec,
8
+ )
9
+
10
+ VERSION = "0.3.0"
11
+
12
+ __all__ = [
13
+ "VERSION",
14
+ "CreatureSpec",
15
+ "EpisodeTrace",
16
+ "FrameState",
17
+ "TaskSpec",
18
+ ]
@@ -0,0 +1,5 @@
1
+ """Agent design loop: validated tools, policies, and the hill-climbing loop.
2
+
3
+ Agents act only through validated tools that return a fresh, validated
4
+ ``CreatureSpec`` — they never touch a backend or unvalidated state.
5
+ """
@@ -0,0 +1,53 @@
1
+ """A no-LLM policy that proposes random, valid tool calls.
2
+
3
+ Lets the agent loop (and ``creature-lab ask --offline``) run without any model
4
+ provider, so the lab is useful and testable out of the box. Deterministic for a
5
+ given seed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import random
11
+
12
+ from creature_lab.agents.loop import Observation, Proposal
13
+ from creature_lab.schema.creature import ShapeType
14
+
15
+ _LENGTH_SHAPES = (ShapeType.CAPSULE, ShapeType.CYLINDER)
16
+
17
+
18
+ class RandomToolPolicy:
19
+ """Propose a random parameter tweak via the validated tool layer."""
20
+
21
+ def __init__(self, seed: int = 0) -> None:
22
+ self._rng = random.Random(seed)
23
+
24
+ def __call__(self, observation: Observation) -> Proposal:
25
+ creature = observation.creature
26
+ rng = self._rng
27
+ options: list[str] = []
28
+ if creature.motors:
29
+ options += ["amplitude", "frequency", "phase"]
30
+ if any(part.shape in _LENGTH_SHAPES for part in creature.parts):
31
+ options.append("length")
32
+ if not options:
33
+ # Nothing tweakable; a no-op proposal the loop will reject as invalid.
34
+ return Proposal("set_motor", {"joint": "_none_"}, "no tunable parameters")
35
+
36
+ choice = rng.choice(options)
37
+ if choice == "length":
38
+ limbs = [part for part in creature.parts if part.shape in _LENGTH_SHAPES]
39
+ limb = rng.choice(limbs)
40
+ new_length = round(max(0.05, limb.length * rng.uniform(0.85, 1.15)), 4)
41
+ return Proposal("resize_limb", {"part": limb.id, "length": new_length}, "resize limb")
42
+
43
+ motor = rng.choice(creature.motors)
44
+ if choice == "amplitude":
45
+ value = round(max(0.0, motor.amplitude * rng.uniform(0.7, 1.3)), 4)
46
+ args = {"joint": motor.joint, "amplitude": value}
47
+ return Proposal("set_motor", args, "tune amplitude")
48
+ if choice == "frequency":
49
+ value = round(max(0.1, motor.frequency * rng.uniform(0.7, 1.3)), 4)
50
+ args = {"joint": motor.joint, "frequency": value}
51
+ return Proposal("set_motor", args, "tune frequency")
52
+ value = round(motor.phase + rng.uniform(-0.5, 0.5), 4)
53
+ return Proposal("set_motor", {"joint": motor.joint, "phase": value}, "tune phase")
@@ -0,0 +1,33 @@
1
+ """LiteLLM-backed policy (optional ``llm`` extra).
2
+
3
+ Imports litellm lazily so the rest of the agent stack works without it. The
4
+ prompt building and response parsing live in ``prompts.py`` and are tested
5
+ without any network call.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from creature_lab.agents.loop import Observation, Proposal
11
+ from creature_lab.agents.prompts import SYSTEM_PROMPT, build_prompt, parse_proposal
12
+
13
+
14
+ class LLMPolicy:
15
+ """Ask a model (via LiteLLM) for the next tool call."""
16
+
17
+ def __init__(self, model: str = "gpt-4o-mini", temperature: float = 0.3) -> None:
18
+ self.model = model
19
+ self.temperature = temperature
20
+
21
+ def __call__(self, observation: Observation) -> Proposal:
22
+ import litellm
23
+
24
+ response = litellm.completion(
25
+ model=self.model,
26
+ temperature=self.temperature,
27
+ messages=[
28
+ {"role": "system", "content": SYSTEM_PROMPT},
29
+ {"role": "user", "content": build_prompt(observation)},
30
+ ],
31
+ )
32
+ text = response.choices[0].message.content or ""
33
+ return parse_proposal(text)
@@ -0,0 +1,112 @@
1
+ """Policy-driven design loop.
2
+
3
+ The loop is provider-agnostic: a ``Policy`` proposes a tool call given an
4
+ ``Observation``; the loop validates and applies it through the tool layer,
5
+ re-evaluates the creature with a caller-supplied ``evaluate`` function, keeps the
6
+ best, and records an ``AgentTrace``. This keeps the loop testable without any LLM
7
+ or simulator.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ from creature_lab.agents.tools import ToolError, apply_tool
17
+ from creature_lab.schema import AgentStep, AgentTrace, CreatureSpec
18
+
19
+ EvaluateFn = Callable[[CreatureSpec], float]
20
+ #: Optional: summarize why a creature is scoring the way it does, as prompt-ready text.
21
+ DiagnoseFn = Callable[[CreatureSpec], str]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Observation:
26
+ """What a policy sees before proposing the next edit."""
27
+
28
+ creature: CreatureSpec
29
+ best_score: float
30
+ attempt: int
31
+ goal: str = ""
32
+ #: Plain-language failure-pattern summary for the current best (empty if none/unavailable).
33
+ diagnosis: str = ""
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Proposal:
38
+ """A tool call proposed by a policy."""
39
+
40
+ tool: str
41
+ args: dict[str, Any] = field(default_factory=dict)
42
+ note: str = ""
43
+
44
+
45
+ Policy = Callable[[Observation], Proposal]
46
+
47
+
48
+ @dataclass
49
+ class DesignResult:
50
+ best: CreatureSpec
51
+ best_score: float
52
+ trace: AgentTrace
53
+
54
+
55
+ def design_loop(
56
+ creature: CreatureSpec,
57
+ evaluate: EvaluateFn,
58
+ policy: Policy,
59
+ *,
60
+ attempts: int,
61
+ goal: str = "",
62
+ task_name: str = "design",
63
+ diagnose: DiagnoseFn | None = None,
64
+ ) -> DesignResult:
65
+ """Run the policy for `attempts` steps, keeping any higher-scoring valid edit.
66
+
67
+ When ``diagnose`` is given, it is called on the current best before every proposal
68
+ and its text is attached to the ``Observation`` — so a policy (an LLM prompt, or a
69
+ future rule-based one) can react to *why* the creature is scoring the way it is,
70
+ not just the scalar score.
71
+ """
72
+ if attempts < 0:
73
+ raise ValueError("attempts must not be negative")
74
+
75
+ best = creature
76
+ best_score = evaluate(creature)
77
+ steps = [AgentStep(attempt=0, action="seed", valid=True, score=best_score, accepted=True)]
78
+
79
+ for attempt in range(1, attempts + 1):
80
+ diagnosis = diagnose(best) if diagnose is not None else ""
81
+ proposal = policy(Observation(best, best_score, attempt, goal, diagnosis))
82
+ action = f"{proposal.tool}({proposal.args})"
83
+ try:
84
+ candidate = apply_tool(best, proposal.tool, proposal.args)
85
+ except ToolError as exc:
86
+ steps.append(
87
+ AgentStep(attempt=attempt, action=action, valid=False, note=str(exc)[:160])
88
+ )
89
+ continue
90
+ score = evaluate(candidate)
91
+ accepted = score > best_score
92
+ if accepted:
93
+ best, best_score = candidate, score
94
+ steps.append(
95
+ AgentStep(
96
+ attempt=attempt,
97
+ action=action,
98
+ valid=True,
99
+ score=score,
100
+ accepted=accepted,
101
+ note=proposal.note,
102
+ )
103
+ )
104
+
105
+ trace = AgentTrace(
106
+ creature_name=creature.name,
107
+ task_name=task_name,
108
+ goal=goal,
109
+ best_score=best_score,
110
+ steps=steps,
111
+ )
112
+ return DesignResult(best=best, best_score=best_score, trace=trace)
@@ -0,0 +1,63 @@
1
+ """Prompt construction and response parsing for the LLM policy.
2
+
3
+ Kept free of any provider import so the parser is unit-testable without network.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+
10
+ from creature_lab.agents.loop import Observation, Proposal
11
+ from creature_lab.agents.tools import TOOL_SIGNATURES
12
+
13
+ SYSTEM_PROMPT = (
14
+ "You are a creature-design assistant. You improve a small simulated robot-creature "
15
+ "by proposing ONE tool call at a time. Respond with a single JSON object only, of the "
16
+ 'form {"tool": <name>, "args": {<kwargs>}, "note": <short reason>}. Do not add prose.'
17
+ )
18
+
19
+
20
+ def _creature_summary(observation: Observation) -> str:
21
+ creature = observation.creature
22
+ motors = ", ".join(
23
+ f"{m.joint}(amp={m.amplitude}, freq={m.frequency}, phase={m.phase})"
24
+ for m in creature.motors
25
+ )
26
+ limbs = ", ".join(f"{p.id}(len={p.length})" for p in creature.parts if p.length is not None)
27
+ summary = (
28
+ f"goal: {observation.goal}\n"
29
+ f"current best score: {observation.best_score:.4f}\n"
30
+ f"motors: {motors or 'none'}\n"
31
+ f"resizable limbs: {limbs or 'none'}"
32
+ )
33
+ if observation.diagnosis:
34
+ summary += f"\ndiagnosed issues: {observation.diagnosis}"
35
+ return summary
36
+
37
+
38
+ def build_prompt(observation: Observation) -> str:
39
+ """Build the user message describing the creature and available tools."""
40
+ tools = "\n".join(f"- {name}({sig})" for name, sig in TOOL_SIGNATURES.items())
41
+ return (
42
+ f"{_creature_summary(observation)}\n\navailable tools:\n{tools}\n\nPropose one tool call."
43
+ )
44
+
45
+
46
+ def parse_proposal(text: str) -> Proposal:
47
+ """Parse a model response into a Proposal, tolerating surrounding prose/code fences."""
48
+ start = text.find("{")
49
+ end = text.rfind("}")
50
+ if start == -1 or end == -1 or end < start:
51
+ raise ValueError("no JSON object found in model response")
52
+ try:
53
+ data = json.loads(text[start : end + 1])
54
+ except json.JSONDecodeError as exc:
55
+ raise ValueError(f"invalid JSON in model response: {exc}") from exc
56
+ tool = data.get("tool")
57
+ if not isinstance(tool, str) or not tool:
58
+ raise ValueError("model response missing a 'tool' name")
59
+ args = data.get("args", {})
60
+ if not isinstance(args, dict):
61
+ raise ValueError("'args' must be an object")
62
+ note = str(data.get("note", ""))
63
+ return Proposal(tool=tool, args=args, note=note)
@@ -0,0 +1,101 @@
1
+ """Validated creature-editing tools.
2
+
3
+ Each tool takes a ``CreatureSpec`` plus keyword arguments and returns a new,
4
+ re-validated ``CreatureSpec``. Anything invalid (unknown id, wrong shape, value
5
+ out of range) raises ``ToolError`` rather than producing an unvalidated creature,
6
+ so the agent loop can record the failure and move on.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from pydantic import ValidationError
14
+
15
+ from creature_lab.schema import CreatureSpec
16
+ from creature_lab.schema.creature import ShapeType
17
+
18
+ _LENGTH_SHAPES = (ShapeType.CAPSULE, ShapeType.CYLINDER)
19
+
20
+
21
+ class ToolError(ValueError):
22
+ """Raised when a tool is called with arguments that cannot be applied."""
23
+
24
+
25
+ def _revalidate(data: dict) -> CreatureSpec:
26
+ try:
27
+ return CreatureSpec.model_validate(data)
28
+ except ValidationError as exc:
29
+ raise ToolError(f"resulting creature is invalid: {exc.errors()[0]['msg']}") from exc
30
+
31
+
32
+ def set_motor(
33
+ creature: CreatureSpec,
34
+ *,
35
+ joint: str,
36
+ amplitude: float | None = None,
37
+ frequency: float | None = None,
38
+ phase: float | None = None,
39
+ ) -> CreatureSpec:
40
+ """Update an existing motor's sinusoid parameters."""
41
+ data = creature.model_dump()
42
+ motors = [motor for motor in data["motors"] if motor["joint"] == joint]
43
+ if not motors:
44
+ raise ToolError(f"no motor drives joint {joint!r}")
45
+ motor = motors[0]
46
+ if amplitude is not None:
47
+ motor["amplitude"] = amplitude
48
+ if frequency is not None:
49
+ motor["frequency"] = frequency
50
+ if phase is not None:
51
+ motor["phase"] = phase
52
+ return _revalidate(data)
53
+
54
+
55
+ def set_joint_limit(
56
+ creature: CreatureSpec, *, joint: str, lower: float, upper: float
57
+ ) -> CreatureSpec:
58
+ """Set the lower/upper limit of a joint."""
59
+ data = creature.model_dump()
60
+ joints = [item for item in data["joints"] if item["id"] == joint]
61
+ if not joints:
62
+ raise ToolError(f"unknown joint {joint!r}")
63
+ joints[0]["limit"] = [lower, upper]
64
+ return _revalidate(data)
65
+
66
+
67
+ def resize_limb(creature: CreatureSpec, *, part: str, length: float) -> CreatureSpec:
68
+ """Change the length of a capsule/cylinder part."""
69
+ data = creature.model_dump()
70
+ parts = [item for item in data["parts"] if item["id"] == part]
71
+ if not parts:
72
+ raise ToolError(f"unknown part {part!r}")
73
+ if parts[0]["shape"] not in _LENGTH_SHAPES:
74
+ raise ToolError(f"part {part!r} has no length to resize")
75
+ parts[0]["length"] = length
76
+ return _revalidate(data)
77
+
78
+
79
+ #: Public tool registry and a short, prompt-friendly description of each tool's args.
80
+ TOOLS = {
81
+ "set_motor": set_motor,
82
+ "set_joint_limit": set_joint_limit,
83
+ "resize_limb": resize_limb,
84
+ }
85
+
86
+ TOOL_SIGNATURES = {
87
+ "set_motor": "joint:str, amplitude?:float, frequency?:float, phase?:float",
88
+ "set_joint_limit": "joint:str, lower:float, upper:float",
89
+ "resize_limb": "part:str, length:float",
90
+ }
91
+
92
+
93
+ def apply_tool(creature: CreatureSpec, name: str, args: dict[str, Any]) -> CreatureSpec:
94
+ """Dispatch to a tool by name, validating the call."""
95
+ tool = TOOLS.get(name)
96
+ if tool is None:
97
+ raise ToolError(f"unknown tool {name!r}")
98
+ try:
99
+ return tool(creature, **args)
100
+ except TypeError as exc: # wrong/missing keyword arguments
101
+ raise ToolError(f"bad arguments for {name!r}: {exc}") from exc