devops-bot-sdk 1.6.4__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.4 → devops_bot_sdk-1.6.6}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/__init__.py +2 -2
  4. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agentd.py +8 -6
  5. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/chat.py +56 -10
  6. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/manifest.py +59 -2
  7. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/secrets.py +9 -1
  8. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/ticket_run.py +15 -14
  9. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/cli.py +14 -22
  10. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/tests/test_agent_run_ticket.py +56 -14
  11. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/tests/test_agents_connectors_builder.py +16 -2
  12. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/README.md +0 -0
  13. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  14. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  15. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  16. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/devops_bot_sdk.egg-info/requires.txt +0 -0
  17. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  18. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/pyproject.toml +0 -0
  19. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/__init__.py +0 -0
  20. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/activity.py +0 -0
  21. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/builder.py +0 -0
  22. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/connectors.py +0 -0
  23. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/engines/__init__.py +0 -0
  24. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/engines/claude_code.py +0 -0
  25. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/engines/http.py +0 -0
  26. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/engines/mcp.py +0 -0
  27. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/engines/n8n.py +0 -0
  28. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/engines/shell.py +0 -0
  29. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/integrations.py +0 -0
  30. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/inventory.py +0 -0
  31. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/library/__init__.py +0 -0
  32. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/prompt_studio.py +0 -0
  33. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/prompt_template.py +0 -0
  34. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/prompt_vars.py +0 -0
  35. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/run_log.py +0 -0
  36. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/usage.py +0 -0
  37. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/agents/varstore.py +0 -0
  38. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/client.py +0 -0
  39. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/collectors/__init__.py +0 -0
  40. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/collectors/files.py +0 -0
  41. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/collectors/process.py +0 -0
  42. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/collectors/screenshot.py +0 -0
  43. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/config.py +0 -0
  44. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/crucial.py +0 -0
  45. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/design_verify/__init__.py +0 -0
  46. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/design_verify/assets.py +0 -0
  47. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/design_verify/bootstrap.py +0 -0
  48. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/design_verify/browser.py +0 -0
  49. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/design_verify/compare.py +0 -0
  50. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/design_verify/loop.py +0 -0
  51. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/exceptions.py +0 -0
  52. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/git_ops.py +0 -0
  53. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/github_ingest.py +0 -0
  54. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/graphify.py +0 -0
  55. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/hooks/__init__.py +0 -0
  56. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/hooks/crucial_guard.py +0 -0
  57. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ipc/__init__.py +0 -0
  58. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ipc/electron_bridge.py +0 -0
  59. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ipc/handlers.py +0 -0
  60. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/local_exec.py +0 -0
  61. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/models/__init__.py +0 -0
  62. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/models/envelope.py +0 -0
  63. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/models/requests.py +0 -0
  64. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/models/responses.py +0 -0
  65. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/models/snapshots.py +0 -0
  66. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/platform_compat.py +0 -0
  67. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/py.typed +0 -0
  68. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/run_auto.py +0 -0
  69. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/sse.py +0 -0
  70. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/test.py +0 -0
  71. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/test_pipeline.py +0 -0
  72. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/__init__.py +0 -0
  73. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/api.py +0 -0
  74. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/dashboard.py +0 -0
  75. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/health.py +0 -0
  76. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/ip_allowlist.py +0 -0
  77. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/jobs.py +0 -0
  78. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/models.py +0 -0
  79. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/runner.py +0 -0
  80. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/security.py +0 -0
  81. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/server.py +0 -0
  82. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/service.py +0 -0
  83. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/session.py +0 -0
  84. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/setup_manager.py +0 -0
  85. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/static/agents.html +0 -0
  86. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/static/app.js +0 -0
  87. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/static/custom-agents.html +0 -0
  88. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/static/default-agents.html +0 -0
  89. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/static/index.html +0 -0
  90. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/static/login.html +0 -0
  91. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/static/runs.html +0 -0
  92. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/static/setup.html +0 -0
  93. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/static/styles.css +0 -0
  94. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/ui/static/tickets.html +0 -0
  95. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/updater.py +0 -0
  96. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/sdk/vps_health.py +0 -0
  97. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/setup.cfg +0 -0
  98. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/tests/test_agent_activity.py +0 -0
  99. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/tests/test_agent_chat_features.py +0 -0
  100. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/tests/test_agent_listing.py +0 -0
  101. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/tests/test_agent_prompt_async.py +0 -0
  102. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/tests/test_agent_prompt_models.py +0 -0
  103. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/tests/test_git_action.py +0 -0
  104. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/tests/test_github_ingest.py +0 -0
  105. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/tests/test_health.py +0 -0
  106. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/tests/test_otp_login.py +0 -0
  107. {devops_bot_sdk-1.6.4 → devops_bot_sdk-1.6.6}/tests/test_updater_extras.py +0 -0
  108. {devops_bot_sdk-1.6.4 → 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.4
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.4
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
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.6.4
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.4" # 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)
@@ -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(
@@ -333,35 +333,27 @@ def _agent_run_ticket(name: str, flags: list[str]) -> None:
333
333
 
334
334
 
335
335
  def _agent_run(name: str) -> None:
336
- 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
337
346
  from sdk.client import BackendClient
338
347
  from sdk import agentd
339
348
 
340
349
  client = BackendClient.from_config()
341
-
342
- matched = next((m for m in load_agents() if m.name == name), None)
343
- if matched is not None:
344
- asyncio.run(agentd._dispatch_agent_run(matched, client))
345
- return
346
-
347
- # Not a local personal agent -- check this user's department defaults
348
- # (read-only, pulled from the backend; see AgentDaemon._load_department_agents).
349
350
  try:
350
- dept_row = next(
351
- (r for r in asyncio.run(client.list_department_agents()) if r.get("name") == name),
352
- None,
353
- )
354
- except Exception as exc: # noqa: BLE001
355
- print(f"error: could not fetch department agents ({exc})")
356
- sys.exit(1)
357
-
358
- if dept_row is None:
359
- print(f"error: no agent named '{name}' (checked ~/.agentos/agents/ and "
360
- 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}")
361
354
  sys.exit(1)
362
355
 
363
- dept_manifest = AgentManifest(**dept_row["manifest"])
364
- asyncio.run(agentd._dispatch_agent_run(dept_manifest, client))
356
+ asyncio.run(agentd._dispatch_agent_run(manifest, client))
365
357
 
366
358
 
367
359
  def _agent_logs(name: str) -> None:
@@ -110,42 +110,84 @@ async def test_policy_always_without_auto_approve_waits_for_human():
110
110
  # ── ticket_run helper ───────────────────────────────────────────────────────
111
111
 
112
112
  class _ResolveClient:
113
- def __init__(self, dept_rows=None, tasks=None):
113
+ def __init__(self, dept_rows=None, tasks=None, custom_rows=None):
114
114
  self._dept = dept_rows or []
115
+ self._custom = custom_rows or []
115
116
  self._tasks = tasks or []
116
117
 
117
118
  async def list_department_agents(self):
118
119
  return self._dept
119
120
 
121
+ async def list_custom_agents(self):
122
+ return self._custom
123
+
120
124
  async def list_tasks_by_keys(self, keys):
121
125
  return self._tasks
122
126
 
123
127
 
128
+ def _row(name, prompt="Extract the latest AI news and summarize it.", status="active",
129
+ skills=()):
130
+ """A backend prompt-based ``agents`` row (docs/DATABASE_SCHEMA.md §4).
131
+
132
+ There is no ``manifest`` column — it was dropped in the prompt-based
133
+ migration, which is why reading one made every backend agent unrunnable.
134
+ """
135
+ return {"name": name, "status": status,
136
+ "prompt": {"system_prompt": prompt, "variables": []},
137
+ "skills": [{"name": n, "description": d} for n, d in skills]}
138
+
139
+
124
140
  @pytest.mark.asyncio
125
- async def test_resolve_manifest_custom():
141
+ async def test_resolve_manifest_custom_prefers_local_then_backend():
126
142
  m = _stub_manifest()
127
- with patch("sdk.agents.manifest.load_agents", return_value=[m]):
143
+ # A local manifest wins. chat._find_agent binds load_agents at import, so
144
+ # patch the name the resolver actually calls.
145
+ with patch("sdk.agents.chat.load_agents", return_value=[m]):
128
146
  got = await ticket_run.resolve_manifest(_ResolveClient(), "Tix", scope="custom")
129
147
  assert got is m
130
- with patch("sdk.agents.manifest.load_agents", return_value=[]):
148
+
149
+ # No local manifest → the caller's own backend agents are used.
150
+ with patch("sdk.agents.chat.load_agents", return_value=[]):
151
+ client = _ResolveClient(custom_rows=[_row("AI Research Agent")])
152
+ got = await ticket_run.resolve_manifest(client, "AI Research Agent", scope="custom")
153
+ assert got is not None and got.name == "AI Research Agent"
154
+ assert got.engine == "claude_code" and got.trigger.type == "manual"
155
+
131
156
  assert await ticket_run.resolve_manifest(_ResolveClient(), "Nope") is None
132
157
 
133
158
 
134
159
  @pytest.mark.asyncio
135
160
  async def test_resolve_manifest_department_active_only():
136
- rows = [
137
- {"name": "DeptA", "status": "inactive", "manifest": {}},
138
- {"name": "DeptA", "status": "active",
139
- "manifest": {"name": "DeptA", "prompt": "p", "engine": "claude_code"}},
140
- ]
141
- client = _ResolveClient(dept_rows=rows)
142
- # resolve_manifest does `from sdk.agents.manifest import AgentManifest` at call
143
- # time, so patch it on that module; build a stand-in from the manifest dict.
144
- with patch("sdk.agents.manifest.AgentManifest", side_effect=lambda **kw: SimpleNamespace(**kw)):
145
- got = await ticket_run.resolve_manifest(client, "DeptA", scope="department")
161
+ rows = [_row("DeptA", status="inactive"), _row("DeptA", status="active")]
162
+ got = await ticket_run.resolve_manifest(
163
+ _ResolveClient(dept_rows=rows), "DeptA", scope="department")
146
164
  assert got is not None and got.name == "DeptA"
147
165
 
148
166
 
167
+ @pytest.mark.asyncio
168
+ async def test_resolve_manifest_department_with_empty_prompt_is_not_runnable():
169
+ """Every department row on the live DB has an empty system_prompt — there is
170
+ no instruction to execute, so it must not resolve to a bogus manifest."""
171
+ rows = [_row("Model QA Specialist", prompt="")]
172
+ got = await ticket_run.resolve_manifest(
173
+ _ResolveClient(dept_rows=rows), "Model QA Specialist", scope="department")
174
+ assert got is None
175
+
176
+
177
+ @pytest.mark.asyncio
178
+ async def test_backend_skills_are_folded_into_the_prompt():
179
+ """Skill names on a row point at BACKEND skills rows; left in manifest.skills
180
+ they would be dropped as dangling local references, losing the text."""
181
+ client = _ResolveClient(custom_rows=[
182
+ _row("Researcher", skills=(("web-search", "Search the web for sources."),))])
183
+ with patch("sdk.agents.chat.load_agents", return_value=[]):
184
+ got = await ticket_run.resolve_manifest(client, "Researcher", scope="custom")
185
+ assert got is not None
186
+ assert "## Skill: web-search" in got.prompt
187
+ assert "Search the web for sources." in got.prompt
188
+ assert got.skills == []
189
+
190
+
149
191
  @pytest.mark.asyncio
150
192
  async def test_run_on_ticket_missing_ticket_raises():
151
193
  client = _ResolveClient(tasks=[])
@@ -257,9 +257,23 @@ def test_build_agent_enforce_template_flag():
257
257
 
258
258
 
259
259
  def test_chat_module_finds_missing_agent():
260
- from sdk.agents.chat import _find_agent
260
+ """_find_agent is LOCAL-only and returns None; resolve_agent raises after it
261
+ has also checked the backend (that is where agents live now)."""
262
+ import asyncio
263
+
264
+ from sdk.agents.chat import _find_agent, resolve_agent
265
+
266
+ assert _find_agent("no-such-agent-xyz") is None
267
+
268
+ class _NoAgents:
269
+ async def list_custom_agents(self):
270
+ return []
271
+
272
+ async def list_department_agents(self):
273
+ return []
274
+
261
275
  with pytest.raises(LookupError):
262
- _find_agent("no-such-agent-xyz")
276
+ asyncio.run(resolve_agent("no-such-agent-xyz", _NoAgents()))
263
277
 
264
278
 
265
279
  def test_save_agent_writes_manifest(tmp_path, monkeypatch):
File without changes
File without changes