devops-bot-sdk 1.4.150__tar.gz → 1.4.153__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 (96) hide show
  1. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/devops_bot_sdk.egg-info/SOURCES.txt +1 -0
  4. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/inventory.py +1 -0
  6. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/client.py +57 -0
  7. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/config.py +14 -2
  8. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/api.py +114 -69
  9. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/updater.py +3 -3
  10. devops_bot_sdk-1.4.153/tests/test_agent_prompt_async.py +74 -0
  11. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/README.md +0 -0
  12. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  13. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  14. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/devops_bot_sdk.egg-info/requires.txt +0 -0
  15. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  16. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/pyproject.toml +0 -0
  17. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agentd.py +0 -0
  18. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/__init__.py +0 -0
  19. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/builder.py +0 -0
  20. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/chat.py +0 -0
  21. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/connectors.py +0 -0
  22. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/engines/__init__.py +0 -0
  23. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/engines/claude_code.py +0 -0
  24. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/engines/http.py +0 -0
  25. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/engines/mcp.py +0 -0
  26. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/engines/n8n.py +0 -0
  27. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/engines/shell.py +0 -0
  28. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/integrations.py +0 -0
  29. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/library/__init__.py +0 -0
  30. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/manifest.py +0 -0
  31. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/prompt_studio.py +0 -0
  32. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/prompt_template.py +0 -0
  33. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/prompt_vars.py +0 -0
  34. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/run_log.py +0 -0
  35. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/secrets.py +0 -0
  36. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/usage.py +0 -0
  37. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/agents/varstore.py +0 -0
  38. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/cli.py +0 -0
  39. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/collectors/__init__.py +0 -0
  40. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/collectors/files.py +0 -0
  41. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/collectors/process.py +0 -0
  42. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/collectors/screenshot.py +0 -0
  43. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/crucial.py +0 -0
  44. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/design_verify/__init__.py +0 -0
  45. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/design_verify/assets.py +0 -0
  46. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/design_verify/bootstrap.py +0 -0
  47. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/design_verify/browser.py +0 -0
  48. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/design_verify/compare.py +0 -0
  49. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/design_verify/loop.py +0 -0
  50. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/exceptions.py +0 -0
  51. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/git_ops.py +0 -0
  52. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/graphify.py +0 -0
  53. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/hooks/__init__.py +0 -0
  54. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/hooks/crucial_guard.py +0 -0
  55. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ipc/__init__.py +0 -0
  56. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ipc/electron_bridge.py +0 -0
  57. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ipc/handlers.py +0 -0
  58. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/local_exec.py +0 -0
  59. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/models/__init__.py +0 -0
  60. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/models/envelope.py +0 -0
  61. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/models/requests.py +0 -0
  62. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/models/responses.py +0 -0
  63. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/models/snapshots.py +0 -0
  64. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/platform_compat.py +0 -0
  65. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/py.typed +0 -0
  66. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/run_auto.py +0 -0
  67. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/sse.py +0 -0
  68. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/test.py +0 -0
  69. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/test_pipeline.py +0 -0
  70. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/__init__.py +0 -0
  71. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/dashboard.py +0 -0
  72. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/health.py +0 -0
  73. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/ip_allowlist.py +0 -0
  74. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/jobs.py +0 -0
  75. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/models.py +0 -0
  76. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/runner.py +0 -0
  77. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/security.py +0 -0
  78. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/server.py +0 -0
  79. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/service.py +0 -0
  80. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/setup_manager.py +0 -0
  81. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/static/agents.html +0 -0
  82. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/static/app.js +0 -0
  83. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/static/custom-agents.html +0 -0
  84. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/static/default-agents.html +0 -0
  85. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/static/index.html +0 -0
  86. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/static/runs.html +0 -0
  87. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/static/setup.html +0 -0
  88. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/static/styles.css +0 -0
  89. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/sdk/ui/static/tickets.html +0 -0
  90. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/setup.cfg +0 -0
  91. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/tests/test_agent_chat_features.py +0 -0
  92. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/tests/test_agent_prompt_models.py +0 -0
  93. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/tests/test_agents_connectors_builder.py +0 -0
  94. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/tests/test_git_action.py +0 -0
  95. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/tests/test_health.py +0 -0
  96. {devops_bot_sdk-1.4.150 → devops_bot_sdk-1.4.153}/tests/test_updater_extras.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.150
3
+ Version: 1.4.153
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.150
3
+ Version: 1.4.153
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
@@ -86,6 +86,7 @@ sdk/ui/static/setup.html
86
86
  sdk/ui/static/styles.css
87
87
  sdk/ui/static/tickets.html
88
88
  tests/test_agent_chat_features.py
89
+ tests/test_agent_prompt_async.py
89
90
  tests/test_agent_prompt_models.py
90
91
  tests/test_agents_connectors_builder.py
91
92
  tests/test_git_action.py
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.150
3
+ Version: 1.4.153
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.150" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
33
+ __version__ = "1.4.153" # 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
@@ -26,6 +26,7 @@ def _map_backend_agent(row: dict, source: str) -> dict:
26
26
  sys_prompt = prompt.get("system_prompt", "") if isinstance(prompt, dict) else ""
27
27
  pvars = prompt.get("variables", []) if isinstance(prompt, dict) else []
28
28
  return {
29
+ "id": row.get("id"), # backend agentId — used for activity webhook
29
30
  "name": row.get("name"),
30
31
  "description": sys_prompt[:200],
31
32
  "variables": [v.get("name") for v in pvars if isinstance(v, dict) and v.get("name")],
@@ -37,6 +37,11 @@ from sdk.sse import _check_status, stream_with_reconnect
37
37
 
38
38
  logger = logging.getLogger(__name__)
39
39
 
40
+ # Process-lifetime cache of agent NAME → backend agentId (""=known non-backend).
41
+ # Used by resolve_agent_id so the agent-activity webhook fires only for backend
42
+ # agents without a lookup on every run.
43
+ _AGENT_ID_CACHE: dict[str, str] = {}
44
+
40
45
  # ── Ticket status-change observers ────────────────────────────────────────
41
46
  # Best-effort, synchronous hooks fired when the client changes a ticket's backend
42
47
  # status (In Progress / Backlog / To Do). The CLI loop registers one to track
@@ -1036,6 +1041,58 @@ class BackendClient:
1036
1041
  logger.warning("client.log_activity error err=%s", exc)
1037
1042
  return False
1038
1043
 
1044
+ async def log_agent_activity(self, activity: str, agent_id: str) -> bool:
1045
+ """POST {ML_GATEWAY_URL}/ml-api/agent-activity — record an agent-activity
1046
+ event for a **backend** agent (body ``{activity, agentId}``). Best-effort;
1047
+ never raises. No-op when ``agent_id`` is empty (local-only agents are not
1048
+ tracked — the backend is the source of truth for agents).
1049
+ """
1050
+ from sdk.config import ML_GATEWAY_URL
1051
+
1052
+ aid = (agent_id or "").strip()
1053
+ if not aid:
1054
+ return False
1055
+ url = f"{ML_GATEWAY_URL.rstrip('/')}/ml-api/agent-activity"
1056
+ headers = {**self._headers, "accept": "*/*", "developer-name": "null", "product": ""}
1057
+ try:
1058
+ async with httpx.AsyncClient(timeout=10.0) as client:
1059
+ resp = await client.post(
1060
+ url, json={"activity": str(activity), "agentId": aid}, headers=headers,
1061
+ )
1062
+ if resp.status_code >= 400:
1063
+ logger.warning("client.log_agent_activity failed status=%s body=%s",
1064
+ resp.status_code, resp.text[:200])
1065
+ return False
1066
+ return True
1067
+ except Exception as exc: # noqa: BLE001
1068
+ logger.warning("client.log_agent_activity error err=%s", exc)
1069
+ return False
1070
+
1071
+ async def resolve_agent_id(self, name: str) -> str | None:
1072
+ """Best-effort backend ``agentId`` for an agent NAME (custom + department).
1073
+
1074
+ Returns None when the name isn't a backend agent (e.g. a local-only manifest)
1075
+ — the caller then skips activity emit, so only backend agents are tracked.
1076
+ Cached per process (the id↔name mapping is stable within a session).
1077
+ """
1078
+ nm = (name or "").strip()
1079
+ if not nm:
1080
+ return None
1081
+ if nm in _AGENT_ID_CACHE:
1082
+ return _AGENT_ID_CACHE[nm] or None
1083
+ rows: list[dict] = []
1084
+ for fetch in (self.list_custom_agents, self.list_department_agents):
1085
+ try:
1086
+ rows += await fetch()
1087
+ except Exception: # noqa: BLE001 — best-effort across both sources
1088
+ pass
1089
+ for r in rows:
1090
+ if r.get("name") == nm and r.get("id"):
1091
+ _AGENT_ID_CACHE[nm] = str(r["id"])
1092
+ return _AGENT_ID_CACHE[nm]
1093
+ _AGENT_ID_CACHE[nm] = "" # negative-cache: not a backend agent
1094
+ return None
1095
+
1039
1096
  async def notify_whatsapp(
1040
1097
  self,
1041
1098
  task_id: str,
@@ -25,7 +25,10 @@ _CONFIG_FILE = _CONFIG_DIR / "config.toml"
25
25
  _SENTINEL = "co_"
26
26
 
27
27
  AI_DEFAULT_URL = "https://devopsbot-ai.apiswagger.co.uk"
28
- BE_DEFAULT_URL = "https://devopsbot-be.apiswagger.co.uk"
28
+ # ML backend / gateway — all /ml-api, /tasks-ml, /approvals-ml, agent-activity, etc.
29
+ # now go through the ML gateway (was devopsbot-be.apiswagger.co.uk).
30
+ BE_DEFAULT_URL = "https://gateway-ml.apiswagger.co.uk"
31
+ ML_GATEWAY_URL = "https://gateway-ml.apiswagger.co.uk"
29
32
 
30
33
 
31
34
  # ── Machine-derived Fernet key ────────────────────────────────────────
@@ -146,6 +149,13 @@ def _dotenv_value(key: str) -> str:
146
149
  return ""
147
150
 
148
151
 
152
+ # Built-in permanent allowlist — the central AI backend's IP(s). Baked in so the
153
+ # backend can reach EVERY VPS's /api/ui/* out of the box (no per-VPS .env needed);
154
+ # always allowed, treated as permanent (not removable via the API). Extend/override
155
+ # with AGENTOS_UI_ALLOWED_IPS in .env or the process env.
156
+ _DEFAULT_ALLOWED_IPS: list[str] = ["20.12.201.148"]
157
+
158
+
149
159
  def _parse_ip_list(raw: str) -> list[str]:
150
160
  """Split an IP allowlist string, tolerant of JSON-ish list syntax.
151
161
 
@@ -178,6 +188,7 @@ def get_allowed_ips() -> list[str]:
178
188
  change. CIDR/prefix entries are supported by the middleware.
179
189
  """
180
190
  sources = [
191
+ ",".join(_DEFAULT_ALLOWED_IPS), # built-in backend IP(s) — always allowed
181
192
  _dotenv_value("AGENTOS_UI_ALLOWED_IPS"),
182
193
  os.environ.get("AGENTOS_UI_ALLOWED_IPS", ""),
183
194
  _load_raw().get("allowed_ips", ""),
@@ -202,7 +213,8 @@ def permanent_allowed_ips() -> list[str]:
202
213
  through the API — change them by editing ``.env`` and restarting.
203
214
  """
204
215
  out: list[str] = []
205
- for src in (_dotenv_value("AGENTOS_UI_ALLOWED_IPS"),
216
+ for src in (",".join(_DEFAULT_ALLOWED_IPS), # built-in backend IP(s) — always permanent
217
+ _dotenv_value("AGENTOS_UI_ALLOWED_IPS"),
206
218
  os.environ.get("AGENTOS_UI_ALLOWED_IPS", "")):
207
219
  for ip in _parse_ip_list(src):
208
220
  if ip not in out:
@@ -304,6 +304,28 @@ async def logs_stream() -> StreamingResponse:
304
304
  )
305
305
 
306
306
 
307
+ # ── Agent-activity webhook ──────────────────────────────────────────────────
308
+ # Every custom/default agent run/chat records to the LOCAL usage ledger AND emits
309
+ # a `{activity, agentId}` event to the ML gateway (POST /ml-api/agent-activity),
310
+ # which stores it for RBAC-scoped reporting via the chat endpoint. Only agents that
311
+ # exist in the backend (have an agentId) are emitted — local-only agents are skipped.
312
+ # Best-effort: never raises into the run.
313
+
314
+ async def _emit_agent_activity(name: str, kind: str, result: dict, duration_s: float = 0.0) -> None:
315
+ try:
316
+ from sdk.client import BackendClient
317
+ client = BackendClient.from_config()
318
+ agent_id = await client.resolve_agent_id(name)
319
+ if not agent_id: # local-only agent → not tracked
320
+ return
321
+ verb = "Chatted with" if kind == "chat" else "Ran"
322
+ status = "ok" if result.get("ok") else "error"
323
+ detail = f" ({round(duration_s, 1)}s)" if duration_s else ""
324
+ await client.log_agent_activity(f"{verb} agent '{name}' — {status}{detail}", agent_id)
325
+ except Exception: # noqa: BLE001 — activity emit must never break a run
326
+ pass
327
+
328
+
307
329
  # ── Agents: catalog, creation, chat, default agents ────────────────────────
308
330
 
309
331
  @router.get("/connectors", dependencies=[_auth])
@@ -689,12 +711,14 @@ async def agent_chat(body: AgentChatRequest) -> dict:
689
711
  started = _time.monotonic()
690
712
  result = await _run()
691
713
 
714
+ elapsed = _time.monotonic() - started
692
715
  usage_mod.record(
693
- name, kind="chat", duration_s=_time.monotonic() - started,
716
+ name, kind="chat", duration_s=elapsed,
694
717
  input_tokens=result.get("input_tokens", 0),
695
718
  output_tokens=result.get("output_tokens", 0),
696
719
  cost_usd=result.get("cost_usd", 0.0), ok=bool(result.get("ok")),
697
720
  )
721
+ await _emit_agent_activity(name, "chat", result, elapsed)
698
722
  return result
699
723
 
700
724
  # Run detached from this request: navigating away can't kill it. Poll
@@ -732,12 +756,14 @@ async def agent_run(body: AgentRunRequest) -> dict:
732
756
  started = _time.monotonic()
733
757
  result = await _run()
734
758
 
759
+ elapsed = _time.monotonic() - started
735
760
  usage_mod.record(
736
- name, kind="run", duration_s=_time.monotonic() - started,
761
+ name, kind="run", duration_s=elapsed,
737
762
  input_tokens=result.get("input_tokens", 0),
738
763
  output_tokens=result.get("output_tokens", 0),
739
764
  cost_usd=result.get("cost_usd", 0.0), ok=bool(result.get("ok")),
740
765
  )
766
+ await _emit_agent_activity(name, "run", result, elapsed)
741
767
  return result
742
768
 
743
769
  return {"job_id": jobs.start(work())}
@@ -968,55 +994,70 @@ async def _screen_agent_inputs(agent_name: str, agent_type: str, job: str) -> li
968
994
  async def agent_prompt_generate(body: AgentPromptGenerateRequest) -> dict:
969
995
  """name + type + job → a structured system prompt + {{variables}} (claude_code).
970
996
 
971
- Body: ``agent_name``, ``agent_type``, ``job``, optional ``context``.
972
- Returns ``{agent_name, agent_type, system_prompt, variables[], engine}``.
997
+ ASYNC: local Claude Code generation can take a while, so this starts a detached
998
+ job and returns ``{job_id}`` immediately (navigation-safe, and no long request
999
+ held across a proxy/gateway — avoids 504s). Poll ``GET /api/ui/jobs/{job_id}``
1000
+ for the result: ``{status, engine, ai_comments, agent_name, agent_type,
1001
+ system_prompt, variables[]}`` (or ``status="needs_revision"`` / ``"error"``).
973
1002
  """
974
1003
  from sdk.agents.prompt_studio import build_generate_prompt, parse_generation
1004
+ from sdk.ui import jobs
975
1005
 
976
1006
  # Empty agent_name/agent_type/job already 422'd by the request model.
977
1007
  agent_name = body.agent_name
978
1008
  agent_type = body.agent_type
979
1009
  job = body.job
1010
+ context = body.context
1011
+
1012
+ async def work():
1013
+ # Semantic screen (offensive / meaningless) → unified needs_revision. Fail-open.
1014
+ comments = await _screen_agent_inputs(agent_name, agent_type, job)
1015
+ if comments:
1016
+ return _needs_revision(comments)
1017
+ text = await _run_prompt_engine(build_generate_prompt(agent_name, agent_type, job, context))
1018
+ parsed = parse_generation(text)
1019
+ if not parsed["system_prompt"]:
1020
+ return {"status": "error", "engine": "claude_code", "ai_comments": [],
1021
+ "error": "generation returned no system_prompt"}
1022
+ return {"status": "ok", "engine": "claude_code", "ai_comments": [],
1023
+ "agent_name": agent_name, "agent_type": agent_type, **parsed}
980
1024
 
981
- # Semantic screen (offensive / meaningless) → unified needs_revision. Fail-open.
982
- comments = await _screen_agent_inputs(agent_name, agent_type, job)
983
- if comments:
984
- return _needs_revision(comments)
985
-
986
- text = await _run_prompt_engine(
987
- build_generate_prompt(agent_name, agent_type, job, body.context)
988
- )
989
- parsed = parse_generation(text)
990
- if not parsed["system_prompt"]:
991
- raise HTTPException(status_code=502, detail="generation returned no system_prompt")
992
- return {"status": "ok", "engine": "claude_code", "ai_comments": [],
993
- "agent_name": agent_name, "agent_type": agent_type, **parsed}
1025
+ return {"job_id": jobs.start(work())}
994
1026
 
995
1027
 
996
1028
  @router.post("/agent-prompt/regenerate", dependencies=[_auth])
997
1029
  async def agent_prompt_regenerate(body: PromptRegenerateRequest) -> dict:
998
1030
  """Improve an existing prompt (optional feedback) → prompt + {{variables}}.
999
1031
 
1000
- Body: ``system_prompt``, optional ``feedback``, ``agent_name``, ``agent_type``,
1001
- ``job``. Same response shape as /agent-prompt/generate.
1032
+ ASYNC (same as generate): returns ``{job_id}``; poll ``GET /api/ui/jobs/{job_id}``.
1033
+ Body: ``system_prompt``, optional ``feedback``, ``agent_name``, ``agent_type``, ``job``.
1002
1034
  """
1003
1035
  from sdk.agents.prompt_studio import build_regenerate_prompt, parse_generation
1036
+ from sdk.ui import jobs
1004
1037
 
1005
- # Empty system_prompt already 422'd by the request model. Screen the agent
1006
- # metadata (blank fields ignored by the screener; fail-open).
1007
- comments = await _screen_agent_inputs(body.agent_name, body.agent_type, body.job)
1008
- if comments:
1009
- return _needs_revision(comments)
1038
+ # Empty system_prompt already 422'd by the request model.
1039
+ agent_name = body.agent_name
1040
+ agent_type = body.agent_type
1041
+ job = body.job
1042
+ system_prompt = body.system_prompt
1043
+ feedback = body.feedback
1044
+
1045
+ async def work():
1046
+ # Screen the agent metadata (blank fields ignored by the screener; fail-open).
1047
+ comments = await _screen_agent_inputs(agent_name, agent_type, job)
1048
+ if comments:
1049
+ return _needs_revision(comments)
1050
+ text = await _run_prompt_engine(build_regenerate_prompt(
1051
+ system_prompt, feedback, agent_name, agent_type, job,
1052
+ ))
1053
+ parsed = parse_generation(text)
1054
+ if not parsed["system_prompt"]:
1055
+ return {"status": "error", "engine": "claude_code", "ai_comments": [],
1056
+ "error": "regeneration returned no system_prompt"}
1057
+ return {"status": "ok", "engine": "claude_code", "ai_comments": [],
1058
+ "agent_name": agent_name, "agent_type": agent_type, **parsed}
1010
1059
 
1011
- text = await _run_prompt_engine(build_regenerate_prompt(
1012
- body.system_prompt, body.feedback,
1013
- body.agent_name, body.agent_type, body.job,
1014
- ))
1015
- parsed = parse_generation(text)
1016
- if not parsed["system_prompt"]:
1017
- raise HTTPException(status_code=502, detail="regeneration returned no system_prompt")
1018
- return {"status": "ok", "engine": "claude_code", "ai_comments": [],
1019
- "agent_name": body.agent_name, "agent_type": body.agent_type, **parsed}
1060
+ return {"job_id": jobs.start(work())}
1020
1061
 
1021
1062
 
1022
1063
  @router.post("/agent-prompt/verify", dependencies=[_auth])
@@ -1025,53 +1066,57 @@ async def agent_prompt_verify(body: PromptVerifyRequest) -> dict:
1025
1066
 
1026
1067
  Standards check is deterministic (our Role/Objective/Rules/Workflow/Output
1027
1068
  template); secrets + variable metadata are inferred by Claude Code locally.
1028
- Body: ``system_prompt`` → ``{valid, missing_required, missing_recommended,
1029
- variables[], secrets[]}``.
1069
+ ASYNC: returns ``{job_id}``; poll ``GET /api/ui/jobs/{job_id}`` → ``{valid,
1070
+ missing_required, missing_recommended, variables[], secrets[], ...}``.
1030
1071
  """
1031
1072
  from sdk.agents.prompt_studio import build_analyze_prompt, parse_analysis
1032
1073
  from sdk.agents.prompt_template import check_system_prompt
1033
1074
  from sdk.agents.prompt_vars import extract_variables
1075
+ from sdk.ui import jobs
1034
1076
 
1035
1077
  # Too-short system_prompt already 422'd by the request model.
1036
1078
  system_prompt = body.system_prompt
1037
1079
 
1038
- standards = check_system_prompt(system_prompt)
1080
+ async def work():
1081
+ standards = check_system_prompt(system_prompt)
1039
1082
 
1040
- # Enrichment (secrets + variable metadata) via local Claude Code — best-effort:
1041
- # a failure must not sink the deterministic verdict.
1042
- analysis: dict = {"inputs": [], "secrets": []}
1043
- try:
1044
- analysis = parse_analysis(await _run_prompt_engine(build_analyze_prompt(system_prompt)))
1045
- except HTTPException:
1046
- pass
1083
+ # Enrichment (secrets + variable metadata) via local Claude Code — best-effort:
1084
+ # a failure must not sink the deterministic verdict.
1085
+ analysis: dict = {"inputs": [], "secrets": []}
1086
+ try:
1087
+ analysis = parse_analysis(await _run_prompt_engine(build_analyze_prompt(system_prompt)))
1088
+ except HTTPException:
1089
+ pass
1090
+
1091
+ meta = {str(i.get("name", "")).strip(): i for i in analysis.get("inputs", []) if isinstance(i, dict)}
1092
+ variables = []
1093
+ for name in extract_variables(system_prompt): # {{placeholders}} are authoritative
1094
+ m = meta.get(name, {})
1095
+ variables.append({
1096
+ "name": name,
1097
+ "label": str(m.get("label") or name.replace("_", " ").title()).strip(),
1098
+ "description": str(m.get("description", "")).strip(),
1099
+ "type": str(m.get("type", "string")).strip() or "string",
1100
+ })
1101
+
1102
+ # Structural gaps → the SAME unified ai_comments list (all messages together).
1103
+ ai_comments = (
1104
+ [f"Missing required section: {s}." for s in standards["missing_required"]]
1105
+ + [f"Recommended section missing: {s}." for s in standards["missing_recommended"]]
1106
+ )
1047
1107
 
1048
- meta = {str(i.get("name", "")).strip(): i for i in analysis.get("inputs", []) if isinstance(i, dict)}
1049
- variables = []
1050
- for name in extract_variables(system_prompt): # {{placeholders}} are authoritative
1051
- m = meta.get(name, {})
1052
- variables.append({
1053
- "name": name,
1054
- "label": str(m.get("label") or name.replace("_", " ").title()).strip(),
1055
- "description": str(m.get("description", "")).strip(),
1056
- "type": str(m.get("type", "string")).strip() or "string",
1057
- })
1058
-
1059
- # Structural gaps → the SAME unified ai_comments list (all messages together).
1060
- ai_comments = (
1061
- [f"Missing required section: {s}." for s in standards["missing_required"]]
1062
- + [f"Recommended section missing: {s}." for s in standards["missing_recommended"]]
1063
- )
1108
+ return {
1109
+ "status": "needs_revision" if standards["missing_required"] else "ok",
1110
+ "engine": "claude_code",
1111
+ "ai_comments": ai_comments,
1112
+ "valid": not standards["missing_required"],
1113
+ "missing_required": standards["missing_required"],
1114
+ "missing_recommended": standards["missing_recommended"],
1115
+ "variables": variables,
1116
+ "secrets": analysis.get("secrets", []),
1117
+ }
1064
1118
 
1065
- return {
1066
- "status": "needs_revision" if standards["missing_required"] else "ok",
1067
- "engine": "claude_code",
1068
- "ai_comments": ai_comments,
1069
- "valid": not standards["missing_required"],
1070
- "missing_required": standards["missing_required"],
1071
- "missing_recommended": standards["missing_recommended"],
1072
- "variables": variables,
1073
- "secrets": analysis.get("secrets", []),
1074
- }
1119
+ return {"job_id": jobs.start(work())}
1075
1120
 
1076
1121
 
1077
1122
  @router.get("/agents/form", dependencies=[_auth])
@@ -132,10 +132,10 @@ def _ml_api_base() -> str:
132
132
  try:
133
133
  from sdk.config import get_base_url
134
134
  base = get_base_url()
135
- # devopsbot-ai.apiswagger.co.uk → devopsbot-be.apiswagger.co.uk
136
- return base.replace("devopsbot-ai", "devopsbot-be")
135
+ # devopsbot-ai.apiswagger.co.uk → gateway-ml.apiswagger.co.uk
136
+ return base.replace("devopsbot-ai.apiswagger.co.uk", "gateway-ml.apiswagger.co.uk")
137
137
  except Exception: # noqa: BLE001
138
- return "https://devopsbot-be.apiswagger.co.uk"
138
+ return "https://gateway-ml.apiswagger.co.uk"
139
139
 
140
140
 
141
141
  def _env_github_token() -> str | None:
@@ -0,0 +1,74 @@
1
+ """Agent-prompt endpoints are async job-based (avoids long requests / 504s).
2
+
3
+ POST /api/ui/agent-prompt/{generate,regenerate,verify} return `{job_id}`
4
+ immediately — the local Claude Code run happens in the background; the result is
5
+ fetched from GET /api/ui/jobs/{job_id}.
6
+
7
+ Note: we assert the async *contract* (an immediate `{job_id}`). Driving the
8
+ background job to completion isn't done via the sync TestClient (each request
9
+ runs its own event loop, so a detached task from the POST doesn't advance across
10
+ later GETs) — that's a harness limitation, not a code one. The job's *result*
11
+ logic is unchanged from the pre-async version (just wrapped in `work()`).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from unittest.mock import AsyncMock, patch
17
+
18
+ import pytest
19
+
20
+ fastapi = pytest.importorskip("fastapi")
21
+ from fastapi.testclient import TestClient # noqa: E402
22
+
23
+ from sdk.ui.api import require_token, router # noqa: E402
24
+
25
+ _JOB_RE = re.compile(r"^[0-9a-f]{32}$") # uuid4().hex
26
+
27
+
28
+ def _client():
29
+ app = fastapi.FastAPI()
30
+ app.include_router(router)
31
+ app.dependency_overrides[require_token] = lambda: None
32
+ return TestClient(app)
33
+
34
+
35
+ # Keep the local Claude Code call mocked so a background task (if it runs) never
36
+ # shells out to a real `claude`. Autouse across this module.
37
+ @pytest.fixture(autouse=True)
38
+ def _no_real_claude():
39
+ with patch("sdk.ui.api._run_prompt_engine", AsyncMock(return_value="<raw>")), \
40
+ patch("sdk.ui.api._screen_agent_inputs", AsyncMock(return_value=[])):
41
+ yield
42
+
43
+
44
+ class TestAsyncContract:
45
+ def test_generate_returns_job_id(self):
46
+ resp = _client().post("/api/ui/agent-prompt/generate", json={
47
+ "agent_name": "Repo Onboarder", "agent_type": "developer",
48
+ "job": "summarise a repo", "context": "", "engine": "claude_code"})
49
+ assert resp.status_code == 200
50
+ body = resp.json()
51
+ assert set(body) == {"job_id"} and _JOB_RE.match(body["job_id"])
52
+
53
+ def test_regenerate_returns_job_id(self):
54
+ resp = _client().post("/api/ui/agent-prompt/regenerate", json={
55
+ "system_prompt": "# Role\n## Objective\nDo X with {{repo_url}}.",
56
+ "feedback": "stricter", "engine": "claude_code"})
57
+ assert resp.status_code == 200
58
+ assert _JOB_RE.match(resp.json()["job_id"])
59
+
60
+ def test_verify_returns_job_id(self):
61
+ resp = _client().post("/api/ui/agent-prompt/verify", json={
62
+ "system_prompt": "# Role\n## Objective\n## Rules\n## Output\nUse {{repo_url}}."})
63
+ assert resp.status_code == 200
64
+ assert _JOB_RE.match(resp.json()["job_id"])
65
+
66
+ def test_verify_still_422_on_short_prompt(self):
67
+ # Validation stays synchronous (before the job) — no job id for bad input.
68
+ resp = _client().post("/api/ui/agent-prompt/verify", json={"system_prompt": "short"})
69
+ assert resp.status_code == 422
70
+
71
+ def test_generate_still_422_on_empty_required(self):
72
+ resp = _client().post("/api/ui/agent-prompt/generate",
73
+ json={"agent_name": "", "agent_type": "x", "job": "y"})
74
+ assert resp.status_code == 422