devops-bot-sdk 1.6.2__tar.gz → 1.6.4__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 (108) hide show
  1. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/devops_bot_sdk.egg-info/SOURCES.txt +1 -0
  4. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/inventory.py +6 -4
  6. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/cli.py +66 -29
  7. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/client.py +18 -5
  8. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/api.py +56 -38
  9. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/static/app.js +8 -1
  10. devops_bot_sdk-1.6.4/tests/test_agent_listing.py +170 -0
  11. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/README.md +0 -0
  12. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  13. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  14. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/devops_bot_sdk.egg-info/requires.txt +0 -0
  15. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  16. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/pyproject.toml +0 -0
  17. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agentd.py +0 -0
  18. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/__init__.py +0 -0
  19. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/activity.py +0 -0
  20. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/builder.py +0 -0
  21. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/chat.py +0 -0
  22. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/connectors.py +0 -0
  23. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/engines/__init__.py +0 -0
  24. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/engines/claude_code.py +0 -0
  25. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/engines/http.py +0 -0
  26. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/engines/mcp.py +0 -0
  27. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/engines/n8n.py +0 -0
  28. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/engines/shell.py +0 -0
  29. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/integrations.py +0 -0
  30. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/library/__init__.py +0 -0
  31. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/manifest.py +0 -0
  32. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/prompt_studio.py +0 -0
  33. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/prompt_template.py +0 -0
  34. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/prompt_vars.py +0 -0
  35. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/run_log.py +0 -0
  36. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/secrets.py +0 -0
  37. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/ticket_run.py +0 -0
  38. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/usage.py +0 -0
  39. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/agents/varstore.py +0 -0
  40. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/collectors/__init__.py +0 -0
  41. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/collectors/files.py +0 -0
  42. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/collectors/process.py +0 -0
  43. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/collectors/screenshot.py +0 -0
  44. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/config.py +0 -0
  45. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/crucial.py +0 -0
  46. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/design_verify/__init__.py +0 -0
  47. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/design_verify/assets.py +0 -0
  48. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/design_verify/bootstrap.py +0 -0
  49. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/design_verify/browser.py +0 -0
  50. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/design_verify/compare.py +0 -0
  51. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/design_verify/loop.py +0 -0
  52. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/exceptions.py +0 -0
  53. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/git_ops.py +0 -0
  54. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/github_ingest.py +0 -0
  55. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/graphify.py +0 -0
  56. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/hooks/__init__.py +0 -0
  57. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/hooks/crucial_guard.py +0 -0
  58. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ipc/__init__.py +0 -0
  59. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ipc/electron_bridge.py +0 -0
  60. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ipc/handlers.py +0 -0
  61. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/local_exec.py +0 -0
  62. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/models/__init__.py +0 -0
  63. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/models/envelope.py +0 -0
  64. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/models/requests.py +0 -0
  65. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/models/responses.py +0 -0
  66. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/models/snapshots.py +0 -0
  67. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/platform_compat.py +0 -0
  68. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/py.typed +0 -0
  69. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/run_auto.py +0 -0
  70. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/sse.py +0 -0
  71. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/test.py +0 -0
  72. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/test_pipeline.py +0 -0
  73. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/__init__.py +0 -0
  74. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/dashboard.py +0 -0
  75. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/health.py +0 -0
  76. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/ip_allowlist.py +0 -0
  77. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/jobs.py +0 -0
  78. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/models.py +0 -0
  79. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/runner.py +0 -0
  80. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/security.py +0 -0
  81. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/server.py +0 -0
  82. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/service.py +0 -0
  83. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/session.py +0 -0
  84. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/setup_manager.py +0 -0
  85. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/static/agents.html +0 -0
  86. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/static/custom-agents.html +0 -0
  87. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/static/default-agents.html +0 -0
  88. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/static/index.html +0 -0
  89. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/static/login.html +0 -0
  90. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/static/runs.html +0 -0
  91. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/static/setup.html +0 -0
  92. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/static/styles.css +0 -0
  93. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/ui/static/tickets.html +0 -0
  94. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/updater.py +0 -0
  95. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/sdk/vps_health.py +0 -0
  96. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/setup.cfg +0 -0
  97. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/tests/test_agent_activity.py +0 -0
  98. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/tests/test_agent_chat_features.py +0 -0
  99. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/tests/test_agent_prompt_async.py +0 -0
  100. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/tests/test_agent_prompt_models.py +0 -0
  101. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/tests/test_agent_run_ticket.py +0 -0
  102. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/tests/test_agents_connectors_builder.py +0 -0
  103. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/tests/test_git_action.py +0 -0
  104. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/tests/test_github_ingest.py +0 -0
  105. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/tests/test_health.py +0 -0
  106. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/tests/test_otp_login.py +0 -0
  107. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/tests/test_updater_extras.py +0 -0
  108. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.4}/tests/test_vps_health.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.6.2
3
+ Version: 1.6.4
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.6.2
3
+ Version: 1.6.4
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
@@ -93,6 +93,7 @@ sdk/ui/static/styles.css
93
93
  sdk/ui/static/tickets.html
94
94
  tests/test_agent_activity.py
95
95
  tests/test_agent_chat_features.py
96
+ tests/test_agent_listing.py
96
97
  tests/test_agent_prompt_async.py
97
98
  tests/test_agent_prompt_models.py
98
99
  tests/test_agent_run_ticket.py
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.6.2
3
+ Version: 1.6.4
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.6.2" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
33
+ __version__ = "1.6.4" # 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
@@ -37,17 +37,19 @@ def _map_backend_agent(row: dict, source: str) -> dict:
37
37
 
38
38
 
39
39
  async def _custom_agents() -> list[dict]:
40
- """The caller's custom agents (``relation_type=person``) from the backend.
40
+ """The caller's own custom agents (``relation_type='User'``) from the backend.
41
41
 
42
- Backend is the source of truth — agents are authored in the web app and read
43
- via GET /api/v1/agents. Best-effort: degrades to empty on any error.
42
+ Backend is the source of truth — agents are authored in the web app, owned by
43
+ the caller's ``users.id``, and read via GET /api/v1/agents. Best-effort:
44
+ degrades to empty on any error, but logs at WARNING because "this user owns no
45
+ agents" and "the fetch failed" look identical downstream once both are ``[]``.
44
46
  """
45
47
  try:
46
48
  from sdk.client import BackendClient
47
49
  rows = await BackendClient.from_config().list_custom_agents()
48
50
  return [_map_backend_agent(r, "custom") for r in rows]
49
51
  except Exception as exc: # noqa: BLE001
50
- logger.debug("custom-agent inventory failed: %s", exc)
52
+ logger.warning("custom-agent inventory failed (listing as empty): %s", exc)
51
53
  return []
52
54
 
53
55
 
@@ -201,43 +201,80 @@ def _agent_chat(name: str, message: str) -> None:
201
201
  sys.exit(1)
202
202
 
203
203
 
204
+ def _agent_row_summary(row: dict) -> tuple[str, str]:
205
+ """``(variables, skills)`` display columns for a backend prompt-based row.
206
+
207
+ The live ``agents`` table is prompt-based — ``prompt`` is
208
+ ``{system_prompt, variables:[{name,…}]}`` and skills arrive hydrated from the
209
+ ``_AgentSkills`` join. The old ``engine``/``trigger_type`` columns this listing
210
+ used to print no longer exist on the row, so they are gone from the table.
211
+ """
212
+ prompt = row.get("prompt") or {}
213
+ pvars = prompt.get("variables", []) if isinstance(prompt, dict) else []
214
+ names = [v.get("name") for v in pvars if isinstance(v, dict) and v.get("name")]
215
+ skills = [s.get("name") for s in (row.get("skills") or []) if isinstance(s, dict)]
216
+ return ",".join(n for n in names if n), ",".join(s for s in skills if s)
217
+
218
+
204
219
  def _agent_list() -> None:
220
+ """`agentos agent list` — every agent this user can reach.
221
+
222
+ The BACKEND is the source of truth for personal agents: they are authored in the
223
+ web app and stored as ``agents`` rows owned by the caller
224
+ (``relation_type='User'``, ``relation_id=<the caller's users.id>``), read via
225
+ GET /api/v1/agents. Local ``~/.agentos/agents/*.yaml`` manifests are still
226
+ listed — legacy/hand-written ones keep working — but they are no longer the
227
+ only source: since agent creation moved to the web app nothing writes that
228
+ directory, so listing it alone showed every user an empty table even when they
229
+ owned agents server-side.
230
+ """
205
231
  from sdk.agents.manifest import load_agents
206
232
  from sdk.client import BackendClient
207
233
 
208
234
  manifests = load_agents()
209
235
 
210
- statuses: dict[str, str] = {}
236
+ custom_rows: list[dict] = []
211
237
  dept_rows: list[dict] = []
212
- try:
213
- client = BackendClient.from_config()
214
- remote = asyncio.run(client.list_custom_agents())
215
- for item in remote or []:
216
- if isinstance(item, dict):
217
- nm, st = item.get("name"), item.get("status", "")
218
- else:
219
- nm, st = getattr(item, "name", None), getattr(item, "status", "")
220
- if nm:
221
- statuses[nm] = st or ""
222
- dept_rows = asyncio.run(client.list_department_agents())
223
- except Exception as exc: # noqa: BLE001 — backend status is best-effort for `list`
224
- print(f"warning: could not fetch backend status ({exc})", file=sys.stderr)
225
-
226
- if not manifests and not dept_rows:
227
- print("no local agents found in ~/.agentos/agents/, and no department "
228
- "default agents returned by the backend")
229
- return
230
-
231
- print(f"{'NAME':<24}{'ENGINE':<16}{'TRIGGER':<16}{'SOURCE':<12}{'STATUS'}")
232
- for m in manifests:
238
+ errors: list[str] = []
239
+ client = BackendClient.from_config()
240
+ for label, fetch in (("custom", client.list_custom_agents),
241
+ ("department", client.list_department_agents)):
242
+ try:
243
+ rows = asyncio.run(fetch()) or []
244
+ except Exception as exc: # noqa: BLE001 — one source failing must not hide the other
245
+ # str() is EMPTY on some httpx errors (ReadTimeout, ConnectTimeout), which
246
+ # would print a warning with no reason at all — name the class then.
247
+ errors.append(f"{label}: {str(exc) or type(exc).__name__}")
248
+ continue
249
+ if label == "custom":
250
+ custom_rows = [r for r in rows if isinstance(r, dict)]
251
+ else:
252
+ dept_rows = [r for r in rows if isinstance(r, dict)]
253
+
254
+ for err in errors:
255
+ print(f"warning: could not fetch {err}", file=sys.stderr)
256
+
257
+ # Local manifests that also exist server-side are the same agent — show once.
258
+ remote_names = {r.get("name") for r in custom_rows}
259
+ local_only = [m for m in manifests if m.name not in remote_names]
260
+
261
+ if not custom_rows and not dept_rows and not local_only:
262
+ print("no agents found: none owned by you on the backend, no department "
263
+ "defaults, and no local manifests in ~/.agentos/agents/")
264
+ if not errors:
265
+ print("(personal agents are created in the web app — they appear here "
266
+ "once saved)")
267
+ return
268
+
269
+ print(f"{'NAME':<32}{'SOURCE':<12}{'STATUS':<10}{'VARIABLES':<24}SKILLS")
270
+ for source, rows in (("personal", custom_rows), ("department", dept_rows)):
271
+ for row in rows:
272
+ variables, skills = _agent_row_summary(row)
273
+ print(f"{str(row.get('name', ''))[:31]:<32}{source:<12}"
274
+ f"{str(row.get('status', ''))[:9]:<10}{variables[:23]:<24}{skills}")
275
+ for m in local_only:
233
276
  trigger_type = m.trigger.type if m.trigger else ""
234
- status = statuses.get(m.name, "-")
235
- print(f"{m.name:<24}{m.engine:<16}{trigger_type:<16}{'personal':<12}{status}")
236
- for row in dept_rows:
237
- print(
238
- f"{row.get('name', ''):<24}{row.get('engine', ''):<16}"
239
- f"{row.get('trigger_type', ''):<16}{'department':<12}{row.get('status', '')}"
240
- )
277
+ print(f"{m.name[:31]:<32}{'local':<12}{'-':<10}{trigger_type[:23]:<24}")
241
278
 
242
279
 
243
280
  def _agent_run_ticket(name: str, flags: list[str]) -> None:
@@ -1372,12 +1372,25 @@ class BackendClient:
1372
1372
  # This client only lists/reads them and manages per-agent status/delete.
1373
1373
 
1374
1374
  async def list_custom_agents(self) -> list[dict]:
1375
- """GET /api/v1/agents — list registered custom agents."""
1375
+ """GET /api/v1/agents — the caller's OWN custom agents.
1376
+
1377
+ The backend scopes these to ``agents`` rows whose owner is this caller
1378
+ (``relation_type='User'``, ``relation_id`` = the ``users.id`` it resolves
1379
+ from ``GET /ml-api/users/profile`` for the presented token), so the list is
1380
+ per-user with nothing to pass in. Rows are prompt-based: ``prompt`` =
1381
+ ``{system_prompt, variables:[…]}``, plus a hydrated ``skills`` list.
1382
+
1383
+ An empty list means "this user owns no agents" and nothing else — the
1384
+ backend answers 401 when it cannot verify the identity behind the token and
1385
+ 404 when the endpoint is absent (``BackendFeatureMissing``), so a silent
1386
+ empty is never a masked failure. Agents are authored in the web app; the SDK
1387
+ is consume-only.
1388
+ """
1389
+ url = self._url("/api/v1/agents")
1376
1390
  async with httpx.AsyncClient(timeout=15.0) as client:
1377
- resp = await client.get(
1378
- self._url("/api/v1/agents"),
1379
- headers=self._headers,
1380
- )
1391
+ resp = await client.get(url, headers=self._headers)
1392
+ if resp.status_code == 404:
1393
+ raise BackendFeatureMissing("Custom agents", url)
1381
1394
  _check_status(resp.status_code, self._base_url)
1382
1395
  return resp.json().get("agents", [])
1383
1396
 
@@ -499,12 +499,14 @@ async def product_agents(product_id: str = Query(...)) -> dict:
499
499
 
500
500
  @router.get("/agents", dependencies=[_auth])
501
501
  async def agents() -> dict:
502
- """The caller's custom agents (``relation_type=person``) from the backend.
503
-
504
- Backend is the source of truth — agents are authored in the web app and read
505
- here via GET /api/v1/agents (prompt-based shape: prompt = {system_prompt,
506
- variables:[…]}; skills = [{name,…}]). Best-effort: on backend error the list
507
- is empty with an ``error`` string the UI can surface.
502
+ """The caller's own custom agents (``relation_type='User'``) from the backend.
503
+
504
+ Backend is the source of truth — agents are authored in the web app, owned by
505
+ the caller's ``users.id`` (which the backend resolves from the profile for the
506
+ presented token), and read here via GET /api/v1/agents (prompt-based shape:
507
+ prompt = {system_prompt, variables:[…]}; skills = [{name,…}]). Best-effort: on
508
+ backend error the list is empty with an ``error`` string the UI can surface, so
509
+ "you own none" stays distinguishable from "the call failed".
508
510
  """
509
511
  from sdk.client import BackendClient
510
512
  from sdk.exceptions import BackendFeatureMissing
@@ -544,11 +546,16 @@ async def agents_all(department_id: str = Query("")) -> dict:
544
546
  """Every agent a person can pick to chat: their custom agents + department
545
547
  defaults, both read from the backend (source of truth).
546
548
 
547
- Custom agents (``relation_type=person``) come from GET /api/v1/agents; defaults
548
- (``relation_type=department`` + org-wide common) from GET /api/v1/department-agents.
549
- Custom wins on a name collision. Each entry carries its detected variables so
550
- the UI can render input fields. Both are best-effort an unreachable backend
551
- yields an empty list plus an error string rather than a hard failure.
549
+ The caller's own custom agents (``relation_type='User'``, owned by their
550
+ ``users.id``) come from GET /api/v1/agents; defaults (``relation_type='Department'``
551
+ + org-wide common) from GET /api/v1/department-agents. Custom wins on a name
552
+ collision. Each entry carries its detected variables so the UI can render input
553
+ fields.
554
+
555
+ Both sources are independent and best-effort: one failing never empties the
556
+ other, and each reports its own reason (``custom_error`` / ``defaults_error``)
557
+ so the picker can say WHY it is empty instead of just "(no agents installed)".
558
+ An empty ``agents`` with no error means the user genuinely owns none yet.
552
559
  """
553
560
  from sdk.client import BackendClient
554
561
  from sdk.exceptions import BackendFeatureMissing
@@ -556,41 +563,52 @@ async def agents_all(department_id: str = Query("")) -> dict:
556
563
  out: list[dict] = []
557
564
  seen: set[str] = set()
558
565
  dept = department_id
566
+ custom_error = ""
559
567
  defaults_error = ""
560
568
 
561
569
  try:
562
570
  client = BackendClient.from_config()
563
- if not dept:
571
+ except Exception as exc: # noqa: BLE001 — not configured / no token
572
+ return {"agents": [], "department_id": dept,
573
+ "custom_error": str(exc), "defaults_error": str(exc)}
574
+
575
+ if not dept:
576
+ try:
564
577
  profile = await client.bootstrap()
565
578
  dept = getattr(profile, "department_id", None) or ""
579
+ except Exception: # noqa: BLE001 — the department label is cosmetic here
580
+ dept = ""
566
581
 
567
- # Custom agents first so they win any name collision with a default.
568
- try:
569
- for r in await client.list_custom_agents():
570
- nm = r.get("name")
571
- if not nm or nm in seen:
572
- continue
573
- seen.add(nm)
574
- out.append(_flat_agent(r, "custom", chattable=True))
575
- except BackendFeatureMissing:
576
- defaults_error = ("Custom agents aren't available on this backend — "
577
- "it may be running an older version.")
578
-
579
- # Department defaults (a default must be activated/cloned before it runs).
580
- try:
581
- for r in await client.list_department_agents():
582
- nm = r.get("name")
583
- if not nm or nm in seen:
584
- continue
585
- seen.add(nm)
586
- out.append(_flat_agent(r, "default", chattable=False, department_id=dept))
587
- except BackendFeatureMissing:
588
- defaults_error = ("Department default agents aren't available on this "
589
- "backend — it may be running an older version.")
590
- except Exception: # noqa: BLE001 best-effort; return whatever we gathered
591
- pass
582
+ # Custom agents first so they win any name collision with a default.
583
+ try:
584
+ for r in await client.list_custom_agents():
585
+ nm = r.get("name")
586
+ if not nm or nm in seen:
587
+ continue
588
+ seen.add(nm)
589
+ out.append(_flat_agent(r, "custom", chattable=True))
590
+ except BackendFeatureMissing:
591
+ custom_error = ("Custom agents aren't available on this backend — "
592
+ "it may be running an older version.")
593
+ except Exception as exc: # noqa: BLE001 — surface it; don't hide it as "none"
594
+ custom_error = f"Couldn't load your agents: {exc}"
595
+
596
+ # Department defaults (a default must be activated/cloned before it runs).
597
+ try:
598
+ for r in await client.list_department_agents():
599
+ nm = r.get("name")
600
+ if not nm or nm in seen:
601
+ continue
602
+ seen.add(nm)
603
+ out.append(_flat_agent(r, "default", chattable=False, department_id=dept))
604
+ except BackendFeatureMissing:
605
+ defaults_error = ("Department default agents aren't available on this "
606
+ "backend — it may be running an older version.")
607
+ except Exception as exc: # noqa: BLE001
608
+ defaults_error = f"Couldn't load department defaults: {exc}"
592
609
 
593
- return {"agents": out, "department_id": dept, "defaults_error": defaults_error}
610
+ return {"agents": out, "department_id": dept,
611
+ "custom_error": custom_error, "defaults_error": defaults_error}
594
612
 
595
613
 
596
614
  @router.get("/profile", dependencies=[_auth])
@@ -605,10 +605,17 @@
605
605
  const custom = [], deflt = [];
606
606
  (d.agents || []).forEach(a => { agents[a.name] = a; (a.source === "default" ? deflt : custom).push(a); });
607
607
  const opt = a => `<option value="${esc(a.name)}">${esc(a.name)}${a.variables && a.variables.length ? " · " + a.variables.length + " input(s)" : ""}${a.source === "default" ? " (department)" : ""}</option>`;
608
+ // An empty picker has two very different causes — say which. A load error
609
+ // must never read as "you have no agents"; agents live in the web app, so
610
+ // the genuinely-empty case points the user there.
611
+ const emptyLabel = d.custom_error
612
+ ? "(couldn't load your agents — see the message below)"
613
+ : "(no agents yet — create one in the web app)";
608
614
  $("agent-pick").innerHTML =
609
615
  (custom.length ? `<optgroup label="Your agents">${custom.map(opt).join("")}</optgroup>` : "") +
610
616
  (deflt.length ? `<optgroup label="Department defaults">${deflt.map(opt).join("")}</optgroup>` : "") ||
611
- `<option value="">(no agents installed)</option>`;
617
+ `<option value="">${esc(emptyLabel)}</option>`;
618
+ if (d.custom_error) toast(d.custom_error, "err");
612
619
  // Deep-link support: /agents.html?agent=NAME preselects that agent.
613
620
  const want = new URLSearchParams(location.search).get("agent");
614
621
  if (want && agents[want]) $("agent-pick").value = want;
@@ -0,0 +1,170 @@
1
+ """Listing the agents a person actually has — CLI and control panel.
2
+
3
+ The bug this covers: `agentos agent list` listed only local
4
+ ``~/.agentos/agents/*.yaml`` manifests as "personal", but nothing writes that
5
+ directory since agent creation moved to the web app — so every user saw an empty
6
+ table while owning agents server-side. The BACKEND is the source of truth
7
+ (``GET /api/v1/agents``, scoped to the caller's own ``users.id``), and a fetch
8
+ FAILURE must never be rendered as "you have no agents".
9
+
10
+ Covers:
11
+ * ``sdk.cli._agent_list`` — backend rows are the personal source; local-only
12
+ manifests still show; one source failing does not hide the other.
13
+ * ``sdk.client.BackendClient.list_custom_agents`` — 404 → BackendFeatureMissing.
14
+ * ``GET /api/ui/agents/all`` — per-source error strings (custom vs defaults).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from types import SimpleNamespace
19
+ from unittest.mock import AsyncMock, patch
20
+
21
+ import pytest
22
+
23
+ from sdk import cli
24
+
25
+
26
+ def _row(name, *, variables=(), skills=(), status="active"):
27
+ """A backend prompt-based ``agents`` row (docs/DATABASE_SCHEMA.md §4)."""
28
+ return {
29
+ "id": f"id-{name}", "name": name, "status": status,
30
+ "relation_type": "User", "relation_id": "0c2fa9fe-5707-420c-89c6-a36a11a4bcc3",
31
+ "prompt": {"system_prompt": "do things", "variables": [{"name": v} for v in variables]},
32
+ "skills": [{"name": s} for s in skills],
33
+ }
34
+
35
+
36
+ class TestAgentListCli:
37
+ def _run(self, capsys, *, custom, dept, manifests=()):
38
+ client = SimpleNamespace(
39
+ list_custom_agents=AsyncMock(**custom),
40
+ list_department_agents=AsyncMock(**dept),
41
+ )
42
+ with patch("sdk.agents.manifest.load_agents", return_value=list(manifests)), \
43
+ patch("sdk.client.BackendClient.from_config", return_value=client):
44
+ cli._agent_list()
45
+ return capsys.readouterr()
46
+
47
+ def test_backend_agents_are_listed_as_personal(self, capsys):
48
+ """The whole point: web-app-authored agents appear with no local files."""
49
+ out = self._run(
50
+ capsys,
51
+ custom={"return_value": [_row("My Research Agent", variables=["topic"],
52
+ skills=["web-search"])]},
53
+ dept={"return_value": [_row("AI Engineer")]},
54
+ )
55
+ assert "My Research Agent" in out.out
56
+ assert "personal" in out.out
57
+ assert "topic" in out.out and "web-search" in out.out
58
+ assert "AI Engineer" in out.out and "department" in out.out
59
+
60
+ def test_local_manifest_also_on_the_backend_is_listed_once(self, capsys):
61
+ manifest = SimpleNamespace(name="My Research Agent", engine="claude_code",
62
+ trigger=SimpleNamespace(type="schedule"))
63
+ out = self._run(capsys, custom={"return_value": [_row("My Research Agent")]},
64
+ dept={"return_value": []}, manifests=[manifest])
65
+ assert out.out.count("My Research Agent") == 1
66
+ assert "local" not in out.out
67
+
68
+ def test_local_only_manifest_still_shows(self, capsys):
69
+ manifest = SimpleNamespace(name="legacy-agent", engine="claude_code",
70
+ trigger=SimpleNamespace(type="schedule"))
71
+ out = self._run(capsys, custom={"return_value": []}, dept={"return_value": []},
72
+ manifests=[manifest])
73
+ assert "legacy-agent" in out.out and "local" in out.out
74
+
75
+ def test_owning_none_says_so_without_an_error(self, capsys):
76
+ out = self._run(capsys, custom={"return_value": []}, dept={"return_value": []})
77
+ assert "no agents found" in out.out
78
+ assert "web app" in out.out # points the user where agents are made
79
+ assert "warning" not in out.err
80
+
81
+ def test_custom_failure_is_a_warning_and_does_not_hide_defaults(self, capsys):
82
+ out = self._run(capsys, custom={"side_effect": RuntimeError("boom")},
83
+ dept={"return_value": [_row("AI Engineer")]})
84
+ assert "could not fetch custom: boom" in out.err
85
+ assert "AI Engineer" in out.out, "one source failing must not empty the other"
86
+
87
+ def test_blank_exception_message_still_names_the_failure(self, capsys):
88
+ """httpx.ReadTimeout stringifies to "" — a reasonless warning is useless."""
89
+ class ReadTimeout(Exception):
90
+ def __str__(self):
91
+ return ""
92
+ out = self._run(capsys, custom={"side_effect": ReadTimeout()},
93
+ dept={"return_value": []})
94
+ assert "could not fetch custom: ReadTimeout" in out.err
95
+
96
+
97
+ class TestListCustomAgentsClient:
98
+ @pytest.mark.asyncio
99
+ async def test_404_raises_backend_feature_missing(self):
100
+ """Parity with list_department_agents — an absent endpoint is not "no agents"."""
101
+ from sdk.client import BackendClient
102
+ from sdk.exceptions import BackendFeatureMissing
103
+
104
+ client = BackendClient(base_url="http://be", token="co_x_y")
105
+ resp = SimpleNamespace(status_code=404, json=lambda: {})
106
+ http = AsyncMock()
107
+ http.get = AsyncMock(return_value=resp)
108
+ http.__aenter__ = AsyncMock(return_value=http)
109
+ http.__aexit__ = AsyncMock(return_value=False)
110
+ with patch("sdk.client.httpx.AsyncClient", return_value=http):
111
+ with pytest.raises(BackendFeatureMissing):
112
+ await client.list_custom_agents()
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_200_returns_rows(self):
116
+ from sdk.client import BackendClient
117
+
118
+ client = BackendClient(base_url="http://be", token="co_x_y")
119
+ rows = [_row("a")]
120
+ resp = SimpleNamespace(status_code=200, json=lambda: {"agents": rows})
121
+ http = AsyncMock()
122
+ http.get = AsyncMock(return_value=resp)
123
+ http.__aenter__ = AsyncMock(return_value=http)
124
+ http.__aexit__ = AsyncMock(return_value=False)
125
+ with patch("sdk.client.httpx.AsyncClient", return_value=http):
126
+ assert await client.list_custom_agents() == rows
127
+
128
+
129
+ class TestUiAgentsAll:
130
+ @pytest.mark.asyncio
131
+ async def test_reports_custom_and_default_errors_separately(self):
132
+ from sdk.ui import api
133
+
134
+ client = SimpleNamespace(
135
+ list_custom_agents=AsyncMock(side_effect=RuntimeError("custom down")),
136
+ list_department_agents=AsyncMock(return_value=[_row("AI Engineer")]),
137
+ bootstrap=AsyncMock(return_value=SimpleNamespace(department_id="d-1")),
138
+ )
139
+ with patch("sdk.client.BackendClient.from_config", return_value=client):
140
+ out = await api.agents_all(department_id="d-1")
141
+ assert "custom down" in out["custom_error"]
142
+ assert out["defaults_error"] == ""
143
+ assert [a["name"] for a in out["agents"]] == ["AI Engineer"]
144
+
145
+ @pytest.mark.asyncio
146
+ async def test_no_errors_when_the_user_simply_owns_none(self):
147
+ from sdk.ui import api
148
+
149
+ client = SimpleNamespace(
150
+ list_custom_agents=AsyncMock(return_value=[]),
151
+ list_department_agents=AsyncMock(return_value=[]),
152
+ bootstrap=AsyncMock(return_value=SimpleNamespace(department_id="d-1")),
153
+ )
154
+ with patch("sdk.client.BackendClient.from_config", return_value=client):
155
+ out = await api.agents_all(department_id="d-1")
156
+ assert out == {"agents": [], "department_id": "d-1",
157
+ "custom_error": "", "defaults_error": ""}
158
+
159
+ @pytest.mark.asyncio
160
+ async def test_custom_wins_a_name_collision_with_a_default(self):
161
+ from sdk.ui import api
162
+
163
+ client = SimpleNamespace(
164
+ list_custom_agents=AsyncMock(return_value=[_row("Shared")]),
165
+ list_department_agents=AsyncMock(return_value=[_row("Shared")]),
166
+ bootstrap=AsyncMock(return_value=SimpleNamespace(department_id="d-1")),
167
+ )
168
+ with patch("sdk.client.BackendClient.from_config", return_value=client):
169
+ out = await api.agents_all(department_id="d-1")
170
+ assert [(a["name"], a["source"]) for a in out["agents"]] == [("Shared", "custom")]
File without changes
File without changes