Yash030 commited on
Commit
12b5d50
·
1 Parent(s): 3edc290

fix: Hugging Face sync, routing, dashboard, and agent isolation bugs

Browse files
src/app.py CHANGED
@@ -92,6 +92,8 @@ def create_app() -> Flask:
92
 
93
  # 4. Flask app + blueprints
94
  flask_app = Flask(__name__)
 
 
95
  from routes import register_blueprints
96
  register_blueprints(flask_app)
97
 
 
92
 
93
  # 4. Flask app + blueprints
94
  flask_app = Flask(__name__)
95
+ from werkzeug.middleware.proxy_fix import ProxyFix
96
+ flask_app.wsgi_app = ProxyFix(flask_app.wsgi_app, x_proto=1, x_host=1, x_port=1, x_prefix=1)
97
  from routes import register_blueprints
98
  register_blueprints(flask_app)
99
 
src/functions.py CHANGED
@@ -3494,6 +3494,10 @@ def folder_graph_build(kv: StateKV) -> Dict[str, Any]:
3494
  Edges are deduplicated on (source, target, type).
3495
  """
3496
  index_entries = kv.list(KV.folders)
 
 
 
 
3497
 
3498
  # --- Build folder_map and collect obs text per (folder, agent) pair ---
3499
  # folder_map: folderPath -> {"folderPath", "agentIds": set, "obsCount", "color"}
 
3494
  Edges are deduplicated on (source, target, type).
3495
  """
3496
  index_entries = kv.list(KV.folders)
3497
+ if is_agent_scope_isolated():
3498
+ aid = get_agent_id()
3499
+ if aid:
3500
+ index_entries = [e for e in index_entries if e.get("agentId") == aid]
3501
 
3502
  # --- Build folder_map and collect obs text per (folder, agent) pair ---
3503
  # folder_map: folderPath -> {"folderPath", "agentIds": set, "obsCount", "color"}
src/routes/graph.py CHANGED
@@ -59,55 +59,9 @@ def api_graph_stats():
59
  return auth_err
60
 
61
  kv = _get_kv()
62
- sessions = functions.list_sessions(kv)
63
- memories = kv.list(KV.memories)
64
-
65
- folders = set()
66
- concepts_by_folder = {}
67
-
68
- for s in sessions:
69
- project = s.get("project", "").strip()
70
- if project:
71
- folders.add(project)
72
-
73
- for m in memories:
74
- project = m.get("project", "").strip()
75
- if project:
76
- folders.add(project)
77
- concepts = m.get("concepts", [])
78
- if concepts:
79
- if project not in concepts_by_folder:
80
- concepts_by_folder[project] = set()
81
- for c in concepts:
82
- if isinstance(c, str):
83
- concepts_by_folder[project].add(c.lower())
84
-
85
- node_count = len(folders)
86
- edge_count = 0
87
- folder_list = list(folders)
88
-
89
- for i in range(len(folder_list)):
90
- for j in range(i + 1, len(folder_list)):
91
- f1 = folder_list[i]
92
- f2 = folder_list[j]
93
-
94
- c1 = concepts_by_folder.get(f1, set())
95
- c2 = concepts_by_folder.get(f2, set())
96
- shared = c1.intersection(c2)
97
-
98
- p1 = [p for p in f1.replace("\\", "/").split("/") if p]
99
- p2 = [p for p in f2.replace("\\", "/").split("/") if p]
100
- common_subdirs = 0
101
- for k in range(min(len(p1), len(p2))):
102
- if p1[k].lower() == p2[k].lower():
103
- p_low = p1[k].lower()
104
- if p_low not in ("c:", "d:", "downloads", "projects", "other projects"):
105
- common_subdirs += 1
106
- else:
107
- break
108
-
109
- if len(shared) > 0 or common_subdirs > 0:
110
- edge_count += 1
111
 
112
  return jsonify({"nodes": node_count, "edges": edge_count, "success": True}), 200
113
 
 
59
  return auth_err
60
 
61
  kv = _get_kv()
62
+ g = functions.folder_graph_build(kv)
63
+ node_count = len(g.get("nodes", []))
64
+ edge_count = len(g.get("edges", []))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
  return jsonify({"nodes": node_count, "edges": edge_count, "success": True}), 200
67
 
src/routes/mcp.py CHANGED
@@ -247,6 +247,12 @@ def mcp_tools_call():
247
  limit = int(args.get("limit") or 10)
248
  folder_path = args.get("folderPath")
249
  agent_id = args.get("agentId")
 
 
 
 
 
 
250
  res = functions.folder_search(kv, q, limit, folder_path=folder_path, agent_id=agent_id)
251
  text_out = json.dumps(res, indent=2)
252
 
@@ -269,6 +275,12 @@ def mcp_tools_call():
269
  limit = int(args.get("limit") or 10)
270
  folder_path = args.get("folderPath")
271
  agent_id = args.get("agentId")
 
 
 
 
 
 
272
  res = functions.folder_search(kv, q, limit, folder_path=folder_path, agent_id=agent_id)
273
  text_out = json.dumps(res, indent=2)
274
 
@@ -313,6 +325,13 @@ def mcp_tools_call():
313
  or "agent"
314
  )
315
 
 
 
 
 
 
 
 
316
  if not text:
317
  return jsonify({"error": "text (or content) is required"}), 400
318
 
@@ -338,6 +357,14 @@ def mcp_tools_call():
338
  mem_type = args.get("type") or "fact"
339
  concepts = _parse_mcp_list_arg(args.get("concepts"))
340
  files = _parse_mcp_list_arg(args.get("files"))
 
 
 
 
 
 
 
 
341
  if not content:
342
  return jsonify({"error": "content is required"}), 400
343
  payload = {
@@ -357,6 +384,10 @@ def mcp_tools_call():
357
  key=lambda x: x.get("lastUpdated", ""),
358
  reverse=True,
359
  )
 
 
 
 
360
  text_out = json.dumps(pairs, indent=2)
361
 
362
  elif name == "memory_folder_observations":
@@ -364,6 +395,10 @@ def mcp_tools_call():
364
  aid = args.get("agentId", "")
365
  if not fp or not aid:
366
  return jsonify({"error": "folderPath and agentId are required"}), 400
 
 
 
 
367
  obs = sorted(
368
  kv.list(KV.folder_obs(fp, aid)),
369
  key=lambda x: x.get("timestamp", ""),
@@ -372,11 +407,18 @@ def mcp_tools_call():
372
  text_out = json.dumps(obs, indent=2)
373
 
374
  elif name == "memory_timeline":
 
 
 
 
 
 
 
375
  res = functions.folder_timeline(
376
  kv,
377
  limit=int(args.get("limit", 100)),
378
  folder_path=args.get("folderPath"),
379
- agent_id=args.get("agentId"),
380
  before=args.get("before"),
381
  after=args.get("after"),
382
  )
 
247
  limit = int(args.get("limit") or 10)
248
  folder_path = args.get("folderPath")
249
  agent_id = args.get("agentId")
250
+ if functions.is_agent_scope_isolated():
251
+ current_aid = functions.get_agent_id()
252
+ if current_aid:
253
+ if agent_id and agent_id != current_aid:
254
+ return jsonify({"error": "Unauthorized: Agent scope is isolated"}), 403
255
+ agent_id = current_aid
256
  res = functions.folder_search(kv, q, limit, folder_path=folder_path, agent_id=agent_id)
257
  text_out = json.dumps(res, indent=2)
258
 
 
275
  limit = int(args.get("limit") or 10)
276
  folder_path = args.get("folderPath")
277
  agent_id = args.get("agentId")
278
+ if functions.is_agent_scope_isolated():
279
+ current_aid = functions.get_agent_id()
280
+ if current_aid:
281
+ if agent_id and agent_id != current_aid:
282
+ return jsonify({"error": "Unauthorized: Agent scope is isolated"}), 403
283
+ agent_id = current_aid
284
  res = functions.folder_search(kv, q, limit, folder_path=folder_path, agent_id=agent_id)
285
  text_out = json.dumps(res, indent=2)
286
 
 
325
  or "agent"
326
  )
327
 
328
+ if functions.is_agent_scope_isolated():
329
+ current_aid = functions.get_agent_id()
330
+ if current_aid:
331
+ if agent_id and agent_id != current_aid:
332
+ return jsonify({"error": "Unauthorized: Agent scope is isolated"}), 403
333
+ agent_id = current_aid
334
+
335
  if not text:
336
  return jsonify({"error": "text (or content) is required"}), 400
337
 
 
357
  mem_type = args.get("type") or "fact"
358
  concepts = _parse_mcp_list_arg(args.get("concepts"))
359
  files = _parse_mcp_list_arg(args.get("files"))
360
+
361
+ if functions.is_agent_scope_isolated():
362
+ current_aid = functions.get_agent_id()
363
+ if current_aid:
364
+ if agent_id and agent_id != current_aid:
365
+ return jsonify({"error": "Unauthorized: Agent scope is isolated"}), 403
366
+ agent_id = current_aid
367
+
368
  if not content:
369
  return jsonify({"error": "content is required"}), 400
370
  payload = {
 
384
  key=lambda x: x.get("lastUpdated", ""),
385
  reverse=True,
386
  )
387
+ if functions.is_agent_scope_isolated():
388
+ aid = functions.get_agent_id()
389
+ if aid:
390
+ pairs = [p for p in pairs if p.get("agentId") == aid]
391
  text_out = json.dumps(pairs, indent=2)
392
 
393
  elif name == "memory_folder_observations":
 
395
  aid = args.get("agentId", "")
396
  if not fp or not aid:
397
  return jsonify({"error": "folderPath and agentId are required"}), 400
398
+ if functions.is_agent_scope_isolated():
399
+ current_aid = functions.get_agent_id()
400
+ if current_aid and aid != current_aid:
401
+ return jsonify({"error": "Unauthorized: Agent scope is isolated"}), 403
402
  obs = sorted(
403
  kv.list(KV.folder_obs(fp, aid)),
404
  key=lambda x: x.get("timestamp", ""),
 
407
  text_out = json.dumps(obs, indent=2)
408
 
409
  elif name == "memory_timeline":
410
+ request_aid = args.get("agentId")
411
+ if functions.is_agent_scope_isolated():
412
+ current_aid = functions.get_agent_id()
413
+ if current_aid:
414
+ if request_aid and request_aid != current_aid:
415
+ return jsonify({"error": "Unauthorized: Agent scope is isolated"}), 403
416
+ request_aid = current_aid
417
  res = functions.folder_timeline(
418
  kv,
419
  limit=int(args.get("limit", 100)),
420
  folder_path=args.get("folderPath"),
421
+ agent_id=request_aid,
422
  before=args.get("before"),
423
  after=args.get("after"),
424
  )
src/routes/observations.py CHANGED
@@ -57,7 +57,8 @@ def api_observe():
57
  body = request.get_json(force=True) or {}
58
 
59
  # Compat shim: accept both folder-based and legacy session-based payloads.
60
- # Old clients send sessionId/project/cwd; new clients send folderPath/agentId.
 
61
  folder_path = (
62
  body.get("folderPath")
63
  or body.get("cwd")
@@ -72,7 +73,22 @@ def api_observe():
72
  or os.getenv("AGENT_ID")
73
  or "agent"
74
  )
 
 
75
  text = body.get("text") or body.get("content") or ""
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
  payload = {
78
  "folderPath": folder_path,
@@ -89,9 +105,9 @@ def api_observe():
89
  return jsonify(res), 201
90
  except Exception as e:
91
  import traceback
92
- print(f"[observe] 400 — keys={list(body.keys())} {type(e).__name__}: {e}")
93
- print(traceback.format_exc())
94
- return jsonify({"error": str(e), "detail": type(e).__name__}), 400
95
 
96
 
97
  # ---------------------------------------------------------------------------
@@ -155,6 +171,10 @@ def api_folders():
155
  key=lambda x: x.get("lastUpdated", ""),
156
  reverse=True,
157
  )
 
 
 
 
158
  return jsonify({"folders": folders}), 200
159
 
160
 
@@ -171,6 +191,10 @@ def api_folder_observations():
171
  aid = request.args.get("agentId")
172
  if not fp or not aid:
173
  return jsonify({"error": "folderPath and agentId are required"}), 400
 
 
 
 
174
  observations = sorted(
175
  _get_kv().list(KV.folder_obs(fp, aid)),
176
  key=lambda x: x.get("timestamp", ""),
 
57
  body = request.get_json(force=True) or {}
58
 
59
  # Compat shim: accept both folder-based and legacy session-based payloads.
60
+ # Old clients send sessionId/project/cwd + data{tool_input/output}
61
+ # New clients send folderPath/agentId/text
62
  folder_path = (
63
  body.get("folderPath")
64
  or body.get("cwd")
 
73
  or os.getenv("AGENT_ID")
74
  or "agent"
75
  )
76
+
77
+ # Build text from whichever field the client used
78
  text = body.get("text") or body.get("content") or ""
79
+ if not text:
80
+ # Legacy clients put content in a nested 'data' dict
81
+ data = body.get("data")
82
+ if isinstance(data, dict):
83
+ parts = [str(v) for k, v in data.items()
84
+ if v and k in ("tool_input", "tool_output", "prompt",
85
+ "response", "tool_name", "content")]
86
+ text = " | ".join(parts) if parts else str(data)
87
+ elif isinstance(data, str):
88
+ text = data
89
+ if not text:
90
+ # Last resort: use hookType as a minimal marker so we don't 400
91
+ text = body.get("hookType") or "observation"
92
 
93
  payload = {
94
  "folderPath": folder_path,
 
105
  return jsonify(res), 201
106
  except Exception as e:
107
  import traceback
108
+ tb = traceback.format_exc()
109
+ print(f"[observe] 400 — keys={list(body.keys())} {type(e).__name__}: {e}\n{tb}")
110
+ return jsonify({"error": str(e), "detail": type(e).__name__, "keys": list(body.keys()), "tb": tb}), 400
111
 
112
 
113
  # ---------------------------------------------------------------------------
 
171
  key=lambda x: x.get("lastUpdated", ""),
172
  reverse=True,
173
  )
174
+ if functions.is_agent_scope_isolated():
175
+ aid = functions.get_agent_id()
176
+ if aid:
177
+ folders = [f for f in folders if f.get("agentId") == aid]
178
  return jsonify({"folders": folders}), 200
179
 
180
 
 
191
  aid = request.args.get("agentId")
192
  if not fp or not aid:
193
  return jsonify({"error": "folderPath and agentId are required"}), 400
194
+ if functions.is_agent_scope_isolated():
195
+ current_aid = functions.get_agent_id()
196
+ if current_aid and aid != current_aid:
197
+ return jsonify({"error": "Unauthorized: Agent scope is isolated to another agent"}), 403
198
  observations = sorted(
199
  _get_kv().list(KV.folder_obs(fp, aid)),
200
  key=lambda x: x.get("timestamp", ""),
start.sh CHANGED
@@ -60,3 +60,7 @@ export III_REST_PORT=7860
60
  # Start Flask application in the foreground
61
  echo "[start] Starting Flask application on port 7860..."
62
  python3 src/app.py
 
 
 
 
 
60
  # Start Flask application in the foreground
61
  echo "[start] Starting Flask application on port 7860..."
62
  python3 src/app.py
63
+
64
+ # Perform a final backup on shutdown
65
+ echo "[start] Flask application exited. Performing final backup..."
66
+ python3 /app/sync.py backup
sync.py CHANGED
@@ -156,11 +156,28 @@ def _list_repo_prefix(api, prefix):
156
  return []
157
 
158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  def backup():
160
  if not HF_TOKEN:
161
  return
162
  api = get_api()
163
 
 
 
 
164
  targets = _collect_sync_targets()
165
  if not targets:
166
  print("[sync] nothing to backup")
@@ -225,7 +242,6 @@ def backup():
225
  finally:
226
  shutil.rmtree(staging, ignore_errors=True)
227
 
228
-
229
  if __name__ == "__main__":
230
  cmd = sys.argv[1] if len(sys.argv) > 1 else "backup"
231
  if cmd == "restore":
@@ -235,9 +251,3 @@ if __name__ == "__main__":
235
  else:
236
  print(f"[sync] unknown command: {cmd}")
237
  sys.exit(1)
238
-
239
-
240
- HF_TOKEN = os.environ.get("HF_TOKEN", "")
241
- REPO_ID = os.environ.get("AGENTMEMORY_DATASET_REPO", "Yash030/agentmemory-python-data")
242
- DATA_DIR = os.path.expanduser("~/.agentmemory")
243
- DB_PATH = os.path.join(DATA_DIR, "agentmemory.db")
 
156
  return []
157
 
158
 
159
+ def _checkpoint_db():
160
+ """Checkpoint the SQLite WAL file before backing up to ensure all data is in the main DB file."""
161
+ try:
162
+ if os.path.exists(DB_PATH):
163
+ conn = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=10)
164
+ try:
165
+ conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
166
+ print("[sync] DB checkpoint complete (WAL merged)")
167
+ finally:
168
+ conn.close()
169
+ except Exception as e:
170
+ print(f"[sync] DB checkpoint failed: {e}")
171
+
172
+
173
  def backup():
174
  if not HF_TOKEN:
175
  return
176
  api = get_api()
177
 
178
+ # Checkpoint WAL changes to main DB file before backup
179
+ _checkpoint_db()
180
+
181
  targets = _collect_sync_targets()
182
  if not targets:
183
  print("[sync] nothing to backup")
 
242
  finally:
243
  shutil.rmtree(staging, ignore_errors=True)
244
 
 
245
  if __name__ == "__main__":
246
  cmd = sys.argv[1] if len(sys.argv) > 1 else "backup"
247
  if cmd == "restore":
 
251
  else:
252
  print(f"[sync] unknown command: {cmd}")
253
  sys.exit(1)
 
 
 
 
 
 
tests/test_properties.py CHANGED
@@ -76,7 +76,7 @@ def _safe_text():
76
  # Two distinct (folderPath, agentId) pairs never share observations.
77
  # ---------------------------------------------------------------------------
78
 
79
- @settings(max_examples=50)
80
  @given(
81
  path1=_safe_path(),
82
  agent1=_safe_agent(),
@@ -106,7 +106,7 @@ def test_property_1_pair_isolation(path1, agent1, path2, agent2, text):
106
  # meta.obsCount == len(kv.list(folder_obs_scope))
107
  # ---------------------------------------------------------------------------
108
 
109
- @settings(max_examples=50)
110
  @given(
111
  path=_safe_path(),
112
  agent=_safe_agent(),
@@ -132,7 +132,7 @@ def test_property_2_obs_count_consistency(path, agent, texts):
132
  # Every written pair has a KV.folders entry.
133
  # ---------------------------------------------------------------------------
134
 
135
- @settings(max_examples=50)
136
  @given(
137
  path=_safe_path(),
138
  agent=_safe_agent(),
@@ -160,7 +160,7 @@ def test_property_3_index_coverage(path, agent, text):
160
  # No stored obs text contains raw secrets after folder_observe().
161
  # ---------------------------------------------------------------------------
162
 
163
- @settings(max_examples=30)
164
  @given(
165
  path=_safe_path(),
166
  agent=_safe_agent(),
@@ -190,7 +190,7 @@ def test_property_4_privacy_invariant(path, agent, prefix):
190
  # folder_timeline() always returns results sorted newest-first.
191
  # ---------------------------------------------------------------------------
192
 
193
- @settings(max_examples=40)
194
  @given(
195
  path=_safe_path(),
196
  agent=_safe_agent(),
@@ -220,7 +220,7 @@ def test_property_5_timeline_ordering(path, agent, n):
220
  # After forget({folderPath, agentId}), all three scopes are empty.
221
  # ---------------------------------------------------------------------------
222
 
223
- @settings(max_examples=40)
224
  @given(
225
  path=_safe_path(),
226
  agent=_safe_agent(),
@@ -251,7 +251,7 @@ def test_property_6_forget_completeness(path, agent, texts):
251
  # Superseded memories have parentId; at least one memory is always latest.
252
  # ---------------------------------------------------------------------------
253
 
254
- @settings(max_examples=30, suppress_health_check=[HealthCheck.filter_too_much])
255
  @given(
256
  base_content=st.text(
257
  alphabet="abcdefghijklmnopqrstuvwxyz ",
@@ -292,7 +292,7 @@ def test_property_7_memory_version_uniqueness(base_content, n_variants):
292
  # normalize(normalize(p)) == normalize(p) for all valid inputs.
293
  # ---------------------------------------------------------------------------
294
 
295
- @settings(max_examples=100)
296
  @given(
297
  path=st.text(
298
  alphabet="abcdefghijklmnopqrstuvwxyz0123456789/_-.",
 
76
  # Two distinct (folderPath, agentId) pairs never share observations.
77
  # ---------------------------------------------------------------------------
78
 
79
+ @settings(max_examples=50, deadline=None)
80
  @given(
81
  path1=_safe_path(),
82
  agent1=_safe_agent(),
 
106
  # meta.obsCount == len(kv.list(folder_obs_scope))
107
  # ---------------------------------------------------------------------------
108
 
109
+ @settings(max_examples=50, deadline=None)
110
  @given(
111
  path=_safe_path(),
112
  agent=_safe_agent(),
 
132
  # Every written pair has a KV.folders entry.
133
  # ---------------------------------------------------------------------------
134
 
135
+ @settings(max_examples=50, deadline=None)
136
  @given(
137
  path=_safe_path(),
138
  agent=_safe_agent(),
 
160
  # No stored obs text contains raw secrets after folder_observe().
161
  # ---------------------------------------------------------------------------
162
 
163
+ @settings(max_examples=30, deadline=None)
164
  @given(
165
  path=_safe_path(),
166
  agent=_safe_agent(),
 
190
  # folder_timeline() always returns results sorted newest-first.
191
  # ---------------------------------------------------------------------------
192
 
193
+ @settings(max_examples=40, deadline=None)
194
  @given(
195
  path=_safe_path(),
196
  agent=_safe_agent(),
 
220
  # After forget({folderPath, agentId}), all three scopes are empty.
221
  # ---------------------------------------------------------------------------
222
 
223
+ @settings(max_examples=40, deadline=None)
224
  @given(
225
  path=_safe_path(),
226
  agent=_safe_agent(),
 
251
  # Superseded memories have parentId; at least one memory is always latest.
252
  # ---------------------------------------------------------------------------
253
 
254
+ @settings(max_examples=30, suppress_health_check=[HealthCheck.filter_too_much], deadline=None)
255
  @given(
256
  base_content=st.text(
257
  alphabet="abcdefghijklmnopqrstuvwxyz ",
 
292
  # normalize(normalize(p)) == normalize(p) for all valid inputs.
293
  # ---------------------------------------------------------------------------
294
 
295
+ @settings(max_examples=100, deadline=None)
296
  @given(
297
  path=st.text(
298
  alphabet="abcdefghijklmnopqrstuvwxyz0123456789/_-.",