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,853 @@
|
|
|
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
|
+
"""Sandboxed Python expression evaluation backed by :mod:`asteval`."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import builtins
|
|
18
|
+
import contextlib
|
|
19
|
+
import io
|
|
20
|
+
import json
|
|
21
|
+
import math
|
|
22
|
+
import statistics
|
|
23
|
+
import threading
|
|
24
|
+
from collections.abc import Callable, Iterable, Mapping, MutableMapping, Sequence
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import UTC, datetime
|
|
27
|
+
from importlib import import_module
|
|
28
|
+
from types import MappingProxyType, ModuleType
|
|
29
|
+
from typing import Final, Literal, Protocol, TextIO, cast
|
|
30
|
+
|
|
31
|
+
from ..prompt.markdown import MarkdownSection
|
|
32
|
+
from ..prompt.tool import Tool, ToolContext, ToolResult
|
|
33
|
+
from ..runtime.logging import StructuredLogger, get_logger
|
|
34
|
+
from ..runtime.session import (
|
|
35
|
+
ReducerContextProtocol,
|
|
36
|
+
ReducerEvent,
|
|
37
|
+
Session,
|
|
38
|
+
TypedReducer,
|
|
39
|
+
select_latest,
|
|
40
|
+
)
|
|
41
|
+
from ._context import ensure_context_uses_session
|
|
42
|
+
from .errors import ToolValidationError
|
|
43
|
+
from .vfs import VfsFile, VfsPath, VirtualFileSystem
|
|
44
|
+
|
|
45
|
+
_LOGGER: StructuredLogger = get_logger(__name__, context={"component": "tools.asteval"})
|
|
46
|
+
|
|
47
|
+
_MAX_CODE_LENGTH: Final[int] = 2_000
|
|
48
|
+
_MAX_STREAM_LENGTH: Final[int] = 4_096
|
|
49
|
+
_MAX_WRITE_LENGTH: Final[int] = 48_000
|
|
50
|
+
_MAX_PATH_DEPTH: Final[int] = 16
|
|
51
|
+
_MAX_SEGMENT_LENGTH: Final[int] = 80
|
|
52
|
+
_ASCII: Final[str] = "ascii"
|
|
53
|
+
_TIMEOUT_SECONDS: Final[float] = 5.0
|
|
54
|
+
_MISSING_DEPENDENCY_MESSAGE: Final[str] = (
|
|
55
|
+
"Install weakincentives[asteval] to enable the Python evaluation tool."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
_SAFE_GLOBALS: Final[Mapping[str, object]] = MappingProxyType(
|
|
59
|
+
{
|
|
60
|
+
"abs": abs,
|
|
61
|
+
"len": len,
|
|
62
|
+
"min": min,
|
|
63
|
+
"max": max,
|
|
64
|
+
"print": print,
|
|
65
|
+
"range": range,
|
|
66
|
+
"round": round,
|
|
67
|
+
"sum": sum,
|
|
68
|
+
"str": str,
|
|
69
|
+
"math": math,
|
|
70
|
+
"statistics": MappingProxyType(
|
|
71
|
+
{
|
|
72
|
+
"mean": statistics.mean,
|
|
73
|
+
"median": statistics.median,
|
|
74
|
+
"pstdev": statistics.pstdev,
|
|
75
|
+
"stdev": statistics.stdev,
|
|
76
|
+
"variance": statistics.variance,
|
|
77
|
+
}
|
|
78
|
+
),
|
|
79
|
+
"PI": math.pi,
|
|
80
|
+
"TAU": math.tau,
|
|
81
|
+
"E": math.e,
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
_EVAL_TEMPLATE: Final[str] = (
|
|
86
|
+
"Use the Python evaluation tool for quick calculations and one-off scripts.\n"
|
|
87
|
+
"- Scripts run in a sandbox with a narrow set of safe builtins (abs, len, max, min,\n"
|
|
88
|
+
" print, range, round, sum, str) plus math/statistics helpers. Import statements\n"
|
|
89
|
+
" and other blocked nodes are stripped, so networking and host filesystem access\n"
|
|
90
|
+
" are unavailable.\n"
|
|
91
|
+
"- Keep code concise (<=2,000 characters) and avoid control characters other than\n"
|
|
92
|
+
" newlines or tabs.\n"
|
|
93
|
+
"- Pre-load files via `reads`, or call `read_text(path)` inside code to fetch VFS\n"
|
|
94
|
+
" files. Paths must be relative, use <=16 segments of <=80 ASCII characters, and\n"
|
|
95
|
+
" may not target a read and write in the same call.\n"
|
|
96
|
+
"- Stage edits with `write_text(path, content, mode)` or declare them in `writes`.\n"
|
|
97
|
+
" Content must be ASCII, <=48k characters, and choose from modes create,\n"
|
|
98
|
+
" overwrite, or append.\n"
|
|
99
|
+
"- Globals accept JSON-encoded strings keyed by valid identifiers. Payloads are\n"
|
|
100
|
+
" parsed before execution; invalid JSON or names raise a validation error.\n"
|
|
101
|
+
"- Execution stops after five seconds. Stdout/stderr are captured and truncated to\n"
|
|
102
|
+
" 4,096 characters, and the repr of the final expression is returned when present.\n\n"
|
|
103
|
+
"The tool executes multi-line scripts, captures stdout, and returns the repr of the final expression when present:\n"
|
|
104
|
+
"```json\n"
|
|
105
|
+
"{\n"
|
|
106
|
+
' "name": "evaluate_python",\n'
|
|
107
|
+
' "arguments": {\n'
|
|
108
|
+
' "code": "total = 0\\nfor value in range(5):\\n total += value\\nprint(total)\\ntotal",\n'
|
|
109
|
+
' "globals": {},\n'
|
|
110
|
+
' "reads": [],\n'
|
|
111
|
+
' "writes": []\n'
|
|
112
|
+
" }\n"
|
|
113
|
+
"}\n"
|
|
114
|
+
"```"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _load_asteval_module() -> ModuleType:
|
|
119
|
+
try:
|
|
120
|
+
return import_module("asteval")
|
|
121
|
+
except ModuleNotFoundError as error: # pragma: no cover - configuration guard
|
|
122
|
+
raise RuntimeError(_MISSING_DEPENDENCY_MESSAGE) from error
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _str_dict_factory() -> dict[str, str]:
|
|
126
|
+
return {}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(slots=True, frozen=True)
|
|
130
|
+
class EvalFileRead:
|
|
131
|
+
"""File that should be read from the virtual filesystem before execution."""
|
|
132
|
+
|
|
133
|
+
path: VfsPath = field(
|
|
134
|
+
metadata={
|
|
135
|
+
"description": (
|
|
136
|
+
"Relative VFS path to load. Contents are injected into "
|
|
137
|
+
"`reads` for convenience."
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(slots=True, frozen=True)
|
|
144
|
+
class EvalFileWrite:
|
|
145
|
+
"""File that should be written back to the virtual filesystem."""
|
|
146
|
+
|
|
147
|
+
path: VfsPath = field(
|
|
148
|
+
metadata={"description": "Relative VFS path to create or update."}
|
|
149
|
+
)
|
|
150
|
+
content: str = field(
|
|
151
|
+
metadata={
|
|
152
|
+
"description": (
|
|
153
|
+
"ASCII text to persist after execution. Content longer than 48k "
|
|
154
|
+
"characters is rejected."
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
mode: Literal["create", "overwrite", "append"] = field(
|
|
159
|
+
default="create",
|
|
160
|
+
metadata={
|
|
161
|
+
"description": (
|
|
162
|
+
"Write strategy for the file: create a new entry, overwrite the "
|
|
163
|
+
"existing content, or append."
|
|
164
|
+
)
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(slots=True, frozen=True)
|
|
170
|
+
class EvalParams:
|
|
171
|
+
"""Parameter payload passed to the Python evaluation tool."""
|
|
172
|
+
|
|
173
|
+
code: str = field(
|
|
174
|
+
metadata={"description": "Python script to execute (<=2,000 characters)."}
|
|
175
|
+
)
|
|
176
|
+
globals: dict[str, str] = field(
|
|
177
|
+
default_factory=_str_dict_factory,
|
|
178
|
+
metadata={
|
|
179
|
+
"description": (
|
|
180
|
+
"Mapping of global variable names to JSON-encoded strings. The "
|
|
181
|
+
"payload is decoded before execution."
|
|
182
|
+
)
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
reads: tuple[EvalFileRead, ...] = field(
|
|
186
|
+
default_factory=tuple,
|
|
187
|
+
metadata={
|
|
188
|
+
"description": (
|
|
189
|
+
"Files to load into the VFS before execution. Each entry is "
|
|
190
|
+
"available to helper utilities."
|
|
191
|
+
)
|
|
192
|
+
},
|
|
193
|
+
)
|
|
194
|
+
writes: tuple[EvalFileWrite, ...] = field(
|
|
195
|
+
default_factory=tuple,
|
|
196
|
+
metadata={
|
|
197
|
+
"description": (
|
|
198
|
+
"Files to write after execution completes. These mirror calls to "
|
|
199
|
+
"`write_text`."
|
|
200
|
+
)
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(slots=True, frozen=True)
|
|
206
|
+
class EvalResult:
|
|
207
|
+
"""Structured result produced by the Python evaluation tool."""
|
|
208
|
+
|
|
209
|
+
value_repr: str | None = field(
|
|
210
|
+
metadata={
|
|
211
|
+
"description": (
|
|
212
|
+
"String representation of the final expression result. Null when "
|
|
213
|
+
"no value was produced."
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
stdout: str = field(
|
|
218
|
+
metadata={
|
|
219
|
+
"description": (
|
|
220
|
+
"Captured standard output stream, truncated to 4,096 characters."
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
)
|
|
224
|
+
stderr: str = field(
|
|
225
|
+
metadata={
|
|
226
|
+
"description": (
|
|
227
|
+
"Captured standard error stream, truncated to 4,096 characters."
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
globals: dict[str, str] = field(
|
|
232
|
+
metadata={
|
|
233
|
+
"description": (
|
|
234
|
+
"JSON-serialisable globals returned from the sandbox after execution."
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
reads: tuple[EvalFileRead, ...] = field(
|
|
239
|
+
metadata={"description": "File read requests fulfilled during execution."}
|
|
240
|
+
)
|
|
241
|
+
writes: tuple[EvalFileWrite, ...] = field(
|
|
242
|
+
metadata={"description": "File write operations requested by the code."}
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(slots=True, frozen=True)
|
|
247
|
+
class _AstevalSectionParams:
|
|
248
|
+
"""Placeholder params container for the asteval section."""
|
|
249
|
+
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _now() -> datetime:
|
|
254
|
+
value = datetime.now(UTC)
|
|
255
|
+
microsecond = value.microsecond - value.microsecond % 1000
|
|
256
|
+
return value.replace(microsecond=microsecond, tzinfo=UTC)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _truncate_stream(text: str) -> str:
|
|
260
|
+
if len(text) <= _MAX_STREAM_LENGTH:
|
|
261
|
+
return text
|
|
262
|
+
suffix = "..."
|
|
263
|
+
keep = _MAX_STREAM_LENGTH - len(suffix)
|
|
264
|
+
return f"{text[:keep]}{suffix}"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _ensure_ascii(value: str, label: str) -> None:
|
|
268
|
+
try:
|
|
269
|
+
_ = value.encode(_ASCII)
|
|
270
|
+
except UnicodeEncodeError as error: # pragma: no cover - defensive guard
|
|
271
|
+
raise ToolValidationError(f"{label} must be ASCII text.") from error
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _normalize_segments(raw_segments: Iterable[str]) -> tuple[str, ...]:
|
|
275
|
+
segments: list[str] = []
|
|
276
|
+
for raw_segment in raw_segments:
|
|
277
|
+
stripped = raw_segment.strip()
|
|
278
|
+
if not stripped:
|
|
279
|
+
continue
|
|
280
|
+
if stripped.startswith("/"):
|
|
281
|
+
raise ToolValidationError("Absolute paths are not allowed in the VFS.")
|
|
282
|
+
for piece in stripped.split("/"):
|
|
283
|
+
if not piece:
|
|
284
|
+
continue
|
|
285
|
+
if piece in {".", ".."}:
|
|
286
|
+
raise ToolValidationError("Path segments may not include '.' or '..'.")
|
|
287
|
+
_ensure_ascii(piece, "path segment")
|
|
288
|
+
if len(piece) > _MAX_SEGMENT_LENGTH:
|
|
289
|
+
raise ToolValidationError(
|
|
290
|
+
"Path segments must be 80 characters or fewer."
|
|
291
|
+
)
|
|
292
|
+
segments.append(piece)
|
|
293
|
+
if len(segments) > _MAX_PATH_DEPTH:
|
|
294
|
+
raise ToolValidationError("Path depth exceeds the allowed limit (16 segments).")
|
|
295
|
+
return tuple(segments)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _normalize_vfs_path(path: VfsPath) -> VfsPath:
|
|
299
|
+
return VfsPath(_normalize_segments(path.segments))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _require_file(snapshot: VirtualFileSystem, path: VfsPath) -> VfsFile:
|
|
303
|
+
normalized = _normalize_vfs_path(path)
|
|
304
|
+
for file in snapshot.files:
|
|
305
|
+
if file.path.segments == normalized.segments:
|
|
306
|
+
return file
|
|
307
|
+
raise ToolValidationError("File does not exist in the virtual filesystem.")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _normalize_code(code: str) -> str:
|
|
311
|
+
if len(code) > _MAX_CODE_LENGTH:
|
|
312
|
+
raise ToolValidationError("Code exceeds maximum length of 2,000 characters.")
|
|
313
|
+
for char in code:
|
|
314
|
+
code_point = ord(char)
|
|
315
|
+
if code_point < 32 and char not in {"\n", "\t"}:
|
|
316
|
+
raise ToolValidationError("Code contains unsupported control characters.")
|
|
317
|
+
return code
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _normalize_write(write: EvalFileWrite) -> EvalFileWrite:
|
|
321
|
+
path = _normalize_vfs_path(write.path)
|
|
322
|
+
content = write.content
|
|
323
|
+
_ensure_ascii(content, "write content")
|
|
324
|
+
if len(content) > _MAX_WRITE_LENGTH:
|
|
325
|
+
raise ToolValidationError(
|
|
326
|
+
"Content exceeds maximum length of 48,000 characters."
|
|
327
|
+
)
|
|
328
|
+
mode = write.mode
|
|
329
|
+
if mode not in {"create", "overwrite", "append"}:
|
|
330
|
+
raise ToolValidationError("Unsupported write mode requested.")
|
|
331
|
+
return EvalFileWrite(path=path, content=content, mode=mode)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _normalize_reads(reads: Iterable[EvalFileRead]) -> tuple[EvalFileRead, ...]:
|
|
335
|
+
normalized: list[EvalFileRead] = []
|
|
336
|
+
seen: set[tuple[str, ...]] = set()
|
|
337
|
+
for read in reads:
|
|
338
|
+
path = _normalize_vfs_path(read.path)
|
|
339
|
+
key = path.segments
|
|
340
|
+
if key in seen:
|
|
341
|
+
raise ToolValidationError("Duplicate read targets detected.")
|
|
342
|
+
seen.add(key)
|
|
343
|
+
normalized.append(EvalFileRead(path=path))
|
|
344
|
+
return tuple(normalized)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _normalize_writes(writes: Iterable[EvalFileWrite]) -> tuple[EvalFileWrite, ...]:
|
|
348
|
+
normalized: list[EvalFileWrite] = []
|
|
349
|
+
seen: set[tuple[str, ...]] = set()
|
|
350
|
+
for write in writes:
|
|
351
|
+
normalized_write = _normalize_write(write)
|
|
352
|
+
key = normalized_write.path.segments
|
|
353
|
+
if key in seen:
|
|
354
|
+
raise ToolValidationError("Duplicate write targets detected.")
|
|
355
|
+
seen.add(key)
|
|
356
|
+
normalized.append(normalized_write)
|
|
357
|
+
return tuple(normalized)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _alias_for_path(path: VfsPath) -> str:
|
|
361
|
+
return "/".join(path.segments)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _format_value(value: object) -> str:
|
|
365
|
+
if isinstance(value, str):
|
|
366
|
+
return value
|
|
367
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
368
|
+
return json.dumps(value)
|
|
369
|
+
if isinstance(value, bool) or value is None:
|
|
370
|
+
return json.dumps(value)
|
|
371
|
+
return f"!repr:{value!r}"
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _merge_globals(
|
|
375
|
+
initial: Mapping[str, object], updates: Mapping[str, object]
|
|
376
|
+
) -> dict[str, object]:
|
|
377
|
+
merged = dict(initial)
|
|
378
|
+
merged.update(updates)
|
|
379
|
+
return merged
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _summarize_writes(writes: Sequence[EvalFileWrite]) -> str | None:
|
|
383
|
+
if not writes:
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
total = len(writes)
|
|
387
|
+
preview_count = min(3, total)
|
|
388
|
+
preview_paths = ", ".join(
|
|
389
|
+
_alias_for_path(write.path) for write in writes[:preview_count]
|
|
390
|
+
)
|
|
391
|
+
if total > preview_count:
|
|
392
|
+
preview_paths = f"{preview_paths}, +{total - preview_count} more"
|
|
393
|
+
return f"writes={total} file(s): {preview_paths}"
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _apply_writes(
|
|
397
|
+
snapshot: VirtualFileSystem, writes: Iterable[EvalFileWrite]
|
|
398
|
+
) -> VirtualFileSystem:
|
|
399
|
+
files = list(snapshot.files)
|
|
400
|
+
timestamp = _now()
|
|
401
|
+
for write in writes:
|
|
402
|
+
existing_index = next(
|
|
403
|
+
(index for index, file in enumerate(files) if file.path == write.path),
|
|
404
|
+
None,
|
|
405
|
+
)
|
|
406
|
+
existing = files[existing_index] if existing_index is not None else None
|
|
407
|
+
if write.mode == "create" and existing is not None:
|
|
408
|
+
raise ToolValidationError("File already exists; use overwrite or append.")
|
|
409
|
+
if write.mode in {"overwrite", "append"} and existing is None:
|
|
410
|
+
raise ToolValidationError("File does not exist for the requested mode.")
|
|
411
|
+
if write.mode == "append" and existing is not None:
|
|
412
|
+
content = existing.content + write.content
|
|
413
|
+
created_at = existing.created_at
|
|
414
|
+
version = existing.version + 1
|
|
415
|
+
elif existing is not None:
|
|
416
|
+
content = write.content
|
|
417
|
+
created_at = existing.created_at
|
|
418
|
+
version = existing.version + 1
|
|
419
|
+
else:
|
|
420
|
+
content = write.content
|
|
421
|
+
created_at = timestamp
|
|
422
|
+
version = 1
|
|
423
|
+
size_bytes = len(content.encode("utf-8"))
|
|
424
|
+
updated = VfsFile(
|
|
425
|
+
path=write.path,
|
|
426
|
+
content=content,
|
|
427
|
+
encoding="utf-8",
|
|
428
|
+
size_bytes=size_bytes,
|
|
429
|
+
version=version,
|
|
430
|
+
created_at=created_at,
|
|
431
|
+
updated_at=timestamp,
|
|
432
|
+
)
|
|
433
|
+
if existing_index is not None:
|
|
434
|
+
_ = files.pop(existing_index)
|
|
435
|
+
files.append(updated)
|
|
436
|
+
files.sort(key=lambda file: file.path.segments)
|
|
437
|
+
return VirtualFileSystem(files=tuple(files))
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _parse_string_path(path: str) -> VfsPath:
|
|
441
|
+
if not path.strip():
|
|
442
|
+
raise ToolValidationError("Path must be non-empty.")
|
|
443
|
+
return VfsPath(_normalize_segments((path,)))
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _build_eval_globals(
|
|
447
|
+
snapshot: VirtualFileSystem, reads: tuple[EvalFileRead, ...]
|
|
448
|
+
) -> dict[str, str]:
|
|
449
|
+
values: dict[str, str] = {}
|
|
450
|
+
for read in reads:
|
|
451
|
+
alias = _alias_for_path(read.path)
|
|
452
|
+
file = _require_file(snapshot, read.path)
|
|
453
|
+
values[alias] = file.content
|
|
454
|
+
return values
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _parse_user_globals(payload: Mapping[str, str]) -> dict[str, object]:
|
|
458
|
+
parsed: dict[str, object] = {}
|
|
459
|
+
for name, encoded in payload.items():
|
|
460
|
+
identifier = name.strip()
|
|
461
|
+
if not identifier:
|
|
462
|
+
raise ToolValidationError("Global variable names must be non-empty.")
|
|
463
|
+
if not identifier.isidentifier():
|
|
464
|
+
raise ToolValidationError(f"Invalid global variable name '{identifier}'.")
|
|
465
|
+
try:
|
|
466
|
+
parsed_value = json.loads(encoded)
|
|
467
|
+
except json.JSONDecodeError as error:
|
|
468
|
+
raise ToolValidationError(
|
|
469
|
+
f"Invalid JSON for global '{identifier}'."
|
|
470
|
+
) from error
|
|
471
|
+
parsed[identifier] = parsed_value
|
|
472
|
+
return parsed
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class InterpreterProtocol(Protocol):
|
|
476
|
+
symtable: MutableMapping[str, object]
|
|
477
|
+
node_handlers: MutableMapping[str, object] | None
|
|
478
|
+
error: list[object]
|
|
479
|
+
|
|
480
|
+
def eval(self, expression: str) -> object: ...
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _sanitize_interpreter(interpreter: InterpreterProtocol) -> None:
|
|
484
|
+
module = _load_asteval_module()
|
|
485
|
+
|
|
486
|
+
for name in getattr(module, "ALL_DISALLOWED", ()): # pragma: no cover - defensive
|
|
487
|
+
_ = interpreter.symtable.pop(name, None)
|
|
488
|
+
node_handlers = getattr(interpreter, "node_handlers", None)
|
|
489
|
+
if isinstance(node_handlers, MutableMapping):
|
|
490
|
+
handlers = cast(MutableMapping[str, object], node_handlers)
|
|
491
|
+
for key in ("Eval", "Exec", "Import", "ImportFrom"):
|
|
492
|
+
_ = handlers.pop(key, None)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _create_interpreter() -> InterpreterProtocol:
|
|
496
|
+
module = _load_asteval_module()
|
|
497
|
+
interpreter_cls = getattr(module, "Interpreter", None)
|
|
498
|
+
if not callable(interpreter_cls): # pragma: no cover - defensive guard
|
|
499
|
+
message = _MISSING_DEPENDENCY_MESSAGE
|
|
500
|
+
raise TypeError(message)
|
|
501
|
+
|
|
502
|
+
interpreter = cast(
|
|
503
|
+
InterpreterProtocol, interpreter_cls(use_numpy=False, minimal=True)
|
|
504
|
+
)
|
|
505
|
+
interpreter.symtable = dict(_SAFE_GLOBALS)
|
|
506
|
+
_sanitize_interpreter(interpreter)
|
|
507
|
+
return interpreter
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _execute_with_timeout(
|
|
511
|
+
func: Callable[[], object],
|
|
512
|
+
) -> tuple[bool, object | None, str]:
|
|
513
|
+
timeout_message = "Execution timed out."
|
|
514
|
+
result_container: dict[str, object | None] = {}
|
|
515
|
+
error_container: dict[str, str] = {"message": ""}
|
|
516
|
+
completed = threading.Event()
|
|
517
|
+
|
|
518
|
+
def runner() -> None:
|
|
519
|
+
try:
|
|
520
|
+
result_container["value"] = func()
|
|
521
|
+
except TimeoutError:
|
|
522
|
+
error_container["message"] = timeout_message
|
|
523
|
+
except Exception as error: # pragma: no cover - forwarded later
|
|
524
|
+
result_container["error"] = error
|
|
525
|
+
finally:
|
|
526
|
+
completed.set()
|
|
527
|
+
|
|
528
|
+
thread = threading.Thread(target=runner, daemon=True)
|
|
529
|
+
thread.start()
|
|
530
|
+
_ = completed.wait(_TIMEOUT_SECONDS)
|
|
531
|
+
if not completed.is_set():
|
|
532
|
+
return True, None, timeout_message
|
|
533
|
+
if "error" in result_container:
|
|
534
|
+
error = cast(Exception, result_container["error"])
|
|
535
|
+
raise error
|
|
536
|
+
return False, result_container.get("value"), error_container["message"]
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
class _AstevalToolSuite:
|
|
540
|
+
def __init__(self, *, section: AstevalSection) -> None:
|
|
541
|
+
super().__init__()
|
|
542
|
+
self._section = section
|
|
543
|
+
|
|
544
|
+
def run(
|
|
545
|
+
self, params: EvalParams, *, context: ToolContext
|
|
546
|
+
) -> ToolResult[EvalResult]:
|
|
547
|
+
ensure_context_uses_session(context=context, session=self._section.session)
|
|
548
|
+
del context
|
|
549
|
+
session = self._section.session
|
|
550
|
+
code = _normalize_code(params.code)
|
|
551
|
+
reads = _normalize_reads(params.reads)
|
|
552
|
+
writes = _normalize_writes(params.writes)
|
|
553
|
+
read_paths = {read.path.segments for read in reads}
|
|
554
|
+
write_paths = {write.path.segments for write in writes}
|
|
555
|
+
if read_paths & write_paths:
|
|
556
|
+
raise ToolValidationError("Reads and writes must not target the same path.")
|
|
557
|
+
|
|
558
|
+
snapshot = select_latest(session, VirtualFileSystem) or VirtualFileSystem()
|
|
559
|
+
read_globals = _build_eval_globals(snapshot, reads)
|
|
560
|
+
user_globals = _parse_user_globals(params.globals)
|
|
561
|
+
|
|
562
|
+
interpreter = _create_interpreter()
|
|
563
|
+
stdout_buffer = io.StringIO()
|
|
564
|
+
stderr_buffer = io.StringIO()
|
|
565
|
+
write_queue: list[EvalFileWrite] = list(writes)
|
|
566
|
+
helper_writes: list[EvalFileWrite] = []
|
|
567
|
+
write_targets = {write.path.segments for write in write_queue}
|
|
568
|
+
builtin_print = builtins.print
|
|
569
|
+
pending_write_attempted = bool(write_queue)
|
|
570
|
+
|
|
571
|
+
def sandbox_print(
|
|
572
|
+
*args: object,
|
|
573
|
+
sep: object | None = " ",
|
|
574
|
+
end: object | None = "\n",
|
|
575
|
+
file: TextIO | None = None,
|
|
576
|
+
flush: bool = False,
|
|
577
|
+
) -> None:
|
|
578
|
+
if sep is not None and not isinstance(sep, str):
|
|
579
|
+
raise TypeError("sep must be None or a string.")
|
|
580
|
+
if end is not None and not isinstance(end, str):
|
|
581
|
+
raise TypeError("end must be None or a string.")
|
|
582
|
+
actual_sep = " " if sep is None else str(sep)
|
|
583
|
+
actual_end = "\n" if end is None else str(end)
|
|
584
|
+
if file is not None: # pragma: no cover - requires custom injected writer
|
|
585
|
+
builtin_print(
|
|
586
|
+
*args, sep=actual_sep, end=actual_end, file=file, flush=flush
|
|
587
|
+
)
|
|
588
|
+
return
|
|
589
|
+
text = actual_sep.join(str(arg) for arg in args)
|
|
590
|
+
_ = stdout_buffer.write(text)
|
|
591
|
+
_ = stdout_buffer.write(actual_end)
|
|
592
|
+
if flush:
|
|
593
|
+
_ = stdout_buffer.flush()
|
|
594
|
+
|
|
595
|
+
def read_text(path: str) -> str:
|
|
596
|
+
normalized = _normalize_vfs_path(_parse_string_path(path))
|
|
597
|
+
file = _require_file(snapshot, normalized)
|
|
598
|
+
return file.content
|
|
599
|
+
|
|
600
|
+
def write_text(path: str, content: str, mode: str = "create") -> None:
|
|
601
|
+
nonlocal pending_write_attempted
|
|
602
|
+
pending_write_attempted = True
|
|
603
|
+
normalized_path = _normalize_vfs_path(_parse_string_path(path))
|
|
604
|
+
helper_write = _normalize_write(
|
|
605
|
+
EvalFileWrite(
|
|
606
|
+
path=normalized_path,
|
|
607
|
+
content=content,
|
|
608
|
+
mode=cast(Literal["create", "overwrite", "append"], mode),
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
key = helper_write.path.segments
|
|
612
|
+
if key in read_paths:
|
|
613
|
+
raise ToolValidationError(
|
|
614
|
+
"Writes queued during execution must not target read paths."
|
|
615
|
+
)
|
|
616
|
+
if key in write_targets:
|
|
617
|
+
raise ToolValidationError("Duplicate write targets detected.")
|
|
618
|
+
write_targets.add(key)
|
|
619
|
+
helper_writes.append(helper_write)
|
|
620
|
+
|
|
621
|
+
symtable = interpreter.symtable
|
|
622
|
+
symtable.update(_merge_globals(read_globals, user_globals))
|
|
623
|
+
symtable["vfs_reads"] = dict(read_globals)
|
|
624
|
+
symtable["read_text"] = read_text
|
|
625
|
+
symtable["write_text"] = write_text
|
|
626
|
+
symtable["print"] = sandbox_print
|
|
627
|
+
|
|
628
|
+
all_keys = set(symtable)
|
|
629
|
+
captured_errors: list[str] = []
|
|
630
|
+
value_repr: str | None = None
|
|
631
|
+
stderr_text = ""
|
|
632
|
+
try:
|
|
633
|
+
with (
|
|
634
|
+
contextlib.redirect_stdout(stdout_buffer),
|
|
635
|
+
contextlib.redirect_stderr(stderr_buffer),
|
|
636
|
+
):
|
|
637
|
+
interpreter.error = []
|
|
638
|
+
|
|
639
|
+
def runner() -> object:
|
|
640
|
+
return interpreter.eval(code)
|
|
641
|
+
|
|
642
|
+
timed_out, result, timeout_error = _execute_with_timeout(runner)
|
|
643
|
+
if timed_out:
|
|
644
|
+
stderr_text = timeout_error
|
|
645
|
+
elif interpreter.error:
|
|
646
|
+
captured_errors.extend(str(err) for err in interpreter.error)
|
|
647
|
+
if not timed_out and not captured_errors and not stderr_text:
|
|
648
|
+
value_repr = None if result is None else repr(result)
|
|
649
|
+
except ToolValidationError: # pragma: no cover - interpreter wraps tool errors
|
|
650
|
+
raise
|
|
651
|
+
except Exception as error: # pragma: no cover - runtime exception
|
|
652
|
+
captured_errors.append(str(error))
|
|
653
|
+
stdout = _truncate_stream(stdout_buffer.getvalue())
|
|
654
|
+
stderr_raw = (
|
|
655
|
+
stderr_text or "\n".join(captured_errors) or stderr_buffer.getvalue()
|
|
656
|
+
)
|
|
657
|
+
stderr = _truncate_stream(stderr_raw)
|
|
658
|
+
|
|
659
|
+
param_writes = tuple(write_queue)
|
|
660
|
+
pending_writes = pending_write_attempted or bool(helper_writes)
|
|
661
|
+
if stderr and not value_repr:
|
|
662
|
+
final_writes: tuple[EvalFileWrite, ...] = ()
|
|
663
|
+
if pending_writes:
|
|
664
|
+
message = (
|
|
665
|
+
"Evaluation failed; pending file writes were discarded. "
|
|
666
|
+
"Review stderr details in the payload."
|
|
667
|
+
)
|
|
668
|
+
else:
|
|
669
|
+
message = "Evaluation failed; review stderr details in the payload."
|
|
670
|
+
else:
|
|
671
|
+
format_context = {
|
|
672
|
+
key: value for key, value in symtable.items() if not key.startswith("_")
|
|
673
|
+
}
|
|
674
|
+
resolved_param_writes: list[EvalFileWrite] = []
|
|
675
|
+
for write in param_writes:
|
|
676
|
+
try:
|
|
677
|
+
resolved_content = write.content.format_map(format_context)
|
|
678
|
+
except KeyError as error:
|
|
679
|
+
missing = error.args[0]
|
|
680
|
+
raise ToolValidationError(
|
|
681
|
+
f"Missing template variable '{missing}' in write request."
|
|
682
|
+
) from error
|
|
683
|
+
resolved_param_writes.append(
|
|
684
|
+
_normalize_write(
|
|
685
|
+
EvalFileWrite(
|
|
686
|
+
path=write.path,
|
|
687
|
+
content=resolved_content,
|
|
688
|
+
mode=write.mode,
|
|
689
|
+
)
|
|
690
|
+
)
|
|
691
|
+
)
|
|
692
|
+
final_writes = tuple(resolved_param_writes + helper_writes)
|
|
693
|
+
seen_targets: set[tuple[str, ...]] = set()
|
|
694
|
+
for write in final_writes:
|
|
695
|
+
key = write.path.segments
|
|
696
|
+
if key in seen_targets:
|
|
697
|
+
raise ToolValidationError(
|
|
698
|
+
"Duplicate write targets detected."
|
|
699
|
+
) # pragma: no cover - upstream checks prevent duplicates
|
|
700
|
+
seen_targets.add(key)
|
|
701
|
+
if final_writes:
|
|
702
|
+
message = (
|
|
703
|
+
"Evaluation succeeded with "
|
|
704
|
+
f"{len(final_writes)} pending file write"
|
|
705
|
+
f"{'s' if len(final_writes) != 1 else ''}."
|
|
706
|
+
)
|
|
707
|
+
else:
|
|
708
|
+
message = "Evaluation succeeded without pending file writes."
|
|
709
|
+
|
|
710
|
+
helper_writes_tuple = tuple(helper_writes)
|
|
711
|
+
pending_sources: Sequence[EvalFileWrite] = (
|
|
712
|
+
final_writes or param_writes + helper_writes_tuple
|
|
713
|
+
)
|
|
714
|
+
summary = _summarize_writes(pending_sources)
|
|
715
|
+
if pending_writes and summary:
|
|
716
|
+
message = f"{message} {summary}"
|
|
717
|
+
|
|
718
|
+
globals_payload: dict[str, str] = {}
|
|
719
|
+
visible_keys = {
|
|
720
|
+
key for key in symtable if key not in all_keys and not key.startswith("_")
|
|
721
|
+
}
|
|
722
|
+
visible_keys.update(user_globals.keys())
|
|
723
|
+
for key in visible_keys:
|
|
724
|
+
globals_payload[key] = _format_value(symtable.get(key))
|
|
725
|
+
globals_payload.update(
|
|
726
|
+
{f"vfs:{alias}": content for alias, content in read_globals.items()}
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
result = EvalResult(
|
|
730
|
+
value_repr=value_repr,
|
|
731
|
+
stdout=stdout,
|
|
732
|
+
stderr=stderr,
|
|
733
|
+
globals=globals_payload,
|
|
734
|
+
reads=reads,
|
|
735
|
+
writes=final_writes,
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
_LOGGER.debug(
|
|
739
|
+
"Asteval evaluation completed.",
|
|
740
|
+
event="asteval_run",
|
|
741
|
+
context={
|
|
742
|
+
"stdout_len": len(stdout),
|
|
743
|
+
"stderr_len": len(stderr),
|
|
744
|
+
"write_count": len(final_writes),
|
|
745
|
+
"code_preview": code[:200],
|
|
746
|
+
},
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
return ToolResult(message=message, value=result)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _make_eval_result_reducer() -> TypedReducer[VirtualFileSystem]:
|
|
753
|
+
def reducer(
|
|
754
|
+
slice_values: tuple[VirtualFileSystem, ...],
|
|
755
|
+
event: ReducerEvent,
|
|
756
|
+
*,
|
|
757
|
+
context: ReducerContextProtocol,
|
|
758
|
+
) -> tuple[VirtualFileSystem, ...]:
|
|
759
|
+
del context
|
|
760
|
+
previous = slice_values[-1] if slice_values else VirtualFileSystem()
|
|
761
|
+
value = cast(EvalResult, event.value)
|
|
762
|
+
if not value.writes:
|
|
763
|
+
return (previous,)
|
|
764
|
+
snapshot = _apply_writes(previous, value.writes)
|
|
765
|
+
return (snapshot,)
|
|
766
|
+
|
|
767
|
+
return reducer
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def normalize_eval_reads(reads: Iterable[EvalFileRead]) -> tuple[EvalFileRead, ...]:
|
|
771
|
+
return _normalize_reads(reads)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def normalize_eval_writes(
|
|
775
|
+
writes: Iterable[EvalFileWrite],
|
|
776
|
+
) -> tuple[EvalFileWrite, ...]:
|
|
777
|
+
return _normalize_writes(writes)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def normalize_eval_write(write: EvalFileWrite) -> EvalFileWrite:
|
|
781
|
+
return _normalize_write(write)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def parse_eval_globals(payload: Mapping[str, str]) -> dict[str, object]:
|
|
785
|
+
return _parse_user_globals(payload)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def alias_for_eval_path(path: VfsPath) -> str:
|
|
789
|
+
return _alias_for_path(path)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def summarize_eval_writes(writes: Sequence[EvalFileWrite]) -> str | None:
|
|
793
|
+
return _summarize_writes(writes)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def make_eval_result_reducer() -> TypedReducer[VirtualFileSystem]:
|
|
797
|
+
return _make_eval_result_reducer()
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
class AstevalSection(MarkdownSection[_AstevalSectionParams]):
|
|
801
|
+
"""Prompt section exposing the :mod:`asteval` evaluation tool."""
|
|
802
|
+
|
|
803
|
+
def __init__(
|
|
804
|
+
self,
|
|
805
|
+
*,
|
|
806
|
+
session: Session,
|
|
807
|
+
accepts_overrides: bool = False,
|
|
808
|
+
) -> None:
|
|
809
|
+
self._session = session
|
|
810
|
+
session.register_reducer(
|
|
811
|
+
EvalResult,
|
|
812
|
+
_make_eval_result_reducer(),
|
|
813
|
+
slice_type=VirtualFileSystem,
|
|
814
|
+
)
|
|
815
|
+
tool_suite = _AstevalToolSuite(section=self)
|
|
816
|
+
tool = Tool[EvalParams, EvalResult](
|
|
817
|
+
name="evaluate_python",
|
|
818
|
+
description=(
|
|
819
|
+
"Run a short Python expression or script in a sandbox. Supports "
|
|
820
|
+
"preloading VFS files, staging writes, and returning captured "
|
|
821
|
+
"stdout, stderr, and result data."
|
|
822
|
+
),
|
|
823
|
+
handler=tool_suite.run,
|
|
824
|
+
accepts_overrides=accepts_overrides,
|
|
825
|
+
)
|
|
826
|
+
super().__init__(
|
|
827
|
+
title="Python Evaluation Tool",
|
|
828
|
+
key="tools.asteval",
|
|
829
|
+
template=_EVAL_TEMPLATE,
|
|
830
|
+
default_params=_AstevalSectionParams(),
|
|
831
|
+
tools=(tool,),
|
|
832
|
+
accepts_overrides=accepts_overrides,
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
@property
|
|
836
|
+
def session(self) -> Session:
|
|
837
|
+
return self._session
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
__all__ = [
|
|
841
|
+
"AstevalSection",
|
|
842
|
+
"EvalFileRead",
|
|
843
|
+
"EvalFileWrite",
|
|
844
|
+
"EvalParams",
|
|
845
|
+
"EvalResult",
|
|
846
|
+
"alias_for_eval_path",
|
|
847
|
+
"make_eval_result_reducer",
|
|
848
|
+
"normalize_eval_reads",
|
|
849
|
+
"normalize_eval_write",
|
|
850
|
+
"normalize_eval_writes",
|
|
851
|
+
"parse_eval_globals",
|
|
852
|
+
"summarize_eval_writes",
|
|
853
|
+
]
|