agentenv-framework 0.9.1254__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.
Files changed (312) hide show
  1. agent_env/__init__.py +0 -0
  2. agent_env/a2a_agent/__init__.py +23 -0
  3. agent_env/a2a_agent/a2a_agent.py +600 -0
  4. agent_env/a2a_agent/conversation_store.py +356 -0
  5. agent_env/a2a_agent/object_transfer.py +609 -0
  6. agent_env/a2a_agent/protocol.py +238 -0
  7. agent_env/a2a_agent/store.py +201 -0
  8. agent_env/a2a_agent/validator.py +901 -0
  9. agent_env/artifact/__init__.py +48 -0
  10. agent_env/artifact/artifact.py +74 -0
  11. agent_env/artifact/artifacts/__init__.py +0 -0
  12. agent_env/artifact/artifacts/cli.py +83 -0
  13. agent_env/artifact/artifacts/docker_image.py +457 -0
  14. agent_env/artifact/artifacts/environment.py +89 -0
  15. agent_env/artifact/artifacts/environment_universe.py +156 -0
  16. agent_env/artifact/artifacts/file.py +214 -0
  17. agent_env/artifact/artifacts/file_artifact_universe.py +206 -0
  18. agent_env/artifact/artifacts/skill.py +206 -0
  19. agent_env/artifact/artifacts/vm_image.py +95 -0
  20. agent_env/artifact/ref.py +15 -0
  21. agent_env/artifact/registry.py +251 -0
  22. agent_env/artifact/store.py +295 -0
  23. agent_env/artifact/universe.py +38 -0
  24. agent_env/attribution.py +41 -0
  25. agent_env/bundle/__init__.py +4 -0
  26. agent_env/bundle/_fs.py +128 -0
  27. agent_env/bundle/authoring.py +239 -0
  28. agent_env/bundle/installed.py +175 -0
  29. agent_env/bundle/ledger.py +269 -0
  30. agent_env/bundle/materialize.py +204 -0
  31. agent_env/bundle/parse.py +594 -0
  32. agent_env/bundle/plan.py +414 -0
  33. agent_env/bundle/resolve.py +404 -0
  34. agent_env/bundle/run.py +361 -0
  35. agent_env/cli/__init__.py +69 -0
  36. agent_env/cli/__main__.py +10 -0
  37. agent_env/cli/_installers.py +576 -0
  38. agent_env/cli/_plugin_changes.py +947 -0
  39. agent_env/cli/a2a_agent/__init__.py +22 -0
  40. agent_env/cli/a2a_agent/add_skill.py +97 -0
  41. agent_env/cli/a2a_agent/deploy.py +60 -0
  42. agent_env/cli/a2a_agent/get.py +15 -0
  43. agent_env/cli/a2a_agent/get_instance.py +26 -0
  44. agent_env/cli/a2a_agent/put.py +78 -0
  45. agent_env/cli/a2a_agent/validate.py +56 -0
  46. agent_env/cli/artifact/__init__.py +20 -0
  47. agent_env/cli/artifact/cli.py +83 -0
  48. agent_env/cli/artifact/environment.py +44 -0
  49. agent_env/cli/artifact/environment_universe.py +163 -0
  50. agent_env/cli/artifact/file_artifact_universe.py +281 -0
  51. agent_env/cli/artifact/skill.py +128 -0
  52. agent_env/cli/banner.py +25 -0
  53. agent_env/cli/config.py +263 -0
  54. agent_env/cli/env/__init__.py +31 -0
  55. agent_env/cli/env/deploy.py +112 -0
  56. agent_env/cli/env/gateway.py +57 -0
  57. agent_env/cli/env/get_instance.py +40 -0
  58. agent_env/cli/env/mcp_server.py +410 -0
  59. agent_env/cli/env/multi.py +282 -0
  60. agent_env/cli/env/service_db.py +99 -0
  61. agent_env/cli/env/snapshot.py +35 -0
  62. agent_env/cli/env/state/__init__.py +6 -0
  63. agent_env/cli/env/state/init.py +108 -0
  64. agent_env/cli/env/state/teardown.py +44 -0
  65. agent_env/cli/env/website.py +216 -0
  66. agent_env/cli/env/website_browser.py +67 -0
  67. agent_env/cli/eval/__init__.py +16 -0
  68. agent_env/cli/eval/add_tasks.py +63 -0
  69. agent_env/cli/eval/create.py +49 -0
  70. agent_env/cli/eval/run.py +194 -0
  71. agent_env/cli/identity/__init__.py +5 -0
  72. agent_env/cli/identity/client_identity.py +37 -0
  73. agent_env/cli/plugin.py +438 -0
  74. agent_env/cli/run.py +285 -0
  75. agent_env/cli/task/__init__.py +21 -0
  76. agent_env/cli/task/create.py +109 -0
  77. agent_env/cli/task/get.py +17 -0
  78. agent_env/cli/task/get_instance.py +34 -0
  79. agent_env/cli/task/run.py +615 -0
  80. agent_env/cli/task/validate.py +31 -0
  81. agent_env/cli/up.py +123 -0
  82. agent_env/cli/utils.py +183 -0
  83. agent_env/config/__init__.py +50 -0
  84. agent_env/config/describe.py +1067 -0
  85. agent_env/config/errors.py +13 -0
  86. agent_env/config/loader.py +245 -0
  87. agent_env/config/model.py +154 -0
  88. agent_env/config/paths.py +25 -0
  89. agent_env/config/plugin_tables.py +101 -0
  90. agent_env/config/provenance.py +134 -0
  91. agent_env/config/runtime.py +1064 -0
  92. agent_env/config/snapshot.py +81 -0
  93. agent_env/entity_refs.py +186 -0
  94. agent_env/env/__init__.py +49 -0
  95. agent_env/env/env.py +433 -0
  96. agent_env/env/env_artifact_store.py +87 -0
  97. agent_env/env/envs/__init__.py +7 -0
  98. agent_env/env/envs/_deployment.py +281 -0
  99. agent_env/env/envs/gateway_server.py +32 -0
  100. agent_env/env/envs/mcp_server.py +387 -0
  101. agent_env/env/envs/multi_env.py +780 -0
  102. agent_env/env/envs/service_db/Dockerfile +20 -0
  103. agent_env/env/envs/service_db/Dockerfile.db-mcp +42 -0
  104. agent_env/env/envs/service_db/Dockerfile.db-web +8 -0
  105. agent_env/env/envs/service_db.py +92 -0
  106. agent_env/env/envs/website.py +294 -0
  107. agent_env/env/envs/website_browser/Dockerfile +40 -0
  108. agent_env/env/envs/website_browser/__init__.py +17 -0
  109. agent_env/env/envs/website_browser/entrypoint.sh +13 -0
  110. agent_env/env/gateway/Dockerfile +14 -0
  111. agent_env/env/gateway/__init__.py +37 -0
  112. agent_env/env/gateway/clock.py +184 -0
  113. agent_env/env/gateway/constants.py +190 -0
  114. agent_env/env/gateway/entrypoint.py +156 -0
  115. agent_env/env/gateway/gateway.py +1095 -0
  116. agent_env/env/gateway/get_time.py +60 -0
  117. agent_env/env/gateway/requirements.txt +4 -0
  118. agent_env/env/gateway/triggers.py +1193 -0
  119. agent_env/env/legacy_protocol.py +146 -0
  120. agent_env/env/registry.py +93 -0
  121. agent_env/env/snapshot_store.py +372 -0
  122. agent_env/env/store.py +411 -0
  123. agent_env/eval/__init__.py +14 -0
  124. agent_env/eval/eval.py +68 -0
  125. agent_env/eval/store.py +116 -0
  126. agent_env/examples/hello/README.md +9 -0
  127. agent_env/examples/hello/artifacts/greeting/check.sh +1 -0
  128. agent_env/examples/hello/artifacts/greeting/hello.txt +1 -0
  129. agent_env/examples/hello/tasks/hello.json +10 -0
  130. agent_env/explorer/__init__.py +12 -0
  131. agent_env/explorer/app.py +277 -0
  132. agent_env/explorer/openapi_docs.py +242 -0
  133. agent_env/explorer/plugin.py +95 -0
  134. agent_env/explorer/routers/__init__.py +1 -0
  135. agent_env/explorer/routers/common.py +174 -0
  136. agent_env/explorer/routers/conversations.py +25 -0
  137. agent_env/explorer/routers/objects.py +112 -0
  138. agent_env/explorer/routers/runs.py +475 -0
  139. agent_env/plugins/__init__.py +59 -0
  140. agent_env/plugins/_cli.py +244 -0
  141. agent_env/plugins/_discovery.py +97 -0
  142. agent_env/plugins/_inventory.py +262 -0
  143. agent_env/plugins/_registration.py +268 -0
  144. agent_env/plugins/_report.py +47 -0
  145. agent_env/plugins/_requirements.py +55 -0
  146. agent_env/providers/__init__.py +37 -0
  147. agent_env/providers/env_providers/__init__.py +12 -0
  148. agent_env/providers/env_providers/constants.py +13 -0
  149. agent_env/providers/env_providers/env_gateway_provider.py +1354 -0
  150. agent_env/providers/env_providers/env_provider.py +237 -0
  151. agent_env/providers/env_providers/env_server_provider.py +133 -0
  152. agent_env/providers/env_state/__init__.py +49 -0
  153. agent_env/providers/env_state/env_state_provider.py +554 -0
  154. agent_env/providers/env_state/local_postgres.py +390 -0
  155. agent_env/providers/env_state/store.py +112 -0
  156. agent_env/providers/sandbox_providers/__init__.py +29 -0
  157. agent_env/providers/sandbox_providers/chained_sandbox_provider.py +93 -0
  158. agent_env/providers/sandbox_providers/e2b/__init__.py +6 -0
  159. agent_env/providers/sandbox_providers/e2b/provider.py +376 -0
  160. agent_env/providers/sandbox_providers/e2b/sandbox.py +342 -0
  161. agent_env/providers/sandbox_providers/e2b/template.py +158 -0
  162. agent_env/providers/sandbox_providers/local_sandbox.py +408 -0
  163. agent_env/providers/sandbox_providers/modal_sandbox.py +500 -0
  164. agent_env/providers/sandbox_providers/modal_vm_sandbox.py +466 -0
  165. agent_env/providers/sandbox_providers/sandbox.py +387 -0
  166. agent_env/providers/sandbox_providers/sandbox_provider.py +614 -0
  167. agent_env/py.typed +0 -0
  168. agent_env/runner/__init__.py +16 -0
  169. agent_env/runner/local_runner.py +190 -0
  170. agent_env/runner/runner.py +127 -0
  171. agent_env/runner/store.py +94 -0
  172. agent_env/store/__init__.py +126 -0
  173. agent_env/store/_google.py +68 -0
  174. agent_env/store/base.py +33 -0
  175. agent_env/store/document_store/__init__.py +60 -0
  176. agent_env/store/document_store/document_store.py +519 -0
  177. agent_env/store/document_store/dynamodb_document_store.py +306 -0
  178. agent_env/store/document_store/evaluation.py +199 -0
  179. agent_env/store/document_store/firestore_mongo_document_store.py +242 -0
  180. agent_env/store/document_store/mongo_document_store.py +259 -0
  181. agent_env/store/document_store/sqlite_document_store.py +314 -0
  182. agent_env/store/ids.py +123 -0
  183. agent_env/store/image_store/__init__.py +34 -0
  184. agent_env/store/image_store/ecr_image_store.py +131 -0
  185. agent_env/store/image_store/google_credentials.py +113 -0
  186. agent_env/store/image_store/image_store.py +113 -0
  187. agent_env/store/image_store/local_registry_image_store.py +106 -0
  188. agent_env/store/image_store/oci_registry_credentials.py +196 -0
  189. agent_env/store/local_state.py +27 -0
  190. agent_env/store/object_store/__init__.py +24 -0
  191. agent_env/store/object_store/gcs_object_store.py +415 -0
  192. agent_env/store/object_store/local_object_store.py +143 -0
  193. agent_env/store/object_store/object_store.py +198 -0
  194. agent_env/store/object_store/s3_object_store.py +354 -0
  195. agent_env/store/query.py +112 -0
  196. agent_env/store/routing.py +609 -0
  197. agent_env/store/secret_store/__init__.py +19 -0
  198. agent_env/store/secret_store/aws_secrets_manager_secret_store.py +293 -0
  199. agent_env/store/secret_store/gcp_secret_manager_secret_store.py +319 -0
  200. agent_env/store/secret_store/local_secret_store.py +47 -0
  201. agent_env/store/secret_store/secret_store.py +19 -0
  202. agent_env/task/__init__.py +27 -0
  203. agent_env/task/interrupts.py +109 -0
  204. agent_env/task/registry.py +27 -0
  205. agent_env/task/step_journal.py +99 -0
  206. agent_env/task/store.py +940 -0
  207. agent_env/task/task.py +815 -0
  208. agent_env/task/teardown.py +142 -0
  209. agent_env/task_step/__init__.py +47 -0
  210. agent_env/task_step/context.py +195 -0
  211. agent_env/task_step/context_ops.py +224 -0
  212. agent_env/task_step/registry.py +182 -0
  213. agent_env/task_step/review_store.py +118 -0
  214. agent_env/task_step/snapshot_utils/__init__.py +2 -0
  215. agent_env/task_step/snapshot_utils/agent_state_capture.py +261 -0
  216. agent_env/task_step/snapshot_utils/snapshot_series.py +631 -0
  217. agent_env/task_step/store.py +129 -0
  218. agent_env/task_step/task_step.py +173 -0
  219. agent_env/task_step/task_steps/__init__.py +27 -0
  220. agent_env/task_step/task_steps/a2a_agent_validator/__init__.py +31 -0
  221. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/README.md +158 -0
  222. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.m4a +0 -0
  223. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp3 +0 -0
  224. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp4 +0 -0
  225. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.ogg +0 -0
  226. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.wav +0 -0
  227. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/document.pdf +0 -0
  228. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.gif +0 -0
  229. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.jpg +0 -0
  230. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.png +0 -0
  231. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_card.py +103 -0
  232. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_config_identity.py +69 -0
  233. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_mcp.py +178 -0
  234. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_core_protocol.py +67 -0
  235. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_install.py +115 -0
  236. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution.py +124 -0
  237. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution_runtime.py +173 -0
  238. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_modalities.py +270 -0
  239. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_peer_agents.py +102 -0
  240. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_role.py +151 -0
  241. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_skill_config.py +194 -0
  242. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_snapshot.py +111 -0
  243. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_system_prompt.py +215 -0
  244. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_trajectory.py +164 -0
  245. agent_env/task_step/task_steps/add_skills.py +240 -0
  246. agent_env/task_step/task_steps/apply_server_config.py +288 -0
  247. agent_env/task_step/task_steps/collect_artifacts.py +940 -0
  248. agent_env/task_step/task_steps/deploy_agent.py +626 -0
  249. agent_env/task_step/task_steps/deploy_env.py +197 -0
  250. agent_env/task_step/task_steps/deploy_human_agent.py +100 -0
  251. agent_env/task_step/task_steps/deploy_sandbox.py +170 -0
  252. agent_env/task_step/task_steps/env_card_validator/__init__.py +4 -0
  253. agent_env/task_step/task_steps/env_card_validator/verify_env_card.py +109 -0
  254. agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py +108 -0
  255. agent_env/task_step/task_steps/install_agent.py +376 -0
  256. agent_env/task_step/task_steps/load_artifact.py +775 -0
  257. agent_env/task_step/task_steps/mcp_cli_builder/__init__.py +4 -0
  258. agent_env/task_step/task_steps/mcp_cli_builder/build_mcp_cli.py +201 -0
  259. agent_env/task_step/task_steps/mcp_cli_builder/codegen.py +627 -0
  260. agent_env/task_step/task_steps/mcp_env_validator/__init__.py +44 -0
  261. agent_env/task_step/task_steps/mcp_env_validator/validation_gate_aggregator.py +102 -0
  262. agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_env_assessment.py +78 -0
  263. agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_tool_schema.py +152 -0
  264. agent_env/task_step/task_steps/mcp_env_validator/verify_spec_conformance.py +486 -0
  265. agent_env/task_step/task_steps/modify_env_tool_access.py +80 -0
  266. agent_env/task_step/task_steps/multienv_validator/__init__.py +3 -0
  267. agent_env/task_step/task_steps/multienv_validator/combine_universe_verdicts.py +107 -0
  268. agent_env/task_step/task_steps/multienv_validator/universe_comparison.py +253 -0
  269. agent_env/task_step/task_steps/multienv_validator/verify_universe_agent_judge.py +180 -0
  270. agent_env/task_step/task_steps/multienv_validator/verify_universe_roundtrip.py +241 -0
  271. agent_env/task_step/task_steps/peer_agents.py +92 -0
  272. agent_env/task_step/task_steps/prompt_agent.py +900 -0
  273. agent_env/task_step/task_steps/register_agent_triggers.py +113 -0
  274. agent_env/task_step/task_steps/register_env_triggers.py +97 -0
  275. agent_env/task_step/task_steps/reset_env.py +71 -0
  276. agent_env/task_step/task_steps/review.py +152 -0
  277. agent_env/task_step/task_steps/run_code.py +461 -0
  278. agent_env/task_step/task_steps/run_code_runner.py +52 -0
  279. agent_env/task_step/task_steps/run_docker_container.py +380 -0
  280. agent_env/task_step/task_steps/sandbox_utils/__init__.py +0 -0
  281. agent_env/task_step/task_steps/sandbox_utils/sandbox_utils.py +79 -0
  282. agent_env/task_step/task_steps/snapshot_agent_state.py +205 -0
  283. agent_env/task_step/task_steps/snapshot_env.py +637 -0
  284. agent_env/task_step/task_steps/sync_env_clock.py +132 -0
  285. agent_env/task_step/task_steps/teardown_sandboxes.py +170 -0
  286. agent_env/task_step/task_steps/verifiers/__init__.py +23 -0
  287. agent_env/task_step/task_steps/verifiers/agent_prompt_response_verifier.py +166 -0
  288. agent_env/task_step/task_steps/verifiers/aggregate_verifiers.py +90 -0
  289. agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py +127 -0
  290. agent_env/task_step/task_steps/verifiers/judge_utils/__init__.py +2 -0
  291. agent_env/task_step/task_steps/verifiers/judge_utils/frame_selection.py +377 -0
  292. agent_env/task_step/task_steps/verifiers/judge_utils/judge_output_format.py +1293 -0
  293. agent_env/task_step/task_steps/verifiers/judge_utils/trajectory_filter.py +514 -0
  294. agent_env/task_step/task_steps/verifiers/rubrics_verifier.py +1318 -0
  295. agent_env/task_step/task_steps/verifiers/run_container_unit_tests_verifier.py +434 -0
  296. agent_env/task_step/task_steps/verifiers/scoring.py +40 -0
  297. agent_env/task_step/task_steps/verifiers/verify_sandbox.py +308 -0
  298. agent_env/task_step/thread_work.py +61 -0
  299. agent_env/utils/__init__.py +0 -0
  300. agent_env/utils/card_naming.py +108 -0
  301. agent_env/utils/deprecation.py +109 -0
  302. agent_env/utils/docker_build.py +29 -0
  303. agent_env/utils/exec_retry.py +77 -0
  304. agent_env/utils/litellm_attribution.py +59 -0
  305. agent_env/utils/paths.py +20 -0
  306. agentenv_framework-0.9.1254.dist-info/METADATA +394 -0
  307. agentenv_framework-0.9.1254.dist-info/RECORD +312 -0
  308. agentenv_framework-0.9.1254.dist-info/WHEEL +4 -0
  309. agentenv_framework-0.9.1254.dist-info/entry_points.txt +5 -0
  310. agentenv_framework-0.9.1254.dist-info/licenses/LICENSE +202 -0
  311. agentenv_framework-0.9.1254.dist-info/licenses/NOTICE +4 -0
  312. agentenv_framework-0.9.1254.dist-info/licenses/THIRD_PARTY_NOTICES.md +7665 -0
@@ -0,0 +1,314 @@
1
+ """SQLite implementation of the DocumentStore interface (stdlib, no infra).
2
+
3
+ Stores each document as a JSON row in a per-collection table; SQLite provides
4
+ uniqueness (via unique indexes on ``json_extract``), atomicity (transactions),
5
+ and durability. ``Filter`` / ``UpdateSpec`` / ``Sort`` semantics are evaluated in
6
+ Python, mirroring ``mongo_document_store`` as the oracle. Datetimes are
7
+ serialized as ISO strings.
8
+
9
+ SQL safety note: every user-controlled *value* is bound as a ``?`` placeholder.
10
+ The only interpolated tokens are SQL *identifiers* (table / index / column
11
+ names), which cannot be passed as bound parameters. Those are derived from a
12
+ strict allowlist (see ``_safe_ident`` / ``_safe_path``) before interpolation, so
13
+ the ``execute(f"...")`` sites carry ``# nosemgrep`` for the raw-query rule.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import re
21
+ import sqlite3
22
+ import threading
23
+ import time
24
+ from contextlib import contextmanager
25
+ from datetime import datetime
26
+ from pathlib import Path
27
+ from typing import Optional
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ from agent_env.store.document_store.document_store import (
32
+ DocumentStore,
33
+ DuplicateKeyError,
34
+ Filter,
35
+ Sort,
36
+ UpdateSpec,
37
+ )
38
+ from agent_env.store.document_store import evaluation
39
+ from agent_env.store.ids import aliases_local_encoding
40
+ from agent_env.store.local_state import ensure_state_dir
41
+
42
+ # SQL identifiers (table/index names) must be a bare word; JSON-path field
43
+ # references additionally allow dots for nested paths (e.g. ``metadata.rev``).
44
+ _IDENT_RE = re.compile(r"[A-Za-z0-9_]+")
45
+ _PATH_RE = re.compile(r"[A-Za-z0-9_.]+")
46
+
47
+ _BUSY_TIMEOUT_SECONDS = 5.0
48
+ _WAL_SWITCH_RETRY_DELAY_SECONDS = 0.01
49
+
50
+
51
+ def _enable_wal(conn: sqlite3.Connection) -> None:
52
+ """Switch the database to WAL. Processes opening a new database at the same time all need
53
+ the exclusive lock this takes, and SQLite fails the losers at once instead of waiting, so
54
+ retry until the winner is done."""
55
+ deadline = time.monotonic() + _BUSY_TIMEOUT_SECONDS
56
+ while True:
57
+ try:
58
+ conn.execute("PRAGMA journal_mode=WAL")
59
+ return
60
+ except sqlite3.OperationalError as e:
61
+ if e.sqlite_errorcode != sqlite3.SQLITE_BUSY or time.monotonic() >= deadline:
62
+ raise
63
+ time.sleep(_WAL_SWITCH_RETRY_DELAY_SECONDS)
64
+
65
+
66
+ class LocalSqliteDocumentStore(DocumentStore):
67
+ """DocumentStore backed by a single stdlib-``sqlite3`` database file."""
68
+
69
+ def __init__(self, path: str) -> None:
70
+ # Nothing touches disk until the first operation: constructing a store creates nothing.
71
+ self._path = Path(path)
72
+ self._connection: Optional[sqlite3.Connection] = None
73
+ self._lock = threading.RLock()
74
+ self._tables: set[str] = set()
75
+
76
+ @property
77
+ def path(self) -> Path:
78
+ return self._path
79
+
80
+ def check_id(self, entity_id: str) -> None:
81
+ super().check_id(entity_id)
82
+ if aliases_local_encoding(entity_id):
83
+ raise ValueError(
84
+ f"id {entity_id!r} is spelled like an encoded @local id, so it would share that id's objects, "
85
+ "images and files; a bare id in a local store can't start with 'local/' or 'local-<name>-<12 hex>'"
86
+ )
87
+
88
+ @property
89
+ def _conn(self) -> sqlite3.Connection:
90
+ with self._lock:
91
+ if self._connection is None:
92
+ ensure_state_dir(self._path.parent)
93
+ conn = sqlite3.connect(
94
+ str(self._path), timeout=_BUSY_TIMEOUT_SECONDS, check_same_thread=False, isolation_level=None
95
+ )
96
+ _enable_wal(conn)
97
+ self._connection = conn
98
+ return self._connection
99
+
100
+ def find_one(
101
+ self, collection: str, filter: Filter, sort: Optional[Sort] = None
102
+ ) -> Optional[dict]:
103
+ with self._lock:
104
+ matches = [doc for _, doc in self._load(collection) if evaluation.matches(doc, filter)]
105
+ matches = evaluation.sort_docs(matches, sort)
106
+ return matches[0] if matches else None
107
+
108
+ def query(
109
+ self,
110
+ collection: str,
111
+ filter: Filter,
112
+ sort: Optional[Sort] = None,
113
+ limit: Optional[int] = None,
114
+ offset: Optional[int] = None,
115
+ ) -> list[dict]:
116
+ with self._lock:
117
+ docs = [doc for _, doc in self._load(collection) if evaluation.matches(doc, filter)]
118
+ docs = evaluation.sort_docs(docs, sort)
119
+ if offset:
120
+ docs = docs[offset:]
121
+ if limit:
122
+ docs = docs[:limit]
123
+ return docs
124
+
125
+ def count(self, collection: str, filter: Filter) -> int:
126
+ with self._lock:
127
+ return sum(1 for _, doc in self._load(collection) if evaluation.matches(doc, filter))
128
+
129
+ def insert(self, collection: str, doc: dict) -> None:
130
+ with self._lock:
131
+ tbl = self._ensure_table(collection)
132
+ self._insert_raw(tbl, dict(doc))
133
+
134
+ def update(
135
+ self, collection: str, filter: Filter, update: UpdateSpec, upsert: bool = False
136
+ ) -> int:
137
+ update.validate()
138
+ with self._lock, self._immediate():
139
+ match = self._first_match(collection, filter)
140
+ if match is not None:
141
+ rowid, doc = match
142
+ evaluation.apply_update(doc, update)
143
+ self._conn.execute(
144
+ # nosemgrep: sqlalchemy-execute-raw-query -- table is a validated identifier; doc/rowid are bound
145
+ f'UPDATE "{self._table(collection)}" SET doc=? WHERE rowid=?',
146
+ (self._dumps(doc), rowid),
147
+ )
148
+ return 1
149
+ if upsert:
150
+ self._insert_raw(self._ensure_table(collection), evaluation.synthesize(filter, update))
151
+ return 1
152
+ return 0
153
+
154
+ def update_one_and_get(
155
+ self,
156
+ collection: str,
157
+ filter: Filter,
158
+ update: UpdateSpec,
159
+ return_after: bool = True,
160
+ upsert: bool = False,
161
+ ) -> Optional[dict]:
162
+ update.validate()
163
+ with self._lock, self._immediate():
164
+ match = self._first_match(collection, filter)
165
+ if match is not None:
166
+ rowid, doc = match
167
+ before = json.loads(self._dumps(doc))
168
+ evaluation.apply_update(doc, update)
169
+ self._conn.execute(
170
+ # nosemgrep: sqlalchemy-execute-raw-query -- table is a validated identifier; doc/rowid are bound
171
+ f'UPDATE "{self._table(collection)}" SET doc=? WHERE rowid=?',
172
+ (self._dumps(doc), rowid),
173
+ )
174
+ return doc if return_after else before
175
+ if upsert:
176
+ new_doc = evaluation.synthesize(filter, update)
177
+ self._insert_raw(self._ensure_table(collection), new_doc)
178
+ return new_doc if return_after else None
179
+ return None
180
+
181
+ def replace(
182
+ self, collection: str, filter: Filter, doc: dict, upsert: bool = False
183
+ ) -> int:
184
+ with self._lock, self._immediate():
185
+ match = self._first_match(collection, filter)
186
+ if match is not None:
187
+ rowid, _ = match
188
+ self._conn.execute(
189
+ # nosemgrep: sqlalchemy-execute-raw-query -- table is a validated identifier; doc/rowid are bound
190
+ f'UPDATE "{self._table(collection)}" SET doc=? WHERE rowid=?',
191
+ (self._dumps(dict(doc)), rowid),
192
+ )
193
+ return 1
194
+ if upsert:
195
+ self._insert_raw(self._ensure_table(collection), dict(doc))
196
+ return 1
197
+ return 0
198
+
199
+ def delete(self, collection: str, filter: Filter) -> int:
200
+ with self._lock, self._immediate():
201
+ match = self._first_match(collection, filter)
202
+ if match is None:
203
+ return 0
204
+ rowid, _ = match
205
+ # nosemgrep: sqlalchemy-execute-raw-query -- table is a validated identifier; rowid is bound
206
+ self._conn.execute(f'DELETE FROM "{self._table(collection)}" WHERE rowid=?', (rowid,))
207
+ return 1
208
+
209
+ def ensure_index(
210
+ self,
211
+ collection: str,
212
+ fields: list[str],
213
+ unique: bool = False,
214
+ ttl_seconds: Optional[int] = None,
215
+ ) -> None:
216
+ if ttl_seconds is not None:
217
+ logger.warning(
218
+ "LocalSqliteDocumentStore has no TTL support; ttl_seconds=%s on %s%s ignored — rows will not auto-expire.",
219
+ ttl_seconds, collection, fields,
220
+ )
221
+ with self._lock:
222
+ tbl = self._ensure_table(collection)
223
+ raw_name = tbl + "_" + "_".join(fields) + ("_unique" if unique else "_index")
224
+ name = self._safe_ident(re.sub(r"\W", "_", raw_name))
225
+ cols = ", ".join(f"json_extract(doc, '$.{self._safe_path(f)}')" for f in fields)
226
+ uniq = "UNIQUE " if unique else ""
227
+ self._conn.execute(
228
+ # nosemgrep: sqlalchemy-execute-raw-query -- name/tbl/cols are validated identifiers
229
+ f'CREATE {uniq}INDEX IF NOT EXISTS "{name}" ON "{tbl}" ({cols})'
230
+ )
231
+
232
+ # --- sqlite plumbing ---
233
+
234
+ @staticmethod
235
+ def _safe_ident(name: str) -> str:
236
+ """Validate a SQL identifier (table/index name) against a strict allowlist.
237
+
238
+ Identifiers cannot be passed as bound parameters, so we assert they are a
239
+ bare ``[A-Za-z0-9_]`` word before interpolating them into SQL. User input
240
+ only ever reaches SQL as a bound ``?`` value, never as an identifier.
241
+ """
242
+ if not _IDENT_RE.fullmatch(name):
243
+ raise ValueError(f"unsafe SQL identifier: {name!r}")
244
+ return name
245
+
246
+ @staticmethod
247
+ def _safe_path(field: str) -> str:
248
+ """Validate a JSON-path field reference used inside ``json_extract('$.…')``."""
249
+ if not _PATH_RE.fullmatch(field):
250
+ raise ValueError(f"unsafe JSON field path: {field!r}")
251
+ return field
252
+
253
+ def _table(self, collection: str) -> str:
254
+ # The table name is an identifier (not bindable): sanitize the collection
255
+ # to a bare word, then assert the allowlist as defense-in-depth.
256
+ return self._safe_ident("docs_" + re.sub(r"\W", "_", collection))
257
+
258
+ def _ensure_table(self, collection: str) -> str:
259
+ tbl = self._table(collection)
260
+ if tbl not in self._tables:
261
+ # nosemgrep: sqlalchemy-execute-raw-query -- tbl is a validated identifier
262
+ self._conn.execute(f'CREATE TABLE IF NOT EXISTS "{tbl}" (doc TEXT NOT NULL)')
263
+ self._tables.add(tbl)
264
+ return tbl
265
+
266
+ def _adopt_if_created(self, tbl: str) -> bool:
267
+ """Adopt a table that exists on disk but is not yet in ``self._tables``."""
268
+ if self._conn.execute(
269
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (tbl,)
270
+ ).fetchone():
271
+ self._tables.add(tbl)
272
+ return True
273
+ return False
274
+
275
+ def _load(self, collection: str) -> list[tuple[int, dict]]:
276
+ tbl = self._table(collection)
277
+ if tbl not in self._tables and not self._adopt_if_created(tbl):
278
+ return []
279
+ return [
280
+ (row[0], json.loads(row[1]))
281
+ # nosemgrep: sqlalchemy-execute-raw-query -- tbl is a validated identifier
282
+ for row in self._conn.execute(f'SELECT rowid, doc FROM "{tbl}"')
283
+ ]
284
+
285
+ def _first_match(self, collection: str, filter: Filter) -> Optional[tuple[int, dict]]:
286
+ for rowid, doc in self._load(collection):
287
+ if evaluation.matches(doc, filter):
288
+ return rowid, doc
289
+ return None
290
+
291
+ @contextmanager
292
+ def _immediate(self):
293
+ self._conn.execute("BEGIN IMMEDIATE")
294
+ try:
295
+ yield
296
+ self._conn.execute("COMMIT")
297
+ except BaseException:
298
+ self._conn.execute("ROLLBACK")
299
+ raise
300
+
301
+ def _insert_raw(self, tbl: str, doc: dict) -> None:
302
+ try:
303
+ # nosemgrep: sqlalchemy-execute-raw-query -- tbl is a validated identifier; doc is bound
304
+ self._conn.execute(f'INSERT INTO "{tbl}"(doc) VALUES(?)', (self._dumps(doc),))
305
+ except sqlite3.IntegrityError as e:
306
+ raise DuplicateKeyError(str(e)) from e
307
+
308
+ def _json_default(self, o):
309
+ if isinstance(o, datetime):
310
+ return o.isoformat()
311
+ raise TypeError(f"Object of type {type(o).__name__} is not JSON-serializable")
312
+
313
+ def _dumps(self, doc: dict) -> str:
314
+ return json.dumps(doc, default=self._json_default)
agent_env/store/ids.py ADDED
@@ -0,0 +1,123 @@
1
+ """Namespaced entity ids, and the object keys, image repositories and filenames derived from them.
2
+
3
+ An id opening ``@<namespace>/`` names the store it lives in; ``@local/…`` is the only namespace
4
+ today. Documents keep an id verbatim. Everything derived from an ``@local`` id uses one bounded
5
+ segment, ``local/<slug>-<sha256(id)[:12]>``, and every other id passes through byte-identical.
6
+ The encoders never raise: they expect ids that ``validate_local_id`` accepts, and still return a
7
+ safe segment for any other input. An id may contain spaces, ``&`` and parentheses, so quote one
8
+ before it reaches a shell or a URL, and derive object keys, image repositories and filenames with
9
+ the helpers here rather than from the raw id.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import re
16
+ import unicodedata
17
+ from typing import Optional
18
+
19
+ LOCAL_NAMESPACE = "local"
20
+ LOCAL_PREFIX = f"@{LOCAL_NAMESPACE}/"
21
+ MAX_LOCAL_ID_BYTES = 4096
22
+
23
+ _SLUG_LENGTH = 48
24
+ _HASH_LENGTH = 12
25
+ _NON_SLUG_RUN = re.compile(r"[^a-z0-9]+")
26
+ _ENCODED_LOCAL_ID = re.compile(
27
+ rf"{LOCAL_NAMESPACE}(?:/|-(?:[a-z0-9]+(?:-[a-z0-9]+)*-)?[0-9a-f]{{{_HASH_LENGTH}}}(?:/|$))"
28
+ )
29
+ _ID_PUNCTUATION = frozenset(" ._-~/@()+,&")
30
+ _ID_CATEGORIES = frozenset("LNM")
31
+ _INVISIBLE = re.compile(
32
+ "[\u034f\u115f\u1160\u17b4\u17b5\u180b-\u180d\u180f\u3164\ufe00-\ufe0f\uffa0\U000e0100-\U000e01ef]"
33
+ )
34
+
35
+
36
+ def parse_namespace(entity_id: str) -> Optional[str]:
37
+ """The namespace an ``@<namespace>/…`` id names, or None for a bare id."""
38
+ if not entity_id.startswith("@"):
39
+ return None
40
+ return entity_id[1:].split("/", 1)[0]
41
+
42
+
43
+ def is_local_id(entity_id: str) -> bool:
44
+ return entity_id.startswith(LOCAL_PREFIX)
45
+
46
+
47
+ def validate_local_id(entity_id: str) -> None:
48
+ """Raise ValueError unless ``entity_id`` is ``@local/`` followed by non-empty path segments,
49
+ none of them ``.`` or ``..`` or opening or closing with a space, made only of letters, digits,
50
+ spaces and ``. _ - ~ / @ ( ) + , &``, and at most 4096 bytes of UTF-8 in all (Linux's PATH_MAX).
51
+ Only the leading ``@`` names a namespace, so one inside a segment is an ordinary character.
52
+
53
+ Letters include every script's, with its combining marks, as long as each mark follows a
54
+ letter, digit or mark. The invisible (default-ignorable) letters and marks are refused."""
55
+ if not entity_id.startswith(LOCAL_PREFIX):
56
+ raise ValueError(f"local id {entity_id!r} must start with {LOCAL_PREFIX!r}")
57
+ size = len(entity_id.encode("utf-8", "surrogatepass"))
58
+ if size > MAX_LOCAL_ID_BYTES:
59
+ raise ValueError(f"local id is {size} bytes; the limit is {MAX_LOCAL_ID_BYTES}: {entity_id[:64]!r}…")
60
+ disallowed = sorted({c for c in entity_id[len(LOCAL_PREFIX):] if not _allowed_in_id(c)})
61
+ if disallowed:
62
+ raise ValueError(
63
+ f"local id {entity_id!r} contains {ascii(''.join(disallowed))}; "
64
+ "a local id may contain only letters, digits, spaces and . _ - ~ / @ ( ) + , &"
65
+ )
66
+ segments = entity_id[len(LOCAL_PREFIX):].split("/")
67
+ if any(segment in ("", ".", "..") for segment in segments):
68
+ raise ValueError(f"local id {entity_id!r} has an empty, '.' or '..' path segment")
69
+ if any(segment != segment.strip(" ") for segment in segments):
70
+ raise ValueError(f"local id {entity_id!r} has a path segment that opens or closes with a space")
71
+ if any(_detached_mark(segment) for segment in segments):
72
+ raise ValueError(f"local id {entity_id!r} has a combining mark that does not follow a letter or digit")
73
+
74
+
75
+ def _allowed_in_id(character: str) -> bool:
76
+ if _INVISIBLE.match(character):
77
+ return False
78
+ return character in _ID_PUNCTUATION or unicodedata.category(character)[0] in _ID_CATEGORIES
79
+
80
+
81
+ def _detached_mark(segment: str) -> bool:
82
+ categories = [unicodedata.category(character)[0] for character in segment]
83
+ return any(this == "M" and before not in _ID_CATEGORIES for before, this in zip(["Z", *categories], categories))
84
+
85
+
86
+ def _local_segment(entity_id: str) -> str:
87
+ digest = hashlib.sha256(entity_id.encode("utf-8", "surrogatepass")).hexdigest()[:_HASH_LENGTH]
88
+ decomposed = unicodedata.normalize("NFKD", entity_id.removeprefix(LOCAL_PREFIX))
89
+ folded = "".join(c for c in decomposed if not unicodedata.combining(c)).casefold().replace("~", "")
90
+ slug = _NON_SLUG_RUN.sub("-", folded).strip("-")[:_SLUG_LENGTH].rstrip("-")
91
+ return f"{LOCAL_NAMESPACE}/{slug}-{digest}" if slug else f"{LOCAL_NAMESPACE}/{digest}"
92
+
93
+
94
+ def aliases_local_encoding(entity_id: str) -> bool:
95
+ """Whether a bare id is spelled like an ``@local`` id's encoding: ``local/…``, or the filename form
96
+ ``local-<slug>-<hash>`` alone or opening a path. Its objects, images and files would take that id's."""
97
+ return _ENCODED_LOCAL_ID.match(entity_id) is not None
98
+
99
+
100
+ def key_segment(entity_id: str) -> str:
101
+ """The object-key segment for ``entity_id`` (``artifacts/<type>/<segment>/<version>/…``)."""
102
+ return _local_segment(entity_id) if is_local_id(entity_id) else entity_id
103
+
104
+
105
+ def image_repository(entity_id: str) -> str:
106
+ """The image repository for ``entity_id``; an ``@local`` id yields a valid OCI repository path."""
107
+ return key_segment(entity_id)
108
+
109
+
110
+ def fs_safe(entity_id: str) -> str:
111
+ """A filename for an ``@local`` id: its key segment with no ``/``. Any other id is returned
112
+ unchanged, so it is only as filename-safe as it already was."""
113
+ return _local_segment(entity_id).replace("/", "-", 1) if is_local_id(entity_id) else entity_id
114
+
115
+
116
+ # The longest id derive_id makes is a skill's or CLI's per-file artifact, ``{id}__files__<16 hex>``,
117
+ # so an authored id leaves room for those 25 bytes.
118
+ MAX_AUTHORED_LOCAL_ID_BYTES = MAX_LOCAL_ID_BYTES - len("__files__") - 16
119
+
120
+
121
+ def derive_id(base: str, suffix: str) -> str:
122
+ """The id of an entity derived from ``base``: a suffix, so the base's namespace carries over."""
123
+ return f"{base}__{suffix}"
@@ -0,0 +1,34 @@
1
+ """Backend-agnostic image (registry) store abstraction + its implementations.
2
+
3
+ ``image_store`` defines the abstractions and generic OCI implementation;
4
+ ``ecr_image_store`` and ``local_registry_image_store`` retain compatibility names.
5
+ """
6
+
7
+ from agent_env.store.image_store.ecr_image_store import EcrCredentials, EcrImageStore
8
+ from agent_env.store.image_store.image_store import (
9
+ ImageStore,
10
+ OciRegistryImageStore,
11
+ )
12
+ from agent_env.store.image_store.local_registry_image_store import (
13
+ LocalRegistryImageStore,
14
+ )
15
+ from agent_env.store.image_store.oci_registry_credentials import (
16
+ OciRegistryCredentials,
17
+ RegistryAuth,
18
+ SecretStoreCredentials,
19
+ normalize_registry_host,
20
+ registry_host_from_ref,
21
+ )
22
+
23
+ __all__ = [
24
+ "ImageStore",
25
+ "OciRegistryImageStore",
26
+ "RegistryAuth",
27
+ "OciRegistryCredentials",
28
+ "normalize_registry_host",
29
+ "registry_host_from_ref",
30
+ "EcrCredentials",
31
+ "SecretStoreCredentials",
32
+ "EcrImageStore",
33
+ "LocalRegistryImageStore",
34
+ ]
@@ -0,0 +1,131 @@
1
+ """ECR implementation of the ImageStore interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import logging
8
+ from typing import Any
9
+
10
+ import boto3
11
+ from botocore.exceptions import ClientError
12
+
13
+ from agent_env.config.errors import ConfigError
14
+ from agent_env.store.image_store.image_store import OciRegistryImageStore
15
+ from agent_env.store.image_store.oci_registry_credentials import (
16
+ OciRegistryCredentials,
17
+ RegistryAuth,
18
+ normalize_registry_host,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class EcrCredentials(OciRegistryCredentials):
25
+ """Mint short-lived Docker credentials through the AWS ECR API."""
26
+
27
+ def __init__(
28
+ self,
29
+ region: str | None = None,
30
+ access_key: str | None = None,
31
+ secret_key: str | None = None,
32
+ *,
33
+ client: Any = None,
34
+ ) -> None:
35
+ if (access_key is None) != (secret_key is None):
36
+ raise ValueError("access_key and secret_key must be provided together")
37
+ self._region = region
38
+ self._access_key = access_key
39
+ self._secret_key = secret_key
40
+ self._client = client
41
+
42
+ @classmethod
43
+ def from_config(
44
+ cls,
45
+ *,
46
+ region: str | None = None,
47
+ access_key: str | None = None,
48
+ secret_key: str | None = None,
49
+ ) -> EcrCredentials:
50
+ """Build explicitly configured ECR credentials.
51
+
52
+ The keyless constructor (the legacy ``EcrImageStore`` default) resolves
53
+ the AWS default credential chain; generic OCI configuration must name its
54
+ credential inputs.
55
+ """
56
+ if not region or not access_key or not secret_key:
57
+ raise ValueError("region, access_key, and secret_key must be configured")
58
+ return cls(region=region, access_key=access_key, secret_key=secret_key)
59
+
60
+ @property
61
+ def client(self):
62
+ if self._client is None:
63
+ if self._region is None:
64
+ raise ConfigError(
65
+ "EcrCredentials has no region: configure it on the store's "
66
+ "credentials (e.g. [stores.image.config] region)."
67
+ )
68
+ if self._access_key is None:
69
+ self._client = boto3.client("ecr", region_name=self._region)
70
+ else:
71
+ self._client = boto3.client(
72
+ "ecr",
73
+ region_name=self._region,
74
+ aws_access_key_id=self._access_key,
75
+ aws_secret_access_key=self._secret_key,
76
+ )
77
+ return self._client
78
+
79
+ def mint(self, host: str) -> RegistryAuth:
80
+ token = self.client.get_authorization_token()["authorizationData"][0][
81
+ "authorizationToken"
82
+ ]
83
+ try:
84
+ username, password = (
85
+ base64.b64decode(token, validate=True).decode().split(":", 1)
86
+ )
87
+ except (binascii.Error, UnicodeDecodeError, ValueError):
88
+ raise ValueError("ECR returned an invalid authorization token") from None
89
+ return RegistryAuth(
90
+ registry=normalize_registry_host(host), username=username, password=password
91
+ )
92
+
93
+
94
+ class EcrImageStore(OciRegistryImageStore):
95
+ """ImageStore backed by AWS ECR; the client is built lazily on the first ECR op."""
96
+
97
+ def __init__(
98
+ self,
99
+ registry_host: str,
100
+ repository_prefix: str = "",
101
+ *,
102
+ credentials: EcrCredentials | None = None,
103
+ ) -> None:
104
+ if credentials is not None and not isinstance(credentials, EcrCredentials):
105
+ raise TypeError(
106
+ "EcrImageStore credentials must be an EcrCredentials instance"
107
+ )
108
+ self._ecr_credentials = credentials if credentials is not None else EcrCredentials()
109
+ super().__init__(
110
+ registry_host=registry_host,
111
+ repository_prefix=repository_prefix,
112
+ credentials=self._ecr_credentials,
113
+ )
114
+
115
+ @property
116
+ def _ecr(self):
117
+ return self._ecr_credentials.client
118
+
119
+ def ensure_repository(self, repository: str) -> None:
120
+ name = self._repo_name(repository)
121
+ try:
122
+ self._ecr.create_repository(repositoryName=name)
123
+ logger.info(f"Created ECR repository {name}")
124
+ except self._ecr.exceptions.RepositoryAlreadyExistsException:
125
+ pass
126
+ except ClientError as e:
127
+ if e.response["Error"]["Code"] != "AccessDeniedException":
128
+ raise
129
+ logger.info(
130
+ f"No CreateRepository permission for {name}; assuming it exists"
131
+ )