openhands-agent-server 1.40.0__tar.gz → 1.40.1__tar.gz

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 (81) hide show
  1. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/PKG-INFO +1 -1
  2. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/_secrets_exposure.py +19 -0
  3. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/agent_profiles_router.py +16 -33
  4. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/config.py +17 -2
  5. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/conversation_service.py +147 -34
  6. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/mcp_oauth_store.py +4 -1
  7. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/profiles_router.py +7 -25
  8. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/settings_router.py +43 -0
  9. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  10. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/pyproject.toml +1 -1
  11. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/__init__.py +0 -0
  12. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/__main__.py +0 -0
  13. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/_secret_redaction.py +0 -0
  14. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/api.py +0 -0
  15. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/auth_router.py +0 -0
  16. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/bash_router.py +0 -0
  17. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/bash_service.py +0 -0
  18. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/conversation_lease.py +0 -0
  19. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/conversation_router.py +0 -0
  20. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/credential_binding.py +0 -0
  21. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/dependencies.py +0 -0
  22. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/desktop_router.py +0 -0
  23. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/desktop_service.py +0 -0
  24. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/docker/Dockerfile +0 -0
  25. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/docker/build.py +0 -0
  26. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/docker/wallpaper.svg +0 -0
  27. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/env_parser.py +0 -0
  28. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/event_router.py +0 -0
  29. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/event_service.py +0 -0
  30. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/file_router.py +0 -0
  31. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/git_router.py +0 -0
  32. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/hooks_router.py +0 -0
  33. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/hooks_service.py +0 -0
  34. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/init_router.py +0 -0
  35. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/llm_router.py +0 -0
  36. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/logging_config.py +0 -0
  37. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/mcp_router.py +0 -0
  38. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/middleware.py +0 -0
  39. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/models.py +0 -0
  40. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/openai/__init__.py +0 -0
  41. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/openai/models.py +0 -0
  42. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/openai/router.py +0 -0
  43. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/openai/service.py +0 -0
  44. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/openapi.py +0 -0
  45. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/persistence/__init__.py +0 -0
  46. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/persistence/models.py +0 -0
  47. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/persistence/store.py +0 -0
  48. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/plugins_router.py +0 -0
  49. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/plugins_service.py +0 -0
  50. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/pub_sub.py +0 -0
  51. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/py.typed +0 -0
  52. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/server_details_router.py +0 -0
  53. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/skills_router.py +0 -0
  54. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/skills_service.py +0 -0
  55. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/sockets.py +0 -0
  56. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/sub_agents_router.py +0 -0
  57. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/telemetry/__init__.py +0 -0
  58. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/telemetry/factory.py +0 -0
  59. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/telemetry/http_exporter.py +0 -0
  60. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/telemetry/models.py +0 -0
  61. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/telemetry/policy.py +0 -0
  62. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/telemetry/posthog_exporter.py +0 -0
  63. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/telemetry/sanitizer.py +0 -0
  64. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/telemetry/service.py +0 -0
  65. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/telemetry/sink.py +0 -0
  66. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/telemetry/subscriber.py +0 -0
  67. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/tool_preload_service.py +0 -0
  68. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/tool_router.py +0 -0
  69. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/utils.py +0 -0
  70. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  71. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  72. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/vscode_router.py +0 -0
  73. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/vscode_service.py +0 -0
  74. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/workspace_router.py +0 -0
  75. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands/agent_server/workspaces_router.py +0 -0
  76. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands_agent_server.egg-info/SOURCES.txt +0 -0
  77. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  78. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  79. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands_agent_server.egg-info/requires.txt +0 -0
  80. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/openhands_agent_server.egg-info/top_level.txt +0 -0
  81. {openhands_agent_server-1.40.0 → openhands_agent_server-1.40.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.40.0
3
+ Version: 1.40.1
4
4
  Summary: OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent
5
5
  Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
6
6
  Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
@@ -120,3 +120,22 @@ def translate_missing_cipher() -> Iterator[None]:
120
120
  ),
121
121
  )
122
122
  raise
123
+
124
+
125
+ @contextmanager
126
+ def store_errors() -> Iterator[None]:
127
+ """Map profile-store errors (``LLMProfileStore``/``AgentProfileStore``) to
128
+ HTTP responses. Shared by the settings, profiles, and agent-profiles
129
+ routers."""
130
+ try:
131
+ yield
132
+ except TimeoutError:
133
+ raise HTTPException(
134
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
135
+ detail="Profile store is busy. Please retry.",
136
+ )
137
+ except ValueError as e:
138
+ raise HTTPException(
139
+ status_code=status.HTTP_400_BAD_REQUEST,
140
+ detail=str(e),
141
+ )
@@ -12,14 +12,16 @@ MCP references and returns :class:`~openhands.sdk.profiles.AgentProfileDiagnosti
12
12
  """
13
13
 
14
14
  import asyncio
15
- from collections.abc import Iterator
16
- from contextlib import contextmanager
17
15
  from typing import Annotated, Any
18
16
 
19
17
  from fastapi import APIRouter, HTTPException, Path, Request, status
20
18
  from pydantic import BaseModel, Field, ValidationError
21
19
 
22
- from openhands.agent_server._secrets_exposure import get_cipher, get_config
20
+ from openhands.agent_server._secrets_exposure import (
21
+ get_cipher,
22
+ get_config,
23
+ store_errors,
24
+ )
23
25
  from openhands.agent_server.persistence import (
24
26
  PersistedSettings,
25
27
  get_agent_profile_store,
@@ -105,25 +107,6 @@ class RenameAgentProfileRequest(BaseModel):
105
107
  )
106
108
 
107
109
 
108
- @contextmanager
109
- def _store_errors() -> Iterator[None]:
110
- """Map ``AgentProfileStore`` errors to HTTP responses.
111
-
112
- Mirrors ``profiles_router._store_errors``: ``TimeoutError`` and
113
- ``ValueError`` only. ``FileNotFoundError`` / ``FileExistsError`` are handled
114
- inline per-endpoint so each gets a clean, resource-specific message.
115
- """
116
- try:
117
- yield
118
- except TimeoutError:
119
- raise HTTPException(
120
- status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
121
- detail="Agent profile store is busy. Please retry.",
122
- )
123
- except ValueError as e:
124
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
125
-
126
-
127
110
  def _llm_has_real_config(llm: LLM) -> bool:
128
111
  """True when ``llm`` carries real, user-provided configuration.
129
112
 
@@ -173,7 +156,7 @@ def _seed_default_llm_profile(llm: LLM, cipher: Cipher | None) -> str:
173
156
  silently clobber it.
174
157
  """
175
158
  llm_store = get_llm_profile_store()
176
- with _store_errors():
159
+ with store_errors():
177
160
  try:
178
161
  llm_store.load(SEED_PROFILE_NAME, cipher=cipher)
179
162
  return SEED_PROFILE_NAME
@@ -227,7 +210,7 @@ def _seed_default_profile(
227
210
  The lock spans empty-check + save + pointer write so concurrent first
228
211
  requests seed exactly once and the pointer matches the persisted id.
229
212
  """
230
- with _store_errors(), store.lock():
213
+ with store_errors(), store.lock():
231
214
  # Double-checked under the lock: a concurrent first request may have
232
215
  # already seeded (the outer emptiness check in the list endpoint is
233
216
  # unlocked).
@@ -261,7 +244,7 @@ def _seed_default_profile(
261
244
 
262
245
  def _summary_id_for_name(store: AgentProfileStore, name: str) -> str | None:
263
246
  """Return the stable id of the profile stored under ``name``, if present."""
264
- with _store_errors():
247
+ with store_errors():
265
248
  for summary in store.list_summaries():
266
249
  if summary.get("name") == name:
267
250
  sid = summary.get("id")
@@ -282,14 +265,14 @@ async def list_agent_profiles(request: Request) -> AgentProfileListResponse:
282
265
  settings = settings_store.load() or PersistedSettings()
283
266
 
284
267
  store = get_agent_profile_store()
285
- with _store_errors():
268
+ with store_errors():
286
269
  existing = store.list()
287
270
 
288
271
  if not existing and settings.active_agent_profile_id is None:
289
272
  _seed_default_profile(store, request, settings, get_cipher(request))
290
273
  settings = settings_store.load() or settings
291
274
 
292
- with _store_errors():
275
+ with store_errors():
293
276
  summaries = store.list_summaries()
294
277
 
295
278
  return AgentProfileListResponse(
@@ -308,7 +291,7 @@ async def get_agent_profile(name: ProfileName) -> AgentProfileDetailResponse:
308
291
  """
309
292
  store = get_agent_profile_store()
310
293
  try:
311
- with _store_errors():
294
+ with store_errors():
312
295
  profile = store.load(name)
313
296
  except FileNotFoundError:
314
297
  raise HTTPException(
@@ -361,7 +344,7 @@ async def save_agent_profile(
361
344
  # holds the store lock across read + mint + save so two concurrent creates
362
345
  # of the same new name can't both mint an id and clobber each other.
363
346
  try:
364
- with _store_errors():
347
+ with store_errors():
365
348
  save_profile_preserving_identity(
366
349
  store, profile, max_profiles=MAX_AGENT_PROFILES
367
350
  )
@@ -392,7 +375,7 @@ async def delete_agent_profile(
392
375
  store = get_agent_profile_store()
393
376
  deleted_id = _summary_id_for_name(store, name)
394
377
 
395
- with _store_errors():
378
+ with store_errors():
396
379
  store.delete(name)
397
380
 
398
381
  if deleted_id is not None:
@@ -428,7 +411,7 @@ async def rename_agent_profile(
428
411
  """
429
412
  store = get_agent_profile_store()
430
413
  try:
431
- with _store_errors():
414
+ with store_errors():
432
415
  store.rename(name, body.new_name)
433
416
  except FileNotFoundError:
434
417
  raise HTTPException(
@@ -462,7 +445,7 @@ async def activate_agent_profile(
462
445
  creation-time-only contract). Returns 404 if no stored profile has that id.
463
446
  """
464
447
  store = get_agent_profile_store()
465
- with _store_errors():
448
+ with store_errors():
466
449
  known_ids = {
467
450
  str(s["id"]) for s in store.list_summaries() if s.get("id") is not None
468
451
  }
@@ -515,7 +498,7 @@ async def materialize_agent_profile(
515
498
  """
516
499
  store = get_agent_profile_store()
517
500
  try:
518
- with _store_errors():
501
+ with store_errors():
519
502
  profile = store.load(name)
520
503
  except FileNotFoundError:
521
504
  raise HTTPException(
@@ -106,11 +106,26 @@ class WebhookSpec(BaseModel):
106
106
  default=1000,
107
107
  ge=1,
108
108
  description=(
109
- "Upper bound on the number of events buffered for delivery. When the "
110
- "downstream is failing and events are re-queued for retry, the oldest "
109
+ "Upper bound on the number of events buffered for delivery. The oldest "
111
110
  "events are dropped past this bound to prevent unbounded memory growth."
112
111
  ),
113
112
  )
113
+ max_batch_bytes: int = Field(
114
+ default=5 * 1024 * 1024,
115
+ ge=1,
116
+ description=(
117
+ "Upper bound on the serialized size of each webhook request. A single "
118
+ "event larger than this limit is sent by itself."
119
+ ),
120
+ )
121
+ max_queue_bytes: int = Field(
122
+ default=50 * 1024 * 1024,
123
+ ge=1,
124
+ description=(
125
+ "Upper bound on the serialized size of events buffered for delivery. "
126
+ "The oldest events are dropped when the queue exceeds this bound."
127
+ ),
128
+ )
114
129
 
115
130
 
116
131
  TelemetryExporterKind = Literal["none", "posthog", "http"]
@@ -50,6 +50,7 @@ from openhands.sdk import LLM, AgentContext, Event, Message
50
50
  from openhands.sdk.agent import ACPAgent
51
51
  from openhands.sdk.agent.acp_file_credentials import CODEX_AUTH_SECRET_NAME
52
52
  from openhands.sdk.agent.base import AgentBase
53
+ from openhands.sdk.conversation.impl.local_conversation import LocalConversation
53
54
  from openhands.sdk.conversation.persistence_const import BASE_STATE
54
55
  from openhands.sdk.conversation.state import (
55
56
  ConversationExecutionStatus,
@@ -65,6 +66,7 @@ from openhands.sdk.event.conversation_state import ConversationStateUpdateEvent
65
66
  from openhands.sdk.git.exceptions import GitCommandError, GitRepositoryError
66
67
  from openhands.sdk.git.utils import run_git_command, validate_git_repository
67
68
  from openhands.sdk.mcp.utils import MCPToolProvider
69
+ from openhands.sdk.observability import OPERATION_METADATA_KEY, observe
68
70
  from openhands.sdk.tool import BROWSER_TOOL_NAME, Tool, is_tool_usable
69
71
  from openhands.sdk.tool.client_tool import register_client_tools
70
72
  from openhands.sdk.utils.cipher import Cipher
@@ -2193,6 +2195,22 @@ class _EventSubscriber(Subscriber):
2193
2195
  update_last_execution_time()
2194
2196
 
2195
2197
 
2198
+ @observe(
2199
+ name="conversation.generate_title",
2200
+ ignore_inputs=["conversation", "llm"],
2201
+ metadata={OPERATION_METADATA_KEY: "title_generation"},
2202
+ )
2203
+ def _generate_title_traced(
2204
+ # Unused, but must stay first and positional: ``observe`` re-attaches the
2205
+ # root span it carries, and this runs on a context-less executor thread.
2206
+ conversation: LocalConversation | None, # noqa: ARG001
2207
+ message: str,
2208
+ llm: LLM | None,
2209
+ max_length: int,
2210
+ ) -> str:
2211
+ return generate_title_from_message(message, llm, max_length)
2212
+
2213
+
2196
2214
  @dataclass
2197
2215
  class AutoTitleSubscriber(Subscriber):
2198
2216
  service: EventService
@@ -2215,9 +2233,9 @@ class AutoTitleSubscriber(Subscriber):
2215
2233
  # Precedence: title_llm_profile (if configured and loads) → agent.llm →
2216
2234
  # truncation. This keeps auto-titling non-breaking for consumers who
2217
2235
  # don't configure title_llm_profile.
2236
+ conversation = self.service._conversation
2218
2237
  title_llm = self._load_title_llm()
2219
2238
  if title_llm is None:
2220
- conversation = self.service._conversation
2221
2239
  title_llm = conversation.agent.llm if conversation else None
2222
2240
 
2223
2241
  async def _generate_and_save() -> None:
@@ -2225,7 +2243,8 @@ class AutoTitleSubscriber(Subscriber):
2225
2243
  loop = asyncio.get_running_loop()
2226
2244
  title = await loop.run_in_executor(
2227
2245
  None,
2228
- generate_title_from_message,
2246
+ _generate_title_traced,
2247
+ conversation,
2229
2248
  message_text,
2230
2249
  title_llm,
2231
2250
  50,
@@ -2278,6 +2297,11 @@ class WebhookSubscriber(Subscriber):
2278
2297
  session_api_key: str | None = None
2279
2298
  queue: list[Event] = field(default_factory=list)
2280
2299
  _flush_timer: asyncio.Task | None = field(default=None, init=False)
2300
+ _post_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
2301
+ _queue_sizes: list[int] = field(default_factory=list, init=False)
2302
+ _queue_bytes: int = field(default=0, init=False)
2303
+ _dropped_events: int = field(default=0, init=False)
2304
+ _closed: bool = field(default=False, init=False)
2281
2305
  # Per-instance sleep seam so tests override delays without patching the
2282
2306
  # global asyncio.sleep. default_factory (not default) keeps it an instance
2283
2307
  # attribute, else the function would be descriptor-bound as a method.
@@ -2287,17 +2311,24 @@ class WebhookSubscriber(Subscriber):
2287
2311
 
2288
2312
  async def __call__(self, event: Event):
2289
2313
  """Add event to queue and post to webhook when buffer size is reached."""
2290
- self.queue.append(event)
2314
+ self._enqueue(event)
2291
2315
 
2292
- if len(self.queue) >= self.spec.event_buffer_size:
2316
+ if (
2317
+ len(self.queue) >= self.spec.event_buffer_size
2318
+ or self._queue_bytes >= self.spec.max_batch_bytes
2319
+ ):
2320
+ if self._post_lock.locked():
2321
+ self._start_flush_timer()
2322
+ return
2293
2323
  # Cancel timer since we're flushing due to buffer size
2294
2324
  self._cancel_flush_timer()
2295
2325
  await self._post_events()
2296
- elif not self._flush_timer:
2297
- self._flush_timer = asyncio.create_task(self._flush_after_delay())
2326
+ elif self.queue:
2327
+ self._start_flush_timer()
2298
2328
 
2299
2329
  async def close(self):
2300
2330
  """Post any remaining items in the queue to the webhook."""
2331
+ self._closed = True
2301
2332
  # Cancel any pending flush timer
2302
2333
  self._cancel_flush_timer()
2303
2334
 
@@ -2305,30 +2336,26 @@ class WebhookSubscriber(Subscriber):
2305
2336
  await self._post_events()
2306
2337
 
2307
2338
  async def _post_events(self):
2308
- """Post queued events to the webhook with retry logic."""
2309
- if not self.queue:
2310
- return
2339
+ """Post bounded batches serially until the queue is empty or a post fails."""
2340
+ async with self._post_lock:
2341
+ self._sync_queue_sizes()
2342
+ self._trim_queue()
2343
+ events_remaining = len(self.queue)
2344
+ while events_remaining:
2345
+ events_to_post, event_data = self._take_batch(events_remaining)
2346
+ if not await self._post_batch(event_data):
2347
+ self._requeue(events_to_post)
2348
+ return
2349
+ events_remaining -= len(events_to_post)
2311
2350
 
2312
- events_to_post = self.queue.copy()
2313
- self.queue.clear()
2351
+ async def _post_batch(self, event_data: list[dict[str, Any]]) -> bool:
2352
+ """Post one serialized batch with retry logic."""
2314
2353
 
2315
2354
  # Prepare headers
2316
2355
  headers = self.spec.headers.copy()
2317
2356
  if self.session_api_key:
2318
2357
  headers["X-Session-API-Key"] = self.session_api_key
2319
2358
 
2320
- # Convert events to a JSON-serializable format. mode="json" is required
2321
- # so types like set and SecretStr become JSON-safe primitives; without
2322
- # it httpx's encoder raises "Object of type set/SecretStr is not JSON
2323
- # serializable", every retry fails identically, and the events are
2324
- # dropped. (Mirrors ConversationWebhookSubscriber.post_conversation_info.)
2325
- event_data = [
2326
- event.model_dump(mode="json")
2327
- if hasattr(event, "model_dump")
2328
- else event.__dict__
2329
- for event in events_to_post
2330
- ]
2331
-
2332
2359
  # Construct events URL
2333
2360
  events_url = (
2334
2361
  f"{self.spec.base_url.rstrip('/')}/events/{self.conversation_id.hex}"
@@ -2350,7 +2377,7 @@ class WebhookSubscriber(Subscriber):
2350
2377
  f"Successfully posted {len(event_data)} events "
2351
2378
  f"to webhook {events_url}"
2352
2379
  )
2353
- return
2380
+ return True
2354
2381
  except Exception as e:
2355
2382
  logger.warning(f"Webhook post attempt {attempt + 1} failed: {e}")
2356
2383
  if attempt < self.spec.num_retries:
@@ -2360,15 +2387,95 @@ class WebhookSubscriber(Subscriber):
2360
2387
  f"Failed to post events to webhook {events_url} "
2361
2388
  f"after {self.spec.num_retries + 1} attempts"
2362
2389
  )
2363
- self.queue.extend(events_to_post)
2364
- overflow = len(self.queue) - self.spec.max_queue_size
2365
- if overflow > 0:
2366
- del self.queue[:overflow]
2367
- logger.warning(
2368
- f"Webhook queue exceeded max_queue_size="
2369
- f"{self.spec.max_queue_size}; dropped {overflow} "
2370
- f"oldest event(s) for {events_url}."
2371
- )
2390
+ return False
2391
+
2392
+ @staticmethod
2393
+ def _event_data(event: Event) -> dict[str, Any]:
2394
+ # mode="json" makes types such as set and SecretStr JSON-safe.
2395
+ if hasattr(event, "model_dump"):
2396
+ return event.model_dump(mode="json")
2397
+ return event.__dict__
2398
+
2399
+ @classmethod
2400
+ def _event_size(cls, event: Event) -> int:
2401
+ return len(
2402
+ json.dumps(
2403
+ cls._event_data(event),
2404
+ ensure_ascii=False,
2405
+ separators=(",", ":"),
2406
+ allow_nan=False,
2407
+ ).encode()
2408
+ )
2409
+
2410
+ def _sync_queue_sizes(self):
2411
+ """Refresh size accounting after callers directly replace the public queue."""
2412
+ if len(self._queue_sizes) != len(self.queue):
2413
+ self._queue_sizes = [self._event_size(event) for event in self.queue]
2414
+ self._queue_bytes = sum(self._queue_sizes)
2415
+
2416
+ def _enqueue(self, event: Event):
2417
+ self._sync_queue_sizes()
2418
+ event_size = self._event_size(event)
2419
+ self.queue.append(event)
2420
+ self._queue_sizes.append(event_size)
2421
+ self._queue_bytes += event_size
2422
+ self._trim_queue()
2423
+
2424
+ def _requeue(self, events: list[Event]):
2425
+ sizes = [self._event_size(event) for event in events]
2426
+ self.queue[:0] = events
2427
+ self._queue_sizes[:0] = sizes
2428
+ self._queue_bytes += sum(sizes)
2429
+ self._trim_queue()
2430
+
2431
+ def _trim_queue(self):
2432
+ dropped = 0
2433
+ while self.queue and (
2434
+ len(self.queue) > self.spec.max_queue_size
2435
+ or self._queue_bytes > self.spec.max_queue_bytes
2436
+ ):
2437
+ del self.queue[0]
2438
+ self._queue_bytes -= self._queue_sizes.pop(0)
2439
+ dropped += 1
2440
+ if dropped:
2441
+ previous_dropped = self._dropped_events
2442
+ self._dropped_events += dropped
2443
+ if (
2444
+ previous_dropped == 0
2445
+ or self._dropped_events // 100 > previous_dropped // 100
2446
+ ):
2447
+ logger.warning(
2448
+ "Webhook queue exceeded its configured count or byte limit; "
2449
+ f"dropped {self._dropped_events} event(s) so far for conversation "
2450
+ f"{self.conversation_id.hex}."
2451
+ )
2452
+
2453
+ def _take_batch(self, max_events: int) -> tuple[list[Event], list[dict[str, Any]]]:
2454
+ self._sync_queue_sizes()
2455
+ batch_size = 2 # JSON array brackets
2456
+ batch_count = 0
2457
+ event_data: list[dict[str, Any]] = []
2458
+
2459
+ for event, event_size in zip(self.queue, self._queue_sizes, strict=True):
2460
+ next_size = batch_size + event_size + (1 if batch_count else 0)
2461
+ if batch_count and next_size > self.spec.max_batch_bytes:
2462
+ break
2463
+ event_data.append(self._event_data(event))
2464
+ batch_size = next_size
2465
+ batch_count += 1
2466
+ if batch_count >= min(self.spec.event_buffer_size, max_events):
2467
+ break
2468
+
2469
+ events = self.queue[:batch_count]
2470
+ del self.queue[:batch_count]
2471
+ removed_sizes = self._queue_sizes[:batch_count]
2472
+ del self._queue_sizes[:batch_count]
2473
+ self._queue_bytes -= sum(removed_sizes)
2474
+ return events, event_data
2475
+
2476
+ def _start_flush_timer(self):
2477
+ if not self._closed and not self._flush_timer:
2478
+ self._flush_timer = asyncio.create_task(self._flush_after_delay())
2372
2479
 
2373
2480
  def _cancel_flush_timer(self):
2374
2481
  """Cancel the current flush timer if it exists."""
@@ -2378,16 +2485,22 @@ class WebhookSubscriber(Subscriber):
2378
2485
 
2379
2486
  async def _flush_after_delay(self):
2380
2487
  """Wait for flush_delay seconds then flush events if any exist."""
2488
+ current_task = asyncio.current_task()
2489
+ should_reschedule = False
2381
2490
  try:
2382
2491
  await self._sleep(self.spec.flush_delay)
2383
2492
  # Only flush if there are events in the queue
2384
2493
  if self.queue:
2385
2494
  await self._post_events()
2495
+ should_reschedule = bool(self.queue)
2386
2496
  except asyncio.CancelledError:
2387
2497
  # Timer was cancelled, which is expected behavior
2388
2498
  pass
2389
2499
  finally:
2390
- self._flush_timer = None
2500
+ if self._flush_timer is current_task:
2501
+ self._flush_timer = None
2502
+ if should_reschedule:
2503
+ self._start_flush_timer()
2391
2504
 
2392
2505
 
2393
2506
  @dataclass
@@ -24,7 +24,10 @@ from openhands.sdk.mcp.config import (
24
24
  MCPOAuthTokenStorageField,
25
25
  MCPServer,
26
26
  )
27
- from openhands.sdk.mcp.utils import ToolsChangedCallback, create_mcp_tools
27
+ from openhands.sdk.mcp.utils import (
28
+ ToolsChangedCallback,
29
+ create_mcp_tools,
30
+ )
28
31
 
29
32
 
30
33
  logger = get_logger(__name__)
@@ -1,7 +1,5 @@
1
1
  """HTTP endpoints for managing named LLM configurations (profiles)."""
2
2
 
3
- from collections.abc import Iterator
4
- from contextlib import contextmanager
5
3
  from typing import Annotated, Any
6
4
 
7
5
  from fastapi import APIRouter, HTTPException, Path, Request, status
@@ -13,6 +11,7 @@ from openhands.agent_server._secrets_exposure import (
13
11
  get_cipher,
14
12
  get_config,
15
13
  parse_expose_secrets_header,
14
+ store_errors,
16
15
  translate_missing_cipher,
17
16
  )
18
17
  from openhands.agent_server.persistence import (
@@ -88,23 +87,6 @@ class RenameProfileRequest(BaseModel):
88
87
  )
89
88
 
90
89
 
91
- @contextmanager
92
- def _store_errors() -> Iterator[None]:
93
- """Map ``LLMProfileStore`` errors to HTTP responses."""
94
- try:
95
- yield
96
- except TimeoutError:
97
- raise HTTPException(
98
- status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
99
- detail="Profile store is busy. Please retry.",
100
- )
101
- except ValueError as e:
102
- raise HTTPException(
103
- status_code=status.HTTP_400_BAD_REQUEST,
104
- detail=str(e),
105
- )
106
-
107
-
108
90
  def _has_api_key(llm: LLM) -> bool:
109
91
  if not isinstance(llm.api_key, SecretStr):
110
92
  return False
@@ -141,7 +123,7 @@ async def list_profiles(request: Request) -> ProfileListResponse:
141
123
  settings = settings_store.load() or PersistedSettings()
142
124
 
143
125
  store = get_llm_profile_store()
144
- with _store_errors():
126
+ with store_errors():
145
127
  summaries = store.list_summaries()
146
128
 
147
129
  return ProfileListResponse(
@@ -164,7 +146,7 @@ async def get_profile(request: Request, name: ProfileName) -> ProfileDetailRespo
164
146
 
165
147
  store = get_llm_profile_store()
166
148
  try:
167
- with _store_errors():
149
+ with store_errors():
168
150
  llm = store.load(name, cipher=cipher)
169
151
  except FileNotFoundError:
170
152
  raise HTTPException(
@@ -208,7 +190,7 @@ async def save_profile(
208
190
  llm = decrypt_incoming_llm_secrets(body.llm, cipher) if cipher else body.llm
209
191
  store = get_llm_profile_store()
210
192
  try:
211
- with _store_errors():
193
+ with store_errors():
212
194
  store.save(
213
195
  name,
214
196
  llm,
@@ -241,7 +223,7 @@ async def delete_profile(
241
223
  store = get_llm_profile_store()
242
224
  agent_store = get_agent_profile_store()
243
225
  try:
244
- with _store_errors():
226
+ with store_errors():
245
227
  delete_llm_profile(agent_store, store, name)
246
228
  except ProfileReferenced as e:
247
229
  raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
@@ -269,7 +251,7 @@ async def rename_profile(
269
251
  store = get_llm_profile_store()
270
252
  agent_store = get_agent_profile_store()
271
253
  try:
272
- with _store_errors():
254
+ with store_errors():
273
255
  rename_llm_profile(agent_store, store, name, body.new_name)
274
256
  except FileNotFoundError:
275
257
  raise HTTPException(
@@ -325,7 +307,7 @@ async def activate_profile(
325
307
  # Load the profile
326
308
  profile_store = get_llm_profile_store()
327
309
  try:
328
- with _store_errors():
310
+ with store_errors():
329
311
  llm = profile_store.load(name, cipher=cipher)
330
312
  except FileNotFoundError:
331
313
  raise HTTPException(
@@ -7,13 +7,16 @@ from pydantic import ValidationError
7
7
 
8
8
  from openhands.agent_server._secrets_exposure import (
9
9
  build_expose_context,
10
+ get_cipher,
10
11
  get_config,
11
12
  parse_expose_secrets_header,
13
+ store_errors,
12
14
  translate_missing_cipher,
13
15
  )
14
16
  from openhands.agent_server.persistence import (
15
17
  SECRET_NAME_PATTERN,
16
18
  PersistedSettings,
19
+ get_llm_profile_store,
17
20
  get_secrets_store,
18
21
  get_settings_store,
19
22
  )
@@ -179,6 +182,10 @@ async def update_settings(
179
182
 
180
183
  Accepts ``agent_settings_diff``, ``conversation_settings_diff``,
181
184
  ``misc_settings_diff``, and/or ``active_profile`` for incremental updates.
185
+ Setting ``active_profile`` loads and applies that profile's LLM, same as
186
+ ``POST /api/profiles/{name}/activate``, unless ``agent_settings_diff.llm``
187
+ is also given.
188
+
182
189
  The three ``*_settings_diff`` fields are deep-merged; nested objects merge
183
190
  recursively, and a ``null`` value **inside a nested map deletes that entry**
184
191
  — the "unset" primitive that lets a client remove a single map key without
@@ -226,11 +233,47 @@ async def update_settings(
226
233
  )
227
234
 
228
235
 
236
+ def _resolve_active_profile_llm(
237
+ request: Request, update_data: SettingsUpdatePayload
238
+ ) -> SettingsUpdatePayload:
239
+ """Fold the named profile's LLM into ``agent_settings_diff`` unless the
240
+ caller already gave one explicitly. Mirrors ``/activate``."""
241
+ profile_name = update_data.get("active_profile")
242
+ agent_diff = update_data.get("agent_settings_diff")
243
+ explicit_llm_diff = isinstance(agent_diff, dict) and "llm" in agent_diff
244
+ if not profile_name or explicit_llm_diff:
245
+ return update_data
246
+
247
+ cipher = get_cipher(request)
248
+ profile_store = get_llm_profile_store()
249
+ try:
250
+ with store_errors():
251
+ llm = profile_store.load(profile_name, cipher=cipher)
252
+ except FileNotFoundError:
253
+ raise HTTPException(
254
+ status_code=status.HTTP_404_NOT_FOUND,
255
+ detail=f"Profile '{profile_name}' not found",
256
+ )
257
+
258
+ return cast(
259
+ SettingsUpdatePayload,
260
+ {
261
+ **update_data,
262
+ "agent_settings_diff": {
263
+ **(agent_diff if isinstance(agent_diff, dict) else {}),
264
+ "llm": llm.model_dump(mode="json", context={"expose_secrets": True}),
265
+ },
266
+ },
267
+ )
268
+
269
+
229
270
  def _apply_settings_update(
230
271
  request: Request,
231
272
  update_data: SettingsUpdatePayload,
232
273
  before_update: Callable[[PersistedSettings], None] | None = None,
233
274
  ) -> SettingsResponse:
275
+ update_data = _resolve_active_profile_llm(request, update_data)
276
+
234
277
  # Apply updates atomically with file locking
235
278
  def apply_update(settings: PersistedSettings) -> PersistedSettings:
236
279
  if before_update is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.40.0
3
+ Version: 1.40.1
4
4
  Summary: OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent
5
5
  Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
6
6
  Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openhands-agent-server"
3
- version = "1.40.0"
3
+ version = "1.40.1"
4
4
  description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
5
5
 
6
6
  requires-python = ">=3.12"