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.
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/PKG-INFO +1 -1
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/devops_bot_sdk.egg-info/SOURCES.txt +10 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/__init__.py +2 -2
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/chat.py +35 -5
- devops_bot_sdk-1.6.10/sdk/agents/engines/claude_code.py +122 -0
- devops_bot_sdk-1.6.10/sdk/agents/run_log.py +383 -0
- devops_bot_sdk-1.6.10/sdk/agents/schedules.py +322 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/config.py +64 -6
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/design_verify/loop.py +5 -4
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/local_exec.py +23 -11
- devops_bot_sdk-1.6.10/sdk/scheduler.py +367 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/api.py +165 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/models.py +34 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/server.py +9 -3
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/service.py +112 -1
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/app.js +183 -0
- devops_bot_sdk-1.6.10/sdk/ui/static/schedules.html +58 -0
- devops_bot_sdk-1.6.10/tests/test_agent_schedules.py +201 -0
- devops_bot_sdk-1.6.10/tests/test_agent_workspace_settings.py +140 -0
- devops_bot_sdk-1.6.10/tests/test_allowed_tools_config.py +102 -0
- devops_bot_sdk-1.6.10/tests/test_run_outputs.py +190 -0
- devops_bot_sdk-1.6.10/tests/test_schedule_api.py +193 -0
- devops_bot_sdk-1.6.10/tests/test_scheduler.py +288 -0
- devops_bot_sdk-1.6.10/tests/test_update_timer.py +92 -0
- devops_bot_sdk-1.6.6/sdk/agents/engines/claude_code.py +0 -61
- devops_bot_sdk-1.6.6/sdk/agents/run_log.py +0 -147
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/README.md +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/devops_bot_sdk.egg-info/requires.txt +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/devops_bot_sdk.egg-info/top_level.txt +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/pyproject.toml +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agentd.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/__init__.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/activity.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/builder.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/connectors.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/engines/__init__.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/engines/http.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/engines/mcp.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/engines/n8n.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/engines/shell.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/integrations.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/inventory.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/library/__init__.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/manifest.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/prompt_studio.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/prompt_template.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/prompt_vars.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/secrets.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/ticket_run.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/usage.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/agents/varstore.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/cli.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/client.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/collectors/__init__.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/collectors/files.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/collectors/process.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/collectors/screenshot.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/crucial.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/design_verify/__init__.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/design_verify/assets.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/design_verify/bootstrap.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/design_verify/browser.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/design_verify/compare.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/exceptions.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/git_ops.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/github_ingest.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/graphify.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/hooks/__init__.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/hooks/crucial_guard.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ipc/__init__.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ipc/electron_bridge.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ipc/handlers.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/models/__init__.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/models/envelope.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/models/requests.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/models/responses.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/models/snapshots.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/platform_compat.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/py.typed +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/run_auto.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/sse.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/test.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/test_pipeline.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/__init__.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/dashboard.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/health.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/ip_allowlist.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/jobs.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/runner.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/security.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/session.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/setup_manager.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/agents.html +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/custom-agents.html +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/default-agents.html +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/index.html +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/login.html +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/runs.html +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/setup.html +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/styles.css +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/ui/static/tickets.html +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/updater.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/sdk/vps_health.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/setup.cfg +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_agent_activity.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_agent_chat_features.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_agent_listing.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_agent_prompt_async.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_agent_prompt_models.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_agent_run_ticket.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_agents_connectors_builder.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_git_action.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_github_ingest.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_health.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_otp_login.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_updater_extras.py +0 -0
- {devops_bot_sdk-1.6.6 → devops_bot_sdk-1.6.10}/tests/test_vps_health.py +0 -0
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|