ya-agent-environment 0.74.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.
@@ -0,0 +1,85 @@
1
+ """Environment abstractions for file operations and shell execution.
2
+
3
+ This module provides Protocol-based interfaces and implementations for
4
+ environment operations, allowing different backends (local, remote, S3, SSH, etc.)
5
+ to be used interchangeably.
6
+ """
7
+
8
+ from ya_agent_environment.environment import Environment
9
+ from ya_agent_environment.exceptions import (
10
+ EnvironmentError as EnvironmentError,
11
+ )
12
+ from ya_agent_environment.exceptions import (
13
+ EnvironmentNotEnteredError,
14
+ FileOperationError,
15
+ PathNotAllowedError,
16
+ ShellExecutionError,
17
+ ShellTimeoutError,
18
+ )
19
+ from ya_agent_environment.file_operator import (
20
+ DEFAULT_INSTRUCTIONS_MAX_DEPTH,
21
+ DEFAULT_INSTRUCTIONS_SKIP_DIRS,
22
+ FileOperator,
23
+ LocalTmpFileOperator,
24
+ )
25
+ from ya_agent_environment.protocols import (
26
+ DEFAULT_CHUNK_SIZE,
27
+ InstructableResource,
28
+ Resource,
29
+ ResumableResource,
30
+ TmpFileOperator,
31
+ )
32
+ from ya_agent_environment.resources import (
33
+ BaseResource,
34
+ ResourceEntry,
35
+ ResourceFactory,
36
+ ResourceRegistry,
37
+ ResourceRegistryState,
38
+ )
39
+ from ya_agent_environment.shell import (
40
+ BackgroundProcess,
41
+ CompletedProcess,
42
+ DeferredShell,
43
+ ExecutionHandle,
44
+ OutputBuffer,
45
+ ReadyState,
46
+ Shell,
47
+ StdinAdapter,
48
+ )
49
+ from ya_agent_environment.types import FileStat, TruncatedResult
50
+ from ya_agent_environment.utils import generate_filetree
51
+
52
+ __all__ = [
53
+ "DEFAULT_CHUNK_SIZE",
54
+ "DEFAULT_INSTRUCTIONS_MAX_DEPTH",
55
+ "DEFAULT_INSTRUCTIONS_SKIP_DIRS",
56
+ "BackgroundProcess",
57
+ "BaseResource",
58
+ "CompletedProcess",
59
+ "DeferredShell",
60
+ "Environment",
61
+ "EnvironmentError",
62
+ "EnvironmentNotEnteredError",
63
+ "ExecutionHandle",
64
+ "FileOperationError",
65
+ "FileOperator",
66
+ "FileStat",
67
+ "InstructableResource",
68
+ "LocalTmpFileOperator",
69
+ "OutputBuffer",
70
+ "PathNotAllowedError",
71
+ "ReadyState",
72
+ "Resource",
73
+ "ResourceEntry",
74
+ "ResourceFactory",
75
+ "ResourceRegistry",
76
+ "ResourceRegistryState",
77
+ "ResumableResource",
78
+ "Shell",
79
+ "ShellExecutionError",
80
+ "ShellTimeoutError",
81
+ "StdinAdapter",
82
+ "TmpFileOperator",
83
+ "TruncatedResult",
84
+ "generate_filetree",
85
+ ]
@@ -0,0 +1,382 @@
1
+ """Environment abstraction for environment module.
2
+
3
+ This module provides an abstract base class for environment context managers
4
+ that manage the lifecycle of shared resources.
5
+ """
6
+
7
+ import asyncio
8
+ from abc import ABC, abstractmethod
9
+ from contextlib import AbstractAsyncContextManager
10
+ from typing import Any, Self
11
+
12
+ from ya_agent_environment.exceptions import EnvironmentNotEnteredError
13
+ from ya_agent_environment.file_operator import FileOperator
14
+ from ya_agent_environment.resources import ResourceFactory, ResourceRegistry, ResourceRegistryState
15
+ from ya_agent_environment.shell import Shell
16
+
17
+
18
+ class Environment(ABC):
19
+ """Abstract base class for environment context manager.
20
+
21
+ Environment manages the lifecycle of shared resources (file_operator, shell, resources)
22
+ that can be reused across multiple AgentContext sessions.
23
+
24
+ Subclasses should:
25
+ - Call super().__init__() to initialize the resource registry
26
+ - Implement _setup() to create file_operator, shell, and any custom resources
27
+ - Implement _teardown() to clean up environment-specific resources
28
+ - Optionally populate self._toolsets in _setup() to provide environment-specific tools
29
+ - NOT override __aenter__ or __aexit__ (use _setup/_teardown instead)
30
+
31
+ The base class handles:
32
+ - Calling _setup() in __aenter__
33
+ - Binding resource registry to environment (so factories can access infrastructure)
34
+ - Calling resources.restore_all() after _setup() for resumable resources
35
+ - Calling _teardown() then resources.close_all() in __aexit__
36
+
37
+ Resource Factory Pattern:
38
+ Factories receive the Environment instance, allowing access to infrastructure:
39
+
40
+ ```python
41
+ async def create_browser(env: Environment) -> BrowserSession:
42
+ return BrowserSession(
43
+ file_operator=env.file_operator, # Access file system
44
+ shell=env.shell, # Execute commands
45
+ tmp_dir=env.tmp_dir, # Temporary storage
46
+ )
47
+
48
+ async with LocalEnvironment() as env:
49
+ env.resources.register_factory("browser", create_browser)
50
+ browser = await env.resources.get_or_create("browser")
51
+ ```
52
+
53
+ Resumable Resources:
54
+ Environment supports resource state persistence via ResourceRegistry.
55
+ Resources implementing ResumableResource can have their state exported
56
+ and restored across process restarts.
57
+
58
+ Example:
59
+ # First run
60
+ async with LocalEnvironment() as env:
61
+ env.resources.register_factory("browser", create_browser)
62
+ browser = await env.resources.get_or_create("browser")
63
+ # ... use browser ...
64
+ state = env.export_resource_state()
65
+ save_state(state)
66
+
67
+ # Subsequent run
68
+ state = load_state()
69
+ async with LocalEnvironment(
70
+ resource_state=state,
71
+ resource_factories={"browser": create_browser},
72
+ ) as env:
73
+ # Browser automatically restored with previous state
74
+ browser = env.resources.get("browser")
75
+
76
+ Example:
77
+ Using AsyncExitStack (recommended for dependent contexts):
78
+
79
+ ```python
80
+ from contextlib import AsyncExitStack
81
+
82
+ async with AsyncExitStack() as stack:
83
+ env = await stack.enter_async_context(
84
+ LocalEnvironment(allowed_paths=[Path("/workspace")])
85
+ )
86
+ ctx = await stack.enter_async_context(
87
+ AgentContext(env=env)
88
+ )
89
+ # Get combined toolsets from environment and resources
90
+ toolsets = env.get_toolsets()
91
+ agent = Agent(..., toolsets=[*core_toolsets, *toolsets])
92
+ ...
93
+ # Resources cleaned up when stack exits
94
+ ```
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ resource_state: ResourceRegistryState | None = None,
100
+ resource_factories: dict[str, ResourceFactory] | None = None,
101
+ ) -> None:
102
+ """Initialize the environment.
103
+
104
+ Args:
105
+ resource_state: Optional state to restore resources from.
106
+ Resources will be restored when entering the context.
107
+ resource_factories: Optional dictionary of resource factories.
108
+ Required for any resources in resource_state.
109
+ """
110
+ self._resources = ResourceRegistry(
111
+ state=resource_state,
112
+ factories=resource_factories,
113
+ )
114
+ self._file_operator: FileOperator | None = None
115
+ self._shell: Shell | None = None
116
+ self._toolsets: list[Any] = []
117
+ self._entered: bool = False
118
+ self._enter_lock: asyncio.Lock = asyncio.Lock()
119
+
120
+ @property
121
+ def entered(self) -> bool:
122
+ """Whether the environment has been entered via async context manager."""
123
+ return self._entered
124
+
125
+ @property
126
+ def file_operator(self) -> FileOperator | None:
127
+ """Return the file operator, or None if not configured.
128
+
129
+ Raises:
130
+ EnvironmentNotEnteredError: If environment has not been entered.
131
+
132
+ Returns None when the subclass chooses not to provide a file operator.
133
+ """
134
+ if not self._entered:
135
+ raise EnvironmentNotEnteredError("file_operator")
136
+ return self._file_operator
137
+
138
+ @property
139
+ def shell(self) -> Shell | None:
140
+ """Return the shell, or None if not configured.
141
+
142
+ Raises:
143
+ EnvironmentNotEnteredError: If environment has not been entered.
144
+
145
+ Returns None when the subclass chooses not to provide a shell.
146
+ """
147
+ if not self._entered:
148
+ raise EnvironmentNotEnteredError("shell")
149
+ return self._shell
150
+
151
+ @property
152
+ def resources(self) -> ResourceRegistry:
153
+ """Return the resource registry for runtime resources.
154
+
155
+ Resources can be accessed by AgentContext and tools.
156
+ """
157
+ return self._resources
158
+
159
+ def get_toolsets(self) -> list[Any]:
160
+ """Return combined toolsets from environment and all resources.
161
+
162
+ Collects toolsets from:
163
+ 1. Environment-level toolsets (self._toolsets, set in _setup())
164
+ 2. All registered resources via ResourceRegistry.get_toolsets()
165
+
166
+ This is the recommended way to get all available toolsets.
167
+
168
+ Returns:
169
+ Combined list of toolsets from environment and resources.
170
+
171
+ Example:
172
+ ```python
173
+ async with MyEnvironment() as env:
174
+ toolsets = env.get_toolsets()
175
+ agent = Agent(..., toolsets=[*core_toolsets, *toolsets])
176
+ ```
177
+ """
178
+ toolsets = list(self._toolsets)
179
+ toolsets.extend(self._resources.get_toolsets())
180
+ return toolsets
181
+
182
+ # --- Chaining API for resource factories and state ---
183
+
184
+ def with_resource_factory(self, key: str, factory: ResourceFactory) -> "Self":
185
+ """Register a resource factory. Can be chained.
186
+
187
+ Args:
188
+ key: Unique identifier for the resource.
189
+ factory: Async callable that creates the resource.
190
+
191
+ Returns:
192
+ Self for method chaining.
193
+
194
+ Example:
195
+ env = (LocalEnvironment()
196
+ .with_resource_factory("browser", create_browser)
197
+ .with_resource_factory("db", create_db_pool))
198
+ """
199
+ self._resources.register_factory(key, factory)
200
+ return self
201
+
202
+ def with_resource_state(self, state: ResourceRegistryState | None) -> "Self":
203
+ """Set resource state to restore on enter. Can be chained.
204
+
205
+ Args:
206
+ state: State to restore from, or None to clear pending state.
207
+
208
+ Returns:
209
+ Self for method chaining.
210
+
211
+ Example:
212
+ state = ResourceRegistryState.model_validate_json(saved_json)
213
+ env = (LocalEnvironment()
214
+ .with_resource_factory("browser", create_browser)
215
+ .with_resource_state(state))
216
+ """
217
+ if state is not None:
218
+ self._resources._pending_state = state
219
+ return self
220
+
221
+ # --- Export method ---
222
+
223
+ async def export_resource_state(self) -> ResourceRegistryState:
224
+ """Export resource registry state for serialization.
225
+
226
+ Only resources implementing ResumableResource will be included.
227
+
228
+ Returns:
229
+ ResourceRegistryState that can be serialized to JSON.
230
+
231
+ Example:
232
+ state = await env.export_resource_state()
233
+ Path("state.json").write_text(state.model_dump_json())
234
+ """
235
+ return await self._resources.export_state()
236
+
237
+ # --- Subclass hooks ---
238
+
239
+ @abstractmethod
240
+ async def _setup(self) -> None:
241
+ """Initialize environment resources.
242
+
243
+ Subclasses must implement this to:
244
+ - Optionally create and assign self._file_operator
245
+ - Optionally create and assign self._shell
246
+ - Optionally register custom resources via self._resources.set()
247
+
248
+ Both file_operator and shell may be left as None if the environment
249
+ does not need them. Tools should check availability before use.
250
+
251
+ This is called by __aenter__.
252
+ """
253
+ ...
254
+
255
+ @abstractmethod
256
+ async def _teardown(self) -> None:
257
+ """Clean up environment-specific resources.
258
+
259
+ Subclasses must implement this to:
260
+ - Clean up tmp_dir, containers, connections, etc.
261
+ - Set self._file_operator = None
262
+ - Set self._shell = None
263
+
264
+ Note: self._resources.close_all() is called automatically after _teardown().
265
+ This is called by __aexit__.
266
+ """
267
+ ...
268
+
269
+ # --- Workspace isolation ---
270
+
271
+ def fork(self) -> AbstractAsyncContextManager["Self"]:
272
+ """Create an isolated copy of this environment.
273
+
274
+ Returns an async context manager that yields a new Environment instance
275
+ with its own file_operator and shell pointing to an isolated workspace
276
+ directory. The forked environment starts with an empty resource registry.
277
+
278
+ Subclasses that support workspace isolation should override this method.
279
+ The mechanism for creating the isolated directory (e.g., git worktree,
280
+ file copy) is an implementation detail of each subclass.
281
+
282
+ Yields:
283
+ A new Environment instance with isolated workspace.
284
+
285
+ Raises:
286
+ NotImplementedError: If the subclass does not support forking.
287
+
288
+ Example::
289
+
290
+ async with env.fork() as forked_env:
291
+ # forked_env has its own workspace
292
+ await forked_env.shell.execute("make test")
293
+ # Isolated workspace is cleaned up automatically
294
+ """
295
+ raise NotImplementedError(
296
+ f"{self.__class__.__name__} does not support fork(). "
297
+ "Subclasses must override this method to provide workspace isolation."
298
+ )
299
+
300
+ # --- Fixed lifecycle management ---
301
+
302
+ async def __aenter__(self) -> "Self":
303
+ """Enter context and setup resources.
304
+
305
+ This method:
306
+ 1. Calls _setup() to initialize file_operator, shell, etc.
307
+ 2. Binds the resource registry to this environment
308
+ 3. Calls resources.restore_all() to restore pending resources
309
+
310
+ Raises:
311
+ RuntimeError: If the environment has already been entered.
312
+ KeyError: If pending state references a resource without factory.
313
+ """
314
+ async with self._enter_lock:
315
+ if self._entered:
316
+ raise RuntimeError(
317
+ f"{self.__class__.__name__} has already been entered. "
318
+ "Each Environment instance can only be entered once at a time."
319
+ )
320
+ self._entered = True
321
+ await self._setup()
322
+
323
+ # Bind resource registry to this environment so factories can access infrastructure
324
+ self._resources.bind(self)
325
+
326
+ # Restore resources from pending state (if any)
327
+ await self._resources.restore_all()
328
+
329
+ return self
330
+
331
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
332
+ """Exit context and cleanup resources."""
333
+ try:
334
+ await self._teardown()
335
+ finally:
336
+ # Close file_operator and shell, then close all registered resources
337
+ if self._file_operator is not None:
338
+ await self._file_operator.close()
339
+ if self._shell is not None:
340
+ await self._shell.close()
341
+ await self._resources.close_all()
342
+ async with self._enter_lock:
343
+ self._entered = False
344
+
345
+ async def get_context_instructions(self) -> str:
346
+ """Return combined context instructions from file_operator, shell, and resources.
347
+
348
+ Subclasses can override this to provide additional environment-specific
349
+ instructions. The default implementation combines file_operator, shell,
350
+ and resources instructions.
351
+
352
+ Returns:
353
+ Combined XML-formatted instructions string wrapped in <environment-context> tags.
354
+
355
+ Raises:
356
+ EnvironmentNotEnteredError: If environment has not been entered yet.
357
+ """
358
+ if not self._entered:
359
+ raise EnvironmentNotEnteredError("environment")
360
+
361
+ parts: list[str] = []
362
+
363
+ if self._file_operator is not None:
364
+ file_instructions = await self._file_operator.get_context_instructions()
365
+ if file_instructions:
366
+ parts.append(file_instructions)
367
+
368
+ if self._shell is not None:
369
+ shell_instructions = await self._shell.get_context_instructions()
370
+ if shell_instructions:
371
+ parts.append(shell_instructions)
372
+
373
+ # Collect resource instructions
374
+ resource_instructions = await self._resources.get_context_instructions()
375
+ if resource_instructions:
376
+ parts.append(resource_instructions)
377
+
378
+ if not parts:
379
+ return ""
380
+
381
+ content = "\n\n".join(parts)
382
+ return f"<environment-context>\n{content}\n</environment-context>"
@@ -0,0 +1,69 @@
1
+ """Environment-related exceptions."""
2
+
3
+
4
+ class EnvironmentError(Exception): # noqa: A001
5
+ """Base exception for environment operations."""
6
+
7
+ pass
8
+
9
+
10
+ class PathNotAllowedError(EnvironmentError):
11
+ """Raised when a path is outside allowed directories."""
12
+
13
+ def __init__(self, path: str, allowed_paths: list[str] | None = None):
14
+ self.path = path
15
+ self.allowed_paths = allowed_paths or []
16
+ msg = f"Path '{path}' is not within allowed directories"
17
+ if self.allowed_paths:
18
+ msg += f": {', '.join(self.allowed_paths)}"
19
+ super().__init__(msg)
20
+
21
+
22
+ class FileOperationError(EnvironmentError):
23
+ """Raised when a file operation fails."""
24
+
25
+ def __init__(self, operation: str, path: str, reason: str | None = None):
26
+ self.operation = operation
27
+ self.path = path
28
+ self.reason = reason
29
+ msg = f"Failed to {operation} '{path}'"
30
+ if reason:
31
+ msg += f": {reason}"
32
+ super().__init__(msg)
33
+
34
+
35
+ class ShellExecutionError(EnvironmentError):
36
+ """Raised when shell command execution fails."""
37
+
38
+ def __init__(
39
+ self,
40
+ command: str,
41
+ exit_code: int | None = None,
42
+ stderr: str | None = None,
43
+ ):
44
+ self.command = command
45
+ self.exit_code = exit_code
46
+ self.stderr = stderr
47
+ msg = f"Command failed: {command}"
48
+ if exit_code is not None:
49
+ msg += f" (exit code: {exit_code})"
50
+ if stderr:
51
+ msg += f"\n{stderr}"
52
+ super().__init__(msg)
53
+
54
+
55
+ class ShellTimeoutError(ShellExecutionError):
56
+ """Raised when shell command times out."""
57
+
58
+ def __init__(self, command: str, timeout: float):
59
+ self.timeout = timeout
60
+ super().__init__(command)
61
+ self.args = (f"Command timed out after {timeout}s: {command}",)
62
+
63
+
64
+ class EnvironmentNotEnteredError(EnvironmentError):
65
+ """Raised when accessing resources before entering the environment context."""
66
+
67
+ def __init__(self, resource: str):
68
+ self.resource = resource
69
+ super().__init__(f"Environment not entered. Use 'async with environment:' before accessing {resource}.")