5 Commits

9 changed files with 80 additions and 48 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "strix-agent" name = "strix-agent"
version = "1.0.3" version = "1.0.4"
description = "Open-source AI Hackers for your apps" description = "Open-source AI Hackers for your apps"
readme = "README.md" readme = "README.md"
license = "Apache-2.0" license = "Apache-2.0"
+4
View File
@@ -42,10 +42,14 @@ class AgentCoordinator:
self.runtimes: dict[str, AgentRuntime] = {} self.runtimes: dict[str, AgentRuntime] = {}
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._snapshot_path: Path | None = None self._snapshot_path: Path | None = None
self.is_shutting_down = False
def set_snapshot_path(self, path: Path) -> None: def set_snapshot_path(self, path: Path) -> None:
self._snapshot_path = path self._snapshot_path = path
def mark_shutting_down(self) -> None:
self.is_shutting_down = True
async def register( async def register(
self, self,
agent_id: str, agent_id: str,
+16 -6
View File
@@ -11,10 +11,12 @@ from typing import TYPE_CHECKING, Any, cast
from agents import RunConfig, Runner from agents import RunConfig, Runner
from agents.exceptions import AgentsException, MaxTurnsExceeded, UserError from agents.exceptions import AgentsException, MaxTurnsExceeded, UserError
from agents.sandbox.errors import ExecTransportError
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
from openai import APIError from openai import APIError
from strix.core.inputs import child_initial_input from strix.core.inputs import child_initial_input
from strix.core.sessions import open_agent_session, strip_latest_image_from_session from strix.core.sessions import open_agent_session, strip_all_images_from_session
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -320,7 +322,7 @@ async def _run_noninteractive_until_lifecycle(
) )
async def _run_cycle( # noqa: PLR0912 async def _run_cycle( # noqa: PLR0912, PLR0915
agent: Any, agent: Any,
coordinator: AgentCoordinator, coordinator: AgentCoordinator,
agent_id: str, agent_id: str,
@@ -356,6 +358,8 @@ async def _run_cycle( # noqa: PLR0912
event_sink(agent_id, event) event_sink(agent_id, event)
except Exception: except Exception:
logger.exception("stream event sink failed for %s", agent_id) logger.exception("stream event sink failed for %s", agent_id)
if stream.run_loop_exception is not None:
raise stream.run_loop_exception
except RuntimeError as stream_exc: except RuntimeError as stream_exc:
if "after shutdown" not in str(stream_exc): if "after shutdown" not in str(stream_exc):
raise raise
@@ -363,8 +367,14 @@ async def _run_cycle( # noqa: PLR0912
"Ignoring LiteLLM end-of-stream shutdown race for %s", "Ignoring LiteLLM end-of-stream shutdown race for %s",
agent_id, agent_id,
) )
if stream.run_loop_exception is not None: except (ExecTransportError, docker_errors.NotFound):
raise stream.run_loop_exception if not coordinator.is_shutting_down:
raise
logger.warning(
"Ignoring sandbox container error during teardown for %s",
agent_id,
exc_info=True,
)
finally: finally:
await coordinator.detach_stream(agent_id, stream) await coordinator.detach_stream(agent_id, stream)
except Exception as exc: except Exception as exc:
@@ -374,14 +384,14 @@ async def _run_cycle( # noqa: PLR0912
and getattr(exc, "status_code", None) in _INPUT_REJECTION_CODES and getattr(exc, "status_code", None) in _INPUT_REJECTION_CODES
): ):
try: try:
stripped = await strip_latest_image_from_session(session) stripped = await strip_all_images_from_session(session)
except Exception: except Exception:
logger.exception("image-strip recovery failed for %s", agent_id) logger.exception("image-strip recovery failed for %s", agent_id)
stripped = False stripped = False
if stripped: if stripped:
image_strips += 1 image_strips += 1
logger.info( logger.info(
"Stripped latest image from %s session after rejection; retrying (%d)", "Stripped images from %s session after rejection; retrying (%d)",
agent_id, agent_id,
image_strips, image_strips,
) )
+34 -19
View File
@@ -2,7 +2,8 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, cast import contextlib
from typing import TYPE_CHECKING, Any, cast
from agents.memory import SQLiteSession from agents.memory import SQLiteSession
@@ -22,29 +23,43 @@ def open_agent_session(agent_id: str, path: Path) -> SQLiteSession:
_IMAGE_REJECTED_TEXT = "[image rejected by the model]" _IMAGE_REJECTED_TEXT = "[image rejected by the model]"
async def strip_latest_image_from_session(session: Session) -> bool: async def strip_all_images_from_session(session: Session) -> bool:
items = await session.get_items() items = await session.get_items()
if not items: if not items:
return False return False
latest = items[-1]
if not isinstance(latest, dict) or latest.get("type") != "function_call_output": rebuilt: list[Any] = []
return False changed = False
output = latest.get("output") for item in items:
if not isinstance(output, list): item_dict = cast("dict[str, Any]", item) if isinstance(item, dict) else None
return False if (
if not any(isinstance(b, dict) and b.get("type") == "input_image" for b in output): item_dict is not None
return False and item_dict.get("type") == "function_call_output"
await session.pop_item() and isinstance(item_dict.get("output"), list)
await session.add_items( and any(
cast( isinstance(b, dict) and b.get("type") == "input_image" for b in item_dict["output"]
"list[TResponseInputItem]", )
[ ):
rebuilt.append(
{ {
"type": "function_call_output", "type": "function_call_output",
"call_id": latest.get("call_id"), "call_id": item_dict.get("call_id"),
"output": [{"type": "input_text", "text": _IMAGE_REJECTED_TEXT}], "output": [{"type": "input_text", "text": _IMAGE_REJECTED_TEXT}],
}, },
], )
), changed = True
) else:
rebuilt.append(item)
if not changed:
return False
rebuilt_items = cast("list[TResponseInputItem]", rebuilt)
await session.clear_session()
try:
await session.add_items(rebuilt_items)
except Exception:
with contextlib.suppress(Exception):
await session.add_items(rebuilt_items)
raise
return True return True
+6 -17
View File
@@ -751,7 +751,7 @@ class StrixTUIApp(App): # type: ignore[misc]
self.report_state.cleanup() self.report_state.cleanup()
def signal_handler(_signum: int, _frame: Any) -> None: def signal_handler(_signum: int, _frame: Any) -> None:
self._teardown_sandbox_blocking(timeout=10.0) self._fire_sandbox_cleanup()
self.report_state.cleanup(status="interrupted") self.report_state.cleanup(status="interrupted")
sys.exit(0) sys.exit(0)
@@ -1705,36 +1705,25 @@ class StrixTUIApp(App): # type: ignore[misc]
) )
async def action_custom_quit(self) -> None: async def action_custom_quit(self) -> None:
await asyncio.to_thread(self._teardown_sandbox_blocking, timeout=10.0) self._fire_sandbox_cleanup()
if self._scan_thread and self._scan_thread.is_alive(): if self._scan_thread and self._scan_thread.is_alive():
self._scan_stop_event.set() self._scan_stop_event.set()
self._scan_thread.join(timeout=2.0)
self.report_state.cleanup() self.report_state.cleanup()
self.exit() self.exit()
def _teardown_sandbox_blocking(self, *, timeout: float) -> None: def _fire_sandbox_cleanup(self) -> None:
self.coordinator.mark_shutting_down()
loop = self._scan_loop loop = self._scan_loop
if loop is None or loop.is_closed(): if loop is None or loop.is_closed():
return return
run_name = self.scan_config.get("run_name") run_name = self.scan_config.get("run_name")
if not run_name: if not run_name:
return return
future = asyncio.run_coroutine_threadsafe( with contextlib.suppress(Exception):
session_manager.cleanup(run_name), asyncio.run_coroutine_threadsafe(session_manager.cleanup(run_name), loop)
loop,
)
try:
future.result(timeout=timeout)
except TimeoutError:
logger.warning(
"Sandbox cleanup timed out after %.1fs; container may still be running",
timeout,
)
except Exception:
logger.exception("Sandbox cleanup failed")
def _is_widget_safe(self, widget: Any) -> bool: def _is_widget_safe(self, widget: Any) -> bool:
try: try:
@@ -26,6 +26,11 @@ _EXIT_RE = re.compile(r"Process exited with code (-?\d+)")
_SESSION_RE = re.compile(r"Process running with session ID (\d+)") _SESSION_RE = re.compile(r"Process running with session ID (\d+)")
_OUTPUT_HEADER = "\nOutput:\n" _OUTPUT_HEADER = "\nOutput:\n"
_CONTROL_BYTES_TO_DROP = dict.fromkeys(
[b for b in range(0x20) if b not in (0x09, 0x0A)] + [0x7F],
None,
)
@cache @cache
def _get_style_colors() -> dict[Any, str]: def _get_style_colors() -> dict[Any, str]:
@@ -60,14 +65,13 @@ def _parse_sdk_shell_result(result: Any) -> dict[str, Any]:
def _truncate_line(line: str) -> str: def _truncate_line(line: str) -> str:
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) if len(line) > MAX_LINE_LENGTH:
if len(clean_line) > MAX_LINE_LENGTH:
return line[: MAX_LINE_LENGTH - 3] + "..." return line[: MAX_LINE_LENGTH - 3] + "..."
return line return line
def _clean_output(output: str) -> str: def _clean_output(output: str) -> str:
cleaned = output cleaned = Text.from_ansi(output).plain.translate(_CONTROL_BYTES_TO_DROP)
for pattern in STRIP_PATTERNS: for pattern in STRIP_PATTERNS:
cleaned = re.sub(pattern, "", cleaned, flags=re.MULTILINE) cleaned = re.sub(pattern, "", cleaned, flags=re.MULTILINE)
+10
View File
@@ -22,6 +22,7 @@ re-merging the parent body. Track upstream for an injection hook.
from __future__ import annotations from __future__ import annotations
import contextlib
import logging import logging
import uuid import uuid
from typing import Any from typing import Any
@@ -34,6 +35,8 @@ from agents.sandbox.sandboxes.docker import (
_manifest_requires_fuse, _manifest_requires_fuse,
_manifest_requires_sys_admin, _manifest_requires_sys_admin,
) )
from agents.sandbox.session.sandbox_session import SandboxSession
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
from docker.models.containers import Container # type: ignore[import-untyped, unused-ignore] from docker.models.containers import Container # type: ignore[import-untyped, unused-ignore]
from docker.utils import parse_repository_tag # type: ignore[import-untyped, unused-ignore] from docker.utils import parse_repository_tag # type: ignore[import-untyped, unused-ignore]
@@ -121,3 +124,10 @@ class StrixDockerSandboxClient(DockerSandboxClient):
image, image,
) )
return container return container
async def delete(self, session: SandboxSession) -> SandboxSession:
container_id = getattr(getattr(session._inner, "state", None), "container_id", None)
if container_id:
with contextlib.suppress(docker_errors.NotFound, docker_errors.APIError):
self.docker_client.containers.get(container_id).kill()
return await super().delete(session)
+1 -1
View File
@@ -13,5 +13,5 @@ returns it as an image content block for vision-capable models.
`containers/docker-entrypoint.sh`). `containers/docker-entrypoint.sh`).
- **Skill:** screenshot workflow lives in `strix/skills/tooling/agent_browser.md`. - **Skill:** screenshot workflow lives in `strix/skills/tooling/agent_browser.md`.
- **Recovery:** vision-not-supported model rejections are auto-recovered - **Recovery:** vision-not-supported model rejections are auto-recovered
via `strix.core.sessions.strip_latest_image_from_session`, invoked from via `strix.core.sessions.strip_all_images_from_session`, invoked from
`strix/core/execution.py`. `strix/core/execution.py`.
Generated
+1 -1
View File
@@ -2035,7 +2035,7 @@ wheels = [
[[package]] [[package]]
name = "strix-agent" name = "strix-agent"
version = "1.0.3" version = "1.0.4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "caido-sdk-client" }, { name = "caido-sdk-client" },