Skip to content

Commit 694b50e

Browse files
author
yashab-cyber
committed
Fix Agent Loop Bug
1 parent 88afc0f commit 694b50e

2 files changed

Lines changed: 102 additions & 2 deletions

File tree

hackbot/core/pdf_report.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1103,7 +1103,11 @@ def _normalize_tool_history(tool_history: Optional[List[Dict[str, Any]]]) -> Lis
11031103
cmd = str(entry.get("command", "") or "").strip()
11041104
tool = str(entry.get("tool", "") or "").strip()
11051105
if not tool and cmd:
1106-
tool = cmd.split()[0]
1106+
parts = cmd.split()
1107+
if parts[0] == "sudo" and len(parts) > 1:
1108+
tool = parts[1]
1109+
else:
1110+
tool = parts[0]
11071111

11081112
out = dict(entry)
11091113
out["tool"] = tool or "unknown"

tests/test_modes.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import pytest
44

55
from hackbot.modes.plan import PlanMode, PLAN_TEMPLATES
6-
from hackbot.modes.agent import Severity, Finding
6+
from hackbot.modes.agent import AgentMode, Severity, Finding
7+
from hackbot.core.engine import AIEngine
8+
from hackbot.config import HackBotConfig, AgentConfig, AIConfig
9+
from hackbot.core.runner import ToolResult
10+
from unittest.mock import MagicMock, patch
711

812

913
def test_plan_templates_exist():
@@ -49,3 +53,95 @@ def test_finding_creation():
4953
d = finding.to_dict()
5054
assert d["severity"] == "High"
5155
assert d["title"] == "SQL Injection"
56+
57+
58+
@pytest.fixture
59+
def mock_agent():
60+
"""Create an AgentMode instance with mocked dependencies for testing loop logic."""
61+
config = HackBotConfig()
62+
config.ai = AIConfig(provider="mock", model="mock")
63+
config.agent = AgentConfig(allowed_tools=["echo", "nmap"], safe_mode=False)
64+
65+
engine = MagicMock(spec=AIEngine)
66+
engine.chat.return_value = "Mocked AI response"
67+
68+
with patch("hackbot.core.vulndb.VulnDB"), \
69+
patch("hackbot.core.cve.CVELookup"):
70+
agent = AgentMode(engine=engine, config=config)
71+
agent.target = "127.0.0.1"
72+
agent.conversation = MagicMock()
73+
agent.is_running = True
74+
return agent
75+
76+
def test_process_actions_loop_skips_same_round_duplicates(mock_agent):
77+
"""Test that identical commands within one round are skipped."""
78+
mock_agent._execute_action = MagicMock(return_value=("output", ToolResult(
79+
tool="echo", command="echo test", stdout="test", stderr="",
80+
return_code=0, duration=0.1, success=True
81+
)))
82+
83+
actions = [
84+
{"action": "execute", "command": "echo test"},
85+
{"action": "execute", "command": "echo test"},
86+
{"action": "execute", "command": "echo test"},
87+
]
88+
89+
# We only care about the single-round processing here, so mock Chat to stop the loop
90+
mock_agent.engine.chat.return_value = '{"action": "complete"}'
91+
92+
mock_agent._process_actions_loop(actions, max_rounds=2)
93+
94+
# execute_action should only be called once, the others are deduplicated in same round
95+
assert mock_agent._execute_action.call_count == 1
96+
97+
def test_process_actions_loop_skips_session_duplicates(mock_agent):
98+
"""Test that commands executed >=2 times in a session are skipped."""
99+
mock_agent._command_history = {"echo test": 2} # Already run twice
100+
101+
mock_agent._execute_action = MagicMock()
102+
mock_agent.engine.chat.return_value = '{"action": "complete"}'
103+
104+
actions = [
105+
{"action": "execute", "command": "echo test"},
106+
]
107+
mock_agent._process_actions_loop(actions, max_rounds=2)
108+
109+
# Should be skipped entirely
110+
mock_agent._execute_action.assert_not_called()
111+
112+
def test_command_history_increments(mock_agent):
113+
"""Test that executing an action increments command history."""
114+
# Ensure runner is a mock that won't actually execute
115+
mock_agent.runner = MagicMock()
116+
mock_agent.runner.execute.return_value = ToolResult(
117+
tool="echo", command="echo hello", stdout="hello", stderr="",
118+
return_code=0, duration=0.1, success=True
119+
)
120+
121+
action = {"action": "execute", "command": "echo hello"}
122+
123+
assert "echo hello" not in mock_agent._command_history
124+
mock_agent._execute_action(action)
125+
assert mock_agent._command_history["echo hello"] == 1
126+
127+
mock_agent._execute_action(action)
128+
assert mock_agent._command_history["echo hello"] == 2
129+
130+
def test_should_nudge_capped_per_step(mock_agent):
131+
"""Test that _should_nudge only returns True once per step."""
132+
response_with_tool = "I will run nmap now."
133+
134+
# First check should be True
135+
assert mock_agent._should_nudge(response_with_tool) is True
136+
# Second check should be False (already nudged)
137+
assert mock_agent._should_nudge(response_with_tool) is False
138+
139+
# Reset count (as happens at top of step())
140+
mock_agent._nudge_count = 0
141+
assert mock_agent._should_nudge(response_with_tool) is True
142+
143+
def test_should_nudge_ignores_non_tools(mock_agent):
144+
"""Test that _should_nudge returns False if no tool names are present."""
145+
response_no_tool = "I have begun assessing the target based on the scope provided."
146+
# Should be false since it doesn't mention nmap/sqlmap/etc
147+
assert mock_agent._should_nudge(response_no_tool) is False

0 commit comments

Comments
 (0)