aury-agent 0.0.4__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.
- aury/__init__.py +2 -0
- aury/agents/__init__.py +55 -0
- aury/agents/a2a/__init__.py +168 -0
- aury/agents/backends/__init__.py +196 -0
- aury/agents/backends/artifact/__init__.py +9 -0
- aury/agents/backends/artifact/memory.py +130 -0
- aury/agents/backends/artifact/types.py +133 -0
- aury/agents/backends/code/__init__.py +65 -0
- aury/agents/backends/file/__init__.py +11 -0
- aury/agents/backends/file/local.py +66 -0
- aury/agents/backends/file/types.py +40 -0
- aury/agents/backends/invocation/__init__.py +8 -0
- aury/agents/backends/invocation/memory.py +81 -0
- aury/agents/backends/invocation/types.py +110 -0
- aury/agents/backends/memory/__init__.py +8 -0
- aury/agents/backends/memory/memory.py +179 -0
- aury/agents/backends/memory/types.py +136 -0
- aury/agents/backends/message/__init__.py +9 -0
- aury/agents/backends/message/memory.py +122 -0
- aury/agents/backends/message/types.py +124 -0
- aury/agents/backends/sandbox.py +275 -0
- aury/agents/backends/session/__init__.py +8 -0
- aury/agents/backends/session/memory.py +93 -0
- aury/agents/backends/session/types.py +124 -0
- aury/agents/backends/shell/__init__.py +11 -0
- aury/agents/backends/shell/local.py +110 -0
- aury/agents/backends/shell/types.py +55 -0
- aury/agents/backends/shell.py +209 -0
- aury/agents/backends/snapshot/__init__.py +19 -0
- aury/agents/backends/snapshot/git.py +95 -0
- aury/agents/backends/snapshot/hybrid.py +125 -0
- aury/agents/backends/snapshot/memory.py +86 -0
- aury/agents/backends/snapshot/types.py +59 -0
- aury/agents/backends/state/__init__.py +29 -0
- aury/agents/backends/state/composite.py +49 -0
- aury/agents/backends/state/file.py +57 -0
- aury/agents/backends/state/memory.py +52 -0
- aury/agents/backends/state/sqlite.py +262 -0
- aury/agents/backends/state/types.py +178 -0
- aury/agents/backends/subagent/__init__.py +165 -0
- aury/agents/cli/__init__.py +41 -0
- aury/agents/cli/chat.py +239 -0
- aury/agents/cli/config.py +236 -0
- aury/agents/cli/extensions.py +460 -0
- aury/agents/cli/main.py +189 -0
- aury/agents/cli/session.py +337 -0
- aury/agents/cli/workflow.py +276 -0
- aury/agents/context_providers/__init__.py +66 -0
- aury/agents/context_providers/artifact.py +299 -0
- aury/agents/context_providers/base.py +177 -0
- aury/agents/context_providers/memory.py +70 -0
- aury/agents/context_providers/message.py +130 -0
- aury/agents/context_providers/skill.py +50 -0
- aury/agents/context_providers/subagent.py +46 -0
- aury/agents/context_providers/tool.py +68 -0
- aury/agents/core/__init__.py +83 -0
- aury/agents/core/base.py +573 -0
- aury/agents/core/context.py +797 -0
- aury/agents/core/context_builder.py +303 -0
- aury/agents/core/event_bus/__init__.py +15 -0
- aury/agents/core/event_bus/bus.py +203 -0
- aury/agents/core/factory.py +169 -0
- aury/agents/core/isolator.py +97 -0
- aury/agents/core/logging.py +95 -0
- aury/agents/core/parallel.py +194 -0
- aury/agents/core/runner.py +139 -0
- aury/agents/core/services/__init__.py +5 -0
- aury/agents/core/services/file_session.py +144 -0
- aury/agents/core/services/message.py +53 -0
- aury/agents/core/services/session.py +53 -0
- aury/agents/core/signals.py +109 -0
- aury/agents/core/state.py +363 -0
- aury/agents/core/types/__init__.py +107 -0
- aury/agents/core/types/action.py +176 -0
- aury/agents/core/types/artifact.py +135 -0
- aury/agents/core/types/block.py +736 -0
- aury/agents/core/types/message.py +350 -0
- aury/agents/core/types/recall.py +144 -0
- aury/agents/core/types/session.py +257 -0
- aury/agents/core/types/subagent.py +154 -0
- aury/agents/core/types/tool.py +205 -0
- aury/agents/eval/__init__.py +331 -0
- aury/agents/hitl/__init__.py +57 -0
- aury/agents/hitl/ask_user.py +242 -0
- aury/agents/hitl/compaction.py +230 -0
- aury/agents/hitl/exceptions.py +87 -0
- aury/agents/hitl/permission.py +617 -0
- aury/agents/hitl/revert.py +216 -0
- aury/agents/llm/__init__.py +31 -0
- aury/agents/llm/adapter.py +367 -0
- aury/agents/llm/openai.py +294 -0
- aury/agents/llm/provider.py +476 -0
- aury/agents/mcp/__init__.py +153 -0
- aury/agents/memory/__init__.py +46 -0
- aury/agents/memory/compaction.py +394 -0
- aury/agents/memory/manager.py +465 -0
- aury/agents/memory/processor.py +177 -0
- aury/agents/memory/store.py +187 -0
- aury/agents/memory/types.py +137 -0
- aury/agents/messages/__init__.py +40 -0
- aury/agents/messages/config.py +47 -0
- aury/agents/messages/raw_store.py +224 -0
- aury/agents/messages/store.py +118 -0
- aury/agents/messages/types.py +88 -0
- aury/agents/middleware/__init__.py +31 -0
- aury/agents/middleware/base.py +341 -0
- aury/agents/middleware/chain.py +342 -0
- aury/agents/middleware/message.py +129 -0
- aury/agents/middleware/message_container.py +126 -0
- aury/agents/middleware/raw_message.py +153 -0
- aury/agents/middleware/truncation.py +139 -0
- aury/agents/middleware/types.py +81 -0
- aury/agents/plugin.py +162 -0
- aury/agents/react/__init__.py +4 -0
- aury/agents/react/agent.py +1923 -0
- aury/agents/sandbox/__init__.py +23 -0
- aury/agents/sandbox/local.py +239 -0
- aury/agents/sandbox/remote.py +200 -0
- aury/agents/sandbox/types.py +115 -0
- aury/agents/skill/__init__.py +16 -0
- aury/agents/skill/loader.py +180 -0
- aury/agents/skill/types.py +83 -0
- aury/agents/tool/__init__.py +39 -0
- aury/agents/tool/builtin/__init__.py +23 -0
- aury/agents/tool/builtin/ask_user.py +155 -0
- aury/agents/tool/builtin/bash.py +107 -0
- aury/agents/tool/builtin/delegate.py +726 -0
- aury/agents/tool/builtin/edit.py +121 -0
- aury/agents/tool/builtin/plan.py +277 -0
- aury/agents/tool/builtin/read.py +91 -0
- aury/agents/tool/builtin/thinking.py +111 -0
- aury/agents/tool/builtin/yield_result.py +130 -0
- aury/agents/tool/decorator.py +252 -0
- aury/agents/tool/set.py +204 -0
- aury/agents/usage/__init__.py +12 -0
- aury/agents/usage/tracker.py +236 -0
- aury/agents/workflow/__init__.py +85 -0
- aury/agents/workflow/adapter.py +268 -0
- aury/agents/workflow/dag.py +116 -0
- aury/agents/workflow/dsl.py +575 -0
- aury/agents/workflow/executor.py +659 -0
- aury/agents/workflow/expression.py +136 -0
- aury/agents/workflow/parser.py +182 -0
- aury/agents/workflow/state.py +145 -0
- aury/agents/workflow/types.py +86 -0
- aury_agent-0.0.4.dist-info/METADATA +90 -0
- aury_agent-0.0.4.dist-info/RECORD +149 -0
- aury_agent-0.0.4.dist-info/WHEEL +4 -0
- aury_agent-0.0.4.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""In-memory session backend implementation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InMemorySessionBackend:
|
|
9
|
+
"""In-memory implementation of SessionBackend.
|
|
10
|
+
|
|
11
|
+
Suitable for testing and simple single-process use cases.
|
|
12
|
+
Data is lost when the process exits.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._sessions: dict[str, dict[str, Any]] = {}
|
|
17
|
+
# Index: user_id -> list of session_ids
|
|
18
|
+
self._user_sessions: dict[str, list[str]] = {}
|
|
19
|
+
|
|
20
|
+
async def create(
|
|
21
|
+
self,
|
|
22
|
+
id: str,
|
|
23
|
+
data: dict[str, Any],
|
|
24
|
+
user_id: str | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Create a new session."""
|
|
27
|
+
session_data = {
|
|
28
|
+
"id": id,
|
|
29
|
+
"user_id": user_id,
|
|
30
|
+
"created_at": datetime.now().isoformat(),
|
|
31
|
+
"updated_at": datetime.now().isoformat(),
|
|
32
|
+
**data,
|
|
33
|
+
}
|
|
34
|
+
self._sessions[id] = session_data
|
|
35
|
+
|
|
36
|
+
# Update user index
|
|
37
|
+
if user_id:
|
|
38
|
+
if user_id not in self._user_sessions:
|
|
39
|
+
self._user_sessions[user_id] = []
|
|
40
|
+
self._user_sessions[user_id].append(id)
|
|
41
|
+
|
|
42
|
+
async def get(self, id: str) -> dict[str, Any] | None:
|
|
43
|
+
"""Get session by ID."""
|
|
44
|
+
return self._sessions.get(id)
|
|
45
|
+
|
|
46
|
+
async def update(self, id: str, data: dict[str, Any]) -> None:
|
|
47
|
+
"""Update session data."""
|
|
48
|
+
if id in self._sessions:
|
|
49
|
+
self._sessions[id].update(data)
|
|
50
|
+
self._sessions[id]["updated_at"] = datetime.now().isoformat()
|
|
51
|
+
|
|
52
|
+
async def delete(self, id: str) -> bool:
|
|
53
|
+
"""Delete a session."""
|
|
54
|
+
if id not in self._sessions:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
session = self._sessions.pop(id)
|
|
58
|
+
|
|
59
|
+
# Update user index
|
|
60
|
+
user_id = session.get("user_id")
|
|
61
|
+
if user_id and user_id in self._user_sessions:
|
|
62
|
+
self._user_sessions[user_id] = [
|
|
63
|
+
sid for sid in self._user_sessions[user_id] if sid != id
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
async def list(
|
|
69
|
+
self,
|
|
70
|
+
user_id: str | None = None,
|
|
71
|
+
limit: int = 100,
|
|
72
|
+
offset: int = 0,
|
|
73
|
+
) -> list[dict[str, Any]]:
|
|
74
|
+
"""List sessions."""
|
|
75
|
+
if user_id:
|
|
76
|
+
# Filter by user
|
|
77
|
+
session_ids = self._user_sessions.get(user_id, [])
|
|
78
|
+
sessions = [
|
|
79
|
+
self._sessions[sid]
|
|
80
|
+
for sid in session_ids
|
|
81
|
+
if sid in self._sessions
|
|
82
|
+
]
|
|
83
|
+
else:
|
|
84
|
+
sessions = list(self._sessions.values())
|
|
85
|
+
|
|
86
|
+
# Sort by created_at descending (newest first)
|
|
87
|
+
sessions.sort(key=lambda s: s.get("created_at", ""), reverse=True)
|
|
88
|
+
|
|
89
|
+
# Apply pagination
|
|
90
|
+
return sessions[offset:offset + limit]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
__all__ = ["InMemorySessionBackend"]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Session backend types and protocols."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Protocol, runtime_checkable, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from ...core.context import InvocationContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class SessionBackend(Protocol):
|
|
12
|
+
"""Protocol for session management.
|
|
13
|
+
|
|
14
|
+
Sessions represent a conversation scope. Each session can contain
|
|
15
|
+
multiple invocations (user turns).
|
|
16
|
+
|
|
17
|
+
All methods accept an optional `ctx` (InvocationContext) parameter.
|
|
18
|
+
When ctx is provided, session_id/user_id can be extracted from it.
|
|
19
|
+
|
|
20
|
+
Example usage:
|
|
21
|
+
# With explicit params
|
|
22
|
+
await backend.create("sess_123", {"root_agent_id": "agent"}, user_id="user_1")
|
|
23
|
+
|
|
24
|
+
# With ctx (auto-extract session_id)
|
|
25
|
+
await backend.get(ctx=ctx) # uses ctx.session_id
|
|
26
|
+
|
|
27
|
+
# List user's sessions
|
|
28
|
+
sessions = await backend.list(user_id="user_1")
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
async def create(
|
|
32
|
+
self,
|
|
33
|
+
data: dict[str, Any],
|
|
34
|
+
*,
|
|
35
|
+
id: str | None = None,
|
|
36
|
+
user_id: str | None = None,
|
|
37
|
+
ctx: "InvocationContext | None" = None,
|
|
38
|
+
) -> str:
|
|
39
|
+
"""Create a new session.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
data: Session data (root_agent_id, metadata, etc.)
|
|
43
|
+
id: Session ID (auto-generated if None)
|
|
44
|
+
user_id: Optional user ID for multi-tenant isolation
|
|
45
|
+
ctx: Optional InvocationContext
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Created session ID
|
|
49
|
+
"""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
async def get(
|
|
53
|
+
self,
|
|
54
|
+
id: str | None = None,
|
|
55
|
+
*,
|
|
56
|
+
ctx: "InvocationContext | None" = None,
|
|
57
|
+
) -> dict[str, Any] | None:
|
|
58
|
+
"""Get session by ID.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
id: Session ID (or extracted from ctx.session_id)
|
|
62
|
+
ctx: Optional InvocationContext
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Session data dict or None if not found
|
|
66
|
+
"""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
async def update(
|
|
70
|
+
self,
|
|
71
|
+
data: dict[str, Any],
|
|
72
|
+
*,
|
|
73
|
+
id: str | None = None,
|
|
74
|
+
ctx: "InvocationContext | None" = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Update session data.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
data: Fields to update (partial update)
|
|
80
|
+
id: Session ID (or extracted from ctx.session_id)
|
|
81
|
+
ctx: Optional InvocationContext
|
|
82
|
+
"""
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
async def delete(
|
|
86
|
+
self,
|
|
87
|
+
id: str | None = None,
|
|
88
|
+
*,
|
|
89
|
+
ctx: "InvocationContext | None" = None,
|
|
90
|
+
) -> bool:
|
|
91
|
+
"""Delete a session.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
id: Session ID (or extracted from ctx.session_id)
|
|
95
|
+
ctx: Optional InvocationContext
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
True if deleted, False if not found
|
|
99
|
+
"""
|
|
100
|
+
...
|
|
101
|
+
|
|
102
|
+
async def list(
|
|
103
|
+
self,
|
|
104
|
+
*,
|
|
105
|
+
user_id: str | None = None,
|
|
106
|
+
limit: int = 100,
|
|
107
|
+
offset: int = 0,
|
|
108
|
+
ctx: "InvocationContext | None" = None,
|
|
109
|
+
) -> list[dict[str, Any]]:
|
|
110
|
+
"""List sessions.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
user_id: Optional filter by user
|
|
114
|
+
limit: Max sessions to return
|
|
115
|
+
offset: Offset for pagination
|
|
116
|
+
ctx: Optional InvocationContext
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
List of session data dicts
|
|
120
|
+
"""
|
|
121
|
+
...
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = ["SessionBackend"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Shell backend for command execution.
|
|
2
|
+
|
|
3
|
+
Supports different execution environments:
|
|
4
|
+
- Local shell
|
|
5
|
+
- Docker/nsjail sandbox
|
|
6
|
+
- E2B cloud sandbox
|
|
7
|
+
"""
|
|
8
|
+
from .types import ShellBackend, ShellResult
|
|
9
|
+
from .local import LocalShellBackend
|
|
10
|
+
|
|
11
|
+
__all__ = ["ShellBackend", "ShellResult", "LocalShellBackend"]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Local shell execution backend."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from typing import AsyncIterator
|
|
8
|
+
|
|
9
|
+
from .types import ShellResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LocalShellBackend:
|
|
13
|
+
"""Local shell execution backend."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, default_cwd: str | None = None, shell: str = "/bin/bash"):
|
|
16
|
+
self.default_cwd = default_cwd
|
|
17
|
+
self.shell = shell
|
|
18
|
+
|
|
19
|
+
async def execute(
|
|
20
|
+
self,
|
|
21
|
+
command: str,
|
|
22
|
+
cwd: str | None = None,
|
|
23
|
+
env: dict[str, str] | None = None,
|
|
24
|
+
timeout: int = 120,
|
|
25
|
+
) -> ShellResult:
|
|
26
|
+
"""Execute command locally."""
|
|
27
|
+
work_dir = cwd or self.default_cwd or os.getcwd()
|
|
28
|
+
start_time = time.time()
|
|
29
|
+
|
|
30
|
+
full_env = os.environ.copy()
|
|
31
|
+
if env:
|
|
32
|
+
full_env.update(env)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
proc = await asyncio.create_subprocess_shell(
|
|
36
|
+
command,
|
|
37
|
+
cwd=work_dir,
|
|
38
|
+
env=full_env,
|
|
39
|
+
stdout=asyncio.subprocess.PIPE,
|
|
40
|
+
stderr=asyncio.subprocess.PIPE,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
stdout, stderr = await asyncio.wait_for(
|
|
44
|
+
proc.communicate(),
|
|
45
|
+
timeout=timeout,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return ShellResult(
|
|
49
|
+
stdout=stdout.decode("utf-8", errors="replace"),
|
|
50
|
+
stderr=stderr.decode("utf-8", errors="replace"),
|
|
51
|
+
exit_code=proc.returncode or 0,
|
|
52
|
+
command=command,
|
|
53
|
+
cwd=work_dir,
|
|
54
|
+
duration_ms=int((time.time() - start_time) * 1000),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
except asyncio.TimeoutError:
|
|
58
|
+
return ShellResult(
|
|
59
|
+
stdout="",
|
|
60
|
+
stderr=f"Command timed out after {timeout}s",
|
|
61
|
+
exit_code=-1,
|
|
62
|
+
command=command,
|
|
63
|
+
cwd=work_dir,
|
|
64
|
+
duration_ms=timeout * 1000,
|
|
65
|
+
)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
return ShellResult(
|
|
68
|
+
stdout="",
|
|
69
|
+
stderr=str(e),
|
|
70
|
+
exit_code=-1,
|
|
71
|
+
command=command,
|
|
72
|
+
cwd=work_dir,
|
|
73
|
+
duration_ms=int((time.time() - start_time) * 1000),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def execute_stream(
|
|
77
|
+
self,
|
|
78
|
+
command: str,
|
|
79
|
+
cwd: str | None = None,
|
|
80
|
+
env: dict[str, str] | None = None,
|
|
81
|
+
timeout: int = 120,
|
|
82
|
+
) -> AsyncIterator[str]:
|
|
83
|
+
"""Execute with streaming output."""
|
|
84
|
+
work_dir = cwd or self.default_cwd or os.getcwd()
|
|
85
|
+
|
|
86
|
+
full_env = os.environ.copy()
|
|
87
|
+
if env:
|
|
88
|
+
full_env.update(env)
|
|
89
|
+
|
|
90
|
+
proc = await asyncio.create_subprocess_shell(
|
|
91
|
+
command,
|
|
92
|
+
cwd=work_dir,
|
|
93
|
+
env=full_env,
|
|
94
|
+
stdout=asyncio.subprocess.PIPE,
|
|
95
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
async with asyncio.timeout(timeout):
|
|
100
|
+
while True:
|
|
101
|
+
line = await proc.stdout.readline()
|
|
102
|
+
if not line:
|
|
103
|
+
break
|
|
104
|
+
yield line.decode("utf-8", errors="replace")
|
|
105
|
+
except asyncio.TimeoutError:
|
|
106
|
+
proc.kill()
|
|
107
|
+
yield f"\n[Timeout after {timeout}s]\n"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = ["LocalShellBackend"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Shell backend types and protocols."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import AsyncIterator, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ShellResult:
|
|
10
|
+
"""Result of shell command execution."""
|
|
11
|
+
stdout: str
|
|
12
|
+
stderr: str
|
|
13
|
+
exit_code: int
|
|
14
|
+
command: str = ""
|
|
15
|
+
cwd: str = ""
|
|
16
|
+
duration_ms: int = 0
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def success(self) -> bool:
|
|
20
|
+
return self.exit_code == 0
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def output(self) -> str:
|
|
24
|
+
"""Combined output."""
|
|
25
|
+
if self.success:
|
|
26
|
+
return self.stdout
|
|
27
|
+
return f"{self.stdout}\n{self.stderr}".strip()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class ShellBackend(Protocol):
|
|
32
|
+
"""Protocol for shell command execution."""
|
|
33
|
+
|
|
34
|
+
async def execute(
|
|
35
|
+
self,
|
|
36
|
+
command: str,
|
|
37
|
+
cwd: str | None = None,
|
|
38
|
+
env: dict[str, str] | None = None,
|
|
39
|
+
timeout: int = 120,
|
|
40
|
+
) -> ShellResult:
|
|
41
|
+
"""Execute a shell command."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
async def execute_stream(
|
|
45
|
+
self,
|
|
46
|
+
command: str,
|
|
47
|
+
cwd: str | None = None,
|
|
48
|
+
env: dict[str, str] | None = None,
|
|
49
|
+
timeout: int = 120,
|
|
50
|
+
) -> AsyncIterator[str]:
|
|
51
|
+
"""Execute command with streaming output."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = ["ShellResult", "ShellBackend"]
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Shell backend protocol for command execution.
|
|
2
|
+
|
|
3
|
+
Supports different execution environments:
|
|
4
|
+
- Local shell
|
|
5
|
+
- Docker/nsjail sandbox
|
|
6
|
+
- E2B cloud sandbox
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import AsyncIterator, Literal, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ShellResult:
|
|
16
|
+
"""Result of shell command execution."""
|
|
17
|
+
stdout: str
|
|
18
|
+
stderr: str
|
|
19
|
+
exit_code: int
|
|
20
|
+
|
|
21
|
+
# Execution metadata
|
|
22
|
+
command: str = ""
|
|
23
|
+
cwd: str = ""
|
|
24
|
+
duration_ms: int = 0
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def success(self) -> bool:
|
|
28
|
+
return self.exit_code == 0
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def output(self) -> str:
|
|
32
|
+
"""Combined output (stdout + stderr if error)."""
|
|
33
|
+
if self.success:
|
|
34
|
+
return self.stdout
|
|
35
|
+
return f"{self.stdout}\n{self.stderr}".strip()
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict:
|
|
38
|
+
return {
|
|
39
|
+
"stdout": self.stdout,
|
|
40
|
+
"stderr": self.stderr,
|
|
41
|
+
"exit_code": self.exit_code,
|
|
42
|
+
"command": self.command,
|
|
43
|
+
"cwd": self.cwd,
|
|
44
|
+
"duration_ms": self.duration_ms,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@runtime_checkable
|
|
49
|
+
class ShellBackend(Protocol):
|
|
50
|
+
"""Protocol for shell command execution.
|
|
51
|
+
|
|
52
|
+
Implementations:
|
|
53
|
+
- LocalShellBackend - Execute on local machine
|
|
54
|
+
- SandboxShellBackend - Execute in Docker/nsjail sandbox
|
|
55
|
+
- E2BShellBackend - Execute in E2B cloud sandbox
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
async def execute(
|
|
59
|
+
self,
|
|
60
|
+
command: str,
|
|
61
|
+
cwd: str | None = None,
|
|
62
|
+
env: dict[str, str] | None = None,
|
|
63
|
+
timeout: int = 120,
|
|
64
|
+
) -> ShellResult:
|
|
65
|
+
"""Execute a shell command.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
command: Command to execute
|
|
69
|
+
cwd: Working directory (optional)
|
|
70
|
+
env: Environment variables (optional)
|
|
71
|
+
timeout: Timeout in seconds (default: 120)
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
ShellResult with stdout, stderr, exit_code
|
|
75
|
+
"""
|
|
76
|
+
...
|
|
77
|
+
|
|
78
|
+
async def execute_stream(
|
|
79
|
+
self,
|
|
80
|
+
command: str,
|
|
81
|
+
cwd: str | None = None,
|
|
82
|
+
env: dict[str, str] | None = None,
|
|
83
|
+
timeout: int = 120,
|
|
84
|
+
) -> AsyncIterator[str]:
|
|
85
|
+
"""Execute command with streaming output.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
command: Command to execute
|
|
89
|
+
cwd: Working directory (optional)
|
|
90
|
+
env: Environment variables (optional)
|
|
91
|
+
timeout: Timeout in seconds (default: 120)
|
|
92
|
+
|
|
93
|
+
Yields:
|
|
94
|
+
Output chunks as they arrive
|
|
95
|
+
"""
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class LocalShellBackend:
|
|
100
|
+
"""Local shell execution backend."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, default_cwd: str | None = None, default_shell: str = "/bin/bash"):
|
|
103
|
+
self.default_cwd = default_cwd
|
|
104
|
+
self.default_shell = default_shell
|
|
105
|
+
|
|
106
|
+
async def execute(
|
|
107
|
+
self,
|
|
108
|
+
command: str,
|
|
109
|
+
cwd: str | None = None,
|
|
110
|
+
env: dict[str, str] | None = None,
|
|
111
|
+
timeout: int = 120,
|
|
112
|
+
) -> ShellResult:
|
|
113
|
+
"""Execute command locally."""
|
|
114
|
+
import asyncio
|
|
115
|
+
import os
|
|
116
|
+
import time
|
|
117
|
+
|
|
118
|
+
work_dir = cwd or self.default_cwd or os.getcwd()
|
|
119
|
+
start_time = time.time()
|
|
120
|
+
|
|
121
|
+
# Merge environment
|
|
122
|
+
full_env = os.environ.copy()
|
|
123
|
+
if env:
|
|
124
|
+
full_env.update(env)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
proc = await asyncio.create_subprocess_shell(
|
|
128
|
+
command,
|
|
129
|
+
cwd=work_dir,
|
|
130
|
+
env=full_env,
|
|
131
|
+
stdout=asyncio.subprocess.PIPE,
|
|
132
|
+
stderr=asyncio.subprocess.PIPE,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
stdout, stderr = await asyncio.wait_for(
|
|
136
|
+
proc.communicate(),
|
|
137
|
+
timeout=timeout,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return ShellResult(
|
|
141
|
+
stdout=stdout.decode("utf-8", errors="replace"),
|
|
142
|
+
stderr=stderr.decode("utf-8", errors="replace"),
|
|
143
|
+
exit_code=proc.returncode or 0,
|
|
144
|
+
command=command,
|
|
145
|
+
cwd=work_dir,
|
|
146
|
+
duration_ms=int((time.time() - start_time) * 1000),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
except asyncio.TimeoutError:
|
|
150
|
+
return ShellResult(
|
|
151
|
+
stdout="",
|
|
152
|
+
stderr=f"Command timed out after {timeout} seconds",
|
|
153
|
+
exit_code=-1,
|
|
154
|
+
command=command,
|
|
155
|
+
cwd=work_dir,
|
|
156
|
+
duration_ms=timeout * 1000,
|
|
157
|
+
)
|
|
158
|
+
except Exception as e:
|
|
159
|
+
return ShellResult(
|
|
160
|
+
stdout="",
|
|
161
|
+
stderr=str(e),
|
|
162
|
+
exit_code=-1,
|
|
163
|
+
command=command,
|
|
164
|
+
cwd=work_dir,
|
|
165
|
+
duration_ms=int((time.time() - start_time) * 1000),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
async def execute_stream(
|
|
169
|
+
self,
|
|
170
|
+
command: str,
|
|
171
|
+
cwd: str | None = None,
|
|
172
|
+
env: dict[str, str] | None = None,
|
|
173
|
+
timeout: int = 120,
|
|
174
|
+
) -> AsyncIterator[str]:
|
|
175
|
+
"""Execute with streaming output."""
|
|
176
|
+
import asyncio
|
|
177
|
+
import os
|
|
178
|
+
|
|
179
|
+
work_dir = cwd or self.default_cwd or os.getcwd()
|
|
180
|
+
|
|
181
|
+
full_env = os.environ.copy()
|
|
182
|
+
if env:
|
|
183
|
+
full_env.update(env)
|
|
184
|
+
|
|
185
|
+
proc = await asyncio.create_subprocess_shell(
|
|
186
|
+
command,
|
|
187
|
+
cwd=work_dir,
|
|
188
|
+
env=full_env,
|
|
189
|
+
stdout=asyncio.subprocess.PIPE,
|
|
190
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
async def read_with_timeout():
|
|
194
|
+
try:
|
|
195
|
+
async with asyncio.timeout(timeout):
|
|
196
|
+
while True:
|
|
197
|
+
line = await proc.stdout.readline()
|
|
198
|
+
if not line:
|
|
199
|
+
break
|
|
200
|
+
yield line.decode("utf-8", errors="replace")
|
|
201
|
+
except asyncio.TimeoutError:
|
|
202
|
+
proc.kill()
|
|
203
|
+
yield f"\n[Timeout after {timeout} seconds]\n"
|
|
204
|
+
|
|
205
|
+
async for chunk in read_with_timeout():
|
|
206
|
+
yield chunk
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
__all__ = ["ShellBackend", "ShellResult", "LocalShellBackend"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Snapshot backend for file state tracking and revert.
|
|
2
|
+
|
|
3
|
+
Supports different snapshot strategies:
|
|
4
|
+
- Git-based (local)
|
|
5
|
+
- In-memory (testing)
|
|
6
|
+
- Git + S3 hybrid (cloud persistence)
|
|
7
|
+
"""
|
|
8
|
+
from .types import Patch, SnapshotBackend
|
|
9
|
+
from .memory import InMemorySnapshotBackend
|
|
10
|
+
from .git import GitSnapshotBackend
|
|
11
|
+
from .hybrid import GitS3HybridBackend
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"SnapshotBackend",
|
|
15
|
+
"Patch",
|
|
16
|
+
"InMemorySnapshotBackend",
|
|
17
|
+
"GitSnapshotBackend",
|
|
18
|
+
"GitS3HybridBackend",
|
|
19
|
+
]
|