Skip to content
Open
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
16 changes: 16 additions & 0 deletions strix/agents/base_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,17 @@ def __init__(self, config: dict[str, Any]):

self._add_to_agents_graph()

self._conv_log = None
if self.state.parent_id is None and tracer:
from strix.telemetry.conversation_log import ConversationLog

self._conv_log = ConversationLog(tracer.get_run_dir(), tracer.run_name or "")
is_resume = config.get("state") is not None
if not is_resume:
self._conv_log.write_session_start(tracer.scan_config or {})
self.state.set_conversation_log(self._conv_log)
tracer._conversation_log = self._conv_log

def _add_to_agents_graph(self) -> None:
from strix.tools.agents_graph import agents_graph_actions

Expand Down Expand Up @@ -216,6 +227,11 @@ async def agent_loop(self, task: str) -> dict[str, Any]: # noqa: PLR0912, PLR09
should_finish = await iteration_task
self._current_task = None

if self._conv_log is not None:
self._conv_log.append_iteration_end(
self.state.iteration, self.state.context, self.state.completed
)

if should_finish is None and self.interactive:
await self._enter_waiting_state(tracer, text_response=True)
continue
Expand Down
19 changes: 17 additions & 2 deletions strix/agents/state.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import uuid
from datetime import UTC, datetime
from typing import Any
from typing import TYPE_CHECKING, Any

from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, PrivateAttr

if TYPE_CHECKING:
from strix.telemetry.conversation_log import ConversationLog


def _generate_agent_id() -> str:
Expand Down Expand Up @@ -40,6 +43,11 @@ class AgentState(BaseModel):

errors: list[str] = Field(default_factory=list)

_conversation_log: "ConversationLog | None" = PrivateAttr(default=None)

def set_conversation_log(self, log: "ConversationLog") -> None:
self._conversation_log = log

def increment_iteration(self) -> None:
self.iteration += 1
self.last_updated = datetime.now(UTC).isoformat()
Expand All @@ -52,6 +60,13 @@ def add_message(
message["thinking_blocks"] = thinking_blocks
self.messages.append(message)
self.last_updated = datetime.now(UTC).isoformat()
if self._conversation_log is not None:
self._conversation_log.append_message(
role=role,
content=content,
iteration=self.iteration,
thinking_blocks=thinking_blocks,
)

def add_action(self, action: dict[str, Any]) -> None:
self.actions_taken.append(
Expand Down
14 changes: 11 additions & 3 deletions strix/interface/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
padding=(1, 2),
)

console.print("\n")
console.print(startup_panel)
console.print()
is_resume = getattr(args, "resumed_state", None) is not None
if not is_resume:
console.print("\n")
console.print(startup_panel)
console.print()

scan_mode = getattr(args, "scan_mode", "deep")

Expand All @@ -72,6 +74,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
"targets": args.targets_info,
"user_instructions": args.instruction or "",
"run_name": args.run_name,
"scan_mode": scan_mode,
"diff_scope": getattr(args, "diff_scope", {"active": False}),
}

Expand All @@ -87,6 +90,11 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
if getattr(args, "local_sources", None):
agent_config["local_sources"] = args.local_sources

if is_resume:
from strix.sessions import merge_into_agent_config

merge_into_agent_config(agent_config, args.resume_bundle)

tracer = Tracer(args.run_name)
tracer.set_scan_config(scan_config)

Expand Down
127 changes: 110 additions & 17 deletions strix/interface/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,11 +310,34 @@ def parse_arguments() -> argparse.Namespace:
"-t",
"--target",
type=str,
required=True,
required=False,
action="append",
help="Target to test (URL, repository, local directory path, domain name, or IP address). "
"Can be specified multiple times for multi-target scans.",
)

parser.add_argument(
"--resume",
nargs="?",
const="__PICK__",
default=None,
metavar="RUN_NAME",
help="Resume a past scan session. Omit RUN_NAME to open an interactive picker.",
)

parser.add_argument(
"-c",
"--continue",
dest="continue_recent",
action="store_true",
help="Resume the most recent scan session.",
)

parser.add_argument(
"--list-sessions",
action="store_true",
help="List all past scan sessions and exit.",
)
parser.add_argument(
"--instruction",
type=str,
Expand Down Expand Up @@ -389,6 +412,11 @@ def parse_arguments() -> argparse.Namespace:

args = parser.parse_args()

# Resume flags make --target optional
_resume_flags = args.resume or args.continue_recent or args.list_sessions
if not args.target and not _resume_flags:
parser.error("the following arguments are required: -t/--target")

if args.instruction and args.instruction_file:
parser.error(
"Cannot specify both --instruction and --instruction-file. Use one or the other."
Expand All @@ -405,23 +433,27 @@ def parse_arguments() -> argparse.Namespace:
parser.error(f"Failed to read instruction file '{instruction_path}': {e}")

args.targets_info = []
for target in args.target:
try:
target_type, target_dict = infer_target_type(target)

if target_type == "local_code":
display_target = target_dict.get("target_path", target)
else:
display_target = target
if args.target:
for target in args.target:
try:
target_type, target_dict = infer_target_type(target)

if target_type == "local_code":
display_target = target_dict.get("target_path", target)
else:
display_target = target

args.targets_info.append(
{"type": target_type, "details": target_dict, "original": display_target}
)
except ValueError:
parser.error(f"Invalid target '{target}'")

args.targets_info.append(
{"type": target_type, "details": target_dict, "original": display_target}
)
except ValueError:
parser.error(f"Invalid target '{target}'")
assign_workspace_subdirs(args.targets_info)
rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)

assign_workspace_subdirs(args.targets_info)
rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)
# Sentinel to distinguish an explicit --target from a resume-provided one
args._explicit_target = bool(args.target)

return args

Expand Down Expand Up @@ -544,6 +576,64 @@ def persist_config() -> None:
save_current_config()


def _handle_resume_bootstrap(args: argparse.Namespace) -> None:
"""Resolve resume/list-sessions flags early, before docker/env setup."""
from rich.console import Console

from strix.sessions import ResumeError, apply_resume_to_args, load_resume_bundle, most_recent
from strix.sessions.listing import list_sessions

console = Console()

# --list-sessions: print table and exit
if args.list_sessions:
from strix.interface.session_picker_cli import print_session_table

rows = list_sessions()
print_session_table(rows, console)
sys.exit(0)

bundle = None

if args.continue_recent:
row = most_recent()
if row is None:
console.print("[red]No resumable sessions found.[/red]")
sys.exit(1)
try:
bundle = load_resume_bundle(row.run_name)
except ResumeError as exc:
console.print(f"[red]Resume failed:[/red] {exc}")
sys.exit(1)

elif args.resume == "__PICK__":
if args.non_interactive:
console.print(
"[red]Interactive session picker unavailable in non-interactive mode.[/red]\n"
"Use [bold]--resume <run_name>[/bold] or [bold]--continue[/bold] instead."
)
sys.exit(1)
# TUI mode: defer to the TUI to push SessionPickerScreen
args.resume_pick = True
return

elif args.resume:
try:
bundle = load_resume_bundle(args.resume)
except ResumeError as exc:
console.print(f"[red]Resume failed:[/red] {exc}")
sys.exit(1)

if bundle is not None:
apply_resume_to_args(args, bundle)
mode_label = "Reopening" if bundle.mode == "reopen" else "Resuming"
console.print(
f"[green]{mode_label}[/green] session [bold cyan]{bundle.run_name}[/bold cyan] "
f"— iteration [bold]{bundle.agent_state.iteration}[/bold], "
f"mode [bold]{bundle.mode}[/bold]"
)


def main() -> None: # noqa: PLR0912, PLR0915
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
Expand All @@ -553,6 +643,8 @@ def main() -> None: # noqa: PLR0912, PLR0915
if args.config:
apply_config_override(args.config)

_handle_resume_bootstrap(args)

check_docker_installed()
pull_docker_image()

Expand All @@ -561,7 +653,8 @@ def main() -> None: # noqa: PLR0912, PLR0915

persist_config()

args.run_name = generate_run_name(args.targets_info)
if not getattr(args, "run_name", None):
args.run_name = generate_run_name(args.targets_info)

for target_info in args.targets_info:
if target_info["type"] == "repository":
Expand Down
Loading