From a4043fb6f45662a631aaf51126dbfadddb2763ce Mon Sep 17 00:00:00 2001 From: oxyplay Date: Mon, 13 Apr 2026 10:28:35 +0100 Subject: [PATCH 01/14] bot is working --- flexus_client_kit/ckit_cloudtool.py | 2 +- .../integration_tester/__init__.py | 0 .../integration_tester-1024x1536.webp | Bin 0 -> 91770 bytes .../integration_tester-256x256.webp | Bin 0 -> 9704 bytes .../integration_tester_bot.py | 283 ++++++++++++++++++ .../integration_tester_install.py | 205 +++++++++++++ .../integration_tester/setup_schema.json | 11 + 7 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 flexus_simple_bots/integration_tester/__init__.py create mode 100644 flexus_simple_bots/integration_tester/integration_tester-1024x1536.webp create mode 100644 flexus_simple_bots/integration_tester/integration_tester-256x256.webp create mode 100644 flexus_simple_bots/integration_tester/integration_tester_bot.py create mode 100644 flexus_simple_bots/integration_tester/integration_tester_install.py create mode 100644 flexus_simple_bots/integration_tester/setup_schema.json diff --git a/flexus_client_kit/ckit_cloudtool.py b/flexus_client_kit/ckit_cloudtool.py index fc04184f..b4788632 100644 --- a/flexus_client_kit/ckit_cloudtool.py +++ b/flexus_client_kit/ckit_cloudtool.py @@ -36,7 +36,7 @@ CLOUDTOOLS_QUITE_A_LOT = KANBAN_ADVANCED | CLOUDTOOLS_NOT_KANBAN | CLOUDTOOLS_MCP -CLOUDTOOLS_ALL_KNOWN = KANBAN_ALL | CLOUDTOOLS_NOT_KANBAN # unsable in a bot +CLOUDTOOLS_ALL_KNOWN = KANBAN_ALL | CLOUDTOOLS_NOT_KANBAN | CLOUDTOOLS_SCARY_ENOUGH # unusable in a bot def gql_error_4xx_to_model_reraise_5xx(e: gql.transport.exceptions.TransportQueryError, label: str) -> str: diff --git a/flexus_simple_bots/integration_tester/__init__.py b/flexus_simple_bots/integration_tester/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/flexus_simple_bots/integration_tester/integration_tester-1024x1536.webp b/flexus_simple_bots/integration_tester/integration_tester-1024x1536.webp new file mode 100644 index 0000000000000000000000000000000000000000..d7f734522973a055ece5c65ffbee176b162144c7 GIT binary patch literal 91770 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 Z0000000000000000000000000006}8E{gyF literal 0 HcmV?d00001 diff --git a/flexus_simple_bots/integration_tester/integration_tester-256x256.webp b/flexus_simple_bots/integration_tester/integration_tester-256x256.webp new file mode 100644 index 0000000000000000000000000000000000000000..3295df1d181eb16275ad66d356e357dbe9d45c60 GIT binary patch literal 9704 zcmV(_dMM6+kP&il$0000G000300093006|PpNZJhm00FRtZL?`R z)?YPNoU}V>+qP}nwr$%!=d^9xw!5RYZM(Dgtg8QFt-X`%j5*fjiHHfn|NZ~}ysBzS zNk!VWuBD;Uo^5~24Hq81_2Pp8KvUZ#&}V2QJ3hbffVp}BlC-@-!shHWbCUe~`I8oF z0IF>j1eegr2s4uQ!rn6ilE#dtc4fR*XlxF*Ab-4OQ9#m|094x%DCYT=-qdVP{(JA* z3f0luZ9_oY5hSehFQW@GEXW5|>IK#A3GU$)L!gBtjg%UVMoOwG-5)zhIFQy2k`}BU zT@;d}I|*BpA1;nY*^EsQRZWdXBdMy0bPp=$)4N5Q^Y4phglabg1KuLPUjeCL1T!3Q z(M2a8u>E=~E;i>3(@s6r6jMw#c#;VR4d|;FebJPXinI_k?x%H+H0RITC#ZCRgu~g% zUlxa!i{c6zTXz2T-It$#_UXqTefa)6BZt27%wrGUbKCWo|M&ExciU)%g=QYVx1!6` zP!TAGjHHbQ!^r!mLXs{N^ZiBRXEQ>JC1GEVs;bI^V>Rs0FW-Lo{!5P9Wz7X96*Nog zh55dwj|LgW&o_Z+D*>LS8y`>ww z4E{Mcw2lOgM|c*Jj3q$Pf2WU`7dn4m6-hCB2ZMBOiBv}kE6}(%G>s+EUI~477|F;9 zo!`ub1}6Khsp2VhbsDSSNeyVyF(j%<0Lb8V&iODiG>(`UnDDD6`{|D+T51t6&w`n^LZX+eJT&`~Q+Kd3ia zOYcF`EWYg-_q_8fNrqW4$IdUNl`e)ikOVsS5%-P!nKRqQYZ@Q4dIpLy>!~ z^WM4Un+(!;6EOdi44S#Sn~}U?B53B2a5Ae_%@N_&+T9~qgU&}kX{vZE_WX@z!7js` zZ!L_Jz111rsqtW}emdRR{V@Kt8Z>W$A)hd6E?WKY1U=ZzXr}S!#h_V(;wn}(*G&($ z46mwSbJ!?;Fa(mhMLQW~5Nzqr=IY;nI-I2OXr!z`aXGCFy7||u*BrFg0&~nh*Ag2Y zas9JDTnfpXV7kxg*Z+$U_rh+-h`I`z0cHdu6s0Me^>Ls5r{<5)#G zwCwoy;R6@y4`>pR{6TRh&5Oa!JV!-{LsG-bV-3q{ve7C zzG9e{Lvy$Vt6S!RYW^SzQ=j_>qbx{<1^+mCd?d*ppn{nXe2GyQB+dE8Qc%fX5@3y2 zN`ztjXLqP%P8I!jc#Wm%!E+KMXQF}?Uu3u!3d4A51ClRE7<@%1%~-Bc@qB2uRI$m& zbZ6NbPl9Gj(eGS_1q(OA*Mep#VaDg_&T@@tLFX4UKr@uEH6E9~TfXkoLR2nM}hR%GWw(3~V}O=q{mOUHQtQceW-(z@f$;x*m} z$%ta+-x*!GlcOH`kx?|k?*>Eik#HD|t=)s4pE2up43=$hO{8oH@JJWo!7oml6n%!# z3#Z}e4C|MZ{b^zRbJt!-3MiIh(T@6oLFc;@=Hx_n8edryssNC1JgsCpFZ_*k zelST+PGaR34nPvLzQ1UU=bui`%?|u&Gl-(4V3LpM1><~SqM@w#?nL|FjzFPB<>(jMnXO%DZ1If9<-97b9>D6#>3-7GBTP8 zC$TD;if2Fv^+&!F{#!W48<8NAv-6ARQ9uOkoAb3^5M=GY#q<8ayftu4;RHXMAa~o- zn<9$k)A93`uoBDG`ToGXDQ5hc!QdC)FbZXO@4N~6y+)_={AcKe@+9&G#l^IsFL@iS zNQ^f_k@bYt7{;e@exX!64JmH|JkJhph=U5nxjA~}4GCM2Pc*O*qez0gA!*(Oy{>p~ z5nzFT7$rj5B@;rk20$AS40@MdCeD|qg62&G0>#y=$|Q_$PY2B!M1X|#8D&CNe5^kt zYt4cQ-=`M}EB*(XJyl#rE0y4=#h{rJ%y<5Gj8dWTen7IO*zR8ji*`AroC&7-idE4V z{{zjLV&M*!t#d>Jk}<(#UoeU$*f}>eYv~z!(a^awQpTXTgjO~cPtRMzmNbi!FT!Vkl0L@$j+tAC!IlKYMT*6_raxq>BMdlS} zl#Fw0r2PH2V1hqQ1Igd{1!KGhigqrz3Q`T6RW8n9{UO;)IJ8`hdm$nF4Qxv<6z9;s zko+Yq$5M4}frR`ErulTbpa^qP;ou2M2v?cp%xSNq8Etq0YyzJ zmg-;>hw;7Xpfw?w@I88I7{8et33aKsf>mJ{e_jd+wVALgqbS1o)iOw^PeI?e=>_4= z5wjqnP8I*56@wdj*+fv(%p%4p0MuOV`1pYSP(}SL@HeCO$oT5Y z4JU>q)Ue`zwA$-DdD)2pBtabu27f@WH{<;fXcE+P!n!pV{BuF{64bcjR9c-m4~L@G z1-(jAqjvqq*fq``7ZmFQ9onU zzkcR_n@tNqQ=LFmFvquy8VZlD-Q6l@^Y3@6CI~P=I1BxqY1wqoBe;%8VY9nEVU}1&@ADaWI zE(E<_qL&33#-G=N)&vr^E)UY!xi7RP1bv6mi-Ybw45=QFun(;~7|#8X>Os)&1zLG9 zB6dWo1thH0!6**~e_IGz3s9U*D-arA9RR5X!GuHU1w!M|NVNdPf)anCfjBi!}JlEYR!;`aVr#sl+Ns*;AZIW2ri~&7WfV zDxzTa$euu+qOpxjSd+n$*)Deid74%(L0>s{3ClBBGQZ9O&E0Xdf^j~hkh}>VC>rDb zNXWWiz$c7y34S{jByWmYMwN{5d?<3aM3jtkb2KuSury01_~DGuyea0dN`}stCxK*5 zFy-$>L*s?e%=LYbUNUs9i`+K^xe1qHO zM^rG~8DBFzSVZe)M-_wi`IceM5)S^e2sA5_1RK1`FlQMDzZ?R|i6l&agVCL(Y`hJM zjEZ9IZ)whQHtvI7DIbbnr+3nW1s(iyH-Snf5(eK%3l?_p(Z!*Xg@l<#unHEJv-9u< zGy@59eL`c2oAbWGNaI|>Tp!U`=3yKJOHo~2~FFpy%2B9;GiV3LD%FiLmX^D)-5(d6DYF-)$m zlE9;@^-m+0EJ%!Y4hqp3IFlpTL-(^d#Ev`{WLV($^kljQ!>~yDmR(9}APwWL+RXm? z!JAbb)lw3pE_lVbc(kQIPBxWGJL$nZOk*=jA4|?E@xo+8{qznaWDoZM3b3*U`Gsu{ zdLO8fpfWTXUa+e1(HLN@!?Jr!j72;ILADFUUHbbqdk2W1k60H29>1sLvR1OaJB+CDr%VaLelC5DJ@S5Fby1u z5gerRKF)=TPNJ-aHr8+mfby7OveM);t^@NrA?Vlz>OC$DZyz(&xpY%^YJZJ5ev&#IMtN1Y32QmT+(6Fcd$+t`WO>3jTZI4OxC1~M%;V`hzHMCM+A z2Gt9GZi%z)An9}WhuWyhh@Pp`>s**SOT*66SZGpnQ>J%d^7h9M38aHAD%5N4~^jtmLqYTW_T-F#`5 zcXP1F@}>x~0@L^ar!oBb#NZO~1nU|c-A*oZ_{^Qo{K`vufBiX2`-{9;9Ya(BXRWa~ zwnOQBt}IQcDpXwOD#%EX$G|`VgdUP&e3cvvV z`~6S=000054%I&Qp6iM8i%FA|O3WjqmD8z-55qio6cbuIWVfQPXu%3a~DV~FN} z05#)_0dB@V_K&dy%V`fvT@}peA55C~Ha#=9kuoiR`F8NM)9x+jF40a;MCmq#;4)PZr|Lzh%s2cxm&a67fPiGbXfj80Fp(TG@Kxck?s z^GFZZJOU@oIDt>fX69`LP;1(jH^9*7)xv}yjbi)EYIP>zf6h}a*4Ok?<0(ncZNNHE zOXYK$mZ-7V8Ih<|fIf+*=Z1yc8~ftF0J#wpTnuVzqWtDN1}?9p$TAt1*8)1TF#?K2 z5{Uu0i_i3X>D9Rn46?xK1B6Unb0tym75pgq52K?C?OU#b41DCM1z3I4LVmCr*8nOG zRF!Bo#43B*E+Mv+p_2ZN(Ptwn@}6B68RjYyiDb{zNF75`V+>Kq(-3xXu`xI>vKFNe zq{})EmgR98Vn)?|h_Sa<$8mq)Y~HoGC5-7emLunWcTj3?0vLu1jE-(=NomXB0=8DG zJ4)vv;+yOvF6zigj}U2Ej7o92dVP0K9VB3nXX-2jYXX@~w0(}|1BfY7K_{|cG0XZr z%2Iows7V1gs){KmQc|y~;R!ki8`rSN=}0~nd6F%64L^Y!bYR2Z9<;{Vq>Q_>&1lAX z6`D?#JHFp~h*x^P+u+DobK@;f?2>M+_~WH(M?ONK^u?sru@c|`#5!prI-la3`=$s0 z#50HIxvBiAUeNa1mrr9{zpSX4cjPXtP*Z#}57;$@94Q4ffyxFUmQ5FhVUsoD@av71 z;gGcx6)|U85noY(XXz~VL`Dnt_C*rT0FVUei5@edEz!LFKU-xg#AEj($Vuy$|2Y8z z5VIOXjC3+yd2XL3$<^yrPyYEXMEr9&;BALlr#(si+9UE8=Uv!gS&+X{bu^b(9@JI& zt7M|v^ScE)3iYJAHlRq6eav#QHZ$xL%POFQ4C{j*)($x{puLO?Mr|yw9N;G}-eTvU z+Lf3~-a&SE>Z{Y{b3^x&>pTDPHd~CfRA4#?@P`&gQ?m1hSEw!)sV{^)&WpEqTGdhr zoSH3lF8WAVJ-2!j0yYyB-K)zhs0n2y5J_n@<0h~Ai86P-;qm(xzq$s(Ly!L#Yh!iE zvfdluHl$Vvm@^yj)a&6sE`bS#o#YA%;kR&-G@qO;Hn~6bTMRuBo9s(AU3<(f_pkD1 zV>GzWL&_aR&MS8fg-MRySMl;^Y(`v26Qt)#K;1(vMY%l5v47+=S*ptH=Sk$_-{HOR zErreKDC$4J?!Va@%_#;k!cMs(uUi@c(gRhz)tVh0O}j;Yizm52V4B#J zAJ>u36J^0tv=Dt=NFIi}A*}uQt~0?po12VNPOeG%kHRrf$A&v6*uIjP0MDjm9q6Nv z6H@!N+!7l3hTg|>>aOB}U|%+=(yu=Z`M@fM79KzuCm3%TI@!a5tUU9gE4<=ZV5Fob zU2HX-ngUV}-cW#eoX)C45MEAd?sr$mAJdRji=7Z-yGJfNIeB3*HgcObcnS%N3#P#0i1ev zd&CmN`~fS-jJ?CV2U&U$Sdfx=6_ty2{?WL97})h@`eXH={RS#}l3ry;%GruIb#0x2 z3l0A5aAZ5K79!F$a<$T;59#%4HK7!CNO3^4DlLZy%0tY5@~C3mLb5F+TjOA1Tv~mT z`oDZ3`T}y}I_i%@i8AZ5X*D_v(|pDhSniLLM9eh>`r6%0u|HLL!@fuk{3R3IUH*>a zidebB6YJ7(Ow(4=c#O4Iiz)dH9rJ@LB)zWk2D~eXYxk!VTV$h#XH#r&4i!%uHBS?{X5uD&W`Pm%sp(2J`ki+C{-mzL`G-zC z^({{5^Zbl=JqY?}=C9NQ*nVw2%4oP$%&-1=u+w7TdrO#&yDeaKoDIO#L}WMT%Iev7 z+)7I&4IZ?tyktGdfwD9DVUywMnKNOXUP2yuTeq~8#3=xQ^75*^cfxSxI*46Td@-0` z5?qVvbCNiBjy*B7rBMss0$Hl_|DI! zd(a*`TmH^ycWP0JUmOFJFybTLz7(WmUWuJZzQgfNAh=2{96obPWS=FH6x(pw&?8@# z=X#Hi2Ac%^qCF~?X&=}O-mY20cnW>>d4#dXqA`@>qA2eSU%u2czIAz3k&1z{qA;xP zG%+dIJY>&mJUyrb{bE`UklGnDe11P(q3GGdgP+bicFB8sy~=|^1N8-iF{GwNV=lj} z*2h(MLS$b3Z}d`Uqq%5j0PtUzJ*~vONfnORruaCqMUvJxASy@A-g8Sk3r8CN7vDEa zQF%c-)5?)2M3Cr3I{V?HfZZkiGg5dR9dnd-}}dNU6@d8 z!2QWKf1b1X|MLIFb$ldM;g<%F0MFwzeF`?JA%aj46>Vxug;r}2afUwvd(9Tz?P*de zA)pSHKtu24unGO7@rGhcHcwI5qQVj7vW5&?SrS*Iv9b3X>4T^ky(vHKS&BYvRb^(80h?t>C{LCL z8s44~OJH3arOq=bblbNZmkS|GtugJoihl@AnOF`?QpV}~a_ufiK@=lTnmp@hC>})F zs(v%4+;kg$NlO4Jw?pomRAIUtDrliUB&$>G2j8od6i3^vK|FCu^7ZABDZl8_yel7M z>c8J|&O0l|@~bmnD?m|jMv$^zo?aY>>N)r@h)jgy40mZczF~!!OI4>*h)zHX{z%(hMVk^I2xX~Y7YSY)R==Y2RuQ%hyuM+1(7~+Lk0jql2OFB#^SQyU`OYlf>EmPs_sIY_EC6XaFv?* z1@lTo9{9&J_ks3hW5U{6S=&{m*EG1;p;89^v|8aV$PwCfn7wAml;w2kQ(YWrJc6m_ z;O_ls0|b14jII%{+Fj-~$=+?yjFgDvx$J`dS!Z}) zLb2f{n!7JyNJYBxr9?#VJkmfOL9n8XMBn$`!ixx-_e+sHVt*Ue(ki*bh7lN>a<|Zj z>gzgfL$O=o!_f4rB#CYUH7vjDaQ+CeOrWfnDAgC=^cbl9_sFS;+t1Aw0G*h@y@XIg z4QK82|Giyhtl5Ik9Y}}%h;CuAYFDC z0ZRazh9ZBK`n+S5b_cAGga)dz^VaoXX?K9-nt%q8;ofGMgkFK9!}ynHVPq^FvP6`w3le_nj7TF{iDT%UR{Vn@LgB=Lz!UOQ@ z(+$38CJxCe=5Q-@nU2`_KCOMg>BwIc{fpJw`*t;mIi+0HEylZ)7h_q2ZlYx&Jmq2o zbv%mq;suwXgm%q`ko0y0x;P%v{jHV7<7V^Z^bH)S0*^DZQ7AL*u z+Qbu-AASh3bcxlGvL!0(&K)@zzpaibo4Y2Ryd+p}rsX~<^zi4#1tGYNf(ou2%bLvi zzH1g^8TxW= zBFbpj;k444suFNm6_7X9Gjq;&{ZxnTn-+MW+j}psU>B)sTpjXse(D8Za?CFS&hMFM zp_S|w#g`%O4-|;#D-Lt-*OokJfEO(-iBBy4RCZ1jmR%JGVK`W0uPMre-Hyp`-pueb z3I5C)f)89pv&x$J`pY1{W~#J?)E568igk#<5EC1p2yThOI(N{UI9SumjqeeFaZ!u? zLw|~t$Pg@ItW-lkeBu53Em!EjF9G9w-@k0Q7Gm`FS6@L{`|pMg;a)^~X%^#Di!R*LeL za8XZU&oU~ZU1R1fq^kQ0GVIRa=_e(iJqFiuUeF{9Nz?18!f(5Zb3V)2Ru0I>Rs(ap z9>5B~&OqTII^$-bNWi_Gzh6Urk}>rET54Au2=fFF8k(W-;OT8T{#lU~uC3A*PJXUo z?)5eka4S~kI3Z+nLhp0s$b!-Dw}9~v8DTc_lQ=Ju?iyB1YaF|0lyu@7Ph49&p;M2D z=m1!*9AfnGZ}Wf7j)QcEbG%YJ7K#CUg*f4nl&H>ND&Q~V$i$|XhEYEF|5kqehdDOq z0!UUp*cZ$&M@9p0_Qu41kwcUqKW{r1FE3a~F)7q9wo6pMCaP=GbK<@oFC+BQ#+e78 z+d))NmsOF%PW7c{7T9g|qJ&^$pS^m|pUeDK&%36m7(iTB02=K`gpui+fHCO9A3BpwHmGCw*Xr_1)$8* zmcTR5i<%I4eeD%?We2qnH?Q8NcO7rCB-DPW?f+i+YwvCORdT{&C}vh5P(toxZ27i? z%C1Zc6MvrL#hZT+brII`JSc6Sanngh3agbtISCP}J7je_$OpatqtDof71Zq1q_=D* zLLcoQ9>kRmr2-sdyNv6>lb4CvpeL+Z_()fFmXOR#mi?NLo0cUyjjbX~KQQj!-@qnY zk&vkMN{L{F9w+EU_v}udOv*CmKEIFF_{dz|lSWI~mD94!T#Qs6(%8)zryLJv8t8Dt zn9@donw-AFoU`#``3%)+LKoz-vY|DamSC=0S3g)Q>hK|v@l=%~3vsDL0?ncxuHquX zg06Wnm~uA9^sopeL^zWO6b!TTEqrhphVU*o=fTBlkJ*9&M qqhW@E^R_MA0>S-)@|u`C List[List[str]]: + if n < 1: + n = 1 + return [xs[i:i + n] for i in range(0, len(xs), n)] + + +def _requested_names(raw: str) -> List[str]: + s = (raw or "").strip().lower() + if not s or s == "all": + return ["all"] + names = [] + for x in s.replace(";", ",").split(","): + x = x.strip() + if x: + names.append(x) + return names or ["all"] + + +def load_env_config(setup: Dict[str, Any]) -> None: + env_config = setup.get("ENV_CONFIG", "") + if not env_config: + logger.info("No ENV_CONFIG found in persona_setup") + return + count = 0 + for line in env_config.strip().split('\n'): + if '=' in line and not line.startswith('#'): + key, value = line.split('=', 1) + os.environ[key.strip()] = value.strip() + count += 1 + logger.info(f"Loaded {count} environment variables from ENV_CONFIG") + + +def get_configured_integrations() -> List[Dict[str, Any]]: + result = [] + + newsapi_key = os.environ.get("NEWSAPI_API_KEY") or os.environ.get("NEWSAPI_KEY") + if newsapi_key: + result.append({ + "name": "newsapi", + "env_var": "NEWSAPI_API_KEY", + "key_hint": newsapi_key[-4:] if len(newsapi_key) > 4 else "***", + }) + + resend_key = os.environ.get("RESEND_API_KEY") + if resend_key: + result.append({ + "name": "resend", + "env_var": "RESEND_API_KEY", + "key_hint": resend_key[-4:] if len(resend_key) > 4 else "***", + }) + + return result + + +async def integration_tester_main_loop( + fclient: ckit_client.FlexusClient, + rcx: ckit_bot_exec.RobotContext, +) -> None: + setup = ckit_bot_exec.official_setup_mixing_procedure(SETUP_SCHEMA, rcx.persona.persona_setup) + load_env_config(setup) + + newsapi = fi_newsapi.IntegrationNewsapi(rcx) + domains = setup.get("DOMAINS", {}) if isinstance(setup, dict) else {} + resend = fi_resend.IntegrationResend(fclient, rcx, domains) + + @rcx.on_tool_call(NEWSAPI_TOOL.name) + async def toolcall_newsapi(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + newsapi_key = os.environ.get("NEWSAPI_API_KEY") or os.environ.get("NEWSAPI_KEY") + logger.info(f"Testing newsapi - API key present: {bool(newsapi_key)}") + if not newsapi_key: + logger.warning("newsapi test FAILED - no API key configured") + return "Error: NEWSAPI_API_KEY not configured. Resolve the kanban task as FAILED with status: 'FAILED - No API key configured for newsapi'" + try: + result = await newsapi.called_by_model(toolcall, model_produced_args) + logger.info(f"newsapi test result: {result[:100]}..." if len(result) > 100 else f"newsapi test result: {result}") + return result + except Exception as e: + logger.error("toolcall_newsapi: %s" % str(e), exc_info=True) + return "Error: %s" % str(e) + + @rcx.on_tool_call(PLAN_BATCHES_TOOL.name) + async def toolcall_plan_batches(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + args = model_produced_args or {} + req = _requested_names(str(args.get("requested", "all"))) + bs = args.get("batch_size", 5) + configured_only = bool(args.get("configured_only", True)) + try: + bs = int(bs) + except (TypeError, ValueError): + bs = 5 + + configured = {x["name"] for x in get_configured_integrations()} + selected: List[str] = [] + unsupported: List[str] = [] + + if "all" in req: + pool = [x for x in SUPPORTED_INTEGRATIONS if (x in configured or not configured_only)] + selected = pool + else: + for x in req: + if x not in SUPPORTED_INTEGRATIONS: + unsupported.append(x) + continue + if configured_only and x not in configured: + continue + if x not in selected: + selected.append(x) + + batches = _chunk_names(selected, bs) + task_specs = [] + total = len(batches) + for i, b in enumerate(batches, start=1): + task_specs.append({ + "title": f"Test integrations batch {i}/{total}", + "description": f"Integrations: {','.join(b)}", + "integrations": b, + }) + + return json.dumps({ + "ok": True, + "requested": req, + "supported": SUPPORTED_INTEGRATIONS, + "configured": sorted(configured), + "configured_only": configured_only, + "selected": selected, + "unsupported": unsupported, + "batch_size": bs, + "batches": batches, + "task_specs": task_specs, + }, indent=2) + + @rcx.on_tool_call(fi_resend.RESEND_SETUP_TOOL.name) + async def toolcall_email_setup_domain(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + resend_key = os.environ.get("RESEND_API_KEY") + logger.info(f"Testing resend - API key present: {bool(resend_key)}") + if not resend_key: + logger.warning("resend test FAILED - no API key configured") + return "Error: RESEND_API_KEY not configured. Resolve the kanban task as FAILED with status: 'FAILED - No API key configured for resend'" + try: + result = await resend.setup_called_by_model(toolcall, model_produced_args) + logger.info(f"resend test result: {result[:100]}..." if len(result) > 100 else f"resend test result: {result}") + return result + except Exception as e: + logger.error("toolcall_email_setup_domain: %s" % str(e), exc_info=True) + return "Error: %s" % str(e) + + configured = get_configured_integrations() + logger.info(f"Integration Tester started. Configured integrations: {[i['name'] for i in configured]}") + + @rcx.on_updated_task + async def on_task_update(action: str, old_task, new_task): + task = new_task or old_task + if not task: + logger.info(f"TASK UPDATE: {action} with no task payload") + return + col = task.calc_bucket() + title = task.ktask_title + tid = task.ktask_id + if col == "inprogress": + logger.info(f"TASK ASSIGNED: {title} (id={tid}) - will test now") + elif col == "done": + logger.info(f"TASK COMPLETED: {title} (id={tid})") + else: + logger.info(f"TASK UPDATE: {title} moved to {col} (id={tid})") + + while not ckit_shutdown.shutdown_event.is_set(): + await rcx.unpark_collected_events(sleep_if_no_work=10.0) + + logger.info(f"{rcx.persona.persona_id} exit") + + +def main(): + scenario_fn = ckit_bot_exec.parse_bot_args() + fclient = ckit_client.FlexusClient( + ckit_client.bot_service_name(BOT_NAME, BOT_VERSION), + endpoint="/v1/jailed-bot", + ) + + from dotenv import load_dotenv + load_dotenv() + + from flexus_simple_bots.integration_tester import integration_tester_install + + async def _install_compat(client: ckit_client.FlexusClient) -> int: + await integration_tester_install.install( + client, + bot_name=BOT_NAME, + bot_version=BOT_VERSION, + tools=TOOLS, + ) + return 0 + + asyncio.run(ckit_bot_exec.run_bots_in_this_group( + fclient, + bot_main_loop=integration_tester_main_loop, + inprocess_tools=TOOLS, + scenario_fn=scenario_fn, + install_func=_install_compat, + )) + + +if __name__ == "__main__": + main() diff --git a/flexus_simple_bots/integration_tester/integration_tester_install.py b/flexus_simple_bots/integration_tester/integration_tester_install.py new file mode 100644 index 00000000..15e87252 --- /dev/null +++ b/flexus_simple_bots/integration_tester/integration_tester_install.py @@ -0,0 +1,205 @@ +import asyncio +import json +import logging +import os +from pathlib import Path +from typing import Any, Dict, List + +from flexus_client_kit import ckit_client, ckit_bot_install, ckit_cloudtool, ckit_skills +from flexus_simple_bots import prompts_common +from flexus_client_kit.integrations import fi_newsapi, fi_resend + +logger = logging.getLogger("integration_tester") + +INTEGRATION_TESTER_ROOTDIR = Path(__file__).parent +INTEGRATION_TESTER_SKILLS = ckit_skills.static_skills_find(INTEGRATION_TESTER_ROOTDIR, shared_skills_allowlist="", integration_skills_allowlist="") + +API_KEY_INTEGRATIONS = { + "newsapi": { + "env_var": "NEWSAPI_API_KEY", + "tool_class": fi_newsapi.IntegrationNewsapi, + "test_op": "sources", + }, + "resend": { + "env_var": "RESEND_API_KEY", + "tool_name": "email_setup_domain", + "test_op": "list", + }, +} + + +def get_available_integrations() -> List[Dict[str, Any]]: + result = [] + for name, config in API_KEY_INTEGRATIONS.items(): + key = os.environ.get(config["env_var"]) + if key: + result.append({ + "name": name, + "env_var": config["env_var"], + "has_key": True, + "key_hint": key[-4:] if len(key) > 4 else "***", + }) + else: + result.append({ + "name": name, + "env_var": config["env_var"], + "has_key": False, + "key_hint": None, + }) + return result + + +def _build_experts(tools): + builtin_skills = ckit_skills.read_name_description(INTEGRATION_TESTER_ROOTDIR, INTEGRATION_TESTER_SKILLS) + tool_names = {t.name for t in tools} + allow_tools = ",".join(tool_names | ckit_cloudtool.KANBAN_ADVANCED | {"flexus_hand_over_task"}) + + available = get_available_integrations() + available_list = "\n".join([ + f"- {item['name']} ({item['env_var']}: ***{item['key_hint']})" if item['has_key'] + else f"- {item['name']} ({item['env_var']}: NOT SET)" + for item in available + ]) + + default_prompt = f"""You are Integration Tester. Test API key-based integrations via kanban fan-out. + +== AVAILABLE INTEGRATIONS == +{available_list} + +== RULES == +- Parse user request into integration list: "all", "newsapi", "resend". +- First call integration_plan_batches(requested="...", batch_size=5, configured_only=true). +- Use returned task_specs and create tasks with flexus_hand_over_task(to_bot="Integration Tester", title=..., description=..., fexp_name="autonomous"). +- Do not execute test tools in this interactive chat after fan-out. +- After creating tasks, reply with short queue summary and stop. +- If no supported integrations were requested, explain supported options and stop. +""" + + autonomous_prompt = f"""You are Integration Tester autonomous worker. + +== AVAILABLE INTEGRATIONS == +{available_list} + +== TASK MODE == +- You are running with one assigned kanban task. +- Read assigned task title + description. +- Parse integration names from description line: "Integrations: name1,name2,...". +- If parsing fails, resolve task as FAILED with reason "Batch parse error". + +== EXECUTION == +- For each integration in the batch, run the mapped test: + - newsapi -> newsapi(op="call", args={{"method_id":"newsapi.sources.v1"}}) + - resend -> email_setup_domain(op="list", args={{}}) +- Process integrations one by one. +- Build per-integration result lines: PASSED/FAILED + short detail. +- After all tests, call flexus_kanban_advanced(op="resolve", args={{"task_id":"", "resolution_code":"PASSED"|"FAILED", "resolution_summary":"..."}}). +- Use PASSED only if all integrations in batch passed. +- Do not wait for user input. +""" + + return [ + ("default", ckit_bot_install.FMarketplaceExpertInput( + fexp_system_prompt=default_prompt, + fexp_python_kernel="", + fexp_allow_tools=allow_tools, + fexp_nature="NATURE_INTERACTIVE", + fexp_builtin_skills=builtin_skills, + fexp_description="Test API key integrations", + )), + ("autonomous", ckit_bot_install.FMarketplaceExpertInput( + fexp_system_prompt=autonomous_prompt, + fexp_python_kernel="", + fexp_allow_tools=allow_tools, + fexp_nature="NATURE_AUTONOMOUS", + fexp_inactivity_timeout=600, + fexp_builtin_skills=builtin_skills, + fexp_description="Autonomous integration testing", + )), + ] + + +INTEGRATION_TESTER_DESC = """ +**Job description** + +Integration Tester validates that Flexus API key-based integrations are properly configured and functional. +It tests newsapi and resend integrations using API keys from ENV_CONFIG. + +**How it works:** +1. User starts a test session via "Test Integrations" button +2. Bot checks which API keys are configured +3. User selects what to test (all or specific) +4. Bot creates deterministic kanban batch tasks in inbox +5. Autonomous worker resolves assigned batch tasks + +**What it tests:** +- newsapi: Calls sources endpoint to verify API key works +- resend: Lists domains to verify API key works + +**Results:** +- PASSED: Integration responds correctly +- FAILED: API key invalid or integration unreachable +""" + + +def _ensure_marketplace_images() -> None: + pic_big_path = INTEGRATION_TESTER_ROOTDIR / "integration_tester-1024x1536.webp" + pic_small_path = INTEGRATION_TESTER_ROOTDIR / "integration_tester-256x256.webp" + fallback_big_path = INTEGRATION_TESTER_ROOTDIR.parent / "bob" / "bob-1024x1536.webp" + fallback_small_path = INTEGRATION_TESTER_ROOTDIR.parent / "bob" / "bob-256x256.webp" + + if not pic_big_path.exists() and fallback_big_path.exists(): + pic_big_path.write_bytes(fallback_big_path.read_bytes()) + if not pic_small_path.exists() and fallback_small_path.exists(): + pic_small_path.write_bytes(fallback_small_path.read_bytes()) + + +async def install( + client: ckit_client.FlexusClient, + bot_name: str, + bot_version: str, + tools: List[ckit_cloudtool.CloudTool], +): + setup_schema_path = INTEGRATION_TESTER_ROOTDIR / "setup_schema.json" + integration_tester_setup_default = json.loads(setup_schema_path.read_text()) + + _ensure_marketplace_images() + + experts = _build_experts(tools) + + await ckit_bot_install.marketplace_upsert_dev_bot( + client, + ws_id=client.ws_id, + bot_dir=INTEGRATION_TESTER_ROOTDIR, + marketable_title1="Integration Tester", + marketable_title2="Test API key integrations", + marketable_author="Flexus", + marketable_accent_color="#4CAF50", + marketable_occupation="QA Engineer", + marketable_description=INTEGRATION_TESTER_DESC, + marketable_typical_group="Development", + marketable_schedule=[ + prompts_common.SCHED_TASK_SORT_10M | {"sched_when": "EVERY:1m", "sched_fexp_name": "default"}, + prompts_common.SCHED_TODO_5M | {"sched_when": "EVERY:1m", "sched_fexp_name": "autonomous"}, + ], + marketable_setup_default=integration_tester_setup_default, + marketable_featured_actions=[ + {"feat_question": "Test all integrations", "feat_expert": "default"}, + {"feat_question": "Test newsapi", "feat_expert": "default"}, + {"feat_question": "Test resend", "feat_expert": "default"}, + ], + marketable_intro_message="Hi! I'm Integration Tester. I create deterministic kanban batch tasks and resolve them autonomously.", + marketable_preferred_model_expensive="gpt-5.4-mini", + marketable_preferred_model_cheap="gpt-5.4-mini", + marketable_experts=[(name, exp.filter_tools(tools)) for name, exp in experts], + marketable_tags=["testing", "integrations", "qa"], + marketable_forms=ckit_bot_install.load_form_bundles(__file__), + ) + + +if __name__ == "__main__": + from dotenv import load_dotenv + load_dotenv() + + from flexus_simple_bots.integration_tester import integration_tester_bot + client = ckit_client.FlexusClient(f"{integration_tester_bot.BOT_NAME}_install") + asyncio.run(install(client, bot_name=integration_tester_bot.BOT_NAME, bot_version=integration_tester_bot.BOT_VERSION, tools=integration_tester_bot.TOOLS)) diff --git a/flexus_simple_bots/integration_tester/setup_schema.json b/flexus_simple_bots/integration_tester/setup_schema.json new file mode 100644 index 00000000..97e8bcda --- /dev/null +++ b/flexus_simple_bots/integration_tester/setup_schema.json @@ -0,0 +1,11 @@ +[ + { + "bs_name": "ENV_CONFIG", + "bs_type": "string_multiline", + "bs_default": "", + "bs_group": "integrations", + "bs_importance": 1, + "bs_placeholder": "SLACK_BOT_TOKEN=xoxb-...\nGITHUB_TOKEN=ghp_...\nJIRA_INSTANCE_URL=https://...\n...", + "bs_description": "Paste your .env file content here with all integration keys. Each line should be KEY=VALUE format." + } +] From d268f639a7a5dd22b384dca3078240fa57a2b78d Mon Sep 17 00:00:00 2001 From: oxyplay Date: Mon, 13 Apr 2026 10:53:05 +0100 Subject: [PATCH 02/14] dynamic integrations list --- .../integration_tester_bot.py | 35 ++++++++++++++++--- .../integration_tester_install.py | 14 ++++++-- .../integration_tester/setup_schema.json | 9 +++++ 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/flexus_simple_bots/integration_tester/integration_tester_bot.py b/flexus_simple_bots/integration_tester/integration_tester_bot.py index e57e7e08..fcaaad37 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_bot.py +++ b/flexus_simple_bots/integration_tester/integration_tester_bot.py @@ -7,6 +7,7 @@ from flexus_client_kit import ckit_bot_exec, ckit_client, ckit_shutdown, ckit_cloudtool from flexus_client_kit import ckit_bot_version +from flexus_client_kit import ckit_integrations_db from flexus_client_kit.integrations import fi_newsapi, fi_resend logger = logging.getLogger("integration_tester") @@ -14,7 +15,12 @@ BOT_NAME = ckit_bot_version.bot_name_from_file(__file__) BOT_VERSION = (Path(__file__).parents[1] / "VERSION").read_text().strip() SETUP_SCHEMA = json.loads((Path(__file__).parent / "setup_schema.json").read_text()) -SUPPORTED_INTEGRATIONS = ["newsapi", "resend"] + +INTEGRATION_TESTER_INTEGRATIONS: list[ckit_integrations_db.IntegrationRecord] = ckit_integrations_db.static_integrations_load( + Path(__file__).parent, + allowlist=["newsapi", "resend"], + builtin_skills=[], +) PLAN_BATCHES_TOOL = ckit_cloudtool.CloudTool( strict=True, @@ -96,6 +102,18 @@ def _requested_names(raw: str) -> List[str]: return names or ["all"] +def _setup_allowlist_names(setup: Dict[str, Any]) -> List[str]: + raw = str(setup.get("INTEGRATION_TESTER_ALLOWLIST", "") or "").strip().lower() + if not raw: + return [] + names: List[str] = [] + for x in raw.replace(";", ",").split(","): + x = x.strip() + if x: + names.append(x) + return names + + def load_env_config(setup: Dict[str, Any]) -> None: env_config = setup.get("ENV_CONFIG", "") if not env_config: @@ -139,6 +157,15 @@ async def integration_tester_main_loop( setup = ckit_bot_exec.official_setup_mixing_procedure(SETUP_SCHEMA, rcx.persona.persona_setup) load_env_config(setup) + integr_records = INTEGRATION_TESTER_INTEGRATIONS + setup_allow = _setup_allowlist_names(setup) + if setup_allow: + allow = set(setup_allow) + integr_records = [r for r in integr_records if r.integr_name in allow] + + await ckit_integrations_db.main_loop_integrations_init(integr_records, rcx, setup) + supported_integrations = sorted({r.integr_name for r in integr_records}) + newsapi = fi_newsapi.IntegrationNewsapi(rcx) domains = setup.get("DOMAINS", {}) if isinstance(setup, dict) else {} resend = fi_resend.IntegrationResend(fclient, rcx, domains) @@ -174,11 +201,11 @@ async def toolcall_plan_batches(toolcall: ckit_cloudtool.FCloudtoolCall, model_p unsupported: List[str] = [] if "all" in req: - pool = [x for x in SUPPORTED_INTEGRATIONS if (x in configured or not configured_only)] + pool = [x for x in supported_integrations if (x in configured or not configured_only)] selected = pool else: for x in req: - if x not in SUPPORTED_INTEGRATIONS: + if x not in supported_integrations: unsupported.append(x) continue if configured_only and x not in configured: @@ -199,7 +226,7 @@ async def toolcall_plan_batches(toolcall: ckit_cloudtool.FCloudtoolCall, model_p return json.dumps({ "ok": True, "requested": req, - "supported": SUPPORTED_INTEGRATIONS, + "supported": supported_integrations, "configured": sorted(configured), "configured_only": configured_only, "selected": selected, diff --git a/flexus_simple_bots/integration_tester/integration_tester_install.py b/flexus_simple_bots/integration_tester/integration_tester_install.py index 15e87252..f4305abf 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_install.py +++ b/flexus_simple_bots/integration_tester/integration_tester_install.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any, Dict, List -from flexus_client_kit import ckit_client, ckit_bot_install, ckit_cloudtool, ckit_skills +from flexus_client_kit import ckit_client, ckit_bot_install, ckit_cloudtool, ckit_skills, ckit_integrations_db from flexus_simple_bots import prompts_common from flexus_client_kit.integrations import fi_newsapi, fi_resend @@ -27,10 +27,20 @@ }, } +INTEGRATION_TESTER_INTEGRATIONS: list[ckit_integrations_db.IntegrationRecord] = ckit_integrations_db.static_integrations_load( + INTEGRATION_TESTER_ROOTDIR, + allowlist=["newsapi", "resend"], + builtin_skills=[], +) + def get_available_integrations() -> List[Dict[str, Any]]: result = [] - for name, config in API_KEY_INTEGRATIONS.items(): + for rec in INTEGRATION_TESTER_INTEGRATIONS: + name = rec.integr_name + config = API_KEY_INTEGRATIONS.get(name) + if not config: + continue key = os.environ.get(config["env_var"]) if key: result.append({ diff --git a/flexus_simple_bots/integration_tester/setup_schema.json b/flexus_simple_bots/integration_tester/setup_schema.json index 97e8bcda..cd5582dd 100644 --- a/flexus_simple_bots/integration_tester/setup_schema.json +++ b/flexus_simple_bots/integration_tester/setup_schema.json @@ -7,5 +7,14 @@ "bs_importance": 1, "bs_placeholder": "SLACK_BOT_TOKEN=xoxb-...\nGITHUB_TOKEN=ghp_...\nJIRA_INSTANCE_URL=https://...\n...", "bs_description": "Paste your .env file content here with all integration keys. Each line should be KEY=VALUE format." + }, + { + "bs_name": "INTEGRATION_TESTER_ALLOWLIST", + "bs_type": "string_short", + "bs_default": "", + "bs_group": "integrations", + "bs_importance": 1, + "bs_placeholder": "newsapi,resend", + "bs_description": "Optional CSV list of integration names to include. Empty means all discovered integrations with supported smoke tests." } ] From 9e43e6d9dc7c3fe33e9014f9fe80cccf2b57dd8e Mon Sep 17 00:00:00 2001 From: oxyplay Date: Mon, 13 Apr 2026 11:00:51 +0100 Subject: [PATCH 03/14] more data in report --- .../integration_tester/integration_tester_install.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/flexus_simple_bots/integration_tester/integration_tester_install.py b/flexus_simple_bots/integration_tester/integration_tester_install.py index f4305abf..d35dbeed 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_install.py +++ b/flexus_simple_bots/integration_tester/integration_tester_install.py @@ -101,7 +101,12 @@ def _build_experts(tools): - newsapi -> newsapi(op="call", args={{"method_id":"newsapi.sources.v1"}}) - resend -> email_setup_domain(op="list", args={{}}) - Process integrations one by one. -- Build per-integration result lines: PASSED/FAILED + short detail. +- Build per-integration result lines with visible metrics, not only pass/fail. +- Preferred format examples: + - newsapi: PASSED - 126 sources returned + - resend: PASSED - 3 domains listed + - resend: FAILED - 403 dev bots must use own Resend account +- If response is JSON, extract a useful count (sources/domains/items/articles) and include it. - After all tests, call flexus_kanban_advanced(op="resolve", args={{"task_id":"", "resolution_code":"PASSED"|"FAILED", "resolution_summary":"..."}}). - Use PASSED only if all integrations in batch passed. - Do not wait for user input. From 142e1b399a32cc9d3cef96f60d09089fc202fb90 Mon Sep 17 00:00:00 2001 From: oxyplay Date: Mon, 13 Apr 2026 11:23:47 +0100 Subject: [PATCH 04/14] Integration Tester: add registry pattern, scenario, remove DOMAINS dependency --- .../integration_tester__s1.yaml | 48 +++++++ .../integration_tester_bot.py | 117 ++++++++++-------- .../integration_tester_install.py | 42 ++++--- 3 files changed, 132 insertions(+), 75 deletions(-) create mode 100644 flexus_simple_bots/integration_tester/integration_tester__s1.yaml diff --git a/flexus_simple_bots/integration_tester/integration_tester__s1.yaml b/flexus_simple_bots/integration_tester/integration_tester__s1.yaml new file mode 100644 index 00000000..49452274 --- /dev/null +++ b/flexus_simple_bots/integration_tester/integration_tester__s1.yaml @@ -0,0 +1,48 @@ +messages: +- role: user + content: test all integrations +- role: assistant + tool_calls: + - function: + arguments: '{"requested":"all","batch_size":5,"configured_only":true}' + name: integration_plan_batches + id: call_plan_001 + type: function +- role: tool + call_id: call_plan_001 + content: |- + { + "ok": true, + "requested": ["all"], + "supported": ["newsapi", "resend"], + "configured": ["newsapi", "resend"], + "configured_only": true, + "selected": ["newsapi", "resend"], + "unsupported": [], + "batch_size": 5, + "batches": [["newsapi", "resend"]], + "task_specs": [ + { + "title": "Test integrations batch 1/1", + "description": "Integrations: newsapi,resend", + "integrations": ["newsapi", "resend"] + } + ] + } +- role: assistant + tool_calls: + - function: + arguments: '{"description":"Integrations: newsapi,resend","fexp_name":"autonomous","title":"Test integrations batch 1/1","to_bot":"Integration Tester"}' + name: flexus_hand_over_task + id: call_handover_001 + type: function +- role: tool + call_id: call_handover_001 + content: |- + ✓ Task handed over to 'Integration Tester' + Task ID: int_test_001 +- role: assistant + content: |- + Created 1 batch task for 2 integrations (newsapi, resend). + + Task handed over to autonomous worker for execution. diff --git a/flexus_simple_bots/integration_tester/integration_tester_bot.py b/flexus_simple_bots/integration_tester/integration_tester_bot.py index fcaaad37..fa845127 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_bot.py +++ b/flexus_simple_bots/integration_tester/integration_tester_bot.py @@ -77,11 +77,29 @@ ) -TOOLS = [ - PLAN_BATCHES_TOOL, - NEWSAPI_TOOL, - fi_resend.RESEND_SETUP_TOOL, -] +INTEGRATION_REGISTRY: Dict[str, Dict[str, Any]] = { + "newsapi": { + "env_var": "NEWSAPI_API_KEY", + "alt_env_vars": ["NEWSAPI_KEY"], + "tool": NEWSAPI_TOOL, + "integration_cls": fi_newsapi.IntegrationNewsapi, + "integration_args": lambda fclient, rcx, setup: (rcx,), + "handler_method": "called_by_model", + "test_prompt_op": "call", + "test_prompt_args": {"method_id": "newsapi.sources.v1"}, + }, + "resend": { + "env_var": "RESEND_API_KEY", + "tool": fi_resend.RESEND_SETUP_TOOL, + "integration_cls": fi_resend.IntegrationResend, + "integration_args": lambda fclient, rcx, setup: (fclient, rcx, {}), + "handler_method": "setup_called_by_model", + "test_prompt_op": "list", + "test_prompt_args": {}, + }, +} + +TOOLS = [PLAN_BATCHES_TOOL] + [reg["tool"] for reg in INTEGRATION_REGISTRY.values()] def _chunk_names(xs: List[str], n: int) -> List[List[str]]: @@ -130,23 +148,19 @@ def load_env_config(setup: Dict[str, Any]) -> None: def get_configured_integrations() -> List[Dict[str, Any]]: result = [] - - newsapi_key = os.environ.get("NEWSAPI_API_KEY") or os.environ.get("NEWSAPI_KEY") - if newsapi_key: - result.append({ - "name": "newsapi", - "env_var": "NEWSAPI_API_KEY", - "key_hint": newsapi_key[-4:] if len(newsapi_key) > 4 else "***", - }) - - resend_key = os.environ.get("RESEND_API_KEY") - if resend_key: - result.append({ - "name": "resend", - "env_var": "RESEND_API_KEY", - "key_hint": resend_key[-4:] if len(resend_key) > 4 else "***", - }) - + for name, reg in INTEGRATION_REGISTRY.items(): + key = os.environ.get(reg["env_var"]) + if not key and "alt_env_vars" in reg: + for alt in reg["alt_env_vars"]: + key = os.environ.get(alt) + if key: + break + if key: + result.append({ + "name": name, + "env_var": reg["env_var"], + "key_hint": key[-4:] if len(key) > 4 else "***", + }) return result @@ -166,24 +180,32 @@ async def integration_tester_main_loop( await ckit_integrations_db.main_loop_integrations_init(integr_records, rcx, setup) supported_integrations = sorted({r.integr_name for r in integr_records}) - newsapi = fi_newsapi.IntegrationNewsapi(rcx) - domains = setup.get("DOMAINS", {}) if isinstance(setup, dict) else {} - resend = fi_resend.IntegrationResend(fclient, rcx, domains) - - @rcx.on_tool_call(NEWSAPI_TOOL.name) - async def toolcall_newsapi(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: - newsapi_key = os.environ.get("NEWSAPI_API_KEY") or os.environ.get("NEWSAPI_KEY") - logger.info(f"Testing newsapi - API key present: {bool(newsapi_key)}") - if not newsapi_key: - logger.warning("newsapi test FAILED - no API key configured") - return "Error: NEWSAPI_API_KEY not configured. Resolve the kanban task as FAILED with status: 'FAILED - No API key configured for newsapi'" - try: - result = await newsapi.called_by_model(toolcall, model_produced_args) - logger.info(f"newsapi test result: {result[:100]}..." if len(result) > 100 else f"newsapi test result: {result}") - return result - except Exception as e: - logger.error("toolcall_newsapi: %s" % str(e), exc_info=True) - return "Error: %s" % str(e) + for name, reg in INTEGRATION_REGISTRY.items(): + integration_obj = reg["integration_cls"](*reg["integration_args"](fclient, rcx, setup)) + + def make_handler(reg, obj): + env_var = reg["env_var"] + alt_env_vars = reg.get("alt_env_vars", []) + handler_method = reg["handler_method"] + tool_name = reg["tool"].name + + async def handler(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + keys = [os.environ.get(env_var)] + [os.environ.get(v) for v in alt_env_vars] + key = next((k for k in keys if k), None) + logger.info(f"Testing {tool_name} - API key present: {bool(key)}") + if not key: + logger.warning(f"{tool_name} test FAILED - no API key configured") + return f"Error: {env_var} not configured. Resolve the kanban task as FAILED with status: 'FAILED - No API key configured for {tool_name}'" + try: + result = await getattr(obj, handler_method)(toolcall, model_produced_args) + logger.info(f"{tool_name} test result: {result[:100]}..." if len(result) > 100 else f"{tool_name} test result: {result}") + return result + except Exception as e: + logger.error(f"toolcall_{tool_name}: %s" % str(e), exc_info=True) + return "Error: %s" % str(e) + return handler + + rcx.on_tool_call(reg["tool"].name)(make_handler(reg, integration_obj)) @rcx.on_tool_call(PLAN_BATCHES_TOOL.name) async def toolcall_plan_batches(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: @@ -236,21 +258,6 @@ async def toolcall_plan_batches(toolcall: ckit_cloudtool.FCloudtoolCall, model_p "task_specs": task_specs, }, indent=2) - @rcx.on_tool_call(fi_resend.RESEND_SETUP_TOOL.name) - async def toolcall_email_setup_domain(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: - resend_key = os.environ.get("RESEND_API_KEY") - logger.info(f"Testing resend - API key present: {bool(resend_key)}") - if not resend_key: - logger.warning("resend test FAILED - no API key configured") - return "Error: RESEND_API_KEY not configured. Resolve the kanban task as FAILED with status: 'FAILED - No API key configured for resend'" - try: - result = await resend.setup_called_by_model(toolcall, model_produced_args) - logger.info(f"resend test result: {result[:100]}..." if len(result) > 100 else f"resend test result: {result}") - return result - except Exception as e: - logger.error("toolcall_email_setup_domain: %s" % str(e), exc_info=True) - return "Error: %s" % str(e) - configured = get_configured_integrations() logger.info(f"Integration Tester started. Configured integrations: {[i['name'] for i in configured]}") diff --git a/flexus_simple_bots/integration_tester/integration_tester_install.py b/flexus_simple_bots/integration_tester/integration_tester_install.py index d35dbeed..196b5222 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_install.py +++ b/flexus_simple_bots/integration_tester/integration_tester_install.py @@ -14,18 +14,10 @@ INTEGRATION_TESTER_ROOTDIR = Path(__file__).parent INTEGRATION_TESTER_SKILLS = ckit_skills.static_skills_find(INTEGRATION_TESTER_ROOTDIR, shared_skills_allowlist="", integration_skills_allowlist="") -API_KEY_INTEGRATIONS = { - "newsapi": { - "env_var": "NEWSAPI_API_KEY", - "tool_class": fi_newsapi.IntegrationNewsapi, - "test_op": "sources", - }, - "resend": { - "env_var": "RESEND_API_KEY", - "tool_name": "email_setup_domain", - "test_op": "list", - }, -} +from flexus_simple_bots.integration_tester.integration_tester_bot import ( + INTEGRATION_REGISTRY, + PLAN_BATCHES_TOOL, +) INTEGRATION_TESTER_INTEGRATIONS: list[ckit_integrations_db.IntegrationRecord] = ckit_integrations_db.static_integrations_load( INTEGRATION_TESTER_ROOTDIR, @@ -38,21 +30,26 @@ def get_available_integrations() -> List[Dict[str, Any]]: result = [] for rec in INTEGRATION_TESTER_INTEGRATIONS: name = rec.integr_name - config = API_KEY_INTEGRATIONS.get(name) - if not config: + reg = INTEGRATION_REGISTRY.get(name) + if not reg: continue - key = os.environ.get(config["env_var"]) + key = os.environ.get(reg["env_var"]) + if not key and "alt_env_vars" in reg: + for alt in reg["alt_env_vars"]: + key = os.environ.get(alt) + if key: + break if key: result.append({ "name": name, - "env_var": config["env_var"], + "env_var": reg["env_var"], "has_key": True, "key_hint": key[-4:] if len(key) > 4 else "***", }) else: result.append({ "name": name, - "env_var": config["env_var"], + "env_var": reg["env_var"], "has_key": False, "key_hint": None, }) @@ -61,7 +58,8 @@ def get_available_integrations() -> List[Dict[str, Any]]: def _build_experts(tools): builtin_skills = ckit_skills.read_name_description(INTEGRATION_TESTER_ROOTDIR, INTEGRATION_TESTER_SKILLS) - tool_names = {t.name for t in tools} + tool_names = {reg["tool"].name for reg in INTEGRATION_REGISTRY.values()} + tool_names.add(PLAN_BATCHES_TOOL.name) allow_tools = ",".join(tool_names | ckit_cloudtool.KANBAN_ADVANCED | {"flexus_hand_over_task"}) available = get_available_integrations() @@ -71,6 +69,11 @@ def _build_experts(tools): for item in available ]) + test_examples = "\n".join([ + f' - {name} -> {reg["tool"].name}(op="{reg["test_prompt_op"]}", args={json.dumps(reg["test_prompt_args"])})' + for name, reg in INTEGRATION_REGISTRY.items() + ]) + default_prompt = f"""You are Integration Tester. Test API key-based integrations via kanban fan-out. == AVAILABLE INTEGRATIONS == @@ -98,8 +101,7 @@ def _build_experts(tools): == EXECUTION == - For each integration in the batch, run the mapped test: - - newsapi -> newsapi(op="call", args={{"method_id":"newsapi.sources.v1"}}) - - resend -> email_setup_domain(op="list", args={{}}) +{test_examples} - Process integrations one by one. - Build per-integration result lines with visible metrics, not only pass/fail. - Preferred format examples: From dd60d129c9802bfc9693668ffd5d42e0f461564b Mon Sep 17 00:00:00 2001 From: oxyplay Date: Mon, 13 Apr 2026 12:11:19 +0100 Subject: [PATCH 05/14] Fix cloudtool ALL_KNOWN, add sys.path for scenario runs, update placeholder --- flexus_client_kit/ckit_cloudtool.py | 5 +++-- .../integration_tester/integration_tester_bot.py | 5 +++++ flexus_simple_bots/integration_tester/setup_schema.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/flexus_client_kit/ckit_cloudtool.py b/flexus_client_kit/ckit_cloudtool.py index b4788632..e8df4182 100644 --- a/flexus_client_kit/ckit_cloudtool.py +++ b/flexus_client_kit/ckit_cloudtool.py @@ -30,13 +30,14 @@ CLOUDTOOLS_VECDB = {"flexus_vector_search", "flexus_read_original"} CLOUDTOOLS_PYTHON = {"python_execute"} CLOUDTOOLS_WEB = {"web"} -CLOUDTOOLS_NOT_KANBAN = CLOUDTOOLS_VECDB | CLOUDTOOLS_PYTHON | CLOUDTOOLS_WEB +CLOUDTOOLS_AGENTS = {"flexus_hand_over_task"} +CLOUDTOOLS_NOT_KANBAN = CLOUDTOOLS_VECDB | CLOUDTOOLS_PYTHON | CLOUDTOOLS_WEB | CLOUDTOOLS_AGENTS CLOUDTOOLS_MCP = {"mcp_*"} CLOUDTOOLS_QUITE_A_LOT = KANBAN_ADVANCED | CLOUDTOOLS_NOT_KANBAN | CLOUDTOOLS_MCP -CLOUDTOOLS_ALL_KNOWN = KANBAN_ALL | CLOUDTOOLS_NOT_KANBAN | CLOUDTOOLS_SCARY_ENOUGH # unusable in a bot +CLOUDTOOLS_ALL_KNOWN = KANBAN_ALL | CLOUDTOOLS_NOT_KANBAN # unusable in a bot def gql_error_4xx_to_model_reraise_5xx(e: gql.transport.exceptions.TransportQueryError, label: str) -> str: diff --git a/flexus_simple_bots/integration_tester/integration_tester_bot.py b/flexus_simple_bots/integration_tester/integration_tester_bot.py index fa845127..46df47b6 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_bot.py +++ b/flexus_simple_bots/integration_tester/integration_tester_bot.py @@ -2,9 +2,14 @@ import json import logging import os +import sys from pathlib import Path from typing import Any, Dict, List +_repo_root = Path(__file__).parents[2] +if str(_repo_root) not in sys.path: + sys.path.insert(0, str(_repo_root)) + from flexus_client_kit import ckit_bot_exec, ckit_client, ckit_shutdown, ckit_cloudtool from flexus_client_kit import ckit_bot_version from flexus_client_kit import ckit_integrations_db diff --git a/flexus_simple_bots/integration_tester/setup_schema.json b/flexus_simple_bots/integration_tester/setup_schema.json index cd5582dd..6536bbca 100644 --- a/flexus_simple_bots/integration_tester/setup_schema.json +++ b/flexus_simple_bots/integration_tester/setup_schema.json @@ -5,7 +5,7 @@ "bs_default": "", "bs_group": "integrations", "bs_importance": 1, - "bs_placeholder": "SLACK_BOT_TOKEN=xoxb-...\nGITHUB_TOKEN=ghp_...\nJIRA_INSTANCE_URL=https://...\n...", + "bs_placeholder": "NEWSAPI_API_KEY=...\nRESEND_API_KEY=...\n...", "bs_description": "Paste your .env file content here with all integration keys. Each line should be KEY=VALUE format." }, { From 681f73fc7cf7ddf82e39f5ab802c163ce7e95e81 Mon Sep 17 00:00:00 2001 From: oxyplay Date: Mon, 13 Apr 2026 12:15:08 +0100 Subject: [PATCH 06/14] Add test_description/unit to registry for richer autonomous output --- .../integration_tester/integration_tester_bot.py | 4 ++++ .../integration_tester_install.py | 16 +++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/flexus_simple_bots/integration_tester/integration_tester_bot.py b/flexus_simple_bots/integration_tester/integration_tester_bot.py index 46df47b6..2c5c52ae 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_bot.py +++ b/flexus_simple_bots/integration_tester/integration_tester_bot.py @@ -92,6 +92,8 @@ "handler_method": "called_by_model", "test_prompt_op": "call", "test_prompt_args": {"method_id": "newsapi.sources.v1"}, + "test_description": "sources endpoint", + "test_unit": "source", }, "resend": { "env_var": "RESEND_API_KEY", @@ -101,6 +103,8 @@ "handler_method": "setup_called_by_model", "test_prompt_op": "list", "test_prompt_args": {}, + "test_description": "domain list endpoint", + "test_unit": "domain", }, } diff --git a/flexus_simple_bots/integration_tester/integration_tester_install.py b/flexus_simple_bots/integration_tester/integration_tester_install.py index 196b5222..3a9e0372 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_install.py +++ b/flexus_simple_bots/integration_tester/integration_tester_install.py @@ -70,7 +70,15 @@ def _build_experts(tools): ]) test_examples = "\n".join([ - f' - {name} -> {reg["tool"].name}(op="{reg["test_prompt_op"]}", args={json.dumps(reg["test_prompt_args"])})' + f' - {name} -> {reg["tool"].name}(op="{reg["test_prompt_op"]}", args=' + json.dumps(reg["test_prompt_args"]) + ')' + for name, reg in INTEGRATION_REGISTRY.items() + ]) + + format_examples = "\n".join([ + f' - {name}: PASSED - {reg["test_description"]} returned {{count}} {reg["test_unit"]}s' + for name, reg in INTEGRATION_REGISTRY.items() + ] + [ + f' - {name}: FAILED - {reg["test_description"]} returned {{error_message}}' for name, reg in INTEGRATION_REGISTRY.items() ]) @@ -105,10 +113,8 @@ def _build_experts(tools): - Process integrations one by one. - Build per-integration result lines with visible metrics, not only pass/fail. - Preferred format examples: - - newsapi: PASSED - 126 sources returned - - resend: PASSED - 3 domains listed - - resend: FAILED - 403 dev bots must use own Resend account -- If response is JSON, extract a useful count (sources/domains/items/articles) and include it. +{format_examples} +- If response is JSON, extract a useful count and include it after the test description. - After all tests, call flexus_kanban_advanced(op="resolve", args={{"task_id":"", "resolution_code":"PASSED"|"FAILED", "resolution_summary":"..."}}). - Use PASSED only if all integrations in batch passed. - Do not wait for user input. From 6ce6ce292645ae1efbb6e17661fd82d6c0a4d9d5 Mon Sep 17 00:00:00 2001 From: oxyplay Date: Mon, 13 Apr 2026 12:17:24 +0100 Subject: [PATCH 07/14] Remove per-integration test hardcode; use dynamic help-based discovery in autonomous prompt --- .../integration_tester_bot.py | 8 ------- .../integration_tester_install.py | 24 ++++--------------- 2 files changed, 5 insertions(+), 27 deletions(-) diff --git a/flexus_simple_bots/integration_tester/integration_tester_bot.py b/flexus_simple_bots/integration_tester/integration_tester_bot.py index 2c5c52ae..2f2476ef 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_bot.py +++ b/flexus_simple_bots/integration_tester/integration_tester_bot.py @@ -90,10 +90,6 @@ "integration_cls": fi_newsapi.IntegrationNewsapi, "integration_args": lambda fclient, rcx, setup: (rcx,), "handler_method": "called_by_model", - "test_prompt_op": "call", - "test_prompt_args": {"method_id": "newsapi.sources.v1"}, - "test_description": "sources endpoint", - "test_unit": "source", }, "resend": { "env_var": "RESEND_API_KEY", @@ -101,10 +97,6 @@ "integration_cls": fi_resend.IntegrationResend, "integration_args": lambda fclient, rcx, setup: (fclient, rcx, {}), "handler_method": "setup_called_by_model", - "test_prompt_op": "list", - "test_prompt_args": {}, - "test_description": "domain list endpoint", - "test_unit": "domain", }, } diff --git a/flexus_simple_bots/integration_tester/integration_tester_install.py b/flexus_simple_bots/integration_tester/integration_tester_install.py index 3a9e0372..443ec416 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_install.py +++ b/flexus_simple_bots/integration_tester/integration_tester_install.py @@ -69,19 +69,6 @@ def _build_experts(tools): for item in available ]) - test_examples = "\n".join([ - f' - {name} -> {reg["tool"].name}(op="{reg["test_prompt_op"]}", args=' + json.dumps(reg["test_prompt_args"]) + ')' - for name, reg in INTEGRATION_REGISTRY.items() - ]) - - format_examples = "\n".join([ - f' - {name}: PASSED - {reg["test_description"]} returned {{count}} {reg["test_unit"]}s' - for name, reg in INTEGRATION_REGISTRY.items() - ] + [ - f' - {name}: FAILED - {reg["test_description"]} returned {{error_message}}' - for name, reg in INTEGRATION_REGISTRY.items() - ]) - default_prompt = f"""You are Integration Tester. Test API key-based integrations via kanban fan-out. == AVAILABLE INTEGRATIONS == @@ -108,13 +95,12 @@ def _build_experts(tools): - If parsing fails, resolve task as FAILED with reason "Batch parse error". == EXECUTION == -- For each integration in the batch, run the mapped test: -{test_examples} +For each integration in the batch: +1. Call its tool with op="help" and args={{}} to see available operations. +2. Choose the simplest read-only smoke test (prefer: status, list_methods, list, sources; avoid: send, add, delete, verify). +3. Run the chosen test. +4. Build result line: "{{integration}}: PASSED - {{operation}}: {{summary with count if available}}" or "{{integration}}: FAILED - {{operation}}: {{error}}". - Process integrations one by one. -- Build per-integration result lines with visible metrics, not only pass/fail. -- Preferred format examples: -{format_examples} -- If response is JSON, extract a useful count and include it after the test description. - After all tests, call flexus_kanban_advanced(op="resolve", args={{"task_id":"", "resolution_code":"PASSED"|"FAILED", "resolution_summary":"..."}}). - Use PASSED only if all integrations in batch passed. - Do not wait for user input. From d5118bf305f102160218250bde84f2568e919722 Mon Sep 17 00:00:00 2001 From: oxyplay Date: Mon, 13 Apr 2026 12:26:03 +0100 Subject: [PATCH 08/14] Show method names in test results; prefer list_methods in autonomous prompt --- .../integration_tester_bot.py | 24 +++++++++++++++++-- .../integration_tester_install.py | 4 ++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/flexus_simple_bots/integration_tester/integration_tester_bot.py b/flexus_simple_bots/integration_tester/integration_tester_bot.py index 2f2476ef..bb94e123 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_bot.py +++ b/flexus_simple_bots/integration_tester/integration_tester_bot.py @@ -190,6 +190,25 @@ def make_handler(reg, obj): handler_method = reg["handler_method"] tool_name = reg["tool"].name + def _format_result(raw: str) -> str: + try: + data = json.loads(raw) + if isinstance(data, dict): + skip = {"ok", "provider", "description", "help_text"} + parts = [] + for k, v in data.items(): + if k in skip: + continue + if isinstance(v, list) and v and all(isinstance(x, str) for x in v): + parts.append(f"{k}=[{', '.join(v)}]") + elif not isinstance(v, (dict, list)): + parts.append(f"{k}={v}") + if parts: + return ", ".join(parts) + except json.JSONDecodeError: + pass + return raw + async def handler(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: keys = [os.environ.get(env_var)] + [os.environ.get(v) for v in alt_env_vars] key = next((k for k in keys if k), None) @@ -199,8 +218,9 @@ async def handler(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: return f"Error: {env_var} not configured. Resolve the kanban task as FAILED with status: 'FAILED - No API key configured for {tool_name}'" try: result = await getattr(obj, handler_method)(toolcall, model_produced_args) - logger.info(f"{tool_name} test result: {result[:100]}..." if len(result) > 100 else f"{tool_name} test result: {result}") - return result + formatted = _format_result(result) + logger.info(f"{tool_name} test result: {formatted[:100]}..." if len(formatted) > 100 else f"{tool_name} test result: {formatted}") + return formatted except Exception as e: logger.error(f"toolcall_{tool_name}: %s" % str(e), exc_info=True) return "Error: %s" % str(e) diff --git a/flexus_simple_bots/integration_tester/integration_tester_install.py b/flexus_simple_bots/integration_tester/integration_tester_install.py index 443ec416..21521297 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_install.py +++ b/flexus_simple_bots/integration_tester/integration_tester_install.py @@ -97,9 +97,9 @@ def _build_experts(tools): == EXECUTION == For each integration in the batch: 1. Call its tool with op="help" and args={{}} to see available operations. -2. Choose the simplest read-only smoke test (prefer: status, list_methods, list, sources; avoid: send, add, delete, verify). +2. Choose the simplest read-only smoke test (prefer: list_methods, call a simple method, status, list, sources; avoid: send, add, delete, verify). 3. Run the chosen test. -4. Build result line: "{{integration}}: PASSED - {{operation}}: {{summary with count if available}}" or "{{integration}}: FAILED - {{operation}}: {{error}}". +4. Build result line: "{{integration}}: PASSED - {{operation}}: {{tool_result_summary}}" or "{{integration}}: FAILED - {{operation}}: {{error}}". Include the key=value summary returned by the tool directly. - Process integrations one by one. - After all tests, call flexus_kanban_advanced(op="resolve", args={{"task_id":"", "resolution_code":"PASSED"|"FAILED", "resolution_summary":"..."}}). - Use PASSED only if all integrations in batch passed. From 221893f1fe482c0f02fd64d7e3b8e96f54dfe2c9 Mon Sep 17 00:00:00 2001 From: oxyplay Date: Mon, 13 Apr 2026 12:33:50 +0100 Subject: [PATCH 09/14] Enforce real tests after help; include api_key_hint in tool results; require detailed metrics in autonomous prompt --- .../integration_tester/integration_tester_bot.py | 6 ++++-- .../integration_tester/integration_tester_install.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/flexus_simple_bots/integration_tester/integration_tester_bot.py b/flexus_simple_bots/integration_tester/integration_tester_bot.py index bb94e123..911c00e3 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_bot.py +++ b/flexus_simple_bots/integration_tester/integration_tester_bot.py @@ -219,8 +219,10 @@ async def handler(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: try: result = await getattr(obj, handler_method)(toolcall, model_produced_args) formatted = _format_result(result) - logger.info(f"{tool_name} test result: {formatted[:100]}..." if len(formatted) > 100 else f"{tool_name} test result: {formatted}") - return formatted + key_hint = key[-3:] if len(key) > 3 else "***" + out = f"api_key_hint=***{key_hint}, {formatted}" + logger.info(f"{tool_name} test result: {out[:120]}..." if len(out) > 120 else f"{tool_name} test result: {out}") + return out except Exception as e: logger.error(f"toolcall_{tool_name}: %s" % str(e), exc_info=True) return "Error: %s" % str(e) diff --git a/flexus_simple_bots/integration_tester/integration_tester_install.py b/flexus_simple_bots/integration_tester/integration_tester_install.py index 21521297..fbe36643 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_install.py +++ b/flexus_simple_bots/integration_tester/integration_tester_install.py @@ -96,10 +96,14 @@ def _build_experts(tools): == EXECUTION == For each integration in the batch: -1. Call its tool with op="help" and args={{}} to see available operations. -2. Choose the simplest read-only smoke test (prefer: list_methods, call a simple method, status, list, sources; avoid: send, add, delete, verify). -3. Run the chosen test. -4. Build result line: "{{integration}}: PASSED - {{operation}}: {{tool_result_summary}}" or "{{integration}}: FAILED - {{operation}}: {{error}}". Include the key=value summary returned by the tool directly. +1. You may call op="help" once to learn available operations, but do NOT treat help as the test result. +2. You MUST run a real read-only test afterwards (prefer: list_methods, status, list, call a simple method; avoid: send, add, delete, verify). +3. Each tool result includes api_key_hint — this confirms the API key is configured. Include it in your result line. +4. Build a detailed result line with exact operation and concrete metrics: + "{{integration}}: PASSED - {{operation}}: api_key_hint=***XXX, {{metrics}}" + or + "{{integration}}: FAILED - {{operation}}: api_key_hint=***XXX, {{error}}" +5. If you see method_ids, include them. If you see counts (total, method_count, etc.), include them. - Process integrations one by one. - After all tests, call flexus_kanban_advanced(op="resolve", args={{"task_id":"", "resolution_code":"PASSED"|"FAILED", "resolution_summary":"..."}}). - Use PASSED only if all integrations in batch passed. From e24fe1bbaee1895ed8bb9dc5a762c209200315bb Mon Sep 17 00:00:00 2001 From: oxyplay Date: Mon, 13 Apr 2026 12:42:02 +0100 Subject: [PATCH 10/14] Improve default prompt summary to mention upcoming detailed results from autonomous worker --- .../integration_tester/integration_tester_install.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flexus_simple_bots/integration_tester/integration_tester_install.py b/flexus_simple_bots/integration_tester/integration_tester_install.py index fbe36643..b2eca504 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_install.py +++ b/flexus_simple_bots/integration_tester/integration_tester_install.py @@ -79,7 +79,10 @@ def _build_experts(tools): - First call integration_plan_batches(requested="...", batch_size=5, configured_only=true). - Use returned task_specs and create tasks with flexus_hand_over_task(to_bot="Integration Tester", title=..., description=..., fexp_name="autonomous"). - Do not execute test tools in this interactive chat after fan-out. -- After creating tasks, reply with short queue summary and stop. +- After creating tasks, reply with a short queue summary that includes: + - Number of batches created and integrations in each batch. + - Mention that detailed per-integration results (API key checks, method lists, counts) will appear here shortly from the autonomous worker. + - Note any unsupported integrations if they were requested. - If no supported integrations were requested, explain supported options and stop. """ From d8976eb21c18107bf4dc5d4c2a0551305f81ada4 Mon Sep 17 00:00:00 2001 From: oxyplay Date: Mon, 13 Apr 2026 12:55:29 +0100 Subject: [PATCH 11/14] Format test results as markdown tables in both default and autonomous prompts --- .../integration_tester_install.py | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/flexus_simple_bots/integration_tester/integration_tester_install.py b/flexus_simple_bots/integration_tester/integration_tester_install.py index b2eca504..6b0f61f8 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_install.py +++ b/flexus_simple_bots/integration_tester/integration_tester_install.py @@ -79,10 +79,13 @@ def _build_experts(tools): - First call integration_plan_batches(requested="...", batch_size=5, configured_only=true). - Use returned task_specs and create tasks with flexus_hand_over_task(to_bot="Integration Tester", title=..., description=..., fexp_name="autonomous"). - Do not execute test tools in this interactive chat after fan-out. -- After creating tasks, reply with a short queue summary that includes: - - Number of batches created and integrations in each batch. - - Mention that detailed per-integration results (API key checks, method lists, counts) will appear here shortly from the autonomous worker. - - Note any unsupported integrations if they were requested. +- After creating tasks, reply with a clean queue summary in this exact style: + + Queued {{N}} batch covering {{X}} integrations: {{name1}} and {{name2}}. + + Detailed per-integration results (API key checks, method lists, counts) will appear here shortly from the autonomous worker. + +- Note any unsupported integrations if they were requested. - If no supported integrations were requested, explain supported options and stop. """ @@ -101,15 +104,20 @@ def _build_experts(tools): For each integration in the batch: 1. You may call op="help" once to learn available operations, but do NOT treat help as the test result. 2. You MUST run a real read-only test afterwards (prefer: list_methods, status, list, call a simple method; avoid: send, add, delete, verify). -3. Each tool result includes api_key_hint — this confirms the API key is configured. Include it in your result line. -4. Build a detailed result line with exact operation and concrete metrics: - "{{integration}}: PASSED - {{operation}}: api_key_hint=***XXX, {{metrics}}" - or - "{{integration}}: FAILED - {{operation}}: api_key_hint=***XXX, {{error}}" -5. If you see method_ids, include them. If you see counts (total, method_count, etc.), include them. -- Process integrations one by one. -- After all tests, call flexus_kanban_advanced(op="resolve", args={{"task_id":"", "resolution_code":"PASSED"|"FAILED", "resolution_summary":"..."}}). -- Use PASSED only if all integrations in batch passed. +3. Each tool result includes api_key_hint — include it. +4. Collect one concise details string per integration (operation + key metrics or error). + +== REPORT FORMAT == +When resolving, use a markdown table for the resolution_summary and final reply: + +| Integration | Status | Details | +|-------------|--------|---------| +| newsapi | PASSED | list_methods: api_key_hint=***dc9, method_ids=[newsapi.everything.v1, ...], total=126 | +| resend | FAILED | list: api_key_hint=***oGH, 403: dev bots must use their own Resend account | + +- Use PASSED only if all integrations in the batch passed; otherwise FAILED. +- Keep Details concise but informative (mention operation, api_key_hint, counts, method names, or error). +- After building the table, call flexus_kanban_advanced(op="resolve", args={{"task_id":"", "resolution_code":"PASSED"|"FAILED", "resolution_summary":""}}). - Do not wait for user input. """ From 6635110128875f423819ce9ddab8683dab9220e3 Mon Sep 17 00:00:00 2001 From: oxyplay Date: Mon, 13 Apr 2026 13:02:16 +0100 Subject: [PATCH 12/14] Prevent help from counting as test; format completed task results as tables in default chat --- .../integration_tester/integration_tester_bot.py | 3 +++ .../integration_tester/integration_tester_install.py | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/flexus_simple_bots/integration_tester/integration_tester_bot.py b/flexus_simple_bots/integration_tester/integration_tester_bot.py index 911c00e3..ce28abc2 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_bot.py +++ b/flexus_simple_bots/integration_tester/integration_tester_bot.py @@ -218,6 +218,9 @@ async def handler(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: return f"Error: {env_var} not configured. Resolve the kanban task as FAILED with status: 'FAILED - No API key configured for {tool_name}'" try: result = await getattr(obj, handler_method)(toolcall, model_produced_args) + op = str(model_produced_args.get("op", "")).strip() if model_produced_args else "" + if op == "help": + result = "[HELP OUTPUT - NOT A TEST] " + result formatted = _format_result(result) key_hint = key[-3:] if len(key) > 3 else "***" out = f"api_key_hint=***{key_hint}, {formatted}" diff --git a/flexus_simple_bots/integration_tester/integration_tester_install.py b/flexus_simple_bots/integration_tester/integration_tester_install.py index 6b0f61f8..e9f049af 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_install.py +++ b/flexus_simple_bots/integration_tester/integration_tester_install.py @@ -86,6 +86,7 @@ def _build_experts(tools): Detailed per-integration results (API key checks, method lists, counts) will appear here shortly from the autonomous worker. - Note any unsupported integrations if they were requested. +- 💿-messages inform you when a handed-over task completes. When you see one, extract the resolution_summary and present it to the user as a markdown table (or plain summary if it is not a table). Do not just dump raw text. - If no supported integrations were requested, explain supported options and stop. """ @@ -103,7 +104,10 @@ def _build_experts(tools): == EXECUTION == For each integration in the batch: 1. You may call op="help" once to learn available operations, but do NOT treat help as the test result. -2. You MUST run a real read-only test afterwards (prefer: list_methods, status, list, call a simple method; avoid: send, add, delete, verify). +2. You MUST run a real read-only test afterwards. + - Best choices: list_methods, status, list, call a simple method. + - Never treat help, op="help", or documentation text as a successful test. + - If list/status returns 403/401/error, that integration FAILED. 3. Each tool result includes api_key_hint — include it. 4. Collect one concise details string per integration (operation + key metrics or error). From e342f14699e1eeb363e30a3aa107c6b700f0431e Mon Sep 17 00:00:00 2001 From: oxyplay Date: Mon, 13 Apr 2026 14:22:39 +0100 Subject: [PATCH 13/14] code rework + prompt update --- .../autonomous__failed.yaml | 57 ++++ .../autonomous__untested.yaml | 57 ++++ .../integration_tester_bot.py | 253 ++---------------- .../integration_tester_install.py | 219 +++++++-------- .../integration_tester_shared.py | 225 ++++++++++++++++ 5 files changed, 464 insertions(+), 347 deletions(-) create mode 100644 flexus_simple_bots/integration_tester/autonomous__failed.yaml create mode 100644 flexus_simple_bots/integration_tester/autonomous__untested.yaml create mode 100644 flexus_simple_bots/integration_tester/integration_tester_shared.py diff --git a/flexus_simple_bots/integration_tester/autonomous__failed.yaml b/flexus_simple_bots/integration_tester/autonomous__failed.yaml new file mode 100644 index 00000000..0e483733 --- /dev/null +++ b/flexus_simple_bots/integration_tester/autonomous__failed.yaml @@ -0,0 +1,57 @@ +messages: +- role: user + content: Work on the assigned task. +- role: assistant + tool_calls: + - function: + arguments: '{"op":"assign_to_this_chat","args":{"batch":["int_test_002"]}}' + name: flexus_kanban_advanced + id: call_assign_002 + type: function +- role: tool + call_id: call_assign_002 + content: |- + Assigned task int_test_002 to this chat and also moved it to 'inprogress' column + + { + "id": "int_test_002", + "title": "Test integrations batch 1/1", + "details": { + "description": "Integrations: newsapi" + } + } +- role: assistant + tool_calls: + - function: + arguments: '{"op":"help","args":{}}' + name: newsapi + id: call_help_002 + type: function +- role: tool + call_id: call_help_002 + content: '[HELP OUTPUT - NOT A TEST] newsapi help' +- role: assistant + tool_calls: + - function: + arguments: '{"op":"status","args":{}}' + name: newsapi + id: call_status_002 + type: function +- role: tool + call_id: call_status_002 + content: 'Error [AUTH_ERROR]: API key invalid or unauthorized' +- role: assistant + tool_calls: + - function: + arguments: '{"op":"resolve","args":{"task_id":"int_test_002","resolution_code":"FAILED","resolution_summary":"| Integration | Status | Details |\n|-------------|--------|---------|\n| newsapi | FAILED | op=status: Error [AUTH_ERROR]: API key invalid or unauthorized |"}}' + name: flexus_kanban_advanced + id: call_resolve_002 + type: function +- role: tool + call_id: call_resolve_002 + content: Task int_test_002 resolved as FAILED +- role: assistant + content: |- + | Integration | Status | Details | + |-------------|--------|---------| + | newsapi | FAILED | op=status: Error [AUTH_ERROR]: API key invalid or unauthorized | diff --git a/flexus_simple_bots/integration_tester/autonomous__untested.yaml b/flexus_simple_bots/integration_tester/autonomous__untested.yaml new file mode 100644 index 00000000..21f65b90 --- /dev/null +++ b/flexus_simple_bots/integration_tester/autonomous__untested.yaml @@ -0,0 +1,57 @@ +messages: +- role: user + content: Work on the assigned task. +- role: assistant + tool_calls: + - function: + arguments: '{"op":"assign_to_this_chat","args":{"batch":["int_test_001"]}}' + name: flexus_kanban_advanced + id: call_assign_001 + type: function +- role: tool + call_id: call_assign_001 + content: |- + Assigned task int_test_001 to this chat and also moved it to 'inprogress' column + + { + "id": "int_test_001", + "title": "Test integrations batch 1/1", + "details": { + "description": "Integrations: newsapi" + } + } +- role: assistant + tool_calls: + - function: + arguments: '{"op":"help","args":{}}' + name: newsapi + id: call_help_001 + type: function +- role: tool + call_id: call_help_001 + content: '[HELP OUTPUT - NOT A TEST] newsapi help' +- role: assistant + tool_calls: + - function: + arguments: '{"op":"list_methods","args":{}}' + name: newsapi + id: call_methods_001 + type: function +- role: tool + call_id: call_methods_001 + content: 'api_key_hint=***dc9, method_ids=[newsapi.everything.v1, newsapi.top-headlines.v1]' +- role: assistant + tool_calls: + - function: + arguments: '{"op":"resolve","args":{"task_id":"int_test_001","resolution_code":"FAILED","resolution_summary":"| Integration | Status | Details |\n|-------------|--------|---------|\n| newsapi | UNTESTED | help and list_methods only; no real API test; api_key_hint=***dc9 |"}}' + name: flexus_kanban_advanced + id: call_resolve_001 + type: function +- role: tool + call_id: call_resolve_001 + content: Task int_test_001 resolved as FAILED +- role: assistant + content: |- + | Integration | Status | Details | + |-------------|--------|---------| + | newsapi | UNTESTED | help and list_methods only; no real API test; api_key_hint=***dc9 | diff --git a/flexus_simple_bots/integration_tester/integration_tester_bot.py b/flexus_simple_bots/integration_tester/integration_tester_bot.py index ce28abc2..641415a6 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_bot.py +++ b/flexus_simple_bots/integration_tester/integration_tester_bot.py @@ -1,179 +1,31 @@ import asyncio import json -import logging -import os import sys from pathlib import Path -from typing import Any, Dict, List _repo_root = Path(__file__).parents[2] if str(_repo_root) not in sys.path: sys.path.insert(0, str(_repo_root)) -from flexus_client_kit import ckit_bot_exec, ckit_client, ckit_shutdown, ckit_cloudtool +from flexus_client_kit import ckit_bot_exec, ckit_client, ckit_shutdown, ckit_integrations_db from flexus_client_kit import ckit_bot_version -from flexus_client_kit import ckit_integrations_db -from flexus_client_kit.integrations import fi_newsapi, fi_resend - -logger = logging.getLogger("integration_tester") +from flexus_simple_bots.integration_tester import integration_tester_shared as shared +from flexus_simple_bots.integration_tester import integration_tester_install BOT_NAME = ckit_bot_version.bot_name_from_file(__file__) BOT_VERSION = (Path(__file__).parents[1] / "VERSION").read_text().strip() SETUP_SCHEMA = json.loads((Path(__file__).parent / "setup_schema.json").read_text()) -INTEGRATION_TESTER_INTEGRATIONS: list[ckit_integrations_db.IntegrationRecord] = ckit_integrations_db.static_integrations_load( - Path(__file__).parent, - allowlist=["newsapi", "resend"], - builtin_skills=[], -) - -PLAN_BATCHES_TOOL = ckit_cloudtool.CloudTool( - strict=True, - name="integration_plan_batches", - description="Plan deterministic integration test batches and return task specs for kanban fan-out.", - parameters={ - "type": "object", - "additionalProperties": False, - "properties": { - "requested": {"type": "string", "description": "Requested integrations, e.g. 'all' or 'newsapi,resend'."}, - "batch_size": {"type": "integer", "description": "Max integrations per task batch."}, - "configured_only": {"type": "boolean", "description": "If true, include only integrations with configured keys."}, - }, - "required": ["requested", "batch_size", "configured_only"], - }, -) - -NEWSAPI_TOOL = ckit_cloudtool.CloudTool( - strict=False, - name=fi_newsapi.PROVIDER_NAME, - description=f"{fi_newsapi.PROVIDER_NAME}: data provider. op=help|status|list_methods|call", - parameters={ - "type": "object", - "additionalProperties": False, - "properties": { - "op": {"type": "string", "enum": ["help", "status", "list_methods", "call"]}, - "args": { - "type": "object", - "additionalProperties": False, - "properties": { - "method_id": {"type": "string"}, - "include_raw": {"type": "boolean"}, - "q": {"type": "string"}, - "query": {"type": "string"}, - "sources": {"type": "string"}, - "domains": {"type": "string"}, - "excludeDomains": {"type": "string"}, - "from": {"type": "string"}, - "to": {"type": "string"}, - "language": {"type": "string"}, - "sortBy": {"type": "string"}, - "pageSize": {"type": "integer"}, - "page": {"type": "integer"}, - "country": {"type": "string"}, - "category": {"type": "string"}, - "time_window": {"type": "string"}, - "start_date": {"type": "string"}, - "end_date": {"type": "string"} - } - }, - }, - "required": ["op", "args"], - }, -) - - -INTEGRATION_REGISTRY: Dict[str, Dict[str, Any]] = { - "newsapi": { - "env_var": "NEWSAPI_API_KEY", - "alt_env_vars": ["NEWSAPI_KEY"], - "tool": NEWSAPI_TOOL, - "integration_cls": fi_newsapi.IntegrationNewsapi, - "integration_args": lambda fclient, rcx, setup: (rcx,), - "handler_method": "called_by_model", - }, - "resend": { - "env_var": "RESEND_API_KEY", - "tool": fi_resend.RESEND_SETUP_TOOL, - "integration_cls": fi_resend.IntegrationResend, - "integration_args": lambda fclient, rcx, setup: (fclient, rcx, {}), - "handler_method": "setup_called_by_model", - }, -} - -TOOLS = [PLAN_BATCHES_TOOL] + [reg["tool"] for reg in INTEGRATION_REGISTRY.values()] - - -def _chunk_names(xs: List[str], n: int) -> List[List[str]]: - if n < 1: - n = 1 - return [xs[i:i + n] for i in range(0, len(xs), n)] - - -def _requested_names(raw: str) -> List[str]: - s = (raw or "").strip().lower() - if not s or s == "all": - return ["all"] - names = [] - for x in s.replace(";", ",").split(","): - x = x.strip() - if x: - names.append(x) - return names or ["all"] - - -def _setup_allowlist_names(setup: Dict[str, Any]) -> List[str]: - raw = str(setup.get("INTEGRATION_TESTER_ALLOWLIST", "") or "").strip().lower() - if not raw: - return [] - names: List[str] = [] - for x in raw.replace(";", ",").split(","): - x = x.strip() - if x: - names.append(x) - return names - - -def load_env_config(setup: Dict[str, Any]) -> None: - env_config = setup.get("ENV_CONFIG", "") - if not env_config: - logger.info("No ENV_CONFIG found in persona_setup") - return - count = 0 - for line in env_config.strip().split('\n'): - if '=' in line and not line.startswith('#'): - key, value = line.split('=', 1) - os.environ[key.strip()] = value.strip() - count += 1 - logger.info(f"Loaded {count} environment variables from ENV_CONFIG") - - -def get_configured_integrations() -> List[Dict[str, Any]]: - result = [] - for name, reg in INTEGRATION_REGISTRY.items(): - key = os.environ.get(reg["env_var"]) - if not key and "alt_env_vars" in reg: - for alt in reg["alt_env_vars"]: - key = os.environ.get(alt) - if key: - break - if key: - result.append({ - "name": name, - "env_var": reg["env_var"], - "key_hint": key[-4:] if len(key) > 4 else "***", - }) - return result - async def integration_tester_main_loop( fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext, ) -> None: setup = ckit_bot_exec.official_setup_mixing_procedure(SETUP_SCHEMA, rcx.persona.persona_setup) - load_env_config(setup) + shared.load_env_config(setup) - integr_records = INTEGRATION_TESTER_INTEGRATIONS - setup_allow = _setup_allowlist_names(setup) + integr_records = shared.INTEGRATION_TESTER_INTEGRATIONS + setup_allow = shared._setup_allowlist_names(setup) if setup_allow: allow = set(setup_allow) integr_records = [r for r in integr_records if r.integr_name in allow] @@ -181,62 +33,14 @@ async def integration_tester_main_loop( await ckit_integrations_db.main_loop_integrations_init(integr_records, rcx, setup) supported_integrations = sorted({r.integr_name for r in integr_records}) - for name, reg in INTEGRATION_REGISTRY.items(): - integration_obj = reg["integration_cls"](*reg["integration_args"](fclient, rcx, setup)) + for name, reg in shared.INTEGRATION_REGISTRY.items(): + obj = reg["integration_cls"](*reg["integration_args"](fclient, rcx, setup)) + rcx.on_tool_call(reg["tool"].name)(shared.IntegrationHandler(reg, obj)) - def make_handler(reg, obj): - env_var = reg["env_var"] - alt_env_vars = reg.get("alt_env_vars", []) - handler_method = reg["handler_method"] - tool_name = reg["tool"].name - - def _format_result(raw: str) -> str: - try: - data = json.loads(raw) - if isinstance(data, dict): - skip = {"ok", "provider", "description", "help_text"} - parts = [] - for k, v in data.items(): - if k in skip: - continue - if isinstance(v, list) and v and all(isinstance(x, str) for x in v): - parts.append(f"{k}=[{', '.join(v)}]") - elif not isinstance(v, (dict, list)): - parts.append(f"{k}={v}") - if parts: - return ", ".join(parts) - except json.JSONDecodeError: - pass - return raw - - async def handler(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: - keys = [os.environ.get(env_var)] + [os.environ.get(v) for v in alt_env_vars] - key = next((k for k in keys if k), None) - logger.info(f"Testing {tool_name} - API key present: {bool(key)}") - if not key: - logger.warning(f"{tool_name} test FAILED - no API key configured") - return f"Error: {env_var} not configured. Resolve the kanban task as FAILED with status: 'FAILED - No API key configured for {tool_name}'" - try: - result = await getattr(obj, handler_method)(toolcall, model_produced_args) - op = str(model_produced_args.get("op", "")).strip() if model_produced_args else "" - if op == "help": - result = "[HELP OUTPUT - NOT A TEST] " + result - formatted = _format_result(result) - key_hint = key[-3:] if len(key) > 3 else "***" - out = f"api_key_hint=***{key_hint}, {formatted}" - logger.info(f"{tool_name} test result: {out[:120]}..." if len(out) > 120 else f"{tool_name} test result: {out}") - return out - except Exception as e: - logger.error(f"toolcall_{tool_name}: %s" % str(e), exc_info=True) - return "Error: %s" % str(e) - return handler - - rcx.on_tool_call(reg["tool"].name)(make_handler(reg, integration_obj)) - - @rcx.on_tool_call(PLAN_BATCHES_TOOL.name) - async def toolcall_plan_batches(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + @rcx.on_tool_call(shared.PLAN_BATCHES_TOOL.name) + async def toolcall_plan_batches(toolcall, model_produced_args): args = model_produced_args or {} - req = _requested_names(str(args.get("requested", "all"))) + req = shared._requested_names(str(args.get("requested", "all"))) bs = args.get("batch_size", 5) configured_only = bool(args.get("configured_only", True)) try: @@ -244,9 +48,9 @@ async def toolcall_plan_batches(toolcall: ckit_cloudtool.FCloudtoolCall, model_p except (TypeError, ValueError): bs = 5 - configured = {x["name"] for x in get_configured_integrations()} - selected: List[str] = [] - unsupported: List[str] = [] + configured = {x["name"] for x in shared.get_configured_integrations()} + selected = [] + unsupported = [] if "all" in req: pool = [x for x in supported_integrations if (x in configured or not configured_only)] @@ -261,13 +65,14 @@ async def toolcall_plan_batches(toolcall: ckit_cloudtool.FCloudtoolCall, model_p if x not in selected: selected.append(x) - batches = _chunk_names(selected, bs) + batches = shared._chunk_names(selected, bs) task_specs = [] total = len(batches) for i, b in enumerate(batches, start=1): + tool_map = ", ".join(f"{name}->{shared.INTEGRATION_REGISTRY[name]['tool'].name}" for name in b) task_specs.append({ "title": f"Test integrations batch {i}/{total}", - "description": f"Integrations: {','.join(b)}", + "description": f"Integrations: {','.join(b)}\nTool mapping: {tool_map}", "integrations": b, }) @@ -284,29 +89,29 @@ async def toolcall_plan_batches(toolcall: ckit_cloudtool.FCloudtoolCall, model_p "task_specs": task_specs, }, indent=2) - configured = get_configured_integrations() - logger.info(f"Integration Tester started. Configured integrations: {[i['name'] for i in configured]}") + configured = shared.get_configured_integrations() + shared.logger.info(f"Integration Tester started. Configured integrations: {[i['name'] for i in configured]}") @rcx.on_updated_task - async def on_task_update(action: str, old_task, new_task): + async def on_task_update(action, old_task, new_task): task = new_task or old_task if not task: - logger.info(f"TASK UPDATE: {action} with no task payload") + shared.logger.info(f"TASK UPDATE: {action} with no task payload") return col = task.calc_bucket() title = task.ktask_title tid = task.ktask_id if col == "inprogress": - logger.info(f"TASK ASSIGNED: {title} (id={tid}) - will test now") + shared.logger.info(f"TASK ASSIGNED: {title} (id={tid}) - will test now") elif col == "done": - logger.info(f"TASK COMPLETED: {title} (id={tid})") + shared.logger.info(f"TASK COMPLETED: {title} (id={tid})") else: - logger.info(f"TASK UPDATE: {title} moved to {col} (id={tid})") + shared.logger.info(f"TASK UPDATE: {title} moved to {col} (id={tid})") while not ckit_shutdown.shutdown_event.is_set(): await rcx.unpark_collected_events(sleep_if_no_work=10.0) - logger.info(f"{rcx.persona.persona_id} exit") + shared.logger.info(f"{rcx.persona.persona_id} exit") def main(): @@ -319,21 +124,19 @@ def main(): from dotenv import load_dotenv load_dotenv() - from flexus_simple_bots.integration_tester import integration_tester_install - async def _install_compat(client: ckit_client.FlexusClient) -> int: await integration_tester_install.install( client, bot_name=BOT_NAME, bot_version=BOT_VERSION, - tools=TOOLS, + tools=shared.TOOLS, ) return 0 asyncio.run(ckit_bot_exec.run_bots_in_this_group( fclient, bot_main_loop=integration_tester_main_loop, - inprocess_tools=TOOLS, + inprocess_tools=shared.TOOLS, scenario_fn=scenario_fn, install_func=_install_compat, )) diff --git a/flexus_simple_bots/integration_tester/integration_tester_install.py b/flexus_simple_bots/integration_tester/integration_tester_install.py index e9f049af..5a83fcf2 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_install.py +++ b/flexus_simple_bots/integration_tester/integration_tester_install.py @@ -2,127 +2,101 @@ import json import logging import os -from pathlib import Path -from typing import Any, Dict, List +from typing import List -from flexus_client_kit import ckit_client, ckit_bot_install, ckit_cloudtool, ckit_skills, ckit_integrations_db +from flexus_client_kit import ckit_client, ckit_bot_install, ckit_cloudtool, ckit_skills from flexus_simple_bots import prompts_common -from flexus_client_kit.integrations import fi_newsapi, fi_resend +from flexus_simple_bots.integration_tester import integration_tester_shared as shared logger = logging.getLogger("integration_tester") -INTEGRATION_TESTER_ROOTDIR = Path(__file__).parent -INTEGRATION_TESTER_SKILLS = ckit_skills.static_skills_find(INTEGRATION_TESTER_ROOTDIR, shared_skills_allowlist="", integration_skills_allowlist="") - -from flexus_simple_bots.integration_tester.integration_tester_bot import ( - INTEGRATION_REGISTRY, - PLAN_BATCHES_TOOL, -) - -INTEGRATION_TESTER_INTEGRATIONS: list[ckit_integrations_db.IntegrationRecord] = ckit_integrations_db.static_integrations_load( - INTEGRATION_TESTER_ROOTDIR, - allowlist=["newsapi", "resend"], - builtin_skills=[], -) - - -def get_available_integrations() -> List[Dict[str, Any]]: - result = [] - for rec in INTEGRATION_TESTER_INTEGRATIONS: - name = rec.integr_name - reg = INTEGRATION_REGISTRY.get(name) - if not reg: - continue - key = os.environ.get(reg["env_var"]) - if not key and "alt_env_vars" in reg: - for alt in reg["alt_env_vars"]: - key = os.environ.get(alt) - if key: - break - if key: - result.append({ - "name": name, - "env_var": reg["env_var"], - "has_key": True, - "key_hint": key[-4:] if len(key) > 4 else "***", - }) - else: - result.append({ - "name": name, - "env_var": reg["env_var"], - "has_key": False, - "key_hint": None, - }) - return result +INTEGRATION_TESTER_SKILLS = ckit_skills.static_skills_find(shared.INTEGRATION_TESTER_ROOTDIR, shared_skills_allowlist="", integration_skills_allowlist="") def _build_experts(tools): - builtin_skills = ckit_skills.read_name_description(INTEGRATION_TESTER_ROOTDIR, INTEGRATION_TESTER_SKILLS) - tool_names = {reg["tool"].name for reg in INTEGRATION_REGISTRY.values()} - tool_names.add(PLAN_BATCHES_TOOL.name) + builtin_skills = ckit_skills.read_name_description(shared.INTEGRATION_TESTER_ROOTDIR, INTEGRATION_TESTER_SKILLS) + tool_names = {reg["tool"].name for reg in shared.INTEGRATION_REGISTRY.values()} + tool_names.add(shared.PLAN_BATCHES_TOOL.name) allow_tools = ",".join(tool_names | ckit_cloudtool.KANBAN_ADVANCED | {"flexus_hand_over_task"}) - available = get_available_integrations() - available_list = "\n".join([ - f"- {item['name']} ({item['env_var']}: ***{item['key_hint']})" if item['has_key'] - else f"- {item['name']} ({item['env_var']}: NOT SET)" - for item in available - ]) + default_prompt = """You are Integration Tester. Your job is to queue autonomous smoke tests for supported API-key integrations and then report the finished results clearly. - default_prompt = f"""You are Integration Tester. Test API key-based integrations via kanban fan-out. - -== AVAILABLE INTEGRATIONS == -{available_list} - -== RULES == -- Parse user request into integration list: "all", "newsapi", "resend". +Rules: +- Supported requests are: "all" or a comma-separated list of supported integration names. - First call integration_plan_batches(requested="...", batch_size=5, configured_only=true). -- Use returned task_specs and create tasks with flexus_hand_over_task(to_bot="Integration Tester", title=..., description=..., fexp_name="autonomous"). -- Do not execute test tools in this interactive chat after fan-out. -- After creating tasks, reply with a clean queue summary in this exact style: +- Use every returned task_spec to create a task with flexus_hand_over_task(to_bot="Integration Tester", title=..., description=..., fexp_name="autonomous"). +- Do not run integration tools in this interactive chat. This chat only plans work and reports completed task results. +- If nothing supported/configured was selected, explain that briefly and stop. +- Mention unsupported requested names if any. - Queued {{N}} batch covering {{X}} integrations: {{name1}} and {{name2}}. +After queueing tasks, reply in this format: +Queued {{N}} batch covering {{X}} integrations: {{name1}} and {{name2}}. - Detailed per-integration results (API key checks, method lists, counts) will appear here shortly from the autonomous worker. +Detailed per-integration results will appear here after the autonomous worker finishes. -- Note any unsupported integrations if they were requested. -- 💿-messages inform you when a handed-over task completes. When you see one, extract the resolution_summary and present it to the user as a markdown table (or plain summary if it is not a table). Do not just dump raw text. -- If no supported integrations were requested, explain supported options and stop. +When a completed-task message arrives: +- read resolution_summary +- present it as a markdown table if it is a table, otherwise give a short plain summary +- do not dump raw task metadata """ - autonomous_prompt = f"""You are Integration Tester autonomous worker. - -== AVAILABLE INTEGRATIONS == -{available_list} - -== TASK MODE == -- You are running with one assigned kanban task. -- Read assigned task title + description. -- Parse integration names from description line: "Integrations: name1,name2,...". -- If parsing fails, resolve task as FAILED with reason "Batch parse error". - -== EXECUTION == -For each integration in the batch: -1. You may call op="help" once to learn available operations, but do NOT treat help as the test result. -2. You MUST run a real read-only test afterwards. - - Best choices: list_methods, status, list, call a simple method. - - Never treat help, op="help", or documentation text as a successful test. - - If list/status returns 403/401/error, that integration FAILED. -3. Each tool result includes api_key_hint — include it. -4. Collect one concise details string per integration (operation + key metrics or error). - -== REPORT FORMAT == -When resolving, use a markdown table for the resolution_summary and final reply: - -| Integration | Status | Details | -|-------------|--------|---------| -| newsapi | PASSED | list_methods: api_key_hint=***dc9, method_ids=[newsapi.everything.v1, ...], total=126 | -| resend | FAILED | list: api_key_hint=***oGH, 403: dev bots must use their own Resend account | - -- Use PASSED only if all integrations in the batch passed; otherwise FAILED. -- Keep Details concise but informative (mention operation, api_key_hint, counts, method names, or error). -- After building the table, call flexus_kanban_advanced(op="resolve", args={{"task_id":"", "resolution_code":"PASSED"|"FAILED", "resolution_summary":""}}). -- Do not wait for user input. + autonomous_prompt = """You are Integration Tester autonomous worker. You own one kanban task and must finish it without asking the user anything. + +Task handling: +- Read the assigned task. +- Parse integration names from the description line: "Integrations: name1,name2,...". +- Read the optional mapping line: "Tool mapping: integration1->tool_name1, integration2->tool_name2". +- If a mapping line is present, use that tool name literally for the matching integration. +- Example: integration `resend` may map to tool `email_setup_domain`. +- If parsing fails, resolve the task as FAILED with summary "Batch parse error". +- You must finish this batch in the current task/thread. +- Do not hand over, delegate, split, or create sibling tasks for individual integrations. +- A delegated or promised future test does not count as a test result for this task. + +For each integration: +1. Call op="help" once to discover safe operations. +2. If available, you may call list_methods to inspect method names. +3. Then run at least one more real non-discovery read-only call. +3. Never use create, update, delete, send, or other state-changing operations. +4. Prefer status, list, get, search, or a simple call with harmless arguments. + +Operation classes: +- discovery/local ops: help, list_methods, and any status op that only reports local readiness, configured credentials, or known method counts +- provider-check ops: call, list, get, search, or a status op only when it clearly performs a real provider/API check instead of local metadata reporting +- prefer provider-check ops over discovery/local ops +- when using op="call", method_id must be copied literally from list_methods or help output +- never invent or guess method_id values +- op names such as help, status, list, or search are not valid method_id values unless they appear literally in the method list + +What counts as a real test: +- help is not a test +- list_methods is discovery, not a test +- a local/status readiness check is not a test if it only reports local metadata such as has_api_key, ready, configured, or method_count +- tool output starting with [HELP OUTPUT - NOT A TEST] is not a test +- you must not stop after help or list_methods +- the first call that can count as a test must be a provider-check op +- every integration listed in the task must get its own real non-discovery call +- the real non-discovery call must happen in this task/thread, not in another task +- the integration is UNTESTED if you only called help/list_methods or never made a non-discovery call +- if you skipped a listed integration, it is UNTESTED +- if you delegated a listed integration instead of testing it here, it is UNTESTED +- do not invent reasons such as "no safe method available" unless the tool itself explicitly told you that +- the integration is FAILED if a non-help call returns an error, including 401/403/auth problems +- the integration is PASSED if a provider-check op succeeds and returns concrete provider data or provider-backed metadata +- if op="call" fails with METHOD_UNKNOWN because you guessed method_id, read list_methods and retry once with a literal listed method_id before deciding the final status + +Report requirements: +- Build a markdown table with exactly these columns: Integration | Status | Details +- Status must be one of: PASSED, FAILED, UNTESTED +- Details must include the real provider-check operation you used, api_key_hint if present, and one concrete result such as count, returned object type, key fields, or error text +- Keep details concise and factual + +Resolve the task with flexus_kanban_advanced using: +- resolution_code=PASSED only if every listed integration is PASSED +- if any integration is FAILED or UNTESTED, resolution_code must be FAILED +- resolution_summary= + +Do not wait for user input. Do not leave the task unresolved. """ return [ @@ -150,30 +124,32 @@ def _build_experts(tools): **Job description** Integration Tester validates that Flexus API key-based integrations are properly configured and functional. -It tests newsapi and resend integrations using API keys from ENV_CONFIG. +It only tests integrations that are explicitly allowed for this bot and have API keys provided through ENV_CONFIG. **How it works:** 1. User starts a test session via "Test Integrations" button -2. Bot checks which API keys are configured -3. User selects what to test (all or specific) +2. Bot checks which supported integrations are configured +3. User selects what to test (all or specific supported integrations) 4. Bot creates deterministic kanban batch tasks in inbox -5. Autonomous worker resolves assigned batch tasks +5. Autonomous worker discovers safe operations, runs at least one real read-only API call per integration, and resolves the task with a table of results **What it tests:** -- newsapi: Calls sources endpoint to verify API key works -- resend: Lists domains to verify API key works +- Any integration included in this bot's supported allowlist +- Real read-only operations only +- No create/update/delete/send actions **Results:** -- PASSED: Integration responds correctly -- FAILED: API key invalid or integration unreachable +- PASSED: A real non-help read-only call succeeded +- FAILED: A real non-help call failed +- UNTESTED: Only discovery calls were made, so the integration was not actually tested """ def _ensure_marketplace_images() -> None: - pic_big_path = INTEGRATION_TESTER_ROOTDIR / "integration_tester-1024x1536.webp" - pic_small_path = INTEGRATION_TESTER_ROOTDIR / "integration_tester-256x256.webp" - fallback_big_path = INTEGRATION_TESTER_ROOTDIR.parent / "bob" / "bob-1024x1536.webp" - fallback_small_path = INTEGRATION_TESTER_ROOTDIR.parent / "bob" / "bob-256x256.webp" + pic_big_path = shared.INTEGRATION_TESTER_ROOTDIR / "integration_tester-1024x1536.webp" + pic_small_path = shared.INTEGRATION_TESTER_ROOTDIR / "integration_tester-256x256.webp" + fallback_big_path = shared.INTEGRATION_TESTER_ROOTDIR.parent / "bob" / "bob-1024x1536.webp" + fallback_small_path = shared.INTEGRATION_TESTER_ROOTDIR.parent / "bob" / "bob-256x256.webp" if not pic_big_path.exists() and fallback_big_path.exists(): pic_big_path.write_bytes(fallback_big_path.read_bytes()) @@ -187,7 +163,7 @@ async def install( bot_version: str, tools: List[ckit_cloudtool.CloudTool], ): - setup_schema_path = INTEGRATION_TESTER_ROOTDIR / "setup_schema.json" + setup_schema_path = shared.INTEGRATION_TESTER_ROOTDIR / "setup_schema.json" integration_tester_setup_default = json.loads(setup_schema_path.read_text()) _ensure_marketplace_images() @@ -197,7 +173,7 @@ async def install( await ckit_bot_install.marketplace_upsert_dev_bot( client, ws_id=client.ws_id, - bot_dir=INTEGRATION_TESTER_ROOTDIR, + bot_dir=shared.INTEGRATION_TESTER_ROOTDIR, marketable_title1="Integration Tester", marketable_title2="Test API key integrations", marketable_author="Flexus", @@ -227,7 +203,6 @@ async def install( if __name__ == "__main__": from dotenv import load_dotenv load_dotenv() - - from flexus_simple_bots.integration_tester import integration_tester_bot - client = ckit_client.FlexusClient(f"{integration_tester_bot.BOT_NAME}_install") - asyncio.run(install(client, bot_name=integration_tester_bot.BOT_NAME, bot_version=integration_tester_bot.BOT_VERSION, tools=integration_tester_bot.TOOLS)) + + client = ckit_client.FlexusClient("integration_tester_install") + asyncio.run(install(client, bot_name="integration_tester", bot_version="dev", tools=shared.TOOLS)) diff --git a/flexus_simple_bots/integration_tester/integration_tester_shared.py b/flexus_simple_bots/integration_tester/integration_tester_shared.py new file mode 100644 index 00000000..8f00358c --- /dev/null +++ b/flexus_simple_bots/integration_tester/integration_tester_shared.py @@ -0,0 +1,225 @@ +import json +import os +import logging +from pathlib import Path +from typing import Any, Dict, List + +from flexus_client_kit import ckit_cloudtool, ckit_integrations_db +from flexus_client_kit.integrations import fi_newsapi, fi_resend + +logger = logging.getLogger("integration_tester") + +INTEGRATION_TESTER_ROOTDIR = Path(__file__).parent + +PLAN_BATCHES_TOOL = ckit_cloudtool.CloudTool( + strict=True, + name="integration_plan_batches", + description="Plan deterministic integration test batches and return task specs for kanban fan-out.", + parameters={ + "type": "object", + "additionalProperties": False, + "properties": { + "requested": {"type": "string", "description": "Requested integrations, e.g. 'all' or 'newsapi,resend'."}, + "batch_size": {"type": "integer", "description": "Max integrations per task batch."}, + "configured_only": {"type": "boolean", "description": "If true, include only integrations with configured keys."}, + }, + "required": ["requested", "batch_size", "configured_only"], + }, +) + +NEWSAPI_TOOL = ckit_cloudtool.CloudTool( + strict=False, + name=fi_newsapi.PROVIDER_NAME, + description=f"{fi_newsapi.PROVIDER_NAME}: data provider. op=help|status|list_methods|call", + parameters={ + "type": "object", + "additionalProperties": False, + "properties": { + "op": {"type": "string", "enum": ["help", "status", "list_methods", "call"]}, + "args": { + "type": "object", + "additionalProperties": False, + "properties": { + "method_id": {"type": "string"}, + "include_raw": {"type": "boolean"}, + "q": {"type": "string"}, + "query": {"type": "string"}, + "sources": {"type": "string"}, + "domains": {"type": "string"}, + "excludeDomains": {"type": "string"}, + "from": {"type": "string"}, + "to": {"type": "string"}, + "language": {"type": "string"}, + "sortBy": {"type": "string"}, + "pageSize": {"type": "integer"}, + "page": {"type": "integer"}, + "country": {"type": "string"}, + "category": {"type": "string"}, + "time_window": {"type": "string"}, + "start_date": {"type": "string"}, + "end_date": {"type": "string"}, + }, + }, + }, + "required": ["op", "args"], + }, +) + +INTEGRATION_REGISTRY: Dict[str, Dict[str, Any]] = { + "newsapi": { + "env_var": "NEWSAPI_API_KEY", + "alt_env_vars": ["NEWSAPI_KEY"], + "tool": NEWSAPI_TOOL, + "integration_cls": fi_newsapi.IntegrationNewsapi, + "integration_args": lambda fclient, rcx, setup: (rcx,), + "handler_method": "called_by_model", + }, + "resend": { + "env_var": "RESEND_API_KEY", + "tool": fi_resend.RESEND_SETUP_TOOL, + "integration_cls": fi_resend.IntegrationResend, + "integration_args": lambda fclient, rcx, setup: (fclient, rcx, {}), + "handler_method": "setup_called_by_model", + }, +} + +INTEGRATION_TESTER_INTEGRATIONS: list[ckit_integrations_db.IntegrationRecord] = ckit_integrations_db.static_integrations_load( + INTEGRATION_TESTER_ROOTDIR, + allowlist=["newsapi", "resend"], + builtin_skills=[], +) + +TOOLS = [PLAN_BATCHES_TOOL] + [reg["tool"] for reg in INTEGRATION_REGISTRY.values()] + + +def _chunk_names(xs: List[str], n: int) -> List[List[str]]: + if n < 1: + n = 1 + return [xs[i:i + n] for i in range(0, len(xs), n)] + + +def _requested_names(raw: str) -> List[str]: + s = (raw or "").strip().lower() + if not s or s == "all": + return ["all"] + names = [] + for x in s.replace(";", ",").split(","): + x = x.strip() + if x: + names.append(x) + return names or ["all"] + + +def _setup_allowlist_names(setup: Dict[str, Any]) -> List[str]: + raw = str(setup.get("INTEGRATION_TESTER_ALLOWLIST", "") or "").strip().lower() + if not raw: + return [] + names: List[str] = [] + for x in raw.replace(";", ",").split(","): + x = x.strip() + if x: + names.append(x) + return names + + +def load_env_config(setup: Dict[str, Any]) -> None: + env_config = setup.get("ENV_CONFIG", "") + if not env_config: + logger.info("No ENV_CONFIG found in persona_setup") + return + count = 0 + for line in env_config.strip().split('\n'): + if '=' in line and not line.startswith('#'): + key, value = line.split('=', 1) + os.environ[key.strip()] = value.strip() + count += 1 + logger.info(f"Loaded {count} environment variables from ENV_CONFIG") + + +def get_configured_integrations() -> List[Dict[str, Any]]: + result = [] + for name, reg in INTEGRATION_REGISTRY.items(): + key = os.environ.get(reg["env_var"]) + if not key and "alt_env_vars" in reg: + for alt in reg["alt_env_vars"]: + key = os.environ.get(alt) + if key: + break + if key: + result.append({ + "name": name, + "env_var": reg["env_var"], + "key_hint": key[-4:] if len(key) > 4 else "***", + }) + return result + + +def _resolve_api_key(env_var: str, alt_env_vars: list[str]) -> str | None: + key = os.environ.get(env_var) + if not key: + for alt in alt_env_vars: + key = os.environ.get(alt) + if key: + break + return key + + +def classify_error(e: Exception) -> tuple[str, str]: + msg = str(e).lower() + if any(k in msg for k in ("401", "403", "unauthorized", "invalid api", "forbidden", "invalid_api")): + return "AUTH_ERROR", "API key invalid or unauthorized" + if any(k in msg for k in ("timeout", "connection", "dns", "network", "connect", "refused")): + return "NETWORK_ERROR", "Network/connectivity issue" + if any(k in msg for k in ("rate", "429", "quota", "limit")): + return "RATE_LIMIT", "API rate limit or quota exceeded" + return "UNKNOWN_ERROR", str(e)[:200] + + +class IntegrationHandler: + def __init__(self, reg: Dict[str, Any], obj: Any): + self.env_var = reg["env_var"] + self.alt_env_vars = reg.get("alt_env_vars", []) + self.handler_method = reg["handler_method"] + self.tool_name = reg["tool"].name + self.obj = obj + + @staticmethod + def _format_result(raw: str) -> str: + try: + data = json.loads(raw) + if isinstance(data, dict): + skip = {"ok", "provider", "description", "help_text"} + parts = [] + for k, v in data.items(): + if k in skip: + continue + if isinstance(v, list) and v and all(isinstance(x, str) for x in v): + parts.append(f"{k}=[{', '.join(v)}]") + elif not isinstance(v, (dict, list)): + parts.append(f"{k}={v}") + if parts: + return ", ".join(parts) + except json.JSONDecodeError: + pass + return raw + + async def __call__(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + key = _resolve_api_key(self.env_var, self.alt_env_vars) + logger.info(f"Testing {self.tool_name} - API key present: {bool(key)}") + if not key: + logger.warning(f"{self.tool_name} test FAILED - no API key configured") + return f"Error [AUTH_ERROR]: {self.env_var} not configured. Resolve the kanban task as FAILED with status: 'FAILED - No API key configured for {self.tool_name}'" + try: + result = await getattr(self.obj, self.handler_method)(toolcall, model_produced_args) + op = str(model_produced_args.get("op", "")).strip() if model_produced_args else "" + if op == "help": + result = "[HELP OUTPUT - NOT A TEST] " + result + formatted = self._format_result(result) + key_hint = key[-3:] if len(key) > 3 else "***" + out = f"api_key_hint=***{key_hint}, {formatted}" + logger.info(f"{self.tool_name} test result: {out[:120]}..." if len(out) > 120 else f"{self.tool_name} test result: {out}") + return out + except Exception as e: + category, detail = classify_error(e) + logger.error(f"toolcall_{self.tool_name}: {category}: {detail}", exc_info=True) + return f"Error [{category}]: {detail}" From 4ca454821c3c151fb6e0477070cff1183a0ba9e0 Mon Sep 17 00:00:00 2001 From: oxyplay Date: Mon, 13 Apr 2026 14:33:39 +0100 Subject: [PATCH 14/14] scenarios update + filter --- flexus_simple_bots/integration_tester/autonomous__failed.yaml | 2 +- .../integration_tester/autonomous__untested.yaml | 4 ++-- .../integration_tester/integration_tester__s1.yaml | 4 ++-- .../integration_tester/integration_tester_bot.py | 2 ++ 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/flexus_simple_bots/integration_tester/autonomous__failed.yaml b/flexus_simple_bots/integration_tester/autonomous__failed.yaml index 0e483733..c25c4015 100644 --- a/flexus_simple_bots/integration_tester/autonomous__failed.yaml +++ b/flexus_simple_bots/integration_tester/autonomous__failed.yaml @@ -17,7 +17,7 @@ messages: "id": "int_test_002", "title": "Test integrations batch 1/1", "details": { - "description": "Integrations: newsapi" + "description": "Integrations: newsapi\nTool mapping: newsapi->newsapi" } } - role: assistant diff --git a/flexus_simple_bots/integration_tester/autonomous__untested.yaml b/flexus_simple_bots/integration_tester/autonomous__untested.yaml index 21f65b90..d026d4be 100644 --- a/flexus_simple_bots/integration_tester/autonomous__untested.yaml +++ b/flexus_simple_bots/integration_tester/autonomous__untested.yaml @@ -17,7 +17,7 @@ messages: "id": "int_test_001", "title": "Test integrations batch 1/1", "details": { - "description": "Integrations: newsapi" + "description": "Integrations: newsapi\nTool mapping: newsapi->newsapi" } } - role: assistant @@ -39,7 +39,7 @@ messages: type: function - role: tool call_id: call_methods_001 - content: 'api_key_hint=***dc9, method_ids=[newsapi.everything.v1, newsapi.top-headlines.v1]' + content: 'api_key_hint=***dc9, method_ids=[newsapi.everything.v1, newsapi.top_headlines.v1, newsapi.sources.v1]' - role: assistant tool_calls: - function: diff --git a/flexus_simple_bots/integration_tester/integration_tester__s1.yaml b/flexus_simple_bots/integration_tester/integration_tester__s1.yaml index 49452274..4b74e92c 100644 --- a/flexus_simple_bots/integration_tester/integration_tester__s1.yaml +++ b/flexus_simple_bots/integration_tester/integration_tester__s1.yaml @@ -24,7 +24,7 @@ messages: "task_specs": [ { "title": "Test integrations batch 1/1", - "description": "Integrations: newsapi,resend", + "description": "Integrations: newsapi,resend\nTool mapping: newsapi->newsapi, resend->email_setup_domain", "integrations": ["newsapi", "resend"] } ] @@ -32,7 +32,7 @@ messages: - role: assistant tool_calls: - function: - arguments: '{"description":"Integrations: newsapi,resend","fexp_name":"autonomous","title":"Test integrations batch 1/1","to_bot":"Integration Tester"}' + arguments: '{"description":"Integrations: newsapi,resend\nTool mapping: newsapi->newsapi, resend->email_setup_domain","fexp_name":"autonomous","title":"Test integrations batch 1/1","to_bot":"Integration Tester"}' name: flexus_hand_over_task id: call_handover_001 type: function diff --git a/flexus_simple_bots/integration_tester/integration_tester_bot.py b/flexus_simple_bots/integration_tester/integration_tester_bot.py index 641415a6..d3bf7a44 100644 --- a/flexus_simple_bots/integration_tester/integration_tester_bot.py +++ b/flexus_simple_bots/integration_tester/integration_tester_bot.py @@ -34,6 +34,8 @@ async def integration_tester_main_loop( supported_integrations = sorted({r.integr_name for r in integr_records}) for name, reg in shared.INTEGRATION_REGISTRY.items(): + if name not in supported_integrations: + continue obj = reg["integration_cls"](*reg["integration_args"](fclient, rcx, setup)) rcx.on_tool_call(reg["tool"].name)(shared.IntegrationHandler(reg, obj))