refactor: nuke `strix_tool` shim + dead package re-exports

``@strix_tool`` was passing through every kwarg to ``@function_tool``
with the same defaults — zero Strix-specific value-add. The docstring
also still claimed terminal/browser/python tools opted into
``timeout_behavior="raise_exception"``, but those tools were all
deleted in the recent migrations.

- Replace 30 ``@strix_tool(...)`` callsites with ``@function_tool(...)``.
- Inline ``dump_tool_result(x)`` as ``json.dumps(x, ensure_ascii=False,
  default=str)`` at all 64 callsites — no helper.
- Delete ``strix/tools/_decorator.py``.

Drive-by: gut dead package re-exports.

- ``strix/{agents,orchestration,tools}/__init__.py`` re-exported
  symbols nobody imports via the package — every consumer uses deep
  paths (``from strix.agents.factory import build_strix_agent``).
- The 8 ``strix/tools/<sub>/__init__.py`` re-exports only fed the
  splat ``from .agents_graph import *`` etc. in the parent package
  init, which is also gone now.
- Reduced to docstrings (or empty) so ``import strix.tools`` doesn't
  drag every tool's transitive deps in eagerly.

Drive-by: drop dead helpers in ``runtime.session_manager``
(``cached_scan_ids``, ``_reset_cache_for_tests``) — zero callers since
``tests/`` was nuked in ``a6d578c``.

Verified all tool timeouts preserved (think=10, list_requests=120,
finish_scan=60, web_search=330) and ruff/mypy at baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0xallam
2026-04-25 15:17:46 -07:00
parent 6990fd4ef1
commit b3f7cfd040
22 changed files with 257 additions and 320 deletions
-3
View File
@@ -214,9 +214,6 @@ ignore = [
"strix/llm/multi_provider_setup.py" = [ "strix/llm/multi_provider_setup.py" = [
"TC002", # Model, ModelProvider imported for annotations "TC002", # Model, ModelProvider imported for annotations
] ]
"strix/tools/_decorator.py" = [
"TC002", # FunctionTool imported for annotation
]
# SDK function-tool wrappers: the SDK calls get_type_hints() at registration # SDK function-tool wrappers: the SDK calls get_type_hints() at registration
# time to derive the JSON schema, which evaluates annotations at runtime — # time to derive the JSON schema, which evaluates annotations at runtime —
# so RunContextWrapper / Tool / TResponseInputItem must be imported eagerly, # so RunContextWrapper / Tool / TResponseInputItem must be imported eagerly,
+9 -16
View File
@@ -1,19 +1,12 @@
"""Strix agent package. """Strix agent assembly.
Public surface: - :func:`strix.agents.factory.build_strix_agent` — assemble a root or
child ``SandboxAgent``.
- :func:`strix.agents.factory.make_child_factory` — closure factory
passed via context to the multi-agent ``create_agent`` graph tool.
- :func:`strix.agents.prompt.render_system_prompt` — render the Jinja
system prompt.
- :func:`build_strix_agent` — assemble a root or child ``agents.Agent``. Import deeply so ``import strix.agents`` doesn't pull every submodule's
- :func:`make_child_factory` — closure factory passed via context to deps in eagerly.
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__ = [
"build_strix_agent",
"make_child_factory",
"render_system_prompt",
]
+9 -15
View File
@@ -1,18 +1,12 @@
"""Strix multi-agent orchestration on top of OpenAI Agents SDK. """Strix multi-agent orchestration on top of OpenAI Agents SDK.
Provides: - :class:`AgentMessageBus` — peer-to-peer agent inbox + status + stats.
- AgentMessageBus: peer-to-peer agent inbox + status + stats aggregation - :func:`inject_messages_filter` — SDK ``call_model_input_filter`` for
- inject_messages_filter: SDK call_model_input_filter for inbox drain inbox drain at the top of each LLM turn.
- StrixOrchestrationHooks: SDK RunHooks subclass for lifecycle wiring - :class:`StrixOrchestrationHooks` — SDK ``RunHooks`` subclass for
lifecycle wiring.
Import deeply (``from strix.orchestration.bus import AgentMessageBus``)
so ``import strix.orchestration`` doesn't drag every submodule's deps
in eagerly.
""" """
from strix.orchestration.bus import AgentMessageBus
from strix.orchestration.filter import inject_messages_filter
from strix.orchestration.hooks import StrixOrchestrationHooks
__all__ = [
"AgentMessageBus",
"StrixOrchestrationHooks",
"inject_messages_filter",
]
-10
View File
@@ -144,13 +144,3 @@ async def cleanup(scan_id: str) -> None:
"cleanup(%s): client.delete raised; container may need manual reaping", "cleanup(%s): client.delete raised; container may need manual reaping",
scan_id, scan_id,
) )
def cached_scan_ids() -> list[str]:
"""Snapshot of currently-cached scan ids. Used by the TUI / CLI."""
return list(_SESSION_CACHE.keys())
def _reset_cache_for_tests() -> None:
"""Test helper — clears the module cache between unit tests."""
_SESSION_CACHE.clear()
+3 -9
View File
@@ -5,13 +5,7 @@ imported directly by :mod:`strix.agents.factory`. The sandbox-bound
shell + filesystem tools are emitted by the SDK's ``Shell`` and shell + filesystem tools are emitted by the SDK's ``Shell`` and
``Filesystem`` capabilities and bound to the live sandbox session ``Filesystem`` capabilities and bound to the live sandbox session
per-run. per-run.
"""
from .agents_graph import * # noqa: F403 Import deeply so ``import strix.tools`` doesn't pull every submodule's
from .finish import * # noqa: F403 deps in eagerly.
from .notes import * # noqa: F403 """
from .proxy import * # noqa: F403
from .reporting import * # noqa: F403
from .thinking import * # noqa: F403
from .todo import * # noqa: F403
from .web_search import * # noqa: F403
-73
View File
@@ -1,73 +0,0 @@
"""``strix_tool`` — ``function_tool`` factory with Strix defaults.
Every tool uses ``@strix_tool`` instead of bare ``@function_tool`` so
defaults stay consistent across the suite. Override per call when
needed.
Defaults:
- ``timeout``: 120s.
- ``timeout_behavior``: ``"error_as_result"`` for idempotent tools.
Critical sandbox tools (terminal, browser, python) opt into
``timeout_behavior="raise_exception"`` explicitly so the SDK
fails the run rather than letting the model retry a hung call.
"""
from __future__ import annotations
import json
from collections.abc import Callable
from typing import Any, Literal
from agents import function_tool
from agents.tool import FunctionTool
_ToolFn = Callable[..., Any]
_ToolBehavior = Literal["error_as_result", "raise_exception"]
def dump_tool_result(result: dict[str, Any]) -> str:
"""Serialize a tool's dict result to JSON for the LLM.
Every Strix tool returns a dict; the SDK passes the tool's return
straight into ``str(result)``, which produces ugly Python repr
output. JSON is what the model expects.
"""
return json.dumps(result, ensure_ascii=False, default=str)
def strix_tool(
*,
timeout: float = 120.0,
timeout_behavior: _ToolBehavior = "error_as_result",
name_override: str | None = None,
description_override: str | None = None,
strict_mode: bool = True,
) -> Callable[[_ToolFn], FunctionTool]:
"""Wrap ``agents.function_tool`` with Strix defaults.
Strict mode is on by default (forbids free-form ``dict[str, X]``
parameters because the strict JSON schema needs
``additionalProperties: false``). A few tools that take arbitrary
header / modification dicts opt out via ``strict_mode=False``.
Usage::
@strix_tool()
async def my_tool(ctx: RunContextWrapper, x: int) -> str: ...
@strix_tool(timeout=300, timeout_behavior="raise_exception")
async def critical_tool(ctx: RunContextWrapper, ...) -> str: ...
@strix_tool(strict_mode=False)
async def free_form_dict_tool(
ctx: RunContextWrapper, headers: dict[str, str],
) -> str: ...
"""
return function_tool(
timeout=timeout,
timeout_behavior=timeout_behavior,
name_override=name_override,
description_override=description_override,
strict_mode=strict_mode,
)
-18
View File
@@ -1,18 +0,0 @@
from .tools import (
agent_finish,
agent_status,
create_agent,
send_message_to_agent,
view_agent_graph,
wait_for_message,
)
__all__ = [
"agent_finish",
"agent_status",
"create_agent",
"send_message_to_agent",
"view_agent_graph",
"wait_for_message",
]
+89 -40
View File
@@ -21,12 +21,11 @@ import logging
import uuid import uuid
from typing import TYPE_CHECKING, Any, Literal from typing import TYPE_CHECKING, Any, Literal
from agents import RunContextWrapper, Runner from agents import RunContextWrapper, Runner, function_tool
from agents.items import TResponseInputItem from agents.items import TResponseInputItem
from strix.orchestration.hooks import StrixOrchestrationHooks from strix.orchestration.hooks import StrixOrchestrationHooks
from strix.run_config_factory import make_agent_context, make_run_config from strix.run_config_factory import make_agent_context, make_run_config
from strix.tools._decorator import dump_tool_result, strix_tool
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -42,7 +41,7 @@ def _ctx(ctx: RunContextWrapper) -> dict[str, Any]:
return ctx.context if isinstance(ctx.context, dict) else {} return ctx.context if isinstance(ctx.context, dict) else {}
@strix_tool(timeout=30) @function_tool(timeout=30)
async def view_agent_graph(ctx: RunContextWrapper) -> str: async def view_agent_graph(ctx: RunContextWrapper) -> str:
"""Print the multi-agent tree — every agent, its parent, its status. """Print the multi-agent tree — every agent, its parent, its status.
@@ -57,7 +56,11 @@ async def view_agent_graph(ctx: RunContextWrapper) -> str:
bus = inner.get("bus") bus = inner.get("bus")
me = inner.get("agent_id") me = inner.get("agent_id")
if bus is None: if bus is None:
return dump_tool_result({"success": False, "error": "Bus not initialized in context."}) return json.dumps(
{"success": False, "error": "Bus not initialized in context."},
ensure_ascii=False,
default=str,
)
async with bus._lock: async with bus._lock:
parent_of = dict(bus.parent_of) parent_of = dict(bus.parent_of)
@@ -86,16 +89,18 @@ async def view_agent_graph(ctx: RunContextWrapper) -> str:
"crashed": sum(1 for s in statuses.values() if s == "crashed"), "crashed": sum(1 for s in statuses.values() if s == "crashed"),
"stopped": sum(1 for s in statuses.values() if s == "stopped"), "stopped": sum(1 for s in statuses.values() if s == "stopped"),
} }
return dump_tool_result( return json.dumps(
{ {
"success": True, "success": True,
"graph_structure": "\n".join(lines) or "(no agents)", "graph_structure": "\n".join(lines) or "(no agents)",
"summary": summary, "summary": summary,
} },
ensure_ascii=False,
default=str,
) )
@strix_tool(timeout=30) @function_tool(timeout=30)
async def agent_status(ctx: RunContextWrapper, agent_id: str) -> str: async def agent_status(ctx: RunContextWrapper, agent_id: str) -> str:
"""Look up one agent's lifecycle state + pending message count. """Look up one agent's lifecycle state + pending message count.
@@ -111,17 +116,23 @@ async def agent_status(ctx: RunContextWrapper, agent_id: str) -> str:
inner = _ctx(ctx) inner = _ctx(ctx)
bus = inner.get("bus") bus = inner.get("bus")
if bus is None: if bus is None:
return dump_tool_result({"success": False, "error": "Bus not initialized in context."}) return json.dumps(
{"success": False, "error": "Bus not initialized in context."},
ensure_ascii=False,
default=str,
)
async with bus._lock: async with bus._lock:
if agent_id not in bus.statuses: if agent_id not in bus.statuses:
return dump_tool_result( return json.dumps(
{ {
"success": False, "success": False,
"error": f"Unknown agent_id: {agent_id}", "error": f"Unknown agent_id: {agent_id}",
} },
ensure_ascii=False,
default=str,
) )
return dump_tool_result( return json.dumps(
{ {
"success": True, "success": True,
"agent_id": agent_id, "agent_id": agent_id,
@@ -129,11 +140,13 @@ async def agent_status(ctx: RunContextWrapper, agent_id: str) -> str:
"status": bus.statuses.get(agent_id), "status": bus.statuses.get(agent_id),
"parent_id": bus.parent_of.get(agent_id), "parent_id": bus.parent_of.get(agent_id),
"pending_messages": len(bus.inboxes.get(agent_id, [])), "pending_messages": len(bus.inboxes.get(agent_id, [])),
} },
ensure_ascii=False,
default=str,
) )
@strix_tool(timeout=30) @function_tool(timeout=30)
async def send_message_to_agent( async def send_message_to_agent(
ctx: RunContextWrapper, ctx: RunContextWrapper,
target_agent_id: str, target_agent_id: str,
@@ -169,24 +182,32 @@ async def send_message_to_agent(
bus = inner.get("bus") bus = inner.get("bus")
me = inner.get("agent_id") me = inner.get("agent_id")
if bus is None or me is None: if bus is None or me is None:
return dump_tool_result({"success": False, "error": "Bus or agent_id missing in context."}) return json.dumps(
{"success": False, "error": "Bus or agent_id missing in context."},
ensure_ascii=False,
default=str,
)
async with bus._lock: async with bus._lock:
if target_agent_id not in bus.statuses: if target_agent_id not in bus.statuses:
return dump_tool_result( return json.dumps(
{ {
"success": False, "success": False,
"error": f"Target agent '{target_agent_id}' not found.", "error": f"Target agent '{target_agent_id}' not found.",
} },
ensure_ascii=False,
default=str,
) )
target_status = bus.statuses.get(target_agent_id) target_status = bus.statuses.get(target_agent_id)
if target_status in ("completed", "crashed", "stopped"): if target_status in ("completed", "crashed", "stopped"):
return dump_tool_result( return json.dumps(
{ {
"success": False, "success": False,
"error": f"Target agent '{target_agent_id}' is {target_status}; message dropped.", "error": f"Target agent '{target_agent_id}' is {target_status}; message dropped.",
} },
ensure_ascii=False,
default=str,
) )
msg_id = f"msg_{uuid.uuid4().hex[:8]}" msg_id = f"msg_{uuid.uuid4().hex[:8]}"
@@ -200,13 +221,15 @@ async def send_message_to_agent(
"priority": priority, "priority": priority,
}, },
) )
return dump_tool_result( return json.dumps(
{ {
"success": True, "success": True,
"message_id": msg_id, "message_id": msg_id,
"target_agent_id": target_agent_id, "target_agent_id": target_agent_id,
"delivery_status": "queued", "delivery_status": "queued",
} },
ensure_ascii=False,
default=str,
) )
@@ -215,7 +238,7 @@ async def send_message_to_agent(
_WAIT_POLL_SECONDS = 1.0 _WAIT_POLL_SECONDS = 1.0
@strix_tool(timeout=601) @function_tool(timeout=601)
async def wait_for_message( async def wait_for_message(
ctx: RunContextWrapper, ctx: RunContextWrapper,
reason: str = "Waiting for messages from other agents", reason: str = "Waiting for messages from other agents",
@@ -251,7 +274,11 @@ async def wait_for_message(
bus = inner.get("bus") bus = inner.get("bus")
me = inner.get("agent_id") me = inner.get("agent_id")
if bus is None or me is None: if bus is None or me is None:
return dump_tool_result({"success": False, "error": "Bus or agent_id missing in context."}) return json.dumps(
{"success": False, "error": "Bus or agent_id missing in context."},
ensure_ascii=False,
default=str,
)
async with bus._lock: async with bus._lock:
bus.statuses[me] = "waiting" bus.statuses[me] = "waiting"
@@ -264,13 +291,15 @@ async def wait_for_message(
if pending > 0: if pending > 0:
async with bus._lock: async with bus._lock:
bus.statuses[me] = "running" bus.statuses[me] = "running"
return dump_tool_result( return json.dumps(
{ {
"success": True, "success": True,
"status": "message_arrived", "status": "message_arrived",
"pending_messages": pending, "pending_messages": pending,
"reason": reason, "reason": reason,
} },
ensure_ascii=False,
default=str,
) )
await asyncio.sleep(_WAIT_POLL_SECONDS) await asyncio.sleep(_WAIT_POLL_SECONDS)
finally: finally:
@@ -280,18 +309,20 @@ async def wait_for_message(
if bus.statuses.get(me) == "waiting": if bus.statuses.get(me) == "waiting":
bus.statuses[me] = "running" bus.statuses[me] = "running"
return dump_tool_result( return json.dumps(
{ {
"success": True, "success": True,
"status": "timeout", "status": "timeout",
"timeout_seconds": timeout_seconds, "timeout_seconds": timeout_seconds,
"reason": reason, "reason": reason,
"note": "No messages within timeout — continue work or call agent_finish.", "note": "No messages within timeout — continue work or call agent_finish.",
} },
ensure_ascii=False,
default=str,
) )
@strix_tool(timeout=120) @function_tool(timeout=120)
async def create_agent( async def create_agent(
ctx: RunContextWrapper, ctx: RunContextWrapper,
name: str, name: str,
@@ -344,16 +375,22 @@ async def create_agent(
factory: Callable[..., SDKAgent] | None = inner.get("agent_factory") factory: Callable[..., SDKAgent] | None = inner.get("agent_factory")
if bus is None or parent_id is None: if bus is None or parent_id is None:
return dump_tool_result({"success": False, "error": "Bus or agent_id missing in context."}) return json.dumps(
{"success": False, "error": "Bus or agent_id missing in context."},
ensure_ascii=False,
default=str,
)
if factory is None: if factory is None:
return dump_tool_result( return json.dumps(
{ {
"success": False, "success": False,
"error": ( "error": (
"No agent_factory in context. " "No agent_factory in context. "
"The root assembly must inject one via make_agent_context." "The root assembly must inject one via make_agent_context."
), ),
} },
ensure_ascii=False,
default=str,
) )
child_id = uuid.uuid4().hex[:8] child_id = uuid.uuid4().hex[:8]
@@ -362,11 +399,13 @@ async def create_agent(
child_agent = factory(name=name, skills=skills or []) child_agent = factory(name=name, skills=skills or [])
except Exception as e: except Exception as e:
logger.exception("agent_factory raised while building child '%s'", name) logger.exception("agent_factory raised while building child '%s'", name)
return dump_tool_result( return json.dumps(
{ {
"success": False, "success": False,
"error": f"agent_factory failed: {e!s}", "error": f"agent_factory failed: {e!s}",
} },
ensure_ascii=False,
default=str,
) )
await bus.register(child_id, name, parent_id) await bus.register(child_id, name, parent_id)
@@ -437,18 +476,20 @@ async def create_agent(
async with bus._lock: async with bus._lock:
bus.tasks[child_id] = task_handle bus.tasks[child_id] = task_handle
return dump_tool_result( return json.dumps(
{ {
"success": True, "success": True,
"agent_id": child_id, "agent_id": child_id,
"name": name, "name": name,
"parent_id": parent_id, "parent_id": parent_id,
"message": f"Spawned '{name}' ({child_id}) running in parallel.", "message": f"Spawned '{name}' ({child_id}) running in parallel.",
} },
ensure_ascii=False,
default=str,
) )
@strix_tool(timeout=30) @function_tool(timeout=30)
async def agent_finish( async def agent_finish(
ctx: RunContextWrapper, ctx: RunContextWrapper,
result_summary: str, result_summary: str,
@@ -495,11 +536,15 @@ async def agent_finish(
bus = inner.get("bus") bus = inner.get("bus")
me = inner.get("agent_id") me = inner.get("agent_id")
if bus is None or me is None: if bus is None or me is None:
return dump_tool_result({"success": False, "error": "Bus or agent_id missing in context."}) return json.dumps(
{"success": False, "error": "Bus or agent_id missing in context."},
ensure_ascii=False,
default=str,
)
parent_id = inner.get("parent_id") parent_id = inner.get("parent_id")
if parent_id is None: if parent_id is None:
return dump_tool_result( return json.dumps(
{ {
"success": False, "success": False,
"agent_completed": False, "agent_completed": False,
@@ -507,7 +552,9 @@ async def agent_finish(
"agent_finish is for subagents. Root/main agents must call finish_scan instead." "agent_finish is for subagents. Root/main agents must call finish_scan instead."
), ),
"parent_notified": False, "parent_notified": False,
} },
ensure_ascii=False,
default=str,
) )
inner["agent_finish_called"] = True inner["agent_finish_called"] = True
@@ -540,7 +587,7 @@ async def agent_finish(
) )
parent_notified = True parent_notified = True
return dump_tool_result( return json.dumps(
{ {
"success": True, "success": True,
"agent_completed": True, "agent_completed": True,
@@ -549,5 +596,7 @@ async def agent_finish(
"summary": result_summary, "summary": result_summary,
"findings_count": len(findings or []), "findings_count": len(findings or []),
"has_recommendations": bool(final_recommendations), "has_recommendations": bool(final_recommendations),
} },
ensure_ascii=False,
default=str,
) )
-4
View File
@@ -1,4 +0,0 @@
from .tool import finish_scan
__all__ = ["finish_scan"]
+2 -4
View File
@@ -7,9 +7,7 @@ import json
import logging import logging
from typing import Any from typing import Any
from agents import RunContextWrapper from agents import RunContextWrapper, function_tool
from strix.tools._decorator import strix_tool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -71,7 +69,7 @@ def _do_finish(
return {"success": False, "message": f"Failed to complete scan: {e!s}"} return {"success": False, "message": f"Failed to complete scan: {e!s}"}
@strix_tool(timeout=60) @function_tool(timeout=60)
async def finish_scan( async def finish_scan(
ctx: RunContextWrapper, ctx: RunContextWrapper,
executive_summary: str, executive_summary: str,
-10
View File
@@ -1,10 +0,0 @@
from .tools import create_note, delete_note, get_note, list_notes, update_note
__all__ = [
"create_note",
"delete_note",
"get_note",
"list_notes",
"update_note",
]
+21 -13
View File
@@ -21,9 +21,7 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
from agents import RunContextWrapper from agents import RunContextWrapper, function_tool
from strix.tools._decorator import dump_tool_result, strix_tool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -397,7 +395,7 @@ def _delete_note_impl(note_id: str) -> dict[str, Any]:
# --- public tools --------------------------------------------------------- # --- public tools ---------------------------------------------------------
@strix_tool(timeout=30) @function_tool(timeout=30)
async def create_note( async def create_note(
ctx: RunContextWrapper, ctx: RunContextWrapper,
title: str, title: str,
@@ -437,12 +435,14 @@ async def create_note(
category: One of the categories above. Default ``"general"``. category: One of the categories above. Default ``"general"``.
tags: Optional free-form tags. tags: Optional free-form tags.
""" """
return dump_tool_result( return json.dumps(
await asyncio.to_thread(_create_note_impl, title, content, category, tags), await asyncio.to_thread(_create_note_impl, title, content, category, tags),
ensure_ascii=False,
default=str,
) )
@strix_tool(timeout=30) @function_tool(timeout=30)
async def list_notes( async def list_notes(
ctx: RunContextWrapper, ctx: RunContextWrapper,
category: str | None = None, category: str | None = None,
@@ -468,7 +468,7 @@ async def list_notes(
include_content: When False (default) entries have a preview; include_content: When False (default) entries have a preview;
when True the full ``content`` is included. when True the full ``content`` is included.
""" """
return dump_tool_result( return json.dumps(
await asyncio.to_thread( await asyncio.to_thread(
_list_notes_impl, _list_notes_impl,
category=category, category=category,
@@ -476,20 +476,24 @@ async def list_notes(
search=search, search=search,
include_content=include_content, include_content=include_content,
), ),
ensure_ascii=False,
default=str,
) )
@strix_tool(timeout=30) @function_tool(timeout=30)
async def get_note(ctx: RunContextWrapper, note_id: str) -> str: async def get_note(ctx: RunContextWrapper, note_id: str) -> str:
"""Fetch one note by its 5-char ID. Returns the full content. """Fetch one note by its 5-char ID. Returns the full content.
Args: Args:
note_id: Note id from ``create_note`` or a ``list_notes`` entry. note_id: Note id from ``create_note`` or a ``list_notes`` entry.
""" """
return dump_tool_result(await asyncio.to_thread(_get_note_impl, note_id)) return json.dumps(
await asyncio.to_thread(_get_note_impl, note_id), ensure_ascii=False, default=str
)
@strix_tool(timeout=30) @function_tool(timeout=30)
async def update_note( async def update_note(
ctx: RunContextWrapper, ctx: RunContextWrapper,
note_id: str, note_id: str,
@@ -509,7 +513,7 @@ async def update_note(
content: New content, or ``None`` to keep. content: New content, or ``None`` to keep.
tags: New tags list, or ``None`` to keep. tags: New tags list, or ``None`` to keep.
""" """
return dump_tool_result( return json.dumps(
await asyncio.to_thread( await asyncio.to_thread(
_update_note_impl, _update_note_impl,
note_id=note_id, note_id=note_id,
@@ -517,14 +521,18 @@ async def update_note(
content=content, content=content,
tags=tags, tags=tags,
), ),
ensure_ascii=False,
default=str,
) )
@strix_tool(timeout=30) @function_tool(timeout=30)
async def delete_note(ctx: RunContextWrapper, note_id: str) -> str: async def delete_note(ctx: RunContextWrapper, note_id: str) -> str:
"""Delete a note. For wiki notes, also removes the rendered Markdown file. """Delete a note. For wiki notes, also removes the rendered Markdown file.
Args: Args:
note_id: Note id to delete. note_id: Note id to delete.
""" """
return dump_tool_result(await asyncio.to_thread(_delete_note_impl, note_id)) return json.dumps(
await asyncio.to_thread(_delete_note_impl, note_id), ensure_ascii=False, default=str
)
-10
View File
@@ -1,10 +0,0 @@
from .tools import list_requests, repeat_request, scope_rules, send_request, view_request
__all__ = [
"list_requests",
"repeat_request",
"scope_rules",
"send_request",
"view_request",
]
+81 -30
View File
@@ -12,6 +12,7 @@ Tools: ``list_requests``, ``view_request``, ``send_request``,
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import json
import re import re
import time import time
from dataclasses import is_dataclass from dataclasses import is_dataclass
@@ -19,7 +20,7 @@ from datetime import datetime
from typing import TYPE_CHECKING, Any, Literal from typing import TYPE_CHECKING, Any, Literal
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from agents import RunContextWrapper from agents import RunContextWrapper, function_tool
from caido_sdk_client.types import ( from caido_sdk_client.types import (
ConnectionInfoInput, ConnectionInfoInput,
CreateReplaySessionFromRaw, CreateReplaySessionFromRaw,
@@ -30,8 +31,6 @@ from caido_sdk_client.types import (
UpdateScopeOptions, UpdateScopeOptions,
) )
from strix.tools._decorator import dump_tool_result, strix_tool
if TYPE_CHECKING: if TYPE_CHECKING:
from caido_sdk_client import Client from caido_sdk_client import Client
@@ -92,18 +91,20 @@ def _serialize(value: Any) -> Any:
def _no_client() -> str: def _no_client() -> str:
return dump_tool_result( return json.dumps(
{ {
"success": False, "success": False,
"error": "Caido client not initialized in context.", "error": "Caido client not initialized in context.",
}, },
ensure_ascii=False,
default=str,
) )
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# list_requests # list_requests
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@strix_tool(timeout=120) @function_tool(timeout=120)
async def list_requests( async def list_requests(
ctx: RunContextWrapper, ctx: RunContextWrapper,
httpql_filter: str | None = None, httpql_filter: str | None = None,
@@ -201,7 +202,7 @@ async def list_requests(
}, },
) )
return dump_tool_result( return json.dumps(
{ {
"success": True, "success": True,
"entries": entries, "entries": entries,
@@ -212,15 +213,21 @@ async def list_requests(
"end_cursor": connection.page_info.end_cursor, "end_cursor": connection.page_info.end_cursor,
}, },
}, },
ensure_ascii=False,
default=str,
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return dump_tool_result({"success": False, "error": f"list_requests failed: {exc}"}) return json.dumps(
{"success": False, "error": f"list_requests failed: {exc}"},
ensure_ascii=False,
default=str,
)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# view_request # view_request
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@strix_tool(timeout=60) @function_tool(timeout=60)
async def view_request( async def view_request(
ctx: RunContextWrapper, ctx: RunContextWrapper,
request_id: str, request_id: str,
@@ -265,8 +272,10 @@ async def view_request(
) )
result = await client.request.get(request_id, opts) result = await client.request.get(request_id, opts)
if result is None: if result is None:
return dump_tool_result( return json.dumps(
{"success": False, "error": f"Request {request_id} not found"}, {"success": False, "error": f"Request {request_id} not found"},
ensure_ascii=False,
default=str,
) )
raw_bytes = ( raw_bytes = (
@@ -275,20 +284,30 @@ async def view_request(
else (result.response.raw if result.response is not None else None) else (result.response.raw if result.response is not None else None)
) )
if raw_bytes is None: if raw_bytes is None:
return dump_tool_result( return json.dumps(
{ {
"success": False, "success": False,
"error": f"No raw {part} for {request_id}", "error": f"No raw {part} for {request_id}",
}, },
ensure_ascii=False,
default=str,
) )
content = raw_bytes.decode("utf-8", errors="replace") content = raw_bytes.decode("utf-8", errors="replace")
if search_pattern: if search_pattern:
return dump_tool_result(_regex_hits(content, search_pattern)) return json.dumps(_regex_hits(content, search_pattern), ensure_ascii=False, default=str)
return dump_tool_result(_paginate_lines(content, page=page, page_size=page_size)) return json.dumps(
_paginate_lines(content, page=page, page_size=page_size),
ensure_ascii=False,
default=str,
)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return dump_tool_result({"success": False, "error": f"view_request failed: {exc}"}) return json.dumps(
{"success": False, "error": f"view_request failed: {exc}"},
ensure_ascii=False,
default=str,
)
def _regex_hits(content: str, pattern: str) -> dict[str, Any]: def _regex_hits(content: str, pattern: str) -> dict[str, Any]:
@@ -333,7 +352,7 @@ def _paginate_lines(content: str, *, page: int, page_size: int) -> dict[str, Any
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# send_request # send_request
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@strix_tool(timeout=120, strict_mode=False) @function_tool(timeout=120, strict_mode=False)
async def send_request( async def send_request(
ctx: RunContextWrapper, ctx: RunContextWrapper,
method: str, method: str,
@@ -367,13 +386,17 @@ async def send_request(
) )
return await _replay_send(client, raw=raw, connection=connection) return await _replay_send(client, raw=raw, connection=connection)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return dump_tool_result({"success": False, "error": f"send_request failed: {exc}"}) return json.dumps(
{"success": False, "error": f"send_request failed: {exc}"},
ensure_ascii=False,
default=str,
)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# repeat_request # repeat_request
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@strix_tool(timeout=120, strict_mode=False) @function_tool(timeout=120, strict_mode=False)
async def repeat_request( async def repeat_request(
ctx: RunContextWrapper, ctx: RunContextWrapper,
request_id: str, request_id: str,
@@ -412,8 +435,10 @@ async def repeat_request(
try: try:
result = await client.request.get(request_id, RequestGetOptions(request_raw=True)) result = await client.request.get(request_id, RequestGetOptions(request_raw=True))
if result is None or result.request.raw is None: if result is None or result.request.raw is None:
return dump_tool_result( return json.dumps(
{"success": False, "error": f"Request {request_id} not found"}, {"success": False, "error": f"Request {request_id} not found"},
ensure_ascii=False,
default=str,
) )
original = result.request original = result.request
@@ -429,13 +454,17 @@ async def repeat_request(
) )
return await _replay_send(client, raw=raw, connection=connection) return await _replay_send(client, raw=raw, connection=connection)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return dump_tool_result({"success": False, "error": f"repeat_request failed: {exc}"}) return json.dumps(
{"success": False, "error": f"repeat_request failed: {exc}"},
ensure_ascii=False,
default=str,
)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# scope_rules # scope_rules
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@strix_tool(timeout=60) @function_tool(timeout=60)
async def scope_rules( async def scope_rules(
ctx: RunContextWrapper, ctx: RunContextWrapper,
action: ScopeAction, action: ScopeAction,
@@ -488,20 +517,28 @@ async def scope_rules(
try: try:
if action == "list": if action == "list":
scopes = await client.scope.list() scopes = await client.scope.list()
return dump_tool_result( return json.dumps(
{"success": True, "scopes": [_serialize(s) for s in scopes]}, {"success": True, "scopes": [_serialize(s) for s in scopes]},
ensure_ascii=False,
default=str,
) )
if action == "get": if action == "get":
if not scope_id: if not scope_id:
return dump_tool_result( return json.dumps(
{"success": False, "error": "scope_id required for get"}, {"success": False, "error": "scope_id required for get"},
ensure_ascii=False,
default=str,
) )
scope = await client.scope.get(scope_id) scope = await client.scope.get(scope_id)
return dump_tool_result({"success": True, "scope": _serialize(scope)}) return json.dumps(
{"success": True, "scope": _serialize(scope)}, ensure_ascii=False, default=str
)
if action == "create": if action == "create":
if not scope_name: if not scope_name:
return dump_tool_result( return json.dumps(
{"success": False, "error": "scope_name required for create"}, {"success": False, "error": "scope_name required for create"},
ensure_ascii=False,
default=str,
) )
scope = await client.scope.create( scope = await client.scope.create(
CreateScopeOptions( CreateScopeOptions(
@@ -510,14 +547,18 @@ async def scope_rules(
denylist=list(denylist or []), denylist=list(denylist or []),
), ),
) )
return dump_tool_result({"success": True, "scope": _serialize(scope)}) return json.dumps(
{"success": True, "scope": _serialize(scope)}, ensure_ascii=False, default=str
)
if action == "update": if action == "update":
if not scope_id or not scope_name: if not scope_id or not scope_name:
return dump_tool_result( return json.dumps(
{ {
"success": False, "success": False,
"error": "scope_id and scope_name required for update", "error": "scope_id and scope_name required for update",
}, },
ensure_ascii=False,
default=str,
) )
scope = await client.scope.update( scope = await client.scope.update(
scope_id, scope_id,
@@ -527,16 +568,24 @@ async def scope_rules(
denylist=list(denylist or []), denylist=list(denylist or []),
), ),
) )
return dump_tool_result({"success": True, "scope": _serialize(scope)}) return json.dumps(
{"success": True, "scope": _serialize(scope)}, ensure_ascii=False, default=str
)
# action == "delete" — exhaustive Literal # action == "delete" — exhaustive Literal
if not scope_id: if not scope_id:
return dump_tool_result( return json.dumps(
{"success": False, "error": "scope_id required for delete"}, {"success": False, "error": "scope_id required for delete"},
ensure_ascii=False,
default=str,
) )
await client.scope.delete(scope_id) await client.scope.delete(scope_id)
return dump_tool_result({"success": True, "deleted": scope_id}) return json.dumps({"success": True, "deleted": scope_id}, ensure_ascii=False, default=str)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return dump_tool_result({"success": False, "error": f"scope_rules failed: {exc}"}) return json.dumps(
{"success": False, "error": f"scope_rules failed: {exc}"},
ensure_ascii=False,
default=str,
)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@@ -670,7 +719,7 @@ async def _replay_send(
"raw": response_raw.decode("utf-8", errors="replace"), "raw": response_raw.decode("utf-8", errors="replace"),
} }
return dump_tool_result( return json.dumps(
{ {
"success": result.status == "DONE", "success": result.status == "DONE",
"status": result.status, "status": result.status,
@@ -679,4 +728,6 @@ async def _replay_send(
"elapsed_ms": elapsed_ms, "elapsed_ms": elapsed_ms,
"response": response, "response": response,
}, },
ensure_ascii=False,
default=str,
) )
-4
View File
@@ -1,4 +0,0 @@
from .tool import create_vulnerability_report
__all__ = ["create_vulnerability_report"]
+2 -4
View File
@@ -9,9 +9,7 @@ import re
from pathlib import PurePosixPath from pathlib import PurePosixPath
from typing import Any from typing import Any
from agents import RunContextWrapper from agents import RunContextWrapper, function_tool
from strix.tools._decorator import strix_tool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -293,7 +291,7 @@ def _do_create( # noqa: PLR0912
# large scans can have many existing reports to compare against. # large scans can have many existing reports to compare against.
# strict_mode=False because cvss_breakdown is a dict[str, str] and # strict_mode=False because cvss_breakdown is a dict[str, str] and
# code_locations is list[dict] — both free-form for the strict schema. # code_locations is list[dict] — both free-form for the strict schema.
@strix_tool(timeout=180, strict_mode=False) @function_tool(timeout=180, strict_mode=False)
async def create_vulnerability_report( async def create_vulnerability_report(
ctx: RunContextWrapper, ctx: RunContextWrapper,
title: str, title: str,
-4
View File
@@ -1,4 +0,0 @@
from .tool import think
__all__ = ["think"]
+2 -2
View File
@@ -4,10 +4,10 @@ from __future__ import annotations
import json import json
from strix.tools._decorator import strix_tool from agents import function_tool
@strix_tool(timeout=10) @function_tool(timeout=10)
async def think(thought: str) -> str: async def think(thought: str) -> str:
"""Record a private chain-of-thought note. No side effects, no new info. """Record a private chain-of-thought note. No side effects, no new info.
-18
View File
@@ -1,18 +0,0 @@
from .tools import (
create_todo,
delete_todo,
list_todos,
mark_todo_done,
mark_todo_pending,
update_todo,
)
__all__ = [
"create_todo",
"delete_todo",
"list_todos",
"mark_todo_done",
"mark_todo_pending",
"update_todo",
]
+37 -25
View File
@@ -13,9 +13,7 @@ import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from agents import RunContextWrapper from agents import RunContextWrapper, function_tool
from strix.tools._decorator import dump_tool_result, strix_tool
VALID_PRIORITIES = ["low", "normal", "high", "critical"] VALID_PRIORITIES = ["low", "normal", "high", "critical"]
@@ -198,7 +196,7 @@ def _apply_single_update(
# --- public tools --------------------------------------------------------- # --- public tools ---------------------------------------------------------
@strix_tool(timeout=30) @function_tool(timeout=30)
async def create_todo( async def create_todo(
ctx: RunContextWrapper, ctx: RunContextWrapper,
title: str | None = None, title: str | None = None,
@@ -248,12 +246,14 @@ async def create_todo(
}, },
) )
if not tasks: if not tasks:
return dump_tool_result( return json.dumps(
{ {
"success": False, "success": False,
"error": "Provide a title or 'todos' list to create.", "error": "Provide a title or 'todos' list to create.",
"todo_id": None, "todo_id": None,
}, },
ensure_ascii=False,
default=str,
) )
agent_todos = _get_agent_todos(agent_id) agent_todos = _get_agent_todos(agent_id)
@@ -273,11 +273,13 @@ async def create_todo(
} }
created.append({"todo_id": todo_id, "title": task["title"], "priority": task_priority}) created.append({"todo_id": todo_id, "title": task["title"], "priority": task_priority})
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return dump_tool_result( return json.dumps(
{"success": False, "error": f"Failed to create todo: {e}", "todo_id": None} {"success": False, "error": f"Failed to create todo: {e}", "todo_id": None},
ensure_ascii=False,
default=str,
) )
return dump_tool_result( return json.dumps(
{ {
"success": True, "success": True,
"created": created, "created": created,
@@ -285,10 +287,12 @@ async def create_todo(
"todos": _sorted_todos(agent_id), "todos": _sorted_todos(agent_id),
"total_count": len(_get_agent_todos(agent_id)), "total_count": len(_get_agent_todos(agent_id)),
}, },
ensure_ascii=False,
default=str,
) )
@strix_tool(timeout=30) @function_tool(timeout=30)
async def list_todos( async def list_todos(
ctx: RunContextWrapper, ctx: RunContextWrapper,
status: str | None = None, status: str | None = None,
@@ -327,7 +331,7 @@ async def list_todos(
sv = todo.get("status", "pending") sv = todo.get("status", "pending")
summary[sv] = summary.get(sv, 0) + 1 summary[sv] = summary.get(sv, 0) + 1
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return dump_tool_result( return json.dumps(
{ {
"success": False, "success": False,
"error": f"Failed to list todos: {e}", "error": f"Failed to list todos: {e}",
@@ -335,19 +339,23 @@ async def list_todos(
"total_count": 0, "total_count": 0,
"summary": {"pending": 0, "in_progress": 0, "done": 0}, "summary": {"pending": 0, "in_progress": 0, "done": 0},
}, },
ensure_ascii=False,
default=str,
) )
return dump_tool_result( return json.dumps(
{ {
"success": True, "success": True,
"todos": todos_list, "todos": todos_list,
"total_count": len(todos_list), "total_count": len(todos_list),
"summary": summary, "summary": summary,
}, },
ensure_ascii=False,
default=str,
) )
@strix_tool(timeout=30) @function_tool(timeout=30)
async def update_todo( async def update_todo(
ctx: RunContextWrapper, ctx: RunContextWrapper,
todo_id: str | None = None, todo_id: str | None = None,
@@ -387,8 +395,10 @@ async def update_todo(
}, },
) )
if not updates_to_apply: if not updates_to_apply:
return dump_tool_result( return json.dumps(
{"success": False, "error": "Provide todo_id or 'updates' list to update."}, {"success": False, "error": "Provide todo_id or 'updates' list to update."},
ensure_ascii=False,
default=str,
) )
updated: list[str] = [] updated: list[str] = []
@@ -407,7 +417,7 @@ async def update_todo(
else: else:
updated.append(upd["todo_id"]) updated.append(upd["todo_id"])
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return dump_tool_result({"success": False, "error": str(e)}) return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False, default=str)
response: dict[str, Any] = { response: dict[str, Any] = {
"success": len(errors) == 0, "success": len(errors) == 0,
@@ -418,7 +428,7 @@ async def update_todo(
} }
if errors: if errors:
response["errors"] = errors response["errors"] = errors
return dump_tool_result(response) return json.dumps(response, ensure_ascii=False, default=str)
def _mark( def _mark(
@@ -437,7 +447,7 @@ def _mark(
ids.append(todo_id) ids.append(todo_id)
if not ids: if not ids:
msg = f"Provide todo_id or todo_ids to mark as {new_status}." msg = f"Provide todo_id or todo_ids to mark as {new_status}."
return dump_tool_result({"success": False, "error": msg}) return json.dumps({"success": False, "error": msg}, ensure_ascii=False, default=str)
marked: list[str] = [] marked: list[str] = []
errors: list[dict[str, Any]] = [] errors: list[dict[str, Any]] = []
@@ -452,7 +462,7 @@ def _mark(
todo["updated_at"] = timestamp todo["updated_at"] = timestamp
marked.append(tid) marked.append(tid)
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return dump_tool_result({"success": False, "error": str(e)}) return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False, default=str)
key = "marked_done" if new_status == "done" else "marked_pending" key = "marked_done" if new_status == "done" else "marked_pending"
response: dict[str, Any] = { response: dict[str, Any] = {
@@ -464,10 +474,10 @@ def _mark(
} }
if errors: if errors:
response["errors"] = errors response["errors"] = errors
return dump_tool_result(response) return json.dumps(response, ensure_ascii=False, default=str)
@strix_tool(timeout=30) @function_tool(timeout=30)
async def mark_todo_done( async def mark_todo_done(
ctx: RunContextWrapper, ctx: RunContextWrapper,
todo_id: str | None = None, todo_id: str | None = None,
@@ -488,7 +498,7 @@ async def mark_todo_done(
) )
@strix_tool(timeout=30) @function_tool(timeout=30)
async def mark_todo_pending( async def mark_todo_pending(
ctx: RunContextWrapper, ctx: RunContextWrapper,
todo_id: str | None = None, todo_id: str | None = None,
@@ -508,7 +518,7 @@ async def mark_todo_pending(
) )
@strix_tool(timeout=30) @function_tool(timeout=30)
async def delete_todo( async def delete_todo(
ctx: RunContextWrapper, ctx: RunContextWrapper,
todo_id: str | None = None, todo_id: str | None = None,
@@ -529,8 +539,10 @@ async def delete_todo(
if todo_id is not None: if todo_id is not None:
ids.append(todo_id) ids.append(todo_id)
if not ids: if not ids:
return dump_tool_result( return json.dumps(
{"success": False, "error": "Provide todo_id or todo_ids to delete."} {"success": False, "error": "Provide todo_id or todo_ids to delete."},
ensure_ascii=False,
default=str,
) )
deleted: list[str] = [] deleted: list[str] = []
@@ -542,7 +554,7 @@ async def delete_todo(
del agent_todos[tid] del agent_todos[tid]
deleted.append(tid) deleted.append(tid)
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return dump_tool_result({"success": False, "error": str(e)}) return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False, default=str)
response: dict[str, Any] = { response: dict[str, Any] = {
"success": len(errors) == 0, "success": len(errors) == 0,
@@ -553,4 +565,4 @@ async def delete_todo(
} }
if errors: if errors:
response["errors"] = errors response["errors"] = errors
return dump_tool_result(response) return json.dumps(response, ensure_ascii=False, default=str)
-4
View File
@@ -1,4 +0,0 @@
from .tool import web_search
__all__ = ["web_search"]
+2 -4
View File
@@ -8,9 +8,7 @@ import os
from typing import Any from typing import Any
import requests import requests
from agents import RunContextWrapper from agents import RunContextWrapper, function_tool
from strix.tools._decorator import strix_tool
_SYSTEM_PROMPT = """You are assisting a cybersecurity agent specialized in vulnerability scanning _SYSTEM_PROMPT = """You are assisting a cybersecurity agent specialized in vulnerability scanning
@@ -84,7 +82,7 @@ def _do_search(query: str) -> dict[str, Any]:
# Perplexity request timeout is 300s; give the SDK a slightly larger # Perplexity request timeout is 300s; give the SDK a slightly larger
# budget so the round-trip + JSON decode doesn't push us over. # budget so the round-trip + JSON decode doesn't push us over.
@strix_tool(timeout=330) @function_tool(timeout=330)
async def web_search(ctx: RunContextWrapper, query: str) -> str: async def web_search(ctx: RunContextWrapper, query: str) -> str:
"""Real-time web search via Perplexity — your primary research tool. """Real-time web search via Perplexity — your primary research tool.