Skip to content
Closed
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
9f0c625
refactor: align prompts and scan modes with owasp wstg methodology
0xhis Feb 25, 2026
a54ba27
Merge branch 'main' into prompt-optimization
0xhis Feb 25, 2026
4b72fc0
feat(ui): add live status updates during agent initialization
0xhis Feb 25, 2026
8c5d946
fix(ui): show live status messages during all agent phases, not just …
0xhis Feb 25, 2026
c56631e
fix(ui): stabilize live agent status updates
0xhis Feb 25, 2026
0439d70
style: wrap update_agent_status signature to fix line length lint
0xhis Feb 25, 2026
8f02d52
feat: enforce WSTG ID prefixes and deep agent chaining
0xhis Feb 25, 2026
6c02017
feat: enforce testing of newly exposed surfaces after a bypass
0xhis Feb 25, 2026
8859f2b
feat: enforce spawning specialized subagents for heavy exploitation l…
0xhis Feb 25, 2026
8abbb58
feat: add WAF & rate limit adaptation rule to execution guidelines
0xhis Feb 25, 2026
e5b0464
fix(tui): persist thinking blocks & apply copilot review feedback
0xhis Feb 25, 2026
bf6ea9c
style: address copilot review styling suggestions
0xhis Feb 25, 2026
4a3cc13
feat(prompt): add attacker perspective verification to deep/standard …
0xhis Feb 25, 2026
64aa3b5
style: address PR #328 review suggestions
0xhis Feb 25, 2026
24b5147
refactor: drop thinking_blocks from AgentState.messages and dedup tui.py
Feb 25, 2026
76fcf75
fix: address Copilot review suggestions
Feb 25, 2026
650ec46
chore: simplify PR by removing thinking blocks and redundant code
Mar 9, 2026
e7e03e0
Merge remote-tracking branch 'origin/main' into pr-328
Mar 9, 2026
5be1025
Fix agent telemetry update events
Mar 9, 2026
82bbc11
fix: address Copilot review suggestions
0xhis Feb 25, 2026
ff30eee
fix: revert get_conversation_history copy (memory leak) and remove re…
0xhis Feb 25, 2026
7c7940b
refactor(prompt): enforce subagent delegation for Phase 1 context gat…
0xhis Feb 26, 2026
dc23c1f
fix: address prompt-optimization branch review bugs
0xhis Feb 26, 2026
a567677
refactor(prompt): mitigate exploitation phase refusals and simplify a…
0xhis Feb 26, 2026
19631e2
chore: ignore test_run.sh
0xhis Feb 26, 2026
877af2b
refactor(prompt): update deep scan mode with authorization framing
0xhis Feb 26, 2026
6592a6f
feat(prompt): add mandatory skill assignment triggers for subagent cr…
0xhis Feb 26, 2026
4785d4b
fix(agent): mitigate LLM refusals via explicit authorization and atta…
0xhis Feb 26, 2026
88ffb3c
fix(agent): add todo list instruction and remove WSTG prefixes from a…
0xhis Feb 26, 2026
62bdf09
fix(prompt): tighter legal mandate & target infra bypass framing
0xhis Feb 26, 2026
25f8bd7
Enhance prompt structure with XML bounding and refusal suppression
0xhis Feb 27, 2026
1fc997d
fix(tool): strictly constrain todo priority values to prevent halluci…
0xhis Mar 2, 2026
2f6c1ed
fix(agent): fix XML tag nesting and UI rendering issues from PR review
0xhis Mar 3, 2026
e9f43c3
fix(agent): stabilize sender attribution and align scan/TUI prompt up…
0xhis Mar 7, 2026
a913f76
refactor(prompt): condense quick scan mode to baseline-style flow
0xhis Mar 7, 2026
95e2f88
fix(tui): sanitize merged text spans to prevent render crash
0xhis Mar 7, 2026
9dcb302
fix(agent): address review comments for thinking blocks, empty conten…
0xhis Mar 7, 2026
2bc2522
fix(tui): sanitize text spans on all single-renderable bypass paths
0xhis Mar 7, 2026
1236065
fix(llm): reduce conversation token budget to 80k to prevent exceedin…
0xhis Mar 7, 2026
ce2353a
fix(llm): include system prompt tokens in memory compressor budget
0xhis Mar 7, 2026
b15d3d6
fix(llm): handle malformed function/parameter open tags from GLM-5
0xhis Mar 10, 2026
9573242
Fix GLM-5 regex lookahead and tracer payload None regression
0xhis Mar 12, 2026
cfb8b35
Refactor verification workflow to mirror upstream 3-step process usin…
0xhis Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,4 @@ Thumbs.db
schema.graphql

.opencode/
/test_run.sh
42 changes: 28 additions & 14 deletions strix/agents/StrixAgent/strix_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,31 +57,45 @@ async def execute_scan(self, scan_config: dict[str, Any]) -> dict[str, Any]: #
elif target_type == "ip_address":
ip_addresses.append(details["target_ip"])

task_parts = []
target_lines = []

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']})")
target_lines.append(f' <target type="repository">{repo["url"]} (code at: {repo["workspace_path"]})</target>')
else:
task_parts.append(f"- {repo['url']}")
target_lines.append(f' <target type="repository">{repo["url"]}</target>')

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
)
for code in local_code:
target_lines.append(f' <target type="local_code">{code["path"]} (code at: {code["workspace_path"]})</target>')

if urls:
task_parts.append("\n\nURLs:")
task_parts.extend(f"- {url}" for url in urls)
for url in urls:
target_lines.append(f' <target type="url">{url}</target>')

if ip_addresses:
task_parts.append("\n\nIP Addresses:")
task_parts.extend(f"- {ip}" for ip in ip_addresses)

task_description = " ".join(task_parts)
for ip in ip_addresses:
target_lines.append(f' <target type="ip">{ip}</target>')

targets_block = "\n".join(target_lines)

has_code = bool(repositories or local_code)
has_urls = bool(urls or ip_addresses)
if has_code and has_urls:
mode = "COMBINED MODE (code + deployed target)"
elif has_code:
mode = "WHITE-BOX (source code provided)"
else:
mode = "BLACK-BOX (URL/domain targets)"

task_description = (
f"<scan_task>\n"
f"<targets>\n{targets_block}\n</targets>\n"
f"<mode>{mode}</mode>\n"
f"<action>Begin security assessment NOW. Your first tool call must be create_agent to spawn context-gathering subagents for the targets listed above. Do NOT call wait_for_message — the targets are already specified.</action>\n"
f"</scan_task>"
)

if user_instructions:
task_description += f"\n\nSpecial instructions: {user_instructions}"
Expand Down
467 changes: 303 additions & 164 deletions strix/agents/StrixAgent/system_prompt.jinja

Large diffs are not rendered by default.

62 changes: 39 additions & 23 deletions strix/agents/base_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,13 @@ async def _initialize_sandbox_and_state(self, task: str) -> None:
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
from strix.telemetry.tracer import get_global_tracer

tracer = get_global_tracer()
if tracer:
tracer.update_agent_system_message(
self.state.agent_id, "Setting up sandbox environment..."
)

try:
runtime = get_runtime()
Expand Down Expand Up @@ -355,6 +362,9 @@ async def _initialize_sandbox_and_state(self, task: str) -> None:
async def _process_iteration(self, tracer: Optional["Tracer"]) -> bool:
final_response = None

if tracer:
tracer.update_agent_system_message(self.state.agent_id, "Thinking...")

async for response in self.llm.generate(self.state.get_conversation_history()):
final_response = response
if tracer and response.content:
Expand Down Expand Up @@ -396,8 +406,30 @@ async def _process_iteration(self, tracer: Optional["Tracer"]) -> bool:
)

if actions:
if tracer:
tool_names = [a.get("toolName") or a.get("tool_name") or "tool" for a in actions]
display_names = tool_names[:2]
overflow = len(tool_names) - 2
suffix = f" +{overflow} more" if overflow > 0 else ""
tracer.update_agent_system_message(
self.state.agent_id, f"Executing {', '.join(display_names)}{suffix}..."
)
return await self._execute_actions(actions, tracer)

if tracer:
tracer.update_agent_system_message(self.state.agent_id, "Processing response...")

corrective_message = (
"You responded with plain text instead of a tool call. "
"While the agent loop is running, EVERY response MUST be a tool call. "
"Do NOT send plain text messages. Act via tools:\n"
"- Use the think tool to reason through problems\n"
"- Use create_agent to spawn subagents for testing\n"
"- Use terminal_execute to run commands\n"
"- Use wait_for_message ONLY when waiting for subagent results\n"
"Review your task and take action now."
)
self.state.add_message("user", corrective_message)
return False
Comment on lines +422 to 433
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrective message injection has no retry cap

Every time the LLM produces a plain-text response with no tool calls, corrective_message is injected as a user turn into self.state.messages and the iteration returns False (loop continues). There is no guard limiting how many times this can happen per run. If a model consistently produces plain-text (e.g., due to a prompt formatting mismatch or a model that ignores tool-call instructions), every failed iteration appends another ~150-token user message to the conversation history. Over the lifetime of an agent with a high max-iteration budget this can consume a significant portion of the context window with repetitive corrective content, crowding out actual task history and compounding the existing memory growth concern.

Consider tracking a per-agent retry counter and triggering a harder recovery (e.g., agent_finish with an error, or raising LLMRequestFailedError) after N consecutive plain-text responses:

self._no_tool_call_streak = getattr(self, "_no_tool_call_streak", 0) + 1
if self._no_tool_call_streak > MAX_NO_TOOL_CALL_RETRIES:
    raise LLMRequestFailedError("Agent produced too many plain-text responses")
self.state.add_message("user", corrective_message)
return False

Reset _no_tool_call_streak to 0 at the top of _process_iteration whenever actions is non-empty.

Prompt To Fix With AI
This is a comment left during a code review.
Path: strix/agents/base_agent.py
Line: 422-433

Comment:
**Corrective message injection has no retry cap**

Every time the LLM produces a plain-text response with no tool calls, `corrective_message` is injected as a `user` turn into `self.state.messages` and the iteration returns `False` (loop continues). There is no guard limiting how many times this can happen per run. If a model consistently produces plain-text (e.g., due to a prompt formatting mismatch or a model that ignores tool-call instructions), every failed iteration appends another ~150-token user message to the conversation history. Over the lifetime of an agent with a high max-iteration budget this can consume a significant portion of the context window with repetitive corrective content, crowding out actual task history and compounding the existing memory growth concern.

Consider tracking a per-agent retry counter and triggering a harder recovery (e.g., `agent_finish` with an error, or raising `LLMRequestFailedError`) after `N` consecutive plain-text responses:

```python
self._no_tool_call_streak = getattr(self, "_no_tool_call_streak", 0) + 1
if self._no_tool_call_streak > MAX_NO_TOOL_CALL_RETRIES:
    raise LLMRequestFailedError("Agent produced too many plain-text responses")
self.state.add_message("user", corrective_message)
return False
```

Reset `_no_tool_call_streak` to `0` at the top of `_process_iteration` whenever `actions` is non-empty.

How can I resolve this? If you propose a fix, please make it concise.


async def _execute_actions(self, actions: list[Any], tracer: Optional["Tracer"]) -> bool:
Expand Down Expand Up @@ -472,33 +504,17 @@ def _check_agent_messages(self, state: AgentState) -> None: # noqa: PLR0912
sender_name = "User"
state.add_message("user", message.get("content", ""))
else:
sender_name = sender_id or "Unknown"
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_content = f"""<agent_message
from="{sender_name}"
id="{sender_id}"
type="{message.get("message_type", "information")}"
priority="{message.get("priority", "normal")}">
{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>"""
</agent_message>"""
state.add_message("user", message_content.strip())

message["read"] = True
Expand Down
4 changes: 1 addition & 3 deletions strix/agents/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,7 @@ 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:
def add_message(self, role: str, content: Any, thinking_blocks: list | None = None) -> None:
message = {"role": role, "content": content}
if thinking_blocks:
message["thinking_blocks"] = thinking_blocks
Expand Down
3 changes: 2 additions & 1 deletion strix/interface/tool_components/thinking_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ def render(cls, tool_data: dict[str, Any]) -> Static:
text.append("\n ")

if thought:
text.append(thought, style="italic dim")
indented_thought = "\n ".join(thought.split("\n"))
text.append(indented_thought, style="italic dim")
else:
text.append("Thinking...", style="italic dim")

Expand Down
76 changes: 66 additions & 10 deletions strix/interface/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -1036,13 +1036,39 @@ def _merge_renderables(renderables: list[Any]) -> Text:
if i > 0:
combined.append("\n")
StrixTUIApp._append_renderable(combined, item)
return combined
return StrixTUIApp._sanitize_text_spans(combined)

@staticmethod
def _sanitize_text_spans(text: Text) -> Text:
plain = text.plain
plain_len = len(plain)

if plain_len == 0 or not text.spans:
return text

sanitized = Text(
plain,
style=text.style,
justify=text.justify,
overflow=text.overflow,
no_wrap=text.no_wrap,
end=text.end,
tab_size=text.tab_size,
)

for span in text.spans:
start = max(0, min(span.start, plain_len))
end = max(0, min(span.end, plain_len))
if end > start:
sanitized.stylize(span.style, start, end)

return sanitized

@staticmethod
def _append_renderable(combined: Text, item: Any) -> None:
"""Recursively append a renderable's text content to a combined Text."""
if isinstance(item, Text):
combined.append_text(item)
combined.append_text(StrixTUIApp._sanitize_text_spans(item))
elif isinstance(item, Group):
for j, sub in enumerate(item.renderables):
if j > 0:
Expand Down Expand Up @@ -1087,7 +1113,7 @@ def _get_rendered_events_content(self, events: list[dict[str, Any]]) -> Any:
return Text()

if len(renderables) == 1 and isinstance(renderables[0], Text):
return renderables[0]
return self._sanitize_text_spans(renderables[0])

return self._merge_renderables(renderables)

Expand Down Expand Up @@ -1123,7 +1149,7 @@ def _render_streaming_content(self, content: str, agent_id: str | None = None) -
if not renderables:
result = Text()
elif len(renderables) == 1 and isinstance(renderables[0], Text):
result = renderables[0]
result = self._sanitize_text_spans(renderables[0])
else:
result = self._merge_renderables(renderables)

Expand Down Expand Up @@ -1215,14 +1241,19 @@ def keymap_styled(keys: list[tuple[str, str]]) -> Text:
return (Text(" "), keymap, False)

if status == "running":
sys_msg = agent_data.get("system_message", "")
if self._agent_has_real_activity(agent_id):
animated_text = Text()
animated_text.append_text(self._get_sweep_animation(self._sweep_colors))
if sys_msg:
animated_text.append(sys_msg, style="dim italic")
animated_text.append(" ", style="dim")
animated_text.append("esc", style="white")
animated_text.append(" ", style="dim")
animated_text.append("stop", style="dim")
return (animated_text, keymap_styled([("ctrl-q", "quit")]), True)
animated_text = self._get_animated_verb_text(agent_id, "Initializing")
msg = sys_msg or "Initializing..."
animated_text = self._get_animated_verb_text(agent_id, msg)
return (animated_text, keymap_styled([("ctrl-q", "quit")]), True)

return (None, Text(), False)
Expand Down Expand Up @@ -1394,7 +1425,7 @@ def _animate_dots(self) -> None:
if not has_active_agents:
has_active_agents = any(
agent_data.get("status", "running") in ["running", "waiting"]
for agent_data in self.tracer.agents.values()
for agent_data in list(self.tracer.agents.values())
)

if not has_active_agents:
Expand Down Expand Up @@ -1655,12 +1686,26 @@ def _render_chat_content(self, msg_data: dict[str, Any]) -> Any:
content = msg_data.get("content", "")
metadata = msg_data.get("metadata", {})

if not content:
return None

if role == "user":
if not content:
return None
return UserMessageRenderer.render_simple(content)
Comment on lines 1689 to 1692
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty user content bypasses None guard

Before this change the function started with:

if not content:
    return None

That check ran before the role branch, so user messages with empty content returned None safely.

Now the user branch fires first and immediately calls UserMessageRenderer.render_simple(content) without verifying that content is non-empty. If a user-role message arrives with content == "" (e.g. a synthetic message injected by process_tool_invocations before its content is set, or any future code path that appends an empty user turn), render_simple is called with an empty string and likely returns a blank widget entry in the chat log instead of None.

The assistant branch keeps the guard (if not content and not renderables: return None), so the asymmetry is inconsistent. A minimal fix:

Suggested change
if role == "user":
return UserMessageRenderer.render_simple(content)
if role == "user":
if not content:
return None
return UserMessageRenderer.render_simple(content)
Prompt To Fix With AI
This is a comment left during a code review.
Path: strix/interface/tui.py
Line: 1689-1690

Comment:
**Empty user `content` bypasses `None` guard**

Before this change the function started with:

```python
if not content:
    return None
```

That check ran before the `role` branch, so user messages with empty content returned `None` safely.

Now the user branch fires *first* and immediately calls `UserMessageRenderer.render_simple(content)` without verifying that `content` is non-empty. If a user-role message arrives with `content == ""` (e.g. a synthetic message injected by `process_tool_invocations` before its content is set, or any future code path that appends an empty user turn), `render_simple` is called with an empty string and likely returns a blank widget entry in the chat log instead of `None`.

The assistant branch keeps the guard (`if not content and not renderables: return None`), so the asymmetry is inconsistent. A minimal fix:

```suggestion
        if role == "user":
            if not content:
                return None
            return UserMessageRenderer.render_simple(content)
```

How can I resolve this? If you propose a fix, please make it concise.


renderables = []

if "thinking_blocks" in metadata and metadata["thinking_blocks"]:
from strix.interface.tool_components.thinking_renderer import ThinkRenderer

for block in metadata["thinking_blocks"]:
thought = block.get("thinking", "")
if thought:
renderables.append(
ThinkRenderer.render({"args": {"thought": thought}}).renderable
)

Comment on lines +1696 to +1705
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thinking-block UI rendering here duplicates the existing ThinkRenderer implementation (strix/interface/tool_components/thinking_renderer.py) and hard-codes the CSS class string. To avoid divergence (styling/formatting changes in one place but not the other), consider reusing the renderer/helper that already formats "🧠 Thinking" blocks, or centralizing this formatting in a shared function.

Copilot uses AI. Check for mistakes.
if not content and not renderables:
return None

if metadata.get("interrupted"):
streaming_result = self._render_streaming_content(content)
interrupted_text = Text()
Expand All @@ -1669,7 +1714,18 @@ def _render_chat_content(self, msg_data: dict[str, Any]) -> Any:
interrupted_text.append("Interrupted by user", style="yellow dim")
return self._merge_renderables([streaming_result, interrupted_text])

return AgentMessageRenderer.render_simple(content)
if content:
msg_renderable = AgentMessageRenderer.render_simple(content)
renderables.append(msg_renderable)

if not renderables:
return None

if len(renderables) == 1:
r = renderables[0]
return self._sanitize_text_spans(r) if isinstance(r, Text) else r

return self._merge_renderables(renderables)

def _render_tool_content_simple(self, tool_data: dict[str, Any]) -> Any:
tool_name = tool_data.get("tool_name", "Unknown Tool")
Expand Down
Loading