devops-bot-sdk 1.6.6__tar.gz → 1.6.10__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.6 → devops_bot_sdk-1.6.10}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/devops_bot_sdk.egg-info/SOURCES.txt +10 -0
  4. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/chat.py +35 -5
  6. devops_bot_sdk-1.6.10/sdk/agents/engines/claude_code.py +122 -0
  7. devops_bot_sdk-1.6.10/sdk/agents/run_log.py +383 -0
  8. devops_bot_sdk-1.6.10/sdk/agents/schedules.py +322 -0
  9. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/config.py +64 -6
  10. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/design_verify/loop.py +5 -4
  11. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/local_exec.py +23 -11
  12. devops_bot_sdk-1.6.10/sdk/scheduler.py +367 -0
  13. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/api.py +165 -0
  14. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/models.py +34 -0
  15. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/server.py +9 -3
  16. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/service.py +112 -1
  17. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/app.js +183 -0
  18. devops_bot_sdk-1.6.10/sdk/ui/static/schedules.html +58 -0
  19. devops_bot_sdk-1.6.10/tests/test_agent_schedules.py +201 -0
  20. devops_bot_sdk-1.6.10/tests/test_agent_workspace_settings.py +140 -0
  21. devops_bot_sdk-1.6.10/tests/test_allowed_tools_config.py +102 -0
  22. devops_bot_sdk-1.6.10/tests/test_run_outputs.py +190 -0
  23. devops_bot_sdk-1.6.10/tests/test_schedule_api.py +193 -0
  24. devops_bot_sdk-1.6.10/tests/test_scheduler.py +288 -0
  25. devops_bot_sdk-1.6.10/tests/test_update_timer.py +92 -0
  26. devops_bot_sdk-1.6.6/sdk/agents/engines/claude_code.py +0 -61
  27. devops_bot_sdk-1.6.6/sdk/agents/run_log.py +0 -147
  28. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/README.md +0 -0
  29. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  30. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  31. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/devops_bot_sdk.egg-info/requires.txt +0 -0
  32. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  33. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/pyproject.toml +0 -0
  34. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agentd.py +0 -0
  35. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/__init__.py +0 -0
  36. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/activity.py +0 -0
  37. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/builder.py +0 -0
  38. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/connectors.py +0 -0
  39. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/engines/__init__.py +0 -0
  40. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/engines/http.py +0 -0
  41. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/engines/mcp.py +0 -0
  42. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/engines/n8n.py +0 -0
  43. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/engines/shell.py +0 -0
  44. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/integrations.py +0 -0
  45. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/inventory.py +0 -0
  46. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/library/__init__.py +0 -0
  47. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/manifest.py +0 -0
  48. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/prompt_studio.py +0 -0
  49. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/prompt_template.py +0 -0
  50. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/prompt_vars.py +0 -0
  51. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/secrets.py +0 -0
  52. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/ticket_run.py +0 -0
  53. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/usage.py +0 -0
  54. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/varstore.py +0 -0
  55. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/cli.py +0 -0
  56. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/client.py +0 -0
  57. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/collectors/__init__.py +0 -0
  58. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/collectors/files.py +0 -0
  59. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/collectors/process.py +0 -0
  60. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/collectors/screenshot.py +0 -0
  61. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/crucial.py +0 -0
  62. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/design_verify/__init__.py +0 -0
  63. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/design_verify/assets.py +0 -0
  64. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/design_verify/bootstrap.py +0 -0
  65. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/design_verify/browser.py +0 -0
  66. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/design_verify/compare.py +0 -0
  67. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/exceptions.py +0 -0
  68. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/git_ops.py +0 -0
  69. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/github_ingest.py +0 -0
  70. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/graphify.py +0 -0
  71. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/hooks/__init__.py +0 -0
  72. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/hooks/crucial_guard.py +0 -0
  73. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ipc/__init__.py +0 -0
  74. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ipc/electron_bridge.py +0 -0
  75. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ipc/handlers.py +0 -0
  76. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/models/__init__.py +0 -0
  77. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/models/envelope.py +0 -0
  78. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/models/requests.py +0 -0
  79. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/models/responses.py +0 -0
  80. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/models/snapshots.py +0 -0
  81. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/platform_compat.py +0 -0
  82. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/py.typed +0 -0
  83. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/run_auto.py +0 -0
  84. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/sse.py +0 -0
  85. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/test.py +0 -0
  86. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/test_pipeline.py +0 -0
  87. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/__init__.py +0 -0
  88. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/dashboard.py +0 -0
  89. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/health.py +0 -0
  90. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/ip_allowlist.py +0 -0
  91. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/jobs.py +0 -0
  92. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/runner.py +0 -0
  93. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/security.py +0 -0
  94. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/session.py +0 -0
  95. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/setup_manager.py +0 -0
  96. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/agents.html +0 -0
  97. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/custom-agents.html +0 -0
  98. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/default-agents.html +0 -0
  99. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/index.html +0 -0
  100. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/login.html +0 -0
  101. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/runs.html +0 -0
  102. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/setup.html +0 -0
  103. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/styles.css +0 -0
  104. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/tickets.html +0 -0
  105. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/updater.py +0 -0
  106. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/vps_health.py +0 -0
  107. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/setup.cfg +0 -0
  108. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_agent_activity.py +0 -0
  109. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_agent_chat_features.py +0 -0
  110. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_agent_listing.py +0 -0
  111. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_agent_prompt_async.py +0 -0
  112. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_agent_prompt_models.py +0 -0
  113. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_agent_run_ticket.py +0 -0
  114. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_agents_connectors_builder.py +0 -0
  115. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_git_action.py +0 -0
  116. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_github_ingest.py +0 -0
  117. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_health.py +0 -0
  118. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_otp_login.py +0 -0
  119. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_updater_extras.py +0 -0
  120. {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/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.6
3
+ Version: 1.6.10
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.6
3
+ Version: 1.6.10
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,10 +100,17 @@ 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
104
+ tests/test_agent_workspace_settings.py
100
105
  tests/test_agents_connectors_builder.py
106
+ tests/test_allowed_tools_config.py
101
107
  tests/test_git_action.py
102
108
  tests/test_github_ingest.py
103
109
  tests/test_health.py
104
110
  tests/test_otp_login.py
111
+ tests/test_run_outputs.py
112
+ tests/test_schedule_api.py
113
+ tests/test_scheduler.py
114
+ tests/test_update_timer.py
105
115
  tests/test_updater_extras.py
106
116
  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.6
3
+ Version: 1.6.10
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.6" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
33
+ __version__ = "1.6.10" # 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
@@ -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,7 +106,8 @@ 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
  )
97
- raise LookupError(f"no agent named '{name}' — {detail}")
109
+ message = f"no agent named '{name}' — {detail}"
110
+ raise AgentLookupUnavailable(message) if unreadable else LookupError(message)
98
111
 
99
112
 
100
113
  def _render_conversation(history: list[dict] | None, message: str) -> str:
@@ -196,6 +209,10 @@ async def run_agent(
196
209
  on_event: EventHandler | None = None,
197
210
  project_path: str | None = None,
198
211
  scope: str = "custom",
212
+ persist_vars: bool = True,
213
+ trigger: str = "",
214
+ kind: str = "run",
215
+ client=None,
199
216
  ) -> dict:
200
217
  """Run an agent once against its own standing prompt — NOT a chat turn.
201
218
 
@@ -206,11 +223,21 @@ async def run_agent(
206
223
  (custom agents) or the host environment (department agents), exactly as in
207
224
  :func:`chat_with_agent`.
208
225
 
226
+ ``persist_vars=False`` skips writing the values to the prefill store. A
227
+ SCHEDULED run must pass this: the store exists to remember what a HUMAN last
228
+ typed into the Run form, and a nightly cron would otherwise overwrite that
229
+ person's values every night with its own.
230
+
231
+ ``trigger`` / ``kind`` are recorded in the run's output file so a reader can
232
+ tell a cron firing from someone pressing Run. ``client`` lets a caller that
233
+ already has a :class:`BackendClient` reuse it instead of having one built per
234
+ run (see :func:`resolve_agent`).
235
+
209
236
  Returns the engine's normalized result dict. Raises ``ValueError`` if any
210
237
  ``{{variable}}`` is left unfilled — the UI gates the Run button on this, and
211
238
  this is the server-side backstop.
212
239
  """
213
- manifest = await resolve_agent(name)
240
+ manifest = await resolve_agent(name, client)
214
241
 
215
242
  skills = load_skills()
216
243
  skill_text = resolve_skill_text(manifest, skills)
@@ -233,11 +260,14 @@ async def run_agent(
233
260
 
234
261
  # Remember these values so the next run can PREFILL them ("show previous
235
262
  # ones"); the user can still change them every run. Not secret — see varstore.
236
- agent_varstore.save_vars(manifest.name, variables)
263
+ # Skipped for scheduled runs, which carry their own stored values and must not
264
+ # clobber what the human last typed.
265
+ if persist_vars:
266
+ agent_varstore.save_vars(manifest.name, variables)
237
267
 
238
268
  log_path = run_log.start_run(
239
- manifest.name, scope=scope, engine=manifest.engine, kind="run",
240
- variables=variables,
269
+ manifest.name, scope=scope, engine=manifest.engine, kind=kind,
270
+ variables=variables, trigger=trigger,
241
271
  )
242
272
  teed = _tee_events(log_path, on_event)
243
273
 
@@ -0,0 +1,122 @@
1
+ """claude_code engine -- a thin adapter from an agent manifest onto ``sdk.local_exec.run_claude_local``."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable
8
+
9
+ from sdk import config
10
+ from sdk.local_exec import run_claude_local
11
+
12
+ if TYPE_CHECKING:
13
+ from sdk.agents.manifest import AgentManifest
14
+
15
+ logger = logging.getLogger("sdk.agents.engines.claude_code")
16
+
17
+ # Same shape as sdk.local_exec.EventHandler -- kept local so this module doesn't
18
+ # need to reach into local_exec for a type alias.
19
+ EventHandler = Callable[[dict], Awaitable[None]]
20
+
21
+ # Directory every agent without a real code workspace runs in.
22
+ AGENT_RUNS_DIR = Path.home() / ".agentos" / "runs"
23
+
24
+
25
+ def ensure_agent_workspace(agent_name: str) -> Path:
26
+ """The working directory for ``agent_name``, with its Claude settings in place.
27
+
28
+ Returns ``~/.agentos/runs/<agent>`` and makes sure
29
+ ``<that>/.claude/settings.json`` grants the configured tools
30
+ (``config.get_allowed_tools()``) under ``permissions.allow``.
31
+
32
+ Why write it at all when ``run_claude_local`` already passes
33
+ ``--allowedTools``: that flag covers the headless run, but the run directory IS
34
+ a Claude Code project, so anything reading project settings — a resumed
35
+ session, a nested/interactive invocation, a hook — sees the same grants instead
36
+ of falling back to prompting in a place where nobody can answer.
37
+
38
+ MERGES rather than overwrites: existing keys and any extra entries already in
39
+ ``permissions.allow`` are preserved, so a hand-added rule survives, and a tool
40
+ added to the configured list later shows up on the next run. Best-effort — a
41
+ write failure is logged and the run proceeds on ``--allowedTools`` alone.
42
+
43
+ Applies to CUSTOM and DEPARTMENT agents alike: both arrive as prompt-based
44
+ backend rows and both run through here.
45
+ """
46
+ workspace = AGENT_RUNS_DIR / agent_name
47
+ workspace.mkdir(parents=True, exist_ok=True)
48
+
49
+ settings_path = workspace / ".claude" / "settings.json"
50
+ try:
51
+ settings: dict = {}
52
+ if settings_path.exists():
53
+ try:
54
+ loaded = json.loads(settings_path.read_text(encoding="utf-8"))
55
+ if isinstance(loaded, dict):
56
+ settings = loaded
57
+ except (OSError, json.JSONDecodeError) as exc:
58
+ # A corrupt file silently disables ALL settings from it, so replace
59
+ # it rather than leave the agent running with none.
60
+ logger.warning("rewriting unparseable %s: %s", settings_path, exc)
61
+
62
+ permissions = settings.get("permissions")
63
+ if not isinstance(permissions, dict):
64
+ permissions = {}
65
+ allow = [t for t in (permissions.get("allow") or []) if isinstance(t, str)]
66
+ for tool in config.get_allowed_tools():
67
+ if tool not in allow:
68
+ allow.append(tool)
69
+ permissions["allow"] = allow
70
+ settings["permissions"] = permissions
71
+
72
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
73
+ settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
74
+ except OSError as exc:
75
+ logger.warning("could not write %s: %s", settings_path, exc)
76
+
77
+ return workspace
78
+
79
+
80
+ async def run(
81
+ manifest: "AgentManifest",
82
+ skill_text: str,
83
+ mcp_config: dict[str, Any] | None,
84
+ project_path: str | None = None,
85
+ on_event: EventHandler | None = None,
86
+ extra_env: dict[str, str] | None = None,
87
+ ) -> dict[str, Any]:
88
+ """Run a custom agent's manifest through the local ``claude`` CLI.
89
+
90
+ This does not reimplement any of ``run_claude_local``'s subprocess handling,
91
+ stream-json parsing, or crucial-hook logic -- it only assembles the prompt
92
+ and a working directory, then delegates.
93
+
94
+ ``skill_text`` is expected to already be the resolved skill body (see
95
+ ``sdk.agents.manifest.resolve_skill_text``), concatenated by the caller
96
+ where needed. To be safe, the final prompt is (re-)assembled here from
97
+ ``manifest.prompt`` plus, when ``skill_text`` is truthy, two newlines and
98
+ the skill body.
99
+ """
100
+ full_prompt = manifest.prompt
101
+ if skill_text:
102
+ full_prompt = f"{full_prompt}\n\n{skill_text}"
103
+
104
+ if project_path is None:
105
+ # Most custom agents (e.g. "summarise my PRs daily") have no real code
106
+ # workspace -- Claude Code still needs a valid current-working-directory
107
+ # to run in, so fall back to a per-agent scratch directory.
108
+ project_path = str(ensure_agent_workspace(manifest.name))
109
+
110
+ # mcp_config: accepted here for forward compatibility only. Wiring an
111
+ # --mcp-config flag into run_claude_local's subprocess command is a later
112
+ # follow-up, once run_claude_local itself grows an mcp_config parameter --
113
+ # this module does not attempt to work around that in the meantime, so the
114
+ # argument is currently unused.
115
+
116
+ return await run_claude_local(
117
+ prompt=full_prompt,
118
+ project_path=project_path,
119
+ allowed_tools=manifest.allowed_tools or None,
120
+ on_event=on_event,
121
+ extra_env=extra_env,
122
+ )
@@ -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