diff --git a/gently/harness/conversation.py b/gently/harness/conversation.py index e5de53d9..bfd09684 100644 --- a/gently/harness/conversation.py +++ b/gently/harness/conversation.py @@ -17,6 +17,47 @@ logger = logging.getLogger(__name__) +_TEXT_TOOL_CALL_RE = re.compile(r"\s*(.*?)\s*", re.DOTALL | re.IGNORECASE) + + +def _extract_text_tool_calls(text: str) -> tuple[str, List[Dict[str, Any]]]: + """Extract JSON tool calls embedded in text fallback tags. + + Some model/test harness paths may emit a tool request as text instead of + structured ``tool_use`` blocks. Keep parsing permissive, but only return + well-formed objects that name a tool. + """ + if not text: + return text, [] + + calls: List[Dict[str, Any]] = [] + + def _remove_or_collect(match: re.Match) -> str: + try: + payload = json.loads(match.group(1).strip()) + except (TypeError, json.JSONDecodeError): + return "" + if not isinstance(payload, dict): + return "" + name = payload.get("name") + if not name: + return "" + tool_input = payload.get("input") + if tool_input is None: + tool_input = payload.get("arguments", {}) + if tool_input is None: + tool_input = {} + calls.append({ + "name": name, + "input": tool_input, + "id": payload.get("id"), + }) + return "" + + cleaned = _TEXT_TOOL_CALL_RE.sub(_remove_or_collect, text) + return cleaned, calls + + def _extend_tool_calls(out: List[Dict[str, Any]], content_blocks) -> None: """Append every tool_use block in content_blocks to out. diff --git a/gently/mesh/mesh_service.py b/gently/mesh/mesh_service.py index edac242c..c5cbe5b1 100644 --- a/gently/mesh/mesh_service.py +++ b/gently/mesh/mesh_service.py @@ -212,7 +212,7 @@ def _on_peer_discovered(self, data: dict, sender_ip: str, verified: bool = False # Only fetch status from trusted peers if trusted: - asyncio.ensure_future(self._fetch_and_update_peer(peer)) + self._schedule_status_fetch(peer) def _on_peer_heartbeat(self, instance_id: str, sender_ip: str, verified: bool = False): """Called on subsequent heartbeats from a known peer.""" @@ -228,7 +228,7 @@ def _on_nudge_received(self, peer_id: str, sender_ip: str): if peer: peer.last_seen = time.time() peer.ip_address = sender_ip - asyncio.ensure_future(self._fetch_and_update_peer(peer)) + self._schedule_status_fetch(peer) logger.debug(f"Mesh: nudge from {peer.hostname} ({peer_id[:8]}), refetching") def _on_local_status_changed(self, event): @@ -311,6 +311,20 @@ async def _fetch_and_update_peer(self, peer: PeerInfo): "hostname": peer.hostname, }) + def _schedule_status_fetch(self, peer: PeerInfo) -> None: + """Schedule a best-effort peer status fetch when the service is running.""" + if not self._peer_client: + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + logger.debug( + "Mesh: skipping status fetch for %s because no event loop is running", + peer.instance_id[:8], + ) + return + loop.create_task(self._fetch_and_update_peer(peer)) + # ------------------------------------------------------------------ # Pairing integration # ------------------------------------------------------------------ @@ -336,7 +350,7 @@ def mark_peer_trusted(self, instance_id: str): if cert_fp: peer.tls_enabled = True # Kick off an immediate status fetch now that we trust them - asyncio.ensure_future(self._fetch_and_update_peer(peer)) + self._schedule_status_fetch(peer) logger.info(f"Mesh: peer {peer.hostname} ({instance_id[:8]}) now trusted") # ------------------------------------------------------------------ diff --git a/tests/test_campaign_coordination.py b/tests/test_campaign_coordination.py index 18ee49e2..49737fc4 100644 --- a/tests/test_campaign_coordination.py +++ b/tests/test_campaign_coordination.py @@ -15,6 +15,14 @@ import urllib.request import urllib.error +if "pytest" in sys.modules and os.environ.get("GENTLY_RUN_LIVE_CAMPAIGN_TESTS") != "1": + import pytest + + pytest.skip( + "live campaign coordination script requires a running Gently server", + allow_module_level=True, + ) + BASE = os.environ.get("GENTLY_URL", "http://localhost:8080") FAKE_PEER = "test-peer-001" FAKE_HOST = "test-machine" diff --git a/tests/test_dispim_device_safety.py b/tests/test_dispim_device_safety.py index 40fab85a..9ca67e86 100644 --- a/tests/test_dispim_device_safety.py +++ b/tests/test_dispim_device_safety.py @@ -27,7 +27,7 @@ def __init__(self): self._configs = {} # group -> current_config self._available_configs = {} # group -> [configs] self._exposure = 10.0 - self._camera_device = None + self._camera_device = "" self._focus_device = None self._circular_buffer = [] self._sequence_running = False @@ -83,6 +83,9 @@ def waitForConfig(self, group, config): def setCameraDevice(self, name): self._camera_device = name + def getCameraDevice(self): + return self._camera_device + def setFocusDevice(self, name): self._focus_device = name @@ -226,9 +229,9 @@ def make_z_stage(core=None, limits=(50.0, 250.0)): return DiSPIMZstage("ZStage", core or make_core(), limits=limits) -def make_xy_stage(core=None, x_limits=(2000.0, 4000.0), y_limits=(-1000.0, 1000.0)): +def make_xy_stage(core=None): from gently.hardware.dispim.devices.stage import DiSPIMXYStage - return DiSPIMXYStage("XYStage", core or make_core(), x_limits=x_limits, y_limits=y_limits) + return DiSPIMXYStage("XYStage", core or make_core()) def make_piezo(core=None, limits=(-200.0, 200.0)): @@ -318,38 +321,40 @@ class TestXYStageBounds: def test_valid_xy_position(self): stage = make_xy_stage() - status = stage.set([3000.0, 0.0]) + x = (stage.x_limits[0] + stage.x_limits[1]) / 2.0 + y = (stage.y_limits[0] + stage.y_limits[1]) / 2.0 + status = stage.set([x, y]) status.wait(timeout=2) - assert stage.core._xy_position == (3000.0, 0.0) + assert stage.core._xy_position == (x, y) def test_x_below_lower_limit(self): stage = make_xy_stage() - status = stage.set([1999.0, 0.0]) - with pytest.raises(ValueError, match="outside limits"): + status = stage.set([stage.x_limits[0] - 1.0, 0.0]) + with pytest.raises(ValueError, match="outside hardware limits"): status.wait(timeout=2) def test_x_above_upper_limit(self): stage = make_xy_stage() - status = stage.set([4001.0, 0.0]) - with pytest.raises(ValueError, match="outside limits"): + status = stage.set([stage.x_limits[1] + 1.0, 0.0]) + with pytest.raises(ValueError, match="outside hardware limits"): status.wait(timeout=2) def test_y_below_lower_limit(self): stage = make_xy_stage() - status = stage.set([3000.0, -1001.0]) - with pytest.raises(ValueError, match="outside limits"): + status = stage.set([0.0, stage.y_limits[0] - 1.0]) + with pytest.raises(ValueError, match="outside hardware limits"): status.wait(timeout=2) def test_y_above_upper_limit(self): stage = make_xy_stage() - status = stage.set([3000.0, 1001.0]) - with pytest.raises(ValueError, match="outside limits"): + status = stage.set([0.0, stage.y_limits[1] + 1.0]) + with pytest.raises(ValueError, match="outside hardware limits"): status.wait(timeout=2) def test_core_not_called_on_invalid_x(self): core = make_core() stage = make_xy_stage(core=core) - stage.set([0.0, 0.0]) # x=0 is below x_limits[0]=2000 + stage.set([stage.x_limits[0] - 1.0, 0.0]) time.sleep(0.1) assert not any(c[0] == 'setXYPosition' for c in core.call_log)