glaip-sdk 0.6.15b2__py3-none-any.whl → 0.6.15b3__py3-none-any.whl
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.
- glaip_sdk/agents/__init__.py +27 -0
- glaip_sdk/agents/base.py +1196 -0
- glaip_sdk/cli/__init__.py +9 -0
- glaip_sdk/cli/account_store.py +540 -0
- glaip_sdk/cli/agent_config.py +78 -0
- glaip_sdk/cli/auth.py +699 -0
- glaip_sdk/cli/commands/__init__.py +5 -0
- glaip_sdk/cli/commands/accounts.py +746 -0
- glaip_sdk/cli/commands/agents.py +1509 -0
- glaip_sdk/cli/commands/common_config.py +104 -0
- glaip_sdk/cli/commands/configure.py +896 -0
- glaip_sdk/cli/commands/mcps.py +1356 -0
- glaip_sdk/cli/commands/models.py +69 -0
- glaip_sdk/cli/commands/tools.py +576 -0
- glaip_sdk/cli/commands/transcripts.py +755 -0
- glaip_sdk/cli/commands/update.py +61 -0
- glaip_sdk/cli/config.py +95 -0
- glaip_sdk/cli/constants.py +38 -0
- glaip_sdk/cli/context.py +150 -0
- glaip_sdk/cli/core/__init__.py +79 -0
- glaip_sdk/cli/core/context.py +124 -0
- glaip_sdk/cli/core/output.py +851 -0
- glaip_sdk/cli/core/prompting.py +649 -0
- glaip_sdk/cli/core/rendering.py +187 -0
- glaip_sdk/cli/display.py +355 -0
- glaip_sdk/cli/hints.py +57 -0
- glaip_sdk/cli/io.py +112 -0
- glaip_sdk/cli/main.py +615 -0
- glaip_sdk/cli/masking.py +136 -0
- glaip_sdk/cli/mcp_validators.py +287 -0
- glaip_sdk/cli/pager.py +266 -0
- glaip_sdk/cli/parsers/__init__.py +7 -0
- glaip_sdk/cli/parsers/json_input.py +177 -0
- glaip_sdk/cli/resolution.py +67 -0
- glaip_sdk/cli/rich_helpers.py +27 -0
- glaip_sdk/cli/slash/__init__.py +15 -0
- glaip_sdk/cli/slash/accounts_controller.py +578 -0
- glaip_sdk/cli/slash/accounts_shared.py +75 -0
- glaip_sdk/cli/slash/agent_session.py +285 -0
- glaip_sdk/cli/slash/prompt.py +256 -0
- glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
- glaip_sdk/cli/slash/session.py +1708 -0
- glaip_sdk/cli/slash/tui/__init__.py +9 -0
- glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
- glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
- glaip_sdk/cli/slash/tui/loading.py +58 -0
- glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
- glaip_sdk/cli/transcript/__init__.py +31 -0
- glaip_sdk/cli/transcript/cache.py +536 -0
- glaip_sdk/cli/transcript/capture.py +329 -0
- glaip_sdk/cli/transcript/export.py +38 -0
- glaip_sdk/cli/transcript/history.py +815 -0
- glaip_sdk/cli/transcript/launcher.py +77 -0
- glaip_sdk/cli/transcript/viewer.py +374 -0
- glaip_sdk/cli/update_notifier.py +290 -0
- glaip_sdk/cli/utils.py +263 -0
- glaip_sdk/cli/validators.py +238 -0
- glaip_sdk/client/__init__.py +11 -0
- glaip_sdk/client/_agent_payloads.py +520 -0
- glaip_sdk/client/agent_runs.py +147 -0
- glaip_sdk/client/agents.py +1335 -0
- glaip_sdk/client/base.py +502 -0
- glaip_sdk/client/main.py +249 -0
- glaip_sdk/client/mcps.py +370 -0
- glaip_sdk/client/run_rendering.py +700 -0
- glaip_sdk/client/shared.py +21 -0
- glaip_sdk/client/tools.py +661 -0
- glaip_sdk/client/validators.py +198 -0
- glaip_sdk/config/constants.py +52 -0
- glaip_sdk/mcps/__init__.py +21 -0
- glaip_sdk/mcps/base.py +345 -0
- glaip_sdk/models/__init__.py +90 -0
- glaip_sdk/models/agent.py +47 -0
- glaip_sdk/models/agent_runs.py +116 -0
- glaip_sdk/models/common.py +42 -0
- glaip_sdk/models/mcp.py +33 -0
- glaip_sdk/models/tool.py +33 -0
- glaip_sdk/payload_schemas/__init__.py +7 -0
- glaip_sdk/payload_schemas/agent.py +85 -0
- glaip_sdk/registry/__init__.py +55 -0
- glaip_sdk/registry/agent.py +164 -0
- glaip_sdk/registry/base.py +139 -0
- glaip_sdk/registry/mcp.py +253 -0
- glaip_sdk/registry/tool.py +232 -0
- glaip_sdk/runner/__init__.py +59 -0
- glaip_sdk/runner/base.py +84 -0
- glaip_sdk/runner/deps.py +112 -0
- glaip_sdk/runner/langgraph.py +782 -0
- glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
- glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
- glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
- glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
- glaip_sdk/runner/tool_adapter/__init__.py +18 -0
- glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
- glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
- glaip_sdk/tools/__init__.py +22 -0
- glaip_sdk/tools/base.py +435 -0
- glaip_sdk/utils/__init__.py +86 -0
- glaip_sdk/utils/a2a/__init__.py +34 -0
- glaip_sdk/utils/a2a/event_processor.py +188 -0
- glaip_sdk/utils/agent_config.py +194 -0
- glaip_sdk/utils/bundler.py +267 -0
- glaip_sdk/utils/client.py +111 -0
- glaip_sdk/utils/client_utils.py +486 -0
- glaip_sdk/utils/datetime_helpers.py +58 -0
- glaip_sdk/utils/discovery.py +78 -0
- glaip_sdk/utils/display.py +135 -0
- glaip_sdk/utils/export.py +143 -0
- glaip_sdk/utils/general.py +61 -0
- glaip_sdk/utils/import_export.py +168 -0
- glaip_sdk/utils/import_resolver.py +492 -0
- glaip_sdk/utils/instructions.py +101 -0
- glaip_sdk/utils/rendering/__init__.py +115 -0
- glaip_sdk/utils/rendering/formatting.py +264 -0
- glaip_sdk/utils/rendering/layout/__init__.py +64 -0
- glaip_sdk/utils/rendering/layout/panels.py +156 -0
- glaip_sdk/utils/rendering/layout/progress.py +202 -0
- glaip_sdk/utils/rendering/layout/summary.py +74 -0
- glaip_sdk/utils/rendering/layout/transcript.py +606 -0
- glaip_sdk/utils/rendering/models.py +85 -0
- glaip_sdk/utils/rendering/renderer/__init__.py +55 -0
- glaip_sdk/utils/rendering/renderer/base.py +1024 -0
- glaip_sdk/utils/rendering/renderer/config.py +27 -0
- glaip_sdk/utils/rendering/renderer/console.py +55 -0
- glaip_sdk/utils/rendering/renderer/debug.py +178 -0
- glaip_sdk/utils/rendering/renderer/factory.py +138 -0
- glaip_sdk/utils/rendering/renderer/stream.py +202 -0
- glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
- glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
- glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
- glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
- glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
- glaip_sdk/utils/rendering/state.py +204 -0
- glaip_sdk/utils/rendering/step_tree_state.py +100 -0
- glaip_sdk/utils/rendering/steps/__init__.py +34 -0
- glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
- glaip_sdk/utils/rendering/steps/format.py +176 -0
- glaip_sdk/utils/rendering/steps/manager.py +387 -0
- glaip_sdk/utils/rendering/timing.py +36 -0
- glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
- glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
- glaip_sdk/utils/resource_refs.py +195 -0
- glaip_sdk/utils/run_renderer.py +41 -0
- glaip_sdk/utils/runtime_config.py +425 -0
- glaip_sdk/utils/serialization.py +424 -0
- glaip_sdk/utils/sync.py +142 -0
- glaip_sdk/utils/tool_detection.py +33 -0
- glaip_sdk/utils/validation.py +264 -0
- {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/METADATA +1 -1
- glaip_sdk-0.6.15b3.dist-info/RECORD +160 -0
- glaip_sdk-0.6.15b2.dist-info/RECORD +0 -12
- {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/entry_points.txt +0 -0
- {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""Helpers for storing and exporting agent run transcripts.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
from collections.abc import Iterable, Iterator
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from glaip_sdk.utils.datetime_helpers import (
|
|
19
|
+
coerce_datetime as _coerce_datetime,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
DEFAULT_CACHE_ROOT = Path(
|
|
23
|
+
os.getenv(
|
|
24
|
+
"AIP_TRANSCRIPT_CACHE_DIR",
|
|
25
|
+
Path.home() / ".config" / "glaip-sdk" / "transcripts",
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
MANIFEST_FILENAME = "manifest.jsonl"
|
|
29
|
+
JSONL_SUFFIX = ".jsonl"
|
|
30
|
+
UTC_OFFSET_SUFFIX = "+00:00"
|
|
31
|
+
|
|
32
|
+
_RUN_ID_PREFIX = "run_"
|
|
33
|
+
_RUN_ID_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class TranscriptPayload:
|
|
38
|
+
"""Data bundle representing a captured agent run."""
|
|
39
|
+
|
|
40
|
+
events: list[dict[str, Any]]
|
|
41
|
+
default_output: str
|
|
42
|
+
final_output: str
|
|
43
|
+
agent_id: str | None
|
|
44
|
+
agent_name: str | None
|
|
45
|
+
model: str | None
|
|
46
|
+
server_run_id: str | None
|
|
47
|
+
started_at: float | None
|
|
48
|
+
finished_at: float | None
|
|
49
|
+
created_at: datetime
|
|
50
|
+
source: str
|
|
51
|
+
meta: dict[str, Any]
|
|
52
|
+
run_id: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True)
|
|
56
|
+
class TranscriptStoreResult:
|
|
57
|
+
"""Result of writing a transcript to the local cache."""
|
|
58
|
+
|
|
59
|
+
path: Path
|
|
60
|
+
manifest_entry: dict[str, Any]
|
|
61
|
+
pruned_entries: list[dict[str, Any]]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(slots=True)
|
|
65
|
+
class TranscriptCacheStats:
|
|
66
|
+
"""Lightweight usage snapshot for the transcript cache."""
|
|
67
|
+
|
|
68
|
+
cache_dir: Path
|
|
69
|
+
entry_count: int
|
|
70
|
+
total_bytes: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def generate_run_id(length: int = 6) -> str:
|
|
74
|
+
"""Return a short, human-friendly run identifier."""
|
|
75
|
+
length = max(4, min(int(length or 0), 16)) or 6
|
|
76
|
+
return _RUN_ID_PREFIX + "".join(secrets.choice(_RUN_ID_ALPHABET) for _ in range(length))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _timestamp_to_iso(value: Any) -> str | None:
|
|
80
|
+
"""Convert supported timestamp-like values to an ISO8601 string with UTC designator."""
|
|
81
|
+
dt = _coerce_datetime(value)
|
|
82
|
+
if dt is None:
|
|
83
|
+
return None
|
|
84
|
+
if dt.year < 2000:
|
|
85
|
+
return None
|
|
86
|
+
return dt.isoformat().replace(UTC_OFFSET_SUFFIX, "Z")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _compute_duration_seconds(start: Any, end: Any) -> int | None:
|
|
90
|
+
"""Compute whole-second duration between two timestamp-like values."""
|
|
91
|
+
start_dt = _coerce_datetime(start)
|
|
92
|
+
end_dt = _coerce_datetime(end)
|
|
93
|
+
if start_dt is None or end_dt is None:
|
|
94
|
+
return None
|
|
95
|
+
delta = (end_dt - start_dt).total_seconds()
|
|
96
|
+
if delta < 0:
|
|
97
|
+
return None
|
|
98
|
+
return int(round(delta))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _iter_candidate_paths(entry: dict[str, Any], directory: Path) -> Iterator[Path]:
|
|
102
|
+
"""Yield plausible transcript paths for a manifest entry, deduplicated."""
|
|
103
|
+
seen: set[str] = set()
|
|
104
|
+
|
|
105
|
+
def _offer(path: Path) -> Iterator[Path]:
|
|
106
|
+
key = str(path)
|
|
107
|
+
if key not in seen:
|
|
108
|
+
seen.add(key)
|
|
109
|
+
yield path
|
|
110
|
+
|
|
111
|
+
for candidate in _filename_candidate_paths(entry, directory):
|
|
112
|
+
yield from _offer(candidate)
|
|
113
|
+
for candidate in _cache_path_candidate_paths(entry):
|
|
114
|
+
yield from _offer(candidate)
|
|
115
|
+
for candidate in _run_id_candidate_paths(entry, directory):
|
|
116
|
+
yield from _offer(candidate)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _filename_candidate_paths(entry: dict[str, Any], directory: Path) -> tuple[Path, ...]:
|
|
120
|
+
"""Return possible transcript paths derived from the manifest filename."""
|
|
121
|
+
filename = entry.get("filename")
|
|
122
|
+
if not filename:
|
|
123
|
+
return ()
|
|
124
|
+
candidate = Path(str(filename))
|
|
125
|
+
if not candidate.is_absolute():
|
|
126
|
+
candidate = directory / candidate
|
|
127
|
+
return (candidate,)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _cache_path_candidate_paths(entry: dict[str, Any]) -> tuple[Path, ...]:
|
|
131
|
+
"""Return legacy cache_path-derived transcript candidates."""
|
|
132
|
+
cache_path = entry.get("cache_path")
|
|
133
|
+
if not cache_path:
|
|
134
|
+
return ()
|
|
135
|
+
return (Path(str(cache_path)).expanduser(),)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _run_id_candidate_paths(entry: dict[str, Any], directory: Path) -> tuple[Path, ...]:
|
|
139
|
+
"""Return candidate transcript paths derived from the run id."""
|
|
140
|
+
run_id = entry.get("run_id")
|
|
141
|
+
if not run_id:
|
|
142
|
+
return ()
|
|
143
|
+
paths: list[Path] = []
|
|
144
|
+
for variant in _run_id_variants(str(run_id)):
|
|
145
|
+
name = variant if variant.endswith(JSONL_SUFFIX) else f"{variant}{JSONL_SUFFIX}"
|
|
146
|
+
paths.append(directory / name)
|
|
147
|
+
return tuple(paths)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _run_id_variants(run_id: str) -> set[str]:
|
|
151
|
+
"""Return plausible filename stems derived from a run id."""
|
|
152
|
+
variants = {run_id}
|
|
153
|
+
if run_id.startswith(_RUN_ID_PREFIX):
|
|
154
|
+
suffix = run_id[len(_RUN_ID_PREFIX) :]
|
|
155
|
+
if suffix:
|
|
156
|
+
variants.update({suffix, f"run-{suffix}"})
|
|
157
|
+
variants.add(f"run-{run_id}")
|
|
158
|
+
else:
|
|
159
|
+
variants.update({f"run-{run_id}", _RUN_ID_PREFIX + run_id})
|
|
160
|
+
return variants
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def transcript_path_candidates(entry: dict[str, Any], cache_dir: Path | None = None) -> list[Path]:
|
|
164
|
+
"""Return possible transcript file locations for a manifest entry."""
|
|
165
|
+
directory = ensure_cache_dir(cache_dir)
|
|
166
|
+
return list(_iter_candidate_paths(entry, directory))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def resolve_transcript_path(entry: dict[str, Any], cache_dir: Path | None = None) -> Path:
|
|
170
|
+
"""Resolve the cached transcript path for a manifest entry or raise informative errors."""
|
|
171
|
+
candidates = transcript_path_candidates(entry, cache_dir)
|
|
172
|
+
if not candidates:
|
|
173
|
+
raise FileNotFoundError("Cached transcript path missing from manifest.")
|
|
174
|
+
|
|
175
|
+
for candidate in candidates:
|
|
176
|
+
if candidate.exists():
|
|
177
|
+
return candidate
|
|
178
|
+
|
|
179
|
+
raise FileNotFoundError(f"Cached transcript file not found: {candidates[0]}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _manifest_sort_key(entry: dict[str, Any]) -> datetime:
|
|
183
|
+
"""Return a datetime for ordering manifest rows, defaulting to the distant past."""
|
|
184
|
+
for key in ("started_at", "created_at"):
|
|
185
|
+
dt = _coerce_datetime(entry.get(key))
|
|
186
|
+
if dt is not None:
|
|
187
|
+
return dt
|
|
188
|
+
return datetime.min.replace(tzinfo=timezone.utc)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def ensure_cache_dir(cache_dir: Path | None = None) -> Path:
|
|
192
|
+
"""Ensure the cache directory exists and return it."""
|
|
193
|
+
directory = cache_dir or DEFAULT_CACHE_ROOT
|
|
194
|
+
try:
|
|
195
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
except PermissionError:
|
|
197
|
+
return _fallback_cache_dir()
|
|
198
|
+
|
|
199
|
+
if not os.access(directory, os.W_OK):
|
|
200
|
+
return _fallback_cache_dir()
|
|
201
|
+
|
|
202
|
+
return directory
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _fallback_cache_dir() -> Path:
|
|
206
|
+
"""Return a writable fallback cache directory under the current working tree."""
|
|
207
|
+
fallback = Path.cwd() / ".glaip-transcripts"
|
|
208
|
+
fallback.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
return fallback
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def manifest_path(cache_dir: Path | None = None) -> Path:
|
|
213
|
+
"""Return the manifest file path."""
|
|
214
|
+
return ensure_cache_dir(cache_dir) / MANIFEST_FILENAME
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _parse_iso(ts: str | None) -> datetime | None:
|
|
218
|
+
"""Parse metadata timestamps that may use the legacy Z suffix."""
|
|
219
|
+
if not ts:
|
|
220
|
+
return None
|
|
221
|
+
try:
|
|
222
|
+
return datetime.fromisoformat(ts.replace("Z", UTC_OFFSET_SUFFIX))
|
|
223
|
+
except Exception:
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _load_manifest_entries(cache_dir: Path | None = None) -> list[dict[str, Any]]:
|
|
228
|
+
"""Read manifest entries from disk, returning an empty list when missing."""
|
|
229
|
+
path = manifest_path(cache_dir)
|
|
230
|
+
entries: list[dict[str, Any]] = []
|
|
231
|
+
if not path.exists():
|
|
232
|
+
return entries
|
|
233
|
+
|
|
234
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
235
|
+
for line in fh:
|
|
236
|
+
line = line.strip()
|
|
237
|
+
if not line:
|
|
238
|
+
continue
|
|
239
|
+
try:
|
|
240
|
+
entry = json.loads(line)
|
|
241
|
+
entries.append(entry)
|
|
242
|
+
except json.JSONDecodeError:
|
|
243
|
+
continue
|
|
244
|
+
return entries
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _json_default(value: Any) -> Any:
|
|
248
|
+
"""Ensure non-serialisable values degrade to readable strings."""
|
|
249
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
250
|
+
return value
|
|
251
|
+
if isinstance(value, Path):
|
|
252
|
+
return str(value)
|
|
253
|
+
return repr(value)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _write_manifest(entries: Iterable[dict[str, Any]], cache_dir: Path | None = None) -> None:
|
|
257
|
+
"""Atomically write manifest entries back to disk."""
|
|
258
|
+
path = manifest_path(cache_dir)
|
|
259
|
+
tmp_path = path.with_name(f"{path.name}.tmp")
|
|
260
|
+
with tmp_path.open("w", encoding="utf-8") as fh:
|
|
261
|
+
for entry in entries:
|
|
262
|
+
fh.write(json.dumps(entry, ensure_ascii=False, default=_json_default))
|
|
263
|
+
fh.write("\n")
|
|
264
|
+
tmp_path.replace(path)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def load_manifest_entries(cache_dir: Path | None = None) -> list[dict[str, Any]]:
|
|
268
|
+
"""Public wrapper around manifest loading for downstream tooling."""
|
|
269
|
+
return _load_manifest_entries(cache_dir)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def write_manifest(entries: Iterable[dict[str, Any]], cache_dir: Path | None = None) -> None:
|
|
273
|
+
"""Persist manifest entries atomically."""
|
|
274
|
+
_write_manifest(entries, cache_dir)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def store_transcript(
|
|
278
|
+
payload: TranscriptPayload,
|
|
279
|
+
*,
|
|
280
|
+
cache_dir: Path | None = None,
|
|
281
|
+
) -> TranscriptStoreResult:
|
|
282
|
+
"""Persist a transcript to disk and update the manifest."""
|
|
283
|
+
directory = ensure_cache_dir(cache_dir)
|
|
284
|
+
filename = _normalise_run_filename(payload.run_id)
|
|
285
|
+
transcript_path = directory / filename
|
|
286
|
+
|
|
287
|
+
meta_line = _build_meta_line(payload)
|
|
288
|
+
transcript_path = _write_transcript_file(transcript_path, filename, meta_line, payload.events)
|
|
289
|
+
size_bytes = _safe_file_size(transcript_path)
|
|
290
|
+
manifest_entry = _build_manifest_entry(payload, transcript_path.name, size_bytes)
|
|
291
|
+
if transcript_path.parent != directory:
|
|
292
|
+
manifest_entry["cache_path"] = str(transcript_path)
|
|
293
|
+
|
|
294
|
+
existing_entries = _load_manifest_entries(directory)
|
|
295
|
+
existing_entries.append(manifest_entry)
|
|
296
|
+
_write_manifest(existing_entries, directory)
|
|
297
|
+
|
|
298
|
+
return TranscriptStoreResult(
|
|
299
|
+
path=transcript_path,
|
|
300
|
+
manifest_entry=manifest_entry,
|
|
301
|
+
pruned_entries=[],
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _normalise_run_filename(run_id: str) -> str:
|
|
306
|
+
"""Ensure cached run filenames always end with .jsonl."""
|
|
307
|
+
run_basename = run_id.rstrip()
|
|
308
|
+
if run_basename.endswith(JSONL_SUFFIX):
|
|
309
|
+
run_basename = run_basename[: -len(JSONL_SUFFIX)]
|
|
310
|
+
return f"{run_basename}{JSONL_SUFFIX}"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _build_meta_line(payload: TranscriptPayload) -> dict[str, Any]:
|
|
314
|
+
"""Return the metadata header stored at the top of transcript files."""
|
|
315
|
+
return {
|
|
316
|
+
"type": "meta",
|
|
317
|
+
"run_id": payload.run_id,
|
|
318
|
+
"agent_id": payload.agent_id,
|
|
319
|
+
"agent_name": payload.agent_name,
|
|
320
|
+
"model": payload.model,
|
|
321
|
+
"created_at": payload.created_at.isoformat(),
|
|
322
|
+
"default_output": payload.default_output,
|
|
323
|
+
"final_output": payload.final_output,
|
|
324
|
+
"server_run_id": payload.server_run_id,
|
|
325
|
+
"started_at": payload.started_at,
|
|
326
|
+
"finished_at": payload.finished_at,
|
|
327
|
+
"meta": payload.meta,
|
|
328
|
+
"source": payload.source,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _write_transcript_file(
|
|
333
|
+
path: Path,
|
|
334
|
+
filename: str,
|
|
335
|
+
meta_line: dict[str, Any],
|
|
336
|
+
events: list[dict[str, Any]],
|
|
337
|
+
) -> Path:
|
|
338
|
+
"""Persist the transcript JSONL file, falling back to cwd when necessary."""
|
|
339
|
+
|
|
340
|
+
def _write(target: Path) -> None:
|
|
341
|
+
with target.open("w", encoding="utf-8") as fh:
|
|
342
|
+
fh.write(json.dumps(meta_line, ensure_ascii=False, default=_json_default))
|
|
343
|
+
fh.write("\n")
|
|
344
|
+
for event in events:
|
|
345
|
+
fh.write(
|
|
346
|
+
json.dumps(
|
|
347
|
+
{"type": "event", "event": event},
|
|
348
|
+
ensure_ascii=False,
|
|
349
|
+
default=_json_default,
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
fh.write("\n")
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
_write(path)
|
|
356
|
+
return path
|
|
357
|
+
except PermissionError:
|
|
358
|
+
fallback_dir = _fallback_cache_dir()
|
|
359
|
+
fallback_path = fallback_dir / filename
|
|
360
|
+
_write(fallback_path)
|
|
361
|
+
return fallback_path
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _safe_file_size(path: Path) -> int:
|
|
365
|
+
"""Return the file size, tolerating missing paths."""
|
|
366
|
+
try:
|
|
367
|
+
return path.stat().st_size
|
|
368
|
+
except FileNotFoundError:
|
|
369
|
+
return 0
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _build_manifest_entry(payload: TranscriptPayload, filename: str, size_bytes: int) -> dict[str, Any]:
|
|
373
|
+
"""Generate the manifest row corresponding to a stored transcript."""
|
|
374
|
+
entry: dict[str, Any] = {
|
|
375
|
+
"run_id": payload.run_id,
|
|
376
|
+
"agent_id": payload.agent_id,
|
|
377
|
+
"agent_name": payload.agent_name,
|
|
378
|
+
"started_at": _timestamp_to_iso(payload.started_at) or payload.created_at.isoformat(),
|
|
379
|
+
"finished_at": _timestamp_to_iso(payload.finished_at),
|
|
380
|
+
"duration_seconds": _compute_duration_seconds(payload.started_at, payload.finished_at),
|
|
381
|
+
"size_bytes": size_bytes,
|
|
382
|
+
"filename": filename,
|
|
383
|
+
"retained": True,
|
|
384
|
+
"model": payload.model,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
api_url = payload.meta.get("api_url")
|
|
388
|
+
if api_url:
|
|
389
|
+
entry["api_url"] = api_url
|
|
390
|
+
|
|
391
|
+
if entry["duration_seconds"] is None:
|
|
392
|
+
entry["duration_seconds"] = _coerce_duration_hint(payload.meta.get("final_duration_seconds"))
|
|
393
|
+
|
|
394
|
+
if entry.get("finished_at") is None and entry.get("started_at") and entry.get("duration_seconds") is not None:
|
|
395
|
+
start_dt = _coerce_datetime(entry["started_at"])
|
|
396
|
+
if start_dt is not None:
|
|
397
|
+
finished_dt = start_dt + timedelta(seconds=int(entry["duration_seconds"]))
|
|
398
|
+
entry["finished_at"] = finished_dt.isoformat().replace(UTC_OFFSET_SUFFIX, "Z")
|
|
399
|
+
|
|
400
|
+
return entry
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _coerce_duration_hint(value: Any) -> int | None:
|
|
404
|
+
"""Convert loose duration hints to whole seconds."""
|
|
405
|
+
try:
|
|
406
|
+
if value is None:
|
|
407
|
+
return None
|
|
408
|
+
return int(round(float(value)))
|
|
409
|
+
except Exception:
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def latest_manifest_entry(cache_dir: Path | None = None) -> dict[str, Any] | None:
|
|
414
|
+
"""Return the most recent manifest entry, if any."""
|
|
415
|
+
entries = _load_manifest_entries(cache_dir)
|
|
416
|
+
if not entries:
|
|
417
|
+
return None
|
|
418
|
+
return max(entries, key=_manifest_sort_key)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def resolve_manifest_entry(
|
|
422
|
+
run_id: str,
|
|
423
|
+
cache_dir: Path | None = None,
|
|
424
|
+
) -> dict[str, Any] | None:
|
|
425
|
+
"""Find a manifest entry by run id."""
|
|
426
|
+
entries = _load_manifest_entries(cache_dir)
|
|
427
|
+
for entry in entries:
|
|
428
|
+
if entry.get("run_id") == run_id:
|
|
429
|
+
return entry
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def export_transcript(
|
|
434
|
+
*,
|
|
435
|
+
destination: Path,
|
|
436
|
+
run_id: str | None = None,
|
|
437
|
+
cache_dir: Path | None = None,
|
|
438
|
+
) -> Path:
|
|
439
|
+
"""Copy a cached transcript to the requested destination path."""
|
|
440
|
+
directory = ensure_cache_dir(cache_dir)
|
|
441
|
+
entry = resolve_manifest_entry(run_id, directory) if run_id else latest_manifest_entry(directory)
|
|
442
|
+
if entry is None:
|
|
443
|
+
raise FileNotFoundError("No cached transcripts available for export.")
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
cache_file = resolve_transcript_path(entry, directory)
|
|
447
|
+
except FileNotFoundError as exc:
|
|
448
|
+
raise FileNotFoundError(str(exc)) from exc
|
|
449
|
+
|
|
450
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
lines = cache_file.read_text(encoding="utf-8").splitlines()
|
|
454
|
+
records = [json.loads(line) for line in lines if line.strip()]
|
|
455
|
+
except json.JSONDecodeError as exc:
|
|
456
|
+
raise FileNotFoundError(f"Cached transcript file is corrupted: {cache_file}") from exc
|
|
457
|
+
|
|
458
|
+
with destination.open("w", encoding="utf-8") as fh:
|
|
459
|
+
for idx, record in enumerate(records):
|
|
460
|
+
json.dump(record, fh, ensure_ascii=False, indent=2)
|
|
461
|
+
fh.write("\n")
|
|
462
|
+
if idx != len(records) - 1:
|
|
463
|
+
fh.write("\n")
|
|
464
|
+
|
|
465
|
+
return destination
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def suggest_filename(entry: dict[str, Any] | None = None) -> str:
|
|
469
|
+
"""Return a friendly filename suggestion for exporting a transcript."""
|
|
470
|
+
run_id = entry.get("run_id") if entry else None
|
|
471
|
+
if not run_id:
|
|
472
|
+
run_id = generate_run_id()
|
|
473
|
+
|
|
474
|
+
timestamp_source = None
|
|
475
|
+
if entry:
|
|
476
|
+
timestamp_source = entry.get("started_at") or entry.get("created_at")
|
|
477
|
+
|
|
478
|
+
if not timestamp_source:
|
|
479
|
+
timestamp_source = datetime.now(timezone.utc).isoformat()
|
|
480
|
+
|
|
481
|
+
timestamp = str(timestamp_source).replace(":", "").replace("-", "").replace("T", "_").split("+")[0]
|
|
482
|
+
safe_run_id = str(run_id).replace("/", "-").replace(" ", "-")
|
|
483
|
+
return f"aip-run-{timestamp}-{safe_run_id}{JSONL_SUFFIX}"
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def build_payload(
|
|
487
|
+
*,
|
|
488
|
+
events: list[dict[str, Any]],
|
|
489
|
+
renderer_output: str,
|
|
490
|
+
final_output: str,
|
|
491
|
+
agent_id: str | None,
|
|
492
|
+
agent_name: str | None,
|
|
493
|
+
model: str | None,
|
|
494
|
+
server_run_id: str | None,
|
|
495
|
+
started_at: float | None,
|
|
496
|
+
finished_at: float | None,
|
|
497
|
+
meta: dict[str, Any],
|
|
498
|
+
source: str,
|
|
499
|
+
) -> TranscriptPayload:
|
|
500
|
+
"""Factory helper to prepare payload objects consistently."""
|
|
501
|
+
return TranscriptPayload(
|
|
502
|
+
events=events,
|
|
503
|
+
default_output=renderer_output,
|
|
504
|
+
final_output=final_output,
|
|
505
|
+
agent_id=agent_id,
|
|
506
|
+
agent_name=agent_name,
|
|
507
|
+
model=model,
|
|
508
|
+
server_run_id=server_run_id,
|
|
509
|
+
started_at=started_at,
|
|
510
|
+
finished_at=finished_at,
|
|
511
|
+
created_at=datetime.now(timezone.utc),
|
|
512
|
+
source=source,
|
|
513
|
+
meta=meta,
|
|
514
|
+
run_id=generate_run_id(),
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def get_transcript_cache_stats(
|
|
519
|
+
cache_dir: Path | None = None,
|
|
520
|
+
) -> TranscriptCacheStats:
|
|
521
|
+
"""Return basic usage information about the transcript cache."""
|
|
522
|
+
directory = ensure_cache_dir(cache_dir)
|
|
523
|
+
entries = _load_manifest_entries(directory)
|
|
524
|
+
|
|
525
|
+
total_bytes = 0
|
|
526
|
+
for entry in entries:
|
|
527
|
+
try:
|
|
528
|
+
total_bytes += int(entry.get("size_bytes") or 0)
|
|
529
|
+
except Exception:
|
|
530
|
+
continue
|
|
531
|
+
|
|
532
|
+
return TranscriptCacheStats(
|
|
533
|
+
cache_dir=directory,
|
|
534
|
+
entry_count=len(entries),
|
|
535
|
+
total_bytes=total_bytes,
|
|
536
|
+
)
|