You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
No single place for documentation - The reason we use JSON roundtrip (threading.Lock cannot be pickled) is documented in scattered comments
Violates "agents are immutable" principle - Raw model_copy makes mutation patterns non-obvious
Solution
Add 4 methods to provide a clean, consistent API:
# On AgentBasedefdeep_copy(self) ->"AgentBase"defreplace(self, *, llm=UNSET, agent_context=UNSET, mcp_config=UNSET) ->"AgentBase"# On LLM defdeep_copy(self) ->"LLM"defreplace(self, *, stream=UNSET, usage_id=UNSET) ->"LLM"
Implementation Sketch
# In openhands/sdk/utils/copy.py (or inline in each class)class_Unset:
"""Sentinel for distinguishing 'not provided' from None."""__slots__= ()
def__repr__(self) ->str:
return"UNSET"UNSET=_Unset()
# In openhands/sdk/agent/base.pyclassAgentBase(DiscriminatedUnionMixin, ABC):
# ... existing code ...defdeep_copy(self) ->"AgentBase":
"""Create an independent deep-copy of this agent. Returns a new agent with its own object graph, including a new LLM instance. Safe for use in independent conversations (e.g., forks). Uses JSON roundtrip because agent/LLM private attributes hold threading.Lock objects that cannot be pickled with copy.deepcopy() or model_copy(deep=True). """cls=type(self)
returncls.model_validate(
self.model_dump(context={"expose_secrets": True}),
)
defreplace(
self,
*,
llm: LLM|_Unset=UNSET,
agent_context: AgentContext|None|_Unset=UNSET,
mcp_config: dict[str, Any] |_Unset=UNSET,
) ->"AgentBase":
"""Return a new agent with the specified field(s) replaced. The original agent is unchanged (agents are immutable). """updates: dict[str, Any] = {}
ifnotisinstance(llm, _Unset):
updates["llm"] =llmifnotisinstance(agent_context, _Unset):
updates["agent_context"] =agent_contextifnotisinstance(mcp_config, _Unset):
updates["mcp_config"] =mcp_configifnotupdates:
returnselfreturnself.model_copy(update=updates)
# In openhands/sdk/llm/llm.pyclassLLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
# ... existing code ...defdeep_copy(self) ->"LLM":
"""Create an independent deep-copy of this LLM. Returns a new LLM with its own metrics, tokenizer, and other internal state. """cls=type(self)
returncls.model_validate(
self.model_dump(context={"expose_secrets": True}),
)
defreplace(
self,
*,
stream: bool|_Unset=UNSET,
usage_id: str|_Unset=UNSET,
) ->"LLM":
"""Return a new LLM with the specified field(s) replaced. The original LLM is unchanged. """updates: dict[str, Any] = {}
ifnotisinstance(stream, _Unset):
updates["stream"] =streamifnotisinstance(usage_id, _Unset):
updates["usage_id"] =usage_idifnotupdates:
returnselfreturnself.model_copy(update=updates)
Problem
Agent and LLM copying/updating is scattered across the codebase using raw Pydantic methods (
model_copy,model_dump/model_validate). This leads to:model_copy(update={...}), others usemodel_dump()/model_validate()roundtrip_prompt_cache_keybecause the deep-copy requirement was not obviousmodel_copymakes mutation patterns non-obviousSolution
Add 4 methods to provide a clean, consistent API:
Implementation Sketch
Call Sites to Refactor
Agent
deep_copy()(3 sites)local_conversation.pyagent_cls.model_validate(self.agent.model_dump(...))remote_conversation.pyagent_cls.model_validate(self.agent.model_dump(...))event_service.pyagent_cls.model_validate(self.stored.agent.model_dump(...))Agent
replace()(5 sites)local_conversation.pyagent_context,mcp_configlocal_conversation.pyllmplugin/loader.pyagent_context,mcp_configtask/manager.pyllmdelegate/impl.pyllmLLM
deep_copy()(1 site)delegate/impl.pyparent_llm.model_copy()LLM
replace()(10 sites)local_conversation.pyusage_idlocal_conversation.pyusage_id(currently usesdeep=True, change todeep_copy().replace())task/manager.pystreamtask/manager.pystreamdelegate/impl.pystreampreset/default.pyusage_idpreset/gpt5.pyusage_idpreset/planning.pyusage_idpreset/gemini.pyusage_idTotal: 19 call sites across 10 files
Example Transformations
Notes
deep_copy()uses JSON roundtrip to handlethreading.Lockin private attrsreplace()usesmodel_copy(update={...})internally (shallow copy is fine when replacing with new objects)UNSETsentinel allows distinguishing "not provided" fromNonemodel_copy()on Agent/LLMRelated