Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions gently/harness/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,47 @@
logger = logging.getLogger(__name__)


_TEXT_TOOL_CALL_RE = re.compile(r"<tool_call>\s*(.*?)\s*</tool_call>", 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.

Expand Down
20 changes: 17 additions & 3 deletions gently/mesh/mesh_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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):
Expand Down Expand Up @@ -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
# ------------------------------------------------------------------
Expand All @@ -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")

# ------------------------------------------------------------------
Expand Down
8 changes: 8 additions & 0 deletions tests/test_campaign_coordination.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
33 changes: 19 additions & 14 deletions tests/test_dispim_device_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)):
Expand Down Expand Up @@ -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)

Expand Down