amfs-http-server 0.3.41__tar.gz → 0.3.43__tar.gz

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.
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: amfs-http-server
3
- Version: 0.3.41
3
+ Version: 0.3.43
4
4
  Summary: AMFS HTTP/REST API server with SSE support
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.11
7
7
  Requires-Dist: amfs-core>=0.3.37
8
- Requires-Dist: amfs>=0.5.27
8
+ Requires-Dist: amfs>=0.5.28
9
9
  Requires-Dist: fastapi>=0.115
10
10
  Requires-Dist: httpx>=0.27
11
11
  Requires-Dist: sse-starlette>=2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "amfs-http-server"
3
- version = "0.3.41"
3
+ version = "0.3.43"
4
4
  description = "AMFS HTTP/REST API server with SSE support"
5
5
  requires-python = ">=3.11"
6
6
  license = "Apache-2.0"
@@ -13,7 +13,7 @@ dependencies = [
13
13
  # this tree is what test_dependency_floors forbids.
14
14
  # 0.5.18 for AgentMemory.as_agent, which the agent detail page calls to list
15
15
  # an agent's entries as that agent rather than as the server.
16
- "amfs>=0.5.27",
16
+ "amfs>=0.5.28",
17
17
  # 0.3.23 is real in the silent way: the write path calls
18
18
  # adapter.scope_counts(), which is a default on AdapterABC from 0.3.23. On an
19
19
  # older core the adapters that do not override it — filesystem, S3 — have no
@@ -1610,7 +1610,7 @@ async def health_v1() -> dict[str, str]:
1610
1610
 
1611
1611
 
1612
1612
  @app.get("/api/v1/auth/whoami")
1613
- async def whoami(
1613
+ def whoami(
1614
1614
  request: Request,
1615
1615
  _auth: str | None = Depends(verify_api_key),
1616
1616
  ) -> dict[str, Any]:
@@ -2304,7 +2304,7 @@ async def list_entity_summaries(
2304
2304
 
2305
2305
 
2306
2306
  @app.post("/api/v1/aggregate")
2307
- async def aggregate_entries_endpoint(
2307
+ def aggregate_entries_endpoint(
2308
2308
  request: Request,
2309
2309
  req: AggregateRequest,
2310
2310
  _auth: str | None = Depends(verify_api_key),
@@ -3545,7 +3545,7 @@ async def get_stats(
3545
3545
 
3546
3546
 
3547
3547
  @app.post("/api/v1/verify")
3548
- async def verify_integrity(
3548
+ def verify_integrity(
3549
3549
  request: Request,
3550
3550
  body: dict[str, Any] = {},
3551
3551
  _auth: str | None = Depends(verify_api_key),
@@ -3576,7 +3576,7 @@ _COMMIT_WRITE_OPTIONS = ("confidence", "memory_type", "pattern_refs", "shared")
3576
3576
 
3577
3577
 
3578
3578
  @app.post("/api/v1/commits")
3579
- async def create_commit(
3579
+ def create_commit(
3580
3580
  body: dict[str, Any],
3581
3581
  request: Request,
3582
3582
  _auth: str | None = Depends(verify_api_key),
@@ -3640,56 +3640,52 @@ async def create_commit(
3640
3640
  # from the moment this deploys. The body wins when both are present.
3641
3641
  agent_id = body.get("agent_id") or request.headers.get("x-amfs-agent-id")
3642
3642
  session_id = body.get("session_id")
3643
- original_agent = mem._tagger.agent_id if agent_id else None
3644
- original_session = mem._tagger.session_id if session_id else None
3645
3643
 
3646
- try:
3647
- if agent_id:
3648
- mem._tagger.agent_id = agent_id
3649
- try:
3650
- # The gateway ensures its agent when the session opens, so this
3651
- # is normally a no-op. It is here for a client committing as an
3652
- # agent this server has not seen, where the entry insert would
3653
- # otherwise fail on a name nothing has registered.
3654
- mem._adapter.ensure_agent(agent_id, mem.namespace)
3655
- except Exception: # noqa: BLE001
3656
- logger.warning(
3657
- "ensure_agent failed for %s — committing anyway", agent_id,
3658
- exc_info=True,
3659
- )
3660
- _link_agent_owner_once(request, agent_id, mem.namespace)
3644
+ # A per-request handle carries the caller's identity (``AgentMemory.as_agent``).
3645
+ # Swapping the shared tagger and restoring it in a ``finally`` was atomic
3646
+ # only while this body ran on the event loop; on the threadpool two commits
3647
+ # would stamp each other's writes.
3648
+ handle = mem
3649
+ if agent_id or session_id:
3650
+ handle = mem.as_agent(agent_id or mem.agent_id)
3661
3651
  if session_id:
3662
- mem._tagger.session_id = session_id
3663
-
3664
- with mem.transaction(message) as tx:
3665
- for w in writes:
3666
- options = {k: w[k] for k in _COMMIT_WRITE_OPTIONS if k in w}
3667
- # Arrives over the wire as a string, and the write path wants
3668
- # the enum. An unknown one is refused rather than dropped:
3669
- # writing the entry with a default type would put the wrong
3670
- # metadata on the right memory, and nothing later can tell that
3671
- # happened.
3672
- if "memory_type" in options:
3673
- raw = str(options["memory_type"])
3674
- try:
3675
- options["memory_type"] = MemoryType(raw)
3676
- except ValueError as exc:
3677
- raise HTTPException(
3678
- status_code=422,
3679
- detail=(
3680
- f"Invalid memory_type {raw!r}. Valid: "
3681
- + ", ".join(m.value for m in MemoryType)
3682
- ),
3683
- ) from exc
3684
- tx.write(w["entity_path"], w["key"], w.get("value"), **options)
3685
- finally:
3686
- # Restored even when the transaction raised. This memory is a process
3687
- # singleton, so a tagger left holding one caller's name would sign the
3688
- # next caller's writes with it.
3689
- if original_agent is not None:
3690
- mem._tagger.agent_id = original_agent
3691
- if original_session is not None:
3692
- mem._tagger.session_id = original_session
3652
+ handle._tagger.session_id = session_id
3653
+
3654
+ if agent_id:
3655
+ try:
3656
+ # The gateway ensures its agent when the session opens, so this
3657
+ # is normally a no-op. It is here for a client committing as an
3658
+ # agent this server has not seen, where the entry insert would
3659
+ # otherwise fail on a name nothing has registered.
3660
+ mem._adapter.ensure_agent(agent_id, mem.namespace)
3661
+ except Exception: # noqa: BLE001
3662
+ logger.warning(
3663
+ "ensure_agent failed for %s — committing anyway", agent_id,
3664
+ exc_info=True,
3665
+ )
3666
+ _link_agent_owner_once(request, agent_id, mem.namespace)
3667
+
3668
+ with handle.transaction(message) as tx:
3669
+ for w in writes:
3670
+ options = {k: w[k] for k in _COMMIT_WRITE_OPTIONS if k in w}
3671
+ # Arrives over the wire as a string, and the write path wants
3672
+ # the enum. An unknown one is refused rather than dropped:
3673
+ # writing the entry with a default type would put the wrong
3674
+ # metadata on the right memory, and nothing later can tell that
3675
+ # happened.
3676
+ if "memory_type" in options:
3677
+ raw = str(options["memory_type"])
3678
+ try:
3679
+ options["memory_type"] = MemoryType(raw)
3680
+ except ValueError as exc:
3681
+ raise HTTPException(
3682
+ status_code=422,
3683
+ detail=(
3684
+ f"Invalid memory_type {raw!r}. Valid: "
3685
+ + ", ".join(m.value for m in MemoryType)
3686
+ ),
3687
+ ) from exc
3688
+ tx.write(w["entity_path"], w["key"], w.get("value"), **options)
3693
3689
 
3694
3690
  commit = tx.commit
3695
3691
  return {
@@ -3704,7 +3700,7 @@ async def create_commit(
3704
3700
 
3705
3701
 
3706
3702
  @app.get("/api/v1/commits")
3707
- async def list_commits(
3703
+ def list_commits(
3708
3704
  request: Request,
3709
3705
  limit: int = Query(50, ge=1, le=500),
3710
3706
  branch: str | None = Query(None),
@@ -3752,7 +3748,7 @@ async def list_commits(
3752
3748
 
3753
3749
 
3754
3750
  @app.get("/api/v1/commits/{commit_id}")
3755
- async def get_commit(
3751
+ def get_commit(
3756
3752
  request: Request,
3757
3753
  commit_id: str,
3758
3754
  _auth: str | None = Depends(verify_api_key),
@@ -3769,7 +3765,7 @@ async def get_commit(
3769
3765
 
3770
3766
 
3771
3767
  @app.post("/api/v1/merge-base")
3772
- async def merge_base(
3768
+ def merge_base(
3773
3769
  body: dict[str, Any],
3774
3770
  _auth: str | None = Depends(verify_api_key),
3775
3771
  ) -> dict[str, Any]:
@@ -3939,7 +3935,7 @@ def _infer_tags(
3939
3935
 
3940
3936
 
3941
3937
  @app.put("/api/v1/agents/{agent_id:path}/profile")
3942
- async def update_agent_profile(
3938
+ def update_agent_profile(
3943
3939
  request: Request,
3944
3940
  agent_id: str,
3945
3941
  body: dict[str, Any],
@@ -3960,7 +3956,7 @@ async def update_agent_profile(
3960
3956
 
3961
3957
 
3962
3958
  @app.put("/api/v1/agents/{agent_id:path}/capabilities")
3963
- async def update_agent_capabilities(
3959
+ def update_agent_capabilities(
3964
3960
  request: Request,
3965
3961
  agent_id: str,
3966
3962
  body: dict[str, Any],
@@ -3976,7 +3972,7 @@ async def update_agent_capabilities(
3976
3972
 
3977
3973
 
3978
3974
  @app.put("/api/v1/agents/{agent_id:path}/contracts")
3979
- async def update_agent_contracts(
3975
+ def update_agent_contracts(
3980
3976
  request: Request,
3981
3977
  agent_id: str,
3982
3978
  body: dict[str, Any],
@@ -3992,7 +3988,7 @@ async def update_agent_contracts(
3992
3988
 
3993
3989
 
3994
3990
  @app.get("/api/v1/agents/discover")
3995
- async def discover_agents(
3991
+ def discover_agents(
3996
3992
  request: Request,
3997
3993
  capability: str | None = Query(None),
3998
3994
  entity_path: str | None = Query(None),
@@ -4027,7 +4023,7 @@ def _require_entry_visible(request: Request, entity_path: str, key: str) -> None
4027
4023
 
4028
4024
 
4029
4025
  @app.post("/api/v1/diff")
4030
- async def compute_diff(
4026
+ def compute_diff(
4031
4027
  request: Request,
4032
4028
  body: dict[str, Any],
4033
4029
  _auth: str | None = Depends(verify_api_key),
@@ -4042,7 +4038,7 @@ async def compute_diff(
4042
4038
 
4043
4039
 
4044
4040
  @app.post("/api/v1/patches")
4045
- async def create_patch(
4041
+ def create_patch(
4046
4042
  request: Request,
4047
4043
  body: dict[str, Any],
4048
4044
  _auth: str | None = Depends(verify_api_key),
@@ -4787,7 +4783,7 @@ async def commit_outcome(
4787
4783
 
4788
4784
 
4789
4785
  @app.get("/api/v1/outcomes")
4790
- async def list_outcomes(
4786
+ def list_outcomes(
4791
4787
  request: Request,
4792
4788
  entity_path: str | None = Query(None),
4793
4789
  since: str | None = Query(None),
@@ -4815,7 +4811,7 @@ async def list_outcomes(
4815
4811
 
4816
4812
 
4817
4813
  @app.post("/api/v1/context")
4818
- async def record_context(
4814
+ def record_context(
4819
4815
  req: ContextRequest,
4820
4816
  _auth: str | None = Depends(verify_api_key),
4821
4817
  ) -> dict[str, Any]:
@@ -4825,7 +4821,7 @@ async def record_context(
4825
4821
 
4826
4822
 
4827
4823
  @app.get("/api/v1/explain")
4828
- async def explain(
4824
+ def explain(
4829
4825
  request: Request,
4830
4826
  outcome_ref: str | None = Query(None),
4831
4827
  _auth: str | None = Depends(verify_api_key),
@@ -4997,7 +4993,7 @@ async def save_trace(
4997
4993
  # NOTE: must be registered before /api/v1/traces/{trace_id} so "share-stats"
4998
4994
  # isn't captured as a trace_id.
4999
4995
  @app.get("/api/v1/traces/share-stats")
5000
- async def get_share_stats(
4996
+ def get_share_stats(
5001
4997
  request: Request,
5002
4998
  since: datetime | None = Query(None),
5003
4999
  pair_limit: int = Query(20, ge=1, le=100),
@@ -5034,7 +5030,7 @@ async def get_trace(
5034
5030
 
5035
5031
 
5036
5032
  @app.post("/api/v1/traces/{trace_id}/explain")
5037
- async def explain_trace(
5033
+ def explain_trace(
5038
5034
  request: Request,
5039
5035
  trace_id: str,
5040
5036
  _auth: str | None = Depends(verify_api_key),
@@ -5442,7 +5438,7 @@ async def list_agents(
5442
5438
 
5443
5439
 
5444
5440
  @app.get("/api/v1/reuse")
5445
- async def reuse_summary(
5441
+ def reuse_summary(
5446
5442
  request: Request,
5447
5443
  days: int = 7,
5448
5444
  limit: int = 10,
@@ -5519,7 +5515,7 @@ async def reuse_summary(
5519
5515
 
5520
5516
 
5521
5517
  @app.get("/api/v1/agents/{agent_id:path}/memory-graph")
5522
- async def agent_memory_graph(
5518
+ def agent_memory_graph(
5523
5519
  request: Request,
5524
5520
  agent_id: str,
5525
5521
  _auth: str | None = Depends(verify_api_key),
@@ -5690,7 +5686,7 @@ async def agent_cross_reads(
5690
5686
 
5691
5687
 
5692
5688
  @app.get("/api/v1/agents/{agent_id:path}/recall/{entity_path:path}/{key}")
5693
- async def agent_recall(
5689
+ def agent_recall(
5694
5690
  request: Request,
5695
5691
  agent_id: str,
5696
5692
  entity_path: str,
@@ -5706,13 +5702,10 @@ async def agent_recall(
5706
5702
  if vis is not None and vis.should_filter() and not vis.is_agent_visible(agent_id):
5707
5703
  raise HTTPException(status_code=404, detail="Agent not found")
5708
5704
 
5709
- mem = _get_memory()
5710
- original_agent = mem._tagger.agent_id
5711
- mem._tagger.agent_id = agent_id
5712
- try:
5713
- entry = mem.recall(entity_path, key)
5714
- finally:
5715
- mem._tagger.agent_id = original_agent
5705
+ # Acting as the agent through a per-request handle, not by swapping the
5706
+ # shared tagger: these routes run on the threadpool now, and a swap there
5707
+ # is a race (``AgentMemory.as_agent``).
5708
+ entry = _get_memory().as_agent(agent_id).recall(entity_path, key)
5716
5709
  if entry is None:
5717
5710
  return {"status": "not_found", "agentId": agent_id,
5718
5711
  "entityPath": entity_path, "key": key}
@@ -5720,7 +5713,7 @@ async def agent_recall(
5720
5713
 
5721
5714
 
5722
5715
  @app.get("/api/v1/agents/{agent_id:path}/entries")
5723
- async def agent_entries(
5716
+ def agent_entries(
5724
5717
  request: Request,
5725
5718
  agent_id: str,
5726
5719
  entity_path: str | None = Query(None),
@@ -5741,7 +5734,7 @@ async def agent_entries(
5741
5734
 
5742
5735
 
5743
5736
  @app.get("/api/v1/agents/{agent_id:path}/read-from/{source_agent_id}/{entity_path:path}/{key}")
5744
- async def agent_read_from(
5737
+ def agent_read_from(
5745
5738
  request: Request,
5746
5739
  agent_id: str,
5747
5740
  source_agent_id: str,
@@ -5764,15 +5757,8 @@ async def agent_read_from(
5764
5757
  # from another — and that edge is what the authority ranking is built on,
5765
5758
  # so a cross-agent read over HTTP never counted for anything.
5766
5759
  #
5767
- # Swapping the tagger is how the rest of this file attributes work to the
5768
- # caller (see agent_recall above): the memory is a process singleton, so a
5769
- # tagger left holding one caller's name would sign the next caller's reads.
5770
- original_agent = mem._tagger.agent_id
5771
- mem._tagger.agent_id = agent_id
5772
- try:
5773
- entry = mem.read_from(source_agent_id, entity_path, key)
5774
- finally:
5775
- mem._tagger.agent_id = original_agent
5760
+ # A per-request handle attributes the read to the caller (see agent_recall).
5761
+ entry = mem.as_agent(agent_id).read_from(source_agent_id, entity_path, key)
5776
5762
 
5777
5763
  if entry is None:
5778
5764
  return {"status": "not_found", "sourceAgentId": source_agent_id,
@@ -5973,7 +5959,7 @@ class LogEventRequest(BaseModel):
5973
5959
 
5974
5960
 
5975
5961
  @app.post("/api/v1/timeline/events")
5976
- async def log_timeline_event(
5962
+ def log_timeline_event(
5977
5963
  request: Request,
5978
5964
  body: LogEventRequest,
5979
5965
  _auth: str | None = Depends(verify_api_key),
@@ -6024,7 +6010,7 @@ class UpsertGraphEdgeRequest(BaseModel):
6024
6010
 
6025
6011
 
6026
6012
  @app.post("/api/v1/graph/edges")
6027
- async def upsert_graph_edge_endpoint(
6013
+ def upsert_graph_edge_endpoint(
6028
6014
  body: UpsertGraphEdgeRequest,
6029
6015
  _auth: str | None = Depends(verify_api_key),
6030
6016
  ) -> dict[str, Any]:
@@ -6048,7 +6034,7 @@ async def upsert_graph_edge_endpoint(
6048
6034
 
6049
6035
 
6050
6036
  @app.get("/api/v1/agents/enriched")
6051
- async def list_agents_enriched(
6037
+ def list_agents_enriched(
6052
6038
  request: Request,
6053
6039
  histograms: bool = True,
6054
6040
  _auth: str | None = Depends(verify_api_key),
@@ -6078,7 +6064,7 @@ async def list_agents_enriched(
6078
6064
 
6079
6065
 
6080
6066
  @app.get("/api/v1/agent-groups")
6081
- async def list_agent_groups(
6067
+ def list_agent_groups(
6082
6068
  request: Request,
6083
6069
  _auth: str | None = Depends(verify_api_key),
6084
6070
  ) -> dict[str, Any]:
@@ -6151,7 +6137,7 @@ async def update_agent_group_endpoint(
6151
6137
 
6152
6138
 
6153
6139
  @app.delete("/api/v1/agent-groups/{group_id}")
6154
- async def delete_agent_group_endpoint(
6140
+ def delete_agent_group_endpoint(
6155
6141
  group_id: str,
6156
6142
  _auth: str | None = Depends(verify_api_key),
6157
6143
  ) -> dict[str, Any]:
@@ -6226,7 +6212,7 @@ async def reorder_agent_groups_endpoint(
6226
6212
 
6227
6213
 
6228
6214
  @app.get("/api/v1/agent-groups/suggestions")
6229
- async def agent_group_suggestions(
6215
+ def agent_group_suggestions(
6230
6216
  request: Request,
6231
6217
  _auth: str | None = Depends(verify_api_key),
6232
6218
  ) -> dict[str, Any]:
@@ -6263,7 +6249,7 @@ async def agent_group_suggestions(
6263
6249
 
6264
6250
 
6265
6251
  @app.post("/api/v1/agent-groups/suggestions/{cluster_id}/accept", status_code=201)
6266
- async def accept_cluster_suggestion(
6252
+ def accept_cluster_suggestion(
6267
6253
  cluster_id: str,
6268
6254
  _auth: str | None = Depends(verify_api_key),
6269
6255
  ) -> dict[str, Any]:
@@ -6302,7 +6288,7 @@ async def accept_cluster_suggestion(
6302
6288
 
6303
6289
 
6304
6290
  @app.post("/api/v1/agent-groups/suggestions/{cluster_id}/dismiss")
6305
- async def dismiss_cluster_suggestion_endpoint(
6291
+ def dismiss_cluster_suggestion_endpoint(
6306
6292
  cluster_id: str,
6307
6293
  _auth: str | None = Depends(verify_api_key),
6308
6294
  ) -> dict[str, Any]:
@@ -6312,32 +6298,86 @@ async def dismiss_cluster_suggestion_endpoint(
6312
6298
  return {"dismissed": True}
6313
6299
 
6314
6300
 
6301
+ # The cluster compile walks every agent in the namespace and is pure Python
6302
+ # CPU — minutes on an account with thousands of agents. The dashboard's Agents
6303
+ # page POSTs it on every mount, so without a guard two tabs or one reload queue
6304
+ # a second full compile behind the first while the process is pinned at one
6305
+ # core. One compile at a time per process, and a compile that finished within
6306
+ # the window is reused rather than repeated; the caller's ``ok`` is unchanged
6307
+ # because it only ever waited for the suggestions to be refreshed.
6308
+ _CLUSTER_RECOMPUTE_MIN_INTERVAL_S = float(
6309
+ os.environ.get("AMFS_CLUSTER_RECOMPUTE_MIN_INTERVAL_S", "300"),
6310
+ )
6311
+ _cluster_recompute_lock = threading.Lock()
6312
+ _cluster_recompute_last: dict[str, float] = {}
6313
+
6314
+
6315
+ def _recompute_window_key(request: Request, namespace: str) -> str:
6316
+ """Who the reuse window belongs to.
6317
+
6318
+ A multi-tenant deployment runs every account under one namespace and keeps
6319
+ them apart with row-level security, so the namespace alone would let the
6320
+ first account's compile mark every other account on the instance as fresh.
6321
+ The account the compile will actually run under is the one on the RLS
6322
+ context — the same ContextVar the Postgres connection reads — so that is
6323
+ the key, whichever middleware branch authenticated the request.
6324
+ ``request.state.account_id`` is the fallback for a deployment whose
6325
+ middleware sets the request state but not the RLS context; a single-tenant
6326
+ server has neither and the namespace is the whole story.
6327
+ """
6328
+ account_id: Any = None
6329
+ try:
6330
+ from amfs_postgres.tenant_context import get_request_tenant_account_id
6331
+
6332
+ account_id = get_request_tenant_account_id()
6333
+ except ImportError:
6334
+ pass
6335
+ if not account_id:
6336
+ account_id = getattr(request.state, "account_id", None)
6337
+ return f"{account_id or ''}:{namespace}"
6338
+
6339
+
6315
6340
  @app.post("/api/v1/agent-groups/recompute")
6316
- async def recompute_clusters(
6341
+ def recompute_clusters(
6342
+ request: Request,
6343
+ force: bool = False,
6317
6344
  _auth: str | None = Depends(verify_api_key),
6318
6345
  ) -> dict[str, Any]:
6319
- """Trigger recomputation of agent clusters."""
6346
+ """Trigger recomputation of agent clusters.
6347
+
6348
+ Runs on the threadpool (plain ``def``): the compile is CPU-bound Python and
6349
+ used to freeze the event loop for its whole duration. ``force=true`` skips
6350
+ the reuse window but still waits its turn behind a compile in flight. The
6351
+ window is per tenant account; the lock is per process, because the compile
6352
+ is what pins the core.
6353
+ """
6320
6354
  try:
6321
6355
  from amfs_cortex.compiler import DigestCompiler
6322
6356
  from amfs_postgres.adapter import PostgresAdapter
6323
-
6324
- mem = _get_memory()
6325
- adapter = mem._adapter
6326
- if not isinstance(adapter, PostgresAdapter):
6327
- return JSONResponse(
6328
- status_code=400,
6329
- content={"error": "Cluster recomputation requires Postgres adapter"},
6330
- )
6331
- compiler = DigestCompiler(
6332
- adapter=adapter,
6333
- namespace=mem.namespace,
6334
- )
6335
- compiler.compile(f"cluster:account:{mem.namespace}")
6336
6357
  except ImportError:
6337
6358
  return JSONResponse(
6338
6359
  status_code=400,
6339
6360
  content={"error": "amfs-cortex package is required for recomputation"},
6340
6361
  )
6362
+ mem = _get_memory()
6363
+ adapter = mem._adapter
6364
+ if not isinstance(adapter, PostgresAdapter):
6365
+ return JSONResponse(
6366
+ status_code=400,
6367
+ content={"error": "Cluster recomputation requires Postgres adapter"},
6368
+ )
6369
+ ns = mem.namespace
6370
+ window_key = _recompute_window_key(request, ns)
6371
+ with _cluster_recompute_lock:
6372
+ last = _cluster_recompute_last.get(window_key)
6373
+ if (
6374
+ not force
6375
+ and last is not None
6376
+ and time.monotonic() - last < _CLUSTER_RECOMPUTE_MIN_INTERVAL_S
6377
+ ):
6378
+ return {"ok": True, "skipped": "recent"}
6379
+ DigestCompiler(adapter=adapter, namespace=ns).compile(f"cluster:account:{ns}")
6380
+ _cluster_recompute_last[window_key] = time.monotonic()
6341
6381
  return {"ok": True}
6342
6382
 
6343
6383
 
@@ -6350,7 +6390,7 @@ SNAPSHOT_ENTITY = "_system/agent-snapshots"
6350
6390
 
6351
6391
 
6352
6392
  @app.post("/api/v1/agents/{agent_id:path}/snapshots")
6353
- async def create_snapshot(
6393
+ def create_snapshot(
6354
6394
  request: Request,
6355
6395
  agent_id: str,
6356
6396
  req: CreateSnapshotRequest,
@@ -6370,17 +6410,12 @@ async def create_snapshot(
6370
6410
  "data": req.snapshot_data,
6371
6411
  }
6372
6412
 
6373
- original_agent = mem._tagger.agent_id
6374
- mem._tagger.agent_id = agent_id
6375
- try:
6376
- mem.write(
6377
- SNAPSHOT_ENTITY,
6378
- f"{agent_id}/{snapshot_id}",
6379
- snapshot_value,
6380
- confidence=1.0,
6381
- )
6382
- finally:
6383
- mem._tagger.agent_id = original_agent
6413
+ mem.as_agent(agent_id).write(
6414
+ SNAPSHOT_ENTITY,
6415
+ f"{agent_id}/{snapshot_id}",
6416
+ snapshot_value,
6417
+ confidence=1.0,
6418
+ )
6384
6419
 
6385
6420
  try:
6386
6421
  mem._adapter.log_event(Event(
@@ -6408,7 +6443,7 @@ async def create_snapshot(
6408
6443
 
6409
6444
 
6410
6445
  @app.get("/api/v1/agents/{agent_id:path}/snapshots")
6411
- async def list_snapshots(
6446
+ def list_snapshots(
6412
6447
  request: Request,
6413
6448
  agent_id: str,
6414
6449
  _auth: str | None = Depends(verify_api_key),
@@ -6436,7 +6471,7 @@ async def list_snapshots(
6436
6471
 
6437
6472
 
6438
6473
  @app.get("/api/v1/agents/{agent_id:path}/snapshots/{snapshot_id}")
6439
- async def get_snapshot(
6474
+ def get_snapshot(
6440
6475
  request: Request,
6441
6476
  agent_id: str,
6442
6477
  snapshot_id: str,
@@ -6454,7 +6489,7 @@ async def get_snapshot(
6454
6489
 
6455
6490
 
6456
6491
  @app.delete("/api/v1/agents/{agent_id:path}/snapshots/{snapshot_id}")
6457
- async def delete_snapshot(
6492
+ def delete_snapshot(
6458
6493
  request: Request,
6459
6494
  agent_id: str,
6460
6495
  snapshot_id: str,
@@ -6472,7 +6507,7 @@ async def delete_snapshot(
6472
6507
 
6473
6508
 
6474
6509
  @app.post("/api/v1/agents/{agent_id:path}/snapshots/{snapshot_id}/recover")
6475
- async def recover_snapshot(
6510
+ def recover_snapshot(
6476
6511
  request: Request,
6477
6512
  agent_id: str,
6478
6513
  snapshot_id: str,
@@ -6527,7 +6562,7 @@ async def recover_snapshot(
6527
6562
 
6528
6563
 
6529
6564
  @app.post("/api/v1/rollback")
6530
- async def rollback(
6565
+ def rollback(
6531
6566
  request: Request,
6532
6567
  body: dict[str, Any],
6533
6568
  _auth: str | None = Depends(verify_api_key),
@@ -6604,7 +6639,7 @@ async def rollback(
6604
6639
 
6605
6640
 
6606
6641
  @app.get("/api/v1/agents/{agent_id:path}/branches")
6607
- async def agent_branches(
6642
+ def agent_branches(
6608
6643
  request: Request,
6609
6644
  agent_id: str,
6610
6645
  _auth: str | None = Depends(verify_api_key),
@@ -6629,7 +6664,7 @@ async def agent_branches(
6629
6664
 
6630
6665
 
6631
6666
  @app.get("/api/v1/agents/{agent_id:path}/pull-requests")
6632
- async def agent_pull_requests(
6667
+ def agent_pull_requests(
6633
6668
  request: Request,
6634
6669
  agent_id: str,
6635
6670
  _auth: str | None = Depends(verify_api_key),
@@ -6947,7 +6982,7 @@ def list_audit_log(
6947
6982
 
6948
6983
 
6949
6984
  @app.get("/api/v1/patterns")
6950
- async def list_patterns(
6985
+ def list_patterns(
6951
6986
  request: Request,
6952
6987
  entity_path: str | None = Query(None),
6953
6988
  limit: int = Query(100),
@@ -7724,7 +7759,7 @@ def _filter_graph_edges(request: Request, edges: list) -> list:
7724
7759
 
7725
7760
 
7726
7761
  @app.get("/api/v1/pro/graph/neighbors")
7727
- async def graph_neighbors(
7762
+ def graph_neighbors(
7728
7763
  request: Request,
7729
7764
  entity: str = Query(...),
7730
7765
  relation: str | None = Query(None),
@@ -7841,7 +7876,7 @@ async def expertise_graph(
7841
7876
 
7842
7877
 
7843
7878
  @app.post("/api/v1/pro/graph/backfill")
7844
- async def graph_backfill(
7879
+ def graph_backfill(
7845
7880
  _auth: str | None = Depends(verify_api_key),
7846
7881
  ) -> dict[str, Any]:
7847
7882
  """Backfill knowledge graph edges from existing entries and outcomes.
@@ -8050,7 +8085,7 @@ async def stream(
8050
8085
 
8051
8086
 
8052
8087
  @app.get("/api/v1/admin/config")
8053
- async def get_system_config(
8088
+ def get_system_config(
8054
8089
  _auth: str | None = Depends(verify_api_key),
8055
8090
  ) -> dict[str, Any]:
8056
8091
  """Return system configuration and active Pro module status."""
@@ -8086,7 +8121,7 @@ async def get_system_config(
8086
8121
 
8087
8122
 
8088
8123
  @app.get("/api/v1/connectors")
8089
- async def list_connectors(
8124
+ def list_connectors(
8090
8125
  _auth: str | None = Depends(verify_api_key),
8091
8126
  ) -> dict[str, Any]:
8092
8127
  """List available and installed connectors."""
@@ -8387,7 +8422,7 @@ async def get_briefing(
8387
8422
 
8388
8423
 
8389
8424
  @app.get("/api/v1/cortex/status")
8390
- async def cortex_status(
8425
+ def cortex_status(
8391
8426
  request: Request,
8392
8427
  _auth: str | None = Depends(verify_api_key),
8393
8428
  ) -> dict[str, Any]:
@@ -8427,7 +8462,7 @@ async def cortex_status(
8427
8462
 
8428
8463
 
8429
8464
  @app.get("/api/v1/cortex/digests")
8430
- async def list_cortex_digests(
8465
+ def list_cortex_digests(
8431
8466
  request: Request,
8432
8467
  digest_type: str | None = Query(None),
8433
8468
  scope: str | None = Query(None),
@@ -8464,7 +8499,7 @@ async def list_cortex_digests(
8464
8499
 
8465
8500
 
8466
8501
  @app.get("/api/v1/cortex/activity")
8467
- async def cortex_activity(
8502
+ def cortex_activity(
8468
8503
  request: Request,
8469
8504
  limit: int = Query(default=50, le=200),
8470
8505
  _auth: str | None = Depends(verify_api_key),
@@ -8486,7 +8521,7 @@ async def cortex_activity(
8486
8521
 
8487
8522
 
8488
8523
  @app.post("/api/v1/cortex/recompile")
8489
- async def cortex_recompile(
8524
+ def cortex_recompile(
8490
8525
  _auth: str | None = Depends(verify_api_key),
8491
8526
  ) -> dict[str, Any]:
8492
8527
  """Trigger a full digest recompilation."""
@@ -8502,7 +8537,7 @@ async def cortex_recompile(
8502
8537
 
8503
8538
 
8504
8539
  @app.post("/api/v1/cortex/consolidate")
8505
- async def run_consolidation(
8540
+ def run_consolidation(
8506
8541
  request: Request,
8507
8542
  body: dict[str, Any] | None = None,
8508
8543
  _auth: str | None = Depends(verify_api_key),
@@ -8539,7 +8574,7 @@ async def run_consolidation(
8539
8574
 
8540
8575
 
8541
8576
  @app.get("/api/v1/cortex/consolidation/candidates")
8542
- async def list_consolidation_candidates(
8577
+ def list_consolidation_candidates(
8543
8578
  request: Request,
8544
8579
  entity_path: str = Query(...),
8545
8580
  branch: str = Query("main"),
@@ -8566,7 +8601,7 @@ async def list_consolidation_candidates(
8566
8601
 
8567
8602
 
8568
8603
  @app.get("/api/v1/cortex/consolidation/proposals")
8569
- async def list_consolidation_proposals(
8604
+ def list_consolidation_proposals(
8570
8605
  request: Request,
8571
8606
  entity_path: str | None = Query(None),
8572
8607
  status: str | None = Query(None),
@@ -8656,7 +8691,7 @@ async def list_consolidation_proposals(
8656
8691
 
8657
8692
 
8658
8693
  @app.get("/api/v1/cortex/consolidation/status")
8659
- async def consolidation_status(
8694
+ def consolidation_status(
8660
8695
  request: Request,
8661
8696
  _auth: str | None = Depends(verify_api_key),
8662
8697
  ) -> dict[str, Any]:
@@ -8762,23 +8797,19 @@ async def ingest_webhook(
8762
8797
  event_id=event_id,
8763
8798
  )
8764
8799
 
8765
- original_agent = mem._tagger.agent_id
8766
- mem._tagger.agent_id = f"webhook/{connector_name}"
8767
8800
  persisted = 0
8768
- try:
8769
- for r in results:
8770
- if r.success and r.action == "write":
8771
- entry = mem.write(
8772
- r.entity_path,
8773
- r.key,
8774
- r.details,
8775
- confidence=1.0,
8776
- memory_type=MemoryType.EXPERIENCE,
8777
- )
8778
- _sse_manager.broadcast(entry)
8779
- persisted += 1
8780
- finally:
8781
- mem._tagger.agent_id = original_agent
8801
+ as_webhook = mem.as_agent(f"webhook/{connector_name}")
8802
+ for r in results:
8803
+ if r.success and r.action == "write":
8804
+ entry = as_webhook.write(
8805
+ r.entity_path,
8806
+ r.key,
8807
+ r.details,
8808
+ confidence=1.0,
8809
+ memory_type=MemoryType.EXPERIENCE,
8810
+ )
8811
+ _sse_manager.broadcast(entry)
8812
+ persisted += 1
8782
8813
 
8783
8814
  return {
8784
8815
  "results": [r.model_dump(mode="json") for r in results],
@@ -8788,7 +8819,7 @@ async def ingest_webhook(
8788
8819
 
8789
8820
 
8790
8821
  @app.post("/api/v1/events")
8791
- async def ingest_event(
8822
+ def ingest_event(
8792
8823
  body: EventRequest,
8793
8824
  _auth: str | None = Depends(verify_api_key),
8794
8825
  ) -> dict[str, Any]:
@@ -8799,19 +8830,14 @@ async def ingest_event(
8799
8830
  """
8800
8831
  mem = _get_memory()
8801
8832
 
8802
- original_agent = mem._tagger.agent_id
8803
- mem._tagger.agent_id = f"external/{body.source}"
8804
- try:
8805
- entry = mem.write(
8806
- body.entity_path,
8807
- body.key,
8808
- body.value,
8809
- confidence=1.0,
8810
- memory_type=MemoryType.EXPERIENCE,
8811
- )
8812
- _sse_manager.broadcast(entry)
8813
- finally:
8814
- mem._tagger.agent_id = original_agent
8833
+ entry = mem.as_agent(f"external/{body.source}").write(
8834
+ body.entity_path,
8835
+ body.key,
8836
+ body.value,
8837
+ confidence=1.0,
8838
+ memory_type=MemoryType.EXPERIENCE,
8839
+ )
8840
+ _sse_manager.broadcast(entry)
8815
8841
 
8816
8842
  return {
8817
8843
  "status": "ok",