group_store.py 312 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383538453855386538753885389539053915392539353945395539653975398539954005401540254035404540554065407540854095410541154125413541454155416541754185419542054215422542354245425542654275428542954305431543254335434543554365437543854395440544154425443544454455446544754485449545054515452545354545455545654575458545954605461546254635464546554665467546854695470547154725473547454755476547754785479548054815482548354845485548654875488548954905491549254935494549554965497549854995500550155025503550455055506550755085509551055115512551355145515551655175518551955205521552255235524552555265527552855295530553155325533553455355536553755385539554055415542554355445545554655475548554955505551555255535554555555565557555855595560556155625563556455655566556755685569557055715572557355745575557655775578557955805581558255835584558555865587558855895590559155925593559455955596559755985599560056015602560356045605560656075608560956105611561256135614561556165617561856195620562156225623562456255626562756285629563056315632563356345635563656375638563956405641564256435644564556465647564856495650565156525653565456555656565756585659566056615662566356645665566656675668566956705671567256735674567556765677567856795680568156825683568456855686568756885689569056915692569356945695569656975698569957005701570257035704570557065707570857095710571157125713571457155716571757185719572057215722572357245725572657275728572957305731573257335734573557365737573857395740574157425743574457455746574757485749575057515752575357545755575657575758575957605761576257635764576557665767576857695770577157725773577457755776577757785779578057815782578357845785578657875788578957905791579257935794579557965797579857995800580158025803580458055806580758085809581058115812581358145815581658175818581958205821582258235824582558265827582858295830583158325833583458355836583758385839584058415842584358445845584658475848584958505851585258535854585558565857585858595860586158625863586458655866586758685869587058715872587358745875587658775878587958805881588258835884588558865887588858895890589158925893589458955896589758985899590059015902590359045905590659075908590959105911591259135914591559165917591859195920592159225923592459255926592759285929593059315932593359345935593659375938593959405941594259435944594559465947594859495950595159525953595459555956595759585959596059615962596359645965596659675968596959705971597259735974597559765977597859795980598159825983598459855986598759885989599059915992599359945995599659975998599960006001600260036004600560066007600860096010601160126013601460156016601760186019602060216022602360246025602660276028602960306031603260336034603560366037603860396040604160426043604460456046604760486049605060516052605360546055605660576058605960606061606260636064606560666067606860696070607160726073607460756076607760786079608060816082608360846085608660876088608960906091609260936094609560966097609860996100610161026103610461056106610761086109611061116112611361146115611661176118611961206121612261236124612561266127612861296130613161326133613461356136613761386139614061416142614361446145614661476148614961506151615261536154615561566157615861596160616161626163616461656166616761686169617061716172617361746175617661776178617961806181618261836184618561866187618861896190619161926193619461956196619761986199620062016202620362046205620662076208620962106211621262136214621562166217621862196220622162226223622462256226622762286229623062316232623362346235623662376238623962406241624262436244624562466247624862496250625162526253625462556256625762586259626062616262626362646265626662676268626962706271627262736274627562766277627862796280628162826283628462856286628762886289629062916292629362946295629662976298629963006301630263036304630563066307630863096310631163126313631463156316631763186319632063216322632363246325632663276328632963306331633263336334633563366337633863396340634163426343634463456346634763486349635063516352635363546355635663576358635963606361636263636364636563666367636863696370637163726373637463756376637763786379638063816382638363846385638663876388638963906391639263936394639563966397639863996400640164026403640464056406640764086409641064116412641364146415641664176418641964206421642264236424642564266427642864296430643164326433643464356436643764386439644064416442644364446445644664476448644964506451645264536454645564566457645864596460646164626463646464656466646764686469647064716472647364746475647664776478647964806481648264836484648564866487648864896490649164926493649464956496649764986499650065016502650365046505650665076508650965106511651265136514651565166517651865196520652165226523652465256526652765286529653065316532653365346535653665376538653965406541654265436544654565466547654865496550655165526553655465556556655765586559656065616562656365646565656665676568656965706571657265736574657565766577657865796580658165826583658465856586658765886589659065916592659365946595659665976598659966006601660266036604660566066607660866096610661166126613661466156616661766186619662066216622662366246625662666276628662966306631663266336634663566366637663866396640664166426643664466456646664766486649665066516652665366546655665666576658665966606661666266636664666566666667666866696670667166726673667466756676667766786679668066816682668366846685668666876688668966906691669266936694669566966697669866996700670167026703670467056706670767086709671067116712671367146715671667176718671967206721672267236724672567266727672867296730673167326733673467356736673767386739674067416742674367446745674667476748674967506751675267536754675567566757675867596760676167626763676467656766676767686769677067716772677367746775677667776778677967806781678267836784678567866787678867896790679167926793679467956796679767986799680068016802680368046805680668076808680968106811681268136814681568166817681868196820682168226823682468256826682768286829683068316832683368346835683668376838683968406841684268436844684568466847684868496850685168526853685468556856685768586859686068616862686368646865686668676868686968706871687268736874687568766877687868796880688168826883688468856886688768886889689068916892689368946895689668976898689969006901690269036904690569066907690869096910691169126913691469156916691769186919692069216922692369246925692669276928692969306931693269336934693569366937693869396940694169426943694469456946694769486949695069516952695369546955695669576958695969606961696269636964696569666967696869696970697169726973697469756976697769786979698069816982698369846985698669876988698969906991699269936994699569966997699869997000700170027003700470057006700770087009701070117012701370147015701670177018701970207021702270237024702570267027702870297030703170327033703470357036703770387039704070417042704370447045704670477048704970507051705270537054705570567057705870597060706170627063706470657066706770687069707070717072707370747075707670777078707970807081708270837084708570867087708870897090709170927093709470957096709770987099710071017102710371047105710671077108710971107111711271137114711571167117711871197120712171227123712471257126712771287129713071317132713371347135713671377138713971407141714271437144714571467147714871497150715171527153715471557156715771587159716071617162716371647165716671677168716971707171717271737174717571767177717871797180718171827183718471857186718771887189719071917192719371947195719671977198719972007201720272037204720572067207720872097210721172127213721472157216721772187219722072217222722372247225722672277228722972307231723272337234723572367237723872397240724172427243724472457246724772487249725072517252725372547255725672577258725972607261726272637264726572667267726872697270727172727273727472757276727772787279728072817282728372847285728672877288728972907291729272937294729572967297729872997300730173027303730473057306730773087309731073117312731373147315731673177318731973207321732273237324732573267327732873297330733173327333733473357336733773387339734073417342734373447345734673477348734973507351735273537354735573567357735873597360736173627363736473657366736773687369737073717372737373747375737673777378737973807381738273837384738573867387738873897390739173927393739473957396739773987399740074017402740374047405740674077408740974107411741274137414741574167417741874197420742174227423742474257426742774287429743074317432
  1. """Durable SQLite storage primitives for the YaoYao Group Chat plugin."""
  2. from __future__ import annotations
  3. import base64
  4. import binascii
  5. import hashlib
  6. import json
  7. import math
  8. import os
  9. import re
  10. import sqlite3
  11. import time
  12. import unicodedata
  13. import uuid
  14. from contextlib import contextmanager
  15. from dataclasses import dataclass
  16. from pathlib import Path
  17. from typing import Callable, Iterator, Mapping
  18. from .data_paths import ensure_durable_data_root
  19. from .group_protocol import (
  20. ALL_MENTION_ALIASES,
  21. CONTEXT_CHARACTER_BUDGET,
  22. DEFAULT_MAX_REPLY_ROUNDS,
  23. INITIAL_CONTEXT_MESSAGE_LIMIT,
  24. MAX_AGENTS_PER_ROOM,
  25. MAX_EVENT_BATCH_SIZE,
  26. MAX_FINITE_REPLY_ROUNDS,
  27. MAX_INTERACTION_PAYLOAD_BYTES,
  28. MAX_MESSAGE_BYTES,
  29. MAX_MESSAGE_PAGE_SIZE,
  30. MAX_PLUGIN_CONCURRENCY,
  31. MAX_ROOM_CONCURRENCY,
  32. MAX_ROOM_INSTRUCTIONS_LENGTH,
  33. MAX_TOOL_STATE_BYTES,
  34. ORCHESTRATION_MODES,
  35. is_reserved_mention_alias,
  36. normalize_display_name,
  37. normalize_interaction_id,
  38. normalize_max_reply_rounds,
  39. normalize_room_cwd,
  40. normalize_room_instructions,
  41. normalize_room_avatar,
  42. normalize_room_name,
  43. )
  44. SCHEMA_VERSION = 14
  45. _WAL_RETRY_DELAYS = (0.01, 0.02, 0.04, 0.08)
  46. EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
  47. MIN_RETAINED_EVENTS = 100_000
  48. UNSET = object()
  49. RUN_TRANSITIONS = {
  50. "queued": frozenset({"running", "interrupted", "failed"}),
  51. "running": frozenset({"awaiting_input", "completed", "failed", "interrupted"}),
  52. "awaiting_input": frozenset({"running", "failed", "interrupted"}),
  53. "completed": frozenset(),
  54. "failed": frozenset(),
  55. "interrupted": frozenset(),
  56. }
  57. _MESSAGE_STATUSES = frozenset(
  58. {"queued", "streaming", "completed", "failed", "interrupted"}
  59. )
  60. _AGENT_MESSAGE_STATUSES_BY_RUN = {
  61. "queued": frozenset({"queued"}),
  62. "running": frozenset({"queued", "streaming", "completed"}),
  63. "awaiting_input": frozenset({"queued", "streaming"}),
  64. "completed": frozenset(),
  65. "failed": frozenset(),
  66. "interrupted": frozenset(),
  67. }
  68. _AGENT_MESSAGE_TRANSITIONS = {
  69. "queued": frozenset({"queued", "streaming", "completed"}),
  70. "streaming": frozenset({"streaming", "completed"}),
  71. "completed": frozenset(),
  72. "failed": frozenset(),
  73. "interrupted": frozenset(),
  74. }
  75. _MESSAGE_STATUSES_BY_RUN = {
  76. "queued": frozenset({"queued"}),
  77. "running": frozenset({"queued", "streaming", "completed"}),
  78. "awaiting_input": frozenset({"queued", "streaming"}),
  79. "completed": frozenset({"completed"}),
  80. "failed": frozenset({"failed", "completed"}),
  81. "interrupted": frozenset({"interrupted", "completed"}),
  82. }
  83. _INTERACTION_KINDS = frozenset({"approval", "clarification"})
  84. _INTERACTION_TERMINAL_STATUSES = frozenset({"resolved", "cancelled"})
  85. _CASCADE_PENDING_OPERATION = "internal.cascade.pending"
  86. _CASCADE_COMPLETED_OPERATION = "internal.cascade.completed"
  87. _CASCADE_DISCARDED_OPERATION = "internal.cascade.discarded"
  88. _CASCADE_OPERATIONS = frozenset(
  89. {
  90. _CASCADE_PENDING_OPERATION,
  91. _CASCADE_COMPLETED_OPERATION,
  92. _CASCADE_DISCARDED_OPERATION,
  93. }
  94. )
  95. _CASCADE_PARSE_VERSION = 3
  96. _MAX_CASCADE_PAGE_SIZE = 32
  97. _MAX_CASCADE_PLAN_BYTES = 16 * 1024
  98. _HOST_FLOW_BARRIER_CONTENT = "internal.host-flow.parallel-barrier"
  99. _TOPIC_TITLE_LENGTH = 120
  100. _TOPIC_PREVIEW_LENGTH = 240
  101. _DEFAULT_SESSION_CONFIGURATION_JSON = (
  102. '{"fast":null,"model":null,"provider":null,"reasoning_effort":null}'
  103. )
  104. _SCHEMA_STATEMENTS = (
  105. """CREATE TABLE IF NOT EXISTS group_meta (
  106. key TEXT PRIMARY KEY,
  107. value TEXT NOT NULL
  108. )""",
  109. """CREATE TABLE IF NOT EXISTS group_rooms (
  110. id TEXT PRIMARY KEY,
  111. name TEXT NOT NULL,
  112. cwd TEXT NOT NULL DEFAULT '',
  113. instructions TEXT NOT NULL DEFAULT '',
  114. avatar_data_url TEXT NOT NULL DEFAULT '',
  115. max_reply_rounds INTEGER NOT NULL DEFAULT 3,
  116. orchestration_mode TEXT NOT NULL DEFAULT 'free',
  117. created_at REAL NOT NULL,
  118. updated_at REAL NOT NULL,
  119. archived INTEGER NOT NULL DEFAULT 0
  120. )""",
  121. """CREATE TABLE IF NOT EXISTS group_topics (
  122. id TEXT PRIMARY KEY,
  123. room_id TEXT NOT NULL REFERENCES group_rooms(id) ON DELETE CASCADE,
  124. title TEXT NOT NULL,
  125. last_read_message_seq INTEGER NOT NULL DEFAULT 0,
  126. created_at REAL NOT NULL,
  127. updated_at REAL NOT NULL,
  128. archived INTEGER NOT NULL DEFAULT 0,
  129. pinned INTEGER NOT NULL DEFAULT 0,
  130. UNIQUE(room_id, id)
  131. )""",
  132. """CREATE TABLE IF NOT EXISTS group_agents (
  133. id TEXT PRIMARY KEY,
  134. room_id TEXT NOT NULL REFERENCES group_rooms(id) ON DELETE CASCADE,
  135. profile TEXT NOT NULL,
  136. node_id TEXT NOT NULL DEFAULT 'local',
  137. node_label TEXT NOT NULL DEFAULT '',
  138. execution_profile TEXT,
  139. display_name TEXT NOT NULL,
  140. display_name_key TEXT NOT NULL,
  141. description TEXT NOT NULL DEFAULT '',
  142. stored_session_id TEXT,
  143. last_context_message_seq INTEGER NOT NULL DEFAULT 0,
  144. enabled INTEGER NOT NULL DEFAULT 1,
  145. reply_without_mention INTEGER NOT NULL DEFAULT 0,
  146. is_host INTEGER NOT NULL DEFAULT 0,
  147. model_override TEXT,
  148. provider_override TEXT,
  149. reasoning_effort_override TEXT,
  150. fast_mode_override INTEGER,
  151. session_config_json TEXT,
  152. created_at REAL NOT NULL,
  153. updated_at REAL NOT NULL,
  154. UNIQUE(room_id, profile),
  155. UNIQUE(room_id, display_name_key)
  156. )""",
  157. """CREATE TABLE IF NOT EXISTS group_agent_topic_state (
  158. agent_id TEXT NOT NULL REFERENCES group_agents(id) ON DELETE CASCADE,
  159. topic_id TEXT NOT NULL REFERENCES group_topics(id) ON DELETE CASCADE,
  160. last_context_message_seq INTEGER NOT NULL DEFAULT 0,
  161. created_at REAL NOT NULL,
  162. updated_at REAL NOT NULL,
  163. PRIMARY KEY(agent_id, topic_id)
  164. )""",
  165. """CREATE TABLE IF NOT EXISTS group_messages (
  166. seq INTEGER PRIMARY KEY AUTOINCREMENT,
  167. id TEXT NOT NULL UNIQUE,
  168. room_id TEXT NOT NULL REFERENCES group_rooms(id) ON DELETE CASCADE,
  169. topic_id TEXT NOT NULL,
  170. sender_kind TEXT NOT NULL,
  171. sender_id TEXT NOT NULL,
  172. sender_name TEXT NOT NULL,
  173. root_message_id TEXT NOT NULL,
  174. reply_to_message_id TEXT,
  175. client_message_id TEXT UNIQUE,
  176. content TEXT NOT NULL DEFAULT '',
  177. reasoning TEXT NOT NULL DEFAULT '',
  178. tool_state_json TEXT NOT NULL DEFAULT '[]',
  179. status TEXT NOT NULL,
  180. error TEXT NOT NULL DEFAULT '',
  181. visible INTEGER NOT NULL DEFAULT 1,
  182. created_at REAL NOT NULL,
  183. updated_at REAL NOT NULL,
  184. FOREIGN KEY(room_id, topic_id)
  185. REFERENCES group_topics(room_id, id) ON DELETE CASCADE
  186. )""",
  187. """CREATE TABLE IF NOT EXISTS group_agent_runs (
  188. id TEXT PRIMARY KEY,
  189. room_id TEXT NOT NULL,
  190. topic_id TEXT NOT NULL,
  191. agent_id TEXT NOT NULL,
  192. trigger_message_id TEXT NOT NULL,
  193. response_message_id TEXT NOT NULL,
  194. root_message_id TEXT NOT NULL,
  195. depth INTEGER NOT NULL,
  196. reply_mode TEXT NOT NULL DEFAULT 'mentioned',
  197. required_reply INTEGER NOT NULL DEFAULT 0,
  198. status TEXT NOT NULL,
  199. runtime_session_id TEXT,
  200. requested_model TEXT,
  201. requested_provider TEXT,
  202. requested_reasoning_effort TEXT,
  203. requested_fast_mode INTEGER,
  204. actual_model TEXT,
  205. actual_provider TEXT,
  206. actual_reasoning_effort TEXT,
  207. actual_fast_mode INTEGER,
  208. error TEXT NOT NULL DEFAULT '',
  209. created_at REAL NOT NULL,
  210. updated_at REAL NOT NULL,
  211. FOREIGN KEY(room_id, topic_id)
  212. REFERENCES group_topics(room_id, id) ON DELETE CASCADE
  213. )""",
  214. """CREATE TABLE IF NOT EXISTS group_interactions (
  215. id TEXT PRIMARY KEY,
  216. room_id TEXT NOT NULL,
  217. topic_id TEXT NOT NULL,
  218. agent_id TEXT NOT NULL,
  219. run_id TEXT NOT NULL,
  220. kind TEXT NOT NULL,
  221. payload_json TEXT NOT NULL,
  222. status TEXT NOT NULL,
  223. created_at REAL NOT NULL,
  224. resolved_at REAL,
  225. FOREIGN KEY(room_id, topic_id)
  226. REFERENCES group_topics(room_id, id) ON DELETE CASCADE
  227. )""",
  228. """CREATE TABLE IF NOT EXISTS group_events (
  229. cursor INTEGER PRIMARY KEY AUTOINCREMENT,
  230. epoch TEXT NOT NULL,
  231. room_id TEXT,
  232. event_type TEXT NOT NULL,
  233. payload_json TEXT NOT NULL,
  234. created_at REAL NOT NULL
  235. )""",
  236. """CREATE TABLE IF NOT EXISTS group_idempotency (
  237. request_id TEXT PRIMARY KEY,
  238. operation TEXT NOT NULL,
  239. request_hash TEXT NOT NULL,
  240. response_json TEXT NOT NULL,
  241. created_at REAL NOT NULL
  242. )""",
  243. "CREATE INDEX IF NOT EXISTS idx_group_rooms_ordering ON group_rooms(archived, updated_at DESC, id DESC)",
  244. "CREATE INDEX IF NOT EXISTS idx_group_topics_room_ordering ON group_topics(room_id, archived, pinned DESC, updated_at DESC, id DESC)",
  245. "CREATE INDEX IF NOT EXISTS idx_group_messages_room_seq ON group_messages(room_id, seq)",
  246. "CREATE INDEX IF NOT EXISTS idx_group_messages_topic_seq ON group_messages(topic_id, seq)",
  247. "CREATE INDEX IF NOT EXISTS idx_group_agent_runs_room ON group_agent_runs(room_id)",
  248. "CREATE INDEX IF NOT EXISTS idx_group_agent_runs_topic ON group_agent_runs(topic_id)",
  249. "CREATE INDEX IF NOT EXISTS idx_group_agent_runs_agent_status ON group_agent_runs(agent_id, status)",
  250. "CREATE INDEX IF NOT EXISTS idx_group_agent_runs_room_status ON group_agent_runs(room_id, status)",
  251. "CREATE INDEX IF NOT EXISTS idx_group_agent_runs_status_order ON group_agent_runs(status, created_at, id)",
  252. """CREATE UNIQUE INDEX IF NOT EXISTS idx_group_agents_room_host
  253. ON group_agents(room_id) WHERE is_host = 1""",
  254. """CREATE UNIQUE INDEX IF NOT EXISTS idx_group_agent_runs_active_runtime
  255. ON group_agent_runs(runtime_session_id)
  256. WHERE runtime_session_id IS NOT NULL
  257. AND status IN ('running', 'awaiting_input')""",
  258. "CREATE INDEX IF NOT EXISTS idx_group_interactions_room_agent ON group_interactions(room_id, agent_id)",
  259. "CREATE INDEX IF NOT EXISTS idx_group_interactions_topic ON group_interactions(topic_id)",
  260. "CREATE INDEX IF NOT EXISTS idx_group_interactions_run_status_kind ON group_interactions(run_id, status, kind)",
  261. "CREATE INDEX IF NOT EXISTS idx_group_events_room_created_at ON group_events(room_id, created_at)",
  262. "CREATE INDEX IF NOT EXISTS idx_group_events_created_at ON group_events(created_at)",
  263. "CREATE INDEX IF NOT EXISTS idx_group_idempotency_operation ON group_idempotency(operation)",
  264. )
  265. _EXPECTED_TABLES = frozenset(
  266. {
  267. "group_meta",
  268. "group_rooms",
  269. "group_topics",
  270. "group_agents",
  271. "group_agent_topic_state",
  272. "group_messages",
  273. "group_agent_runs",
  274. "group_interactions",
  275. "group_events",
  276. "group_idempotency",
  277. }
  278. )
  279. _LEGACY_TABLES = _EXPECTED_TABLES - {"group_topics", "group_agent_topic_state"}
  280. class GroupStoreError(RuntimeError):
  281. """Raised when the group-chat database is uninitialized or corrupt."""
  282. class GroupNotFoundError(GroupStoreError):
  283. """Raised when a room or agent is absent, or unavailable because it is archived."""
  284. class GroupConflictError(GroupStoreError):
  285. """Raised when a requested room-member invariant would be violated."""
  286. class IdempotencyConflict(GroupStoreError):
  287. """Raised when a request ID is reused for a different operation or payload."""
  288. @dataclass(frozen=True)
  289. class CursorPage:
  290. """A page of room summaries and the opaque cursor for its successor."""
  291. items: list[dict[str, object]]
  292. next_cursor: str | None
  293. class GroupStore:
  294. """The versioned SQLite authority for public group-chat state."""
  295. def __init__(
  296. self,
  297. path: Path,
  298. *,
  299. agent_name_resolver: Callable[[str], str] | None = None,
  300. ):
  301. self.path = Path(path)
  302. self._agent_name_resolver = agent_name_resolver or (lambda _profile: "")
  303. @classmethod
  304. def from_environment(
  305. cls, *, agent_name_resolver: Callable[[str], str] | None = None
  306. ) -> "GroupStore":
  307. hermes_home = Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes")
  308. return cls(
  309. ensure_durable_data_root(hermes_home).data_root / "group-chat.db",
  310. agent_name_resolver=agent_name_resolver,
  311. )
  312. def connect(self) -> sqlite3.Connection:
  313. self.path.parent.mkdir(parents=True, exist_ok=True)
  314. connection = sqlite3.connect(self.path, timeout=5, isolation_level=None)
  315. connection.row_factory = sqlite3.Row
  316. connection.execute("PRAGMA foreign_keys = ON")
  317. connection.execute("PRAGMA busy_timeout = 5000")
  318. return connection
  319. @contextmanager
  320. def connection(self) -> Iterator[sqlite3.Connection]:
  321. """Yield a raw SQLite connection and always close it afterwards."""
  322. connection = self.connect()
  323. try:
  324. yield connection
  325. finally:
  326. connection.close()
  327. def initialize(self) -> None:
  328. """Create or validate the versioned schema without changing its epoch."""
  329. for attempt, delay in enumerate(_WAL_RETRY_DELAYS):
  330. try:
  331. self._initialize_once()
  332. return
  333. except sqlite3.OperationalError as error:
  334. if (
  335. not self._is_busy_or_locked(error)
  336. or attempt == len(_WAL_RETRY_DELAYS) - 1
  337. ):
  338. raise
  339. time.sleep(delay)
  340. def _initialize_once(self) -> None:
  341. with self.connection() as connection:
  342. connection.execute("PRAGMA journal_mode = WAL")
  343. connection.execute("BEGIN IMMEDIATE")
  344. try:
  345. table_names = self._user_table_names(connection)
  346. if not table_names:
  347. self._create_fresh_schema(connection)
  348. else:
  349. self._validate_existing_schema(connection, table_names)
  350. self._ensure_indexes(connection)
  351. self._sync_legacy_fallback_agent_names(connection)
  352. except Exception:
  353. connection.execute("ROLLBACK")
  354. raise
  355. else:
  356. connection.execute("COMMIT")
  357. def _sync_legacy_fallback_agent_names(
  358. self, connection: sqlite3.Connection
  359. ) -> None:
  360. """Adopt configured names for active legacy members without overriding edits."""
  361. rows = connection.execute(
  362. """SELECT group_agents.* FROM group_agents
  363. JOIN group_rooms ON group_rooms.id = group_agents.room_id
  364. WHERE group_agents.display_name = group_agents.profile
  365. AND group_rooms.archived = 0
  366. ORDER BY group_rooms.created_at ASC, group_rooms.id ASC,
  367. group_agents.created_at ASC, group_agents.id ASC"""
  368. ).fetchall()
  369. for row in rows:
  370. configured = self._resolved_agent_name(row["profile"])
  371. if not configured:
  372. continue
  373. try:
  374. display_name, display_name_key = normalize_display_name(configured)
  375. except ValueError:
  376. continue
  377. if (
  378. display_name == row["display_name"]
  379. and display_name_key == row["display_name_key"]
  380. ):
  381. continue
  382. conflict = connection.execute(
  383. """SELECT 1 FROM group_agents
  384. WHERE room_id = ? AND display_name_key = ? AND id != ?
  385. LIMIT 1""",
  386. (row["room_id"], display_name_key, row["id"]),
  387. ).fetchone()
  388. if conflict is not None:
  389. continue
  390. now = self._now()
  391. connection.execute(
  392. """UPDATE group_agents
  393. SET display_name = ?, display_name_key = ?, updated_at = ?
  394. WHERE id = ? AND room_id = ?""",
  395. (display_name, display_name_key, now, row["id"], row["room_id"]),
  396. )
  397. connection.execute(
  398. "UPDATE group_rooms SET updated_at = ? WHERE id = ?",
  399. (now, row["room_id"]),
  400. )
  401. agent = self._agent_detail(connection, row["room_id"], row["id"])
  402. self._append_event(
  403. connection,
  404. row["room_id"],
  405. "agent.updated",
  406. agent,
  407. created_at=now,
  408. )
  409. self._append_room_updated_summary(
  410. connection, row["room_id"], created_at=now
  411. )
  412. def schema_version(self) -> int:
  413. value = self._metadata_value("schema_version")
  414. return self._validated_schema_version(value)
  415. def journal_epoch(self) -> str:
  416. value = self._metadata_value("journal_epoch")
  417. try:
  418. canonical = str(uuid.UUID(value))
  419. except (ValueError, AttributeError) as error:
  420. raise GroupStoreError("GroupStore journal_epoch is corrupt") from error
  421. if value != canonical:
  422. raise GroupStoreError("GroupStore journal_epoch is corrupt")
  423. return canonical
  424. def _create_fresh_schema(self, connection: sqlite3.Connection) -> None:
  425. for statement in _SCHEMA_STATEMENTS:
  426. connection.execute(statement)
  427. connection.execute(
  428. "INSERT INTO group_meta(key, value) VALUES ('schema_version', ?)",
  429. (str(SCHEMA_VERSION),),
  430. )
  431. connection.execute(
  432. "INSERT INTO group_meta(key, value) VALUES ('journal_epoch', ?)",
  433. (str(uuid.uuid4()),),
  434. )
  435. def _validate_existing_schema(
  436. self, connection: sqlite3.Connection, table_names: set[str]
  437. ) -> None:
  438. if table_names not in {_EXPECTED_TABLES, _LEGACY_TABLES}:
  439. raise GroupStoreError("GroupStore schema is partial or corrupt")
  440. raw_version = self._metadata_value_from_connection(connection, "schema_version")
  441. if raw_version == "1":
  442. self._migrate_v1_to_v2(connection)
  443. raw_version = "2"
  444. if raw_version == "2":
  445. self._migrate_v2_to_v3(connection)
  446. raw_version = "3"
  447. if raw_version == "3":
  448. self._migrate_v3_to_v4(connection)
  449. raw_version = "4"
  450. if raw_version == "4":
  451. self._repair_early_v4(connection)
  452. self._migrate_v4_to_v5(connection)
  453. raw_version = "5"
  454. if raw_version == "5":
  455. self._migrate_v5_to_v6(connection)
  456. raw_version = "6"
  457. if raw_version == "6":
  458. self._migrate_v6_to_v7(connection)
  459. raw_version = "7"
  460. if raw_version == "7":
  461. self._migrate_v7_to_v8(connection)
  462. raw_version = "8"
  463. if raw_version == "8":
  464. self._migrate_v8_to_v9(connection)
  465. raw_version = "9"
  466. if raw_version == "9":
  467. self._migrate_v9_to_v10(connection)
  468. raw_version = "10"
  469. if raw_version == "10":
  470. self._migrate_v10_to_v11(connection)
  471. raw_version = "11"
  472. if raw_version == "11":
  473. self._migrate_v11_to_v12(connection)
  474. raw_version = "12"
  475. if raw_version == "12":
  476. self._migrate_v12_to_v13(connection)
  477. raw_version = "13"
  478. if raw_version == "13":
  479. self._migrate_v13_to_v14(connection)
  480. self._validated_schema_version(
  481. self._metadata_value_from_connection(connection, "schema_version")
  482. )
  483. self._validated_epoch(
  484. self._metadata_value_from_connection(connection, "journal_epoch")
  485. )
  486. self._validate_v8_columns(connection)
  487. self._validate_v7_values(connection)
  488. @staticmethod
  489. def _migrate_v1_to_v2(connection: sqlite3.Connection) -> None:
  490. connection.execute(
  491. "ALTER TABLE group_rooms ADD COLUMN max_reply_rounds INTEGER NOT NULL DEFAULT 3"
  492. )
  493. connection.execute(
  494. "ALTER TABLE group_agents ADD COLUMN reply_without_mention INTEGER NOT NULL DEFAULT 0"
  495. )
  496. connection.execute(
  497. "ALTER TABLE group_messages ADD COLUMN visible INTEGER NOT NULL DEFAULT 1"
  498. )
  499. connection.execute(
  500. "ALTER TABLE group_agent_runs ADD COLUMN reply_mode TEXT NOT NULL DEFAULT 'mentioned'"
  501. )
  502. connection.execute(
  503. "UPDATE group_meta SET value = '2' WHERE key = 'schema_version'"
  504. )
  505. @staticmethod
  506. def _migrate_v2_to_v3(connection: sqlite3.Connection) -> None:
  507. connection.execute("ALTER TABLE group_agents ADD COLUMN model_override TEXT")
  508. connection.execute("ALTER TABLE group_agents ADD COLUMN provider_override TEXT")
  509. connection.execute(
  510. "ALTER TABLE group_agents ADD COLUMN reasoning_effort_override TEXT"
  511. )
  512. connection.execute(
  513. "ALTER TABLE group_agents ADD COLUMN fast_mode_override INTEGER"
  514. )
  515. connection.execute(
  516. "ALTER TABLE group_agents ADD COLUMN session_config_json TEXT"
  517. )
  518. connection.execute(
  519. """UPDATE group_agents SET session_config_json = ?
  520. WHERE stored_session_id IS NOT NULL""",
  521. (_DEFAULT_SESSION_CONFIGURATION_JSON,),
  522. )
  523. connection.execute(
  524. "UPDATE group_meta SET value = '3' WHERE key = 'schema_version'"
  525. )
  526. @staticmethod
  527. def _migrate_v3_to_v4(connection: sqlite3.Connection) -> None:
  528. """Split legacy root chains into writable topics without touching rooms/Agents."""
  529. orphan_run = connection.execute(
  530. """SELECT run.id FROM group_agent_runs AS run
  531. LEFT JOIN group_messages AS trigger ON trigger.id = run.trigger_message_id
  532. LEFT JOIN group_messages AS response ON response.id = run.response_message_id
  533. WHERE trigger.id IS NULL OR response.id IS NULL LIMIT 1"""
  534. ).fetchone()
  535. if orphan_run is not None:
  536. raise GroupStoreError("GroupStore run messages are orphaned")
  537. orphan_interaction = connection.execute(
  538. """SELECT interaction.id FROM group_interactions AS interaction
  539. LEFT JOIN group_agent_runs AS run ON run.id = interaction.run_id
  540. WHERE run.id IS NULL LIMIT 1"""
  541. ).fetchone()
  542. if orphan_interaction is not None:
  543. raise GroupStoreError("GroupStore interaction run is orphaned")
  544. connection.execute(
  545. """CREATE TABLE group_topics (
  546. id TEXT PRIMARY KEY,
  547. room_id TEXT NOT NULL REFERENCES group_rooms(id) ON DELETE CASCADE,
  548. title TEXT NOT NULL,
  549. created_at REAL NOT NULL,
  550. updated_at REAL NOT NULL,
  551. UNIQUE(room_id, id)
  552. )"""
  553. )
  554. roots = connection.execute(
  555. """SELECT messages.room_id, messages.root_message_id,
  556. MIN(messages.created_at) AS created_at,
  557. COALESCE(
  558. MAX(CASE WHEN messages.visible = 1
  559. THEN messages.updated_at END),
  560. MAX(messages.updated_at)
  561. ) AS updated_at,
  562. COALESCE(
  563. (SELECT human.content FROM group_messages AS human
  564. WHERE human.room_id = messages.room_id
  565. AND human.root_message_id = messages.root_message_id
  566. AND human.sender_kind = 'human'
  567. ORDER BY human.seq ASC LIMIT 1),
  568. (SELECT visible.content FROM group_messages AS visible
  569. WHERE visible.room_id = messages.room_id
  570. AND visible.root_message_id = messages.root_message_id
  571. AND visible.visible = 1
  572. ORDER BY visible.seq ASC LIMIT 1),
  573. '话题'
  574. ) AS title
  575. FROM group_messages AS messages
  576. GROUP BY messages.room_id, messages.root_message_id
  577. ORDER BY MIN(messages.seq) ASC"""
  578. ).fetchall()
  579. for root in roots:
  580. title = " ".join(str(root["title"]).split()).strip()
  581. if not title:
  582. title = "话题"
  583. connection.execute(
  584. """INSERT INTO group_topics
  585. (id, room_id, title, created_at, updated_at)
  586. VALUES (?, ?, ?, ?, ?)""",
  587. (
  588. root["root_message_id"],
  589. root["room_id"],
  590. title[:_TOPIC_TITLE_LENGTH],
  591. root["created_at"],
  592. root["updated_at"],
  593. ),
  594. )
  595. connection.execute(
  596. """CREATE TABLE group_messages_v4 (
  597. seq INTEGER PRIMARY KEY AUTOINCREMENT,
  598. id TEXT NOT NULL UNIQUE,
  599. room_id TEXT NOT NULL REFERENCES group_rooms(id) ON DELETE CASCADE,
  600. topic_id TEXT NOT NULL,
  601. sender_kind TEXT NOT NULL,
  602. sender_id TEXT NOT NULL,
  603. sender_name TEXT NOT NULL,
  604. root_message_id TEXT NOT NULL,
  605. reply_to_message_id TEXT,
  606. client_message_id TEXT UNIQUE,
  607. content TEXT NOT NULL DEFAULT '',
  608. reasoning TEXT NOT NULL DEFAULT '',
  609. tool_state_json TEXT NOT NULL DEFAULT '[]',
  610. status TEXT NOT NULL,
  611. error TEXT NOT NULL DEFAULT '',
  612. visible INTEGER NOT NULL DEFAULT 1,
  613. created_at REAL NOT NULL,
  614. updated_at REAL NOT NULL,
  615. FOREIGN KEY(room_id, topic_id)
  616. REFERENCES group_topics(room_id, id) ON DELETE CASCADE
  617. )"""
  618. )
  619. connection.execute(
  620. """INSERT INTO group_messages_v4
  621. (seq, id, room_id, topic_id, sender_kind, sender_id, sender_name,
  622. root_message_id, reply_to_message_id, client_message_id, content,
  623. reasoning, tool_state_json, status, error, visible, created_at, updated_at)
  624. SELECT seq, id, room_id, root_message_id, sender_kind, sender_id,
  625. sender_name, root_message_id, reply_to_message_id,
  626. client_message_id, content, reasoning, tool_state_json,
  627. status, error, visible, created_at, updated_at
  628. FROM group_messages ORDER BY seq ASC"""
  629. )
  630. connection.execute(
  631. """CREATE TABLE group_agent_runs_v4 (
  632. id TEXT PRIMARY KEY,
  633. room_id TEXT NOT NULL,
  634. topic_id TEXT NOT NULL,
  635. agent_id TEXT NOT NULL,
  636. trigger_message_id TEXT NOT NULL,
  637. response_message_id TEXT NOT NULL,
  638. root_message_id TEXT NOT NULL,
  639. depth INTEGER NOT NULL,
  640. reply_mode TEXT NOT NULL DEFAULT 'mentioned',
  641. status TEXT NOT NULL,
  642. runtime_session_id TEXT,
  643. error TEXT NOT NULL DEFAULT '',
  644. created_at REAL NOT NULL,
  645. updated_at REAL NOT NULL,
  646. FOREIGN KEY(room_id, topic_id)
  647. REFERENCES group_topics(room_id, id) ON DELETE CASCADE
  648. )"""
  649. )
  650. connection.execute(
  651. """INSERT INTO group_agent_runs_v4
  652. (id, room_id, topic_id, agent_id, trigger_message_id,
  653. response_message_id, root_message_id, depth, reply_mode, status,
  654. runtime_session_id, error, created_at, updated_at)
  655. SELECT id, room_id, root_message_id, agent_id, trigger_message_id,
  656. response_message_id, root_message_id, depth, reply_mode,
  657. status, runtime_session_id, error, created_at, updated_at
  658. FROM group_agent_runs"""
  659. )
  660. connection.execute(
  661. """CREATE TABLE group_interactions_v4 (
  662. id TEXT PRIMARY KEY,
  663. room_id TEXT NOT NULL,
  664. topic_id TEXT NOT NULL,
  665. agent_id TEXT NOT NULL,
  666. run_id TEXT NOT NULL,
  667. kind TEXT NOT NULL,
  668. payload_json TEXT NOT NULL,
  669. status TEXT NOT NULL,
  670. created_at REAL NOT NULL,
  671. resolved_at REAL,
  672. FOREIGN KEY(room_id, topic_id)
  673. REFERENCES group_topics(room_id, id) ON DELETE CASCADE
  674. )"""
  675. )
  676. connection.execute(
  677. """INSERT INTO group_interactions_v4
  678. (id, room_id, topic_id, agent_id, run_id, kind, payload_json,
  679. status, created_at, resolved_at)
  680. SELECT interaction.id, interaction.room_id, run.topic_id,
  681. interaction.agent_id, interaction.run_id, interaction.kind,
  682. interaction.payload_json, interaction.status,
  683. interaction.created_at, interaction.resolved_at
  684. FROM group_interactions AS interaction
  685. JOIN group_agent_runs_v4 AS run ON run.id = interaction.run_id"""
  686. )
  687. connection.execute("DROP TABLE group_interactions")
  688. connection.execute("DROP TABLE group_agent_runs")
  689. connection.execute("DROP TABLE group_messages")
  690. connection.execute("ALTER TABLE group_messages_v4 RENAME TO group_messages")
  691. connection.execute(
  692. "ALTER TABLE group_agent_runs_v4 RENAME TO group_agent_runs"
  693. )
  694. connection.execute(
  695. "ALTER TABLE group_interactions_v4 RENAME TO group_interactions"
  696. )
  697. connection.execute(
  698. """CREATE TABLE group_agent_topic_state (
  699. agent_id TEXT NOT NULL REFERENCES group_agents(id) ON DELETE CASCADE,
  700. topic_id TEXT NOT NULL REFERENCES group_topics(id) ON DELETE CASCADE,
  701. last_context_message_seq INTEGER NOT NULL DEFAULT 0,
  702. created_at REAL NOT NULL,
  703. updated_at REAL NOT NULL,
  704. PRIMARY KEY(agent_id, topic_id)
  705. )"""
  706. )
  707. connection.execute(
  708. """INSERT INTO group_agent_topic_state
  709. (agent_id, topic_id, last_context_message_seq, created_at, updated_at)
  710. SELECT agent.id, topic.id,
  711. COALESCE((
  712. SELECT MAX(message.seq) FROM group_messages AS message
  713. WHERE message.topic_id = topic.id
  714. AND message.seq <= agent.last_context_message_seq
  715. ), 0),
  716. topic.created_at,
  717. CASE WHEN agent.updated_at > topic.updated_at
  718. THEN agent.updated_at ELSE topic.updated_at END
  719. FROM group_agents AS agent
  720. JOIN group_topics AS topic ON topic.room_id = agent.room_id"""
  721. )
  722. def add_topic_ids(value: object) -> object:
  723. if isinstance(value, list):
  724. return [add_topic_ids(item) for item in value]
  725. if not isinstance(value, dict):
  726. return value
  727. rewritten = {key: add_topic_ids(item) for key, item in value.items()}
  728. identity = rewritten.get("id")
  729. table: str | None = None
  730. if isinstance(identity, str):
  731. if "senderKind" in rewritten:
  732. table = "group_messages"
  733. elif "triggerMessageId" in rewritten:
  734. table = "group_agent_runs"
  735. elif "runId" in rewritten and "kind" in rewritten:
  736. table = "group_interactions"
  737. if table is not None and "topicId" not in rewritten:
  738. row = connection.execute(
  739. f"SELECT topic_id FROM {table} WHERE id = ?", (identity,)
  740. ).fetchone()
  741. if row is not None:
  742. rewritten["topicId"] = row["topic_id"]
  743. return rewritten
  744. for ledger in connection.execute(
  745. "SELECT request_id, operation, response_json FROM group_idempotency"
  746. ).fetchall():
  747. try:
  748. response = json.loads(ledger["response_json"])
  749. except (TypeError, ValueError, json.JSONDecodeError) as error:
  750. raise GroupStoreError("Stored idempotency response is corrupt") from error
  751. rewritten_value = add_topic_ids(response)
  752. if (
  753. GroupStore._interaction_response_operation(ledger["operation"])
  754. is not None
  755. and isinstance(rewritten_value, dict)
  756. and rewritten_value.get("state") == "failed"
  757. and isinstance(rewritten_value.get("run"), dict)
  758. ):
  759. rewritten_value["run"].pop("topicId", None)
  760. rewritten = GroupStore._canonical_json(rewritten_value)
  761. if rewritten != ledger["response_json"]:
  762. connection.execute(
  763. "UPDATE group_idempotency SET response_json = ? WHERE request_id = ?",
  764. (rewritten, ledger["request_id"]),
  765. )
  766. for event in connection.execute(
  767. """SELECT cursor, payload_json FROM group_events
  768. WHERE event_type IN ('message.upsert', 'run.updated',
  769. 'interaction.requested', 'interaction.resolved')"""
  770. ).fetchall():
  771. try:
  772. payload = json.loads(event["payload_json"])
  773. except (TypeError, ValueError, json.JSONDecodeError) as error:
  774. raise GroupStoreError("Stored event is corrupt") from error
  775. rewritten = GroupStore._canonical_json(add_topic_ids(payload))
  776. if rewritten != event["payload_json"]:
  777. connection.execute(
  778. "UPDATE group_events SET payload_json = ? WHERE cursor = ?",
  779. (rewritten, event["cursor"]),
  780. )
  781. connection.execute(
  782. "UPDATE group_meta SET value = '4' WHERE key = 'schema_version'"
  783. )
  784. @staticmethod
  785. def _topic_foreign_key_kind(
  786. connection: sqlite3.Connection, table: str
  787. ) -> str:
  788. foreign_keys = list(connection.execute(f"PRAGMA foreign_key_list({table})"))
  789. grouped: dict[int, list[sqlite3.Row]] = {}
  790. for foreign_key in foreign_keys:
  791. grouped.setdefault(int(foreign_key["id"]), []).append(foreign_key)
  792. for foreign_key_rows in grouped.values():
  793. if foreign_key_rows[0]["table"] != "group_topics" or not all(
  794. row["on_delete"] == "CASCADE" for row in foreign_key_rows
  795. ):
  796. continue
  797. columns = [
  798. (row["from"], row["to"])
  799. for row in sorted(foreign_key_rows, key=lambda item: item["seq"])
  800. ]
  801. if columns == [("room_id", "room_id"), ("topic_id", "id")]:
  802. return "final"
  803. if columns == [("topic_id", "id")]:
  804. return "early"
  805. return "invalid"
  806. @classmethod
  807. def _repair_early_v4(cls, connection: sqlite3.Connection) -> None:
  808. """Upgrade the unpublished single-topic-FK v4 draft in one transaction."""
  809. tables = ("group_messages", "group_agent_runs", "group_interactions")
  810. foreign_key_kinds = {
  811. table: cls._topic_foreign_key_kind(connection, table) for table in tables
  812. }
  813. if all(kind == "final" for kind in foreign_key_kinds.values()):
  814. return
  815. if any(kind not in {"early", "final"} for kind in foreign_key_kinds.values()):
  816. raise GroupStoreError("GroupStore early v4 topic ownership is corrupt")
  817. invalid_queries = (
  818. """SELECT message.id FROM group_messages AS message
  819. LEFT JOIN group_topics AS topic ON topic.id = message.topic_id
  820. WHERE topic.id IS NULL OR topic.room_id != message.room_id LIMIT 1""",
  821. """SELECT run.id FROM group_agent_runs AS run
  822. LEFT JOIN group_topics AS topic ON topic.id = run.topic_id
  823. LEFT JOIN group_messages AS trigger
  824. ON trigger.id = run.trigger_message_id
  825. LEFT JOIN group_messages AS response
  826. ON response.id = run.response_message_id
  827. WHERE topic.id IS NULL OR topic.room_id != run.room_id
  828. OR trigger.id IS NULL OR response.id IS NULL
  829. OR trigger.room_id != run.room_id
  830. OR response.room_id != run.room_id
  831. OR trigger.topic_id != run.topic_id
  832. OR response.topic_id != run.topic_id
  833. LIMIT 1""",
  834. """SELECT interaction.id FROM group_interactions AS interaction
  835. LEFT JOIN group_topics AS topic ON topic.id = interaction.topic_id
  836. LEFT JOIN group_agent_runs AS run ON run.id = interaction.run_id
  837. WHERE topic.id IS NULL OR topic.room_id != interaction.room_id
  838. OR run.id IS NULL OR run.room_id != interaction.room_id
  839. OR run.topic_id != interaction.topic_id
  840. LIMIT 1""",
  841. )
  842. if any(connection.execute(query).fetchone() is not None for query in invalid_queries):
  843. raise GroupStoreError("GroupStore early v4 ownership is corrupt")
  844. counts = {
  845. table: int(connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
  846. for table in tables
  847. }
  848. connection.execute(
  849. """CREATE TABLE group_messages_v4_repair (
  850. seq INTEGER PRIMARY KEY AUTOINCREMENT,
  851. id TEXT NOT NULL UNIQUE,
  852. room_id TEXT NOT NULL REFERENCES group_rooms(id) ON DELETE CASCADE,
  853. topic_id TEXT NOT NULL,
  854. sender_kind TEXT NOT NULL,
  855. sender_id TEXT NOT NULL,
  856. sender_name TEXT NOT NULL,
  857. root_message_id TEXT NOT NULL,
  858. reply_to_message_id TEXT,
  859. client_message_id TEXT UNIQUE,
  860. content TEXT NOT NULL DEFAULT '',
  861. reasoning TEXT NOT NULL DEFAULT '',
  862. tool_state_json TEXT NOT NULL DEFAULT '[]',
  863. status TEXT NOT NULL,
  864. error TEXT NOT NULL DEFAULT '',
  865. visible INTEGER NOT NULL DEFAULT 1,
  866. created_at REAL NOT NULL,
  867. updated_at REAL NOT NULL,
  868. FOREIGN KEY(room_id, topic_id)
  869. REFERENCES group_topics(room_id, id) ON DELETE CASCADE
  870. )"""
  871. )
  872. connection.execute(
  873. """INSERT INTO group_messages_v4_repair
  874. (seq, id, room_id, topic_id, sender_kind, sender_id, sender_name,
  875. root_message_id, reply_to_message_id, client_message_id, content,
  876. reasoning, tool_state_json, status, error, visible, created_at, updated_at)
  877. SELECT seq, id, room_id, topic_id, sender_kind, sender_id, sender_name,
  878. root_message_id, reply_to_message_id, client_message_id, content,
  879. reasoning, tool_state_json, status, error, visible,
  880. created_at, updated_at
  881. FROM group_messages ORDER BY seq ASC"""
  882. )
  883. connection.execute(
  884. """CREATE TABLE group_agent_runs_v4_repair (
  885. id TEXT PRIMARY KEY,
  886. room_id TEXT NOT NULL,
  887. topic_id TEXT NOT NULL,
  888. agent_id TEXT NOT NULL,
  889. trigger_message_id TEXT NOT NULL,
  890. response_message_id TEXT NOT NULL,
  891. root_message_id TEXT NOT NULL,
  892. depth INTEGER NOT NULL,
  893. reply_mode TEXT NOT NULL DEFAULT 'mentioned',
  894. status TEXT NOT NULL,
  895. runtime_session_id TEXT,
  896. error TEXT NOT NULL DEFAULT '',
  897. created_at REAL NOT NULL,
  898. updated_at REAL NOT NULL,
  899. FOREIGN KEY(room_id, topic_id)
  900. REFERENCES group_topics(room_id, id) ON DELETE CASCADE
  901. )"""
  902. )
  903. connection.execute(
  904. """INSERT INTO group_agent_runs_v4_repair
  905. (id, room_id, topic_id, agent_id, trigger_message_id,
  906. response_message_id, root_message_id, depth, reply_mode, status,
  907. runtime_session_id, error, created_at, updated_at)
  908. SELECT id, room_id, topic_id, agent_id, trigger_message_id,
  909. response_message_id, root_message_id, depth, reply_mode,
  910. status, runtime_session_id, error, created_at, updated_at
  911. FROM group_agent_runs"""
  912. )
  913. connection.execute(
  914. """CREATE TABLE group_interactions_v4_repair (
  915. id TEXT PRIMARY KEY,
  916. room_id TEXT NOT NULL,
  917. topic_id TEXT NOT NULL,
  918. agent_id TEXT NOT NULL,
  919. run_id TEXT NOT NULL,
  920. kind TEXT NOT NULL,
  921. payload_json TEXT NOT NULL,
  922. status TEXT NOT NULL,
  923. created_at REAL NOT NULL,
  924. resolved_at REAL,
  925. FOREIGN KEY(room_id, topic_id)
  926. REFERENCES group_topics(room_id, id) ON DELETE CASCADE
  927. )"""
  928. )
  929. connection.execute(
  930. """INSERT INTO group_interactions_v4_repair
  931. (id, room_id, topic_id, agent_id, run_id, kind, payload_json,
  932. status, created_at, resolved_at)
  933. SELECT id, room_id, topic_id, agent_id, run_id, kind, payload_json,
  934. status, created_at, resolved_at
  935. FROM group_interactions"""
  936. )
  937. connection.execute("DROP TABLE group_interactions")
  938. connection.execute("DROP TABLE group_agent_runs")
  939. connection.execute("DROP TABLE group_messages")
  940. connection.execute(
  941. "ALTER TABLE group_messages_v4_repair RENAME TO group_messages"
  942. )
  943. connection.execute(
  944. "ALTER TABLE group_agent_runs_v4_repair RENAME TO group_agent_runs"
  945. )
  946. connection.execute(
  947. "ALTER TABLE group_interactions_v4_repair RENAME TO group_interactions"
  948. )
  949. repaired_counts = {
  950. table: int(connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
  951. for table in tables
  952. }
  953. if repaired_counts != counts:
  954. raise GroupStoreError("GroupStore early v4 repair lost rows")
  955. for event in connection.execute(
  956. """SELECT cursor, payload_json FROM group_events
  957. WHERE event_type = 'agent.status'"""
  958. ).fetchall():
  959. try:
  960. payload = cls._load_json(event["payload_json"])
  961. except (TypeError, ValueError, json.JSONDecodeError) as error:
  962. raise GroupStoreError("Stored event is corrupt") from error
  963. if not isinstance(payload, dict):
  964. raise GroupStoreError("Stored event is corrupt")
  965. payload.pop("topicId", None)
  966. encoded = cls._canonical_json(payload)
  967. if encoded != event["payload_json"]:
  968. connection.execute(
  969. "UPDATE group_events SET payload_json = ? WHERE cursor = ?",
  970. (encoded, event["cursor"]),
  971. )
  972. for ledger in connection.execute(
  973. "SELECT request_id, operation, response_json FROM group_idempotency"
  974. ).fetchall():
  975. if cls._interaction_response_operation(ledger["operation"]) is None:
  976. continue
  977. try:
  978. response = cls._load_json(ledger["response_json"])
  979. except (TypeError, ValueError, json.JSONDecodeError) as error:
  980. raise GroupStoreError("Stored interaction response is corrupt") from error
  981. if not isinstance(response, dict):
  982. raise GroupStoreError("Stored interaction response is corrupt")
  983. run = response.get("run")
  984. if response.get("state") != "failed" or not isinstance(run, dict):
  985. continue
  986. run.pop("topicId", None)
  987. encoded = cls._canonical_json(response)
  988. if encoded != ledger["response_json"]:
  989. connection.execute(
  990. """UPDATE group_idempotency SET response_json = ?
  991. WHERE request_id = ?""",
  992. (encoded, ledger["request_id"]),
  993. )
  994. connection.execute(
  995. """UPDATE group_topics AS topic
  996. SET updated_at = COALESCE(
  997. (SELECT MAX(message.updated_at) FROM group_messages AS message
  998. WHERE message.room_id = topic.room_id
  999. AND message.topic_id = topic.id AND message.visible = 1),
  1000. (SELECT MAX(message.updated_at) FROM group_messages AS message
  1001. WHERE message.room_id = topic.room_id
  1002. AND message.topic_id = topic.id),
  1003. topic.created_at
  1004. )"""
  1005. )
  1006. @classmethod
  1007. def _migrate_v4_to_v5(cls, connection: sqlite3.Connection) -> None:
  1008. """Assign one durable host per room and freeze required-reply run state."""
  1009. connection.execute(
  1010. "ALTER TABLE group_agents ADD COLUMN is_host INTEGER NOT NULL DEFAULT 0"
  1011. )
  1012. connection.execute(
  1013. """ALTER TABLE group_agent_runs
  1014. ADD COLUMN required_reply INTEGER NOT NULL DEFAULT 0"""
  1015. )
  1016. for room in connection.execute(
  1017. "SELECT id FROM group_rooms ORDER BY created_at ASC, id ASC"
  1018. ).fetchall():
  1019. host = connection.execute(
  1020. """SELECT id FROM group_agents WHERE room_id = ?
  1021. ORDER BY CASE
  1022. WHEN enabled = 1 AND reply_without_mention = 1 THEN 0
  1023. WHEN enabled = 1 THEN 1
  1024. ELSE 2
  1025. END, created_at ASC, id ASC
  1026. LIMIT 1""",
  1027. (room["id"],),
  1028. ).fetchone()
  1029. if host is None:
  1030. raise GroupStoreError("GroupStore room has no administrator candidate")
  1031. connection.execute(
  1032. "UPDATE group_agents SET is_host = 1 WHERE id = ? AND room_id = ?",
  1033. (host["id"], room["id"]),
  1034. )
  1035. def add_host_flags(value: object) -> object:
  1036. if isinstance(value, list):
  1037. return [add_host_flags(item) for item in value]
  1038. if not isinstance(value, dict):
  1039. return value
  1040. rewritten = {key: add_host_flags(item) for key, item in value.items()}
  1041. identity = rewritten.get("id")
  1042. room_id = rewritten.get("roomId")
  1043. if (
  1044. isinstance(identity, str)
  1045. and isinstance(room_id, str)
  1046. and {"profile", "displayName", "replyWithoutMention"}
  1047. <= rewritten.keys()
  1048. ):
  1049. agent = connection.execute(
  1050. "SELECT is_host FROM group_agents WHERE id = ? AND room_id = ?",
  1051. (identity, room_id),
  1052. ).fetchone()
  1053. rewritten["isHost"] = bool(agent["is_host"]) if agent else False
  1054. return rewritten
  1055. for ledger in connection.execute(
  1056. "SELECT request_id, response_json FROM group_idempotency"
  1057. ).fetchall():
  1058. try:
  1059. response = cls._load_json(ledger["response_json"])
  1060. except (TypeError, ValueError, json.JSONDecodeError) as error:
  1061. raise GroupStoreError("Stored idempotency response is corrupt") from error
  1062. rewritten = cls._canonical_json(add_host_flags(response))
  1063. if rewritten != ledger["response_json"]:
  1064. connection.execute(
  1065. """UPDATE group_idempotency SET response_json = ?
  1066. WHERE request_id = ?""",
  1067. (rewritten, ledger["request_id"]),
  1068. )
  1069. for event in connection.execute(
  1070. "SELECT cursor, payload_json FROM group_events"
  1071. ).fetchall():
  1072. try:
  1073. payload = cls._load_json(event["payload_json"])
  1074. except (TypeError, ValueError, json.JSONDecodeError) as error:
  1075. raise GroupStoreError("Stored event is corrupt") from error
  1076. rewritten = cls._canonical_json(add_host_flags(payload))
  1077. if rewritten != event["payload_json"]:
  1078. connection.execute(
  1079. "UPDATE group_events SET payload_json = ? WHERE cursor = ?",
  1080. (rewritten, event["cursor"]),
  1081. )
  1082. connection.execute(
  1083. "UPDATE group_meta SET value = '5' WHERE key = 'schema_version'"
  1084. )
  1085. @staticmethod
  1086. def _migrate_v5_to_v6(connection: sqlite3.Connection) -> None:
  1087. """Add immutable configured and terminal effective model attribution."""
  1088. for column in (
  1089. "requested_model",
  1090. "requested_provider",
  1091. "actual_model",
  1092. "actual_provider",
  1093. ):
  1094. connection.execute(
  1095. f"ALTER TABLE group_agent_runs ADD COLUMN {column} TEXT"
  1096. )
  1097. connection.execute(
  1098. "UPDATE group_meta SET value = '6' WHERE key = 'schema_version'"
  1099. )
  1100. @staticmethod
  1101. def _migrate_v6_to_v7(connection: sqlite3.Connection) -> None:
  1102. """Add requested and effective reasoning/fast execution attribution."""
  1103. for definition in (
  1104. "requested_reasoning_effort TEXT",
  1105. "requested_fast_mode INTEGER",
  1106. "actual_reasoning_effort TEXT",
  1107. "actual_fast_mode INTEGER",
  1108. ):
  1109. connection.execute(
  1110. f"ALTER TABLE group_agent_runs ADD COLUMN {definition}"
  1111. )
  1112. connection.execute(
  1113. "UPDATE group_meta SET value = '7' WHERE key = 'schema_version'"
  1114. )
  1115. @staticmethod
  1116. def _migrate_v7_to_v8(connection: sqlite3.Connection) -> None:
  1117. """Add one monotonic human-read watermark to every topic."""
  1118. columns = {
  1119. str(row["name"])
  1120. for row in connection.execute("PRAGMA table_info(group_topics)")
  1121. }
  1122. if "last_read_message_seq" not in columns:
  1123. connection.execute(
  1124. """ALTER TABLE group_topics ADD COLUMN
  1125. last_read_message_seq INTEGER NOT NULL DEFAULT 0"""
  1126. )
  1127. connection.execute(
  1128. """UPDATE group_topics
  1129. SET last_read_message_seq = COALESCE((
  1130. SELECT MAX(message.seq) FROM group_messages AS message
  1131. WHERE message.topic_id = group_topics.id
  1132. AND message.visible = 1
  1133. ), 0)"""
  1134. )
  1135. connection.execute(
  1136. "UPDATE group_meta SET value = '8' WHERE key = 'schema_version'"
  1137. )
  1138. @staticmethod
  1139. def _migrate_v8_to_v9(connection: sqlite3.Connection) -> None:
  1140. """Make topics independently archivable without removing history."""
  1141. columns = {
  1142. str(row["name"])
  1143. for row in connection.execute("PRAGMA table_info(group_topics)")
  1144. }
  1145. if "archived" not in columns:
  1146. connection.execute(
  1147. "ALTER TABLE group_topics ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"
  1148. )
  1149. connection.execute("DROP INDEX IF EXISTS idx_group_topics_room_ordering")
  1150. connection.execute(
  1151. "CREATE INDEX IF NOT EXISTS idx_group_topics_room_ordering "
  1152. "ON group_topics(room_id, archived, updated_at DESC, id DESC)"
  1153. )
  1154. connection.execute(
  1155. "UPDATE group_meta SET value = '9' WHERE key = 'schema_version'"
  1156. )
  1157. @staticmethod
  1158. def _migrate_v9_to_v10(connection: sqlite3.Connection) -> None:
  1159. columns = {
  1160. str(row["name"])
  1161. for row in connection.execute("PRAGMA table_info(group_topics)")
  1162. }
  1163. if "pinned" not in columns:
  1164. connection.execute(
  1165. "ALTER TABLE group_topics ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0"
  1166. )
  1167. connection.execute("DROP INDEX IF EXISTS idx_group_topics_room_ordering")
  1168. connection.execute(
  1169. "CREATE INDEX IF NOT EXISTS idx_group_topics_room_ordering "
  1170. "ON group_topics(room_id, archived, pinned DESC, updated_at DESC, id DESC)"
  1171. )
  1172. connection.execute(
  1173. "UPDATE group_meta SET value = '10' WHERE key = 'schema_version'"
  1174. )
  1175. @staticmethod
  1176. def _migrate_v10_to_v11(connection: sqlite3.Connection) -> None:
  1177. columns = {
  1178. str(row["name"])
  1179. for row in connection.execute("PRAGMA table_info(group_rooms)")
  1180. }
  1181. if "orchestration_mode" not in columns:
  1182. connection.execute(
  1183. "ALTER TABLE group_rooms ADD COLUMN orchestration_mode "
  1184. "TEXT NOT NULL DEFAULT 'free'"
  1185. )
  1186. connection.execute(
  1187. "UPDATE group_meta SET value = '11' WHERE key = 'schema_version'"
  1188. )
  1189. @staticmethod
  1190. def _migrate_v11_to_v12(connection: sqlite3.Connection) -> None:
  1191. columns = {
  1192. str(row["name"])
  1193. for row in connection.execute("PRAGMA table_info(group_rooms)")
  1194. }
  1195. if "instructions" not in columns:
  1196. connection.execute(
  1197. "ALTER TABLE group_rooms ADD COLUMN instructions "
  1198. "TEXT NOT NULL DEFAULT ''"
  1199. )
  1200. connection.execute(
  1201. "UPDATE group_meta SET value = '12' WHERE key = 'schema_version'"
  1202. )
  1203. @staticmethod
  1204. def _migrate_v12_to_v13(connection: sqlite3.Connection) -> None:
  1205. columns = {
  1206. str(row["name"])
  1207. for row in connection.execute("PRAGMA table_info(group_agents)")
  1208. }
  1209. if "node_id" not in columns:
  1210. connection.execute(
  1211. "ALTER TABLE group_agents ADD COLUMN node_id "
  1212. "TEXT NOT NULL DEFAULT 'local'"
  1213. )
  1214. if "execution_profile" not in columns:
  1215. connection.execute(
  1216. "ALTER TABLE group_agents ADD COLUMN execution_profile TEXT"
  1217. )
  1218. if "node_label" not in columns:
  1219. connection.execute(
  1220. "ALTER TABLE group_agents ADD COLUMN node_label "
  1221. "TEXT NOT NULL DEFAULT ''"
  1222. )
  1223. connection.execute(
  1224. "UPDATE group_meta SET value = '13' WHERE key = 'schema_version'"
  1225. )
  1226. @staticmethod
  1227. def _migrate_v13_to_v14(connection: sqlite3.Connection) -> None:
  1228. columns = {
  1229. str(row["name"])
  1230. for row in connection.execute("PRAGMA table_info(group_rooms)")
  1231. }
  1232. if "avatar_data_url" not in columns:
  1233. connection.execute(
  1234. "ALTER TABLE group_rooms ADD COLUMN avatar_data_url "
  1235. "TEXT NOT NULL DEFAULT ''"
  1236. )
  1237. connection.execute(
  1238. "UPDATE group_meta SET value = '14' WHERE key = 'schema_version'"
  1239. )
  1240. @staticmethod
  1241. def _validate_v8_columns(connection: sqlite3.Connection) -> None:
  1242. expected_columns = {
  1243. "group_meta": {"key", "value"},
  1244. "group_rooms": {"id", "name", "cwd", "instructions", "avatar_data_url", "max_reply_rounds", "orchestration_mode", "created_at", "updated_at", "archived"},
  1245. "group_topics": {"id", "room_id", "title", "last_read_message_seq", "created_at", "updated_at", "archived", "pinned"},
  1246. "group_agents": {"id", "room_id", "profile", "node_id", "node_label", "execution_profile", "display_name", "display_name_key", "description", "stored_session_id", "last_context_message_seq", "enabled", "reply_without_mention", "is_host", "model_override", "provider_override", "reasoning_effort_override", "fast_mode_override", "session_config_json", "created_at", "updated_at"},
  1247. "group_agent_topic_state": {"agent_id", "topic_id", "last_context_message_seq", "created_at", "updated_at"},
  1248. "group_messages": {"seq", "id", "room_id", "topic_id", "sender_kind", "sender_id", "sender_name", "root_message_id", "reply_to_message_id", "client_message_id", "content", "reasoning", "tool_state_json", "status", "error", "visible", "created_at", "updated_at"},
  1249. "group_agent_runs": {"id", "room_id", "topic_id", "agent_id", "trigger_message_id", "response_message_id", "root_message_id", "depth", "reply_mode", "required_reply", "status", "runtime_session_id", "requested_model", "requested_provider", "requested_reasoning_effort", "requested_fast_mode", "actual_model", "actual_provider", "actual_reasoning_effort", "actual_fast_mode", "error", "created_at", "updated_at"},
  1250. "group_interactions": {"id", "room_id", "topic_id", "agent_id", "run_id", "kind", "payload_json", "status", "created_at", "resolved_at"},
  1251. "group_events": {"cursor", "epoch", "room_id", "event_type", "payload_json", "created_at"},
  1252. "group_idempotency": {"request_id", "operation", "request_hash", "response_json", "created_at"},
  1253. }
  1254. exact_specs = {
  1255. ("group_rooms", "max_reply_rounds"): ("INTEGER", 1, "3"),
  1256. ("group_rooms", "orchestration_mode"): ("TEXT", 1, "'free'"),
  1257. ("group_rooms", "instructions"): ("TEXT", 1, "''"),
  1258. ("group_rooms", "avatar_data_url"): ("TEXT", 1, "''"),
  1259. ("group_agents", "reply_without_mention"): ("INTEGER", 1, "0"),
  1260. ("group_agents", "is_host"): ("INTEGER", 1, "0"),
  1261. ("group_agents", "node_id"): ("TEXT", 1, "'local'"),
  1262. ("group_agents", "node_label"): ("TEXT", 1, "''"),
  1263. ("group_messages", "visible"): ("INTEGER", 1, "1"),
  1264. ("group_agent_runs", "reply_mode"): ("TEXT", 1, "'mentioned'"),
  1265. ("group_agent_runs", "required_reply"): ("INTEGER", 1, "0"),
  1266. ("group_messages", "topic_id"): ("TEXT", 1, None),
  1267. ("group_agent_runs", "topic_id"): ("TEXT", 1, None),
  1268. ("group_interactions", "topic_id"): ("TEXT", 1, None),
  1269. ("group_agent_topic_state", "last_context_message_seq"): ("INTEGER", 1, "0"),
  1270. ("group_topics", "last_read_message_seq"): ("INTEGER", 1, "0"),
  1271. ("group_topics", "archived"): ("INTEGER", 1, "0"),
  1272. ("group_topics", "pinned"): ("INTEGER", 1, "0"),
  1273. }
  1274. for table, required in expected_columns.items():
  1275. rows = list(connection.execute(f"PRAGMA table_info({table})"))
  1276. columns = {row["name"]: row for row in rows}
  1277. if set(columns) != required:
  1278. raise GroupStoreError("GroupStore schema is partial or corrupt")
  1279. for (target_table, name), spec in exact_specs.items():
  1280. if target_table != table:
  1281. continue
  1282. row = columns[name]
  1283. actual = (str(row["type"]).upper(), int(row["notnull"]), row["dflt_value"])
  1284. if actual != spec:
  1285. raise GroupStoreError("GroupStore schema is partial or corrupt")
  1286. for table in ("group_messages", "group_agent_runs", "group_interactions"):
  1287. if GroupStore._topic_foreign_key_kind(connection, table) != "final":
  1288. raise GroupStoreError("GroupStore schema is partial or corrupt")
  1289. @staticmethod
  1290. def _validate_v7_values(connection: sqlite3.Connection) -> None:
  1291. invalid_value_queries = (
  1292. f"""SELECT 1 FROM group_rooms
  1293. WHERE typeof(max_reply_rounds) != 'integer'
  1294. OR (max_reply_rounds != -1 AND
  1295. (max_reply_rounds < 1 OR
  1296. max_reply_rounds > {MAX_FINITE_REPLY_ROUNDS}))
  1297. LIMIT 1""",
  1298. """SELECT 1 FROM group_rooms
  1299. WHERE typeof(orchestration_mode) != 'text'
  1300. OR orchestration_mode NOT IN ('free', 'host')
  1301. LIMIT 1""",
  1302. f"""SELECT 1 FROM group_rooms
  1303. WHERE typeof(instructions) != 'text'
  1304. OR length(instructions) > {MAX_ROOM_INSTRUCTIONS_LENGTH}
  1305. OR instr(instructions, char(13)) > 0
  1306. LIMIT 1""",
  1307. """SELECT 1 FROM group_agents
  1308. WHERE typeof(reply_without_mention) != 'integer'
  1309. OR reply_without_mention NOT IN (0, 1)
  1310. LIMIT 1""",
  1311. """SELECT 1 FROM group_agents
  1312. WHERE typeof(is_host) != 'integer' OR is_host NOT IN (0, 1)
  1313. LIMIT 1""",
  1314. """SELECT 1 FROM group_agents
  1315. WHERE typeof(node_id) != 'text'
  1316. OR typeof(node_label) != 'text'
  1317. OR length(node_label) > 100
  1318. OR (node_id = 'local' AND execution_profile IS NOT NULL)
  1319. OR (node_id != 'local' AND
  1320. (execution_profile IS NULL
  1321. OR profile != 'node:' || node_id || ':' || execution_profile))
  1322. LIMIT 1""",
  1323. """SELECT 1 FROM group_rooms AS room
  1324. LEFT JOIN group_agents AS agent ON agent.room_id = room.id
  1325. GROUP BY room.id
  1326. HAVING SUM(CASE WHEN agent.is_host = 1 THEN 1 ELSE 0 END) != 1
  1327. LIMIT 1""",
  1328. """SELECT 1 FROM group_agents AS host
  1329. WHERE host.is_host = 1 AND host.enabled = 0
  1330. AND EXISTS (
  1331. SELECT 1 FROM group_agents AS candidate
  1332. WHERE candidate.room_id = host.room_id
  1333. AND candidate.enabled = 1 AND candidate.id != host.id
  1334. )
  1335. LIMIT 1""",
  1336. """SELECT 1 FROM group_agents
  1337. WHERE fast_mode_override IS NOT NULL
  1338. AND (typeof(fast_mode_override) != 'integer'
  1339. OR fast_mode_override NOT IN (0, 1))
  1340. LIMIT 1""",
  1341. """SELECT 1 FROM group_agents
  1342. WHERE reasoning_effort_override IS NOT NULL
  1343. AND reasoning_effort_override NOT IN
  1344. ('none','minimal','low','medium','high','xhigh','max','ultra')
  1345. LIMIT 1""",
  1346. """SELECT 1 FROM group_messages
  1347. WHERE typeof(visible) != 'integer'
  1348. OR visible NOT IN (0, 1)
  1349. LIMIT 1""",
  1350. """SELECT 1 FROM group_agent_runs
  1351. WHERE typeof(reply_mode) != 'text'
  1352. OR reply_mode NOT IN ('mentioned', 'automatic')
  1353. LIMIT 1""",
  1354. """SELECT 1 FROM group_agent_runs
  1355. WHERE typeof(required_reply) != 'integer'
  1356. OR required_reply NOT IN (0, 1)
  1357. OR (required_reply = 1 AND reply_mode != 'automatic')
  1358. LIMIT 1""",
  1359. """SELECT 1 FROM group_agent_runs
  1360. WHERE (requested_model IS NULL) != (requested_provider IS NULL)
  1361. OR (actual_model IS NULL) != (actual_provider IS NULL)
  1362. OR length(COALESCE(requested_model, '')) > 4096
  1363. OR length(COALESCE(requested_provider, '')) > 4096
  1364. OR length(COALESCE(actual_model, '')) > 4096
  1365. OR length(COALESCE(actual_provider, '')) > 4096
  1366. LIMIT 1""",
  1367. """SELECT 1 FROM group_agent_runs
  1368. WHERE (requested_reasoning_effort IS NOT NULL
  1369. AND requested_reasoning_effort NOT IN
  1370. ('none','minimal','low','medium','high','xhigh','max','ultra'))
  1371. OR (actual_reasoning_effort IS NOT NULL
  1372. AND actual_reasoning_effort NOT IN
  1373. ('none','minimal','low','medium','high','xhigh','max','ultra'))
  1374. OR (requested_fast_mode IS NOT NULL
  1375. AND (typeof(requested_fast_mode) != 'integer'
  1376. OR requested_fast_mode NOT IN (0, 1)))
  1377. OR (actual_fast_mode IS NOT NULL
  1378. AND (typeof(actual_fast_mode) != 'integer'
  1379. OR actual_fast_mode NOT IN (0, 1)))
  1380. LIMIT 1""",
  1381. """SELECT 1 FROM group_agent_runs AS run
  1382. JOIN group_messages AS response
  1383. ON response.id = run.response_message_id
  1384. WHERE run.required_reply = 1 AND response.visible != 1
  1385. LIMIT 1""",
  1386. """SELECT 1 FROM group_agent_topic_state
  1387. WHERE typeof(last_context_message_seq) != 'integer'
  1388. OR last_context_message_seq < 0
  1389. LIMIT 1""",
  1390. """SELECT 1 FROM group_topics AS topic
  1391. WHERE typeof(topic.last_read_message_seq) != 'integer'
  1392. OR topic.last_read_message_seq < 0
  1393. OR topic.last_read_message_seq > COALESCE((
  1394. SELECT MAX(message.seq) FROM group_messages AS message
  1395. WHERE message.topic_id = topic.id
  1396. AND message.visible = 1
  1397. ), 0)
  1398. LIMIT 1""",
  1399. """SELECT 1 FROM group_messages AS message
  1400. LEFT JOIN group_topics AS topic ON topic.id = message.topic_id
  1401. WHERE topic.id IS NULL OR topic.room_id != message.room_id
  1402. LIMIT 1""",
  1403. """SELECT 1 FROM group_agent_runs AS run
  1404. LEFT JOIN group_topics AS topic ON topic.id = run.topic_id
  1405. LEFT JOIN group_messages AS trigger ON trigger.id = run.trigger_message_id
  1406. LEFT JOIN group_messages AS response ON response.id = run.response_message_id
  1407. WHERE topic.id IS NULL OR topic.room_id != run.room_id
  1408. OR trigger.id IS NULL OR response.id IS NULL
  1409. OR trigger.topic_id != run.topic_id
  1410. OR response.topic_id != run.topic_id
  1411. LIMIT 1""",
  1412. """SELECT 1 FROM group_interactions AS interaction
  1413. LEFT JOIN group_agent_runs AS run ON run.id = interaction.run_id
  1414. WHERE run.id IS NULL OR interaction.topic_id != run.topic_id
  1415. OR interaction.room_id != run.room_id
  1416. LIMIT 1""",
  1417. """SELECT 1 FROM group_agent_topic_state AS state
  1418. LEFT JOIN group_agents AS agent ON agent.id = state.agent_id
  1419. LEFT JOIN group_topics AS topic ON topic.id = state.topic_id
  1420. WHERE agent.id IS NULL OR topic.id IS NULL
  1421. OR agent.room_id != topic.room_id
  1422. LIMIT 1""",
  1423. )
  1424. for query in invalid_value_queries:
  1425. if connection.execute(query).fetchone() is not None:
  1426. raise GroupStoreError("GroupStore stored values are corrupt")
  1427. for table, column in (
  1428. ("group_topics", "id"),
  1429. ("group_topics", "room_id"),
  1430. ("group_messages", "topic_id"),
  1431. ("group_agent_runs", "topic_id"),
  1432. ("group_interactions", "topic_id"),
  1433. ):
  1434. for row in connection.execute(f"SELECT {column} AS value FROM {table}"):
  1435. try:
  1436. canonical = str(uuid.UUID(row["value"]))
  1437. except (TypeError, ValueError, AttributeError) as error:
  1438. raise GroupStoreError("GroupStore stored values are corrupt") from error
  1439. if row["value"] != canonical:
  1440. raise GroupStoreError("GroupStore stored values are corrupt")
  1441. @staticmethod
  1442. def _ensure_indexes(connection: sqlite3.Connection) -> None:
  1443. """Repair v1 operational indexes without a schema-version migration."""
  1444. def canonical_index_sql(value: str) -> str:
  1445. without_guard = re.sub(
  1446. r"\bIF\s+NOT\s+EXISTS\b", "", value, flags=re.IGNORECASE
  1447. )
  1448. return " ".join(without_guard.split()).casefold()
  1449. indexes = {
  1450. "idx_group_rooms_ordering": (
  1451. [("archived", 0), ("updated_at", 1), ("id", 1)],
  1452. "group_rooms(archived, updated_at DESC, id DESC)",
  1453. ),
  1454. "idx_group_topics_room_ordering": (
  1455. [("room_id", 0), ("archived", 0), ("pinned", 1), ("updated_at", 1), ("id", 1)],
  1456. "group_topics(room_id, archived, pinned DESC, updated_at DESC, id DESC)",
  1457. ),
  1458. "idx_group_messages_room_seq": (
  1459. [("room_id", 0), ("seq", 0)],
  1460. "group_messages(room_id, seq)",
  1461. ),
  1462. "idx_group_messages_topic_seq": (
  1463. [("topic_id", 0), ("seq", 0)],
  1464. "group_messages(topic_id, seq)",
  1465. ),
  1466. "idx_group_agent_runs_room": (
  1467. [("room_id", 0)],
  1468. "group_agent_runs(room_id)",
  1469. ),
  1470. "idx_group_agent_runs_topic": (
  1471. [("topic_id", 0)],
  1472. "group_agent_runs(topic_id)",
  1473. ),
  1474. "idx_group_agent_runs_agent_status": (
  1475. [("agent_id", 0), ("status", 0)],
  1476. "group_agent_runs(agent_id, status)",
  1477. ),
  1478. "idx_group_agent_runs_room_status": (
  1479. [("room_id", 0), ("status", 0)],
  1480. "group_agent_runs(room_id, status)",
  1481. ),
  1482. "idx_group_agent_runs_status_order": (
  1483. [("status", 0), ("created_at", 0), ("id", 0)],
  1484. "group_agent_runs(status, created_at, id)",
  1485. ),
  1486. "idx_group_idempotency_operation": (
  1487. [("operation", 0)],
  1488. "group_idempotency(operation)",
  1489. ),
  1490. "idx_group_interactions_run_status_kind": (
  1491. [("run_id", 0), ("status", 0), ("kind", 0)],
  1492. "group_interactions(run_id, status, kind)",
  1493. ),
  1494. "idx_group_interactions_room_agent": (
  1495. [("room_id", 0), ("agent_id", 0)],
  1496. "group_interactions(room_id, agent_id)",
  1497. ),
  1498. "idx_group_interactions_topic": (
  1499. [("topic_id", 0)],
  1500. "group_interactions(topic_id)",
  1501. ),
  1502. }
  1503. for name, (expected, definition) in indexes.items():
  1504. columns = [
  1505. (row["name"], row["desc"])
  1506. for row in connection.execute(f"PRAGMA index_xinfo({name})")
  1507. if row["key"]
  1508. ]
  1509. stored = connection.execute(
  1510. """SELECT sql FROM sqlite_master
  1511. WHERE type = 'index' AND name = ?""",
  1512. (name,),
  1513. ).fetchone()
  1514. expected_sql = canonical_index_sql(f"CREATE INDEX {name} ON {definition}")
  1515. stored_sql = (
  1516. None
  1517. if stored is None or stored["sql"] is None
  1518. else canonical_index_sql(stored["sql"])
  1519. )
  1520. if columns == expected and stored_sql == expected_sql:
  1521. continue
  1522. connection.execute(f"DROP INDEX IF EXISTS {name}")
  1523. connection.execute(f"CREATE INDEX {name} ON {definition}")
  1524. host_definition = """CREATE UNIQUE INDEX idx_group_agents_room_host
  1525. ON group_agents(room_id) WHERE is_host = 1"""
  1526. stored = connection.execute(
  1527. """SELECT sql FROM sqlite_master
  1528. WHERE type = 'index' AND name = ?""",
  1529. ("idx_group_agents_room_host",),
  1530. ).fetchone()
  1531. expected_sql = canonical_index_sql(host_definition)
  1532. stored_sql = (
  1533. None
  1534. if stored is None or stored["sql"] is None
  1535. else canonical_index_sql(stored["sql"])
  1536. )
  1537. if stored_sql != expected_sql:
  1538. connection.execute("DROP INDEX IF EXISTS idx_group_agents_room_host")
  1539. try:
  1540. connection.execute(host_definition)
  1541. except sqlite3.IntegrityError as error:
  1542. raise GroupStoreError("Room administrators are not unique") from error
  1543. active_runtime_definition = """CREATE UNIQUE INDEX
  1544. idx_group_agent_runs_active_runtime
  1545. ON group_agent_runs(runtime_session_id)
  1546. WHERE runtime_session_id IS NOT NULL
  1547. AND status IN ('running', 'awaiting_input')"""
  1548. stored = connection.execute(
  1549. """SELECT sql FROM sqlite_master
  1550. WHERE type = 'index' AND name = ?""",
  1551. ("idx_group_agent_runs_active_runtime",),
  1552. ).fetchone()
  1553. expected_sql = canonical_index_sql(active_runtime_definition)
  1554. stored_sql = (
  1555. None
  1556. if stored is None or stored["sql"] is None
  1557. else canonical_index_sql(stored["sql"])
  1558. )
  1559. if stored_sql == expected_sql:
  1560. return
  1561. connection.execute("DROP INDEX IF EXISTS idx_group_agent_runs_active_runtime")
  1562. try:
  1563. connection.execute(active_runtime_definition)
  1564. except sqlite3.IntegrityError as error:
  1565. raise GroupStoreError("Active runtime sessions are not unique") from error
  1566. @staticmethod
  1567. def _user_table_names(connection: sqlite3.Connection) -> set[str]:
  1568. return {
  1569. row["name"]
  1570. for row in connection.execute(
  1571. """SELECT name FROM sqlite_master
  1572. WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"""
  1573. )
  1574. }
  1575. @staticmethod
  1576. def _is_busy_or_locked(error: sqlite3.OperationalError) -> bool:
  1577. message = str(error).lower()
  1578. return "locked" in message or "busy" in message
  1579. @staticmethod
  1580. def _validated_schema_version(value: str) -> int:
  1581. try:
  1582. version = int(value)
  1583. except (TypeError, ValueError) as error:
  1584. raise GroupStoreError("GroupStore schema_version is corrupt") from error
  1585. if value != str(SCHEMA_VERSION) or version != SCHEMA_VERSION:
  1586. raise GroupStoreError("Unsupported or corrupt GroupStore schema_version")
  1587. return version
  1588. def _metadata_value(self, key: str) -> str:
  1589. try:
  1590. with self.connection() as connection:
  1591. return self._metadata_value_from_connection(connection, key)
  1592. except sqlite3.OperationalError as error:
  1593. raise GroupStoreError("GroupStore is not initialized") from error
  1594. @staticmethod
  1595. def _metadata_value_from_connection(
  1596. connection: sqlite3.Connection, key: str
  1597. ) -> str:
  1598. row = connection.execute(
  1599. "SELECT value FROM group_meta WHERE key = ?", (key,)
  1600. ).fetchone()
  1601. if row is None:
  1602. raise GroupStoreError(f"GroupStore metadata is missing {key}")
  1603. return row["value"]
  1604. @staticmethod
  1605. def _validated_epoch(value: str) -> str:
  1606. try:
  1607. canonical = str(uuid.UUID(value))
  1608. except (ValueError, AttributeError) as error:
  1609. raise GroupStoreError("GroupStore journal_epoch is corrupt") from error
  1610. if value != canonical:
  1611. raise GroupStoreError("GroupStore journal_epoch is corrupt")
  1612. return canonical
  1613. @contextmanager
  1614. def read_transaction(self) -> Iterator[sqlite3.Connection]:
  1615. """Yield one consistent read snapshot and always close its connection."""
  1616. with self.connection() as connection:
  1617. connection.execute("BEGIN")
  1618. try:
  1619. yield connection
  1620. except Exception:
  1621. connection.execute("ROLLBACK")
  1622. raise
  1623. else:
  1624. connection.execute("COMMIT")
  1625. @contextmanager
  1626. def write_transaction(self) -> Iterator[sqlite3.Connection]:
  1627. """Yield one immediately-locked write transaction and always close it."""
  1628. with self.connection() as connection:
  1629. connection.execute("BEGIN IMMEDIATE")
  1630. try:
  1631. yield connection
  1632. except Exception:
  1633. connection.execute("ROLLBACK")
  1634. raise
  1635. else:
  1636. connection.execute("COMMIT")
  1637. def execute_idempotent(
  1638. self,
  1639. operation: str,
  1640. request_id: str,
  1641. payload: Mapping[str, object],
  1642. action: Callable[[sqlite3.Connection], dict[str, object]],
  1643. ) -> dict[str, object]:
  1644. """Execute a mutation exactly once for the canonical request and payload."""
  1645. canonical_request_id = self._canonical_uuid(request_id, "requestId")
  1646. request_hash = hashlib.sha256(
  1647. self._canonical_json(payload).encode()
  1648. ).hexdigest()
  1649. try:
  1650. with self.write_transaction() as connection:
  1651. existing = connection.execute(
  1652. """SELECT operation, request_hash, response_json FROM group_idempotency
  1653. WHERE request_id = ?""",
  1654. (canonical_request_id,),
  1655. ).fetchone()
  1656. if existing is not None:
  1657. if (
  1658. existing["operation"] != operation
  1659. or existing["request_hash"] != request_hash
  1660. ):
  1661. raise IdempotencyConflict(
  1662. "requestId was already used for a different request"
  1663. )
  1664. stored_response = existing["response_json"]
  1665. try:
  1666. response = self._load_json(stored_response)
  1667. except (ValueError, json.JSONDecodeError, TypeError) as error:
  1668. raise GroupStoreError(
  1669. "Stored idempotency response is corrupt"
  1670. ) from error
  1671. if (
  1672. not isinstance(response, dict)
  1673. or self._canonical_json(response) != stored_response
  1674. ):
  1675. raise GroupStoreError("Stored idempotency response is corrupt")
  1676. return response
  1677. response = action(connection)
  1678. response_json = self._canonical_json(response)
  1679. connection.execute(
  1680. """INSERT INTO group_idempotency
  1681. (request_id, operation, request_hash, response_json, created_at)
  1682. VALUES (?, ?, ?, ?, ?)""",
  1683. (
  1684. canonical_request_id,
  1685. operation,
  1686. request_hash,
  1687. response_json,
  1688. self._now(),
  1689. ),
  1690. )
  1691. return response
  1692. except sqlite3.IntegrityError as error:
  1693. raise self._integrity_conflict(error) from error
  1694. def create_room(self, command: Mapping[str, object]) -> dict[str, object]:
  1695. command = self._command(
  1696. command,
  1697. {
  1698. "requestId",
  1699. "name",
  1700. "cwd",
  1701. "instructions",
  1702. "avatar",
  1703. "maxReplyRounds",
  1704. "orchestrationMode",
  1705. "agents",
  1706. },
  1707. )
  1708. request_id = self._command_request_id(command)
  1709. def action(connection: sqlite3.Connection) -> dict[str, object]:
  1710. name = normalize_room_name(self._string(command, "name"))
  1711. cwd = normalize_room_cwd(self._string(command, "cwd"))
  1712. instructions = normalize_room_instructions(
  1713. command.get("instructions", "")
  1714. )
  1715. avatar = normalize_room_avatar(command.get("avatar", ""))
  1716. max_reply_rounds = normalize_max_reply_rounds(
  1717. command.get("maxReplyRounds", DEFAULT_MAX_REPLY_ROUNDS)
  1718. )
  1719. orchestration_mode = self._orchestration_mode(
  1720. command.get("orchestrationMode", "free")
  1721. )
  1722. agents = self._new_agents(command.get("agents"))
  1723. now = self._now()
  1724. room_id = str(uuid.uuid4())
  1725. connection.execute(
  1726. """INSERT INTO group_rooms
  1727. (id, name, cwd, instructions, avatar_data_url, max_reply_rounds, orchestration_mode,
  1728. created_at, updated_at, archived)
  1729. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)""",
  1730. (
  1731. room_id, name, cwd, instructions, avatar, max_reply_rounds,
  1732. orchestration_mode, now, now,
  1733. ),
  1734. )
  1735. for agent in agents:
  1736. self._insert_agent(connection, room_id, agent, now)
  1737. room = self._room_detail(connection, room_id, include_archived=True)
  1738. self._append_event(
  1739. connection, room_id, "room.created", room, created_at=now
  1740. )
  1741. return room
  1742. return self.execute_idempotent("room.created", request_id, command, action)
  1743. def get_room(
  1744. self, room_id: str, *, include_archived: bool = False
  1745. ) -> dict[str, object]:
  1746. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  1747. with self.read_transaction() as connection:
  1748. return self._room_detail(
  1749. connection, canonical_room_id, include_archived=include_archived
  1750. )
  1751. def room_snapshot(self, room_id: str) -> dict[str, object]:
  1752. """Read all state needed to recover one active room in one snapshot."""
  1753. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  1754. with self.read_transaction() as connection:
  1755. result = self._room_detail(
  1756. connection, canonical_room_id, include_archived=False
  1757. )
  1758. run_rows = connection.execute(
  1759. """SELECT * FROM group_agent_runs
  1760. WHERE room_id = ? AND status IN ('queued', 'running', 'awaiting_input')
  1761. ORDER BY created_at ASC, id ASC""",
  1762. (canonical_room_id,),
  1763. ).fetchall()
  1764. interaction_rows = connection.execute(
  1765. """SELECT * FROM group_interactions
  1766. WHERE room_id = ? AND status = 'pending'
  1767. ORDER BY created_at ASC, id ASC""",
  1768. (canonical_room_id,),
  1769. ).fetchall()
  1770. cursor_row = connection.execute(
  1771. "SELECT COALESCE(MAX(cursor), 0) AS cursor FROM group_events"
  1772. ).fetchone()
  1773. status_priority = {
  1774. "queued": 1,
  1775. "running": 2,
  1776. "awaiting_input": 3,
  1777. }
  1778. statuses: dict[str, str] = {}
  1779. for run in run_rows:
  1780. previous = statuses.get(run["agent_id"], "idle")
  1781. if status_priority[run["status"]] > status_priority.get(previous, 0):
  1782. statuses[run["agent_id"]] = run["status"]
  1783. for agent in result["agents"]:
  1784. agent["status"] = statuses.get(agent["id"], "idle")
  1785. result["runs"] = [self._run_wire(row) for row in run_rows]
  1786. result["pendingInteractions"] = [
  1787. self._interaction_wire(row) for row in interaction_rows
  1788. ]
  1789. result["latestCursor"] = int(cursor_row["cursor"])
  1790. return result
  1791. def list_rooms(
  1792. self, *, limit: int, cursor: str | None, archived: bool = False
  1793. ) -> CursorPage:
  1794. if (
  1795. not isinstance(limit, int)
  1796. or isinstance(limit, bool)
  1797. or not 1 <= limit <= 100
  1798. ):
  1799. raise ValueError("limit must be an integer from 1 to 100")
  1800. cursor_values = self._decode_cursor(cursor) if cursor is not None else None
  1801. query = """SELECT id, name, cwd, instructions, avatar_data_url, max_reply_rounds, orchestration_mode,
  1802. created_at, updated_at, archived,
  1803. (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id) AS agent_count,
  1804. (SELECT COUNT(*) FROM group_topics WHERE room_id = group_rooms.id) AS topic_count
  1805. FROM group_rooms WHERE archived = ?"""
  1806. params: list[object] = [int(archived)]
  1807. if cursor_values is not None:
  1808. query += " AND ((updated_at < ?) OR (updated_at = ? AND id < ?))"
  1809. params.extend((cursor_values[0], cursor_values[0], cursor_values[1]))
  1810. query += " ORDER BY updated_at DESC, id DESC LIMIT ?"
  1811. params.append(limit + 1)
  1812. with self.connection() as connection:
  1813. rows = connection.execute(query, params).fetchall()
  1814. items = []
  1815. for row in rows[:limit]:
  1816. summary = self._room_summary(
  1817. row, self._room_avatar_members(connection, str(row["id"]))
  1818. )
  1819. activity = self._room_activity_from_connection(
  1820. connection, str(row["id"])
  1821. )
  1822. summary.update({
  1823. "activeRunCount": activity["activeRunCount"],
  1824. "unreadCount": activity["unreadCount"],
  1825. "lastMessage": activity["lastMessage"],
  1826. })
  1827. items.append(summary)
  1828. has_more = len(rows) > limit
  1829. next_cursor = None
  1830. if has_more and items:
  1831. last = rows[limit - 1]
  1832. next_cursor = self._encode_cursor(last["updated_at"], last["id"])
  1833. return CursorPage(items=items, next_cursor=next_cursor)
  1834. def list_topics(
  1835. self, room_id: str, *, limit: int, cursor: str | None,
  1836. archived: bool = False,
  1837. ) -> CursorPage:
  1838. """Return one room's topics in stable most-recently-active order."""
  1839. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  1840. if (
  1841. not isinstance(limit, int)
  1842. or isinstance(limit, bool)
  1843. or not 1 <= limit <= 100
  1844. ):
  1845. raise ValueError("limit must be an integer from 1 to 100")
  1846. cursor_values = (
  1847. self._decode_topic_cursor(cursor, canonical_room_id)
  1848. if cursor is not None
  1849. else None
  1850. )
  1851. query = """SELECT topic.*,
  1852. (SELECT COUNT(*) FROM group_messages AS counted
  1853. WHERE counted.topic_id = topic.id AND counted.visible = 1)
  1854. AS message_count,
  1855. COALESCE((SELECT CASE WHEN TRIM(latest.content) != ''
  1856. THEN latest.content ELSE latest.error END
  1857. FROM group_messages AS latest
  1858. WHERE latest.topic_id = topic.id AND latest.visible = 1
  1859. AND (TRIM(latest.content) != '' OR TRIM(latest.error) != '')
  1860. ORDER BY latest.seq DESC LIMIT 1), '') AS preview,
  1861. (SELECT COUNT(*) FROM group_messages AS unread
  1862. WHERE unread.topic_id = topic.id AND unread.visible = 1
  1863. AND unread.sender_kind = 'agent'
  1864. AND (TRIM(unread.content) != '' OR TRIM(unread.error) != '')
  1865. AND unread.seq > topic.last_read_message_seq) AS unread_count,
  1866. COALESCE((SELECT MAX(visible_message.seq)
  1867. FROM group_messages AS visible_message
  1868. WHERE visible_message.topic_id = topic.id
  1869. AND visible_message.visible = 1), 0) AS latest_message_seq
  1870. FROM group_topics AS topic
  1871. WHERE topic.room_id = ? AND topic.archived = ?"""
  1872. params: list[object] = [canonical_room_id, int(archived)]
  1873. if cursor_values is not None:
  1874. query += " AND ((topic.updated_at < ?) OR (topic.updated_at = ? AND topic.id < ?))"
  1875. params.extend((cursor_values[0], cursor_values[0], cursor_values[1]))
  1876. query += " ORDER BY topic.pinned DESC, topic.updated_at DESC, topic.id DESC LIMIT ?"
  1877. params.append(limit + 1)
  1878. with self.read_transaction() as connection:
  1879. self._room_detail(connection, canonical_room_id, include_archived=True)
  1880. rows = connection.execute(query, params).fetchall()
  1881. has_more = len(rows) > limit
  1882. items = [self._topic_summary(row) for row in rows[:limit]]
  1883. next_cursor = None
  1884. if has_more and items:
  1885. last = rows[limit - 1]
  1886. next_cursor = self._encode_topic_cursor(
  1887. canonical_room_id, last["updated_at"], last["id"]
  1888. )
  1889. return CursorPage(items=items, next_cursor=next_cursor)
  1890. def list_global_topics(
  1891. self, *, limit: int, cursor: str | None
  1892. ) -> CursorPage:
  1893. """Return active topics across active rooms in stable professional order."""
  1894. if (
  1895. not isinstance(limit, int)
  1896. or isinstance(limit, bool)
  1897. or not 1 <= limit <= 100
  1898. ):
  1899. raise ValueError("limit must be an integer from 1 to 100")
  1900. cursor_values = (
  1901. self._decode_global_topic_cursor(cursor)
  1902. if cursor is not None
  1903. else None
  1904. )
  1905. query = """SELECT topic.*,
  1906. (SELECT COUNT(*) FROM group_messages AS counted
  1907. WHERE counted.topic_id = topic.id AND counted.visible = 1)
  1908. AS message_count,
  1909. COALESCE((SELECT CASE WHEN TRIM(latest.content) != ''
  1910. THEN latest.content ELSE latest.error END
  1911. FROM group_messages AS latest
  1912. WHERE latest.topic_id = topic.id AND latest.visible = 1
  1913. AND (TRIM(latest.content) != '' OR TRIM(latest.error) != '')
  1914. ORDER BY latest.seq DESC LIMIT 1), '') AS preview,
  1915. (SELECT COUNT(*) FROM group_messages AS unread
  1916. WHERE unread.topic_id = topic.id AND unread.visible = 1
  1917. AND unread.sender_kind = 'agent'
  1918. AND (TRIM(unread.content) != '' OR TRIM(unread.error) != '')
  1919. AND unread.seq > topic.last_read_message_seq) AS unread_count,
  1920. COALESCE((SELECT MAX(visible_message.seq)
  1921. FROM group_messages AS visible_message
  1922. WHERE visible_message.topic_id = topic.id
  1923. AND visible_message.visible = 1), 0) AS latest_message_seq
  1924. FROM group_topics AS topic
  1925. INNER JOIN group_rooms AS room ON room.id = topic.room_id
  1926. WHERE topic.archived = 0 AND room.archived = 0"""
  1927. params: list[object] = []
  1928. if cursor_values is not None:
  1929. pinned, updated_at, topic_id = cursor_values
  1930. query += """ AND (
  1931. topic.pinned < ? OR (
  1932. topic.pinned = ? AND (
  1933. topic.updated_at < ? OR (
  1934. topic.updated_at = ? AND topic.id < ?
  1935. )
  1936. )
  1937. )
  1938. )"""
  1939. params.extend((pinned, pinned, updated_at, updated_at, topic_id))
  1940. query += " ORDER BY topic.pinned DESC, topic.updated_at DESC, topic.id DESC LIMIT ?"
  1941. params.append(limit + 1)
  1942. with self.read_transaction() as connection:
  1943. rows = connection.execute(query, params).fetchall()
  1944. has_more = len(rows) > limit
  1945. items = [self._topic_summary(row) for row in rows[:limit]]
  1946. next_cursor = None
  1947. if has_more and items:
  1948. last = rows[limit - 1]
  1949. next_cursor = self._encode_global_topic_cursor(
  1950. bool(last["pinned"]), last["updated_at"], last["id"]
  1951. )
  1952. return CursorPage(items=items, next_cursor=next_cursor)
  1953. def list_pinned_topics(self, *, limit: int) -> CursorPage:
  1954. if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 100:
  1955. raise ValueError("limit must be an integer from 1 to 100")
  1956. with self.read_transaction() as connection:
  1957. rows = connection.execute(
  1958. """SELECT id, room_id FROM group_topics
  1959. WHERE archived = 0 AND pinned = 1
  1960. ORDER BY updated_at DESC, id DESC LIMIT ?""",
  1961. (limit,),
  1962. ).fetchall()
  1963. items = [self._topic_summary_by_id(connection, row["room_id"], row["id"])
  1964. for row in rows]
  1965. return CursorPage(items=items, next_cursor=None)
  1966. def room_activity(self, room_id: str) -> dict[str, object]:
  1967. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  1968. with self.read_transaction() as connection:
  1969. self._room_detail(connection, canonical_room_id, include_archived=True)
  1970. return self._room_activity_from_connection(connection, canonical_room_id)
  1971. def _room_activity_from_connection(
  1972. self, connection: sqlite3.Connection, room_id: str
  1973. ) -> dict[str, object]:
  1974. counts = connection.execute(
  1975. """SELECT
  1976. (SELECT COUNT(*) FROM group_agent_runs AS run
  1977. WHERE run.room_id = ?
  1978. AND run.status IN ('queued', 'running', 'awaiting_input'))
  1979. AS active_run_count,
  1980. (SELECT COUNT(*) FROM group_messages AS message
  1981. JOIN group_topics AS topic ON topic.id = message.topic_id
  1982. WHERE message.room_id = ? AND message.visible = 1
  1983. AND message.sender_kind = 'agent'
  1984. AND (TRIM(message.content) != '' OR TRIM(message.error) != '')
  1985. AND message.seq > topic.last_read_message_seq)
  1986. AS unread_count""",
  1987. (room_id, room_id),
  1988. ).fetchone()
  1989. latest = connection.execute(
  1990. """SELECT id FROM group_messages
  1991. WHERE room_id = ? AND visible = 1
  1992. ORDER BY seq DESC LIMIT 1""",
  1993. (room_id,),
  1994. ).fetchone()
  1995. last_message = None
  1996. if latest is not None:
  1997. last_message = self._message_wire(
  1998. self._message_with_execution_row(connection, latest["id"])
  1999. )
  2000. return {
  2001. "roomId": room_id,
  2002. "activeRunCount": int(counts["active_run_count"]),
  2003. "unreadCount": int(counts["unread_count"]),
  2004. "lastMessage": last_message,
  2005. }
  2006. def update_room(
  2007. self, room_id: str, command: Mapping[str, object]
  2008. ) -> dict[str, object]:
  2009. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  2010. command = self._command(
  2011. command,
  2012. {
  2013. "requestId", "name", "cwd", "instructions", "avatar",
  2014. "maxReplyRounds", "orchestrationMode",
  2015. },
  2016. )
  2017. request_id = self._command_request_id(command)
  2018. changes = {
  2019. key
  2020. for key in (
  2021. "name", "cwd", "instructions", "avatar", "maxReplyRounds", "orchestrationMode"
  2022. )
  2023. if key in command
  2024. }
  2025. if not changes:
  2026. raise ValueError(
  2027. "Room update requires name, cwd, instructions, avatar, maxReplyRounds, or orchestrationMode"
  2028. )
  2029. def action(connection: sqlite3.Connection) -> dict[str, object]:
  2030. current_room = self._active_room(connection, canonical_room_id)
  2031. values: list[object] = []
  2032. assignments: list[str] = []
  2033. if "name" in changes:
  2034. assignments.append("name = ?")
  2035. values.append(normalize_room_name(self._string(command, "name")))
  2036. if "cwd" in changes:
  2037. assignments.append("cwd = ?")
  2038. values.append(normalize_room_cwd(self._string(command, "cwd")))
  2039. if "instructions" in changes:
  2040. assignments.append("instructions = ?")
  2041. values.append(normalize_room_instructions(command["instructions"]))
  2042. if "avatar" in changes:
  2043. assignments.append("avatar_data_url = ?")
  2044. values.append(normalize_room_avatar(command["avatar"]))
  2045. if "maxReplyRounds" in changes:
  2046. assignments.append("max_reply_rounds = ?")
  2047. values.append(normalize_max_reply_rounds(command["maxReplyRounds"]))
  2048. if "orchestrationMode" in changes:
  2049. next_mode = self._orchestration_mode(command["orchestrationMode"])
  2050. if next_mode != current_room["orchestration_mode"]:
  2051. active = connection.execute(
  2052. """SELECT 1 FROM group_agent_runs AS run
  2053. LEFT JOIN group_idempotency AS cascade
  2054. ON cascade.request_id = 'internal:cascade:' || run.id
  2055. WHERE run.room_id = ?
  2056. AND (
  2057. run.status IN ('queued', 'running', 'awaiting_input')
  2058. OR cascade.operation = ?
  2059. )
  2060. LIMIT 1""",
  2061. (canonical_room_id, _CASCADE_PENDING_OPERATION),
  2062. ).fetchone()
  2063. if active is not None:
  2064. raise GroupConflictError(
  2065. "Cannot change orchestration mode while runs are active"
  2066. )
  2067. assignments.append("orchestration_mode = ?")
  2068. values.append(next_mode)
  2069. assignments.append("updated_at = ?")
  2070. values.extend((self._now(), canonical_room_id))
  2071. connection.execute(
  2072. f"UPDATE group_rooms SET {', '.join(assignments)} WHERE id = ?", values
  2073. )
  2074. room = self._room_detail(
  2075. connection, canonical_room_id, include_archived=True
  2076. )
  2077. self._append_event(connection, canonical_room_id, "room.updated", room)
  2078. return room
  2079. return self.execute_idempotent(
  2080. "room.updated",
  2081. request_id,
  2082. self._scoped_payload(command, canonical_room_id),
  2083. action,
  2084. )
  2085. def update_topic(
  2086. self, room_id: str, topic_id: str, command: Mapping[str, object]
  2087. ) -> dict[str, object]:
  2088. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  2089. canonical_topic_id = self._canonical_uuid(topic_id, "topicId")
  2090. command = self._command(command, {"requestId", "title", "pinned"})
  2091. request_id = self._command_request_id(command)
  2092. if not ({"title", "pinned"} & set(command)):
  2093. raise ValueError("Topic update requires title or pinned")
  2094. def action(connection: sqlite3.Connection) -> dict[str, object]:
  2095. self._active_room(connection, canonical_room_id)
  2096. self._active_topic(connection, canonical_room_id, canonical_topic_id)
  2097. assignments: list[str] = []
  2098. values: list[object] = []
  2099. if "title" in command:
  2100. assignments.append("title = ?")
  2101. values.append(self._topic_title(self._string(command, "title")))
  2102. if "pinned" in command:
  2103. if not isinstance(command["pinned"], bool):
  2104. raise ValueError("pinned must be a boolean")
  2105. assignments.append("pinned = ?")
  2106. values.append(int(command["pinned"]))
  2107. now = self._now()
  2108. assignments.append("updated_at = ?")
  2109. values.extend((now, canonical_topic_id, canonical_room_id))
  2110. connection.execute(
  2111. f"UPDATE group_topics SET {', '.join(assignments)} "
  2112. "WHERE id = ? AND room_id = ?",
  2113. values,
  2114. )
  2115. topic = self._topic_summary_by_id(
  2116. connection, canonical_room_id, canonical_topic_id
  2117. )
  2118. self._append_event(
  2119. connection, canonical_room_id, "topic.updated", topic,
  2120. created_at=now,
  2121. )
  2122. return topic
  2123. return self.execute_idempotent(
  2124. "topic.updated",
  2125. request_id,
  2126. self._scoped_payload(command, canonical_room_id, canonical_topic_id),
  2127. action,
  2128. )
  2129. def archive_topic(
  2130. self, room_id: str, topic_id: str, command: Mapping[str, object]
  2131. ) -> dict[str, object]:
  2132. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  2133. canonical_topic_id = self._canonical_uuid(topic_id, "topicId")
  2134. command = self._command(command, {"requestId"})
  2135. request_id = self._command_request_id(command)
  2136. def action(connection: sqlite3.Connection) -> dict[str, object]:
  2137. self._active_room(connection, canonical_room_id)
  2138. self._active_topic(connection, canonical_room_id, canonical_topic_id)
  2139. now = self._now()
  2140. self._interrupt_runs_for_scope(
  2141. connection, room_id=canonical_room_id, topic_id=canonical_topic_id,
  2142. reason="Topic archived", now=now,
  2143. )
  2144. connection.execute(
  2145. "UPDATE group_topics SET archived = 1, updated_at = ? "
  2146. "WHERE id = ? AND room_id = ?",
  2147. (now, canonical_topic_id, canonical_room_id),
  2148. )
  2149. topic = self._topic_summary_by_id(
  2150. connection, canonical_room_id, canonical_topic_id
  2151. )
  2152. self._append_event(
  2153. connection, canonical_room_id, "topic.archived", topic,
  2154. created_at=now,
  2155. )
  2156. return topic
  2157. return self.execute_idempotent(
  2158. "topic.archived", request_id,
  2159. self._scoped_payload(command, canonical_room_id, canonical_topic_id),
  2160. action,
  2161. )
  2162. def restore_topic(
  2163. self, room_id: str, topic_id: str, command: Mapping[str, object]
  2164. ) -> dict[str, object]:
  2165. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  2166. canonical_topic_id = self._canonical_uuid(topic_id, "topicId")
  2167. command = self._command(command, {"requestId"})
  2168. request_id = self._command_request_id(command)
  2169. def action(connection: sqlite3.Connection) -> dict[str, object]:
  2170. self._active_room(connection, canonical_room_id)
  2171. topic_row = self._topic_row(
  2172. connection, canonical_room_id, canonical_topic_id
  2173. )
  2174. if not topic_row["archived"]:
  2175. raise GroupConflictError("Topic is already active")
  2176. now = self._now()
  2177. connection.execute(
  2178. "UPDATE group_topics SET archived = 0, updated_at = ? "
  2179. "WHERE id = ? AND room_id = ?",
  2180. (now, canonical_topic_id, canonical_room_id),
  2181. )
  2182. topic = self._topic_summary_by_id(
  2183. connection, canonical_room_id, canonical_topic_id
  2184. )
  2185. self._append_event(
  2186. connection, canonical_room_id, "topic.restored", topic,
  2187. created_at=now,
  2188. )
  2189. return topic
  2190. return self.execute_idempotent(
  2191. "topic.restored", request_id,
  2192. self._scoped_payload(command, canonical_room_id, canonical_topic_id),
  2193. action,
  2194. )
  2195. def mark_topic_read(
  2196. self, room_id: str, topic_id: str, command: Mapping[str, object]
  2197. ) -> dict[str, object]:
  2198. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  2199. canonical_topic_id = self._canonical_uuid(topic_id, "topicId")
  2200. command = self._command(command, {"requestId", "throughSeq"})
  2201. request_id = self._command_request_id(command)
  2202. through_seq = self._nonnegative_int(command.get("throughSeq"), "throughSeq")
  2203. def action(connection: sqlite3.Connection) -> dict[str, object]:
  2204. self._active_room(connection, canonical_room_id)
  2205. topic_row = self._topic_row(
  2206. connection, canonical_room_id, canonical_topic_id
  2207. )
  2208. maximum = int(connection.execute(
  2209. """SELECT COALESCE(MAX(seq), 0) FROM group_messages
  2210. WHERE room_id = ? AND topic_id = ? AND visible = 1""",
  2211. (canonical_room_id, canonical_topic_id),
  2212. ).fetchone()[0])
  2213. if through_seq > maximum:
  2214. raise ValueError("throughSeq exceeds the latest visible message")
  2215. changed = through_seq > int(topic_row["last_read_message_seq"])
  2216. if changed:
  2217. connection.execute(
  2218. """UPDATE group_topics SET last_read_message_seq = ?
  2219. WHERE id = ? AND room_id = ?""",
  2220. (through_seq, canonical_topic_id, canonical_room_id),
  2221. )
  2222. topic = self._topic_summary_by_id(
  2223. connection, canonical_room_id, canonical_topic_id
  2224. )
  2225. room = self._room_activity_from_connection(
  2226. connection, canonical_room_id
  2227. )
  2228. if changed:
  2229. now = self._now()
  2230. self._append_event(
  2231. connection, canonical_room_id, "topic.updated", topic,
  2232. created_at=now,
  2233. )
  2234. self._append_event(
  2235. connection, canonical_room_id, "room.activity", room,
  2236. created_at=now,
  2237. )
  2238. return {"topic": topic, "room": room}
  2239. return self.execute_idempotent(
  2240. "topic.read",
  2241. request_id,
  2242. self._scoped_payload(command, canonical_room_id, canonical_topic_id),
  2243. action,
  2244. )
  2245. def archive_room(
  2246. self, room_id: str, command: Mapping[str, object]
  2247. ) -> dict[str, object]:
  2248. return self.archive_room_with_runtime_targets(room_id, command)["result"]
  2249. def archive_room_with_runtime_targets(
  2250. self, room_id: str, command: Mapping[str, object]
  2251. ) -> dict[str, object]:
  2252. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  2253. command = self._command(command, {"requestId"})
  2254. request_id = self._command_request_id(command)
  2255. def action(connection: sqlite3.Connection) -> dict[str, object]:
  2256. self._active_room(connection, canonical_room_id)
  2257. now = self._now()
  2258. interrupted = self._interrupt_runs_for_scope(
  2259. connection,
  2260. room_id=canonical_room_id,
  2261. reason="Room archived",
  2262. now=now,
  2263. )
  2264. connection.execute(
  2265. "UPDATE group_rooms SET archived = 1, updated_at = ? WHERE id = ?",
  2266. (now, canonical_room_id),
  2267. )
  2268. room = self._room_detail(
  2269. connection, canonical_room_id, include_archived=True
  2270. )
  2271. self._append_event(
  2272. connection, canonical_room_id, "room.deleted", room, created_at=now
  2273. )
  2274. return {
  2275. "result": room,
  2276. "runtimeSessionIds": interrupted["runtimeSessionIds"],
  2277. }
  2278. response = self.execute_idempotent(
  2279. "room.deleted",
  2280. request_id,
  2281. self._scoped_payload(command, canonical_room_id),
  2282. action,
  2283. )
  2284. return self._runtime_envelope(response)
  2285. def restore_room(self, room_id: str, command: Mapping[str, object]) -> dict[str, object]:
  2286. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  2287. command = self._command(command, {"requestId"})
  2288. request_id = self._command_request_id(command)
  2289. def action(connection: sqlite3.Connection) -> dict[str, object]:
  2290. room = self._room_detail(
  2291. connection, canonical_room_id, include_archived=True
  2292. )
  2293. if not room["archived"]:
  2294. raise GroupConflictError("Room is already active")
  2295. now = self._now()
  2296. connection.execute(
  2297. "UPDATE group_rooms SET archived = 0, updated_at = ? WHERE id = ?",
  2298. (now, canonical_room_id),
  2299. )
  2300. restored = self._room_detail(
  2301. connection, canonical_room_id, include_archived=True
  2302. )
  2303. self._append_event(
  2304. connection, canonical_room_id, "room.restored", restored,
  2305. created_at=now,
  2306. )
  2307. return restored
  2308. return self.execute_idempotent(
  2309. "room.restored", request_id,
  2310. self._scoped_payload(command, canonical_room_id), action,
  2311. )
  2312. def add_agent(
  2313. self, room_id: str, command: Mapping[str, object]
  2314. ) -> dict[str, object]:
  2315. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  2316. command = self._command(
  2317. command,
  2318. {
  2319. "requestId",
  2320. "profile",
  2321. "nodeId",
  2322. "nodeLabel",
  2323. "displayName",
  2324. "description",
  2325. "replyWithoutMention",
  2326. "isHost",
  2327. "model",
  2328. "provider",
  2329. "reasoningEffort",
  2330. "fastMode",
  2331. },
  2332. )
  2333. request_id = self._command_request_id(command)
  2334. def action(connection: sqlite3.Connection) -> dict[str, object]:
  2335. self._active_room(connection, canonical_room_id)
  2336. count = connection.execute(
  2337. "SELECT COUNT(*) FROM group_agents WHERE room_id = ?",
  2338. (canonical_room_id,),
  2339. ).fetchone()[0]
  2340. if count >= MAX_AGENTS_PER_ROOM:
  2341. raise GroupConflictError(
  2342. "Room has reached the maximum number of agents"
  2343. )
  2344. agent = self._new_agent(
  2345. {
  2346. key: command[key]
  2347. for key in (
  2348. "profile",
  2349. "nodeId",
  2350. "nodeLabel",
  2351. "displayName",
  2352. "description",
  2353. "replyWithoutMention",
  2354. "isHost",
  2355. "model",
  2356. "provider",
  2357. "reasoningEffort",
  2358. "fastMode",
  2359. )
  2360. if key in command
  2361. }
  2362. )
  2363. now = self._now()
  2364. requested_host = bool(agent["is_host"])
  2365. agent["is_host"] = False
  2366. agent_id = self._insert_agent(connection, canonical_room_id, agent, now)
  2367. current_host = self._room_host(connection, canonical_room_id)
  2368. switched: tuple[dict[str, object], dict[str, object]] | None = None
  2369. if requested_host or not bool(current_host["enabled"]):
  2370. switched = self._switch_host(
  2371. connection, canonical_room_id, agent_id, now=now
  2372. )
  2373. result = self._agent_detail(connection, canonical_room_id, agent_id)
  2374. connection.execute(
  2375. "UPDATE group_rooms SET updated_at = ? WHERE id = ?",
  2376. (now, canonical_room_id),
  2377. )
  2378. if switched is not None:
  2379. self._append_event(
  2380. connection,
  2381. canonical_room_id,
  2382. "agent.updated",
  2383. switched[0],
  2384. created_at=now,
  2385. )
  2386. self._append_event(
  2387. connection,
  2388. canonical_room_id,
  2389. "agent.created",
  2390. result,
  2391. created_at=now,
  2392. )
  2393. self._append_room_updated_summary(
  2394. connection, canonical_room_id, created_at=now
  2395. )
  2396. return result
  2397. return self.execute_idempotent(
  2398. "agent.created",
  2399. request_id,
  2400. self._scoped_payload(command, canonical_room_id),
  2401. action,
  2402. )
  2403. def update_agent(
  2404. self, room_id: str, agent_id: str, command: Mapping[str, object]
  2405. ) -> dict[str, object]:
  2406. return self.update_agent_with_runtime_targets(room_id, agent_id, command)[
  2407. "result"
  2408. ]
  2409. def update_agent_with_runtime_targets(
  2410. self, room_id: str, agent_id: str, command: Mapping[str, object]
  2411. ) -> dict[str, object]:
  2412. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  2413. canonical_agent_id = self._canonical_uuid(agent_id, "agentId")
  2414. command = self._command(
  2415. command,
  2416. {
  2417. "requestId",
  2418. "displayName",
  2419. "description",
  2420. "enabled",
  2421. "replyWithoutMention",
  2422. "isHost",
  2423. "model",
  2424. "provider",
  2425. "reasoningEffort",
  2426. "fastMode",
  2427. },
  2428. )
  2429. request_id = self._command_request_id(command)
  2430. changes = {
  2431. key
  2432. for key in (
  2433. "displayName",
  2434. "description",
  2435. "enabled",
  2436. "replyWithoutMention",
  2437. "isHost",
  2438. "model",
  2439. "provider",
  2440. "reasoningEffort",
  2441. "fastMode",
  2442. )
  2443. if key in command
  2444. }
  2445. if not changes:
  2446. raise ValueError(
  2447. "Agent update requires a mutable agent field"
  2448. )
  2449. model_changes = changes & {"model", "provider"}
  2450. if model_changes and model_changes != {"model", "provider"}:
  2451. raise ValueError("model and provider must be updated together")
  2452. if model_changes and (
  2453. (command["model"] is None) != (command["provider"] is None)
  2454. ):
  2455. raise ValueError("model and provider must both be set or both be null")
  2456. def action(connection: sqlite3.Connection) -> dict[str, object]:
  2457. self._active_room(connection, canonical_room_id)
  2458. previous = self._owned_agent(
  2459. connection, canonical_room_id, canonical_agent_id
  2460. )
  2461. requested_host = command.get("isHost")
  2462. if requested_host is not None and not isinstance(requested_host, bool):
  2463. raise ValueError("isHost must be a boolean")
  2464. if requested_host is False and bool(previous["is_host"]):
  2465. raise GroupConflictError(
  2466. "Current administrator cannot be cleared without selecting a replacement"
  2467. )
  2468. if requested_host is True and command.get("enabled") is False:
  2469. raise GroupConflictError("Administrator Agent must be enabled")
  2470. replacement_id: str | None = None
  2471. if (
  2472. bool(previous["is_host"])
  2473. and command.get("enabled") is False
  2474. and bool(previous["enabled"])
  2475. ):
  2476. replacement = self._first_enabled_host_candidate(
  2477. connection,
  2478. canonical_room_id,
  2479. exclude_agent_id=canonical_agent_id,
  2480. )
  2481. if replacement is None:
  2482. raise GroupConflictError(
  2483. "Administrator Agent cannot be disabled without an enabled replacement"
  2484. )
  2485. replacement_id = str(replacement["id"])
  2486. assignments: list[str] = []
  2487. values: list[object] = []
  2488. if "displayName" in changes:
  2489. raw_name = command.get("displayName")
  2490. configured = self._resolved_agent_name(previous["profile"])
  2491. effective = (
  2492. raw_name.strip()
  2493. if isinstance(raw_name, str) and raw_name.strip()
  2494. else configured or previous["profile"]
  2495. )
  2496. display_name, display_key = normalize_display_name(effective)
  2497. self._check_agent_conflicts(
  2498. connection,
  2499. canonical_room_id,
  2500. display_key,
  2501. exclude_agent_id=canonical_agent_id,
  2502. )
  2503. assignments.extend(("display_name = ?", "display_name_key = ?"))
  2504. values.extend((display_name, display_key))
  2505. if "description" in changes:
  2506. assignments.append("description = ?")
  2507. values.append(self._description(command.get("description")))
  2508. if "enabled" in changes:
  2509. enabled = command["enabled"]
  2510. if not isinstance(enabled, bool):
  2511. raise ValueError("enabled must be a boolean")
  2512. assignments.append("enabled = ?")
  2513. values.append(int(enabled))
  2514. if "replyWithoutMention" in changes:
  2515. reply_without_mention = command["replyWithoutMention"]
  2516. if not isinstance(reply_without_mention, bool):
  2517. raise ValueError("replyWithoutMention must be a boolean")
  2518. assignments.append("reply_without_mention = ?")
  2519. values.append(int(reply_without_mention))
  2520. configuration_columns = {
  2521. "model": "model_override",
  2522. "provider": "provider_override",
  2523. "reasoningEffort": "reasoning_effort_override",
  2524. }
  2525. for key, column in configuration_columns.items():
  2526. if key in changes:
  2527. assignments.append(f"{column} = ?")
  2528. values.append(self._agent_configuration_value(command[key], key))
  2529. if "fastMode" in changes:
  2530. fast_mode = command["fastMode"]
  2531. if fast_mode is not None and not isinstance(fast_mode, bool):
  2532. raise ValueError("fastMode must be a boolean or null")
  2533. assignments.append("fast_mode_override = ?")
  2534. values.append(None if fast_mode is None else int(fast_mode))
  2535. if changes & {"model", "provider", "reasoningEffort", "fastMode"}:
  2536. # The active run keeps its current session. The next scheduler
  2537. # pass sees this stale marker and rotates before submitting.
  2538. assignments.append("session_config_json = NULL")
  2539. now = self._now()
  2540. interrupted = {"runIds": [], "runtimeSessionIds": []}
  2541. if (
  2542. "enabled" in changes
  2543. and bool(previous["enabled"])
  2544. and command["enabled"] is False
  2545. ):
  2546. interrupted = self._interrupt_runs_for_scope(
  2547. connection,
  2548. room_id=canonical_room_id,
  2549. agent_id=canonical_agent_id,
  2550. reason="Agent disabled",
  2551. now=now,
  2552. )
  2553. assignments.append("updated_at = ?")
  2554. values.extend((now, canonical_agent_id, canonical_room_id))
  2555. connection.execute(
  2556. f"UPDATE group_agents SET {', '.join(assignments)} WHERE id = ? AND room_id = ?",
  2557. values,
  2558. )
  2559. switched: tuple[dict[str, object], dict[str, object]] | None = None
  2560. if replacement_id is not None:
  2561. switched = self._switch_host(
  2562. connection, canonical_room_id, replacement_id, now=now
  2563. )
  2564. elif requested_host is True:
  2565. switched = self._switch_host(
  2566. connection, canonical_room_id, canonical_agent_id, now=now
  2567. )
  2568. elif command.get("enabled") is True:
  2569. current_host = self._room_host(connection, canonical_room_id)
  2570. if not bool(current_host["enabled"]):
  2571. switched = self._switch_host(
  2572. connection, canonical_room_id, canonical_agent_id, now=now
  2573. )
  2574. connection.execute(
  2575. "UPDATE group_rooms SET updated_at = ? WHERE id = ?",
  2576. (now, canonical_room_id),
  2577. )
  2578. result = self._agent_detail(
  2579. connection, canonical_room_id, canonical_agent_id
  2580. )
  2581. if switched is None:
  2582. self._append_event(
  2583. connection,
  2584. canonical_room_id,
  2585. "agent.updated",
  2586. result,
  2587. created_at=now,
  2588. )
  2589. else:
  2590. self._append_event(
  2591. connection,
  2592. canonical_room_id,
  2593. "agent.updated",
  2594. switched[0],
  2595. created_at=now,
  2596. )
  2597. self._append_event(
  2598. connection,
  2599. canonical_room_id,
  2600. "agent.updated",
  2601. switched[1],
  2602. created_at=now,
  2603. )
  2604. self._append_room_updated_summary(
  2605. connection, canonical_room_id, created_at=now
  2606. )
  2607. if interrupted["runIds"]:
  2608. self._append_agent_status(
  2609. connection, canonical_room_id, canonical_agent_id, now
  2610. )
  2611. return {
  2612. "result": result,
  2613. "runtimeSessionIds": interrupted["runtimeSessionIds"],
  2614. }
  2615. response = self.execute_idempotent(
  2616. "agent.updated",
  2617. request_id,
  2618. self._scoped_payload(command, canonical_room_id, canonical_agent_id),
  2619. action,
  2620. )
  2621. return self._runtime_envelope(response)
  2622. def delete_agent(
  2623. self, room_id: str, agent_id: str, command: Mapping[str, object]
  2624. ) -> dict[str, object]:
  2625. return self.delete_agent_with_runtime_targets(room_id, agent_id, command)[
  2626. "result"
  2627. ]
  2628. def delete_agent_with_runtime_targets(
  2629. self, room_id: str, agent_id: str, command: Mapping[str, object]
  2630. ) -> dict[str, object]:
  2631. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  2632. canonical_agent_id = self._canonical_uuid(agent_id, "agentId")
  2633. command = self._command(command, {"requestId"})
  2634. request_id = self._command_request_id(command)
  2635. def action(connection: sqlite3.Connection) -> dict[str, object]:
  2636. self._active_room(connection, canonical_room_id)
  2637. result = self._agent_detail(
  2638. connection, canonical_room_id, canonical_agent_id
  2639. )
  2640. agent_count = connection.execute(
  2641. "SELECT COUNT(*) FROM group_agents WHERE room_id = ?",
  2642. (canonical_room_id,),
  2643. ).fetchone()[0]
  2644. if agent_count <= 1:
  2645. raise GroupConflictError("Room must retain at least one agent")
  2646. now = self._now()
  2647. switched: tuple[dict[str, object], dict[str, object]] | None = None
  2648. if result["isHost"] is True:
  2649. replacement = self._first_enabled_host_candidate(
  2650. connection,
  2651. canonical_room_id,
  2652. exclude_agent_id=canonical_agent_id,
  2653. )
  2654. if replacement is None:
  2655. raise GroupConflictError(
  2656. "Administrator Agent cannot be removed without an enabled replacement"
  2657. )
  2658. switched = self._switch_host(
  2659. connection,
  2660. canonical_room_id,
  2661. str(replacement["id"]),
  2662. now=now,
  2663. )
  2664. interrupted = self._interrupt_runs_for_scope(
  2665. connection,
  2666. room_id=canonical_room_id,
  2667. agent_id=canonical_agent_id,
  2668. reason="Agent removed",
  2669. now=now,
  2670. )
  2671. connection.execute(
  2672. "DELETE FROM group_agents WHERE id = ? AND room_id = ?",
  2673. (canonical_agent_id, canonical_room_id),
  2674. )
  2675. connection.execute(
  2676. "UPDATE group_rooms SET updated_at = ? WHERE id = ?",
  2677. (now, canonical_room_id),
  2678. )
  2679. if switched is not None:
  2680. self._append_event(
  2681. connection,
  2682. canonical_room_id,
  2683. "agent.updated",
  2684. switched[1],
  2685. created_at=now,
  2686. )
  2687. self._append_event(
  2688. connection, canonical_room_id, "agent.deleted", result, created_at=now
  2689. )
  2690. self._append_room_updated_summary(
  2691. connection, canonical_room_id, created_at=now
  2692. )
  2693. return {
  2694. "result": result,
  2695. "runtimeSessionIds": interrupted["runtimeSessionIds"],
  2696. }
  2697. response = self.execute_idempotent(
  2698. "agent.deleted",
  2699. request_id,
  2700. self._scoped_payload(command, canonical_room_id, canonical_agent_id),
  2701. action,
  2702. )
  2703. return self._runtime_envelope(response)
  2704. def interrupt_agent_with_runtime_targets(
  2705. self, room_id: str, agent_id: str, command: Mapping[str, object]
  2706. ) -> dict[str, object]:
  2707. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  2708. canonical_agent_id = self._canonical_uuid(agent_id, "agentId")
  2709. command = self._command(command, {"requestId"})
  2710. request_id = self._command_request_id(command)
  2711. def action(connection: sqlite3.Connection) -> dict[str, object]:
  2712. self._active_room(connection, canonical_room_id)
  2713. self._owned_agent(connection, canonical_room_id, canonical_agent_id)
  2714. now = self._now()
  2715. interrupted = self._interrupt_runs_for_scope(
  2716. connection,
  2717. room_id=canonical_room_id,
  2718. agent_id=canonical_agent_id,
  2719. reason="Agent interrupted",
  2720. now=now,
  2721. include_queued=False,
  2722. )
  2723. if interrupted["runIds"]:
  2724. self._append_agent_status(
  2725. connection, canonical_room_id, canonical_agent_id, now
  2726. )
  2727. return {
  2728. "result": {
  2729. "roomId": canonical_room_id,
  2730. "agentId": canonical_agent_id,
  2731. "interruptedRunIds": interrupted["runIds"],
  2732. },
  2733. "runtimeSessionIds": interrupted["runtimeSessionIds"],
  2734. }
  2735. response = self.execute_idempotent(
  2736. "agent.interrupted",
  2737. request_id,
  2738. self._scoped_payload(command, canonical_room_id, canonical_agent_id),
  2739. action,
  2740. )
  2741. return self._runtime_envelope(response)
  2742. def create_human_message(
  2743. self,
  2744. room_id: str,
  2745. *,
  2746. request_id: str,
  2747. client_message_id: str,
  2748. content: str,
  2749. mention_agent_ids: object,
  2750. topic_id: str | None = None,
  2751. ) -> dict[str, object]:
  2752. """Persist one human turn and the deterministic agent work it starts."""
  2753. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  2754. canonical_request_id = self._canonical_uuid(request_id, "requestId")
  2755. canonical_client_id = self._canonical_uuid(client_message_id, "clientMessageId")
  2756. canonical_topic_id = (
  2757. self._compatibility_topic_id(canonical_room_id)
  2758. if topic_id is None
  2759. else self._canonical_uuid(topic_id, "topicId")
  2760. )
  2761. message_content = self._message_text(
  2762. content, "content", max_bytes=MAX_MESSAGE_BYTES, nonblank=True
  2763. )
  2764. agent_ids = self._mention_agent_ids(mention_agent_ids)
  2765. payload = {
  2766. "roomId": canonical_room_id,
  2767. "clientMessageId": canonical_client_id,
  2768. "content": message_content,
  2769. "mentionAgentIds": agent_ids,
  2770. }
  2771. if topic_id is not None:
  2772. payload["topicId"] = canonical_topic_id
  2773. def action(connection: sqlite3.Connection) -> dict[str, object]:
  2774. room = self._active_room(connection, canonical_room_id)
  2775. agents: list[tuple[sqlite3.Row, str, bool]] = []
  2776. room_agents = connection.execute(
  2777. """SELECT * FROM group_agents WHERE room_id = ?
  2778. ORDER BY created_at ASC, id ASC""",
  2779. (canonical_room_id,),
  2780. ).fetchall()
  2781. parsed_ids, _warnings = self._plan_cascade_mentions(
  2782. message_content,
  2783. room_agents,
  2784. source_agent_id="human",
  2785. include_automatic=False,
  2786. )
  2787. agents_by_id = {str(agent["id"]): agent for agent in room_agents}
  2788. explicit_agent_ids = [
  2789. agent_id
  2790. for agent_id in dict.fromkeys([*agent_ids, *parsed_ids])
  2791. if agent_id in agents_by_id and agents_by_id[agent_id]["enabled"]
  2792. ]
  2793. if room["orchestration_mode"] == "host":
  2794. host = self._room_host(connection, canonical_room_id)
  2795. agents.append((host, "automatic", True))
  2796. elif explicit_agent_ids:
  2797. agents.extend(
  2798. (agents_by_id[agent_id], "mentioned", False)
  2799. for agent_id in explicit_agent_ids
  2800. )
  2801. else:
  2802. host = self._room_host(connection, canonical_room_id)
  2803. scheduled_ids: set[str] = set()
  2804. if host["enabled"]:
  2805. agents.append((host, "automatic", True))
  2806. scheduled_ids.add(str(host["id"]))
  2807. agents.extend(
  2808. (agent, "automatic", False)
  2809. for agent in room_agents
  2810. if agent["enabled"]
  2811. and agent["reply_without_mention"]
  2812. and agent["id"] not in scheduled_ids
  2813. )
  2814. now = self._now()
  2815. self._ensure_topic(
  2816. connection,
  2817. canonical_room_id,
  2818. canonical_topic_id,
  2819. message_content,
  2820. now,
  2821. )
  2822. human_id = str(uuid.uuid4())
  2823. connection.execute(
  2824. """INSERT INTO group_messages
  2825. (id, room_id, topic_id, sender_kind, sender_id, sender_name, root_message_id,
  2826. reply_to_message_id, client_message_id, content, reasoning, tool_state_json,
  2827. status, error, created_at, updated_at)
  2828. VALUES (?, ?, ?, 'human', 'human', '你', ?, NULL, ?, ?, '', '[]', 'completed', '', ?, ?)""",
  2829. (
  2830. human_id,
  2831. canonical_room_id,
  2832. canonical_topic_id,
  2833. human_id,
  2834. canonical_client_id,
  2835. message_content,
  2836. now,
  2837. now,
  2838. ),
  2839. )
  2840. message = self._message_wire(self._message_row(connection, human_id))
  2841. self._append_event(
  2842. connection, canonical_room_id, "message.upsert", message, created_at=now
  2843. )
  2844. runs: list[dict[str, object]] = []
  2845. for agent, reply_mode, required_reply in agents:
  2846. visible = reply_mode == "mentioned" or required_reply
  2847. response_id, run_id = str(uuid.uuid4()), str(uuid.uuid4())
  2848. connection.execute(
  2849. """INSERT INTO group_messages
  2850. (id, room_id, topic_id, sender_kind, sender_id, sender_name, root_message_id,
  2851. reply_to_message_id, client_message_id, content, reasoning, tool_state_json,
  2852. status, error, visible, created_at, updated_at)
  2853. VALUES (?, ?, ?, 'agent', ?, ?, ?, ?, NULL, '', '', '[]', 'queued', '', ?, ?, ?)""",
  2854. (
  2855. response_id,
  2856. canonical_room_id,
  2857. canonical_topic_id,
  2858. agent["id"],
  2859. agent["display_name"],
  2860. human_id,
  2861. human_id,
  2862. int(visible),
  2863. now,
  2864. now,
  2865. ),
  2866. )
  2867. connection.execute(
  2868. """INSERT INTO group_agent_runs
  2869. (id, room_id, topic_id, agent_id, trigger_message_id, response_message_id, root_message_id,
  2870. depth, reply_mode, required_reply, status, runtime_session_id,
  2871. requested_model, requested_provider,
  2872. requested_reasoning_effort, requested_fast_mode,
  2873. actual_model, actual_provider, actual_reasoning_effort,
  2874. actual_fast_mode, error, created_at, updated_at)
  2875. VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 'queued', NULL,
  2876. ?, ?, ?, ?, NULL, NULL, NULL, NULL, '', ?, ?)""",
  2877. (
  2878. run_id,
  2879. canonical_room_id,
  2880. canonical_topic_id,
  2881. agent["id"],
  2882. human_id,
  2883. response_id,
  2884. human_id,
  2885. reply_mode,
  2886. int(required_reply),
  2887. agent["model_override"],
  2888. agent["provider_override"],
  2889. agent["reasoning_effort_override"],
  2890. agent["fast_mode_override"],
  2891. now,
  2892. now,
  2893. ),
  2894. )
  2895. response = self._message_wire(
  2896. self._message_row(connection, response_id)
  2897. )
  2898. run = self._run_wire(self._run_row(connection, run_id))
  2899. if visible:
  2900. self._append_event(
  2901. connection,
  2902. canonical_room_id,
  2903. "message.upsert",
  2904. response,
  2905. created_at=now,
  2906. )
  2907. self._append_event(
  2908. connection, canonical_room_id, "run.updated", run, created_at=now
  2909. )
  2910. runs.append(run)
  2911. return {"message": message, "runs": runs}
  2912. try:
  2913. return self.execute_idempotent(
  2914. "message.create", canonical_request_id, payload, action
  2915. )
  2916. except sqlite3.IntegrityError as error:
  2917. raise self._integrity_conflict(error) from error
  2918. def get_message(self, message_id: str) -> dict[str, object]:
  2919. canonical_message_id = self._canonical_uuid(message_id, "messageId")
  2920. with self.read_transaction() as connection:
  2921. return self._message_wire(
  2922. self._message_with_execution_row(connection, canonical_message_id)
  2923. )
  2924. def get_run(self, run_id: str) -> dict[str, object]:
  2925. canonical_run_id = self._canonical_uuid(run_id, "runId")
  2926. with self.read_transaction() as connection:
  2927. return self._run_wire(self._run_row(connection, canonical_run_id))
  2928. def get_interaction(self, interaction_id: str) -> dict[str, object]:
  2929. canonical_interaction_id = self._interaction_id(interaction_id)
  2930. with self.read_transaction() as connection:
  2931. return self._interaction_wire(
  2932. self._interaction_row(connection, canonical_interaction_id)
  2933. )
  2934. def list_messages(
  2935. self,
  2936. room_id: str,
  2937. *,
  2938. topic_id: str | None = None,
  2939. before_seq: int | None = None,
  2940. after_seq: int | None = None,
  2941. limit: int = MAX_MESSAGE_PAGE_SIZE,
  2942. ) -> list[dict[str, object]]:
  2943. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  2944. canonical_topic_id = (
  2945. None
  2946. if topic_id is None
  2947. else self._canonical_uuid(topic_id, "topicId")
  2948. )
  2949. self._page_limit(limit, MAX_MESSAGE_PAGE_SIZE, "limit")
  2950. if before_seq is not None and after_seq is not None:
  2951. raise ValueError("before_seq and after_seq are mutually exclusive")
  2952. before = (
  2953. self._positive_int(before_seq, "before_seq")
  2954. if before_seq is not None
  2955. else None
  2956. )
  2957. after = (
  2958. self._positive_int(after_seq, "after_seq")
  2959. if after_seq is not None
  2960. else None
  2961. )
  2962. with self.read_transaction() as connection:
  2963. self._room_detail(connection, canonical_room_id, include_archived=True)
  2964. if canonical_topic_id is not None:
  2965. self._topic_row(connection, canonical_room_id, canonical_topic_id)
  2966. scope = "message.room_id = ?"
  2967. scope_params: tuple[object, ...] = (canonical_room_id,)
  2968. if canonical_topic_id is not None:
  2969. scope += " AND message.topic_id = ?"
  2970. scope_params = (canonical_room_id, canonical_topic_id)
  2971. select = """SELECT message.*,
  2972. run.requested_model AS execution_requested_model,
  2973. run.requested_provider AS execution_requested_provider,
  2974. run.requested_reasoning_effort AS execution_requested_reasoning_effort,
  2975. run.requested_fast_mode AS execution_requested_fast_mode,
  2976. run.actual_model AS execution_actual_model,
  2977. run.actual_provider AS execution_actual_provider,
  2978. run.actual_reasoning_effort AS execution_actual_reasoning_effort,
  2979. run.actual_fast_mode AS execution_actual_fast_mode
  2980. FROM group_messages AS message
  2981. LEFT JOIN group_agent_runs AS run
  2982. ON run.response_message_id = message.id"""
  2983. if before is not None:
  2984. rows = connection.execute(
  2985. f"{select} WHERE {scope} AND message.visible = 1 AND message.seq < ? ORDER BY message.seq DESC LIMIT ?",
  2986. (*scope_params, before, limit),
  2987. ).fetchall()
  2988. rows = list(reversed(rows))
  2989. elif after is not None:
  2990. rows = connection.execute(
  2991. f"{select} WHERE {scope} AND message.visible = 1 AND message.seq > ? ORDER BY message.seq ASC LIMIT ?",
  2992. (*scope_params, after, limit),
  2993. ).fetchall()
  2994. else:
  2995. rows = connection.execute(
  2996. f"{select} WHERE {scope} AND message.visible = 1 ORDER BY message.seq DESC LIMIT ?",
  2997. (*scope_params, limit),
  2998. ).fetchall()
  2999. rows = list(reversed(rows))
  3000. return [self._message_wire(row) for row in rows]
  3001. def claim_next_runnable_run(self) -> dict[str, object] | None:
  3002. """Atomically claim the oldest eligible queued run."""
  3003. with self.write_transaction() as connection:
  3004. active_count = int(
  3005. connection.execute(
  3006. """SELECT COUNT(*) FROM group_agent_runs
  3007. WHERE status IN ('running', 'awaiting_input')"""
  3008. ).fetchone()[0]
  3009. )
  3010. if active_count >= MAX_PLUGIN_CONCURRENCY:
  3011. return None
  3012. run = connection.execute(
  3013. """SELECT candidate.* FROM group_agent_runs AS candidate
  3014. JOIN group_rooms AS room ON room.id = candidate.room_id
  3015. JOIN group_agents AS agent
  3016. ON agent.id = candidate.agent_id
  3017. AND agent.room_id = candidate.room_id
  3018. JOIN group_messages AS trigger_message
  3019. ON trigger_message.id = candidate.trigger_message_id
  3020. JOIN group_messages AS root_message
  3021. ON root_message.id = candidate.root_message_id
  3022. WHERE candidate.status = 'queued'
  3023. AND room.archived = 0
  3024. AND agent.enabled = 1
  3025. AND NOT EXISTS (
  3026. SELECT 1 FROM group_agent_runs AS occupied_agent
  3027. WHERE occupied_agent.agent_id = candidate.agent_id
  3028. AND occupied_agent.status IN ('running', 'awaiting_input')
  3029. )
  3030. AND (
  3031. SELECT COUNT(*) FROM group_agent_runs AS occupied_room
  3032. WHERE occupied_room.room_id = candidate.room_id
  3033. AND occupied_room.status IN ('running', 'awaiting_input')
  3034. ) < ?
  3035. AND (
  3036. room.orchestration_mode != 'host'
  3037. OR NOT EXISTS (
  3038. SELECT 1 FROM group_agent_runs AS occupied_topic
  3039. WHERE occupied_topic.topic_id = candidate.topic_id
  3040. AND occupied_topic.status IN ('running', 'awaiting_input')
  3041. )
  3042. OR (
  3043. agent.is_host = 0
  3044. AND NOT EXISTS (
  3045. SELECT 1 FROM group_agent_runs AS occupied_topic
  3046. JOIN group_agents AS occupied_topic_agent
  3047. ON occupied_topic_agent.id = occupied_topic.agent_id
  3048. WHERE occupied_topic.topic_id = candidate.topic_id
  3049. AND occupied_topic.status IN ('running', 'awaiting_input')
  3050. AND (
  3051. occupied_topic.root_message_id != candidate.root_message_id
  3052. OR occupied_topic.trigger_message_id != candidate.trigger_message_id
  3053. OR occupied_topic.depth != candidate.depth
  3054. OR occupied_topic_agent.is_host = 1
  3055. )
  3056. )
  3057. )
  3058. )
  3059. ORDER BY CASE WHEN room.orchestration_mode = 'host'
  3060. THEN root_message.seq ELSE trigger_message.seq END ASC,
  3061. trigger_message.seq ASC,
  3062. candidate.created_at ASC,
  3063. candidate.id ASC
  3064. LIMIT 1""",
  3065. (MAX_ROOM_CONCURRENCY,),
  3066. ).fetchone()
  3067. if run is None:
  3068. return None
  3069. response = self._message_row(connection, run["response_message_id"])
  3070. self._validate_run_message_state(run, response)
  3071. now = self._now()
  3072. result = self._update_run(connection, run, "running", now, UNSET, UNSET)
  3073. self._append_event(
  3074. connection,
  3075. run["room_id"],
  3076. "run.updated",
  3077. result,
  3078. created_at=now,
  3079. )
  3080. self._append_agent_status(connection, run["room_id"], run["agent_id"], now)
  3081. return result
  3082. def read_run_projection(self, run_id: str) -> dict[str, object]:
  3083. """Return a stable, bounded context projection for one claimed run."""
  3084. canonical_run_id = self._canonical_uuid(run_id, "runId")
  3085. with self.read_transaction() as connection:
  3086. run = self._run_row(connection, canonical_run_id)
  3087. if run["status"] not in {"running", "awaiting_input"}:
  3088. raise GroupConflictError("Run is not claimed")
  3089. room = self._active_room(connection, run["room_id"])
  3090. agent = self._enabled_agent(connection, run["room_id"], run["agent_id"])
  3091. trigger = self._message_row(connection, run["trigger_message_id"])
  3092. if (
  3093. trigger["room_id"] != run["room_id"]
  3094. or trigger["topic_id"] != run["topic_id"]
  3095. ):
  3096. raise GroupStoreError("Run trigger message is corrupt")
  3097. topic_state = self._agent_topic_state(
  3098. connection, run["agent_id"], run["topic_id"]
  3099. )
  3100. lower_seq = int(topic_state["last_context_message_seq"])
  3101. trigger_seq = int(trigger["seq"])
  3102. through_seq = self._safe_context_seq(
  3103. connection,
  3104. run["room_id"],
  3105. run["topic_id"],
  3106. lower_seq,
  3107. trigger_seq,
  3108. )
  3109. eligibility = """FROM group_messages
  3110. WHERE room_id = ? AND topic_id = ? AND seq > ? AND seq <= ?
  3111. AND visible = 1
  3112. AND status IN ('completed', 'failed', 'interrupted')
  3113. AND NOT (sender_kind = 'agent' AND sender_id = ?)"""
  3114. parameters = (
  3115. run["room_id"],
  3116. run["topic_id"],
  3117. lower_seq,
  3118. trigger_seq,
  3119. run["agent_id"],
  3120. )
  3121. total = int(
  3122. connection.execute(
  3123. f"SELECT COUNT(*) {eligibility}", parameters
  3124. ).fetchone()[0]
  3125. )
  3126. rows = connection.execute(
  3127. f"""SELECT * {eligibility}
  3128. ORDER BY seq DESC LIMIT ?""",
  3129. (*parameters, INITIAL_CONTEXT_MESSAGE_LIMIT),
  3130. ).fetchall()
  3131. rows = list(reversed(rows))
  3132. omitted = total - len(rows)
  3133. omitted_through_seq: int | None = None
  3134. if omitted:
  3135. omitted_through_seq = int(
  3136. connection.execute(
  3137. f"""SELECT MAX(seq) {eligibility}
  3138. AND seq < ?""",
  3139. (*parameters, rows[0]["seq"]),
  3140. ).fetchone()[0]
  3141. )
  3142. initial = agent["stored_session_id"] is None
  3143. projected_agent = self._agent_wire(agent)
  3144. projected_agent["lastContextMessageSeq"] = lower_seq
  3145. messages = [self._projection_message(row) for row in rows]
  3146. messages, omitted, omitted_through_seq, omitted_summary = (
  3147. self._bound_projection_messages(
  3148. messages,
  3149. omitted_count=omitted,
  3150. omitted_through_seq=omitted_through_seq,
  3151. )
  3152. )
  3153. projected_run = self._run_wire(run)
  3154. projected_run["requiredReply"] = bool(run["required_reply"])
  3155. return {
  3156. "run": projected_run,
  3157. "room": self._room_wire(room),
  3158. "agent": projected_agent,
  3159. "messages": messages,
  3160. "initial": initial,
  3161. "triggerSeq": trigger_seq,
  3162. "throughSeq": through_seq,
  3163. "omittedMessageCount": omitted,
  3164. "omittedThroughSeq": omitted_through_seq,
  3165. "omittedSummary": omitted_summary,
  3166. "characterBudget": CONTEXT_CHARACTER_BUDGET,
  3167. }
  3168. def bind_run_session(
  3169. self,
  3170. run_id: str,
  3171. *,
  3172. expected_stored_session_id: str | None,
  3173. stored_session_id: str,
  3174. runtime_session_id: str,
  3175. ) -> dict[str, object]:
  3176. """CAS-bind stored and runtime identities for the current generation."""
  3177. canonical_run_id = self._canonical_uuid(run_id, "runId")
  3178. expected_stored = self._runtime_session_id(expected_stored_session_id)
  3179. stored = self._runtime_session_id(stored_session_id)
  3180. runtime = self._runtime_session_id(runtime_session_id)
  3181. if stored is None:
  3182. raise ValueError("storedSessionId is invalid")
  3183. if runtime is None:
  3184. raise ValueError("runtimeSessionId is invalid")
  3185. with self.write_transaction() as connection:
  3186. run = self._run_row(connection, canonical_run_id)
  3187. self._active_room(connection, run["room_id"])
  3188. agent = self._enabled_agent(connection, run["room_id"], run["agent_id"])
  3189. response = self._message_row(connection, run["response_message_id"])
  3190. self._validate_run_message_state(run, response)
  3191. if run["status"] not in {"running", "awaiting_input"}:
  3192. raise GroupConflictError("Run does not accept a session binding")
  3193. if agent["stored_session_id"] != expected_stored:
  3194. raise GroupConflictError("stored session generation changed")
  3195. if run["runtime_session_id"] not in {None, runtime}:
  3196. raise GroupConflictError("Run runtime is already bound")
  3197. self._require_available_runtime(connection, runtime, canonical_run_id)
  3198. now = self._now()
  3199. if agent["stored_session_id"] != stored:
  3200. connection.execute(
  3201. """UPDATE group_agents
  3202. SET stored_session_id = ?, updated_at = ? WHERE id = ?""",
  3203. (stored, now, agent["id"]),
  3204. )
  3205. agent = self._owned_agent(connection, run["room_id"], run["agent_id"])
  3206. self._append_event(
  3207. connection,
  3208. run["room_id"],
  3209. "agent.updated",
  3210. self._agent_wire(agent),
  3211. created_at=now,
  3212. )
  3213. if run["runtime_session_id"] != runtime:
  3214. changed = self._update_run(
  3215. connection, run, run["status"], now, runtime, UNSET
  3216. )
  3217. self._append_event(
  3218. connection,
  3219. run["room_id"],
  3220. "run.updated",
  3221. changed,
  3222. created_at=now,
  3223. )
  3224. else:
  3225. changed = self._run_wire(run)
  3226. return {"run": changed, "agent": self._agent_wire(agent)}
  3227. def advance_run_context(
  3228. self,
  3229. run_id: str,
  3230. *,
  3231. runtime_session_id: str,
  3232. expected_context_seq: int,
  3233. through_seq: int,
  3234. ) -> dict[str, object]:
  3235. """Advance one Agent context watermark with generation and hole CAS checks."""
  3236. canonical_run_id = self._canonical_uuid(run_id, "runId")
  3237. runtime = self._runtime_session_id(runtime_session_id)
  3238. if runtime is None:
  3239. raise ValueError("runtimeSessionId is invalid")
  3240. expected = self._nonnegative_int(expected_context_seq, "expectedContextSeq")
  3241. through = self._nonnegative_int(through_seq, "throughSeq")
  3242. with self.write_transaction() as connection:
  3243. run = self._run_row(connection, canonical_run_id)
  3244. self._active_room(connection, run["room_id"])
  3245. agent = self._enabled_agent(connection, run["room_id"], run["agent_id"])
  3246. if run["status"] not in {"running", "awaiting_input"}:
  3247. raise GroupConflictError("Run no longer accepts context")
  3248. if run["runtime_session_id"] != runtime:
  3249. raise GroupConflictError("Run runtime generation changed")
  3250. topic_state = self._agent_topic_state(
  3251. connection, run["agent_id"], run["topic_id"]
  3252. )
  3253. if int(topic_state["last_context_message_seq"]) != expected:
  3254. raise GroupConflictError("Agent context generation changed")
  3255. trigger = self._message_row(connection, run["trigger_message_id"])
  3256. safe = self._safe_context_seq(
  3257. connection,
  3258. run["room_id"],
  3259. run["topic_id"],
  3260. expected,
  3261. int(trigger["seq"]),
  3262. )
  3263. if through < expected or through > safe:
  3264. raise GroupConflictError("throughSeq exceeds safe projection")
  3265. now = self._now()
  3266. connection.execute(
  3267. """UPDATE group_agent_topic_state
  3268. SET last_context_message_seq = ?, updated_at = ?
  3269. WHERE agent_id = ? AND topic_id = ?""",
  3270. (through, now, agent["id"], run["topic_id"]),
  3271. )
  3272. changed = self._agent_wire(agent)
  3273. changed["lastContextMessageSeq"] = through
  3274. return changed
  3275. def commit_prompt_submission(
  3276. self,
  3277. run_id: str,
  3278. *,
  3279. expected_stored_session_id: str | None,
  3280. stored_session_id: str,
  3281. runtime_session_id: str,
  3282. expected_context_seq: int,
  3283. through_seq: int,
  3284. ) -> dict[str, object]:
  3285. """Commit a successful prompt submission and its durable context CAS."""
  3286. canonical_run_id = self._canonical_uuid(run_id, "runId")
  3287. expected_stored = self._runtime_session_id(expected_stored_session_id)
  3288. stored = self._runtime_session_id(stored_session_id)
  3289. runtime = self._runtime_session_id(runtime_session_id)
  3290. expected = self._nonnegative_int(expected_context_seq, "expectedContextSeq")
  3291. through = self._nonnegative_int(through_seq, "throughSeq")
  3292. if stored is None:
  3293. raise ValueError("storedSessionId is invalid")
  3294. if runtime is None:
  3295. raise ValueError("runtimeSessionId is invalid")
  3296. operation = f"run.prompt.submitted:{canonical_run_id}"
  3297. legacy_ledger_id = str(
  3298. uuid.uuid5(
  3299. uuid.NAMESPACE_URL,
  3300. f"hermes://yaoyao-group/{canonical_run_id}/prompt-submission",
  3301. )
  3302. )
  3303. ledger_id = f"internal:prompt:{canonical_run_id}"
  3304. payload = {
  3305. "runId": canonical_run_id,
  3306. "expectedStoredSessionId": expected_stored,
  3307. "storedSessionId": stored,
  3308. "runtimeSessionId": runtime,
  3309. "expectedContextSeq": expected,
  3310. "throughSeq": through,
  3311. }
  3312. request_hash = hashlib.sha256(
  3313. self._canonical_json(payload).encode()
  3314. ).hexdigest()
  3315. with self.write_transaction() as connection:
  3316. run = self._run_row(connection, canonical_run_id)
  3317. self._active_room(connection, run["room_id"])
  3318. agent = self._enabled_agent(connection, run["room_id"], run["agent_id"])
  3319. response = self._message_row(connection, run["response_message_id"])
  3320. self._validate_run_message_state(run, response)
  3321. if run["status"] != "running":
  3322. raise GroupConflictError("Run no longer accepts a prompt submission")
  3323. if run["runtime_session_id"] != runtime:
  3324. raise GroupConflictError("Run runtime generation changed")
  3325. current_stored = agent["stored_session_id"]
  3326. topic_state = self._agent_topic_state(
  3327. connection, run["agent_id"], run["topic_id"]
  3328. )
  3329. current_context = int(topic_state["last_context_message_seq"])
  3330. existing = connection.execute(
  3331. """SELECT request_id, operation, request_hash, response_json
  3332. FROM group_idempotency WHERE request_id = ?""",
  3333. (ledger_id,),
  3334. ).fetchone()
  3335. legacy_existing = None
  3336. if existing is None:
  3337. candidate = connection.execute(
  3338. """SELECT operation, request_hash, response_json
  3339. FROM group_idempotency WHERE request_id = ?""",
  3340. (legacy_ledger_id,),
  3341. ).fetchone()
  3342. if candidate is not None and candidate["operation"] == operation:
  3343. existing = candidate
  3344. legacy_existing = candidate
  3345. if existing is not None:
  3346. if (
  3347. existing["operation"] != operation
  3348. or existing["request_hash"] != request_hash
  3349. ):
  3350. raise GroupConflictError("Prompt submission generation changed")
  3351. if current_stored != stored or current_context != through:
  3352. raise GroupConflictError("Prompt submission generation changed")
  3353. stored_response = existing["response_json"]
  3354. try:
  3355. replay = self._load_json(stored_response)
  3356. except (TypeError, ValueError, json.JSONDecodeError) as error:
  3357. raise GroupStoreError(
  3358. "Stored prompt submission is corrupt"
  3359. ) from error
  3360. if (
  3361. not isinstance(replay, dict)
  3362. or self._canonical_json(replay) != stored_response
  3363. ):
  3364. raise GroupStoreError("Stored prompt submission is corrupt")
  3365. if legacy_existing is not None:
  3366. connection.execute(
  3367. """UPDATE group_idempotency SET request_id = ?
  3368. WHERE request_id = ?""",
  3369. (ledger_id, legacy_ledger_id),
  3370. )
  3371. return replay
  3372. if current_stored != expected_stored:
  3373. raise GroupConflictError("stored session generation changed")
  3374. if current_context != expected:
  3375. raise GroupConflictError("Agent context generation changed")
  3376. trigger = self._message_row(connection, run["trigger_message_id"])
  3377. safe = self._safe_context_seq(
  3378. connection,
  3379. run["room_id"],
  3380. run["topic_id"],
  3381. expected,
  3382. int(trigger["seq"]),
  3383. )
  3384. if through < expected or through > safe:
  3385. raise GroupConflictError("throughSeq exceeds safe projection")
  3386. now = self._now()
  3387. if current_stored != stored:
  3388. connection.execute(
  3389. """UPDATE group_agents
  3390. SET stored_session_id = ?, updated_at = ? WHERE id = ?""",
  3391. (stored, now, agent["id"]),
  3392. )
  3393. agent = self._owned_agent(connection, run["room_id"], run["agent_id"])
  3394. self._append_event(
  3395. connection,
  3396. run["room_id"],
  3397. "agent.updated",
  3398. self._agent_wire(agent),
  3399. created_at=now,
  3400. )
  3401. if current_context != through:
  3402. connection.execute(
  3403. """UPDATE group_agent_topic_state
  3404. SET last_context_message_seq = ?, updated_at = ?
  3405. WHERE agent_id = ? AND topic_id = ?""",
  3406. (through, now, agent["id"], run["topic_id"]),
  3407. )
  3408. result_agent = self._agent_wire(agent)
  3409. result_agent["lastContextMessageSeq"] = through
  3410. result = {
  3411. "run": self._run_wire(run),
  3412. "agent": result_agent,
  3413. }
  3414. connection.execute(
  3415. """INSERT INTO group_idempotency
  3416. (request_id, operation, request_hash, response_json, created_at)
  3417. VALUES (?, ?, ?, ?, ?)""",
  3418. (
  3419. ledger_id,
  3420. operation,
  3421. request_hash,
  3422. self._canonical_json(result),
  3423. now,
  3424. ),
  3425. )
  3426. return result
  3427. def settle_run(
  3428. self,
  3429. run_id: str,
  3430. *,
  3431. runtime_session_id: str | None,
  3432. expected_stored_session_id: str | None,
  3433. stored_session_id: str | None,
  3434. outcome: str,
  3435. actual_model: str | None = None,
  3436. actual_provider: str | None = None,
  3437. actual_reasoning_effort: str | None = None,
  3438. actual_fast_mode: bool | None = None,
  3439. error: str = "",
  3440. ) -> dict[str, object]:
  3441. """Settle message, run, session rotation, and status in one transaction."""
  3442. canonical_run_id = self._canonical_uuid(run_id, "runId")
  3443. runtime = self._runtime_session_id(runtime_session_id)
  3444. expected_stored = self._runtime_session_id(expected_stored_session_id)
  3445. stored = self._runtime_session_id(stored_session_id)
  3446. effective_model = self._agent_configuration_value(actual_model, "model")
  3447. effective_provider = self._agent_configuration_value(
  3448. actual_provider, "provider"
  3449. )
  3450. if (effective_model is None) != (effective_provider is None):
  3451. raise ValueError("actual model and provider must both be set or both be null")
  3452. effective_reasoning = self._agent_configuration_value(
  3453. actual_reasoning_effort, "reasoningEffort"
  3454. )
  3455. if actual_fast_mode is not None and not isinstance(actual_fast_mode, bool):
  3456. raise ValueError("actual fast mode must be a boolean or null")
  3457. run_error = self._message_text(error, "error", max_bytes=4096)
  3458. if outcome not in {"completed", "failed", "interrupted"}:
  3459. raise ValueError("outcome is invalid")
  3460. if runtime is None and outcome != "failed":
  3461. raise ValueError("A runtime-less settle only supports failed")
  3462. with self.write_transaction() as connection:
  3463. run = self._run_row(connection, canonical_run_id)
  3464. room = self._active_room(connection, run["room_id"])
  3465. agent = self._enabled_agent(connection, run["room_id"], run["agent_id"])
  3466. response = self._message_row(connection, run["response_message_id"])
  3467. self._validate_run_message_state(run, response)
  3468. if run["status"] not in {"running", "awaiting_input"}:
  3469. raise GroupConflictError("Run is already settled")
  3470. if run["runtime_session_id"] != runtime:
  3471. raise GroupConflictError("Run runtime generation changed")
  3472. if agent["stored_session_id"] != expected_stored:
  3473. raise GroupConflictError("stored session generation changed")
  3474. if outcome == "completed" and response["status"] != "completed":
  3475. raise GroupConflictError("Response message must be completed first")
  3476. now = self._now()
  3477. if stored != agent["stored_session_id"]:
  3478. connection.execute(
  3479. """UPDATE group_agents
  3480. SET stored_session_id = ?, updated_at = ? WHERE id = ?""",
  3481. (stored, now, agent["id"]),
  3482. )
  3483. agent = self._owned_agent(connection, run["room_id"], run["agent_id"])
  3484. self._append_event(
  3485. connection,
  3486. run["room_id"],
  3487. "agent.updated",
  3488. self._agent_wire(agent),
  3489. created_at=now,
  3490. )
  3491. connection.execute(
  3492. """UPDATE group_agent_runs
  3493. SET actual_model = ?, actual_provider = ?,
  3494. actual_reasoning_effort = ?, actual_fast_mode = ?
  3495. WHERE id = ?""",
  3496. (
  3497. effective_model,
  3498. effective_provider,
  3499. effective_reasoning,
  3500. None if actual_fast_mode is None else int(actual_fast_mode),
  3501. run["id"],
  3502. ),
  3503. )
  3504. run = self._run_row(connection, run["id"])
  3505. self._cancel_pending_interactions(connection, run, now)
  3506. changed = self._update_run(connection, run, outcome, now, None, run_error)
  3507. self._fail_claimed_interaction_ledgers(
  3508. connection,
  3509. run_id=run["id"],
  3510. reason=run_error or f"Run {outcome}",
  3511. runtime_session_ids=[] if runtime is None else [runtime],
  3512. run=changed,
  3513. )
  3514. self._append_event(
  3515. connection,
  3516. run["room_id"],
  3517. "run.updated",
  3518. changed,
  3519. created_at=now,
  3520. )
  3521. if (
  3522. outcome in {"failed", "interrupted"}
  3523. and response["status"] != "completed"
  3524. ):
  3525. connection.execute(
  3526. """UPDATE group_messages
  3527. SET status = ?, error = ?, updated_at = ? WHERE id = ?""",
  3528. (outcome, run_error, now, response["id"]),
  3529. )
  3530. response = self._message_row(connection, response["id"])
  3531. if response["visible"]:
  3532. self._append_event(
  3533. connection,
  3534. run["room_id"],
  3535. "message.upsert",
  3536. self._message_wire(
  3537. self._message_with_execution_row(
  3538. connection, response["id"]
  3539. )
  3540. ),
  3541. created_at=now,
  3542. )
  3543. elif (
  3544. effective_model is not None
  3545. or effective_reasoning is not None
  3546. or actual_fast_mode is not None
  3547. ) and response["visible"]:
  3548. self._append_event(
  3549. connection,
  3550. run["room_id"],
  3551. "message.upsert",
  3552. self._message_wire(
  3553. self._message_with_execution_row(connection, response["id"])
  3554. ),
  3555. created_at=now,
  3556. )
  3557. if outcome == "completed" or (
  3558. room["orchestration_mode"] == "host" and not agent["is_host"]
  3559. ):
  3560. self._record_cascade_plan(
  3561. connection,
  3562. source=run,
  3563. source_message=response,
  3564. created_at=now,
  3565. )
  3566. self._append_agent_status(connection, run["room_id"], run["agent_id"], now)
  3567. return {
  3568. "run": changed,
  3569. "message": self._message_wire(
  3570. self._message_with_execution_row(connection, response["id"])
  3571. ),
  3572. "agent": self._agent_wire(agent),
  3573. }
  3574. @classmethod
  3575. def _cascade_request_id(cls, source_run_id: str) -> str:
  3576. return f"internal:cascade:{source_run_id}"
  3577. @classmethod
  3578. def _cascade_request_hash(
  3579. cls, source_run_id: str, *, parse_version: int = _CASCADE_PARSE_VERSION
  3580. ) -> str:
  3581. payload = {
  3582. "parseVersion": parse_version,
  3583. "sourceRunId": source_run_id,
  3584. "version": 1,
  3585. }
  3586. return hashlib.sha256(cls._canonical_json(payload).encode()).hexdigest()
  3587. def _record_cascade_plan(
  3588. self,
  3589. connection: sqlite3.Connection,
  3590. *,
  3591. source: sqlite3.Row,
  3592. source_message: sqlite3.Row,
  3593. created_at: float,
  3594. ) -> None:
  3595. request_id = self._cascade_request_id(source["id"])
  3596. existing = connection.execute(
  3597. "SELECT 1 FROM group_idempotency WHERE request_id = ?",
  3598. (request_id,),
  3599. ).fetchone()
  3600. if existing is not None:
  3601. raise GroupStoreError("Cascade ledger already exists")
  3602. agents = connection.execute(
  3603. "SELECT * FROM group_agents WHERE room_id = ? "
  3604. "ORDER BY created_at ASC, id ASC",
  3605. (source["room_id"],),
  3606. ).fetchall()
  3607. room = self._active_room(connection, source["room_id"])
  3608. source_agent = next(
  3609. (agent for agent in agents if agent["id"] == source["agent_id"]), None
  3610. )
  3611. if source_agent is None:
  3612. raise GroupStoreError("Cascade source Agent is missing")
  3613. if room["orchestration_mode"] == "host":
  3614. if source_agent["is_host"] and source_message["visible"]:
  3615. target_ids, warnings = self._plan_cascade_mentions(
  3616. source_message["content"],
  3617. agents,
  3618. source_agent_id=source["agent_id"],
  3619. include_automatic=False,
  3620. )
  3621. explicit_ids = list(target_ids)
  3622. elif not source_agent["is_host"]:
  3623. host = next(
  3624. (agent for agent in agents if agent["is_host"] and agent["enabled"]),
  3625. None,
  3626. )
  3627. if host is None:
  3628. raise GroupStoreError("Administrator flow has no enabled administrator Agent")
  3629. target_ids, warnings = [str(host["id"])], []
  3630. explicit_ids = []
  3631. else:
  3632. target_ids, warnings = [], []
  3633. explicit_ids = []
  3634. elif source_message["visible"]:
  3635. target_ids, warnings = self._plan_cascade_mentions(
  3636. source_message["content"],
  3637. agents,
  3638. source_agent_id=source["agent_id"],
  3639. )
  3640. explicit_ids, _ = self._plan_cascade_mentions(
  3641. source_message["content"],
  3642. agents,
  3643. source_agent_id=source["agent_id"],
  3644. include_automatic=False,
  3645. )
  3646. else:
  3647. target_ids, warnings = [], []
  3648. explicit_ids = []
  3649. explicit_set = set(explicit_ids)
  3650. plan = {
  3651. "parseVersion": _CASCADE_PARSE_VERSION,
  3652. "sourceMessageId": source_message["id"],
  3653. "sourceRunId": source["id"],
  3654. "state": "pending",
  3655. "targets": [
  3656. {
  3657. "agentId": agent_id,
  3658. "replyMode": (
  3659. "mentioned" if agent_id in explicit_set else "automatic"
  3660. ),
  3661. }
  3662. for agent_id in target_ids
  3663. ],
  3664. "warnings": warnings,
  3665. "version": 1,
  3666. }
  3667. encoded = self._canonical_json(plan)
  3668. if len(encoded.encode("utf-8")) > _MAX_CASCADE_PLAN_BYTES:
  3669. raise GroupStoreError("Cascade plan exceeds the internal limit")
  3670. connection.execute(
  3671. """INSERT INTO group_idempotency
  3672. (request_id, operation, request_hash, response_json, created_at)
  3673. VALUES (?, ?, ?, ?, ?)""",
  3674. (
  3675. request_id,
  3676. _CASCADE_PENDING_OPERATION,
  3677. self._cascade_request_hash(source["id"]),
  3678. encoded,
  3679. created_at,
  3680. ),
  3681. )
  3682. def _record_host_flow_terminal_plan_if_needed(
  3683. self,
  3684. connection: sqlite3.Connection,
  3685. run: sqlite3.Row,
  3686. created_at: float,
  3687. ) -> None:
  3688. room = connection.execute(
  3689. "SELECT orchestration_mode, archived FROM group_rooms WHERE id = ?",
  3690. (run["room_id"],),
  3691. ).fetchone()
  3692. agent = connection.execute(
  3693. "SELECT is_host, enabled FROM group_agents WHERE id = ? AND room_id = ?",
  3694. (run["agent_id"], run["room_id"]),
  3695. ).fetchone()
  3696. if (
  3697. room is None
  3698. or room["archived"]
  3699. or room["orchestration_mode"] != "host"
  3700. or agent is None
  3701. or not agent["enabled"]
  3702. or agent["is_host"]
  3703. ):
  3704. return
  3705. self._record_cascade_plan(
  3706. connection,
  3707. source=run,
  3708. source_message=self._message_row(connection, run["response_message_id"]),
  3709. created_at=created_at,
  3710. )
  3711. @classmethod
  3712. def _plan_cascade_mentions(
  3713. cls,
  3714. content: str,
  3715. agents: list[sqlite3.Row],
  3716. *,
  3717. source_agent_id: str,
  3718. include_automatic: bool = True,
  3719. ) -> tuple[list[str], list[dict[str, str]]]:
  3720. searchable = cls._mention_search_text(content)
  3721. entries: list[tuple[str, int, sqlite3.Row]] = []
  3722. for agent in agents:
  3723. aliases = (
  3724. (agent["id"], 0),
  3725. (agent["profile"], 1),
  3726. (agent["display_name"], 2),
  3727. )
  3728. seen: set[str] = set()
  3729. for alias, priority in aliases:
  3730. key = alias.casefold()
  3731. if key in seen or is_reserved_mention_alias(key):
  3732. continue
  3733. seen.add(key)
  3734. entries.append((key, priority, agent))
  3735. entries.sort(key=lambda item: (-len(item[0]), item[1], item[0], item[2]["id"]))
  3736. targets: list[str] = []
  3737. warning_names: list[str] = []
  3738. index = 0
  3739. while index < len(searchable):
  3740. if searchable[index] != "@" or (
  3741. index > 0 and cls._mention_word_char(searchable[index - 1])
  3742. ):
  3743. index += 1
  3744. continue
  3745. room_wide_match = next(
  3746. (
  3747. match
  3748. for alias in ALL_MENTION_ALIASES
  3749. if (match := cls._casefold_prefix(searchable, index + 1, alias))
  3750. is not None
  3751. and cls._mention_end(searchable, match)
  3752. ),
  3753. None,
  3754. )
  3755. if room_wide_match is not None:
  3756. for agent in agents:
  3757. if (
  3758. agent["enabled"]
  3759. and agent["id"] != source_agent_id
  3760. and agent["id"] not in targets
  3761. ):
  3762. targets.append(agent["id"])
  3763. index = room_wide_match
  3764. continue
  3765. matched = False
  3766. for key, _priority, agent in entries:
  3767. end = cls._casefold_prefix(searchable, index + 1, key)
  3768. if end is None or not cls._mention_end(searchable, end):
  3769. continue
  3770. matched = True
  3771. if not agent["enabled"]:
  3772. warning_names.append(f"Agent disabled: {agent['display_name']}")
  3773. elif agent["id"] != source_agent_id and agent["id"] not in targets:
  3774. targets.append(agent["id"])
  3775. index = end
  3776. break
  3777. if matched:
  3778. continue
  3779. token = re.match(
  3780. r"[^\s@,,。!?!?;;::()()\[\]{}]{1,100}",
  3781. searchable[index + 1 :],
  3782. )
  3783. if token is not None:
  3784. warning_names.append(f"Unknown Mention: @{token.group(0)}")
  3785. index += 1 + len(token.group(0))
  3786. else:
  3787. index += 1
  3788. if include_automatic and not targets:
  3789. for agent in agents:
  3790. if (
  3791. agent["enabled"]
  3792. and agent["reply_without_mention"]
  3793. and agent["id"] != source_agent_id
  3794. and agent["id"] not in targets
  3795. ):
  3796. targets.append(agent["id"])
  3797. warnings = [
  3798. {"code": "mention", "message": value}
  3799. for value in list(dict.fromkeys(warning_names))[:MAX_AGENTS_PER_ROOM]
  3800. ]
  3801. return targets[:MAX_AGENTS_PER_ROOM], warnings
  3802. @staticmethod
  3803. def _casefold_prefix(content: str, start: int, expected: str) -> int | None:
  3804. folded = ""
  3805. index = start
  3806. while index < len(content) and len(folded) < len(expected):
  3807. folded += content[index].casefold()
  3808. index += 1
  3809. return index if folded == expected else None
  3810. @staticmethod
  3811. def _mention_search_text(content: str) -> str:
  3812. lines = content.splitlines(keepends=True)
  3813. visible: list[str] = []
  3814. fence: tuple[str, int] | None = None
  3815. for line in lines:
  3816. leading_spaces = len(line) - len(line.lstrip(" "))
  3817. candidate = line[leading_spaces:] if leading_spaces <= 3 else ""
  3818. marker_character = (
  3819. candidate[0]
  3820. if candidate.startswith("`") or candidate.startswith("~")
  3821. else None
  3822. )
  3823. marker_length = 0
  3824. if marker_character is not None:
  3825. while (
  3826. marker_length < len(candidate)
  3827. and candidate[marker_length] == marker_character
  3828. ):
  3829. marker_length += 1
  3830. if fence is None and marker_length >= 3:
  3831. fence = (marker_character, marker_length)
  3832. continue
  3833. if fence is not None:
  3834. fence_character, fence_length = fence
  3835. remainder = candidate[marker_length:]
  3836. if (
  3837. marker_character == fence_character
  3838. and marker_length >= fence_length
  3839. and not remainder.strip()
  3840. ):
  3841. fence = None
  3842. continue
  3843. visible.append(line)
  3844. searchable = "".join(visible)
  3845. inline_visible: list[str] = []
  3846. for line in searchable.splitlines(keepends=True):
  3847. index = 0
  3848. while index < len(line):
  3849. if line[index] != "`":
  3850. inline_visible.append(line[index])
  3851. index += 1
  3852. continue
  3853. end = index + 1
  3854. while end < len(line) and line[end] == "`":
  3855. end += 1
  3856. delimiter = line[index:end]
  3857. close = line.find(delimiter, end)
  3858. if close < 0:
  3859. if line.endswith("\n"):
  3860. inline_visible.append("\n")
  3861. break
  3862. index = close + len(delimiter)
  3863. searchable = "".join(inline_visible)
  3864. searchable = re.sub(
  3865. r"(?:[A-Za-z][A-Za-z0-9+.-]*://|www\.)"
  3866. r"[^\s,。!?;:()()\[\]{}]+",
  3867. "",
  3868. searchable,
  3869. flags=re.IGNORECASE,
  3870. )
  3871. return re.sub(r"\\@", "", searchable)
  3872. @staticmethod
  3873. def _mention_word_char(value: str) -> bool:
  3874. return value.isalnum() or value in {"_", ".", "-", "+", "@"}
  3875. @classmethod
  3876. def _mention_end(cls, value: str, end: int) -> bool:
  3877. return end >= len(value) or not cls._mention_word_char(value[end])
  3878. def list_pending_cascades(
  3879. self, *, limit: int = _MAX_CASCADE_PAGE_SIZE, after: int | None = None
  3880. ) -> dict[str, object]:
  3881. if (
  3882. isinstance(limit, bool)
  3883. or not isinstance(limit, int)
  3884. or not (1 <= limit <= _MAX_CASCADE_PAGE_SIZE)
  3885. ):
  3886. raise ValueError("limit must be between 1 and 32")
  3887. if after is None:
  3888. after_value = 0
  3889. elif isinstance(after, bool) or not isinstance(after, int) or after < 0:
  3890. raise ValueError("after must be a non-negative integer")
  3891. else:
  3892. after_value = after
  3893. with self.read_transaction() as connection:
  3894. rows = connection.execute(
  3895. """SELECT rowid, request_id, operation, request_hash, response_json
  3896. FROM group_idempotency
  3897. WHERE operation = ? AND rowid > ?
  3898. ORDER BY rowid ASC LIMIT ?""",
  3899. (_CASCADE_PENDING_OPERATION, after_value, limit + 1),
  3900. ).fetchall()
  3901. validated = [
  3902. (int(row["rowid"]), self._cascade_plan_from_ledger(row)) for row in rows
  3903. ]
  3904. has_more = len(validated) > limit
  3905. validated = validated[:limit]
  3906. items = [{"sourceRunId": plan["sourceRunId"]} for _rowid, plan in validated]
  3907. return {
  3908. "items": items,
  3909. "nextCursor": (validated[-1][0] if has_more and validated else None),
  3910. }
  3911. def _cascade_plan_from_ledger(
  3912. self, ledger: Mapping[str, object]
  3913. ) -> dict[str, object]:
  3914. try:
  3915. request_id = ledger["request_id"]
  3916. operation = ledger["operation"]
  3917. request_hash = ledger["request_hash"]
  3918. encoded = ledger["response_json"]
  3919. except (KeyError, IndexError, TypeError) as error:
  3920. raise GroupStoreError("Stored cascade ledger is corrupt") from error
  3921. prefix = "internal:cascade:"
  3922. if (
  3923. not isinstance(request_id, str)
  3924. or not request_id.startswith(prefix)
  3925. or operation != _CASCADE_PENDING_OPERATION
  3926. or not isinstance(encoded, str)
  3927. or len(encoded.encode("utf-8")) > _MAX_CASCADE_PLAN_BYTES
  3928. ):
  3929. raise GroupStoreError("Stored cascade ledger is corrupt")
  3930. raw_source_id = request_id[len(prefix) :]
  3931. try:
  3932. source_id = self._canonical_uuid(raw_source_id, "sourceRunId")
  3933. except ValueError as error:
  3934. raise GroupStoreError("Stored cascade ledger is corrupt") from error
  3935. if raw_source_id != source_id:
  3936. raise GroupStoreError("Stored cascade ledger is corrupt")
  3937. try:
  3938. plan = self._load_json(encoded)
  3939. except (TypeError, ValueError, json.JSONDecodeError) as error:
  3940. raise GroupStoreError("Stored cascade plan is corrupt") from error
  3941. if not isinstance(plan, dict) or self._canonical_json(plan) != encoded:
  3942. raise GroupStoreError("Stored cascade plan is corrupt")
  3943. parse_version = plan.get("parseVersion")
  3944. if (
  3945. parse_version not in {1, 2, 3}
  3946. or request_hash
  3947. != self._cascade_request_hash(source_id, parse_version=parse_version)
  3948. ):
  3949. raise GroupStoreError("Stored cascade ledger is corrupt")
  3950. common_invalid = (
  3951. plan.get("state") != "pending"
  3952. or plan.get("version") != 1
  3953. or plan.get("sourceRunId") != source_id
  3954. or not isinstance(plan.get("warnings"), list)
  3955. or not all(isinstance(item, dict) for item in plan["warnings"])
  3956. or len(plan["warnings"]) > MAX_AGENTS_PER_ROOM
  3957. )
  3958. if common_invalid:
  3959. raise GroupStoreError("Stored cascade plan is corrupt")
  3960. if parse_version == 1:
  3961. expected_keys = {
  3962. "parseVersion",
  3963. "sourceMessageId",
  3964. "sourceRunId",
  3965. "state",
  3966. "targetAgentIds",
  3967. "version",
  3968. "warnings",
  3969. }
  3970. target_ids = plan.get("targetAgentIds")
  3971. if (
  3972. set(plan) != expected_keys
  3973. or not isinstance(target_ids, list)
  3974. or not all(isinstance(item, str) for item in target_ids)
  3975. ):
  3976. raise GroupStoreError("Stored cascade plan is corrupt")
  3977. targets = [
  3978. {"agentId": item, "replyMode": "mentioned"} for item in target_ids
  3979. ]
  3980. else:
  3981. expected_keys = {
  3982. "parseVersion",
  3983. "sourceMessageId",
  3984. "sourceRunId",
  3985. "state",
  3986. "targets",
  3987. "version",
  3988. "warnings",
  3989. }
  3990. targets = plan.get("targets")
  3991. if (
  3992. set(plan) != expected_keys
  3993. or not isinstance(targets, list)
  3994. or not all(isinstance(item, dict) for item in targets)
  3995. ):
  3996. raise GroupStoreError("Stored cascade plan is corrupt")
  3997. if (
  3998. len(targets) > MAX_AGENTS_PER_ROOM
  3999. or any(set(item) != {"agentId", "replyMode"} for item in targets)
  4000. or any(not isinstance(item.get("agentId"), str) for item in targets)
  4001. or any(item.get("replyMode") not in {"mentioned", "automatic"} for item in targets)
  4002. or len({item.get("agentId") for item in targets}) != len(targets)
  4003. ):
  4004. raise GroupStoreError("Stored cascade plan is corrupt")
  4005. try:
  4006. if (
  4007. self._canonical_uuid(plan["sourceMessageId"], "sourceMessageId")
  4008. != plan["sourceMessageId"]
  4009. ):
  4010. raise ValueError
  4011. for target in targets:
  4012. if (
  4013. self._canonical_uuid(target["agentId"], "targetAgentId")
  4014. != target["agentId"]
  4015. ):
  4016. raise ValueError
  4017. for warning in plan["warnings"]:
  4018. if set(warning) != {"code", "message"} or warning.get("code") != "mention":
  4019. raise ValueError
  4020. self._message_text(
  4021. warning.get("message"),
  4022. "warning.message",
  4023. max_bytes=MAX_MESSAGE_BYTES,
  4024. nonblank=True,
  4025. )
  4026. except (KeyError, TypeError, ValueError) as error:
  4027. raise GroupStoreError("Stored cascade plan is corrupt") from error
  4028. return {
  4029. "parseVersion": parse_version,
  4030. "sourceMessageId": plan["sourceMessageId"],
  4031. "sourceRunId": source_id,
  4032. "state": "pending",
  4033. "targets": targets,
  4034. "version": 1,
  4035. "warnings": plan["warnings"],
  4036. }
  4037. def enqueue_cascade_runs(
  4038. self,
  4039. source_run_id: str,
  4040. *,
  4041. agent_ids: list[str],
  4042. warnings: list[Mapping[str, object]],
  4043. ) -> dict[str, object]:
  4044. """Create bounded, deduplicated Agent cascades and visible warnings."""
  4045. canonical_source_id = self._canonical_uuid(source_run_id, "sourceRunId")
  4046. canonical_agent_ids, requested_warnings = self._validated_cascade_inputs(
  4047. agent_ids, warnings
  4048. )
  4049. with self.write_transaction() as connection:
  4050. source = self._run_row(connection, canonical_source_id)
  4051. self._active_room(connection, source["room_id"])
  4052. self._enabled_agent(connection, source["room_id"], source["agent_id"])
  4053. if source["status"] != "completed":
  4054. raise GroupConflictError("Cascade source run must be completed")
  4055. source_message = self._message_row(
  4056. connection, source["response_message_id"]
  4057. )
  4058. if source_message["status"] != "completed":
  4059. raise GroupConflictError("Cascade source message must be completed")
  4060. return self._enqueue_cascade_runs_transaction(
  4061. connection,
  4062. source=source,
  4063. canonical_agent_ids=canonical_agent_ids,
  4064. requested_warnings=requested_warnings,
  4065. now=self._now(),
  4066. )
  4067. def _validated_cascade_inputs(
  4068. self,
  4069. agent_ids: object,
  4070. warnings: object,
  4071. ) -> tuple[list[str], list[str]]:
  4072. if not isinstance(agent_ids, list):
  4073. raise ValueError("agent_ids must be a list")
  4074. if len(agent_ids) > MAX_AGENTS_PER_ROOM:
  4075. raise ValueError(
  4076. f"agent_ids must contain at most {MAX_AGENTS_PER_ROOM} entries"
  4077. )
  4078. canonical_agent_ids: list[str] = []
  4079. seen_agent_ids: set[str] = set()
  4080. for value in agent_ids:
  4081. agent_id = self._canonical_uuid(value, "agentId")
  4082. if agent_id not in seen_agent_ids:
  4083. seen_agent_ids.add(agent_id)
  4084. canonical_agent_ids.append(agent_id)
  4085. if not isinstance(warnings, list):
  4086. raise ValueError("warnings must be a list of objects")
  4087. if len(warnings) > MAX_AGENTS_PER_ROOM:
  4088. raise ValueError(
  4089. f"warnings must contain at most {MAX_AGENTS_PER_ROOM} entries"
  4090. )
  4091. if not all(isinstance(item, Mapping) for item in warnings):
  4092. raise ValueError("warnings must be a list of objects")
  4093. warnings_json = self._strict_json(
  4094. [dict(warning) for warning in warnings], "warnings"
  4095. )
  4096. if len(warnings_json.encode("utf-8")) > MAX_MESSAGE_BYTES:
  4097. raise ValueError("warnings exceed maximum size")
  4098. requested_warnings: list[str] = []
  4099. for warning in warnings:
  4100. message = warning.get("message")
  4101. requested_warnings.append(
  4102. self._message_text(
  4103. message,
  4104. "warning.message",
  4105. max_bytes=MAX_MESSAGE_BYTES,
  4106. nonblank=True,
  4107. )
  4108. )
  4109. return canonical_agent_ids, requested_warnings
  4110. def complete_cascade(self, source_run_id: str) -> dict[str, object]:
  4111. """Execute one frozen Mention plan exactly once and terminalize it."""
  4112. canonical_source_id = self._canonical_uuid(source_run_id, "sourceRunId")
  4113. request_id = self._cascade_request_id(canonical_source_id)
  4114. expected_hashes = {
  4115. self._cascade_request_hash(canonical_source_id, parse_version=1),
  4116. self._cascade_request_hash(canonical_source_id, parse_version=2),
  4117. self._cascade_request_hash(canonical_source_id, parse_version=3),
  4118. }
  4119. with self.write_transaction() as connection:
  4120. ledger = connection.execute(
  4121. """SELECT request_id, operation, request_hash, response_json
  4122. FROM group_idempotency WHERE request_id = ?""",
  4123. (request_id,),
  4124. ).fetchone()
  4125. if ledger is None:
  4126. raise GroupNotFoundError("Cascade source not found")
  4127. if (
  4128. ledger["operation"] not in _CASCADE_OPERATIONS
  4129. or ledger["request_hash"] not in expected_hashes
  4130. ):
  4131. raise GroupStoreError("Stored cascade ledger is corrupt")
  4132. if ledger["operation"] != _CASCADE_PENDING_OPERATION:
  4133. return self._cascade_result(
  4134. ledger["response_json"], ledger["operation"]
  4135. )
  4136. plan = self._cascade_plan_from_ledger(ledger)
  4137. source = connection.execute(
  4138. "SELECT * FROM group_agent_runs WHERE id = ?",
  4139. (canonical_source_id,),
  4140. ).fetchone()
  4141. if source is None:
  4142. raise GroupStoreError("Stored cascade source is missing")
  4143. room = connection.execute(
  4144. "SELECT archived, orchestration_mode FROM group_rooms WHERE id = ?",
  4145. (source["room_id"],),
  4146. ).fetchone()
  4147. source_agent = connection.execute(
  4148. "SELECT enabled, is_host FROM group_agents WHERE id = ? AND room_id = ?",
  4149. (source["agent_id"], source["room_id"]),
  4150. ).fetchone()
  4151. if room is None:
  4152. raise GroupStoreError("Stored cascade ownership is corrupt")
  4153. source_message = self._message_row(
  4154. connection, source["response_message_id"]
  4155. )
  4156. host_flow_member_terminal = (
  4157. room["orchestration_mode"] == "host"
  4158. and source_agent is not None
  4159. and not source_agent["is_host"]
  4160. and source["status"] in {"completed", "failed", "interrupted"}
  4161. )
  4162. if source["status"] != "completed" and not host_flow_member_terminal:
  4163. raise GroupStoreError("Stored cascade source is not terminal")
  4164. if source_message["id"] != plan["sourceMessageId"]:
  4165. raise GroupStoreError("Stored cascade message is corrupt")
  4166. self._validate_run_message_status(
  4167. str(source["status"]), str(source_message["status"])
  4168. )
  4169. if room["archived"] or source_agent is None or not source_agent["enabled"]:
  4170. result = self._cascade_summary([], [], state="discarded")
  4171. connection.execute(
  4172. """UPDATE group_idempotency
  4173. SET operation = ?, response_json = ? WHERE request_id = ?""",
  4174. (
  4175. _CASCADE_DISCARDED_OPERATION,
  4176. self._canonical_json(result),
  4177. request_id,
  4178. ),
  4179. )
  4180. return result
  4181. try:
  4182. canonical_ids, warning_texts = self._validated_cascade_inputs(
  4183. [target["agentId"] for target in plan["targets"]],
  4184. plan["warnings"],
  4185. )
  4186. reply_modes = {
  4187. target["agentId"]: target["replyMode"]
  4188. for target in plan["targets"]
  4189. }
  4190. except ValueError as error:
  4191. raise GroupStoreError("Stored cascade plan is corrupt") from error
  4192. created = self._enqueue_cascade_runs_transaction(
  4193. connection,
  4194. source=source,
  4195. canonical_agent_ids=canonical_ids,
  4196. requested_warnings=warning_texts,
  4197. reply_modes=reply_modes,
  4198. now=self._now(),
  4199. )
  4200. result = self._cascade_summary(
  4201. created["runs"], created["systemMessages"], state="completed"
  4202. )
  4203. connection.execute(
  4204. """UPDATE group_idempotency
  4205. SET operation = ?, response_json = ? WHERE request_id = ?""",
  4206. (
  4207. _CASCADE_COMPLETED_OPERATION,
  4208. self._canonical_json(result),
  4209. request_id,
  4210. ),
  4211. )
  4212. return result
  4213. @classmethod
  4214. def _cascade_result(cls, encoded: object, operation: str) -> dict[str, object]:
  4215. try:
  4216. result = cls._load_json(encoded)
  4217. except (TypeError, ValueError, json.JSONDecodeError) as error:
  4218. raise GroupStoreError("Stored cascade result is corrupt") from error
  4219. expected_state = (
  4220. "completed" if operation == _CASCADE_COMPLETED_OPERATION else "discarded"
  4221. )
  4222. expected_keys = {
  4223. "runCount",
  4224. "runIds",
  4225. "state",
  4226. "systemMessageCount",
  4227. "systemMessageIds",
  4228. }
  4229. if expected_state == "discarded":
  4230. expected_keys.add("discarded")
  4231. if (
  4232. not isinstance(result, dict)
  4233. or cls._canonical_json(result) != encoded
  4234. or set(result) != expected_keys
  4235. or result.get("state") != expected_state
  4236. or not isinstance(result.get("runIds"), list)
  4237. or not all(isinstance(item, str) for item in result["runIds"])
  4238. or len(result["runIds"]) > MAX_AGENTS_PER_ROOM
  4239. or len(result["runIds"]) != len(set(result["runIds"]))
  4240. or type(result.get("runCount")) is not int
  4241. or result.get("runCount") != len(result["runIds"])
  4242. or not isinstance(result.get("systemMessageIds"), list)
  4243. or not all(isinstance(item, str) for item in result["systemMessageIds"])
  4244. or len(result["systemMessageIds"]) > MAX_AGENTS_PER_ROOM
  4245. or len(result["systemMessageIds"]) != len(set(result["systemMessageIds"]))
  4246. or type(result.get("systemMessageCount")) is not int
  4247. or result.get("systemMessageCount") != len(result["systemMessageIds"])
  4248. or (expected_state == "discarded") != (result.get("discarded") is True)
  4249. ):
  4250. raise GroupStoreError("Stored cascade result is corrupt")
  4251. try:
  4252. if any(
  4253. cls._canonical_uuid(item, "runId") != item for item in result["runIds"]
  4254. ) or any(
  4255. cls._canonical_uuid(item, "messageId") != item
  4256. for item in result["systemMessageIds"]
  4257. ):
  4258. raise ValueError
  4259. except ValueError as error:
  4260. raise GroupStoreError("Stored cascade result is corrupt") from error
  4261. return result
  4262. @staticmethod
  4263. def _cascade_summary(
  4264. runs: list[Mapping[str, object]],
  4265. messages: list[Mapping[str, object]],
  4266. *,
  4267. state: str,
  4268. ) -> dict[str, object]:
  4269. result: dict[str, object] = {
  4270. "runCount": len(runs),
  4271. "runIds": [run["id"] for run in runs],
  4272. "state": state,
  4273. "systemMessageCount": len(messages),
  4274. "systemMessageIds": [message["id"] for message in messages],
  4275. }
  4276. if state == "discarded":
  4277. result["discarded"] = True
  4278. return result
  4279. def _enqueue_cascade_runs_transaction(
  4280. self,
  4281. connection: sqlite3.Connection,
  4282. *,
  4283. source: sqlite3.Row,
  4284. canonical_agent_ids: list[str],
  4285. requested_warnings: list[str],
  4286. now: float,
  4287. reply_modes: Mapping[str, str] | None = None,
  4288. ) -> dict[str, list[dict[str, object]]]:
  4289. room = self._active_room(connection, source["room_id"])
  4290. room_agents = connection.execute(
  4291. """SELECT * FROM group_agents WHERE room_id = ?
  4292. ORDER BY created_at ASC, id ASC""",
  4293. (source["room_id"],),
  4294. ).fetchall()
  4295. agents_by_id = {agent["id"]: agent for agent in room_agents}
  4296. warning_texts = list(requested_warnings)
  4297. source_agent = agents_by_id.get(source["agent_id"])
  4298. if source_agent is None:
  4299. raise GroupStoreError("Cascade source Agent is missing")
  4300. host_review = (
  4301. room["orchestration_mode"] == "host" and not source_agent["is_host"]
  4302. )
  4303. next_depth = int(source["depth"]) + (0 if host_review else 1)
  4304. max_reply_rounds = int(room["max_reply_rounds"])
  4305. if max_reply_rounds != -1 and next_depth >= max_reply_rounds:
  4306. warning_texts.append("Cascade depth limit reached")
  4307. canonical_agent_ids = []
  4308. source_message = self._message_row(connection, source["response_message_id"])
  4309. trigger_message_id = str(source["response_message_id"])
  4310. if host_review:
  4311. current_host = next(
  4312. (agent for agent in room_agents if agent["is_host"] and agent["enabled"]),
  4313. None,
  4314. )
  4315. if current_host is None:
  4316. raise GroupStoreError("Administrator flow has no enabled administrator Agent")
  4317. canonical_agent_ids = [str(current_host["id"])]
  4318. reply_modes = {str(current_host["id"]): "automatic"}
  4319. unfinished_sibling = connection.execute(
  4320. """SELECT 1 FROM group_agent_runs
  4321. WHERE topic_id = ? AND root_message_id = ? AND depth = ?
  4322. AND trigger_message_id = ? AND id != ?
  4323. AND status NOT IN ('completed', 'failed', 'interrupted')
  4324. LIMIT 1""",
  4325. (
  4326. source["topic_id"],
  4327. source["root_message_id"],
  4328. source["depth"],
  4329. source["trigger_message_id"],
  4330. source["id"],
  4331. ),
  4332. ).fetchone()
  4333. if unfinished_sibling is not None:
  4334. return {"runs": [], "systemMessages": []}
  4335. existing_review = connection.execute(
  4336. """SELECT 1 FROM group_agent_runs
  4337. WHERE topic_id = ? AND root_message_id = ? AND depth = ?
  4338. AND agent_id = ? LIMIT 1""",
  4339. (
  4340. source["topic_id"],
  4341. source["root_message_id"],
  4342. next_depth,
  4343. current_host["id"],
  4344. ),
  4345. ).fetchone()
  4346. if existing_review is not None:
  4347. return {"runs": [], "systemMessages": []}
  4348. barrier = connection.execute(
  4349. """SELECT id FROM group_messages
  4350. WHERE room_id = ? AND topic_id = ? AND root_message_id = ?
  4351. AND reply_to_message_id = ? AND sender_kind = 'system'
  4352. AND visible = 0 AND content = ? AND status = 'completed'
  4353. ORDER BY seq ASC LIMIT 1""",
  4354. (
  4355. source["room_id"],
  4356. source["topic_id"],
  4357. source["root_message_id"],
  4358. source["trigger_message_id"],
  4359. _HOST_FLOW_BARRIER_CONTENT,
  4360. ),
  4361. ).fetchone()
  4362. if barrier is None:
  4363. barrier_id = str(uuid.uuid4())
  4364. connection.execute(
  4365. """INSERT INTO group_messages
  4366. (id, room_id, topic_id, sender_kind, sender_id, sender_name,
  4367. root_message_id, reply_to_message_id, client_message_id,
  4368. content, reasoning, tool_state_json, status, error, visible,
  4369. created_at, updated_at)
  4370. VALUES (?, ?, ?, 'system', 'system', '系统', ?, ?, NULL,
  4371. ?, '', '[]', 'completed', '', 0, ?, ?)""",
  4372. (
  4373. barrier_id,
  4374. source["room_id"],
  4375. source["topic_id"],
  4376. source["root_message_id"],
  4377. source["trigger_message_id"],
  4378. _HOST_FLOW_BARRIER_CONTENT,
  4379. now,
  4380. now,
  4381. ),
  4382. )
  4383. trigger_message_id = barrier_id
  4384. else:
  4385. trigger_message_id = str(barrier["id"])
  4386. explicit_ids, _ = self._plan_cascade_mentions(
  4387. source_message["content"],
  4388. room_agents,
  4389. source_agent_id=source["agent_id"],
  4390. include_automatic=False,
  4391. )
  4392. explicit_set = set(explicit_ids)
  4393. created_runs: list[dict[str, object]] = []
  4394. for agent_id in canonical_agent_ids:
  4395. agent = agents_by_id.get(agent_id)
  4396. if agent is None:
  4397. warning_texts.append(f"Unknown Agent: {agent_id}")
  4398. continue
  4399. if not agent["enabled"]:
  4400. warning_texts.append(f"Agent disabled: {agent['display_name']}")
  4401. continue
  4402. duplicate = connection.execute(
  4403. """SELECT 1 FROM group_agent_runs
  4404. WHERE room_id = ? AND topic_id = ? AND root_message_id = ?
  4405. AND agent_id = ? AND depth = ? LIMIT 1""",
  4406. (
  4407. source["room_id"],
  4408. source["topic_id"],
  4409. source["root_message_id"],
  4410. agent_id,
  4411. next_depth,
  4412. ),
  4413. ).fetchone()
  4414. if duplicate is not None:
  4415. continue
  4416. reply_mode = (
  4417. reply_modes.get(agent_id, "mentioned")
  4418. if reply_modes is not None
  4419. else ("mentioned" if agent_id in explicit_set else "automatic")
  4420. )
  4421. if reply_mode not in {"mentioned", "automatic"}:
  4422. raise GroupStoreError("Stored cascade plan is corrupt")
  4423. required_reply = bool(host_review and agent["is_host"])
  4424. visible = reply_mode == "mentioned" or required_reply
  4425. response_id = str(uuid.uuid4())
  4426. run_id = str(uuid.uuid4())
  4427. connection.execute(
  4428. """INSERT INTO group_messages
  4429. (id, room_id, topic_id, sender_kind, sender_id, sender_name,
  4430. root_message_id, reply_to_message_id, client_message_id,
  4431. content, reasoning, tool_state_json, status, error, visible,
  4432. created_at, updated_at)
  4433. VALUES (?, ?, ?, 'agent', ?, ?, ?, ?, NULL,
  4434. '', '', '[]', 'queued', '', ?, ?, ?)""",
  4435. (
  4436. response_id,
  4437. source["room_id"],
  4438. source["topic_id"],
  4439. agent_id,
  4440. agent["display_name"],
  4441. source["root_message_id"],
  4442. trigger_message_id,
  4443. int(visible),
  4444. now,
  4445. now,
  4446. ),
  4447. )
  4448. connection.execute(
  4449. """INSERT INTO group_agent_runs
  4450. (id, room_id, topic_id, agent_id, trigger_message_id,
  4451. response_message_id, root_message_id, depth, reply_mode,
  4452. required_reply, status, runtime_session_id, requested_model,
  4453. requested_provider, requested_reasoning_effort,
  4454. requested_fast_mode, actual_model, actual_provider,
  4455. actual_reasoning_effort, actual_fast_mode, error, created_at,
  4456. updated_at)
  4457. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', NULL,
  4458. ?, ?, ?, ?, NULL, NULL, NULL, NULL, '', ?, ?)""",
  4459. (
  4460. run_id,
  4461. source["room_id"],
  4462. source["topic_id"],
  4463. agent_id,
  4464. trigger_message_id,
  4465. response_id,
  4466. source["root_message_id"],
  4467. next_depth,
  4468. reply_mode,
  4469. int(required_reply),
  4470. agent["model_override"],
  4471. agent["provider_override"],
  4472. agent["reasoning_effort_override"],
  4473. agent["fast_mode_override"],
  4474. now,
  4475. now,
  4476. ),
  4477. )
  4478. message = self._message_wire(self._message_row(connection, response_id))
  4479. run = self._run_wire(self._run_row(connection, run_id))
  4480. if visible:
  4481. self._append_event(
  4482. connection,
  4483. source["room_id"],
  4484. "message.upsert",
  4485. message,
  4486. created_at=now,
  4487. )
  4488. self._append_event(
  4489. connection,
  4490. source["room_id"],
  4491. "run.updated",
  4492. run,
  4493. created_at=now,
  4494. )
  4495. created_runs.append(run)
  4496. if (
  4497. room["orchestration_mode"] == "host"
  4498. and source_agent["is_host"]
  4499. and created_runs
  4500. ):
  4501. barrier_id = str(uuid.uuid4())
  4502. connection.execute(
  4503. """INSERT INTO group_messages
  4504. (id, room_id, topic_id, sender_kind, sender_id, sender_name,
  4505. root_message_id, reply_to_message_id, client_message_id,
  4506. content, reasoning, tool_state_json, status, error, visible,
  4507. created_at, updated_at)
  4508. VALUES (?, ?, ?, 'system', 'system', '系统', ?, ?, NULL,
  4509. ?, '', '[]', 'completed', '', 0, ?, ?)""",
  4510. (
  4511. barrier_id,
  4512. source["room_id"],
  4513. source["topic_id"],
  4514. source["root_message_id"],
  4515. source["response_message_id"],
  4516. _HOST_FLOW_BARRIER_CONTENT,
  4517. now,
  4518. now,
  4519. ),
  4520. )
  4521. system_messages: list[dict[str, object]] = []
  4522. for warning_text in list(dict.fromkeys(warning_texts))[:MAX_AGENTS_PER_ROOM]:
  4523. duplicate = connection.execute(
  4524. """SELECT * FROM group_messages
  4525. WHERE room_id = ? AND topic_id = ? AND sender_kind = 'system'
  4526. AND root_message_id = ? AND reply_to_message_id = ?
  4527. AND content = ? AND status = 'completed'
  4528. ORDER BY seq ASC LIMIT 1""",
  4529. (
  4530. source["room_id"],
  4531. source["topic_id"],
  4532. source["root_message_id"],
  4533. source["response_message_id"],
  4534. warning_text,
  4535. ),
  4536. ).fetchone()
  4537. if duplicate is not None:
  4538. continue
  4539. message_id = str(uuid.uuid4())
  4540. connection.execute(
  4541. """INSERT INTO group_messages
  4542. (id, room_id, topic_id, sender_kind, sender_id, sender_name,
  4543. root_message_id, reply_to_message_id, client_message_id,
  4544. content, reasoning, tool_state_json, status, error,
  4545. created_at, updated_at)
  4546. VALUES (?, ?, ?, 'system', 'system', '系统', ?, ?, NULL,
  4547. ?, '', '[]', 'completed', '', ?, ?)""",
  4548. (
  4549. message_id,
  4550. source["room_id"],
  4551. source["topic_id"],
  4552. source["root_message_id"],
  4553. source["response_message_id"],
  4554. warning_text,
  4555. now,
  4556. now,
  4557. ),
  4558. )
  4559. message = self._message_wire(self._message_row(connection, message_id))
  4560. self._append_event(
  4561. connection,
  4562. source["room_id"],
  4563. "message.upsert",
  4564. message,
  4565. created_at=now,
  4566. )
  4567. system_messages.append(message)
  4568. return {"runs": created_runs, "systemMessages": system_messages}
  4569. def upsert_agent_message(
  4570. self,
  4571. run_id: str,
  4572. *,
  4573. content: str,
  4574. reasoning: str,
  4575. tool_state: object,
  4576. status: str,
  4577. error: str = "",
  4578. publish: bool = True,
  4579. ) -> dict[str, object]:
  4580. canonical_run_id = self._canonical_uuid(run_id, "runId")
  4581. content = self._message_text(content, "content", max_bytes=MAX_MESSAGE_BYTES)
  4582. reasoning = self._message_text(
  4583. reasoning, "reasoning", max_bytes=MAX_MESSAGE_BYTES
  4584. )
  4585. error = self._message_text(error, "error", max_bytes=4096)
  4586. if not isinstance(status, str) or status not in _MESSAGE_STATUSES:
  4587. raise ValueError("status is invalid")
  4588. tool_json = self._tool_state_json(tool_state)
  4589. with self.write_transaction() as connection:
  4590. run = self._run_row(connection, canonical_run_id)
  4591. self._active_room(connection, run["room_id"])
  4592. self._enabled_agent(connection, run["room_id"], run["agent_id"])
  4593. response = self._message_row(connection, run["response_message_id"])
  4594. if (
  4595. response["room_id"] != run["room_id"]
  4596. or response["sender_id"] != run["agent_id"]
  4597. ):
  4598. raise GroupStoreError("Run response message is corrupt")
  4599. self._validate_run_message_state(run, response)
  4600. if status not in _AGENT_MESSAGE_STATUSES_BY_RUN[run["status"]]:
  4601. raise GroupConflictError("Message status is invalid for run state")
  4602. if status not in _AGENT_MESSAGE_TRANSITIONS.get(
  4603. response["status"], frozenset()
  4604. ):
  4605. raise GroupConflictError("Message status cannot move backwards")
  4606. now = self._now()
  4607. visible = bool(response["visible"]) or publish
  4608. connection.execute(
  4609. """UPDATE group_messages SET content = ?, reasoning = ?, tool_state_json = ?,
  4610. status = ?, error = ?, visible = ?, updated_at = ? WHERE id = ?""",
  4611. (
  4612. content,
  4613. reasoning,
  4614. tool_json,
  4615. status,
  4616. error,
  4617. int(visible),
  4618. now,
  4619. run["response_message_id"],
  4620. ),
  4621. )
  4622. message = self._message_wire(
  4623. self._message_row(connection, run["response_message_id"])
  4624. )
  4625. if visible:
  4626. self._append_event(
  4627. connection,
  4628. run["room_id"],
  4629. "message.upsert",
  4630. message,
  4631. created_at=now,
  4632. )
  4633. return message
  4634. def transition_run(
  4635. self,
  4636. run_id: str,
  4637. status: str,
  4638. *,
  4639. runtime_session_id: object = UNSET,
  4640. error: object = UNSET,
  4641. ) -> dict[str, object]:
  4642. canonical_run_id = self._canonical_uuid(run_id, "runId")
  4643. if not isinstance(status, str) or status not in RUN_TRANSITIONS:
  4644. raise ValueError("status is invalid")
  4645. runtime_value = (
  4646. self._runtime_session_id(runtime_session_id)
  4647. if runtime_session_id is not UNSET
  4648. else UNSET
  4649. )
  4650. error_value = (
  4651. self._message_text(error, "error", max_bytes=4096)
  4652. if error is not UNSET
  4653. else UNSET
  4654. )
  4655. with self.write_transaction() as connection:
  4656. run = self._run_row(connection, canonical_run_id)
  4657. room = self._active_room(connection, run["room_id"])
  4658. agent = self._enabled_agent(connection, run["room_id"], run["agent_id"])
  4659. response = self._message_row(connection, run["response_message_id"])
  4660. if (
  4661. response["room_id"] != run["room_id"]
  4662. or response["sender_id"] != run["agent_id"]
  4663. ):
  4664. raise GroupStoreError("Run response message is corrupt")
  4665. self._validate_run_message_state(run, response)
  4666. if status == run["status"]:
  4667. if status == "running":
  4668. if (
  4669. runtime_value is not UNSET
  4670. and runtime_value is not None
  4671. and run["runtime_session_id"] is not None
  4672. and runtime_value == run["runtime_session_id"]
  4673. ):
  4674. return self._run_wire(run)
  4675. raise GroupConflictError("Run is already claimed")
  4676. return self._run_wire(run)
  4677. if status not in RUN_TRANSITIONS[run["status"]]:
  4678. raise GroupConflictError("Illegal run status transition")
  4679. if status == "completed" and response["status"] != "completed":
  4680. raise GroupConflictError("Response message must be completed first")
  4681. if status not in {"failed", "interrupted"}:
  4682. self._validate_run_message_status(status, response["status"])
  4683. now = self._now()
  4684. if status in {"completed", "failed", "interrupted"}:
  4685. self._cancel_pending_interactions(connection, run, now)
  4686. result = self._update_run(
  4687. connection, run, status, now, runtime_value, error_value
  4688. )
  4689. if status in {"completed", "failed", "interrupted"}:
  4690. self._fail_claimed_interaction_ledgers(
  4691. connection,
  4692. run_id=run["id"],
  4693. reason=result["error"] or f"Run {status}",
  4694. runtime_session_ids=(
  4695. []
  4696. if run["runtime_session_id"] is None
  4697. else [run["runtime_session_id"]]
  4698. ),
  4699. run=result,
  4700. )
  4701. self._append_event(
  4702. connection, result["roomId"], "run.updated", result, created_at=now
  4703. )
  4704. if status in {"failed", "interrupted"}:
  4705. if response["status"] == "completed":
  4706. self._validate_run_message_status(status, response["status"])
  4707. else:
  4708. terminal_error = result["error"]
  4709. connection.execute(
  4710. """UPDATE group_messages
  4711. SET status = ?, error = ?, updated_at = ? WHERE id = ?""",
  4712. (status, terminal_error, now, run["response_message_id"]),
  4713. )
  4714. message = self._message_wire(
  4715. self._message_row(connection, run["response_message_id"])
  4716. )
  4717. self._validate_run_message_status(
  4718. result["status"], message["status"]
  4719. )
  4720. if message["visible"]:
  4721. self._append_event(
  4722. connection,
  4723. result["roomId"],
  4724. "message.upsert",
  4725. message,
  4726. created_at=now,
  4727. )
  4728. if status == "completed" or (
  4729. room["orchestration_mode"] == "host" and not agent["is_host"]
  4730. ):
  4731. self._record_cascade_plan(
  4732. connection,
  4733. source=self._run_row(connection, run["id"]),
  4734. source_message=self._message_row(
  4735. connection, run["response_message_id"]
  4736. ),
  4737. created_at=now,
  4738. )
  4739. return result
  4740. def bind_run_runtime(
  4741. self, run_id: str, runtime_session_id: str
  4742. ) -> dict[str, object]:
  4743. """Bind the first real runtime identity without reopening the run claim."""
  4744. canonical_run_id = self._canonical_uuid(run_id, "runId")
  4745. runtime_value = self._runtime_session_id(runtime_session_id)
  4746. if runtime_value is None:
  4747. raise ValueError("runtimeSessionId is invalid")
  4748. with self.write_transaction() as connection:
  4749. run = self._run_row(connection, canonical_run_id)
  4750. self._active_room(connection, run["room_id"])
  4751. self._enabled_agent(connection, run["room_id"], run["agent_id"])
  4752. response = self._message_row(connection, run["response_message_id"])
  4753. self._validate_run_message_state(run, response)
  4754. if run["status"] not in {"running", "awaiting_input"}:
  4755. raise GroupConflictError("Run does not accept a runtime binding")
  4756. if run["runtime_session_id"] == runtime_value:
  4757. return self._run_wire(run)
  4758. if run["runtime_session_id"] is not None:
  4759. raise GroupConflictError("Run runtime is already bound")
  4760. self._require_available_runtime(connection, runtime_value, canonical_run_id)
  4761. now = self._now()
  4762. result = self._update_run(
  4763. connection, run, run["status"], now, runtime_value, UNSET
  4764. )
  4765. self._append_event(
  4766. connection, run["room_id"], "run.updated", result, created_at=now
  4767. )
  4768. return result
  4769. def create_interaction(
  4770. self,
  4771. interaction_id: str,
  4772. run_id: str,
  4773. *,
  4774. kind: str,
  4775. payload: object,
  4776. ) -> dict[str, object]:
  4777. return self._create_gateway_interaction(
  4778. run_id,
  4779. kind=kind,
  4780. gateway_interaction_id=interaction_id,
  4781. payload=payload,
  4782. allow_awaiting=False,
  4783. )
  4784. def create_gateway_interaction(
  4785. self,
  4786. run_id: str,
  4787. *,
  4788. kind: str,
  4789. gateway_interaction_id: str | None,
  4790. payload: object,
  4791. ) -> dict[str, object]:
  4792. return self._create_gateway_interaction(
  4793. run_id,
  4794. kind=kind,
  4795. gateway_interaction_id=gateway_interaction_id,
  4796. payload=payload,
  4797. allow_awaiting=True,
  4798. )
  4799. def _create_gateway_interaction(
  4800. self,
  4801. run_id: str,
  4802. *,
  4803. kind: str,
  4804. gateway_interaction_id: str | None,
  4805. payload: object,
  4806. allow_awaiting: bool,
  4807. ) -> dict[str, object]:
  4808. canonical_run_id = self._canonical_uuid(run_id, "runId")
  4809. if not isinstance(kind, str) or kind not in _INTERACTION_KINDS:
  4810. raise ValueError("kind is invalid")
  4811. payload_json = self._json_object(payload, "payload")
  4812. payload_value = self._load_json(payload_json)
  4813. clarification_gateway_id: str | None = None
  4814. if kind == "approval" and gateway_interaction_id is None:
  4815. interaction_id = f"approval-{uuid.uuid4()}"
  4816. elif gateway_interaction_id is None:
  4817. raise ValueError("Gateway clarification interactionId is required")
  4818. else:
  4819. gateway_id = self._interaction_id(gateway_interaction_id)
  4820. if kind == "clarification" and allow_awaiting:
  4821. if "gatewayRequestId" in payload_value:
  4822. raise ValueError("payload.gatewayRequestId is reserved")
  4823. payload_value["gatewayRequestId"] = gateway_id
  4824. payload_json = self._json_object(payload_value, "payload")
  4825. clarification_gateway_id = gateway_id
  4826. interaction_id = None
  4827. else:
  4828. interaction_id = gateway_id
  4829. try:
  4830. with self.write_transaction() as connection:
  4831. run = self._run_row(connection, canonical_run_id)
  4832. self._active_room(connection, run["room_id"])
  4833. self._enabled_agent(connection, run["room_id"], run["agent_id"])
  4834. response = self._message_row(connection, run["response_message_id"])
  4835. self._validate_run_message_state(run, response)
  4836. accepted_statuses = (
  4837. {"running", "awaiting_input"} if allow_awaiting else {"running"}
  4838. )
  4839. if run["status"] not in accepted_statuses:
  4840. raise GroupConflictError("Interaction requires a running run")
  4841. self._validate_run_message_status("awaiting_input", response["status"])
  4842. if clarification_gateway_id is not None:
  4843. pending = connection.execute(
  4844. """SELECT * FROM group_interactions
  4845. WHERE run_id = ? AND kind = 'clarification'
  4846. AND status = 'pending' ORDER BY rowid ASC""",
  4847. (canonical_run_id,),
  4848. ).fetchall()
  4849. matching = []
  4850. for candidate in pending:
  4851. candidate_wire = self._interaction_wire(candidate)
  4852. if (
  4853. candidate_wire["payload"].get("gatewayRequestId")
  4854. == clarification_gateway_id
  4855. ):
  4856. matching.append(candidate)
  4857. if len(matching) > 1:
  4858. raise GroupStoreError(
  4859. "Stored clarification identities are not unique"
  4860. )
  4861. if matching:
  4862. existing = matching[0]
  4863. if existing["payload_json"] != payload_json:
  4864. raise GroupConflictError("Interaction already exists")
  4865. return self._interaction_wire(existing)
  4866. interaction_id = f"clarification-{uuid.uuid4()}"
  4867. if interaction_id is None:
  4868. raise GroupStoreError("Interaction identity is missing")
  4869. existing = connection.execute(
  4870. "SELECT * FROM group_interactions WHERE id = ?",
  4871. (interaction_id,),
  4872. ).fetchone()
  4873. if existing is not None:
  4874. if (
  4875. existing["room_id"] != run["room_id"]
  4876. or existing["agent_id"] != run["agent_id"]
  4877. or existing["run_id"] != canonical_run_id
  4878. or existing["kind"] != kind
  4879. or existing["payload_json"] != payload_json
  4880. ):
  4881. raise GroupConflictError("Interaction already exists")
  4882. return self._interaction_wire(existing)
  4883. now = self._now()
  4884. connection.execute(
  4885. """INSERT INTO group_interactions
  4886. (id, room_id, topic_id, agent_id, run_id, kind, payload_json, status, created_at, resolved_at)
  4887. VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, NULL)""",
  4888. (
  4889. interaction_id,
  4890. run["room_id"],
  4891. run["topic_id"],
  4892. run["agent_id"],
  4893. canonical_run_id,
  4894. kind,
  4895. payload_json,
  4896. now,
  4897. ),
  4898. )
  4899. interaction = self._interaction_wire(
  4900. self._interaction_row(connection, interaction_id)
  4901. )
  4902. self._append_event(
  4903. connection,
  4904. run["room_id"],
  4905. "interaction.requested",
  4906. interaction,
  4907. created_at=now,
  4908. )
  4909. if run["status"] == "running":
  4910. changed = self._update_run(
  4911. connection, run, "awaiting_input", now, UNSET, UNSET
  4912. )
  4913. self._append_event(
  4914. connection,
  4915. run["room_id"],
  4916. "run.updated",
  4917. changed,
  4918. created_at=now,
  4919. )
  4920. return interaction
  4921. except sqlite3.IntegrityError as error:
  4922. if "group_interactions.id" in str(error):
  4923. raise GroupConflictError("Interaction already exists") from error
  4924. raise self._integrity_conflict(error) from error
  4925. def resolve_interaction(
  4926. self, interaction_id: str, *, status: str = "resolved"
  4927. ) -> dict[str, object]:
  4928. interaction_id = self._interaction_id(interaction_id)
  4929. if not isinstance(status, str) or status not in _INTERACTION_TERMINAL_STATUSES:
  4930. raise ValueError("status must be resolved or cancelled")
  4931. with self.write_transaction() as connection:
  4932. interaction = self._interaction_row(connection, interaction_id)
  4933. if interaction["status"] != "pending":
  4934. raise GroupConflictError("Interaction is already resolved")
  4935. run = self._run_row(connection, interaction["run_id"])
  4936. self._active_room(connection, run["room_id"])
  4937. self._enabled_agent(connection, run["room_id"], run["agent_id"])
  4938. response = self._message_row(connection, run["response_message_id"])
  4939. self._validate_run_message_state(run, response)
  4940. now = self._now()
  4941. connection.execute(
  4942. "UPDATE group_interactions SET status = ?, resolved_at = ? WHERE id = ?",
  4943. (status, now, interaction_id),
  4944. )
  4945. result = self._interaction_wire(
  4946. self._interaction_row(connection, interaction_id)
  4947. )
  4948. self._append_event(
  4949. connection,
  4950. result["roomId"],
  4951. "interaction.resolved",
  4952. result,
  4953. created_at=now,
  4954. )
  4955. if status == "resolved":
  4956. if run["status"] != "awaiting_input":
  4957. raise GroupConflictError("Interaction run is not awaiting input")
  4958. remaining = connection.execute(
  4959. """SELECT 1 FROM group_interactions
  4960. WHERE run_id = ? AND status = 'pending' LIMIT 1""",
  4961. (run["id"],),
  4962. ).fetchone()
  4963. if remaining is None:
  4964. self._validate_run_message_status("running", response["status"])
  4965. changed = self._update_run(
  4966. connection, run, "running", now, UNSET, UNSET
  4967. )
  4968. self._append_event(
  4969. connection,
  4970. changed["roomId"],
  4971. "run.updated",
  4972. changed,
  4973. created_at=now,
  4974. )
  4975. run_result = changed
  4976. else:
  4977. run_result = self._run_wire(run)
  4978. else:
  4979. run_result = self._run_wire(run)
  4980. self._fail_claimed_interaction_ledgers(
  4981. connection,
  4982. run_id=run["id"],
  4983. interaction_id=interaction_id,
  4984. reason=f"Interaction {status}",
  4985. runtime_session_ids=(
  4986. []
  4987. if run["runtime_session_id"] is None
  4988. else [run["runtime_session_id"]]
  4989. ),
  4990. run=run_result,
  4991. )
  4992. return result
  4993. def begin_interaction_response(
  4994. self,
  4995. room_id: str,
  4996. interaction_id: str,
  4997. *,
  4998. request_id: str,
  4999. kind: str,
  5000. response: Mapping[str, object],
  5001. ) -> dict[str, object]:
  5002. """Durably claim one FIFO Gateway response without a public status."""
  5003. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  5004. interaction_id = self._interaction_id(interaction_id)
  5005. canonical_request_id = self._canonical_uuid(request_id, "requestId")
  5006. if kind not in _INTERACTION_KINDS:
  5007. raise ValueError("kind is invalid")
  5008. response_json = self._json_object(response, "response")
  5009. response_value = self._load_json(response_json)
  5010. legacy_operation = f"interaction.{kind}.response"
  5011. operation = self._interaction_operation(kind, interaction_id)
  5012. payload = {
  5013. "roomId": canonical_room_id,
  5014. "interactionId": interaction_id,
  5015. "kind": kind,
  5016. "response": response_value,
  5017. }
  5018. request_hash = hashlib.sha256(
  5019. self._canonical_json(payload).encode()
  5020. ).hexdigest()
  5021. with self.write_transaction() as connection:
  5022. existing = connection.execute(
  5023. """SELECT operation, request_hash, response_json
  5024. FROM group_idempotency WHERE request_id = ?""",
  5025. (canonical_request_id,),
  5026. ).fetchone()
  5027. if existing is not None:
  5028. if (
  5029. existing["operation"] not in {operation, legacy_operation}
  5030. or existing["request_hash"] != request_hash
  5031. ):
  5032. raise IdempotencyConflict(
  5033. "requestId was already used for a different request"
  5034. )
  5035. state = self._idempotency_state(existing["response_json"])
  5036. if state["state"] == "claimed":
  5037. raise GroupConflictError(
  5038. "Interaction response is already in progress"
  5039. )
  5040. if state["state"] == "completed":
  5041. return {"state": "replay", "response": state["response"]}
  5042. if state["state"] == "failed":
  5043. return state
  5044. raise GroupStoreError("Stored interaction response is corrupt")
  5045. competing = connection.execute(
  5046. """SELECT operation, response_json FROM group_idempotency
  5047. WHERE operation IN (?, ?)""",
  5048. (operation, legacy_operation),
  5049. ).fetchall()
  5050. for candidate in competing:
  5051. state = self._idempotency_state(candidate["response_json"])
  5052. if (
  5053. state["state"] == "claimed"
  5054. and state["interactionId"] == interaction_id
  5055. ):
  5056. raise GroupConflictError("Interaction response is already claimed")
  5057. interaction = self._interaction_row(connection, interaction_id)
  5058. if interaction["room_id"] != canonical_room_id:
  5059. raise GroupNotFoundError("Interaction not found")
  5060. if interaction["kind"] != kind:
  5061. raise GroupConflictError("Interaction kind does not match")
  5062. if interaction["status"] != "pending":
  5063. raise GroupConflictError("Interaction is already resolved")
  5064. run = self._run_row(connection, interaction["run_id"])
  5065. self._active_room(connection, canonical_room_id)
  5066. self._enabled_agent(connection, canonical_room_id, run["agent_id"])
  5067. if run["status"] != "awaiting_input":
  5068. raise GroupConflictError("Interaction run is not awaiting input")
  5069. if run["runtime_session_id"] is None:
  5070. raise GroupConflictError("Interaction run has no runtime session")
  5071. first = connection.execute(
  5072. """SELECT id FROM group_interactions
  5073. WHERE run_id = ? AND kind = ? AND status = 'pending'
  5074. ORDER BY rowid ASC LIMIT 1""",
  5075. (run["id"], kind),
  5076. ).fetchone()
  5077. if first is None or first["id"] != interaction_id:
  5078. raise GroupConflictError(
  5079. "Interaction responses must preserve FIFO order"
  5080. )
  5081. sentinel = {
  5082. "state": "claimed",
  5083. "interactionId": interaction_id,
  5084. "kind": kind,
  5085. }
  5086. connection.execute(
  5087. """INSERT INTO group_idempotency
  5088. (request_id, operation, request_hash, response_json, created_at)
  5089. VALUES (?, ?, ?, ?, ?)""",
  5090. (
  5091. canonical_request_id,
  5092. operation,
  5093. request_hash,
  5094. self._canonical_json(sentinel),
  5095. self._now(),
  5096. ),
  5097. )
  5098. return {
  5099. "state": "claimed",
  5100. "interaction": self._interaction_wire(interaction),
  5101. "run": self._run_wire(run),
  5102. }
  5103. def finish_interaction_response(
  5104. self,
  5105. request_id: str,
  5106. *,
  5107. response: Mapping[str, object],
  5108. interaction_status: str = "resolved",
  5109. ) -> dict[str, object]:
  5110. """Commit a known Gateway response and release the run when possible."""
  5111. if interaction_status not in _INTERACTION_TERMINAL_STATUSES:
  5112. raise ValueError("interaction_status must be resolved or cancelled")
  5113. canonical_request_id = self._canonical_uuid(request_id, "requestId")
  5114. response_json = self._json_object(response, "response")
  5115. public_response = self._load_json(response_json)
  5116. with self.write_transaction() as connection:
  5117. claim = connection.execute(
  5118. """SELECT operation, response_json FROM group_idempotency
  5119. WHERE request_id = ?""",
  5120. (canonical_request_id,),
  5121. ).fetchone()
  5122. operation = (
  5123. None
  5124. if claim is None
  5125. else self._interaction_response_operation(claim["operation"])
  5126. )
  5127. if claim is None or operation is None:
  5128. raise GroupNotFoundError("Interaction response claim not found")
  5129. state = self._idempotency_state(claim["response_json"])
  5130. if state["state"] == "claimed" and (
  5131. operation[0] != state["kind"]
  5132. or (operation[1] is not None and operation[1] != state["interactionId"])
  5133. ):
  5134. raise GroupStoreError("Stored interaction response is corrupt")
  5135. if state["state"] == "completed":
  5136. stored_status = state.get("interactionStatus", "resolved")
  5137. if stored_status != interaction_status:
  5138. raise GroupConflictError(
  5139. "Interaction response terminal status changed"
  5140. )
  5141. return state["response"]
  5142. if state["state"] != "claimed":
  5143. raise GroupConflictError("Interaction response claim is terminal")
  5144. interaction = self._interaction_row(connection, state["interactionId"])
  5145. if interaction["status"] != "pending":
  5146. raise GroupConflictError("Interaction is already resolved")
  5147. run = self._run_row(connection, interaction["run_id"])
  5148. self._active_room(connection, run["room_id"])
  5149. self._enabled_agent(connection, run["room_id"], run["agent_id"])
  5150. if run["status"] != "awaiting_input":
  5151. raise GroupConflictError("Interaction run is not awaiting input")
  5152. now = self._now()
  5153. connection.execute(
  5154. """UPDATE group_interactions
  5155. SET status = ?, resolved_at = ? WHERE id = ?""",
  5156. (interaction_status, now, interaction["id"]),
  5157. )
  5158. resolved = self._interaction_wire(
  5159. self._interaction_row(connection, interaction["id"])
  5160. )
  5161. self._append_event(
  5162. connection,
  5163. run["room_id"],
  5164. "interaction.resolved",
  5165. resolved,
  5166. created_at=now,
  5167. )
  5168. pending = connection.execute(
  5169. """SELECT 1 FROM group_interactions
  5170. WHERE run_id = ? AND status = 'pending' LIMIT 1""",
  5171. (run["id"],),
  5172. ).fetchone()
  5173. if pending is None:
  5174. response_row = self._message_row(connection, run["response_message_id"])
  5175. self._validate_run_message_status("running", response_row["status"])
  5176. changed = self._update_run(
  5177. connection, run, "running", now, UNSET, UNSET
  5178. )
  5179. self._append_event(
  5180. connection,
  5181. run["room_id"],
  5182. "run.updated",
  5183. changed,
  5184. created_at=now,
  5185. )
  5186. final = {
  5187. "state": "completed",
  5188. "interactionStatus": interaction_status,
  5189. "response": public_response,
  5190. }
  5191. connection.execute(
  5192. """UPDATE group_idempotency SET response_json = ?
  5193. WHERE request_id = ?""",
  5194. (self._canonical_json(final), canonical_request_id),
  5195. )
  5196. return public_response
  5197. def fail_interaction_response(
  5198. self, request_id: str, *, reason: str, uncertain: bool
  5199. ) -> dict[str, object]:
  5200. """Fail closed on an unknown Gateway outcome or release a retryable claim."""
  5201. canonical_request_id = self._canonical_uuid(request_id, "requestId")
  5202. failure_reason = self._message_text(
  5203. reason, "reason", max_bytes=4096, nonblank=True
  5204. )
  5205. if not isinstance(uncertain, bool):
  5206. raise ValueError("uncertain must be a boolean")
  5207. with self.write_transaction() as connection:
  5208. claim = connection.execute(
  5209. """SELECT operation, response_json FROM group_idempotency
  5210. WHERE request_id = ?""",
  5211. (canonical_request_id,),
  5212. ).fetchone()
  5213. operation = (
  5214. None
  5215. if claim is None
  5216. else self._interaction_response_operation(claim["operation"])
  5217. )
  5218. if claim is None or operation is None:
  5219. raise GroupNotFoundError("Interaction response claim not found")
  5220. state = self._idempotency_state(claim["response_json"])
  5221. if state["state"] == "claimed" and (
  5222. operation[0] != state["kind"]
  5223. or (operation[1] is not None and operation[1] != state["interactionId"])
  5224. ):
  5225. raise GroupStoreError("Stored interaction response is corrupt")
  5226. if state["state"] == "failed":
  5227. return state
  5228. if state["state"] != "claimed":
  5229. raise GroupConflictError("Interaction response claim is terminal")
  5230. interaction = self._interaction_row(connection, state["interactionId"])
  5231. run = self._run_row(connection, interaction["run_id"])
  5232. self._active_room(connection, run["room_id"])
  5233. self._enabled_agent(connection, run["room_id"], run["agent_id"])
  5234. if run["status"] != "awaiting_input":
  5235. raise GroupConflictError("Interaction run is not awaiting input")
  5236. if not uncertain:
  5237. connection.execute(
  5238. "DELETE FROM group_idempotency WHERE request_id = ?",
  5239. (canonical_request_id,),
  5240. )
  5241. return {"state": "retryable"}
  5242. runtime_ids = (
  5243. [] if run["runtime_session_id"] is None else [run["runtime_session_id"]]
  5244. )
  5245. now = self._now()
  5246. self._cancel_pending_interactions(connection, run, now)
  5247. response_row = self._message_row(connection, run["response_message_id"])
  5248. changed = self._update_run(
  5249. connection, run, "failed", now, None, failure_reason
  5250. )
  5251. self._append_event(
  5252. connection,
  5253. run["room_id"],
  5254. "run.updated",
  5255. changed,
  5256. created_at=now,
  5257. )
  5258. if response_row["status"] != "completed":
  5259. connection.execute(
  5260. """UPDATE group_messages
  5261. SET status = 'failed', error = ?, updated_at = ? WHERE id = ?""",
  5262. (failure_reason, now, response_row["id"]),
  5263. )
  5264. message = self._message_wire(
  5265. self._message_row(connection, response_row["id"])
  5266. )
  5267. if message["visible"]:
  5268. self._append_event(
  5269. connection,
  5270. run["room_id"],
  5271. "message.upsert",
  5272. message,
  5273. created_at=now,
  5274. )
  5275. self._append_agent_status(connection, run["room_id"], run["agent_id"], now)
  5276. final = {
  5277. "state": "failed",
  5278. "reason": failure_reason,
  5279. "runtimeSessionIds": runtime_ids,
  5280. "run": self._legacy_interaction_failure_run(changed),
  5281. }
  5282. self._fail_claimed_interaction_ledgers(
  5283. connection,
  5284. run_id=run["id"],
  5285. reason=failure_reason,
  5286. runtime_session_ids=runtime_ids,
  5287. run=changed,
  5288. )
  5289. connection.execute(
  5290. """UPDATE group_idempotency SET response_json = ?
  5291. WHERE request_id = ?""",
  5292. (self._canonical_json(final), canonical_request_id),
  5293. )
  5294. return final
  5295. def expire_interaction(self, interaction_id: str) -> dict[str, object]:
  5296. """Idempotently cancel a stale interaction and resume when it was last."""
  5297. interaction_id = self._interaction_id(interaction_id)
  5298. with self.write_transaction() as connection:
  5299. interaction = self._interaction_row(connection, interaction_id)
  5300. if interaction["status"] != "pending":
  5301. return self._interaction_wire(interaction)
  5302. run = self._run_row(connection, interaction["run_id"])
  5303. self._active_room(connection, run["room_id"])
  5304. self._enabled_agent(connection, run["room_id"], run["agent_id"])
  5305. now = self._now()
  5306. connection.execute(
  5307. """UPDATE group_interactions
  5308. SET status = 'cancelled', resolved_at = ? WHERE id = ?""",
  5309. (now, interaction_id),
  5310. )
  5311. result = self._interaction_wire(
  5312. self._interaction_row(connection, interaction_id)
  5313. )
  5314. self._append_event(
  5315. connection,
  5316. run["room_id"],
  5317. "interaction.resolved",
  5318. result,
  5319. created_at=now,
  5320. )
  5321. remaining = connection.execute(
  5322. """SELECT 1 FROM group_interactions
  5323. WHERE run_id = ? AND status = 'pending' LIMIT 1""",
  5324. (run["id"],),
  5325. ).fetchone()
  5326. if run["status"] == "awaiting_input" and remaining is None:
  5327. response = self._message_row(connection, run["response_message_id"])
  5328. self._validate_run_message_status("running", response["status"])
  5329. changed = self._update_run(
  5330. connection, run, "running", now, UNSET, UNSET
  5331. )
  5332. self._append_event(
  5333. connection,
  5334. run["room_id"],
  5335. "run.updated",
  5336. changed,
  5337. created_at=now,
  5338. )
  5339. run_result = changed
  5340. else:
  5341. run_result = self._run_wire(run)
  5342. self._fail_claimed_interaction_ledgers(
  5343. connection,
  5344. run_id=run["id"],
  5345. interaction_id=interaction_id,
  5346. reason="Interaction expired",
  5347. runtime_session_ids=(
  5348. []
  5349. if run["runtime_session_id"] is None
  5350. else [run["runtime_session_id"]]
  5351. ),
  5352. run=run_result,
  5353. )
  5354. return result
  5355. def events_after(
  5356. self, cursor: int, limit: int = MAX_EVENT_BATCH_SIZE
  5357. ) -> list[dict[str, object]]:
  5358. cursor = self._nonnegative_int(cursor, "cursor")
  5359. self._page_limit(limit, MAX_EVENT_BATCH_SIZE, "limit")
  5360. with self.read_transaction() as connection:
  5361. current_epoch = self._validated_epoch(
  5362. self._metadata_value_from_connection(connection, "journal_epoch")
  5363. )
  5364. rows = connection.execute(
  5365. "SELECT * FROM group_events WHERE cursor > ? ORDER BY cursor ASC LIMIT ?",
  5366. (cursor, limit),
  5367. ).fetchall()
  5368. return [self._event_wire(row, current_epoch) for row in rows]
  5369. def cursor_status(self, epoch: str, cursor: int) -> str:
  5370. canonical_epoch = self._canonical_uuid(epoch, "epoch")
  5371. cursor = self._nonnegative_int(cursor, "cursor")
  5372. with self.read_transaction() as connection:
  5373. if canonical_epoch != self._validated_epoch(
  5374. self._metadata_value_from_connection(connection, "journal_epoch")
  5375. ):
  5376. return "epoch_mismatch"
  5377. bounds = connection.execute(
  5378. "SELECT MIN(cursor) AS oldest, MAX(cursor) AS latest FROM group_events"
  5379. ).fetchone()
  5380. latest = int(bounds["latest"] or 0)
  5381. if cursor > latest:
  5382. return "cursor_expired"
  5383. oldest = bounds["oldest"]
  5384. if oldest is not None and cursor < int(oldest) - 1:
  5385. return "cursor_expired"
  5386. return "ok"
  5387. def prune_events(self, now: float | None = None) -> int:
  5388. prune_now = self._now() if now is None else self._finite_timestamp(now, "now")
  5389. with self.write_transaction() as connection:
  5390. threshold = connection.execute(
  5391. "SELECT cursor FROM group_events ORDER BY cursor DESC LIMIT 1 OFFSET ?",
  5392. (MIN_RETAINED_EVENTS - 1,),
  5393. ).fetchone()
  5394. if threshold is None:
  5395. return 0
  5396. result = connection.execute(
  5397. "DELETE FROM group_events WHERE created_at < ? AND cursor < ?",
  5398. (prune_now - EVENT_RETENTION_SECONDS, threshold["cursor"]),
  5399. )
  5400. return result.rowcount
  5401. def _interrupt_runs_for_scope(
  5402. self,
  5403. connection: sqlite3.Connection,
  5404. *,
  5405. room_id: str,
  5406. reason: str,
  5407. now: float,
  5408. agent_id: str | None = None,
  5409. topic_id: str | None = None,
  5410. include_queued: bool = True,
  5411. ) -> dict[str, list[str]]:
  5412. statuses = (
  5413. "('queued', 'running', 'awaiting_input')"
  5414. if include_queued
  5415. else "('running', 'awaiting_input')"
  5416. )
  5417. query = f"""SELECT run.* FROM group_agent_runs AS run
  5418. JOIN group_messages AS response
  5419. ON response.id = run.response_message_id
  5420. WHERE run.room_id = ? AND run.status IN {statuses}"""
  5421. params: list[object] = [room_id]
  5422. if agent_id is not None:
  5423. query += " AND run.agent_id = ?"
  5424. params.append(agent_id)
  5425. if topic_id is not None:
  5426. query += " AND run.topic_id = ?"
  5427. params.append(topic_id)
  5428. query += " ORDER BY response.seq ASC, run.id ASC"
  5429. runs = connection.execute(query, params).fetchall()
  5430. changed_ids: list[str] = []
  5431. runtime_ids: list[str] = []
  5432. for run in runs:
  5433. run_runtime_ids = (
  5434. [] if run["runtime_session_id"] is None else [run["runtime_session_id"]]
  5435. )
  5436. if (
  5437. run["runtime_session_id"] is not None
  5438. and run["runtime_session_id"] not in runtime_ids
  5439. ):
  5440. runtime_ids.append(run["runtime_session_id"])
  5441. response = self._message_row(connection, run["response_message_id"])
  5442. if (
  5443. response["room_id"] != run["room_id"]
  5444. or response["sender_id"] != run["agent_id"]
  5445. ):
  5446. raise GroupStoreError("Run response message is corrupt")
  5447. self._validate_run_message_state(run, response)
  5448. changed = self._update_run(
  5449. connection, run, "interrupted", now, None, reason
  5450. )
  5451. self._append_event(
  5452. connection, room_id, "run.updated", changed, created_at=now
  5453. )
  5454. if response["status"] == "completed":
  5455. self._validate_run_message_status(changed["status"], response["status"])
  5456. else:
  5457. connection.execute(
  5458. """UPDATE group_messages
  5459. SET status = 'interrupted', error = ?, updated_at = ? WHERE id = ?""",
  5460. (reason, now, run["response_message_id"]),
  5461. )
  5462. message = self._message_wire(
  5463. self._message_row(connection, run["response_message_id"])
  5464. )
  5465. self._validate_run_message_status(changed["status"], message["status"])
  5466. if message["visible"]:
  5467. self._append_event(
  5468. connection,
  5469. room_id,
  5470. "message.upsert",
  5471. message,
  5472. created_at=now,
  5473. )
  5474. self._cancel_pending_interactions(
  5475. connection, run, now, order_by_created_at=True
  5476. )
  5477. self._fail_claimed_interaction_ledgers(
  5478. connection,
  5479. run_id=run["id"],
  5480. reason=reason,
  5481. runtime_session_ids=run_runtime_ids,
  5482. run=changed,
  5483. )
  5484. self._record_host_flow_terminal_plan_if_needed(
  5485. connection, self._run_row(connection, run["id"]), now
  5486. )
  5487. changed_ids.append(run["id"])
  5488. return {"runIds": changed_ids, "runtimeSessionIds": runtime_ids}
  5489. def recover_after_restart(self) -> list[str]:
  5490. with self.write_transaction() as connection:
  5491. runs = connection.execute(
  5492. "SELECT * FROM group_agent_runs WHERE status IN ('running', 'awaiting_input') ORDER BY created_at ASC, id ASC"
  5493. ).fetchall()
  5494. if not runs:
  5495. return []
  5496. now = self._now()
  5497. changed_ids: list[str] = []
  5498. for run in runs:
  5499. runtime_ids = (
  5500. []
  5501. if run["runtime_session_id"] is None
  5502. else [run["runtime_session_id"]]
  5503. )
  5504. response = self._message_row(connection, run["response_message_id"])
  5505. if (
  5506. response["room_id"] != run["room_id"]
  5507. or response["sender_id"] != run["agent_id"]
  5508. ):
  5509. raise GroupStoreError("Run response message is corrupt")
  5510. self._validate_run_message_state(run, response)
  5511. changed = self._update_run(
  5512. connection, run, "interrupted", now, None, "Dashboard restarted"
  5513. )
  5514. self._append_event(
  5515. connection, run["room_id"], "run.updated", changed, created_at=now
  5516. )
  5517. if response["status"] == "completed":
  5518. self._validate_run_message_status(
  5519. changed["status"], response["status"]
  5520. )
  5521. else:
  5522. connection.execute(
  5523. """UPDATE group_messages
  5524. SET status = 'interrupted', error = 'Dashboard restarted',
  5525. updated_at = ? WHERE id = ?""",
  5526. (now, run["response_message_id"]),
  5527. )
  5528. message = self._message_wire(
  5529. self._message_row(connection, run["response_message_id"])
  5530. )
  5531. self._validate_run_message_status(
  5532. changed["status"], message["status"]
  5533. )
  5534. if message["visible"]:
  5535. self._append_event(
  5536. connection,
  5537. run["room_id"],
  5538. "message.upsert",
  5539. message,
  5540. created_at=now,
  5541. )
  5542. self._cancel_pending_interactions(
  5543. connection, run, now, order_by_created_at=True
  5544. )
  5545. self._fail_claimed_interaction_ledgers(
  5546. connection,
  5547. run_id=run["id"],
  5548. reason="Dashboard restarted",
  5549. runtime_session_ids=runtime_ids,
  5550. run=changed,
  5551. )
  5552. self._record_host_flow_terminal_plan_if_needed(
  5553. connection, self._run_row(connection, run["id"]), now
  5554. )
  5555. changed_ids.append(run["id"])
  5556. return changed_ids
  5557. def _cancel_pending_interactions(
  5558. self,
  5559. connection: sqlite3.Connection,
  5560. run: sqlite3.Row,
  5561. now: float,
  5562. *,
  5563. order_by_created_at: bool = False,
  5564. ) -> list[dict[str, object]]:
  5565. """Cancel a run's pending cards and publish their durable resolutions."""
  5566. ordering = "created_at ASC, id ASC" if order_by_created_at else "rowid ASC"
  5567. interactions = connection.execute(
  5568. f"""SELECT id FROM group_interactions
  5569. WHERE run_id = ? AND status = 'pending' ORDER BY {ordering}""",
  5570. (run["id"],),
  5571. ).fetchall()
  5572. cancelled: list[dict[str, object]] = []
  5573. for interaction in interactions:
  5574. connection.execute(
  5575. """UPDATE group_interactions
  5576. SET status = 'cancelled', resolved_at = ? WHERE id = ?""",
  5577. (now, interaction["id"]),
  5578. )
  5579. resolved = self._interaction_wire(
  5580. self._interaction_row(connection, interaction["id"])
  5581. )
  5582. self._append_event(
  5583. connection,
  5584. run["room_id"],
  5585. "interaction.resolved",
  5586. resolved,
  5587. created_at=now,
  5588. )
  5589. cancelled.append(resolved)
  5590. return cancelled
  5591. def _fail_claimed_interaction_ledgers(
  5592. self,
  5593. connection: sqlite3.Connection,
  5594. *,
  5595. run_id: str,
  5596. reason: str,
  5597. runtime_session_ids: list[str],
  5598. run: Mapping[str, object],
  5599. interaction_id: str | None = None,
  5600. ) -> None:
  5601. interactions = connection.execute(
  5602. """SELECT id, kind FROM group_interactions
  5603. WHERE run_id = ? ORDER BY rowid ASC""",
  5604. (run_id,),
  5605. ).fetchall()
  5606. if interaction_id is not None:
  5607. interactions = [
  5608. item for item in interactions if item["id"] == interaction_id
  5609. ]
  5610. if not interactions:
  5611. return
  5612. target_ids = {item["id"] for item in interactions}
  5613. scoped_operations = [
  5614. self._interaction_operation(item["kind"], item["id"])
  5615. for item in interactions
  5616. ]
  5617. placeholders = ", ".join("?" for _ in scoped_operations)
  5618. ledgers = connection.execute(
  5619. f"""SELECT request_id, operation, response_json
  5620. FROM group_idempotency WHERE operation IN ({placeholders})""",
  5621. scoped_operations,
  5622. ).fetchall()
  5623. legacy = connection.execute(
  5624. """SELECT request_id, operation, response_json
  5625. FROM group_idempotency WHERE operation IN (
  5626. 'interaction.approval.response',
  5627. 'interaction.clarification.response'
  5628. )"""
  5629. ).fetchall()
  5630. seen: set[str] = set()
  5631. terminal = {
  5632. "state": "failed",
  5633. "reason": reason,
  5634. "runtimeSessionIds": list(runtime_session_ids),
  5635. "run": self._legacy_interaction_failure_run(run),
  5636. }
  5637. encoded = self._canonical_json(terminal)
  5638. for ledger in (*ledgers, *legacy):
  5639. if ledger["request_id"] in seen:
  5640. continue
  5641. seen.add(ledger["request_id"])
  5642. operation = self._interaction_response_operation(ledger["operation"])
  5643. state = self._idempotency_state(ledger["response_json"])
  5644. if operation is None or state["state"] != "claimed":
  5645. continue
  5646. claimed_id = state["interactionId"]
  5647. if claimed_id not in target_ids:
  5648. continue
  5649. if operation[0] != state["kind"] or (
  5650. operation[1] is not None and operation[1] != claimed_id
  5651. ):
  5652. raise GroupStoreError("Stored interaction response is corrupt")
  5653. connection.execute(
  5654. """UPDATE group_idempotency SET response_json = ?
  5655. WHERE request_id = ?""",
  5656. (encoded, ledger["request_id"]),
  5657. )
  5658. def queued_run_count(self) -> int:
  5659. with self.connection() as connection:
  5660. return int(
  5661. connection.execute(
  5662. "SELECT COUNT(*) FROM group_agent_runs WHERE status = 'queued'"
  5663. ).fetchone()[0]
  5664. )
  5665. def list_pending_interactions(self, room_id: str) -> list[dict[str, object]]:
  5666. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  5667. with self.read_transaction() as connection:
  5668. self._room_detail(connection, canonical_room_id, include_archived=True)
  5669. rows = connection.execute(
  5670. "SELECT * FROM group_interactions WHERE room_id = ? AND status = 'pending' ORDER BY created_at, id",
  5671. (canonical_room_id,),
  5672. ).fetchall()
  5673. return [self._interaction_wire(row) for row in rows]
  5674. def list_queued_runs(self, room_id: str) -> list[dict[str, object]]:
  5675. canonical_room_id = self._canonical_uuid(room_id, "roomId")
  5676. with self.read_transaction() as connection:
  5677. self._room_detail(connection, canonical_room_id, include_archived=True)
  5678. rows = connection.execute(
  5679. "SELECT * FROM group_agent_runs WHERE room_id = ? AND status = 'queued' ORDER BY created_at, id",
  5680. (canonical_room_id,),
  5681. ).fetchall()
  5682. return [self._run_wire(row) for row in rows]
  5683. def latest_cursor(self) -> int:
  5684. with self.connection() as connection:
  5685. row = connection.execute(
  5686. "SELECT COALESCE(MAX(cursor), 0) AS cursor FROM group_events"
  5687. ).fetchone()
  5688. return int(row["cursor"])
  5689. @staticmethod
  5690. def _safe_context_seq(
  5691. connection: sqlite3.Connection,
  5692. room_id: str,
  5693. topic_id: str,
  5694. after_seq: int,
  5695. trigger_seq: int,
  5696. ) -> int:
  5697. hole = connection.execute(
  5698. """SELECT MIN(seq) AS seq FROM group_messages
  5699. WHERE room_id = ? AND topic_id = ? AND seq > ? AND seq <= ?
  5700. AND status NOT IN ('completed', 'failed', 'interrupted')""",
  5701. (room_id, topic_id, after_seq, trigger_seq),
  5702. ).fetchone()["seq"]
  5703. if hole is None:
  5704. return max(after_seq, trigger_seq)
  5705. return max(after_seq, int(hole) - 1)
  5706. def _append_agent_status(
  5707. self,
  5708. connection: sqlite3.Connection,
  5709. room_id: str,
  5710. agent_id: str,
  5711. now: float,
  5712. ) -> None:
  5713. row = connection.execute(
  5714. """SELECT id, status FROM group_agent_runs
  5715. WHERE room_id = ? AND agent_id = ?
  5716. AND status IN ('queued', 'running', 'awaiting_input')
  5717. ORDER BY CASE status
  5718. WHEN 'awaiting_input' THEN 3
  5719. WHEN 'running' THEN 2
  5720. ELSE 1 END DESC,
  5721. created_at ASC, id ASC
  5722. LIMIT 1""",
  5723. (room_id, agent_id),
  5724. ).fetchone()
  5725. payload: dict[str, object] = {
  5726. "roomId": room_id,
  5727. "agentId": agent_id,
  5728. "status": "idle" if row is None else row["status"],
  5729. "runId": None if row is None else row["id"],
  5730. }
  5731. self._append_event(connection, room_id, "agent.status", payload, created_at=now)
  5732. def _append_event(
  5733. self,
  5734. connection: sqlite3.Connection,
  5735. room_id: str,
  5736. event_type: str,
  5737. payload: Mapping[str, object],
  5738. *,
  5739. created_at: float | None = None,
  5740. ) -> int:
  5741. epoch = self._metadata_value_from_connection(connection, "journal_epoch")
  5742. event_time = self._now() if created_at is None else created_at
  5743. cursor = connection.execute(
  5744. """INSERT INTO group_events(epoch, room_id, event_type, payload_json, created_at)
  5745. VALUES (?, ?, ?, ?, ?) RETURNING cursor""",
  5746. (
  5747. epoch,
  5748. room_id,
  5749. event_type,
  5750. self._canonical_json(payload),
  5751. event_time,
  5752. ),
  5753. ).fetchone()["cursor"]
  5754. if event_type == "message.upsert":
  5755. topic_id = payload.get("topicId")
  5756. if not isinstance(topic_id, str):
  5757. raise GroupStoreError("Message event topic identity is missing")
  5758. connection.execute(
  5759. """UPDATE group_topics
  5760. SET updated_at = CASE WHEN updated_at < ? THEN ? ELSE updated_at END
  5761. WHERE id = ? AND room_id = ?""",
  5762. (event_time, event_time, topic_id, room_id),
  5763. )
  5764. topic = self._topic_summary_by_id(connection, room_id, topic_id)
  5765. connection.execute(
  5766. """INSERT INTO group_events
  5767. (epoch, room_id, event_type, payload_json, created_at)
  5768. VALUES (?, ?, 'topic.updated', ?, ?)""",
  5769. (epoch, room_id, self._canonical_json(topic), event_time),
  5770. )
  5771. message_activity = (
  5772. event_type == "message.upsert"
  5773. and payload.get("status") in {"completed", "failed", "interrupted"}
  5774. )
  5775. if event_type == "run.updated" or message_activity:
  5776. activity = self._room_activity_from_connection(connection, room_id)
  5777. connection.execute(
  5778. """INSERT INTO group_events
  5779. (epoch, room_id, event_type, payload_json, created_at)
  5780. VALUES (?, ?, 'room.activity', ?, ?)""",
  5781. (epoch, room_id, self._canonical_json(activity), event_time),
  5782. )
  5783. return int(cursor)
  5784. def _append_room_updated_summary(
  5785. self,
  5786. connection: sqlite3.Connection,
  5787. room_id: str,
  5788. *,
  5789. created_at: float,
  5790. ) -> None:
  5791. room = connection.execute(
  5792. """SELECT id, name, cwd, instructions, avatar_data_url, max_reply_rounds, orchestration_mode,
  5793. created_at, updated_at, archived,
  5794. (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id)
  5795. AS agent_count,
  5796. (SELECT COUNT(*) FROM group_topics WHERE room_id = group_rooms.id)
  5797. AS topic_count
  5798. FROM group_rooms WHERE id = ? AND archived = 0""",
  5799. (room_id,),
  5800. ).fetchone()
  5801. if room is None:
  5802. raise GroupNotFoundError("Room not found")
  5803. self._append_event(
  5804. connection,
  5805. room_id,
  5806. "room.updated",
  5807. self._room_summary(
  5808. room, self._room_avatar_members(connection, room_id)
  5809. ),
  5810. created_at=created_at,
  5811. )
  5812. def _room_detail(
  5813. self, connection: sqlite3.Connection, room_id: str, *, include_archived: bool
  5814. ) -> dict[str, object]:
  5815. room = connection.execute(
  5816. "SELECT * FROM group_rooms WHERE id = ?", (room_id,)
  5817. ).fetchone()
  5818. if room is None or (room["archived"] and not include_archived):
  5819. raise GroupNotFoundError("Room not found")
  5820. agents = connection.execute(
  5821. "SELECT * FROM group_agents WHERE room_id = ? ORDER BY created_at ASC, id ASC",
  5822. (room_id,),
  5823. ).fetchall()
  5824. result = self._room_wire(room)
  5825. result["agents"] = [self._agent_wire(agent) for agent in agents]
  5826. result["avatarMembers"] = [
  5827. self._avatar_member_wire(agent) for agent in agents[:4]
  5828. ]
  5829. return result
  5830. def prepare_run_session_configuration(
  5831. self, run_id: str, configuration: Mapping[str, object]
  5832. ) -> bool:
  5833. """Detach a stale stored session before this claimed run is bound.
  5834. Configuration changes never interrupt the currently bound run. A later
  5835. claimed run reaches this method only after agent serialization, so it
  5836. can safely rotate the dormant stored session and rebuild full context.
  5837. """
  5838. canonical_run_id = self._canonical_uuid(run_id, "runId")
  5839. normalized = {
  5840. "model": configuration.get("model"),
  5841. "provider": configuration.get("provider"),
  5842. "reasoning_effort": configuration.get("reasoning_effort"),
  5843. "fast": configuration.get("fast"),
  5844. }
  5845. serialized = self._canonical_json(normalized)
  5846. with self.write_transaction() as connection:
  5847. run = self._run_row(connection, canonical_run_id)
  5848. if run["status"] != "running" or run["runtime_session_id"] is not None:
  5849. raise GroupConflictError("Run does not accept configuration rotation")
  5850. agent = self._owned_agent(connection, run["room_id"], run["agent_id"])
  5851. if agent["session_config_json"] == serialized:
  5852. return False
  5853. now = self._now()
  5854. connection.execute(
  5855. """UPDATE group_agents
  5856. SET stored_session_id = NULL,
  5857. last_context_message_seq = 0,
  5858. session_config_json = ?,
  5859. updated_at = ?
  5860. WHERE id = ? AND room_id = ?""",
  5861. (serialized, now, agent["id"], run["room_id"]),
  5862. )
  5863. connection.execute(
  5864. """UPDATE group_agent_topic_state
  5865. SET last_context_message_seq = 0, updated_at = ?
  5866. WHERE agent_id = ?""",
  5867. (now, agent["id"]),
  5868. )
  5869. refreshed = self._owned_agent(connection, run["room_id"], run["agent_id"])
  5870. self._append_event(
  5871. connection,
  5872. run["room_id"],
  5873. "agent.updated",
  5874. self._agent_wire(refreshed),
  5875. created_at=now,
  5876. )
  5877. return True
  5878. @staticmethod
  5879. def _compatibility_topic_id(room_id: str) -> str:
  5880. return str(
  5881. uuid.uuid5(
  5882. uuid.NAMESPACE_URL,
  5883. f"hermes://yaoyao-group/{room_id}/compatibility-topic",
  5884. )
  5885. )
  5886. @staticmethod
  5887. def _topic_title(content: str) -> str:
  5888. normalized = " ".join(content.split()).strip()
  5889. return (normalized or "话题")[:_TOPIC_TITLE_LENGTH]
  5890. @staticmethod
  5891. def _topic_row(
  5892. connection: sqlite3.Connection, room_id: str, topic_id: str
  5893. ) -> sqlite3.Row:
  5894. topic = connection.execute(
  5895. "SELECT * FROM group_topics WHERE id = ? AND room_id = ?",
  5896. (topic_id, room_id),
  5897. ).fetchone()
  5898. if topic is None:
  5899. raise GroupNotFoundError("Topic not found in room")
  5900. return topic
  5901. def _active_topic(
  5902. self, connection: sqlite3.Connection, room_id: str, topic_id: str
  5903. ) -> sqlite3.Row:
  5904. topic = self._topic_row(connection, room_id, topic_id)
  5905. if topic["archived"]:
  5906. raise GroupNotFoundError("Topic not found in room")
  5907. return topic
  5908. @classmethod
  5909. def _topic_summary_by_id(
  5910. cls, connection: sqlite3.Connection, room_id: str, topic_id: str
  5911. ) -> dict[str, object]:
  5912. row = connection.execute(
  5913. """SELECT topic.*,
  5914. (SELECT COUNT(*) FROM group_messages AS counted
  5915. WHERE counted.topic_id = topic.id AND counted.visible = 1)
  5916. AS message_count,
  5917. COALESCE((SELECT CASE WHEN TRIM(latest.content) != ''
  5918. THEN latest.content ELSE latest.error END
  5919. FROM group_messages AS latest
  5920. WHERE latest.topic_id = topic.id AND latest.visible = 1
  5921. AND (TRIM(latest.content) != '' OR TRIM(latest.error) != '')
  5922. ORDER BY latest.seq DESC LIMIT 1), '') AS preview,
  5923. (SELECT COUNT(*) FROM group_messages AS unread
  5924. WHERE unread.topic_id = topic.id AND unread.visible = 1
  5925. AND unread.sender_kind = 'agent'
  5926. AND (TRIM(unread.content) != '' OR TRIM(unread.error) != '')
  5927. AND unread.seq > topic.last_read_message_seq) AS unread_count,
  5928. COALESCE((SELECT MAX(visible_message.seq)
  5929. FROM group_messages AS visible_message
  5930. WHERE visible_message.topic_id = topic.id
  5931. AND visible_message.visible = 1), 0) AS latest_message_seq
  5932. FROM group_topics AS topic
  5933. WHERE topic.id = ? AND topic.room_id = ?""",
  5934. (topic_id, room_id),
  5935. ).fetchone()
  5936. if row is None:
  5937. raise GroupNotFoundError("Topic not found in room")
  5938. return cls._topic_summary(row)
  5939. def _ensure_topic(
  5940. self,
  5941. connection: sqlite3.Connection,
  5942. room_id: str,
  5943. topic_id: str,
  5944. title_source: str,
  5945. now: float,
  5946. ) -> sqlite3.Row:
  5947. existing = connection.execute(
  5948. "SELECT * FROM group_topics WHERE id = ?", (topic_id,)
  5949. ).fetchone()
  5950. if existing is not None:
  5951. if existing["room_id"] != room_id:
  5952. raise GroupConflictError("Topic does not belong to room")
  5953. if existing["archived"]:
  5954. raise GroupConflictError("Topic is archived")
  5955. return existing
  5956. connection.execute(
  5957. """INSERT INTO group_topics
  5958. (id, room_id, title, created_at, updated_at)
  5959. VALUES (?, ?, ?, ?, ?)""",
  5960. (topic_id, room_id, self._topic_title(title_source), now, now),
  5961. )
  5962. connection.execute(
  5963. """INSERT INTO group_agent_topic_state
  5964. (agent_id, topic_id, last_context_message_seq, created_at, updated_at)
  5965. SELECT id, ?, 0, ?, ? FROM group_agents WHERE room_id = ?""",
  5966. (topic_id, now, now, room_id),
  5967. )
  5968. return self._topic_row(connection, room_id, topic_id)
  5969. @staticmethod
  5970. def _agent_topic_state(
  5971. connection: sqlite3.Connection, agent_id: str, topic_id: str
  5972. ) -> sqlite3.Row:
  5973. state = connection.execute(
  5974. """SELECT * FROM group_agent_topic_state
  5975. WHERE agent_id = ? AND topic_id = ?""",
  5976. (agent_id, topic_id),
  5977. ).fetchone()
  5978. if state is None:
  5979. raise GroupStoreError("Agent topic context state is missing")
  5980. return state
  5981. def _active_room(self, connection: sqlite3.Connection, room_id: str) -> sqlite3.Row:
  5982. room = connection.execute(
  5983. "SELECT * FROM group_rooms WHERE id = ? AND archived = 0", (room_id,)
  5984. ).fetchone()
  5985. if room is None:
  5986. raise GroupNotFoundError("Room not found")
  5987. return room
  5988. def _owned_agent(
  5989. self, connection: sqlite3.Connection, room_id: str, agent_id: str
  5990. ) -> sqlite3.Row:
  5991. agent = connection.execute(
  5992. "SELECT * FROM group_agents WHERE id = ? AND room_id = ?",
  5993. (agent_id, room_id),
  5994. ).fetchone()
  5995. if agent is None:
  5996. raise GroupNotFoundError("Agent not found in room")
  5997. return agent
  5998. @staticmethod
  5999. def _room_host(
  6000. connection: sqlite3.Connection, room_id: str
  6001. ) -> sqlite3.Row:
  6002. host = connection.execute(
  6003. "SELECT * FROM group_agents WHERE room_id = ? AND is_host = 1",
  6004. (room_id,),
  6005. ).fetchone()
  6006. if host is None:
  6007. raise GroupStoreError("Room administrator is missing")
  6008. return host
  6009. @staticmethod
  6010. def _first_enabled_host_candidate(
  6011. connection: sqlite3.Connection,
  6012. room_id: str,
  6013. *,
  6014. exclude_agent_id: str,
  6015. ) -> sqlite3.Row | None:
  6016. return connection.execute(
  6017. """SELECT * FROM group_agents
  6018. WHERE room_id = ? AND enabled = 1 AND id != ?
  6019. ORDER BY created_at ASC, id ASC LIMIT 1""",
  6020. (room_id, exclude_agent_id),
  6021. ).fetchone()
  6022. def _switch_host(
  6023. self,
  6024. connection: sqlite3.Connection,
  6025. room_id: str,
  6026. agent_id: str,
  6027. *,
  6028. now: float,
  6029. ) -> tuple[dict[str, object], dict[str, object]] | None:
  6030. current = self._room_host(connection, room_id)
  6031. if current["id"] == agent_id:
  6032. return None
  6033. replacement = self._owned_agent(connection, room_id, agent_id)
  6034. if not replacement["enabled"]:
  6035. raise GroupConflictError("Administrator Agent must be enabled")
  6036. connection.execute(
  6037. """UPDATE group_agents SET is_host = 0, updated_at = ?
  6038. WHERE id = ? AND room_id = ?""",
  6039. (now, current["id"], room_id),
  6040. )
  6041. connection.execute(
  6042. """UPDATE group_agents SET is_host = 1, updated_at = ?
  6043. WHERE id = ? AND room_id = ?""",
  6044. (now, replacement["id"], room_id),
  6045. )
  6046. return (
  6047. self._agent_detail(connection, room_id, str(current["id"])),
  6048. self._agent_detail(connection, room_id, str(replacement["id"])),
  6049. )
  6050. def _enabled_agent(
  6051. self, connection: sqlite3.Connection, room_id: str, agent_id: str
  6052. ) -> sqlite3.Row:
  6053. agent = self._owned_agent(connection, room_id, agent_id)
  6054. if not agent["enabled"]:
  6055. raise GroupConflictError("Agent is disabled")
  6056. return agent
  6057. @staticmethod
  6058. def _require_available_runtime(
  6059. connection: sqlite3.Connection, runtime_session_id: str, run_id: str
  6060. ) -> None:
  6061. occupied = connection.execute(
  6062. """SELECT id FROM group_agent_runs
  6063. WHERE runtime_session_id = ?
  6064. AND status IN ('running', 'awaiting_input')
  6065. AND id != ? LIMIT 1""",
  6066. (runtime_session_id, run_id),
  6067. ).fetchone()
  6068. if occupied is not None:
  6069. raise GroupConflictError("Runtime session is already in use")
  6070. def _agent_detail(
  6071. self, connection: sqlite3.Connection, room_id: str, agent_id: str
  6072. ) -> dict[str, object]:
  6073. return self._agent_wire(self._owned_agent(connection, room_id, agent_id))
  6074. @staticmethod
  6075. def _message_row(connection: sqlite3.Connection, message_id: str) -> sqlite3.Row:
  6076. row = connection.execute(
  6077. "SELECT * FROM group_messages WHERE id = ?", (message_id,)
  6078. ).fetchone()
  6079. if row is None:
  6080. raise GroupNotFoundError("Message not found")
  6081. return row
  6082. @staticmethod
  6083. def _message_with_execution_row(
  6084. connection: sqlite3.Connection, message_id: str
  6085. ) -> sqlite3.Row:
  6086. row = connection.execute(
  6087. """SELECT message.*,
  6088. run.requested_model AS execution_requested_model,
  6089. run.requested_provider AS execution_requested_provider,
  6090. run.requested_reasoning_effort AS execution_requested_reasoning_effort,
  6091. run.requested_fast_mode AS execution_requested_fast_mode,
  6092. run.actual_model AS execution_actual_model,
  6093. run.actual_provider AS execution_actual_provider,
  6094. run.actual_reasoning_effort AS execution_actual_reasoning_effort,
  6095. run.actual_fast_mode AS execution_actual_fast_mode
  6096. FROM group_messages AS message
  6097. LEFT JOIN group_agent_runs AS run
  6098. ON run.response_message_id = message.id
  6099. WHERE message.id = ?""",
  6100. (message_id,),
  6101. ).fetchone()
  6102. if row is None:
  6103. raise GroupNotFoundError("Message not found")
  6104. return row
  6105. @staticmethod
  6106. def _run_row(connection: sqlite3.Connection, run_id: str) -> sqlite3.Row:
  6107. row = connection.execute(
  6108. "SELECT * FROM group_agent_runs WHERE id = ?", (run_id,)
  6109. ).fetchone()
  6110. if row is None:
  6111. raise GroupNotFoundError("Run not found")
  6112. return row
  6113. @staticmethod
  6114. def _interaction_row(
  6115. connection: sqlite3.Connection, interaction_id: str
  6116. ) -> sqlite3.Row:
  6117. row = connection.execute(
  6118. "SELECT * FROM group_interactions WHERE id = ?", (interaction_id,)
  6119. ).fetchone()
  6120. if row is None:
  6121. raise GroupNotFoundError("Interaction not found")
  6122. return row
  6123. @classmethod
  6124. def _validate_run_message_state(
  6125. cls, run: sqlite3.Row, message: sqlite3.Row
  6126. ) -> None:
  6127. if (
  6128. run["room_id"] != message["room_id"]
  6129. or run["topic_id"] != message["topic_id"]
  6130. ):
  6131. raise GroupStoreError("Run response message is corrupt")
  6132. cls._validate_run_message_status(run["status"], message["status"])
  6133. @staticmethod
  6134. def _validate_run_message_status(run_status: str, message_status: str) -> None:
  6135. if message_status not in _MESSAGE_STATUSES_BY_RUN.get(run_status, frozenset()):
  6136. raise GroupConflictError("Run and response message states conflict")
  6137. def _update_run(
  6138. self,
  6139. connection: sqlite3.Connection,
  6140. run: sqlite3.Row,
  6141. status: str,
  6142. now: float,
  6143. runtime_session_id: object,
  6144. error: object,
  6145. ) -> dict[str, object]:
  6146. runtime = (
  6147. run["runtime_session_id"]
  6148. if runtime_session_id is UNSET
  6149. else runtime_session_id
  6150. )
  6151. run_error = run["error"] if error is UNSET else error
  6152. try:
  6153. connection.execute(
  6154. "UPDATE group_agent_runs SET status = ?, runtime_session_id = ?, error = ?, updated_at = ? WHERE id = ?",
  6155. (status, runtime, run_error, now, run["id"]),
  6156. )
  6157. except sqlite3.IntegrityError as conflict:
  6158. if "group_agent_runs.runtime_session_id" in str(conflict):
  6159. raise GroupConflictError(
  6160. "Runtime session is already in use"
  6161. ) from conflict
  6162. raise
  6163. return self._run_wire(self._run_row(connection, run["id"]))
  6164. @staticmethod
  6165. def _message_wire(row: sqlite3.Row) -> dict[str, object]:
  6166. try:
  6167. tool_state = GroupStore._load_json(row["tool_state_json"])
  6168. except (
  6169. TypeError,
  6170. ValueError,
  6171. UnicodeDecodeError,
  6172. json.JSONDecodeError,
  6173. ) as error:
  6174. raise GroupStoreError("Stored message tool state is corrupt") from error
  6175. if not isinstance(tool_state, list) or not all(
  6176. isinstance(item, dict) for item in tool_state
  6177. ):
  6178. raise GroupStoreError("Stored message tool state is corrupt")
  6179. result = {
  6180. "seq": row["seq"],
  6181. "id": row["id"],
  6182. "roomId": row["room_id"],
  6183. "topicId": row["topic_id"],
  6184. "senderKind": row["sender_kind"],
  6185. "senderId": row["sender_id"],
  6186. "senderName": row["sender_name"],
  6187. "rootMessageId": row["root_message_id"],
  6188. "replyToMessageId": row["reply_to_message_id"],
  6189. "clientMessageId": row["client_message_id"],
  6190. "content": row["content"],
  6191. "reasoning": row["reasoning"],
  6192. "toolState": tool_state,
  6193. "status": row["status"],
  6194. "error": row["error"],
  6195. "visible": bool(row["visible"]),
  6196. "createdAt": row["created_at"],
  6197. "updatedAt": row["updated_at"],
  6198. }
  6199. keys = set(row.keys())
  6200. if "execution_actual_model" in keys:
  6201. values = {
  6202. "requestedModel": row["execution_requested_model"],
  6203. "requestedProvider": row["execution_requested_provider"],
  6204. "requestedReasoningEffort": row[
  6205. "execution_requested_reasoning_effort"
  6206. ],
  6207. "requestedFastMode": (
  6208. None
  6209. if row["execution_requested_fast_mode"] is None
  6210. else bool(row["execution_requested_fast_mode"])
  6211. ),
  6212. "actualModel": row["execution_actual_model"],
  6213. "actualProvider": row["execution_actual_provider"],
  6214. "actualReasoningEffort": row["execution_actual_reasoning_effort"],
  6215. "actualFastMode": (
  6216. None
  6217. if row["execution_actual_fast_mode"] is None
  6218. else bool(row["execution_actual_fast_mode"])
  6219. ),
  6220. }
  6221. if any(value is not None for value in values.values()):
  6222. result["execution"] = values
  6223. return result
  6224. @classmethod
  6225. def _projection_message(cls, row: sqlite3.Row) -> dict[str, object]:
  6226. try:
  6227. tool_state = cls._load_json(row["tool_state_json"])
  6228. except (
  6229. TypeError,
  6230. ValueError,
  6231. UnicodeDecodeError,
  6232. json.JSONDecodeError,
  6233. ) as error:
  6234. raise GroupStoreError("Stored message tool state is corrupt") from error
  6235. if not isinstance(tool_state, list) or not all(
  6236. isinstance(item, dict) for item in tool_state
  6237. ):
  6238. raise GroupStoreError("Stored message tool state is corrupt")
  6239. tool_names: list[str] = []
  6240. tool_names_truncated = False
  6241. for item in tool_state:
  6242. name = None
  6243. for key in ("name", "toolName"):
  6244. candidate = item.get(key)
  6245. if not isinstance(candidate, str) or not candidate:
  6246. continue
  6247. if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,119}", candidate) is None:
  6248. tool_names_truncated = True
  6249. continue
  6250. name = candidate
  6251. break
  6252. if name is None or name in tool_names:
  6253. continue
  6254. if len(tool_names) >= 16:
  6255. tool_names_truncated = True
  6256. break
  6257. tool_names.append(name)
  6258. return {
  6259. "seq": int(row["seq"]),
  6260. "senderKind": row["sender_kind"],
  6261. "senderId": row["sender_id"],
  6262. "senderName": row["sender_name"],
  6263. "content": row["content"],
  6264. "reasoning": row["reasoning"],
  6265. "toolNames": tool_names,
  6266. "status": row["status"],
  6267. "summary": row["error"],
  6268. "truncated": tool_names_truncated,
  6269. }
  6270. @classmethod
  6271. def _bound_projection_messages(
  6272. cls,
  6273. messages: list[dict[str, object]],
  6274. *,
  6275. omitted_count: int,
  6276. omitted_through_seq: int | None,
  6277. ) -> tuple[list[dict[str, object]], int, int | None, dict[str, object] | None]:
  6278. while len(messages) > 1:
  6279. omitted_summary = cls._projection_omitted_summary(
  6280. omitted_count, omitted_through_seq
  6281. )
  6282. if (
  6283. cls._projection_size(messages, omitted_summary)
  6284. <= CONTEXT_CHARACTER_BUDGET
  6285. ):
  6286. break
  6287. removed = messages.pop(0)
  6288. omitted_count += 1
  6289. omitted_through_seq = int(removed["seq"])
  6290. omitted_summary = cls._projection_omitted_summary(
  6291. omitted_count, omitted_through_seq
  6292. )
  6293. if (
  6294. messages
  6295. and cls._projection_size(messages, omitted_summary)
  6296. > CONTEXT_CHARACTER_BUDGET
  6297. ):
  6298. cls._truncate_projection_message(messages[0], omitted_summary)
  6299. if cls._projection_size(messages, omitted_summary) > CONTEXT_CHARACTER_BUDGET:
  6300. raise GroupStoreError("Projection metadata exceeds the character budget")
  6301. return messages, omitted_count, omitted_through_seq, omitted_summary
  6302. @staticmethod
  6303. def _projection_omitted_summary(
  6304. count: int, through_seq: int | None
  6305. ) -> dict[str, object] | None:
  6306. if count == 0:
  6307. return None
  6308. if through_seq is None:
  6309. raise GroupStoreError("Projection omission metadata is corrupt")
  6310. return {
  6311. "messageCount": count,
  6312. "throughSeq": through_seq,
  6313. "text": f"Omitted {count} earlier messages through seq {through_seq}.",
  6314. }
  6315. @classmethod
  6316. def _projection_size(
  6317. cls,
  6318. messages: list[dict[str, object]],
  6319. omitted_summary: dict[str, object] | None,
  6320. ) -> int:
  6321. return len(
  6322. cls._canonical_json(
  6323. {"messages": messages, "omittedSummary": omitted_summary}
  6324. ).encode("utf-8")
  6325. )
  6326. @classmethod
  6327. def _truncate_projection_message(
  6328. cls,
  6329. message: dict[str, object],
  6330. omitted_summary: dict[str, object] | None,
  6331. ) -> None:
  6332. marker = "…[truncated]"
  6333. originals = {
  6334. field: message[field]
  6335. for field in ("content", "reasoning", "summary")
  6336. if isinstance(message[field], str) and message[field]
  6337. }
  6338. message["truncated"] = True
  6339. for field in originals:
  6340. message[field] = marker
  6341. wrapper = [message]
  6342. if cls._projection_size(wrapper, omitted_summary) > CONTEXT_CHARACTER_BUDGET:
  6343. raise GroupStoreError("Projection identity exceeds the character budget")
  6344. for field in ("content", "reasoning", "summary"):
  6345. original = originals.get(field)
  6346. if original is None:
  6347. continue
  6348. low = 0
  6349. high = len(original)
  6350. while low < high:
  6351. candidate = (low + high + 1) // 2
  6352. message[field] = (
  6353. original
  6354. if candidate == len(original)
  6355. else f"{original[:candidate]}{marker}"
  6356. )
  6357. if (
  6358. cls._projection_size(wrapper, omitted_summary)
  6359. <= CONTEXT_CHARACTER_BUDGET
  6360. ):
  6361. low = candidate
  6362. else:
  6363. high = candidate - 1
  6364. message[field] = (
  6365. original if low == len(original) else f"{original[:low]}{marker}"
  6366. )
  6367. @staticmethod
  6368. def _run_wire(row: sqlite3.Row) -> dict[str, object]:
  6369. return {
  6370. "id": row["id"],
  6371. "roomId": row["room_id"],
  6372. "topicId": row["topic_id"],
  6373. "agentId": row["agent_id"],
  6374. "triggerMessageId": row["trigger_message_id"],
  6375. "responseMessageId": row["response_message_id"],
  6376. "rootMessageId": row["root_message_id"],
  6377. "depth": row["depth"],
  6378. "replyMode": row["reply_mode"],
  6379. "status": row["status"],
  6380. "runtimeSessionId": row["runtime_session_id"],
  6381. "requestedModel": row["requested_model"],
  6382. "requestedProvider": row["requested_provider"],
  6383. "requestedReasoningEffort": row["requested_reasoning_effort"],
  6384. "requestedFastMode": (
  6385. None
  6386. if row["requested_fast_mode"] is None
  6387. else bool(row["requested_fast_mode"])
  6388. ),
  6389. "actualModel": row["actual_model"],
  6390. "actualProvider": row["actual_provider"],
  6391. "actualReasoningEffort": row["actual_reasoning_effort"],
  6392. "actualFastMode": (
  6393. None
  6394. if row["actual_fast_mode"] is None
  6395. else bool(row["actual_fast_mode"])
  6396. ),
  6397. "error": row["error"],
  6398. "createdAt": row["created_at"],
  6399. "updatedAt": row["updated_at"],
  6400. }
  6401. @staticmethod
  6402. def _legacy_interaction_failure_run(
  6403. run: Mapping[str, object],
  6404. ) -> dict[str, object]:
  6405. """Keep the pre-v4 strict HTTP replay shape for failed interactions."""
  6406. result = dict(run)
  6407. result.pop("topicId", None)
  6408. for field in (
  6409. "requestedModel",
  6410. "requestedProvider",
  6411. "requestedReasoningEffort",
  6412. "requestedFastMode",
  6413. "actualModel",
  6414. "actualProvider",
  6415. "actualReasoningEffort",
  6416. "actualFastMode",
  6417. ):
  6418. result.pop(field, None)
  6419. return result
  6420. @staticmethod
  6421. def _interaction_wire(row: sqlite3.Row) -> dict[str, object]:
  6422. try:
  6423. payload = GroupStore._load_json(row["payload_json"])
  6424. except (
  6425. TypeError,
  6426. ValueError,
  6427. UnicodeDecodeError,
  6428. json.JSONDecodeError,
  6429. ) as error:
  6430. raise GroupStoreError("Stored interaction payload is corrupt") from error
  6431. if not isinstance(payload, dict):
  6432. raise GroupStoreError("Stored interaction payload is corrupt")
  6433. return {
  6434. "id": row["id"],
  6435. "roomId": row["room_id"],
  6436. "topicId": row["topic_id"],
  6437. "agentId": row["agent_id"],
  6438. "runId": row["run_id"],
  6439. "kind": row["kind"],
  6440. "payload": payload,
  6441. "status": row["status"],
  6442. "createdAt": row["created_at"],
  6443. "resolvedAt": row["resolved_at"],
  6444. }
  6445. def _event_wire(self, row: sqlite3.Row, current_epoch: str) -> dict[str, object]:
  6446. try:
  6447. epoch = self._validated_epoch(row["epoch"])
  6448. payload = self._load_json(row["payload_json"])
  6449. except (
  6450. TypeError,
  6451. ValueError,
  6452. UnicodeDecodeError,
  6453. json.JSONDecodeError,
  6454. ) as error:
  6455. raise GroupStoreError("Stored event is corrupt") from error
  6456. if epoch != current_epoch or not isinstance(payload, dict):
  6457. raise GroupStoreError("Stored event is corrupt")
  6458. return {
  6459. "cursor": row["cursor"],
  6460. "epoch": epoch,
  6461. "roomId": row["room_id"],
  6462. "eventType": row["event_type"],
  6463. "payload": payload,
  6464. "createdAt": row["created_at"],
  6465. }
  6466. @staticmethod
  6467. def _message_text(
  6468. value: object, field: str, *, max_bytes: int, nonblank: bool = False
  6469. ) -> str:
  6470. if not isinstance(value, str):
  6471. raise ValueError(f"{field} must be a string")
  6472. try:
  6473. encoded = value.encode("utf-8")
  6474. except UnicodeEncodeError as error:
  6475. raise ValueError(f"{field} must be valid UTF-8") from error
  6476. if len(encoded) > max_bytes:
  6477. raise ValueError(f"{field} exceeds maximum size")
  6478. if nonblank and not value.strip():
  6479. raise ValueError(f"{field} must not be blank")
  6480. return value
  6481. @staticmethod
  6482. def _orchestration_mode(value: object) -> str:
  6483. if not isinstance(value, str) or value not in ORCHESTRATION_MODES:
  6484. raise ValueError("orchestrationMode must be free or host")
  6485. return value
  6486. @classmethod
  6487. def _mention_agent_ids(cls, value: object) -> list[str]:
  6488. if not isinstance(value, list):
  6489. raise ValueError("mentionAgentIds must be a list")
  6490. result = [
  6491. cls._canonical_uuid(agent_id, "mentionAgentIds") for agent_id in value
  6492. ]
  6493. if len(result) != len(set(result)):
  6494. raise GroupConflictError("mentionAgentIds must be unique")
  6495. return result
  6496. @staticmethod
  6497. def _interaction_id(value: object) -> str:
  6498. return normalize_interaction_id(value)
  6499. @staticmethod
  6500. def _runtime_session_id(value: object) -> str | None:
  6501. if value is None:
  6502. return None
  6503. if (
  6504. not isinstance(value, str)
  6505. or not value
  6506. or len(value) > 200
  6507. or any(unicodedata.category(character) == "Cc" for character in value)
  6508. ):
  6509. raise ValueError("runtimeSessionId is invalid")
  6510. return value
  6511. @classmethod
  6512. def _tool_state_json(cls, value: object) -> str:
  6513. if not isinstance(value, list):
  6514. raise ValueError("tool_state must be a list")
  6515. if not all(isinstance(item, Mapping) for item in value):
  6516. raise ValueError("tool_state entries must be objects")
  6517. encoded = cls._strict_json(value, "tool_state")
  6518. if len(encoded.encode("utf-8")) > MAX_TOOL_STATE_BYTES:
  6519. raise ValueError("tool_state exceeds maximum size")
  6520. return encoded
  6521. @classmethod
  6522. def _json_object(cls, value: object, field: str) -> str:
  6523. if not isinstance(value, Mapping):
  6524. raise ValueError(f"{field} must be an object")
  6525. encoded = cls._strict_json(dict(value), field)
  6526. if (
  6527. field == "payload"
  6528. and len(encoded.encode("utf-8")) > MAX_INTERACTION_PAYLOAD_BYTES
  6529. ):
  6530. raise ValueError("payload exceeds maximum size")
  6531. return encoded
  6532. @staticmethod
  6533. def _strict_json(value: object, field: str) -> str:
  6534. try:
  6535. encoded = json.dumps(
  6536. value,
  6537. ensure_ascii=False,
  6538. sort_keys=True,
  6539. separators=(",", ":"),
  6540. allow_nan=False,
  6541. )
  6542. encoded.encode("utf-8")
  6543. return encoded
  6544. except (TypeError, ValueError, UnicodeEncodeError) as error:
  6545. raise ValueError(f"{field} must be JSON-serializable") from error
  6546. @staticmethod
  6547. def _load_json(value: object) -> object:
  6548. if not isinstance(value, str):
  6549. raise ValueError("JSON must be a string")
  6550. return json.loads(
  6551. value,
  6552. parse_constant=lambda constant: (_ for _ in ()).throw(ValueError(constant)),
  6553. )
  6554. @staticmethod
  6555. def _page_limit(value: object, maximum: int, field: str) -> int:
  6556. if (
  6557. not isinstance(value, int)
  6558. or isinstance(value, bool)
  6559. or not 1 <= value <= maximum
  6560. ):
  6561. raise ValueError(f"{field} must be an integer from 1 to {maximum}")
  6562. return value
  6563. @staticmethod
  6564. def _positive_int(value: object, field: str) -> int:
  6565. if not isinstance(value, int) or isinstance(value, bool) or value < 1:
  6566. raise ValueError(f"{field} must be a positive integer")
  6567. return value
  6568. @staticmethod
  6569. def _nonnegative_int(value: object, field: str) -> int:
  6570. if not isinstance(value, int) or isinstance(value, bool) or value < 0:
  6571. raise ValueError(f"{field} must be a nonnegative integer")
  6572. return value
  6573. @staticmethod
  6574. def _finite_timestamp(value: object, field: str) -> float:
  6575. try:
  6576. finite = (
  6577. math.isfinite(value)
  6578. if isinstance(value, (int, float)) and not isinstance(value, bool)
  6579. else False
  6580. )
  6581. except OverflowError:
  6582. finite = False
  6583. if not finite:
  6584. raise ValueError(f"{field} must be finite")
  6585. return float(value)
  6586. def _new_agents(self, value: object) -> list[dict[str, object]]:
  6587. if not isinstance(value, list) or not 1 <= len(value) <= MAX_AGENTS_PER_ROOM:
  6588. raise ValueError(f"agents must contain 1 to {MAX_AGENTS_PER_ROOM} members")
  6589. agents = [self._new_agent(agent) for agent in value]
  6590. profiles = [agent["profile"] for agent in agents]
  6591. display_keys = [agent["display_name_key"] for agent in agents]
  6592. if len(profiles) != len(set(profiles)):
  6593. raise GroupConflictError("Agent profile must be unique within a room")
  6594. if len(display_keys) != len(set(display_keys)):
  6595. raise GroupConflictError("Agent display name must be unique within a room")
  6596. host_count = sum(bool(agent["is_host"]) for agent in agents)
  6597. if host_count > 1:
  6598. raise GroupConflictError("Room may contain only one administrator Agent")
  6599. if host_count == 0:
  6600. agents[0]["is_host"] = True
  6601. return agents
  6602. def _new_agent(self, command: object) -> dict[str, object]:
  6603. command = self._command(
  6604. command,
  6605. {
  6606. "profile", "displayName", "description", "replyWithoutMention",
  6607. "isHost", "nodeId", "nodeLabel",
  6608. "model", "provider", "reasoningEffort", "fastMode",
  6609. },
  6610. )
  6611. execution_profile = self._profile(command.get("profile"))
  6612. node_id = self._node_id(command.get("nodeId", "local"))
  6613. profile = (
  6614. execution_profile
  6615. if node_id == "local"
  6616. else f"node:{node_id}:{execution_profile}"
  6617. )
  6618. node_label = command.get("nodeLabel", "")
  6619. if not isinstance(node_label, str) or len(node_label) > 100:
  6620. raise ValueError("nodeLabel must be a string up to 100 characters")
  6621. node_label = " ".join(node_label.split())
  6622. raw_name = command.get("displayName")
  6623. explicit_name = (
  6624. raw_name.strip()
  6625. if isinstance(raw_name, str) and raw_name.strip()
  6626. else ""
  6627. )
  6628. display_name, display_name_key = normalize_display_name(
  6629. explicit_name
  6630. or (
  6631. self._resolved_agent_name(execution_profile)
  6632. if node_id == "local"
  6633. else ""
  6634. )
  6635. or execution_profile
  6636. )
  6637. reply_without_mention = command.get("replyWithoutMention", False)
  6638. if not isinstance(reply_without_mention, bool):
  6639. raise ValueError("replyWithoutMention must be a boolean")
  6640. is_host = command.get("isHost", False)
  6641. if not isinstance(is_host, bool):
  6642. raise ValueError("isHost must be a boolean")
  6643. fast_mode = command.get("fastMode")
  6644. if fast_mode is not None and not isinstance(fast_mode, bool):
  6645. raise ValueError("fastMode must be a boolean or null")
  6646. if (command.get("model") is None) != (command.get("provider") is None):
  6647. raise ValueError("model and provider must both be set or both be null")
  6648. return {
  6649. "profile": profile,
  6650. "node_id": node_id,
  6651. "node_label": node_label,
  6652. "execution_profile": (
  6653. None if node_id == "local" else execution_profile
  6654. ),
  6655. "display_name": display_name,
  6656. "display_name_key": display_name_key,
  6657. "description": self._description(command.get("description", "")),
  6658. "reply_without_mention": reply_without_mention,
  6659. "is_host": is_host,
  6660. "model_override": self._agent_configuration_value(
  6661. command.get("model"), "model"
  6662. ),
  6663. "provider_override": self._agent_configuration_value(
  6664. command.get("provider"), "provider"
  6665. ),
  6666. "reasoning_effort_override": self._agent_configuration_value(
  6667. command.get("reasoningEffort"), "reasoningEffort"
  6668. ),
  6669. "fast_mode_override": fast_mode,
  6670. }
  6671. @staticmethod
  6672. def _agent_configuration_value(value: object, field: str) -> str | None:
  6673. if value is None:
  6674. return None
  6675. if not isinstance(value, str) or not value.strip():
  6676. raise ValueError(f"{field} must be a non-empty string or null")
  6677. normalized = value.strip()
  6678. if field == "reasoningEffort" and normalized not in {
  6679. "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"
  6680. }:
  6681. raise ValueError("reasoningEffort is invalid")
  6682. return normalized
  6683. def _resolved_agent_name(self, profile: str) -> str:
  6684. try:
  6685. value = self._agent_name_resolver(profile)
  6686. except Exception as error:
  6687. raise GroupStoreError("Agent name settings could not be read") from error
  6688. if not isinstance(value, str):
  6689. raise GroupStoreError("Agent name settings are invalid")
  6690. normalized = value.strip()
  6691. return "" if is_reserved_mention_alias(normalized) else normalized
  6692. def _insert_agent(
  6693. self,
  6694. connection: sqlite3.Connection,
  6695. room_id: str,
  6696. agent: Mapping[str, object],
  6697. now: float,
  6698. ) -> str:
  6699. self._check_agent_conflicts(
  6700. connection, room_id, agent["display_name_key"], profile=agent["profile"]
  6701. )
  6702. agent_id = str(uuid.uuid4())
  6703. connection.execute(
  6704. """INSERT INTO group_agents
  6705. (id, room_id, profile, node_id, node_label, execution_profile,
  6706. display_name, display_name_key, description,
  6707. reply_without_mention, is_host, model_override, provider_override,
  6708. reasoning_effort_override, fast_mode_override, created_at, updated_at)
  6709. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
  6710. (
  6711. agent_id,
  6712. room_id,
  6713. agent["profile"],
  6714. agent["node_id"],
  6715. agent["node_label"],
  6716. agent["execution_profile"],
  6717. agent["display_name"],
  6718. agent["display_name_key"],
  6719. agent["description"],
  6720. int(bool(agent["reply_without_mention"])),
  6721. int(bool(agent["is_host"])),
  6722. agent["model_override"],
  6723. agent["provider_override"],
  6724. agent["reasoning_effort_override"],
  6725. None if agent["fast_mode_override"] is None
  6726. else int(bool(agent["fast_mode_override"])),
  6727. now,
  6728. now,
  6729. ),
  6730. )
  6731. connection.execute(
  6732. """INSERT INTO group_agent_topic_state
  6733. (agent_id, topic_id, last_context_message_seq, created_at, updated_at)
  6734. SELECT ?, id, 0, ?, ? FROM group_topics WHERE room_id = ?""",
  6735. (agent_id, now, now, room_id),
  6736. )
  6737. return agent_id
  6738. @staticmethod
  6739. def _check_agent_conflicts(
  6740. connection: sqlite3.Connection,
  6741. room_id: str,
  6742. display_name_key: str,
  6743. *,
  6744. profile: str | None = None,
  6745. exclude_agent_id: str | None = None,
  6746. ) -> None:
  6747. exclusion = " AND id != ?" if exclude_agent_id is not None else ""
  6748. suffix: tuple[object, ...] = (
  6749. (exclude_agent_id,) if exclude_agent_id is not None else ()
  6750. )
  6751. if (
  6752. profile is not None
  6753. and connection.execute(
  6754. f"SELECT 1 FROM group_agents WHERE room_id = ? AND profile = ?{exclusion}",
  6755. (room_id, profile, *suffix),
  6756. ).fetchone()
  6757. is not None
  6758. ):
  6759. raise GroupConflictError("Agent profile must be unique within a room")
  6760. if (
  6761. connection.execute(
  6762. f"SELECT 1 FROM group_agents WHERE room_id = ? AND display_name_key = ?{exclusion}",
  6763. (room_id, display_name_key, *suffix),
  6764. ).fetchone()
  6765. is not None
  6766. ):
  6767. raise GroupConflictError("Agent display name must be unique within a room")
  6768. @staticmethod
  6769. def _room_wire(row: sqlite3.Row) -> dict[str, object]:
  6770. return {
  6771. "id": row["id"],
  6772. "name": row["name"],
  6773. "cwd": row["cwd"],
  6774. "instructions": row["instructions"],
  6775. "avatar": row["avatar_data_url"],
  6776. "maxReplyRounds": row["max_reply_rounds"],
  6777. "orchestrationMode": row["orchestration_mode"],
  6778. "createdAt": row["created_at"],
  6779. "updatedAt": row["updated_at"],
  6780. "archived": bool(row["archived"]),
  6781. }
  6782. @staticmethod
  6783. def _room_summary(
  6784. row: sqlite3.Row,
  6785. avatar_members: list[dict[str, object]] | None = None,
  6786. ) -> dict[str, object]:
  6787. return {
  6788. "id": row["id"],
  6789. "name": row["name"],
  6790. "cwd": row["cwd"],
  6791. "instructions": row["instructions"],
  6792. "avatar": row["avatar_data_url"],
  6793. "avatarMembers": avatar_members or [],
  6794. "maxReplyRounds": row["max_reply_rounds"],
  6795. "orchestrationMode": row["orchestration_mode"],
  6796. "createdAt": row["created_at"],
  6797. "updatedAt": row["updated_at"],
  6798. "archived": False,
  6799. "agentCount": row["agent_count"],
  6800. "topicCount": row["topic_count"],
  6801. }
  6802. @staticmethod
  6803. def _avatar_member_wire(row: sqlite3.Row) -> dict[str, object]:
  6804. return {
  6805. "profile": row["execution_profile"] or row["profile"],
  6806. "nodeId": row["node_id"],
  6807. "displayName": row["display_name"],
  6808. }
  6809. def _room_avatar_members(
  6810. self, connection: sqlite3.Connection, room_id: str
  6811. ) -> list[dict[str, object]]:
  6812. rows = connection.execute(
  6813. """SELECT profile, execution_profile, node_id, display_name FROM group_agents
  6814. WHERE room_id = ? ORDER BY created_at ASC, id ASC LIMIT 4""",
  6815. (room_id,),
  6816. ).fetchall()
  6817. return [self._avatar_member_wire(row) for row in rows]
  6818. @staticmethod
  6819. def _topic_summary(row: sqlite3.Row) -> dict[str, object]:
  6820. preview = str(row["preview"] or "")
  6821. return {
  6822. "id": row["id"],
  6823. "roomId": row["room_id"],
  6824. "title": row["title"],
  6825. "preview": preview[:_TOPIC_PREVIEW_LENGTH],
  6826. "messageCount": int(row["message_count"]),
  6827. "unreadCount": int(row["unread_count"]),
  6828. "latestMessageSeq": int(row["latest_message_seq"]),
  6829. "lastReadMessageSeq": int(row["last_read_message_seq"]),
  6830. "archived": bool(row["archived"]),
  6831. "pinned": bool(row["pinned"]),
  6832. "createdAt": row["created_at"],
  6833. "updatedAt": row["updated_at"],
  6834. }
  6835. @staticmethod
  6836. def _agent_wire(row: sqlite3.Row) -> dict[str, object]:
  6837. return {
  6838. "id": row["id"],
  6839. "roomId": row["room_id"],
  6840. "profile": row["execution_profile"] or row["profile"],
  6841. "nodeId": row["node_id"],
  6842. "nodeLabel": row["node_label"],
  6843. "displayName": row["display_name"],
  6844. "description": row["description"],
  6845. "storedSessionId": row["stored_session_id"],
  6846. "lastContextMessageSeq": row["last_context_message_seq"],
  6847. "enabled": bool(row["enabled"]),
  6848. "replyWithoutMention": bool(row["reply_without_mention"]),
  6849. "isHost": bool(row["is_host"]),
  6850. "model": row["model_override"],
  6851. "provider": row["provider_override"],
  6852. "reasoningEffort": row["reasoning_effort_override"],
  6853. "fastMode": None if row["fast_mode_override"] is None
  6854. else bool(row["fast_mode_override"]),
  6855. "createdAt": row["created_at"],
  6856. "updatedAt": row["updated_at"],
  6857. }
  6858. @staticmethod
  6859. def _canonical_uuid(value: object, field: str) -> str:
  6860. if not isinstance(value, str):
  6861. raise ValueError(f"{field} must be a canonical UUID")
  6862. try:
  6863. canonical = str(uuid.UUID(value))
  6864. except ValueError as error:
  6865. raise ValueError(f"{field} must be a canonical UUID") from error
  6866. if value != canonical:
  6867. raise ValueError(f"{field} must be a canonical UUID")
  6868. return canonical
  6869. @staticmethod
  6870. def _canonical_json(value: object) -> str:
  6871. try:
  6872. encoded = json.dumps(
  6873. value,
  6874. ensure_ascii=False,
  6875. sort_keys=True,
  6876. separators=(",", ":"),
  6877. allow_nan=False,
  6878. )
  6879. encoded.encode("utf-8")
  6880. return encoded
  6881. except (TypeError, ValueError, UnicodeEncodeError) as error:
  6882. raise ValueError("value must be strict JSON") from error
  6883. @classmethod
  6884. def _interaction_response_operation(
  6885. cls, value: object
  6886. ) -> tuple[str, str | None] | None:
  6887. if not isinstance(value, str):
  6888. return None
  6889. base, separator, interaction_id = value.partition(":")
  6890. operations = {
  6891. "interaction.approval.response": "approval",
  6892. "interaction.clarification.response": "clarification",
  6893. }
  6894. kind = operations.get(base)
  6895. if kind is None:
  6896. return None
  6897. if not separator:
  6898. return kind, None
  6899. try:
  6900. canonical_interaction_id = cls._interaction_id(interaction_id)
  6901. except ValueError:
  6902. return None
  6903. if canonical_interaction_id != interaction_id:
  6904. return None
  6905. return kind, canonical_interaction_id
  6906. @classmethod
  6907. def _interaction_operation(cls, kind: str, interaction_id: str) -> str:
  6908. if kind not in _INTERACTION_KINDS:
  6909. raise GroupStoreError("Stored interaction kind is corrupt")
  6910. canonical_interaction_id = cls._interaction_id(interaction_id)
  6911. if canonical_interaction_id != interaction_id:
  6912. raise GroupStoreError("Stored interaction identity is corrupt")
  6913. return f"interaction.{kind}.response:{canonical_interaction_id}"
  6914. @classmethod
  6915. def _idempotency_state(cls, value: object) -> dict[str, object]:
  6916. try:
  6917. state = cls._load_json(value)
  6918. except (TypeError, ValueError, json.JSONDecodeError) as error:
  6919. raise GroupStoreError("Stored interaction response is corrupt") from error
  6920. if not isinstance(state, dict) or cls._canonical_json(state) != value:
  6921. raise GroupStoreError("Stored interaction response is corrupt")
  6922. status = state.get("state")
  6923. if status == "claimed":
  6924. if (
  6925. not isinstance(state.get("interactionId"), str)
  6926. or state.get("kind") not in _INTERACTION_KINDS
  6927. ):
  6928. raise GroupStoreError("Stored interaction response is corrupt")
  6929. elif status == "completed":
  6930. if not isinstance(state.get("response"), dict):
  6931. raise GroupStoreError("Stored interaction response is corrupt")
  6932. interaction_status = state.get("interactionStatus", "resolved")
  6933. if interaction_status not in _INTERACTION_TERMINAL_STATUSES:
  6934. raise GroupStoreError("Stored interaction response is corrupt")
  6935. elif status == "failed":
  6936. runtime_ids = state.get("runtimeSessionIds")
  6937. if (
  6938. not isinstance(state.get("reason"), str)
  6939. or not isinstance(runtime_ids, list)
  6940. or not all(isinstance(item, str) for item in runtime_ids)
  6941. or len(runtime_ids) != len(set(runtime_ids))
  6942. or not isinstance(state.get("run"), dict)
  6943. ):
  6944. raise GroupStoreError("Stored interaction response is corrupt")
  6945. else:
  6946. raise GroupStoreError("Stored interaction response is corrupt")
  6947. return state
  6948. @staticmethod
  6949. def _runtime_envelope(response: dict[str, object]) -> dict[str, object]:
  6950. if "result" not in response and "runtimeSessionIds" not in response:
  6951. return {"result": response, "runtimeSessionIds": []}
  6952. result = response.get("result")
  6953. runtime_ids = response.get("runtimeSessionIds")
  6954. if not isinstance(result, dict) or not isinstance(runtime_ids, list):
  6955. raise GroupStoreError("Stored lifecycle response is corrupt")
  6956. if not all(isinstance(value, str) for value in runtime_ids):
  6957. raise GroupStoreError("Stored lifecycle response is corrupt")
  6958. if len(runtime_ids) != len(set(runtime_ids)):
  6959. raise GroupStoreError("Stored lifecycle response is corrupt")
  6960. return {"result": result, "runtimeSessionIds": runtime_ids}
  6961. @staticmethod
  6962. def _scoped_payload(
  6963. command: Mapping[str, object], room_id: str, agent_id: str | None = None
  6964. ) -> dict[str, object]:
  6965. payload: dict[str, object] = {"body": dict(command), "roomId": room_id}
  6966. if agent_id is not None:
  6967. payload["agentId"] = agent_id
  6968. return payload
  6969. @staticmethod
  6970. def _integrity_conflict(error: sqlite3.IntegrityError) -> GroupStoreError:
  6971. message = str(error)
  6972. if "group_agents.room_id, group_agents.profile" in message:
  6973. return GroupConflictError("Agent profile must be unique within a room")
  6974. if "group_agents.room_id, group_agents.display_name_key" in message:
  6975. return GroupConflictError("Agent display name must be unique within a room")
  6976. if "group_messages.client_message_id" in message:
  6977. return GroupConflictError("clientMessageId already exists")
  6978. if "group_agent_runs.runtime_session_id" in message:
  6979. return GroupConflictError("Runtime session is already in use")
  6980. return GroupStoreError("Group storage constraint failed")
  6981. @staticmethod
  6982. def _command(value: object, allowed: set[str]) -> dict[str, object]:
  6983. if not isinstance(value, Mapping) or not all(
  6984. isinstance(key, str) for key in value
  6985. ):
  6986. raise ValueError("command must be an object")
  6987. unexpected = set(value) - allowed
  6988. if unexpected:
  6989. raise ValueError(
  6990. f"Unexpected command fields: {', '.join(sorted(unexpected))}"
  6991. )
  6992. return dict(value)
  6993. def _command_request_id(self, command: Mapping[str, object]) -> str:
  6994. if "requestId" not in command:
  6995. raise ValueError("requestId is required")
  6996. return self._canonical_uuid(command["requestId"], "requestId")
  6997. @staticmethod
  6998. def _string(command: Mapping[str, object], key: str) -> str:
  6999. value = command.get(key)
  7000. if not isinstance(value, str):
  7001. raise ValueError(f"{key} must be a string")
  7002. return value
  7003. @staticmethod
  7004. def _profile(value: object) -> str:
  7005. if not isinstance(value, str):
  7006. raise ValueError("profile must be a string")
  7007. profile = value.strip()
  7008. if not 1 <= len(profile) <= 100:
  7009. raise ValueError("profile must contain 1 to 100 characters")
  7010. return profile
  7011. @staticmethod
  7012. def _node_id(value: object) -> str:
  7013. if not isinstance(value, str):
  7014. raise ValueError("nodeId must be a string")
  7015. normalized = value.strip().lower()
  7016. if normalized == "local":
  7017. return normalized
  7018. try:
  7019. canonical = str(uuid.UUID(normalized))
  7020. except ValueError as error:
  7021. raise ValueError("nodeId must be local or a canonical UUID") from error
  7022. if canonical != normalized:
  7023. raise ValueError("nodeId must be local or a canonical UUID")
  7024. return canonical
  7025. @staticmethod
  7026. def _description(value: object) -> str:
  7027. if not isinstance(value, str):
  7028. raise ValueError("description must be a string")
  7029. description = value.strip()
  7030. if len(description) > 500:
  7031. raise ValueError("description must contain at most 500 characters")
  7032. return description
  7033. @staticmethod
  7034. def _now() -> float:
  7035. return time.time()
  7036. @staticmethod
  7037. def _encode_cursor(updated_at: float, room_id: str) -> str:
  7038. raw = json.dumps([updated_at, room_id], separators=(",", ":")).encode()
  7039. return base64.urlsafe_b64encode(raw).decode().rstrip("=")
  7040. @classmethod
  7041. def _decode_cursor(cls, cursor: str) -> tuple[float, str]:
  7042. if (
  7043. not isinstance(cursor, str)
  7044. or not cursor
  7045. or not re.fullmatch(r"[A-Za-z0-9_-]+", cursor)
  7046. ):
  7047. raise ValueError("cursor is malformed")
  7048. try:
  7049. raw = base64.b64decode(
  7050. cursor + "=" * (-len(cursor) % 4), altchars=b"-_", validate=True
  7051. )
  7052. decoded = json.loads(raw.decode("utf-8"))
  7053. if (
  7054. not isinstance(decoded, list)
  7055. or len(decoded) != 2
  7056. or isinstance(decoded[0], bool)
  7057. or not isinstance(decoded[0], (int, float))
  7058. or not math.isfinite(decoded[0])
  7059. ):
  7060. raise ValueError
  7061. updated_at = float(decoded[0])
  7062. room_id = cls._canonical_uuid(decoded[1], "cursor room id")
  7063. if cursor != cls._encode_cursor(updated_at, room_id):
  7064. raise ValueError
  7065. return updated_at, room_id
  7066. except (
  7067. ValueError,
  7068. TypeError,
  7069. UnicodeDecodeError,
  7070. json.JSONDecodeError,
  7071. binascii.Error,
  7072. OverflowError,
  7073. ) as error:
  7074. raise ValueError("cursor is malformed") from error
  7075. @staticmethod
  7076. def _encode_topic_cursor(
  7077. room_id: str, updated_at: float, topic_id: str
  7078. ) -> str:
  7079. raw = json.dumps(
  7080. ["topic", room_id, updated_at, topic_id], separators=(",", ":")
  7081. ).encode()
  7082. return base64.urlsafe_b64encode(raw).decode().rstrip("=")
  7083. @classmethod
  7084. def _decode_topic_cursor(
  7085. cls, cursor: str, expected_room_id: str
  7086. ) -> tuple[float, str]:
  7087. if (
  7088. not isinstance(cursor, str)
  7089. or not cursor
  7090. or not re.fullmatch(r"[A-Za-z0-9_-]+", cursor)
  7091. ):
  7092. raise ValueError("cursor is malformed")
  7093. try:
  7094. raw = base64.b64decode(
  7095. cursor + "=" * (-len(cursor) % 4), altchars=b"-_", validate=True
  7096. )
  7097. decoded = json.loads(raw.decode("utf-8"))
  7098. if (
  7099. not isinstance(decoded, list)
  7100. or len(decoded) != 4
  7101. or decoded[0] != "topic"
  7102. or isinstance(decoded[2], bool)
  7103. or not isinstance(decoded[2], (int, float))
  7104. or not math.isfinite(decoded[2])
  7105. ):
  7106. raise ValueError
  7107. room_id = cls._canonical_uuid(decoded[1], "cursor room id")
  7108. updated_at = float(decoded[2])
  7109. topic_id = cls._canonical_uuid(decoded[3], "cursor topic id")
  7110. if cursor != cls._encode_topic_cursor(room_id, updated_at, topic_id):
  7111. raise ValueError
  7112. except (
  7113. ValueError,
  7114. TypeError,
  7115. UnicodeDecodeError,
  7116. json.JSONDecodeError,
  7117. binascii.Error,
  7118. OverflowError,
  7119. ) as error:
  7120. raise ValueError("cursor is malformed") from error
  7121. if room_id != expected_room_id:
  7122. raise ValueError("cursor does not belong to room")
  7123. return updated_at, topic_id
  7124. @staticmethod
  7125. def _encode_global_topic_cursor(
  7126. pinned: bool, updated_at: float, topic_id: str
  7127. ) -> str:
  7128. raw = json.dumps(
  7129. ["global-topic", int(pinned), updated_at, topic_id],
  7130. separators=(",", ":"),
  7131. ).encode()
  7132. return base64.urlsafe_b64encode(raw).decode().rstrip("=")
  7133. @classmethod
  7134. def _decode_global_topic_cursor(
  7135. cls, cursor: str
  7136. ) -> tuple[int, float, str]:
  7137. if (
  7138. not isinstance(cursor, str)
  7139. or not cursor
  7140. or not re.fullmatch(r"[A-Za-z0-9_-]+", cursor)
  7141. ):
  7142. raise ValueError("cursor is malformed")
  7143. try:
  7144. raw = base64.b64decode(
  7145. cursor + "=" * (-len(cursor) % 4), altchars=b"-_", validate=True
  7146. )
  7147. decoded = json.loads(raw.decode("utf-8"))
  7148. if (
  7149. not isinstance(decoded, list)
  7150. or len(decoded) != 4
  7151. or decoded[0] != "global-topic"
  7152. or isinstance(decoded[1], bool)
  7153. or decoded[1] not in (0, 1)
  7154. or isinstance(decoded[2], bool)
  7155. or not isinstance(decoded[2], (int, float))
  7156. or not math.isfinite(decoded[2])
  7157. ):
  7158. raise ValueError
  7159. pinned = int(decoded[1])
  7160. updated_at = float(decoded[2])
  7161. topic_id = cls._canonical_uuid(decoded[3], "cursor topic id")
  7162. if cursor != cls._encode_global_topic_cursor(
  7163. bool(pinned), updated_at, topic_id
  7164. ):
  7165. raise ValueError
  7166. return pinned, updated_at, topic_id
  7167. except (
  7168. ValueError,
  7169. TypeError,
  7170. UnicodeDecodeError,
  7171. json.JSONDecodeError,
  7172. binascii.Error,
  7173. OverflowError,
  7174. ) as error:
  7175. raise ValueError("cursor is malformed") from error