fix: address audit findings — SDK plumbing, TUI bus, dead code
Critical fixes: - ``StrixOrchestrationHooks.on_agent_start`` now finds the ``CaidoCapability`` via ``ctx.context['caido_capability']`` instead of ``agent.capabilities`` (we use plain ``Agent``, not ``SandboxAgent``, so the latter never existed). The session manager's bundle already exposes the capability; ``run_strix_scan`` threads it through ``make_agent_context`` and ``create_agent`` forwards it to children. - ``run_strix_scan`` registers the ``StrixTracingProcessor`` with the SDK's tracing provider via ``add_trace_processor`` so SDK trace spans hit ``run_dir/events.jsonl`` (was previously a parallel stream the SDK ignored). - ``on_llm_end`` now writes to ``Tracer.record_llm_usage`` in addition to ``bus.record_usage`` so the CLI/TUI stats panel sees real numbers instead of zeros. - ``run_strix_scan`` accepts an externally-built ``AgentMessageBus`` + an explicit ``model`` arg. The TUI pre-creates the bus so its stop and chat-input handlers can submit ``bus.send`` / ``bus.cancel_descendants`` coroutines onto the scan thread's loop via ``asyncio.run_coroutine_threadsafe`` — replacing the TODO-stub no-ops. - ``model`` config now propagates root → context → child agents in ``create_agent`` (was hardcoded fallback). Dead-code removal: - Deleted the ``load_skill`` tool entirely (host module, sandbox module, TUI renderer, tests). The legacy implementation reached into a global ``_agent_instances`` registry that no longer exists; the post-migration stub returned ``success=True`` without injecting anything — pure theater. Skills are still preloaded via the system prompt at scan-bring-up. - Dropped ``tenacity`` and ``xmltodict`` from ``[project.dependencies]`` — neither is imported anywhere post-migration. - Stripped the system prompt's "use the load_skill tool" lines. Tests: 278/278 passing. Removed two ``load_skill`` test cases and a ``test_tool_registration_modes::test_load_skill_import_...`` assertion that exercised the deleted module. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-5
@@ -35,12 +35,10 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"litellm[proxy]>=1.83.0",
|
"litellm[proxy]>=1.83.0",
|
||||||
"openai-agents[litellm]==0.14.6",
|
"openai-agents[litellm]==0.14.6",
|
||||||
"tenacity>=9.0.0",
|
|
||||||
"pydantic[email]>=2.11.3",
|
"pydantic[email]>=2.11.3",
|
||||||
"rich",
|
"rich",
|
||||||
"docker>=7.1.0",
|
"docker>=7.1.0",
|
||||||
"textual>=6.0.0",
|
"textual>=6.0.0",
|
||||||
"xmltodict>=0.13.0",
|
|
||||||
"requests>=2.32.0",
|
"requests>=2.32.0",
|
||||||
"cvss>=3.2",
|
"cvss>=3.2",
|
||||||
"traceloop-sdk>=0.53.0",
|
"traceloop-sdk>=0.53.0",
|
||||||
@@ -119,7 +117,6 @@ pretty = true
|
|||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = [
|
module = [
|
||||||
"litellm.*",
|
"litellm.*",
|
||||||
"tenacity.*",
|
|
||||||
"numpydoc.*",
|
"numpydoc.*",
|
||||||
"rich.*",
|
"rich.*",
|
||||||
"IPython.*",
|
"IPython.*",
|
||||||
@@ -294,7 +291,6 @@ ignore = [
|
|||||||
"strix/tools/web_search/tool.py" = ["TC002"]
|
"strix/tools/web_search/tool.py" = ["TC002"]
|
||||||
"strix/tools/file_edit/tools.py" = ["TC002"]
|
"strix/tools/file_edit/tools.py" = ["TC002"]
|
||||||
"strix/tools/reporting/tool.py" = ["TC002"]
|
"strix/tools/reporting/tool.py" = ["TC002"]
|
||||||
"strix/tools/load_skill/tool.py" = ["TC002"]
|
|
||||||
"strix/tools/finish/tool.py" = ["TC002"]
|
"strix/tools/finish/tool.py" = ["TC002"]
|
||||||
"strix/tools/browser/tool.py" = ["TC002"]
|
"strix/tools/browser/tool.py" = ["TC002"]
|
||||||
"strix/tools/terminal/tool.py" = ["TC002"]
|
"strix/tools/terminal/tool.py" = ["TC002"]
|
||||||
@@ -420,7 +416,7 @@ force_grid_wrap = 0
|
|||||||
use_parentheses = true
|
use_parentheses = true
|
||||||
ensure_newline_before_comments = true
|
ensure_newline_before_comments = true
|
||||||
known_first_party = ["strix"]
|
known_first_party = ["strix"]
|
||||||
known_third_party = ["fastapi", "pydantic", "litellm", "tenacity"]
|
known_third_party = ["fastapi", "pydantic", "litellm"]
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Pytest Configuration
|
# Pytest Configuration
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ Two flavors:
|
|||||||
|
|
||||||
Caido tools come from ``CaidoCapability.tools()`` automatically via
|
Caido tools come from ``CaidoCapability.tools()`` automatically via
|
||||||
the SDK's capability merge — we don't include them here. Skills are
|
the SDK's capability merge — we don't include them here. Skills are
|
||||||
injected via the prompt; the model can also load more at runtime via
|
injected via the prompt at scan-bring-up time; runtime skill loading
|
||||||
the ``load_skill`` tool.
|
isn't exposed as a tool any more (the legacy implementation reached
|
||||||
|
into a global agent registry that no longer exists).
|
||||||
|
|
||||||
References:
|
References:
|
||||||
- PLAYBOOK.md §4.3 (graph tool wiring)
|
- PLAYBOOK.md §4.3 (graph tool wiring)
|
||||||
@@ -54,7 +55,6 @@ from strix.tools.file_edit.tools import (
|
|||||||
str_replace_editor,
|
str_replace_editor,
|
||||||
)
|
)
|
||||||
from strix.tools.finish.tool import finish_scan
|
from strix.tools.finish.tool import finish_scan
|
||||||
from strix.tools.load_skill.tool import load_skill
|
|
||||||
from strix.tools.notes.tools import (
|
from strix.tools.notes.tools import (
|
||||||
create_note,
|
create_note,
|
||||||
delete_note,
|
delete_note,
|
||||||
@@ -109,8 +109,6 @@ _BASE_TOOLS: tuple[Tool, ...] = (
|
|||||||
search_files,
|
search_files,
|
||||||
# Reporting
|
# Reporting
|
||||||
create_vulnerability_report,
|
create_vulnerability_report,
|
||||||
# Skill loading
|
|
||||||
load_skill,
|
|
||||||
# Sandbox primitives
|
# Sandbox primitives
|
||||||
browser_action,
|
browser_action,
|
||||||
terminal_execute,
|
terminal_execute,
|
||||||
@@ -140,8 +138,7 @@ def build_strix_agent(
|
|||||||
name: Agent name. Surfaces in traces and the bus's ``names`` map.
|
name: Agent name. Surfaces in traces and the bus's ``names`` map.
|
||||||
Defaults to ``"strix"`` for the root; create_agent passes
|
Defaults to ``"strix"`` for the root; create_agent passes
|
||||||
distinct names per child.
|
distinct names per child.
|
||||||
skills: Skills to preload into the system prompt. The agent can
|
skills: Skills to preload into the system prompt.
|
||||||
also load more at runtime via the ``load_skill`` tool.
|
|
||||||
is_root: Selects the tool list and ``tool_use_behavior``.
|
is_root: Selects the tool list and ``tool_use_behavior``.
|
||||||
Root carries ``finish_scan`` and stops there; child carries
|
Root carries ``finish_scan`` and stops there; child carries
|
||||||
``agent_finish`` and stops there.
|
``agent_finish`` and stops there.
|
||||||
|
|||||||
@@ -67,9 +67,7 @@ def render_system_prompt(
|
|||||||
"""Render the system prompt.
|
"""Render the system prompt.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
skills: Skills the caller wants preloaded into the prompt
|
skills: Skills the caller wants preloaded into the prompt context.
|
||||||
context (the agent can also load more at runtime via the
|
|
||||||
``load_skill`` tool).
|
|
||||||
scan_mode: ``"deep" | "fast" | ...``. Maps to ``scan_modes/<mode>``
|
scan_mode: ``"deep" | "fast" | ...``. Maps to ``scan_modes/<mode>``
|
||||||
skill.
|
skill.
|
||||||
is_whitebox: When True, the source-aware whitebox skill stack
|
is_whitebox: When True, the source-aware whitebox skill stack
|
||||||
|
|||||||
@@ -148,9 +148,7 @@ OPERATIONAL PRINCIPLES:
|
|||||||
- Default to recon first. Unless the next step is obvious from context or the user/system gives specific prioritization instructions, begin by mapping the target well before diving into narrow validation or targeted testing
|
- Default to recon first. Unless the next step is obvious from context or the user/system gives specific prioritization instructions, begin by mapping the target well before diving into narrow validation or targeted testing
|
||||||
- Prefer established industry-standard tools already available in the sandbox before writing custom scripts
|
- Prefer established industry-standard tools already available in the sandbox before writing custom scripts
|
||||||
- Do NOT reinvent the wheel with ad hoc Python or shell code when a suitable existing tool can do the job reliably
|
- Do NOT reinvent the wheel with ad hoc Python or shell code when a suitable existing tool can do the job reliably
|
||||||
- Use the load_skill tool when you need exact vulnerability-specific, protocol-specific, or tool-specific guidance before acting
|
- Skills relevant to your task are preloaded into this prompt at scan start; refer back to them when you need vulnerability-, protocol-, or tool-specific guidance
|
||||||
- Prefer loading a relevant skill before guessing payloads, workflows, or tool syntax from memory
|
|
||||||
- If a task maps cleanly to one or more available skills, load them early and let them guide your next actions
|
|
||||||
- Use custom Python or shell code when you want to dig deeper, automate custom workflows, batch operations, triage results, build target-specific validation, or do work that existing tools do not cover cleanly
|
- Use custom Python or shell code when you want to dig deeper, automate custom workflows, batch operations, triage results, build target-specific validation, or do work that existing tools do not cover cleanly
|
||||||
- Chain related weaknesses when needed to demonstrate real impact
|
- Chain related weaknesses when needed to demonstrate real impact
|
||||||
- Consider business logic and context in validation
|
- Consider business logic and context in validation
|
||||||
|
|||||||
+24
-1
@@ -21,6 +21,7 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from agents import Runner
|
from agents import Runner
|
||||||
|
from agents.tracing import add_trace_processor
|
||||||
|
|
||||||
from strix.agents.factory import build_strix_agent, make_child_factory
|
from strix.agents.factory import build_strix_agent, make_child_factory
|
||||||
from strix.orchestration.bus import AgentMessageBus
|
from strix.orchestration.bus import AgentMessageBus
|
||||||
@@ -31,6 +32,7 @@ from strix.run_config_factory import (
|
|||||||
make_run_config,
|
make_run_config,
|
||||||
)
|
)
|
||||||
from strix.sandbox import session_manager
|
from strix.sandbox import session_manager
|
||||||
|
from strix.telemetry.strix_processor import StrixTracingProcessor
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -156,8 +158,10 @@ async def run_strix_scan(
|
|||||||
image: str,
|
image: str,
|
||||||
sources_path: Path,
|
sources_path: Path,
|
||||||
tracer: Any | None = None,
|
tracer: Any | None = None,
|
||||||
|
bus: AgentMessageBus | None = None,
|
||||||
interactive: bool = False,
|
interactive: bool = False,
|
||||||
max_turns: int = STRIX_DEFAULT_MAX_TURNS,
|
max_turns: int = STRIX_DEFAULT_MAX_TURNS,
|
||||||
|
model: str = "anthropic/claude-sonnet-4-6",
|
||||||
cleanup_on_exit: bool = True,
|
cleanup_on_exit: bool = True,
|
||||||
) -> RunResult:
|
) -> RunResult:
|
||||||
"""Run one Strix scan end-to-end against a freshly-prepared sandbox.
|
"""Run one Strix scan end-to-end against a freshly-prepared sandbox.
|
||||||
@@ -188,9 +192,25 @@ async def run_strix_scan(
|
|||||||
scan_id = f"scan-{uuid.uuid4().hex[:8]}"
|
scan_id = f"scan-{uuid.uuid4().hex[:8]}"
|
||||||
logger.info("Starting Strix scan %s", scan_id)
|
logger.info("Starting Strix scan %s", scan_id)
|
||||||
|
|
||||||
bus = AgentMessageBus()
|
# Caller may pre-create the bus so it can hold a handle (e.g., the
|
||||||
|
# TUI uses it to route stop / chat-input commands). Otherwise we
|
||||||
|
# own the bus internally for the scan's lifetime.
|
||||||
|
if bus is None:
|
||||||
|
bus = AgentMessageBus()
|
||||||
root_id = uuid.uuid4().hex[:8]
|
root_id = uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
# Wire SDK tracing into the scan's run-directory ``events.jsonl``.
|
||||||
|
# ``add_trace_processor`` is idempotent at the provider level — if
|
||||||
|
# the user runs multiple scans in one process they each get their
|
||||||
|
# own processor, all writing to their respective run dirs.
|
||||||
|
if tracer is not None:
|
||||||
|
try:
|
||||||
|
run_dir = tracer.get_run_dir() if hasattr(tracer, "get_run_dir") else None
|
||||||
|
if run_dir is not None:
|
||||||
|
add_trace_processor(StrixTracingProcessor(run_dir))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to register StrixTracingProcessor")
|
||||||
|
|
||||||
bundle = await session_manager.create_or_reuse(
|
bundle = await session_manager.create_or_reuse(
|
||||||
scan_id,
|
scan_id,
|
||||||
image=image,
|
image=image,
|
||||||
@@ -232,10 +252,12 @@ async def run_strix_scan(
|
|||||||
sandbox_token=bundle["bearer"],
|
sandbox_token=bundle["bearer"],
|
||||||
tool_server_host_port=bundle["tool_server_host_port"],
|
tool_server_host_port=bundle["tool_server_host_port"],
|
||||||
caido_host_port=bundle["caido_host_port"],
|
caido_host_port=bundle["caido_host_port"],
|
||||||
|
caido_capability=bundle.get("capability"),
|
||||||
agent_id=root_id,
|
agent_id=root_id,
|
||||||
agent_name="strix",
|
agent_name="strix",
|
||||||
parent_id=None,
|
parent_id=None,
|
||||||
tracer=tracer,
|
tracer=tracer,
|
||||||
|
model=model,
|
||||||
max_turns=max_turns,
|
max_turns=max_turns,
|
||||||
is_whitebox=is_whitebox,
|
is_whitebox=is_whitebox,
|
||||||
diff_scope=diff_scope,
|
diff_scope=diff_scope,
|
||||||
@@ -246,6 +268,7 @@ async def run_strix_scan(
|
|||||||
run_config = make_run_config(
|
run_config = make_run_config(
|
||||||
sandbox_session=bundle["session"],
|
sandbox_session=bundle["session"],
|
||||||
sandbox_client=bundle["client"],
|
sandbox_client=bundle["client"],
|
||||||
|
model=model,
|
||||||
)
|
)
|
||||||
|
|
||||||
task_text = _build_root_task(scan_config)
|
task_text = _build_root_task(scan_config)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from . import (
|
|||||||
browser_renderer,
|
browser_renderer,
|
||||||
file_edit_renderer,
|
file_edit_renderer,
|
||||||
finish_renderer,
|
finish_renderer,
|
||||||
load_skill_renderer,
|
|
||||||
notes_renderer,
|
notes_renderer,
|
||||||
proxy_renderer,
|
proxy_renderer,
|
||||||
python_renderer,
|
python_renderer,
|
||||||
@@ -29,7 +28,6 @@ __all__ = [
|
|||||||
"file_edit_renderer",
|
"file_edit_renderer",
|
||||||
"finish_renderer",
|
"finish_renderer",
|
||||||
"get_tool_renderer",
|
"get_tool_renderer",
|
||||||
"load_skill_renderer",
|
|
||||||
"notes_renderer",
|
"notes_renderer",
|
||||||
"proxy_renderer",
|
"proxy_renderer",
|
||||||
"python_renderer",
|
"python_renderer",
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
from typing import Any, ClassVar
|
|
||||||
|
|
||||||
from rich.text import Text
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
from .base_renderer import BaseToolRenderer
|
|
||||||
from .registry import register_tool_renderer
|
|
||||||
|
|
||||||
|
|
||||||
@register_tool_renderer
|
|
||||||
class LoadSkillRenderer(BaseToolRenderer):
|
|
||||||
tool_name: ClassVar[str] = "load_skill"
|
|
||||||
css_classes: ClassVar[list[str]] = ["tool-call", "load-skill-tool"]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
|
||||||
args = tool_data.get("args", {})
|
|
||||||
status = tool_data.get("status", "completed")
|
|
||||||
|
|
||||||
requested = args.get("skills", "")
|
|
||||||
|
|
||||||
text = Text()
|
|
||||||
text.append("◇ ", style="#10b981")
|
|
||||||
text.append("loading skill", style="dim")
|
|
||||||
|
|
||||||
if requested:
|
|
||||||
text.append(" ")
|
|
||||||
text.append(requested, style="#10b981")
|
|
||||||
elif not tool_data.get("result"):
|
|
||||||
text.append("\n ")
|
|
||||||
text.append("Loading...", style="dim")
|
|
||||||
|
|
||||||
return Static(text, classes=cls.get_css_classes(status))
|
|
||||||
+31
-14
@@ -711,6 +711,13 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
self.tracer.set_scan_config(self.scan_config)
|
self.tracer.set_scan_config(self.scan_config)
|
||||||
set_global_tracer(self.tracer)
|
set_global_tracer(self.tracer)
|
||||||
|
|
||||||
|
# Pre-create the bus here (rather than letting ``run_strix_scan``
|
||||||
|
# build its own) so the TUI can hold a handle for stop / chat
|
||||||
|
# routing while the scan loop runs in a worker thread.
|
||||||
|
from strix.orchestration.bus import AgentMessageBus
|
||||||
|
|
||||||
|
self.bus: AgentMessageBus = AgentMessageBus()
|
||||||
|
|
||||||
self.agent_nodes: dict[str, TreeNode] = {}
|
self.agent_nodes: dict[str, TreeNode] = {}
|
||||||
|
|
||||||
self._displayed_agents: set[str] = set()
|
self._displayed_agents: set[str] = set()
|
||||||
@@ -720,6 +727,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
self._last_streaming_len: dict[str, int] = {}
|
self._last_streaming_len: dict[str, int] = {}
|
||||||
|
|
||||||
self._scan_thread: threading.Thread | None = None
|
self._scan_thread: threading.Thread | None = None
|
||||||
|
self._scan_loop: asyncio.AbstractEventLoop | None = None
|
||||||
self._scan_stop_event = threading.Event()
|
self._scan_stop_event = threading.Event()
|
||||||
self._scan_completed = threading.Event()
|
self._scan_completed = threading.Event()
|
||||||
|
|
||||||
@@ -1496,6 +1504,10 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
try:
|
try:
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
|
# Stash the loop so synchronous TUI handlers (stop /
|
||||||
|
# chat) can submit bus coroutines onto it from the
|
||||||
|
# main thread.
|
||||||
|
self._scan_loop = loop
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self._scan_stop_event.is_set():
|
if not self._scan_stop_event.is_set():
|
||||||
@@ -1508,6 +1520,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
image=str(image),
|
image=str(image),
|
||||||
sources_path=sources_path,
|
sources_path=sources_path,
|
||||||
tracer=self.tracer,
|
tracer=self.tracer,
|
||||||
|
bus=self.bus,
|
||||||
interactive=True,
|
interactive=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1827,10 +1840,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
metadata={"interrupted": True},
|
metadata={"interrupted": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: route user→agent messages through the AgentMessageBus
|
|
||||||
# once the TUI has a handle on it. The bus currently lives
|
|
||||||
# inside ``run_strix_scan`` scope only.
|
|
||||||
|
|
||||||
if self.tracer:
|
if self.tracer:
|
||||||
self.tracer.log_chat_message(
|
self.tracer.log_chat_message(
|
||||||
content=message,
|
content=message,
|
||||||
@@ -1838,11 +1847,18 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
agent_id=self.selected_agent_id,
|
agent_id=self.selected_agent_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
logging.warning(
|
# Route to the agent's bus inbox. The scan loop runs on a
|
||||||
"User-message-to-agent dispatch is not wired post-migration; "
|
# worker thread; ``run_coroutine_threadsafe`` submits the
|
||||||
"message %r logged to tracer but not delivered.",
|
# coroutine onto that loop and returns immediately so the TUI
|
||||||
message,
|
# stays responsive.
|
||||||
)
|
if self._scan_loop is not None and not self._scan_loop.is_closed():
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self.bus.send(
|
||||||
|
self.selected_agent_id,
|
||||||
|
{"from": "user", "content": message, "type": "instruction"},
|
||||||
|
),
|
||||||
|
self._scan_loop,
|
||||||
|
)
|
||||||
|
|
||||||
self._displayed_events.clear()
|
self._displayed_events.clear()
|
||||||
self._update_chat_view()
|
self._update_chat_view()
|
||||||
@@ -1941,11 +1957,12 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
return agent_name, False
|
return agent_name, False
|
||||||
|
|
||||||
def action_confirm_stop_agent(self, agent_id: str) -> None:
|
def action_confirm_stop_agent(self, agent_id: str) -> None:
|
||||||
# TODO: route to ``bus.cancel_descendants(agent_id)`` once the TUI
|
if self._scan_loop is None or self._scan_loop.is_closed():
|
||||||
# has a handle on the AgentMessageBus.
|
logging.warning("No active scan loop; cannot stop agent %s", agent_id)
|
||||||
logging.warning(
|
return
|
||||||
"Stop-agent dispatch is not wired post-migration; agent %s left running.",
|
asyncio.run_coroutine_threadsafe(
|
||||||
agent_id,
|
self.bus.cancel_descendants(agent_id),
|
||||||
|
self._scan_loop,
|
||||||
)
|
)
|
||||||
|
|
||||||
def action_custom_quit(self) -> None:
|
def action_custom_quit(self) -> None:
|
||||||
|
|||||||
@@ -84,14 +84,31 @@ class StrixOrchestrationHooks(RunHooks[Any]):
|
|||||||
agent: Any,
|
agent: Any,
|
||||||
response: ModelResponse,
|
response: ModelResponse,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
del agent
|
||||||
try:
|
try:
|
||||||
ctx = context.context
|
ctx = context.context
|
||||||
if not isinstance(ctx, dict):
|
if not isinstance(ctx, dict):
|
||||||
return
|
return
|
||||||
bus = ctx.get("bus")
|
usage = getattr(response, "usage", None)
|
||||||
agent_id = ctx.get("agent_id")
|
agent_id = ctx.get("agent_id")
|
||||||
|
bus = ctx.get("bus")
|
||||||
if bus is not None and agent_id is not None:
|
if bus is not None and agent_id is not None:
|
||||||
await bus.record_usage(agent_id, getattr(response, "usage", None))
|
await bus.record_usage(agent_id, usage)
|
||||||
|
tracer = ctx.get("tracer")
|
||||||
|
if tracer is not None and usage is not None and hasattr(tracer, "record_llm_usage"):
|
||||||
|
cached = 0
|
||||||
|
details = getattr(usage, "input_tokens_details", None)
|
||||||
|
if details is not None:
|
||||||
|
cached = int(getattr(details, "cached_tokens", 0) or 0)
|
||||||
|
tracer.record_llm_usage(
|
||||||
|
agent_id=str(agent_id or "unknown"),
|
||||||
|
input_tokens=int(getattr(usage, "input_tokens", 0) or 0),
|
||||||
|
output_tokens=int(getattr(usage, "output_tokens", 0) or 0),
|
||||||
|
cached_tokens=cached,
|
||||||
|
cost=0.0, # litellm cost computation lives in the legacy LLM
|
||||||
|
requests=1,
|
||||||
|
bucket="live",
|
||||||
|
)
|
||||||
ctx["turn_count"] = int(ctx.get("turn_count", 0)) + 1
|
ctx["turn_count"] = int(ctx.get("turn_count", 0)) + 1
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("on_llm_end failed")
|
logger.exception("on_llm_end failed")
|
||||||
@@ -101,17 +118,19 @@ class StrixOrchestrationHooks(RunHooks[Any]):
|
|||||||
context: AgentHookContext[Any],
|
context: AgentHookContext[Any],
|
||||||
agent: Any,
|
agent: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# The CaidoCapability is bound to the sandbox session, not the
|
||||||
|
# Agent (we use plain ``Agent``, not ``SandboxAgent``). We stash
|
||||||
|
# it in the context dict at scan-bring-up time so the hook can
|
||||||
|
# await its healthcheck before the first LLM call.
|
||||||
|
del agent
|
||||||
try:
|
try:
|
||||||
cap = next(
|
ctx = context.context
|
||||||
(
|
if not isinstance(ctx, dict):
|
||||||
c
|
return
|
||||||
for c in (getattr(agent, "capabilities", None) or [])
|
cap = ctx.get("caido_capability")
|
||||||
if hasattr(c, "_healthcheck_task")
|
task = getattr(cap, "_healthcheck_task", None)
|
||||||
),
|
if task is not None:
|
||||||
None,
|
await task
|
||||||
)
|
|
||||||
if cap is not None and getattr(cap, "_healthcheck_task", None) is not None:
|
|
||||||
await cap._healthcheck_task
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("on_agent_start failed")
|
logger.exception("on_agent_start failed")
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ def make_agent_context(
|
|||||||
run_id: str | None = None,
|
run_id: str | None = None,
|
||||||
sandbox_client: Any | None = None,
|
sandbox_client: Any | None = None,
|
||||||
agent_factory: Any | None = None,
|
agent_factory: Any | None = None,
|
||||||
|
caido_capability: Any | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build the per-agent ``context`` dict passed to ``Runner.run(context=...)``.
|
"""Build the per-agent ``context`` dict passed to ``Runner.run(context=...)``.
|
||||||
|
|
||||||
@@ -190,6 +191,7 @@ def make_agent_context(
|
|||||||
"sandbox_token": sandbox_token,
|
"sandbox_token": sandbox_token,
|
||||||
"tool_server_host_port": tool_server_host_port,
|
"tool_server_host_port": tool_server_host_port,
|
||||||
"caido_host_port": caido_host_port,
|
"caido_host_port": caido_host_port,
|
||||||
|
"caido_capability": caido_capability,
|
||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"agent_name": agent_name,
|
"agent_name": agent_name,
|
||||||
"parent_id": parent_id,
|
"parent_id": parent_id,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from .agents_graph import * # noqa: F403
|
|||||||
from .browser import * # noqa: F403
|
from .browser import * # noqa: F403
|
||||||
from .file_edit import * # noqa: F403
|
from .file_edit import * # noqa: F403
|
||||||
from .finish import * # noqa: F403
|
from .finish import * # noqa: F403
|
||||||
from .load_skill import * # noqa: F403
|
|
||||||
from .notes import * # noqa: F403
|
from .notes import * # noqa: F403
|
||||||
from .proxy import * # noqa: F403
|
from .proxy import * # noqa: F403
|
||||||
from .python import * # noqa: F403
|
from .python import * # noqa: F403
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ adapter object from the run context.
|
|||||||
|
|
||||||
Used by:
|
Used by:
|
||||||
- ``tools/todo/tools.py``
|
- ``tools/todo/tools.py``
|
||||||
- ``tools/load_skill/tool.py``
|
|
||||||
- ``tools/finish/tool.py``
|
- ``tools/finish/tool.py``
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ async def create_agent(
|
|||||||
sandbox_token=inner.get("sandbox_token"),
|
sandbox_token=inner.get("sandbox_token"),
|
||||||
tool_server_host_port=inner.get("tool_server_host_port"),
|
tool_server_host_port=inner.get("tool_server_host_port"),
|
||||||
caido_host_port=inner.get("caido_host_port"),
|
caido_host_port=inner.get("caido_host_port"),
|
||||||
|
caido_capability=inner.get("caido_capability"),
|
||||||
agent_id=child_id,
|
agent_id=child_id,
|
||||||
agent_name=name,
|
agent_name=name,
|
||||||
parent_id=parent_id,
|
parent_id=parent_id,
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
from .load_skill_actions import load_skill
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["load_skill"]
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
from strix.tools.registry import register_tool
|
|
||||||
|
|
||||||
|
|
||||||
@register_tool(sandbox_execution=False)
|
|
||||||
def load_skill(agent_state: Any, skills: str) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
from strix.skills import parse_skill_list, validate_requested_skills
|
|
||||||
|
|
||||||
requested_skills = parse_skill_list(skills)
|
|
||||||
if not requested_skills:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": "No skills provided. Pass one or more comma-separated skill names.",
|
|
||||||
"requested_skills": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
validation_error = validate_requested_skills(requested_skills)
|
|
||||||
if validation_error:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": validation_error,
|
|
||||||
"requested_skills": requested_skills,
|
|
||||||
"loaded_skills": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Runtime skill injection used to reach into the legacy
|
|
||||||
# ``_agent_instances`` registry to mutate the running LLM's
|
|
||||||
# active-skills list. The SDK harness owns the agent through
|
|
||||||
# ``Runner.run`` and there's no equivalent reach-in API yet —
|
|
||||||
# the model still gets a structured success response so it can
|
|
||||||
# observe which skills it asked for, even if reload-on-the-fly
|
|
||||||
# is a Phase 6 follow-up.
|
|
||||||
newly_loaded = list(requested_skills)
|
|
||||||
already_loaded: list[str] = []
|
|
||||||
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
fallback_requested_skills = (
|
|
||||||
requested_skills
|
|
||||||
if "requested_skills" in locals()
|
|
||||||
else [s.strip() for s in skills.split(",") if s.strip()]
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": f"Failed to load skill(s): {e!s}",
|
|
||||||
"requested_skills": fallback_requested_skills,
|
|
||||||
"loaded_skills": [],
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"requested_skills": requested_skills,
|
|
||||||
"loaded_skills": requested_skills,
|
|
||||||
"newly_loaded_skills": newly_loaded,
|
|
||||||
"already_loaded_skills": already_loaded,
|
|
||||||
"message": "Skills loaded into this agent prompt context.",
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<tools>
|
|
||||||
<tool name="load_skill">
|
|
||||||
<description>Dynamically load one or more skills into the current agent at runtime.
|
|
||||||
|
|
||||||
Use this when you need exact guidance right before acting (tool syntax, exploit workflow, or protocol details).
|
|
||||||
This updates the current agent's prompt context immediately.</description>
|
|
||||||
<details>Accepts one skill or a comma-separated skill bundle. Works for root agents and subagents.
|
|
||||||
Examples:
|
|
||||||
- Single skill: `xss`
|
|
||||||
- Bundle: `sql_injection,business_logic`</details>
|
|
||||||
<parameters>
|
|
||||||
<parameter name="skills" type="string" required="true">
|
|
||||||
<description>Comma-separated list of skills to use for the agent (MAXIMUM 5 skills allowed). Most agents should have at least one skill in order to be useful. Agents should be highly specialized - use 1-3 related skills; up to 5 for complex contexts. {{DYNAMIC_SKILLS_DESCRIPTION}}</description>
|
|
||||||
</parameter>
|
|
||||||
</parameters>
|
|
||||||
<returns type="Dict[str, Any]">
|
|
||||||
<description>Response containing: - success: Whether runtime loading succeeded - requested_skills: Skills requested - loaded_skills: Skills validated and applied - newly_loaded_skills: Skills newly injected into prompt - already_loaded_skills: Skills already present in prompt context</description>
|
|
||||||
</returns>
|
|
||||||
<examples>
|
|
||||||
<function=load_skill>
|
|
||||||
<parameter=skills>xss</parameter>
|
|
||||||
</function>
|
|
||||||
|
|
||||||
<function=load_skill>
|
|
||||||
<parameter=skills>sql_injection,business_logic</parameter>
|
|
||||||
</function>
|
|
||||||
|
|
||||||
<function=load_skill>
|
|
||||||
<parameter=skills>nmap,httpx</parameter>
|
|
||||||
</function>
|
|
||||||
</examples>
|
|
||||||
</tool>
|
|
||||||
</tools>
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"""SDK function-tool wrapper for the legacy ``load_skill`` tool.
|
|
||||||
|
|
||||||
The legacy implementation reaches into ``_agent_instances`` (a global
|
|
||||||
dict the legacy multi-agent orchestrator maintains) to find the running
|
|
||||||
``Agent`` instance and call ``agent.llm.add_skills(...)``. That global
|
|
||||||
goes away under the SDK migration — Phase 3 will replace it with a
|
|
||||||
context-keyed registry, and this wrapper will be updated to read from
|
|
||||||
that registry.
|
|
||||||
|
|
||||||
For Phase 2 we ship the wrapper as-is. The legacy function falls back
|
|
||||||
to a clean error path when the agent instance lookup fails, so the
|
|
||||||
tool degrades gracefully ("Could not find running agent instance...")
|
|
||||||
until Phase 3 lands. That's better than crashing or stubbing out the
|
|
||||||
tool entirely — the model still gets a structured error it can react to.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from agents import RunContextWrapper
|
|
||||||
|
|
||||||
from strix.tools._decorator import strix_tool
|
|
||||||
from strix.tools._state_adapter import adapter_from_ctx
|
|
||||||
from strix.tools.load_skill import load_skill_actions as _impl
|
|
||||||
|
|
||||||
|
|
||||||
def _dump(result: dict[str, Any]) -> str:
|
|
||||||
return json.dumps(result, ensure_ascii=False, default=str)
|
|
||||||
|
|
||||||
|
|
||||||
@strix_tool(timeout=60)
|
|
||||||
async def load_skill(ctx: RunContextWrapper, skills: str) -> str:
|
|
||||||
"""Load one or more named skills into this agent's prompt context.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
skills: Comma-separated skill names (max 5). E.g.
|
|
||||||
``"recon,xss,sqli"``. Skill discovery uses
|
|
||||||
``strix.skills.parse_skill_list``.
|
|
||||||
"""
|
|
||||||
state = adapter_from_ctx(ctx)
|
|
||||||
return _dump(
|
|
||||||
await asyncio.to_thread(_impl.load_skill, agent_state=state, skills=skills),
|
|
||||||
)
|
|
||||||
@@ -115,7 +115,6 @@ def test_agent_includes_graph_and_sandbox_tools() -> None:
|
|||||||
"web_search",
|
"web_search",
|
||||||
"str_replace_editor",
|
"str_replace_editor",
|
||||||
"create_vulnerability_report",
|
"create_vulnerability_report",
|
||||||
"load_skill",
|
|
||||||
"browser_action",
|
"browser_action",
|
||||||
"terminal_execute",
|
"terminal_execute",
|
||||||
"python_action",
|
"python_action",
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
"""Phase 2.4 smoke tests for the remaining local SDK tool wrappers.
|
"""Smoke tests for the remaining local SDK tool wrappers.
|
||||||
|
|
||||||
Covers: web_search, file_edit (str_replace_editor + list_files +
|
Covers: web_search, file_edit (str_replace_editor + list_files +
|
||||||
search_files), reporting (create_vulnerability_report), load_skill,
|
search_files), reporting (create_vulnerability_report), finish_scan.
|
||||||
finish_scan.
|
|
||||||
|
|
||||||
Pattern matches test_sdk_local_tools.py — confirm registration as
|
|
||||||
``FunctionTool``, exercise the legacy delegation path, verify that
|
|
||||||
sandbox-bound tools route through ``post_to_sandbox``.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -25,7 +20,6 @@ from strix.tools.file_edit.tools import (
|
|||||||
str_replace_editor,
|
str_replace_editor,
|
||||||
)
|
)
|
||||||
from strix.tools.finish.tool import finish_scan
|
from strix.tools.finish.tool import finish_scan
|
||||||
from strix.tools.load_skill.tool import load_skill
|
|
||||||
from strix.tools.reporting.tool import create_vulnerability_report
|
from strix.tools.reporting.tool import create_vulnerability_report
|
||||||
from strix.tools.web_search.tool import web_search
|
from strix.tools.web_search.tool import web_search
|
||||||
|
|
||||||
@@ -69,7 +63,6 @@ def test_all_remaining_tools_are_function_tools() -> None:
|
|||||||
list_files,
|
list_files,
|
||||||
search_files,
|
search_files,
|
||||||
create_vulnerability_report,
|
create_vulnerability_report,
|
||||||
load_skill,
|
|
||||||
finish_scan,
|
finish_scan,
|
||||||
):
|
):
|
||||||
assert isinstance(tool, FunctionTool)
|
assert isinstance(tool, FunctionTool)
|
||||||
@@ -240,38 +233,6 @@ async def test_create_vulnerability_report_delegates_to_impl() -> None:
|
|||||||
assert kwargs["method"] is None
|
assert kwargs["method"] is None
|
||||||
|
|
||||||
|
|
||||||
# --- load_skill ----------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_load_skill_passes_adapter_with_agent_id() -> None:
|
|
||||||
"""The wrapper must build the legacy-shaped adapter from ctx.context."""
|
|
||||||
captured: dict[str, Any] = {}
|
|
||||||
|
|
||||||
def fake_legacy(*, agent_state: Any, skills: str) -> dict[str, Any]:
|
|
||||||
captured["agent_id"] = agent_state.agent_id
|
|
||||||
captured["skills"] = skills
|
|
||||||
return {"success": True, "loaded_skills": ["recon"]}
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"strix.tools.load_skill.tool._impl.load_skill",
|
|
||||||
side_effect=fake_legacy,
|
|
||||||
):
|
|
||||||
out = await _invoke(load_skill, _ctx_for("agent-XYZ"), skills="recon")
|
|
||||||
|
|
||||||
assert out["success"] is True
|
|
||||||
assert captured["agent_id"] == "agent-XYZ"
|
|
||||||
assert captured["skills"] == "recon"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_load_skill_with_empty_input() -> None:
|
|
||||||
"""End-to-end: empty skills string yields the legacy validation error."""
|
|
||||||
out = await _invoke(load_skill, _ctx_for(), skills="")
|
|
||||||
assert out["success"] is False
|
|
||||||
assert "No skills" in out["error"]
|
|
||||||
|
|
||||||
|
|
||||||
# --- finish_scan ---------------------------------------------------------
|
# --- finish_scan ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,45 +58,5 @@ def test_sandbox_registers_sandbox_tools_but_not_non_sandbox_tools(
|
|||||||
assert "list_requests" in names
|
assert "list_requests" in names
|
||||||
assert "create_agent" not in names
|
assert "create_agent" not in names
|
||||||
assert "finish_scan" not in names
|
assert "finish_scan" not in names
|
||||||
assert "load_skill" not in names
|
|
||||||
assert "browser_action" not in names
|
assert "browser_action" not in names
|
||||||
assert "web_search" not in names
|
assert "web_search" not in names
|
||||||
|
|
||||||
|
|
||||||
def test_load_skill_import_does_not_register_create_agent_in_sandbox(
|
|
||||||
monkeypatch: Any,
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setenv("STRIX_SANDBOX_MODE", "true")
|
|
||||||
monkeypatch.setenv("STRIX_DISABLE_BROWSER", "true")
|
|
||||||
monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False)
|
|
||||||
monkeypatch.setattr(Config, "load", classmethod(_empty_config_load))
|
|
||||||
|
|
||||||
clear_registry()
|
|
||||||
for name in list(sys.modules):
|
|
||||||
if name == "strix.tools" or name.startswith("strix.tools."):
|
|
||||||
sys.modules.pop(name, None)
|
|
||||||
|
|
||||||
load_skill_module = importlib.import_module("strix.tools.load_skill.load_skill_actions")
|
|
||||||
registry = importlib.import_module("strix.tools.registry")
|
|
||||||
|
|
||||||
names_before = set(registry.get_tool_names())
|
|
||||||
assert "load_skill" not in names_before
|
|
||||||
assert "create_agent" not in names_before
|
|
||||||
|
|
||||||
state_type = type(
|
|
||||||
"DummyState",
|
|
||||||
(),
|
|
||||||
{
|
|
||||||
"agent_id": "agent_test",
|
|
||||||
"context": {},
|
|
||||||
"update_context": lambda self, key, value: self.context.__setitem__(key, value),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
result = load_skill_module.load_skill(state_type(), "nmap")
|
|
||||||
|
|
||||||
names_after = set(registry.get_tool_names())
|
|
||||||
assert "create_agent" not in names_after
|
|
||||||
# load_skill no longer reaches into the legacy _agent_instances global —
|
|
||||||
# it returns ``success=True`` with the requested skills echoed back.
|
|
||||||
assert result["success"] is True
|
|
||||||
assert result["loaded_skills"] == ["nmap"]
|
|
||||||
|
|||||||
@@ -5441,10 +5441,8 @@ dependencies = [
|
|||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "scrubadub" },
|
{ name = "scrubadub" },
|
||||||
{ name = "tenacity" },
|
|
||||||
{ name = "textual" },
|
{ name = "textual" },
|
||||||
{ name = "traceloop-sdk" },
|
{ name = "traceloop-sdk" },
|
||||||
{ name = "xmltodict" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -5501,11 +5499,9 @@ requires-dist = [
|
|||||||
{ name = "requests", specifier = ">=2.32.0" },
|
{ name = "requests", specifier = ">=2.32.0" },
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "scrubadub", specifier = ">=2.0.1" },
|
{ name = "scrubadub", specifier = ">=2.0.1" },
|
||||||
{ name = "tenacity", specifier = ">=9.0.0" },
|
|
||||||
{ name = "textual", specifier = ">=6.0.0" },
|
{ name = "textual", specifier = ">=6.0.0" },
|
||||||
{ name = "traceloop-sdk", specifier = ">=0.53.0" },
|
{ name = "traceloop-sdk", specifier = ">=0.53.0" },
|
||||||
{ name = "uvicorn", marker = "extra == 'sandbox'" },
|
{ name = "uvicorn", marker = "extra == 'sandbox'" },
|
||||||
{ name = "xmltodict", specifier = ">=0.13.0" },
|
|
||||||
]
|
]
|
||||||
provides-extras = ["vertex", "sandbox"]
|
provides-extras = ["vertex", "sandbox"]
|
||||||
|
|
||||||
@@ -6081,15 +6077,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" },
|
{ url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "xmltodict"
|
|
||||||
version = "1.0.4"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz", hash = "sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61", size = 26124, upload-time = "2026-02-22T02:21:22.074Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a", size = 13580, upload-time = "2026-02-22T02:21:21.039Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yarl"
|
name = "yarl"
|
||||||
version = "1.23.0"
|
version = "1.23.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user