openhands-agent-server 1.28.1__tar.gz → 1.29.2__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 (67) hide show
  1. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/PKG-INFO +1 -1
  2. openhands_agent_server-1.29.2/openhands/agent_server/agent_profiles_router.py +602 -0
  3. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/api.py +93 -55
  4. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/bash_router.py +20 -6
  5. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/config.py +65 -4
  6. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/conversation_router.py +105 -11
  7. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/conversation_service.py +113 -6
  8. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/dependencies.py +35 -32
  9. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/docker/Dockerfile +16 -7
  10. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/event_service.py +314 -18
  11. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/file_router.py +30 -16
  12. openhands_agent_server-1.29.2/openhands/agent_server/init_router.py +336 -0
  13. openhands_agent_server-1.29.2/openhands/agent_server/llm_router.py +262 -0
  14. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/middleware.py +20 -5
  15. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/models.py +40 -5
  16. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/openai/router.py +17 -18
  17. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/openai/service.py +7 -4
  18. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/persistence/__init__.py +4 -0
  19. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/persistence/models.py +43 -59
  20. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/persistence/store.py +61 -0
  21. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/profiles_router.py +28 -12
  22. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/settings_router.py +8 -6
  23. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/skills_router.py +18 -8
  24. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/skills_service.py +32 -21
  25. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/sockets.py +44 -6
  26. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  27. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/SOURCES.txt +4 -2
  28. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/pyproject.toml +1 -1
  29. openhands_agent_server-1.28.1/openhands/agent_server/cloud_proxy_router.py +0 -226
  30. openhands_agent_server-1.28.1/openhands/agent_server/llm_router.py +0 -78
  31. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/__init__.py +0 -0
  32. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/__main__.py +0 -0
  33. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/_secrets_exposure.py +0 -0
  34. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/auth_router.py +0 -0
  35. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/bash_service.py +0 -0
  36. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/conversation_lease.py +0 -0
  37. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/desktop_router.py +0 -0
  38. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/desktop_service.py +0 -0
  39. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/docker/build.py +0 -0
  40. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/docker/wallpaper.svg +0 -0
  41. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/env_parser.py +0 -0
  42. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/event_router.py +0 -0
  43. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/git_router.py +0 -0
  44. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/hooks_router.py +0 -0
  45. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/hooks_service.py +0 -0
  46. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/logging_config.py +0 -0
  47. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/mcp_router.py +0 -0
  48. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/openai/__init__.py +0 -0
  49. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/openai/models.py +0 -0
  50. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/openapi.py +0 -0
  51. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/pub_sub.py +0 -0
  52. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/py.typed +0 -0
  53. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/server_details_router.py +0 -0
  54. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/tool_preload_service.py +0 -0
  55. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/tool_router.py +0 -0
  56. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/utils.py +0 -0
  57. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  58. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  59. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/vscode_router.py +0 -0
  60. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/vscode_service.py +0 -0
  61. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/workspace_router.py +0 -0
  62. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands/agent_server/workspaces_router.py +0 -0
  63. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  64. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  65. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/requires.txt +0 -0
  66. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/top_level.txt +0 -0
  67. {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.28.1
3
+ Version: 1.29.2
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
@@ -0,0 +1,602 @@
1
+ """HTTP endpoints for managing named ``AgentProfile`` launch specs.
2
+
3
+ Mirrors ``profiles_router.py`` (the LLM ``/api/profiles`` router) but serves the
4
+ reference-bearing :class:`~openhands.sdk.profiles.AgentProfile` union and keeps a
5
+ *separate* active pointer (``active_agent_profile_id``). Activation here is
6
+ pointer-only — unlike the LLM ``/activate`` it must **not** write
7
+ ``agent_settings`` (the creation-time-only contract).
8
+
9
+ ``POST /{name}/materialize`` performs a dry-run resolve of a profile's LLM and
10
+ MCP references and returns :class:`~openhands.sdk.profiles.AgentProfileDiagnostics`
11
+ (never raises on dangling refs — those appear in the body).
12
+ """
13
+
14
+ import copy
15
+ import shlex
16
+ from collections.abc import Iterator
17
+ from contextlib import contextmanager
18
+ from typing import Annotated, Any
19
+ from uuid import UUID, uuid4
20
+
21
+ from fastapi import APIRouter, HTTPException, Path, Request, status
22
+ from pydantic import BaseModel, Field, ValidationError
23
+
24
+ from openhands.agent_server._secrets_exposure import (
25
+ build_expose_context,
26
+ get_cipher,
27
+ get_config,
28
+ parse_expose_secrets_header,
29
+ translate_missing_cipher,
30
+ )
31
+ from openhands.agent_server.persistence import (
32
+ PersistedSettings,
33
+ get_agent_profile_store,
34
+ get_llm_profile_store,
35
+ get_settings_store,
36
+ )
37
+ from openhands.sdk.logger import get_logger
38
+ from openhands.sdk.profiles import (
39
+ ACPAgentProfile,
40
+ AgentProfileDiagnostics,
41
+ AgentProfileStore,
42
+ OpenHandsAgentProfile,
43
+ ProfileLimitExceeded,
44
+ ProfileVerificationSettings,
45
+ resolve_agent_profile_dry_run,
46
+ validate_agent_profile,
47
+ )
48
+ from openhands.sdk.profiles.agent_profile_store import PROFILE_NAME_PATTERN
49
+ from openhands.sdk.settings import AgentSettingsConfig
50
+ from openhands.sdk.settings.model import VerificationSettings
51
+ from openhands.sdk.utils.cipher import Cipher
52
+ from openhands.sdk.utils.pydantic_secrets import decrypt_str_with_cipher_or_keep
53
+
54
+
55
+ logger = get_logger(__name__)
56
+
57
+ agent_profiles_router = APIRouter(prefix="/agent-profiles", tags=["Agent Profiles"])
58
+
59
+ MAX_AGENT_PROFILES = 50
60
+
61
+ # Name the lazily-seeded migration profile (and its LLM ref fallback).
62
+ SEED_PROFILE_NAME = "default"
63
+
64
+ ProfileName = Annotated[
65
+ str,
66
+ Path(min_length=1, max_length=64, pattern=PROFILE_NAME_PATTERN),
67
+ ]
68
+ ProfileId = Annotated[str, Path(min_length=1, max_length=128)]
69
+
70
+
71
+ class AgentProfileInfo(BaseModel):
72
+ """Summary projection of a stored profile (no secret instantiation)."""
73
+
74
+ id: str | None = None
75
+ name: str
76
+ agent_kind: str = "openhands"
77
+ revision: int | None = None
78
+ llm_profile_ref: str | None = None
79
+ mcp_server_refs: list[str] | None = None
80
+
81
+
82
+ class AgentProfileListResponse(BaseModel):
83
+ profiles: list[AgentProfileInfo]
84
+ active_agent_profile_id: str | None = None
85
+
86
+
87
+ class AgentProfileDetailResponse(BaseModel):
88
+ name: str
89
+ profile: dict[str, Any]
90
+
91
+
92
+ class AgentProfileMutationResponse(BaseModel):
93
+ name: str
94
+ message: str
95
+
96
+
97
+ class ActivateAgentProfileResponse(BaseModel):
98
+ id: str
99
+ message: str
100
+ # Always False: activation is pointer-only by contract. The field documents
101
+ # that agent_settings was untouched; materialize (#3717) is the path that
102
+ # resolves a profile into settings.
103
+ agent_settings_applied: bool = False
104
+
105
+
106
+ class RenameAgentProfileRequest(BaseModel):
107
+ new_name: str = Field(
108
+ ...,
109
+ min_length=1,
110
+ max_length=64,
111
+ pattern=PROFILE_NAME_PATTERN,
112
+ )
113
+
114
+
115
+ @contextmanager
116
+ def _store_errors() -> Iterator[None]:
117
+ """Map ``AgentProfileStore`` errors to HTTP responses.
118
+
119
+ Mirrors ``profiles_router._store_errors``: ``TimeoutError`` and
120
+ ``ValueError`` only. ``FileNotFoundError`` / ``FileExistsError`` are handled
121
+ inline per-endpoint so each gets a clean, resource-specific message.
122
+ """
123
+ try:
124
+ yield
125
+ except TimeoutError:
126
+ raise HTTPException(
127
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
128
+ detail="Agent profile store is busy. Please retry.",
129
+ )
130
+ except ValueError as e:
131
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
132
+
133
+
134
+ def _decrypt_mcp_tools(tools: dict[str, Any], cipher: Cipher) -> dict[str, Any]:
135
+ """Return a copy of an ``mcp_tools`` dict with env/headers Fernet tokens
136
+ decrypted. Non-Fernet (plaintext) values pass through unchanged."""
137
+ servers = tools.get("mcpServers")
138
+ if not isinstance(servers, dict):
139
+ return tools
140
+ out = copy.deepcopy(tools)
141
+ for server in out["mcpServers"].values():
142
+ if not isinstance(server, dict):
143
+ continue
144
+ for key in ("env", "headers"):
145
+ mapping = server.get(key)
146
+ if isinstance(mapping, dict):
147
+ server[key] = {
148
+ k: decrypt_str_with_cipher_or_keep(
149
+ cipher, v, description="MCP env/headers"
150
+ )
151
+ for k, v in mapping.items()
152
+ }
153
+ return out
154
+
155
+
156
+ def _decrypt_profile_mcp_tools(
157
+ profile: OpenHandsAgentProfile | ACPAgentProfile, cipher: Cipher | None
158
+ ) -> OpenHandsAgentProfile | ACPAgentProfile:
159
+ """Decrypt Fernet ``skills[].mcp_tools`` env/headers (no-op without a cipher).
160
+
161
+ The store masks/encrypts these on save but has no symmetric load-time
162
+ validator, so values arrive as ciphertext on both GET (at-rest) and save
163
+ (client round-trip). Decrypting here lets GET re-mask from plaintext and
164
+ stops save from double-encrypting an already-encrypted value.
165
+ """
166
+ if cipher is None:
167
+ return profile
168
+ skills = getattr(profile, "skills", None)
169
+ if not skills:
170
+ return profile
171
+ new_skills = [
172
+ skill.model_copy(
173
+ update={"mcp_tools": _decrypt_mcp_tools(skill.mcp_tools, cipher)}
174
+ )
175
+ if skill.mcp_tools
176
+ else skill
177
+ for skill in skills
178
+ ]
179
+ return profile.model_copy(update={"skills": new_skills})
180
+
181
+
182
+ def _profile_verification(v: VerificationSettings) -> ProfileVerificationSettings:
183
+ """Project the secret-free subset of ``VerificationSettings``.
184
+
185
+ Drops ``critic_api_key`` — the profile is secret-free; the critic reuses
186
+ the resolved LLM profile's key.
187
+ """
188
+ return ProfileVerificationSettings(
189
+ critic_enabled=v.critic_enabled,
190
+ critic_mode=v.critic_mode,
191
+ enable_iterative_refinement=v.enable_iterative_refinement,
192
+ critic_threshold=v.critic_threshold,
193
+ max_refinement_iterations=v.max_refinement_iterations,
194
+ critic_server_url=v.critic_server_url,
195
+ critic_model_name=v.critic_model_name,
196
+ )
197
+
198
+
199
+ def _build_seed_profile(
200
+ agent_settings: AgentSettingsConfig, active_llm_profile: str | None
201
+ ) -> OpenHandsAgentProfile | ACPAgentProfile:
202
+ """Build one ``AgentProfile`` faithfully from the current ``agent_settings``.
203
+
204
+ Carries every cleanly-overlapping launch field so the migrated profile is a
205
+ stable representation of the user's current configuration (the active
206
+ pointer is otherwise just a lightweight id). ``mcp_server_refs=None`` exposes
207
+ all of the user's MCP servers. An OpenHands profile references the active LLM
208
+ profile (falling back to ``"default"`` when none is set — a soft ref the
209
+ resolver checks at materialize time).
210
+ """
211
+ if agent_settings.agent_kind == "acp":
212
+ return ACPAgentProfile(
213
+ name=SEED_PROFILE_NAME,
214
+ acp_server=agent_settings.acp_server,
215
+ acp_model=agent_settings.acp_model,
216
+ acp_session_mode=agent_settings.acp_session_mode,
217
+ acp_prompt_timeout=agent_settings.acp_prompt_timeout,
218
+ # settings store the command as a token list; the profile holds a
219
+ # single (re-parseable) string. Empty list => use the server default.
220
+ acp_command=(
221
+ shlex.join(agent_settings.acp_command)
222
+ if agent_settings.acp_command
223
+ else None
224
+ ),
225
+ acp_args=list(agent_settings.acp_args) or None,
226
+ mcp_server_refs=None,
227
+ )
228
+ context = agent_settings.agent_context
229
+ return OpenHandsAgentProfile(
230
+ name=SEED_PROFILE_NAME,
231
+ llm_profile_ref=active_llm_profile or SEED_PROFILE_NAME,
232
+ agent=agent_settings.agent,
233
+ skills=list(context.skills),
234
+ system_message_suffix=context.system_message_suffix,
235
+ condenser=agent_settings.condenser,
236
+ verification=_profile_verification(agent_settings.verification),
237
+ enable_sub_agents=agent_settings.enable_sub_agents,
238
+ tool_concurrency_limit=agent_settings.tool_concurrency_limit,
239
+ mcp_server_refs=None,
240
+ )
241
+
242
+
243
+ def _seed_default_profile(
244
+ store: AgentProfileStore,
245
+ request: Request,
246
+ settings: PersistedSettings,
247
+ cipher: Cipher | None,
248
+ ) -> None:
249
+ """Persist one default profile and point ``active_agent_profile_id`` at it.
250
+
251
+ The lock spans empty-check + save + pointer write so concurrent first
252
+ requests seed exactly once and the pointer matches the persisted id.
253
+ """
254
+ with _store_errors(), store._acquire_lock():
255
+ # Double-checked under the lock: a concurrent first request may have
256
+ # already seeded (the outer emptiness check in the list endpoint is
257
+ # unlocked).
258
+ if store.list():
259
+ return
260
+ profile = _build_seed_profile(settings.agent_settings, settings.active_profile)
261
+ # Settings persist skills[].mcp_tools encrypted (and never decrypt on
262
+ # load), so decrypt before re-encrypting at save to avoid double-encrypt.
263
+ profile = _decrypt_profile_mcp_tools(profile, cipher)
264
+ store.save(profile, cipher=cipher, max_profiles=MAX_AGENT_PROFILES)
265
+
266
+ profile_id = str(profile.id)
267
+ settings_store = get_settings_store(get_config(request))
268
+
269
+ def set_pointer(s: PersistedSettings) -> PersistedSettings:
270
+ s.active_agent_profile_id = profile_id
271
+ return s
272
+
273
+ settings_store.update(set_pointer)
274
+ logger.info(f"Seeded default agent profile '{profile.name}' (id={profile_id})")
275
+
276
+
277
+ def _summary_id_for_name(store: AgentProfileStore, name: str) -> str | None:
278
+ """Return the stable id of the profile stored under ``name``, if present."""
279
+ with _store_errors():
280
+ for summary in store.list_summaries():
281
+ if summary.get("name") == name:
282
+ sid = summary.get("id")
283
+ return str(sid) if sid is not None else None
284
+ return None
285
+
286
+
287
+ def _existing_identity(
288
+ store: AgentProfileStore, name: str
289
+ ) -> tuple[UUID | None, int | None]:
290
+ """Return the stable ``(id, revision)`` of the profile under ``name``.
291
+
292
+ Used to keep ``id`` stable across an overwrite — the active pointer is keyed
293
+ on it — and to bump ``revision`` monotonically. Ignores a malformed stored
294
+ id (treated as no prior identity).
295
+ """
296
+ with _store_errors():
297
+ for summary in store.list_summaries():
298
+ if summary.get("name") != name:
299
+ continue
300
+ sid = summary.get("id")
301
+ rev = summary.get("revision")
302
+ try:
303
+ parsed = UUID(str(sid)) if sid is not None else None
304
+ except (ValueError, TypeError):
305
+ parsed = None
306
+ return parsed, rev if isinstance(rev, int) else None
307
+ return None, None
308
+
309
+
310
+ @agent_profiles_router.get("", response_model=AgentProfileListResponse)
311
+ async def list_agent_profiles(request: Request) -> AgentProfileListResponse:
312
+ """List all stored agent profiles and the active pointer.
313
+
314
+ On the first call against an empty store with no active pointer, lazily
315
+ seeds one default profile from the current ``agent_settings`` and activates
316
+ it (the one-time migration that replaces a dedicated seed step).
317
+ """
318
+ config = get_config(request)
319
+ settings_store = get_settings_store(config)
320
+ settings = settings_store.load() or PersistedSettings()
321
+
322
+ store = get_agent_profile_store()
323
+ with _store_errors():
324
+ existing = store.list()
325
+
326
+ if not existing and settings.active_agent_profile_id is None:
327
+ _seed_default_profile(store, request, settings, get_cipher(request))
328
+ settings = settings_store.load() or settings
329
+
330
+ with _store_errors():
331
+ summaries = store.list_summaries()
332
+
333
+ return AgentProfileListResponse(
334
+ profiles=[AgentProfileInfo(**s) for s in summaries],
335
+ active_agent_profile_id=settings.active_agent_profile_id,
336
+ )
337
+
338
+
339
+ @agent_profiles_router.get("/{name}", response_model=AgentProfileDetailResponse)
340
+ async def get_agent_profile(
341
+ request: Request, name: ProfileName
342
+ ) -> AgentProfileDetailResponse:
343
+ """Get a stored profile.
344
+
345
+ Use the ``X-Expose-Secrets`` header to control ``skills[].mcp_tools`` secret
346
+ exposure (``encrypted`` / ``plaintext``); absent, those values are masked.
347
+ """
348
+ expose_mode = parse_expose_secrets_header(request)
349
+ cipher = get_cipher(request)
350
+
351
+ store = get_agent_profile_store()
352
+ try:
353
+ with _store_errors():
354
+ profile = store.load(name, cipher=cipher)
355
+ except FileNotFoundError:
356
+ raise HTTPException(
357
+ status_code=status.HTTP_404_NOT_FOUND,
358
+ detail=f"Agent profile '{name}' not found",
359
+ )
360
+
361
+ # The store leaves skills[].mcp_tools encrypted on load; decrypt to plaintext
362
+ # so the expose serializer can correctly redact / re-encrypt / reveal them.
363
+ profile = _decrypt_profile_mcp_tools(profile, cipher)
364
+
365
+ context = build_expose_context(expose_mode, cipher)
366
+ with translate_missing_cipher():
367
+ payload = profile.model_dump(mode="json", context=context)
368
+
369
+ return AgentProfileDetailResponse(name=name, profile=payload)
370
+
371
+
372
+ @agent_profiles_router.post(
373
+ "/{name}",
374
+ response_model=AgentProfileMutationResponse,
375
+ status_code=status.HTTP_201_CREATED,
376
+ )
377
+ async def save_agent_profile(
378
+ request: Request, name: ProfileName, body: dict[str, Any]
379
+ ) -> AgentProfileMutationResponse:
380
+ """Save an ``AgentProfile`` under ``name`` (overwriting a namesake).
381
+
382
+ The path ``name`` is authoritative — it overrides any ``name`` in the body.
383
+ With ``OH_SECRET_KEY`` configured, ``skills[].mcp_tools`` secrets are
384
+ encrypted at rest; otherwise they are redacted. Returns 409 if creating a
385
+ new profile would exceed ``MAX_AGENT_PROFILES``.
386
+ """
387
+ cipher = get_cipher(request)
388
+ try:
389
+ profile = validate_agent_profile({**body, "name": name})
390
+ except ValidationError as e:
391
+ # Match FastAPI's request-validation shape (``detail`` is a list of error
392
+ # objects), but surface only ``loc``/``type`` — a nested mcp_tools
393
+ # MCPConfig error embeds the input (which may carry secrets) in ``msg``.
394
+ raise HTTPException(
395
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
396
+ detail=[{"loc": err["loc"], "type": err["type"]} for err in e.errors()],
397
+ )
398
+ except Exception:
399
+ # Any other validation failure (e.g. SkillValidationError from a
400
+ # malformed mcp_tools, or a schema/migration error) is a client error,
401
+ # never a 500. Stay generic — these messages can embed the input.
402
+ raise HTTPException(
403
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
404
+ detail="Invalid agent profile",
405
+ )
406
+
407
+ # A client editing a profile fetched with X-Expose-Secrets: encrypted posts
408
+ # back Fernet tokens; decrypt them so the save re-encrypts the original
409
+ # secret once rather than double-encrypting the token.
410
+ profile = _decrypt_profile_mcp_tools(profile, cipher)
411
+
412
+ store = get_agent_profile_store()
413
+ # The id is server-managed (the active pointer is keyed on it): overwrite
414
+ # keeps the namesake's id and bumps revision; create mints a fresh id,
415
+ # ignoring any client-supplied one. The lock spans read + mint + save so two
416
+ # concurrent creates of the same new name can't both mint an id and clobber
417
+ # each other (the seed path guards the same window).
418
+ try:
419
+ with _store_errors(), store._acquire_lock():
420
+ existing_id, existing_rev = _existing_identity(store, name)
421
+ if existing_id is not None:
422
+ profile = profile.model_copy(
423
+ update={"id": existing_id, "revision": (existing_rev or 0) + 1}
424
+ )
425
+ else:
426
+ profile = profile.model_copy(update={"id": uuid4()})
427
+ store.save(profile, cipher=cipher, max_profiles=MAX_AGENT_PROFILES)
428
+ except ProfileLimitExceeded:
429
+ raise HTTPException(
430
+ status_code=status.HTTP_409_CONFLICT,
431
+ detail=(
432
+ f"Agent profile limit reached ({MAX_AGENT_PROFILES}). "
433
+ "Delete a profile before saving a new one."
434
+ ),
435
+ )
436
+
437
+ logger.info(f"Saved agent profile '{name}'")
438
+ return AgentProfileMutationResponse(
439
+ name=name, message=f"Agent profile '{name}' saved"
440
+ )
441
+
442
+
443
+ @agent_profiles_router.delete("/{name}", response_model=AgentProfileMutationResponse)
444
+ async def delete_agent_profile(
445
+ request: Request, name: ProfileName
446
+ ) -> AgentProfileMutationResponse:
447
+ """Delete a stored profile (idempotent).
448
+
449
+ If the deleted profile was the active one, ``active_agent_profile_id`` is
450
+ cleared.
451
+ """
452
+ store = get_agent_profile_store()
453
+ deleted_id = _summary_id_for_name(store, name)
454
+
455
+ with _store_errors():
456
+ store.delete(name)
457
+
458
+ if deleted_id is not None:
459
+ config = get_config(request)
460
+ settings_store = get_settings_store(config)
461
+ settings = settings_store.load() or PersistedSettings()
462
+ if settings.active_agent_profile_id == deleted_id:
463
+
464
+ def clear_pointer(s: PersistedSettings) -> PersistedSettings:
465
+ s.active_agent_profile_id = None
466
+ return s
467
+
468
+ settings_store.update(clear_pointer)
469
+ logger.info(f"Cleared active pointer for deleted profile '{name}'")
470
+
471
+ logger.info(f"Deleted agent profile '{name}'")
472
+ return AgentProfileMutationResponse(
473
+ name=name, message=f"Agent profile '{name}' deleted"
474
+ )
475
+
476
+
477
+ @agent_profiles_router.post(
478
+ "/{name}/rename", response_model=AgentProfileMutationResponse
479
+ )
480
+ async def rename_agent_profile(
481
+ name: ProfileName, body: RenameAgentProfileRequest
482
+ ) -> AgentProfileMutationResponse:
483
+ """Rename a stored profile atomically.
484
+
485
+ The stable ``id`` is preserved, so an active pointer (keyed on ``id``)
486
+ survives the rename untouched. Returns 404 if the source is missing, 409 if
487
+ ``new_name`` is taken.
488
+ """
489
+ store = get_agent_profile_store()
490
+ try:
491
+ with _store_errors():
492
+ store.rename(name, body.new_name)
493
+ except FileNotFoundError:
494
+ raise HTTPException(
495
+ status_code=status.HTTP_404_NOT_FOUND,
496
+ detail=f"Agent profile '{name}' not found",
497
+ )
498
+ except FileExistsError:
499
+ raise HTTPException(
500
+ status_code=status.HTTP_409_CONFLICT,
501
+ detail=f"Agent profile '{body.new_name}' already exists",
502
+ )
503
+
504
+ if name == body.new_name:
505
+ message = f"Agent profile '{name}' unchanged (same name)"
506
+ else:
507
+ message = f"Agent profile '{name}' renamed to '{body.new_name}'"
508
+ logger.info(message)
509
+ return AgentProfileMutationResponse(name=body.new_name, message=message)
510
+
511
+
512
+ @agent_profiles_router.post(
513
+ "/{profile_id}/activate", response_model=ActivateAgentProfileResponse
514
+ )
515
+ async def activate_agent_profile(
516
+ request: Request, profile_id: ProfileId
517
+ ) -> ActivateAgentProfileResponse:
518
+ """Activate a profile by its stable ``id`` — pointer only.
519
+
520
+ Sets ``active_agent_profile_id`` and nothing else: unlike the LLM
521
+ ``/activate``, this does **not** write ``agent_settings`` (the
522
+ creation-time-only contract). Returns 404 if no stored profile has that id.
523
+ """
524
+ store = get_agent_profile_store()
525
+ with _store_errors():
526
+ known_ids = {
527
+ str(s["id"]) for s in store.list_summaries() if s.get("id") is not None
528
+ }
529
+ if profile_id not in known_ids:
530
+ raise HTTPException(
531
+ status_code=status.HTTP_404_NOT_FOUND,
532
+ detail=f"Agent profile with id '{profile_id}' not found",
533
+ )
534
+
535
+ config = get_config(request)
536
+ settings_store = get_settings_store(config)
537
+
538
+ def set_pointer(settings: PersistedSettings) -> PersistedSettings:
539
+ settings.active_agent_profile_id = profile_id
540
+ return settings
541
+
542
+ try:
543
+ settings_store.update(set_pointer)
544
+ except (OSError, PermissionError):
545
+ logger.error("Failed to activate agent profile - file I/O error")
546
+ raise HTTPException(status_code=500, detail="Failed to activate agent profile")
547
+ except RuntimeError as e:
548
+ # A corrupted / mis-keyed settings file is a server-side integrity
549
+ # failure, not a client conflict.
550
+ logger.error(f"Failed to activate agent profile: {e}")
551
+ raise HTTPException(
552
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
553
+ detail="Failed to activate agent profile",
554
+ )
555
+
556
+ logger.info(f"Activated agent profile id '{profile_id}'")
557
+ return ActivateAgentProfileResponse(
558
+ id=profile_id,
559
+ message=f"Agent profile '{profile_id}' activated",
560
+ )
561
+
562
+
563
+ @agent_profiles_router.post(
564
+ "/{name}/materialize",
565
+ response_model=AgentProfileDiagnostics,
566
+ )
567
+ async def materialize_agent_profile(
568
+ request: Request, name: ProfileName
569
+ ) -> AgentProfileDiagnostics:
570
+ """Dry-run resolve a profile's LLM/MCP references; return a diagnostics report.
571
+
572
+ Dangling LLM/MCP references are reported in the body (valid=False) rather
573
+ than raising — the only error status is 404 (unknown profile name).
574
+ resolved_settings is redacted (api_key_set booleans; no raw secrets).
575
+ """
576
+ cipher = get_cipher(request)
577
+
578
+ store = get_agent_profile_store()
579
+ try:
580
+ with _store_errors():
581
+ profile = store.load(name, cipher=cipher)
582
+ except FileNotFoundError:
583
+ raise HTTPException(
584
+ status_code=status.HTTP_404_NOT_FOUND,
585
+ detail=f"Agent profile '{name}' not found",
586
+ )
587
+
588
+ # The store leaves skills[].mcp_tools encrypted on load; decrypt so the
589
+ # resolver builds settings from plaintext (not ciphertext) values.
590
+ profile = _decrypt_profile_mcp_tools(profile, cipher)
591
+
592
+ config = get_config(request)
593
+ settings = get_settings_store(config).load() or PersistedSettings()
594
+ mcp_config = settings.agent_settings.mcp_config
595
+
596
+ llm_store = get_llm_profile_store()
597
+ return resolve_agent_profile_dry_run(
598
+ profile,
599
+ llm_store=llm_store,
600
+ mcp_config=mcp_config,
601
+ cipher=cipher,
602
+ )