@openlucaskaka/kagent 0.1.7
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.
- package/README.md +353 -0
- package/npm/bin/kagent-serve.js +6 -0
- package/npm/bin/kagent.js +6 -0
- package/npm/lib/App.js +524 -0
- package/npm/lib/app-state.js +224 -0
- package/npm/lib/approval-choice.js +25 -0
- package/npm/lib/commands.js +59 -0
- package/npm/lib/editor.js +188 -0
- package/npm/lib/ink-runner.js +41 -0
- package/npm/lib/kagent-home.js +39 -0
- package/npm/lib/launcher.js +221 -0
- package/npm/lib/protocol.js +33 -0
- package/npm/lib/provider-setup.js +139 -0
- package/npm/lib/python-runner.js +892 -0
- package/npm/lib/runtime-client.js +390 -0
- package/npm/lib/terminal-input.js +127 -0
- package/npm/lib/terminal-text.js +19 -0
- package/npm/lib/terminal-width.js +14 -0
- package/npm/lib/transcript.js +227 -0
- package/npm/lib/ui-components.js +247 -0
- package/npm/lib/update-manager.js +334 -0
- package/package.json +39 -0
- package/pyproject.toml +55 -0
- package/src/kagent/__init__.py +90 -0
- package/src/kagent/cli/__init__.py +5 -0
- package/src/kagent/cli/__main__.py +6 -0
- package/src/kagent/cli/commands.py +112 -0
- package/src/kagent/cli/conversation.py +127 -0
- package/src/kagent/cli/interactive.py +841 -0
- package/src/kagent/cli/main.py +685 -0
- package/src/kagent/cli/memory.py +460 -0
- package/src/kagent/cli/pending_approval.py +169 -0
- package/src/kagent/cli/provider.py +27 -0
- package/src/kagent/cli/session_commands.py +401 -0
- package/src/kagent/cli/stdio_runtime.py +784 -0
- package/src/kagent/cli/trace.py +67 -0
- package/src/kagent/cli/ui.py +931 -0
- package/src/kagent/core/__init__.py +0 -0
- package/src/kagent/core/agent.py +296 -0
- package/src/kagent/core/faults.py +11 -0
- package/src/kagent/core/invariants.py +47 -0
- package/src/kagent/core/normalization.py +81 -0
- package/src/kagent/core/planning.py +48 -0
- package/src/kagent/core/state.py +73 -0
- package/src/kagent/core/summary.py +64 -0
- package/src/kagent/core/tools.py +196 -0
- package/src/kagent/core/trace.py +57 -0
- package/src/kagent/eval/__init__.py +11 -0
- package/src/kagent/eval/cases.py +229 -0
- package/src/kagent/eval/evaluator.py +216 -0
- package/src/kagent/integrations/__init__.py +3 -0
- package/src/kagent/integrations/audit.py +95 -0
- package/src/kagent/integrations/backends.py +131 -0
- package/src/kagent/integrations/memory.py +301 -0
- package/src/kagent/ops/__init__.py +0 -0
- package/src/kagent/ops/batch.py +156 -0
- package/src/kagent/ops/doctor.py +255 -0
- package/src/kagent/ops/metrics.py +214 -0
- package/src/kagent/ops/release_evidence.py +877 -0
- package/src/kagent/ops/release_manifest.py +142 -0
- package/src/kagent/ops/trace_replay.py +285 -0
- package/src/kagent/providers/__init__.py +7 -0
- package/src/kagent/providers/embeddings.py +187 -0
- package/src/kagent/providers/llm.py +770 -0
- package/src/kagent/py.typed +1 -0
- package/src/kagent/runtime/__init__.py +28 -0
- package/src/kagent/runtime/action_graph.py +543 -0
- package/src/kagent/runtime/agent.py +2089 -0
- package/src/kagent/runtime/approval.py +64 -0
- package/src/kagent/runtime/cancellation.py +64 -0
- package/src/kagent/runtime/checkpoint_state.py +146 -0
- package/src/kagent/runtime/checkpoint_storage.py +270 -0
- package/src/kagent/runtime/context.py +65 -0
- package/src/kagent/runtime/file_transaction.py +195 -0
- package/src/kagent/runtime/hooks.py +74 -0
- package/src/kagent/runtime/metadata.py +116 -0
- package/src/kagent/runtime/patch_checkpoints.py +385 -0
- package/src/kagent/runtime/policy.py +50 -0
- package/src/kagent/runtime/presentation.py +205 -0
- package/src/kagent/runtime/redaction.py +130 -0
- package/src/kagent/runtime/sandbox.py +331 -0
- package/src/kagent/runtime/skills.py +66 -0
- package/src/kagent/runtime/steering.py +56 -0
- package/src/kagent/runtime/steps.py +255 -0
- package/src/kagent/runtime/task_state.py +40 -0
- package/src/kagent/runtime/tools.py +3532 -0
- package/src/kagent/runtime/types.py +240 -0
- package/src/kagent/runtime/workspace.py +597 -0
- package/src/kagent/service/__init__.py +26 -0
- package/src/kagent/service/__main__.py +6 -0
- package/src/kagent/service/active_runs.py +178 -0
- package/src/kagent/service/cli.py +737 -0
- package/src/kagent/service/contract.py +2571 -0
- package/src/kagent/service/errors.py +71 -0
- package/src/kagent/service/idempotency.py +584 -0
- package/src/kagent/service/router.py +884 -0
- package/src/kagent/service/run.py +150 -0
- package/src/kagent/service/runtime.py +1915 -0
- package/src/kagent/service/runtime_approval.py +20 -0
- package/src/kagent/service/runtime_cancel.py +192 -0
- package/src/kagent/service/runtime_lifecycle.py +229 -0
- package/src/kagent/service/runtime_metadata.py +21 -0
- package/src/kagent/service/runtime_policy.py +115 -0
- package/src/kagent/service/runtime_recovery.py +573 -0
- package/src/kagent/service/runtime_resume.py +382 -0
- package/src/kagent/service/runtime_resume_claim.py +116 -0
- package/src/kagent/service/runtime_run.py +350 -0
- package/src/kagent/service/runtime_status.py +2007 -0
- package/src/kagent/service/safety.py +139 -0
- package/src/kagent/service/server.py +114 -0
- package/src/kagent/service/status.py +233 -0
- package/src/kagent/service/trace_store.py +322 -0
- package/src/kagent/service/transport.py +24 -0
- package/src/kagent/utils/__init__.py +0 -0
- package/src/kagent/utils/config_validation.py +21 -0
- package/src/kagent/utils/json_output.py +23 -0
- package/src/kagent/utils/paths.py +473 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
from kagent.runtime.types import parse_agent_plan
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_resumable_plan(
|
|
10
|
+
plan: Dict[str, Any],
|
|
11
|
+
pending_approval: Dict[str, Any],
|
|
12
|
+
) -> Dict[str, Any] | None:
|
|
13
|
+
"""Return the pending action and every action that follows it.
|
|
14
|
+
|
|
15
|
+
Actions before the approval boundary already completed in the persisted run.
|
|
16
|
+
Their dependencies are therefore removed while dependencies between remaining
|
|
17
|
+
actions stay intact.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
parsed_plan = parse_agent_plan(
|
|
22
|
+
json.dumps(plan, ensure_ascii=False, sort_keys=True)
|
|
23
|
+
)
|
|
24
|
+
except (TypeError, ValueError):
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
pending_action_id = str(pending_approval.get("id", "")).strip()
|
|
28
|
+
raw_actions = plan.get("actions")
|
|
29
|
+
if not pending_action_id or not isinstance(raw_actions, list):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
matching_indexes = [
|
|
33
|
+
index
|
|
34
|
+
for index, action in enumerate(raw_actions)
|
|
35
|
+
if isinstance(action, dict) and action.get("id") == pending_action_id
|
|
36
|
+
]
|
|
37
|
+
if len(matching_indexes) != 1:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
pending_index = matching_indexes[0]
|
|
41
|
+
if raw_actions[pending_index] != pending_approval:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
completed_action_ids = {
|
|
45
|
+
action.id for action in parsed_plan.actions[:pending_index]
|
|
46
|
+
}
|
|
47
|
+
remaining_actions = []
|
|
48
|
+
for action in parsed_plan.actions[pending_index:]:
|
|
49
|
+
payload = action.to_dict()
|
|
50
|
+
remaining_dependencies = [
|
|
51
|
+
dependency
|
|
52
|
+
for dependency in action.depends_on
|
|
53
|
+
if dependency not in completed_action_ids
|
|
54
|
+
]
|
|
55
|
+
if remaining_dependencies:
|
|
56
|
+
payload["depends_on"] = remaining_dependencies
|
|
57
|
+
else:
|
|
58
|
+
payload.pop("depends_on", None)
|
|
59
|
+
remaining_actions.append(payload)
|
|
60
|
+
|
|
61
|
+
resumable_plan: Dict[str, Any] = {"actions": remaining_actions}
|
|
62
|
+
if parsed_plan.final_answer:
|
|
63
|
+
resumable_plan["final_answer"] = parsed_plan.final_answer
|
|
64
|
+
return resumable_plan
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from threading import Event, Lock
|
|
5
|
+
from typing import Any, Callable, Dict, Mapping, Optional
|
|
6
|
+
|
|
7
|
+
ExternalCancellationProbe = Callable[[], Optional[Mapping[str, Any]]]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RuntimeCancellationToken:
|
|
11
|
+
"""Thread-safe cooperative cancellation shared across runtime boundaries."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
*,
|
|
16
|
+
external_cancellation_probe: ExternalCancellationProbe | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
self._event = Event()
|
|
19
|
+
self._lock = Lock()
|
|
20
|
+
self._reason = ""
|
|
21
|
+
self._cancelled_at = ""
|
|
22
|
+
self._external_cancellation_probe = external_cancellation_probe
|
|
23
|
+
|
|
24
|
+
def cancel(self, reason: str = "", *, cancelled_at: str = "") -> bool:
|
|
25
|
+
normalized_reason = reason.strip()
|
|
26
|
+
normalized_cancelled_at = cancelled_at.strip()
|
|
27
|
+
with self._lock:
|
|
28
|
+
if self._event.is_set():
|
|
29
|
+
return False
|
|
30
|
+
self._reason = normalized_reason
|
|
31
|
+
self._cancelled_at = (
|
|
32
|
+
normalized_cancelled_at or datetime.now(timezone.utc).isoformat()
|
|
33
|
+
)
|
|
34
|
+
self._event.set()
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
def is_cancelled(self) -> bool:
|
|
38
|
+
self._sync_external_cancellation()
|
|
39
|
+
return self._event.is_set()
|
|
40
|
+
|
|
41
|
+
def snapshot(self) -> Dict[str, str]:
|
|
42
|
+
self._sync_external_cancellation()
|
|
43
|
+
with self._lock:
|
|
44
|
+
return {
|
|
45
|
+
"cancelled": str(self._event.is_set()).lower(),
|
|
46
|
+
"reason": self._reason,
|
|
47
|
+
"cancelled_at": self._cancelled_at,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def _sync_external_cancellation(self) -> None:
|
|
51
|
+
if self._event.is_set() or self._external_cancellation_probe is None:
|
|
52
|
+
return
|
|
53
|
+
try:
|
|
54
|
+
cancellation = self._external_cancellation_probe()
|
|
55
|
+
except Exception:
|
|
56
|
+
return
|
|
57
|
+
if not cancellation:
|
|
58
|
+
return
|
|
59
|
+
reason = cancellation.get("reason", cancellation.get("cancel_reason", ""))
|
|
60
|
+
cancelled_at = cancellation.get("cancelled_at", "")
|
|
61
|
+
self.cancel(
|
|
62
|
+
str(reason) if reason is not None else "",
|
|
63
|
+
cancelled_at=str(cancelled_at) if cancelled_at is not None else "",
|
|
64
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Callable, Dict, List, TypedDict
|
|
6
|
+
|
|
7
|
+
from kagent.runtime.cancellation import RuntimeCancellationToken
|
|
8
|
+
from kagent.runtime.policy import RuntimePolicy
|
|
9
|
+
from kagent.runtime.redaction import redact_runtime_text
|
|
10
|
+
from kagent.runtime.steering import RuntimeSteeringBuffer
|
|
11
|
+
from kagent.runtime.tools import RuntimeToolSpec
|
|
12
|
+
|
|
13
|
+
_CHECKPOINT_SECRET_KEY_PARTS = (
|
|
14
|
+
"api_key",
|
|
15
|
+
"apikey",
|
|
16
|
+
"authorization",
|
|
17
|
+
"bearer",
|
|
18
|
+
"password",
|
|
19
|
+
"secret",
|
|
20
|
+
"token",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
RuntimeEventSink = Callable[[Dict[str, Any]], None]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RuntimeGraphState(TypedDict, total=False):
|
|
27
|
+
goal: str
|
|
28
|
+
run_id: str
|
|
29
|
+
max_iterations: int
|
|
30
|
+
approved_action_ids: List[str]
|
|
31
|
+
metadata: Dict[str, str]
|
|
32
|
+
tags: List[str]
|
|
33
|
+
started_at: str
|
|
34
|
+
initial_events: List[Dict[str, Any]]
|
|
35
|
+
initial_hook_failure_count: int
|
|
36
|
+
initial_planner: Dict[str, Any]
|
|
37
|
+
initial_progress_events: List[Dict[str, Any]]
|
|
38
|
+
initial_progress_event_sink_failure_count: int
|
|
39
|
+
initial_action_prepared: Dict[str, Any]
|
|
40
|
+
initial_action_phase: Dict[str, Any]
|
|
41
|
+
initial_action_outcome: Dict[str, Any]
|
|
42
|
+
result: Dict[str, Any]
|
|
43
|
+
graph_phases: List[Dict[str, str]]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RuntimeGraphContext(TypedDict, total=False):
|
|
47
|
+
goal: str
|
|
48
|
+
provider: Any
|
|
49
|
+
policy: RuntimePolicy
|
|
50
|
+
tools: Dict[str, RuntimeToolSpec]
|
|
51
|
+
cancellation_token: RuntimeCancellationToken
|
|
52
|
+
steering_buffer: RuntimeSteeringBuffer
|
|
53
|
+
event_sink: RuntimeEventSink
|
|
54
|
+
hooks: List[Any]
|
|
55
|
+
runtime_workspace_dir: str
|
|
56
|
+
redis_url: str
|
|
57
|
+
milvus_url: str
|
|
58
|
+
embedding_base_url: str
|
|
59
|
+
embedding_api_key: str
|
|
60
|
+
embedding_model: str
|
|
61
|
+
embedding_timeout_seconds: float
|
|
62
|
+
embedding_max_retries: int
|
|
63
|
+
embedding_retry_backoff_seconds: float
|
|
64
|
+
external_backend_timeout_seconds: float
|
|
65
|
+
stream_answers: bool
|
|
66
|
+
planner_plan_cache: Dict[str, Dict[str, Any]]
|
|
67
|
+
prepared_action_cache: Dict[str, Dict[str, Any]]
|
|
68
|
+
executing_action_cache: Dict[str, Dict[str, Any]]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def checkpoint_safe_value(value: Any) -> Any:
|
|
72
|
+
if value is None or isinstance(value, (bool, int, float)):
|
|
73
|
+
return value
|
|
74
|
+
if isinstance(value, str):
|
|
75
|
+
return redact_runtime_text(value)
|
|
76
|
+
if isinstance(value, dict):
|
|
77
|
+
return {
|
|
78
|
+
redact_runtime_text(str(key)): checkpoint_safe_value(item)
|
|
79
|
+
for key, item in value.items()
|
|
80
|
+
}
|
|
81
|
+
if isinstance(value, (list, tuple)):
|
|
82
|
+
return [checkpoint_safe_value(item) for item in value]
|
|
83
|
+
return f"[unsupported {type(value).__name__}]"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def checkpoint_plan_projection(value: Any, *, key: str = "") -> tuple[Any, bool]:
|
|
87
|
+
if key and any(part in key.lower() for part in _CHECKPOINT_SECRET_KEY_PARTS):
|
|
88
|
+
return "[REDACTED]", True
|
|
89
|
+
if value is None or isinstance(value, (bool, int, float)):
|
|
90
|
+
return value, False
|
|
91
|
+
if isinstance(value, str):
|
|
92
|
+
redacted = redact_runtime_text(value)
|
|
93
|
+
return redacted, redacted != value
|
|
94
|
+
if isinstance(value, dict):
|
|
95
|
+
projection: Dict[str, Any] = {}
|
|
96
|
+
changed = False
|
|
97
|
+
for raw_key, item in value.items():
|
|
98
|
+
projected_key = str(raw_key)
|
|
99
|
+
projected_item, item_changed = checkpoint_plan_projection(
|
|
100
|
+
item,
|
|
101
|
+
key=projected_key,
|
|
102
|
+
)
|
|
103
|
+
projection[projected_key] = projected_item
|
|
104
|
+
changed = changed or item_changed
|
|
105
|
+
return projection, changed
|
|
106
|
+
if isinstance(value, (list, tuple)):
|
|
107
|
+
projection = []
|
|
108
|
+
changed = False
|
|
109
|
+
for item in value:
|
|
110
|
+
projected_item, item_changed = checkpoint_plan_projection(item)
|
|
111
|
+
projection.append(projected_item)
|
|
112
|
+
changed = changed or item_changed
|
|
113
|
+
return projection, changed
|
|
114
|
+
return f"[unsupported {type(value).__name__}]", True
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def append_graph_phase(
|
|
118
|
+
phases: List[Dict[str, str]] | None,
|
|
119
|
+
node: str,
|
|
120
|
+
started_at: str,
|
|
121
|
+
started_timer: float,
|
|
122
|
+
) -> List[Dict[str, str]]:
|
|
123
|
+
return [
|
|
124
|
+
*(phases or []),
|
|
125
|
+
{
|
|
126
|
+
"node": node,
|
|
127
|
+
"status": "ok",
|
|
128
|
+
**timing_fields(started_at, started_timer),
|
|
129
|
+
},
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def utc_timestamp() -> str:
|
|
134
|
+
return datetime.now(timezone.utc).isoformat()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def timing_fields(started_at: str, started_timer: float) -> Dict[str, str]:
|
|
138
|
+
return {
|
|
139
|
+
"started_at": started_at,
|
|
140
|
+
"completed_at": utc_timestamp(),
|
|
141
|
+
"duration_seconds": duration_since(started_timer),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def duration_since(started_at: float) -> str:
|
|
146
|
+
return f"{time.perf_counter() - started_at:.4f}"
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import tempfile
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
DIRECTORY_MODE = 0o700
|
|
15
|
+
FILE_MODE = 0o600
|
|
16
|
+
MANIFEST_VERSION = 1
|
|
17
|
+
_SIGNING_KEY_NAME = ".checkpoint-signing-key"
|
|
18
|
+
_CHECKPOINT_ID_PATTERN = re.compile(
|
|
19
|
+
r"^\d{8}T\d{6}\.\d{6}Z-[0-9a-f]{12}$"
|
|
20
|
+
)
|
|
21
|
+
_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def new_checkpoint_id() -> str:
|
|
25
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ")
|
|
26
|
+
return f"{timestamp}-{secrets.token_hex(6)}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_checkpoint_id(checkpoint_id: str) -> None:
|
|
30
|
+
if not isinstance(checkpoint_id, str) or not _CHECKPOINT_ID_PATTERN.fullmatch(
|
|
31
|
+
checkpoint_id
|
|
32
|
+
):
|
|
33
|
+
raise ValueError("invalid checkpoint_id")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_checkpoint_target(workspace_root: Path, relative_path: Any) -> Path:
|
|
37
|
+
if not isinstance(relative_path, str):
|
|
38
|
+
raise ValueError("checkpoint manifest is invalid")
|
|
39
|
+
candidate = Path(relative_path)
|
|
40
|
+
if candidate.is_absolute() or any(
|
|
41
|
+
part in {"", ".", ".."} for part in candidate.parts
|
|
42
|
+
):
|
|
43
|
+
raise ValueError("checkpoint path must stay inside the workspace")
|
|
44
|
+
target = workspace_root.joinpath(*candidate.parts)
|
|
45
|
+
try:
|
|
46
|
+
target.relative_to(workspace_root)
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
raise ValueError("checkpoint path must stay inside the workspace") from exc
|
|
49
|
+
return target
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def ensure_store_layout(
|
|
53
|
+
state_root: Path,
|
|
54
|
+
workspace_directory: Path,
|
|
55
|
+
checkpoint_directory: Path,
|
|
56
|
+
) -> None:
|
|
57
|
+
reject_existing_symlink_chain(state_root)
|
|
58
|
+
directories = (
|
|
59
|
+
state_root,
|
|
60
|
+
workspace_directory,
|
|
61
|
+
workspace_directory / "checkpoints",
|
|
62
|
+
checkpoint_directory,
|
|
63
|
+
)
|
|
64
|
+
for directory in directories:
|
|
65
|
+
if directory.exists() and directory.is_symlink():
|
|
66
|
+
raise ValueError("checkpoint path must not contain symlinks")
|
|
67
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
directory.chmod(DIRECTORY_MODE)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def reject_existing_symlink_chain(path: Path) -> None:
|
|
72
|
+
absolute_path = path.absolute()
|
|
73
|
+
current = Path(absolute_path.anchor)
|
|
74
|
+
for part in absolute_path.parts[1:]:
|
|
75
|
+
current = current / part
|
|
76
|
+
if current.exists() and current.is_symlink():
|
|
77
|
+
raise ValueError("checkpoint path must not contain symlinks")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def reject_symlink(path: Path) -> None:
|
|
81
|
+
if path.is_symlink():
|
|
82
|
+
raise ValueError("checkpoint path must not contain symlinks")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def remove_checkpoint_directory(checkpoint_directory: Path) -> None:
|
|
86
|
+
reject_symlink(checkpoint_directory)
|
|
87
|
+
for path in checkpoint_directory.iterdir():
|
|
88
|
+
reject_symlink(path)
|
|
89
|
+
if not path.is_file():
|
|
90
|
+
raise ValueError("checkpoint directory contains an unexpected entry")
|
|
91
|
+
path.unlink()
|
|
92
|
+
checkpoint_directory.rmdir()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def atomic_write_text(target: Path, content: str) -> None:
|
|
96
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
97
|
+
prefix=f".{target.name}.",
|
|
98
|
+
suffix=".tmp",
|
|
99
|
+
dir=target.parent,
|
|
100
|
+
)
|
|
101
|
+
temporary_path = Path(temporary_name)
|
|
102
|
+
try:
|
|
103
|
+
os.chmod(temporary_path, FILE_MODE)
|
|
104
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
105
|
+
descriptor = -1
|
|
106
|
+
handle.write(content)
|
|
107
|
+
handle.flush()
|
|
108
|
+
os.fsync(handle.fileno())
|
|
109
|
+
temporary_path.replace(target)
|
|
110
|
+
_fsync_directory(target.parent)
|
|
111
|
+
except Exception:
|
|
112
|
+
if descriptor != -1:
|
|
113
|
+
os.close(descriptor)
|
|
114
|
+
temporary_path.unlink(missing_ok=True)
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def write_signed_manifest(
|
|
119
|
+
target: Path,
|
|
120
|
+
manifest: dict[str, Any],
|
|
121
|
+
state_root: Path,
|
|
122
|
+
) -> None:
|
|
123
|
+
signing_key = load_or_create_signing_key(state_root)
|
|
124
|
+
atomic_write_text(
|
|
125
|
+
target,
|
|
126
|
+
json.dumps(
|
|
127
|
+
signed_manifest_payload(manifest, signing_key),
|
|
128
|
+
ensure_ascii=False,
|
|
129
|
+
separators=(",", ":"),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def read_signed_manifest(
|
|
135
|
+
manifest_path: Path,
|
|
136
|
+
state_root: Path,
|
|
137
|
+
checkpoint_id: str,
|
|
138
|
+
) -> dict[str, Any]:
|
|
139
|
+
try:
|
|
140
|
+
envelope = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
141
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
142
|
+
raise ValueError("checkpoint manifest is invalid") from exc
|
|
143
|
+
if (
|
|
144
|
+
not isinstance(envelope, dict)
|
|
145
|
+
or set(envelope) != {"manifest", "hmac_sha256"}
|
|
146
|
+
or not isinstance(envelope["manifest"], dict)
|
|
147
|
+
or not isinstance(envelope["hmac_sha256"], str)
|
|
148
|
+
):
|
|
149
|
+
raise ValueError("checkpoint manifest is invalid")
|
|
150
|
+
manifest = envelope["manifest"]
|
|
151
|
+
expected_hmac = _manifest_hmac(manifest, read_signing_key(state_root))
|
|
152
|
+
if not hmac.compare_digest(envelope["hmac_sha256"], expected_hmac):
|
|
153
|
+
raise ValueError("checkpoint manifest integrity check failed")
|
|
154
|
+
_validate_manifest(manifest, checkpoint_id)
|
|
155
|
+
return manifest
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def signed_manifest_payload(
|
|
159
|
+
manifest: dict[str, Any],
|
|
160
|
+
signing_key: bytes,
|
|
161
|
+
) -> dict[str, Any]:
|
|
162
|
+
return {
|
|
163
|
+
"manifest": manifest,
|
|
164
|
+
"hmac_sha256": _manifest_hmac(manifest, signing_key),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def load_or_create_signing_key(state_root: Path) -> bytes:
|
|
169
|
+
key_path = state_root / _SIGNING_KEY_NAME
|
|
170
|
+
reject_symlink(key_path)
|
|
171
|
+
if key_path.exists():
|
|
172
|
+
return read_signing_key(state_root)
|
|
173
|
+
signing_key = secrets.token_bytes(32)
|
|
174
|
+
try:
|
|
175
|
+
descriptor = os.open(
|
|
176
|
+
key_path,
|
|
177
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
178
|
+
FILE_MODE,
|
|
179
|
+
)
|
|
180
|
+
except FileExistsError:
|
|
181
|
+
return read_signing_key(state_root)
|
|
182
|
+
try:
|
|
183
|
+
os.write(descriptor, signing_key)
|
|
184
|
+
os.fsync(descriptor)
|
|
185
|
+
finally:
|
|
186
|
+
os.close(descriptor)
|
|
187
|
+
_fsync_directory(state_root)
|
|
188
|
+
return signing_key
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def read_signing_key(state_root: Path) -> bytes:
|
|
192
|
+
key_path = state_root / _SIGNING_KEY_NAME
|
|
193
|
+
reject_existing_symlink_chain(state_root)
|
|
194
|
+
reject_symlink(key_path)
|
|
195
|
+
if not key_path.is_file():
|
|
196
|
+
raise ValueError("checkpoint signing key is missing")
|
|
197
|
+
signing_key = key_path.read_bytes()
|
|
198
|
+
if len(signing_key) != 32:
|
|
199
|
+
raise ValueError("checkpoint signing key is invalid")
|
|
200
|
+
return signing_key
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _manifest_hmac(manifest: dict[str, Any], signing_key: bytes) -> str:
|
|
204
|
+
canonical = json.dumps(
|
|
205
|
+
manifest,
|
|
206
|
+
ensure_ascii=False,
|
|
207
|
+
separators=(",", ":"),
|
|
208
|
+
sort_keys=True,
|
|
209
|
+
).encode("utf-8")
|
|
210
|
+
return hmac.new(signing_key, canonical, hashlib.sha256).hexdigest()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _validate_manifest(manifest: Any, checkpoint_id: str) -> None:
|
|
214
|
+
if (
|
|
215
|
+
not isinstance(manifest, dict)
|
|
216
|
+
or manifest.get("version") != MANIFEST_VERSION
|
|
217
|
+
or manifest.get("checkpoint_id") != checkpoint_id
|
|
218
|
+
or manifest.get("status") not in {"prepared", "committed"}
|
|
219
|
+
or not isinstance(manifest.get("created_at"), str)
|
|
220
|
+
or not isinstance(manifest.get("entries"), list)
|
|
221
|
+
or not manifest["entries"]
|
|
222
|
+
):
|
|
223
|
+
raise ValueError("checkpoint manifest is invalid")
|
|
224
|
+
paths = []
|
|
225
|
+
for entry in manifest["entries"]:
|
|
226
|
+
if not isinstance(entry, dict) or set(entry) != {"path", "before", "after"}:
|
|
227
|
+
raise ValueError("checkpoint manifest is invalid")
|
|
228
|
+
if not isinstance(entry["path"], str):
|
|
229
|
+
raise ValueError("checkpoint manifest is invalid")
|
|
230
|
+
_validate_state(entry["before"])
|
|
231
|
+
_validate_state(entry["after"])
|
|
232
|
+
paths.append(entry["path"])
|
|
233
|
+
if len(paths) != len(set(paths)):
|
|
234
|
+
raise ValueError("checkpoint manifest contains duplicate paths")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _validate_state(state: Any) -> None:
|
|
238
|
+
if not isinstance(state, dict) or set(state) != {
|
|
239
|
+
"exists",
|
|
240
|
+
"sha256",
|
|
241
|
+
"mode",
|
|
242
|
+
"blob",
|
|
243
|
+
}:
|
|
244
|
+
raise ValueError("checkpoint manifest is invalid")
|
|
245
|
+
exists = state["exists"]
|
|
246
|
+
mode = state["mode"]
|
|
247
|
+
blob = state["blob"]
|
|
248
|
+
sha256 = state["sha256"]
|
|
249
|
+
if not isinstance(exists, bool):
|
|
250
|
+
raise ValueError("checkpoint manifest is invalid")
|
|
251
|
+
if isinstance(mode, bool) or not isinstance(mode, int) or not 0 <= mode <= 0o7777:
|
|
252
|
+
raise ValueError("checkpoint manifest is invalid")
|
|
253
|
+
if not isinstance(blob, str) or not isinstance(sha256, str):
|
|
254
|
+
raise ValueError("checkpoint manifest is invalid")
|
|
255
|
+
if exists:
|
|
256
|
+
if Path(blob).name != blob or not blob or not _SHA256_PATTERN.fullmatch(sha256):
|
|
257
|
+
raise ValueError("checkpoint manifest is invalid")
|
|
258
|
+
elif blob or sha256:
|
|
259
|
+
raise ValueError("checkpoint manifest is invalid")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _fsync_directory(directory: Path) -> None:
|
|
263
|
+
flags = os.O_RDONLY
|
|
264
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
265
|
+
flags |= os.O_DIRECTORY
|
|
266
|
+
descriptor = os.open(directory, flags)
|
|
267
|
+
try:
|
|
268
|
+
os.fsync(descriptor)
|
|
269
|
+
finally:
|
|
270
|
+
os.close(descriptor)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class RuntimeContextStats:
|
|
9
|
+
truncated_string_count: int = 0
|
|
10
|
+
truncated_chars: int = 0
|
|
11
|
+
omitted_artifact_content_count: int = 0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RuntimeContextManager:
|
|
15
|
+
def __init__(self, *, max_string_chars: int = 500) -> None:
|
|
16
|
+
if max_string_chars < 1:
|
|
17
|
+
raise ValueError("max_string_chars must be positive")
|
|
18
|
+
self.max_string_chars = max_string_chars
|
|
19
|
+
self._stats = RuntimeContextStats()
|
|
20
|
+
|
|
21
|
+
def compact_observation_output(self, output: Any) -> Any:
|
|
22
|
+
if isinstance(output, dict) and str(output.get("artifact_id", "")).strip():
|
|
23
|
+
self._stats.omitted_artifact_content_count += 1
|
|
24
|
+
return self._artifact_prompt_metadata(output)
|
|
25
|
+
return self.compact_value(output)
|
|
26
|
+
|
|
27
|
+
def compact_value(self, value: Any) -> Any:
|
|
28
|
+
if isinstance(value, str):
|
|
29
|
+
if len(value) <= self.max_string_chars:
|
|
30
|
+
return value
|
|
31
|
+
self._stats.truncated_string_count += 1
|
|
32
|
+
truncated_chars = len(value) - self.max_string_chars
|
|
33
|
+
self._stats.truncated_chars += truncated_chars
|
|
34
|
+
return {
|
|
35
|
+
"text_prefix": value[: self.max_string_chars],
|
|
36
|
+
"original_chars": len(value),
|
|
37
|
+
"truncated_chars": truncated_chars,
|
|
38
|
+
}
|
|
39
|
+
if isinstance(value, dict):
|
|
40
|
+
return {key: self.compact_value(item) for key, item in value.items()}
|
|
41
|
+
if isinstance(value, list):
|
|
42
|
+
return [self.compact_value(item) for item in value]
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
def report(self) -> Dict[str, Any]:
|
|
46
|
+
return {
|
|
47
|
+
"strategy": "runtime_context_manager",
|
|
48
|
+
"artifact_content_omitted": True,
|
|
49
|
+
"max_string_chars": str(self.max_string_chars),
|
|
50
|
+
"long_string_shape": "text_prefix/original_chars/truncated_chars",
|
|
51
|
+
"truncated_string_count": str(self._stats.truncated_string_count),
|
|
52
|
+
"truncated_chars": str(self._stats.truncated_chars),
|
|
53
|
+
"omitted_artifact_content_count": str(
|
|
54
|
+
self._stats.omitted_artifact_content_count
|
|
55
|
+
),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def _artifact_prompt_metadata(self, output: Dict[str, Any]) -> Dict[str, Any]:
|
|
59
|
+
metadata = {
|
|
60
|
+
key: output[key]
|
|
61
|
+
for key in ["artifact_id", "title", "kind", "format", "tags", "bytes"]
|
|
62
|
+
if key in output
|
|
63
|
+
}
|
|
64
|
+
metadata["content_omitted"] = True
|
|
65
|
+
return metadata
|