weakincentives 0.9.0__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.
- weakincentives/__init__.py +67 -0
- weakincentives/adapters/__init__.py +37 -0
- weakincentives/adapters/_names.py +32 -0
- weakincentives/adapters/_provider_protocols.py +69 -0
- weakincentives/adapters/_tool_messages.py +80 -0
- weakincentives/adapters/core.py +102 -0
- weakincentives/adapters/litellm.py +254 -0
- weakincentives/adapters/openai.py +254 -0
- weakincentives/adapters/shared.py +1021 -0
- weakincentives/cli/__init__.py +23 -0
- weakincentives/cli/wink.py +58 -0
- weakincentives/dbc/__init__.py +412 -0
- weakincentives/deadlines.py +58 -0
- weakincentives/prompt/__init__.py +105 -0
- weakincentives/prompt/_generic_params_specializer.py +64 -0
- weakincentives/prompt/_normalization.py +48 -0
- weakincentives/prompt/_overrides_protocols.py +33 -0
- weakincentives/prompt/_types.py +34 -0
- weakincentives/prompt/chapter.py +146 -0
- weakincentives/prompt/composition.py +281 -0
- weakincentives/prompt/errors.py +57 -0
- weakincentives/prompt/markdown.py +108 -0
- weakincentives/prompt/overrides/__init__.py +59 -0
- weakincentives/prompt/overrides/_fs.py +164 -0
- weakincentives/prompt/overrides/inspection.py +141 -0
- weakincentives/prompt/overrides/local_store.py +275 -0
- weakincentives/prompt/overrides/validation.py +534 -0
- weakincentives/prompt/overrides/versioning.py +269 -0
- weakincentives/prompt/prompt.py +353 -0
- weakincentives/prompt/protocols.py +103 -0
- weakincentives/prompt/registry.py +375 -0
- weakincentives/prompt/rendering.py +288 -0
- weakincentives/prompt/response_format.py +60 -0
- weakincentives/prompt/section.py +166 -0
- weakincentives/prompt/structured_output.py +179 -0
- weakincentives/prompt/tool.py +397 -0
- weakincentives/prompt/tool_result.py +30 -0
- weakincentives/py.typed +0 -0
- weakincentives/runtime/__init__.py +82 -0
- weakincentives/runtime/events/__init__.py +126 -0
- weakincentives/runtime/events/_types.py +110 -0
- weakincentives/runtime/logging.py +284 -0
- weakincentives/runtime/session/__init__.py +46 -0
- weakincentives/runtime/session/_slice_types.py +24 -0
- weakincentives/runtime/session/_types.py +55 -0
- weakincentives/runtime/session/dataclasses.py +29 -0
- weakincentives/runtime/session/protocols.py +34 -0
- weakincentives/runtime/session/reducer_context.py +40 -0
- weakincentives/runtime/session/reducers.py +82 -0
- weakincentives/runtime/session/selectors.py +56 -0
- weakincentives/runtime/session/session.py +387 -0
- weakincentives/runtime/session/snapshots.py +310 -0
- weakincentives/serde/__init__.py +19 -0
- weakincentives/serde/_utils.py +240 -0
- weakincentives/serde/dataclass_serde.py +55 -0
- weakincentives/serde/dump.py +189 -0
- weakincentives/serde/parse.py +417 -0
- weakincentives/serde/schema.py +260 -0
- weakincentives/tools/__init__.py +154 -0
- weakincentives/tools/_context.py +38 -0
- weakincentives/tools/asteval.py +853 -0
- weakincentives/tools/errors.py +26 -0
- weakincentives/tools/planning.py +831 -0
- weakincentives/tools/podman.py +1655 -0
- weakincentives/tools/subagents.py +346 -0
- weakincentives/tools/vfs.py +1390 -0
- weakincentives/types/__init__.py +35 -0
- weakincentives/types/json.py +45 -0
- weakincentives-0.9.0.dist-info/METADATA +775 -0
- weakincentives-0.9.0.dist-info/RECORD +73 -0
- weakincentives-0.9.0.dist-info/WHEEL +4 -0
- weakincentives-0.9.0.dist-info/entry_points.txt +2 -0
- weakincentives-0.9.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
2
|
+
# you may not use this file except in compliance with the License.
|
|
3
|
+
# You may obtain a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
9
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
10
|
+
# See the License for the specific language governing permissions and
|
|
11
|
+
# limitations under the License.
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import textwrap
|
|
16
|
+
from collections.abc import Callable, Sequence
|
|
17
|
+
from dataclasses import fields, is_dataclass
|
|
18
|
+
from string import Template
|
|
19
|
+
from typing import Any, TypeVar, override
|
|
20
|
+
|
|
21
|
+
from ._types import SupportsDataclass
|
|
22
|
+
from .errors import PromptRenderError
|
|
23
|
+
from .section import Section
|
|
24
|
+
|
|
25
|
+
MarkdownParamsT = TypeVar("MarkdownParamsT", bound=SupportsDataclass, covariant=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MarkdownSection(Section[MarkdownParamsT]):
|
|
29
|
+
"""Render markdown content using :class:`string.Template`."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
title: str,
|
|
35
|
+
template: str,
|
|
36
|
+
key: str,
|
|
37
|
+
default_params: MarkdownParamsT | None = None,
|
|
38
|
+
children: Sequence[Section[SupportsDataclass]] | None = None,
|
|
39
|
+
enabled: Callable[[SupportsDataclass], bool] | None = None,
|
|
40
|
+
tools: Sequence[object] | None = None,
|
|
41
|
+
accepts_overrides: bool = True,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.template = template
|
|
44
|
+
super().__init__(
|
|
45
|
+
title=title,
|
|
46
|
+
key=key,
|
|
47
|
+
default_params=default_params,
|
|
48
|
+
children=children,
|
|
49
|
+
enabled=enabled,
|
|
50
|
+
tools=tools,
|
|
51
|
+
accepts_overrides=accepts_overrides,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@override
|
|
55
|
+
def render(self, params: SupportsDataclass | None, depth: int) -> str:
|
|
56
|
+
return self.render_with_template(self.template, params, depth)
|
|
57
|
+
|
|
58
|
+
def render_with_template(
|
|
59
|
+
self, template_text: str, params: SupportsDataclass | None, depth: int
|
|
60
|
+
) -> str:
|
|
61
|
+
heading_level = "#" * (depth + 2)
|
|
62
|
+
heading = f"{heading_level} {self.title.strip()}"
|
|
63
|
+
template = Template(textwrap.dedent(template_text).strip())
|
|
64
|
+
try:
|
|
65
|
+
normalized_params = self._normalize_params(params)
|
|
66
|
+
rendered_body = template.substitute(normalized_params)
|
|
67
|
+
except KeyError as error:
|
|
68
|
+
missing = error.args[0]
|
|
69
|
+
raise PromptRenderError(
|
|
70
|
+
"Missing placeholder during render.",
|
|
71
|
+
placeholder=str(missing),
|
|
72
|
+
) from error
|
|
73
|
+
if rendered_body:
|
|
74
|
+
return f"{heading}\n\n{rendered_body.strip()}"
|
|
75
|
+
return heading
|
|
76
|
+
|
|
77
|
+
@override
|
|
78
|
+
def placeholder_names(self) -> set[str]:
|
|
79
|
+
template = Template(textwrap.dedent(self.template).strip())
|
|
80
|
+
placeholders: set[str] = set()
|
|
81
|
+
for match in template.pattern.finditer(template.template):
|
|
82
|
+
named = match.group("named")
|
|
83
|
+
if named:
|
|
84
|
+
placeholders.add(named)
|
|
85
|
+
continue
|
|
86
|
+
braced = match.group("braced")
|
|
87
|
+
if braced:
|
|
88
|
+
placeholders.add(braced)
|
|
89
|
+
return placeholders
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _normalize_params(params: SupportsDataclass | None) -> dict[str, Any]:
|
|
93
|
+
if params is None:
|
|
94
|
+
return {}
|
|
95
|
+
if not is_dataclass(params) or isinstance(params, type):
|
|
96
|
+
raise PromptRenderError(
|
|
97
|
+
"Section params must be a dataclass instance.",
|
|
98
|
+
dataclass_type=type(params),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return {field.name: getattr(params, field.name) for field in fields(params)}
|
|
102
|
+
|
|
103
|
+
@override
|
|
104
|
+
def original_body_template(self) -> str:
|
|
105
|
+
return self.template
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
__all__ = ["MarkdownSection"]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
2
|
+
# you may not use this file except in compliance with the License.
|
|
3
|
+
# You may obtain a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
9
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
10
|
+
# See the License for the specific language governing permissions and
|
|
11
|
+
# limitations under the License.
|
|
12
|
+
|
|
13
|
+
"""Prompt override infrastructure used by :mod:`weakincentives.prompt`."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .inspection import (
|
|
18
|
+
OverrideFileMetadata,
|
|
19
|
+
iter_override_files,
|
|
20
|
+
resolve_overrides_root,
|
|
21
|
+
)
|
|
22
|
+
from .local_store import LocalPromptOverridesStore
|
|
23
|
+
from .validation import filter_override_for_descriptor
|
|
24
|
+
from .versioning import (
|
|
25
|
+
HexDigest,
|
|
26
|
+
PromptDescriptor,
|
|
27
|
+
PromptLike,
|
|
28
|
+
PromptOverride,
|
|
29
|
+
PromptOverridesError,
|
|
30
|
+
PromptOverridesStore,
|
|
31
|
+
SectionDescriptor,
|
|
32
|
+
SectionOverride,
|
|
33
|
+
ToolDescriptor,
|
|
34
|
+
ToolOverride,
|
|
35
|
+
ensure_hex_digest,
|
|
36
|
+
hash_json,
|
|
37
|
+
hash_text,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"HexDigest",
|
|
42
|
+
"LocalPromptOverridesStore",
|
|
43
|
+
"OverrideFileMetadata",
|
|
44
|
+
"PromptDescriptor",
|
|
45
|
+
"PromptLike",
|
|
46
|
+
"PromptOverride",
|
|
47
|
+
"PromptOverridesError",
|
|
48
|
+
"PromptOverridesStore",
|
|
49
|
+
"SectionDescriptor",
|
|
50
|
+
"SectionOverride",
|
|
51
|
+
"ToolDescriptor",
|
|
52
|
+
"ToolOverride",
|
|
53
|
+
"ensure_hex_digest",
|
|
54
|
+
"filter_override_for_descriptor",
|
|
55
|
+
"hash_json",
|
|
56
|
+
"hash_text",
|
|
57
|
+
"iter_override_files",
|
|
58
|
+
"resolve_overrides_root",
|
|
59
|
+
]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
2
|
+
# you may not use this file except in compliance with the License.
|
|
3
|
+
# You may obtain a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
9
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
10
|
+
# See the License for the specific language governing permissions and
|
|
11
|
+
# limitations under the License.
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
# Bandit false positive: git invocation uses explicit arguments for root
|
|
19
|
+
# discovery.
|
|
20
|
+
import subprocess # nosec B404
|
|
21
|
+
import tempfile
|
|
22
|
+
from collections.abc import Iterator, Mapping
|
|
23
|
+
from contextlib import contextmanager
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from threading import RLock
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .versioning import PromptOverridesError
|
|
29
|
+
|
|
30
|
+
_IDENTIFIER_PATTERN = r"^[a-z0-9][a-z0-9._-]{0,63}$"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OverrideFilesystem:
|
|
34
|
+
"""Handle filesystem interactions for prompt overrides."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
explicit_root: Path | None,
|
|
40
|
+
overrides_relative_path: Path,
|
|
41
|
+
) -> None:
|
|
42
|
+
super().__init__()
|
|
43
|
+
self._explicit_root = explicit_root
|
|
44
|
+
self._overrides_relative_path = overrides_relative_path
|
|
45
|
+
self._root_lock = RLock()
|
|
46
|
+
self._root: Path | None = None
|
|
47
|
+
self._path_locks: dict[Path, RLock] = {}
|
|
48
|
+
self._path_locks_lock = RLock()
|
|
49
|
+
|
|
50
|
+
def resolve_root(self) -> Path:
|
|
51
|
+
"""Resolve the repository root for overrides operations."""
|
|
52
|
+
|
|
53
|
+
with self._root_lock:
|
|
54
|
+
if self._root is not None:
|
|
55
|
+
return self._root
|
|
56
|
+
if self._explicit_root is not None:
|
|
57
|
+
self._root = self._explicit_root
|
|
58
|
+
return self._root
|
|
59
|
+
|
|
60
|
+
git_root = self._git_toplevel()
|
|
61
|
+
if git_root is not None:
|
|
62
|
+
self._root = git_root
|
|
63
|
+
return self._root
|
|
64
|
+
|
|
65
|
+
traversal_root = self._walk_to_git_root()
|
|
66
|
+
if traversal_root is None:
|
|
67
|
+
raise PromptOverridesError(
|
|
68
|
+
"Failed to locate repository root. Provide root_path explicitly."
|
|
69
|
+
)
|
|
70
|
+
self._root = traversal_root
|
|
71
|
+
return self._root
|
|
72
|
+
|
|
73
|
+
def overrides_dir(self) -> Path:
|
|
74
|
+
return self.resolve_root() / self._overrides_relative_path
|
|
75
|
+
|
|
76
|
+
def override_file_path(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
ns: str,
|
|
80
|
+
prompt_key: str,
|
|
81
|
+
tag: str,
|
|
82
|
+
) -> Path:
|
|
83
|
+
segments = self._split_namespace(ns)
|
|
84
|
+
prompt_component = self.validate_identifier(prompt_key, "prompt key")
|
|
85
|
+
tag_component = self.validate_identifier(tag, "tag")
|
|
86
|
+
directory = self.overrides_dir().joinpath(*segments, prompt_component)
|
|
87
|
+
return directory / f"{tag_component}.json"
|
|
88
|
+
|
|
89
|
+
def validate_identifier(self, value: str, label: str) -> str:
|
|
90
|
+
stripped = value.strip()
|
|
91
|
+
if not stripped:
|
|
92
|
+
raise PromptOverridesError(
|
|
93
|
+
f"{label.capitalize()} must be a non-empty string."
|
|
94
|
+
)
|
|
95
|
+
pattern = _IDENTIFIER_PATTERN
|
|
96
|
+
if not re.fullmatch(pattern, stripped):
|
|
97
|
+
raise PromptOverridesError(
|
|
98
|
+
f"{label.capitalize()} must match pattern {pattern}."
|
|
99
|
+
)
|
|
100
|
+
return stripped
|
|
101
|
+
|
|
102
|
+
@contextmanager
|
|
103
|
+
def locked_override_path(self, file_path: Path) -> Iterator[None]:
|
|
104
|
+
lock = self._get_override_path_lock(file_path)
|
|
105
|
+
with lock:
|
|
106
|
+
yield
|
|
107
|
+
|
|
108
|
+
def atomic_write(self, file_path: Path, payload: Mapping[str, Any]) -> None:
|
|
109
|
+
directory = file_path.parent
|
|
110
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
with tempfile.NamedTemporaryFile(
|
|
112
|
+
"w", dir=directory, delete=False, encoding="utf-8"
|
|
113
|
+
) as handle:
|
|
114
|
+
json.dump(payload, handle, indent=2, sort_keys=True)
|
|
115
|
+
_ = handle.write("\n")
|
|
116
|
+
temp_name = Path(handle.name)
|
|
117
|
+
_ = Path(temp_name).replace(file_path)
|
|
118
|
+
|
|
119
|
+
def _get_override_path_lock(self, file_path: Path) -> RLock:
|
|
120
|
+
with self._path_locks_lock:
|
|
121
|
+
lock = self._path_locks.get(file_path)
|
|
122
|
+
if lock is None:
|
|
123
|
+
lock = RLock()
|
|
124
|
+
self._path_locks[file_path] = lock
|
|
125
|
+
return lock
|
|
126
|
+
|
|
127
|
+
def _split_namespace(self, ns: str) -> tuple[str, ...]:
|
|
128
|
+
stripped = ns.strip()
|
|
129
|
+
if not stripped:
|
|
130
|
+
raise PromptOverridesError("Namespace must be a non-empty string.")
|
|
131
|
+
segments = tuple(part.strip() for part in stripped.split("/") if part.strip())
|
|
132
|
+
if not segments:
|
|
133
|
+
raise PromptOverridesError("Namespace must contain at least one segment.")
|
|
134
|
+
return tuple(
|
|
135
|
+
self.validate_identifier(segment, "namespace segment")
|
|
136
|
+
for segment in segments
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def _git_toplevel(self) -> Path | None:
|
|
140
|
+
try:
|
|
141
|
+
# Bandit false positive: git invocation uses explicit arguments.
|
|
142
|
+
result = subprocess.run( # nosec B603 B607
|
|
143
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
144
|
+
check=True,
|
|
145
|
+
capture_output=True,
|
|
146
|
+
text=True,
|
|
147
|
+
)
|
|
148
|
+
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
149
|
+
return None
|
|
150
|
+
path = result.stdout.strip()
|
|
151
|
+
if not path:
|
|
152
|
+
return None
|
|
153
|
+
return Path(path).resolve()
|
|
154
|
+
|
|
155
|
+
def _walk_to_git_root(self) -> Path | None:
|
|
156
|
+
current = Path.cwd().resolve()
|
|
157
|
+
for candidate in (current, *current.parents):
|
|
158
|
+
git_dir = candidate / ".git"
|
|
159
|
+
if git_dir.exists():
|
|
160
|
+
return candidate
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
__all__ = ["OverrideFilesystem"]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
2
|
+
# you may not use this file except in compliance with the License.
|
|
3
|
+
# You may obtain a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
9
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
10
|
+
# See the License for the specific language governing permissions and
|
|
11
|
+
# limitations under the License.
|
|
12
|
+
|
|
13
|
+
"""Helpers for inspecting prompt override files on disk."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from hashlib import sha256
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, cast
|
|
23
|
+
|
|
24
|
+
from .local_store import LocalPromptOverridesStore
|
|
25
|
+
from .versioning import HexDigest, PromptOverridesError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class OverrideFileMetadata:
|
|
30
|
+
"""Summary information about a prompt override file."""
|
|
31
|
+
|
|
32
|
+
path: Path
|
|
33
|
+
relative_segments: tuple[str, ...]
|
|
34
|
+
modified_time: float
|
|
35
|
+
content_hash: HexDigest
|
|
36
|
+
section_count: int
|
|
37
|
+
tool_count: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _InspectableLocalStore(LocalPromptOverridesStore):
|
|
41
|
+
"""Expose internal path resolution for read-only inspection helpers."""
|
|
42
|
+
|
|
43
|
+
def overrides_dir(self) -> Path:
|
|
44
|
+
return self._filesystem.overrides_dir()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_overrides_root(
|
|
48
|
+
*,
|
|
49
|
+
root_path: str | Path | None = None,
|
|
50
|
+
overrides_relative_path: str | Path | None = None,
|
|
51
|
+
) -> Path:
|
|
52
|
+
"""Return the directory that stores local prompt overrides."""
|
|
53
|
+
|
|
54
|
+
if overrides_relative_path is None:
|
|
55
|
+
store = _InspectableLocalStore(root_path=root_path)
|
|
56
|
+
else:
|
|
57
|
+
store = _InspectableLocalStore(
|
|
58
|
+
root_path=root_path,
|
|
59
|
+
overrides_relative_path=overrides_relative_path,
|
|
60
|
+
)
|
|
61
|
+
return store.overrides_dir()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def iter_override_files(
|
|
65
|
+
*,
|
|
66
|
+
overrides_root: Path | None = None,
|
|
67
|
+
root_path: str | Path | None = None,
|
|
68
|
+
overrides_relative_path: str | Path | None = None,
|
|
69
|
+
) -> Iterator[OverrideFileMetadata]:
|
|
70
|
+
"""Yield metadata for every JSON override file on disk."""
|
|
71
|
+
|
|
72
|
+
root = (
|
|
73
|
+
overrides_root
|
|
74
|
+
if overrides_root is not None
|
|
75
|
+
else resolve_overrides_root(
|
|
76
|
+
root_path=root_path, overrides_relative_path=overrides_relative_path
|
|
77
|
+
)
|
|
78
|
+
).resolve()
|
|
79
|
+
|
|
80
|
+
if not root.exists():
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
for file_path in sorted(root.rglob("*.json")):
|
|
84
|
+
if not file_path.is_file():
|
|
85
|
+
continue
|
|
86
|
+
yield _build_metadata(file_path, root)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _build_metadata(file_path: Path, overrides_root: Path) -> OverrideFileMetadata:
|
|
90
|
+
try:
|
|
91
|
+
raw = file_path.read_bytes()
|
|
92
|
+
except OSError as error: # pragma: no cover - exercised in practice.
|
|
93
|
+
raise PromptOverridesError(
|
|
94
|
+
f"Failed to read override file: {file_path}"
|
|
95
|
+
) from error
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
stat_result = file_path.stat()
|
|
99
|
+
except OSError as error: # pragma: no cover - exercised in practice.
|
|
100
|
+
raise PromptOverridesError(
|
|
101
|
+
f"Failed to stat override file: {file_path}"
|
|
102
|
+
) from error
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
payload_obj = json.loads(raw.decode("utf-8"))
|
|
106
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
107
|
+
raise PromptOverridesError(
|
|
108
|
+
f"Failed to parse prompt override JSON: {file_path}"
|
|
109
|
+
) from error
|
|
110
|
+
|
|
111
|
+
if not isinstance(payload_obj, dict):
|
|
112
|
+
raise PromptOverridesError(
|
|
113
|
+
f"Prompt override payload must be an object: {file_path}"
|
|
114
|
+
)
|
|
115
|
+
payload = cast(dict[str, Any], payload_obj)
|
|
116
|
+
|
|
117
|
+
sections_obj = payload.get("sections", {})
|
|
118
|
+
if not isinstance(sections_obj, dict):
|
|
119
|
+
raise PromptOverridesError(
|
|
120
|
+
f"Override file sections must be a mapping: {file_path}"
|
|
121
|
+
)
|
|
122
|
+
sections = cast(dict[str, Any], sections_obj)
|
|
123
|
+
|
|
124
|
+
tools_obj = payload.get("tools", {})
|
|
125
|
+
if not isinstance(tools_obj, dict):
|
|
126
|
+
raise PromptOverridesError(
|
|
127
|
+
f"Override file tools must be a mapping: {file_path}"
|
|
128
|
+
)
|
|
129
|
+
tools = cast(dict[str, Any], tools_obj)
|
|
130
|
+
|
|
131
|
+
relative_segments = file_path.resolve().relative_to(overrides_root).parts
|
|
132
|
+
content_hash = HexDigest(sha256(raw).hexdigest())
|
|
133
|
+
|
|
134
|
+
return OverrideFileMetadata(
|
|
135
|
+
path=file_path.resolve(),
|
|
136
|
+
relative_segments=relative_segments,
|
|
137
|
+
modified_time=stat_result.st_mtime,
|
|
138
|
+
content_hash=content_hash,
|
|
139
|
+
section_count=len(sections),
|
|
140
|
+
tool_count=len(tools),
|
|
141
|
+
)
|