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.
Files changed (149) hide show
  1. aury/__init__.py +2 -0
  2. aury/agents/__init__.py +55 -0
  3. aury/agents/a2a/__init__.py +168 -0
  4. aury/agents/backends/__init__.py +196 -0
  5. aury/agents/backends/artifact/__init__.py +9 -0
  6. aury/agents/backends/artifact/memory.py +130 -0
  7. aury/agents/backends/artifact/types.py +133 -0
  8. aury/agents/backends/code/__init__.py +65 -0
  9. aury/agents/backends/file/__init__.py +11 -0
  10. aury/agents/backends/file/local.py +66 -0
  11. aury/agents/backends/file/types.py +40 -0
  12. aury/agents/backends/invocation/__init__.py +8 -0
  13. aury/agents/backends/invocation/memory.py +81 -0
  14. aury/agents/backends/invocation/types.py +110 -0
  15. aury/agents/backends/memory/__init__.py +8 -0
  16. aury/agents/backends/memory/memory.py +179 -0
  17. aury/agents/backends/memory/types.py +136 -0
  18. aury/agents/backends/message/__init__.py +9 -0
  19. aury/agents/backends/message/memory.py +122 -0
  20. aury/agents/backends/message/types.py +124 -0
  21. aury/agents/backends/sandbox.py +275 -0
  22. aury/agents/backends/session/__init__.py +8 -0
  23. aury/agents/backends/session/memory.py +93 -0
  24. aury/agents/backends/session/types.py +124 -0
  25. aury/agents/backends/shell/__init__.py +11 -0
  26. aury/agents/backends/shell/local.py +110 -0
  27. aury/agents/backends/shell/types.py +55 -0
  28. aury/agents/backends/shell.py +209 -0
  29. aury/agents/backends/snapshot/__init__.py +19 -0
  30. aury/agents/backends/snapshot/git.py +95 -0
  31. aury/agents/backends/snapshot/hybrid.py +125 -0
  32. aury/agents/backends/snapshot/memory.py +86 -0
  33. aury/agents/backends/snapshot/types.py +59 -0
  34. aury/agents/backends/state/__init__.py +29 -0
  35. aury/agents/backends/state/composite.py +49 -0
  36. aury/agents/backends/state/file.py +57 -0
  37. aury/agents/backends/state/memory.py +52 -0
  38. aury/agents/backends/state/sqlite.py +262 -0
  39. aury/agents/backends/state/types.py +178 -0
  40. aury/agents/backends/subagent/__init__.py +165 -0
  41. aury/agents/cli/__init__.py +41 -0
  42. aury/agents/cli/chat.py +239 -0
  43. aury/agents/cli/config.py +236 -0
  44. aury/agents/cli/extensions.py +460 -0
  45. aury/agents/cli/main.py +189 -0
  46. aury/agents/cli/session.py +337 -0
  47. aury/agents/cli/workflow.py +276 -0
  48. aury/agents/context_providers/__init__.py +66 -0
  49. aury/agents/context_providers/artifact.py +299 -0
  50. aury/agents/context_providers/base.py +177 -0
  51. aury/agents/context_providers/memory.py +70 -0
  52. aury/agents/context_providers/message.py +130 -0
  53. aury/agents/context_providers/skill.py +50 -0
  54. aury/agents/context_providers/subagent.py +46 -0
  55. aury/agents/context_providers/tool.py +68 -0
  56. aury/agents/core/__init__.py +83 -0
  57. aury/agents/core/base.py +573 -0
  58. aury/agents/core/context.py +797 -0
  59. aury/agents/core/context_builder.py +303 -0
  60. aury/agents/core/event_bus/__init__.py +15 -0
  61. aury/agents/core/event_bus/bus.py +203 -0
  62. aury/agents/core/factory.py +169 -0
  63. aury/agents/core/isolator.py +97 -0
  64. aury/agents/core/logging.py +95 -0
  65. aury/agents/core/parallel.py +194 -0
  66. aury/agents/core/runner.py +139 -0
  67. aury/agents/core/services/__init__.py +5 -0
  68. aury/agents/core/services/file_session.py +144 -0
  69. aury/agents/core/services/message.py +53 -0
  70. aury/agents/core/services/session.py +53 -0
  71. aury/agents/core/signals.py +109 -0
  72. aury/agents/core/state.py +363 -0
  73. aury/agents/core/types/__init__.py +107 -0
  74. aury/agents/core/types/action.py +176 -0
  75. aury/agents/core/types/artifact.py +135 -0
  76. aury/agents/core/types/block.py +736 -0
  77. aury/agents/core/types/message.py +350 -0
  78. aury/agents/core/types/recall.py +144 -0
  79. aury/agents/core/types/session.py +257 -0
  80. aury/agents/core/types/subagent.py +154 -0
  81. aury/agents/core/types/tool.py +205 -0
  82. aury/agents/eval/__init__.py +331 -0
  83. aury/agents/hitl/__init__.py +57 -0
  84. aury/agents/hitl/ask_user.py +242 -0
  85. aury/agents/hitl/compaction.py +230 -0
  86. aury/agents/hitl/exceptions.py +87 -0
  87. aury/agents/hitl/permission.py +617 -0
  88. aury/agents/hitl/revert.py +216 -0
  89. aury/agents/llm/__init__.py +31 -0
  90. aury/agents/llm/adapter.py +367 -0
  91. aury/agents/llm/openai.py +294 -0
  92. aury/agents/llm/provider.py +476 -0
  93. aury/agents/mcp/__init__.py +153 -0
  94. aury/agents/memory/__init__.py +46 -0
  95. aury/agents/memory/compaction.py +394 -0
  96. aury/agents/memory/manager.py +465 -0
  97. aury/agents/memory/processor.py +177 -0
  98. aury/agents/memory/store.py +187 -0
  99. aury/agents/memory/types.py +137 -0
  100. aury/agents/messages/__init__.py +40 -0
  101. aury/agents/messages/config.py +47 -0
  102. aury/agents/messages/raw_store.py +224 -0
  103. aury/agents/messages/store.py +118 -0
  104. aury/agents/messages/types.py +88 -0
  105. aury/agents/middleware/__init__.py +31 -0
  106. aury/agents/middleware/base.py +341 -0
  107. aury/agents/middleware/chain.py +342 -0
  108. aury/agents/middleware/message.py +129 -0
  109. aury/agents/middleware/message_container.py +126 -0
  110. aury/agents/middleware/raw_message.py +153 -0
  111. aury/agents/middleware/truncation.py +139 -0
  112. aury/agents/middleware/types.py +81 -0
  113. aury/agents/plugin.py +162 -0
  114. aury/agents/react/__init__.py +4 -0
  115. aury/agents/react/agent.py +1923 -0
  116. aury/agents/sandbox/__init__.py +23 -0
  117. aury/agents/sandbox/local.py +239 -0
  118. aury/agents/sandbox/remote.py +200 -0
  119. aury/agents/sandbox/types.py +115 -0
  120. aury/agents/skill/__init__.py +16 -0
  121. aury/agents/skill/loader.py +180 -0
  122. aury/agents/skill/types.py +83 -0
  123. aury/agents/tool/__init__.py +39 -0
  124. aury/agents/tool/builtin/__init__.py +23 -0
  125. aury/agents/tool/builtin/ask_user.py +155 -0
  126. aury/agents/tool/builtin/bash.py +107 -0
  127. aury/agents/tool/builtin/delegate.py +726 -0
  128. aury/agents/tool/builtin/edit.py +121 -0
  129. aury/agents/tool/builtin/plan.py +277 -0
  130. aury/agents/tool/builtin/read.py +91 -0
  131. aury/agents/tool/builtin/thinking.py +111 -0
  132. aury/agents/tool/builtin/yield_result.py +130 -0
  133. aury/agents/tool/decorator.py +252 -0
  134. aury/agents/tool/set.py +204 -0
  135. aury/agents/usage/__init__.py +12 -0
  136. aury/agents/usage/tracker.py +236 -0
  137. aury/agents/workflow/__init__.py +85 -0
  138. aury/agents/workflow/adapter.py +268 -0
  139. aury/agents/workflow/dag.py +116 -0
  140. aury/agents/workflow/dsl.py +575 -0
  141. aury/agents/workflow/executor.py +659 -0
  142. aury/agents/workflow/expression.py +136 -0
  143. aury/agents/workflow/parser.py +182 -0
  144. aury/agents/workflow/state.py +145 -0
  145. aury/agents/workflow/types.py +86 -0
  146. aury_agent-0.0.4.dist-info/METADATA +90 -0
  147. aury_agent-0.0.4.dist-info/RECORD +149 -0
  148. aury_agent-0.0.4.dist-info/WHEEL +4 -0
  149. 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"]