mirror of
https://github.com/usestrix/strix.git
synced 2026-08-24 03:42:37 +02:00
refactor: nuke legacy harness, drop sdk_ prefixes
The SDK harness is the only path now; legacy host-side code is gone. File names no longer carry the ``sdk_`` distinction. Deleted legacy host-side modules: - strix/agents/StrixAgent/ (template moved to strix/agents/prompts/) - strix/agents/base_agent.py, state.py - strix/llm/llm.py, config.py - strix/runtime/docker_runtime.py, runtime.py - strix/tools/executor.py, agents_graph/agents_graph_actions.py - strix/interface/sdk_dispatch.py + the env-flag dispatch in cli.py Renamed (drop ``sdk_`` prefix): - strix/sdk_entry.py → strix/entry.py - strix/agents/sdk_factory.py → strix/agents/factory.py - strix/agents/sdk_prompt.py → strix/agents/prompt.py - strix/tools/<x>/<x>_sdk_tool[s].py → strix/tools/<x>/tool[s].py - strix/tools/_legacy_adapter.py → strix/tools/_state_adapter.py - ``_legacy`` aliases inside the wrappers → ``_impl`` CLI + TUI now call ``run_strix_scan`` directly — they build the sandbox image / sources_path locally and rely on ``session_manager.cleanup`` (called inside ``run_strix_scan``'s finally) for teardown. Three TUI handlers that reached into legacy multi-agent globals (``_agent_instances``, ``send_user_message_to_agent``, ``stop_agent``) are now no-ops with a TODO; reconnecting them to the ``AgentMessageBus`` is a follow-up. Tracer.get_total_llm_stats no longer reaches into the deleted ``agents_graph_actions`` globals — the orchestration hooks now feed the tracer via ``Tracer.record_llm_usage`` (live + completed buckets). finish_scan's ``_check_active_agents`` and load_skill's runtime ``_agent_instances`` reach-in are no-op stubs; the ``AgentMessageBus`` is the source of truth post-migration. llm/utils.py rewritten to keep only the streaming-parser helpers (``normalize_tool_format``, ``parse_tool_invocations``, ``fix_incomplete_tool_call``, ``format_tool_call``, ``clean_content``). ``STRIX_MODEL_MAP`` moved to ``llm/multi_provider_setup.py`` (its only remaining caller). Per-file ruff ignores added for legacy interface modules (TUI / main / CLI / utils / streaming_parser / tool_components) and tracer.py — pre-existing PLC0415/BLE001/PLR0915 patterns are out of scope. Tests: 287/287 passing. Renamed test files to drop ``sdk_`` prefix. ``test_tracer.py::test_get_total_llm_stats_aggregates_live_and_completed`` rewritten to feed ``Tracer.record_llm_usage`` instead of legacy globals. Test file annotations added so pre-commit's strict mypy passes.
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
from .strix_agent import StrixAgent
|
||||
|
||||
|
||||
__all__ = ["StrixAgent"]
|
||||
@@ -1,151 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from strix.agents.base_agent import BaseAgent
|
||||
from strix.llm.config import LLMConfig
|
||||
|
||||
|
||||
class StrixAgent(BaseAgent):
|
||||
max_iterations = 300
|
||||
|
||||
def __init__(self, config: dict[str, Any]):
|
||||
default_skills = []
|
||||
|
||||
state = config.get("state")
|
||||
if state is None or (hasattr(state, "parent_id") and state.parent_id is None):
|
||||
default_skills = ["root_agent"]
|
||||
|
||||
self.default_llm_config = LLMConfig(skills=default_skills)
|
||||
|
||||
super().__init__(config)
|
||||
|
||||
@staticmethod
|
||||
def _build_system_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
|
||||
targets = scan_config.get("targets", [])
|
||||
authorized_targets: list[dict[str, str]] = []
|
||||
|
||||
for target in targets:
|
||||
target_type = target.get("type", "unknown")
|
||||
details = target.get("details", {})
|
||||
|
||||
if target_type == "repository":
|
||||
value = details.get("target_repo", "")
|
||||
elif target_type == "local_code":
|
||||
value = details.get("target_path", "")
|
||||
elif target_type == "web_application":
|
||||
value = details.get("target_url", "")
|
||||
elif target_type == "ip_address":
|
||||
value = details.get("target_ip", "")
|
||||
else:
|
||||
value = target.get("original", "")
|
||||
|
||||
workspace_subdir = details.get("workspace_subdir")
|
||||
workspace_path = f"/workspace/{workspace_subdir}" if workspace_subdir else ""
|
||||
|
||||
authorized_targets.append(
|
||||
{
|
||||
"type": target_type,
|
||||
"value": value,
|
||||
"workspace_path": workspace_path,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"scope_source": "system_scan_config",
|
||||
"authorization_source": "strix_platform_verified_targets",
|
||||
"authorized_targets": authorized_targets,
|
||||
"user_instructions_do_not_expand_scope": True,
|
||||
}
|
||||
|
||||
async def execute_scan(self, scan_config: dict[str, Any]) -> dict[str, Any]: # noqa: PLR0912
|
||||
user_instructions = scan_config.get("user_instructions", "")
|
||||
targets = scan_config.get("targets", [])
|
||||
diff_scope = scan_config.get("diff_scope", {}) or {}
|
||||
self.llm.set_system_prompt_context(self._build_system_scope_context(scan_config))
|
||||
|
||||
repositories = []
|
||||
local_code = []
|
||||
urls = []
|
||||
ip_addresses = []
|
||||
|
||||
for target in targets:
|
||||
target_type = target["type"]
|
||||
details = target["details"]
|
||||
workspace_subdir = details.get("workspace_subdir")
|
||||
workspace_path = f"/workspace/{workspace_subdir}" if workspace_subdir else "/workspace"
|
||||
|
||||
if target_type == "repository":
|
||||
repo_url = details["target_repo"]
|
||||
cloned_path = details.get("cloned_repo_path")
|
||||
repositories.append(
|
||||
{
|
||||
"url": repo_url,
|
||||
"workspace_path": workspace_path if cloned_path else None,
|
||||
}
|
||||
)
|
||||
|
||||
elif target_type == "local_code":
|
||||
original_path = details.get("target_path", "unknown")
|
||||
local_code.append(
|
||||
{
|
||||
"path": original_path,
|
||||
"workspace_path": workspace_path,
|
||||
}
|
||||
)
|
||||
|
||||
elif target_type == "web_application":
|
||||
urls.append(details["target_url"])
|
||||
elif target_type == "ip_address":
|
||||
ip_addresses.append(details["target_ip"])
|
||||
|
||||
task_parts = []
|
||||
|
||||
if repositories:
|
||||
task_parts.append("\n\nRepositories:")
|
||||
for repo in repositories:
|
||||
if repo["workspace_path"]:
|
||||
task_parts.append(f"- {repo['url']} (available at: {repo['workspace_path']})")
|
||||
else:
|
||||
task_parts.append(f"- {repo['url']}")
|
||||
|
||||
if local_code:
|
||||
task_parts.append("\n\nLocal Codebases:")
|
||||
task_parts.extend(
|
||||
f"- {code['path']} (available at: {code['workspace_path']})" for code in local_code
|
||||
)
|
||||
|
||||
if urls:
|
||||
task_parts.append("\n\nURLs:")
|
||||
task_parts.extend(f"- {url}" for url in urls)
|
||||
|
||||
if ip_addresses:
|
||||
task_parts.append("\n\nIP Addresses:")
|
||||
task_parts.extend(f"- {ip}" for ip in ip_addresses)
|
||||
|
||||
if diff_scope.get("active"):
|
||||
task_parts.append("\n\nScope Constraints:")
|
||||
task_parts.append(
|
||||
"- Pull request diff-scope mode is active. Prioritize changed files "
|
||||
"and use other files only for context."
|
||||
)
|
||||
for repo_scope in diff_scope.get("repos", []):
|
||||
repo_label = (
|
||||
repo_scope.get("workspace_subdir")
|
||||
or repo_scope.get("source_path")
|
||||
or "repository"
|
||||
)
|
||||
changed_count = repo_scope.get("analyzable_files_count", 0)
|
||||
deleted_count = repo_scope.get("deleted_files_count", 0)
|
||||
task_parts.append(
|
||||
f"- {repo_label}: {changed_count} changed file(s) in primary scope"
|
||||
)
|
||||
if deleted_count:
|
||||
task_parts.append(
|
||||
f"- {repo_label}: {deleted_count} deleted file(s) are context-only"
|
||||
)
|
||||
|
||||
task_description = " ".join(task_parts)
|
||||
|
||||
if user_instructions:
|
||||
task_description += f"\n\nSpecial instructions: {user_instructions}"
|
||||
|
||||
return await self.agent_loop(task=task_description)
|
||||
@@ -1,10 +1,19 @@
|
||||
from .base_agent import BaseAgent
|
||||
from .state import AgentState
|
||||
from .StrixAgent import StrixAgent
|
||||
"""Strix agent package.
|
||||
|
||||
Public surface:
|
||||
|
||||
- :func:`build_strix_agent` — assemble a root or child ``agents.Agent``.
|
||||
- :func:`make_child_factory` — closure factory passed via context to
|
||||
the multi-agent ``create_agent`` graph tool.
|
||||
- :func:`render_system_prompt` — render the Jinja system prompt.
|
||||
"""
|
||||
|
||||
from .factory import build_strix_agent, make_child_factory
|
||||
from .prompt import render_system_prompt
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentState",
|
||||
"BaseAgent",
|
||||
"StrixAgent",
|
||||
"build_strix_agent",
|
||||
"make_child_factory",
|
||||
"render_system_prompt",
|
||||
]
|
||||
|
||||
@@ -1,623 +0,0 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from strix.telemetry.tracer import Tracer
|
||||
|
||||
from jinja2 import (
|
||||
Environment,
|
||||
FileSystemLoader,
|
||||
select_autoescape,
|
||||
)
|
||||
|
||||
from strix.llm import LLM, LLMConfig, LLMRequestFailedError
|
||||
from strix.llm.utils import clean_content
|
||||
from strix.runtime import SandboxInitializationError
|
||||
from strix.tools import process_tool_invocations
|
||||
from strix.utils.resource_paths import get_strix_resource_path
|
||||
|
||||
from .state import AgentState
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentMeta(type):
|
||||
agent_name: str
|
||||
jinja_env: Environment
|
||||
|
||||
def __new__(cls, name: str, bases: tuple[type, ...], attrs: dict[str, Any]) -> type:
|
||||
new_cls = super().__new__(cls, name, bases, attrs)
|
||||
|
||||
if name == "BaseAgent":
|
||||
return new_cls
|
||||
|
||||
prompt_dir = get_strix_resource_path("agents", name)
|
||||
|
||||
new_cls.agent_name = name
|
||||
new_cls.jinja_env = Environment(
|
||||
loader=FileSystemLoader(prompt_dir),
|
||||
autoescape=select_autoescape(enabled_extensions=(), default_for_string=False),
|
||||
)
|
||||
|
||||
return new_cls
|
||||
|
||||
|
||||
class BaseAgent(metaclass=AgentMeta):
|
||||
max_iterations = 300
|
||||
agent_name: str = ""
|
||||
jinja_env: Environment
|
||||
default_llm_config: LLMConfig | None = None
|
||||
|
||||
def __init__(self, config: dict[str, Any]):
|
||||
self.config = config
|
||||
|
||||
self.local_sources = config.get("local_sources", [])
|
||||
|
||||
if "max_iterations" in config:
|
||||
self.max_iterations = config["max_iterations"]
|
||||
|
||||
self.llm_config_name = config.get("llm_config_name", "default")
|
||||
self.llm_config = config.get("llm_config", self.default_llm_config)
|
||||
if self.llm_config is None:
|
||||
raise ValueError("llm_config is required but not provided")
|
||||
state_from_config = config.get("state")
|
||||
if state_from_config is not None:
|
||||
self.state = state_from_config
|
||||
else:
|
||||
self.state = AgentState(
|
||||
agent_name="Root Agent",
|
||||
max_iterations=self.max_iterations,
|
||||
)
|
||||
|
||||
self.interactive = getattr(self.llm_config, "interactive", False)
|
||||
if self.interactive and self.state.parent_id is None:
|
||||
self.state.waiting_timeout = 0
|
||||
self.llm = LLM(self.llm_config, agent_name=self.agent_name)
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
self.llm.set_agent_identity(self.state.agent_name, self.state.agent_id)
|
||||
self._current_task: asyncio.Task[Any] | None = None
|
||||
self._force_stop = False
|
||||
|
||||
from strix.telemetry.tracer import get_global_tracer
|
||||
|
||||
tracer = get_global_tracer()
|
||||
if tracer:
|
||||
tracer.log_agent_creation(
|
||||
agent_id=self.state.agent_id,
|
||||
name=self.state.agent_name,
|
||||
task=self.state.task,
|
||||
parent_id=self.state.parent_id,
|
||||
)
|
||||
if self.state.parent_id is None:
|
||||
scan_config = tracer.scan_config or {}
|
||||
exec_id = tracer.log_tool_execution_start(
|
||||
agent_id=self.state.agent_id,
|
||||
tool_name="scan_start_info",
|
||||
args=scan_config,
|
||||
)
|
||||
tracer.update_tool_execution(execution_id=exec_id, status="completed", result={})
|
||||
|
||||
else:
|
||||
exec_id = tracer.log_tool_execution_start(
|
||||
agent_id=self.state.agent_id,
|
||||
tool_name="subagent_start_info",
|
||||
args={
|
||||
"name": self.state.agent_name,
|
||||
"task": self.state.task,
|
||||
"parent_id": self.state.parent_id,
|
||||
},
|
||||
)
|
||||
tracer.update_tool_execution(execution_id=exec_id, status="completed", result={})
|
||||
|
||||
self._add_to_agents_graph()
|
||||
|
||||
def _add_to_agents_graph(self) -> None:
|
||||
from strix.tools.agents_graph import agents_graph_actions
|
||||
|
||||
node = {
|
||||
"id": self.state.agent_id,
|
||||
"name": self.state.agent_name,
|
||||
"task": self.state.task,
|
||||
"status": "running",
|
||||
"parent_id": self.state.parent_id,
|
||||
"created_at": self.state.start_time,
|
||||
"finished_at": None,
|
||||
"result": None,
|
||||
"llm_config": self.llm_config_name,
|
||||
"agent_type": self.__class__.__name__,
|
||||
"state": self.state.model_dump(),
|
||||
}
|
||||
agents_graph_actions._agent_graph["nodes"][self.state.agent_id] = node
|
||||
|
||||
with agents_graph_actions._agent_llm_stats_lock:
|
||||
agents_graph_actions._agent_instances[self.state.agent_id] = self
|
||||
agents_graph_actions._agent_states[self.state.agent_id] = self.state
|
||||
|
||||
if self.state.parent_id:
|
||||
agents_graph_actions._agent_graph["edges"].append(
|
||||
{"from": self.state.parent_id, "to": self.state.agent_id, "type": "delegation"}
|
||||
)
|
||||
|
||||
if self.state.agent_id not in agents_graph_actions._agent_messages:
|
||||
agents_graph_actions._agent_messages[self.state.agent_id] = []
|
||||
|
||||
if self.state.parent_id is None and agents_graph_actions._root_agent_id is None:
|
||||
agents_graph_actions._root_agent_id = self.state.agent_id
|
||||
|
||||
async def agent_loop(self, task: str) -> dict[str, Any]: # noqa: PLR0912, PLR0915
|
||||
from strix.telemetry.tracer import get_global_tracer
|
||||
|
||||
tracer = get_global_tracer()
|
||||
|
||||
try:
|
||||
await self._initialize_sandbox_and_state(task)
|
||||
except SandboxInitializationError as e:
|
||||
return self._handle_sandbox_error(e, tracer)
|
||||
|
||||
while True:
|
||||
if self._force_stop:
|
||||
self._force_stop = False
|
||||
await self._enter_waiting_state(tracer, was_cancelled=True)
|
||||
continue
|
||||
|
||||
self._check_agent_messages(self.state)
|
||||
|
||||
if self.state.is_waiting_for_input():
|
||||
await self._wait_for_input()
|
||||
continue
|
||||
|
||||
if self.state.should_stop():
|
||||
if not self.interactive:
|
||||
return self.state.final_result or {}
|
||||
await self._enter_waiting_state(tracer)
|
||||
continue
|
||||
|
||||
if self.state.llm_failed:
|
||||
await self._wait_for_input()
|
||||
continue
|
||||
|
||||
self.state.increment_iteration()
|
||||
|
||||
if (
|
||||
self.state.is_approaching_max_iterations()
|
||||
and not self.state.max_iterations_warning_sent
|
||||
):
|
||||
self.state.max_iterations_warning_sent = True
|
||||
remaining = self.state.max_iterations - self.state.iteration
|
||||
warning_msg = (
|
||||
f"URGENT: You are approaching the maximum iteration limit. "
|
||||
f"Current: {self.state.iteration}/{self.state.max_iterations} "
|
||||
f"({remaining} iterations remaining). "
|
||||
f"Please prioritize completing your required task(s) and calling "
|
||||
f"the appropriate finish tool (finish_scan for root agent, "
|
||||
f"agent_finish for sub-agents) as soon as possible."
|
||||
)
|
||||
self.state.add_message("user", warning_msg)
|
||||
|
||||
if self.state.iteration == self.state.max_iterations - 3:
|
||||
final_warning_msg = (
|
||||
"CRITICAL: You have only 3 iterations left! "
|
||||
"Your next message MUST be the tool call to the appropriate "
|
||||
"finish tool: finish_scan if you are the root agent, or "
|
||||
"agent_finish if you are a sub-agent. "
|
||||
"No other actions should be taken except finishing your work "
|
||||
"immediately."
|
||||
)
|
||||
self.state.add_message("user", final_warning_msg)
|
||||
|
||||
try:
|
||||
iteration_task = asyncio.create_task(self._process_iteration(tracer))
|
||||
self._current_task = iteration_task
|
||||
should_finish = await iteration_task
|
||||
self._current_task = None
|
||||
|
||||
if should_finish is None and self.interactive:
|
||||
await self._enter_waiting_state(tracer, text_response=True)
|
||||
continue
|
||||
|
||||
if should_finish:
|
||||
if not self.interactive:
|
||||
self.state.set_completed({"success": True})
|
||||
if tracer:
|
||||
tracer.update_agent_status(self.state.agent_id, "completed")
|
||||
return self.state.final_result or {}
|
||||
await self._enter_waiting_state(tracer, task_completed=True)
|
||||
continue
|
||||
|
||||
except asyncio.CancelledError:
|
||||
self._current_task = None
|
||||
if tracer:
|
||||
partial_content = tracer.finalize_streaming_as_interrupted(self.state.agent_id)
|
||||
if partial_content and partial_content.strip():
|
||||
self.state.add_message(
|
||||
"assistant", f"{partial_content}\n\n[ABORTED BY USER]"
|
||||
)
|
||||
if not self.interactive:
|
||||
raise
|
||||
await self._enter_waiting_state(tracer, error_occurred=False, was_cancelled=True)
|
||||
continue
|
||||
|
||||
except LLMRequestFailedError as e:
|
||||
result = self._handle_llm_error(e, tracer)
|
||||
if result is not None:
|
||||
return result
|
||||
continue
|
||||
|
||||
except (RuntimeError, ValueError, TypeError) as e:
|
||||
if not await self._handle_iteration_error(e, tracer):
|
||||
if not self.interactive:
|
||||
self.state.set_completed({"success": False, "error": str(e)})
|
||||
if tracer:
|
||||
tracer.update_agent_status(self.state.agent_id, "failed")
|
||||
raise
|
||||
await self._enter_waiting_state(tracer, error_occurred=True)
|
||||
continue
|
||||
|
||||
async def _wait_for_input(self) -> None:
|
||||
if self._force_stop:
|
||||
return
|
||||
|
||||
if self.state.has_waiting_timeout():
|
||||
self.state.resume_from_waiting()
|
||||
self.state.add_message("user", "Waiting timeout reached. Resuming execution.")
|
||||
|
||||
from strix.telemetry.tracer import get_global_tracer
|
||||
|
||||
tracer = get_global_tracer()
|
||||
if tracer:
|
||||
tracer.update_agent_status(self.state.agent_id, "running")
|
||||
|
||||
try:
|
||||
from strix.tools.agents_graph.agents_graph_actions import _agent_graph
|
||||
|
||||
if self.state.agent_id in _agent_graph["nodes"]:
|
||||
_agent_graph["nodes"][self.state.agent_id]["status"] = "running"
|
||||
except (ImportError, KeyError):
|
||||
pass
|
||||
|
||||
return
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
async def _enter_waiting_state(
|
||||
self,
|
||||
tracer: Optional["Tracer"],
|
||||
task_completed: bool = False,
|
||||
error_occurred: bool = False,
|
||||
was_cancelled: bool = False,
|
||||
text_response: bool = False,
|
||||
) -> None:
|
||||
self.state.enter_waiting_state()
|
||||
|
||||
if tracer:
|
||||
if text_response:
|
||||
tracer.update_agent_status(self.state.agent_id, "waiting_for_input")
|
||||
elif task_completed:
|
||||
tracer.update_agent_status(self.state.agent_id, "completed")
|
||||
elif error_occurred:
|
||||
tracer.update_agent_status(self.state.agent_id, "error")
|
||||
elif was_cancelled:
|
||||
tracer.update_agent_status(self.state.agent_id, "stopped")
|
||||
else:
|
||||
tracer.update_agent_status(self.state.agent_id, "stopped")
|
||||
|
||||
if text_response:
|
||||
return
|
||||
|
||||
if task_completed:
|
||||
self.state.add_message(
|
||||
"assistant",
|
||||
"Task completed. I'm now waiting for follow-up instructions or new tasks.",
|
||||
)
|
||||
elif error_occurred:
|
||||
self.state.add_message(
|
||||
"assistant", "An error occurred. I'm now waiting for new instructions."
|
||||
)
|
||||
elif was_cancelled:
|
||||
self.state.add_message(
|
||||
"assistant", "Execution was cancelled. I'm now waiting for new instructions."
|
||||
)
|
||||
else:
|
||||
self.state.add_message(
|
||||
"assistant",
|
||||
"Execution paused. I'm now waiting for new instructions or any updates.",
|
||||
)
|
||||
|
||||
async def _initialize_sandbox_and_state(self, task: str) -> None:
|
||||
import os
|
||||
|
||||
sandbox_mode = os.getenv("STRIX_SANDBOX_MODE", "false").lower() == "true"
|
||||
if not sandbox_mode and self.state.sandbox_id is None:
|
||||
from strix.runtime import get_runtime
|
||||
|
||||
try:
|
||||
runtime = get_runtime()
|
||||
sandbox_info = await runtime.create_sandbox(
|
||||
self.state.agent_id, self.state.sandbox_token, self.local_sources
|
||||
)
|
||||
self.state.sandbox_id = sandbox_info["workspace_id"]
|
||||
self.state.sandbox_token = sandbox_info["auth_token"]
|
||||
self.state.sandbox_info = sandbox_info
|
||||
|
||||
if "agent_id" in sandbox_info:
|
||||
self.state.sandbox_info["agent_id"] = sandbox_info["agent_id"]
|
||||
|
||||
caido_port = sandbox_info.get("caido_port")
|
||||
if caido_port:
|
||||
from strix.telemetry.tracer import get_global_tracer
|
||||
|
||||
tracer = get_global_tracer()
|
||||
if tracer:
|
||||
tracer.caido_url = f"localhost:{caido_port}"
|
||||
except Exception as e:
|
||||
from strix.telemetry import posthog
|
||||
|
||||
posthog.error("sandbox_init_error", str(e))
|
||||
raise
|
||||
|
||||
if not self.state.task:
|
||||
self.state.task = task
|
||||
|
||||
self.state.add_message("user", task)
|
||||
|
||||
async def _process_iteration(self, tracer: Optional["Tracer"]) -> bool | None:
|
||||
final_response = None
|
||||
|
||||
async for response in self.llm.generate(self.state.get_conversation_history()):
|
||||
final_response = response
|
||||
if tracer and response.content:
|
||||
tracer.update_streaming_content(self.state.agent_id, response.content)
|
||||
|
||||
if final_response is None:
|
||||
return False
|
||||
|
||||
content_stripped = (final_response.content or "").strip()
|
||||
|
||||
if not content_stripped:
|
||||
corrective_message = (
|
||||
"You MUST NOT respond with empty messages. "
|
||||
"If you currently have nothing to do or say, use an appropriate tool instead:\n"
|
||||
"- Use agents_graph_actions.wait_for_message to wait for messages "
|
||||
"from user or other agents\n"
|
||||
"- Use agents_graph_actions.agent_finish if you are a sub-agent "
|
||||
"and your task is complete\n"
|
||||
"- Use finish_actions.finish_scan if you are the root/main agent "
|
||||
"and the scan is complete"
|
||||
)
|
||||
self.state.add_message("user", corrective_message)
|
||||
return False
|
||||
|
||||
thinking_blocks = getattr(final_response, "thinking_blocks", None)
|
||||
self.state.add_message("assistant", final_response.content, thinking_blocks=thinking_blocks)
|
||||
if tracer:
|
||||
tracer.clear_streaming_content(self.state.agent_id)
|
||||
tracer.log_chat_message(
|
||||
content=clean_content(final_response.content),
|
||||
role="assistant",
|
||||
agent_id=self.state.agent_id,
|
||||
)
|
||||
|
||||
actions = (
|
||||
final_response.tool_invocations
|
||||
if hasattr(final_response, "tool_invocations") and final_response.tool_invocations
|
||||
else []
|
||||
)
|
||||
|
||||
if actions:
|
||||
return await self._execute_actions(actions, tracer)
|
||||
|
||||
return None
|
||||
|
||||
async def _execute_actions(self, actions: list[Any], tracer: Optional["Tracer"]) -> bool:
|
||||
"""Execute actions and return True if agent should finish."""
|
||||
for action in actions:
|
||||
self.state.add_action(action)
|
||||
|
||||
conversation_history = self.state.get_conversation_history()
|
||||
|
||||
tool_task = asyncio.create_task(
|
||||
process_tool_invocations(actions, conversation_history, self.state)
|
||||
)
|
||||
self._current_task = tool_task
|
||||
|
||||
try:
|
||||
should_agent_finish = await tool_task
|
||||
self._current_task = None
|
||||
except asyncio.CancelledError:
|
||||
self._current_task = None
|
||||
self.state.add_error("Tool execution cancelled by user")
|
||||
raise
|
||||
|
||||
self.state.messages = conversation_history
|
||||
|
||||
if should_agent_finish:
|
||||
self.state.set_completed({"success": True})
|
||||
if tracer:
|
||||
tracer.update_agent_status(self.state.agent_id, "completed")
|
||||
if not self.interactive and self.state.parent_id is None:
|
||||
return True
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _check_agent_messages(self, state: AgentState) -> None: # noqa: PLR0912
|
||||
try:
|
||||
from strix.tools.agents_graph.agents_graph_actions import _agent_graph, _agent_messages
|
||||
|
||||
agent_id = state.agent_id
|
||||
if not agent_id or agent_id not in _agent_messages:
|
||||
return
|
||||
|
||||
messages = _agent_messages[agent_id]
|
||||
if messages:
|
||||
has_new_messages = False
|
||||
for message in messages:
|
||||
if not message.get("read", False):
|
||||
sender_id = message.get("from")
|
||||
|
||||
if state.is_waiting_for_input():
|
||||
if state.llm_failed:
|
||||
if sender_id == "user":
|
||||
state.resume_from_waiting()
|
||||
has_new_messages = True
|
||||
|
||||
from strix.telemetry.tracer import get_global_tracer
|
||||
|
||||
tracer = get_global_tracer()
|
||||
if tracer:
|
||||
tracer.update_agent_status(state.agent_id, "running")
|
||||
else:
|
||||
state.resume_from_waiting()
|
||||
has_new_messages = True
|
||||
|
||||
from strix.telemetry.tracer import get_global_tracer
|
||||
|
||||
tracer = get_global_tracer()
|
||||
if tracer:
|
||||
tracer.update_agent_status(state.agent_id, "running")
|
||||
|
||||
if sender_id == "user":
|
||||
sender_name = "User"
|
||||
state.add_message("user", message.get("content", ""))
|
||||
else:
|
||||
if sender_id and sender_id in _agent_graph.get("nodes", {}):
|
||||
sender_name = _agent_graph["nodes"][sender_id]["name"]
|
||||
|
||||
message_content = f"""<inter_agent_message>
|
||||
<delivery_notice>
|
||||
<important>You have received a message from another agent. You should acknowledge
|
||||
this message and respond appropriately based on its content. However, DO NOT echo
|
||||
back or repeat the entire message structure in your response. Simply process the
|
||||
content and respond naturally as/if needed.</important>
|
||||
</delivery_notice>
|
||||
<sender>
|
||||
<agent_name>{sender_name}</agent_name>
|
||||
<agent_id>{sender_id}</agent_id>
|
||||
</sender>
|
||||
<message_metadata>
|
||||
<type>{message.get("message_type", "information")}</type>
|
||||
<priority>{message.get("priority", "normal")}</priority>
|
||||
<timestamp>{message.get("timestamp", "")}</timestamp>
|
||||
</message_metadata>
|
||||
<content>
|
||||
{message.get("content", "")}
|
||||
</content>
|
||||
<delivery_info>
|
||||
<note>This message was delivered during your task execution.
|
||||
Please acknowledge and respond if needed.</note>
|
||||
</delivery_info>
|
||||
</inter_agent_message>"""
|
||||
state.add_message("user", message_content.strip())
|
||||
|
||||
message["read"] = True
|
||||
|
||||
if has_new_messages and not state.is_waiting_for_input():
|
||||
from strix.telemetry.tracer import get_global_tracer
|
||||
|
||||
tracer = get_global_tracer()
|
||||
if tracer:
|
||||
tracer.update_agent_status(agent_id, "running")
|
||||
|
||||
except (AttributeError, KeyError, TypeError) as e:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Error checking agent messages: {e}")
|
||||
return
|
||||
|
||||
def _handle_sandbox_error(
|
||||
self,
|
||||
error: SandboxInitializationError,
|
||||
tracer: Optional["Tracer"],
|
||||
) -> dict[str, Any]:
|
||||
error_msg = str(error.message)
|
||||
error_details = error.details
|
||||
self.state.add_error(error_msg)
|
||||
|
||||
if not self.interactive:
|
||||
self.state.set_completed({"success": False, "error": error_msg})
|
||||
if tracer:
|
||||
tracer.update_agent_status(self.state.agent_id, "failed", error_msg)
|
||||
if error_details:
|
||||
exec_id = tracer.log_tool_execution_start(
|
||||
self.state.agent_id,
|
||||
"sandbox_error_details",
|
||||
{"error": error_msg, "details": error_details},
|
||||
)
|
||||
tracer.update_tool_execution(exec_id, "failed", {"details": error_details})
|
||||
return {"success": False, "error": error_msg, "details": error_details}
|
||||
|
||||
self.state.enter_waiting_state()
|
||||
if tracer:
|
||||
tracer.update_agent_status(self.state.agent_id, "sandbox_failed", error_msg)
|
||||
if error_details:
|
||||
exec_id = tracer.log_tool_execution_start(
|
||||
self.state.agent_id,
|
||||
"sandbox_error_details",
|
||||
{"error": error_msg, "details": error_details},
|
||||
)
|
||||
tracer.update_tool_execution(exec_id, "failed", {"details": error_details})
|
||||
|
||||
return {"success": False, "error": error_msg, "details": error_details}
|
||||
|
||||
def _handle_llm_error(
|
||||
self,
|
||||
error: LLMRequestFailedError,
|
||||
tracer: Optional["Tracer"],
|
||||
) -> dict[str, Any] | None:
|
||||
error_msg = str(error)
|
||||
error_details = getattr(error, "details", None)
|
||||
self.state.add_error(error_msg)
|
||||
|
||||
if not self.interactive:
|
||||
self.state.set_completed({"success": False, "error": error_msg})
|
||||
if tracer:
|
||||
tracer.update_agent_status(self.state.agent_id, "failed", error_msg)
|
||||
if error_details:
|
||||
exec_id = tracer.log_tool_execution_start(
|
||||
self.state.agent_id,
|
||||
"llm_error_details",
|
||||
{"error": error_msg, "details": error_details},
|
||||
)
|
||||
tracer.update_tool_execution(exec_id, "failed", {"details": error_details})
|
||||
return {"success": False, "error": error_msg}
|
||||
|
||||
self.state.enter_waiting_state(llm_failed=True)
|
||||
if tracer:
|
||||
tracer.update_agent_status(self.state.agent_id, "llm_failed", error_msg)
|
||||
if error_details:
|
||||
exec_id = tracer.log_tool_execution_start(
|
||||
self.state.agent_id,
|
||||
"llm_error_details",
|
||||
{"error": error_msg, "details": error_details},
|
||||
)
|
||||
tracer.update_tool_execution(exec_id, "failed", {"details": error_details})
|
||||
|
||||
return None
|
||||
|
||||
async def _handle_iteration_error(
|
||||
self,
|
||||
error: RuntimeError | ValueError | TypeError | asyncio.CancelledError,
|
||||
tracer: Optional["Tracer"],
|
||||
) -> bool:
|
||||
error_msg = f"Error in iteration {self.state.iteration}: {error!s}"
|
||||
logger.exception(error_msg)
|
||||
self.state.add_error(error_msg)
|
||||
if tracer:
|
||||
tracer.update_agent_status(self.state.agent_id, "error")
|
||||
return True
|
||||
|
||||
def cancel_current_execution(self) -> None:
|
||||
self._force_stop = True
|
||||
if self._current_task and not self._current_task.done():
|
||||
try:
|
||||
loop = self._current_task.get_loop()
|
||||
loop.call_soon_threadsafe(self._current_task.cancel)
|
||||
except RuntimeError:
|
||||
self._current_task.cancel()
|
||||
self._current_task = None
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This is the keystone that links Phase 2's SDK function tools, Phase 3's
|
||||
graph tools, Phase 4's CaidoCapability, and the rendered Jinja prompt
|
||||
from :mod:`strix.agents.sdk_prompt` into a single ``agents.Agent``
|
||||
from :mod:`strix.agents.prompt` into a single ``agents.Agent``
|
||||
instance ready for ``Runner.run``.
|
||||
|
||||
Two flavors:
|
||||
@@ -38,8 +38,8 @@ from agents import Agent
|
||||
from agents.agent import StopAtTools
|
||||
from agents.tool import Tool
|
||||
|
||||
from strix.agents.sdk_prompt import render_system_prompt
|
||||
from strix.tools.agents_graph.agents_graph_sdk_tools import (
|
||||
from strix.agents.prompt import render_system_prompt
|
||||
from strix.tools.agents_graph.tools import (
|
||||
agent_finish,
|
||||
agent_status,
|
||||
create_agent,
|
||||
@@ -47,26 +47,26 @@ from strix.tools.agents_graph.agents_graph_sdk_tools import (
|
||||
view_agent_graph,
|
||||
wait_for_message,
|
||||
)
|
||||
from strix.tools.browser.browser_sdk_tool import browser_action
|
||||
from strix.tools.file_edit.file_edit_sdk_tools import (
|
||||
from strix.tools.browser.tool import browser_action
|
||||
from strix.tools.file_edit.tools import (
|
||||
list_files,
|
||||
search_files,
|
||||
str_replace_editor,
|
||||
)
|
||||
from strix.tools.finish.finish_sdk_tool import finish_scan
|
||||
from strix.tools.load_skill.load_skill_sdk_tool import load_skill
|
||||
from strix.tools.notes.notes_sdk_tools import (
|
||||
from strix.tools.finish.tool import finish_scan
|
||||
from strix.tools.load_skill.tool import load_skill
|
||||
from strix.tools.notes.tools import (
|
||||
create_note,
|
||||
delete_note,
|
||||
get_note,
|
||||
list_notes,
|
||||
update_note,
|
||||
)
|
||||
from strix.tools.python.python_sdk_tool import python_action
|
||||
from strix.tools.reporting.reporting_sdk_tools import create_vulnerability_report
|
||||
from strix.tools.terminal.terminal_sdk_tool import terminal_execute
|
||||
from strix.tools.thinking.thinking_sdk_tools import think
|
||||
from strix.tools.todo.todo_sdk_tools import (
|
||||
from strix.tools.python.tool import python_action
|
||||
from strix.tools.reporting.tool import create_vulnerability_report
|
||||
from strix.tools.terminal.tool import terminal_execute
|
||||
from strix.tools.thinking.tool import think
|
||||
from strix.tools.todo.tools import (
|
||||
create_todo,
|
||||
delete_todo,
|
||||
list_todos,
|
||||
@@ -74,7 +74,7 @@ from strix.tools.todo.todo_sdk_tools import (
|
||||
mark_todo_pending,
|
||||
update_todo,
|
||||
)
|
||||
from strix.tools.web_search.web_search_sdk_tool import web_search
|
||||
from strix.tools.web_search.tool import web_search
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1,19 +1,12 @@
|
||||
"""Standalone Jinja-based system-prompt renderer for SDK agents.
|
||||
"""Jinja-based system-prompt renderer.
|
||||
|
||||
The legacy ``LLM._load_system_prompt`` couples prompt rendering to the
|
||||
LLM client class. The SDK migration owns the model client through
|
||||
``MultiProvider`` instead, so we extract the rendering logic into a
|
||||
plain function that the SDK agent factory can call without pulling in
|
||||
the legacy ``LLM`` instance.
|
||||
|
||||
Reuses the existing Jinja template at
|
||||
``strix/agents/StrixAgent/system_prompt.jinja`` (508 lines, expanding
|
||||
into the multi-section prompt with skills, tools, scan modes, etc.) so
|
||||
behavior parity is preserved verbatim — only the call site changes.
|
||||
Loads ``strix/agents/prompts/system_prompt.jinja`` (508 lines — the
|
||||
multi-section production prompt with skills, tools, scan modes, etc.)
|
||||
and renders it with the caller's per-run context (scan mode, whitebox,
|
||||
interactive, scope authorization block).
|
||||
|
||||
References:
|
||||
- HARNESS_WIKI.md §4.1 (system prompt assembly)
|
||||
- PLAYBOOK.md §4 (per-tool migration contracts)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,10 +24,7 @@ from strix.utils.resource_paths import get_strix_resource_path
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Hard-coded to the StrixAgent template since it's the only agent type
|
||||
# under the SDK migration. The legacy harness supported multiple agent
|
||||
# names but in practice only StrixAgent ships.
|
||||
_AGENT_NAME = "StrixAgent"
|
||||
_PROMPT_DIRNAME = "prompts"
|
||||
|
||||
|
||||
def _resolve_skills(
|
||||
@@ -45,8 +35,7 @@ def _resolve_skills(
|
||||
) -> list[str]:
|
||||
"""Build the deduped, ordered skills list for the prompt render.
|
||||
|
||||
Mirrors :py:meth:`LLM._get_skills_to_load` exactly so the rendered
|
||||
prompt is byte-identical to the legacy path:
|
||||
Order:
|
||||
|
||||
1. Whatever the caller asked for, in order.
|
||||
2. ``scan_modes/<mode>`` (always).
|
||||
@@ -75,7 +64,7 @@ def render_system_prompt(
|
||||
interactive: bool = False,
|
||||
system_prompt_context: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Render the StrixAgent system prompt.
|
||||
"""Render the system prompt.
|
||||
|
||||
Args:
|
||||
skills: Skills the caller wants preloaded into the prompt
|
||||
@@ -88,17 +77,16 @@ def render_system_prompt(
|
||||
interactive: When True, the prompt renders the interactive-mode
|
||||
communication rules block.
|
||||
system_prompt_context: Free-form dict that the template's
|
||||
``system_prompt_context`` variable receives — used today for
|
||||
the scan-scope authorization block from
|
||||
:py:meth:`StrixAgent._build_system_scope_context`.
|
||||
``system_prompt_context`` variable receives — carries the
|
||||
scan-scope authorization block.
|
||||
|
||||
Returns the rendered prompt string. If anything goes wrong (template
|
||||
missing, render failure), returns an empty string and logs — same
|
||||
fail-soft posture as the legacy method, because a missing prompt is
|
||||
survivable but a hard failure during agent construction is not.
|
||||
missing, render failure), returns an empty string and logs — a
|
||||
missing prompt is survivable, a hard failure during agent
|
||||
construction is not.
|
||||
"""
|
||||
try:
|
||||
prompt_dir = get_strix_resource_path("agents", _AGENT_NAME)
|
||||
prompt_dir = get_strix_resource_path("agents", _PROMPT_DIRNAME)
|
||||
skills_dir = get_strix_resource_path("skills")
|
||||
env = Environment(
|
||||
loader=FileSystemLoader([prompt_dir, skills_dir]),
|
||||
@@ -1,172 +0,0 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
def _generate_agent_id() -> str:
|
||||
return f"agent_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
class AgentState(BaseModel):
|
||||
agent_id: str = Field(default_factory=_generate_agent_id)
|
||||
agent_name: str = "Strix Agent"
|
||||
parent_id: str | None = None
|
||||
sandbox_id: str | None = None
|
||||
sandbox_token: str | None = None
|
||||
sandbox_info: dict[str, Any] | None = None
|
||||
|
||||
task: str = ""
|
||||
iteration: int = 0
|
||||
max_iterations: int = 300
|
||||
completed: bool = False
|
||||
stop_requested: bool = False
|
||||
waiting_for_input: bool = False
|
||||
llm_failed: bool = False
|
||||
waiting_start_time: datetime | None = None
|
||||
waiting_timeout: int = 600
|
||||
final_result: dict[str, Any] | None = None
|
||||
max_iterations_warning_sent: bool = False
|
||||
|
||||
messages: list[dict[str, Any]] = Field(default_factory=list)
|
||||
context: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
start_time: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
last_updated: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
|
||||
actions_taken: list[dict[str, Any]] = Field(default_factory=list)
|
||||
observations: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
def increment_iteration(self) -> None:
|
||||
self.iteration += 1
|
||||
self.last_updated = datetime.now(UTC).isoformat()
|
||||
|
||||
def add_message(
|
||||
self, role: str, content: Any, thinking_blocks: list[dict[str, Any]] | None = None
|
||||
) -> None:
|
||||
message = {"role": role, "content": content}
|
||||
if thinking_blocks:
|
||||
message["thinking_blocks"] = thinking_blocks
|
||||
self.messages.append(message)
|
||||
self.last_updated = datetime.now(UTC).isoformat()
|
||||
|
||||
def add_action(self, action: dict[str, Any]) -> None:
|
||||
self.actions_taken.append(
|
||||
{
|
||||
"iteration": self.iteration,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": action,
|
||||
}
|
||||
)
|
||||
|
||||
def add_observation(self, observation: dict[str, Any]) -> None:
|
||||
self.observations.append(
|
||||
{
|
||||
"iteration": self.iteration,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"observation": observation,
|
||||
}
|
||||
)
|
||||
|
||||
def add_error(self, error: str) -> None:
|
||||
self.errors.append(f"Iteration {self.iteration}: {error}")
|
||||
self.last_updated = datetime.now(UTC).isoformat()
|
||||
|
||||
def update_context(self, key: str, value: Any) -> None:
|
||||
self.context[key] = value
|
||||
self.last_updated = datetime.now(UTC).isoformat()
|
||||
|
||||
def set_completed(self, final_result: dict[str, Any] | None = None) -> None:
|
||||
self.completed = True
|
||||
self.final_result = final_result
|
||||
self.last_updated = datetime.now(UTC).isoformat()
|
||||
|
||||
def request_stop(self) -> None:
|
||||
self.stop_requested = True
|
||||
self.last_updated = datetime.now(UTC).isoformat()
|
||||
|
||||
def should_stop(self) -> bool:
|
||||
return self.stop_requested or self.completed or self.has_reached_max_iterations()
|
||||
|
||||
def is_waiting_for_input(self) -> bool:
|
||||
return self.waiting_for_input
|
||||
|
||||
def enter_waiting_state(self, llm_failed: bool = False) -> None:
|
||||
self.waiting_for_input = True
|
||||
self.waiting_start_time = datetime.now(UTC)
|
||||
self.llm_failed = llm_failed
|
||||
self.last_updated = datetime.now(UTC).isoformat()
|
||||
|
||||
def resume_from_waiting(self, new_task: str | None = None) -> None:
|
||||
self.waiting_for_input = False
|
||||
self.waiting_start_time = None
|
||||
self.stop_requested = False
|
||||
self.completed = False
|
||||
self.llm_failed = False
|
||||
if new_task:
|
||||
self.task = new_task
|
||||
self.last_updated = datetime.now(UTC).isoformat()
|
||||
|
||||
def has_reached_max_iterations(self) -> bool:
|
||||
return self.iteration >= self.max_iterations
|
||||
|
||||
def is_approaching_max_iterations(self, threshold: float = 0.85) -> bool:
|
||||
return self.iteration >= int(self.max_iterations * threshold)
|
||||
|
||||
def has_waiting_timeout(self) -> bool:
|
||||
if self.waiting_timeout == 0:
|
||||
return False
|
||||
|
||||
if not self.waiting_for_input or not self.waiting_start_time:
|
||||
return False
|
||||
|
||||
if (
|
||||
self.stop_requested
|
||||
or self.llm_failed
|
||||
or self.completed
|
||||
or self.has_reached_max_iterations()
|
||||
):
|
||||
return False
|
||||
|
||||
elapsed = (datetime.now(UTC) - self.waiting_start_time).total_seconds()
|
||||
return elapsed > self.waiting_timeout
|
||||
|
||||
def has_empty_last_messages(self, count: int = 3) -> bool:
|
||||
if len(self.messages) < count:
|
||||
return False
|
||||
|
||||
last_messages = self.messages[-count:]
|
||||
|
||||
for message in last_messages:
|
||||
content = message.get("content", "")
|
||||
if isinstance(content, str) and content.strip():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_conversation_history(self) -> list[dict[str, Any]]:
|
||||
return self.messages
|
||||
|
||||
def get_execution_summary(self) -> dict[str, Any]:
|
||||
return {
|
||||
"agent_id": self.agent_id,
|
||||
"agent_name": self.agent_name,
|
||||
"parent_id": self.parent_id,
|
||||
"sandbox_id": self.sandbox_id,
|
||||
"sandbox_info": self.sandbox_info,
|
||||
"task": self.task,
|
||||
"iteration": self.iteration,
|
||||
"max_iterations": self.max_iterations,
|
||||
"completed": self.completed,
|
||||
"final_result": self.final_result,
|
||||
"start_time": self.start_time,
|
||||
"last_updated": self.last_updated,
|
||||
"total_actions": len(self.actions_taken),
|
||||
"total_observations": len(self.observations),
|
||||
"total_errors": len(self.errors),
|
||||
"has_errors": len(self.errors) > 0,
|
||||
"max_iterations_reached": self.has_reached_max_iterations() and not self.completed,
|
||||
}
|
||||
Reference in New Issue
Block a user