5 Commits

9 changed files with 80 additions and 48 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "strix-agent"
version = "1.0.3"
version = "1.0.4"
description = "Open-source AI Hackers for your apps"
readme = "README.md"
license = "Apache-2.0"
+4
View File
@@ -42,10 +42,14 @@ class AgentCoordinator:
self.runtimes: dict[str, AgentRuntime] = {}
self._lock = asyncio.Lock()
self._snapshot_path: Path | None = None
self.is_shutting_down = False
def set_snapshot_path(self, path: Path) -> None:
self._snapshot_path = path
def mark_shutting_down(self) -> None:
self.is_shutting_down = True
async def register(
self,
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.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 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:
@@ -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,
coordinator: AgentCoordinator,
agent_id: str,
@@ -356,6 +358,8 @@ async def _run_cycle( # noqa: PLR0912
event_sink(agent_id, event)
except Exception:
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:
if "after shutdown" not in str(stream_exc):
raise
@@ -363,8 +367,14 @@ async def _run_cycle( # noqa: PLR0912
"Ignoring LiteLLM end-of-stream shutdown race for %s",
agent_id,
)
if stream.run_loop_exception is not None:
raise stream.run_loop_exception
except (ExecTransportError, docker_errors.NotFound):
if not coordinator.is_shutting_down:
raise
logger.warning(
"Ignoring sandbox container error during teardown for %s",
agent_id,
exc_info=True,
)
finally:
await coordinator.detach_stream(agent_id, stream)
except Exception as exc:
@@ -374,14 +384,14 @@ async def _run_cycle( # noqa: PLR0912
and getattr(exc, "status_code", None) in _INPUT_REJECTION_CODES
):
try:
stripped = await strip_latest_image_from_session(session)
stripped = await strip_all_images_from_session(session)
except Exception:
logger.exception("image-strip recovery failed for %s", agent_id)
stripped = False
if stripped:
image_strips += 1
logger.info(
"Stripped latest image from %s session after rejection; retrying (%d)",
"Stripped images from %s session after rejection; retrying (%d)",
agent_id,
image_strips,
)
+34 -19
View File
@@ -2,7 +2,8 @@
from __future__ import annotations
from typing import TYPE_CHECKING, cast
import contextlib
from typing import TYPE_CHECKING, Any, cast
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]"
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()
if not items:
return False
latest = items[-1]
if not isinstance(latest, dict) or latest.get("type") != "function_call_output":
return False
output = latest.get("output")
if not isinstance(output, list):
return False
if not any(isinstance(b, dict) and b.get("type") == "input_image" for b in output):
return False
await session.pop_item()
await session.add_items(
cast(
"list[TResponseInputItem]",
[
rebuilt: list[Any] = []
changed = False
for item in items:
item_dict = cast("dict[str, Any]", item) if isinstance(item, dict) else None
if (
item_dict is not None
and item_dict.get("type") == "function_call_output"
and isinstance(item_dict.get("output"), list)
and any(
isinstance(b, dict) and b.get("type") == "input_image" for b in item_dict["output"]
)
):
rebuilt.append(
{
"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}],
},
],
),
)
)
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
+6 -17
View File
@@ -751,7 +751,7 @@ class StrixTUIApp(App): # type: ignore[misc]
self.report_state.cleanup()
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")
sys.exit(0)
@@ -1705,36 +1705,25 @@ class StrixTUIApp(App): # type: ignore[misc]
)
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():
self._scan_stop_event.set()
self._scan_thread.join(timeout=2.0)
self.report_state.cleanup()
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
if loop is None or loop.is_closed():
return
run_name = self.scan_config.get("run_name")
if not run_name:
return
future = asyncio.run_coroutine_threadsafe(
session_manager.cleanup(run_name),
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")
with contextlib.suppress(Exception):
asyncio.run_coroutine_threadsafe(session_manager.cleanup(run_name), loop)
def _is_widget_safe(self, widget: Any) -> bool:
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+)")
_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
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:
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
if len(clean_line) > MAX_LINE_LENGTH:
if len(line) > MAX_LINE_LENGTH:
return line[: MAX_LINE_LENGTH - 3] + "..."
return line
def _clean_output(output: str) -> str:
cleaned = output
cleaned = Text.from_ansi(output).plain.translate(_CONTROL_BYTES_TO_DROP)
for pattern in STRIP_PATTERNS:
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
import contextlib
import logging
import uuid
from typing import Any
@@ -34,6 +35,8 @@ from agents.sandbox.sandboxes.docker import (
_manifest_requires_fuse,
_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.utils import parse_repository_tag # type: ignore[import-untyped, unused-ignore]
@@ -121,3 +124,10 @@ class StrixDockerSandboxClient(DockerSandboxClient):
image,
)
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`).
- **Skill:** screenshot workflow lives in `strix/skills/tooling/agent_browser.md`.
- **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`.
Generated
+1 -1
View File
@@ -2035,7 +2035,7 @@ wheels = [
[[package]]
name = "strix-agent"
version = "1.0.3"
version = "1.0.4"
source = { editable = "." }
dependencies = [
{ name = "caido-sdk-client" },