From f79b58fc8848a5b60a4cea6ef68f8767b82df0ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Tailland?= Date: Sun, 30 Aug 2026 08:03:23 +0200 Subject: [PATCH] fix: bound the token cache, and make peer message ids unique MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs from review, the second in both agents because identity.py is a copy. The exchanged-token cache grew for the life of the process. It is keyed on (subject token, scope) and the subject token is minted fresh every turn, so every turn added keys nothing would ever read again — and expiry was checked on read but never collected. A slow leak that only shows in a pod up for days, which is the worst way to find one. It now drops expired entries first and evicts oldest beyond a bound. Expired first on purpose: an expired entry is dead weight wherever it sits in the ordering, so evicting by age alone would keep it while discarding a live one. The docstring was wrong before the code was: it claimed the cache lasted one turn. It never did. Corrected rather than quietly made true. message_id reused the context id, which is the conversation — and a conversation makes several calls to the same peer, relaying an approval being exactly that. A peer treating the second call as a replay of the first would surface as a stale answer rather than an error. Co-Authored-By: Claude Opus 5 --- k8s/deployment.yaml | 2 +- pyproject.toml | 2 +- .../__pycache__/delegation.cpython-312.pyc | Bin 6218 -> 6358 bytes .../__pycache__/identity.cpython-312.pyc | Bin 5718 -> 7199 bytes src/assistant_agent/delegation.py | 9 +- src/assistant_agent/identity.py | 33 ++++++- ...t_token_cache.cpython-312-pytest-9.1.1.pyc | Bin 0 -> 13904 bytes tests/test_token_cache.py | 83 ++++++++++++++++++ 8 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 tests/__pycache__/test_token_cache.cpython-312-pytest-9.1.1.pyc create mode 100644 tests/test_token_cache.py diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 3712502..2d1a72d 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -28,7 +28,7 @@ spec: # gitea's own registry: the image sits beside the source it was built # from and inherits the repo's public visibility, so the pull needs no # credentials at all. - image: gitea.sttlab.eu/stt/classic-models-agent-assistant:v0.2.0 + image: gitea.sttlab.eu/stt/classic-models-agent-assistant:v0.2.1 ports: - name: http containerPort: 8080 diff --git a/pyproject.toml b/pyproject.toml index 4ae0577..d38f996 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "assistant-agent" -version = "0.2.0" +version = "0.2.1" description = "Assistant — the orchestrating agent for the classic-models estate" readme = "README.md" requires-python = ">=3.12" diff --git a/src/assistant_agent/__pycache__/delegation.cpython-312.pyc b/src/assistant_agent/__pycache__/delegation.cpython-312.pyc index 44e80db4783a0cceda3452de4cdf3221d945879a..69d2b9e0e9135aeec232f9a14d7202f49e75c1d3 100644 GIT binary patch delta 1309 zcmZWpUrbw77(d_r*V}u0dwY9JTS|dJ$3JJ0Lhb%A!3szz(LFGfU5VQ5GNox>+D`XC zB~e~Xcz_%eW5|+4A9w%~7M3i=moX(!OiUXyQMQLA^Tn8$>gL0uiJnua3-LVs&hPtu zf6n*alXG{wehAsWRTK%4_2D*7zNszRz3}s5+p$?T#TlFlCYR#Vf*}ARoK0B_3)%UU zn6?^HMH5nT+Gg0&ilGqOVu~qyS~b)Px27Cv&Cn`bN;%WIp#x&j6q{_PCmvl>H7jtS z7;XkJQ^MlywtA20G}SrI@G@u^nT|PRYAeiPRP6=__jqW<4}?#lVdJXh96%4=5l!gc z2wE=!oW={%GY%UG{|F&?^c|1TC<461)owhL)`2sHToYci`EkE0;_q!PuSo{*p(S*U zpW$-eYJ166T#|HX$G2?t_&r7Mo?%wSYOn9Ws&$1s2wjz`YTjzflcdkQl$U43WDwUX zJ@6dX?0!70DEOx0^7K^8Kiy|+$uK<%U$*N^@)VYBK7r&PQE3sqPN^Q0h53B0w_4(8 z=vi7o%uDmjME`%@mq+p{!L518wCCmd(n+)c^Z!)vW(7YuiRSM{kQI?>`f*SVV4vy) z%03RMGmvbkw(xXcBKI{b6V$Hpc`5f|)sq~cBY+eCr3PRKH#!2EzQP`O$Iu%4K6JS7 znBzSEi%3Mg?Dz@-_>#8dxN>FM+~+BV_t|%+uHwJ7C=B90rvXv?ne(#n0*TMYF|U8} zvR`Sr?+fhtA|+qszQ1M9A1wKUWjksMU7uXn*3aDPztLap9=IL)=KQv?X?#7oH#A-v z8ZW*!QG9)(ICP;nXcPl);7z?*sk7I+AG3}+=Ypl&jLZ7x?qNXM1$a{CIG6OKY(wq= zVzlFBR|hi;xa6v3;(&j3y*k?hs}MWEtwoj57T7!ku})#Lm!-V#`8dPg>48{CxD)0m zKmAhEh?Cj!aj^)u)c~=uxYfu~-W&}R$F`@J;EwL4cmha`oz4n|fMoB4frRcvfO=2q zL@CHH61NKln?MbxYb3}QT4AI^Dzpid2U)6j$)r%B7e@NH!dZdvT}FtzEW0AaW!tXR zLb)SaGuna~cUMhp;o^TRiih2uZ%fIP6OkNF_J>+S|07CnvanK4pnUp5oD#HCaso1MyL z==00Osmvz26B+W%QRW)E$cdI(%}R@V7pc&dOI~Hs7b{S z5-L~(CBoV>&@qM|zl3D4Q9;CCE8Uhj(H7CoSwZlRKQlYg4S#q}+H2PH<2ld!oacSd zd+xpG{xtYaT)8I80wP1axR8BKomT>|HXjYovN<--nH(SsEGEaBJej$iGw(9pEsf6! zdC?T}k|~kcX}NN8UNMyxcjwf+W@;@i(wF4m5!*Z4OzUs$2ZuP!>zBY!vU1z4#3|{9w#3+nz1a&MJIx&$hjETj-~mjFcT{HL)c7 z$N~IH7qsITJ|lyVLw3>9JqZh>`OAt5E#ITW_th5Pu$|MM?RLt7C6~p@>K=3o zD!;aHp@ma>Q02#AbS!AiRB0=pOR~BYQY(0Cx zX6@PX;0Y}PN&J!)@#s}%W92b)o_QT&Ui`84IQ~cb8p8Ms&sm(&lQ51y)lE2r|J6_O z`-!_4!!LMGKVZn+Yr)9%;P7g2crDa@J=DJ%>c7zw|77Zd=fcRB$&1OA#P~|&2tMq) zUshe6#%)H}uAg!?4g9L_Gyf>^oPaw`mh}mDni4X`Nf5#@e{B0Gk^V+oV4a3{Zym%h z`;W~sP=l14JwGNNW#G~Xq$K{*C`0+dp>&v8+65_PG22!IgwNM3;UDttF>pq~XhL#L)v+0U*lW-%rv&pnbeAgy9GOage7U-@14+&2WjsO4v diff --git a/src/assistant_agent/__pycache__/identity.cpython-312.pyc b/src/assistant_agent/__pycache__/identity.cpython-312.pyc index 692e52d306f77250e79b47c6a3848e0c5ea76292..85f64947ea97ae3a741ce603bf3a0db586d2b4a0 100644 GIT binary patch delta 2631 zcmZWrU2Ggz6}~e&yI#-ikJnxY$F%YFPky#GE=?3@-6W0UH3>wuA$6s)B-+l-y|%|5 z&us6Vjk8UnjgW;}$tZCLDWMNgUf{ufK}HnO2c+`A121+-iM0ZO0HN}h%6WiFNSt%m zHYHqX&fYoa+;7gg_k8E>??-<4Z05J=v`Jtr{U%zt+`X3R(SE*m^i(76C70Su?HXYk zi+N_rWQmf++Da*wETwgFp0M^6!c3l7kKt-UN?D!oc4obsTGv_{<&_4n%(~vvthath zsYBVaVC&ekm2#|;9K;G~8ZV2q(r>(#$ZPp-G;2*ma&J~*P*h~1U$zZt$D=>B9UDZ~se;~22p-mi z-!v*g;1v=gi4g55%;>3P-=WXrG=)NeXKM`(rn2ZRZ%M?h;N) z8Y3m0X{8uTFdcF=SR0GMZ=5BW0l$fQywKjv%mjaq?S@5h?>3 z&T}gHRW59g@?8%}1D{jOf%=>?X;K(YAVZ}WLCt406mFGr+nJ}r1`Gi}wy*3}c&SM~ zN{ifcD*zmFuTK3Sq>H@H8LiY6UM(%T7^60I0EDyX?e?Kwy5#yHxXcEEx)N+529EC0 zcKeqaYTXP3td^ih1!I8)HblfkP?eBF)rBLb+=mFc9HGneE(Ga@bl#SLM=KB+U??nE zt1NIQY<-5dwg(_!H|i>VwL|-_4WHVKNlXKf=IxL!yPgMI1pWZ45Cyj(nmOC`>0fSr z58|RVe=kS|Wk+GFIGq#0vQ$~(_IvIur@^c`imC`4F6Ag4S82lz3(p037C3SYGV!8{yd!cWv*`o^P1~#&=_5Ze*EHYNe=Po<1C6C91K6-XC$#sX?Q~z_DOe?jqkpHbl)4bxY@L=YyZ%2;fuf)b)C)zl zyf}(Ju{jrHDly`9ZJ z$o6k#`!|#Q51+B}=BE-v`t~_y);ljUxAn|1I2G@H}xC(0&f0eLu#vCH?%c<^?*T~*iHSW zRwP%lup$PaJsqdXYR(`brmhBlsb_sRln}d+8jD59bxeivsg>_x+PnImllIu^#-8D^ z)!OQF^;kXT=-O@^%cNk``r%VWAR$l=xB={BK!y=4Mt@%wc#O+%)Kp}_wlcZP*I8G{ za0F=N*w8$XI8YmneJa=Y@aQsk=jMTWNF*d4Fk8A|t4ARqFb|m#V5>*EAPxCdpdP+d z>Of9F_mC^gLCs^pW?n@Q=o22N%$1HU82Z{KZ6F{F&%FwWeyek|nhwT;->KNnq6{Hv z>{&Lg)F2Hozy<`h5UOjpBh^RuGyV!*AxK%^S*yh3ZpfFUD)MH`<9;*u;`l4&iSdcX zg~?)hX8Pjf)Jx5_YEVVLf}yIlsVz3OtKuvisl-yjFon8G&cNI2-T&M2M_ZNdqxTAk zTjXJe3?BW-@Q;Qc44l{+IB|dAN^yz)|3;Q#4P|Kmis5$bx(DvD@89gi%BPb?O zoB^i%-%Z_*Iuy^rBeA2bkWX|gp4{n;$1iC+nM3h`M`txNKGM40`!~Lm`?208zKZKk zMnq`wT)Laf#5CZgB!`EES8X1im19v9_cR$PYn>(Sf!^@eJCD&7C)(4_)xn&t9r z4Q^>(E(>TdM7)CHGztu>X*mJlLE#6!Z1r-B*`W>hhm>3j?D@^O+G~Nih%ppjLv89( z%@5&{nH0~9K&Zb(tp=)+$KW3O|I$pJS_)Xr<7Y$#tk4LEd@cI8`^nzHKa&2xlGLBm nXKtC>-NjAP`OqZYJ)0yKBWOfveA1?K?dsTp z1|bzeC6Evj<6Lmb-6jYZph*0HNxR~vTmX}&A<+K7N{5if4d*?fmFSVrM@<(4OJ|mv$!sOpsS?atee?UDw($IJV&Dm2%!TcSy>-GIRs(~Km@ly${xTt`&4(X3I%X= zz7N-bDdT;cQ(q#2D%eF8$;B1fRb7cEs<@gf^SjzsoJ5=!*)Bn{{wkrh3C!}zx8ATD z0kxwwVtdU7iJBY44jOjg6B|xlvKNWhT;bLc@!E#XmmfrS&GrI+b=qsbLtMLFt8?$` zq|N1(w%rN|eZj7=f0CmLIIv;k33k?)Vc(>N@XPE@YVKGQy^pHOK_YEl#j>Naar0R- z)~X_$qtZdPW}e3u``R4SUgWmn82i~ADc+rX=@aF%lqgo6xA1VmOnrn9-rwv#X)R0a z^UM&w#X6Z`&EXn4$#yf>a33pW2NwE(5bf)yV_c5qRT5Fr2%#DQPT35?h!#OKE?${0 zFISh}yu3t5!S@vVID2Xu<|9@jvK7V3dKmb$xJ{&g6g16n@GScJ;!;XHt3NaR^=?E*Oh5__m1`pI2^M3lsdI>=<;MtKQ44 zMbYL~X+CWG^$T8;_z|6eH{<|BszRDP~ z4R8VA$eh~10X)Zd2({Zmdy=Z^`)O4@j~}I!Zc2$*_J~hsSv+`pW`>(P zG8Gk{=W?voy;^f4456!ha438Vak5_XJVL8MgTBh2SJ?beXIuCjIeJ!{H8!f%MvIRw zsa7cjqOSnV0zhZtlv|5x{6%3%LJBh&tD>Ov449k+!o1S*;X_!WG@#-y(Ce%X7I28m zN9qvkvm1fi^2j-Qm8;=j5H>hGlrhG8Qa@Jynnbwx09p4?ieL9p;Q=y#x6XZF+ q Zi~8986Q%qV9@#@29=(k%d<~1b{R?@}9jO2S diff --git a/src/assistant_agent/delegation.py b/src/assistant_agent/delegation.py index ef491fc..491adfb 100644 --- a/src/assistant_agent/delegation.py +++ b/src/assistant_agent/delegation.py @@ -13,6 +13,7 @@ client is a long-lived token. from __future__ import annotations import logging +import uuid from dataclasses import dataclass import httpx @@ -72,7 +73,13 @@ class Delegator: try: request = a2a_types.SendMessageRequest( message=a2a_types.Message( - message_id=f"{context_id}-{specialist.name}", + # Unique per message. The context id is the + # conversation, and a conversation makes several calls to + # the same specialist — relaying an approval is exactly + # that. Reusing an id invites a peer to treat the second + # call as a replay of the first, which surfaces as a + # stale answer rather than as an error. + message_id=f"{context_id}-{specialist.name}-{uuid.uuid4().hex[:8]}", # Carried across so the specialist keeps one conversation # per user conversation rather than one per question — # which is what lets its own approval flow resume. diff --git a/src/assistant_agent/identity.py b/src/assistant_agent/identity.py index 7893a2c..53a7c58 100644 --- a/src/assistant_agent/identity.py +++ b/src/assistant_agent/identity.py @@ -24,6 +24,7 @@ from __future__ import annotations import base64 import json import time +from collections import OrderedDict from dataclasses import dataclass import httpx @@ -32,6 +33,11 @@ GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" +#: Enough for every scope of every conversation in flight, and small enough that +#: the linear prune below costs nothing. +MAX_CACHED_TOKENS = 500 + + class TokenExchangeError(RuntimeError): """Keycloak refused the exchange. Carries its response, which names the cause.""" @@ -51,9 +57,15 @@ class ExchangedToken: class TokenExchanger: """Exchanges a user token for audience-scoped tokens. - Caches per (subject token, scope) for the lifetime of one turn. Deliberately - not a long-lived cache keyed by user: a token that outlives the turn it was - minted for is a token that can be replayed after the user's session ended. + Caches per (subject token, scope), so the several exchanges one turn needs + cost one round trip each rather than one per use. Deliberately not keyed by + user: a cached token that outlives the turn it was minted for is a token + that can be replayed after the user's session ended. + + The cache is bounded and drops expired entries, which it has to be. The + subject token changes every turn, so every turn adds keys that will never be + read again — an unbounded dict here grows for the life of the process. An + earlier docstring claimed the cache lasted one turn; the code never did. """ def __init__(self, client: httpx.AsyncClient, *, token_endpoint: str, client_id: str, client_secret: str) -> None: @@ -61,7 +73,7 @@ class TokenExchanger: self._endpoint = token_endpoint self._client_id = client_id self._client_secret = client_secret - self._cache: dict[tuple[str, str], ExchangedToken] = {} + self._cache: OrderedDict[tuple[str, str], ExchangedToken] = OrderedDict() async def exchange(self, subject_token: str, audience_scope: str) -> str: key = (subject_token, audience_scope) @@ -92,8 +104,21 @@ class TokenExchanger: expires_at=time.monotonic() + float(payload.get("expires_in", 60)), ) self._cache[key] = token + self._prune() return token.access_token + def _prune(self) -> None: + """Drop what is dead, then what is oldest. + + Expired first, because an expired entry is dead weight wherever it sits + in the ordering — evicting by age alone would keep it while discarding a + live one. The bound is the backstop for a burst that outruns expiry. + """ + for key in [k for k, v in self._cache.items() if v.expired]: + del self._cache[key] + while len(self._cache) > MAX_CACHED_TOKENS: + self._cache.popitem(last=False) + def caller_of(access_token: str) -> str: """The username inside a token, for the prompt and nothing else. diff --git a/tests/__pycache__/test_token_cache.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_token_cache.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..43f0b304680e7f834e9ccf3f7a459ab5e775be51 GIT binary patch literal 13904 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 T{9B3O@7I3?@V_L2eCGcfO9kH$ literal 0 HcmV?d00001 diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py new file mode 100644 index 0000000..6d4ac9e --- /dev/null +++ b/tests/test_token_cache.py @@ -0,0 +1,83 @@ +"""The token cache must not grow for the life of the process. + +The subject token changes every turn, so every turn adds keys that will never be +read again. An unbounded dict here is a slow leak that only shows up in a pod +that has been up for days — which is the worst way to find it. +""" + +from __future__ import annotations + +import time + +import httpx +import pytest + +from assistant_agent.identity import MAX_CACHED_TOKENS, ExchangedToken, TokenExchanger + + +def exchanger(handler) -> TokenExchanger: + transport = httpx.MockTransport(handler) + return TokenExchanger( + httpx.AsyncClient(transport=transport), + token_endpoint="https://keycloak/token", + client_id="agent", + client_secret="secret", + ) + + +def ok(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"access_token": "a-token", "expires_in": 300}) + + +class TestExpiry: + def test_an_expired_entry_is_reported_expired(self) -> None: + assert ExchangedToken("t", time.monotonic() - 1).expired is True + + def test_a_fresh_one_is_not(self) -> None: + assert ExchangedToken("t", time.monotonic() + 300).expired is False + + def test_the_margin_covers_a_turn_that_stalls(self) -> None: + # A token valid for another 10s is treated as spent: a turn that stalls + # on a slow model must not send one that dies mid-flight. + assert ExchangedToken("t", time.monotonic() + 10).expired is True + + +class TestPruning: + async def test_a_hit_costs_no_round_trip(self) -> None: + calls = 0 + + def counting(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return ok(request) + + ex = exchanger(counting) + await ex.exchange("subject", "scope") + await ex.exchange("subject", "scope") + assert calls == 1 + + async def test_expired_entries_are_dropped(self) -> None: + ex = exchanger(ok) + await ex.exchange("subject", "scope") + # Age the only entry, then exchange something else: the prune that runs + # on the way out must collect it. + for key in list(ex._cache): + ex._cache[key] = ExchangedToken("stale", time.monotonic() - 1) + await ex.exchange("other", "scope") + assert all(not v.expired for v in ex._cache.values()) + assert len(ex._cache) == 1 + + async def test_the_cache_is_bounded(self) -> None: + # Every turn brings a new subject token, so without a bound this grows + # until the pod does. + ex = exchanger(ok) + for i in range(MAX_CACHED_TOKENS + 50): + await ex.exchange(f"subject-{i}", "scope") + assert len(ex._cache) <= MAX_CACHED_TOKENS + + async def test_eviction_drops_the_oldest_first(self) -> None: + ex = exchanger(ok) + for i in range(MAX_CACHED_TOKENS + 10): + await ex.exchange(f"subject-{i}", "scope") + assert ("subject-0", "scope") not in ex._cache + assert (f"subject-{MAX_CACHED_TOKENS + 9}", "scope") in ex._cache