Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 7 additions & 1 deletion openhands/sdk/agent/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,8 @@ def step(
def resolve_diff_from_deserialized(self, persisted: "AgentBase") -> "AgentBase":
"""
Return a new AgentBase instance equivalent to `persisted` but with
explicitly whitelisted fields (e.g. api_key) taken from `self`.
explicitly whitelisted fields (e.g. api_key, security_analyzer) taken from
`self`.
"""
if persisted.__class__ is not self.__class__:
raise ValueError(
Expand Down Expand Up @@ -282,6 +283,11 @@ def resolve_diff_from_deserialized(self, persisted: "AgentBase") -> "AgentBase":
)
updates["condenser"] = new_condenser

# Allow security_analyzer to differ - use the runtime (self) version
# This allows users to add/remove security analyzers mid-conversation
# (e.g., when switching to weaker LLMs that can't handle security_risk field)
updates["security_analyzer"] = self.security_analyzer

# Create maps by tool name for easy lookup
runtime_tools_map = {tool.name: tool for tool in self.tools}
persisted_tools_map = {tool.name: tool for tool in persisted.tools}
Expand Down
181 changes: 181 additions & 0 deletions tests/cross/test_agent_reconciliation.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,184 @@ def test_agent_resolve_diff_from_deserialized():
assert resolved.model_dump(mode="json") == runtime_agent.model_dump(mode="json")
assert resolved.llm.model == runtime_agent.llm.model
assert resolved.__class__ == runtime_agent.__class__


def test_agent_resolve_diff_allows_security_analyzer_change():
"""Test that security_analyzer can differ between runtime and persisted agents."""
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer

with tempfile.TemporaryDirectory():
# Create original agent WITH security analyzer
tools = [Tool(name="BashTool")]
llm = LLM(
model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="test-llm"
)
original_agent = Agent(
llm=llm, tools=tools, security_analyzer=LLMSecurityAnalyzer()
)

# Serialize and deserialize to simulate persistence
serialized = original_agent.model_dump_json()
deserialized_agent = AgentBase.model_validate_json(serialized)

# Verify deserialized agent has security analyzer
assert deserialized_agent.security_analyzer is not None
assert isinstance(deserialized_agent.security_analyzer, LLMSecurityAnalyzer)

# Create runtime agent WITHOUT security analyzer
llm2 = LLM(
model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="test-llm"
)
runtime_agent = Agent(llm=llm2, tools=tools, security_analyzer=None)

# Should resolve successfully even though security_analyzer differs
resolved = runtime_agent.resolve_diff_from_deserialized(deserialized_agent)

# Resolved agent should use runtime's security_analyzer (None)
assert resolved.security_analyzer is None
assert resolved.llm.model == runtime_agent.llm.model
assert resolved.__class__ == runtime_agent.__class__


def test_agent_resolve_diff_allows_adding_security_analyzer():
"""Test that security_analyzer can be added to a persisted agent without one."""
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer

with tempfile.TemporaryDirectory():
# Create original agent WITHOUT security analyzer
tools = [Tool(name="BashTool")]
llm = LLM(
model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="test-llm"
)
original_agent = Agent(llm=llm, tools=tools, security_analyzer=None)

# Serialize and deserialize to simulate persistence
serialized = original_agent.model_dump_json()
deserialized_agent = AgentBase.model_validate_json(serialized)

# Verify deserialized agent has no security analyzer
assert deserialized_agent.security_analyzer is None

# Create runtime agent WITH security analyzer
llm2 = LLM(
model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="test-llm"
)
runtime_agent = Agent(
llm=llm2, tools=tools, security_analyzer=LLMSecurityAnalyzer()
)

# Should resolve successfully even though security_analyzer differs
resolved = runtime_agent.resolve_diff_from_deserialized(deserialized_agent)

# Resolved agent should use runtime's security_analyzer
assert resolved.security_analyzer is not None
assert isinstance(resolved.security_analyzer, LLMSecurityAnalyzer)
assert resolved.llm.model == runtime_agent.llm.model
assert resolved.__class__ == runtime_agent.__class__


def test_conversation_restart_with_different_security_analyzer():
"""Test restarting conversation with different security analyzer (issue #668)."""
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer

with tempfile.TemporaryDirectory() as temp_dir:
# Create conversation with security analyzer
tools = [
Tool(name="BashTool"),
Tool(name="FileEditorTool"),
]
llm = LLM(
model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="test-llm"
)
agent_with_security = Agent(
llm=llm, tools=tools, security_analyzer=LLMSecurityAnalyzer()
)

conversation = LocalConversation(
agent=agent_with_security,
workspace=temp_dir,
persistence_dir=temp_dir,
visualize=False,
)

# Send a message to create some state
conversation.send_message(
Message(role="user", content=[TextContent(text="test message")])
)

conversation_id = conversation.state.id
del conversation

# Restart conversation WITHOUT security analyzer
# This should succeed (previously would fail with reconciliation error)
llm2 = LLM(
model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="test-llm"
)
agent_without_security = Agent(llm=llm2, tools=tools, security_analyzer=None)

new_conversation = LocalConversation(
agent=agent_without_security,
workspace=temp_dir,
persistence_dir=temp_dir,
conversation_id=conversation_id,
visualize=False,
)

# Verify conversation loaded successfully
assert new_conversation.id == conversation_id
assert new_conversation.agent.security_analyzer is None
assert len(new_conversation.state.events) > 0


def test_conversation_restart_adding_security_analyzer():
"""Test restarting conversation and adding security analyzer (issue #668)."""
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer

with tempfile.TemporaryDirectory() as temp_dir:
# Create conversation WITHOUT security analyzer
tools = [
Tool(name="BashTool"),
Tool(name="FileEditorTool"),
]
llm = LLM(
model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="test-llm"
)
agent_without_security = Agent(llm=llm, tools=tools, security_analyzer=None)

conversation = LocalConversation(
agent=agent_without_security,
workspace=temp_dir,
persistence_dir=temp_dir,
visualize=False,
)

# Send a message to create some state
conversation.send_message(
Message(role="user", content=[TextContent(text="test message")])
)

conversation_id = conversation.state.id
del conversation

# Restart conversation WITH security analyzer
# This should succeed
llm2 = LLM(
model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="test-llm"
)
agent_with_security = Agent(
llm=llm2, tools=tools, security_analyzer=LLMSecurityAnalyzer()
)

new_conversation = LocalConversation(
agent=agent_with_security,
workspace=temp_dir,
persistence_dir=temp_dir,
conversation_id=conversation_id,
visualize=False,
)

# Verify conversation loaded successfully
assert new_conversation.id == conversation_id
assert new_conversation.agent.security_analyzer is not None
assert isinstance(new_conversation.agent.security_analyzer, LLMSecurityAnalyzer)
assert len(new_conversation.state.events) > 0
Loading