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.
- package/.aws-setup.sh +25 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.mcp.json +12 -0
- package/AGENTS.md +93 -0
- package/CLAUDE.md +99 -0
- package/CLAUDE_PLATFORM_AWS_SETUP.md +105 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/assets/tylor_logo.png +0 -0
- package/assets/tylor_threads_concept.png +0 -0
- package/bin/tylor.js +23 -0
- package/hooks/kill-thread-trigger.sh +7 -0
- package/hooks/post-tool-use-code-index.sh +7 -0
- package/hooks/session-checkpoint.sh +7 -0
- package/hooks/session-start.sh +7 -0
- package/install.py +401 -0
- package/install.sh +260 -0
- package/package.json +24 -0
- package/pytest.ini +2 -0
- package/registry.json +26 -0
- package/server/.env.example +24 -0
- package/server/__init__.py +0 -0
- package/server/config.py +89 -0
- package/server/main.py +93 -0
- package/server/personas/analyst.md +15 -0
- package/server/personas/ceo.md +14 -0
- package/server/personas/code_agent.md +15 -0
- package/server/personas/cto.md +14 -0
- package/server/provision.py +260 -0
- package/server/provision_opensearch.py +154 -0
- package/server/requirements.txt +26 -0
- package/server/storage/__init__.py +0 -0
- package/server/storage/dynamo.py +399 -0
- package/server/storage/json_store.py +359 -0
- package/server/storage/opensearch.py +194 -0
- package/server/storage/s3.py +96 -0
- package/server/storage/tests/__init__.py +0 -0
- package/server/storage/tests/test_dynamo.py +452 -0
- package/server/storage/tests/test_json_store.py +226 -0
- package/server/storage/tests/test_opensearch.py +270 -0
- package/server/storage/tests/test_s3.py +125 -0
- package/server/tests/__init__.py +0 -0
- package/server/tests/test_install.py +606 -0
- package/server/tests/test_isolation.py +90 -0
- package/server/tests/test_ui_server.py +385 -0
- package/server/tests/test_ui_shader_background.py +52 -0
- package/server/tests/test_ui_story_6_3.py +105 -0
- package/server/tools/__init__.py +0 -0
- package/server/tools/_mcp.py +4 -0
- package/server/tools/agents.py +160 -0
- package/server/tools/ecc/__init__.py +1 -0
- package/server/tools/ecc/data.py +35 -0
- package/server/tools/ecc/diagrams.py +23 -0
- package/server/tools/ecc/pipeline.py +24 -0
- package/server/tools/ecc/presentation.py +24 -0
- package/server/tools/ecc/web.py +23 -0
- package/server/tools/executor.py +880 -0
- package/server/tools/harness.py +330 -0
- package/server/tools/help.py +162 -0
- package/server/tools/hooks.py +357 -0
- package/server/tools/personas.py +110 -0
- package/server/tools/registry.py +195 -0
- package/server/tools/router.py +117 -0
- package/server/tools/skill_installer.py +230 -0
- package/server/tools/summarizer.py +168 -0
- package/server/tools/tests/__init__.py +0 -0
- package/server/tools/tests/test_agents.py +246 -0
- package/server/tools/tests/test_code_index.py +108 -0
- package/server/tools/tests/test_ecc_tools.py +51 -0
- package/server/tools/tests/test_executor.py +584 -0
- package/server/tools/tests/test_help_agent101.py +149 -0
- package/server/tools/tests/test_hooks.py +124 -0
- package/server/tools/tests/test_kill_thread.py +125 -0
- package/server/tools/tests/test_new_thread_list_threads.py +293 -0
- package/server/tools/tests/test_personas.py +52 -0
- package/server/tools/tests/test_recall_memory.py +55 -0
- package/server/tools/tests/test_registry_client.py +308 -0
- package/server/tools/tests/test_router.py +263 -0
- package/server/tools/tests/test_skill_installer.py +174 -0
- package/server/tools/tests/test_switch_thread.py +163 -0
- package/server/tools/tests/test_thread_command_skills.py +54 -0
- package/server/tools/tests/test_thread_resolver.py +165 -0
- package/server/tools/tests/test_tier1_schema.py +296 -0
- package/server/tools/thread_resolver.py +75 -0
- package/server/tools/tylor.py +374 -0
- package/server/tools/ui.py +38 -0
- package/server/ui_server.py +292 -0
- package/server/validate.py +237 -0
- package/skills/add-skill/SKILL.md +37 -0
- package/skills/afk-status/SKILL.md +20 -0
- package/skills/bmad/SKILL.md +14 -0
- package/skills/help-agent101/SKILL.md +48 -0
- package/skills/kill-thread/SKILL.md +35 -0
- package/skills/list-threads/SKILL.md +35 -0
- package/skills/new-thread/SKILL.md +35 -0
- package/skills/recall/SKILL.md +39 -0
- package/skills/run/SKILL.md +33 -0
- package/skills/set-sandbox/SKILL.md +38 -0
- package/skills/switch-thread/SKILL.md +38 -0
- package/ui/claude-logo.png +0 -0
- 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")
|