devops-bot-sdk 1.5.0__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.
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/PKG-INFO +1 -1
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/devops_bot_sdk.egg-info/SOURCES.txt +1 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/__init__.py +2 -2
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/inventory.py +6 -4
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/cli.py +66 -29
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/client.py +18 -5
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/github_ingest.py +66 -3
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/run_auto.py +41 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/api.py +56 -38
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/static/app.js +8 -1
- devops_bot_sdk-1.6.4/tests/test_agent_listing.py +170 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/tests/test_github_ingest.py +72 -3
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/README.md +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/devops_bot_sdk.egg-info/requires.txt +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/devops_bot_sdk.egg-info/top_level.txt +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/pyproject.toml +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agentd.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/__init__.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/activity.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/builder.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/chat.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/connectors.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/engines/__init__.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/engines/claude_code.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/engines/http.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/engines/mcp.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/engines/n8n.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/engines/shell.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/integrations.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/library/__init__.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/manifest.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/prompt_studio.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/prompt_template.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/prompt_vars.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/run_log.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/secrets.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/ticket_run.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/usage.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/agents/varstore.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/collectors/__init__.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/collectors/files.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/collectors/process.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/collectors/screenshot.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/config.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/crucial.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/design_verify/__init__.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/design_verify/assets.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/design_verify/bootstrap.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/design_verify/browser.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/design_verify/compare.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/design_verify/loop.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/exceptions.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/git_ops.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/graphify.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/hooks/__init__.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/hooks/crucial_guard.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ipc/__init__.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ipc/electron_bridge.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ipc/handlers.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/local_exec.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/models/__init__.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/models/envelope.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/models/requests.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/models/responses.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/models/snapshots.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/platform_compat.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/py.typed +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/sse.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/test.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/test_pipeline.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/__init__.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/dashboard.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/health.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/ip_allowlist.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/jobs.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/models.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/runner.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/security.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/server.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/service.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/session.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/setup_manager.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/static/agents.html +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/static/custom-agents.html +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/static/default-agents.html +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/static/index.html +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/static/login.html +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/static/runs.html +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/static/setup.html +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/static/styles.css +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/ui/static/tickets.html +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/updater.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/sdk/vps_health.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/setup.cfg +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/tests/test_agent_activity.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/tests/test_agent_chat_features.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/tests/test_agent_prompt_async.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/tests/test_agent_prompt_models.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/tests/test_agent_run_ticket.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/tests/test_agents_connectors_builder.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/tests/test_git_action.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/tests/test_health.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/tests/test_otp_login.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/tests/test_updater_extras.py +0 -0
- {devops_bot_sdk-1.5.0 → devops_bot_sdk-1.6.4}/tests/test_vps_health.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
|
|
2
2
|
|
|
3
|
-
Version: 1.
|
|
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.
|
|
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=
|
|
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
|
|
43
|
-
via GET /api/v1/agents. Best-effort:
|
|
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.
|
|
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
|
-
|
|
236
|
+
custom_rows: list[dict] = []
|
|
211
237
|
dept_rows: list[dict] = []
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
for
|
|
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
|
-
|
|
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 —
|
|
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
|
-
|
|
1379
|
-
|
|
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
|
|
|
@@ -13,6 +13,16 @@ What moves and what does NOT:
|
|
|
13
13
|
Postgres, Qdrant, or Azure, and holds no credential beyond the ``co_`` token
|
|
14
14
|
it was configured with. One VPS cannot write another user's vectors.
|
|
15
15
|
|
|
16
|
+
**Two things run, both on by default, no configuration needed:**
|
|
17
|
+
1. a ONE-TIME 50-day history backfill, fired from ``agentos-auto update`` via
|
|
18
|
+
:func:`run_backfill_once` and guarded by ``BACKFILL_ID`` so it happens at
|
|
19
|
+
most once per machine;
|
|
20
|
+
2. the DAILY loop below, started by the ``agentos-ui`` lifespan alongside the
|
|
21
|
+
2-hourly ``sdk.vps_health`` ping.
|
|
22
|
+
|
|
23
|
+
The daily loop also covers the backfill: if a box never runs ``agentos-auto
|
|
24
|
+
update``, the first tick sees ``backfill_pending`` and seeds the history itself.
|
|
25
|
+
|
|
16
26
|
Schedule semantics — a real daily slot, not a naive interval:
|
|
17
27
|
* the loop ticks every ``_TICK_S`` (cheap: no network unless a run is due),
|
|
18
28
|
* a run is DUE when the UTC clock has passed ``AGENTOS_GITHUB_INGEST_AT``
|
|
@@ -31,7 +41,9 @@ State lives in ``~/.agentos/github_ingest.json``: ``last_success_at`` (the
|
|
|
31
41
|
watermark), plus the last attempt's outcome for ``GET /api/ui/github-ingest``.
|
|
32
42
|
|
|
33
43
|
Config — process env OR ``~/.agentos/.env`` (process env wins), all optional:
|
|
34
|
-
``AGENTOS_GITHUB_INGEST`` ``0``/``false``/``no``/``off`` disables
|
|
44
|
+
``AGENTOS_GITHUB_INGEST`` ``0``/``false``/``no``/``off`` disables everything.
|
|
45
|
+
``AGENTOS_GITHUB_INGEST_DAILY`` ``0`` turns the recurring daily loop OFF (default ON).
|
|
46
|
+
``AGENTOS_GITHUB_INGEST_BACKFILL_DAYS`` one-time seed size (default 50, max 365).
|
|
35
47
|
``AGENTOS_GITHUB_INGEST_AT`` daily UTC slot, ``HH:MM`` (default ``13:30``).
|
|
36
48
|
``AGENTOS_GITHUB_INGEST_LOOKBACK_DAYS`` catch-up cap (default 7, max 90).
|
|
37
49
|
|
|
@@ -88,10 +100,24 @@ def _setting(name: str) -> str:
|
|
|
88
100
|
|
|
89
101
|
|
|
90
102
|
def enabled() -> bool:
|
|
91
|
-
"""
|
|
103
|
+
"""Master switch — False only when ``AGENTOS_GITHUB_INGEST`` is explicitly falsey.
|
|
104
|
+
|
|
105
|
+
Gates BOTH the one-time backfill and the daily loop.
|
|
106
|
+
"""
|
|
92
107
|
return _setting("AGENTOS_GITHUB_INGEST").lower() not in _FALSEY
|
|
93
108
|
|
|
94
109
|
|
|
110
|
+
def daily_enabled() -> bool:
|
|
111
|
+
"""Is the RECURRING daily loop switched on? **Default: yes.**
|
|
112
|
+
|
|
113
|
+
The steady state is two schedules running side by side on every box, with no
|
|
114
|
+
configuration required: ``sdk.vps_health`` every 2 hours, and this one daily
|
|
115
|
+
at :func:`slot`. Opt OUT with ``AGENTOS_GITHUB_INGEST_DAILY=0`` (or turn off
|
|
116
|
+
GitHub ingestion entirely with ``AGENTOS_GITHUB_INGEST=0``).
|
|
117
|
+
"""
|
|
118
|
+
return _setting("AGENTOS_GITHUB_INGEST_DAILY").lower() not in _FALSEY
|
|
119
|
+
|
|
120
|
+
|
|
95
121
|
def slot() -> tuple[int, int]:
|
|
96
122
|
"""The daily UTC slot as ``(hour, minute)``. Bad values fall back to the default."""
|
|
97
123
|
raw = _setting("AGENTOS_GITHUB_INGEST_AT") or DEFAULT_AT
|
|
@@ -307,6 +333,7 @@ def status() -> dict:
|
|
|
307
333
|
state = load_state()
|
|
308
334
|
return {
|
|
309
335
|
"enabled": enabled(),
|
|
336
|
+
"daily_loop": daily_enabled(),
|
|
310
337
|
"slot_utc": f"{hour:02d}:{minute:02d}",
|
|
311
338
|
"lookback_days": lookback_days(),
|
|
312
339
|
"backfill_days": backfill_days(),
|
|
@@ -338,8 +365,44 @@ async def run_forever(tick_s: float = _TICK_S) -> None:
|
|
|
338
365
|
|
|
339
366
|
|
|
340
367
|
def start() -> "asyncio.Task | None":
|
|
341
|
-
"""Start the
|
|
368
|
+
"""Start the RECURRING daily loop. On by default; returns the task, or None
|
|
369
|
+
when GitHub ingestion (or just the daily loop) has been switched off."""
|
|
342
370
|
if not enabled():
|
|
343
371
|
logger.info("github_ingest.disabled (AGENTOS_GITHUB_INGEST is off)")
|
|
344
372
|
return None
|
|
373
|
+
if not daily_enabled():
|
|
374
|
+
logger.info("github_ingest.daily_loop_off (AGENTOS_GITHUB_INGEST_DAILY is off)")
|
|
375
|
+
return None
|
|
345
376
|
return asyncio.ensure_future(run_forever())
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# ── The one-time backfill (what `agentos-auto update` fires) ────────────────
|
|
380
|
+
|
|
381
|
+
def run_backfill_once(client: "BackendClient | None" = None) -> dict:
|
|
382
|
+
"""Run THIS rollout's 50-day history backfill — at most once per machine.
|
|
383
|
+
|
|
384
|
+
Called from ``agentos-auto update``. Safe to call on every update: the
|
|
385
|
+
``BACKFILL_ID`` marker in ``~/.agentos/github_ingest.json`` means the second
|
|
386
|
+
and later calls are a no-op, so a user who updates weekly still ingests the
|
|
387
|
+
history exactly once.
|
|
388
|
+
|
|
389
|
+
Synchronous by design — the update command is a plain CLI flow, not an event
|
|
390
|
+
loop. Returns ``{ran, status, since, job_id, status_url, reason}``;
|
|
391
|
+
``ran=False`` with ``status="already_done"`` when the marker is already set,
|
|
392
|
+
or ``"disabled"`` when the feature is switched off. Never raises: a failed
|
|
393
|
+
backfill must not fail the update.
|
|
394
|
+
"""
|
|
395
|
+
if not enabled():
|
|
396
|
+
return {"ran": False, "status": "disabled", "reason": "AGENTOS_GITHUB_INGEST is off"}
|
|
397
|
+
|
|
398
|
+
state = load_state()
|
|
399
|
+
if not backfill_pending(state):
|
|
400
|
+
return {"ran": False, "status": "already_done",
|
|
401
|
+
"since": state.get("last_since", ""), "job_id": state.get("last_job_id", "")}
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
result = asyncio.run(run_once(client, days=backfill_days()))
|
|
405
|
+
except Exception as exc: # noqa: BLE001 — never fail `agentos-auto update`
|
|
406
|
+
logger.warning("github_ingest.backfill_failed err=%s", exc)
|
|
407
|
+
return {"ran": True, "status": "failed", "reason": str(exc)[:200]}
|
|
408
|
+
return {"ran": True, **result}
|
|
@@ -588,6 +588,47 @@ def main() -> None:
|
|
|
588
588
|
"`agentos-auto stop` then `agentos-auto` to run the new one.")
|
|
589
589
|
else:
|
|
590
590
|
print(f"already up to date (v{after})")
|
|
591
|
+
|
|
592
|
+
# One-time GitHub history backfill — the last 50 days of THIS user's PRs.
|
|
593
|
+
# Fired from `update` because that is the moment every machine passes
|
|
594
|
+
# through exactly once per rollout. Guarded by the BACKFILL_ID marker in
|
|
595
|
+
# ~/.agentos/github_ingest.json, so a second `update` is a no-op — and it
|
|
596
|
+
# runs whether or not the version actually changed, so a box that was
|
|
597
|
+
# already current still seeds its history. Never fails the update.
|
|
598
|
+
try:
|
|
599
|
+
from sdk import github_ingest
|
|
600
|
+
|
|
601
|
+
gh = github_ingest.run_backfill_once()
|
|
602
|
+
status = gh.get("status")
|
|
603
|
+
if gh.get("ran") and status == "queued":
|
|
604
|
+
print(f"✓ GitHub history: queued PRs since {gh.get('since')} "
|
|
605
|
+
f"(job {gh.get('job_id') or 'n/a'})")
|
|
606
|
+
elif gh.get("ran") and status == "no_github_link":
|
|
607
|
+
print(" GitHub history: skipped — no GitHub account connected")
|
|
608
|
+
elif gh.get("ran"):
|
|
609
|
+
print(f" GitHub history: not queued — {gh.get('reason') or status}")
|
|
610
|
+
elif status == "already_done":
|
|
611
|
+
print(" GitHub history: already ingested")
|
|
612
|
+
except Exception as exc: # noqa: BLE001 — never fail the update on this
|
|
613
|
+
print(f" GitHub history: skipped ({exc})")
|
|
614
|
+
|
|
615
|
+
# Refresh this machine's health immediately. The panel pings every 2h on
|
|
616
|
+
# its own, but an update restarts the panel — so without this the
|
|
617
|
+
# allocation row can look stale for up to two hours right after a rollout,
|
|
618
|
+
# which is exactly when someone is checking whether the box came back.
|
|
619
|
+
try:
|
|
620
|
+
import asyncio as _asyncio
|
|
621
|
+
|
|
622
|
+
from sdk import vps_health
|
|
623
|
+
|
|
624
|
+
if vps_health.enabled():
|
|
625
|
+
if _asyncio.run(vps_health.report_once()):
|
|
626
|
+
print("✓ VPS health: reported")
|
|
627
|
+
else:
|
|
628
|
+
last = (vps_health.last_report().get("last") or {})
|
|
629
|
+
print(f" VPS health: not reported — {last.get('reason') or 'unknown'}")
|
|
630
|
+
except Exception as exc: # noqa: BLE001 — never fail the update on this
|
|
631
|
+
print(f" VPS health: skipped ({exc})")
|
|
591
632
|
return
|
|
592
633
|
|
|
593
634
|
if cmd == "stop":
|
|
@@ -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=
|
|
503
|
-
|
|
504
|
-
Backend is the source of truth — agents are authored in the web app
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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,
|
|
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=""
|
|
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")]
|
|
@@ -24,7 +24,8 @@ def isolated_state(tmp_path, monkeypatch):
|
|
|
24
24
|
monkeypatch.setattr(gi, "STATE_FILE", tmp_path / "github_ingest.json")
|
|
25
25
|
import sdk.config as cfg
|
|
26
26
|
monkeypatch.setattr(cfg, "_dotenv_value", lambda key: "")
|
|
27
|
-
for var in ("AGENTOS_GITHUB_INGEST", "
|
|
27
|
+
for var in ("AGENTOS_GITHUB_INGEST", "AGENTOS_GITHUB_INGEST_DAILY",
|
|
28
|
+
"AGENTOS_GITHUB_INGEST_AT",
|
|
28
29
|
"AGENTOS_GITHUB_INGEST_LOOKBACK_DAYS",
|
|
29
30
|
"AGENTOS_GITHUB_INGEST_BACKFILL_DAYS"):
|
|
30
31
|
monkeypatch.delenv(var, raising=False)
|
|
@@ -150,6 +151,14 @@ class TestSinceWindow:
|
|
|
150
151
|
_UNSET = object() # so an explicit None ("no link" / "call failed") is distinguishable
|
|
151
152
|
|
|
152
153
|
|
|
154
|
+
def _sync_stub(client):
|
|
155
|
+
"""Replace run_once with a coroutine that records the window it was asked for."""
|
|
156
|
+
async def _fake(c=None, *, now=None, days=None):
|
|
157
|
+
client.calls.append({"since_days": days})
|
|
158
|
+
return {"ok": True, "status": "queued", "since": "2026-06-14", "job_id": "job-1"}
|
|
159
|
+
return _fake
|
|
160
|
+
|
|
161
|
+
|
|
153
162
|
class _FakeClient:
|
|
154
163
|
def __init__(self, record=_UNSET, resp=_UNSET):
|
|
155
164
|
self._record = {"username": "nouman", "accessToken": "gho_x"} if record is _UNSET else record
|
|
@@ -258,7 +267,23 @@ class TestState:
|
|
|
258
267
|
|
|
259
268
|
|
|
260
269
|
class TestStart:
|
|
261
|
-
def
|
|
270
|
+
def test_daily_loop_is_on_by_default(self):
|
|
271
|
+
"""Steady state: 2-hourly VPS health + this daily loop, no config needed."""
|
|
272
|
+
assert gi.daily_enabled() is True
|
|
273
|
+
|
|
274
|
+
async def go():
|
|
275
|
+
task = gi.start()
|
|
276
|
+
assert task is not None
|
|
277
|
+
task.cancel()
|
|
278
|
+
try:
|
|
279
|
+
await task
|
|
280
|
+
except asyncio.CancelledError:
|
|
281
|
+
pass
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
assert asyncio.run(go()) is True
|
|
285
|
+
|
|
286
|
+
def test_master_switch_stops_everything(self, monkeypatch):
|
|
262
287
|
monkeypatch.setenv("AGENTOS_GITHUB_INGEST", "0")
|
|
263
288
|
|
|
264
289
|
async def go():
|
|
@@ -266,7 +291,16 @@ class TestStart:
|
|
|
266
291
|
|
|
267
292
|
assert asyncio.run(go()) is None
|
|
268
293
|
|
|
269
|
-
def
|
|
294
|
+
def test_daily_loop_can_be_opted_out(self, monkeypatch):
|
|
295
|
+
monkeypatch.setenv("AGENTOS_GITHUB_INGEST_DAILY", "0")
|
|
296
|
+
|
|
297
|
+
async def go():
|
|
298
|
+
return gi.start()
|
|
299
|
+
|
|
300
|
+
assert asyncio.run(go()) is None
|
|
301
|
+
assert gi.daily_enabled() is False
|
|
302
|
+
|
|
303
|
+
def test_loop_starts_and_cancels_cleanly(self):
|
|
270
304
|
async def go():
|
|
271
305
|
task = gi.start()
|
|
272
306
|
assert task is not None
|
|
@@ -280,6 +314,41 @@ class TestStart:
|
|
|
280
314
|
assert asyncio.run(go()) is True
|
|
281
315
|
|
|
282
316
|
|
|
317
|
+
class TestRunBackfillOnce:
|
|
318
|
+
"""What `agentos-auto update` actually calls."""
|
|
319
|
+
|
|
320
|
+
def test_first_call_queues_50_days(self, monkeypatch):
|
|
321
|
+
client = _FakeClient()
|
|
322
|
+
monkeypatch.setattr(gi, "run_once", _sync_stub(client))
|
|
323
|
+
out = gi.run_backfill_once(client)
|
|
324
|
+
assert out["ran"] is True and out["status"] == "queued"
|
|
325
|
+
assert client.calls[0]["since_days"] == 50
|
|
326
|
+
|
|
327
|
+
def test_second_call_is_a_noop(self):
|
|
328
|
+
gi.save_state({"backfill_id": gi.BACKFILL_ID, "last_job_id": "job-1"})
|
|
329
|
+
out = gi.run_backfill_once(_FakeClient())
|
|
330
|
+
assert out["ran"] is False and out["status"] == "already_done"
|
|
331
|
+
|
|
332
|
+
def test_respects_the_master_switch(self, monkeypatch):
|
|
333
|
+
monkeypatch.setenv("AGENTOS_GITHUB_INGEST", "0")
|
|
334
|
+
out = gi.run_backfill_once(_FakeClient())
|
|
335
|
+
assert out["ran"] is False and out["status"] == "disabled"
|
|
336
|
+
|
|
337
|
+
def test_real_call_marks_it_done(self):
|
|
338
|
+
"""End to end through run_once: queued, then the marker blocks a re-run."""
|
|
339
|
+
out = gi.run_backfill_once(_FakeClient())
|
|
340
|
+
assert out["ran"] is True and out["status"] == "queued"
|
|
341
|
+
assert gi.run_backfill_once(_FakeClient())["status"] == "already_done"
|
|
342
|
+
|
|
343
|
+
def test_never_raises_into_the_update(self, monkeypatch):
|
|
344
|
+
def boom(*a, **k):
|
|
345
|
+
raise RuntimeError("kaboom")
|
|
346
|
+
|
|
347
|
+
monkeypatch.setattr(gi, "run_once", boom)
|
|
348
|
+
out = gi.run_backfill_once(_FakeClient())
|
|
349
|
+
assert out["ran"] is True and out["status"] == "failed"
|
|
350
|
+
|
|
351
|
+
|
|
283
352
|
# ── Routes ──────────────────────────────────────────────────────────────────
|
|
284
353
|
|
|
285
354
|
fastapi = pytest.importorskip("fastapi")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|