File size: 82,408 Bytes
2695555
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
# openTARS — nucleo/chat.py
# O laço de conversa com ferramentas: turnos, falhas, troca de modelo, visão.
#
# Parte do núcleo: o tars.py executa este arquivo DENTRO do próprio
# namespace (ver _carregar_nucleo), então tudo aqui é tars.<nome> e as
# partes enxergam umas às outras sem import. Os testes que trocam
# tars.<função> por uma falsa continuam valendo pra todas as partes.
# flake8: noqa

# ============================================================
# CHAT COM OLLAMA
# ============================================================

_MODELOS_SEM_THINKING = set()

# Modelos que confirmadamente não aceitam o parâmetro 'tools' (ex:
# gemma3:270m). Sem isso, escolher um modelo assim quebrava TODA
# mensagem com um 400 do Ollama — o openTARS sempre manda 'tools'.
_MODELOS_SEM_TOOLS = set()

# Modelos que não enxergam imagem (sem 'vision' no catálogo, ou o Ollama
# respondeu "does not support multimodal"). O print de tela não vai pra
# eles: o modelo de visão (o Gargantua 2) descreve a tela em texto.
_MODELOS_SEM_VISAO = set()


def modelo_enxerga(modelo):
    if modelo in _MODELOS_SEM_VISAO:
        return False
    try:
        info = catalogar_modelos().get(modelo)
    except Exception:
        info = None
    if info and info.get("capacidades_conhecidas"):
        return "vision" in info["capacidades"]
    return True  # não sabemos: tenta (e aprende com o erro)


def _erro_de_imagem(erro):
    """O modelo não aceita imagem nenhuma. Diferente de "tools not supported
    with images" (Ollama antigo): aí a imagem serve, as ferramentas não."""
    e = (erro or "").lower()
    if "tool" in e:
        return "multimodal" in e
    return "multimodal" in e or (("image" in e or "vision" in e) and "support" in e)


_visao_avisado = {"em": 0.0}


def _avisar_falha_da_visao():
    """Se o Cooper está instalado mas não respondeu, o usuário vê o motivo
    (e que outro modelo assume), em vez de um fallback silencioso."""
    if not _visao_estado["erro"] or not visao_instalada():
        return
    agora = time.time()
    if agora - _visao_avisado["em"] < 20:
        return
    _visao_avisado["em"] = agora
    print(c(f"[openTARS] {t('visao.sem_resposta', motivo=motivo_da_falha_da_visao())}", Cor.AMARELO))


def _descrever_print(imagem_b64, pedido, modelo_atual):
    """Texto descrevendo o print (janelas, botões, textos e posições em
    pixels DESTA imagem), feito pelo modelo de visão (o Gargantua 2). None se ele não estiver
    instalado ou não responder."""
    texto_cooper, visao_tag = descrever_com_visao(imagem_b64, pedido)
    if texto_cooper:
        print(c(f"[openTARS] {t('msg.descrevendo_tela', modelo=modelo_atual, visao=visao_tag)}", Cor.AMARELO))
        return texto_cooper, visao_tag
    _avisar_falha_da_visao()
    return None, None


def _print_em_texto(imagem_b64, pedido, modelo, janela=None, arquivo=None):
    """Mensagem (sem imagem) que substitui o print pra um modelo que não
    enxerga."""
    descricao, visao = _descrever_print(imagem_b64, pedido, modelo)
    if descricao and arquivo:
        return (
            "[Automatic message from openTARS, not from the user] You cannot see images, so "
            f"the vision model ({visao}) looked at the image file '{arquivo}' for you:\n{descricao}\n\n"
            f"This is a picture, not the screen: don't click on it. Answer the user's request: \"{pedido}\"."
        )
    if descricao:
        if janela and _sem_arvore(janela):
            # Os nomes da descrição NÃO servem pro click_element nesse app.
            como = ("This app does not expose its buttons: click with click_mouse(x, y, target=<what it is>) "
                    "using the coordinates above (they are approximate: 'target' makes openTARS aim exactly), "
                    "and write with type_in_element(text=..., window=..., submit=true).")
        else:
            como = ("To click, use click_element(name) if the app exposes its buttons; if it fails, "
                    "use click_mouse(x, y, target=<what it is>) with the coordinates above (approximate: "
                    "'target' makes openTARS aim exactly). To drag, use drag_mouse. To write, use type_text.")
        return (
            "[Automatic message from openTARS, not from the user] You cannot see images, so "
            f"the vision model ({visao}) described the screenshot you took:\n{descricao}\n\n"
            f"Continue the user's request: \"{pedido}\". {como}"
        )
    if arquivo:
        return (
            "[Automatic message from openTARS, not from the user] The image file "
            f"'{arquivo}' was loaded, but you cannot see images and the vision model "
            + ("did not answer this time" if visao_instalada() else "is not installed")
            + ". Don't retry. Tell the user plainly that you could not look at the image, and that Gargantua 2 "
            "is the model that lets you see it (ollama pull ProjectOpenTARS/Gargantua-2). "
            f"The user's request was: \"{pedido}\"."
        )
    return (
        "[Automatic message from openTARS, not from the user] The screenshot was taken, but "
        "you cannot see images and the vision model "
        + ("did not answer this time" if visao_instalada() else "is not installed")
        + ". Don't retry the screenshot: "
        "use list_elements(window=<app>) to read a window's buttons and texts, and "
        f"click_element to press them. Continue the user's request: \"{pedido}\"."
    )


def _e_mensagem_de_print(m):
    """Mensagem com o print que o openTARS tirou (e não imagem anexada pelo usuário)."""
    return (m.get("content") or "").startswith("[Automatic message from openTARS")


def _tirar_imagens_da_conversa(mensagens, pedido, modelo):
    """Troca toda imagem da conversa por texto (o modelo recusou imagem)."""
    for m in mensagens:
        imagens = m.pop("images", None)
        if imagens and _e_mensagem_de_print(m):
            m["content"] = _print_em_texto(imagens[-1], pedido, modelo)
        # (imagem anexada pelo usuário: só some a imagem; o texto do pedido e do arquivo ficam)


# Pedido de parar a resposta atual (botão "Parar" da janela, Ctrl+C).
_cancelar = threading.Event()


class RespostaCancelada(Exception):
    pass


# O que perguntar_modelo devolve quando o usuário para a resposta.
RESPOSTA_INTERROMPIDA = "(interrompido)"


_resposta_ativa = {"r": None}


def cancelar_resposta():
    """Interrompe a resposta/tarefa em andamento assim que possível: no
    meio do texto, enquanto o Ollama ainda lê a conversa (fecha a
    conexão), ou entre uma ferramenta e outra."""
    _cancelar.set()
    resposta = _resposta_ativa["r"]
    if resposta is not None:
        try:
            resposta.close()
        except Exception:
            pass


def _verificar_cancelamento():
    if _cancelar.is_set():
        raise RespostaCancelada()


def _preparar_payload_ollama(payload):
    """Remove de antemão os parâmetros que a gente já sabe (pelo
    catálogo, via /api/show, ou por uma falha anterior nesta sessão)
    que esse modelo não aceita."""

    modelo = payload.get("model")
    payload = dict(payload)

    try:
        info = catalogar_modelos().get(modelo)
    except Exception:
        info = None

    if info and info.get("capacidades_conhecidas"):
        capacidades = info["capacidades"]
        if "tools" not in capacidades:
            _MODELOS_SEM_TOOLS.add(modelo)
        if "thinking" not in capacidades:
            _MODELOS_SEM_THINKING.add(modelo)

    if modelo in _MODELOS_SEM_THINKING:
        payload.pop("think", None)

    if modelo in _MODELOS_SEM_TOOLS:
        payload.pop("tools", None)

    return payload


def _erro_de_recurso_nao_suportado(texto, recurso):
    t = (texto or "").lower()
    return recurso in t and ("support" in t or "suport" in t)


def _post_cancelavel(fazer):
    """Roda o POST (que só volta quando o Ollama manda os primeiros bytes:
    carregando o modelo, lendo o prompt) numa thread e confere o botão
    Parar a cada 0,1 s. Sem isso o Parar não valia até o Ollama responder."""
    caixa = {}

    def alvo():
        try:
            caixa["r"] = fazer()
        except Exception as e:  # noqa: BLE001
            caixa["e"] = e

    fio = threading.Thread(target=alvo, daemon=True)
    fio.start()
    while fio.is_alive():
        fio.join(0.1)
        if _cancelar.is_set():
            def fechar_depois():
                fio.join()
                r = caixa.get("r")
                if r is not None:
                    try:
                        r.close()
                    except Exception:
                        pass
            threading.Thread(target=fechar_depois, daemon=True).start()
            raise RespostaCancelada()
    if "e" in caixa:
        raise caixa["e"]
    return caixa["r"]


def _chamar_ollama_stream(payload):
    """Chamada de streaming ao Ollama. Se o Ollama disser que o modelo
    não suporta 'think' ou 'tools', tenta de novo sem isso e lembra pro
    resto da sessão.

    Antes qualquer erro (falta de memória, Ollama reiniciando) disparava
    as tentativas sem think/tools, e um sucesso depois de uma falha
    passageira marcava o modelo como "sem ferramentas" pra sempre."""

    modelo = payload.get("model")
    tentativa = _preparar_payload_ollama(payload)

    for _ in range(3):
        try:
            resposta = _post_cancelavel(lambda: ollama_request(tentativa, stream=True))
        except RespostaCancelada:
            raise
        except Exception as e:
            return None, t("msg.erro_comunicar_ollama", erro=e)

        if resposta.status_code == 200:
            return resposta, None

        try:
            detalhe = resposta.text
        except Exception:
            detalhe = ""

        if "think" in tentativa and _erro_de_recurso_nao_suportado(detalhe, "think"):
            _MODELOS_SEM_THINKING.add(modelo)
            tentativa = {k: v for k, v in tentativa.items() if k != "think"}
            continue

        if "tools" in tentativa and _erro_de_recurso_nao_suportado(detalhe, "tool"):
            _MODELOS_SEM_TOOLS.add(modelo)
            tentativa = {k: v for k, v in tentativa.items() if k != "tools"}
            continue

        return None, t("msg.erro_ollama_http", codigo=resposta.status_code, detalhe=detalhe.strip() or "-")

    return None, t("msg.ollama_recusou")


# ------------------------------------------------------------
# 4.0: raciocínio que vaza no texto da resposta
# ------------------------------------------------------------
# Quando o Ollama não separa o raciocínio (modelo importado de um .gguf,
# formato de chat que ele não reconhece), o "pensamento" vem misturado na
# resposta: "<think>...</think>", ou só o texto em inglês terminando num
# "</think>" solto (a tag de abertura some). O filtro separa de novo: o
# raciocínio vai cinza, a resposta fica limpa e nenhuma tag aparece.

_ABRE_THINK, _FECHA_THINK = "<think>", "</think>"
# Começos típicos de raciocínio (qwen3, deepseek-r1...) quando a tag de abertura sumiu.
_PARECE_RACIOCINIO = re.compile(
    r"^\s*(okay|ok|alright|all right|hmm+|let me|let's see|so,? the user|the user|first,? i|"
    r"i need to|i should|wait,|we need to)\b", re.I)
_DECIDIR_COM = 60   # caracteres guardados antes de decidir se é raciocínio


class FiltroPensamento:
    """Recebe os pedaços de 'content' e devolve [(tipo, texto)], com tipo
    'pensamento', 'resposta' ou 'era_pensamento' (o que já foi mostrado como
    resposta era raciocínio: a janela pinta de cinza)."""

    def __init__(self):
        self.estado = "decidindo"      # decidindo | pensando | respondendo
        self.buffer = ""
        self.pensamento = []
        self.resposta = []
        self.chutou_pensamento = False  # entrou em 'pensando' sem ver <think>

    @staticmethod
    def _sem_tags(texto):
        return texto.replace(_ABRE_THINK, "").replace(_FECHA_THINK, "")

    def _cauda_segura(self, texto):
        """Parte que dá pra mostrar sem cortar uma tag no meio ('</thi' + 'nk>')."""
        for n in range(min(len(texto), len(_FECHA_THINK) - 1), 0, -1):
            if (_FECHA_THINK.startswith(texto[-n:]) or _ABRE_THINK.startswith(texto[-n:])):
                return texto[:-n], texto[-n:]
        return texto, ""

    def _pensar(self, texto):
        texto = self._sem_tags(texto)
        if texto:
            self.pensamento.append(texto)
            return [("pensamento", texto)]
        return []

    def _responder(self, texto):
        texto = self._sem_tags(texto)
        if not self.resposta:
            texto = texto.lstrip()  # "</think>\n\n" deixa quebras de linha antes da resposta
        if texto:
            self.resposta.append(texto)
            return [("resposta", texto)]
        return []

    def alimentar(self, pedaco):
        self.buffer += pedaco
        saida = []
        while True:
            if self.estado == "decidindo":
                b = self.buffer
                if _FECHA_THINK in b:
                    antes, _, depois = b.partition(_FECHA_THINK)
                    saida += self._pensar(antes)
                    self.estado, self.buffer = "respondendo", depois.lstrip()
                    continue
                if b.lstrip().startswith(_ABRE_THINK):
                    self.estado, self.buffer = "pensando", b.lstrip()[len(_ABRE_THINK):]
                    continue
                if len(b) < _DECIDIR_COM and not (_ABRE_THINK.startswith(b.lstrip()) and b.strip()) and "\n" not in b.strip():
                    return saida  # ainda pouco texto pra saber
                if _ABRE_THINK.startswith(b.lstrip()) and b.strip():
                    return saida  # pode ser o começo de "<think>"
                if _PARECE_RACIOCINIO.match(b):
                    self.estado, self.chutou_pensamento = "pensando", True
                else:
                    self.estado = "respondendo"
                continue
            if self.estado == "pensando":
                if _FECHA_THINK in self.buffer:
                    antes, _, depois = self.buffer.partition(_FECHA_THINK)
                    saida += self._pensar(antes)
                    self.estado, self.buffer, self.chutou_pensamento = "respondendo", depois.lstrip(), False
                    continue
                mostrar, self.buffer = self._cauda_segura(self.buffer)
                return saida + self._pensar(mostrar)
            # respondendo
            if _FECHA_THINK in self.buffer:
                # O que foi mostrado como resposta era raciocínio (a tag de abertura sumiu).
                antes, _, depois = self.buffer.partition(_FECHA_THINK)
                ja = "".join(self.resposta) + self._sem_tags(antes)
                self.resposta = []
                if ja.strip():
                    self.pensamento.append(ja)
                    saida.append(("era_pensamento", ja))
                self.buffer = depois.lstrip()
                continue
            if _ABRE_THINK in self.buffer:
                antes, _, depois = self.buffer.partition(_ABRE_THINK)
                saida += self._responder(antes)
                self.estado, self.buffer = "pensando", depois
                continue
            mostrar, self.buffer = self._cauda_segura(self.buffer)
            return saida + self._responder(mostrar)

    def terminar(self):
        """Fim do stream: solta o que sobrou. Se chutou 'raciocínio' e ele
        nunca fechou, era a resposta mesmo."""
        saida = []
        if self.estado == "decidindo":
            saida += self._responder(self.buffer)
        elif self.estado == "pensando":
            saida += self._pensar(self.buffer)
            if self.chutou_pensamento and not self.resposta:
                texto = "".join(self.pensamento)
                self.pensamento = []
                saida += self._responder(texto)
        else:
            saida += self._responder(self.buffer)
        self.buffer = ""
        return saida

    @property
    def conteudo(self):
        return "".join(self.resposta).strip()

    @property
    def texto_pensado(self):
        return "".join(self.pensamento).strip()


# Modelos que raciocinam MESMO com think=false (um fine-tune cujo molde não
# conhece o parâmetro): pra eles o pedido já vai com o interruptor /no_think
# do Qwen3. O openTARS aprende isso sozinho: se um turno sem pensar começa a
# raciocinar por mais que ORCAMENTO_PENSAMENTO_SEM_PEDIR, o turno é cortado
# e refeito com o interruptor (uma vez), e o modelo fica na lista.
_PENSA_MESMO_SEM_PEDIR = set()
# A lista fica em disco: sem isso cada abertura do openTARS pagava de novo um
# turno cortado e refeito (segundos jogados fora) até reaprender.
_pensa_carregado = {"ok": False}


def _arquivo_pensa():
    return os.path.join(os.path.dirname(os.path.abspath(str(ARQUIVO_SESSOES))), ".tars_pensa_sem_pedir.json")


def _carregar_pensa_sem_pedir():
    if _pensa_carregado["ok"]:
        return
    _pensa_carregado["ok"] = True
    try:
        with open(_arquivo_pensa(), encoding="utf-8") as f:
            _PENSA_MESMO_SEM_PEDIR.update(x for x in json.load(f) if isinstance(x, str))
    except Exception:
        pass


def _lembrar_que_pensa(modelo):
    _PENSA_MESMO_SEM_PEDIR.add(modelo)
    try:
        with open(_arquivo_pensa(), "w", encoding="utf-8") as f:
            json.dump(sorted(_PENSA_MESMO_SEM_PEDIR), f)
    except Exception:
        pass
ORCAMENTO_PENSAMENTO_SEM_PEDIR = 500   # caracteres de raciocínio tolerados num turno sem pensar
_NO_THINK = "\n/no_think"


def _modelo_pensa_de_verdade(modelo):
    """Modelo da família que raciocina (Qwen3, Gargantua, ou o Ollama diz 'thinking'):
    só nele um "Okay, the user wants..." sem <think> é raciocínio de verdade, e não
    uma resposta normal que começa assim (gemma, llama...)."""
    if eh_gargantua(modelo) or "qwen3" in str(modelo).lower() or "deepseek-r1" in str(modelo).lower():
        return True
    info = (_cache_catalogo.get("dados") or {}).get(modelo) or {}
    return "thinking" in info.get("capacidades", ())


def _mensagens_sem_pensar(mensagens):
    """Cópia da conversa com /no_think na última mensagem do usuário (só pro
    pedido; o histórico guardado não muda)."""
    copia = [dict(m) for m in mensagens]
    for m in reversed(copia):
        if m.get("role") == "user" and isinstance(m.get("content"), str):
            if not m["content"].rstrip().endswith("/no_think"):
                m["content"] = m["content"].rstrip() + _NO_THINK
            break
    return copia


def _familia_do_modelo(modelo):
    """A família que o Ollama informa ('qwen3', 'qwen35', ...). Lê o catálogo se ainda não foi lido: no modo de modelo fixo
    (o da prova do Lab) ele não era, a família vinha vazia, o Gargantua em Qwen3.5 ia sem think=false, pensava em vez de
    agir e devolvia resposta vazia (5 das 84 tarefas em 17/10). '' se não souber."""
    nomes = (modelo, modelo if ":" in str(modelo) else f"{modelo}:latest")
    dados = _cache_catalogo.get("dados")
    if not dados or not any(n in dados for n in nomes):
        try:
            dados = catalogar_modelos() or {}
        except Exception:  # noqa: BLE001  (Ollama fora do ar: o pedido falha depois, com a mensagem de sempre)
            dados = dados or {}
    info = next((dados[n] for n in nomes if n in dados), {})
    return str(info.get("familia") or "").lower()


def _executar_turno_streaming(modelo, mensagens, tools, pensar=True):
    """Envia a conversa ao Ollama em modo streaming e vai mostrando o
    pensamento e a resposta em tempo real. Devolve conteúdo, pensamento
    e as tool_calls. pensar=False desliga o raciocínio dos modelos que
    pensam (qwen3...): ver deve_pensar()."""

    if api.eh_modelo_api(modelo):
        return _turno_api(modelo, mensagens, tools, pensar)

    _carregar_pensa_sem_pedir()
    # o "/no_think" é truque do Qwen3; o Qwen3.5 não conhece e o lê como parte do pedido (17/10: abriu o site "/no_think" e
    # tentou "apt install no_think"). Ele obedece ao think=false.
    forcar_nothink = (not pensar) and modelo in _PENSA_MESMO_SEM_PEDIR and _familia_do_modelo(modelo) != "qwen35"
    payload = {
        "model": modelo,
        "messages": _mensagens_sem_pensar(mensagens) if forcar_nothink else mensagens,
        "stream": True,
        "keep_alive": KEEP_ALIVE,
        "think": bool(pensar),
        "options": {"temperature": temperatura_conversa(), "num_ctx": contexto_ia()},
    }
    if eh_gargantua(modelo) and not pensar and _familia_do_modelo(modelo) != "qwen35":
        # Treinado sem raciocínio: com think=false o molde do .gguf abre a resposta com "<think></think>", que no treino só
        # vinha antes da resposta final em texto, e o modelo respondia sem chamar ferramenta (prova de 14/10/2026).
        # 17/10: o Gargantua em Qwen3.5 é o contrário: o molde dele põe o <think> vazio também antes das ferramentas e o
        # renderer do Ollama PENSA quando o campo não vem (12/21 sem o campo, 15/21 com think=false no ensaio).
        payload.pop("think")

    if tools:
        payload["tools"] = tools
    if eh_gargantua(modelo) and confianca_lib.ligado():
        payload["logprobs"] = True      # anti-alucinação (5.2): a confiança do modelo na própria resposta, sem custo extra

    resposta, erro = _chamar_ollama_stream(payload)

    if erro:
        return None, erro

    partes_pensamento = []
    tool_calls = []
    logprobs = []

    pensando_ativo = False
    escrevendo_ativo = False
    filtro = FiltroPensamento()

    concluido = False
    _resposta_ativa["r"] = resposta
    try:
        if _cancelar.is_set():
            raise RespostaCancelada()

        for linha in resposta.iter_lines():

            if _cancelar.is_set():
                resposta.close()
                if pensando_ativo or escrevendo_ativo:
                    emitir("fim_stream")
                raise RespostaCancelada()

            if not linha:
                continue

            try:
                chunk = json.loads(linha)
            except Exception:
                continue

            if not isinstance(chunk, dict):
                continue

            if chunk.get("error"):
                if pensando_ativo or escrevendo_ativo:
                    emitir("fim_stream")
                return None, t("msg.erro_ollama", detalhe=chunk["error"])

            msg = chunk.get("message", {})
            msg = msg if isinstance(msg, dict) else {}

            pensamento = msg.get("thinking")

            if pensamento:
                emitir("pensamento", texto=pensamento, inicio=not pensando_ativo)
                pensando_ativo = True
                partes_pensamento.append(pensamento)

            if (not pensar and not forcar_nothink and not tool_calls
                    and sum(len(x) for x in partes_pensamento) > ORCAMENTO_PENSAMENTO_SEM_PEDIR):  # campo 'thinking' de verdade
                # raciocinando sem ninguém pedir: corta e refaz com /no_think
                _lembrar_que_pensa(modelo)
                resposta.close()
                if pensando_ativo or escrevendo_ativo:
                    emitir("fim_stream")
                print(c(f"[openTARS] {t('msg.cortando_raciocinio', modelo=modelo)}", Cor.CINZA))
                return _executar_turno_streaming(modelo, mensagens, tools, pensar)

            if chunk.get("logprobs") and len(logprobs) < confianca_lib.JANELA:
                logprobs.extend(confianca_lib.logprobs_do_pedaco(chunk["logprobs"]))

            pedaco = msg.get("content")

            if pedaco:
                for tipo, texto in filtro.alimentar(pedaco):
                    pensando_ativo, escrevendo_ativo = _mostrar_trecho(tipo, texto, pensando_ativo, escrevendo_ativo)
                if (not pensar and not forcar_nothink and not tool_calls and filtro.estado == "pensando"
                        and (not filtro.chutou_pensamento or _modelo_pensa_de_verdade(modelo))
                        and sum(len(x) for x in filtro.pensamento) > ORCAMENTO_PENSAMENTO_SEM_PEDIR):
                    _lembrar_que_pensa(modelo)
                    resposta.close()
                    if pensando_ativo or escrevendo_ativo:
                        emitir("fim_stream")
                    print(c(f"[openTARS] {t('msg.cortando_raciocinio', modelo=modelo)}", Cor.CINZA))
                    return _executar_turno_streaming(modelo, mensagens, tools, pensar)

            # Versões novas do Ollama podem mandar as chamadas de
            # ferramenta em mais de um pedaço: junta todas.
            if msg.get("tool_calls"):
                tool_calls.extend(msg["tool_calls"])

            if chunk.get("done"):
                if MOSTRAR_TEMPOS and chunk.get("total_duration"):
                    total_s = float(chunk["total_duration"]) / 1e9
                    if total_s >= 1.5:
                        print(c(f"\n[openTARS] {modelo}: {_resumo_de_tempos(chunk, total_s)}", Cor.CINZA))
                if chunk.get("done_reason") == "length":
                    print(c(f"\n[openTARS] {t('msg.limite_contexto', tokens=contexto_ia())}", Cor.AMARELO))
                concluido = True
                break

    except RespostaCancelada:
        raise
    except Exception as e:
        if _cancelar.is_set():  # conexão fechada pelo botão Parar
            if pensando_ativo or escrevendo_ativo:
                emitir("fim_stream")
            raise RespostaCancelada()
        if pensando_ativo or escrevendo_ativo:
            emitir("fim_stream")
        return None, t("msg.erro_streaming", erro=e)
    finally:
        _resposta_ativa["r"] = None
        try:
            resposta.close()          # solta a conexão em qualquer saída (erro, cancelamento, fim)
        except Exception:
            pass

    if not concluido:
        # o Ollama fechou a conexão sem mandar "done" (reiniciou, caiu): o texto parcial NÃO é uma resposta completa
        if pensando_ativo or escrevendo_ativo:
            emitir("fim_stream")
        return None, t("msg.erro_streaming", erro="connection closed before the answer was complete")

    for tipo, texto in filtro.terminar():
        pensando_ativo, escrevendo_ativo = _mostrar_trecho(tipo, texto, pensando_ativo, escrevendo_ativo)

    if pensando_ativo or escrevendo_ativo:
        emitir("fim_stream")

    pensado = "".join(partes_pensamento).strip()
    if filtro.texto_pensado:
        pensado = (pensado + "\n" + filtro.texto_pensado).strip()
    return {
        "content": filtro.conteudo,
        "thinking": pensado,
        "tool_calls": tool_calls,
        "logprobs": logprobs,
    }, None


def _mostrar_trecho(tipo, texto, pensando_ativo, escrevendo_ativo):
    """Manda um trecho separado pelo FiltroPensamento pra tela."""
    if tipo == "pensamento":
        emitir("pensamento", texto=texto, inicio=not pensando_ativo)
        return True, escrevendo_ativo
    if tipo == "era_pensamento":
        emitir("resposta_era_pensamento", texto=texto)
        return True, False
    emitir("resposta", texto=texto, inicio=not escrevendo_ativo, apos_pensamento=pensando_ativo)
    return pensando_ativo, True


LIMITE_CUTUCADAS_RESPOSTA_VAZIA = 2

# Falhas seguidas: a partir de 3 da MESMA ferramenta, a IA é mandada
# mudar de estratégia; com 8 de qualquer uma, para e explica ao usuário.
LIMITE_FALHAS_MESMA_FERRAMENTA = 3
LIMITE_FALHAS_SEGUIDAS = 8


# Mensagens automáticas (vão pra IA, com role=user): começam com "[" —
# é assim que _e_pedido_do_usuario sabe que não foi a pessoa que escreveu.

def _mensagem_do_print(pedido, arquivo=None):
    if arquivo:
        return (
            "[Automatic message from openTARS, not from the user] This is the image file "
            f"'{arquivo}' you asked to look at. It is a picture, not the screen: don't click on it. "
            f"Answer the user's request: \"{pedido}\"."
        )
    return (
        "[Automatic message from openTARS, not from the user] This is the "
        "screenshot you took with take_screenshot. Use it to continue the "
        f"user's request: \"{pedido}\". To click, use pixel coordinates of this "
        "image. Don't describe the image: go on with the next action (or answer, "
        "if the task is done)."
    )


def _mensagem_use_ferramentas(pedido):
    return (
        "[Automatic message from openTARS] You DO have tools for this "
        "(open_application, close_application, click_element, take_screenshot, "
        "execute_terminal and others). Don't say you can't: call the right tool "
        f"now for the user's request: \"{pedido}\"."
    )


_PADROES_SUCESSO = [re.compile(p) for p in (
    r"^(pronto|feito|prontinho|done|all set|finished|listo|hecho|c'?est fait|voila|fertig|erledigt)\b",
    r"\b(abri|fechei|cliquei|enviei|digitei|pesquisei|criei|apaguei|movi|executei|consegui)\b",
    r"\b(i (have )?(opened|closed|clicked|sent|typed|searched|created|deleted|moved|ran))\b",
    r"\b(successfully|com sucesso|con exito|avec succes|erfolgreich)\b",
)]


def _diz_que_conseguiu(texto):
    t = normalizar(texto or "")
    return bool(t) and len(t) < 1200 and any(p.search(t) for p in _PADROES_SUCESSO) \
        and not _parece_recusa(texto)


def _mensagem_sucesso_falso(pedido):
    return (
        "[Automatic message from openTARS] Careful: your LAST tool call FAILED (see its result), "
        "so the task is not done yet. Either fix it now with another approach, or tell the user "
        f"honestly what did not work. Don't say it worked. Request: \"{pedido}\"."
    )


LIMITE_REESCALAR = 2          # trocas de modelo por pedido (erro, recusa, nada funcionou)
MENOR_TROCA_ACEITA = 0.75     # 5.1: na troca, o próximo modelo tem ao menos 3/4 do tamanho do que falhou
LIMITE_JANELAS_CONTEXTO = 8
CONTEXTO_JANELAS = os.environ.get("TARS_CONTEXTO_JANELAS", "1") not in ("0", "off", "nao", "no")


def _pode_cobrar_ferramenta(mensagens, inicio_do_pedido):
    """False quando o pedido parece só a continuação de uma CONVERSA: a Murph apenas palpitou o tipo (via melhor_voto ou padrao,
    "em dúvida") e a última resposta da IA, antes deste pedido, foi texto sem ferramenta. 17/10: "but i need to wash the car",
    resposta a uma pergunta de conversa, foi palpitada como visao, o app mandou o modelo "fazer de verdade" e ele FECHOU o Claude
    do Enzo e abriu o Brave. O primeiro pedido de uma conversa continua sendo cobrado mesmo em palpite, e tarefa decidida com
    certeza também (cobrar de menos é inofensivo; mandar agir sem ter pedido, não)."""
    d = _ultima_decisao.get("d")
    if d is None or not str(getattr(d, "via", "")).startswith(("melhor_voto", "padrao")):
        return True
    respondeu_texto = False
    for m in reversed(mensagens[:inicio_do_pedido]):          # a troca anterior: do fim até a última fala de verdade da pessoa
        if m.get("role") == "tool" or m.get("tool_calls"):
            return True                                        # houve trabalho com ferramenta: é sequência de ação
        if m.get("role") == "assistant" and (m.get("content") or "").strip():
            respondeu_texto = True
        if m.get("role") == "user" and not str(m.get("content") or "").startswith("["):
            return not respondeu_texto
    return True


# A resposta já diz que os resultados ficaram abertos no navegador (então não é resposta de memória disfarçada de busca).
_RE_FALA_DO_NAVEGADOR = re.compile(r"navegador|browser|abert[oa]s?\b|\babri\b|opened|\bopen (?:in|on)\b|on (?:the|your) screen|"
                                   r"na tela|\blink\b|resultados? (?:da busca|esta|estao)|results? (?:are|is)\b")


# A resposta diz que ABRIU algo na tela (falso quando a busca só consultou o SearXNG)
_RE_DIZ_QUE_ABRIU = re.compile(r"(?:abert[oa]s?|abri|abrimos|abrindo)\b.{0,50}\b(?:navegador|browser|google|tela|aba)\b|"
                               r"\b(?:opened|opening|open)\b.{0,40}\b(?:browser|google|tab|screen)\b|"
                               r"\b(?:no|in the|in your|on your) (?:navegador|browser)\b|encontrad[oa]s? no google|found (?:it )?on google")


def _mensagem_sem_ferramenta(pedido):
    return (
        "[Automatic message from openTARS] You answered without doing it (or as if it were done), but you did NOT call "
        "any tool, so nothing happened on the computer. Call the right tool(s) now to actually do "
        f"it: \"{pedido}\". Only after a tool result confirms it, say it's done."
    )


def _mensagem_verificar_fato(pedido):
    return (
        "[Automatic message from openTARS] You answered from memory and your confidence in that answer was low, so it may "
        "be wrong or invented. Do not repeat it. Call search_web for it now, then answer ONLY from the results (say which "
        "site), or say you could not confirm it. Question: "
        f"\"{pedido}\"."
    )


def _mensagem_outro_modelo(pedido):
    return (
        "[Automatic message from openTARS] The attempts above (by another model) all failed. You "
        "are taking over: use a DIFFERENT approach from the ones that failed (another tool, "
        "another window name, typing instead of clicking...). If there is really no way, tell the "
        f"user what blocks it. Request: \"{pedido}\"."
    )


def _desfecho(tarefa, conteudo, usou_ferramenta, algum_sucesso, mentiu):
    """Pro histórico do modelo: deu certo (True), falhou (False) ou não dá
    pra dizer (None: ex. todas as ferramentas falharam por causa do
    computador e a IA explicou direito)."""
    if not conteudo or mentiu:
        return False
    if _parece_recusa(conteudo) and not usou_ferramenta:
        return False
    if tarefa in ("acao", "visao", "busca"):
        return True if algum_sucesso else None
    return True


def _janelas_para_contexto():
    if not plataforma.janelas_prontas():       # macOS: o Quartz ainda carregando; o pedido não espera por ele
        return []
    try:
        janelas = listar_janelas_x()
        ativa = janela_ativa_x() if janelas else None
    except Exception:
        return []
    if not janelas:
        return []
    protegidos = _pids_protegidos()
    saida = []
    for j in janelas:
        titulo = (j.get("titulo") or "").strip()
        if not titulo or j.get("pid") in protegidos:
            continue
        titulo = titulo if len(titulo) <= 50 else titulo[:47] + "..."
        saida.append(f"'{titulo}'" + (" (focused)" if j.get("id") == ativa else ""))
        if len(saida) >= LIMITE_JANELAS_CONTEXTO:
            break
    return saida


def _ultimo_pedido_real(mensagens=None):
    """A última fala de verdade da pessoa na conversa (não as mensagens automáticas, que começam com "[")."""
    for m in reversed(mensagens if mensagens is not None else conversa):
        if m.get("role") == "user" and not str(m.get("content") or "").startswith("["):
            return str(m["content"]).split("\n\n[", 1)[0].strip() or None
    return None


_PERGUNTA_DE_IDENTIDADE = re.compile(
    r"\b(?:quem (?:e|eh) (?:voce|vc|tu)|quem (?:te |lhe )?(?:criou|fez|desenvolveu|programou|inventou|treinou)|"
    r"quem (?:e|eh) (?:o )?(?:seu )?(?:criador|dono|fundador|desenvolvedor)|qual (?:e )?(?:o )?seu nome|como (?:voce )?se chama|"
    r"seu nome|voce (?:e|eh) (?:o )?(?:chatgpt|gpt|gemini|claude|llama|qwen|alexa|siri)|"
    r"who (?:are you|made you|created you|built you|developed you|trained you|is your (?:creator|developer|founder|maker))|"
    r"what(?:'s| is) your name|your (?:name|creator|developer|founder)|who (?:is|are) (?:the )?(?:founder|creator) of opentars|"
    r"(?:fundador|criador|dono|founder|creator) (?:do |da |of )?opentars)\b")
_VERBOS_DE_BUSCA = re.compile(r"\b(?:pesquis\w*|busc\w*|procur\w*|search\w*|find|look up|look for|ache|achar)\b")


def _sites_citados_na_busca(texto_n):
    """Os sites com busca própria que o pedido cita ("pesquisa X no spotify"), fora google e youtube (que a IA já conhece)."""
    if not _VERBOS_DE_BUSCA.search(texto_n):
        return []
    nomes = set(_BUSCAS_POR_SERVICO) | set(_APELIDOS_DE_SERVICO)
    achados = [n for n in sorted(nomes, key=len, reverse=True)
               if n not in ("google", "youtube", "yt", "x", "mapa", "mapas", "prime") and re.search(rf"\b{re.escape(n)}\b", texto_n)]
    return [_APELIDOS_DE_SERVICO.get(n, n) for n in achados]


def _contexto_do_pedido(texto, tarefa, multi_etapas, retoma=None):
    """O que a IA precisa saber antes de agir: as janelas abertas (não
    precisa abrir o que já está aberto, sabe o nome certo da janela) e, se
    o pedido tem várias etapas, que é pra fazer TODAS."""
    linhas = []
    if escolha.pedido_em_ingles(normalizar(retoma or texto)):
        linhas.append("The user writes in English: answer in English.")
    if retoma:
        # 18/10: "use the built-in tool" sozinho não diz o quê; o modelo pequeno inventava (abriu o "app" google)
        linhas.append(f"The user's message refers to the previous request: \"{retoma}\". Do that request now with the "
                      "right tool, and say only what the tool results confirm.")
    texto_n = normalizar(retoma or texto)
    if _PERGUNTA_DE_IDENTIDADE.search(texto_n):
        linhas.append("About you: your name is Gargantua. You were developed by Enzo, the founder of openTARS, and you run "
                      "inside the openTARS app (openTARS is the app, Gargantua is you). Say so in your own words; never "
                      "name another company or model as your maker.")
    sites = _sites_citados_na_busca(texto_n)
    if sites:
        linhas.append("To search INSIDE a site, call search_web with query = only the search terms and service = the site "
                      f"name, here: service=\"{sites[0]}\". Do not use google as the service and do not open_website "
                      "for a search.")
    if CONTEXTO_JANELAS and tarefa in ("acao", "visao"):
        janelas = _janelas_para_contexto()
        if janelas:
            linhas.append("Open windows right now: " + "; ".join(janelas) + ".")
    if multi_etapas:
        partes = _etapas_do_pedido(texto)
        if len(partes) >= 2:
            plano = " ".join(f"{i}. {p}" for i, p in enumerate(partes, 1))
            linhas.append(f"This request has {len(partes)} steps. Plan: {plano}. Do ALL of them, in order, "
                          "checking each one worked (its tool result) before the next, and only then give "
                          "your final answer (don't stop after the first one).")
    if not linhas:
        return None
    return "[Context from openTARS, not a new request] " + " ".join(linhas)


# ------------------------------------------------------------
# 3.0: planejar e conferir
# ------------------------------------------------------------
# Ferramentas que só OLHAM (não contam como etapa feita).
_FERRAMENTAS_SO_OLHAM = {
    "take_screenshot", "list_elements", "list_windows",
}


def _etapas_do_pedido(texto):
    return escolha.partes_do_pedido(normalizar(texto), _verbos_de_acao())


def _mensagem_plano_incompleto(pedido, etapas, feitas):
    lista = " ".join(f"{i}. {p}" for i, p in enumerate(etapas, 1))
    return (
        f"[Automatic message from openTARS] Plan check: this request has {len(etapas)} steps ({lista}), "
        f"but only {feitas} action(s) worked so far. Do the missing step(s) now. If one really cannot "
        f"be done, tell the user which one and why. Don't say everything is done. Request: \"{pedido}\"."
    )


def _mensagem_conferir_conclusao(pedido, feitas, resposta):
    lista = "; ".join(feitas[-12:]) or "none"
    return (
        "[Automatic message from openTARS] Completion check before you finish. "
        f"The user asked: \"{pedido}\". Actions that worked so far: {lista}. "
        f"Your last answer was: \"{str(resposta or '').strip()[:400]}\". "
        "Compare them: is EVERY part of the request really done, with its final result on screen, and "
        "nothing left for the user to do? If anything is missing (including something you told the "
        "user to do), call the tool(s) now to finish it. If everything is done, answer briefly."
    )


def _mensagem_sem_efeito(pedido):
    return (
        "[Automatic message from openTARS] Your last click/drag changed NOTHING on screen, so it "
        "probably missed. Check (take_screenshot or list_elements) and retry with target=<what to "
        "click> before saying it's done; if it really can't be done, tell the user honestly. "
        f"Request: \"{pedido}\"."
    )


def _mensagem_faca_voce(pedido):
    return (
        "[Automatic message from openTARS] Don't hand the task back to the user: you have the "
        "tools to do it yourself. Read the last tool result again and follow the way it gives "
        "(e.g. type_in_element with window and submit=true to write in an app that doesn't expose "
        "its buttons, or take_screenshot + click_mouse). If you must invent content (a question, a "
        f"message), invent it. Do it now for: \"{pedido}\"."
    )


def _mensagem_continuar(pedido):
    return (
        "[Automatic message from openTARS] You neither called a tool nor "
        f"answered. Continue the user's request: \"{pedido}\" — call the next "
        "tool or, if you are done, say in one sentence what was done."
    )


_NOMES_FERRAMENTAS = None


def _ferramentas_no_texto(texto):
    """Chamadas de ferramenta escritas no texto da resposta, no formato
    [{"function": {"name", "arguments"}}], ou [] se não houver."""
    global _NOMES_FERRAMENTAS
    if _NOMES_FERRAMENTAS is None:
        _NOMES_FERRAMENTAS = {f["function"]["name"] for f in TOOLS}
    if '"name"' not in texto or not any(nome in texto for nome in _NOMES_FERRAMENTAS):
        return []
    decodificador = json.JSONDecoder()
    chamadas, pos = [], 0
    while True:
        pos = texto.find("{", pos)
        if pos < 0:
            break
        try:
            obj, fim = decodificador.raw_decode(texto, pos)
        except ValueError:
            pos += 1
            continue
        pos = fim
        if isinstance(obj, dict) and isinstance(obj.get("function"), dict):
            obj = obj["function"]
        argumentos = obj.get("arguments", obj.get("parameters")) if isinstance(obj, dict) else None
        if isinstance(argumentos, str):
            try:
                argumentos = json.loads(argumentos)
            except ValueError:
                argumentos = None
        if isinstance(obj, dict) and isinstance(obj.get("name"), str) and obj["name"] in _NOMES_FERRAMENTAS and isinstance(argumentos, dict):
            chamadas.append({"function": {"name": obj["name"], "arguments": argumentos}})
    return chamadas


def _argumentos_da_chamada(funcao):
    argumentos = funcao.get("arguments", {})
    if isinstance(argumentos, str):
        try:
            argumentos = json.loads(argumentos)
        except Exception:
            argumentos = {}
    return argumentos if isinstance(argumentos, dict) else {}


def _carregar_com_fallback(modelo, tarefa):
    """Carrega o modelo pedido; no modo AUTO, se falhar, desce a cadeia
    de alternativas da tarefa."""

    candidatos = [modelo]
    if modo_modelo == "AUTO" and tarefa:
        candidatos += [m for m in construir_cadeia_fallback(tarefa) if m != modelo]

    for candidato in candidatos:
        if carregar_modelo(candidato):
            return candidato, candidatos
        print(c(f"[AUTO] {t('msg.falha_carregar_alternativa', modelo=candidato)}", Cor.AMARELO))
    return None, candidatos


# Resultados de ferramenta vão pra IA com chaves em inglês (o código usa
# as chaves em português; a IA entende melhor um JSON todo em inglês).
_CHAVES_PARA_IA = {
    "sucesso": "success", "mensagem": "message", "acao": "action", "aplicativo": "app",
    "encontrado_como": "found_as", "janela": "window", "janelas": "windows",
    "janelas_fechadas": "closed_windows", "janelas_abertas": "open_windows",
    "titulo": "title", "largura": "width", "altura": "height", "minimizada": "minimized",
    "em_foco": "focused", "diretorio": "directory", "caminho": "path", "arquivos": "files",
    "codigo": "exit_code", "entrada": "input", "servico": "service",
    "ram_percentual": "ram_percent", "ram_disponivel_gb": "ram_available_gb",
    "disco_percentual": "disk_percent", "disco_livre_gb": "disk_free_gb",
    "elementos": "elements", "elemento": "element", "textos": "texts",
}


def _para_ia(valor):
    if isinstance(valor, dict):
        return {_CHAVES_PARA_IA.get(k, k): _para_ia(v) for k, v in valor.items()}
    if isinstance(valor, list):
        return [_para_ia(v) for v in valor]
    return valor


# ------------------------------------------------------------
# 4.1: tarefas longas
# ------------------------------------------------------------
LIMITE_CICLOS_MAX = int(os.environ.get("TARS_LIMITE_ETAPAS", "120"))  # teto absoluto por pedido
FOLEGO_CICLOS = 20             # quanto cada fôlego extra dá
ETAPAS_POR_PASSO_DO_PLANO = 6  # pedido com plano: cada etapa a mais do plano já começa com isso
CICLOS_ANTES_DE_COMPACTAR = 10
RESULTADOS_INTEIROS = 6        # os últimos resultados de ferramenta ficam inteiros
LIMITE_CHARS_RESULTADO_NO_PEDIDO = 500


def limite_de_ciclos(etapas=()):
    """Limite inicial de idas-e-voltas: 20, mais 6 por etapa do plano além
    da segunda (até 50)."""
    extra = ETAPAS_POR_PASSO_DO_PLANO * max(0, len(etapas or ()) - 2)
    return min(max(LIMITE_CICLOS_FERRAMENTAS, 50), LIMITE_CICLOS_FERRAMENTAS + extra)


def _merece_folego(ciclo, acoes_feitas, acoes_no_inicio_do_folego):
    """Mais fôlego só se o último trecho teve ação que deu certo de verdade
    (olhar a tela ou listar janelas não conta) e se ainda cabe no teto."""
    return ciclo < LIMITE_CICLOS_MAX and acoes_feitas > acoes_no_inicio_do_folego


def _mensagem_folego(pedido, etapas, acoes_feitas):
    plano = ""
    if etapas:
        plano = " The plan was: " + "; ".join(f"{i}. {e}" for i, e in enumerate(etapas, 1)) + "."
    return (f"[Automatic message from openTARS] Long task in progress: {acoes_feitas} actions done so far. "
            f"Keep going with what is left of the user's request (\"{pedido}\").{plano} Don't redo what already "
            "worked. When everything is done, check it and tell the user the result in a few sentences.")


def _compactar_pedido_atual(mensagens, inicio):
    """Num pedido longo, os resultados de ferramenta mais antigos (listas de
    arquivos, saída de comando) são encurtados: os últimos ficam inteiros.
    Sem isso, a conversa passa do contexto da IA no meio da tarefa."""
    indices = [i for i in range(max(inicio, 1), len(mensagens)) if mensagens[i].get("role") == "tool"]
    for i in indices[:-RESULTADOS_INTEIROS]:
        conteudo = mensagens[i].get("content") or ""
        if len(conteudo) > LIMITE_CHARS_RESULTADO_NO_PEDIDO:
            mensagens[i]["content"] = conteudo[:LIMITE_CHARS_RESULTADO_NO_PEDIDO] + "…(earlier result shortened)"
    # Orçamento: os resultados "inteiros" também têm teto, senão 6 saídas de 8000 caracteres estouram um contexto de
    # 8k tokens e o Ollama corta o COMEÇO da conversa (o pedido da pessoa). Encurta do mais antigo ao mais novo.
    try:
        orcamento = int(contexto_ia() * 3 * 0.4)
    except Exception:
        orcamento = 9000
    inteiros = indices[-RESULTADOS_INTEIROS:]
    total = sum(len(mensagens[i].get("content") or "") for i in inteiros)
    for i in inteiros[:-1]:           # o mais recente fica inteiro
        if total <= orcamento:
            break
        conteudo = mensagens[i].get("content") or ""
        if len(conteudo) > LIMITE_CHARS_RESULTADO_NO_PEDIDO:
            mensagens[i]["content"] = conteudo[:LIMITE_CHARS_RESULTADO_NO_PEDIDO] + "…(earlier result shortened)"
            total -= len(conteudo) - len(mensagens[i]["content"])


# Buscas que só leem: a mesma busca não roda duas vezes no mesmo pedido, e há um teto de buscas por pedido (27/10).
_LEITURAS_REPETIVEIS = {"search_web", "web_search", "read_page"}
LIMITE_BUSCAS_POR_PEDIDO = 3


def perguntar_modelo(modelo, texto, tarefa=None, multi_etapas=False, anexos=None, retoma=None):

    pendentes = []  # ferramentas pedidas e ainda não executadas neste ciclo

    try:
        modelo_carregado, tentados = _carregar_com_fallback(modelo, tarefa)
    except OllamaOffline:
        modelo_carregado, tentados = None, [modelo]

    if not modelo_carregado and not ollama_online():
        # "Não consegui carregar o modelo" escondia a causa: o Ollama
        # está desligado (ex: parado pra usar o vLLM).
        mensagem_erro = t("aviso.ollama_offline", host=OLLAMA_HOST)
        print(c(f"\n[openTARS] {mensagem_erro}", Cor.VERMELHO))
        return mensagem_erro

    if not modelo_carregado:
        mensagem_erro = t("msg.nenhum_modelo_carregou", tentados=", ".join(tentados))
        print(c(f"\n[openTARS] {mensagem_erro}", Cor.VERMELHO))
        return mensagem_erro

    modelo = modelo_carregado
    usados = [modelo]
    mensagens = obter_sessao(modelo)
    _remover_turnos_recusados(mensagens)
    # a lista de janelas abertas de pedidos anteriores é velha: só a de agora vale
    mensagens[:] = [m for m in mensagens
                    if not (m.get("role") == "user" and str(m.get("content") or "").startswith("[Context from openTARS"))]
    if anexos:
        # arquivos anexados: o conteúdo vai junto com o pedido (a imagem, pra quem enxerga)
        conteudo_usuario, imagens_anexadas = montar_mensagem_com_anexos(texto, anexos, modelo)
        mensagens.append({"role": "user", "content": conteudo_usuario,
                          **({"images": imagens_anexadas} if imagens_anexadas else {})})
    else:
        mensagens.append({"role": "user", "content": texto})
    inicio_do_pedido = len(mensagens) - 1
    contexto = _contexto_do_pedido(texto, tarefa, multi_etapas, retoma)
    if contexto:
        mensagens.append({"role": "user", "content": contexto})

    inicio_total = time.time()
    cutucadas = 0
    usou_ferramenta = False
    lembrou_ferramentas = False
    pensar = pensar_do_modelo(modelo, tarefa, texto, multi_etapas)
    janela_do_print = None
    arquivo_do_print = None
    ultima_falhou = insistiu = conferiu_sucesso = cobrou_acao = False
    algum_sucesso = mentiu = False
    desfecho = None               # pro histórico: True deu certo, False falhou, None não conta
    reescaladas = 0
    ferramentas = ferramentas_do_modelo(modelo, tarefa, texto)
    # 5.1: "keep in memory i use brave" MANDA salvar. Se o modelo não chamar a ferramenta (responde "anotado!" ou vai
    # de shell escrever num arquivo), o openTARS salva o fato sozinho. Só quando o pedido é só isso (sem outra etapa).
    fato_a_lembrar = None if multi_etapas else memoria_lib.intencao_de_lembrar(texto)
    falhas_seguidas = {}          # ferramenta -> falhas desde o último sucesso
    chamadas_que_falharam = {}    # (ferramenta, argumentos) -> motivo
    chamadas_feitas = {}          # (ferramenta, argumentos) -> deu certo, para leituras repetíveis
    buscas_feitas = 0
    # 3.0: planejar e conferir
    etapas = _etapas_do_pedido(texto) if multi_etapas else []
    acoes_feitas = 0              # ações (não só olhar) que deram certo DE VERDADE
    feitas_resumo = []            # "nome(argumentos)" de cada uma, pra conferir no fim
    busca_sem_leitura = False     # search_web abriu resultados que ninguém leu (nem print nem read_page depois)
    busca_sem_abrir = False       # search_web leu pelo SearXNG e NÃO abriu nada na tela (decisão de 18/10)
    revisou = False
    verificou_fato = False        # anti-alucinação: já tentou conferir uma resposta de pouca confiança
    pouca_confianca = False
    sem_efeito_seguidos = 0       # cliques/arrastos "certos" que não mudaram a tela
    ultima_sem_efeito = cobrou_plano = cobrou_efeito = False
    if len(etapas) >= 2:
        emitir("plano", etapas=etapas)

    def aplicar_formato():
        """4.0: entrar ou sair do Gargantua no meio do pedido troca o
        formato junto (prompt de sistema, ferramentas, raciocínio)."""
        nonlocal ferramentas, pensar
        if mensagens and mensagens[0].get("role") == "system":
            mensagens[0]["content"] = prompt_do_modelo(modelo)
        ferramentas = ferramentas_do_modelo(modelo, tarefa, texto)
        pensar = pensar_do_modelo(modelo, tarefa, texto, multi_etapas)

    def trocar_de_modelo(chave_mensagem):
        """Passa o pedido pro próximo modelo da fila (no modo AUTO, até
        LIMITE_REESCALAR vezes por pedido). O que falhou fica no placar."""
        nonlocal modelo, reescaladas
        if modo_modelo != "AUTO" or reescaladas >= LIMITE_REESCALAR:
            return False
        fila = construir_cadeia_fallback(tarefa_da_fila(tarefa or "acao", multi_etapas))
        # 5.1 (item 7 do plano): depois de uma falha a troca é para cima ou para o lado, nunca para um modelo bem menor
        # (no print do Enzo, o Gargantua falhou e quem respondeu foi o qwen3:0.6b, sem dar conta do pedido)
        catalogo = catalogar_modelos()
        atual_b = (catalogo.get(modelo) or {}).get("parametros_b")

        def nao_desce(m):
            b = (catalogo.get(m) or {}).get("parametros_b")
            return atual_b is None or b is None or b >= atual_b * MENOR_TROCA_ACEITA

        proximo = next((m for m in fila if m not in usados and nao_desce(m)), None)
        if not proximo:
            return False
        try:
            if not carregar_modelo(proximo):
                return False
        except OllamaOffline:
            return False
        historico_modelos.registrar(modelo, _tarefa_do_historico(tarefa), False)
        print(c(f"[AUTO] {t(chave_mensagem, modelo=modelo, novo=proximo)}", Cor.AMARELO))
        usados.append(proximo)
        modelo = proximo
        reescaladas += 1
        aplicar_formato()
        return True

    # 4.1: tarefas longas. O limite começa maior quando o pedido tem várias
    # etapas e, enquanto a IA estiver fazendo progresso de verdade (ações que
    # deram certo), ganha mais fôlego, até LIMITE_CICLOS_MAX.
    limite_ciclos = limite_de_ciclos(etapas)
    ciclo = 0
    acoes_no_inicio_do_folego = 0
    folegos = 0

    try:
        while True:
            if ciclo >= limite_ciclos:
                if not _merece_folego(ciclo, acoes_feitas, acoes_no_inicio_do_folego):
                    break
                folegos += 1
                limite_ciclos = min(LIMITE_CICLOS_MAX, limite_ciclos + FOLEGO_CICLOS)
                acoes_no_inicio_do_folego = acoes_feitas
                emitir("folego", etapas=ciclo, acoes=acoes_feitas, limite=limite_ciclos)
                print(c(f"[openTARS] {t('msg.folego', etapas=ciclo, acoes=acoes_feitas)}", Cor.CINZA))
                mensagens.append({"role": "user", "content": _mensagem_folego(texto, etapas, acoes_feitas)})
            ciclo += 1  # conta a partir de 1: "ciclo > 1" = já houve uma volta antes desta
            if ciclo > CICLOS_ANTES_DE_COMPACTAR:
                _compactar_pedido_atual(mensagens, inicio_do_pedido)

            _verificar_cancelamento()

            imagem_no_turno = any(m.get("images") for m in mensagens)

            resultado, erro = _executar_turno_streaming(modelo, mensagens, ferramentas, pensar=pensar)

            if erro and imagem_no_turno and _erro_de_imagem(erro):
                # "Model does not support multimodal": esse modelo não
                # enxerga. Toda imagem da conversa vira texto (descrita
                # por um modelo com visão) e as ferramentas continuam.
                _MODELOS_SEM_VISAO.add(modelo)
                _tirar_imagens_da_conversa(mensagens, texto, modelo)
                resultado, erro = _executar_turno_streaming(modelo, mensagens, ferramentas, pensar=pensar)
            elif erro and imagem_no_turno:
                # Alguns Ollama antigos não aceitam 'tools' junto com
                # imagem: tenta de novo sem as ferramentas.
                print(c(f"[openTARS] {t('msg.imagem_sem_ferramentas', erro=erro)}", Cor.AMARELO))
                resultado, erro = _executar_turno_streaming(modelo, mensagens, None, pensar=pensar)

            if erro:
                # Modelo que quebrou (sem memória, chamada de ferramenta que
                # o Ollama não entendeu...): o próximo da fila continua a
                # MESMA conversa, com o que já foi feito.
                if not _erro_de_imagem(erro) and trocar_de_modelo("msg.tentando_outro_modelo"):
                    continue
                desfecho = False
                print(c(f"\n[openTARS] {erro}", Cor.VERMELHO))
                return erro

            conteudo = resultado["content"]
            tool_calls = resultado["tool_calls"]

            # Modelo que escreve a chamada da ferramenta como TEXTO
            # (<tool_call>{...}</tool_call>, um JSON solto): vira chamada
            # de verdade, em vez de aparecer pro usuário como resposta.
            if not tool_calls and conteudo:
                no_texto = _ferramentas_no_texto(conteudo)
                if no_texto:
                    tool_calls, conteudo = no_texto, ""

            if fato_a_lembrar:
                if any((ch.get("function") or {}).get("name") in ("remember", "forget") for ch in tool_calls):
                    fato_a_lembrar = None          # o modelo mesmo chamou: nada a fazer
                elif ciclo == 1:
                    # nada de remember: a chamada certa entra no lugar do que o modelo fez (resposta solta, shell...)
                    print(c(f"[openTARS] {t('msg.memoria_sozinho', fato=fato_a_lembrar)}", Cor.CINZA))
                    tool_calls = [{"function": {"name": "remember", "arguments": {"fact": fato_a_lembrar}}}]
                    conteudo = ""
                    fato_a_lembrar = None

            if not tool_calls and not conteudo and ciclo > 1 and cutucadas < LIMITE_CUTUCADAS_RESPOSTA_VAZIA:
                # Modelo "pensou" mas não respondeu nem chamou ferramenta
                # no meio de uma tarefa: pede pra continuar em vez de
                # encerrar pela metade.
                cutucadas += 1
                print(c(f"[openTARS] {t('msg.ia_parou')}", Cor.AMARELO))
                mensagens.append({"role": "user", "content": _mensagem_continuar(texto)})
                continue

            if (
                not tool_calls and not usou_ferramenta and not lembrou_ferramentas
                and modelo not in _MODELOS_SEM_TOOLS and _parece_recusa(conteudo)
            ):
                # "Não consigo abrir aplicativos": lembra que consegue, uma
                # vez. A recusa não entra no histórico.
                lembrou_ferramentas = True
                ferramentas = ferramentas_do_modelo(modelo, None, texto)  # talvez faltasse a ferramenta certa
                print(c(f"[openTARS] {t('msg.ia_recusou')}", Cor.AMARELO))
                mensagens.append({"role": "user", "content": _mensagem_use_ferramentas(texto)})
                continue

            if (
                not tool_calls and not usou_ferramenta and lembrou_ferramentas
                and modo_modelo == "AUTO" and _parece_recusa(conteudo)
            ):
                # Recusou de novo mesmo lembrado: esse modelo não serve pra
                # esse pedido. Passa pro próximo da fila (uma vez).
                proximo = next((m for m in construir_cadeia_fallback(tarefa or "acao")
                                if m not in usados), None)
                try:
                    carregou = bool(proximo) and carregar_modelo(proximo)
                except OllamaOffline:
                    carregou = False
                if carregou:
                    historico_modelos.registrar(modelo, _tarefa_do_historico(tarefa), False)
                    print(c(f"[AUTO] {t('msg.trocando_modelo', modelo=modelo, novo=proximo)}", Cor.AMARELO))
                    usados.append(proximo)
                    modelo = proximo
                    aplicar_formato()
                    lembrou_ferramentas = False
                    while mensagens and mensagens[-1].get("role") == "user" and mensagens[-1].get("content", "").startswith("["):
                        mensagens.pop()
                    continue

            if (
                not tool_calls and usou_ferramenta and ultima_falhou and not insistiu
                and tarefa in ("acao", "visao", None) and _devolveu_pro_usuario(conteudo)
            ):
                # Uma ferramenta falhou e a IA mandou o usuário fazer na mão
                # ("clique na caixa e digite"), com as ferramentas certas
                # disponíveis. Uma vez: manda ela mesma fazer.
                insistiu = True
                ferramentas = ferramentas_do_modelo(modelo, None, texto)
                print(c(f"[openTARS] {t('msg.ia_recusou')}", Cor.AMARELO))
                mensagens.append({"role": "user", "content": _mensagem_faca_voce(texto)})
                continue

            if (
                not tool_calls and usou_ferramenta and ultima_falhou and not conferiu_sucesso
                and _diz_que_conseguiu(conteudo)
            ):
                # "Pronto, abri!" logo depois de uma ferramenta que FALHOU:
                # a IA está inventando. Uma vez: conserta ou conta a verdade.
                conferiu_sucesso = mentiu = True
                mensagens.append({"role": "user", "content": _mensagem_sucesso_falso(texto)})
                continue

            if (
                not tool_calls and not usou_ferramenta and not cobrou_acao
                # "use a ferramenta"/"faz de verdade" (retoma): a pessoa pediu ferramenta com todas as letras, em qualquer modo
                and (tarefa in ("acao", "visao") or retoma) and modelo not in _MODELOS_SEM_TOOLS
                and (retoma or _pode_cobrar_ferramenta(mensagens, inicio_do_pedido))
                and (conteudo or "").strip()
            ):
                # Pedido de ação respondido sem ferramenta nenhuma: "Pronto,
                # abri o Firefox!" ou só o resultado ("12 x 8 = 96", sem
                # apertar os botões). Nada aconteceu no computador. Uma vez: faça.
                cobrou_acao = mentiu = True
                ferramentas = ferramentas_do_modelo(modelo, None, retoma or texto)
                mensagens.append({"role": "user", "content": _mensagem_sem_ferramenta(retoma or texto)})
                continue

            if (
                not tool_calls and usou_ferramenta and ultima_sem_efeito and not cobrou_efeito
                and _diz_que_conseguiu(conteudo)
            ):
                # "Pronto!" logo depois de um clique que não mudou nada na
                # tela: confere antes. Uma vez.
                cobrou_efeito = mentiu = True
                mensagens.append({"role": "user", "content": _mensagem_sem_efeito(texto)})
                continue

            if (
                not tool_calls and len(etapas) >= 2 and acoes_feitas < len(etapas) and not cobrou_plano
                and tarefa in ("acao", "visao", None) and modelo not in _MODELOS_SEM_TOOLS
            ):
                # Pedido de várias etapas encerrado antes de fazer todas:
                # mostra o plano e o que falta. Uma vez.
                cobrou_plano = True
                print(c(f"[openTARS] {t('msg.plano_incompleto', feitas=acoes_feitas, total=len(etapas))}",
                        Cor.AMARELO))
                mensagens.append({"role": "user", "content": _mensagem_plano_incompleto(texto, etapas,
                                                                                          acoes_feitas)})
                continue

            if (
                not tool_calls and not revisou and usou_ferramenta
                and (len(etapas) >= 2 or acoes_feitas >= 2)
                and tarefa in ("acao", "visao", None) and modelo not in _MODELOS_SEM_TOOLS
            ):
                # Antes de aceitar "terminei" num pedido de várias ações, a IA
                # confere o pedido inteiro contra o que fez (sem olhar o que ela
                # escreveu: só o pedido e as ações). Uma vez.
                revisou = True
                print(c(f"[openTARS] {t('msg.conferindo_conclusao')}", Cor.CINZA))
                mensagens.append({"role": "assistant", "content": conteudo})
                mensagens.append({"role": "user", "content": _mensagem_conferir_conclusao(texto, feitas_resumo, conteudo)})
                continue

            if (
                not tool_calls and not usou_ferramenta and not verificou_fato and confianca_lib.ligado()
                and confianca_lib.vale_conferir(tarefa, texto, conteudo)
                and confianca_lib.pouca_confianca(resultado.get("logprobs"))
            ):
                # Anti-alucinação: o modelo respondeu um fato com pouca confiança nos próprios tokens. Com a pesquisa web
                # ligada, uma vez, ele confere pela busca; sem ela, o app avisa embaixo da resposta.
                verificou_fato = True
                if _pesquisa_ligada():
                    print(c(f"[openTARS] {t('msg.verificando_fato')}", Cor.AMARELO))
                    mensagens.append({"role": "assistant", "content": conteudo})
                    mensagens.append({"role": "user", "content": _mensagem_verificar_fato(texto)})
                    ferramentas = ferramentas_do_modelo(modelo, "busca", texto)
                    continue
                pouca_confianca = True

            if not tool_calls:
                mensagens.append({"role": "assistant", "content": conteudo})
                if pouca_confianca and conteudo:
                    print(c(f"[openTARS] {t('msg.pouca_confianca')}", Cor.AMARELO))
                if not conteudo:
                    print(c(f"[openTARS] {t('msg.sem_texto', modelo=modelo)}", Cor.AMARELO))
                elif busca_sem_abrir and _RE_DIZ_QUE_ABRIU.search(normalizar(conteudo)):
                    # 18/10: o Lite escreve "o resultado foi aberto no navegador" por hábito do treino; com o SearXNG nada abriu
                    print(c(f"[openTARS] {t('msg.busca_nada_aberto')}", Cor.AMARELO))
                elif busca_sem_leitura and not _RE_FALA_DO_NAVEGADOR.search(normalizar(conteudo)):
                    # 18/10: o Lite abria a busca e respondia de memória como se tivesse lido. Quem avisa é o app, não o modelo.
                    print(c(f"[openTARS] {t('msg.busca_nao_lida')}", Cor.AMARELO))
                desfecho = _desfecho(tarefa, conteudo, usou_ferramenta, algum_sucesso, mentiu)
                emitir("tempo", modo=modo_modelo, modelo=modelo, segundos=time.time() - inicio_total)
                return conteudo

            mensagens.append({"role": "assistant", "content": conteudo, "tool_calls": tool_calls})
            usou_ferramenta = True
            desistiu = False
            pendentes[:] = [ch.get("function", {}).get("name", "") for ch in tool_calls]
            ultima_imagem = None

            for chamada in tool_calls:

                _verificar_cancelamento()

                funcao = chamada.get("function", {})
                nome = funcao.get("name", "")
                argumentos = _argumentos_da_chamada(funcao)
                chave_chamada = (nome, json.dumps(argumentos, sort_keys=True, ensure_ascii=False))
                oferecidas = {f["function"]["name"] for f in ferramentas}
                if nome in _FERRAMENTAS_SO_SE_PEDIDAS and nome not in oferecidas:
                    # Memória/agenda só quando o PEDIDO fala disso: uma página
                    # lida ou um modelo confuso não apaga a memória nem agenda nada.
                    resultado_ferramenta = {"sucesso": False, "mensagem": (
                        f"{nome} is not available for this request (the user did not ask to remember, "
                        "forget or schedule anything). Don't use it.")}
                elif nome in _LEITURAS_REPETIVEIS and chamadas_feitas.get(chave_chamada):
                    # Mesma busca, mesmos argumentos, já deu certo neste pedido: o resultado já está na conversa.
                    resultado_ferramenta = {"sucesso": False, "mensagem": (
                        "You already ran exactly this search in this request; its result is already in the conversation. "
                        "Do not search again: answer the user now.")}
                elif nome in _LEITURAS_REPETIVEIS and buscas_feitas >= LIMITE_BUSCAS_POR_PEDIDO:
                    resultado_ferramenta = {"sucesso": False, "mensagem": (
                        "You already searched several times for this request. Answer the user now with what you have.")}
                elif chave_chamada in chamadas_que_falharam:
                    # Mesma chamada, mesmos argumentos, já falhou neste pedido.
                    resultado_ferramenta = {"sucesso": False, "mensagem": (
                        f"You already tried exactly this {nome} call and it failed: "
                        f"{chamadas_que_falharam[chave_chamada]} Do something different.")}
                else:
                    resultado_ferramenta = executar_ferramenta(nome, argumentos)
                    if nome in _LEITURAS_REPETIVEIS:
                        buscas_feitas += 1
                        chamadas_feitas[chave_chamada] = bool(resultado_ferramenta.get("sucesso"))
                pendentes.pop(0)
                if nome == "take_screenshot":
                    janela_do_print = argumentos.get("window") or None
                    arquivo_do_print = resultado_ferramenta.get("arquivo")

                ultima_falhou = not resultado_ferramenta.get("sucesso")
                sem_efeito = bool(resultado_ferramenta.get("sem_efeito"))
                if nome not in _FERRAMENTAS_SO_OLHAM:
                    ultima_sem_efeito = sem_efeito
                if resultado_ferramenta.get("sucesso") and sem_efeito:
                    # "Clicou", mas a tela não mudou: não é progresso. Conta
                    # pra desistir (andar em círculos clicando no nada).
                    sem_efeito_seguidos += 1
                    if sum(falhas_seguidas.values()) + sem_efeito_seguidos >= LIMITE_FALHAS_SEGUIDAS:
                        desistiu = True
                elif resultado_ferramenta.get("sucesso"):
                    algum_sucesso = True
                    if nome == "search_web":
                        busca_sem_leitura = not resultado_ferramenta.get("leu_resultados")
                        busca_sem_abrir = bool(resultado_ferramenta.get("leu_resultados")) and not resultado_ferramenta.get("url")
                    elif nome in ("take_screenshot", "read_page", "web_search"):
                        busca_sem_leitura = False
                    falhas_seguidas.clear()
                    sem_efeito_seguidos = 0
                    if nome not in _FERRAMENTAS_SO_OLHAM:
                        acoes_feitas += 1
                        feitas_resumo.append(f"{nome}({json.dumps(argumentos, ensure_ascii=False)[:80]})")
                else:
                    chamadas_que_falharam[chave_chamada] = str(resultado_ferramenta.get("mensagem", ""))[:300]
                    falhas_seguidas[nome] = falhas_seguidas.get(nome, 0) + 1
                    total_falhas = sum(falhas_seguidas.values()) + sem_efeito_seguidos
                    if falhas_seguidas[nome] >= LIMITE_FALHAS_MESMA_FERRAMENTA:
                        resultado_ferramenta["mensagem"] = (
                            f"{resultado_ferramenta.get('mensagem', '')} STOP: {nome} failed "
                            f"{falhas_seguidas[nome]} times in a row. Don't call it again for this; "
                            "change approach (another tool) or tell the user what is blocking you."
                        )
                    if total_falhas >= LIMITE_FALHAS_SEGUIDAS:
                        desistiu = True

                # A imagem do print nunca entra no JSON de texto (custaria
                # uma fortuna de tokens): vai como mensagem de visão.
                imagem_b64 = resultado_ferramenta.pop("_imagem_b64", None)
                if imagem_b64:
                    ultima_imagem = imagem_b64

                mensagens.append({
                    "role": "tool",
                    "tool_name": nome,  # ajuda o modelo a ligar resultado e chamada
                    "content": json.dumps(_para_ia(resultado_ferramenta), ensure_ascii=False),
                })

            # A imagem vai DEPOIS de todos os resultados: cada resultado
            # tem que vir logo após as chamadas que o geraram.
            if ultima_imagem and (not modelo_enxerga(modelo) or eh_gargantua(modelo)):
                # Modelo sem visão: o print não vai (o Ollama recusaria o
                # pedido inteiro); vai a descrição em texto. O Gargantua 2 enxerga, mas o treino dele com ferramentas não
                # tem imagem no meio da conversa: ele olha o print numa pergunta à parte (o formato em que foi treinado)
                # e o laço de ferramentas recebe o texto com as posições.
                mensagens.append({"role": "user", "content": _print_em_texto(ultima_imagem, texto, modelo, janela_do_print, arquivo_do_print)})
            elif ultima_imagem:
                # Prints antigos saem da conversa: cada um ocupa ~1 mil
                # tokens de contexto e só a tela atual importa.
                for anterior in mensagens:
                    if anterior.get("images"):
                        anterior.pop("images")
                        if _e_mensagem_de_print(anterior):  # o pedido do usuário (com arquivos anexados) não se apaga
                            anterior["content"] = "[previous screenshot removed: see the latest one]"

                # Vai como "user" (é onde o Ollama aceita imagem), mas
                # sem parecer um pedido novo: antes o modelo lia
                # "usuário mandou um print" e esquecia a tarefa.
                mensagens.append({
                    "role": "user",
                    "content": _mensagem_do_print(texto, arquivo_do_print),
                    "images": [ultima_imagem],
                })

            if desistiu and not algum_sucesso and trocar_de_modelo("msg.tentando_outro_modelo"):
                # Nada funcionou (e nada mudou no computador): outro modelo
                # tenta, vendo o que já falhou (e sem poder repetir igual).
                falhas_seguidas.clear()
                sem_efeito_seguidos = 0
                desistiu = False
                mensagens.append({"role": "user", "content": _mensagem_outro_modelo(texto)})
                continue

            if desistiu:
                desfecho = False
                # Muitas falhas seguidas: a IA está andando em círculos.
                # Uma última rodada SEM ferramentas pra ela explicar ao
                # usuário o que travou, em vez de gastar as 20 etapas.
                print(c(f"[openTARS] {t('msg.muitas_falhas', n=LIMITE_FALHAS_SEGUIDAS)}", Cor.AMARELO))
                mensagens.append({"role": "user", "content": (
                    "[Automatic message from openTARS] Too many tool calls failed in a row. Stop "
                    "using tools: in 2-3 sentences, tell the user what you tried, what blocked you, "
                    "and what they can do (e.g. click it themselves, or another way to ask).")})
                resultado, erro = _executar_turno_streaming(modelo, mensagens, None, pensar=False)
                conteudo = (resultado or {}).get("content") or t("msg.muitas_falhas", n=LIMITE_FALHAS_SEGUIDAS)
                mensagens.append({"role": "assistant", "content": conteudo})
                emitir("tempo", modo=modo_modelo, modelo=modelo, segundos=time.time() - inicio_total)
                return conteudo

            emitir("analisando", modelo=modelo)

        desfecho = False
        mensagem_limite = t("msg.limite_etapas", n=ciclo)
        print(c(f"\n[openTARS] {mensagem_limite}", Cor.VERMELHO))
        return mensagem_limite

    except (RespostaCancelada, KeyboardInterrupt):
        _cancelar.clear()
        # Toda chamada de ferramenta precisa de um resultado, senão o
        # próximo pedido vai com a conversa quebrada.
        for nome in pendentes:
            mensagens.append({
                "role": "tool",
                "tool_name": nome,
                "content": json.dumps({"success": False, "message": "Cancelled by the user."}),
            })
        mensagens.append({"role": "assistant", "content": "(interrupted by the user)"})
        print(c(f"\n[openTARS] {t('msg.interrompido')}", Cor.AMARELO))
        return RESPOSTA_INTERROMPIDA

    finally:
        _podar_historico(mensagens)
        if tarefa and modo_modelo == "AUTO":
            historico_modelos.registrar(modelo, _tarefa_do_historico(tarefa), desfecho)


def _editar_foto_pelo_chat(texto, anexos):
    """25/10 (decisão do Enzo): "deixa essa foto quadrada e mais clara", "tira o fundo" com uma foto anexada ou citada:
    o código entende a frase (tars_fotos, as mesmas regras do painel de arquivos), salva uma CÓPIA ao lado da original e
    mostra no chat. Sem modelo de linguagem: o prompt e as 19 ferramentas do treino não mudam. None = não era isso."""
    import tars_fotos as fotos_lib
    imagens = [a for a in anexos or [] if getattr(a, "tipo", "") == "imagem" and fotos_lib.e_foto(a.caminho)]
    operacoes = fotos_lib.entender(texto) if imagens else []
    if not operacoes:
        return None
    idioma = "pt_BR" if i18n.idioma_atual().startswith("pt") else "en"
    o_que = fotos_lib.descrever(operacoes, idioma)
    feitas, erros = [], []
    for a in imagens:
        try:
            feitas.append(fotos_lib.salvar_copia(a.caminho, operacoes, t("foto.sufixo")))
        except Exception as e:  # noqa: BLE001  (modelinho ainda baixando, formato que o Pillow não grava, disco cheio)
            erros.append(f"{a.nome}: {e}")
    partes = [t("foto.chat_feito", o_que=o_que, nomes=", ".join(p.name for p in feitas))] if feitas else []
    partes += [f"![{p.name}]({p})" for p in feitas]
    partes += [t("foto.chat_erro", erros="; ".join(erros))] if erros else []
    return _responder_sem_modelo(texto, "\n\n".join(partes))


def _responder_sem_modelo(texto, resposta):
    emitir("resposta", texto=resposta, inicio=True, apos_pensamento=False)
    emitir("fim_stream")
    conversa.append({"role": "user", "content": texto})
    conversa.append({"role": "assistant", "content": resposta})
    salvar_sessoes()
    return resposta


def _gerar_imagem_pelo_chat(texto):
    """25/10 (decisão do Enzo): "desenhe um farol ao entardecer" gera a imagem com o Bonsai Image 4B (opcional), pelo
    código: o prompt e as 19 ferramentas do treino não mudam. Sem o Bonsai, explica como instalar. None = não era isso."""
    import tars_imagem
    o_que = tars_imagem.pedido_de_imagem(texto)
    if not o_que:
        return None
    if not tars_imagem.instalado():
        return _responder_sem_modelo(texto, t("imagem.nao_instalado"))
    print(c(f"[openTARS] {t('imagem.gerando', o_que=o_que)}", Cor.CINZA))
    try:
        arquivo = tars_imagem.gerar(o_que, host=OLLAMA_HOST)
    except RuntimeError as e:
        motivo = str(e)
        resposta = (t("imagem.sem_memoria", livre=motivo.split(":", 1)[1]) if motivo.startswith("sem_memoria:")
                    else t("imagem.erro", erro=motivo))
        return _responder_sem_modelo(texto, resposta)
    return _responder_sem_modelo(texto, t("imagem.pronta", arquivo=arquivo.name, pasta=arquivo.parent) + f"\n\n![{o_que}]({arquivo})")


def processar_mensagem(texto, anexos=None):
    """Um pedido do usuário do começo ao fim: comandos (/modelo,
    /limpar...), escolha do modelo, resposta e salvamento. Usado pelo
    terminal e pela janela, que antes repetiam essa lógica cada um."""

    # Limpa aqui (e não no meio do caminho): um Parar apertado enquanto
    # o openTARS ainda escolhe o modelo continua valendo.
    _cancelar.clear()

    if interpretar_comando_modelo(texto):
        return None

    if not anexos and not os.environ.get("TARS_SEM_AUTO_IMAGEM"):
        # o pedido cita uma imagem ("veja home/pictures/foto.png"): anexa sozinho, sem depender de
        # o modelo saber chamar uma ferramenta (o modelo de visão descreve se ele não enxerga)
        try:
            citadas = anexos_lib.achar_imagens_no_texto(texto)
            if citadas:
                anexos, _erros = anexos_lib.carregar_varios([str(x) for x in citadas])
                for a in anexos or []:
                    print(c(f"[openTARS] {t('msg.imagem_citada', nome=a.nome)}", Cor.CINZA))
        except Exception:
            anexos = None

    if anexos and not (texto or "").strip():
        texto = TEXTO_PEDIDO_SO_ANEXOS

    editada = _editar_foto_pelo_chat(texto, anexos)
    if editada is not None:
        return editada
    gerada = _gerar_imagem_pelo_chat(texto) if not anexos else None
    if gerada is not None:
        return gerada
    import tars_imagem
    tars_imagem.parar()        # o Bonsai segura a placa; a conversa (Ollama) precisa dela agora

    # "use a ferramenta", "faz de verdade", "try again": a tarefa e o modelo saem do pedido anterior, que é o que vai ser feito
    retoma = _ultimo_pedido_real() if not anexos and escolha.eh_retomada(normalizar(texto)) else None
    base = retoma or texto
    _pedido_atual["texto"] = base       # o search_web vê se a pessoa pediu para abrir a busca no navegador
    tarefa = detectar_tarefa(base) if modo_modelo == "AUTO" else None
    decisao = _ultima_decisao["d"] if tarefa else None
    if tarefa:
        lembrar_tarefa(tarefa)
    # Várias etapas também contam no modo MANUAL (pensar + lembrete).
    multi = decisao.multi_etapas if decisao else escolha.eh_multi_etapas(normalizar(base), _verbos_de_acao())
    modelo = escolher_modelo(base, tarefa=tarefa, complexa=multi)

    emitir("tarefa", modo=modo_modelo, tarefa=tarefa, modelo=modelo, via=decisao.via if decisao else None)

    try:
        return perguntar_modelo(modelo, texto, tarefa=tarefa, multi_etapas=multi, anexos=anexos or None, retoma=retoma)
    finally:
        salvar_sessoes()