devops-bot-sdk 1.6.2__tar.gz → 1.6.6__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.6}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/devops_bot_sdk.egg-info/SOURCES.txt +1 -0
  4. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agentd.py +8 -6
  6. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/chat.py +56 -10
  7. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/inventory.py +6 -4
  8. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/manifest.py +59 -2
  9. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/secrets.py +9 -1
  10. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/ticket_run.py +15 -14
  11. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/cli.py +80 -51
  12. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/client.py +18 -5
  13. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/api.py +56 -38
  14. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/static/app.js +8 -1
  15. devops_bot_sdk-1.6.6/tests/test_agent_listing.py +170 -0
  16. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/tests/test_agent_run_ticket.py +56 -14
  17. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/tests/test_agents_connectors_builder.py +16 -2
  18. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/README.md +0 -0
  19. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  20. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  21. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/devops_bot_sdk.egg-info/requires.txt +0 -0
  22. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  23. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/pyproject.toml +0 -0
  24. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/__init__.py +0 -0
  25. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/activity.py +0 -0
  26. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/builder.py +0 -0
  27. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/connectors.py +0 -0
  28. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/engines/__init__.py +0 -0
  29. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/engines/claude_code.py +0 -0
  30. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/engines/http.py +0 -0
  31. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/engines/mcp.py +0 -0
  32. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/engines/n8n.py +0 -0
  33. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/engines/shell.py +0 -0
  34. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/integrations.py +0 -0
  35. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/library/__init__.py +0 -0
  36. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/prompt_studio.py +0 -0
  37. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/prompt_template.py +0 -0
  38. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/prompt_vars.py +0 -0
  39. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/run_log.py +0 -0
  40. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/usage.py +0 -0
  41. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/agents/varstore.py +0 -0
  42. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/collectors/__init__.py +0 -0
  43. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/collectors/files.py +0 -0
  44. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/collectors/process.py +0 -0
  45. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/collectors/screenshot.py +0 -0
  46. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/config.py +0 -0
  47. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/crucial.py +0 -0
  48. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/design_verify/__init__.py +0 -0
  49. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/design_verify/assets.py +0 -0
  50. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/design_verify/bootstrap.py +0 -0
  51. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/design_verify/browser.py +0 -0
  52. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/design_verify/compare.py +0 -0
  53. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/design_verify/loop.py +0 -0
  54. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/exceptions.py +0 -0
  55. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/git_ops.py +0 -0
  56. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/github_ingest.py +0 -0
  57. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/graphify.py +0 -0
  58. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/hooks/__init__.py +0 -0
  59. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/hooks/crucial_guard.py +0 -0
  60. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ipc/__init__.py +0 -0
  61. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ipc/electron_bridge.py +0 -0
  62. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ipc/handlers.py +0 -0
  63. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/local_exec.py +0 -0
  64. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/models/__init__.py +0 -0
  65. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/models/envelope.py +0 -0
  66. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/models/requests.py +0 -0
  67. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/models/responses.py +0 -0
  68. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/models/snapshots.py +0 -0
  69. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/platform_compat.py +0 -0
  70. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/py.typed +0 -0
  71. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/run_auto.py +0 -0
  72. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/sse.py +0 -0
  73. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/test.py +0 -0
  74. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/test_pipeline.py +0 -0
  75. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/__init__.py +0 -0
  76. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/dashboard.py +0 -0
  77. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/health.py +0 -0
  78. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/ip_allowlist.py +0 -0
  79. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/jobs.py +0 -0
  80. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/models.py +0 -0
  81. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/runner.py +0 -0
  82. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/security.py +0 -0
  83. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/server.py +0 -0
  84. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/service.py +0 -0
  85. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/session.py +0 -0
  86. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/setup_manager.py +0 -0
  87. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/static/agents.html +0 -0
  88. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/static/custom-agents.html +0 -0
  89. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/static/default-agents.html +0 -0
  90. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/static/index.html +0 -0
  91. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/static/login.html +0 -0
  92. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/static/runs.html +0 -0
  93. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/static/setup.html +0 -0
  94. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/static/styles.css +0 -0
  95. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/ui/static/tickets.html +0 -0
  96. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/updater.py +0 -0
  97. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/sdk/vps_health.py +0 -0
  98. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/setup.cfg +0 -0
  99. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/tests/test_agent_activity.py +0 -0
  100. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/tests/test_agent_chat_features.py +0 -0
  101. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/tests/test_agent_prompt_async.py +0 -0
  102. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/tests/test_agent_prompt_models.py +0 -0
  103. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/tests/test_git_action.py +0 -0
  104. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/tests/test_github_ingest.py +0 -0
  105. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/tests/test_health.py +0 -0
  106. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/tests/test_otp_login.py +0 -0
  107. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/tests/test_updater_extras.py +0 -0
  108. {devops_bot_sdk-1.6.2 → devops_bot_sdk-1.6.6}/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.6
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.6
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.6
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.6" # 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
@@ -543,7 +543,7 @@ class AgentDaemon:
543
543
  validate) is logged and skipped — it must never prevent PERSONAL
544
544
  agents from loading.
545
545
  """
546
- from sdk.agents.manifest import AgentManifest
546
+ from sdk.agents.manifest import manifest_from_backend_row
547
547
 
548
548
  try:
549
549
  rows = await self.client.list_department_agents()
@@ -555,13 +555,15 @@ class AgentDaemon:
555
555
  for row in rows:
556
556
  if row.get("status") != "active":
557
557
  continue
558
- try:
559
- manifests.append(AgentManifest(**row["manifest"]))
560
- except Exception as exc: # noqa: BLE001
558
+ # Prompt-based rows: the `manifest` column this used to read was
559
+ # dropped from the table, so every row raised KeyError here.
560
+ manifest = manifest_from_backend_row(row)
561
+ if manifest is None:
561
562
  logger.warning(
562
- "agentd.department_agent_manifest_invalid name=%s err=%s",
563
- row.get("name"), exc,
563
+ "agentd.department_agent_not_runnable name=%s", row.get("name"),
564
564
  )
565
+ continue
566
+ manifests.append(manifest)
565
567
  return manifests
566
568
 
567
569
  async def watch_tickets(self) -> None:
@@ -41,14 +41,60 @@ def _tee_events(log_path, on_event: "EventHandler | None") -> "EventHandler":
41
41
  EventHandler = Callable[[dict], Awaitable[None]]
42
42
 
43
43
 
44
- def _find_agent(name: str) -> AgentManifest:
45
- match = next((m for m in load_agents() if m.name == name), None)
46
- if match is None:
47
- raise LookupError(
48
- f"no local agent named '{name}' — install it first "
49
- f"(agentos agent add / default-agents activate)"
50
- )
51
- return match
44
+ def _find_agent(name: str) -> AgentManifest | None:
45
+ """A LOCAL manifest by name (``~/.agentos/agents``), or None."""
46
+ return next((m for m in load_agents() if m.name == name), None)
47
+
48
+
49
+ async def resolve_agent(name: str, client=None) -> AgentManifest:
50
+ """Find the agent to run: local manifest → your backend agents → department.
51
+
52
+ The BACKEND is where agents live now — they are authored in the web app, and
53
+ nothing writes ``~/.agentos/agents`` anymore. Resolving only locally meant
54
+ every agent the UI could list was unrunnable ("no local agent named …"), for
55
+ department defaults as much as personal ones. A local manifest still wins when
56
+ present, so a hand-written one keeps overriding its backend namesake.
57
+
58
+ ``client`` lets a caller that already has one pass it in rather than having a
59
+ second one built here behind its back.
60
+
61
+ Raises ``LookupError`` naming every place that was checked and why.
62
+ """
63
+ local = _find_agent(name)
64
+ if local is not None:
65
+ return local
66
+
67
+ from sdk.agents.manifest import manifest_from_backend_row
68
+
69
+ if client is None:
70
+ from sdk.client import BackendClient
71
+ client = BackendClient.from_config()
72
+
73
+ reasons: list[str] = []
74
+ for label, attr in (("your agents", "list_custom_agents"),
75
+ ("department defaults", "list_department_agents")):
76
+ fetch = getattr(client, attr, None)
77
+ if fetch is None:
78
+ continue
79
+ try:
80
+ rows = await fetch() or []
81
+ except Exception as exc: # noqa: BLE001 — a fetch failure is not "not found"
82
+ reasons.append(f"{label} could not be fetched ({str(exc) or type(exc).__name__})")
83
+ continue
84
+ for row in rows:
85
+ if row.get("name") == name:
86
+ manifest = manifest_from_backend_row(row)
87
+ if manifest is not None:
88
+ return manifest
89
+ reasons.append(
90
+ f"'{name}' is in your {label} but its prompt is empty, so there "
91
+ f"is no instruction to run — add one in the web app"
92
+ )
93
+
94
+ detail = "; ".join(reasons) if reasons else (
95
+ "not in ~/.agentos/agents/, your backend agents, or your department defaults"
96
+ )
97
+ raise LookupError(f"no agent named '{name}' — {detail}")
52
98
 
53
99
 
54
100
  def _render_conversation(history: list[dict] | None, message: str) -> str:
@@ -87,7 +133,7 @@ async def chat_with_agent(
87
133
  ``files_changed``, ``error``). ``claude_code``/``model`` engines run through
88
134
  the local Claude executor; ``shell`` runs the composed prompt as a command.
89
135
  """
90
- manifest = _find_agent(name)
136
+ manifest = await resolve_agent(name)
91
137
 
92
138
  skills = load_skills()
93
139
  skill_text = resolve_skill_text(manifest, skills)
@@ -164,7 +210,7 @@ async def run_agent(
164
210
  ``{{variable}}`` is left unfilled — the UI gates the Run button on this, and
165
211
  this is the server-side backstop.
166
212
  """
167
- manifest = _find_agent(name)
213
+ manifest = await resolve_agent(name)
168
214
 
169
215
  skills = load_skills()
170
216
  skill_text = resolve_skill_text(manifest, skills)
@@ -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
 
@@ -23,8 +23,14 @@ INTEGRATIONS_DIR = Path.home() / ".agentos" / "integrations"
23
23
  # Full-string match: ${ENV_VAR_NAME} — no inline secrets allowed in integration env.
24
24
  _ENV_PLACEHOLDER_RE = re.compile(r"^\$\{[A-Za-z_][A-Za-z0-9_]*\}$")
25
25
 
26
- # Agent names: letters, digits, hyphen, underscore only.
27
- _AGENT_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
26
+ # Agent names. Agents are authored in the WEB APP now, where names are free text
27
+ # "AI Research Agent", "Jira & Confluence Project Management Agent". A rule of
28
+ # `[A-Za-z0-9_-]+` rejected every one of them, so a backend agent could not be
29
+ # materialized into a manifest and therefore could not be run at all. Spaces and
30
+ # ordinary punctuation are allowed; what stays banned is anything that could
31
+ # escape a path or break a log line, since the name is used to derive filenames
32
+ # (see agents/secrets.py, varstore.py, run_log.py — all of which sanitize too).
33
+ _AGENT_NAME_RE = re.compile(r"^[^\x00-\x1f/\\<>:\"|?*]+$")
28
34
 
29
35
  # Flags an inline secret like `token: abc123...` in free-text fields while
30
36
  # allowing the ${ENV_VAR} placeholder form. Mirrors the backend
@@ -210,6 +216,57 @@ def assert_secret_free(obj: object, _where: str = "payload") -> None:
210
216
  )
211
217
 
212
218
 
219
+ def manifest_from_backend_row(row: dict) -> AgentManifest | None:
220
+ """Materialize a runnable manifest from a backend prompt-based ``agents`` row.
221
+
222
+ Agents are authored in the web app, so the row — not a local YAML file — is
223
+ what the engine has to run. The row shape (docs/DATABASE_SCHEMA.md §4) is
224
+ ``{name, status, prompt: {system_prompt, variables:[…]}, skills:[{name,
225
+ description, …}]}``; there is no ``engine``/``trigger``/``manifest`` column
226
+ anymore, so those are supplied here:
227
+
228
+ * ``engine="claude_code"`` — the only engine a web-app agent targets.
229
+ * ``trigger=manual`` — the row carries no trigger; scheduled and
230
+ ticket-label dispatch stay a local-manifest feature.
231
+
232
+ Each attached skill's description is folded into the prompt rather than left
233
+ in ``manifest.skills``: skill names on the row refer to backend ``skills``
234
+ rows, which ``resolve_skill_text`` (local ``~/.agentos/skills``) would drop as
235
+ dangling references, silently losing the instruction text.
236
+
237
+ Returns ``None`` if the row cannot make a valid manifest (no name, or an empty
238
+ / too-short prompt), so a malformed row is skipped rather than raising.
239
+ """
240
+ name = str((row or {}).get("name") or "").strip()
241
+ if not name:
242
+ return None
243
+
244
+ prompt_obj = row.get("prompt") or {}
245
+ if not isinstance(prompt_obj, dict):
246
+ prompt_obj = {}
247
+ prompt = str(prompt_obj.get("system_prompt") or "").strip()
248
+
249
+ for skill in row.get("skills") or []:
250
+ if not isinstance(skill, dict):
251
+ continue
252
+ s_name = str(skill.get("name") or "").strip()
253
+ s_body = str(skill.get("description") or "").strip()
254
+ if s_name and s_body:
255
+ prompt = f"{prompt}\n\n## Skill: {s_name}\n\n{s_body}"
256
+
257
+ try:
258
+ return AgentManifest(
259
+ name=name,
260
+ description=prompt[:200],
261
+ engine="claude_code",
262
+ trigger=TriggerSpec(type="manual"),
263
+ prompt=prompt,
264
+ )
265
+ except ValidationError as exc:
266
+ logger.warning("backend agent '%s' is not runnable: %s", name, exc)
267
+ return None
268
+
269
+
213
270
  def load_agents(dir: Path = AGENTS_DIR) -> list[AgentManifest]:
214
271
  """Load every agent manifest under `dir`.
215
272
 
@@ -34,7 +34,15 @@ SECRETS_DIR = Path.home() / ".agentos" / "agent_secrets"
34
34
 
35
35
 
36
36
  def _path(agent_name: str) -> Path:
37
- return SECRETS_DIR / f"{agent_name}.json.enc"
37
+ """Store file for an agent's keys.
38
+
39
+ The name is sanitized because agent names are free text from the web app now
40
+ ("Jira & Confluence Project Management Agent") — an unsanitized name
41
+ containing a path separator would resolve outside SECRETS_DIR. Mirrors
42
+ ``varstore._path`` so the two stores key the same way.
43
+ """
44
+ safe = "".join(c for c in (agent_name or "") if c.isalnum() or c in "-_") or "agent"
45
+ return SECRETS_DIR / f"{safe}.json.enc"
38
46
 
39
47
 
40
48
  def load_secrets(agent_name: str) -> dict[str, str]:
@@ -25,15 +25,19 @@ async def resolve_manifest(
25
25
  ) -> "AgentManifest | None":
26
26
  """Find the agent manifest to run, by ``scope``.
27
27
 
28
- * ``custom`` (default) — a personal agent from the local manifests
29
- (``~/.agentos/agents``).
30
28
  * ``department`` — an ACTIVE department default pulled from the backend
31
- (``list_department_agents``), materialized read-only (never written back),
32
- exactly as ``agentd._load_department_agents`` does.
29
+ (``list_department_agents``) and materialized read-only (never written back).
30
+ * ``custom`` (default) — a local manifest, then the caller's own backend
31
+ agents, then their department defaults, via ``chat.resolve_agent``.
33
32
 
34
- Returns None when no matching agent is found.
33
+ Backend rows are prompt-based (``prompt.system_prompt`` + attached skills);
34
+ the ``manifest`` column this used to read no longer exists on the table, so
35
+ reading it raised ``KeyError`` and made every department agent unrunnable.
36
+
37
+ Returns None when no matching agent is found, so the caller reports it.
35
38
  """
36
- from sdk.agents.manifest import AgentManifest, load_agents
39
+ from sdk.agents.chat import resolve_agent
40
+ from sdk.agents.manifest import manifest_from_backend_row
37
41
 
38
42
  name = (name or "").strip()
39
43
  if scope == "department":
@@ -43,16 +47,13 @@ async def resolve_manifest(
43
47
  return None
44
48
  for row in rows:
45
49
  if row.get("name") == name and row.get("status") == "active":
46
- try:
47
- return AgentManifest(**row["manifest"])
48
- except Exception: # noqa: BLE001 — invalid manifest → not runnable
49
- return None
50
+ return manifest_from_backend_row(row)
50
51
  return None
51
52
 
52
- for m in load_agents():
53
- if m.name == name:
54
- return m
55
- return None
53
+ try:
54
+ return await resolve_agent(name, client)
55
+ except LookupError:
56
+ return None
56
57
 
57
58
 
58
59
  async def run_on_ticket(
@@ -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:
@@ -296,35 +333,27 @@ def _agent_run_ticket(name: str, flags: list[str]) -> None:
296
333
 
297
334
 
298
335
  def _agent_run(name: str) -> None:
299
- from sdk.agents.manifest import AgentManifest, load_agents
336
+ """`agentos agent run NAME` — dispatch one run locally.
337
+
338
+ Resolution order is shared with the UI and `agent chat` (see
339
+ ``sdk.agents.chat.resolve_agent``): a local manifest wins, then the caller's
340
+ own backend agents, then their department defaults. It used to check local
341
+ manifests and department rows only — and read a `manifest` column the
342
+ prompt-based `agents` table no longer has — so nothing authored in the web app
343
+ could be run.
344
+ """
345
+ from sdk.agents.chat import resolve_agent
300
346
  from sdk.client import BackendClient
301
347
  from sdk import agentd
302
348
 
303
349
  client = BackendClient.from_config()
304
-
305
- matched = next((m for m in load_agents() if m.name == name), None)
306
- if matched is not None:
307
- asyncio.run(agentd._dispatch_agent_run(matched, client))
308
- return
309
-
310
- # Not a local personal agent -- check this user's department defaults
311
- # (read-only, pulled from the backend; see AgentDaemon._load_department_agents).
312
350
  try:
313
- dept_row = next(
314
- (r for r in asyncio.run(client.list_department_agents()) if r.get("name") == name),
315
- None,
316
- )
317
- except Exception as exc: # noqa: BLE001
318
- print(f"error: could not fetch department agents ({exc})")
319
- sys.exit(1)
320
-
321
- if dept_row is None:
322
- print(f"error: no agent named '{name}' (checked ~/.agentos/agents/ and "
323
- f"this user's department default agents)")
351
+ manifest = asyncio.run(resolve_agent(name, client))
352
+ except LookupError as exc:
353
+ print(f"error: {exc}")
324
354
  sys.exit(1)
325
355
 
326
- dept_manifest = AgentManifest(**dept_row["manifest"])
327
- asyncio.run(agentd._dispatch_agent_run(dept_manifest, client))
356
+ asyncio.run(agentd._dispatch_agent_run(manifest, client))
328
357
 
329
358
 
330
359
  def _agent_logs(name: 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])