devops-bot-sdk 1.6.8__tar.gz → 1.6.14__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 (120) hide show
  1. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/devops_bot_sdk.egg-info/SOURCES.txt +9 -0
  4. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agentd.py +5 -1
  6. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/chat.py +38 -4
  7. devops_bot_sdk-1.6.14/sdk/agents/run_log.py +383 -0
  8. devops_bot_sdk-1.6.14/sdk/agents/schedules.py +322 -0
  9. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/ticket_run.py +38 -7
  10. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/cli.py +6 -0
  11. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/platform_compat.py +36 -0
  12. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/run_auto.py +7 -1
  13. devops_bot_sdk-1.6.14/sdk/scheduler.py +367 -0
  14. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/api.py +200 -0
  15. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/models.py +34 -0
  16. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/runner.py +18 -0
  17. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/server.py +28 -4
  18. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/service.py +112 -1
  19. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/static/app.js +245 -0
  20. devops_bot_sdk-1.6.14/sdk/ui/static/schedules.html +102 -0
  21. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/updater.py +32 -11
  22. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/tests/test_agent_run_ticket.py +64 -0
  23. devops_bot_sdk-1.6.14/tests/test_agent_schedules.py +201 -0
  24. devops_bot_sdk-1.6.14/tests/test_console_encoding.py +170 -0
  25. devops_bot_sdk-1.6.14/tests/test_run_outputs.py +190 -0
  26. devops_bot_sdk-1.6.14/tests/test_schedule_api.py +250 -0
  27. devops_bot_sdk-1.6.14/tests/test_scheduler.py +288 -0
  28. devops_bot_sdk-1.6.14/tests/test_update_timer.py +92 -0
  29. devops_bot_sdk-1.6.8/sdk/agents/run_log.py +0 -147
  30. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/README.md +0 -0
  31. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  32. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  33. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/devops_bot_sdk.egg-info/requires.txt +0 -0
  34. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  35. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/pyproject.toml +0 -0
  36. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/__init__.py +0 -0
  37. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/activity.py +0 -0
  38. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/builder.py +0 -0
  39. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/connectors.py +0 -0
  40. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/engines/__init__.py +0 -0
  41. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/engines/claude_code.py +0 -0
  42. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/engines/http.py +0 -0
  43. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/engines/mcp.py +0 -0
  44. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/engines/n8n.py +0 -0
  45. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/engines/shell.py +0 -0
  46. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/integrations.py +0 -0
  47. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/inventory.py +0 -0
  48. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/library/__init__.py +0 -0
  49. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/manifest.py +0 -0
  50. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/prompt_studio.py +0 -0
  51. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/prompt_template.py +0 -0
  52. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/prompt_vars.py +0 -0
  53. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/secrets.py +0 -0
  54. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/usage.py +0 -0
  55. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/agents/varstore.py +0 -0
  56. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/client.py +0 -0
  57. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/collectors/__init__.py +0 -0
  58. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/collectors/files.py +0 -0
  59. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/collectors/process.py +0 -0
  60. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/collectors/screenshot.py +0 -0
  61. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/config.py +0 -0
  62. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/crucial.py +0 -0
  63. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/design_verify/__init__.py +0 -0
  64. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/design_verify/assets.py +0 -0
  65. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/design_verify/bootstrap.py +0 -0
  66. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/design_verify/browser.py +0 -0
  67. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/design_verify/compare.py +0 -0
  68. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/design_verify/loop.py +0 -0
  69. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/exceptions.py +0 -0
  70. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/git_ops.py +0 -0
  71. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/github_ingest.py +0 -0
  72. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/graphify.py +0 -0
  73. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/hooks/__init__.py +0 -0
  74. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/hooks/crucial_guard.py +0 -0
  75. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ipc/__init__.py +0 -0
  76. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ipc/electron_bridge.py +0 -0
  77. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ipc/handlers.py +0 -0
  78. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/local_exec.py +0 -0
  79. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/models/__init__.py +0 -0
  80. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/models/envelope.py +0 -0
  81. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/models/requests.py +0 -0
  82. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/models/responses.py +0 -0
  83. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/models/snapshots.py +0 -0
  84. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/py.typed +0 -0
  85. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/sse.py +0 -0
  86. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/test.py +0 -0
  87. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/test_pipeline.py +0 -0
  88. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/__init__.py +0 -0
  89. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/dashboard.py +0 -0
  90. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/health.py +0 -0
  91. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/ip_allowlist.py +0 -0
  92. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/jobs.py +0 -0
  93. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/security.py +0 -0
  94. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/session.py +0 -0
  95. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/setup_manager.py +0 -0
  96. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/static/agents.html +0 -0
  97. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/static/custom-agents.html +0 -0
  98. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/static/default-agents.html +0 -0
  99. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/static/index.html +0 -0
  100. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/static/login.html +0 -0
  101. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/static/runs.html +0 -0
  102. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/static/setup.html +0 -0
  103. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/static/styles.css +0 -0
  104. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/ui/static/tickets.html +0 -0
  105. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/sdk/vps_health.py +0 -0
  106. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/setup.cfg +0 -0
  107. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/tests/test_agent_activity.py +0 -0
  108. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/tests/test_agent_chat_features.py +0 -0
  109. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/tests/test_agent_listing.py +0 -0
  110. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/tests/test_agent_prompt_async.py +0 -0
  111. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/tests/test_agent_prompt_models.py +0 -0
  112. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/tests/test_agent_workspace_settings.py +0 -0
  113. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/tests/test_agents_connectors_builder.py +0 -0
  114. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/tests/test_allowed_tools_config.py +0 -0
  115. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/tests/test_git_action.py +0 -0
  116. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/tests/test_github_ingest.py +0 -0
  117. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/tests/test_health.py +0 -0
  118. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/tests/test_otp_login.py +0 -0
  119. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/tests/test_updater_extras.py +0 -0
  120. {devops_bot_sdk-1.6.8 → devops_bot_sdk-1.6.14}/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.8
3
+ Version: 1.6.14
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.8
3
+ Version: 1.6.14
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
@@ -20,6 +20,7 @@ sdk/local_exec.py
20
20
  sdk/platform_compat.py
21
21
  sdk/py.typed
22
22
  sdk/run_auto.py
23
+ sdk/scheduler.py
23
24
  sdk/sse.py
24
25
  sdk/test.py
25
26
  sdk/test_pipeline.py
@@ -37,6 +38,7 @@ sdk/agents/prompt_studio.py
37
38
  sdk/agents/prompt_template.py
38
39
  sdk/agents/prompt_vars.py
39
40
  sdk/agents/run_log.py
41
+ sdk/agents/schedules.py
40
42
  sdk/agents/secrets.py
41
43
  sdk/agents/ticket_run.py
42
44
  sdk/agents/usage.py
@@ -88,6 +90,7 @@ sdk/ui/static/default-agents.html
88
90
  sdk/ui/static/index.html
89
91
  sdk/ui/static/login.html
90
92
  sdk/ui/static/runs.html
93
+ sdk/ui/static/schedules.html
91
94
  sdk/ui/static/setup.html
92
95
  sdk/ui/static/styles.css
93
96
  sdk/ui/static/tickets.html
@@ -97,12 +100,18 @@ tests/test_agent_listing.py
97
100
  tests/test_agent_prompt_async.py
98
101
  tests/test_agent_prompt_models.py
99
102
  tests/test_agent_run_ticket.py
103
+ tests/test_agent_schedules.py
100
104
  tests/test_agent_workspace_settings.py
101
105
  tests/test_agents_connectors_builder.py
102
106
  tests/test_allowed_tools_config.py
107
+ tests/test_console_encoding.py
103
108
  tests/test_git_action.py
104
109
  tests/test_github_ingest.py
105
110
  tests/test_health.py
106
111
  tests/test_otp_login.py
112
+ tests/test_run_outputs.py
113
+ tests/test_schedule_api.py
114
+ tests/test_scheduler.py
115
+ tests/test_update_timer.py
107
116
  tests/test_updater_extras.py
108
117
  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.8
3
+ Version: 1.6.14
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.8" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
33
+ __version__ = "1.6.14" # 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
@@ -669,10 +669,14 @@ def status() -> None:
669
669
 
670
670
 
671
671
  def main() -> None:
672
+ # Windows: stdout is cp1252 whenever it is not a console (pipe / redirected
673
+ # log), which cannot encode the glyphs we print. Must run before any output.
674
+ from sdk.platform_compat import ensure_macos_fork_safety, ensure_utf8_stdio
675
+ ensure_utf8_stdio()
676
+
672
677
  # macOS: keep the background daemon from aborting ("Python quit unexpectedly")
673
678
  # when it does networking after the double-fork. May re-exec once; must run
674
679
  # before any fork or objc-linked import.
675
- from sdk.platform_compat import ensure_macos_fork_safety
676
680
  ensure_macos_fork_safety()
677
681
 
678
682
  argv = sys.argv[1:]
@@ -41,6 +41,16 @@ def _tee_events(log_path, on_event: "EventHandler | None") -> "EventHandler":
41
41
  EventHandler = Callable[[dict], Awaitable[None]]
42
42
 
43
43
 
44
+ class AgentLookupUnavailable(LookupError):
45
+ """The agent could not be found, but at least one source could not be READ.
46
+
47
+ Distinct from a plain ``LookupError`` (which means the agent genuinely is not
48
+ there) because the two deserve opposite treatment unattended: a renamed or
49
+ deleted agent should stop its schedule, while a backend outage must not — three
50
+ flaky minutes would otherwise disable every schedule on the box.
51
+ """
52
+
53
+
44
54
  def _find_agent(name: str) -> AgentManifest | None:
45
55
  """A LOCAL manifest by name (``~/.agentos/agents``), or None."""
46
56
  return next((m for m in load_agents() if m.name == name), None)
@@ -71,6 +81,7 @@ async def resolve_agent(name: str, client=None) -> AgentManifest:
71
81
  client = BackendClient.from_config()
72
82
 
73
83
  reasons: list[str] = []
84
+ unreadable = False
74
85
  for label, attr in (("your agents", "list_custom_agents"),
75
86
  ("department defaults", "list_department_agents")):
76
87
  fetch = getattr(client, attr, None)
@@ -79,6 +90,7 @@ async def resolve_agent(name: str, client=None) -> AgentManifest:
79
90
  try:
80
91
  rows = await fetch() or []
81
92
  except Exception as exc: # noqa: BLE001 — a fetch failure is not "not found"
93
+ unreadable = True
82
94
  reasons.append(f"{label} could not be fetched ({str(exc) or type(exc).__name__})")
83
95
  continue
84
96
  for row in rows:
@@ -94,6 +106,11 @@ async def resolve_agent(name: str, client=None) -> AgentManifest:
94
106
  detail = "; ".join(reasons) if reasons else (
95
107
  "not in ~/.agentos/agents/, your backend agents, or your department defaults"
96
108
  )
109
+ if unreadable:
110
+ # Do NOT say "no agent named …" here: a source went unread, so absence was
111
+ # never established. Claiming it sends the user hunting for a typo when the
112
+ # fix is a token or a network.
113
+ raise AgentLookupUnavailable(f"'{name}' could not be looked up — {detail}")
97
114
  raise LookupError(f"no agent named '{name}' — {detail}")
98
115
 
99
116
 
@@ -196,6 +213,10 @@ async def run_agent(
196
213
  on_event: EventHandler | None = None,
197
214
  project_path: str | None = None,
198
215
  scope: str = "custom",
216
+ persist_vars: bool = True,
217
+ trigger: str = "",
218
+ kind: str = "run",
219
+ client=None,
199
220
  ) -> dict:
200
221
  """Run an agent once against its own standing prompt — NOT a chat turn.
201
222
 
@@ -206,11 +227,21 @@ async def run_agent(
206
227
  (custom agents) or the host environment (department agents), exactly as in
207
228
  :func:`chat_with_agent`.
208
229
 
230
+ ``persist_vars=False`` skips writing the values to the prefill store. A
231
+ SCHEDULED run must pass this: the store exists to remember what a HUMAN last
232
+ typed into the Run form, and a nightly cron would otherwise overwrite that
233
+ person's values every night with its own.
234
+
235
+ ``trigger`` / ``kind`` are recorded in the run's output file so a reader can
236
+ tell a cron firing from someone pressing Run. ``client`` lets a caller that
237
+ already has a :class:`BackendClient` reuse it instead of having one built per
238
+ run (see :func:`resolve_agent`).
239
+
209
240
  Returns the engine's normalized result dict. Raises ``ValueError`` if any
210
241
  ``{{variable}}`` is left unfilled — the UI gates the Run button on this, and
211
242
  this is the server-side backstop.
212
243
  """
213
- manifest = await resolve_agent(name)
244
+ manifest = await resolve_agent(name, client)
214
245
 
215
246
  skills = load_skills()
216
247
  skill_text = resolve_skill_text(manifest, skills)
@@ -233,11 +264,14 @@ async def run_agent(
233
264
 
234
265
  # Remember these values so the next run can PREFILL them ("show previous
235
266
  # ones"); the user can still change them every run. Not secret — see varstore.
236
- agent_varstore.save_vars(manifest.name, variables)
267
+ # Skipped for scheduled runs, which carry their own stored values and must not
268
+ # clobber what the human last typed.
269
+ if persist_vars:
270
+ agent_varstore.save_vars(manifest.name, variables)
237
271
 
238
272
  log_path = run_log.start_run(
239
- manifest.name, scope=scope, engine=manifest.engine, kind="run",
240
- variables=variables,
273
+ manifest.name, scope=scope, engine=manifest.engine, kind=kind,
274
+ variables=variables, trigger=trigger,
241
275
  )
242
276
  teed = _tee_events(log_path, on_event)
243
277
 
@@ -0,0 +1,383 @@
1
+ """Per-run output files for custom / default / common agent executions.
2
+
3
+ Every agent run — from the control panel, `agentos agent chat/run`, a schedule, or
4
+ the background daemon — writes one Markdown file recording what ran, what it did,
5
+ and what it said. Files live in a folder PER AGENT:
6
+
7
+ ~/.agentos/agent_outputs/<Agent Name>/2026-08-05 18-15-42 PKT — <Agent Name>.md
8
+
9
+ so "show me what this agent has been doing" is one directory listing. The folder is
10
+ browsable from the control panel (Schedules page) and by `agentos agent logs`.
11
+
12
+ Two naming choices worth stating, because both were deliberate:
13
+
14
+ * **Pakistan time in the filename.** Timestamps everywhere else in the SDK are UTC
15
+ (``sdk.agents.usage``, ``sdk.github_ingest``), and local time in a filename is
16
+ normally a trap because it breaks name-sorting across a DST change. PKT is a
17
+ fixed UTC+5 with no DST, so here name-sort still equals time-sort. The file body
18
+ additionally carries the UTC instant, so nothing downstream has to guess.
19
+ * **``agent_outputs``, not ``agent_runs``.** ``~/.agentos/runs/<agent>/`` already
20
+ exists and means something different — it is the working DIRECTORY the agent
21
+ executes in (see ``sdk.agents.engines.claude_code.ensure_agent_workspace``), and
22
+ holds whatever files the agent itself created. Keeping the two a rename apart
23
+ would have been a permanent source of confusion.
24
+
25
+ Everything here is best-effort: a logging failure must never break an agent run.
26
+ Legacy ``<scope>__<name>__<UTC>.log`` files in the old flat ``~/.agentos/logs/agents``
27
+ directory are still listed and read, so history from before this change is intact.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import logging
33
+ import os
34
+ import re
35
+ from datetime import datetime, timedelta, timezone
36
+ from pathlib import Path
37
+
38
+ logger = logging.getLogger("sdk.agents.run_log")
39
+
40
+ # Pakistan Standard Time. Fixed UTC+5, no DST — see the module docstring. The
41
+ # ZoneInfo lookup is preferred (it is the source of truth) but falls back to the
42
+ # fixed offset, because slim containers ship without tzdata and a missing timezone
43
+ # database must not cost us the run record. Same guard as sdk/local_exec.py:155.
44
+ _PKT_FALLBACK = timezone(timedelta(hours=5), "PKT")
45
+
46
+
47
+ def _pkt() -> timezone:
48
+ try:
49
+ from zoneinfo import ZoneInfo
50
+ return ZoneInfo("Asia/Karachi") # type: ignore[return-value]
51
+ except Exception: # noqa: BLE001
52
+ return _PKT_FALLBACK
53
+
54
+
55
+ def _outputs_root() -> Path:
56
+ """Root of the per-agent output folders (override with AGENTOS_AGENT_OUTPUT_DIR)."""
57
+ base = os.getenv("AGENTOS_AGENT_OUTPUT_DIR")
58
+ return Path(base) if base else Path.home() / ".agentos" / "agent_outputs"
59
+
60
+
61
+ def _legacy_dir() -> Path:
62
+ """The pre-per-agent flat directory. Read-only now, but never orphaned."""
63
+ base = os.getenv("AGENTOS_LOG_DIR") or str(Path.home() / ".agentos" / "logs")
64
+ return Path(base) / "agents"
65
+
66
+
67
+ def _slug(text: str) -> str:
68
+ return re.sub(r"[^A-Za-z0-9_.-]+", "-", (text or "agent").strip()) or "agent"
69
+
70
+
71
+ def _folder_name(agent_name: str) -> str:
72
+ """Folder name for an agent — the REAL name, minus only path-unsafe characters.
73
+
74
+ Readability matters here in a way it does not for the schedule/secret stores:
75
+ this folder is shown to the user and browsed by hand, so "AI Research Agent"
76
+ must not become "AI-Research-Agent-3f9c1a02". Only characters that could escape
77
+ the root or break a filesystem are replaced.
78
+ """
79
+ cleaned = re.sub(r'[\x00-\x1f/\\<>:"|?*]+', "-", (agent_name or "").strip())
80
+ cleaned = cleaned.strip(". ")
81
+ return cleaned or "agent"
82
+
83
+
84
+ def agent_dir(agent_name: str) -> Path:
85
+ """The output folder for one agent (created on demand)."""
86
+ return _outputs_root() / _folder_name(agent_name)
87
+
88
+
89
+ def _pkt_stamp(when: datetime | None = None) -> str:
90
+ """``2026-08-05 18-15-42 PKT`` — human-readable, filename-safe, sorts by time."""
91
+ dt = (when or datetime.now(timezone.utc)).astimezone(_pkt())
92
+ return dt.strftime("%Y-%m-%d %H-%M-%S PKT")
93
+
94
+
95
+ def _pkt_human(when: datetime | None = None) -> str:
96
+ """``2026-08-05 06:15:42 PM PKT`` — for reading inside the file."""
97
+ dt = (when or datetime.now(timezone.utc)).astimezone(_pkt())
98
+ return dt.strftime("%Y-%m-%d %I:%M:%S %p PKT")
99
+
100
+
101
+ def _now() -> str:
102
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
103
+
104
+
105
+ def _max_output_chars() -> int:
106
+ try:
107
+ return max(1000, int(os.getenv("AGENTOS_RUN_OUTPUT_MAX_CHARS", "") or 200_000))
108
+ except ValueError:
109
+ return 200_000
110
+
111
+
112
+ def _free_path(folder: Path, stamp: str, agent: str) -> Path:
113
+ """A path that no other run already owns.
114
+
115
+ The stamp has second resolution, so two runs of the same agent in the same
116
+ second — "Run now" firing next to a cron tick, or two quick manual runs — would
117
+ otherwise open the SAME file in append mode and interleave into one unreadable
118
+ record. A ``(2)`` suffix keeps them separate and still readable.
119
+ """
120
+ base = f"{stamp} — {agent}"
121
+ path = folder / f"{base}.md"
122
+ n = 2
123
+ while path.exists() and n < 1000:
124
+ path = folder / f"{base} ({n}).md"
125
+ n += 1
126
+ return path
127
+
128
+
129
+ def _fmt_duration(seconds: float) -> str:
130
+ seconds = max(0.0, float(seconds))
131
+ if seconds < 60:
132
+ return f"{seconds:.1f}s"
133
+ mins, secs = divmod(int(seconds), 60)
134
+ if mins < 60:
135
+ return f"{mins}m {secs}s"
136
+ hours, mins = divmod(mins, 60)
137
+ return f"{hours}h {mins}m {secs}s"
138
+
139
+
140
+ # ── Writing ─────────────────────────────────────────────────────────────────
141
+
142
+ def start_run(agent_name: str, *, scope: str = "custom", engine: str = "",
143
+ kind: str = "run", variables: dict | None = None,
144
+ trigger: str = "") -> Path | None:
145
+ """Open this run's output file and write its front matter. Returns the path.
146
+
147
+ ``scope`` is ``custom`` | ``default`` | ``common``; ``kind`` is
148
+ ``run`` | ``chat`` | ``ticket`` | ``scheduled``. Variable NAMES are recorded,
149
+ never their values — a value can be sensitive and this file is browsable.
150
+ ``trigger`` is free text describing what caused the run (e.g. the cron
151
+ expression), shown in the header.
152
+ """
153
+ try:
154
+ started = datetime.now(timezone.utc)
155
+ d = agent_dir(agent_name)
156
+ d.mkdir(parents=True, exist_ok=True)
157
+ path = _free_path(d, _pkt_stamp(started), _folder_name(agent_name))
158
+ with path.open("a", encoding="utf-8") as fh:
159
+ fh.write(f"# {agent_name} — {_pkt_human(started)}\n\n")
160
+ fh.write(f"- **Started:** {_pkt_human(started)} \n")
161
+ fh.write(f"- **Kind:** {kind} · **Scope:** {scope}"
162
+ + (f" · **Engine:** {engine}" if engine else "") + " \n")
163
+ if trigger:
164
+ fh.write(f"- **Trigger:** {trigger} \n")
165
+ if variables:
166
+ fh.write(f"- **Inputs:** {', '.join(sorted(variables.keys()))} \n")
167
+ # Machine-readable anchors, parsed back by finish_run. Kept as HTML
168
+ # comments so they stay out of the rendered Markdown.
169
+ fh.write(f"\n<!-- started_utc: {started.isoformat(timespec='seconds')} -->\n")
170
+ fh.write("\n## Activity\n\n```\n")
171
+ return path
172
+ except Exception as exc: # noqa: BLE001
173
+ logger.debug("run_log.start failed: %s", exc)
174
+ return None
175
+
176
+
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)."""
179
+ if not path:
180
+ return
181
+ try:
182
+ with path.open("a", encoding="utf-8") as fh:
183
+ fh.write(f"[{_now()}] {text}\n")
184
+ except Exception as exc: # noqa: BLE001
185
+ logger.debug("run_log.line failed: %s", exc)
186
+
187
+
188
+ def log_event(path: Path | None, event: dict) -> None:
189
+ """Record one streamed engine event (tool call / assistant text) compactly."""
190
+ if not path or not isinstance(event, dict):
191
+ return
192
+ try:
193
+ etype = event.get("type", "")
194
+ summary = ""
195
+ if etype == "assistant":
196
+ for c in event.get("message", {}).get("content", []) or []:
197
+ if c.get("type") == "tool_use":
198
+ summary = f"tool_use {c.get('name', '')}"
199
+ break
200
+ if c.get("type") == "text":
201
+ summary = "text: " + " ".join(str(c.get("text", "")).split())[:200]
202
+ break
203
+ elif etype == "result":
204
+ summary = "result"
205
+ else:
206
+ summary = json.dumps(event, ensure_ascii=False)[:200]
207
+ log_line(path, f"{etype}: {summary}" if summary else etype)
208
+ except Exception as exc: # noqa: BLE001
209
+ logger.debug("run_log.event failed: %s", exc)
210
+
211
+
212
+ _TOOL_RE = re.compile(r"tool_use (\S+)")
213
+ _STARTED_RE = re.compile(r"<!-- started_utc: (.+?) -->")
214
+
215
+
216
+ def _tool_counts(text: str) -> list[str]:
217
+ """``["WebSearch × 4", "Write × 1"]`` from the activity block, most-used first.
218
+
219
+ Recovered by re-reading the file rather than by holding counters in memory, so
220
+ a run whose process restarted still produces an honest summary and there is no
221
+ per-run state to leak.
222
+ """
223
+ counts: dict[str, int] = {}
224
+ for name in _TOOL_RE.findall(text):
225
+ counts[name] = counts.get(name, 0) + 1
226
+ return [f"{n} × {c}" if c > 1 else n
227
+ for n, c in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))]
228
+
229
+
230
+ def finish_run(path: Path | None, result: dict | None) -> None:
231
+ """Close the activity block, then write "What it did" and the full response."""
232
+ if not path:
233
+ return
234
+ result = result or {}
235
+ try:
236
+ try:
237
+ existing = path.read_text(encoding="utf-8", errors="replace")
238
+ except OSError:
239
+ existing = ""
240
+
241
+ started = None
242
+ m = _STARTED_RE.search(existing)
243
+ if m:
244
+ try:
245
+ started = datetime.fromisoformat(m.group(1))
246
+ except ValueError:
247
+ started = None
248
+ ended = datetime.now(timezone.utc)
249
+ duration = (ended - started).total_seconds() if started else 0.0
250
+
251
+ # `result` is only the LAST assistant message, so a long artifact followed
252
+ # by a short sign-off would be lost; local_exec keeps `longest_text` for
253
+ # exactly this. It is absent on timeout paths, hence the chained fallback.
254
+ body = (str(result.get("result") or "").strip()
255
+ or str(result.get("longest_text") or "").strip()
256
+ or str(result.get("error") or "").strip()
257
+ or "(no output)")
258
+ cap = _max_output_chars()
259
+ if len(body) > cap:
260
+ body = body[:cap] + f"\n\n… truncated {len(body) - cap} more characters"
261
+
262
+ tools = _tool_counts(existing)
263
+ files = [str(f) for f in (result.get("files_changed") or [])]
264
+
265
+ with path.open("a", encoding="utf-8") as fh:
266
+ fh.write("```\n\n")
267
+ fh.write("## What it did\n\n")
268
+ fh.write(f"- **Tools used:** {', '.join(tools) if tools else 'none'}\n")
269
+ if files:
270
+ fh.write(f"- **Files touched ({len(files)}):**\n")
271
+ for f in files[:50]:
272
+ fh.write(f" - `{f}`\n")
273
+ if len(files) > 50:
274
+ fh.write(f" - … and {len(files) - 50} more\n")
275
+ else:
276
+ fh.write("- **Files touched:** none\n")
277
+ fh.write(
278
+ f"- **Outcome:** {'ok' if result.get('ok') else 'FAILED'}"
279
+ f" · {_fmt_duration(duration)}"
280
+ f" · {result.get('input_tokens', 0):,} in / {result.get('output_tokens', 0):,} out"
281
+ f" · ${float(result.get('cost_usd', 0.0) or 0.0):.4f}\n"
282
+ )
283
+ if result.get("error"):
284
+ fh.write(f"- **Error:** {str(result.get('error'))[:2000]}\n")
285
+ fh.write(f"\n## Response\n\n{body}\n")
286
+ fh.write(f"\n---\n\n*Finished {_pkt_human(ended)} "
287
+ f"({ended.isoformat(timespec='seconds')}).*\n")
288
+ except Exception as exc: # noqa: BLE001
289
+ logger.debug("run_log.finish failed: %s", exc)
290
+
291
+
292
+ # ── Reading ─────────────────────────────────────────────────────────────────
293
+
294
+ def list_runs(agent_name: str | None = None, limit: int = 50) -> list[Path]:
295
+ """Run output paths, newest first. Spans the per-agent folders AND legacy logs."""
296
+ found: list[Path] = []
297
+
298
+ root = _outputs_root()
299
+ try:
300
+ if agent_name:
301
+ d = root / _folder_name(agent_name)
302
+ if d.is_dir():
303
+ found += [p for p in d.iterdir() if p.is_file() and p.suffix in (".md", ".log")]
304
+ elif root.is_dir():
305
+ for d in root.iterdir():
306
+ if d.is_dir():
307
+ found += [p for p in d.iterdir()
308
+ if p.is_file() and p.suffix in (".md", ".log")]
309
+ except OSError:
310
+ pass
311
+
312
+ # Pre-per-agent-folder history stays visible.
313
+ legacy = _legacy_dir()
314
+ try:
315
+ if legacy.is_dir():
316
+ pattern = f"*__{_slug(agent_name)}__*.log" if agent_name else "*.log"
317
+ found += list(legacy.glob(pattern))
318
+ except OSError:
319
+ pass
320
+
321
+ try:
322
+ found.sort(key=lambda p: p.stat().st_mtime, reverse=True)
323
+ except OSError:
324
+ return found[:limit]
325
+ return found[:limit]
326
+
327
+
328
+ def read_run(path: Path, max_chars: int = 20000) -> str:
329
+ try:
330
+ text = path.read_text(encoding="utf-8", errors="replace")
331
+ except Exception as exc: # noqa: BLE001
332
+ return f"(could not read {path}: {exc})"
333
+ return text[-max_chars:]
334
+
335
+
336
+ def resolve_run(agent_name: str, filename: str) -> Path | None:
337
+ """A named file inside one agent's folder, or None.
338
+
339
+ The ONLY way the API turns a caller-supplied filename into a path: the result
340
+ must be a real file that is a direct child of that agent's folder, so
341
+ ``../../.ssh/id_rsa`` and absolute paths resolve outside and are rejected.
342
+ """
343
+ try:
344
+ base = agent_dir(agent_name).resolve()
345
+ target = (base / filename).resolve()
346
+ except OSError:
347
+ return None
348
+ if target.parent != base or not target.is_file():
349
+ return None
350
+ return target
351
+
352
+
353
+ # ── Retention ───────────────────────────────────────────────────────────────
354
+
355
+ def prune(agent_name: str, keep: int = 100, max_age_days: int = 90) -> int:
356
+ """Delete this agent's oldest outputs; returns how many were removed.
357
+
358
+ BOTH bounds apply, because either alone leaks: a count-only rule lets a chatty
359
+ hourly agent grow without limit in bytes, and an age-only rule lets a rarely-run
360
+ agent keep nothing. Called from the scheduler tick rather than on write, so the
361
+ cost is amortised and a slow filesystem never delays a run.
362
+ """
363
+ d = agent_dir(agent_name)
364
+ if not d.is_dir():
365
+ return 0
366
+ try:
367
+ files = sorted((p for p in d.iterdir() if p.is_file() and p.suffix in (".md", ".log")),
368
+ key=lambda p: p.stat().st_mtime, reverse=True)
369
+ except OSError:
370
+ return 0
371
+
372
+ cutoff = datetime.now(timezone.utc).timestamp() - (max_age_days * 86400)
373
+ removed = 0
374
+ for i, path in enumerate(files):
375
+ try:
376
+ too_many = i >= keep
377
+ too_old = path.stat().st_mtime < cutoff
378
+ if too_many or too_old:
379
+ path.unlink()
380
+ removed += 1
381
+ except OSError:
382
+ continue
383
+ return removed