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