devops-bot-sdk 1.4.129__tar.gz → 1.4.139__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 (91) hide show
  1. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/devops_bot_sdk.egg-info/SOURCES.txt +1 -0
  4. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/client.py +74 -3
  6. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/exceptions.py +15 -0
  7. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/models/responses.py +92 -8
  8. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/api.py +33 -1
  9. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/static/app.js +95 -34
  10. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/static/styles.css +15 -0
  11. devops_bot_sdk-1.4.139/tests/test_agent_prompt_models.py +165 -0
  12. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/README.md +0 -0
  13. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  14. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  15. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/devops_bot_sdk.egg-info/requires.txt +0 -0
  16. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  17. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/pyproject.toml +0 -0
  18. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agentd.py +0 -0
  19. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/__init__.py +0 -0
  20. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/builder.py +0 -0
  21. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/chat.py +0 -0
  22. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/connectors.py +0 -0
  23. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/engines/__init__.py +0 -0
  24. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/engines/claude_code.py +0 -0
  25. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/engines/http.py +0 -0
  26. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/engines/mcp.py +0 -0
  27. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/engines/n8n.py +0 -0
  28. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/engines/shell.py +0 -0
  29. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/integrations.py +0 -0
  30. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/inventory.py +0 -0
  31. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/library/__init__.py +0 -0
  32. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/manifest.py +0 -0
  33. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/prompt_studio.py +0 -0
  34. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/prompt_template.py +0 -0
  35. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/prompt_vars.py +0 -0
  36. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/run_log.py +0 -0
  37. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/secrets.py +0 -0
  38. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/usage.py +0 -0
  39. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/agents/varstore.py +0 -0
  40. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/cli.py +0 -0
  41. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/collectors/__init__.py +0 -0
  42. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/collectors/files.py +0 -0
  43. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/collectors/process.py +0 -0
  44. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/collectors/screenshot.py +0 -0
  45. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/config.py +0 -0
  46. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/crucial.py +0 -0
  47. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/design_verify/__init__.py +0 -0
  48. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/design_verify/assets.py +0 -0
  49. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/design_verify/bootstrap.py +0 -0
  50. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/design_verify/browser.py +0 -0
  51. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/design_verify/compare.py +0 -0
  52. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/design_verify/loop.py +0 -0
  53. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/git_ops.py +0 -0
  54. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/graphify.py +0 -0
  55. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/hooks/__init__.py +0 -0
  56. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/hooks/crucial_guard.py +0 -0
  57. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ipc/__init__.py +0 -0
  58. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ipc/electron_bridge.py +0 -0
  59. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ipc/handlers.py +0 -0
  60. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/local_exec.py +0 -0
  61. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/models/__init__.py +0 -0
  62. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/models/envelope.py +0 -0
  63. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/models/requests.py +0 -0
  64. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/models/snapshots.py +0 -0
  65. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/platform_compat.py +0 -0
  66. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/py.typed +0 -0
  67. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/run_auto.py +0 -0
  68. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/sse.py +0 -0
  69. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/test.py +0 -0
  70. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/test_pipeline.py +0 -0
  71. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/__init__.py +0 -0
  72. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/dashboard.py +0 -0
  73. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/ip_allowlist.py +0 -0
  74. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/jobs.py +0 -0
  75. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/models.py +0 -0
  76. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/runner.py +0 -0
  77. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/security.py +0 -0
  78. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/server.py +0 -0
  79. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/service.py +0 -0
  80. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/setup_manager.py +0 -0
  81. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/static/agents.html +0 -0
  82. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/static/custom-agents.html +0 -0
  83. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/static/default-agents.html +0 -0
  84. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/static/index.html +0 -0
  85. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/static/runs.html +0 -0
  86. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/static/setup.html +0 -0
  87. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/ui/static/tickets.html +0 -0
  88. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/sdk/updater.py +0 -0
  89. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/setup.cfg +0 -0
  90. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/tests/test_agent_chat_features.py +0 -0
  91. {devops_bot_sdk-1.4.129 → devops_bot_sdk-1.4.139}/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.129
3
+ Version: 1.4.139
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.129
3
+ Version: 1.4.139
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
@@ -85,4 +85,5 @@ sdk/ui/static/setup.html
85
85
  sdk/ui/static/styles.css
86
86
  sdk/ui/static/tickets.html
87
87
  tests/test_agent_chat_features.py
88
+ tests/test_agent_prompt_models.py
88
89
  tests/test_agents_connectors_builder.py
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.129
3
+ Version: 1.4.139
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.129" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
33
+ __version__ = "1.4.139" # 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
@@ -20,15 +20,15 @@ from typing import Any, AsyncGenerator
20
20
  import httpx
21
21
 
22
22
  from sdk.exceptions import (
23
- BackendAuthFailed, BackendFeatureMissing, BackendUnreachable, BackendVersionTooOld,
24
- TokenNotConfigured,
23
+ BackendAuthFailed, BackendFeatureMissing, BackendPermissionDenied, BackendUnreachable,
24
+ BackendVersionTooOld, TokenNotConfigured,
25
25
  )
26
26
  from sdk.models.envelope import Envelope
27
27
  from sdk.models.requests import (
28
28
  ChatRequest, OrchestrateRequest, OrchestratorRequest, WebhookPayload,
29
29
  )
30
30
  from sdk.models.responses import (
31
- BootstrapData, ChatHistory, OAuthStarted, PipelineResult, PipelineStatus,
31
+ BootstrapData, ChatHistory, CustomAgent, DepartmentAgent, OAuthStarted, PipelineResult, PipelineStatus,
32
32
  ThreadSummary, UserProfile, WebhookAck,
33
33
  OrchestrateAutoStatusResponse, OrchestrateAutoStopResponse, OrchestrateAutoResumeResponse,
34
34
  OrchestrateAutoStopAllResponse, OrchestrateAutoResumeAllResponse,
@@ -164,6 +164,15 @@ class BackendClient:
164
164
  user = data.get("user") or {}
165
165
  role = user.get("role") or {}
166
166
  dept = user.get("department") or {}
167
+ # Department head id — the backend gates department-agent writes on
168
+ # `departments.head_user_id`. Accept the field under any of the shapes the
169
+ # ML backend might use; empty when it isn't surfaced (then headship falls
170
+ # back to role tier — see UserProfile.is_department_head).
171
+ head_id = (
172
+ dept.get("headUserId") or dept.get("head_user_id")
173
+ or (dept.get("head") or {}).get("id")
174
+ or user.get("departmentHeadId") or ""
175
+ )
167
176
  return UserProfile(
168
177
  user_id=str(user.get("id") or ""),
169
178
  email=str(user.get("email") or ""),
@@ -172,6 +181,7 @@ class BackendClient:
172
181
  user_type=str(user.get("userType") or "").lower(),
173
182
  department_id=(user.get("departmentId") or dept.get("id") or None),
174
183
  department_title=str(dept.get("title") or ""),
184
+ department_head_id=str(head_id or ""),
175
185
  )
176
186
 
177
187
  # ── Chatbot SSE ────────────────────────────────────────────────────
@@ -1217,6 +1227,19 @@ class BackendClient:
1217
1227
  _check_status(resp.status_code, self._base_url)
1218
1228
  return resp.json().get("agents", [])
1219
1229
 
1230
+ async def get_custom_agents(self) -> list[CustomAgent]:
1231
+ """GET /api/v1/agents → parsed :class:`CustomAgent` rows.
1232
+
1233
+ The typed counterpart to :meth:`list_custom_agents` (which returns raw
1234
+ dicts for existing dict-based consumers). Each row is validated into a
1235
+ ``CustomAgent`` — including the nested ``prompt`` (source / content /
1236
+ specialty) — so callers get attribute access and IDE completion instead of
1237
+ untyped dict lookups. The models are lenient (``extra=allow``), so unknown
1238
+ backend fields pass through rather than failing validation.
1239
+ """
1240
+ rows = await self.list_custom_agents()
1241
+ return [CustomAgent(**row) for row in rows]
1242
+
1220
1243
  async def update_custom_agent(self, name: str, **fields) -> dict:
1221
1244
  """PATCH /api/v1/agents/{name} — update fields on a custom agent."""
1222
1245
  async with httpx.AsyncClient(timeout=15.0) as client:
@@ -1261,6 +1284,54 @@ class BackendClient:
1261
1284
  _check_status(resp.status_code, self._base_url)
1262
1285
  return resp.json().get("agents", [])
1263
1286
 
1287
+ async def get_department_agents(self) -> list[DepartmentAgent]:
1288
+ """GET /api/v1/department-agents → parsed :class:`DepartmentAgent` rows.
1289
+
1290
+ The typed counterpart to :meth:`list_department_agents` (which returns raw
1291
+ dicts for agentd's manifest path). Each row is validated into a
1292
+ ``DepartmentAgent`` — including the nested ``prompt`` (source / content /
1293
+ specialty) — so callers get attribute access and IDE completion instead of
1294
+ untyped dict lookups. The models are lenient (``extra=allow``), so unknown
1295
+ backend fields pass through rather than failing validation.
1296
+ """
1297
+ rows = await self.list_department_agents()
1298
+ return [DepartmentAgent(**row) for row in rows]
1299
+
1300
+ async def delete_department_agent(self, name: str) -> None:
1301
+ """DELETE /api/v1/department-agents/{name} — remove a department default agent.
1302
+
1303
+ HEAD-GATED server-side: the backend authorises this only for the caller's
1304
+ department head (or ``master``) via ``rbac.scopes.is_department_head`` and
1305
+ returns **403** otherwise — this client does not (and cannot) re-check that
1306
+ locally, so a non-head caller reaching here still fails at the backend. The
1307
+ control panel hides Delete for non-heads (see UserProfile.is_department_head)
1308
+ so a 403 is the exception, not the norm. Status changes (enable/disable) are
1309
+ likewise head-only but not exposed here — agentd reads department agents
1310
+ read-only and re-derives status on restart.
1311
+ """
1312
+ async with httpx.AsyncClient(timeout=15.0) as client:
1313
+ resp = await client.delete(
1314
+ self._url(f"/api/v1/department-agents/{name}"),
1315
+ headers=self._headers,
1316
+ )
1317
+ if resp.status_code == 404:
1318
+ raise BackendFeatureMissing(
1319
+ "Department agents", self._url(f"/api/v1/department-agents/{name}"),
1320
+ )
1321
+ if resp.status_code == 403:
1322
+ # _check_status lets 403 through; surface it as a permission error so
1323
+ # the UI can say "head/manager only" rather than silently "succeeding".
1324
+ detail = ""
1325
+ try:
1326
+ detail = (resp.json() or {}).get("detail") or ""
1327
+ except Exception: # noqa: BLE001 — non-JSON body
1328
+ pass
1329
+ raise BackendPermissionDenied(
1330
+ detail or "Only the department head can delete a department agent.",
1331
+ self._url(f"/api/v1/department-agents/{name}"),
1332
+ )
1333
+ _check_status(resp.status_code, self._base_url)
1334
+
1264
1335
  # NOTE: the legacy per-department "default agents" endpoints
1265
1336
  # (/departments/{id}/default-agents[/seed|/activate|...]) were retired — that
1266
1337
  # table never existed. Department defaults are now plain rows in the `agents`
@@ -69,6 +69,21 @@ class BackendFeatureMissing(Exception):
69
69
  self.url = url
70
70
 
71
71
 
72
+ class BackendPermissionDenied(Exception):
73
+ """Raised when the backend returns HTTP 403 — the caller is authenticated but
74
+ not authorised for this action (e.g. a non-head deleting a department agent,
75
+ gated by ``rbac.scopes.is_department_head``).
76
+
77
+ Distinct from BackendAuthFailed (401, bad/absent token): here the token is
78
+ valid, the action isn't permitted. The UI maps this to a "you don't have
79
+ permission" message rather than re-prompting for a token.
80
+ """
81
+ def __init__(self, detail: str = "", url: str = "") -> None:
82
+ super().__init__(detail or f"Permission denied (HTTP 403 at {url}).")
83
+ self.detail = detail
84
+ self.url = url
85
+
86
+
72
87
  class CollectorError(Exception):
73
88
  """Raised by collectors when a payload fails size or type validation."""
74
89
  def __init__(self, message: str, path: str = "") -> None:
@@ -43,17 +43,45 @@ class UserProfile(BaseModel):
43
43
  an employee may view/activate department agents but not create/edit/delete
44
44
  them; custom agents are unrestricted for everyone.
45
45
  """
46
- user_id: str = ""
47
- email: str = ""
48
- role_slug: str = ""
49
- role_name: str = ""
50
- user_type: str = ""
51
- department_id: str | None = None
52
- department_title: str = ""
46
+ user_id: str = ""
47
+ email: str = ""
48
+ role_slug: str = ""
49
+ role_name: str = ""
50
+ user_type: str = ""
51
+ department_id: str | None = None
52
+ department_title: str = ""
53
+ # `departments.head_user_id` when the profile exposes it (see
54
+ # `is_department_head`). Empty when the backend hasn't migrated / surfaced it.
55
+ department_head_id: str = ""
56
+
57
+ def _role(self) -> str:
58
+ """Normalised role token: lower-case, separators stripped
59
+ (so "team_lead"/"team-lead"/"teamlead" and "super-admin"/"superadmin"
60
+ all collapse to one form)."""
61
+ raw = (self.role_slug or self.user_type or "").lower()
62
+ return raw.replace("-", "").replace("_", "").replace(" ", "")
53
63
 
54
64
  @property
55
65
  def is_employee(self) -> bool:
56
- return (self.role_slug or self.user_type or "").lower() == "employee"
66
+ return self._role() == "employee"
67
+
68
+ @property
69
+ def is_department_head(self) -> bool:
70
+ """True if the caller may perform head-gated department-agent writes
71
+ (delete). Mirrors the backend's ``rbac.scopes.is_department_head``:
72
+
73
+ - ``master`` / ``superadmin`` / ``executive`` tiers act as managers, OR
74
+ - the caller's id matches the department's ``head_user_id`` (when the
75
+ profile provides it).
76
+
77
+ Default-deny: an unknown role with no head match resolves to False, so a
78
+ non-head team-lead never sees Delete (the backend also enforces this — the
79
+ SDK gate only hides an action that would 403 anyway). The head-id path
80
+ activates automatically once the backend surfaces ``head_user_id``.
81
+ """
82
+ if self._role() in {"master", "superadmin", "executive"}:
83
+ return True
84
+ return bool(self.department_head_id) and self.department_head_id == self.user_id
57
85
 
58
86
 
59
87
  class PipelineStatus(BaseModel):
@@ -184,3 +212,59 @@ class OrchestrateAutoStatusResponse(BaseModel):
184
212
  auto_status: str = "" # discovering | running | completed | failed | no_tasks | paused
185
213
  task_count: int = 0
186
214
  tasks: list[OrchestrateAutoTaskItem] = []
215
+
216
+
217
+ # ── Custom / department agents ─────────────────────────────────────────
218
+ # Both GET /api/v1/agents (custom) and GET /api/v1/department-agents share the
219
+ # same backend row shape (`agents` table, projected by `_row_to_dict`); they
220
+ # differ only in `relation_type` ("User"/"person" vs "Department"). The two
221
+ # public models below extend one base so callers get a distinct, self-documenting
222
+ # type per endpoint without duplicating the field list.
223
+
224
+ class AgentPrompt(BaseModel):
225
+ """The `prompt` object on a custom / department agent row.
226
+
227
+ Lenient (extra=allow) so any backend-side additions (e.g. new prompt
228
+ metadata) survive parsing without an SDK bump.
229
+ """
230
+ model_config = {"extra": "allow"}
231
+
232
+ source: str = "" # e.g. "specialized/specialized-model-qa.md"
233
+ content: str = "" # the full system-prompt markdown
234
+ specialty: str = "" # one-line description of what the agent does
235
+
236
+
237
+ class _AgentRow(BaseModel):
238
+ """Shared shape of one `agents`-table row as the backend serialises it.
239
+
240
+ Lenient (extra=allow) so fields not modelled here (and any future ones)
241
+ pass through untouched. Only `status == "active"` rows are scheduled by
242
+ agentd; the model keeps whatever the backend sent.
243
+ """
244
+ model_config = {"extra": "allow"}
245
+
246
+ id: str = ""
247
+ name: str = ""
248
+ media: str | None = None
249
+ relation_type: str = "" # "User" / "person" (custom) | "Department"
250
+ relation_id: str = "" # the owner id (user or department)
251
+ created_at: str = ""
252
+ updated_at: str = ""
253
+ status: str = "" # "active" | "inactive"
254
+ prompt: AgentPrompt | None = None
255
+ secrets: list[Any] = []
256
+ skills: list[Any] = []
257
+
258
+ @property
259
+ def is_active(self) -> bool:
260
+ return (self.status or "").lower() == "active"
261
+
262
+
263
+ class CustomAgent(_AgentRow):
264
+ """One row from GET /api/v1/agents — a personal custom agent visible to the
265
+ caller under RBAC (`relation_type` is "User"/"person")."""
266
+
267
+
268
+ class DepartmentAgent(_AgentRow):
269
+ """One row from GET /api/v1/department-agents — a department's default agent
270
+ (`relation_type` is "Department")."""
@@ -384,11 +384,17 @@ async def ui_profile() -> dict:
384
384
  return {
385
385
  "role_slug": p.role_slug, "role_name": p.role_name,
386
386
  "department_id": p.department_id or "", "department_title": p.department_title,
387
+ # is_manager (team_lead+) gates the Prompt Studio save-as-department
388
+ # choice AND the department-agent Edit affordance. is_department_head
389
+ # (manager/head — or master/executive) additionally gates Delete on a
390
+ # department agent, mirroring the backend's head-only write check.
387
391
  "is_manager": not p.is_employee,
392
+ "is_department_head": p.is_department_head,
388
393
  }
389
394
  except Exception as exc: # noqa: BLE001
390
395
  return {"role_slug": "", "role_name": "", "department_id": "",
391
- "department_title": "", "is_manager": False, "error": str(exc)}
396
+ "department_title": "", "is_manager": False,
397
+ "is_department_head": False, "error": str(exc)}
392
398
 
393
399
 
394
400
  @router.get("/agents/vars", dependencies=[_auth])
@@ -975,6 +981,32 @@ async def default_agents(department_id: str = Query("")) -> dict:
975
981
  return {"department_id": dept, "agents": agents, "defaults_error": ""}
976
982
 
977
983
 
984
+ @router.delete("/default-agents", dependencies=[_auth])
985
+ async def delete_default_agent(name: str = Query(...)) -> dict:
986
+ """Delete a department default agent by name — HEAD/MANAGER ONLY.
987
+
988
+ Proxies DELETE /api/v1/department-agents/{name}, which the AI backend gates
989
+ to the department head (or master). The control panel already hides Delete for
990
+ non-heads (see /profile's ``is_department_head``); this maps a backend 403 to
991
+ HTTP 403 so a non-head who reaches here anyway gets a clear permission error
992
+ rather than a silent success.
993
+ """
994
+ from sdk.client import BackendClient
995
+ from sdk.exceptions import BackendFeatureMissing, BackendPermissionDenied
996
+
997
+ try:
998
+ await BackendClient.from_config().delete_department_agent(name)
999
+ except BackendPermissionDenied as exc:
1000
+ raise HTTPException(status_code=403, detail=str(exc))
1001
+ except BackendFeatureMissing:
1002
+ raise HTTPException(
1003
+ status_code=404,
1004
+ detail=("Department agent management isn't available on this backend — "
1005
+ "it may be running an older version."),
1006
+ )
1007
+ return {"name": name, "deleted": True}
1008
+
1009
+
978
1010
  # ── Dashboard reads ────────────────────────────────────────────────────────
979
1011
 
980
1012
  @router.get("/dashboard", dependencies=[_auth])
@@ -80,6 +80,66 @@
80
80
 
81
81
  window.AgentOS = { api, toast, esc };
82
82
 
83
+ // ── Role gates: cached profile (fetched once per page load) ────────────────
84
+ // is_manager: team_lead+ (may Edit a department agent). is_department_head:
85
+ // manager/head or master/executive (may additionally Delete one). Default-deny
86
+ // on error so a failed lookup hides privileged actions rather than exposing them.
87
+ let _profileCache = null;
88
+ async function getProfile() {
89
+ if (_profileCache) return _profileCache;
90
+ try { _profileCache = await api("/api/ui/profile"); }
91
+ catch (e) { _profileCache = { is_manager: false, is_department_head: false }; }
92
+ return _profileCache;
93
+ }
94
+
95
+ // ── Agent prompt-shape readers ─────────────────────────────────────────────
96
+ // Prompt shapes differ by origin: department agents carry {content, specialty,
97
+ // source}; custom agents may carry {system_prompt, variables}. These read either
98
+ // so a card never shows blank content or a wrong variable count.
99
+ function agentPrompt(a) { return (a && a.prompt) || {}; }
100
+ function agentContent(a) { const p = agentPrompt(a); return String(p.content || p.system_prompt || "").trim(); }
101
+ function agentSpecialty(a) { const p = agentPrompt(a); return String(p.specialty || p.description || "").trim(); }
102
+ function agentVarNames(a) {
103
+ const vs = agentPrompt(a).variables || [];
104
+ return vs.map(v => (typeof v === "string" ? v : (v && v.name))).filter(Boolean);
105
+ }
106
+ function agentSummary(a) { return agentSpecialty(a) || agentContent(a).slice(0, 200); }
107
+
108
+ // One agent card: clickable name toggles an inline panel with the full prompt
109
+ // content. `actionsHTML` is the role-gated button row the caller supplies.
110
+ function agentCardHTML(a, actionsHTML) {
111
+ const content = agentContent(a);
112
+ const summary = agentSummary(a);
113
+ const src = agentPrompt(a).source || "";
114
+ const nvars = agentVarNames(a).length;
115
+ const nskills = (a.skills || []).length;
116
+ const on = a.status === "disabled" ? "off" : "on";
117
+ return `<div class="agent-card">
118
+ <div class="agent-head">
119
+ <button type="button" class="agent-name linklike" data-toggle-content title="Show / hide the full prompt">${esc(a.name)}</button>
120
+ <span class="pill ${on}">${esc(a.status || "active")}</span>
121
+ </div>
122
+ <div class="agent-meta">${nvars} variable(s) · ${nskills} skill(s)${src ? " · <span class='muted'>" + esc(src) + "</span>" : ""}</div>
123
+ <div class="agent-desc">${summary ? esc(summary) : "<span class='muted'>No description provided.</span>"}</div>
124
+ <div class="agent-content" hidden>${content ? "<pre>" + esc(content) + "</pre>" : "<div class='muted'>No prompt content available for this agent.</div>"}</div>
125
+ <div class="agent-actions">${actionsHTML}</div>
126
+ </div>`;
127
+ }
128
+
129
+ // Wire clickable names (toggle) + Edit buttons (reveal) to their card's content
130
+ // panel. Call after rendering a list of agentCardHTML() cards into `container`.
131
+ function wireContentToggles(container) {
132
+ const panelOf = (el) => { const c = el.closest(".agent-card"); return c && c.querySelector(".agent-content"); };
133
+ container.querySelectorAll("[data-toggle-content]").forEach(b => b.onclick = () => {
134
+ const p = panelOf(b); if (p) p.hidden = !p.hidden;
135
+ });
136
+ // Edit = open the content view (read-only): reveal the panel and scroll to it.
137
+ container.querySelectorAll("[data-edit]").forEach(b => b.onclick = () => {
138
+ const p = panelOf(b);
139
+ if (p) { p.hidden = false; p.scrollIntoView({ block: "nearest" }); }
140
+ });
141
+ }
142
+
83
143
  // ── Unlock gate ──────────────────────────────────────────────────────────
84
144
  function setGateMsg(m) { const el = document.getElementById("gate-msg"); if (el) el.textContent = m || ""; }
85
145
  function showGate(message) {
@@ -169,31 +229,38 @@
169
229
 
170
230
  async function loadDefaults() {
171
231
  try {
172
- const d = await api("/api/ui/default-agents");
232
+ const [d, me] = [await api("/api/ui/default-agents"), await getProfile()];
173
233
  const rows = d.agents || [];
174
234
  if (!rows.length && d.defaults_error) {
175
235
  $("default-list").innerHTML =
176
236
  `<div class="empty">${esc(d.defaults_error)} Ask an admin to update the AI backend.</div>`;
177
237
  return;
178
238
  }
239
+ // Role gates (department agents): everyone Runs + views the content;
240
+ // team_lead+ also sees Edit (read view); only a department head/manager
241
+ // sees Delete (the backend enforces head-only regardless).
179
242
  $("default-list").innerHTML = rows.length ? rows.map(a => {
180
- const prompt = a.prompt || {};
181
- const summary = (prompt.system_prompt || "").trim().slice(0, 200);
182
- const nvars = (prompt.variables || []).length;
183
- const nskills = (a.skills || []).length;
184
- return `<div class="agent-card">
185
- <div class="agent-head">
186
- <span class="agent-name">${esc(a.name)}</span>
187
- <span class="pill ${a.status === "disabled" ? "off" : "on"}">${esc(a.status || "active")}</span>
188
- </div>
189
- <div class="agent-meta">${nvars} variable(s) · ${nskills} skill(s)</div>
190
- <div class="agent-desc">${esc(summary)}</div>
191
- <div class="agent-actions">
192
- <a class="btn primary" href="/agents.html?agent=${encodeURIComponent(a.name)}">Run</a>
193
- </div>
194
- </div>`;
243
+ const enc = encodeURIComponent(a.name);
244
+ const actions = [
245
+ `<a class="btn primary" href="/agents.html?agent=${enc}">Run</a>`,
246
+ me.is_manager ? `<button class="btn" data-edit="${esc(a.name)}">Edit</button>` : "",
247
+ me.is_department_head ? `<button class="btn danger" data-del-dept="${esc(a.name)}">Delete</button>` : "",
248
+ ].filter(Boolean).join("");
249
+ return agentCardHTML(a, actions);
195
250
  }).join("")
196
251
  : "<div class='empty'>No default agents for your department yet. They're created in the web app.</div>";
252
+
253
+ const list = $("default-list");
254
+ wireContentToggles(list);
255
+ list.querySelectorAll("[data-del-dept]").forEach(b => b.onclick = async () => {
256
+ const name = b.dataset.delDept;
257
+ if (!confirm("Delete department agent '" + name + "'? This removes it for the whole department.")) return;
258
+ try {
259
+ await api("/api/ui/default-agents?name=" + encodeURIComponent(name), { method: "DELETE" });
260
+ toast("Deleted " + name, "ok");
261
+ loadDefaults();
262
+ } catch (e) { toast("Delete failed: " + (e.message || ""), "err"); }
263
+ });
197
264
  } catch (e) { toast("Could not load default agents", "err"); }
198
265
  }
199
266
 
@@ -664,28 +731,22 @@
664
731
  : `<option value="">(no agents yet)</option>`;
665
732
  onAgentSelect();
666
733
  }
734
+ // Custom agents: full control for everyone (view / Edit-content / enable /
735
+ // disable / delete) — RBAC applies only to department agents.
667
736
  $("local-agents").innerHTML = rows.length ? rows.map(a => {
668
- const prompt = a.prompt || {};
669
- const summary = (prompt.system_prompt || "").trim().slice(0, 200);
670
- const nvars = (prompt.variables || []).length;
671
- const nskills = (a.skills || []).length;
672
- return `<div class="agent-card">
673
- <div class="agent-head">
674
- <span class="agent-name">${esc(a.name)}</span>
675
- <span class="pill ${a.status === "disabled" ? "off" : "on"}">${esc(a.status || "active")}</span>
676
- </div>
677
- <div class="agent-meta">${nvars} variable(s) · ${nskills} skill(s)</div>
678
- <div class="agent-desc">${esc(summary)}</div>
679
- <div class="agent-actions">
680
- <a class="btn primary" href="/agents.html?agent=${encodeURIComponent(a.name)}">Run</a>
681
- <button class="btn" data-enable="${esc(a.name)}">Enable</button>
682
- <button class="btn" data-disable="${esc(a.name)}">Disable</button>
683
- <button class="btn danger" data-del="${esc(a.name)}">Delete</button>
684
- </div>
685
- </div>`;
737
+ const enc = encodeURIComponent(a.name);
738
+ const actions = [
739
+ `<a class="btn primary" href="/agents.html?agent=${enc}">Run</a>`,
740
+ `<button class="btn" data-edit="${esc(a.name)}">Edit</button>`,
741
+ `<button class="btn" data-enable="${esc(a.name)}">Enable</button>`,
742
+ `<button class="btn" data-disable="${esc(a.name)}">Disable</button>`,
743
+ `<button class="btn danger" data-del="${esc(a.name)}">Delete</button>`,
744
+ ].join("");
745
+ return agentCardHTML(a, actions);
686
746
  }).join("")
687
747
  : "<div class='empty'>No custom agents assigned to you yet. Agents are created in the web app.</div>";
688
748
 
749
+ wireContentToggles($("local-agents"));
689
750
  $("local-agents").querySelectorAll("[data-enable]").forEach(b => b.onclick = () => setStatus(b.dataset.enable, "active"));
690
751
  $("local-agents").querySelectorAll("[data-disable]").forEach(b => b.onclick = () => setStatus(b.dataset.disable, "disabled"));
691
752
  $("local-agents").querySelectorAll("[data-del]").forEach(b => b.onclick = async () => {
@@ -233,8 +233,23 @@ tr:last-child td { border-bottom: none; }
233
233
  }
234
234
  .agent-card .agent-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
235
235
  .agent-name { font-weight: 700; font-size: 14px; }
236
+ /* Clickable agent name — reveals the full prompt below. Looks like a link, keeps
237
+ the bold agent-name weight; no default button chrome. */
238
+ .agent-name.linklike {
239
+ background: none; border: 0; padding: 0; margin: 0; cursor: pointer;
240
+ color: var(--text); text-align: left; font: inherit; font-weight: 700;
241
+ }
242
+ .agent-name.linklike:hover { color: var(--accent, #4c8bf5); text-decoration: underline; }
236
243
  .agent-meta { font-size: 12px; color: var(--muted); }
237
244
  .agent-desc { font-size: 13px; color: var(--text); opacity: .85; min-height: 18px; }
245
+ .agent-content {
246
+ border-top: 1px dashed var(--border); margin-top: 4px; padding-top: 8px;
247
+ }
248
+ .agent-content pre {
249
+ margin: 0; max-height: 340px; overflow: auto; white-space: pre-wrap;
250
+ word-break: break-word; font-size: 12px; line-height: 1.45;
251
+ color: var(--text); opacity: .92;
252
+ }
238
253
  .agent-actions { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
239
254
 
240
255
  .pill { font-size: 11px; padding: 2px 8px; border-radius: 999px; font-weight: 600; white-space: nowrap; }
@@ -0,0 +1,165 @@
1
+ """Lock-in tests for the agent-prompt request models and their Swagger shape.
2
+
3
+ These models (``sdk/ui/models.py``) intentionally MIRROR the AI backend's
4
+ ``routes/agent_prompt.py`` request schemas field-for-field, so one JSON body
5
+ works against both ``/api/v1/agent-prompt/*`` (backend) and
6
+ ``/api/ui/agent-prompt/*`` (this SDK). DBAI-101 hardens that contract: ``verify``
7
+ enforces a stripped ``len >= 10`` rule, ``generate``/``regenerate`` reject empty
8
+ required fields (all as HTTP 422), and neither the body nor any ``X-SDK-URL``
9
+ header may steer the SDK target.
10
+
11
+ This module pins that behaviour so it cannot silently drift out of alignment
12
+ with the backend. The validation tests need only ``pydantic`` (a core dep); the
13
+ OpenAPI/Swagger test needs the ``[ui]`` extra (``fastapi``) and is skipped when
14
+ it is absent.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import pytest
19
+ from pydantic import ValidationError
20
+
21
+ from sdk.ui.models import (
22
+ AgentPromptGenerateRequest,
23
+ PromptRegenerateRequest,
24
+ PromptVerifyRequest,
25
+ )
26
+
27
+ # The exact messages the backend's VerifyPrompt/Generate/Regenerate validators
28
+ # must also surface — a caller can rely on one message across both repos.
29
+ _VERIFY_MSG = "Write a system prompt first (a bit longer)."
30
+ _GENERATE_MSG = "agent_name, agent_type, and job are required."
31
+ _REGENERATE_MSG = "system_prompt is required."
32
+
33
+
34
+ def _first_error(exc: ValidationError) -> tuple[tuple, str]:
35
+ err = exc.errors()[0]
36
+ return tuple(err["loc"]), err["msg"]
37
+
38
+
39
+ # ── verify: stripped, non-empty, len >= 10 ───────────────────────────────────
40
+
41
+ def test_verify_rejects_empty():
42
+ with pytest.raises(ValidationError) as ei:
43
+ PromptVerifyRequest(system_prompt="")
44
+ loc, msg = _first_error(ei.value)
45
+ assert loc == ("system_prompt",)
46
+ assert _VERIFY_MSG in msg
47
+
48
+
49
+ def test_verify_rejects_short_after_strip():
50
+ # 9 visible chars once stripped → below the len >= 10 floor.
51
+ with pytest.raises(ValidationError) as ei:
52
+ PromptVerifyRequest(system_prompt=" shortpro ")
53
+ loc, msg = _first_error(ei.value)
54
+ assert loc == ("system_prompt",)
55
+ assert _VERIFY_MSG in msg
56
+
57
+
58
+ def test_verify_accepts_valid_and_strips():
59
+ body = PromptVerifyRequest(system_prompt=" You are a helpful agent. ")
60
+ assert body.system_prompt == "You are a helpful agent."
61
+ assert body.engine == "claude_code" # shared default with the backend
62
+
63
+
64
+ def test_verify_engine_is_passthrough():
65
+ # ``engine`` is part of the shared contract; the field is accepted verbatim.
66
+ assert PromptVerifyRequest(system_prompt="x" * 10, engine="server").engine == "server"
67
+
68
+
69
+ # ── generate: agent_name / agent_type / job all required ──────────────────────
70
+
71
+ @pytest.mark.parametrize("field", ["agent_name", "agent_type", "job"])
72
+ def test_generate_rejects_empty_required_field(field):
73
+ kwargs = {"agent_name": "Notes", "agent_type": "docs", "job": "write notes"}
74
+ kwargs[field] = " " # whitespace-only → empty after strip
75
+ with pytest.raises(ValidationError) as ei:
76
+ AgentPromptGenerateRequest(**kwargs)
77
+ loc, msg = _first_error(ei.value)
78
+ assert loc == (field,)
79
+ assert _GENERATE_MSG in msg
80
+
81
+
82
+ def test_generate_accepts_valid():
83
+ body = AgentPromptGenerateRequest(
84
+ agent_name=" Release Notes ", agent_type=" documentation ", job=" summarise diffs "
85
+ )
86
+ assert body.agent_name == "Release Notes"
87
+ assert body.agent_type == "documentation"
88
+ assert body.job == "summarise diffs"
89
+ assert body.context == ""
90
+ assert body.engine == "claude_code"
91
+
92
+
93
+ # ── regenerate: system_prompt required, context fields optional ───────────────
94
+
95
+ def test_regenerate_rejects_empty_system_prompt():
96
+ with pytest.raises(ValidationError) as ei:
97
+ PromptRegenerateRequest(system_prompt=" ")
98
+ loc, msg = _first_error(ei.value)
99
+ assert loc == ("system_prompt",)
100
+ assert _REGENERATE_MSG in msg
101
+
102
+
103
+ def test_regenerate_accepts_valid_with_optional_defaults():
104
+ body = PromptRegenerateRequest(system_prompt="Existing prompt to improve")
105
+ assert body.system_prompt == "Existing prompt to improve"
106
+ assert body.feedback == ""
107
+ assert body.agent_name == body.agent_type == body.job == ""
108
+ assert body.engine == "claude_code"
109
+
110
+
111
+ # ── field-for-field alignment guard (mirror, don't diverge) ───────────────────
112
+
113
+ def test_shared_engine_default_is_claude_code():
114
+ # All three shared models default engine to claude_code — the SDK always runs
115
+ # locally, so it accepts the field for parity and never 400s on other values.
116
+ assert PromptVerifyRequest.model_fields["engine"].default == "claude_code"
117
+ assert AgentPromptGenerateRequest.model_fields["engine"].default == "claude_code"
118
+ assert PromptRegenerateRequest.model_fields["engine"].default == "claude_code"
119
+
120
+
121
+ def test_model_fields_match_backend_contract():
122
+ # If the backend adds/removes a field, this catches the drift so the two
123
+ # repos are updated together (DBAI-101 full-stack coordination rule).
124
+ assert set(PromptVerifyRequest.model_fields) == {"system_prompt", "engine"}
125
+ assert set(AgentPromptGenerateRequest.model_fields) == {
126
+ "agent_name", "agent_type", "job", "context", "engine",
127
+ }
128
+ assert set(PromptRegenerateRequest.model_fields) == {
129
+ "system_prompt", "feedback", "agent_name", "agent_type", "job", "engine",
130
+ }
131
+
132
+
133
+ # ── Swagger / OpenAPI: no X-SDK-URL, final body shape (needs the [ui] extra) ──
134
+
135
+ def test_openapi_agent_prompt_shape_has_no_sdk_url_header():
136
+ pytest.importorskip("fastapi")
137
+ import json
138
+
139
+ from sdk.ui.server import create_app
140
+
141
+ spec = create_app().openapi()
142
+
143
+ # G-1/G-4: the target is never caller-steerable — no header anywhere.
144
+ blob = json.dumps(spec)
145
+ assert "X-SDK-URL" not in blob
146
+ assert "x_sdk_url" not in blob
147
+
148
+ paths = {
149
+ "/api/ui/agent-prompt/generate",
150
+ "/api/ui/agent-prompt/regenerate",
151
+ "/api/ui/agent-prompt/verify",
152
+ }
153
+ assert paths <= set(spec["paths"])
154
+
155
+ # None of the three routes expose a header parameter.
156
+ for p in paths:
157
+ params = spec["paths"][p]["post"].get("parameters", [])
158
+ assert [pr for pr in params if pr.get("in") == "header"] == []
159
+
160
+ # verify body is PromptVerifyRequest → {system_prompt, engine}.
161
+ verify = spec["paths"]["/api/ui/agent-prompt/verify"]["post"]
162
+ ref = verify["requestBody"]["content"]["application/json"]["schema"]["$ref"]
163
+ model = ref.rsplit("/", 1)[-1]
164
+ props = spec["components"]["schemas"][model]["properties"]
165
+ assert set(props) == {"system_prompt", "engine"}