devops-bot-sdk 1.6.19__tar.gz → 1.6.24__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 (152) hide show
  1. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/devops_bot_sdk.egg-info/SOURCES.txt +16 -0
  4. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agentd.py +36 -1
  6. devops_bot_sdk-1.6.24/sdk/agents/activity.py +250 -0
  7. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/run_log.py +12 -1
  8. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/cli.py +19 -1
  9. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/client.py +122 -7
  10. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/config.py +11 -0
  11. devops_bot_sdk-1.6.24/sdk/graph_recon.py +330 -0
  12. devops_bot_sdk-1.6.24/sdk/graphify.py +475 -0
  13. devops_bot_sdk-1.6.24/sdk/intake.py +236 -0
  14. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ipc/handlers.py +605 -133
  15. devops_bot_sdk-1.6.24/sdk/phase_timer.py +192 -0
  16. devops_bot_sdk-1.6.24/sdk/plan_run.py +287 -0
  17. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/run_auto.py +165 -5
  18. devops_bot_sdk-1.6.24/sdk/task_file.py +221 -0
  19. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/api.py +273 -34
  20. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/models.py +65 -0
  21. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/static/app.js +496 -1
  22. devops_bot_sdk-1.6.24/sdk/ui/static/chat.html +154 -0
  23. devops_bot_sdk-1.6.24/sdk/ui/static/runs.html +79 -0
  24. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/static/styles.css +92 -0
  25. devops_bot_sdk-1.6.24/tests/test_agent_activity_tracking.py +449 -0
  26. devops_bot_sdk-1.6.24/tests/test_epic_decompose_run.py +173 -0
  27. devops_bot_sdk-1.6.24/tests/test_epic_session_isolation.py +100 -0
  28. devops_bot_sdk-1.6.24/tests/test_graph_recon.py +245 -0
  29. devops_bot_sdk-1.6.24/tests/test_graphify_build.py +274 -0
  30. devops_bot_sdk-1.6.24/tests/test_graphify_hook_install.py +187 -0
  31. devops_bot_sdk-1.6.24/tests/test_phase_timer.py +139 -0
  32. devops_bot_sdk-1.6.24/tests/test_recon_session_isolation.py +122 -0
  33. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_run_auto_agent_only.py +9 -0
  34. devops_bot_sdk-1.6.24/tests/test_task_file_and_plan_run.py +392 -0
  35. devops_bot_sdk-1.6.24/tests/test_ticket_speed_fixes.py +318 -0
  36. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_update_timer.py +45 -1
  37. devops_bot_sdk-1.6.19/sdk/agents/activity.py +0 -117
  38. devops_bot_sdk-1.6.19/sdk/graphify.py +0 -172
  39. devops_bot_sdk-1.6.19/sdk/ui/static/runs.html +0 -42
  40. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/README.md +0 -0
  41. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  42. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  43. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/devops_bot_sdk.egg-info/requires.txt +0 -0
  44. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  45. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/pyproject.toml +0 -0
  46. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/__init__.py +0 -0
  47. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/builder.py +0 -0
  48. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/chat.py +0 -0
  49. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/connectors.py +0 -0
  50. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/engines/__init__.py +0 -0
  51. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/engines/claude_code.py +0 -0
  52. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/engines/http.py +0 -0
  53. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/engines/mcp.py +0 -0
  54. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/engines/n8n.py +0 -0
  55. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/engines/shell.py +0 -0
  56. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/integrations.py +0 -0
  57. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/inventory.py +0 -0
  58. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/library/__init__.py +0 -0
  59. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/manifest.py +0 -0
  60. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/prompt_studio.py +0 -0
  61. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/prompt_template.py +0 -0
  62. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/prompt_vars.py +0 -0
  63. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/schedules.py +0 -0
  64. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/secrets.py +0 -0
  65. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/ticket_run.py +0 -0
  66. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/usage.py +0 -0
  67. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/agents/varstore.py +0 -0
  68. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/collectors/__init__.py +0 -0
  69. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/collectors/files.py +0 -0
  70. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/collectors/process.py +0 -0
  71. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/collectors/screenshot.py +0 -0
  72. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/crucial.py +0 -0
  73. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/departments.py +0 -0
  74. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/design_verify/__init__.py +0 -0
  75. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/design_verify/assets.py +0 -0
  76. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/design_verify/bootstrap.py +0 -0
  77. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/design_verify/browser.py +0 -0
  78. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/design_verify/compare.py +0 -0
  79. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/design_verify/loop.py +0 -0
  80. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/exceptions.py +0 -0
  81. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/formats.py +0 -0
  82. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/git_ops.py +0 -0
  83. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/github_ingest.py +0 -0
  84. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/hooks/__init__.py +0 -0
  85. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/hooks/crucial_guard.py +0 -0
  86. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ipc/__init__.py +0 -0
  87. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ipc/electron_bridge.py +0 -0
  88. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/local_exec.py +0 -0
  89. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/models/__init__.py +0 -0
  90. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/models/envelope.py +0 -0
  91. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/models/requests.py +0 -0
  92. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/models/responses.py +0 -0
  93. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/models/snapshots.py +0 -0
  94. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/platform_compat.py +0 -0
  95. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/py.typed +0 -0
  96. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/scheduler.py +0 -0
  97. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/sse.py +0 -0
  98. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/test.py +0 -0
  99. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/test_pipeline.py +0 -0
  100. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ticket_fields.py +0 -0
  101. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/__init__.py +0 -0
  102. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/dashboard.py +0 -0
  103. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/health.py +0 -0
  104. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/ip_allowlist.py +0 -0
  105. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/jobs.py +0 -0
  106. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/runner.py +0 -0
  107. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/security.py +0 -0
  108. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/server.py +0 -0
  109. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/service.py +0 -0
  110. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/session.py +0 -0
  111. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/setup_manager.py +0 -0
  112. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/static/agents.html +0 -0
  113. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/static/custom-agents.html +0 -0
  114. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/static/default-agents.html +0 -0
  115. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/static/index.html +0 -0
  116. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/static/login.html +0 -0
  117. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/static/schedules.html +0 -0
  118. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/static/setup.html +0 -0
  119. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/ui/static/tickets.html +0 -0
  120. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/updater.py +0 -0
  121. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/sdk/vps_health.py +0 -0
  122. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/setup.cfg +0 -0
  123. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_agent_activity.py +0 -0
  124. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_agent_chat_features.py +0 -0
  125. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_agent_listing.py +0 -0
  126. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_agent_prompt_async.py +0 -0
  127. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_agent_prompt_models.py +0 -0
  128. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_agent_run_ticket.py +0 -0
  129. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_agent_schedules.py +0 -0
  130. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_agent_workspace_settings.py +0 -0
  131. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_agents_connectors_builder.py +0 -0
  132. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_allowed_tools_config.py +0 -0
  133. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_auto_agent_dispatch.py +0 -0
  134. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_client_jira_projects.py +0 -0
  135. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_config_token_diagnostics.py +0 -0
  136. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_configure_token_owner.py +0 -0
  137. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_console_encoding.py +0 -0
  138. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_departments.py +0 -0
  139. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_epic_inherited_agent_id.py +0 -0
  140. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_formats.py +0 -0
  141. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_git_action.py +0 -0
  142. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_github_ingest.py +0 -0
  143. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_health.py +0 -0
  144. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_otp_login.py +0 -0
  145. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_run_outputs.py +0 -0
  146. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_schedule_api.py +0 -0
  147. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_scheduler.py +0 -0
  148. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_service_token_warning.py +0 -0
  149. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_ticket_fields.py +0 -0
  150. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_ticket_run_resolve_by_id.py +0 -0
  151. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/tests/test_updater_extras.py +0 -0
  152. {devops_bot_sdk-1.6.19 → devops_bot_sdk-1.6.24}/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.19
3
+ Version: 1.6.24
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.19
3
+ Version: 1.6.24
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
@@ -17,13 +17,18 @@ sdk/exceptions.py
17
17
  sdk/formats.py
18
18
  sdk/git_ops.py
19
19
  sdk/github_ingest.py
20
+ sdk/graph_recon.py
20
21
  sdk/graphify.py
22
+ sdk/intake.py
21
23
  sdk/local_exec.py
24
+ sdk/phase_timer.py
25
+ sdk/plan_run.py
22
26
  sdk/platform_compat.py
23
27
  sdk/py.typed
24
28
  sdk/run_auto.py
25
29
  sdk/scheduler.py
26
30
  sdk/sse.py
31
+ sdk/task_file.py
27
32
  sdk/test.py
28
33
  sdk/test_pipeline.py
29
34
  sdk/ticket_fields.py
@@ -88,6 +93,7 @@ sdk/ui/session.py
88
93
  sdk/ui/setup_manager.py
89
94
  sdk/ui/static/agents.html
90
95
  sdk/ui/static/app.js
96
+ sdk/ui/static/chat.html
91
97
  sdk/ui/static/custom-agents.html
92
98
  sdk/ui/static/default-agents.html
93
99
  sdk/ui/static/index.html
@@ -98,6 +104,7 @@ sdk/ui/static/setup.html
98
104
  sdk/ui/static/styles.css
99
105
  sdk/ui/static/tickets.html
100
106
  tests/test_agent_activity.py
107
+ tests/test_agent_activity_tracking.py
101
108
  tests/test_agent_chat_features.py
102
109
  tests/test_agent_listing.py
103
110
  tests/test_agent_prompt_async.py
@@ -113,19 +120,28 @@ tests/test_config_token_diagnostics.py
113
120
  tests/test_configure_token_owner.py
114
121
  tests/test_console_encoding.py
115
122
  tests/test_departments.py
123
+ tests/test_epic_decompose_run.py
116
124
  tests/test_epic_inherited_agent_id.py
125
+ tests/test_epic_session_isolation.py
117
126
  tests/test_formats.py
118
127
  tests/test_git_action.py
119
128
  tests/test_github_ingest.py
129
+ tests/test_graph_recon.py
130
+ tests/test_graphify_build.py
131
+ tests/test_graphify_hook_install.py
120
132
  tests/test_health.py
121
133
  tests/test_otp_login.py
134
+ tests/test_phase_timer.py
135
+ tests/test_recon_session_isolation.py
122
136
  tests/test_run_auto_agent_only.py
123
137
  tests/test_run_outputs.py
124
138
  tests/test_schedule_api.py
125
139
  tests/test_scheduler.py
126
140
  tests/test_service_token_warning.py
141
+ tests/test_task_file_and_plan_run.py
127
142
  tests/test_ticket_fields.py
128
143
  tests/test_ticket_run_resolve_by_id.py
144
+ tests/test_ticket_speed_fixes.py
129
145
  tests/test_update_timer.py
130
146
  tests/test_updater_extras.py
131
147
  tests/test_vps_health.py
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.6.19
3
+ Version: 1.6.24
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -34,7 +34,7 @@ Rules:
34
34
  - All data egress through submit_webhook only
35
35
  """
36
36
 
37
- __version__ = "1.6.19" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
37
+ __version__ = "1.6.24" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
38
38
  __author__ = "AgentOS" # sdk.client.SDK_VERSION and the UI server version all read this.
39
39
 
40
40
  from sdk.client import BackendClient
@@ -114,6 +114,22 @@ def _stop_requested() -> bool:
114
114
  return _STOP_FILE.exists()
115
115
 
116
116
 
117
+ def _auto_loop_owns_queue() -> bool:
118
+ """True when an `agentos-auto` loop is live and processing the ticket queue.
119
+
120
+ Reuses run_auto's own liveness check, which already distinguishes a running
121
+ daemon from a stale PID file left by a crash. Best-effort: if we cannot tell,
122
+ say False — refusing to dispatch on an unreadable PID file would silently
123
+ disable ticket-label agents, which is worse than the overlap we are avoiding.
124
+ """
125
+ try:
126
+ from sdk import run_auto
127
+
128
+ return run_auto._daemon_running() is not None
129
+ except Exception: # noqa: BLE001
130
+ return False
131
+
132
+
117
133
  def _set_stop() -> None:
118
134
  _AGENTOS_DIR.mkdir(parents=True, exist_ok=True)
119
135
  _STOP_FILE.write_text("stop")
@@ -585,11 +601,30 @@ class AgentDaemon:
585
601
  `_TICKET_POLL_INTERVAL_S` between iterations. Keeps an in-memory set
586
602
  of already-dispatched task ids so a task is never re-fired on a later
587
603
  poll within this daemon's lifetime.
604
+
605
+ DEFERS while an `agentos-auto` loop owns the queue. That loop processes
606
+ tickets strictly sequentially, oldest `createdAt` first; dispatching from
607
+ here concurrently (`asyncio.create_task` below) would run a ticket
608
+ alongside it and out of order — two agents editing the same working
609
+ directory at once, and a queue that no longer honours creation time.
610
+ Deferring costs at most one poll interval: the ticket stays To Do and is
611
+ picked up on a later tick, or by the auto-loop itself.
588
612
  """
589
613
  dispatched_task_ids: set[str] = set()
590
614
  ticket_agents = [m for m in self._manifests if m.trigger.type == "ticket_label"]
615
+ deferring = False
591
616
 
592
617
  while not _stop_requested():
618
+ if ticket_agents and _auto_loop_owns_queue():
619
+ if not deferring:
620
+ logger.info("agentd.watch_tickets_deferred reason=auto_loop_running")
621
+ deferring = True
622
+ await asyncio.sleep(_TICKET_POLL_INTERVAL_S)
623
+ continue
624
+ if deferring:
625
+ logger.info("agentd.watch_tickets_resumed")
626
+ deferring = False
627
+
593
628
  if ticket_agents:
594
629
  try:
595
630
  tasks = await self.client.list_todo_tasks_raw()
@@ -611,7 +646,7 @@ class AgentDaemon:
611
646
 
612
647
  await asyncio.sleep(_TICKET_POLL_INTERVAL_S)
613
648
 
614
- async def run_forever(self) -> None:
649
+ async def run_forever(self) -> None: # noqa: D401 — see class docstring
615
650
  """Load + schedule agents, start the cron scheduler, then watch tickets
616
651
  until stopped. Shuts the scheduler down cleanly on the way out."""
617
652
  await self.load_and_schedule()
@@ -0,0 +1,250 @@
1
+ """Agent-activity reporting — the ONE place a custom / department agent run is
2
+ recorded, for both sinks:
3
+
4
+ * the LOCAL usage ledger (``sdk.agents.usage`` → ``~/.agentos/usage.jsonl``),
5
+ which backs ``GET /api/ui/agents/usage`` and, through it, the AI backend
6
+ report's "VPS Agent Logs" section, and
7
+ * the PLATFORM activity feed — ``POST {ML_GATEWAY}/ml-api/agent-activity``
8
+ with ``{activity, agentId}``, which the ML backend writes into the Postgres
9
+ ``agent_activities`` table (docs/DATABASE_SCHEMA.md #3: ``user_id``,
10
+ ``activity``, ``agent_id``) and the AI backend reads back RBAC-scoped for
11
+ reporting + chat.
12
+
13
+ Every VPS-side agent run goes through here no matter how it was triggered — the
14
+ control panel (chat / run / run-on-ticket), an ``agentd`` cron schedule, an
15
+ ``agentd`` ticket-label trigger, the CLI, or the desktop IPC bridge. Before this
16
+ module only the control-panel paths emitted, so scheduled and ticket-triggered
17
+ runs never reached ``agent_activities``.
18
+
19
+ ``agent_activities.agent_id`` is an FK onto ``agents.id``, so only agents that
20
+ exist in the BACKEND emit — ``BackendClient.resolve_agent_id`` returns None for
21
+ a local-only manifest and the emit is skipped (the local ledger still records
22
+ it). Both custom and department-default agents resolve, so both are tracked.
23
+
24
+ Best-effort throughout: nothing in here raises into a run.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ import os
30
+ from datetime import datetime, timezone
31
+ from typing import TYPE_CHECKING, Any
32
+
33
+ if TYPE_CHECKING: # pragma: no cover — typing only
34
+ from sdk.client import BackendClient
35
+
36
+ logger = logging.getLogger("sdk.agents.activity")
37
+
38
+ # How a run reached us → the verb that opens the activity line. Keep these short:
39
+ # the feed is rendered as one line per row in reports and in chat answers.
40
+ _VERB = {
41
+ "chat": "Chatted with",
42
+ "run": "Ran",
43
+ "run-ticket": "Ran",
44
+ "scheduled": "Ran (schedule)",
45
+ "ticket_label": "Ran (ticket trigger)",
46
+ "cli": "Ran (cli)",
47
+ }
48
+
49
+
50
+ def describe(name: str, kind: str, result: dict | None, duration_s: float = 0.0,
51
+ detail: str = "") -> str:
52
+ """The human-readable ``activity`` string stored in ``agent_activities``."""
53
+ res = result or {}
54
+ verb = _VERB.get(kind, "Ran")
55
+ status = "ok" if res.get("ok") else "error"
56
+ line = f"{verb} agent '{name}' — {status}"
57
+ if duration_s:
58
+ line += f" ({round(float(duration_s), 1)}s)"
59
+ err = str(res.get("error") or "").strip()
60
+ if status == "error" and err:
61
+ line += f": {err[:200]}"
62
+ if detail:
63
+ line += f" — {detail}"
64
+ return line
65
+
66
+
67
+ # ── Run-log lines → the activity event ────────────────────────────────────
68
+ # Every line `run_log.log_line` writes to a run's Markdown file is also noted
69
+ # here, so the activity event a run produces carries what the run actually DID
70
+ # rather than only that it finished. The buffer exists because the alternative —
71
+ # a webhook POST per line — is exactly the pattern that saturated the shared
72
+ # Postgres pool and led to `client.ActivityFlusher` (see sdk/client.py:100).
73
+ #
74
+ # Two sinks, matching that design:
75
+ # * a ticket-scoped `ActivityFlusher`, when one is active (the ticket loop sets
76
+ # it) — `record()` does no I/O and the flusher coalesces into one POST every
77
+ # 3-5 minutes;
78
+ # * otherwise this per-run buffer, drained by `record_run` into the ONE
79
+ # per-run event. So an agent run costs one POST no matter how chatty it is.
80
+ _LINE_BUF: dict[str, list[str]] = {}
81
+ _LINE_BUF_MAX_LINES = 40 # keep the tail — recent lines are the useful ones
82
+ _LINE_BUF_MAX_CHARS = 4000 # bounded like _ACTIVITY_FLUSH_MAX_CHARS
83
+
84
+
85
+ def note(bucket: str, text: str) -> None:
86
+ """Record one run-log line against ``bucket`` (a run's output folder name).
87
+
88
+ Never raises and never does I/O: this is called from `run_log.log_line`, on
89
+ the hot path of a streaming engine run.
90
+ """
91
+ line = str(text or "").strip()
92
+ if not line:
93
+ return
94
+ try:
95
+ from sdk.client import _ACTIVE_ACTIVITY_FLUSHER
96
+ flusher = _ACTIVE_ACTIVITY_FLUSHER.get()
97
+ except Exception: # noqa: BLE001
98
+ flusher = None
99
+ if flusher is not None:
100
+ flusher.record(line)
101
+ return
102
+ buf = _LINE_BUF.setdefault(str(bucket), [])
103
+ buf.append(line)
104
+ if len(buf) > _LINE_BUF_MAX_LINES:
105
+ del buf[: len(buf) - _LINE_BUF_MAX_LINES]
106
+
107
+
108
+ def drain(bucket: str) -> list[str]:
109
+ """Take and clear the lines noted for ``bucket``."""
110
+ return _LINE_BUF.pop(str(bucket), [])
111
+
112
+
113
+ def _lines_block(lines: list[str]) -> str:
114
+ """The recorded lines as one bounded block, newest-biased."""
115
+ if not lines:
116
+ return ""
117
+ out: list[str] = []
118
+ total = 0
119
+ for line in reversed(lines): # newest first, then re-reverse
120
+ if total + len(line) + 1 > _LINE_BUF_MAX_CHARS:
121
+ break
122
+ out.append(line)
123
+ total += len(line) + 1
124
+ return "\n".join(reversed(out))
125
+
126
+
127
+ def mirror_to_auto_log(name: str, kind: str, result: dict | None,
128
+ duration_s: float = 0.0, *, detail: str = "",
129
+ task_id: str = "") -> bool:
130
+ """Append a one-line summary of an agent run to ``~/.agentos/auto.log``.
131
+
132
+ `agentos-auto logs [-f]` tails that file and nothing else, so before this an
133
+ agent run — including a ticket-triggered one — was invisible there: it landed
134
+ in `agent_outputs/<agent>/*.md` and `usage.jsonl`, and an operator watching
135
+ the log saw the ticket loop go quiet with no explanation. Mirroring here
136
+ rather than at each trigger site is deliberate: every VPS-side run already
137
+ funnels through `record_run`, so one append covers the panel, the CLI, an
138
+ agentd cron schedule, an agentd ticket-label trigger and the IPC bridge.
139
+
140
+ A summary, not a transcript — the transcript stays in `agent_outputs/`, and
141
+ the pointer below is how you get from one to the other. Best-effort and
142
+ silent on failure: a log line must never break a run. Set
143
+ ``AGENTOS_NO_AGENT_LOG_MIRROR=1`` to turn it off.
144
+ """
145
+ if os.getenv("AGENTOS_NO_AGENT_LOG_MIRROR"):
146
+ return False
147
+ try:
148
+ from sdk.agents.run_log import agent_dir
149
+ from sdk.run_auto import _LOG_FILE
150
+ except Exception: # noqa: BLE001 — never break a run over a log line
151
+ return False
152
+
153
+ res = result or {}
154
+ stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
155
+ line = f" • [{stamp}] {describe(name, kind, res, duration_s, detail)}"
156
+ # agentd passes the ticket BOTH as `detail` ("task T1") and as `task_id`, so an
157
+ # unconditional suffix renders "… — task T1 · task T1".
158
+ if task_id and f"task {task_id}" not in line:
159
+ line += f" · task {task_id}"
160
+ try:
161
+ where = agent_dir(name)
162
+ except Exception: # noqa: BLE001
163
+ where = None
164
+ if where:
165
+ line += f"\n ↳ transcript: {where}"
166
+
167
+ try:
168
+ _LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
169
+ with _LOG_FILE.open("a", encoding="utf-8") as fh:
170
+ fh.write(line + "\n")
171
+ return True
172
+ except Exception as exc: # noqa: BLE001
173
+ logger.debug("auto.log mirror failed agent=%s err=%s", name, exc)
174
+ return False
175
+
176
+
177
+ async def emit(name: str, kind: str, result: dict | None, duration_s: float = 0.0,
178
+ *, client: "BackendClient | None" = None, detail: str = "",
179
+ task_id: str = "", lines: list[str] | None = None) -> bool:
180
+ """Emit ONE ``{activity, agentId[, taskId]}`` event for a finished agent run.
181
+
182
+ Returns True when the webhook accepted it, False otherwise (unknown agent,
183
+ no token, backend down) — never raises.
184
+
185
+ ``task_id`` attributes a ticket-triggered run to its ticket. A scheduled or
186
+ CLI run has no ticket and omits it.
187
+ """
188
+ try:
189
+ if client is None:
190
+ from sdk.client import BackendClient
191
+ client = BackendClient.from_config()
192
+ agent_id = await client.resolve_agent_id(name)
193
+ if not agent_id:
194
+ # Local-only agent → not tracked. This used to return in silence,
195
+ # which is why "the webhook never fires" had no diagnosable cause:
196
+ # the run succeeded, the ledger recorded it, and the only missing
197
+ # thing was a Postgres row nobody could tie back to a decision.
198
+ # `resolve_agent_id` logs WHY (unreachable listing vs genuinely
199
+ # absent); this says what it cost.
200
+ logger.warning(
201
+ "agent %r has no backend agentId — skipping the agent-activity "
202
+ "webhook, so this run will not appear in agent_activities. It is "
203
+ "still in the local ledger and in agent_outputs/.", name)
204
+ return False
205
+ activity_text = describe(name, kind, result, duration_s, detail)
206
+ block = _lines_block(lines or [])
207
+ if block:
208
+ activity_text += "\n" + block
209
+ return await client.log_agent_activity(activity_text, agent_id, task_id=task_id)
210
+ except Exception as exc: # noqa: BLE001 — activity emit must never break a run
211
+ logger.warning("activity emit failed agent=%s err=%s", name, exc)
212
+ return False
213
+
214
+
215
+ async def record_run(name: str, kind: str, result: dict | None, duration_s: float = 0.0,
216
+ *, client: "BackendClient | None" = None, detail: str = "",
217
+ task_id: str = "") -> None:
218
+ """Record a finished run to BOTH sinks: local ledger, then platform feed.
219
+
220
+ The ledger write happens first and independently of the webhook, so a
221
+ backend outage still leaves per-agent utilisation on the VPS (which is what
222
+ ``/api/ui/agents/usage`` and the report's "VPS Agent Logs" read).
223
+ """
224
+ res: dict[str, Any] = result or {}
225
+ try:
226
+ from sdk.agents import usage as usage_mod
227
+ usage_mod.record(
228
+ name,
229
+ kind=kind,
230
+ duration_s=duration_s,
231
+ input_tokens=res.get("input_tokens", 0) or 0,
232
+ output_tokens=res.get("output_tokens", 0) or 0,
233
+ cost_usd=res.get("cost_usd", 0.0) or 0.0,
234
+ ok=bool(res.get("ok")),
235
+ )
236
+ except Exception as exc: # noqa: BLE001 — ledger write must never break a run
237
+ logger.debug("activity ledger write failed agent=%s err=%s", name, exc)
238
+
239
+ mirror_to_auto_log(name, kind, res, duration_s, detail=detail, task_id=task_id)
240
+
241
+ # Whatever this run's log lines recorded travels with the ONE event, so the
242
+ # activity feed shows what the agent did, not just that it finished.
243
+ try:
244
+ from sdk.agents.run_log import _folder_name
245
+ lines = drain(_folder_name(name))
246
+ except Exception: # noqa: BLE001
247
+ lines = []
248
+
249
+ await emit(name, kind, res, duration_s, client=client, detail=detail,
250
+ task_id=task_id, lines=lines)
@@ -175,7 +175,13 @@ def start_run(agent_name: str, *, scope: str = "custom", engine: str = "",
175
175
 
176
176
 
177
177
  def log_line(path: Path | None, text: str) -> None:
178
- """Append a raw line to the activity block (best-effort, no-op if path is None)."""
178
+ """Append a raw line to the activity block (best-effort, no-op if path is None).
179
+
180
+ The line is also noted for the run's activity event (``activity.note``), so
181
+ what the run did reaches the platform feed and not merely the fact that it
182
+ finished. That is a buffer append with no I/O — a POST per line is what
183
+ saturated the shared Postgres pool and is why the coalescing exists.
184
+ """
179
185
  if not path:
180
186
  return
181
187
  try:
@@ -183,6 +189,11 @@ def log_line(path: Path | None, text: str) -> None:
183
189
  fh.write(f"[{_now()}] {text}\n")
184
190
  except Exception as exc: # noqa: BLE001
185
191
  logger.debug("run_log.line failed: %s", exc)
192
+ try:
193
+ from sdk.agents import activity
194
+ activity.note(path.parent.name, str(text))
195
+ except Exception as exc: # noqa: BLE001 — a feed note must never break a run
196
+ logger.debug("run_log.note failed: %s", exc)
186
197
 
187
198
 
188
199
  def log_event(path: Path | None, event: dict) -> None:
@@ -12,6 +12,14 @@ Command surface:
12
12
  Store (or replace) the co_<uuid>_<hex> backend token. See
13
13
  sdk.config.configure_cli.
14
14
 
15
+ agentos chat [--product-id ID] [--pv-id ID] [--no-autorun]
16
+ Describe work in plain language; the backend asks the follow-up
17
+ questions until it has a goal, scope, acceptance criteria,
18
+ constraints, owning department and priority, then builds the plan,
19
+ creates the ticket(s) and wakes the auto-loop. The new ticket still
20
+ runs in createdAt order behind anything already queued.
21
+ See sdk.intake.chat_cli.
22
+
15
23
  agentos agent new
16
24
  Interactive wizard (input() prompts) — name, description, engine
17
25
  (default "claude_code"), trigger type (schedule/ticket_label/manual/
@@ -86,6 +94,15 @@ def main() -> None:
86
94
  configure_cli(rotate="--rotate" in sys.argv)
87
95
  return
88
96
 
97
+ if noun == "chat":
98
+ from sdk.intake import IntakeError, chat_cli
99
+ try:
100
+ chat_cli(rest)
101
+ except IntakeError as exc:
102
+ print(f"error: {exc}")
103
+ sys.exit(1)
104
+ return
105
+
89
106
  if noun == "agent":
90
107
  _agent_command(rest)
91
108
  return
@@ -111,9 +128,10 @@ def main() -> None:
111
128
 
112
129
 
113
130
  def _print_usage() -> None:
114
- print("usage: agentos <configure|agent|integration|default-agents|agentd|github> ...")
131
+ print("usage: agentos <configure|chat|agent|integration|default-agents|agentd|github> ...")
115
132
  print()
116
133
  print(" agentos configure [--rotate]")
134
+ print(" agentos chat [--product-id ID] [--pv-id ID] [--no-autorun] # requirements → ticket")
117
135
  print(" agentos agent <list|run NAME|chat NAME MSG|logs NAME|enable NAME|disable NAME|rm NAME>")
118
136
  print(" agentos agent <set-key NAME KEY[=VALUE]…|keys NAME|rm-key NAME KEY…> # persistent API keys (custom agents)")
119
137
  print(" agentos integration <add PATH|test NAME|list|rm NAME>")
@@ -45,6 +45,11 @@ logger = logging.getLogger(__name__)
45
45
  # agents without a lookup on every run.
46
46
  _AGENT_ID_CACHE: dict[str, str] = {}
47
47
 
48
+ # GitHub access token, keyed by the co_ token that fetched it → (token, expires_at
49
+ # monotonic). Keyed per credential so two clients on one box never share a token.
50
+ _GITHUB_TOKEN_CACHE: dict[str, tuple[str, float]] = {}
51
+ _GITHUB_TOKEN_TTL_S = float(os.getenv("AGENTOS_GITHUB_TOKEN_TTL_S", "") or 1800.0)
52
+
48
53
  # ── Ticket status-change observers ────────────────────────────────────────
49
54
  # Best-effort, synchronous hooks fired when the client changes a ticket's backend
50
55
  # status (In Progress / Backlog / To Do). The CLI loop registers one to track
@@ -928,9 +933,25 @@ class BackendClient:
928
933
  endpoint lives on the ML backend (devopsbot-be), authorised by the same
929
934
  co_ token. Best-effort: returns None on any failure so a run without a
930
935
  connected GitHub account still proceeds (clone/PR steps just won't auth).
936
+
937
+ Memoized for _GITHUB_TOKEN_TTL_S. The auto-loop asks for this once per
938
+ ticket and again inside epic/story handling, so a long queue made the same
939
+ round-trip dozens of times for a value that does not change between
940
+ tickets. A TTL rather than a permanent cache so a token refreshed on the
941
+ backend is picked up without restarting the daemon; a failed fetch is not
942
+ cached, so a transient outage cannot pin None for the whole window.
931
943
  """
944
+ import time as _time
945
+
946
+ now = _time.monotonic()
947
+ cached = _GITHUB_TOKEN_CACHE.get(self._token)
948
+ if cached and now < cached[1]:
949
+ return cached[0]
932
950
  record = await self.github_token_record()
933
- return (record or {}).get("accessToken") or None
951
+ token = (record or {}).get("accessToken") or None
952
+ if token:
953
+ _GITHUB_TOKEN_CACHE[self._token] = (token, now + _GITHUB_TOKEN_TTL_S)
954
+ return token
934
955
 
935
956
  async def ingest_github_prs(
936
957
  self,
@@ -1435,23 +1456,79 @@ class BackendClient:
1435
1456
  Returns None when the name isn't a backend agent (e.g. a local-only manifest)
1436
1457
  — the caller then skips activity emit, so only backend agents are tracked.
1437
1458
  Cached per process (the id↔name mapping is stable within a session).
1459
+
1460
+ Two things this deliberately does NOT do:
1461
+
1462
+ * **Negative-cache a lookup that failed.** Both listings are best-effort, so
1463
+ a transient 5xx or timeout used to leave `rows` empty, be recorded as
1464
+ "not a backend agent", and disable activity reporting for that agent for
1465
+ the rest of the process — a daemon restart was the only cure, and nothing
1466
+ said why. A failed lookup now returns None WITHOUT caching, so the next
1467
+ run retries.
1468
+ * **Insist on byte-identical names.** The name comes from a local manifest
1469
+ or a caller argument while the row is authored in the web app, so casing
1470
+ and stray whitespace drift. Exact match wins; a casefolded match is
1471
+ accepted as a fallback and logged, because a silent near-miss here
1472
+ presents as "the webhook never fires" with no other symptom.
1438
1473
  """
1439
1474
  nm = (name or "").strip()
1440
1475
  if not nm:
1441
1476
  return None
1442
1477
  if nm in _AGENT_ID_CACHE:
1443
1478
  return _AGENT_ID_CACHE[nm] or None
1479
+
1480
+ # The ML gateway first: it owns the `agents` table that
1481
+ # `agent_activities.agent_id` points at, and answers both scopes in one
1482
+ # call. The AI backend's two per-scope endpoints are the fallback.
1444
1483
  rows: list[dict] = []
1445
- for fetch in (self.list_custom_agents, self.list_department_agents):
1446
- try:
1447
- rows += await fetch()
1448
- except Exception: # noqa: BLE001 — best-effort across both sources
1449
- pass
1484
+ failures = 0
1485
+ try:
1486
+ rows = await self.list_gateway_agents()
1487
+ except Exception as exc: # noqa: BLE001
1488
+ logger.warning("client.resolve_agent_id gateway listing failed err=%s", exc)
1489
+ if not rows:
1490
+ for fetch in (self.list_custom_agents, self.list_department_agents):
1491
+ try:
1492
+ rows += await fetch()
1493
+ except Exception as exc: # noqa: BLE001 — best-effort across sources
1494
+ failures += 1
1495
+ logger.warning("client.resolve_agent_id listing failed source=%s err=%s",
1496
+ getattr(fetch, "__name__", "?"), exc)
1497
+ if not rows and not failures:
1498
+ # Both fallbacks answered, both empty: nothing is visible to this
1499
+ # token at all, which is not the same as "this name is absent".
1500
+ failures = 1
1501
+
1450
1502
  for r in rows:
1451
1503
  if r.get("name") == nm and r.get("id"):
1452
1504
  _AGENT_ID_CACHE[nm] = str(r["id"])
1453
1505
  return _AGENT_ID_CACHE[nm]
1454
- _AGENT_ID_CACHE[nm] = "" # negative-cache: not a backend agent
1506
+
1507
+ folded = nm.casefold()
1508
+ for r in rows:
1509
+ if str(r.get("name") or "").strip().casefold() == folded and r.get("id"):
1510
+ logger.warning(
1511
+ "client.resolve_agent_id matched %r to backend agent %r by "
1512
+ "casefolded name — align the names to make this exact",
1513
+ nm, r.get("name"),
1514
+ )
1515
+ _AGENT_ID_CACHE[nm] = str(r["id"])
1516
+ return _AGENT_ID_CACHE[nm]
1517
+
1518
+ if failures and not rows:
1519
+ # Could not see the catalogue at all — "absent" is not established.
1520
+ logger.warning(
1521
+ "client.resolve_agent_id could not reach any agent listing; not "
1522
+ "caching a negative for %r (activity will retry next run)", nm)
1523
+ return None
1524
+
1525
+ logger.warning(
1526
+ "client.resolve_agent_id: %r is not a backend agent for this token "
1527
+ "(%d visible: %s) — activity will NOT be recorded in agent_activities",
1528
+ nm, len(rows),
1529
+ ", ".join(sorted(str(r.get("name")) for r in rows if r.get("name")))[:300],
1530
+ )
1531
+ _AGENT_ID_CACHE[nm] = "" # negative-cache: genuinely not a backend agent
1455
1532
  return None
1456
1533
 
1457
1534
  async def report_vps_allocation(self, payload: dict) -> bool:
@@ -1687,6 +1764,44 @@ class BackendClient:
1687
1764
  _check_status(resp.status_code, self._base_url)
1688
1765
  return resp.json().get("agents", [])
1689
1766
 
1767
+ async def list_gateway_agents(self) -> list[dict]:
1768
+ """GET ``{ML_GATEWAY_URL}/ml-api/agents`` — every agent this token can see.
1769
+
1770
+ One call returns BOTH scopes: ``data.personal`` (the caller's own custom
1771
+ agents) and ``data.department`` (their department's defaults). Rows carry
1772
+ the same ``id``/``name`` shape as the AI backend's two per-scope endpoints.
1773
+
1774
+ This is the authoritative list for activity reporting, and the reason it is
1775
+ preferred over ``list_custom_agents`` + ``list_department_agents``:
1776
+ ``agent_activities.agent_id`` is a foreign key onto ``agents.id`` in THIS
1777
+ service's Postgres, and this endpoint reads that same table. The AI
1778
+ backend's listings are a re-projection of it — equivalent today (verified:
1779
+ identical ids for all six agents) but able to drift, and they cost two
1780
+ round-trips plus a dependency on a second service being up.
1781
+
1782
+ Best-effort: returns [] rather than raising, so a caller can fall back.
1783
+ """
1784
+ from sdk.config import ML_GATEWAY_URL
1785
+
1786
+ url = f"{ML_GATEWAY_URL.rstrip('/')}/ml-api/agents"
1787
+ headers = {**self._headers, "accept": "*/*",
1788
+ "developer-name": "null", "product": ""}
1789
+ async with httpx.AsyncClient(timeout=15.0) as client:
1790
+ resp = await client.get(url, headers=headers)
1791
+ if resp.status_code >= 400:
1792
+ logger.warning("client.list_gateway_agents failed status=%s body=%s",
1793
+ resp.status_code, resp.text[:200])
1794
+ return []
1795
+ data = (resp.json() or {}).get("data") or {}
1796
+ if not isinstance(data, dict):
1797
+ return []
1798
+ rows: list[dict] = []
1799
+ for scope in ("personal", "department"):
1800
+ got = data.get(scope)
1801
+ if isinstance(got, list):
1802
+ rows += [r for r in got if isinstance(r, dict)]
1803
+ return rows
1804
+
1690
1805
  async def list_product_agents(self, product_id: str) -> dict:
1691
1806
  """GET /api/v1/product/agents?product_id=… — a product's custom agents.
1692
1807
 
@@ -151,6 +151,17 @@ def save(base_url: str, token: str) -> None:
151
151
 
152
152
 
153
153
  def get_base_url() -> str:
154
+ """The AI backend this machine talks to.
155
+
156
+ ``AGENTOS_AI_URL`` overrides the stored value, so a local backend can be
157
+ pointed at for a test run without rewriting config.toml — which is the state
158
+ you do not want to leave a real box in. Same precedence as the other
159
+ overrides here: env, then ``~/.agentos/.env``, then stored, then the default.
160
+ """
161
+ override = (os.environ.get("AGENTOS_AI_URL", "")
162
+ or _dotenv_value("AGENTOS_AI_URL")).strip()
163
+ if override:
164
+ return override.rstrip("/")
154
165
  return load().get("base_url", AI_DEFAULT_URL)
155
166
 
156
167