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.
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/PKG-INFO +1 -1
- openhands_agent_server-1.29.0/openhands/agent_server/agent_profiles_router.py +601 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/api.py +74 -31
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/config.py +65 -4
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/conversation_router.py +105 -11
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/conversation_service.py +107 -4
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/docker/Dockerfile +3 -3
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/event_service.py +290 -15
- openhands_agent_server-1.29.0/openhands/agent_server/init_router.py +319 -0
- openhands_agent_server-1.29.0/openhands/agent_server/llm_router.py +262 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/middleware.py +20 -5
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/models.py +40 -5
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/persistence/models.py +43 -59
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/profiles_router.py +21 -5
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/settings_router.py +8 -6
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/skills_router.py +18 -8
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/skills_service.py +32 -21
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands_agent_server.egg-info/SOURCES.txt +4 -2
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/pyproject.toml +1 -1
- openhands_agent_server-1.28.1/openhands/agent_server/cloud_proxy_router.py +0 -226
- openhands_agent_server-1.28.1/openhands/agent_server/llm_router.py +0 -78
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/__init__.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/__main__.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/_secrets_exposure.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/auth_router.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/bash_router.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/bash_service.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/conversation_lease.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/dependencies.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/desktop_router.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/desktop_service.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/docker/build.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/env_parser.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/event_router.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/file_router.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/git_router.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/hooks_router.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/hooks_service.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/logging_config.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/mcp_router.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/openai/__init__.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/openai/models.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/openai/router.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/openai/service.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/openapi.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/persistence/__init__.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/persistence/store.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/pub_sub.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/py.typed +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/server_details_router.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/sockets.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/tool_preload_service.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/tool_router.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/utils.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/vscode_router.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/vscode_service.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/workspace_router.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands/agent_server/workspaces_router.py +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands_agent_server.egg-info/requires.txt +0 -0
- {openhands_agent_server-1.28.1 → openhands_agent_server-1.29.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
+
)
|