tylor-mcp 1.0.0

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 (101) hide show
  1. package/.aws-setup.sh +25 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.mcp.json +12 -0
  4. package/AGENTS.md +93 -0
  5. package/CLAUDE.md +99 -0
  6. package/CLAUDE_PLATFORM_AWS_SETUP.md +105 -0
  7. package/LICENSE +21 -0
  8. package/README.md +146 -0
  9. package/assets/tylor_logo.png +0 -0
  10. package/assets/tylor_threads_concept.png +0 -0
  11. package/bin/tylor.js +23 -0
  12. package/hooks/kill-thread-trigger.sh +7 -0
  13. package/hooks/post-tool-use-code-index.sh +7 -0
  14. package/hooks/session-checkpoint.sh +7 -0
  15. package/hooks/session-start.sh +7 -0
  16. package/install.py +401 -0
  17. package/install.sh +260 -0
  18. package/package.json +24 -0
  19. package/pytest.ini +2 -0
  20. package/registry.json +26 -0
  21. package/server/.env.example +24 -0
  22. package/server/__init__.py +0 -0
  23. package/server/config.py +89 -0
  24. package/server/main.py +93 -0
  25. package/server/personas/analyst.md +15 -0
  26. package/server/personas/ceo.md +14 -0
  27. package/server/personas/code_agent.md +15 -0
  28. package/server/personas/cto.md +14 -0
  29. package/server/provision.py +260 -0
  30. package/server/provision_opensearch.py +154 -0
  31. package/server/requirements.txt +26 -0
  32. package/server/storage/__init__.py +0 -0
  33. package/server/storage/dynamo.py +399 -0
  34. package/server/storage/json_store.py +359 -0
  35. package/server/storage/opensearch.py +194 -0
  36. package/server/storage/s3.py +96 -0
  37. package/server/storage/tests/__init__.py +0 -0
  38. package/server/storage/tests/test_dynamo.py +452 -0
  39. package/server/storage/tests/test_json_store.py +226 -0
  40. package/server/storage/tests/test_opensearch.py +270 -0
  41. package/server/storage/tests/test_s3.py +125 -0
  42. package/server/tests/__init__.py +0 -0
  43. package/server/tests/test_install.py +606 -0
  44. package/server/tests/test_isolation.py +90 -0
  45. package/server/tests/test_ui_server.py +385 -0
  46. package/server/tests/test_ui_shader_background.py +52 -0
  47. package/server/tests/test_ui_story_6_3.py +105 -0
  48. package/server/tools/__init__.py +0 -0
  49. package/server/tools/_mcp.py +4 -0
  50. package/server/tools/agents.py +160 -0
  51. package/server/tools/ecc/__init__.py +1 -0
  52. package/server/tools/ecc/data.py +35 -0
  53. package/server/tools/ecc/diagrams.py +23 -0
  54. package/server/tools/ecc/pipeline.py +24 -0
  55. package/server/tools/ecc/presentation.py +24 -0
  56. package/server/tools/ecc/web.py +23 -0
  57. package/server/tools/executor.py +880 -0
  58. package/server/tools/harness.py +330 -0
  59. package/server/tools/help.py +162 -0
  60. package/server/tools/hooks.py +357 -0
  61. package/server/tools/personas.py +110 -0
  62. package/server/tools/registry.py +195 -0
  63. package/server/tools/router.py +117 -0
  64. package/server/tools/skill_installer.py +230 -0
  65. package/server/tools/summarizer.py +168 -0
  66. package/server/tools/tests/__init__.py +0 -0
  67. package/server/tools/tests/test_agents.py +246 -0
  68. package/server/tools/tests/test_code_index.py +108 -0
  69. package/server/tools/tests/test_ecc_tools.py +51 -0
  70. package/server/tools/tests/test_executor.py +584 -0
  71. package/server/tools/tests/test_help_agent101.py +149 -0
  72. package/server/tools/tests/test_hooks.py +124 -0
  73. package/server/tools/tests/test_kill_thread.py +125 -0
  74. package/server/tools/tests/test_new_thread_list_threads.py +293 -0
  75. package/server/tools/tests/test_personas.py +52 -0
  76. package/server/tools/tests/test_recall_memory.py +55 -0
  77. package/server/tools/tests/test_registry_client.py +308 -0
  78. package/server/tools/tests/test_router.py +263 -0
  79. package/server/tools/tests/test_skill_installer.py +174 -0
  80. package/server/tools/tests/test_switch_thread.py +163 -0
  81. package/server/tools/tests/test_thread_command_skills.py +54 -0
  82. package/server/tools/tests/test_thread_resolver.py +165 -0
  83. package/server/tools/tests/test_tier1_schema.py +296 -0
  84. package/server/tools/thread_resolver.py +75 -0
  85. package/server/tools/tylor.py +374 -0
  86. package/server/tools/ui.py +38 -0
  87. package/server/ui_server.py +292 -0
  88. package/server/validate.py +237 -0
  89. package/skills/add-skill/SKILL.md +37 -0
  90. package/skills/afk-status/SKILL.md +20 -0
  91. package/skills/bmad/SKILL.md +14 -0
  92. package/skills/help-agent101/SKILL.md +48 -0
  93. package/skills/kill-thread/SKILL.md +35 -0
  94. package/skills/list-threads/SKILL.md +35 -0
  95. package/skills/new-thread/SKILL.md +35 -0
  96. package/skills/recall/SKILL.md +39 -0
  97. package/skills/run/SKILL.md +33 -0
  98. package/skills/set-sandbox/SKILL.md +38 -0
  99. package/skills/switch-thread/SKILL.md +38 -0
  100. package/ui/claude-logo.png +0 -0
  101. package/ui/index.html +1314 -0
@@ -0,0 +1,154 @@
1
+ """
2
+ server/provision_opensearch.py — Provision the agent-memories OpenSearch index.
3
+ Advisory: never blocks install (always exits 0).
4
+
5
+ Run standalone: python3 server/provision_opensearch.py
6
+ """
7
+ import json
8
+ import logging
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ try:
16
+ from opensearchpy import OpenSearch
17
+ _OPENSEARCH_AVAILABLE = True
18
+ except ImportError:
19
+ OpenSearch = None # type: ignore[assignment,misc]
20
+ _OPENSEARCH_AVAILABLE = False
21
+
22
+ INDEX_NAME = "agent-memories"
23
+ VECTOR_DIM = 1536
24
+ VECTOR_FIELD = "embedding"
25
+ SIMILARITY = "cosine"
26
+
27
+ INDEX_BODY = {
28
+ "settings": {
29
+ "index": {
30
+ "knn": True,
31
+ }
32
+ },
33
+ "mappings": {
34
+ "properties": {
35
+ VECTOR_FIELD: {
36
+ "type": "knn_vector",
37
+ "dimension": VECTOR_DIM,
38
+ "method": {
39
+ "name": "hnsw",
40
+ "space_type": SIMILARITY,
41
+ "engine": "lucene",
42
+ },
43
+ },
44
+ "thread_id": {"type": "keyword"},
45
+ "content": {"type": "text"},
46
+ "created_at": {"type": "date"},
47
+ }
48
+ },
49
+ }
50
+
51
+
52
+ def _resolve_config() -> dict:
53
+ """Read host/port from env vars then ~/.agent101/config.json."""
54
+ cfg_path = Path.home() / ".agent101" / "config.json"
55
+ file_cfg: dict = {}
56
+ if cfg_path.exists():
57
+ try:
58
+ file_cfg = json.loads(cfg_path.read_text())
59
+ except (json.JSONDecodeError, OSError):
60
+ pass
61
+
62
+ host = os.getenv("OPENSEARCH_HOST") or file_cfg.get("opensearch_host", "")
63
+ port = int(os.getenv("OPENSEARCH_PORT") or file_cfg.get("opensearch_port", 9200))
64
+ return {"host": host, "port": port}
65
+
66
+
67
+ def provision_index(host: str, port: int) -> tuple:
68
+ """
69
+ Create or validate the agent-memories index.
70
+
71
+ Returns:
72
+ (True, message) — success (created or already compatible)
73
+ (False, message) — advisory failure (incompatible mapping or connection error)
74
+ """
75
+ if not _OPENSEARCH_AVAILABLE or OpenSearch is None:
76
+ return False, "opensearch-py not installed — cannot provision index"
77
+
78
+ client = OpenSearch(
79
+ hosts=[{"host": host, "port": port}],
80
+ http_compress=True,
81
+ )
82
+
83
+ try:
84
+ if client.indices.exists(index=INDEX_NAME):
85
+ # Validate compatibility: knn_vector field with correct dimension
86
+ mapping = client.indices.get_mapping(index=INDEX_NAME)
87
+ props = (
88
+ mapping.get(INDEX_NAME, {})
89
+ .get("mappings", {})
90
+ .get("properties", {})
91
+ )
92
+ emb = props.get(VECTOR_FIELD, {})
93
+
94
+ if emb.get("type") != "knn_vector":
95
+ return (
96
+ False,
97
+ f"Index '{INDEX_NAME}' exists but '{VECTOR_FIELD}' field is not knn_vector "
98
+ f"(got '{emb.get('type', 'missing')}') — incompatible mapping",
99
+ )
100
+
101
+ dim = emb.get("dimension")
102
+ if dim is not None and int(dim) != VECTOR_DIM:
103
+ return (
104
+ False,
105
+ f"Index '{INDEX_NAME}' exists but dimension={dim} "
106
+ f"(expected {VECTOR_DIM}) — incompatible mapping",
107
+ )
108
+
109
+ return True, f"Index '{INDEX_NAME}' already exists with compatible mapping — skipping"
110
+
111
+ else:
112
+ client.indices.create(index=INDEX_NAME, body=INDEX_BODY)
113
+ return (
114
+ True,
115
+ f"Created index '{INDEX_NAME}' "
116
+ f"(knn_vector, {VECTOR_DIM}-dim, {SIMILARITY})",
117
+ )
118
+
119
+ except Exception as exc: # noqa: BLE001
120
+ return False, f"OpenSearch error: {exc}"
121
+
122
+
123
+ def run_all() -> int:
124
+ """Advisory provisioning — always exits 0."""
125
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
126
+
127
+ cfg = _resolve_config()
128
+ host = cfg["host"]
129
+ port = cfg["port"]
130
+
131
+ if not host:
132
+ print(
133
+ " \033[1;33m⚠\033[0m OPENSEARCH_HOST not configured — "
134
+ "skipping OpenSearch index provisioning"
135
+ )
136
+ print(
137
+ " Set OPENSEARCH_HOST in .env or ~/.agent101/config.json "
138
+ "to enable semantic memory recall"
139
+ )
140
+ return 0
141
+
142
+ print("\n\033[1mProvisioning OpenSearch index\033[0m")
143
+ ok, msg = provision_index(host, port)
144
+ if ok:
145
+ print(f" \033[0;32m✓\033[0m {msg}")
146
+ else:
147
+ print(f" \033[1;33m⚠\033[0m {msg}")
148
+ print(" recall_memory will be unavailable until the index is provisioned")
149
+
150
+ return 0
151
+
152
+
153
+ if __name__ == "__main__":
154
+ sys.exit(run_all())
@@ -0,0 +1,26 @@
1
+ # FastMCP / MCP protocol
2
+ mcp[cli]>=1.0.0,<2.0.0
3
+
4
+ # AWS storage clients
5
+ boto3>=1.34.0,<2.0.0
6
+ botocore>=1.34.0,<2.0.0
7
+
8
+ # OpenSearch vector search
9
+ opensearch-py>=2.4.0,<3.0.0
10
+
11
+ # Fuzzy thread name matching
12
+ rapidfuzz>=3.6.0,<4.0.0
13
+
14
+ # UI server (Thread Visualizer)
15
+ aiohttp>=3.9.0,<4.0.0
16
+
17
+ # Anthropic SDK (Platform on AWS fallback)
18
+ anthropic>=0.25.0,<1.0.0
19
+
20
+ # Config / env
21
+ python-dotenv>=1.0.0,<2.0.0
22
+ claude-agent-sdk>=0.1.81
23
+
24
+ # Testing
25
+ pytest>=8.0.0
26
+ pytest-asyncio>=0.23.0
File without changes
@@ -0,0 +1,399 @@
1
+ """
2
+ server/storage/dynamo.py — DynamoDB storage client for agent101.
3
+
4
+ Single-table design. All items MUST include:
5
+ PK, SK, CreatedAt (ISO 8601 UTC), UpdatedAt (ISO 8601 UTC), Version (int)
6
+
7
+ Key schema:
8
+ PK: USER#{user_id} SK: THREAD#{thread_id}#META
9
+ PK: USER#{user_id} SK: THREAD#{thread_id}#MSG#{ts}
10
+ PK: USER#{user_id} SK: THREAD#{thread_id}#BLOB#{key}
11
+ PK: USER#{user_id} SK: MEMORY#{memory_id}
12
+
13
+ Thread isolation: all SK operations are validated to contain THREAD#{thread_id}.
14
+ Size limit: items >400KB are rejected — use s3.py for large content.
15
+ """
16
+ from __future__ import annotations
17
+ import json
18
+ import logging
19
+ import re
20
+ import uuid
21
+ from datetime import datetime, timezone
22
+
23
+ import boto3
24
+ from boto3.dynamodb.conditions import Key
25
+ from boto3.dynamodb.types import TypeSerializer
26
+ from mcp.server.fastmcp.exceptions import ToolError
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ ITEM_SIZE_LIMIT = 400 * 1024 # 400 KB
31
+ _AGENT_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
32
+
33
+
34
+ def _now_iso() -> str:
35
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
36
+
37
+
38
+ def _serialised_size(item: dict) -> int:
39
+ """Approximate DynamoDB item size using JSON serialisation."""
40
+ return len(json.dumps(item, default=str).encode("utf-8"))
41
+
42
+
43
+ def _unique_event_suffix() -> str:
44
+ return f"{_now_iso()}#{uuid.uuid4().hex}"
45
+
46
+
47
+ class DynamoClient:
48
+ """
49
+ Typed DynamoDB client enforcing single-table schema, mandatory base fields,
50
+ item size limit, and thread-isolation on all operations.
51
+ """
52
+
53
+ ITEM_SIZE_LIMIT = ITEM_SIZE_LIMIT
54
+
55
+ CURRENT_THREAD_SK = "THREAD#CURRENT#META"
56
+
57
+ def __init__(
58
+ self,
59
+ table_name: str,
60
+ user_id: str = "default",
61
+ profile: str | None = None,
62
+ ) -> None:
63
+ self.table_name = table_name
64
+ self.user_id = user_id
65
+
66
+ session_kwargs: dict = {}
67
+ if profile:
68
+ session_kwargs["profile_name"] = profile
69
+
70
+ session = boto3.Session(**session_kwargs)
71
+ resource = session.resource("dynamodb")
72
+ self.table = resource.Table(table_name)
73
+ self._client = session.client("dynamodb")
74
+ self._serializer = TypeSerializer()
75
+
76
+ # ------------------------------------------------------------------
77
+ # Internal helpers
78
+ # ------------------------------------------------------------------
79
+
80
+ def _pk(self) -> str:
81
+ return f"USER#{self.user_id}"
82
+
83
+ def _assert_thread_isolation(self, thread_id: str, sk: str) -> None:
84
+ """Raise ToolError if SK does not belong to this thread."""
85
+ thread_prefix = f"THREAD#{thread_id}#"
86
+ if not sk.startswith(thread_prefix):
87
+ raise ToolError(
88
+ f"Thread isolation violation: SK '{sk}' does not belong to thread '{thread_id}'"
89
+ )
90
+
91
+ def _inject_base_fields(self, item: dict, existing_version: int = 0) -> dict:
92
+ """Add/overwrite mandatory base fields. Version increments by 1."""
93
+ now = _now_iso()
94
+ item.setdefault("CreatedAt", now)
95
+ item["UpdatedAt"] = now
96
+ item["Version"] = existing_version + 1
97
+ return item
98
+
99
+ def _validate_size(self, item: dict) -> None:
100
+ size = _serialised_size(item)
101
+ if size > self.ITEM_SIZE_LIMIT:
102
+ raise ToolError(
103
+ f"Item exceeds 400KB ({size // 1024}KB) — use s3.py for large content"
104
+ )
105
+
106
+ # ------------------------------------------------------------------
107
+ # Public API
108
+ # ------------------------------------------------------------------
109
+
110
+ def put_item(self, sk: str, attributes: dict) -> dict:
111
+ """
112
+ Write an item.
113
+ - Injects PK, SK, CreatedAt, UpdatedAt, Version.
114
+ - Validates item size ≤ 400KB.
115
+ - Returns the written item dict.
116
+ """
117
+ # Fetch existing version for increment (if item exists)
118
+ existing = self._raw_get(sk)
119
+ existing_version = int(existing.get("Version", 0)) if existing else 0
120
+
121
+ item = dict(attributes)
122
+ item["PK"] = self._pk()
123
+ item["SK"] = sk
124
+ self._inject_base_fields(item, existing_version)
125
+
126
+ # Preserve original CreatedAt if item already exists
127
+ if existing and "CreatedAt" in existing:
128
+ item["CreatedAt"] = existing["CreatedAt"]
129
+
130
+ self._validate_size(item)
131
+ self.table.put_item(Item=item)
132
+ return item
133
+
134
+ def get_item(self, thread_id: str, sk: str) -> dict | None:
135
+ """
136
+ Read a single item by (PK, SK).
137
+ Enforces thread isolation: SK must contain THREAD#{thread_id}.
138
+ Returns None if not found.
139
+ """
140
+ self._assert_thread_isolation(thread_id, sk)
141
+ response = self.table.get_item(Key={"PK": self._pk(), "SK": sk})
142
+ return response.get("Item")
143
+
144
+ def query_thread(self, thread_id: str, sk_prefix: str) -> list:
145
+ """
146
+ Query all items for a thread by SK prefix.
147
+ SK prefix must contain THREAD#{thread_id} for isolation.
148
+ """
149
+ self._assert_thread_isolation(thread_id, sk_prefix)
150
+ response = self.table.query(
151
+ KeyConditionExpression=(
152
+ Key("PK").eq(self._pk()) & Key("SK").begins_with(sk_prefix)
153
+ )
154
+ )
155
+ return response.get("Items", [])
156
+
157
+ def delete_item(self, thread_id: str, sk: str) -> None:
158
+ """
159
+ Delete an item. Enforces thread isolation.
160
+ """
161
+ self._assert_thread_isolation(thread_id, sk)
162
+ self.table.delete_item(Key={"PK": self._pk(), "SK": sk})
163
+
164
+ def _validate_agent_id(self, agent_id: str) -> None:
165
+ if not agent_id or not _AGENT_ID_RE.match(agent_id):
166
+ raise ToolError("Invalid agent_id")
167
+
168
+ def put_agent_output(
169
+ self,
170
+ thread_id: str,
171
+ agent_id: str,
172
+ output: str,
173
+ task: str | None = None,
174
+ ) -> dict:
175
+ """Persist completed sub-agent output under its parent thread."""
176
+ self._validate_agent_id(agent_id)
177
+ sk = f"THREAD#{thread_id}#AGENT#{agent_id}#OUT#{_unique_event_suffix()}"
178
+ self._assert_thread_isolation(thread_id, sk)
179
+
180
+ attributes = {
181
+ "ThreadId": thread_id,
182
+ "AgentId": agent_id,
183
+ "Type": "agent_output",
184
+ "Output": output,
185
+ }
186
+ if task is not None:
187
+ attributes["Task"] = task
188
+ return self.put_item(sk=sk, attributes=attributes)
189
+
190
+ def put_agent_handoff(
191
+ self,
192
+ thread_id: str,
193
+ agent_id: str,
194
+ handoff_state: dict,
195
+ ) -> dict:
196
+ """Persist sub-agent handoff state as a distinct thread-scoped item."""
197
+ self._validate_agent_id(agent_id)
198
+ sk = f"THREAD#{thread_id}#AGENT#{agent_id}#HANDOFF#{_unique_event_suffix()}"
199
+ self._assert_thread_isolation(thread_id, sk)
200
+ return self.put_item(
201
+ sk=sk,
202
+ attributes={
203
+ "ThreadId": thread_id,
204
+ "AgentId": agent_id,
205
+ "Type": "agent_handoff",
206
+ "HandoffState": handoff_state,
207
+ },
208
+ )
209
+
210
+ def put_agent_state(
211
+ self,
212
+ thread_id: str,
213
+ agent_id: str,
214
+ state: dict,
215
+ ) -> dict:
216
+ """Persist durable sub-agent lifecycle state under its parent thread."""
217
+ self._validate_agent_id(agent_id)
218
+ sk = f"THREAD#{thread_id}#AGENT#{agent_id}#STATE"
219
+ self._assert_thread_isolation(thread_id, sk)
220
+ attributes = {
221
+ "ThreadId": thread_id,
222
+ "AgentId": agent_id,
223
+ "Type": "agent_state",
224
+ **state,
225
+ }
226
+ return self.put_item(sk=sk, attributes=attributes)
227
+
228
+ def query_agent_states(self, thread_id: str) -> list:
229
+ """Return persisted sub-agent state records for one thread only."""
230
+ items = self.query_thread(thread_id, f"THREAD#{thread_id}#AGENT#")
231
+ return [item for item in items if item.get("SK", "").endswith("#STATE")]
232
+
233
+ def get_thread_meta(self, thread_id: str) -> dict | None:
234
+ """Return thread META item for the given thread_id."""
235
+ return self.get_item(thread_id, f"THREAD#{thread_id}#META")
236
+
237
+ def get_current_thread_marker(self) -> dict | None:
238
+ """Return the current-thread marker item without isolation enforcement."""
239
+ return self._raw_get(self.CURRENT_THREAD_SK)
240
+
241
+ def resolve_thread_id(self, thread_id: str | None = None) -> str:
242
+ """Resolve an explicit thread_id or the active current-thread marker."""
243
+ if thread_id:
244
+ return thread_id
245
+ marker = self.get_current_thread_marker()
246
+ if not marker or not marker.get("CurrentThreadId"):
247
+ raise ToolError("No active thread — switch_thread or provide thread_id first")
248
+ return marker["CurrentThreadId"]
249
+
250
+ def set_sandbox_roots(self, thread_id: str, sandbox_roots: list[str]) -> dict:
251
+ """Persist sandbox roots on thread metadata."""
252
+ meta = self.get_thread_meta(thread_id)
253
+ if not meta:
254
+ raise ToolError(f"Thread not found: {thread_id}")
255
+ updated = dict(meta)
256
+ updated["sandbox_roots"] = list(sandbox_roots)
257
+ sk = f"THREAD#{thread_id}#META"
258
+ self._assert_thread_isolation(thread_id, sk)
259
+ return self.put_item(sk=sk, attributes=updated)
260
+
261
+ def query_all(self, sk_prefix: str) -> list:
262
+ """
263
+ Query all items with SK beginning with sk_prefix across all threads.
264
+ No thread isolation check — used for cross-thread operations (list, name-uniqueness).
265
+ """
266
+ response = self.table.query(
267
+ KeyConditionExpression=(
268
+ Key("PK").eq(self._pk()) & Key("SK").begins_with(sk_prefix)
269
+ )
270
+ )
271
+ return response.get("Items", [])
272
+
273
+ def _serialize_item(self, item: dict) -> dict:
274
+ return {key: self._serializer.serialize(value) for key, value in item.items()}
275
+
276
+ def transact_write_items(self, transact_items: list[dict]) -> None:
277
+ """Execute a DynamoDB TransactWriteItems call with serialized items."""
278
+ try:
279
+ self._client.transact_write_items(TransactItems=transact_items)
280
+ except Exception as exc:
281
+ raise ToolError(f"TransactWriteItems failed: {exc}") from exc
282
+
283
+ def switch_thread(self, target_thread_id: str) -> dict:
284
+ """Atomically switch the current thread marker and update thread metadata."""
285
+ target_sk = f"THREAD#{target_thread_id}#META"
286
+ target_meta = self._raw_get(target_sk)
287
+ if not target_meta:
288
+ raise ToolError(f"Thread not found: {target_thread_id}")
289
+
290
+ current_marker = self.get_current_thread_marker()
291
+ now = _now_iso()
292
+
293
+ # Update the current thread marker
294
+ if current_marker:
295
+ marker = dict(current_marker)
296
+ marker["Version"] = int(marker.get("Version", 0)) + 1
297
+ else:
298
+ marker = {
299
+ "PK": self._pk(),
300
+ "SK": self.CURRENT_THREAD_SK,
301
+ "CreatedAt": now,
302
+ "Version": 1,
303
+ }
304
+ marker["CurrentThreadId"] = target_thread_id
305
+ marker["ActiveAt"] = now
306
+ marker["UpdatedAt"] = now
307
+
308
+ # Update the target thread's metadata with a fresh activity timestamp
309
+ target = dict(target_meta)
310
+ target["LastActivity"] = now
311
+ target["UpdatedAt"] = now
312
+ target["Version"] = int(target.get("Version", 0)) + 1
313
+
314
+ transact_items = [
315
+ {
316
+ "Put": {
317
+ "TableName": self.table_name,
318
+ "Item": self._serialize_item(marker),
319
+ }
320
+ },
321
+ {
322
+ "Put": {
323
+ "TableName": self.table_name,
324
+ "Item": self._serialize_item(target),
325
+ }
326
+ },
327
+ ]
328
+
329
+ if current_marker and current_marker.get("CurrentThreadId") != target_thread_id:
330
+ previous_thread_id = current_marker.get("CurrentThreadId")
331
+ if previous_thread_id:
332
+ previous_sk = f"THREAD#{previous_thread_id}#META"
333
+ previous_meta = self._raw_get(previous_sk)
334
+ if previous_meta:
335
+ previous = dict(previous_meta)
336
+ previous["LastActivity"] = current_marker.get("ActiveAt", now)
337
+ previous["UpdatedAt"] = now
338
+ previous["Version"] = int(previous.get("Version", 0)) + 1
339
+ transact_items.append(
340
+ {
341
+ "Put": {
342
+ "TableName": self.table_name,
343
+ "Item": self._serialize_item(previous),
344
+ }
345
+ }
346
+ )
347
+
348
+ for agent_state in self.query_agent_states(previous_thread_id):
349
+ if agent_state.get("Status") == "active":
350
+ suspended = dict(agent_state)
351
+ suspended["Status"] = "suspended"
352
+ suspended["SuspendedAt"] = now
353
+ suspended["UpdatedAt"] = now
354
+ suspended["Version"] = int(suspended.get("Version", 0)) + 1
355
+ transact_items.append(
356
+ {
357
+ "Put": {
358
+ "TableName": self.table_name,
359
+ "Item": self._serialize_item(suspended),
360
+ }
361
+ }
362
+ )
363
+
364
+ for agent_state in self.query_agent_states(target_thread_id):
365
+ if agent_state.get("Status") == "suspended":
366
+ resumed = dict(agent_state)
367
+ resumed["Status"] = "active"
368
+ resumed["ResumedAt"] = now
369
+ resumed["UpdatedAt"] = now
370
+ resumed["Version"] = int(resumed.get("Version", 0)) + 1
371
+ transact_items.append(
372
+ {
373
+ "Put": {
374
+ "TableName": self.table_name,
375
+ "Item": self._serialize_item(resumed),
376
+ }
377
+ }
378
+ )
379
+
380
+ if len(transact_items) > 25:
381
+ raise ToolError(
382
+ f"SwThread transaction has {len(transact_items)} items — "
383
+ "exceeds DynamoDB limit of 25. Reduce active agent count before switching."
384
+ )
385
+ self.transact_write_items(transact_items)
386
+ return {
387
+ "thread_id": target_thread_id,
388
+ "status": "switched",
389
+ "switched_at": now,
390
+ }
391
+
392
+ # ------------------------------------------------------------------
393
+ # Private (used internally, not part of public contract)
394
+ # ------------------------------------------------------------------
395
+
396
+ def _raw_get(self, sk: str) -> dict | None:
397
+ """Get item without thread isolation check (used for version fetch in put_item)."""
398
+ response = self.table.get_item(Key={"PK": self._pk(), "SK": sk})
399
+ return response.get("Item")