Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc23eeb65d | |||
| 7217abfe23 | |||
| f7e3af49bd | |||
| 6202131028 | |||
| 45409cef0d |
+1
-1
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+33
-18
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user