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