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,95 @@
|
|
|
1
|
+
"""Git-based snapshot backend."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .types import Patch
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GitSnapshotBackend:
|
|
12
|
+
"""Git-based snapshot backend."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, worktree: str | Path, snapshot_dir: str | Path | None = None):
|
|
15
|
+
self.worktree = Path(worktree).resolve()
|
|
16
|
+
self.snapshot_dir = Path(snapshot_dir).resolve() if snapshot_dir else self.worktree / ".aury_snapshot"
|
|
17
|
+
self._initialized = False
|
|
18
|
+
|
|
19
|
+
async def _ensure_repo(self) -> None:
|
|
20
|
+
if self._initialized:
|
|
21
|
+
return
|
|
22
|
+
git_dir = self.snapshot_dir / ".git" if not str(self.snapshot_dir).endswith(".git") else self.snapshot_dir
|
|
23
|
+
if not git_dir.exists():
|
|
24
|
+
self.snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
proc = await asyncio.create_subprocess_exec(
|
|
26
|
+
"git", "init",
|
|
27
|
+
cwd=str(self.worktree),
|
|
28
|
+
env={"GIT_DIR": str(self.snapshot_dir)},
|
|
29
|
+
stdout=asyncio.subprocess.PIPE,
|
|
30
|
+
stderr=asyncio.subprocess.PIPE,
|
|
31
|
+
)
|
|
32
|
+
await proc.wait()
|
|
33
|
+
await self._git_config("user.email", "aury@agent.local")
|
|
34
|
+
await self._git_config("user.name", "Aury Agent")
|
|
35
|
+
self._initialized = True
|
|
36
|
+
|
|
37
|
+
async def _git_config(self, key: str, value: str) -> None:
|
|
38
|
+
proc = await asyncio.create_subprocess_exec(
|
|
39
|
+
"git", "config", key, value,
|
|
40
|
+
cwd=str(self.worktree),
|
|
41
|
+
env={"GIT_DIR": str(self.snapshot_dir)},
|
|
42
|
+
stdout=asyncio.subprocess.PIPE,
|
|
43
|
+
stderr=asyncio.subprocess.PIPE,
|
|
44
|
+
)
|
|
45
|
+
await proc.wait()
|
|
46
|
+
|
|
47
|
+
async def _run_git(self, *args: str, check: bool = True) -> tuple[str, str]:
|
|
48
|
+
await self._ensure_repo()
|
|
49
|
+
proc = await asyncio.create_subprocess_exec(
|
|
50
|
+
"git", *args,
|
|
51
|
+
cwd=str(self.worktree),
|
|
52
|
+
env={"GIT_DIR": str(self.snapshot_dir)},
|
|
53
|
+
stdout=asyncio.subprocess.PIPE,
|
|
54
|
+
stderr=asyncio.subprocess.PIPE,
|
|
55
|
+
)
|
|
56
|
+
stdout, stderr = await proc.communicate()
|
|
57
|
+
if check and proc.returncode != 0:
|
|
58
|
+
raise RuntimeError(f"Git command failed: {stderr.decode()}")
|
|
59
|
+
return stdout.decode(), stderr.decode()
|
|
60
|
+
|
|
61
|
+
async def track(self) -> str:
|
|
62
|
+
await self._ensure_repo()
|
|
63
|
+
await self._run_git("add", "-A", check=False)
|
|
64
|
+
timestamp = datetime.now().isoformat()
|
|
65
|
+
await self._run_git("commit", "-m", f"snapshot_{timestamp}", "--allow-empty", check=False)
|
|
66
|
+
stdout, _ = await self._run_git("rev-parse", "HEAD")
|
|
67
|
+
return stdout.strip()
|
|
68
|
+
|
|
69
|
+
async def restore(self, snapshot_id: str) -> None:
|
|
70
|
+
await self._run_git("checkout", snapshot_id, "--", ".")
|
|
71
|
+
|
|
72
|
+
async def revert(self, patches: list[Patch]) -> None:
|
|
73
|
+
for patch in patches:
|
|
74
|
+
for file_path in patch.files:
|
|
75
|
+
await self._run_git("checkout", "HEAD~1", "--", file_path, check=False)
|
|
76
|
+
|
|
77
|
+
async def diff(self, snapshot_id: str) -> str:
|
|
78
|
+
stdout, _ = await self._run_git("diff", snapshot_id, "--", check=False)
|
|
79
|
+
return stdout
|
|
80
|
+
|
|
81
|
+
async def patch(self, snapshot_id: str) -> Patch:
|
|
82
|
+
stdout, _ = await self._run_git("diff", "--stat", snapshot_id, "--", check=False)
|
|
83
|
+
files = []
|
|
84
|
+
additions = deletions = 0
|
|
85
|
+
for line in stdout.strip().split("\n"):
|
|
86
|
+
if "|" in line:
|
|
87
|
+
file_path = line.split("|")[0].strip()
|
|
88
|
+
files.append(file_path)
|
|
89
|
+
stat_part = line.split("|")[1].strip() if "|" in line else ""
|
|
90
|
+
additions += stat_part.count("+")
|
|
91
|
+
deletions += stat_part.count("-")
|
|
92
|
+
return Patch(files=files, additions=additions, deletions=deletions, diff=await self.diff(snapshot_id))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
__all__ = ["GitSnapshotBackend"]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Git + S3 hybrid snapshot backend."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .git import GitSnapshotBackend
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GitS3HybridBackend(GitSnapshotBackend):
|
|
13
|
+
"""Git + S3 hybrid snapshot backend.
|
|
14
|
+
|
|
15
|
+
Combines local Git commits with S3 bundle uploads for remote persistence.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
worktree: str | Path,
|
|
21
|
+
s3_bucket: str,
|
|
22
|
+
session_id: str,
|
|
23
|
+
snapshot_dir: str | Path | None = None,
|
|
24
|
+
s3_client: Any | None = None,
|
|
25
|
+
):
|
|
26
|
+
super().__init__(worktree, snapshot_dir)
|
|
27
|
+
self.s3_bucket = s3_bucket
|
|
28
|
+
self.session_id = session_id
|
|
29
|
+
self._s3 = s3_client
|
|
30
|
+
self._last_uploaded_commit: str | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def s3(self):
|
|
34
|
+
if self._s3 is None:
|
|
35
|
+
try:
|
|
36
|
+
import boto3
|
|
37
|
+
self._s3 = boto3.client('s3')
|
|
38
|
+
except ImportError:
|
|
39
|
+
raise ImportError("boto3 is required for GitS3HybridBackend")
|
|
40
|
+
return self._s3
|
|
41
|
+
|
|
42
|
+
async def track(self) -> str:
|
|
43
|
+
import json
|
|
44
|
+
commit_id = await super().track()
|
|
45
|
+
bundle_path = self.snapshot_dir / f"{commit_id}.bundle"
|
|
46
|
+
|
|
47
|
+
if self._last_uploaded_commit:
|
|
48
|
+
await self._create_bundle(bundle_path, f"{self._last_uploaded_commit}..{commit_id}")
|
|
49
|
+
bundle_type = "incremental"
|
|
50
|
+
else:
|
|
51
|
+
await self._create_bundle(bundle_path, commit_id)
|
|
52
|
+
bundle_type = "full"
|
|
53
|
+
|
|
54
|
+
s3_key = f"agents/{self.session_id}/snapshots/{commit_id}.bundle"
|
|
55
|
+
loop = asyncio.get_event_loop()
|
|
56
|
+
await loop.run_in_executor(None, lambda: self.s3.upload_file(str(bundle_path), self.s3_bucket, s3_key))
|
|
57
|
+
|
|
58
|
+
meta = {
|
|
59
|
+
"commit_id": commit_id,
|
|
60
|
+
"parent_commit": self._last_uploaded_commit,
|
|
61
|
+
"timestamp": datetime.now().isoformat(),
|
|
62
|
+
"bundle_type": bundle_type,
|
|
63
|
+
}
|
|
64
|
+
meta_key = f"agents/{self.session_id}/snapshots/{commit_id}.meta.json"
|
|
65
|
+
await loop.run_in_executor(None, lambda: self.s3.put_object(Bucket=self.s3_bucket, Key=meta_key, Body=json.dumps(meta)))
|
|
66
|
+
|
|
67
|
+
if bundle_path.exists():
|
|
68
|
+
bundle_path.unlink()
|
|
69
|
+
|
|
70
|
+
self._last_uploaded_commit = commit_id
|
|
71
|
+
return commit_id
|
|
72
|
+
|
|
73
|
+
async def _create_bundle(self, path: Path, ref: str) -> None:
|
|
74
|
+
proc = await asyncio.create_subprocess_exec(
|
|
75
|
+
"git", "bundle", "create", str(path), ref,
|
|
76
|
+
cwd=str(self.worktree),
|
|
77
|
+
env={"GIT_DIR": str(self.snapshot_dir)},
|
|
78
|
+
stdout=asyncio.subprocess.PIPE,
|
|
79
|
+
stderr=asyncio.subprocess.PIPE,
|
|
80
|
+
)
|
|
81
|
+
await proc.wait()
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
async def restore_from_s3(
|
|
85
|
+
cls,
|
|
86
|
+
s3_bucket: str,
|
|
87
|
+
session_id: str,
|
|
88
|
+
local_dir: str | Path,
|
|
89
|
+
s3_client: Any | None = None,
|
|
90
|
+
) -> "GitS3HybridBackend":
|
|
91
|
+
import subprocess
|
|
92
|
+
if s3_client is None:
|
|
93
|
+
import boto3
|
|
94
|
+
s3_client = boto3.client('s3')
|
|
95
|
+
|
|
96
|
+
prefix = f"agents/{session_id}/snapshots/"
|
|
97
|
+
response = s3_client.list_objects_v2(Bucket=s3_bucket, Prefix=prefix)
|
|
98
|
+
bundles = sorted([obj['Key'] for obj in response.get('Contents', []) if obj['Key'].endswith('.bundle')])
|
|
99
|
+
|
|
100
|
+
if not bundles:
|
|
101
|
+
raise ValueError(f"No snapshots found for session: {session_id}")
|
|
102
|
+
|
|
103
|
+
local_path = Path(local_dir)
|
|
104
|
+
local_path.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
repo_path = local_path / "repo"
|
|
106
|
+
|
|
107
|
+
for i, bundle_key in enumerate(bundles):
|
|
108
|
+
bundle_file = local_path / "temp.bundle"
|
|
109
|
+
s3_client.download_file(s3_bucket, bundle_key, str(bundle_file))
|
|
110
|
+
if i == 0:
|
|
111
|
+
subprocess.run(["git", "clone", str(bundle_file), str(repo_path)], check=True, capture_output=True)
|
|
112
|
+
else:
|
|
113
|
+
subprocess.run(["git", "fetch", str(bundle_file)], cwd=str(repo_path), check=True, capture_output=True)
|
|
114
|
+
bundle_file.unlink()
|
|
115
|
+
|
|
116
|
+
result = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(repo_path), capture_output=True, text=True)
|
|
117
|
+
last_commit = result.stdout.strip()
|
|
118
|
+
|
|
119
|
+
backend = cls(worktree=repo_path, s3_bucket=s3_bucket, session_id=session_id, snapshot_dir=repo_path / ".git", s3_client=s3_client)
|
|
120
|
+
backend._last_uploaded_commit = last_commit
|
|
121
|
+
backend._initialized = True
|
|
122
|
+
return backend
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = ["GitS3HybridBackend"]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""In-memory snapshot backend for testing."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .types import Patch
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InMemorySnapshotBackend:
|
|
8
|
+
"""In-memory snapshot backend for testing."""
|
|
9
|
+
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
self._snapshots: dict[str, dict[str, str]] = {}
|
|
12
|
+
self._files: dict[str, str] = {}
|
|
13
|
+
self._counter = 0
|
|
14
|
+
|
|
15
|
+
def set_file(self, path: str, content: str) -> None:
|
|
16
|
+
self._files[path] = content
|
|
17
|
+
|
|
18
|
+
def get_file(self, path: str) -> str | None:
|
|
19
|
+
return self._files.get(path)
|
|
20
|
+
|
|
21
|
+
def delete_file(self, path: str) -> None:
|
|
22
|
+
self._files.pop(path, None)
|
|
23
|
+
|
|
24
|
+
async def track(self) -> str:
|
|
25
|
+
self._counter += 1
|
|
26
|
+
snapshot_id = f"snap_{self._counter:04d}"
|
|
27
|
+
self._snapshots[snapshot_id] = dict(self._files)
|
|
28
|
+
return snapshot_id
|
|
29
|
+
|
|
30
|
+
async def restore(self, snapshot_id: str) -> None:
|
|
31
|
+
if snapshot_id not in self._snapshots:
|
|
32
|
+
raise ValueError(f"Unknown snapshot: {snapshot_id}")
|
|
33
|
+
self._files = dict(self._snapshots[snapshot_id])
|
|
34
|
+
|
|
35
|
+
async def revert(self, patches: list[Patch]) -> None:
|
|
36
|
+
snapshots = list(self._snapshots.items())
|
|
37
|
+
if len(snapshots) < 2:
|
|
38
|
+
return
|
|
39
|
+
prev_snapshot = snapshots[-2][1]
|
|
40
|
+
for patch in patches:
|
|
41
|
+
for file_path in patch.files:
|
|
42
|
+
if file_path in prev_snapshot:
|
|
43
|
+
self._files[file_path] = prev_snapshot[file_path]
|
|
44
|
+
else:
|
|
45
|
+
self._files.pop(file_path, None)
|
|
46
|
+
|
|
47
|
+
async def diff(self, snapshot_id: str) -> str:
|
|
48
|
+
if snapshot_id not in self._snapshots:
|
|
49
|
+
return ""
|
|
50
|
+
snapshot = self._snapshots[snapshot_id]
|
|
51
|
+
lines = []
|
|
52
|
+
all_files = set(snapshot.keys()) | set(self._files.keys())
|
|
53
|
+
for path in sorted(all_files):
|
|
54
|
+
old = snapshot.get(path, "")
|
|
55
|
+
new = self._files.get(path, "")
|
|
56
|
+
if old != new:
|
|
57
|
+
lines.append(f"--- a/{path}")
|
|
58
|
+
lines.append(f"+++ b/{path}")
|
|
59
|
+
return "\n".join(lines)
|
|
60
|
+
|
|
61
|
+
async def patch(self, snapshot_id: str) -> Patch:
|
|
62
|
+
if snapshot_id not in self._snapshots:
|
|
63
|
+
return Patch(files=[])
|
|
64
|
+
snapshot = self._snapshots[snapshot_id]
|
|
65
|
+
files = []
|
|
66
|
+
additions = deletions = 0
|
|
67
|
+
for path in set(snapshot.keys()) | set(self._files.keys()):
|
|
68
|
+
old = snapshot.get(path, "")
|
|
69
|
+
new = self._files.get(path, "")
|
|
70
|
+
if old != new:
|
|
71
|
+
files.append(path)
|
|
72
|
+
old_lines = len(old.split("\n")) if old else 0
|
|
73
|
+
new_lines = len(new.split("\n")) if new else 0
|
|
74
|
+
if new_lines > old_lines:
|
|
75
|
+
additions += new_lines - old_lines
|
|
76
|
+
else:
|
|
77
|
+
deletions += old_lines - new_lines
|
|
78
|
+
return Patch(files=files, additions=additions, deletions=deletions, diff=await self.diff(snapshot_id))
|
|
79
|
+
|
|
80
|
+
def clear(self) -> None:
|
|
81
|
+
self._snapshots.clear()
|
|
82
|
+
self._files.clear()
|
|
83
|
+
self._counter = 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
__all__ = ["InMemorySnapshotBackend"]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Snapshot backend types and protocols."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Patch:
|
|
10
|
+
"""File change record."""
|
|
11
|
+
files: list[str]
|
|
12
|
+
additions: int = 0
|
|
13
|
+
deletions: int = 0
|
|
14
|
+
diff: str = ""
|
|
15
|
+
|
|
16
|
+
def to_dict(self) -> dict:
|
|
17
|
+
return {
|
|
18
|
+
"files": self.files,
|
|
19
|
+
"additions": self.additions,
|
|
20
|
+
"deletions": self.deletions,
|
|
21
|
+
"diff": self.diff,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_dict(cls, data: dict) -> Patch:
|
|
26
|
+
return cls(
|
|
27
|
+
files=data.get("files", []),
|
|
28
|
+
additions=data.get("additions", 0),
|
|
29
|
+
deletions=data.get("deletions", 0),
|
|
30
|
+
diff=data.get("diff", ""),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@runtime_checkable
|
|
35
|
+
class SnapshotBackend(Protocol):
|
|
36
|
+
"""Protocol for file state tracking."""
|
|
37
|
+
|
|
38
|
+
async def track(self) -> str:
|
|
39
|
+
"""Record current state, return snapshot ID."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
async def restore(self, snapshot_id: str) -> None:
|
|
43
|
+
"""Fully restore to snapshot state."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
async def revert(self, patches: list[Patch]) -> None:
|
|
47
|
+
"""Revert specific files based on patches."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
async def diff(self, snapshot_id: str) -> str:
|
|
51
|
+
"""Get diff between current state and snapshot."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
async def patch(self, snapshot_id: str) -> Patch:
|
|
55
|
+
"""Get changed files since snapshot."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = ["Patch", "SnapshotBackend"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""State backend for framework internal storage.
|
|
2
|
+
|
|
3
|
+
Used for storing session state, plan data, invocation records, messages, etc.
|
|
4
|
+
This is NOT for user file operations - use FileBackend for that.
|
|
5
|
+
|
|
6
|
+
Architecture:
|
|
7
|
+
- StateStore: Low-level protocol for API layer to implement
|
|
8
|
+
- StateBackend: High-level protocol with namespace support
|
|
9
|
+
- StoreBasedStateBackend: Wraps StateStore as StateBackend
|
|
10
|
+
|
|
11
|
+
Default implementations: SQLiteStateBackend, MemoryStateBackend
|
|
12
|
+
"""
|
|
13
|
+
from .types import StateBackend, StateStore, StoreBasedStateBackend
|
|
14
|
+
from .sqlite import SQLiteStateBackend
|
|
15
|
+
from .memory import MemoryStateBackend
|
|
16
|
+
from .file import FileStateBackend
|
|
17
|
+
from .composite import CompositeStateBackend
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
# Protocols
|
|
21
|
+
"StateBackend",
|
|
22
|
+
"StateStore",
|
|
23
|
+
# Implementations
|
|
24
|
+
"StoreBasedStateBackend", # Wraps StateStore
|
|
25
|
+
"SQLiteStateBackend", # Default
|
|
26
|
+
"MemoryStateBackend", # For testing
|
|
27
|
+
"FileStateBackend",
|
|
28
|
+
"CompositeStateBackend",
|
|
29
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Composite state backend that routes by namespace."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .types import StateBackend
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CompositeStateBackend:
|
|
10
|
+
"""Routes operations to different backends by namespace.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
backend = CompositeStateBackend({
|
|
14
|
+
"session": FileStateBackend("./data/sessions"),
|
|
15
|
+
"usage": PostgresBackend(db),
|
|
16
|
+
}, default=MemoryStateBackend())
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
backends: dict[str, StateBackend],
|
|
22
|
+
default: StateBackend | None = None,
|
|
23
|
+
):
|
|
24
|
+
self._backends = backends
|
|
25
|
+
self._default = default
|
|
26
|
+
|
|
27
|
+
def _get_backend(self, namespace: str) -> StateBackend:
|
|
28
|
+
backend = self._backends.get(namespace, self._default)
|
|
29
|
+
if backend is None:
|
|
30
|
+
raise ValueError(f"No backend for namespace: {namespace}")
|
|
31
|
+
return backend
|
|
32
|
+
|
|
33
|
+
async def get(self, namespace: str, key: str) -> Any | None:
|
|
34
|
+
return await self._get_backend(namespace).get(namespace, key)
|
|
35
|
+
|
|
36
|
+
async def set(self, namespace: str, key: str, value: Any) -> None:
|
|
37
|
+
await self._get_backend(namespace).set(namespace, key, value)
|
|
38
|
+
|
|
39
|
+
async def delete(self, namespace: str, key: str) -> bool:
|
|
40
|
+
return await self._get_backend(namespace).delete(namespace, key)
|
|
41
|
+
|
|
42
|
+
async def list(self, namespace: str, prefix: str = "") -> list[str]:
|
|
43
|
+
return await self._get_backend(namespace).list(namespace, prefix)
|
|
44
|
+
|
|
45
|
+
async def exists(self, namespace: str, key: str) -> bool:
|
|
46
|
+
return await self._get_backend(namespace).exists(namespace, key)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = ["CompositeStateBackend"]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""File-based state backend."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FileStateBackend:
|
|
9
|
+
"""File-based state backend.
|
|
10
|
+
|
|
11
|
+
Stores data as JSON files organized by namespace.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, base_path: str | Path):
|
|
15
|
+
self.base_path = Path(base_path)
|
|
16
|
+
self.base_path.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
|
|
18
|
+
def _get_path(self, namespace: str, key: str) -> Path:
|
|
19
|
+
ns_path = self.base_path / namespace
|
|
20
|
+
ns_path.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
return ns_path / f"{key}.json"
|
|
22
|
+
|
|
23
|
+
async def get(self, namespace: str, key: str) -> Any | None:
|
|
24
|
+
import json
|
|
25
|
+
import aiofiles
|
|
26
|
+
path = self._get_path(namespace, key)
|
|
27
|
+
if not path.exists():
|
|
28
|
+
return None
|
|
29
|
+
async with aiofiles.open(path, "r") as f:
|
|
30
|
+
content = await f.read()
|
|
31
|
+
return json.loads(content)
|
|
32
|
+
|
|
33
|
+
async def set(self, namespace: str, key: str, value: Any) -> None:
|
|
34
|
+
import json
|
|
35
|
+
import aiofiles
|
|
36
|
+
path = self._get_path(namespace, key)
|
|
37
|
+
async with aiofiles.open(path, "w") as f:
|
|
38
|
+
await f.write(json.dumps(value, default=str, ensure_ascii=False, indent=2))
|
|
39
|
+
|
|
40
|
+
async def delete(self, namespace: str, key: str) -> bool:
|
|
41
|
+
path = self._get_path(namespace, key)
|
|
42
|
+
if path.exists():
|
|
43
|
+
path.unlink()
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
async def list(self, namespace: str, prefix: str = "") -> list[str]:
|
|
48
|
+
ns_path = self.base_path / namespace
|
|
49
|
+
if not ns_path.exists():
|
|
50
|
+
return []
|
|
51
|
+
return [f.stem for f in ns_path.glob("*.json") if f.stem.startswith(prefix)]
|
|
52
|
+
|
|
53
|
+
async def exists(self, namespace: str, key: str) -> bool:
|
|
54
|
+
return self._get_path(namespace, key).exists()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = ["FileStateBackend"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""In-memory state backend for testing."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MemoryStateBackend:
|
|
8
|
+
"""In-memory state backend for testing."""
|
|
9
|
+
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
self._data: dict[str, dict[str, Any]] = {}
|
|
12
|
+
self._messages: dict[str, list[dict[str, Any]]] = {} # session_id -> messages
|
|
13
|
+
|
|
14
|
+
async def get(self, namespace: str, key: str) -> Any | None:
|
|
15
|
+
return self._data.get(namespace, {}).get(key)
|
|
16
|
+
|
|
17
|
+
async def set(self, namespace: str, key: str, value: Any) -> None:
|
|
18
|
+
if namespace not in self._data:
|
|
19
|
+
self._data[namespace] = {}
|
|
20
|
+
self._data[namespace][key] = value
|
|
21
|
+
|
|
22
|
+
async def delete(self, namespace: str, key: str) -> bool:
|
|
23
|
+
if namespace in self._data and key in self._data[namespace]:
|
|
24
|
+
del self._data[namespace][key]
|
|
25
|
+
return True
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
async def list(self, namespace: str, prefix: str = "") -> list[str]:
|
|
29
|
+
if namespace not in self._data:
|
|
30
|
+
return []
|
|
31
|
+
return [k for k in self._data[namespace] if k.startswith(prefix)]
|
|
32
|
+
|
|
33
|
+
async def exists(self, namespace: str, key: str) -> bool:
|
|
34
|
+
return namespace in self._data and key in self._data[namespace]
|
|
35
|
+
|
|
36
|
+
async def add_message(self, session_id: str, message: dict[str, Any]) -> None:
|
|
37
|
+
"""Add a message to session history."""
|
|
38
|
+
if session_id not in self._messages:
|
|
39
|
+
self._messages[session_id] = []
|
|
40
|
+
self._messages[session_id].append(message)
|
|
41
|
+
|
|
42
|
+
async def get_messages(self, session_id: str) -> list[dict[str, Any]]:
|
|
43
|
+
"""Get all messages for a session."""
|
|
44
|
+
return self._messages.get(session_id, [])
|
|
45
|
+
|
|
46
|
+
def clear(self) -> None:
|
|
47
|
+
"""Clear all data (for testing)."""
|
|
48
|
+
self._data.clear()
|
|
49
|
+
self._messages.clear()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = ["MemoryStateBackend"]
|