Skip to content

Conversation

@enyst
Copy link
Collaborator

@enyst enyst commented Dec 29, 2025

Summary

This PR removes all reconciliation methods (resolve_diff_from_deserialized) and uses the provided Agent directly when restoring conversations. This is an alternative approach to issue #1451.

What Was Happening on Main

On main, when restoring a conversation, resolve_diff_from_deserialized would:

Override from runtime:

  • agent_context (skills, system_message_suffix, user_message_suffix, secrets)
  • llm secrets (api_key, aws credentials, litellm_extra_body)
  • condenser.llm secrets

Restore from persistence (and require exact match with runtime):

  • tools
  • mcp_config
  • filter_tools_regex
  • system_prompt_filename
  • security_policy_filename
  • system_prompt_kwargs
  • condenser (except its llm secrets)
  • llm config (model, temperature, etc.)

The final equality check meant users effectively couldn't change most Agent configuration between sessions.

What This PR Does

Removes reconciliation. The provided Agent is used directly - subject to limitations that would otherwise not work at all, such as, it has to be the same Agent class, or the same tools.

Users are now free to change Agent configuration between sessions:

  • llm (model, api_key, all settings)
  • mcp_config
  • filter_tools_regex
  • agent_context
  • system_prompt_filename
  • security_policy_filename
  • system_prompt_kwargs
  • condenser

Limitations:

  • tools
  • Agent's class/type

Execution Flow

New Conversation:

  1. Create ConversationState with the provided Agent (Pydantic validation happens here)
  2. Initialize EventLog for event storage
  3. Save initial base state to persistence
  4. Return the new state

Restored Conversation:

  1. Load persisted base_state.json (to get conversation metadata)
  2. Verify conversation ID matches
  3. Create ConversationState with the provided Agent (Pydantic validation happens here)
  4. Restore persisted conversation metadata (execution_status, confirmation_policy, etc.)
  5. Attach EventLog to load persisted events
  6. Save updated base state (with the provided Agent)
  7. Return the resumed state

Validation

Pydantic validation happens when creating instances (LLM, Agent, ConversationState) via the constructor.

Note on Tools

There's a case for checking that tools already used in the conversation history are still available. See issue #1533 for discussion.

Scope: LocalConversation Only

This PR only affects LocalConversation.

For RemoteConversation, the server always creates the Agent from the persisted meta.json - the client's Agent is ignored when restoring. Making RemoteConversation support Agent changes would require:

  1. Client sends new Agent config when attaching to existing conversation
  2. Server accepts and uses the new Agent config instead of persisted one

This is out of scope for this PR but could be a follow-up.

Closes #1451

Checklist

  • If the PR is changing/adding functionality, are there tests to reflect this?
  • If there is an example, have you run the example to make sure that it works?
  • If there are instructions on how to run the code, have you followed the instructions and made sure that it works?
  • If the feature is significant enough to require documentation, is there a PR open on the OpenHands/docs repository with the same branch name?
  • Is the github CI passing?

Agent Server images for this PR

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.12-nodejs22 Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:85336b2-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-85336b2-python \
  ghcr.io/openhands/agent-server:85336b2-python

All tags pushed for this build

ghcr.io/openhands/agent-server:85336b2-golang-amd64
ghcr.io/openhands/agent-server:85336b2-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:85336b2-golang-arm64
ghcr.io/openhands/agent-server:85336b2-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:85336b2-java-amd64
ghcr.io/openhands/agent-server:85336b2-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:85336b2-java-arm64
ghcr.io/openhands/agent-server:85336b2-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:85336b2-python-amd64
ghcr.io/openhands/agent-server:85336b2-nikolaik_s_python-nodejs_tag_python3.12-nodejs22-amd64
ghcr.io/openhands/agent-server:85336b2-python-arm64
ghcr.io/openhands/agent-server:85336b2-nikolaik_s_python-nodejs_tag_python3.12-nodejs22-arm64
ghcr.io/openhands/agent-server:85336b2-golang
ghcr.io/openhands/agent-server:85336b2-java
ghcr.io/openhands/agent-server:85336b2-python

About Multi-Architecture Support

  • Each variant tag (e.g., 85336b2-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 85336b2-python-amd64) are also available if needed

@github-actions
Copy link
Contributor

github-actions bot commented Dec 29, 2025

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/agent
   base.py1631888%164, 170, 189, 238–239, 250–252, 265, 273–274, 322, 342, 354, 391–392, 402–403
openhands-sdk/openhands/sdk/conversation
   state.py1612286%226, 256, 302–304, 320–321, 327, 333–336, 340, 346–349, 376, 394, 403, 418, 424
openhands-sdk/openhands/sdk/llm
   llm.py40215461%344, 349, 353, 357–358, 361, 365–366, 377–378, 380–381, 385, 402, 420–423, 470, 500–502, 523, 527, 542, 548–549, 573–574, 584, 609–614, 635–636, 639, 643, 655, 660–663, 672, 680–687, 691–694, 696, 709, 713–714, 716–717, 722–723, 725, 732, 735–740, 797–802, 859–860, 863–866, 908, 925, 979, 982, 985–993, 997–999, 1002, 1005–1007, 1014–1015, 1024, 1031–1033, 1037, 1039–1044, 1046–1063, 1066–1070, 1072–1073, 1079–1088
TOTAL14408680452% 

@enyst enyst force-pushed the openhands/remove-reconciliation-methods branch from 76b5add to 5b3198d Compare December 29, 2025 19:19
Remove all reconciliation methods (resolve_diff_from_deserialized) and
use the runtime agent directly when restoring conversations.

Key changes:
- LLM: Remove resolve_diff_from_deserialized method entirely
- AgentBase: Remove resolve_diff_from_deserialized method entirely
- ConversationState.create(): Use runtime agent directly, no compatibility
  checking. User is free to change LLM, tools, condenser, agent_context,
  etc. between sessions.

Execution flow for new conversation:
1. Create ConversationState with runtime agent
   (Pydantic validation happens here)
2. Initialize EventLog for event storage
3. Save initial base state to persistence
4. Return the new state

Execution flow for restored conversation:
1. Load persisted base_state.json (only to get conversation metadata)
2. Verify conversation ID matches
3. Create ConversationState with the runtime agent
   (Pydantic validation happens here - runtime agent is always used)
4. Restore persisted conversation metadata (execution_status, etc.)
5. Attach EventLog to load persisted events
6. Save updated base state (with runtime agent)
7. Return the resumed state

NOTE: There's a case for checking that tools already used in the
conversation history are still available - see issue #1533.

Closes #1451

Co-authored-by: openhands <openhands@all-hands.dev>
Reintroduce tools restriction from the original reconcile method:
- Add AgentBase.load(persisted) method that validates tools match
- Tools must match between runtime and persisted agents (they may have
  been used in conversation history)
- All other config (LLM, agent_context, condenser, etc.) can change freely

Update ConversationState.create() to use agent.load() on restore path.

Co-authored-by: openhands <openhands@all-hands.dev>
Copy link
Collaborator

@xingyaoww xingyaoww left a comment

Choose a reason for hiding this comment

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

LGTM! Just need to fix a few minor things!

resolved = agent.resolve_diff_from_deserialized(state.agent)
# Limit to the tools that match - they may have been used in conversation history.
# All other config (LLM, agent_context, condenser, etc.) can change freely.
agent.load(persisted_state.agent)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Shall we get the return value of this function?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done! Currently we don't return something else than self or ValueError, but just in case.

enyst and others added 4 commits December 31, 2025 17:48
Address review comments:
- Rename AgentBase.load() to AgentBase.verify() since it's a verification
  method, not a load method
- Update docstring to say 'Verify that we can resume...'
- Capture return value: verified_agent = agent.verify(persisted_state.agent)
- Update tests to use verify() instead of load()

Co-authored-by: openhands <openhands@all-hands.dev>
Address xingyaoww's review comment: instead of creating state from scratch,
load persisted state and update specific fields. This is more future-proof -
new fields will automatically be preserved.

Co-authored-by: openhands <openhands@all-hands.dev>
Load persisted state but override with runtime-provided values:
- agent (verified against persisted)
- workspace
- max_iterations
- stuck_detection

Keep from persisted state:
- id, persistence_dir, execution_status, confirmation_policy
- activated_knowledge_skills, blocked_actions, blocked_messages
- secret_registry

This gives the best of both approaches: future-proof for new fields
while respecting user-provided runtime configuration.

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
# Override with runtime-provided values
state.agent = verified_agent
state.workspace = workspace
state.max_iterations = max_iterations
Copy link
Collaborator Author

@enyst enyst Dec 31, 2025

Choose a reason for hiding this comment

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

@xingyaoww how does this look like? So we took all from persistent state: except for agent, where we just enforce some limitations; for max iterations, the idea is that if the user set "ok 700 from now on", it will be applied; as for workspace, idk, WDYT?
I'm not sure I understand why it was from persisted state, on main. Wouldn't that prevent "teleporting" a conversation to remote and back to local?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ah, maybe I'm confused. @OpenHands what exactly does the "workspace" variable here in ConversationState refer to, and what possible values it has? Trace all execution paths it's on.

Copy link

Choose a reason for hiding this comment

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

I'm on it! enyst can track my progress at all-hands.dev

Copy link

Choose a reason for hiding this comment

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

Answer: What is the workspace variable in ConversationState?

The workspace field in ConversationState (line 61-64 in state.py) is of type BaseWorkspace:

workspace: BaseWorkspace = Field(
    ...,
    description="Working directory for agent operations and tool execution",
)

What it represents

BaseWorkspace is an abstract base class that provides a sandboxed environment where agents can execute commands, read/write files, and perform other operations. It has a working_dir field that specifies the root directory for agent operations.

Possible Values (Implementations)

  1. LocalWorkspace - Operates directly on the host filesystem (for development/testing)
  2. RemoteWorkspace - Connects to an OpenHands agent server via HTTP
  3. DockerWorkspace - Creates and manages a Docker container running an agent server
  4. DockerDevWorkspace - Extends DockerWorkspace, builds images on-the-fly
  5. OpenHandsCloudWorkspace - Connects to OpenHands Cloud (app.all-hands.dev)
  6. APIRemoteWorkspace - Uses OpenHands runtime API (runtime.all-hands.dev)

Execution Paths

  1. ConversationState.create() - On restore, workspace is always overridden from the runtime-provided value (line 238: state.workspace = workspace)

  2. LocalConversation - Accepts str | Path | LocalWorkspace, converts to LocalWorkspace if needed, uses workspace.working_dir for directory creation and hook initialization

  3. RemoteConversation - Accepts RemoteWorkspace, uses workspace.host and workspace.api_key for HTTP/WebSocket connections

  4. Tool Execution - Tools access conv_state.workspace.working_dir to set working directories and resolve file paths

  5. Agent Server - Gets workspace from stored config, asserts it's LocalWorkspace, creates the working directory

Key Insight for Your Question

The workspace is NOT persisted from the previous session - it's always taken from the runtime-provided value. This is intentional because:

  • The workspace represents the current execution environment
  • It may change between sessions (different machine, different container)
  • It contains runtime-specific information (host URLs, API keys for remote workspaces)

This design does support "teleporting" a conversation between local and remote environments - the conversation history is preserved, but the workspace is always the one provided at runtime. So moving a conversation from remote to local (or vice versa) should work fine, as long as the new workspace has access to the same files the agent was working with.

View full conversation

@OpenHands OpenHands deleted a comment from openhands-ai bot Dec 31, 2025
Test coverage for:
- Runtime values used on resume: workspace, max_iterations
- Persisted values preserved: execution_status, stuck_detection,
  blocked_actions, blocked_messages
- Stats reset on resume (fresh session)
- Conversation ID mismatch raises error

Co-authored-by: openhands <openhands@all-hands.dev>
@enyst enyst marked this pull request as ready for review December 31, 2025 18:47
@enyst
Copy link
Collaborator Author

enyst commented Dec 31, 2025

@OpenHands I look at the diff of this PR and I don't see all I expected to see. For example, in llm.py we have a class var named OVERRIDE... something, which is, I think, only used in the reconciliation method we removed from LLM. Please track it down and confirm what I said, and if it's unused let's clean it up.

Verify if in the other files in this PR, e.g. state or agentbase, we have something similar and do the same.

Review the code and clean it up from such redundancies or similar.

@openhands-ai
Copy link

openhands-ai bot commented Dec 31, 2025

I'm on it! enyst can track my progress at all-hands.dev

Co-authored-by: openhands <openhands@all-hands.dev>
@openhands-ai
Copy link

openhands-ai bot commented Dec 31, 2025

Final summary (new since last summary)

Double-check: request coverage

  • Tracked down LLM.OVERRIDE_ON_SERIALIZE and confirmed it was only used by the removed reconciliation method (LLM.resolve_diff_from_deserialized).
  • Confirmed it was unused after the refactor (no other references in the repo).
  • Searched for similar reconciliation/override leftovers in the other PR-touched files mentioned (agent/base.py, conversation/state.py) and found no comparable unused constants/fields to remove.
  • Removed the redundancy (the now-unused class var + related comments) from llm.py.
  • Ran pre-commit on the edited file and it passed.

Conciseness check

  • Changes are minimal and directly address the redundancy: 13 lines deleted in one file.
  • No unrelated formatting or refactors.

Git / delivery

  • Commit created with the required co-author line.
  • Changes pushed to the existing branch (openhands/remove-reconciliation-methods) to update the PR.

Diff recap (this update only):

  • openhands-sdk/openhands/sdk/llm/llm.py: removed unused OVERRIDE_ON_SERIALIZE class var block (13 deletions).

View full conversation

Co-authored-by: openhands <openhands@all-hands.dev>
@enyst
Copy link
Collaborator Author

enyst commented Jan 3, 2026

@OpenHands Understand the goal that this PR aims to achieve. We have two tests files, modified in this PR, which are no longer named appropriately since we removed reconciliation and replace it with "verification". Rename test_agent_reconciliation.py to test_agent_loading.py (or verification if you prefer, but maybe loading will do, wdyt?)

Review the tests in tests/sdk/llm/test_llm_reconciliation.py and if they're not duplicated in other tests/sdk llm-related files and they're still useful (are they?), then move them in a test llm file.

@openhands-ai
Copy link

openhands-ai bot commented Jan 3, 2026

I'm on it! enyst can track my progress at all-hands.dev

Co-authored-by: openhands <openhands@all-hands.dev>
@openhands-ai
Copy link

openhands-ai bot commented Jan 3, 2026

Final summary (since the previous update)

✅ Requests addressed (checklist)

  • Renamed the misnamed reconciliation-focused agent test file:
    • tests/cross/test_agent_reconciliation.pytests/cross/test_agent_loading.py
    • Updated the module docstring to reflect “loading / restart” semantics.
  • Reviewed tests/sdk/llm/test_llm_reconciliation.py for usefulness + duplication across tests/sdk/llm.
    • Determined these tests were not truly reconciliation-related and largely overlapped with existing serialization coverage.
    • Removed tests/sdk/llm/test_llm_reconciliation.py instead of moving it, to keep the suite concise and avoid redundant coverage.
  • Followed repo workflow:
    • Ran pre-commit on the touched files.
    • Ran targeted pytest for the impacted tests.
  • Committed with required trailer and pushed to openhands/remove-reconciliation-methods.

🔍 Conciseness / diff sanity

  • No extraneous changes beyond:
    • one file rename + docstring tweak
    • one redundant test file deletion
  • Working tree is clean and branch is already pushed (no further action needed).

🧾 Changes included in the pushed commit

  • tests/cross/test_agent_loading.py (renamed from test_agent_reconciliation.py)
  • tests/sdk/llm/test_llm_reconciliation.py (deleted)

View full conversation

Co-authored-by: openhands <openhands@all-hands.dev>
@enyst enyst requested a review from xingyaoww January 3, 2026 22:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposal: agent as composed of immutable instances

3 participants