openhands-sdk 1.11.0__py3-none-any.whl → 1.11.1__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/context/skills/skill.py +13 -5
- openhands/sdk/conversation/base.py +31 -0
- openhands/sdk/conversation/impl/local_conversation.py +44 -0
- openhands/sdk/conversation/impl/remote_conversation.py +118 -13
- openhands/sdk/conversation/state.py +19 -0
- openhands/sdk/llm/message.py +21 -11
- openhands/sdk/llm/utils/verified_models.py +3 -0
- openhands/sdk/mcp/tool.py +27 -4
- {openhands_sdk-1.11.0.dist-info → openhands_sdk-1.11.1.dist-info}/METADATA +1 -1
- {openhands_sdk-1.11.0.dist-info → openhands_sdk-1.11.1.dist-info}/RECORD +12 -12
- {openhands_sdk-1.11.0.dist-info → openhands_sdk-1.11.1.dist-info}/WHEEL +0 -0
- {openhands_sdk-1.11.0.dist-info → openhands_sdk-1.11.1.dist-info}/top_level.txt +0 -0
|
@@ -709,10 +709,16 @@ def load_user_skills() -> list[Skill]:
|
|
|
709
709
|
def load_project_skills(work_dir: str | Path) -> list[Skill]:
|
|
710
710
|
"""Load skills from project-specific directories.
|
|
711
711
|
|
|
712
|
-
Searches for skills in {work_dir}/.
|
|
713
|
-
{work_dir}/.openhands/microagents/
|
|
714
|
-
|
|
715
|
-
duplicate names.
|
|
712
|
+
Searches for skills in {work_dir}/.agents/skills/,
|
|
713
|
+
{work_dir}/.openhands/skills/, and {work_dir}/.openhands/microagents/
|
|
714
|
+
(legacy). Skills are merged in priority order, with earlier directories
|
|
715
|
+
taking precedence for duplicate names.
|
|
716
|
+
|
|
717
|
+
Use .agents/skills for new skills. .openhands/skills is the legacy
|
|
718
|
+
OpenHands location, and .openhands/microagents is deprecated.
|
|
719
|
+
|
|
720
|
+
Example: If "my-skill" exists in both .agents/skills/ and
|
|
721
|
+
.openhands/skills/, the version from .agents/skills/ is used.
|
|
716
722
|
|
|
717
723
|
Also loads third-party skill files (AGENTS.md, .cursorrules, etc.)
|
|
718
724
|
directly from the work directory.
|
|
@@ -745,8 +751,10 @@ def load_project_skills(work_dir: str | Path) -> list[Skill]:
|
|
|
745
751
|
except (SkillError, OSError) as e:
|
|
746
752
|
logger.warning(f"Failed to load third-party skill from {path}: {e}")
|
|
747
753
|
|
|
748
|
-
# Load project-specific skills from .openhands/skills
|
|
754
|
+
# Load project-specific skills from .agents/skills, .openhands/skills,
|
|
755
|
+
# and legacy microagents (priority order; first wins for duplicates)
|
|
749
756
|
project_skills_dirs = [
|
|
757
|
+
work_dir / ".agents" / "skills",
|
|
750
758
|
work_dir / ".openhands" / "skills",
|
|
751
759
|
work_dir / ".openhands" / "microagents", # Legacy support
|
|
752
760
|
]
|
|
@@ -23,6 +23,7 @@ from openhands.sdk.security.confirmation_policy import (
|
|
|
23
23
|
ConfirmationPolicyBase,
|
|
24
24
|
NeverConfirm,
|
|
25
25
|
)
|
|
26
|
+
from openhands.sdk.tool.schema import Action, Observation
|
|
26
27
|
from openhands.sdk.workspace.base import BaseWorkspace
|
|
27
28
|
|
|
28
29
|
|
|
@@ -267,6 +268,36 @@ class BaseConversation(ABC):
|
|
|
267
268
|
"""
|
|
268
269
|
...
|
|
269
270
|
|
|
271
|
+
@abstractmethod
|
|
272
|
+
def execute_tool(self, tool_name: str, action: Action) -> Observation:
|
|
273
|
+
"""Execute a tool directly without going through the agent loop.
|
|
274
|
+
|
|
275
|
+
This method allows executing tools before or outside of the normal
|
|
276
|
+
conversation.run() flow. It handles agent initialization automatically,
|
|
277
|
+
so tools can be executed before the first run() call.
|
|
278
|
+
|
|
279
|
+
Note: This method bypasses the agent loop, including confirmation
|
|
280
|
+
policies and security analyzer checks. Callers are responsible for
|
|
281
|
+
applying any safeguards before executing potentially destructive tools.
|
|
282
|
+
|
|
283
|
+
This is useful for:
|
|
284
|
+
- Pre-run setup operations (e.g., indexing repositories)
|
|
285
|
+
- Manual tool execution for environment setup
|
|
286
|
+
- Testing tool behavior outside the agent loop
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
tool_name: The name of the tool to execute (e.g., "sleeptime_compute")
|
|
290
|
+
action: The action to pass to the tool executor
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
The observation returned by the tool execution
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
KeyError: If the tool is not found in the agent's tools
|
|
297
|
+
NotImplementedError: If the tool has no executor
|
|
298
|
+
"""
|
|
299
|
+
...
|
|
300
|
+
|
|
270
301
|
@staticmethod
|
|
271
302
|
def compose_callbacks(callbacks: Iterable[CallbackType]) -> CallbackType:
|
|
272
303
|
"""Compose multiple callbacks into a single callback function.
|
|
@@ -46,6 +46,7 @@ from openhands.sdk.security.analyzer import SecurityAnalyzerBase
|
|
|
46
46
|
from openhands.sdk.security.confirmation_policy import (
|
|
47
47
|
ConfirmationPolicyBase,
|
|
48
48
|
)
|
|
49
|
+
from openhands.sdk.tool.schema import Action, Observation
|
|
49
50
|
from openhands.sdk.utils.cipher import Cipher
|
|
50
51
|
from openhands.sdk.workspace import LocalWorkspace
|
|
51
52
|
|
|
@@ -867,6 +868,49 @@ class LocalConversation(BaseConversation):
|
|
|
867
868
|
|
|
868
869
|
logger.info("Condensation request processed")
|
|
869
870
|
|
|
871
|
+
def execute_tool(self, tool_name: str, action: Action) -> Observation:
|
|
872
|
+
"""Execute a tool directly without going through the agent loop.
|
|
873
|
+
|
|
874
|
+
This method allows executing tools before or outside of the normal
|
|
875
|
+
conversation.run() flow. It handles agent initialization automatically,
|
|
876
|
+
so tools can be executed before the first run() call.
|
|
877
|
+
|
|
878
|
+
Note: This method bypasses the agent loop, including confirmation
|
|
879
|
+
policies and security analyzer checks. Callers are responsible for
|
|
880
|
+
applying any safeguards before executing potentially destructive tools.
|
|
881
|
+
|
|
882
|
+
This is useful for:
|
|
883
|
+
- Pre-run setup operations (e.g., indexing repositories)
|
|
884
|
+
- Manual tool execution for environment setup
|
|
885
|
+
- Testing tool behavior outside the agent loop
|
|
886
|
+
|
|
887
|
+
Args:
|
|
888
|
+
tool_name: The name of the tool to execute (e.g., "sleeptime_compute")
|
|
889
|
+
action: The action to pass to the tool executor
|
|
890
|
+
|
|
891
|
+
Returns:
|
|
892
|
+
The observation returned by the tool execution
|
|
893
|
+
|
|
894
|
+
Raises:
|
|
895
|
+
KeyError: If the tool is not found in the agent's tools
|
|
896
|
+
NotImplementedError: If the tool has no executor
|
|
897
|
+
"""
|
|
898
|
+
# Ensure agent is initialized (loads plugins and initializes tools)
|
|
899
|
+
self._ensure_agent_ready()
|
|
900
|
+
|
|
901
|
+
# Get the tool from the agent's tools_map
|
|
902
|
+
tool = self.agent.tools_map.get(tool_name)
|
|
903
|
+
if tool is None:
|
|
904
|
+
available_tools = list(self.agent.tools_map.keys())
|
|
905
|
+
raise KeyError(
|
|
906
|
+
f"Tool '{tool_name}' not found. Available tools: {available_tools}"
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
# Execute the tool
|
|
910
|
+
if not tool.executor:
|
|
911
|
+
raise NotImplementedError(f"Tool '{tool_name}' has no executor")
|
|
912
|
+
return tool(action, self)
|
|
913
|
+
|
|
870
914
|
def __del__(self) -> None:
|
|
871
915
|
"""Ensure cleanup happens when conversation is destroyed."""
|
|
872
916
|
try:
|
|
@@ -6,7 +6,8 @@ import threading
|
|
|
6
6
|
import time
|
|
7
7
|
import uuid
|
|
8
8
|
from collections.abc import Mapping
|
|
9
|
-
from
|
|
9
|
+
from queue import Empty, Queue
|
|
10
|
+
from typing import TYPE_CHECKING, SupportsIndex, overload
|
|
10
11
|
from urllib.parse import urlparse
|
|
11
12
|
|
|
12
13
|
import httpx
|
|
@@ -14,6 +15,10 @@ import websockets
|
|
|
14
15
|
|
|
15
16
|
from openhands.sdk.agent.base import AgentBase
|
|
16
17
|
from openhands.sdk.conversation.base import BaseConversation, ConversationStateProtocol
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from openhands.sdk.tool.schema import Action, Observation
|
|
17
22
|
from openhands.sdk.conversation.conversation_stats import ConversationStats
|
|
18
23
|
from openhands.sdk.conversation.events_list_base import EventsListBase
|
|
19
24
|
from openhands.sdk.conversation.exceptions import (
|
|
@@ -555,6 +560,7 @@ class RemoteConversation(BaseConversation):
|
|
|
555
560
|
_client: httpx.Client
|
|
556
561
|
_hook_processor: HookEventProcessor | None
|
|
557
562
|
_cleanup_initiated: bool
|
|
563
|
+
_terminal_status_queue: Queue[str] # Thread-safe queue for terminal status from WS
|
|
558
564
|
delete_on_close: bool = False
|
|
559
565
|
|
|
560
566
|
def __init__(
|
|
@@ -609,6 +615,7 @@ class RemoteConversation(BaseConversation):
|
|
|
609
615
|
self._client = workspace.client
|
|
610
616
|
self._hook_processor = None
|
|
611
617
|
self._cleanup_initiated = False
|
|
618
|
+
self._terminal_status_queue: Queue[str] = Queue()
|
|
612
619
|
|
|
613
620
|
should_create = conversation_id is None
|
|
614
621
|
if conversation_id is not None:
|
|
@@ -708,8 +715,21 @@ class RemoteConversation(BaseConversation):
|
|
|
708
715
|
# No visualization (visualizer is None)
|
|
709
716
|
self._visualizer = None
|
|
710
717
|
|
|
718
|
+
# Add a callback that signals when run completes via WebSocket
|
|
719
|
+
# This ensures we wait for all events to be delivered before run() returns
|
|
720
|
+
def run_complete_callback(event: Event) -> None:
|
|
721
|
+
if isinstance(event, ConversationStateUpdateEvent):
|
|
722
|
+
if event.key == "execution_status":
|
|
723
|
+
try:
|
|
724
|
+
status = ConversationExecutionStatus(event.value)
|
|
725
|
+
if status.is_terminal():
|
|
726
|
+
self._terminal_status_queue.put(event.value)
|
|
727
|
+
except ValueError:
|
|
728
|
+
pass # Unknown status value, ignore
|
|
729
|
+
|
|
711
730
|
# Compose all callbacks into a single callback
|
|
712
|
-
|
|
731
|
+
all_callbacks = self._callbacks + [run_complete_callback]
|
|
732
|
+
composed_callback = BaseConversation.compose_callbacks(all_callbacks)
|
|
713
733
|
|
|
714
734
|
# Initialize WebSocket client for callbacks
|
|
715
735
|
self._ws_client = WebSocketCallbackClient(
|
|
@@ -862,6 +882,14 @@ class RemoteConversation(BaseConversation):
|
|
|
862
882
|
Raises:
|
|
863
883
|
ConversationRunError: If the run fails or times out.
|
|
864
884
|
"""
|
|
885
|
+
# Drain any stale terminal status events from previous runs.
|
|
886
|
+
# This prevents stale events from causing early returns.
|
|
887
|
+
while True:
|
|
888
|
+
try:
|
|
889
|
+
self._terminal_status_queue.get_nowait()
|
|
890
|
+
except Empty:
|
|
891
|
+
break
|
|
892
|
+
|
|
865
893
|
# Trigger a run on the server using the dedicated run endpoint.
|
|
866
894
|
# Let the server tell us if it's already running (409), avoiding an extra GET.
|
|
867
895
|
try:
|
|
@@ -889,10 +917,20 @@ class RemoteConversation(BaseConversation):
|
|
|
889
917
|
poll_interval: float = 1.0,
|
|
890
918
|
timeout: float = 1800.0,
|
|
891
919
|
) -> None:
|
|
892
|
-
"""
|
|
920
|
+
"""Wait for the conversation run to complete.
|
|
921
|
+
|
|
922
|
+
This method waits for the run to complete by listening for the terminal
|
|
923
|
+
status event via WebSocket. This ensures all events are delivered before
|
|
924
|
+
returning, avoiding the race condition where polling sees "finished"
|
|
925
|
+
status before WebSocket delivers the final events.
|
|
926
|
+
|
|
927
|
+
As a fallback, it also polls the server periodically. If the WebSocket
|
|
928
|
+
is delayed or disconnected, we return after multiple consecutive polls
|
|
929
|
+
show a terminal status, and reconcile events to catch any that were
|
|
930
|
+
missed via WebSocket.
|
|
893
931
|
|
|
894
932
|
Args:
|
|
895
|
-
poll_interval: Time in seconds between status polls.
|
|
933
|
+
poll_interval: Time in seconds between status polls (fallback).
|
|
896
934
|
timeout: Maximum time in seconds to wait.
|
|
897
935
|
|
|
898
936
|
Raises:
|
|
@@ -901,6 +939,14 @@ class RemoteConversation(BaseConversation):
|
|
|
901
939
|
responses are retried until timeout.
|
|
902
940
|
"""
|
|
903
941
|
start_time = time.monotonic()
|
|
942
|
+
consecutive_terminal_polls = 0
|
|
943
|
+
# Return after this many consecutive terminal polls (fallback for WS issues).
|
|
944
|
+
# We use 3 polls to balance latency vs reliability:
|
|
945
|
+
# - 1 poll could be a transient state during shutdown
|
|
946
|
+
# - 2 polls might still catch a race condition
|
|
947
|
+
# - 3 polls (with default 1s interval = 3s total) provides high confidence
|
|
948
|
+
# that the run is truly complete while keeping fallback latency reasonable
|
|
949
|
+
TERMINAL_POLL_THRESHOLD = 3
|
|
904
950
|
|
|
905
951
|
while True:
|
|
906
952
|
elapsed = time.monotonic() - start_time
|
|
@@ -913,20 +959,57 @@ class RemoteConversation(BaseConversation):
|
|
|
913
959
|
),
|
|
914
960
|
)
|
|
915
961
|
|
|
962
|
+
# Wait for either:
|
|
963
|
+
# 1. WebSocket delivers terminal status event (preferred)
|
|
964
|
+
# 2. Poll interval expires (fallback - check status via REST)
|
|
965
|
+
try:
|
|
966
|
+
ws_status = self._terminal_status_queue.get(timeout=poll_interval)
|
|
967
|
+
# Handle ERROR/STUCK states - raises ConversationRunError
|
|
968
|
+
self._handle_conversation_status(ws_status)
|
|
969
|
+
|
|
970
|
+
logger.info(
|
|
971
|
+
"Run completed via WebSocket notification "
|
|
972
|
+
"(status: %s, elapsed: %.1fs)",
|
|
973
|
+
ws_status,
|
|
974
|
+
elapsed,
|
|
975
|
+
)
|
|
976
|
+
return
|
|
977
|
+
except Empty:
|
|
978
|
+
pass # Queue.get() timed out, fall through to REST polling
|
|
979
|
+
|
|
980
|
+
# Poll the server for status as a health check and fallback.
|
|
981
|
+
# This catches ERROR/STUCK states that need immediate attention,
|
|
982
|
+
# and provides a fallback if WebSocket is delayed/disconnected.
|
|
916
983
|
try:
|
|
917
984
|
status = self._poll_status_once()
|
|
918
985
|
except Exception as exc:
|
|
919
986
|
self._handle_poll_exception(exc)
|
|
987
|
+
consecutive_terminal_polls = 0 # Reset on error
|
|
920
988
|
else:
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
989
|
+
# Raises ConversationRunError for ERROR/STUCK states
|
|
990
|
+
self._handle_conversation_status(status)
|
|
991
|
+
|
|
992
|
+
# Track consecutive terminal polls as a fallback for WS issues.
|
|
993
|
+
# If WebSocket is delayed/disconnected, we return after multiple
|
|
994
|
+
# consecutive polls confirm the terminal status.
|
|
995
|
+
if status and ConversationExecutionStatus(status).is_terminal():
|
|
996
|
+
consecutive_terminal_polls += 1
|
|
997
|
+
if consecutive_terminal_polls >= TERMINAL_POLL_THRESHOLD:
|
|
998
|
+
logger.info(
|
|
999
|
+
"Run completed via REST fallback after %d consecutive "
|
|
1000
|
+
"terminal polls (status: %s, elapsed: %.1fs). "
|
|
1001
|
+
"Reconciling events...",
|
|
1002
|
+
consecutive_terminal_polls,
|
|
1003
|
+
status,
|
|
1004
|
+
elapsed,
|
|
1005
|
+
)
|
|
1006
|
+
# Reconcile events to catch any that were missed via WS.
|
|
1007
|
+
# This is only called in the fallback path, so it doesn't
|
|
1008
|
+
# add overhead in the common case where WS works.
|
|
1009
|
+
self._state.events.reconcile()
|
|
1010
|
+
return
|
|
1011
|
+
else:
|
|
1012
|
+
consecutive_terminal_polls = 0
|
|
930
1013
|
|
|
931
1014
|
def _poll_status_once(self) -> str | None:
|
|
932
1015
|
"""Fetch the current execution status from the remote conversation."""
|
|
@@ -1116,6 +1199,28 @@ class RemoteConversation(BaseConversation):
|
|
|
1116
1199
|
"""
|
|
1117
1200
|
_send_request(self._client, "POST", f"/api/conversations/{self._id}/condense")
|
|
1118
1201
|
|
|
1202
|
+
def execute_tool(self, tool_name: str, action: "Action") -> "Observation":
|
|
1203
|
+
"""Execute a tool directly without going through the agent loop.
|
|
1204
|
+
|
|
1205
|
+
Note: This method is not yet supported for RemoteConversation.
|
|
1206
|
+
Tool execution for remote conversations happens on the server side
|
|
1207
|
+
during the normal agent loop.
|
|
1208
|
+
|
|
1209
|
+
Args:
|
|
1210
|
+
tool_name: The name of the tool to execute
|
|
1211
|
+
action: The action to pass to the tool executor
|
|
1212
|
+
|
|
1213
|
+
Raises:
|
|
1214
|
+
NotImplementedError: Always, as this feature is not yet supported
|
|
1215
|
+
for remote conversations.
|
|
1216
|
+
"""
|
|
1217
|
+
raise NotImplementedError(
|
|
1218
|
+
"execute_tool is not yet supported for RemoteConversation. "
|
|
1219
|
+
"Tool execution for remote conversations happens on the server side "
|
|
1220
|
+
"during the normal agent loop. Use LocalConversation for direct "
|
|
1221
|
+
"tool execution."
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1119
1224
|
def close(self) -> None:
|
|
1120
1225
|
"""Close the conversation and clean up resources.
|
|
1121
1226
|
|
|
@@ -45,6 +45,25 @@ class ConversationExecutionStatus(str, Enum):
|
|
|
45
45
|
STUCK = "stuck" # Conversation is stuck in a loop or unable to proceed
|
|
46
46
|
DELETING = "deleting" # Conversation is in the process of being deleted
|
|
47
47
|
|
|
48
|
+
def is_terminal(self) -> bool:
|
|
49
|
+
"""Check if this status represents a terminal state.
|
|
50
|
+
|
|
51
|
+
Terminal states indicate the run has completed and the agent is no longer
|
|
52
|
+
actively processing. These are: FINISHED, ERROR, STUCK.
|
|
53
|
+
|
|
54
|
+
Note: IDLE is NOT a terminal state - it's the initial state of a conversation
|
|
55
|
+
before any run has started. Including IDLE would cause false positives when
|
|
56
|
+
the WebSocket delivers the initial state update during connection.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
True if this is a terminal status, False otherwise.
|
|
60
|
+
"""
|
|
61
|
+
return self in (
|
|
62
|
+
ConversationExecutionStatus.FINISHED,
|
|
63
|
+
ConversationExecutionStatus.ERROR,
|
|
64
|
+
ConversationExecutionStatus.STUCK,
|
|
65
|
+
)
|
|
66
|
+
|
|
48
67
|
|
|
49
68
|
class ConversationState(OpenHandsModel):
|
|
50
69
|
# ===== Public, validated fields =====
|
openhands/sdk/llm/message.py
CHANGED
|
@@ -170,21 +170,12 @@ class TextContent(BaseContent):
|
|
|
170
170
|
model_config: ClassVar[ConfigDict] = ConfigDict(
|
|
171
171
|
extra="forbid", populate_by_name=True
|
|
172
172
|
)
|
|
173
|
-
enable_truncation: bool = True
|
|
174
173
|
|
|
175
174
|
def to_llm_dict(self) -> list[dict[str, str | dict[str, str]]]:
|
|
176
175
|
"""Convert to LLM API format."""
|
|
177
|
-
text = self.text
|
|
178
|
-
if self.enable_truncation and len(text) > DEFAULT_TEXT_CONTENT_LIMIT:
|
|
179
|
-
logger.warning(
|
|
180
|
-
f"TextContent text length ({len(text)}) exceeds limit "
|
|
181
|
-
f"({DEFAULT_TEXT_CONTENT_LIMIT}), truncating"
|
|
182
|
-
)
|
|
183
|
-
text = maybe_truncate(text, DEFAULT_TEXT_CONTENT_LIMIT)
|
|
184
|
-
|
|
185
176
|
data: dict[str, str | dict[str, str]] = {
|
|
186
177
|
"type": self.type,
|
|
187
|
-
"text": text,
|
|
178
|
+
"text": self.text,
|
|
188
179
|
}
|
|
189
180
|
if self.cache_prompt:
|
|
190
181
|
data["cache_control"] = {"type": "ephemeral"}
|
|
@@ -342,6 +333,8 @@ class Message(BaseModel):
|
|
|
342
333
|
content = "\n".join(
|
|
343
334
|
item.text for item in self.content if isinstance(item, TextContent)
|
|
344
335
|
)
|
|
336
|
+
if self.role == "tool":
|
|
337
|
+
content = self._maybe_truncate_tool_text(content)
|
|
345
338
|
message_dict: dict[str, Any] = {"content": content, "role": self.role}
|
|
346
339
|
|
|
347
340
|
# tool call keys are added in to_chat_dict to centralize behavior
|
|
@@ -366,6 +359,12 @@ class Message(BaseModel):
|
|
|
366
359
|
# All content types now return list[dict[str, Any]]
|
|
367
360
|
item_dicts = item.to_llm_dict()
|
|
368
361
|
|
|
362
|
+
if self.role == "tool" and item_dicts:
|
|
363
|
+
for d in item_dicts:
|
|
364
|
+
text_val = d.get("text")
|
|
365
|
+
if d.get("type") == "text" and isinstance(text_val, str):
|
|
366
|
+
d["text"] = self._maybe_truncate_tool_text(text_val)
|
|
367
|
+
|
|
369
368
|
# We have to remove cache_prompt for tool content and move it up to the
|
|
370
369
|
# message level
|
|
371
370
|
# See discussion here for details: https://github.com/BerriAI/litellm/issues/6422#issuecomment-2438765472
|
|
@@ -551,17 +550,28 @@ class Message(BaseModel):
|
|
|
551
550
|
)
|
|
552
551
|
for c in self.content:
|
|
553
552
|
if isinstance(c, TextContent):
|
|
553
|
+
output_text = self._maybe_truncate_tool_text(c.text)
|
|
554
554
|
items.append(
|
|
555
555
|
{
|
|
556
556
|
"type": "function_call_output",
|
|
557
557
|
"call_id": resp_call_id,
|
|
558
|
-
"output":
|
|
558
|
+
"output": output_text,
|
|
559
559
|
}
|
|
560
560
|
)
|
|
561
561
|
return items
|
|
562
562
|
|
|
563
563
|
return items
|
|
564
564
|
|
|
565
|
+
def _maybe_truncate_tool_text(self, text: str) -> str:
|
|
566
|
+
if not text or len(text) <= DEFAULT_TEXT_CONTENT_LIMIT:
|
|
567
|
+
return text
|
|
568
|
+
logger.warning(
|
|
569
|
+
"Tool TextContent text length (%s) exceeds limit (%s), truncating",
|
|
570
|
+
len(text),
|
|
571
|
+
DEFAULT_TEXT_CONTENT_LIMIT,
|
|
572
|
+
)
|
|
573
|
+
return maybe_truncate(text, DEFAULT_TEXT_CONTENT_LIMIT)
|
|
574
|
+
|
|
565
575
|
@classmethod
|
|
566
576
|
def from_llm_chat_message(cls, message: LiteLLMMessage) -> "Message":
|
|
567
577
|
"""Convert a LiteLLMMessage (Chat Completions) to our Message class.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
VERIFIED_OPENAI_MODELS = [
|
|
2
2
|
"gpt-5.2",
|
|
3
|
+
"gpt-5.2-codex",
|
|
3
4
|
"gpt-5.1",
|
|
4
5
|
"gpt-5.1-codex-max",
|
|
5
6
|
"gpt-5.1-codex",
|
|
@@ -46,12 +47,14 @@ VERIFIED_OPENHANDS_MODELS = [
|
|
|
46
47
|
"claude-opus-4-5-20251101",
|
|
47
48
|
"claude-sonnet-4-5-20250929",
|
|
48
49
|
"gpt-5.2",
|
|
50
|
+
"gpt-5.2-codex",
|
|
49
51
|
"gpt-5.1-codex-max",
|
|
50
52
|
"gpt-5.1-codex",
|
|
51
53
|
"gpt-5.1",
|
|
52
54
|
"gemini-3-pro-preview",
|
|
53
55
|
"deepseek-chat",
|
|
54
56
|
"kimi-k2-thinking",
|
|
57
|
+
"kimi-k2.5",
|
|
55
58
|
"devstral-medium-2512",
|
|
56
59
|
"devstral-2512",
|
|
57
60
|
]
|
openhands/sdk/mcp/tool.py
CHANGED
|
@@ -29,6 +29,9 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
|
|
|
29
29
|
|
|
30
30
|
logger = get_logger(__name__)
|
|
31
31
|
|
|
32
|
+
# Default timeout for MCP tool execution in seconds
|
|
33
|
+
MCP_TOOL_TIMEOUT_SECONDS = 300
|
|
34
|
+
|
|
32
35
|
|
|
33
36
|
# NOTE: We don't define MCPToolAction because it
|
|
34
37
|
# will be a pydantic BaseModel dynamically created from the MCP tool schema.
|
|
@@ -45,10 +48,17 @@ class MCPToolExecutor(ToolExecutor):
|
|
|
45
48
|
|
|
46
49
|
tool_name: str
|
|
47
50
|
client: MCPClient
|
|
51
|
+
timeout: float
|
|
48
52
|
|
|
49
|
-
def __init__(
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
tool_name: str,
|
|
56
|
+
client: MCPClient,
|
|
57
|
+
timeout: float = MCP_TOOL_TIMEOUT_SECONDS,
|
|
58
|
+
):
|
|
50
59
|
self.tool_name = tool_name
|
|
51
60
|
self.client = client
|
|
61
|
+
self.timeout = timeout
|
|
52
62
|
|
|
53
63
|
@observe(name="MCPToolExecutor.call_tool", span_type="TOOL")
|
|
54
64
|
async def call_tool(self, action: MCPToolAction) -> MCPToolObservation:
|
|
@@ -83,9 +93,22 @@ class MCPToolExecutor(ToolExecutor):
|
|
|
83
93
|
conversation: "LocalConversation | None" = None, # noqa: ARG002
|
|
84
94
|
) -> MCPToolObservation:
|
|
85
95
|
"""Execute an MCP tool call."""
|
|
86
|
-
|
|
87
|
-
self.
|
|
88
|
-
|
|
96
|
+
try:
|
|
97
|
+
return self.client.call_async_from_sync(
|
|
98
|
+
self.call_tool, action=action, timeout=self.timeout
|
|
99
|
+
)
|
|
100
|
+
except TimeoutError:
|
|
101
|
+
error_msg = (
|
|
102
|
+
f"MCP tool '{self.tool_name}' timed out after {self.timeout} seconds. "
|
|
103
|
+
"The tool server may be unresponsive or the operation is taking "
|
|
104
|
+
"too long. Consider retrying or using an alternative approach."
|
|
105
|
+
)
|
|
106
|
+
logger.error(error_msg)
|
|
107
|
+
return MCPToolObservation.from_text(
|
|
108
|
+
text=error_msg,
|
|
109
|
+
is_error=True,
|
|
110
|
+
tool_name=self.tool_name,
|
|
111
|
+
)
|
|
89
112
|
|
|
90
113
|
|
|
91
114
|
_mcp_dynamic_action_type: dict[str, type[Schema]] = {}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-sdk
|
|
3
|
-
Version: 1.11.
|
|
3
|
+
Version: 1.11.1
|
|
4
4
|
Summary: OpenHands SDK - Core functionality for building AI agents
|
|
5
5
|
Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
|
|
6
6
|
Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
|
|
@@ -35,12 +35,12 @@ openhands/sdk/context/prompts/templates/skill_knowledge_info.j2,sha256=boqEsAIsz
|
|
|
35
35
|
openhands/sdk/context/prompts/templates/system_message_suffix.j2,sha256=MJYiCSm8gq--6HKt74baDTarexbG4vQdo6Ba1nvjSHA,2911
|
|
36
36
|
openhands/sdk/context/skills/__init__.py,sha256=dS6XJYLHlm_wboD-C42bRw4HCFu-o9dzCiFRpoyR1h0,938
|
|
37
37
|
openhands/sdk/context/skills/exceptions.py,sha256=tVBSbXTXG32nb1TebVAuzbNNHvn8GScpSM1bHCnQzvY,305
|
|
38
|
-
openhands/sdk/context/skills/skill.py,sha256=
|
|
38
|
+
openhands/sdk/context/skills/skill.py,sha256=GMj_OZNi6KSIRluswO_mZXAJhXGdGpYPZthj4WZf92Q,35760
|
|
39
39
|
openhands/sdk/context/skills/trigger.py,sha256=ZGaDmMpJghnAEuTTYX6UepsA5nX1CSz83zK1Ox46vMk,756
|
|
40
40
|
openhands/sdk/context/skills/types.py,sha256=LvyCveHBSt2-g9Lbpr_eQMvOd4eEBjJb3irAWL-OzE0,1813
|
|
41
41
|
openhands/sdk/context/skills/utils.py,sha256=kpJjVz_BQGjFrgO8QpBwTAqHbAU8spD6crOLI_ollac,11870
|
|
42
42
|
openhands/sdk/conversation/__init__.py,sha256=USxX0PTUI9ufA8CJ8iyDkIaKR5iFbDo3L9G5tBx3k94,1509
|
|
43
|
-
openhands/sdk/conversation/base.py,sha256=
|
|
43
|
+
openhands/sdk/conversation/base.py,sha256=SzqLFHRgQPLzR3jiAD7Cc-ZLCJnNQ1Zwu1yXyHK6eKA,10426
|
|
44
44
|
openhands/sdk/conversation/conversation.py,sha256=PPoR13vFo_9DeIyeW82cxGonDpoOvKjSsQi5OrqR4Zk,6660
|
|
45
45
|
openhands/sdk/conversation/conversation_stats.py,sha256=ZlQ99kgG5YVCrZ4rqJlq63JaiInxX8jqv-q5lS7RN68,3038
|
|
46
46
|
openhands/sdk/conversation/event_store.py,sha256=JZF6AibFezcIzEw4IQqKHG8T8s53SfD_LkZycsOH6xY,7992
|
|
@@ -51,13 +51,13 @@ openhands/sdk/conversation/persistence_const.py,sha256=om3pOQa5sGK8t_NUYb3Tz-7sK
|
|
|
51
51
|
openhands/sdk/conversation/response_utils.py,sha256=rPlC3cDSmoQte6NZ0kK6h6-9ho5cbF8jEw-DiyEhgIM,1548
|
|
52
52
|
openhands/sdk/conversation/secret_registry.py,sha256=6fY1zRxb55rC4uIMFcR0lDssIyyjaPh9pCWGqDikrek,4446
|
|
53
53
|
openhands/sdk/conversation/serialization_diff.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
54
|
-
openhands/sdk/conversation/state.py,sha256=
|
|
54
|
+
openhands/sdk/conversation/state.py,sha256=_Bg2lTyZj3JtFGqMPOlHxz2yBcu-XRmhuHanWhfzi3A,18320
|
|
55
55
|
openhands/sdk/conversation/stuck_detector.py,sha256=p8DljC-YwAVpAUTUqcLy2CY1hTSdpKzN9f6iKJbxs8I,12185
|
|
56
56
|
openhands/sdk/conversation/title_utils.py,sha256=j40-dP-Oes-mhU2xUC7fCC8cB0wkMdbbDJU7WLHiVIo,7063
|
|
57
57
|
openhands/sdk/conversation/types.py,sha256=q_yc3VNc8r3cdmvPXzpj7HvdLeDqv-37hCgOWMU65a4,1507
|
|
58
58
|
openhands/sdk/conversation/impl/__init__.py,sha256=DmDFyNR4RU8eiMocKf2j9eBQomipP-rrJgU1LoVWTDA,220
|
|
59
|
-
openhands/sdk/conversation/impl/local_conversation.py,sha256=
|
|
60
|
-
openhands/sdk/conversation/impl/remote_conversation.py,sha256=
|
|
59
|
+
openhands/sdk/conversation/impl/local_conversation.py,sha256=gb5-_MhrhEQIHGKVGvgodaGRQ2mi1YI_vsy6f30EkE4,39031
|
|
60
|
+
openhands/sdk/conversation/impl/remote_conversation.py,sha256=ocKqRXevb0z9g0AhKGlI6dkIAPFx57tj28ciESSZueE,49305
|
|
61
61
|
openhands/sdk/conversation/visualizer/__init__.py,sha256=0LXpKlt2eJcrqP1z6jQP_nLx23V8ErnQkKYSxvUp0_A,275
|
|
62
62
|
openhands/sdk/conversation/visualizer/base.py,sha256=oMg-JvQc34ebYdC3J9itHraoB2u3MdQ6E77AKiTmu30,3198
|
|
63
63
|
openhands/sdk/conversation/visualizer/default.py,sha256=k-3-l1j8H_EOEn_pfzsUczcc4JDhQni7AUQZgZ2TpB4,11885
|
|
@@ -108,7 +108,7 @@ openhands/sdk/llm/__init__.py,sha256=t7kEGnNq4eYREz5h2n1Pizte8glUnruHTt_G_6DguYk
|
|
|
108
108
|
openhands/sdk/llm/llm.py,sha256=rQpiHy-clgGqN9fms4RaSIkJ7nV5HdXvgMCq95di89Y,52102
|
|
109
109
|
openhands/sdk/llm/llm_registry.py,sha256=DL9yqSbAM7OBkzdIChLuxG2qk_oElW2tC2xem6mq0F8,3530
|
|
110
110
|
openhands/sdk/llm/llm_response.py,sha256=DaBVBkij4Sz-RsYhRb3UUcvJCTzCBcOYQ9IhFwN4ukI,1988
|
|
111
|
-
openhands/sdk/llm/message.py,sha256=
|
|
111
|
+
openhands/sdk/llm/message.py,sha256=pO-uuGByNQb6mPlnkiYo2TNvIp3l2mgDWxsi6CQ-C7k,27561
|
|
112
112
|
openhands/sdk/llm/streaming.py,sha256=tFJ7B0AjJ-e8Xv13DTtc2FdrsLRUCG8wxQex8fDlOp4,214
|
|
113
113
|
openhands/sdk/llm/auth/__init__.py,sha256=GDGU9D6a-o6bXSlVC8NUMxViXJfmEr55HMRosog9F_k,698
|
|
114
114
|
openhands/sdk/llm/auth/credentials.py,sha256=uPWqBCd26snW5Afuinzxy6I-HlG38qXZ9o_hD5iKc64,5167
|
|
@@ -134,7 +134,7 @@ openhands/sdk/llm/utils/model_prompt_spec.py,sha256=onw9-y7x0aJS8IOjNzeqhdvcFNwK
|
|
|
134
134
|
openhands/sdk/llm/utils/retry_mixin.py,sha256=M-hXp8EwP1FjNN6tgHiv133BtUQgRr9Kz_ZWxeAJLGA,4765
|
|
135
135
|
openhands/sdk/llm/utils/telemetry.py,sha256=E7fDPXFdy3u3IVPTOijUCDw8vtqtMPjyhhlIg-NwU-M,15533
|
|
136
136
|
openhands/sdk/llm/utils/unverified_models.py,sha256=SmYrX_WxXOJBanTviztqy1xPjOcLY4i3qvwNBEga_Dk,4797
|
|
137
|
-
openhands/sdk/llm/utils/verified_models.py,sha256=
|
|
137
|
+
openhands/sdk/llm/utils/verified_models.py,sha256=tjIhYM8Bq621ewlmxMztHXtrk98jk98u-TDloWUE3xI,1521
|
|
138
138
|
openhands/sdk/logger/__init__.py,sha256=vZvFDYfW01Y8Act3tveMs3XxTysJlt4HeT-n6X_ujYk,330
|
|
139
139
|
openhands/sdk/logger/logger.py,sha256=en8SHzFC2baohQmqbgE8t0mvV_xMZQrILEycOzdAZL0,6511
|
|
140
140
|
openhands/sdk/logger/rolling.py,sha256=E6oy0asgmOhZHoWlSCw0QK1PKnS6kvtxjoWLAsqlGvs,3440
|
|
@@ -142,7 +142,7 @@ openhands/sdk/mcp/__init__.py,sha256=-wQbZ405PjVRCBtSfirp4jsiRohd7IJAyAdicZ-M8Ok
|
|
|
142
142
|
openhands/sdk/mcp/client.py,sha256=NTDTYTxedRDB0xkkMwT1_uN2ReV4SvLsURKNB8Ih0lg,3754
|
|
143
143
|
openhands/sdk/mcp/definition.py,sha256=vFLQeLW99fBzPGR7X7_1GzTmIHlSVAbmsg3elhhkp5U,3424
|
|
144
144
|
openhands/sdk/mcp/exceptions.py,sha256=N4g7Wju420TQJ7hmck1e5UblWbC_7Torb-UTFj1GN70,448
|
|
145
|
-
openhands/sdk/mcp/tool.py,sha256=
|
|
145
|
+
openhands/sdk/mcp/tool.py,sha256=33jaPGmehCf6AT88e9CFXTlmP7JNups-yIMxlbLvIFo,11153
|
|
146
146
|
openhands/sdk/mcp/utils.py,sha256=Drm3D1SuNknwNa9yfqIVRzZhEXlqHj39LhLz0XbEF04,3081
|
|
147
147
|
openhands/sdk/observability/__init__.py,sha256=JKHDWoj01igaCUChdXgKmstESiMmbAK9CN5XzT2H7fo,122
|
|
148
148
|
openhands/sdk/observability/laminar.py,sha256=U2AbSWpPcUYdrzx__-BEg4LPW13f1l-Hk-V4qeBmE3k,5053
|
|
@@ -190,7 +190,7 @@ openhands/sdk/workspace/remote/__init__.py,sha256=eKkj6NOESMUBGDVC6_L2Wfuc4K6G-m
|
|
|
190
190
|
openhands/sdk/workspace/remote/async_remote_workspace.py,sha256=MfnYoXvx_tZ7MKDGJCofnkYAJxfBKqNtM2Qprx3QQRk,5608
|
|
191
191
|
openhands/sdk/workspace/remote/base.py,sha256=5xqhJf_Hi3kMdNfL4u0XScnVShTRUSeMtvIAemR6Q3A,6317
|
|
192
192
|
openhands/sdk/workspace/remote/remote_workspace_mixin.py,sha256=29Mwe6lVZ-annN0lThRY2cM-FrqXhLm1uT_4j72e7fw,12600
|
|
193
|
-
openhands_sdk-1.11.
|
|
194
|
-
openhands_sdk-1.11.
|
|
195
|
-
openhands_sdk-1.11.
|
|
196
|
-
openhands_sdk-1.11.
|
|
193
|
+
openhands_sdk-1.11.1.dist-info/METADATA,sha256=-s87W5UCYLj1oVPQ7kNSdQtNv5YUZH5Lre2Y-iEMkmU,859
|
|
194
|
+
openhands_sdk-1.11.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
195
|
+
openhands_sdk-1.11.1.dist-info/top_level.txt,sha256=jHgVu9I0Blam8BXFgedoGKfglPF8XvW1TsJFIjcgP4E,10
|
|
196
|
+
openhands_sdk-1.11.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|