devops-bot-sdk 1.4.126__tar.gz → 1.4.129__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 (90) hide show
  1. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/devops_bot_sdk.egg-info/SOURCES.txt +1 -0
  4. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/run_auto.py +11 -0
  6. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/api.py +88 -120
  7. devops_bot_sdk-1.4.129/sdk/ui/models.py +366 -0
  8. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/service.py +35 -0
  9. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/README.md +0 -0
  10. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  11. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  12. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/devops_bot_sdk.egg-info/requires.txt +0 -0
  13. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  14. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/pyproject.toml +0 -0
  15. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agentd.py +0 -0
  16. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/__init__.py +0 -0
  17. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/builder.py +0 -0
  18. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/chat.py +0 -0
  19. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/connectors.py +0 -0
  20. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/engines/__init__.py +0 -0
  21. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/engines/claude_code.py +0 -0
  22. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/engines/http.py +0 -0
  23. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/engines/mcp.py +0 -0
  24. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/engines/n8n.py +0 -0
  25. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/engines/shell.py +0 -0
  26. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/integrations.py +0 -0
  27. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/inventory.py +0 -0
  28. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/library/__init__.py +0 -0
  29. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/manifest.py +0 -0
  30. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/prompt_studio.py +0 -0
  31. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/prompt_template.py +0 -0
  32. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/prompt_vars.py +0 -0
  33. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/run_log.py +0 -0
  34. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/secrets.py +0 -0
  35. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/usage.py +0 -0
  36. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/agents/varstore.py +0 -0
  37. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/cli.py +0 -0
  38. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/client.py +0 -0
  39. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/collectors/__init__.py +0 -0
  40. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/collectors/files.py +0 -0
  41. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/collectors/process.py +0 -0
  42. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/collectors/screenshot.py +0 -0
  43. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/config.py +0 -0
  44. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/crucial.py +0 -0
  45. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/design_verify/__init__.py +0 -0
  46. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/design_verify/assets.py +0 -0
  47. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/design_verify/bootstrap.py +0 -0
  48. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/design_verify/browser.py +0 -0
  49. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/design_verify/compare.py +0 -0
  50. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/design_verify/loop.py +0 -0
  51. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/exceptions.py +0 -0
  52. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/git_ops.py +0 -0
  53. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/graphify.py +0 -0
  54. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/hooks/__init__.py +0 -0
  55. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/hooks/crucial_guard.py +0 -0
  56. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ipc/__init__.py +0 -0
  57. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ipc/electron_bridge.py +0 -0
  58. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ipc/handlers.py +0 -0
  59. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/local_exec.py +0 -0
  60. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/models/__init__.py +0 -0
  61. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/models/envelope.py +0 -0
  62. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/models/requests.py +0 -0
  63. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/models/responses.py +0 -0
  64. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/models/snapshots.py +0 -0
  65. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/platform_compat.py +0 -0
  66. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/py.typed +0 -0
  67. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/sse.py +0 -0
  68. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/test.py +0 -0
  69. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/test_pipeline.py +0 -0
  70. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/__init__.py +0 -0
  71. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/dashboard.py +0 -0
  72. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/ip_allowlist.py +0 -0
  73. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/jobs.py +0 -0
  74. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/runner.py +0 -0
  75. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/security.py +0 -0
  76. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/server.py +0 -0
  77. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/setup_manager.py +0 -0
  78. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/static/agents.html +0 -0
  79. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/static/app.js +0 -0
  80. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/static/custom-agents.html +0 -0
  81. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/static/default-agents.html +0 -0
  82. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/static/index.html +0 -0
  83. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/static/runs.html +0 -0
  84. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/static/setup.html +0 -0
  85. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/static/styles.css +0 -0
  86. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/ui/static/tickets.html +0 -0
  87. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/sdk/updater.py +0 -0
  88. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/setup.cfg +0 -0
  89. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/tests/test_agent_chat_features.py +0 -0
  90. {devops_bot_sdk-1.4.126 → devops_bot_sdk-1.4.129}/tests/test_agents_connectors_builder.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.126
3
+ Version: 1.4.129
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.126
3
+ Version: 1.4.129
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -69,6 +69,7 @@ sdk/ui/api.py
69
69
  sdk/ui/dashboard.py
70
70
  sdk/ui/ip_allowlist.py
71
71
  sdk/ui/jobs.py
72
+ sdk/ui/models.py
72
73
  sdk/ui/runner.py
73
74
  sdk/ui/security.py
74
75
  sdk/ui/server.py
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.126
3
+ Version: 1.4.129
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -30,7 +30,7 @@ Rules:
30
30
  - All data egress through submit_webhook only
31
31
  """
32
32
 
33
- __version__ = "1.4.126" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
33
+ __version__ = "1.4.129" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
34
34
  __author__ = "AgentOS" # sdk.client.SDK_VERSION and the UI server version all read this.
35
35
 
36
36
  from sdk.client import BackendClient
@@ -565,6 +565,17 @@ def main() -> None:
565
565
  after = current_version()
566
566
  if after != before:
567
567
  print(f"✓ updated {before} → {after}")
568
+ # Reload the control-panel server so newly-added /api/ui/* routes take
569
+ # effect — a running server keeps the code it imported at boot. Only
570
+ # restarts a managed (systemd/launchd/schtask) service; a manually-run
571
+ # panel is left alone with a hint.
572
+ try:
573
+ from sdk.ui import service as ui_service
574
+ ok, msg = ui_service.restart()
575
+ print(f" control panel: {msg}" if ok
576
+ else f" control panel: not reloaded — {msg}")
577
+ except Exception as exc: # noqa: BLE001 — never fail the update on this
578
+ print(f" control panel: not reloaded ({exc})")
568
579
  pid = _daemon_running()
569
580
  if pid:
570
581
  print("note: a background loop is running the OLD version — "
@@ -12,6 +12,24 @@ from fastapi.responses import StreamingResponse
12
12
  from fastapi.security import APIKeyHeader
13
13
 
14
14
  from sdk.ui import dashboard, runner, security, setup_manager
15
+ from sdk.ui.models import (
16
+ AgentChatRequest,
17
+ AgentKeysSet,
18
+ AgentPromptGenerateRequest,
19
+ AgentRunRequest,
20
+ AllowlistEntry,
21
+ ConfigureRequest,
22
+ PromptRegenerateRequest,
23
+ PromptRunRequest,
24
+ PromptVerifyRequest,
25
+ SetupRequest,
26
+ StoryRequest,
27
+ SystemChatRequest,
28
+ SystemPromptRequest,
29
+ TicketRequest,
30
+ UpdateAgentRequest,
31
+ WorkspaceRequest,
32
+ )
15
33
 
16
34
  router = APIRouter(prefix="/api/ui")
17
35
 
@@ -82,15 +100,6 @@ async def status() -> dict:
82
100
  # middleware re-reads it per request so a new IP works immediately (no restart).
83
101
  # The backend proxies here (POST /api/v1/sdk/whitelist-ip) to open access for an IP.
84
102
 
85
- def _valid_ip_or_cidr(value: str) -> bool:
86
- import ipaddress
87
- try:
88
- ipaddress.ip_network(value, strict=False)
89
- return True
90
- except ValueError:
91
- return False
92
-
93
-
94
103
  @router.get("/allowlist", dependencies=[_auth])
95
104
  async def get_allowlist() -> dict:
96
105
  """This SDK's allowlist, split into permanent vs runtime.
@@ -109,20 +118,17 @@ async def get_allowlist() -> dict:
109
118
 
110
119
 
111
120
  @router.post("/allowlist", dependencies=[_auth])
112
- async def add_to_allowlist(body: dict) -> dict:
121
+ async def add_to_allowlist(body: AllowlistEntry) -> dict:
113
122
  """Whitelist an IP/CIDR for this SDK at runtime. Body: ``{ip}``. Idempotent.
114
123
 
115
124
  Opens the IP gate for that client across every path (all endpoints + UI + docs)
116
125
  until it's disabled. Effective immediately (the middleware re-reads per request).
126
+ The ``ip`` field is validated (IPv4/IPv6 address or CIDR) by ``AllowlistEntry``;
127
+ a malformed or missing value returns 422 before this handler runs.
117
128
  """
118
129
  from sdk.config import add_allowed_ip
119
130
 
120
- ip = str(body.get("ip", "")).strip()
121
- if not ip:
122
- raise HTTPException(status_code=400, detail="ip is required.")
123
- if not _valid_ip_or_cidr(ip):
124
- raise HTTPException(status_code=400, detail=f"invalid IP or CIDR: {ip!r}")
125
- return {"added": ip, "allowed_ips": add_allowed_ip(ip)}
131
+ return {"added": body.ip, "allowed_ips": add_allowed_ip(body.ip)}
126
132
 
127
133
 
128
134
  @router.delete("/allowlist", dependencies=[_auth])
@@ -137,20 +143,17 @@ async def remove_from_allowlist(ip: str = Query(...)) -> dict:
137
143
 
138
144
 
139
145
  @router.post("/configure", dependencies=[_auth])
140
- async def configure(body: dict) -> dict:
146
+ async def configure(body: ConfigureRequest) -> dict:
141
147
  from sdk import config as sdk_config
142
148
 
143
- token = str(body.get("token", "")).strip()
144
- if not token.startswith("co_"):
145
- raise HTTPException(status_code=400, detail="Token must start with 'co_'.")
146
- sdk_config.save(sdk_config.get_base_url(), token)
149
+ sdk_config.save(sdk_config.get_base_url(), body.token)
147
150
  ok, message = await dashboard.validate_token()
148
151
  return {"saved": True, "connected": ok, "message": message}
149
152
 
150
153
 
151
154
  @router.post("/setup", dependencies=[_auth])
152
- async def setup(body: dict | None = None) -> StreamingResponse:
153
- upgrade = bool((body or {}).get("upgrade", True))
155
+ async def setup(body: SetupRequest | None = None) -> StreamingResponse:
156
+ upgrade = (body or SetupRequest()).upgrade
154
157
 
155
158
  async def gen():
156
159
  async for chunk in setup_manager.reinstall_sdk(upgrade=upgrade):
@@ -184,44 +187,34 @@ async def restart_loop() -> dict:
184
187
  # ── Execution (one per agentos-auto subcommand) ────────────────────────────
185
188
 
186
189
  @router.post("/run", dependencies=[_auth])
187
- async def run(body: dict | None = None) -> dict:
190
+ async def run(body: WorkspaceRequest | None = None) -> dict:
188
191
  args = ["run"]
189
- root = (body or {}).get("workspace_root")
192
+ root = (body or WorkspaceRequest()).workspace_root
190
193
  if root:
191
- args += ["--workspace-root", str(root)]
194
+ args += ["--workspace-root", root]
192
195
  return runner.spawn_auto(*args)
193
196
 
194
197
 
195
198
  @router.post("/resume", dependencies=[_auth])
196
- async def resume(body: dict | None = None) -> dict:
199
+ async def resume(body: WorkspaceRequest | None = None) -> dict:
197
200
  args = ["resume"]
198
- root = (body or {}).get("workspace_root")
201
+ root = (body or WorkspaceRequest()).workspace_root
199
202
  if root:
200
- args += ["--workspace-root", str(root)]
203
+ args += ["--workspace-root", root]
201
204
  return runner.spawn_auto(*args)
202
205
 
203
206
 
204
207
  @router.post("/ticket", dependencies=[_auth])
205
- async def ticket(body: dict) -> dict:
206
- keys = body.get("keys") or []
207
- if isinstance(keys, str):
208
- keys = keys.split()
209
- keys = [str(k).strip() for k in keys if str(k).strip()]
210
- if not keys:
211
- raise HTTPException(status_code=400, detail="Provide at least one ticket key.")
212
- args = ["ticket", *keys]
213
- root = body.get("workspace_root")
214
- if root:
215
- args += ["--workspace-root", str(root)]
208
+ async def ticket(body: TicketRequest) -> dict:
209
+ args = ["ticket", *body.keys]
210
+ if body.workspace_root:
211
+ args += ["--workspace-root", body.workspace_root]
216
212
  return runner.spawn_auto(*args)
217
213
 
218
214
 
219
215
  @router.post("/story", dependencies=[_auth])
220
- async def story(body: dict | None = None) -> dict:
221
- keys = (body or {}).get("keys") or []
222
- if isinstance(keys, str):
223
- keys = keys.split()
224
- keys = [str(k).strip() for k in keys if str(k).strip()]
216
+ async def story(body: StoryRequest | None = None) -> dict:
217
+ keys = (body or StoryRequest()).keys
225
218
  return runner.spawn_auto("story", *keys)
226
219
 
227
220
 
@@ -426,7 +419,7 @@ async def agent_keys_list(name: str = Query(...)) -> dict:
426
419
 
427
420
 
428
421
  @router.post("/agents/keys", dependencies=[_auth])
429
- async def agent_keys_set(body: dict) -> dict:
422
+ async def agent_keys_set(body: AgentKeysSet) -> dict:
430
423
  """Persist/update a custom agent's API keys (encrypted, local, per-agent).
431
424
 
432
425
  Body: ``name``, ``keys`` ({ENV_VAR: value}). Merge semantics — keys omitted
@@ -435,14 +428,10 @@ async def agent_keys_set(body: dict) -> dict:
435
428
  """
436
429
  from sdk.agents import secrets as agent_secrets
437
430
 
438
- name = str(body.get("name", "")).strip()
439
- keys = body.get("keys") or {}
440
- if not name:
441
- raise HTTPException(status_code=400, detail="name is required.")
442
- if not isinstance(keys, dict) or not keys:
443
- raise HTTPException(status_code=400, detail="keys must be a non-empty object.")
444
- stored = agent_secrets.save_secrets(name, {str(k): str(v) for k, v in keys.items()}, merge=True)
445
- return {"name": name, "keys": sorted(stored.keys())}
431
+ stored = agent_secrets.save_secrets(
432
+ body.name, {str(k): str(v) for k, v in body.keys.items()}, merge=True
433
+ )
434
+ return {"name": body.name, "keys": sorted(stored.keys())}
446
435
 
447
436
 
448
437
  @router.delete("/agents/keys", dependencies=[_auth])
@@ -516,7 +505,7 @@ async def job_cancel(job_id: str) -> dict:
516
505
 
517
506
 
518
507
  @router.patch("/agents", dependencies=[_auth])
519
- async def update_agent(body: dict) -> dict:
508
+ async def update_agent(body: UpdateAgentRequest) -> dict:
520
509
  """Update a local custom agent (description / prompt / tools / enabled status).
521
510
 
522
511
  Re-validates and rewrites the local manifest, and best-effort syncs the change
@@ -526,20 +515,19 @@ async def update_agent(body: dict) -> dict:
526
515
 
527
516
  from sdk.agents.manifest import AGENTS_DIR, AgentManifest, load_agents
528
517
 
529
- name = str(body.get("name", "")).strip()
530
- if not name:
531
- raise HTTPException(status_code=400, detail="name is required.")
518
+ name = body.name
519
+ sent = body.model_fields_set # PATCH: only fields the caller sent are applied
532
520
  manifest = next((a for a in load_agents() if a.name == name), None)
533
521
  if manifest is None:
534
522
  raise HTTPException(status_code=404, detail=f"no local agent '{name}'")
535
523
 
536
524
  local_changes: dict = {}
537
- if "description" in body:
538
- local_changes["description"] = str(body.get("description") or "")
539
- if "prompt" in body:
540
- local_changes["prompt"] = str(body.get("prompt") or "")
541
- if "allowed_tools" in body:
542
- local_changes["allowed_tools"] = list(body.get("allowed_tools") or [])
525
+ if "description" in sent:
526
+ local_changes["description"] = str(body.description or "")
527
+ if "prompt" in sent:
528
+ local_changes["prompt"] = str(body.prompt or "")
529
+ if "allowed_tools" in sent:
530
+ local_changes["allowed_tools"] = list(body.allowed_tools or [])
543
531
 
544
532
  updated = None
545
533
  if local_changes:
@@ -552,7 +540,7 @@ async def update_agent(body: dict) -> dict:
552
540
  encoding="utf-8",
553
541
  )
554
542
 
555
- status = str(body.get("status", "")).strip()
543
+ status = (body.status or "").strip()
556
544
  # Backend sync (best-effort). Agent CREATION/authoring is not owned by the SDK
557
545
  # anymore, so a content edit is applied LOCALLY only — no re-register upsert.
558
546
  # A status change still syncs to the backend (per-agent enable/disable).
@@ -593,7 +581,7 @@ async def delete_agent(name: str = Query(...)) -> dict:
593
581
 
594
582
 
595
583
  @router.post("/agents/chat", dependencies=[_auth])
596
- async def agent_chat(body: dict) -> dict:
584
+ async def agent_chat(body: AgentChatRequest) -> dict:
597
585
  """Chat with an agent (runs its engine on this machine).
598
586
 
599
587
  Body: ``name``, ``message``, optional ``variables`` ({{var}} values), optional
@@ -603,13 +591,11 @@ async def agent_chat(body: dict) -> dict:
603
591
  """
604
592
  from sdk.ui import jobs
605
593
 
606
- name = str(body.get("name", "")).strip()
607
- message = str(body.get("message", "")).strip()
608
- if not name or not message:
609
- raise HTTPException(status_code=400, detail="name and message are required.")
610
- variables = body.get("variables") or {}
611
- history = body.get("history") or []
612
- department_id = str(body.get("department_id", "")).strip()
594
+ name = body.name
595
+ message = body.message
596
+ variables = body.variables
597
+ history = body.history
598
+ department_id = body.department_id
613
599
 
614
600
  async def work() -> dict:
615
601
  import time as _time
@@ -637,7 +623,7 @@ async def agent_chat(body: dict) -> dict:
637
623
 
638
624
 
639
625
  @router.post("/agents/run", dependencies=[_auth])
640
- async def agent_run(body: dict) -> dict:
626
+ async def agent_run(body: AgentRunRequest) -> dict:
641
627
  """Run an agent once with fresh ``{{variable}}`` values — no chat.
642
628
 
643
629
  Body: ``name``, ``variables`` ({{var}} → value; may differ every run),
@@ -649,12 +635,10 @@ async def agent_run(body: dict) -> dict:
649
635
  """
650
636
  from sdk.ui import jobs
651
637
 
652
- name = str(body.get("name", "")).strip()
653
- if not name:
654
- raise HTTPException(status_code=400, detail="name is required.")
655
- variables = body.get("variables") or {}
656
- department_id = str(body.get("department_id", "")).strip()
657
- scope = str(body.get("scope") or body.get("source") or "custom").strip() or "custom"
638
+ name = body.name
639
+ variables = body.variables
640
+ department_id = body.department_id
641
+ scope = body.scope
658
642
 
659
643
  async def work() -> dict:
660
644
  import time as _time
@@ -688,7 +672,7 @@ async def system_inventory() -> dict:
688
672
 
689
673
 
690
674
  @router.post("/system/chat", dependencies=[_auth])
691
- async def system_chat(body: dict) -> dict:
675
+ async def system_chat(body: SystemChatRequest) -> dict:
692
676
  """Ask the System Assistant about agents, subagents, tools, plugins & usage.
693
677
 
694
678
  Grounds the local model in the live inventory JSON and answers the question.
@@ -697,10 +681,8 @@ async def system_chat(body: dict) -> dict:
697
681
  """
698
682
  from sdk.ui import jobs
699
683
 
700
- message = str(body.get("message", "")).strip()
701
- if not message:
702
- raise HTTPException(status_code=400, detail="message is required.")
703
- history = body.get("history") or []
684
+ message = body.message
685
+ history = body.history
704
686
 
705
687
  async def work() -> dict:
706
688
  from pathlib import Path
@@ -748,7 +730,7 @@ async def agent_prompt(name: str = Query(...)) -> dict:
748
730
 
749
731
 
750
732
  @router.post("/prompt/analyze", dependencies=[_auth])
751
- async def prompt_analyze(body: dict) -> dict:
733
+ async def prompt_analyze(body: SystemPromptRequest) -> dict:
752
734
  """AI reads a system prompt and infers its input variables + output/format.
753
735
 
754
736
  Body: ``system_prompt``. Runs as a detached job (navigation-safe); poll
@@ -756,9 +738,7 @@ async def prompt_analyze(body: dict) -> dict:
756
738
  """
757
739
  from sdk.ui import jobs
758
740
 
759
- system_prompt = str(body.get("system_prompt", "")).strip()
760
- if len(system_prompt) < 10:
761
- raise HTTPException(status_code=400, detail="Write a system prompt first (a bit longer).")
741
+ system_prompt = body.system_prompt
762
742
 
763
743
  async def work() -> dict:
764
744
  from pathlib import Path
@@ -778,7 +758,7 @@ async def prompt_analyze(body: dict) -> dict:
778
758
 
779
759
 
780
760
  @router.post("/prompt/comments", dependencies=[_auth])
781
- async def prompt_comments(body: dict) -> dict:
761
+ async def prompt_comments(body: SystemPromptRequest) -> dict:
782
762
  """AI reviews the system prompt and returns open questions as 'comments'.
783
763
 
784
764
  Resolve these by editing the prompt BEFORE building the agent — so the agent
@@ -787,9 +767,7 @@ async def prompt_comments(body: dict) -> dict:
787
767
  """
788
768
  from sdk.ui import jobs
789
769
 
790
- system_prompt = str(body.get("system_prompt", "")).strip()
791
- if len(system_prompt) < 10:
792
- raise HTTPException(status_code=400, detail="Write a system prompt first (a bit longer).")
770
+ system_prompt = body.system_prompt
793
771
 
794
772
  async def work() -> dict:
795
773
  from pathlib import Path
@@ -809,7 +787,7 @@ async def prompt_comments(body: dict) -> dict:
809
787
 
810
788
 
811
789
  @router.post("/prompt/run", dependencies=[_auth])
812
- async def prompt_run(body: dict) -> dict:
790
+ async def prompt_run(body: PromptRunRequest) -> dict:
813
791
  """Run a system prompt with the user-supplied input values → the response.
814
792
 
815
793
  Body: ``system_prompt``, ``inputs`` ({name: value}), optional ``output_format``,
@@ -820,14 +798,12 @@ async def prompt_run(body: dict) -> dict:
820
798
  from sdk.agents.prompt_studio import sanitize_tools
821
799
  from sdk.ui import jobs
822
800
 
823
- system_prompt = str(body.get("system_prompt", "")).strip()
824
- if not system_prompt:
825
- raise HTTPException(status_code=400, detail="system_prompt is required.")
826
- inputs = body.get("inputs") or {}
827
- output_format = str(body.get("output_format", "")).strip()
801
+ system_prompt = body.system_prompt
802
+ inputs = body.inputs
803
+ output_format = body.output_format
828
804
  # AI-assessed tools; fall back to a minimal read-only tool so a pure text task
829
805
  # never inherits the broad default allowlist.
830
- tools = sanitize_tools(body.get("tools") or []) or ["Read"]
806
+ tools = sanitize_tools(body.tools) or ["Read"]
831
807
 
832
808
  async def work() -> dict:
833
809
  import time as _time
@@ -874,7 +850,7 @@ async def _run_prompt_engine(prompt: str) -> str:
874
850
 
875
851
 
876
852
  @router.post("/agent-prompt/generate", dependencies=[_auth])
877
- async def agent_prompt_generate(body: dict) -> dict:
853
+ async def agent_prompt_generate(body: AgentPromptGenerateRequest) -> dict:
878
854
  """name + type + job → a structured system prompt + {{variables}} (claude_code).
879
855
 
880
856
  Body: ``agent_name``, ``agent_type``, ``job``, optional ``context``.
@@ -882,14 +858,12 @@ async def agent_prompt_generate(body: dict) -> dict:
882
858
  """
883
859
  from sdk.agents.prompt_studio import build_generate_prompt, parse_generation
884
860
 
885
- agent_name = str(body.get("agent_name", "")).strip()
886
- agent_type = str(body.get("agent_type", "")).strip()
887
- job = str(body.get("job", "")).strip()
888
- if not agent_name or not agent_type or not job:
889
- raise HTTPException(status_code=400, detail="agent_name, agent_type, and job are required.")
861
+ agent_name = body.agent_name
862
+ agent_type = body.agent_type
863
+ job = body.job
890
864
 
891
865
  text = await _run_prompt_engine(
892
- build_generate_prompt(agent_name, agent_type, job, str(body.get("context", "")))
866
+ build_generate_prompt(agent_name, agent_type, job, body.context)
893
867
  )
894
868
  parsed = parse_generation(text)
895
869
  if not parsed["system_prompt"]:
@@ -898,7 +872,7 @@ async def agent_prompt_generate(body: dict) -> dict:
898
872
 
899
873
 
900
874
  @router.post("/agent-prompt/regenerate", dependencies=[_auth])
901
- async def agent_prompt_regenerate(body: dict) -> dict:
875
+ async def agent_prompt_regenerate(body: PromptRegenerateRequest) -> dict:
902
876
  """Improve an existing prompt (optional feedback) → prompt + {{variables}}.
903
877
 
904
878
  Body: ``system_prompt``, optional ``feedback``, ``agent_name``, ``agent_type``,
@@ -906,24 +880,20 @@ async def agent_prompt_regenerate(body: dict) -> dict:
906
880
  """
907
881
  from sdk.agents.prompt_studio import build_regenerate_prompt, parse_generation
908
882
 
909
- system_prompt = str(body.get("system_prompt", "")).strip()
910
- if not system_prompt:
911
- raise HTTPException(status_code=400, detail="system_prompt is required.")
912
-
913
883
  text = await _run_prompt_engine(build_regenerate_prompt(
914
- system_prompt, str(body.get("feedback", "")),
915
- str(body.get("agent_name", "")), str(body.get("agent_type", "")), str(body.get("job", "")),
884
+ body.system_prompt, body.feedback,
885
+ body.agent_name, body.agent_type, body.job,
916
886
  ))
917
887
  parsed = parse_generation(text)
918
888
  if not parsed["system_prompt"]:
919
889
  raise HTTPException(status_code=502, detail="regeneration returned no system_prompt")
920
- return {"agent_name": str(body.get("agent_name", "")).strip(),
921
- "agent_type": str(body.get("agent_type", "")).strip(),
890
+ return {"agent_name": body.agent_name,
891
+ "agent_type": body.agent_type,
922
892
  "engine": "claude_code", **parsed}
923
893
 
924
894
 
925
895
  @router.post("/agent-prompt/verify", dependencies=[_auth])
926
- async def agent_prompt_verify(body: dict) -> dict:
896
+ async def agent_prompt_verify(body: PromptVerifyRequest) -> dict:
927
897
  """Manual-trigger flow: verify a user-written prompt, then extract vars + secrets.
928
898
 
929
899
  Standards check is deterministic (our Role/Objective/Rules/Workflow/Output
@@ -935,9 +905,7 @@ async def agent_prompt_verify(body: dict) -> dict:
935
905
  from sdk.agents.prompt_template import check_system_prompt
936
906
  from sdk.agents.prompt_vars import extract_variables
937
907
 
938
- system_prompt = str(body.get("system_prompt", "")).strip()
939
- if len(system_prompt) < 10:
940
- raise HTTPException(status_code=400, detail="Write a system prompt first (a bit longer).")
908
+ system_prompt = body.system_prompt
941
909
 
942
910
  standards = check_system_prompt(system_prompt)
943
911
 
@@ -0,0 +1,366 @@
1
+ """Pydantic request models for the ``/api/ui/*`` control-panel API.
2
+
3
+ One model per endpoint body. These replace the previous bare ``body: dict``
4
+ signatures so inputs are validated (types, required fields, formats) before a
5
+ handler runs, and Swagger renders a real schema/example instead of a free-form
6
+ object. A validation failure surfaces as HTTP 422 (FastAPI's standard) with a
7
+ field-level message.
8
+
9
+ Behaviour is preserved from the old hand-rolled checks: strings are stripped,
10
+ optional fields keep their previous defaults, and the polymorphic / alias inputs
11
+ (``keys`` as string-or-list, ``scope`` falling back to ``source``) are normalised
12
+ here via validators so handlers stay thin.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import ipaddress
17
+
18
+ from pydantic import BaseModel, Field, field_validator, model_validator
19
+
20
+
21
+ def _normalise_keys(v: object) -> list[str]:
22
+ """A whitespace-separated string OR a list → a clean, non-empty-item list."""
23
+ if v is None:
24
+ return []
25
+ if isinstance(v, str):
26
+ v = v.split()
27
+ if not isinstance(v, (list, tuple)):
28
+ raise ValueError("keys must be a string or a list.")
29
+ return [str(k).strip() for k in v if str(k).strip()]
30
+
31
+
32
+ # ── IP allowlist ────────────────────────────────────────────────────────────
33
+
34
+ class AllowlistEntry(BaseModel):
35
+ """Body for ``POST /api/ui/allowlist`` — one IP or CIDR to whitelist."""
36
+
37
+ ip: str = Field(
38
+ ...,
39
+ description="IPv4/IPv6 address or CIDR block to whitelist, "
40
+ "e.g. '203.0.113.4' or '10.0.0.0/8'.",
41
+ examples=["203.0.113.4"],
42
+ )
43
+
44
+ @field_validator("ip")
45
+ @classmethod
46
+ def _valid_ip_or_cidr(cls, value: str) -> str:
47
+ value = (value or "").strip()
48
+ if not value:
49
+ raise ValueError("ip is required.")
50
+ try:
51
+ ipaddress.ip_network(value, strict=False)
52
+ except ValueError:
53
+ raise ValueError(f"invalid IP or CIDR: {value!r}")
54
+ return value
55
+
56
+
57
+ # ── Setup / configuration ────────────────────────────────────────────────────
58
+
59
+ class ConfigureRequest(BaseModel):
60
+ """Body for ``POST /api/ui/configure`` — save the DevOpsBot API token."""
61
+
62
+ token: str = Field(..., description="DevOpsBot API token; must start with 'co_'.",
63
+ examples=["co_<userUuid>_<secret>"])
64
+
65
+ @field_validator("token")
66
+ @classmethod
67
+ def _co_token(cls, value: str) -> str:
68
+ value = (value or "").strip()
69
+ if not value.startswith("co_"):
70
+ raise ValueError("Token must start with 'co_'.")
71
+ return value
72
+
73
+
74
+ class SetupRequest(BaseModel):
75
+ """Body for ``POST /api/ui/setup`` (optional)."""
76
+
77
+ upgrade: bool = True
78
+
79
+
80
+ # ── Execution (agentos-auto subcommands) ──────────────────────────────────────
81
+
82
+ class WorkspaceRequest(BaseModel):
83
+ """Body for ``POST /api/ui/run`` and ``/resume`` (both optional)."""
84
+
85
+ workspace_root: str | None = None
86
+
87
+ @field_validator("workspace_root")
88
+ @classmethod
89
+ def _strip(cls, value: str | None) -> str | None:
90
+ if value is None:
91
+ return None
92
+ value = str(value).strip()
93
+ return value or None
94
+
95
+
96
+ class TicketRequest(BaseModel):
97
+ """Body for ``POST /api/ui/ticket`` — one or more Jira ticket keys."""
98
+
99
+ keys: list[str] = Field(default_factory=list, examples=[["PROJ-123", "PROJ-124"]])
100
+ workspace_root: str | None = None
101
+
102
+ @field_validator("keys", mode="before")
103
+ @classmethod
104
+ def _norm(cls, v: object) -> list[str]:
105
+ return _normalise_keys(v)
106
+
107
+ @field_validator("keys")
108
+ @classmethod
109
+ def _at_least_one(cls, v: list[str]) -> list[str]:
110
+ if not v:
111
+ raise ValueError("Provide at least one ticket key.")
112
+ return v
113
+
114
+ @field_validator("workspace_root")
115
+ @classmethod
116
+ def _strip(cls, value: str | None) -> str | None:
117
+ if value is None:
118
+ return None
119
+ value = str(value).strip()
120
+ return value or None
121
+
122
+
123
+ class StoryRequest(BaseModel):
124
+ """Body for ``POST /api/ui/story`` (optional) — zero or more story keys."""
125
+
126
+ keys: list[str] = Field(default_factory=list)
127
+
128
+ @field_validator("keys", mode="before")
129
+ @classmethod
130
+ def _norm(cls, v: object) -> list[str]:
131
+ return _normalise_keys(v)
132
+
133
+
134
+ # ── Custom agents: keys / update / chat / run ────────────────────────────────
135
+
136
+ class AgentKeysSet(BaseModel):
137
+ """Body for ``POST /api/ui/agents/keys`` — persist a custom agent's API keys."""
138
+
139
+ name: str = Field(..., description="Custom agent name.")
140
+ # Values are coerced to str by the handler (matching prior behaviour), so accept
141
+ # any JSON scalar here rather than rejecting e.g. a numeric value with 422.
142
+ keys: dict[str, object] = Field(..., description="{ENV_VAR: value} — merged into stored keys.")
143
+
144
+ @field_validator("name")
145
+ @classmethod
146
+ def _name(cls, value: str) -> str:
147
+ value = (value or "").strip()
148
+ if not value:
149
+ raise ValueError("name is required.")
150
+ return value
151
+
152
+ @field_validator("keys")
153
+ @classmethod
154
+ def _nonempty(cls, value: dict[str, object]) -> dict[str, object]:
155
+ if not value:
156
+ raise ValueError("keys must be a non-empty object.")
157
+ return value
158
+
159
+
160
+ class UpdateAgentRequest(BaseModel):
161
+ """Body for ``PATCH /api/ui/agents`` — edit a local custom agent.
162
+
163
+ PATCH semantics: only fields the caller actually sends are applied. The
164
+ handler reads ``model_fields_set`` (via ``exclude_unset``) to tell "not sent"
165
+ apart from "sent as empty". ``name`` is the immutable identity.
166
+ """
167
+
168
+ name: str = Field(..., description="Agent name (identity — immutable).")
169
+ description: str | None = None
170
+ prompt: str | None = None
171
+ allowed_tools: list[str] | None = None
172
+ status: str | None = None
173
+
174
+ @field_validator("name")
175
+ @classmethod
176
+ def _name(cls, value: str) -> str:
177
+ value = (value or "").strip()
178
+ if not value:
179
+ raise ValueError("name is required.")
180
+ return value
181
+
182
+
183
+ class AgentChatRequest(BaseModel):
184
+ """Body for ``POST /api/ui/agents/chat``."""
185
+
186
+ name: str = Field(..., description="Agent name.")
187
+ message: str = Field(..., description="User message.")
188
+ variables: dict = Field(default_factory=dict)
189
+ history: list = Field(default_factory=list)
190
+ department_id: str = ""
191
+
192
+ @field_validator("name", "message")
193
+ @classmethod
194
+ def _required(cls, value: str) -> str:
195
+ value = (value or "").strip()
196
+ if not value:
197
+ raise ValueError("name and message are required.")
198
+ return value
199
+
200
+ @field_validator("department_id")
201
+ @classmethod
202
+ def _strip(cls, value: str) -> str:
203
+ return (value or "").strip()
204
+
205
+
206
+ class AgentRunRequest(BaseModel):
207
+ """Body for ``POST /api/ui/agents/run``.
208
+
209
+ ``scope`` accepts a legacy ``source`` alias and defaults to ``"custom"``.
210
+ """
211
+
212
+ name: str = Field(..., description="Agent name.")
213
+ variables: dict = Field(default_factory=dict)
214
+ department_id: str = ""
215
+ scope: str = "custom"
216
+
217
+ @model_validator(mode="before")
218
+ @classmethod
219
+ def _scope_from_source(cls, data: object) -> object:
220
+ if isinstance(data, dict):
221
+ scope = data.get("scope") or data.get("source") or "custom"
222
+ data = {**data, "scope": scope}
223
+ return data
224
+
225
+ @field_validator("name")
226
+ @classmethod
227
+ def _name(cls, value: str) -> str:
228
+ value = (value or "").strip()
229
+ if not value:
230
+ raise ValueError("name is required.")
231
+ return value
232
+
233
+ @field_validator("department_id")
234
+ @classmethod
235
+ def _strip_dept(cls, value: str) -> str:
236
+ return (value or "").strip()
237
+
238
+ @field_validator("scope")
239
+ @classmethod
240
+ def _scope(cls, value: str) -> str:
241
+ return (value or "").strip() or "custom"
242
+
243
+
244
+ # ── System assistant ─────────────────────────────────────────────────────────
245
+
246
+ class SystemChatRequest(BaseModel):
247
+ """Body for ``POST /api/ui/system/chat``."""
248
+
249
+ message: str = Field(..., description="Question for the System Assistant.")
250
+ history: list = Field(default_factory=list)
251
+
252
+ @field_validator("message")
253
+ @classmethod
254
+ def _required(cls, value: str) -> str:
255
+ value = (value or "").strip()
256
+ if not value:
257
+ raise ValueError("message is required.")
258
+ return value
259
+
260
+
261
+ # ── Prompt Studio ─────────────────────────────────────────────────────────────
262
+
263
+ # The agent-prompt/* models below intentionally mirror the AI backend's request
264
+ # schemas field-for-field (routes/agent_prompt.py: Generate/Regenerate/Verify),
265
+ # so one JSON body works against both /api/v1/agent-prompt/* (backend) and
266
+ # /api/ui/agent-prompt/* (this SDK). ``engine`` is part of that shared contract;
267
+ # the SDK always runs ``claude_code`` locally, so it accepts the field for parity
268
+ # and ignores non-local values rather than 400ing.
269
+ _ENGINE_DESC = "'claude_code' (local SDK) or 'server' (backend LLM)."
270
+
271
+
272
+ class SystemPromptRequest(BaseModel):
273
+ """Body for the SDK-only Prompt Studio helpers (``/prompt/analyze``,
274
+ ``/prompt/comments``) — no backend equivalent, so no ``engine`` field."""
275
+
276
+ system_prompt: str = Field(..., description="The system prompt to analyse.")
277
+
278
+ @field_validator("system_prompt")
279
+ @classmethod
280
+ def _min_len(cls, value: str) -> str:
281
+ value = (value or "").strip()
282
+ if len(value) < 10:
283
+ raise ValueError("Write a system prompt first (a bit longer).")
284
+ return value
285
+
286
+
287
+ class PromptVerifyRequest(BaseModel):
288
+ """Body for ``POST /api/ui/agent-prompt/verify`` — mirrors the backend's
289
+ ``VerifyPromptRequest`` (``system_prompt`` + ``engine``)."""
290
+
291
+ system_prompt: str = Field(..., description="The user-written prompt to verify.")
292
+ engine: str = Field("claude_code", description=_ENGINE_DESC)
293
+
294
+ @field_validator("system_prompt")
295
+ @classmethod
296
+ def _min_len(cls, value: str) -> str:
297
+ value = (value or "").strip()
298
+ if len(value) < 10:
299
+ raise ValueError("Write a system prompt first (a bit longer).")
300
+ return value
301
+
302
+
303
+ class PromptRunRequest(BaseModel):
304
+ """Body for ``POST /api/ui/prompt/run``."""
305
+
306
+ system_prompt: str = Field(..., description="System prompt to run.")
307
+ inputs: dict = Field(default_factory=dict)
308
+ output_format: str = ""
309
+ tools: list[str] = Field(default_factory=list)
310
+
311
+ @field_validator("system_prompt")
312
+ @classmethod
313
+ def _required(cls, value: str) -> str:
314
+ value = (value or "").strip()
315
+ if not value:
316
+ raise ValueError("system_prompt is required.")
317
+ return value
318
+
319
+ @field_validator("output_format")
320
+ @classmethod
321
+ def _strip(cls, value: str) -> str:
322
+ return (value or "").strip()
323
+
324
+
325
+ class PromptRegenerateRequest(BaseModel):
326
+ """Body for ``POST /api/ui/agent-prompt/regenerate`` — mirrors the backend's
327
+ ``RegeneratePromptRequest``."""
328
+
329
+ system_prompt: str = Field(..., description="The existing prompt to improve.")
330
+ feedback: str = Field("", description="Optional guidance on what to change.")
331
+ agent_name: str = Field("", description="Optional context: the agent's name.")
332
+ agent_type: str = Field("", description="Optional context: the agent's type.")
333
+ job: str = Field("", description="Optional context: the agent's main job.")
334
+ engine: str = Field("claude_code", description=_ENGINE_DESC)
335
+
336
+ @field_validator("system_prompt")
337
+ @classmethod
338
+ def _required(cls, value: str) -> str:
339
+ value = (value or "").strip()
340
+ if not value:
341
+ raise ValueError("system_prompt is required.")
342
+ return value
343
+
344
+ @field_validator("agent_name", "agent_type")
345
+ @classmethod
346
+ def _strip(cls, value: str) -> str:
347
+ return (value or "").strip()
348
+
349
+
350
+ class AgentPromptGenerateRequest(BaseModel):
351
+ """Body for ``POST /api/ui/agent-prompt/generate`` — mirrors the backend's
352
+ ``GeneratePromptRequest``."""
353
+
354
+ agent_name: str = Field(..., description="The agent's name, e.g. 'Release Notes Writer'.")
355
+ agent_type: str = Field(..., description="The agent's type/category, e.g. 'documentation'.")
356
+ job: str = Field(..., description="The agent's main job, in plain language.")
357
+ context: str = Field("", description="Optional extra context to steer the prompt.")
358
+ engine: str = Field("claude_code", description=_ENGINE_DESC)
359
+
360
+ @field_validator("agent_name", "agent_type", "job")
361
+ @classmethod
362
+ def _required(cls, value: str) -> str:
363
+ value = (value or "").strip()
364
+ if not value:
365
+ raise ValueError("agent_name, agent_type, and job are required.")
366
+ return value
@@ -222,3 +222,38 @@ def status() -> str:
222
222
  if _systemd_available():
223
223
  return _status_systemd()
224
224
  return "no supported service manager found."
225
+
226
+
227
+ def restart() -> tuple[bool, str]:
228
+ """Restart the installed agentos-ui service so it re-imports the on-disk code.
229
+
230
+ A running server holds the routes it imported at boot, so a package upgrade
231
+ (``agentos-auto update``) doesn't expose newly-added APIs until the process
232
+ restarts. This performs that restart via whatever manager installed it.
233
+ Best-effort: returns ``(False, msg)`` when no managed service is found, so a
234
+ manually-launched panel is left untouched (the operator restarts it himself).
235
+ """
236
+ if IS_WINDOWS:
237
+ _run(["schtasks", "/End", "/TN", WIN_TASK])
238
+ rc, out = _run(["schtasks", "/Run", "/TN", WIN_TASK])
239
+ return (rc == 0), (f"restarted scheduled task '{WIN_TASK}'." if rc == 0 else out)
240
+ if IS_MAC:
241
+ path = _mac_plist_path()
242
+ if not path.exists():
243
+ return False, "no launchd agent installed — restart the panel manually."
244
+ _run(["launchctl", "unload", str(path)])
245
+ rc, out = _run(["launchctl", "load", "-w", str(path)])
246
+ return (rc == 0), (f"restarted launchd agent '{_MAC_LABEL}'." if rc == 0 else out)
247
+ if _systemd_available():
248
+ is_root = hasattr(os, "geteuid") and os.geteuid() == 0
249
+ sysctl = ["systemctl"] if is_root else ["systemctl", "--user"]
250
+ # Only restart a unit that actually exists, so we don't spam errors on a
251
+ # box where the panel runs under nohup/tmux instead of systemd.
252
+ rc, _ = _run([*sysctl, "list-unit-files", f"{SERVICE_NAME}.service"])
253
+ _, active = _run([*sysctl, "is-active", SERVICE_NAME])
254
+ if rc != 0 and active.strip() not in ("active", "activating"):
255
+ return False, f"no systemd unit '{SERVICE_NAME}' — restart the panel manually."
256
+ rc, out = _run([*sysctl, "restart", SERVICE_NAME])
257
+ return (rc == 0), (f"restarted systemd service '{SERVICE_NAME}'." if rc == 0
258
+ else out or f"systemctl restart {SERVICE_NAME} failed")
259
+ return False, "no supported service manager found — restart the panel manually (`agentos-ui`)."