openhands-sdk 1.10.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.
Files changed (29) hide show
  1. openhands/sdk/agent/agent.py +60 -27
  2. openhands/sdk/agent/base.py +1 -1
  3. openhands/sdk/context/condenser/base.py +36 -3
  4. openhands/sdk/context/condenser/llm_summarizing_condenser.py +65 -1
  5. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +2 -1
  6. openhands/sdk/context/skills/skill.py +15 -30
  7. openhands/sdk/conversation/base.py +31 -0
  8. openhands/sdk/conversation/conversation.py +5 -0
  9. openhands/sdk/conversation/impl/local_conversation.py +63 -13
  10. openhands/sdk/conversation/impl/remote_conversation.py +128 -13
  11. openhands/sdk/conversation/state.py +19 -0
  12. openhands/sdk/conversation/stuck_detector.py +18 -9
  13. openhands/sdk/llm/__init__.py +16 -0
  14. openhands/sdk/llm/auth/__init__.py +28 -0
  15. openhands/sdk/llm/auth/credentials.py +157 -0
  16. openhands/sdk/llm/auth/openai.py +762 -0
  17. openhands/sdk/llm/llm.py +175 -20
  18. openhands/sdk/llm/message.py +21 -11
  19. openhands/sdk/llm/options/responses_options.py +8 -7
  20. openhands/sdk/llm/utils/model_features.py +2 -0
  21. openhands/sdk/llm/utils/verified_models.py +3 -0
  22. openhands/sdk/mcp/tool.py +27 -4
  23. openhands/sdk/secret/secrets.py +13 -1
  24. openhands/sdk/workspace/remote/base.py +8 -3
  25. openhands/sdk/workspace/remote/remote_workspace_mixin.py +40 -7
  26. {openhands_sdk-1.10.0.dist-info → openhands_sdk-1.11.1.dist-info}/METADATA +1 -1
  27. {openhands_sdk-1.10.0.dist-info → openhands_sdk-1.11.1.dist-info}/RECORD +29 -26
  28. {openhands_sdk-1.10.0.dist-info → openhands_sdk-1.11.1.dist-info}/WHEEL +0 -0
  29. {openhands_sdk-1.10.0.dist-info → openhands_sdk-1.11.1.dist-info}/top_level.txt +0 -0
openhands/sdk/llm/llm.py CHANGED
@@ -27,8 +27,11 @@ from openhands.sdk.utils.pydantic_secrets import serialize_secret, validate_secr
27
27
 
28
28
 
29
29
  if TYPE_CHECKING: # type hints only, avoid runtime import cycle
30
+ from openhands.sdk.llm.auth import SupportedVendor
30
31
  from openhands.sdk.tool.tool import ToolDefinition
31
32
 
33
+ from openhands.sdk.llm.auth.openai import transform_for_subscription
34
+
32
35
 
33
36
  with warnings.catch_warnings():
34
37
  warnings.simplefilter("ignore")
@@ -50,8 +53,20 @@ from litellm.exceptions import (
50
53
  Timeout as LiteLLMTimeout,
51
54
  )
52
55
  from litellm.responses.main import responses as litellm_responses
53
- from litellm.types.llms.openai import ResponsesAPIResponse
54
- from litellm.types.utils import ModelResponse
56
+ from litellm.responses.streaming_iterator import SyncResponsesAPIStreamingIterator
57
+ from litellm.types.llms.openai import (
58
+ OutputTextDeltaEvent,
59
+ ReasoningSummaryTextDeltaEvent,
60
+ RefusalDeltaEvent,
61
+ ResponseCompletedEvent,
62
+ ResponsesAPIResponse,
63
+ )
64
+ from litellm.types.utils import (
65
+ Delta,
66
+ ModelResponse,
67
+ ModelResponseStream,
68
+ StreamingChoices,
69
+ )
55
70
  from litellm.utils import (
56
71
  create_pretrained_tokenizer,
57
72
  supports_vision,
@@ -335,6 +350,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
335
350
  _model_info: Any = PrivateAttr(default=None)
336
351
  _tokenizer: Any = PrivateAttr(default=None)
337
352
  _telemetry: Telemetry | None = PrivateAttr(default=None)
353
+ _is_subscription: bool = PrivateAttr(default=False)
338
354
 
339
355
  model_config: ClassVar[ConfigDict] = ConfigDict(
340
356
  extra="ignore", arbitrary_types_allowed=True
@@ -499,6 +515,19 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
499
515
  )
500
516
  return self._telemetry
501
517
 
518
+ @property
519
+ def is_subscription(self) -> bool:
520
+ """Check if this LLM uses subscription-based authentication.
521
+
522
+ Returns True when the LLM was created via `LLM.subscription_login()`,
523
+ which uses the ChatGPT subscription Codex backend rather than the
524
+ standard OpenAI API.
525
+
526
+ Returns:
527
+ bool: True if using subscription-based transport, False otherwise.
528
+ """
529
+ return self._is_subscription
530
+
502
531
  def restore_metrics(self, metrics: Metrics) -> None:
503
532
  # Only used by ConversationStats to seed metrics
504
533
  self._metrics = metrics
@@ -662,7 +691,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
662
691
  raise
663
692
 
664
693
  # =========================================================================
665
- # Responses API (non-stream, v1)
694
+ # Responses API (v1)
666
695
  # =========================================================================
667
696
  def responses(
668
697
  self,
@@ -686,16 +715,19 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
686
715
  store: Whether to store the conversation
687
716
  _return_metrics: Whether to return usage metrics
688
717
  add_security_risk_prediction: Add security_risk field to tool schemas
689
- on_token: Optional callback for streaming tokens (not yet supported)
718
+ on_token: Optional callback for streaming deltas
690
719
  **kwargs: Additional arguments passed to the API
691
720
 
692
721
  Note:
693
722
  Summary field is always added to tool schemas for transparency and
694
723
  explainability of agent actions.
695
724
  """
696
- # Streaming not yet supported
697
- if kwargs.get("stream", False) or self.stream or on_token is not None:
698
- raise ValueError("Streaming is not supported for Responses API yet")
725
+ user_enable_streaming = bool(kwargs.get("stream", False)) or self.stream
726
+ if user_enable_streaming:
727
+ if on_token is None and not self.is_subscription:
728
+ # We allow on_token to be None for subscription mode
729
+ raise ValueError("Streaming requires an on_token callback")
730
+ kwargs["stream"] = True
699
731
 
700
732
  # Build instructions + input list using dedicated Responses formatter
701
733
  instructions, input_items = self.format_messages_for_responses(messages)
@@ -771,12 +803,67 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
771
803
  seed=self.seed,
772
804
  **final_kwargs,
773
805
  )
774
- assert isinstance(ret, ResponsesAPIResponse), (
806
+ if isinstance(ret, ResponsesAPIResponse):
807
+ if user_enable_streaming:
808
+ logger.warning(
809
+ "Responses streaming was requested, but the provider "
810
+ "returned a non-streaming response; no on_token deltas "
811
+ "will be emitted."
812
+ )
813
+ self._telemetry.on_response(ret)
814
+ return ret
815
+
816
+ # When stream=True, LiteLLM returns a streaming iterator rather than
817
+ # a single ResponsesAPIResponse. Drain the iterator and use the
818
+ # completed response.
819
+ if final_kwargs.get("stream", False):
820
+ if not isinstance(ret, SyncResponsesAPIStreamingIterator):
821
+ raise AssertionError(
822
+ f"Expected Responses stream iterator, got {type(ret)}"
823
+ )
824
+
825
+ stream_callback = on_token if user_enable_streaming else None
826
+ for event in ret:
827
+ if stream_callback is None:
828
+ continue
829
+ if isinstance(
830
+ event,
831
+ (
832
+ OutputTextDeltaEvent,
833
+ RefusalDeltaEvent,
834
+ ReasoningSummaryTextDeltaEvent,
835
+ ),
836
+ ):
837
+ delta = event.delta
838
+ if delta:
839
+ stream_callback(
840
+ ModelResponseStream(
841
+ choices=[
842
+ StreamingChoices(
843
+ delta=Delta(content=delta)
844
+ )
845
+ ]
846
+ )
847
+ )
848
+
849
+ completed_event = ret.completed_response
850
+ if completed_event is None:
851
+ raise LLMNoResponseError(
852
+ "Responses stream finished without a completed response"
853
+ )
854
+ if not isinstance(completed_event, ResponseCompletedEvent):
855
+ raise LLMNoResponseError(
856
+ f"Unexpected completed event: {type(completed_event)}"
857
+ )
858
+
859
+ completed_resp = completed_event.response
860
+
861
+ self._telemetry.on_response(completed_resp)
862
+ return completed_resp
863
+
864
+ raise AssertionError(
775
865
  f"Expected ResponsesAPIResponse, got {type(ret)}"
776
866
  )
777
- # telemetry (latency, cost). Token usage mapping we handle after.
778
- self._telemetry.on_response(ret)
779
- return ret
780
867
 
781
868
  try:
782
869
  resp: ResponsesAPIResponse = _one_attempt()
@@ -1046,8 +1133,9 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
1046
1133
 
1047
1134
  - Skips prompt caching flags and string serializer concerns
1048
1135
  - Uses Message.to_responses_value to get either instructions (system)
1049
- or input items (others)
1136
+ or input items (others)
1050
1137
  - Concatenates system instructions into a single instructions string
1138
+ - For subscription mode, system prompts are prepended to user content
1051
1139
  """
1052
1140
  msgs = copy.deepcopy(messages)
1053
1141
 
@@ -1057,18 +1145,26 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
1057
1145
  # Assign system instructions as a string, collect input items
1058
1146
  instructions: str | None = None
1059
1147
  input_items: list[dict[str, Any]] = []
1148
+ system_chunks: list[str] = []
1149
+
1060
1150
  for m in msgs:
1061
1151
  val = m.to_responses_value(vision_enabled=vision_active)
1062
1152
  if isinstance(val, str):
1063
1153
  s = val.strip()
1064
- if not s:
1065
- continue
1066
- instructions = (
1067
- s if instructions is None else f"{instructions}\n\n---\n\n{s}"
1068
- )
1069
- else:
1070
- if val:
1071
- input_items.extend(val)
1154
+ if s:
1155
+ if self.is_subscription:
1156
+ system_chunks.append(s)
1157
+ else:
1158
+ instructions = (
1159
+ s
1160
+ if instructions is None
1161
+ else f"{instructions}\n\n---\n\n{s}"
1162
+ )
1163
+ elif val:
1164
+ input_items.extend(val)
1165
+
1166
+ if self.is_subscription:
1167
+ return transform_for_subscription(system_chunks, input_items)
1072
1168
  return instructions, input_items
1073
1169
 
1074
1170
  def get_token_count(self, messages: list[Message]) -> int:
@@ -1159,3 +1255,62 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
1159
1255
  if v is not None:
1160
1256
  data[field_name] = v
1161
1257
  return cls(**data)
1258
+
1259
+ @classmethod
1260
+ def subscription_login(
1261
+ cls,
1262
+ vendor: SupportedVendor,
1263
+ model: str,
1264
+ force_login: bool = False,
1265
+ open_browser: bool = True,
1266
+ **llm_kwargs,
1267
+ ) -> LLM:
1268
+ """Authenticate with a subscription service and return an LLM instance.
1269
+
1270
+ This method provides subscription-based access to LLM models that are
1271
+ available through chat subscriptions (e.g., ChatGPT Plus/Pro) rather
1272
+ than API credits. It handles credential caching, token refresh, and
1273
+ the OAuth login flow.
1274
+
1275
+ Currently supported vendors:
1276
+ - "openai": ChatGPT Plus/Pro subscription for Codex models
1277
+
1278
+ Supported OpenAI models:
1279
+ - gpt-5.1-codex-max
1280
+ - gpt-5.1-codex-mini
1281
+ - gpt-5.2
1282
+ - gpt-5.2-codex
1283
+
1284
+ Args:
1285
+ vendor: The vendor/provider. Currently only "openai" is supported.
1286
+ model: The model to use. Must be supported by the vendor's
1287
+ subscription service.
1288
+ force_login: If True, always perform a fresh login even if valid
1289
+ credentials exist.
1290
+ open_browser: Whether to automatically open the browser for the
1291
+ OAuth login flow.
1292
+ **llm_kwargs: Additional arguments to pass to the LLM constructor.
1293
+
1294
+ Returns:
1295
+ An LLM instance configured for subscription-based access.
1296
+
1297
+ Raises:
1298
+ ValueError: If the vendor or model is not supported.
1299
+ RuntimeError: If authentication fails.
1300
+
1301
+ Example:
1302
+ >>> from openhands.sdk import LLM
1303
+ >>> # First time: opens browser for OAuth login
1304
+ >>> llm = LLM.subscription_login(vendor="openai", model="gpt-5.2-codex")
1305
+ >>> # Subsequent calls: reuses cached credentials
1306
+ >>> llm = LLM.subscription_login(vendor="openai", model="gpt-5.2-codex")
1307
+ """
1308
+ from openhands.sdk.llm.auth.openai import subscription_login
1309
+
1310
+ return subscription_login(
1311
+ vendor=vendor,
1312
+ model=model,
1313
+ force_login=force_login,
1314
+ open_browser=open_browser,
1315
+ **llm_kwargs,
1316
+ )
@@ -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": c.text,
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.
@@ -15,15 +15,16 @@ def select_responses_options(
15
15
  ) -> dict[str, Any]:
16
16
  """Behavior-preserving extraction of _normalize_responses_kwargs."""
17
17
  # Apply defaults for keys that are not forced by policy
18
- out = apply_defaults_if_absent(
19
- user_kwargs,
20
- {
21
- "max_output_tokens": llm.max_output_tokens,
22
- },
23
- )
18
+ # Note: max_output_tokens is not supported in subscription mode
19
+ defaults = {}
20
+ if not llm.is_subscription:
21
+ defaults["max_output_tokens"] = llm.max_output_tokens
22
+ out = apply_defaults_if_absent(user_kwargs, defaults)
24
23
 
25
24
  # Enforce sampling/tool behavior for Responses path
26
- out["temperature"] = 1.0
25
+ # Note: temperature is not supported in subscription mode
26
+ if not llm.is_subscription:
27
+ out["temperature"] = 1.0
27
28
  out["tool_choice"] = "auto"
28
29
 
29
30
  # If user didn't set extra_headers, propagate from llm config
@@ -155,6 +155,7 @@ FORCE_STRING_SERIALIZER_MODELS: list[str] = [
155
155
  # in the message input
156
156
  SEND_REASONING_CONTENT_MODELS: list[str] = [
157
157
  "kimi-k2-thinking",
158
+ "kimi-k2.5",
158
159
  "openrouter/minimax-m2", # MiniMax-M2 via OpenRouter (interleaved thinking)
159
160
  "deepseek/deepseek-reasoner",
160
161
  ]
@@ -181,6 +182,7 @@ def get_features(model: str) -> ModelFeatures:
181
182
  # Each entry: (pattern, default_temperature)
182
183
  DEFAULT_TEMPERATURE_MODELS: list[tuple[str, float]] = [
183
184
  ("kimi-k2-thinking", 1.0),
185
+ ("kimi-k2.5", 1.0),
184
186
  ]
185
187
 
186
188
 
@@ -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__(self, tool_name: str, client: MCPClient):
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
- return self.client.call_async_from_sync(
87
- self.call_tool, action=action, timeout=300
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]] = {}
@@ -92,7 +92,19 @@ class LookupSecret(SecretSource):
92
92
  return result
93
93
 
94
94
 
95
- _SECRET_HEADERS = ["AUTHORIZATION", "KEY", "SECRET"]
95
+ # Patterns used for substring matching against header names (case-insensitive).
96
+ # Headers containing any of these patterns will be redacted during serialization.
97
+ # Examples: X-Access-Token, Cookie, Authorization, X-API-Key, X-API-Secret
98
+ _SECRET_HEADERS = [
99
+ "AUTHORIZATION",
100
+ "COOKIE",
101
+ "CREDENTIAL",
102
+ "KEY",
103
+ "PASSWORD",
104
+ "SECRET",
105
+ "SESSION",
106
+ "TOKEN",
107
+ ]
96
108
 
97
109
 
98
110
  def _is_secret_header(key: str):
@@ -50,12 +50,17 @@ class RemoteWorkspace(RemoteWorkspaceMixin, BaseWorkspace):
50
50
  if client is None:
51
51
  # Configure reasonable timeouts for HTTP requests
52
52
  # - connect: 10 seconds to establish connection
53
- # - read: 60 seconds to read response (for LLM operations)
53
+ # - read: 600 seconds (10 minutes) to read response (for LLM operations)
54
54
  # - write: 10 seconds to send request
55
55
  # - pool: 10 seconds to get connection from pool
56
- timeout = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0)
56
+ timeout = httpx.Timeout(
57
+ connect=10.0, read=self.read_timeout, write=10.0, pool=10.0
58
+ )
57
59
  client = httpx.Client(
58
- base_url=self.host, timeout=timeout, headers=self._headers
60
+ base_url=self.host,
61
+ timeout=timeout,
62
+ headers=self._headers,
63
+ limits=httpx.Limits(max_connections=self.max_connections),
59
64
  )
60
65
  self._client = client
61
66
  return client
@@ -25,6 +25,15 @@ class RemoteWorkspaceMixin(BaseModel):
25
25
  working_dir: str = Field(
26
26
  description="The working directory for agent operations and tool execution."
27
27
  )
28
+ read_timeout: float = Field(
29
+ default=600.0,
30
+ description="Timeout in seconds for reading operations of httpx.Client.",
31
+ )
32
+ max_connections: int | None = Field(
33
+ default=None,
34
+ description="Maximum number of connections for httpx.Client. "
35
+ "None means no limit, useful for running many conversations in parallel.",
36
+ )
28
37
 
29
38
  def model_post_init(self, context: Any) -> None:
30
39
  # Set up remote host
@@ -87,26 +96,50 @@ class RemoteWorkspaceMixin(BaseModel):
87
96
  stdout_parts = []
88
97
  stderr_parts = []
89
98
  exit_code = None
99
+ last_order = -1 # Track highest order seen to fetch only new events
100
+ seen_event_ids: set[str] = set() # Track seen IDs to detect duplicates
90
101
 
91
102
  while time.time() - start_time < timeout:
92
- # Search for all events
103
+ # Search for new events (order > last_order)
104
+ params: dict[str, str | int] = {
105
+ "command_id__eq": command_id,
106
+ "sort_order": "TIMESTAMP",
107
+ "limit": 100,
108
+ "kind__eq": "BashOutput",
109
+ }
110
+ if last_order >= 0:
111
+ params["order__gt"] = last_order
112
+
93
113
  response = yield {
94
114
  "method": "GET",
95
115
  "url": f"{self.host}/api/bash/bash_events/search",
96
- "params": {
97
- "command_id__eq": command_id,
98
- "sort_order": "TIMESTAMP",
99
- "limit": 100,
100
- },
116
+ "params": params,
101
117
  "headers": self._headers,
102
118
  "timeout": timeout,
103
119
  }
104
120
  response.raise_for_status()
105
121
  search_result = response.json()
106
122
 
107
- # Filter for BashOutput events for this command
123
+ # Process BashOutput events
108
124
  for event in search_result.get("items", []):
109
125
  if event.get("kind") == "BashOutput":
126
+ # Check for duplicates - safety check in case caller
127
+ # forgets to add kind__eq filter or API has a bug
128
+ event_id = event.get("id")
129
+ if event_id is not None:
130
+ if event_id in seen_event_ids:
131
+ raise RuntimeError(
132
+ f"Duplicate event received: {event_id}. "
133
+ "This should not happen with order__gt "
134
+ "filtering and kind filtering."
135
+ )
136
+ seen_event_ids.add(event_id)
137
+
138
+ # Track the highest order we've seen
139
+ event_order = event.get("order")
140
+ if event_order is not None and event_order > last_order:
141
+ last_order = event_order
142
+
110
143
  if event.get("stdout"):
111
144
  stdout_parts.append(event["stdout"])
112
145
  if event.get("stderr"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-sdk
3
- Version: 1.10.0
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