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,359 @@
|
|
|
1
|
+
"""
|
|
2
|
+
server/storage/json_store.py — Local JSON storage for agent101 threads.
|
|
3
|
+
|
|
4
|
+
Zero-infra default: all state lives in ~/.tylor/threads.json.
|
|
5
|
+
No database, no cloud account, no configuration required.
|
|
6
|
+
All writes are atomic (write-to-tmp then os.replace).
|
|
7
|
+
|
|
8
|
+
This is the default storage backend. DynamoDB is optional for multi-machine sync.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import uuid
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
STORE_VERSION = "1.0"
|
|
23
|
+
WARN_THRESHOLD = 400 * 1024 # 400 KB
|
|
24
|
+
|
|
25
|
+
_EMPTY_STORE: dict = {"version": STORE_VERSION, "threads": [], "current_thread_id": None}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _now_iso() -> str:
|
|
29
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _default_store_path() -> Path:
|
|
33
|
+
return Path.home() / ".tylor" / "threads.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class JsonStore:
|
|
37
|
+
"""
|
|
38
|
+
Local JSON storage implementing the same interface as DynamoClient
|
|
39
|
+
so tools can use either backend transparently via _get_db().
|
|
40
|
+
|
|
41
|
+
Thread data structure:
|
|
42
|
+
{id, name, status, created_at, updated_at, messages[], summary,
|
|
43
|
+
sandbox_roots[], current: bool, agent_states{}, agent_outputs[]}
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
47
|
+
self.path = path or _default_store_path()
|
|
48
|
+
|
|
49
|
+
# ── Internal load/save ─────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def _load(self) -> dict:
|
|
52
|
+
import copy
|
|
53
|
+
if not self.path.exists():
|
|
54
|
+
return copy.deepcopy(_EMPTY_STORE)
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
57
|
+
data.setdefault("threads", [])
|
|
58
|
+
data.setdefault("current_thread_id", None)
|
|
59
|
+
return data
|
|
60
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
61
|
+
logger.warning("Could not load %s: %s", self.path, exc)
|
|
62
|
+
import copy
|
|
63
|
+
return copy.deepcopy(_EMPTY_STORE)
|
|
64
|
+
|
|
65
|
+
# Public alias — tests call store.load()
|
|
66
|
+
def load(self) -> dict:
|
|
67
|
+
return self._load()
|
|
68
|
+
|
|
69
|
+
def _save(self, data: dict) -> None:
|
|
70
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
raw = json.dumps(data, indent=2, ensure_ascii=False)
|
|
72
|
+
if len(raw.encode()) > WARN_THRESHOLD:
|
|
73
|
+
logger.warning(
|
|
74
|
+
"Thread store approaching file size limit (%d KB)",
|
|
75
|
+
len(raw.encode()) // 1024,
|
|
76
|
+
)
|
|
77
|
+
tmp = self.path.with_suffix(".tmp")
|
|
78
|
+
try:
|
|
79
|
+
tmp.write_text(raw, encoding="utf-8")
|
|
80
|
+
os.replace(tmp, self.path)
|
|
81
|
+
except OSError:
|
|
82
|
+
tmp.unlink(missing_ok=True)
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
def _find(self, data: dict, thread_id: str) -> dict | None:
|
|
86
|
+
for t in data["threads"]:
|
|
87
|
+
if t["id"] == thread_id:
|
|
88
|
+
return t
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def _require(self, data: dict, thread_id: str) -> dict:
|
|
92
|
+
t = self._find(data, thread_id)
|
|
93
|
+
if t is None:
|
|
94
|
+
raise KeyError(f"Thread not found: {thread_id}")
|
|
95
|
+
return t
|
|
96
|
+
|
|
97
|
+
# ── Thread CRUD (high-level API used by JsonStore tests) ───────────
|
|
98
|
+
|
|
99
|
+
def new_thread(self, name: str) -> dict:
|
|
100
|
+
data = self._load()
|
|
101
|
+
now = _now_iso()
|
|
102
|
+
thread: dict = {
|
|
103
|
+
"id": f"thread_{uuid.uuid4().hex}",
|
|
104
|
+
"name": name, "status": "active",
|
|
105
|
+
"created_at": now, "updated_at": now,
|
|
106
|
+
"messages": [], "summary": None,
|
|
107
|
+
"sandbox_roots": [], "agent_states": {}, "agent_outputs": [],
|
|
108
|
+
}
|
|
109
|
+
data["threads"].append(thread)
|
|
110
|
+
self._save(data)
|
|
111
|
+
return thread
|
|
112
|
+
|
|
113
|
+
def get_thread(self, thread_id: str) -> dict | None:
|
|
114
|
+
return self._find(self._load(), thread_id)
|
|
115
|
+
|
|
116
|
+
def update_thread(self, thread_id: str, **fields) -> dict:
|
|
117
|
+
data = self._load()
|
|
118
|
+
t = self._require(data, thread_id)
|
|
119
|
+
fields["updated_at"] = _now_iso()
|
|
120
|
+
t.update(fields)
|
|
121
|
+
self._save(data)
|
|
122
|
+
return t
|
|
123
|
+
|
|
124
|
+
def delete_thread(self, thread_id: str) -> bool:
|
|
125
|
+
data = self._load()
|
|
126
|
+
before = len(data["threads"])
|
|
127
|
+
data["threads"] = [t for t in data["threads"] if t["id"] != thread_id]
|
|
128
|
+
if len(data["threads"]) == before:
|
|
129
|
+
return False
|
|
130
|
+
self._save(data)
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
def list_threads(self) -> list:
|
|
134
|
+
data = self._load()
|
|
135
|
+
return sorted(data["threads"], key=lambda t: t.get("updated_at", ""), reverse=True)
|
|
136
|
+
|
|
137
|
+
# ── DynamoClient-compatible interface ──────────────────────────────
|
|
138
|
+
# All tool code calls these methods via _get_db(). Keeping the same
|
|
139
|
+
# signatures means zero changes needed in tools/*.py
|
|
140
|
+
|
|
141
|
+
def put_item(self, sk: str, attributes: dict) -> dict:
|
|
142
|
+
"""Store a raw item by SK. Used for messages, summaries, events."""
|
|
143
|
+
data = self._load()
|
|
144
|
+
now = _now_iso()
|
|
145
|
+
item = dict(attributes)
|
|
146
|
+
item["SK"] = sk
|
|
147
|
+
item.setdefault("CreatedAt", now)
|
|
148
|
+
item["UpdatedAt"] = now
|
|
149
|
+
|
|
150
|
+
# Route by SK pattern
|
|
151
|
+
if "#META" in sk:
|
|
152
|
+
thread_id = sk.split("#")[1] if sk.startswith("THREAD#") else None
|
|
153
|
+
if thread_id and thread_id != "CURRENT":
|
|
154
|
+
existing = self._find(data, thread_id)
|
|
155
|
+
if existing:
|
|
156
|
+
existing.update({
|
|
157
|
+
"name": item.get("Name", existing.get("name", "")),
|
|
158
|
+
"status": item.get("Status", existing.get("status", "active")).lower(),
|
|
159
|
+
"updated_at": now,
|
|
160
|
+
**({"project": item["Project"]} if item.get("Project") else {}),
|
|
161
|
+
})
|
|
162
|
+
if "Summary" in item:
|
|
163
|
+
existing["summary"] = item["Summary"]
|
|
164
|
+
self._save(data)
|
|
165
|
+
return item
|
|
166
|
+
else:
|
|
167
|
+
thread = {
|
|
168
|
+
"id": thread_id, "name": item.get("Name", ""),
|
|
169
|
+
"status": item.get("Status", "active").lower(),
|
|
170
|
+
"project": item.get("Project", ""),
|
|
171
|
+
"created_at": item.get("CreatedAt", now),
|
|
172
|
+
"updated_at": now, "messages": [], "summary": None,
|
|
173
|
+
"sandbox_roots": [], "agent_states": {}, "agent_outputs": [],
|
|
174
|
+
}
|
|
175
|
+
data["threads"].append(thread)
|
|
176
|
+
self._save(data)
|
|
177
|
+
return item
|
|
178
|
+
elif thread_id == "CURRENT":
|
|
179
|
+
data["current_thread_id"] = item.get("CurrentThreadId")
|
|
180
|
+
data["current_thread_active_at"] = item.get("ActiveAt", now)
|
|
181
|
+
self._save(data)
|
|
182
|
+
return item
|
|
183
|
+
elif "#MSG#" in sk or "#SUMMARY_FAILURE" in sk or "#RECOVERY" in sk or "#SANDBOX" in sk:
|
|
184
|
+
parts = sk.split("#")
|
|
185
|
+
if len(parts) >= 2:
|
|
186
|
+
thread_id = parts[1]
|
|
187
|
+
t = self._find(data, thread_id)
|
|
188
|
+
if t:
|
|
189
|
+
t.setdefault("messages", [])
|
|
190
|
+
item["SK"] = sk
|
|
191
|
+
t["messages"].append(item)
|
|
192
|
+
t["updated_at"] = now
|
|
193
|
+
self._save(data)
|
|
194
|
+
return item
|
|
195
|
+
elif "#SUMMARY" in sk and "#SUMMARY_FAILURE" not in sk:
|
|
196
|
+
parts = sk.split("#")
|
|
197
|
+
thread_id = parts[1] if len(parts) >= 2 else None
|
|
198
|
+
if thread_id:
|
|
199
|
+
t = self._find(data, thread_id)
|
|
200
|
+
if t:
|
|
201
|
+
t["summary"] = item.get("Summary", "")
|
|
202
|
+
t["summary_type"] = item.get("SummaryType", "")
|
|
203
|
+
t["updated_at"] = now
|
|
204
|
+
self._save(data)
|
|
205
|
+
return item
|
|
206
|
+
|
|
207
|
+
# Fallback: store in misc bucket
|
|
208
|
+
data.setdefault("misc", [])
|
|
209
|
+
data["misc"].append(item)
|
|
210
|
+
self._save(data)
|
|
211
|
+
return item
|
|
212
|
+
|
|
213
|
+
def get_item(self, thread_id: str, sk: str) -> dict | None:
|
|
214
|
+
data = self._load()
|
|
215
|
+
t = self._find(data, thread_id)
|
|
216
|
+
if not t:
|
|
217
|
+
return None
|
|
218
|
+
for msg in t.get("messages", []):
|
|
219
|
+
if msg.get("SK") == sk:
|
|
220
|
+
return msg
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
def query_all(self, sk_prefix: str) -> list:
|
|
224
|
+
"""Return all items whose SK starts with sk_prefix."""
|
|
225
|
+
data = self._load()
|
|
226
|
+
results = []
|
|
227
|
+
# Thread META items
|
|
228
|
+
if sk_prefix.startswith("THREAD#"):
|
|
229
|
+
parts = sk_prefix.split("#")
|
|
230
|
+
if len(parts) >= 2 and parts[1]:
|
|
231
|
+
thread_id = parts[1]
|
|
232
|
+
t = self._find(data, thread_id)
|
|
233
|
+
if t:
|
|
234
|
+
results.extend(self._thread_to_items(t))
|
|
235
|
+
else:
|
|
236
|
+
# All threads
|
|
237
|
+
for t in data["threads"]:
|
|
238
|
+
results.extend(self._thread_to_items(t))
|
|
239
|
+
return results
|
|
240
|
+
|
|
241
|
+
def _thread_to_items(self, t: dict) -> list:
|
|
242
|
+
"""Convert thread dict to DynamoDB-style item list."""
|
|
243
|
+
items = [{
|
|
244
|
+
"SK": f"THREAD#{t['id']}#META",
|
|
245
|
+
"Name": t.get("name", ""),
|
|
246
|
+
"Status": t.get("status", "active").capitalize(),
|
|
247
|
+
"LastActivity": t.get("updated_at", ""),
|
|
248
|
+
"MessageCount": len(t.get("messages", [])),
|
|
249
|
+
"CreatedAt": t.get("created_at", ""),
|
|
250
|
+
"UpdatedAt": t.get("updated_at", ""),
|
|
251
|
+
"Project": t.get("project", ""),
|
|
252
|
+
}]
|
|
253
|
+
for msg in t.get("messages", []):
|
|
254
|
+
items.append(msg)
|
|
255
|
+
return items
|
|
256
|
+
|
|
257
|
+
def query_thread(self, thread_id: str, sk_prefix: str) -> list:
|
|
258
|
+
data = self._load()
|
|
259
|
+
t = self._find(data, thread_id)
|
|
260
|
+
if not t:
|
|
261
|
+
return []
|
|
262
|
+
return [m for m in t.get("messages", []) if m.get("SK", "").startswith(sk_prefix)]
|
|
263
|
+
|
|
264
|
+
def get_thread_meta(self, thread_id: str) -> dict | None:
|
|
265
|
+
data = self._load()
|
|
266
|
+
t = self._find(data, thread_id)
|
|
267
|
+
if not t:
|
|
268
|
+
return None
|
|
269
|
+
return {
|
|
270
|
+
"SK": f"THREAD#{thread_id}#META",
|
|
271
|
+
"Name": t.get("name", ""),
|
|
272
|
+
"Status": t.get("status", "active").capitalize(),
|
|
273
|
+
"LastActivity": t.get("updated_at", ""),
|
|
274
|
+
"MessageCount": len(t.get("messages", [])),
|
|
275
|
+
"CreatedAt": t.get("created_at", ""),
|
|
276
|
+
"UpdatedAt": t.get("updated_at", ""),
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
def get_current_thread_marker(self) -> dict | None:
|
|
280
|
+
data = self._load()
|
|
281
|
+
tid = data.get("current_thread_id")
|
|
282
|
+
if not tid:
|
|
283
|
+
return None
|
|
284
|
+
return {
|
|
285
|
+
"CurrentThreadId": tid,
|
|
286
|
+
"ActiveAt": data.get("current_thread_active_at", ""),
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
def resolve_thread_id(self, thread_id: str | None = None) -> str:
|
|
290
|
+
if thread_id:
|
|
291
|
+
return thread_id
|
|
292
|
+
data = self._load()
|
|
293
|
+
tid = data.get("current_thread_id")
|
|
294
|
+
if not tid:
|
|
295
|
+
raise ToolError("No active thread — create one with CT [name]")
|
|
296
|
+
return tid
|
|
297
|
+
|
|
298
|
+
def switch_thread(self, thread_id: str) -> dict:
|
|
299
|
+
"""Make thread_id the active thread. Atomic in JSON via single save."""
|
|
300
|
+
data = self._load()
|
|
301
|
+
t = self._find(data, thread_id)
|
|
302
|
+
if not t:
|
|
303
|
+
raise ToolError(f"Thread not found: {thread_id}")
|
|
304
|
+
old_id = data.get("current_thread_id")
|
|
305
|
+
if old_id and old_id != thread_id:
|
|
306
|
+
old = self._find(data, old_id)
|
|
307
|
+
if old:
|
|
308
|
+
old["updated_at"] = _now_iso()
|
|
309
|
+
data["current_thread_id"] = thread_id
|
|
310
|
+
data["current_thread_active_at"] = _now_iso()
|
|
311
|
+
t["updated_at"] = _now_iso()
|
|
312
|
+
self._save(data)
|
|
313
|
+
return {"status": "switched", "thread_id": thread_id, "switched_at": _now_iso()}
|
|
314
|
+
|
|
315
|
+
def set_sandbox_roots(self, thread_id: str, sandbox_roots: list) -> dict:
|
|
316
|
+
data = self._load()
|
|
317
|
+
t = self._require(data, thread_id)
|
|
318
|
+
t["sandbox_roots"] = sandbox_roots
|
|
319
|
+
t["updated_at"] = _now_iso()
|
|
320
|
+
self._save(data)
|
|
321
|
+
return {"thread_id": thread_id, "sandbox_roots": sandbox_roots}
|
|
322
|
+
|
|
323
|
+
# ── Agent state (stub — works for single-machine use) ──────────────
|
|
324
|
+
|
|
325
|
+
def put_agent_output(self, thread_id: str, agent_id: str, output: str, task: str | None = None) -> dict:
|
|
326
|
+
data = self._load()
|
|
327
|
+
t = self._require(data, thread_id)
|
|
328
|
+
now = _now_iso()
|
|
329
|
+
item = {"SK": f"THREAD#{thread_id}#AGENT#{agent_id}#OUT#{now}", "ThreadId": thread_id, "AgentId": agent_id, "Output": output, "Task": task}
|
|
330
|
+
t.setdefault("agent_outputs", []).append(item)
|
|
331
|
+
t["updated_at"] = now
|
|
332
|
+
self._save(data)
|
|
333
|
+
return item
|
|
334
|
+
|
|
335
|
+
def put_agent_handoff(self, thread_id: str, agent_id: str, handoff_state: dict) -> dict:
|
|
336
|
+
data = self._load()
|
|
337
|
+
t = self._require(data, thread_id)
|
|
338
|
+
now = _now_iso()
|
|
339
|
+
item = {"SK": f"THREAD#{thread_id}#AGENT#{agent_id}#HANDOFF#{now}", "ThreadId": thread_id, "AgentId": agent_id, "HandoffState": handoff_state}
|
|
340
|
+
t.setdefault("agent_outputs", []).append(item)
|
|
341
|
+
t["updated_at"] = now
|
|
342
|
+
self._save(data)
|
|
343
|
+
return item
|
|
344
|
+
|
|
345
|
+
def put_agent_state(self, thread_id: str, agent_id: str, state: dict) -> dict:
|
|
346
|
+
data = self._load()
|
|
347
|
+
t = self._require(data, thread_id)
|
|
348
|
+
t.setdefault("agent_states", {})[agent_id] = {**state, "UpdatedAt": _now_iso()}
|
|
349
|
+
t["updated_at"] = _now_iso()
|
|
350
|
+
self._save(data)
|
|
351
|
+
sk = f"THREAD#{thread_id}#AGENT#{agent_id}#STATE"
|
|
352
|
+
return {"SK": sk, "ThreadId": thread_id, "AgentId": agent_id, **state}
|
|
353
|
+
|
|
354
|
+
def query_agent_states(self, thread_id: str) -> list:
|
|
355
|
+
data = self._load()
|
|
356
|
+
t = self._find(data, thread_id)
|
|
357
|
+
if not t:
|
|
358
|
+
return []
|
|
359
|
+
return [{"AgentId": aid, **s} for aid, s in t.get("agent_states", {}).items()]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
server/storage/opensearch.py — OpenSearch vector client for agent101.
|
|
3
|
+
|
|
4
|
+
Handles semantic memory: embedding via Amazon Titan Embeddings v2 (1536-dim)
|
|
5
|
+
through AWS Bedrock, then k-NN search against the agent-memories index.
|
|
6
|
+
|
|
7
|
+
Thread isolation is enforced at query time via a term filter on thread_id.
|
|
8
|
+
Facts from other threads are NEVER returned.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
|
|
16
|
+
import boto3
|
|
17
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
18
|
+
from opensearchpy import OpenSearch
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
INDEX = "agent-memories"
|
|
23
|
+
TITAN_MODEL = "amazon.titan-embed-text-v2:0"
|
|
24
|
+
VECTOR_DIM = 1536
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _now_iso() -> str:
|
|
28
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OpenSearchClient:
|
|
32
|
+
"""
|
|
33
|
+
Semantic memory client backed by OpenSearch k-NN + Bedrock Titan Embeddings v2.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
host: str,
|
|
39
|
+
port: int = 9200,
|
|
40
|
+
bedrock_region: str = "us-east-1",
|
|
41
|
+
profile: str | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.host = host
|
|
44
|
+
self.port = port
|
|
45
|
+
|
|
46
|
+
self._os = OpenSearch(
|
|
47
|
+
hosts=[{"host": host, "port": port}],
|
|
48
|
+
http_compress=True,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
session_kwargs: dict = {}
|
|
52
|
+
if profile:
|
|
53
|
+
session_kwargs["profile_name"] = profile
|
|
54
|
+
session = boto3.Session(**session_kwargs)
|
|
55
|
+
self._bedrock = session.client("bedrock-runtime", region_name=bedrock_region)
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
# Embedding helper
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def _embed(self, text: str) -> list:
|
|
62
|
+
"""Embed text using Titan Embeddings v2. Returns 1536-dim float list."""
|
|
63
|
+
try:
|
|
64
|
+
response = self._bedrock.invoke_model(
|
|
65
|
+
modelId=TITAN_MODEL,
|
|
66
|
+
body=json.dumps({"inputText": text}),
|
|
67
|
+
contentType="application/json",
|
|
68
|
+
accept="application/json",
|
|
69
|
+
)
|
|
70
|
+
embedding = json.loads(response["body"].read())["embedding"]
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
raise ToolError(f"Bedrock embedding failed: {exc}") from exc
|
|
73
|
+
|
|
74
|
+
if len(embedding) != VECTOR_DIM:
|
|
75
|
+
raise ToolError(
|
|
76
|
+
f"Unexpected embedding dimension {len(embedding)} (expected {VECTOR_DIM})"
|
|
77
|
+
)
|
|
78
|
+
return embedding
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
# Public API
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def index_memory(
|
|
85
|
+
self,
|
|
86
|
+
thread_id: str,
|
|
87
|
+
fact: str,
|
|
88
|
+
metadata: dict | None = None,
|
|
89
|
+
) -> str:
|
|
90
|
+
"""
|
|
91
|
+
Embed fact via Titan v2 and write to agent-memories index.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
thread_id: Thread this fact belongs to (stored for isolation filtering).
|
|
95
|
+
fact: Plain-text fact to embed and store.
|
|
96
|
+
metadata: Optional extra fields stored alongside the fact.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
OpenSearch document ID.
|
|
100
|
+
"""
|
|
101
|
+
embedding = self._embed(fact)
|
|
102
|
+
doc_id = uuid.uuid4().hex
|
|
103
|
+
|
|
104
|
+
doc = {
|
|
105
|
+
"thread_id": thread_id,
|
|
106
|
+
"content": fact,
|
|
107
|
+
"embedding": embedding,
|
|
108
|
+
"created_at": _now_iso(),
|
|
109
|
+
}
|
|
110
|
+
if metadata:
|
|
111
|
+
doc.update(metadata)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
self._os.index(index=INDEX, id=doc_id, body=doc, refresh=True)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
raise ToolError(f"OpenSearch index_memory failed: {exc}") from exc
|
|
117
|
+
|
|
118
|
+
logger.debug("index_memory: doc_id=%s thread=%s", doc_id, thread_id)
|
|
119
|
+
return doc_id
|
|
120
|
+
|
|
121
|
+
def search_memory(
|
|
122
|
+
self,
|
|
123
|
+
thread_id: str,
|
|
124
|
+
query: str,
|
|
125
|
+
k: int = 5,
|
|
126
|
+
type: str | None = None,
|
|
127
|
+
) -> list:
|
|
128
|
+
"""
|
|
129
|
+
k-NN search scoped to thread_id. Never returns other threads' facts.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
thread_id: Only return facts belonging to this thread.
|
|
133
|
+
query: Natural-language query string to embed and search.
|
|
134
|
+
k: Max results to return.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
List of dicts with keys: id, content, thread_id, created_at, score.
|
|
138
|
+
"""
|
|
139
|
+
query_vec = self._embed(query)
|
|
140
|
+
|
|
141
|
+
must = [
|
|
142
|
+
{
|
|
143
|
+
"knn": {
|
|
144
|
+
"embedding": {
|
|
145
|
+
"vector": query_vec,
|
|
146
|
+
"k": k,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
{"term": {"thread_id": thread_id}},
|
|
151
|
+
]
|
|
152
|
+
if type:
|
|
153
|
+
must.append({"term": {"type": type}})
|
|
154
|
+
|
|
155
|
+
os_query = {
|
|
156
|
+
"size": k,
|
|
157
|
+
"query": {
|
|
158
|
+
"bool": {
|
|
159
|
+
"must": must
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
"_source": {"excludes": ["embedding"]},
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
response = self._os.search(index=INDEX, body=os_query)
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
raise ToolError(f"OpenSearch search_memory failed: {exc}") from exc
|
|
169
|
+
|
|
170
|
+
hits = response.get("hits", {}).get("hits", [])
|
|
171
|
+
results = []
|
|
172
|
+
for hit in hits:
|
|
173
|
+
src = hit.get("_source", {})
|
|
174
|
+
# Enforce isolation at result layer too (defence in depth)
|
|
175
|
+
if src.get("thread_id") != thread_id:
|
|
176
|
+
logger.warning(
|
|
177
|
+
"search_memory: skipping result with wrong thread_id '%s' (expected '%s')",
|
|
178
|
+
src.get("thread_id"),
|
|
179
|
+
thread_id,
|
|
180
|
+
)
|
|
181
|
+
continue
|
|
182
|
+
results.append(
|
|
183
|
+
{
|
|
184
|
+
"id": hit["_id"],
|
|
185
|
+
"content": src.get("content", ""),
|
|
186
|
+
"thread_id": src.get("thread_id"),
|
|
187
|
+
"created_at": src.get("created_at"),
|
|
188
|
+
"type": src.get("type"),
|
|
189
|
+
"last_used_at": src.get("last_used_at"),
|
|
190
|
+
"score": hit.get("_score"),
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return results
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
server/storage/s3.py — S3 blob storage client for agent101.
|
|
3
|
+
|
|
4
|
+
Handles content >400KB that cannot fit in a single DynamoDB item.
|
|
5
|
+
All blobs stored at: s3://{bucket}/{user_id}/threads/{thread_id}/{key}
|
|
6
|
+
|
|
7
|
+
Thread isolation: the key path embeds thread_id — cross-thread access
|
|
8
|
+
requires an explicit different thread_id, making accidental bleed impossible.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
import logging
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
import boto3
|
|
15
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _s3_path(user_id: str, thread_id: str, key: str) -> str:
|
|
21
|
+
return f"{user_id}/threads/{thread_id}/{key}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class S3Client:
|
|
25
|
+
"""
|
|
26
|
+
Typed S3 client for agent101 blob storage.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
bucket: str,
|
|
32
|
+
user_id: str = "default",
|
|
33
|
+
profile: str | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.bucket = bucket
|
|
36
|
+
self.user_id = user_id
|
|
37
|
+
|
|
38
|
+
session_kwargs: dict = {}
|
|
39
|
+
if profile:
|
|
40
|
+
session_kwargs["profile_name"] = profile
|
|
41
|
+
|
|
42
|
+
session = boto3.Session(**session_kwargs)
|
|
43
|
+
self._s3 = session.client("s3")
|
|
44
|
+
|
|
45
|
+
def put_blob(self, thread_id: str, key: str, content: str | bytes) -> str:
|
|
46
|
+
"""
|
|
47
|
+
Upload content to S3. Returns the s3:// URI.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
thread_id: Thread this blob belongs to. Embedded in the S3 path.
|
|
51
|
+
key: Blob key within the thread (e.g. "summary", "msg_001").
|
|
52
|
+
content: String or bytes to store.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
s3://{bucket}/{user_id}/threads/{thread_id}/{key}
|
|
56
|
+
"""
|
|
57
|
+
if isinstance(content, str):
|
|
58
|
+
content = content.encode("utf-8")
|
|
59
|
+
|
|
60
|
+
s3_key = _s3_path(self.user_id, thread_id, key)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
self._s3.put_object(
|
|
64
|
+
Bucket=self.bucket,
|
|
65
|
+
Key=s3_key,
|
|
66
|
+
Body=content,
|
|
67
|
+
)
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
raise ToolError(f"S3 put_blob failed for '{key}': {exc}") from exc
|
|
70
|
+
|
|
71
|
+
uri = f"s3://{self.bucket}/{s3_key}"
|
|
72
|
+
logger.debug("put_blob: %s (%d bytes)", uri, len(content))
|
|
73
|
+
return uri
|
|
74
|
+
|
|
75
|
+
def get_blob(self, uri: str) -> bytes:
|
|
76
|
+
"""
|
|
77
|
+
Download a blob by its s3:// URI. Returns raw bytes.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
uri: s3://{bucket}/{key} URI returned by put_blob.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Raw bytes content.
|
|
84
|
+
"""
|
|
85
|
+
parsed = urlparse(uri)
|
|
86
|
+
if parsed.scheme != "s3":
|
|
87
|
+
raise ToolError(f"Invalid S3 URI scheme: '{uri}' (expected s3://...)")
|
|
88
|
+
|
|
89
|
+
bucket = parsed.netloc
|
|
90
|
+
key = parsed.path.lstrip("/")
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
response = self._s3.get_object(Bucket=bucket, Key=key)
|
|
94
|
+
return response["Body"].read()
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
raise ToolError(f"S3 get_blob failed for '{uri}': {exc}") from exc
|
|
File without changes
|