openhands-sdk 1.7.2__py3-none-any.whl → 1.7.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openhands/sdk/agent/agent.py +26 -10
- openhands/sdk/agent/base.py +53 -15
- openhands/sdk/context/condenser/__init__.py +2 -0
- openhands/sdk/context/condenser/base.py +59 -8
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +42 -4
- openhands/sdk/context/skills/skill.py +336 -118
- openhands/sdk/context/view.py +2 -0
- openhands/sdk/conversation/impl/remote_conversation.py +110 -29
- openhands/sdk/conversation/state.py +9 -5
- openhands/sdk/llm/llm.py +1 -2
- openhands/sdk/llm/options/chat_options.py +4 -1
- openhands/sdk/llm/utils/model_features.py +1 -0
- openhands/sdk/llm/utils/verified_models.py +1 -1
- openhands/sdk/mcp/tool.py +3 -1
- openhands/sdk/tool/registry.py +23 -0
- openhands/sdk/tool/schema.py +6 -3
- openhands/sdk/utils/models.py +198 -472
- openhands/sdk/workspace/base.py +22 -0
- openhands/sdk/workspace/local.py +16 -0
- {openhands_sdk-1.7.2.dist-info → openhands_sdk-1.7.4.dist-info}/METADATA +2 -2
- {openhands_sdk-1.7.2.dist-info → openhands_sdk-1.7.4.dist-info}/RECORD +23 -23
- {openhands_sdk-1.7.2.dist-info → openhands_sdk-1.7.4.dist-info}/WHEEL +0 -0
- {openhands_sdk-1.7.2.dist-info → openhands_sdk-1.7.4.dist-info}/top_level.txt +0 -0
|
@@ -28,6 +28,7 @@ from openhands.sdk.conversation.visualizer import (
|
|
|
28
28
|
DefaultConversationVisualizer,
|
|
29
29
|
)
|
|
30
30
|
from openhands.sdk.event.base import Event
|
|
31
|
+
from openhands.sdk.event.conversation_error import ConversationErrorEvent
|
|
31
32
|
from openhands.sdk.event.conversation_state import (
|
|
32
33
|
FULL_STATE_KEY,
|
|
33
34
|
ConversationStateUpdateEvent,
|
|
@@ -488,7 +489,28 @@ class RemoteConversation(BaseConversation):
|
|
|
488
489
|
self._hook_processor = None
|
|
489
490
|
self._cleanup_initiated = False
|
|
490
491
|
|
|
491
|
-
|
|
492
|
+
should_create = conversation_id is None
|
|
493
|
+
if conversation_id is not None:
|
|
494
|
+
# Try to attach to existing conversation
|
|
495
|
+
resp = _send_request(
|
|
496
|
+
self._client,
|
|
497
|
+
"GET",
|
|
498
|
+
f"/api/conversations/{conversation_id}",
|
|
499
|
+
acceptable_status_codes={404},
|
|
500
|
+
)
|
|
501
|
+
if resp.status_code == 404:
|
|
502
|
+
# Conversation doesn't exist, we'll create it
|
|
503
|
+
should_create = True
|
|
504
|
+
else:
|
|
505
|
+
# Conversation exists, use the provided ID
|
|
506
|
+
self._id = conversation_id
|
|
507
|
+
|
|
508
|
+
if should_create:
|
|
509
|
+
# Import here to avoid circular imports
|
|
510
|
+
from openhands.sdk.tool.registry import get_tool_module_qualnames
|
|
511
|
+
|
|
512
|
+
tool_qualnames = get_tool_module_qualnames()
|
|
513
|
+
logger.debug(f"Sending tool_module_qualnames to server: {tool_qualnames}")
|
|
492
514
|
payload = {
|
|
493
515
|
"agent": agent.model_dump(
|
|
494
516
|
mode="json", context={"expose_secrets": True}
|
|
@@ -500,6 +522,8 @@ class RemoteConversation(BaseConversation):
|
|
|
500
522
|
"workspace": LocalWorkspace(
|
|
501
523
|
working_dir=self.workspace.working_dir
|
|
502
524
|
).model_dump(),
|
|
525
|
+
# Include tool module qualnames for dynamic registration on server
|
|
526
|
+
"tool_module_qualnames": tool_qualnames,
|
|
503
527
|
}
|
|
504
528
|
if stuck_detection_thresholds is not None:
|
|
505
529
|
# Convert to StuckDetectionThresholds if dict, then serialize
|
|
@@ -510,6 +534,9 @@ class RemoteConversation(BaseConversation):
|
|
|
510
534
|
else:
|
|
511
535
|
threshold_config = stuck_detection_thresholds
|
|
512
536
|
payload["stuck_detection_thresholds"] = threshold_config.model_dump()
|
|
537
|
+
# Include conversation_id if provided (for creating with specific ID)
|
|
538
|
+
if conversation_id is not None:
|
|
539
|
+
payload["conversation_id"] = str(conversation_id)
|
|
513
540
|
resp = _send_request(
|
|
514
541
|
self._client, "POST", "/api/conversations", json=payload
|
|
515
542
|
)
|
|
@@ -521,11 +548,6 @@ class RemoteConversation(BaseConversation):
|
|
|
521
548
|
"Invalid response from server: missing conversation id"
|
|
522
549
|
)
|
|
523
550
|
self._id = uuid.UUID(cid)
|
|
524
|
-
else:
|
|
525
|
-
# Attach to existing
|
|
526
|
-
self._id = conversation_id
|
|
527
|
-
# Validate it exists
|
|
528
|
-
_send_request(self._client, "GET", f"/api/conversations/{self._id}")
|
|
529
551
|
|
|
530
552
|
# Initialize the remote state
|
|
531
553
|
self._state = RemoteState(self._client, str(self._id))
|
|
@@ -711,12 +733,8 @@ class RemoteConversation(BaseConversation):
|
|
|
711
733
|
|
|
712
734
|
if resp.status_code == 409:
|
|
713
735
|
logger.info("Conversation is already running; skipping run trigger")
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
self._wait_for_run_completion(poll_interval, timeout)
|
|
717
|
-
return
|
|
718
|
-
|
|
719
|
-
logger.info(f"run() triggered successfully: {resp}")
|
|
736
|
+
else:
|
|
737
|
+
logger.info(f"run() triggered successfully: {resp}")
|
|
720
738
|
|
|
721
739
|
if blocking:
|
|
722
740
|
self._wait_for_run_completion(poll_interval, timeout)
|
|
@@ -733,7 +751,9 @@ class RemoteConversation(BaseConversation):
|
|
|
733
751
|
timeout: Maximum time in seconds to wait.
|
|
734
752
|
|
|
735
753
|
Raises:
|
|
736
|
-
ConversationRunError: If the
|
|
754
|
+
ConversationRunError: If the run fails, the conversation disappears,
|
|
755
|
+
or the wait times out. Transient network errors, 429s, and 5xx
|
|
756
|
+
responses are retried until timeout.
|
|
737
757
|
"""
|
|
738
758
|
start_time = time.monotonic()
|
|
739
759
|
|
|
@@ -749,28 +769,89 @@ class RemoteConversation(BaseConversation):
|
|
|
749
769
|
)
|
|
750
770
|
|
|
751
771
|
try:
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
)
|
|
758
|
-
info = resp.json()
|
|
759
|
-
status = info.get("execution_status")
|
|
760
|
-
|
|
761
|
-
if status != ConversationExecutionStatus.RUNNING.value:
|
|
772
|
+
status = self._poll_status_once()
|
|
773
|
+
except Exception as exc:
|
|
774
|
+
self._handle_poll_exception(exc)
|
|
775
|
+
else:
|
|
776
|
+
if self._handle_conversation_status(status):
|
|
762
777
|
logger.info(
|
|
763
|
-
|
|
778
|
+
"Run completed with status: %s (elapsed: %.1fs)",
|
|
779
|
+
status,
|
|
780
|
+
elapsed,
|
|
764
781
|
)
|
|
765
782
|
return
|
|
766
783
|
|
|
767
|
-
except Exception as e:
|
|
768
|
-
# Log but continue polling - transient network errors shouldn't
|
|
769
|
-
# stop us from waiting for the run to complete
|
|
770
|
-
logger.warning(f"Error polling status (will retry): {e}")
|
|
771
|
-
|
|
772
784
|
time.sleep(poll_interval)
|
|
773
785
|
|
|
786
|
+
def _poll_status_once(self) -> str | None:
|
|
787
|
+
"""Fetch the current execution status from the remote conversation."""
|
|
788
|
+
resp = _send_request(
|
|
789
|
+
self._client,
|
|
790
|
+
"GET",
|
|
791
|
+
f"/api/conversations/{self._id}",
|
|
792
|
+
timeout=30,
|
|
793
|
+
)
|
|
794
|
+
info = resp.json()
|
|
795
|
+
return info.get("execution_status")
|
|
796
|
+
|
|
797
|
+
def _handle_conversation_status(self, status: str | None) -> bool:
|
|
798
|
+
"""Handle non-running statuses; return True if the run is complete."""
|
|
799
|
+
if status == ConversationExecutionStatus.RUNNING.value:
|
|
800
|
+
return False
|
|
801
|
+
if status == ConversationExecutionStatus.ERROR.value:
|
|
802
|
+
detail = self._get_last_error_detail()
|
|
803
|
+
raise ConversationRunError(
|
|
804
|
+
self._id,
|
|
805
|
+
RuntimeError(detail or "Remote conversation ended with error"),
|
|
806
|
+
)
|
|
807
|
+
if status == ConversationExecutionStatus.STUCK.value:
|
|
808
|
+
raise ConversationRunError(
|
|
809
|
+
self._id,
|
|
810
|
+
RuntimeError("Remote conversation got stuck"),
|
|
811
|
+
)
|
|
812
|
+
return True
|
|
813
|
+
|
|
814
|
+
def _handle_poll_exception(self, exc: Exception) -> None:
|
|
815
|
+
"""Classify polling exceptions into retryable vs terminal failures."""
|
|
816
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
817
|
+
status_code = exc.response.status_code
|
|
818
|
+
reason = exc.response.reason_phrase
|
|
819
|
+
if status_code == 404:
|
|
820
|
+
raise ConversationRunError(
|
|
821
|
+
self._id,
|
|
822
|
+
RuntimeError(
|
|
823
|
+
"Remote conversation not found (404). "
|
|
824
|
+
"The runtime may have been deleted."
|
|
825
|
+
),
|
|
826
|
+
) from exc
|
|
827
|
+
if 400 <= status_code < 500 and status_code != 429:
|
|
828
|
+
raise ConversationRunError(
|
|
829
|
+
self._id,
|
|
830
|
+
RuntimeError(f"Polling failed with HTTP {status_code} {reason}"),
|
|
831
|
+
) from exc
|
|
832
|
+
logger.warning(
|
|
833
|
+
"Error polling status (will retry): HTTP %d %s",
|
|
834
|
+
status_code,
|
|
835
|
+
reason,
|
|
836
|
+
)
|
|
837
|
+
return
|
|
838
|
+
if isinstance(exc, httpx.RequestError):
|
|
839
|
+
logger.warning(f"Error polling status (will retry): {exc}")
|
|
840
|
+
return
|
|
841
|
+
raise ConversationRunError(self._id, exc) from exc
|
|
842
|
+
|
|
843
|
+
def _get_last_error_detail(self) -> str | None:
|
|
844
|
+
"""Return the most recent ConversationErrorEvent detail, if available."""
|
|
845
|
+
events = self._state.events
|
|
846
|
+
for idx in range(len(events) - 1, -1, -1):
|
|
847
|
+
event = events[idx]
|
|
848
|
+
if isinstance(event, ConversationErrorEvent):
|
|
849
|
+
detail = event.detail.strip()
|
|
850
|
+
code = event.code.strip()
|
|
851
|
+
if detail and code:
|
|
852
|
+
return f"{code}: {detail}"
|
|
853
|
+
return detail or code or None
|
|
854
|
+
|
|
774
855
|
def set_confirmation_policy(self, policy: ConfirmationPolicyBase) -> None:
|
|
775
856
|
payload = {"policy": policy.model_dump()}
|
|
776
857
|
_send_request(
|
|
@@ -133,7 +133,6 @@ class ConversationState(OpenHandsModel):
|
|
|
133
133
|
default_factory=FIFOLock
|
|
134
134
|
) # FIFO lock for thread safety
|
|
135
135
|
|
|
136
|
-
# ===== Public "events" facade (Sequence[Event]) =====
|
|
137
136
|
@property
|
|
138
137
|
def events(self) -> EventLog:
|
|
139
138
|
return self._events
|
|
@@ -200,12 +199,17 @@ class ConversationState(OpenHandsModel):
|
|
|
200
199
|
f"but persisted state has {state.id}"
|
|
201
200
|
)
|
|
202
201
|
|
|
203
|
-
#
|
|
204
|
-
resolved = agent.resolve_diff_from_deserialized(state.agent)
|
|
205
|
-
|
|
206
|
-
# Attach runtime handles and commit reconciled agent (may autosave)
|
|
202
|
+
# Attach event log early so we can read history
|
|
207
203
|
state._fs = file_store
|
|
208
204
|
state._events = EventLog(file_store, dir_path=EVENTS_DIR)
|
|
205
|
+
|
|
206
|
+
# Reconcile agent config with deserialized one
|
|
207
|
+
# Pass event log so tool usage can be checked on-the-fly if needed
|
|
208
|
+
resolved = agent.resolve_diff_from_deserialized(
|
|
209
|
+
state.agent, events=state._events
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Commit reconciled agent (may autosave)
|
|
209
213
|
state._autosave_enabled = True
|
|
210
214
|
state.agent = resolved
|
|
211
215
|
|
openhands/sdk/llm/llm.py
CHANGED
|
@@ -158,7 +158,6 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
|
|
|
158
158
|
top_p: float | None = Field(default=1.0, ge=0, le=1)
|
|
159
159
|
top_k: float | None = Field(default=None, ge=0)
|
|
160
160
|
|
|
161
|
-
custom_llm_provider: str | None = Field(default=None)
|
|
162
161
|
max_input_tokens: int | None = Field(
|
|
163
162
|
default=None,
|
|
164
163
|
ge=1,
|
|
@@ -342,7 +341,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
|
|
|
342
341
|
_telemetry: Telemetry | None = PrivateAttr(default=None)
|
|
343
342
|
|
|
344
343
|
model_config: ClassVar[ConfigDict] = ConfigDict(
|
|
345
|
-
extra="
|
|
344
|
+
extra="ignore", arbitrary_types_allowed=True
|
|
346
345
|
)
|
|
347
346
|
|
|
348
347
|
# =========================================================================
|
|
@@ -51,9 +51,12 @@ def select_chat_options(
|
|
|
51
51
|
# Extended thinking models
|
|
52
52
|
if get_features(llm.model).supports_extended_thinking:
|
|
53
53
|
if llm.extended_thinking_budget:
|
|
54
|
+
# Anthropic throws errors if thinking budget equals or exceeds max output
|
|
55
|
+
# tokens -- force the thinking budget lower if there's a conflict
|
|
56
|
+
budget_tokens = min(llm.extended_thinking_budget, llm.max_output_tokens - 1)
|
|
54
57
|
out["thinking"] = {
|
|
55
58
|
"type": "enabled",
|
|
56
|
-
"budget_tokens":
|
|
59
|
+
"budget_tokens": budget_tokens,
|
|
57
60
|
}
|
|
58
61
|
# Enable interleaved thinking
|
|
59
62
|
# Merge default header with any user-provided headers; user wins on conflict
|
|
@@ -152,6 +152,7 @@ FORCE_STRING_SERIALIZER_MODELS: list[str] = [
|
|
|
152
152
|
# in the message input
|
|
153
153
|
SEND_REASONING_CONTENT_MODELS: list[str] = [
|
|
154
154
|
"kimi-k2-thinking",
|
|
155
|
+
"openrouter/minimax-m2", # MiniMax-M2 via OpenRouter (interleaved thinking)
|
|
155
156
|
"deepseek/deepseek-reasoner",
|
|
156
157
|
]
|
|
157
158
|
|
openhands/sdk/mcp/tool.py
CHANGED
|
@@ -186,7 +186,9 @@ class MCPToolDefinition(ToolDefinition[MCPToolAction, MCPToolObservation]):
|
|
|
186
186
|
# Use exclude_none to avoid injecting nulls back to the call
|
|
187
187
|
# Exclude DiscriminatedUnionMixin fields (e.g., 'kind') as they're
|
|
188
188
|
# internal to OpenHands and not part of the MCP tool schema
|
|
189
|
-
exclude_fields = set(DiscriminatedUnionMixin.model_fields.keys())
|
|
189
|
+
exclude_fields = set(DiscriminatedUnionMixin.model_fields.keys()) | set(
|
|
190
|
+
DiscriminatedUnionMixin.model_computed_fields.keys()
|
|
191
|
+
)
|
|
190
192
|
sanitized = validated.model_dump(exclude_none=True, exclude=exclude_fields)
|
|
191
193
|
return MCPToolAction(data=sanitized)
|
|
192
194
|
|
openhands/sdk/tool/registry.py
CHANGED
|
@@ -29,6 +29,7 @@ Returns: A sequence of ToolDefinition instances. Most of the time this will be a
|
|
|
29
29
|
|
|
30
30
|
_LOCK = RLock()
|
|
31
31
|
_REG: dict[str, Resolver] = {}
|
|
32
|
+
_MODULE_QUALNAMES: dict[str, str] = {} # Maps tool name to module qualname
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
def _resolver_from_instance(name: str, tool: ToolDefinition) -> Resolver:
|
|
@@ -137,11 +138,22 @@ def register_tool(
|
|
|
137
138
|
"(3) a callable factory returning a Sequence[ToolDefinition]"
|
|
138
139
|
)
|
|
139
140
|
|
|
141
|
+
# Track the module qualname for this tool
|
|
142
|
+
module_qualname = None
|
|
143
|
+
if isinstance(factory, type):
|
|
144
|
+
module_qualname = factory.__module__
|
|
145
|
+
elif callable(factory):
|
|
146
|
+
module_qualname = getattr(factory, "__module__", None)
|
|
147
|
+
elif isinstance(factory, ToolDefinition):
|
|
148
|
+
module_qualname = factory.__class__.__module__
|
|
149
|
+
|
|
140
150
|
with _LOCK:
|
|
141
151
|
# TODO: throw exception when registering duplicate name tools
|
|
142
152
|
if name in _REG:
|
|
143
153
|
logger.warning(f"Duplicate tool name registerd {name}")
|
|
144
154
|
_REG[name] = resolver
|
|
155
|
+
if module_qualname:
|
|
156
|
+
_MODULE_QUALNAMES[name] = module_qualname
|
|
145
157
|
|
|
146
158
|
|
|
147
159
|
def resolve_tool(
|
|
@@ -159,3 +171,14 @@ def resolve_tool(
|
|
|
159
171
|
def list_registered_tools() -> list[str]:
|
|
160
172
|
with _LOCK:
|
|
161
173
|
return list(_REG.keys())
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_tool_module_qualnames() -> dict[str, str]:
|
|
177
|
+
"""Get a mapping of tool names to their module qualnames.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
A dictionary mapping tool names to module qualnames (e.g.,
|
|
181
|
+
{"glob": "openhands.tools.glob.definition"}).
|
|
182
|
+
"""
|
|
183
|
+
with _LOCK:
|
|
184
|
+
return dict(_MODULE_QUALNAMES)
|
openhands/sdk/tool/schema.py
CHANGED
|
@@ -121,9 +121,12 @@ class Schema(DiscriminatedUnionMixin):
|
|
|
121
121
|
# so it is fully compatible with MCP tool schema
|
|
122
122
|
result = _process_schema_node(full_schema, full_schema.get("$defs", {}))
|
|
123
123
|
|
|
124
|
-
# Remove
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
# Remove discriminator fields from properties (not for LLM)
|
|
125
|
+
# Need to exclude both regular fields and computed fields (like 'kind')
|
|
126
|
+
exclude_fields = set(DiscriminatedUnionMixin.model_fields.keys()) | set(
|
|
127
|
+
DiscriminatedUnionMixin.model_computed_fields.keys()
|
|
128
|
+
)
|
|
129
|
+
for f in exclude_fields:
|
|
127
130
|
if "properties" in result and f in result["properties"]:
|
|
128
131
|
result["properties"].pop(f)
|
|
129
132
|
# Also remove from required if present
|