Skip to content

Commit 031c02e

Browse files
authored
Merge branch 'main' into vasco/tmux-panels
2 parents cb20bd5 + e2459a3 commit 031c02e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1826
-480
lines changed

.github/workflows/version-bump-prs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ jobs:
337337
echo "- [OpenHands-CLI](https://github.com/OpenHands/openhands-cli/pulls?q=is%3Apr+bump-sdk-$VERSION)" >> $GITHUB_STEP_SUMMARY
338338
339339
- name: Notify Slack
340-
uses: slackapi/slack-github-action@v2.1.1
340+
uses: slackapi/slack-github-action@v3.0.1
341341
with:
342342
method: chat.postMessage
343343
token: ${{ secrets.SLACK_BOT_TOKEN }}

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ When reviewing code, provide constructive feedback:
106106
- `SettingsFieldSchema` intentionally does not export a `required` flag. If a consumer needs nullability semantics, inspect the underlying Python typing rather than inferring from SDK defaults.
107107
- `AgentSettings.tools` is part of the exported settings schema so the schema stays aligned with the settings payload that round-trips through `AgentSettings` and drives `create_agent()`.
108108
- `AgentSettings.mcp_config` now uses FastMCP's typed `MCPConfig` at runtime. When serializing settings back to plain data (e.g. `model_dump()` or `create_agent()`), keep the output compact with `exclude_none=True, exclude_defaults=True` so callers still see the familiar `.mcp.json`-style dict shape.
109+
- Anthropic malformed tool-use/tool-result history errors (for example, missing or duplicated ``tool_result`` blocks) are intentionally mapped to a dedicated `LLMMalformedConversationHistoryError` and caught separately in `Agent.step()`, so recovery can still use condensation while logs preserve that this was malformed history rather than a true context-window overflow.
109110
- AgentSkills progressive disclosure goes through `AgentContext.get_system_message_suffix()` into `<available_skills>`, and `openhands.sdk.context.skills.to_prompt()` truncates each prompt description to 1024 characters because the AgentSkills specification caps `description` at 1-1024 characters.
110111
- Workspace-wide uv resolver guardrails belong in the repository root `[tool.uv]` table. When `exclude-newer` is configured there, `uv lock` persists it into the root `uv.lock` `[options]` section as both an absolute cutoff and `exclude-newer-span`, and `uv sync --frozen` continues to use that locked workspace state.
111112
- `pr-review-by-openhands` delegates to `OpenHands/extensions/plugins/pr-review@main`. Repo-specific reviewer instructions live in `.agents/skills/custom-codereview-guide.md`, and because task-trigger matching is substring-based, that `/codereview` skill is also auto-injected for the workflow's `/codereview-roasted` prompt.

examples/01_standalone_sdk/40_acp_agent_example.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from openhands.sdk.conversation import Conversation
2020

2121

22-
agent = ACPAgent(acp_command=["npx", "-y", "@zed-industries/claude-agent-acp"])
22+
agent = ACPAgent(acp_command=["npx", "-y", "@agentclientprotocol/claude-agent-acp"])
2323

2424
try:
2525
cwd = os.getcwd()

openhands-agent-server/openhands/agent_server/api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
mark_initialization_complete,
3636
server_details_router,
3737
)
38+
from openhands.agent_server.settings_router import settings_router
3839
from openhands.agent_server.skills_router import skills_router
3940
from openhands.agent_server.sockets import sockets_router
4041
from openhands.agent_server.tool_preload_service import get_tool_preload_service
@@ -213,6 +214,7 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
213214
api_router.include_router(skills_router)
214215
api_router.include_router(hooks_router)
215216
api_router.include_router(llm_router)
217+
api_router.include_router(settings_router)
216218
app.include_router(api_router)
217219
app.include_router(sockets_router)
218220

openhands-agent-server/openhands/agent_server/docker/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ RUN set -eux; \
113113
PATH="$ACP_NODE_DIR/bin:$PATH"; \
114114
node --version; \
115115
npm install -g \
116-
@zed-industries/claude-agent-acp \
116+
@agentclientprotocol/claude-agent-acp \
117117
@zed-industries/codex-acp \
118118
@google/gemini-cli; \
119119
# Create wrappers in /usr/local/bin that prepend ACP's Node 22 to PATH.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from functools import lru_cache
2+
3+
from fastapi import APIRouter
4+
5+
from openhands.sdk.settings import AgentSettings, SettingsSchema
6+
7+
8+
settings_router = APIRouter(prefix="/settings", tags=["Settings"])
9+
10+
11+
@lru_cache(maxsize=1)
12+
def _get_agent_settings_schema() -> SettingsSchema:
13+
return AgentSettings.export_schema()
14+
15+
16+
@settings_router.get("/schema", response_model=SettingsSchema)
17+
async def get_agent_settings_schema() -> SettingsSchema:
18+
"""Return the schema used to render AgentSettings-based settings forms."""
19+
return _get_agent_settings_schema()

openhands-agent-server/openhands/agent_server/tool_router.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@
33
from fastapi import APIRouter
44

55
from openhands.sdk.tool.registry import list_registered_tools
6-
from openhands.tools.preset.default import register_default_tools
6+
from openhands.tools.preset.default import (
7+
register_builtins_agents,
8+
register_default_tools,
9+
)
710
from openhands.tools.preset.gemini import register_gemini_tools
811
from openhands.tools.preset.planning import register_planning_tools
912

1013

1114
tool_router = APIRouter(prefix="/tools", tags=["Tools"])
1215
register_default_tools(enable_browser=True)
16+
register_builtins_agents(enable_browser=True)
1317
register_gemini_tools(enable_browser=True)
1418
register_planning_tools()
1519

openhands-sdk/openhands/sdk/agent/acp_agent.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ def __init__(self) -> None:
297297
# signal that the ACP subprocess is still actively working. Set by
298298
# ACPAgent.step() to keep the agent-server's idle timer alive.
299299
self.on_activity: Any = None # Callable[[], None] | None
300-
self._last_activity_signal: float = 0.0
300+
self._last_activity_signal: float = float("-inf")
301301
# Telemetry state from UsageUpdate (persists across turns)
302302
self._last_cost: float = 0.0 # last cumulative cost seen
303303
self._last_cost_by_session: dict[str, float] = {}
@@ -537,7 +537,7 @@ class ACPAgent(AgentBase):
537537
...,
538538
description=(
539539
"Command to start the ACP server, e.g."
540-
" ['npx', '-y', '@zed-industries/claude-agent-acp']"
540+
" ['npx', '-y', '@agentclientprotocol/claude-agent-acp']"
541541
),
542542
)
543543
acp_args: list[str] = Field(

openhands-sdk/openhands/sdk/agent/agent.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
from openhands.sdk.llm.exceptions import (
5252
FunctionCallValidationError,
5353
LLMContextWindowExceedError,
54+
LLMMalformedConversationHistoryError,
5455
)
5556
from openhands.sdk.logger import get_logger
5657
from openhands.sdk.observability.laminar import (
@@ -516,6 +517,30 @@ def step(
516517
)
517518
on_event(error_message)
518519
return
520+
except LLMMalformedConversationHistoryError as e:
521+
# The provider rejected the current message history as structurally
522+
# invalid (for example, broken tool_use/tool_result pairing). Route
523+
# this into condensation recovery, but keep the logs distinct from
524+
# true context-window exhaustion so upstream event-stream bugs remain
525+
# visible.
526+
if (
527+
self.condenser is not None
528+
and self.condenser.handles_condensation_requests()
529+
):
530+
logger.warning(
531+
"LLM raised malformed conversation history error, "
532+
"triggering condensation retry with condensed history: "
533+
f"{e}"
534+
)
535+
on_event(CondensationRequest())
536+
return
537+
logger.warning(
538+
"LLM raised malformed conversation history error but no "
539+
"condenser can handle condensation requests. This usually "
540+
"indicates an upstream event-stream or resume bug: "
541+
f"{e}"
542+
)
543+
raise e
519544
except LLMContextWindowExceedError as e:
520545
# If condenser is available and handles requests, trigger condensation
521546
if (
@@ -820,6 +845,7 @@ def _get_action_event(
820845

821846
# Validate arguments
822847
security_risk: risk.SecurityRisk = risk.SecurityRisk.UNKNOWN
848+
parsed_args: dict | None = None
823849
try:
824850
# Try parsing arguments as-is first. Raw newlines / tabs are
825851
# legal JSON whitespace and many models emit them between tokens
@@ -828,13 +854,14 @@ def _get_action_event(
828854
# Fall back to sanitization only when the raw string is invalid
829855
# (handles models that emit raw control chars *inside* strings).
830856
try:
831-
arguments = json.loads(tool_call.arguments)
857+
parsed_args = json.loads(tool_call.arguments)
832858
except json.JSONDecodeError:
833859
sanitized_args = sanitize_json_control_chars(tool_call.arguments)
834-
arguments = json.loads(sanitized_args)
860+
parsed_args = json.loads(sanitized_args)
835861

836862
# Fix malformed arguments (e.g., JSON strings for list/dict fields)
837-
arguments = fix_malformed_tool_arguments(arguments, tool.action_type)
863+
assert isinstance(parsed_args, dict)
864+
arguments = fix_malformed_tool_arguments(parsed_args, tool.action_type)
838865
security_risk = self._extract_security_risk(
839866
arguments,
840867
tool.name,
@@ -849,10 +876,14 @@ def _get_action_event(
849876

850877
action: Action = tool.action_from_arguments(arguments)
851878
except (json.JSONDecodeError, ValidationError, ValueError) as e:
852-
err = (
853-
f"Error validating args {tool_call.arguments} for tool "
854-
f"'{tool.name}': {e}"
879+
# Build concise error message with parameter names only (not values)
880+
keys = list(parsed_args.keys()) if isinstance(parsed_args, dict) else None
881+
params = (
882+
f"Parameters provided: {keys}"
883+
if keys is not None
884+
else "Arguments: unparseable JSON"
855885
)
886+
err = f"Error validating tool '{tool.name}': {e}. {params}"
856887
# Persist assistant function_call so next turn has matching call_id
857888
tc_event = ActionEvent(
858889
source="agent",
Lines changed: 58 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

33
from collections.abc import Sequence
4-
from functools import cached_property
54
from logging import getLogger
65
from typing import overload
76

@@ -28,18 +27,15 @@ class View(BaseModel):
2827
in deciding whether further condensation is needed.
2928
"""
3029

31-
events: list[LLMConvertibleEvent]
30+
events: list[LLMConvertibleEvent] = Field(default_factory=list)
3231

3332
unhandled_condensation_request: bool = False
3433
"""Whether there is an unhandled condensation request in the view."""
3534

36-
condensations: list[Condensation] = Field(default_factory=list)
37-
"""A list of condensations that were processed to produce the view."""
38-
3935
def __len__(self) -> int:
4036
return len(self.events)
4137

42-
@cached_property
38+
@property
4339
def manipulation_indices(self) -> ManipulationIndices:
4440
"""The indices where the view events can be manipulated without violating the
4541
properties expected by LLM APIs.
@@ -75,26 +71,10 @@ def __getitem__(
7571
else:
7672
raise ValueError(f"Invalid key type: {type(key)}")
7773

78-
@staticmethod
79-
def unhandled_condensation_request_exists(
80-
events: Sequence[Event],
81-
) -> bool:
82-
"""Check if there is an unhandled condensation request in the list of events.
83-
84-
An unhandled condensation request is defined as a CondensationRequest event
85-
that appears after the most recent Condensation event in the list.
86-
"""
87-
for event in reversed(events):
88-
if isinstance(event, Condensation):
89-
return False
90-
if isinstance(event, CondensationRequest):
91-
return True
92-
return False
93-
94-
@staticmethod
9574
def enforce_properties(
96-
current_view_events: list[LLMConvertibleEvent], all_events: Sequence[Event]
97-
) -> list[LLMConvertibleEvent]:
75+
self,
76+
all_events: Sequence[Event],
77+
) -> None:
9878
"""Enforce all properties on the list of current view events.
9979
10080
Repeatedly applies each property's enforcement mechanism until the list of view
@@ -103,62 +83,78 @@ def enforce_properties(
10383
Since enforcement is intended as a fallback to inductively maintaining the
10484
properties via the associated manipulation indices, any time a property must be
10585
enforced a warning is logged.
86+
87+
Modifies the view in-place.
10688
"""
10789
for property in ALL_PROPERTIES:
108-
events_to_forget = property.enforce(current_view_events, all_events)
90+
events_to_forget = property.enforce(self.events, all_events)
10991
if events_to_forget:
11092
logger.warning(
11193
f"Property {property.__class__} enforced, "
11294
f"{len(events_to_forget)} events dropped."
11395
)
114-
return View.enforce_properties(
115-
[
116-
event
117-
for event in current_view_events
118-
if event.id not in events_to_forget
119-
],
120-
all_events,
121-
)
122-
return current_view_events
12396

124-
@staticmethod
125-
def from_events(events: Sequence[Event]) -> View:
126-
"""Create a view from a list of events, respecting the semantics of any
127-
condensation events.
128-
"""
129-
output: list[LLMConvertibleEvent] = []
130-
condensations: list[Condensation] = []
97+
self.events = [
98+
event for event in self.events if event.id not in events_to_forget
99+
]
100+
break
131101

132-
# Generate the LLMConvertibleEvent objects the agent can send to the LLM by
133-
# removing un-sendable events and applying condensations in order.
134-
for event in events:
135-
# By the time we come across a Condensation event, the output list should
102+
# If we get all the way through the loop without hitting a break, that means no
103+
# properties needed to be enforced and we can keep the view as-is.
104+
else:
105+
return
106+
107+
# If we did hit a break in the loop, a property applied and now we need to check
108+
# all the properties again to see if any are unblocked.
109+
self.enforce_properties(all_events)
110+
111+
def append_event(self, event: Event) -> None:
112+
"""Append an event to the end of the view, applying any condensation semantics
113+
as we do.
114+
115+
Modifies the view in-place.
116+
"""
117+
match event:
118+
# By the time we come across a Condensation event, the event list should
136119
# already reflect the events seen by the agent up to that point. We can
137-
# therefore apply the condensation semantics directly to the output list.
138-
if isinstance(event, Condensation):
139-
condensations.append(event)
140-
output = event.apply(output)
120+
# therefore apply the condensation semantics directly to the stored events.
121+
case Condensation():
122+
self.events = event.apply(self.events)
123+
self.unhandled_condensation_request = False
124+
125+
case CondensationRequest():
126+
self.unhandled_condensation_request = True
141127

142-
elif isinstance(event, LLMConvertibleEvent):
143-
output.append(event)
128+
case LLMConvertibleEvent():
129+
self.events.append(event)
144130

145131
# If the event isn't related to condensation and isn't LLMConvertible, it
146132
# should not be in the resulting view. Examples include certain internal
147133
# events used for state tracking that the LLM does not need to see -- see,
148134
# for example, ConversationStateUpdateEvent, PauseEvent, and (relevant here)
149135
# CondensationRequest.
150-
else:
136+
case _:
151137
logger.debug(
152138
f"Skipping non-LLMConvertibleEvent of type {type(event)} "
153-
"in View.from_events"
139+
"in View.append_event"
154140
)
155141

156-
output = View.enforce_properties(output, events)
142+
@staticmethod
143+
def from_events(events: Sequence[Event]) -> View:
144+
"""Create a view from a list of events, respecting the semantics of any
145+
condensation events.
146+
"""
147+
result: View = View()
148+
149+
# Generate the LLMConvertibleEvent objects the agent can send to the LLM by
150+
# adding them one at a time to the result view. This ensures condensations are
151+
# applied in the order they were generated and condensation requests are
152+
# appropriately tracked.
153+
for event in events:
154+
result.append_event(event)
155+
156+
# Once all the events are loaded enforce the relevant properties to ensure
157+
# the construction was done properly.
158+
result.enforce_properties(events)
157159

158-
return View(
159-
events=output,
160-
unhandled_condensation_request=View.unhandled_condensation_request_exists(
161-
events
162-
),
163-
condensations=condensations,
164-
)
160+
return result

0 commit comments

Comments
 (0)