@pheem49/mint 1.17.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +6 -6
- package/Cargo.toml +2 -2
- package/README.md +8 -2
- package/Release_Note.md +222 -71
- package/crates/mint-cli/src/agent/diff_render.rs +66 -44
- package/crates/mint-cli/src/agent/live_status.rs +21 -4
- package/crates/mint-cli/src/agent/markdown_render.rs +342 -5
- package/crates/mint-cli/src/agent.rs +25 -10
- package/crates/mint-cli/src/commands/agent.rs +4 -92
- package/crates/mint-cli/src/commands/browser.rs +2 -15
- package/crates/mint-cli/src/commands/integrations.rs +3 -3
- package/crates/mint-cli/src/commands/mod.rs +0 -3
- package/crates/mint-cli/src/commands/tailnet_web.rs +1 -0
- package/crates/mint-cli/src/interactive/chat_tui/dialog.rs +572 -0
- package/crates/mint-cli/src/interactive/chat_tui/mod.rs +1427 -0
- package/crates/mint-cli/src/interactive/chat_tui/render.rs +1524 -0
- package/crates/mint-cli/src/interactive/chat_tui/selection.rs +401 -0
- package/crates/mint-cli/src/interactive/chat_tui/state.rs +1833 -0
- package/crates/mint-cli/src/interactive/chat_tui/tests.rs +3414 -0
- package/crates/mint-cli/src/interactive/chat_tui/thought.rs +202 -0
- package/crates/mint-cli/src/interactive/chat_tui/transcript.rs +728 -0
- package/crates/mint-cli/src/interactive/chat_tui/watermark.rs +148 -0
- package/crates/mint-cli/src/interactive/command_ui.rs +53 -5
- package/crates/mint-cli/src/interactive/composer.rs +186 -0
- package/crates/mint-cli/src/interactive/format.rs +78 -3
- package/crates/mint-cli/src/interactive/input_box.rs +61 -1
- package/crates/mint-cli/src/interactive/resume_picker.rs +367 -25
- package/crates/mint-cli/src/interactive/slash_commands.rs +648 -371
- package/crates/mint-cli/src/interactive/tool_activity.rs +93 -20
- package/crates/mint-cli/src/interactive.rs +37 -0
- package/crates/mint-cli/src/main.rs +6 -3
- package/crates/mint-cli/src/markdown.rs +2 -37
- package/crates/mint-cli/src/onboard.rs +700 -282
- package/crates/mint-cli/src/setup.rs +1342 -624
- package/crates/mint-cli/src/terminal_theme.rs +159 -35
- package/crates/mint-cli/tests/library_intake.rs +1 -0
- package/crates/mint-core/src/agent/chat/anthropic.rs +318 -0
- package/crates/mint-core/src/agent/chat/custom.rs +149 -0
- package/crates/mint-core/src/agent/chat/gemini.rs +518 -0
- package/crates/mint-core/src/agent/chat/mod.rs +857 -0
- package/crates/mint-core/src/agent/chat/ollama.rs +321 -0
- package/crates/mint-core/src/agent/chat/openai.rs +582 -0
- package/crates/mint-core/src/agent/chat/streaming.rs +469 -0
- package/crates/mint-core/src/agent/chat/tests.rs +1479 -0
- package/crates/mint-core/src/agent/memory.rs +56 -2
- package/crates/mint-core/src/agent/tasks.rs +2 -2
- package/crates/mint-core/src/api_server/routes/chat.rs +24 -2
- package/crates/mint-core/src/api_server/routes/gemini_live.rs +14 -1
- package/crates/mint-core/src/api_server/routes/html_preview.rs +1 -8
- package/crates/mint-core/src/api_server/routes/sessions.rs +21 -0
- package/crates/mint-core/src/avatar_bridge.rs +0 -22
- package/crates/mint-core/src/browser/cdp.rs +10 -4
- package/crates/mint-core/src/browser/headless.rs +582 -0
- package/crates/mint-core/src/browser/input.rs +7 -62
- package/crates/mint-core/src/browser/interact.rs +0 -14
- package/crates/mint-core/src/browser/mod.rs +8 -29
- package/crates/mint-core/src/browser/navigate.rs +2 -36
- package/crates/mint-core/src/browser/runtime.rs +2 -4
- package/crates/mint-core/src/browser/session.rs +50 -466
- package/crates/mint-core/src/browser/workflow.rs +142 -32
- package/crates/mint-core/src/cron/store.rs +2 -2
- package/crates/mint-core/src/integrations/mcp.rs +6 -8
- package/crates/mint-core/src/lib.rs +0 -5
- package/crates/mint-core/src/media/gemini_live.rs +636 -86
- package/crates/mint-core/src/media/library/migration.rs +26 -0
- package/crates/mint-core/src/media/library/tests.rs +1 -1
- package/crates/mint-core/src/media/library/thumbnails.rs +8 -2
- package/crates/mint-core/src/media/pictures.rs +1 -1
- package/crates/mint-core/src/media/tool_images.rs +92 -1
- package/crates/mint-core/src/mods/manager.rs +101 -35
- package/crates/mint-core/src/orchestration/context_render.rs +441 -24
- package/crates/mint-core/src/orchestration/decision_parsing.rs +531 -16
- package/crates/mint-core/src/orchestration/full_skill_result_tests.rs +12 -0
- package/crates/mint-core/src/orchestration/loop_guard.rs +145 -0
- package/crates/mint-core/src/orchestration/mcp_outcome_tests.rs +864 -0
- package/crates/mint-core/src/orchestration/memory_skill.rs +88 -28
- package/crates/mint-core/src/orchestration/mod.rs +320 -4249
- package/crates/mint-core/src/orchestration/reliability.rs +67 -27
- package/crates/mint-core/src/orchestration/review_regressions.rs +5 -0
- package/crates/mint-core/src/orchestration/tests.rs +1665 -0
- package/crates/mint-core/src/orchestration/tools/dispatch.rs +967 -0
- package/crates/mint-core/src/orchestration/tools/files.rs +1 -0
- package/crates/mint-core/src/orchestration/tools/mod.rs +5 -1
- package/crates/mint-core/src/orchestration/tools/planning.rs +102 -42
- package/crates/mint-core/src/orchestration/tools/shell.rs +129 -58
- package/crates/mint-core/src/orchestration/truncation.rs +170 -13
- package/crates/mint-core/src/orchestration/types.rs +648 -0
- package/crates/mint-core/src/orchestration/verification.rs +0 -3
- package/crates/mint-core/src/orchestration/workspace_helpers.rs +24 -6
- package/crates/mint-core/src/prompts/agent.rs +52 -76
- package/crates/mint-core/src/prompts/tool_catalog.rs +8 -151
- package/crates/mint-core/src/search/files.rs +20 -0
- package/crates/mint-core/src/slash/mod.rs +228 -265
- package/crates/mint-core/src/slash/models.rs +6 -1
- package/crates/mint-core/src/system/bg_shell.rs +1 -0
- package/crates/mint-core/src/system/config.rs +176 -34
- package/crates/mint-mod-runtime/src/lib.rs +37 -23
- package/out/web/assets/ArtifactPreviewPanel-Cx6BVOOF.js +2 -0
- package/out/web/assets/CalculationCard-D2E74u5q.js +1 -0
- package/out/web/assets/CliSessionsView-ZbdtSjvK.css +1 -0
- package/out/web/assets/{CliSessionsView-MI8kXMAb.js → CliSessionsView-z5i6qGyb.js} +1 -1
- package/out/web/assets/CodeReviewPage-BK2bOScd.css +1 -0
- package/out/web/assets/CodeReviewPage-Dp6zHuHu.js +2 -0
- package/out/web/assets/{CommandPalette-BD8SlTE5.js → CommandPalette-DaKlzbKs.js} +1 -1
- package/out/web/assets/ImageGenCard-BMCJHP9O.js +1 -0
- package/out/web/assets/ImageSearchCard-Cld_SOUg.js +1 -0
- package/out/web/assets/{ImageStudioPanel-BP7N4rLr.js → ImageStudioPanel-B5N7TjeB.js} +1 -1
- package/out/web/assets/LibraryPage-CMB89rJ0.css +1 -0
- package/out/web/assets/LibraryPage-DbZEi4W_.js +2 -0
- package/out/web/assets/LinkedFoldersView-9B9DIPoZ.js +1 -0
- package/out/web/assets/{McpRegistryPicker-CoS83vtc.js → McpRegistryPicker-DnXPlzI7.js} +1 -1
- package/out/web/assets/McpServersView-CiNBNUl4.js +1 -0
- package/out/web/assets/MermaidCard-BBDNEXbY.js +2 -0
- package/out/web/assets/MintDashboard-C_UYPoNV.js +119 -0
- package/out/web/assets/MintDashboard-DTd8C_c8.css +1 -0
- package/out/web/assets/PlanCard-C2x5pajq.js +1 -0
- package/out/web/assets/{PluginsView-jBYmcKh4.js → PluginsView-DoTg9MVa.js} +1 -1
- package/out/web/assets/RewindModal-B5oowrR0.js +1 -0
- package/out/web/assets/{ScheduledTasksView-5uw9-V9H.js → ScheduledTasksView-BD5DVPkZ.js} +1 -1
- package/out/web/assets/{SelectField-DXBQtrUK.js → SelectField-Cy6CghrU.js} +1 -1
- package/out/web/assets/SettingsWindow-BYl_oCFh.js +4 -0
- package/out/web/assets/SettingsWindow-Ckcgnjt8.css +1 -0
- package/out/web/assets/{SkillsView-C-gp9J7m.js → SkillsView-CIeWwq5z.js} +1 -1
- package/out/web/assets/StockCard-DxUciKyp.js +1 -0
- package/out/web/assets/ToolSurfacePage-qBgs_VHq.css +1 -0
- package/out/web/assets/ToolSurfacePage-saVbVrkE.js +38 -0
- package/out/web/assets/{UiFeatureCard-6BwxTOCe.js → UiFeatureCard-CdY0hGWy.js} +1 -1
- package/out/web/assets/{UiGridCard-DvZcLNxb.js → UiGridCard-BBK3I5hC.js} +1 -1
- package/out/web/assets/UiMockupWidget-CpR9EDuc.js +1 -0
- package/out/web/assets/{VeoStudioPanel-DOj7_WR3.css → VeoStudioPanel-9kU9YLEN.css} +1 -1
- package/out/web/assets/{VeoStudioPanel-DfsOJKv0.js → VeoStudioPanel-Co6-wEWM.js} +2 -2
- package/out/web/assets/WeatherCard-Buzi7Vfu.js +1 -0
- package/out/web/assets/WorkspacePdfPreview-Bi8Zzyir.js +2 -0
- package/out/web/assets/{abnfDiagram-VCTEODGH-BOo3Ckng.js → abnfDiagram-VCTEODGH-CMfR95N2.js} +1 -1
- package/out/web/assets/{architecture-7GRP2DOG-ZOwheW3n.js → architecture-7GRP2DOG-DhAM4HyO.js} +1 -1
- package/out/web/assets/{architectureDiagram-5GKGNRK7-DC2FDzVd.js → architectureDiagram-5GKGNRK7-Yk3TUJ_F.js} +1 -1
- package/out/web/assets/{blockDiagram-I7D4REHJ-BrKFWdAH.js → blockDiagram-I7D4REHJ-X9fBgJik.js} +1 -1
- package/out/web/assets/{c4Diagram-7LVT6UL2-BHTHK1Yd.js → c4Diagram-7LVT6UL2-Bk9x7N-8.js} +1 -1
- package/out/web/assets/{chunk-4HAMMTFA-BYFkZwNj.js → chunk-4HAMMTFA-Dx5fk1_0.js} +1 -1
- package/out/web/assets/{chunk-75Z2AOVW-BSKfSKSA.js → chunk-75Z2AOVW-BszTQoM4.js} +1 -1
- package/out/web/assets/{chunk-DU6HZSFF-vIhsiW6n.js → chunk-DU6HZSFF-DK-As3rk.js} +1 -1
- package/out/web/assets/{chunk-F27PBJKO-BlYpW9xq.js → chunk-F27PBJKO-LmncZZIS.js} +1 -1
- package/out/web/assets/{chunk-GMAD6QVW-BIBz_Ug5.js → chunk-GMAD6QVW-DSQ_sDRJ.js} +1 -1
- package/out/web/assets/{chunk-GVQU2GXP-NPSHxeqC.js → chunk-GVQU2GXP-Cno3ulje.js} +1 -1
- package/out/web/assets/{chunk-IMKFNOWR-YlJK8_Fp.js → chunk-IMKFNOWR-bIzfY9Jc.js} +1 -1
- package/out/web/assets/{chunk-L3NEJ4N5-DMYi4-af.js → chunk-L3NEJ4N5-Dl6eOa0D.js} +1 -1
- package/out/web/assets/{chunk-OSK3NFVY-Begd3-jA.js → chunk-OSK3NFVY-BaUGJ-cM.js} +1 -1
- package/out/web/assets/{chunk-P2QGCYS3-D1I4xFHN.js → chunk-P2QGCYS3-_066ndhH.js} +1 -1
- package/out/web/assets/{chunk-POPQ4Y6H-CafZZzNE.js → chunk-POPQ4Y6H-YT1AKhTv.js} +1 -1
- package/out/web/assets/{chunk-PWAF6VOD-Dwh6_Lnt.js → chunk-PWAF6VOD-CrEgDsxh.js} +1 -1
- package/out/web/assets/{chunk-SHT3W25Y-B0XrCSEb.js → chunk-SHT3W25Y-_KwaRLOl.js} +1 -1
- package/out/web/assets/{chunk-SVP7TREG-CxLsddxC.js → chunk-SVP7TREG-BQ1qb32L.js} +1 -1
- package/out/web/assets/{chunk-TICWLB2K-y156QInb.js → chunk-TICWLB2K-BDyehCtg.js} +1 -1
- package/out/web/assets/classDiagram-ZZMXUADV-BoSpzRCJ.js +1 -0
- package/out/web/assets/classDiagram-v2-VYDZK3BY-BoSpzRCJ.js +1 -0
- package/out/web/assets/{config-DqXJXeZM.css → config-2suVFPZg.css} +1 -1
- package/out/web/assets/config-CkRCdkxn.js +2 -0
- package/out/web/assets/{cynefin-OW5HDTMX-SMVvrLf-.js → cynefin-OW5HDTMX-Dl_31aaN.js} +1 -1
- package/out/web/assets/{cynefinDiagram-5FMLGOSQ-rZznN24T.js → cynefinDiagram-5FMLGOSQ-Bpir8jha.js} +1 -1
- package/out/web/assets/{dagre-GXQ25YYZ-BOToydFu.js → dagre-GXQ25YYZ-D9xu3nsq.js} +1 -1
- package/out/web/assets/{diagram-S7CK7UJ4-TMVaGQEJ.js → diagram-S7CK7UJ4-DeveoEeB.js} +1 -1
- package/out/web/assets/{diagram-UQ7AKVKN-C2JK-GP2.js → diagram-UQ7AKVKN-p4xWRESV.js} +1 -1
- package/out/web/assets/{diagram-VSXAHHWV-Cw8VSi2K.js → diagram-VSXAHHWV-C7qz9NDF.js} +1 -1
- package/out/web/assets/{diagram-VX7I27RA-DXp4fKk9.js → diagram-VX7I27RA-DRiCa3Vd.js} +1 -1
- package/out/web/assets/{diagram-Z3DM3KII-CzSicYiW.js → diagram-Z3DM3KII-Bt7zduQX.js} +1 -1
- package/out/web/assets/{ebnfDiagram-PWID7BFC-Dqj-zqPu.js → ebnfDiagram-PWID7BFC-DV9WpwdR.js} +1 -1
- package/out/web/assets/{erDiagram-RLTQ6QDP-DZwFZAuD.js → erDiagram-RLTQ6QDP-BJ_nwG2m.js} +1 -1
- package/out/web/assets/{eventmodeling-NTZA5JFV-C0O-Lvgp.js → eventmodeling-NTZA5JFV-BTBMrfVa.js} +1 -1
- package/out/web/assets/flowDiagram-HODETNUW-Cdlq3cJL.js +1 -0
- package/out/web/assets/{ganttDiagram-EL5Y4UJY-Da4ULXTl.js → ganttDiagram-EL5Y4UJY-DQLdsXrC.js} +1 -1
- package/out/web/assets/{gitGraph-4MIJSDKK-B9wNFZIP.js → gitGraph-4MIJSDKK-CL1OEOQ_.js} +1 -1
- package/out/web/assets/{gitGraphDiagram-WWUBYQGX-RjrhB0MP.js → gitGraphDiagram-WWUBYQGX-8cSi42H7.js} +1 -1
- package/out/web/assets/index-BocxnrDG.js +168 -0
- package/out/web/assets/index-yeh_r55a.css +1 -0
- package/out/web/assets/{info-A6RAGUB7-B3dfCpeY.js → info-A6RAGUB7-C1zsX5sc.js} +1 -1
- package/out/web/assets/{infoDiagram-27XIBGKW-QVWkLVx7.js → infoDiagram-27XIBGKW-DsRYtIyI.js} +1 -1
- package/out/web/assets/{ishikawaDiagram-5VMMS53U-CLFNIRI9.js → ishikawaDiagram-5VMMS53U-B2crwIXj.js} +1 -1
- package/out/web/assets/{journeyDiagram-3NMN7TZE-fgOQadYF.js → journeyDiagram-3NMN7TZE-BTH-GyVR.js} +1 -1
- package/out/web/assets/{kanban-definition-UXKFOSKX-G3Q_9g9O.js → kanban-definition-UXKFOSKX-BeRfCnuA.js} +1 -1
- package/out/web/assets/{mermaid-parser.core-v3Jm7nco.js → mermaid-parser.core-BJwgCBBc.js} +3 -3
- package/out/web/assets/{mermaid.core-DTEjLUA1.js → mermaid.core-CsDDodN8.js} +4 -4
- package/out/web/assets/{mindmap-definition-YA3MSWOX-C_D42_fF.js → mindmap-definition-YA3MSWOX-B42efOQ1.js} +1 -1
- package/out/web/assets/{modelManager-BPLycQrN.js → modelManager-B3ZFMh8v.js} +2 -2
- package/out/web/assets/{oauthManager-BUK7ic7q.js → oauthManager-Ch5Du2fF.js} +1 -1
- package/out/web/assets/{packet-AYTQ26CC-B-R-SHlF.js → packet-AYTQ26CC-B6xUyE6r.js} +1 -1
- package/out/web/assets/{pegDiagram-XKGWAZYB-Da2q_giz.js → pegDiagram-XKGWAZYB-1LabVycL.js} +1 -1
- package/out/web/assets/{pie-WAS4IAKB-BC8F0N7-.js → pie-WAS4IAKB-DJwo-nf_.js} +1 -1
- package/out/web/assets/{pieDiagram-E7YTZNPT-Iy9REJNC.js → pieDiagram-E7YTZNPT-icmAqzy4.js} +1 -1
- package/out/web/assets/{quadrantDiagram-AXDQQJYC-ztsiYw7h.js → quadrantDiagram-AXDQQJYC-CZ-wEx41.js} +1 -1
- package/out/web/assets/{radar-RG4KPBEZ-B_1P8Z_z.js → radar-RG4KPBEZ-D7d-Xriw.js} +1 -1
- package/out/web/assets/{railroad-74A4TZTK-DuTqczSo.js → railroad-74A4TZTK-viuhdYbs.js} +1 -1
- package/out/web/assets/{railroad-abnf-HS5TGJTU-Df29WLmp.js → railroad-abnf-HS5TGJTU-DNXiCNea.js} +1 -1
- package/out/web/assets/{railroad-ebnf-LZEXJU2U-ePFMo-qp.js → railroad-ebnf-LZEXJU2U-nkSu1-0C.js} +1 -1
- package/out/web/assets/{railroad-peg-WCYAUIDC-DYXLW4_E.js → railroad-peg-WCYAUIDC-BhbMeQzz.js} +1 -1
- package/out/web/assets/{railroadDiagram-O6MQD6OU-LStnZf2S.js → railroadDiagram-O6MQD6OU-CLpG1jUX.js} +1 -1
- package/out/web/assets/{requirementDiagram-BXWQKSXE-DmzonMH-.js → requirementDiagram-BXWQKSXE-kRDrvWrc.js} +1 -1
- package/out/web/assets/{sankeyDiagram-P5KCCOFB-B8vjmUU1.js → sankeyDiagram-P5KCCOFB-DxAwmq4x.js} +1 -1
- package/out/web/assets/{sequenceDiagram-WJ2MYXX4-CTnplOAX.js → sequenceDiagram-WJ2MYXX4-aLgY-6Wz.js} +1 -1
- package/out/web/assets/{stateDiagram-D77RDMKH-BFrIaLdI.js → stateDiagram-D77RDMKH-D5QWfOhp.js} +1 -1
- package/out/web/assets/stateDiagram-v2-MP3YSRHH-D6OiZoAP.js +1 -0
- package/out/web/assets/{swimlanes-42K2YHIH-BXGngUNU.js → swimlanes-42K2YHIH-CRs5UyA-.js} +1 -1
- package/out/web/assets/swimlanesDiagram-VR7AAH4N-UgEXIsV0.js +8 -0
- package/out/web/assets/syntaxHighlight-C-wAGT84.js +3 -0
- package/out/web/assets/{timeline-definition-24CTP7MA-CegH7n43.js → timeline-definition-24CTP7MA-Denqd9qb.js} +1 -1
- package/out/web/assets/{treeView-Q6P3EWNA-DTpJv2Tw.js → treeView-Q6P3EWNA-0my3Z3s4.js} +1 -1
- package/out/web/assets/{treemap-WGGIJYW6-i8M9iJU6.js → treemap-WGGIJYW6-B_EjkLYq.js} +1 -1
- package/out/web/assets/{vennDiagram-4TSXK5OY-Dpzo1S8U.js → vennDiagram-4TSXK5OY-DxVg3Ewr.js} +1 -1
- package/out/web/assets/{wardley-WFR3VGLG-BrNsgtfw.js → wardley-WFR3VGLG-CGD-Tj4p.js} +1 -1
- package/out/web/assets/{wardleyDiagram-VM6X3IG4-lQ92LFE1.js → wardleyDiagram-VM6X3IG4-CmfS39Xm.js} +1 -1
- package/out/web/assets/{xychartDiagram-S5SC5T6Z-BaOaEoBd.js → xychartDiagram-S5SC5T6Z-BVM9bKis.js} +1 -1
- package/out/web/index-web.html +2 -2
- package/out/web/index.html +2 -2
- package/package.json +2 -2
- package/shared-modules/CODEOWNERS +3 -3
- package/shared-modules/SDL2/SDL2_mixer.json +27 -0
- package/shared-modules/appstream/README.md +13 -0
- package/shared-modules/appstream/appstream-compose.json +111 -0
- package/shared-modules/dbus-glib/dbus-glib.json +2 -2
- package/shared-modules/libportal/libportal.json +23 -0
- package/shared-modules/linux-audio/fluidsynth2-static.json +2 -2
- package/shared-modules/linux-audio/fluidsynth2.json +2 -2
- package/shared-modules/linux-audio/libinstpatch.json +2 -2
- package/shared-modules/linux-audio/liblo-static.json +3 -0
- package/shared-modules/luajit/luajit.json +2 -2
- package/shared-modules/squashfs-tools/squashfs-tools.json +0 -3
- package/slash-commands.json +7 -2
- package/src/renderer/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/src/renderer/shared/agentProgress.ts +51 -3
- package/src/renderer/shared/components/Settings/AutomationTab.tsx +2 -56
- package/src/renderer/shared/components/Settings/AvatarCropModal.tsx +15 -1
- package/src/renderer/shared/components/Settings/GeneralTab.tsx +25 -12
- package/src/renderer/shared/components/Settings/ThemeTab.test.tsx +45 -0
- package/src/renderer/shared/components/Settings/ThemeTab.tsx +106 -3
- package/src/renderer/shared/components/activity/AgentActivityDrawer.tsx +67 -17
- package/src/renderer/shared/components/activity/AgentActivityTable.tsx +376 -134
- package/src/renderer/shared/components/activity/AskUserActivityCard.test.tsx +122 -0
- package/src/renderer/shared/components/activity/AskUserActivityCard.tsx +70 -0
- package/src/renderer/shared/components/activity/McpToolResults.tsx +39 -2
- package/src/renderer/shared/components/activity/SubagentDagView.tsx +7 -7
- package/src/renderer/shared/components/activity/ViewedImages.tsx +41 -2
- package/src/renderer/shared/components/app/ChunkErrorBoundary.tsx +3 -3
- package/src/renderer/shared/components/app/CommandPalette.tsx +7 -7
- package/src/renderer/shared/components/app/DesktopTitlebar.tsx +2 -2
- package/src/renderer/shared/components/app/MintDashboard.tsx +27 -17
- package/src/renderer/shared/components/app/ModalErrorBoundary.tsx +3 -3
- package/src/renderer/shared/components/cards/CalculationCard.tsx +3 -3
- package/src/renderer/shared/components/cards/ImageGenCard.tsx +2 -2
- package/src/renderer/shared/components/cards/ImageSearchCard.tsx +2 -2
- package/src/renderer/shared/components/cards/MermaidCard.tsx +1 -1
- package/src/renderer/shared/components/cards/PlanCard.test.tsx +41 -0
- package/src/renderer/shared/components/cards/PlanCard.tsx +32 -0
- package/src/renderer/shared/components/cards/ReleaseNotesCard.test.tsx +29 -0
- package/src/renderer/shared/components/cards/ReleaseNotesCard.tsx +137 -0
- package/src/renderer/shared/components/cards/StockCard.tsx +7 -7
- package/src/renderer/shared/components/cards/ToolResultCards.tsx +11 -3
- package/src/renderer/shared/components/cards/UiMockupWidget.tsx +4 -4
- package/src/renderer/shared/components/cards/WeatherCard.tsx +5 -5
- package/src/renderer/shared/components/chat/ApprovalCard.test.tsx +11 -0
- package/src/renderer/shared/components/chat/ApprovalCard.tsx +21 -2
- package/src/renderer/shared/components/chat/ArtifactCards.test.tsx +69 -0
- package/src/renderer/shared/components/chat/ArtifactCards.tsx +238 -0
- package/src/renderer/shared/components/chat/AttachedDocumentCard.test.tsx +39 -0
- package/src/renderer/shared/components/chat/AttachedDocumentCard.tsx +220 -0
- package/src/renderer/shared/components/chat/ChatCodeBlock.test.tsx +33 -0
- package/src/renderer/shared/components/chat/ChatCodeBlock.tsx +15 -1
- package/src/renderer/shared/components/chat/ChatMessageItem.test.tsx +40 -2
- package/src/renderer/shared/components/chat/ChatMessageItem.tsx +180 -34
- package/src/renderer/shared/components/chat/ChatModeSelector.css +227 -0
- package/src/renderer/shared/components/chat/ChatModeSelector.test.tsx +45 -0
- package/src/renderer/shared/components/chat/ChatModeSelector.tsx +198 -0
- package/src/renderer/shared/components/chat/ChatPanel.test.tsx +22 -0
- package/src/renderer/shared/components/chat/ChatPanel.tsx +227 -53
- package/src/renderer/shared/components/chat/DiffReviewModal.tsx +6 -6
- package/src/renderer/shared/components/chat/GeminiLiveOverlay.test.tsx +142 -1
- package/src/renderer/shared/components/chat/GeminiLiveOverlay.tsx +45 -3
- package/src/renderer/shared/components/chat/ModelSelectorPopover.tsx +42 -14
- package/src/renderer/shared/components/chat/RewindModal.tsx +10 -10
- package/src/renderer/shared/components/chat/SlashSuggestions.test.tsx +14 -0
- package/src/renderer/shared/components/chat/SourcesBlock.test.tsx +53 -0
- package/src/renderer/shared/components/chat/SourcesBlock.tsx +236 -90
- package/src/renderer/shared/components/chat/TurnFileChanges.test.tsx +45 -1
- package/src/renderer/shared/components/chat/TurnFileChanges.tsx +202 -169
- package/src/renderer/shared/components/extensions/CliSessionsView.tsx +5 -4
- package/src/renderer/shared/components/extensions/McpRegistryPicker.tsx +3 -3
- package/src/renderer/shared/components/studio/LibraryPage.tsx +48 -5
- package/src/renderer/shared/components/workspace/ArtifactPreviewPanel.tsx +125 -13
- package/src/renderer/shared/components/workspace/CodeReviewPage.test.tsx +50 -0
- package/src/renderer/shared/components/workspace/CodeReviewPage.tsx +77 -16
- package/src/renderer/shared/components/workspace/GitBranchSelector.tsx +78 -22
- package/src/renderer/shared/components/workspace/ToolSurfacePage.test.tsx +13 -0
- package/src/renderer/shared/components/workspace/ToolSurfacePage.tsx +48 -28
- package/src/renderer/shared/components/workspace/WorkspacePdfPreview.tsx +266 -47
- package/src/renderer/shared/components/workspace/WorkspacePreviewActions.test.tsx +18 -6
- package/src/renderer/shared/components/workspace/background-terminals.css +11 -11
- package/src/renderer/shared/constants/config.ts +1 -5
- package/src/renderer/shared/constants/models.test.ts +51 -0
- package/src/renderer/shared/constants/models.ts +139 -32
- package/src/renderer/shared/conversation/lifecycle.test.ts +91 -3
- package/src/renderer/shared/conversation/lifecycle.ts +104 -5
- package/src/renderer/shared/conversation/progressiveStream.test.ts +83 -0
- package/src/renderer/shared/conversation/progressiveStream.ts +219 -0
- package/src/renderer/shared/css/agent-observability.css +133 -0
- package/src/renderer/shared/css/chat-ui-cards.css +13 -13
- package/src/renderer/shared/css/code-review.css +63 -34
- package/src/renderer/shared/css/code-sessions.css +47 -18
- package/src/renderer/shared/css/collapsed-paste.css +1 -1
- package/src/renderer/shared/css/error-notice.css +1 -1
- package/src/renderer/shared/css/file-changes.css +165 -80
- package/src/renderer/shared/css/library.css +105 -10
- package/src/renderer/shared/css/management-views.css +1 -1
- package/src/renderer/shared/css/mcp-results.css +5 -5
- package/src/renderer/shared/css/model-selector.css +18 -18
- package/src/renderer/shared/css/mods.css +7 -7
- package/src/renderer/shared/css/notification-notice.css +2 -2
- package/src/renderer/shared/css/queued-messages.css +5 -5
- package/src/renderer/shared/css/settings/general.css +18 -13
- package/src/renderer/shared/css/settings/profile.css +5 -5
- package/src/renderer/shared/css/settings/theme.css +5 -5
- package/src/renderer/shared/css/slash-choice.css +2 -2
- package/src/renderer/shared/css/slash-suggestions.css +3 -3
- package/src/renderer/shared/css/sources-block.css +41 -2
- package/src/renderer/shared/css/tools-panel.css +2 -2
- package/src/renderer/shared/css/ui-tailwind.css +8 -0
- package/src/renderer/shared/css/veo-studio.css +2 -2
- package/src/renderer/shared/css/viewed-images.css +7 -7
- package/src/renderer/shared/platform.ts +2 -1
- package/src/renderer/shared/theme/themeManager.test.ts +17 -0
- package/src/renderer/shared/theme/themeManager.ts +21 -4
- package/src/renderer/shared/theme/themes.ts +54 -0
- package/src/renderer/shared/theme/tokens.css +6 -4
- package/src/renderer/shared/types.ts +1 -1
- package/src/renderer/shared/utils/agentActivity.test.ts +468 -2
- package/src/renderer/shared/utils/agentActivity.ts +602 -35
- package/src/renderer/shared/utils/artifactBrowser.test.ts +37 -32
- package/src/renderer/shared/utils/artifactBrowser.ts +17 -19
- package/src/renderer/shared/utils/attachedDocument.test.ts +73 -0
- package/src/renderer/shared/utils/attachedDocument.ts +49 -0
- package/src/renderer/shared/utils/fileIcons.test.ts +59 -0
- package/src/renderer/shared/utils/fileIcons.ts +149 -16
- package/src/renderer/shared/utils/markdown.tsx +72 -19
- package/src/renderer/shared/utils/markdown.webImages.test.tsx +16 -0
- package/src/renderer/shared/utils/mcpResults.test.tsx +29 -0
- package/src/renderer/shared/utils/slashCommandProcessor.ts +4 -1
- package/src/renderer/shared/utils/syntaxHighlight.test.tsx +11 -1
- package/src/renderer/shared/utils/syntaxHighlight.tsx +51 -3
- package/src/renderer/shared/utils/useGeminiLiveVoice.ts +324 -51
- package/src/renderer/shared/utils/workspaceDrag.test.ts +153 -0
- package/src/renderer/shared/utils/workspaceDrag.ts +142 -0
- package/src/renderer/shared/utils/workspaceMedia.ts +10 -2
- package/src/renderer/src/App.tsx +14 -8
- package/src/renderer/src/agentProgress.ts +1 -0
- package/src/renderer/src/components/ScreenPicker.css +1 -1
- package/src/renderer/src/components/WorkspacePanel.tsx +6 -5
- package/src/renderer/src/css/app-shell.css +4 -4
- package/src/renderer/src/css/chat.css +1261 -241
- package/src/renderer/src/css/desktop-tools.css +11 -11
- package/src/renderer/src/css/sidebar-workspace.css +2 -2
- package/src/renderer/src/css/tool-surfaces.css +12 -12
- package/src/renderer/src/index.css +38 -20
- package/src/renderer/src/tauri.ts +22 -2
- package/src/renderer/src-web/App.tsx +14 -0
- package/src/renderer/src-web/agentProgress.ts +1 -0
- package/src/renderer/src-web/css/app-shell.css +4 -4
- package/src/renderer/src-web/css/chat.css +1278 -190
- package/src/renderer/src-web/css/mobile.css +29 -13
- package/src/renderer/src-web/css/settings/mobile.css +2 -2
- package/src/renderer/src-web/css/sidebar-workspace.css +1 -1
- package/src/renderer/src-web/index.css +38 -20
- package/src/renderer/src-web/tauri.ts +22 -2
- package/src/renderer/tests/browserArtifacts.browser.tsx +1 -1
- package/src/renderer/tests/library.browser.tsx +3 -0
- package/src/renderer/tests/liveVoice.browser.html +1 -0
- package/src/renderer/tests/workspaceDrop.browser.tsx +1 -1
- package/src-tauri/src/app/lifecycle.rs +1 -14
- package/src-tauri/src/background/headless.rs +14 -8
- package/src-tauri/src/commands/conversation.rs +38 -0
- package/src-tauri/src/native/mod.rs +0 -1
- package/src-tauri/src/native/system.rs +4 -47
- package/crates/mint-cli/src/interactive/chat_tui.rs +0 -8855
- package/crates/mint-core/src/agent/chat.rs +0 -4394
- package/crates/mint-core/src/browser/lifecycle.rs +0 -291
- package/crates/mint-core/src/browser/logging.rs +0 -20
- package/crates/mint-core/src/orchestration/tools/browser.rs +0 -31
- package/out/web/assets/ArtifactPreviewPanel-S0dPfYFx.js +0 -2
- package/out/web/assets/CalculationCard-CRjP3E1j.js +0 -1
- package/out/web/assets/CliSessionsView-C3Br2v5Y.css +0 -1
- package/out/web/assets/CodeReviewPage-CUJceQme.css +0 -1
- package/out/web/assets/CodeReviewPage-VCUgPla_.js +0 -2
- package/out/web/assets/ImageGenCard-BCXxkVJX.js +0 -1
- package/out/web/assets/ImageSearchCard-N3yQOr8Z.js +0 -1
- package/out/web/assets/LibraryPage-DAxUTAg7.css +0 -1
- package/out/web/assets/LibraryPage-WlviRdR8.js +0 -2
- package/out/web/assets/LinkedFoldersView-CKRgroWK.js +0 -1
- package/out/web/assets/McpServersView-D3wHnuHg.js +0 -1
- package/out/web/assets/MermaidCard-NqrHdyJW.js +0 -2
- package/out/web/assets/MintDashboard-B2DJsDDO.css +0 -1
- package/out/web/assets/MintDashboard-COw_xol7.js +0 -63
- package/out/web/assets/RewindModal-DempuDod.js +0 -1
- package/out/web/assets/SettingsWindow-2YP3yj7s.js +0 -4
- package/out/web/assets/SettingsWindow-Dz8LQVgD.css +0 -1
- package/out/web/assets/StockCard-BDMCe7mx.js +0 -1
- package/out/web/assets/ToolSurfacePage-B0J_q0Jl.js +0 -38
- package/out/web/assets/ToolSurfacePage-CRIQ1owD.css +0 -1
- package/out/web/assets/UiMockupWidget-DD73QWg1.js +0 -1
- package/out/web/assets/WeatherCard-CPucIfq8.js +0 -1
- package/out/web/assets/WorkspacePdfPreview-Dx_lkjNu.js +0 -2
- package/out/web/assets/classDiagram-ZZMXUADV-BgJ5WtSp.js +0 -1
- package/out/web/assets/classDiagram-v2-VYDZK3BY-BgJ5WtSp.js +0 -1
- package/out/web/assets/config-CvIO5hkn.js +0 -2
- package/out/web/assets/flowDiagram-HODETNUW-uqCHJond.js +0 -1
- package/out/web/assets/index-BmpulIkg.js +0 -168
- package/out/web/assets/index-CkvBwhX_.css +0 -1
- package/out/web/assets/stateDiagram-v2-MP3YSRHH-uf3kdNW4.js +0 -1
- package/out/web/assets/swimlanesDiagram-VR7AAH4N-Y8q5bQce.js +0 -8
- package/shared-modules/libsecret/libsecret.json +0 -23
- package/shared-modules/libusb/libusb.json +0 -27
- package/shared-modules/lzo/lzo.json +0 -27
- package/src/renderer/src/components/BrowserShell.tsx +0 -108
- package/src/renderer/src/components/BrowserSuggestionsPopup.tsx +0 -91
- package/src/renderer/src/components/browser-shell.css +0 -185
- package/src-tauri/src/native/browser.rs +0 -252
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
|
2
|
-
use futures_util::StreamExt;
|
|
3
|
-
use serde::{Deserialize, Serialize};
|
|
4
2
|
use serde_json::Value;
|
|
5
3
|
use std::collections::BTreeMap;
|
|
6
4
|
use std::future::Future;
|
|
@@ -9,7 +7,6 @@ use std::pin::Pin;
|
|
|
9
7
|
use std::process::Command;
|
|
10
8
|
use std::sync::Arc;
|
|
11
9
|
use std::time::Instant;
|
|
12
|
-
use thiserror::Error;
|
|
13
10
|
|
|
14
11
|
pub mod approvals;
|
|
15
12
|
#[cfg(test)]
|
|
@@ -24,7 +21,9 @@ pub mod diagnostics;
|
|
|
24
21
|
pub mod loop_guard;
|
|
25
22
|
pub mod trajectory_schema;
|
|
26
23
|
pub mod truncation;
|
|
24
|
+
pub mod types;
|
|
27
25
|
pub use trajectory_schema::{StandardTrajectoryRun, StandardTrajectoryStep, StepTokens};
|
|
26
|
+
pub use types::*;
|
|
28
27
|
mod repeated_failures;
|
|
29
28
|
#[cfg(all(test, unix))]
|
|
30
29
|
mod review_regressions;
|
|
@@ -36,8 +35,8 @@ use crate::chat::{
|
|
|
36
35
|
stream_chat_events_with_fallback, stream_chat_with_fallback,
|
|
37
36
|
};
|
|
38
37
|
use crate::code_tools::{
|
|
39
|
-
CodeEdit,
|
|
40
|
-
|
|
38
|
+
CodeEdit, apply_code_edits, build_code_patch, list_code_files, propose_code_edits,
|
|
39
|
+
read_code_file, search_code,
|
|
41
40
|
};
|
|
42
41
|
use crate::config::ToolCallingMode;
|
|
43
42
|
use crate::knowledge::KnowledgeStore;
|
|
@@ -51,197 +50,14 @@ use crate::{
|
|
|
51
50
|
stream_chat,
|
|
52
51
|
};
|
|
53
52
|
|
|
54
|
-
const CONTEXT_LIMIT: usize =
|
|
53
|
+
pub(crate) const CONTEXT_LIMIT: usize = 15;
|
|
55
54
|
/// Per-message cap (in `chars`, not bytes — Thai text is multi-byte UTF-8)
|
|
56
55
|
/// applied to each recalled `user_text`/`ai_text` when building the "recent
|
|
57
|
-
/// conversation context" injected into a new task's opening system prompt
|
|
58
|
-
///
|
|
59
|
-
///
|
|
60
|
-
///
|
|
61
|
-
|
|
62
|
-
/// continuity, not to re-litigate a whole previous answer.
|
|
63
|
-
const MAX_CONTEXT_MESSAGE_CHARS: usize = 200;
|
|
64
|
-
|
|
65
|
-
struct TurnStartListener {
|
|
66
|
-
chat_id: String,
|
|
67
|
-
callback: Arc<dyn Fn(i64) + Send + Sync>,
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
tokio::task_local! {
|
|
71
|
-
static TURN_START_LISTENER: TurnStartListener;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/// Reports the persisted turn ID to the initiating transport without changing
|
|
75
|
-
/// the agent/chat interfaces used by CLI, jobs, and background integrations.
|
|
76
|
-
pub async fn with_turn_start_listener<F, R>(
|
|
77
|
-
chat_id: String,
|
|
78
|
-
callback: impl Fn(i64) + Send + Sync + 'static,
|
|
79
|
-
future: F,
|
|
80
|
-
) -> R
|
|
81
|
-
where
|
|
82
|
-
F: Future<Output = R>,
|
|
83
|
-
{
|
|
84
|
-
TURN_START_LISTENER
|
|
85
|
-
.scope(
|
|
86
|
-
TurnStartListener {
|
|
87
|
-
chat_id,
|
|
88
|
-
callback: Arc::new(callback),
|
|
89
|
-
},
|
|
90
|
-
future,
|
|
91
|
-
)
|
|
92
|
-
.await
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/// Owns one persisted turn, including its cross-process queue lease. Dropping a
|
|
96
|
-
/// cancelled task marks its prompt interrupted instead of leaving it running.
|
|
97
|
-
struct TurnLease {
|
|
98
|
-
chat_id: String,
|
|
99
|
-
memory: MemoryStore,
|
|
100
|
-
id: i64,
|
|
101
|
-
heartbeat: tokio::task::JoinHandle<()>,
|
|
102
|
-
finished: bool,
|
|
103
|
-
activity: Arc<std::sync::Mutex<Vec<AgentProgress>>>,
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
impl TurnLease {
|
|
107
|
-
async fn start(memory: &MemoryStore, chat_id: &str, text: &str) -> Result<Self, MemoryError> {
|
|
108
|
-
let id = memory.start_turn(chat_id, text)?;
|
|
109
|
-
crate::tts::playback::cancel_chat(memory, chat_id);
|
|
110
|
-
crate::companion::queued(chat_id, id, text);
|
|
111
|
-
let _ = TURN_START_LISTENER.try_with(|listener| {
|
|
112
|
-
if listener.chat_id == chat_id {
|
|
113
|
-
(listener.callback)(id);
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
let heartbeat_memory = memory.clone();
|
|
117
|
-
let heartbeat = tokio::spawn(async move {
|
|
118
|
-
loop {
|
|
119
|
-
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
|
120
|
-
if heartbeat_memory.renew_turn(id).is_err() {
|
|
121
|
-
break;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
let mut lease = Self {
|
|
126
|
-
chat_id: chat_id.to_owned(),
|
|
127
|
-
memory: memory.clone(),
|
|
128
|
-
id,
|
|
129
|
-
heartbeat,
|
|
130
|
-
finished: false,
|
|
131
|
-
activity: Arc::new(std::sync::Mutex::new(Vec::new())),
|
|
132
|
-
};
|
|
133
|
-
loop {
|
|
134
|
-
match memory.claim_turn(chat_id, id) {
|
|
135
|
-
Ok(true) => {
|
|
136
|
-
crate::companion::update(
|
|
137
|
-
chat_id,
|
|
138
|
-
id,
|
|
139
|
-
mint_companion_protocol::Status::Thinking,
|
|
140
|
-
None,
|
|
141
|
-
);
|
|
142
|
-
return Ok(lease);
|
|
143
|
-
}
|
|
144
|
-
Ok(false) => tokio::time::sleep(std::time::Duration::from_millis(250)).await,
|
|
145
|
-
Err(error) => {
|
|
146
|
-
lease.fail();
|
|
147
|
-
return Err(error);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
fn persist_activity(&self) {
|
|
154
|
-
if let Ok(lock) = self.activity.lock() {
|
|
155
|
-
let compacted = compact_agent_progress(&lock);
|
|
156
|
-
if !compacted.is_empty() {
|
|
157
|
-
if let Ok(activity_json) = serde_json::to_string(&compacted) {
|
|
158
|
-
let _ = self
|
|
159
|
-
.memory
|
|
160
|
-
.set_interaction_agent_activity_json(self.id, &activity_json);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
fn complete_with_summary(
|
|
167
|
-
&mut self,
|
|
168
|
-
summary: &str,
|
|
169
|
-
provider: &str,
|
|
170
|
-
model: &str,
|
|
171
|
-
fallback_provider: Option<&str>,
|
|
172
|
-
) -> Result<(), MemoryError> {
|
|
173
|
-
self.persist_activity();
|
|
174
|
-
self.memory
|
|
175
|
-
.finish_turn(self.id, summary, provider, model, fallback_provider)?;
|
|
176
|
-
self.finished = true;
|
|
177
|
-
crate::companion::update(
|
|
178
|
-
&self.chat_id,
|
|
179
|
-
self.id,
|
|
180
|
-
mint_companion_protocol::Status::Completed,
|
|
181
|
-
Some(summary.to_owned()),
|
|
182
|
-
);
|
|
183
|
-
Ok(())
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
fn complete(&mut self, response: &ChatResponse) -> Result<(), MemoryError> {
|
|
187
|
-
self.complete_with_summary(
|
|
188
|
-
&response.text,
|
|
189
|
-
&response.provider,
|
|
190
|
-
&response.model,
|
|
191
|
-
response.fallback_provider.as_deref(),
|
|
192
|
-
)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
fn fail(&mut self) {
|
|
196
|
-
self.persist_activity();
|
|
197
|
-
let _ = self.memory.end_turn(self.id, "failed");
|
|
198
|
-
crate::companion::update(
|
|
199
|
-
&self.chat_id,
|
|
200
|
-
self.id,
|
|
201
|
-
mint_companion_protocol::Status::Failed,
|
|
202
|
-
None,
|
|
203
|
-
);
|
|
204
|
-
self.finished = true;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
fn interrupt(&mut self) {
|
|
208
|
-
self.persist_activity();
|
|
209
|
-
let _ = self.memory.end_turn(self.id, "interrupted");
|
|
210
|
-
crate::companion::update(
|
|
211
|
-
&self.chat_id,
|
|
212
|
-
self.id,
|
|
213
|
-
mint_companion_protocol::Status::Interrupted,
|
|
214
|
-
None,
|
|
215
|
-
);
|
|
216
|
-
self.finished = true;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
impl Drop for TurnLease {
|
|
221
|
-
fn drop(&mut self) {
|
|
222
|
-
self.heartbeat.abort();
|
|
223
|
-
if !self.finished {
|
|
224
|
-
self.persist_activity();
|
|
225
|
-
let _ = self.memory.end_turn(self.id, "interrupted");
|
|
226
|
-
crate::companion::update(
|
|
227
|
-
&self.chat_id,
|
|
228
|
-
self.id,
|
|
229
|
-
mint_companion_protocol::Status::Interrupted,
|
|
230
|
-
None,
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
#[derive(Debug, Error)]
|
|
237
|
-
pub enum OrchestrationError {
|
|
238
|
-
#[error(transparent)]
|
|
239
|
-
Chat(#[from] ChatError),
|
|
240
|
-
#[error(transparent)]
|
|
241
|
-
Memory(#[from] MemoryError),
|
|
242
|
-
#[error("agent error: {0}")]
|
|
243
|
-
Agent(String),
|
|
244
|
-
}
|
|
56
|
+
/// conversation context" injected into a new task's opening system prompt or
|
|
57
|
+
/// structured `native_messages`.
|
|
58
|
+
/// Sized at 4,000 characters to comfortably accommodate rich code snippets,
|
|
59
|
+
/// full multi-line instructions, and Thai conversational text without truncation.
|
|
60
|
+
pub(crate) const MAX_CONTEXT_MESSAGE_CHARS: usize = 4000;
|
|
245
61
|
|
|
246
62
|
pub async fn resolve_github_links(message: &str, config: &MintConfig) -> String {
|
|
247
63
|
// Check if a GitHub MCP server is configured in Settings
|
|
@@ -481,23 +297,35 @@ where
|
|
|
481
297
|
Ok((response, fallback))
|
|
482
298
|
}
|
|
483
299
|
|
|
300
|
+
/// Read-only tools whose *outcome* (not just input) identifies a stuck loop:
|
|
301
|
+
/// image/page observations and headless-Chrome captures run through the shell.
|
|
302
|
+
fn is_observation_action(action: &str, command: &str) -> bool {
|
|
303
|
+
if action == "view_image" {
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
action == "run_shell" && {
|
|
307
|
+
let command = command.to_ascii_lowercase();
|
|
308
|
+
command.contains("chrome") || command.contains("chromium")
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
484
312
|
// Full skill reads must reach the model exactly; other tools keep bounded output.
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
{
|
|
313
|
+
// Large outputs (> 3,000 chars) are safely offloaded to .mint/scratch/tool_outputs/ when root is available.
|
|
314
|
+
pub(super) fn model_tool_result(root: Option<&Path>, action: &str, result: &str) -> String {
|
|
315
|
+
if action == "read_file" && result.starts_with("[Loaded skill: ") {
|
|
489
316
|
result.to_owned()
|
|
490
317
|
} else {
|
|
491
|
-
truncation::
|
|
318
|
+
truncation::truncate_tool_output(
|
|
319
|
+
root,
|
|
320
|
+
action,
|
|
321
|
+
result,
|
|
322
|
+
truncation::TruncationConfig::default(),
|
|
323
|
+
)
|
|
492
324
|
}
|
|
493
325
|
}
|
|
494
326
|
|
|
495
|
-
fn tool_progress_input(root: &Path, decision: &AgentDecision) -> Value {
|
|
327
|
+
pub(super) fn tool_progress_input(root: &Path, decision: &AgentDecision) -> Value {
|
|
496
328
|
let mut input = serde_json::to_value(&decision.input).unwrap_or(Value::Null);
|
|
497
|
-
if matches!(decision.action.as_str(), "browser_type" | "browser_fill") {
|
|
498
|
-
input["text"] = "[redacted input]".into();
|
|
499
|
-
input["query"] = "[redacted input]".into();
|
|
500
|
-
}
|
|
501
329
|
if decision.action == "read_file"
|
|
502
330
|
&& let Some(skill) = crate::skills::skill_for_read_path(root, &decision.input.path)
|
|
503
331
|
&& let Some(object) = input.as_object_mut()
|
|
@@ -519,70 +347,119 @@ pub(super) fn truncate_for_context(text: &str, max_chars: usize) -> String {
|
|
|
519
347
|
truncated
|
|
520
348
|
}
|
|
521
349
|
|
|
350
|
+
/// Deduplicates building user content blocks across plain chat enrichment and agent turn initialization.
|
|
351
|
+
pub(crate) fn build_user_content_blocks(
|
|
352
|
+
text: String,
|
|
353
|
+
image_data: Option<&str>,
|
|
354
|
+
audio_data: Option<&str>,
|
|
355
|
+
video_data: Option<&str>,
|
|
356
|
+
) -> Vec<ContentBlock> {
|
|
357
|
+
let mut content = vec![ContentBlock::Text { text }];
|
|
358
|
+
if let Some(image_data) = image_data {
|
|
359
|
+
for img in image_data.split_whitespace() {
|
|
360
|
+
content.push(ContentBlock::Image {
|
|
361
|
+
data_uri: img.to_owned(),
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if let Some(audio_data) = audio_data {
|
|
366
|
+
for aud in audio_data.split_whitespace() {
|
|
367
|
+
content.push(ContentBlock::Audio {
|
|
368
|
+
data_uri: aud.to_owned(),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if let Some(video_data) = video_data {
|
|
373
|
+
for vid in video_data.split_whitespace() {
|
|
374
|
+
content.push(ContentBlock::Video {
|
|
375
|
+
data_uri: vid.to_owned(),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
content
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
fn construct_system_prompt(
|
|
383
|
+
config: &MintConfig,
|
|
384
|
+
memory: &MemoryStore,
|
|
385
|
+
chat_id: &str,
|
|
386
|
+
root: &Path,
|
|
387
|
+
resolved_task: &str,
|
|
388
|
+
plan_mode: bool,
|
|
389
|
+
system_prompt_native: bool,
|
|
390
|
+
user_name: Option<&str>,
|
|
391
|
+
pinned_mcp_server: Option<&str>,
|
|
392
|
+
) -> String {
|
|
393
|
+
let mut system_prompt = build_system_prompt(
|
|
394
|
+
config,
|
|
395
|
+
plan_mode,
|
|
396
|
+
system_prompt_native,
|
|
397
|
+
user_name,
|
|
398
|
+
pinned_mcp_server,
|
|
399
|
+
);
|
|
400
|
+
append_memory_context(
|
|
401
|
+
&mut system_prompt,
|
|
402
|
+
memory,
|
|
403
|
+
chat_id,
|
|
404
|
+
Some(root.to_string_lossy().as_ref()),
|
|
405
|
+
Some(resolved_task),
|
|
406
|
+
config.semantic_fact_recall,
|
|
407
|
+
!system_prompt_native,
|
|
408
|
+
);
|
|
409
|
+
if config.memory_recall
|
|
410
|
+
&& let Some(recall) = render_recalled_messages(memory, chat_id, resolved_task)
|
|
411
|
+
{
|
|
412
|
+
system_prompt = format!("{}\n\n{}", system_prompt.trim(), recall);
|
|
413
|
+
}
|
|
414
|
+
system_prompt
|
|
415
|
+
}
|
|
416
|
+
|
|
522
417
|
fn enrich_request(
|
|
523
418
|
config: &MintConfig,
|
|
524
419
|
memory: &MemoryStore,
|
|
525
420
|
request: &ChatRequest,
|
|
526
421
|
) -> Result<ChatRequest, MemoryError> {
|
|
527
|
-
let mut interactions =
|
|
528
|
-
memory.recent_completed_interactions_for_chat(&request_chat_id(request), CONTEXT_LIMIT)?;
|
|
529
|
-
interactions.reverse();
|
|
530
|
-
let transcript = interactions
|
|
531
|
-
.into_iter()
|
|
532
|
-
.map(|item| {
|
|
533
|
-
format!(
|
|
534
|
-
"User: {}\nAssistant: {}",
|
|
535
|
-
truncate_for_context(&item.user_text, MAX_CONTEXT_MESSAGE_CHARS),
|
|
536
|
-
truncate_for_context(&item.ai_text, MAX_CONTEXT_MESSAGE_CHARS)
|
|
537
|
-
)
|
|
538
|
-
})
|
|
539
|
-
.collect::<Vec<_>>()
|
|
540
|
-
.join("\n\n");
|
|
541
422
|
let mut enriched = request.clone();
|
|
542
423
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
{
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
424
|
+
// If structured messages were not explicitly provided, build conversation history messages
|
|
425
|
+
// so multi-turn context is preserved natively in plain chat mode across all native providers.
|
|
426
|
+
let is_native = config.tool_calling_mode() == ToolCallingMode::Native;
|
|
427
|
+
if is_native && enriched.messages.is_none() {
|
|
428
|
+
let history = build_conversation_history_messages(
|
|
429
|
+
memory,
|
|
430
|
+
&request_chat_id(request),
|
|
431
|
+
CONTEXT_LIMIT,
|
|
432
|
+
MAX_CONTEXT_MESSAGE_CHARS,
|
|
433
|
+
);
|
|
434
|
+
if !history.is_empty() {
|
|
435
|
+
let mut messages = history;
|
|
436
|
+
let content = build_user_content_blocks(
|
|
437
|
+
request.message.clone(),
|
|
438
|
+
request.image_data_uri.as_deref(),
|
|
439
|
+
request.audio_data_uri.as_deref(),
|
|
440
|
+
request.video_data_uri.as_deref(),
|
|
441
|
+
);
|
|
442
|
+
messages.push(ChatMessage {
|
|
443
|
+
role: ChatRole::User,
|
|
444
|
+
content,
|
|
445
|
+
});
|
|
446
|
+
enriched.messages = Some(messages);
|
|
447
|
+
}
|
|
566
448
|
}
|
|
567
449
|
|
|
568
|
-
|
|
450
|
+
append_memory_context(
|
|
451
|
+
&mut enriched.system_instruction,
|
|
569
452
|
memory,
|
|
453
|
+
&request_chat_id(request),
|
|
570
454
|
request.workspace_path.as_deref(),
|
|
571
|
-
crate::subagent_name(&request_chat_id(request)),
|
|
572
455
|
Some(&request.message),
|
|
573
456
|
config.semantic_fact_recall,
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
"{}\n\nRemembered facts:\n{}",
|
|
577
|
-
enriched.system_instruction.trim(),
|
|
578
|
-
facts
|
|
579
|
-
)
|
|
580
|
-
.trim()
|
|
581
|
-
.to_owned();
|
|
582
|
-
}
|
|
457
|
+
enriched.messages.is_none(),
|
|
458
|
+
);
|
|
583
459
|
|
|
584
460
|
if config.memory_recall
|
|
585
|
-
&& let Some(recall) =
|
|
461
|
+
&& let Some(recall) =
|
|
462
|
+
render_recalled_messages(memory, &request_chat_id(request), &request.message)
|
|
586
463
|
{
|
|
587
464
|
enriched.system_instruction =
|
|
588
465
|
format!("{}\n\n{}", enriched.system_instruction.trim(), recall)
|
|
@@ -600,15 +477,6 @@ fn enrich_request(
|
|
|
600
477
|
);
|
|
601
478
|
enriched.system_instruction.push_str(&active_model_info);
|
|
602
479
|
|
|
603
|
-
if !transcript.is_empty() {
|
|
604
|
-
enriched.system_instruction = format!(
|
|
605
|
-
"{}\n\nRecent conversation context:\n{}",
|
|
606
|
-
enriched.system_instruction.trim(),
|
|
607
|
-
transcript
|
|
608
|
-
)
|
|
609
|
-
.trim()
|
|
610
|
-
.to_owned();
|
|
611
|
-
}
|
|
612
480
|
Ok(enriched)
|
|
613
481
|
}
|
|
614
482
|
|
|
@@ -625,14 +493,19 @@ fn request_chat_id(request: &ChatRequest) -> String {
|
|
|
625
493
|
use crate::prompts::agent::build_system_prompt;
|
|
626
494
|
|
|
627
495
|
mod context_render;
|
|
628
|
-
mod decision_parsing;
|
|
496
|
+
pub mod decision_parsing;
|
|
629
497
|
mod memory_skill;
|
|
630
498
|
mod tools;
|
|
631
499
|
mod verification;
|
|
632
500
|
mod workspace_helpers;
|
|
501
|
+
|
|
633
502
|
use context_render::*;
|
|
634
503
|
use decision_parsing::*;
|
|
504
|
+
pub use decision_parsing::{SwarmTaskInput, is_dummy_value};
|
|
635
505
|
pub(crate) use memory_skill::*;
|
|
506
|
+
pub use tools::execute_mod_requests;
|
|
507
|
+
pub(crate) use tools::execute_tool_from_json;
|
|
508
|
+
use tools::*;
|
|
636
509
|
use verification::*;
|
|
637
510
|
use workspace_helpers::*;
|
|
638
511
|
|
|
@@ -642,7 +515,6 @@ use workspace_helpers::*;
|
|
|
642
515
|
/// tasks earlier, but 24 turned out too tight for legitimate multi-file
|
|
643
516
|
/// work, cutting it off mid-task — now configurable via `MintConfig::agent_max_steps` (default 40).
|
|
644
517
|
// A tool gets its own correction budget, renewed after successful execution.
|
|
645
|
-
const MAX_TOOL_ARGUMENT_RETRIES: usize = 5;
|
|
646
518
|
const MAX_OBSERVATION_BYTES: usize = 16_000;
|
|
647
519
|
/// Compact `native_messages` once a step's reported token usage crosses this
|
|
648
520
|
/// fraction of `MintConfig::context_window_tokens`. Every step resends the whole
|
|
@@ -670,862 +542,6 @@ const ADAPTIVE_COMPACTION_MIN_STEPS: usize = 20;
|
|
|
670
542
|
const NETWORK_RETRY_ATTEMPTS: usize = 4;
|
|
671
543
|
const NETWORK_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
|
|
672
544
|
|
|
673
|
-
/// A single pick-a-choice option offered by the `ask_user` tool, with an
|
|
674
|
-
/// optional one-line explanation shown under the label in both the CLI
|
|
675
|
-
/// picker and the desktop/web approval card.
|
|
676
|
-
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
677
|
-
pub struct AskUserOption {
|
|
678
|
-
pub label: String,
|
|
679
|
-
#[serde(default)]
|
|
680
|
-
pub description: Option<String>,
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
684
|
-
pub enum AgentApproval {
|
|
685
|
-
/// Persistent agent instructions need a decision for each concrete diff.
|
|
686
|
-
SkillWrite {
|
|
687
|
-
path: String,
|
|
688
|
-
content: String,
|
|
689
|
-
diff: String,
|
|
690
|
-
},
|
|
691
|
-
WriteFile {
|
|
692
|
-
path: String,
|
|
693
|
-
content: String,
|
|
694
|
-
diff: String,
|
|
695
|
-
},
|
|
696
|
-
ApplyPatch {
|
|
697
|
-
path: String,
|
|
698
|
-
hunks: Vec<CodePatchHunk>,
|
|
699
|
-
diff: String,
|
|
700
|
-
},
|
|
701
|
-
RunShell {
|
|
702
|
-
command: String,
|
|
703
|
-
mode: String,
|
|
704
|
-
background: bool,
|
|
705
|
-
},
|
|
706
|
-
NoteWrite {
|
|
707
|
-
path: String,
|
|
708
|
-
content: String,
|
|
709
|
-
},
|
|
710
|
-
RunPlugin {
|
|
711
|
-
name: String,
|
|
712
|
-
instruction: String,
|
|
713
|
-
},
|
|
714
|
-
McpTool {
|
|
715
|
-
server: String,
|
|
716
|
-
tool: String,
|
|
717
|
-
arguments: Value,
|
|
718
|
-
},
|
|
719
|
-
UserApproval {
|
|
720
|
-
title: String,
|
|
721
|
-
prompt: String,
|
|
722
|
-
},
|
|
723
|
-
AskUser {
|
|
724
|
-
question: String,
|
|
725
|
-
#[serde(default)]
|
|
726
|
-
options: Vec<AskUserOption>,
|
|
727
|
-
#[serde(default)]
|
|
728
|
-
header: Option<String>,
|
|
729
|
-
#[serde(default, rename = "multiSelect")]
|
|
730
|
-
multi_select: bool,
|
|
731
|
-
},
|
|
732
|
-
ExitPlanMode {
|
|
733
|
-
plan: String,
|
|
734
|
-
},
|
|
735
|
-
EnterPlanMode {
|
|
736
|
-
reason: String,
|
|
737
|
-
},
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
741
|
-
pub enum ApprovalOutcome {
|
|
742
|
-
Approved,
|
|
743
|
-
Denied,
|
|
744
|
-
Intercepted(String),
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
/// The `answer` string a surface returns from an [`AgentApproval::McpTool`]
|
|
748
|
-
/// prompt to mean "run this call **and** persist `allowedMcpTools[server] =
|
|
749
|
-
/// ["*"]`, so this server never prompts again". Travels through the normal
|
|
750
|
-
/// `Intercepted(answer)` channel. The CLI approval picker
|
|
751
|
-
/// (`crates/mint-cli/src/agent.rs`) and the web/desktop `ApprovalCard`
|
|
752
|
-
/// (`src/renderer/shared/components/ApprovalCard.tsx`) send this exact string.
|
|
753
|
-
pub const MCP_ALLOW_ALL_SENTINEL: &str = "__mcp_allow_all__";
|
|
754
|
-
|
|
755
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
756
|
-
#[serde(tag = "type", content = "data")]
|
|
757
|
-
pub enum AgentProgress {
|
|
758
|
-
ToolDiscovery {
|
|
759
|
-
server: String,
|
|
760
|
-
tool: String,
|
|
761
|
-
message: String,
|
|
762
|
-
},
|
|
763
|
-
DeviceState {
|
|
764
|
-
#[serde(rename = "callId")]
|
|
765
|
-
call_id: String,
|
|
766
|
-
server: String,
|
|
767
|
-
tool: String,
|
|
768
|
-
update: crate::mcp::device::DeviceUpdate,
|
|
769
|
-
},
|
|
770
|
-
ToolProgress {
|
|
771
|
-
#[serde(rename = "callId")]
|
|
772
|
-
call_id: String,
|
|
773
|
-
server: String,
|
|
774
|
-
tool: String,
|
|
775
|
-
update: crate::mcp::McpProgress,
|
|
776
|
-
},
|
|
777
|
-
ToolArtifacts {
|
|
778
|
-
#[serde(rename = "callId")]
|
|
779
|
-
call_id: String,
|
|
780
|
-
artifacts: Vec<crate::integrations::mcp_result::McpArtifact>,
|
|
781
|
-
warnings: Vec<String>,
|
|
782
|
-
},
|
|
783
|
-
ExecutionUpdate {
|
|
784
|
-
#[serde(rename = "callId")]
|
|
785
|
-
call_id: String,
|
|
786
|
-
executions: Vec<reliability::ExecutionEvidence>,
|
|
787
|
-
},
|
|
788
|
-
Thinking {
|
|
789
|
-
elapsed_secs: u64,
|
|
790
|
-
#[serde(skip_serializing_if = "Option::is_none")]
|
|
791
|
-
agent_name: Option<String>,
|
|
792
|
-
#[serde(skip_serializing_if = "Option::is_none")]
|
|
793
|
-
model_name: Option<String>,
|
|
794
|
-
/// Context-window usage as of the last completed step, 0-100 — the
|
|
795
|
-
/// last thing we actually know, since usage is only reported in a
|
|
796
|
-
/// step's *response*, not predictable mid-generation. `None` until
|
|
797
|
-
/// the first step of a task has completed at least once.
|
|
798
|
-
#[serde(skip_serializing_if = "Option::is_none")]
|
|
799
|
-
context_pct: Option<u8>,
|
|
800
|
-
/// Running sum of `total_tokens` across every step completed so far
|
|
801
|
-
/// this turn — same accumulator as `AgentResult::total_tokens`, just
|
|
802
|
-
/// surfaced live instead of only once the turn finishes, so the CLI
|
|
803
|
-
/// can count it up next to the "Thinking…" label the way Claude Code
|
|
804
|
-
/// does. 0 until the first step has completed.
|
|
805
|
-
#[serde(default)]
|
|
806
|
-
tokens_used: u64,
|
|
807
|
-
/// Prompt/context tokens the *most recent* completed step processed
|
|
808
|
-
/// (system prompt + tool schemas + the whole resent history) — the
|
|
809
|
-
/// `↑` half of the live counter. Latest-step, not summed: it tracks
|
|
810
|
-
/// how full the context window is, and summing would count the resent
|
|
811
|
-
/// history once per step. 0 until the first step has completed.
|
|
812
|
-
#[serde(default)]
|
|
813
|
-
input_tokens: u64,
|
|
814
|
-
/// Running sum of completion tokens the model generated across every
|
|
815
|
-
/// step this turn — the `↓` half. Summed (unlike `input_tokens`),
|
|
816
|
-
/// since each step's output is distinct work. 0 until the first step
|
|
817
|
-
/// has completed.
|
|
818
|
-
#[serde(default)]
|
|
819
|
-
generated_tokens: u64,
|
|
820
|
-
/// Rough char-count-based estimate of the very first request's size
|
|
821
|
-
/// — constant across every step of the turn. Only meaningful to a
|
|
822
|
-
/// consumer while `tokens_used` is still 0 (i.e. step 1's response
|
|
823
|
-
/// hasn't come back yet), as a stand-in until the real number
|
|
824
|
-
/// exists; ignored once `tokens_used` is nonzero.
|
|
825
|
-
#[serde(default)]
|
|
826
|
-
estimated_tokens: u64,
|
|
827
|
-
},
|
|
828
|
-
Thought {
|
|
829
|
-
thought: String,
|
|
830
|
-
},
|
|
831
|
-
/// Incremental model reasoning for the currently active agent step. These
|
|
832
|
-
/// events are live-only; consumers replace them with the matching final
|
|
833
|
-
/// `ExtendedThinking` record instead of persisting every delta.
|
|
834
|
-
ThinkingDelta {
|
|
835
|
-
id: String,
|
|
836
|
-
delta: String,
|
|
837
|
-
elapsed_ms: u64,
|
|
838
|
-
},
|
|
839
|
-
/// Extended reasoning / chain-of-thought from the model's API thinking
|
|
840
|
-
/// tokens (e.g. Claude `<thinking>`, Gemini Thinking, DeepSeek Reasoner,
|
|
841
|
-
/// or `<think>` tags). Semantically distinct from `Thought`, which
|
|
842
|
-
/// carries short, one-line agent notes (fallback warnings, decision
|
|
843
|
-
/// summaries). The CLI renders this as a collapsible block; the web/
|
|
844
|
-
/// desktop UI shows it in a dedicated "Extended Thinking" panel.
|
|
845
|
-
ExtendedThinking {
|
|
846
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
847
|
-
id: Option<String>,
|
|
848
|
-
thought: String,
|
|
849
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
850
|
-
elapsed_ms: Option<u64>,
|
|
851
|
-
},
|
|
852
|
-
/// Lifecycle of native context summarization, including actionable failures.
|
|
853
|
-
ContextCompaction {
|
|
854
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
855
|
-
subagent: Option<String>,
|
|
856
|
-
status: String,
|
|
857
|
-
message: String,
|
|
858
|
-
},
|
|
859
|
-
/// Emitted while retrying a step after every configured provider was
|
|
860
|
-
/// unreachable (`ChatError::NetworkUnavailable`) — distinct from
|
|
861
|
-
/// `Thinking` because there's nothing to wait *on* here except the
|
|
862
|
-
/// network itself coming back, not a model generating a reply.
|
|
863
|
-
WaitingForNetwork {
|
|
864
|
-
attempt: usize,
|
|
865
|
-
max_attempts: usize,
|
|
866
|
-
},
|
|
867
|
-
ToolStart {
|
|
868
|
-
#[serde(default, rename = "callId", skip_serializing_if = "Option::is_none")]
|
|
869
|
-
call_id: Option<String>,
|
|
870
|
-
action: String,
|
|
871
|
-
input: Value,
|
|
872
|
-
/// Name of the subagent this tool call happened inside, if any —
|
|
873
|
-
/// `None` for the top-level agent's own calls. Set by
|
|
874
|
-
/// `dispatch_one_subagent` wrapping the nested loop's `progress` so
|
|
875
|
-
/// the CLI/GUI can render a subagent's own tool calls nested under
|
|
876
|
-
/// its `dispatch_subagent` call instead of indistinguishable from
|
|
877
|
-
/// the parent's.
|
|
878
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
879
|
-
subagent: Option<String>,
|
|
880
|
-
},
|
|
881
|
-
ToolEnd {
|
|
882
|
-
#[serde(default, alias = "elapsedMs", skip_serializing_if = "Option::is_none")]
|
|
883
|
-
elapsed_ms: Option<u64>,
|
|
884
|
-
#[serde(default, rename = "callId", skip_serializing_if = "Option::is_none")]
|
|
885
|
-
call_id: Option<String>,
|
|
886
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
887
|
-
status: Option<crate::integrations::mcp_result::ToolStatus>,
|
|
888
|
-
action: String,
|
|
889
|
-
input: Value,
|
|
890
|
-
result: String,
|
|
891
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
892
|
-
subagent: Option<String>,
|
|
893
|
-
},
|
|
894
|
-
PlanUpdated {
|
|
895
|
-
plan: ActivePlan,
|
|
896
|
-
},
|
|
897
|
-
RunCompleted {
|
|
898
|
-
summary: RunTelemetrySummary,
|
|
899
|
-
},
|
|
900
|
-
SubagentStarted {
|
|
901
|
-
subagent: String,
|
|
902
|
-
profile: String,
|
|
903
|
-
task: String,
|
|
904
|
-
},
|
|
905
|
-
SubagentFinished {
|
|
906
|
-
subagent: String,
|
|
907
|
-
status: String,
|
|
908
|
-
#[serde(default, alias = "elapsed_ms", rename = "elapsedMs")]
|
|
909
|
-
elapsed_ms: u64,
|
|
910
|
-
steps: u32,
|
|
911
|
-
#[serde(default, alias = "summary_preview", rename = "summaryPreview")]
|
|
912
|
-
summary_preview: String,
|
|
913
|
-
},
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
impl From<crate::swarm::SwarmEvent> for AgentProgress {
|
|
917
|
-
fn from(event: crate::swarm::SwarmEvent) -> Self {
|
|
918
|
-
match event {
|
|
919
|
-
crate::swarm::SwarmEvent::Started {
|
|
920
|
-
label,
|
|
921
|
-
profile,
|
|
922
|
-
task,
|
|
923
|
-
} => AgentProgress::SubagentStarted {
|
|
924
|
-
subagent: label,
|
|
925
|
-
profile,
|
|
926
|
-
task,
|
|
927
|
-
},
|
|
928
|
-
crate::swarm::SwarmEvent::Progress(p) => p,
|
|
929
|
-
crate::swarm::SwarmEvent::Finished {
|
|
930
|
-
label,
|
|
931
|
-
status,
|
|
932
|
-
elapsed_ms,
|
|
933
|
-
steps,
|
|
934
|
-
summary_preview,
|
|
935
|
-
} => AgentProgress::SubagentFinished {
|
|
936
|
-
subagent: label,
|
|
937
|
-
status: status.as_str().to_string(),
|
|
938
|
-
elapsed_ms,
|
|
939
|
-
steps,
|
|
940
|
-
summary_preview,
|
|
941
|
-
},
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
/// Determines whether a thought string represents long-form / internal chain-of-thought
|
|
947
|
-
/// (reasoning tokens, <think> tags, or multi-line reasoning) versus a brief agent note.
|
|
948
|
-
pub fn is_internal_cot(text: &str) -> bool {
|
|
949
|
-
let trimmed = text.trim();
|
|
950
|
-
if trimmed.starts_with("<think>") || trimmed.contains("</think>") {
|
|
951
|
-
return true;
|
|
952
|
-
}
|
|
953
|
-
let line_count = trimmed.lines().count();
|
|
954
|
-
let char_count = trimmed.chars().count();
|
|
955
|
-
if trimmed.contains("\n\n") || line_count > 2 || char_count > 180 {
|
|
956
|
-
return true;
|
|
957
|
-
}
|
|
958
|
-
let lower = trimmed.to_lowercase();
|
|
959
|
-
if lower.starts_with("the user ")
|
|
960
|
-
|| lower.starts_with("let's think")
|
|
961
|
-
|| lower.starts_with("let me analyze")
|
|
962
|
-
|| lower.starts_with("per rule")
|
|
963
|
-
|| lower.starts_with("note the ")
|
|
964
|
-
|| lower.contains("thinking process:")
|
|
965
|
-
{
|
|
966
|
-
return true;
|
|
967
|
-
}
|
|
968
|
-
false
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
/// Compacts an agent progress event stream for storage, removing transport-only
|
|
972
|
-
/// streaming deltas (`ThinkingDelta`) while preserving complete `ExtendedThinking`
|
|
973
|
-
/// blocks, or synthesizing an `ExtendedThinking` block for any interrupted delta sequence.
|
|
974
|
-
pub fn compact_agent_progress(progress: &[AgentProgress]) -> Vec<AgentProgress> {
|
|
975
|
-
let completed_ids: std::collections::HashSet<String> = progress
|
|
976
|
-
.iter()
|
|
977
|
-
.filter_map(|event| match event {
|
|
978
|
-
AgentProgress::ExtendedThinking { id: Some(id), .. } => Some(id.clone()),
|
|
979
|
-
_ => None,
|
|
980
|
-
})
|
|
981
|
-
.collect();
|
|
982
|
-
|
|
983
|
-
let mut result = Vec::new();
|
|
984
|
-
let mut interrupted_order: Vec<String> = Vec::new();
|
|
985
|
-
let mut interrupted: std::collections::HashMap<String, (String, Option<u64>)> =
|
|
986
|
-
std::collections::HashMap::new();
|
|
987
|
-
|
|
988
|
-
let latest_progress: std::collections::HashMap<(u8, &str), usize> = progress
|
|
989
|
-
.iter()
|
|
990
|
-
.enumerate()
|
|
991
|
-
.filter_map(|(i, event)| match event {
|
|
992
|
-
AgentProgress::ToolProgress { call_id, .. } => Some(((0, call_id.as_str()), i)),
|
|
993
|
-
AgentProgress::DeviceState {
|
|
994
|
-
call_id, update, ..
|
|
995
|
-
} if !matches!(update.state, crate::mcp::device::DevicePhase::Accepted) => {
|
|
996
|
-
Some(((1, call_id.as_str()), i))
|
|
997
|
-
}
|
|
998
|
-
_ => None,
|
|
999
|
-
})
|
|
1000
|
-
.collect();
|
|
1001
|
-
for (index, event) in progress.iter().enumerate() {
|
|
1002
|
-
let key = match event {
|
|
1003
|
-
AgentProgress::ToolProgress { call_id, .. } => Some((0, call_id.as_str())),
|
|
1004
|
-
AgentProgress::DeviceState {
|
|
1005
|
-
call_id, update, ..
|
|
1006
|
-
} if !matches!(update.state, crate::mcp::device::DevicePhase::Accepted) => {
|
|
1007
|
-
Some((1, call_id.as_str()))
|
|
1008
|
-
}
|
|
1009
|
-
_ => None,
|
|
1010
|
-
};
|
|
1011
|
-
if key.is_some_and(|key| latest_progress.get(&key) != Some(&index)) {
|
|
1012
|
-
continue;
|
|
1013
|
-
}
|
|
1014
|
-
match event {
|
|
1015
|
-
AgentProgress::ThinkingDelta {
|
|
1016
|
-
id,
|
|
1017
|
-
delta,
|
|
1018
|
-
elapsed_ms,
|
|
1019
|
-
} => {
|
|
1020
|
-
if !completed_ids.contains(id) {
|
|
1021
|
-
if !interrupted.contains_key(id) {
|
|
1022
|
-
interrupted_order.push(id.clone());
|
|
1023
|
-
}
|
|
1024
|
-
interrupted
|
|
1025
|
-
.entry(id.clone())
|
|
1026
|
-
.and_modify(|(text, elapsed)| {
|
|
1027
|
-
text.push_str(delta);
|
|
1028
|
-
*elapsed = Some(*elapsed_ms);
|
|
1029
|
-
})
|
|
1030
|
-
.or_insert_with(|| (delta.clone(), Some(*elapsed_ms)));
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
_ => {
|
|
1034
|
-
result.push(event.clone());
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
for id in interrupted_order {
|
|
1040
|
-
if let Some((thought, elapsed_ms)) = interrupted.remove(&id) {
|
|
1041
|
-
result.push(AgentProgress::ExtendedThinking {
|
|
1042
|
-
id: Some(id),
|
|
1043
|
-
thought,
|
|
1044
|
-
elapsed_ms,
|
|
1045
|
-
});
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
result
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
1053
|
-
#[serde(rename_all = "camelCase")]
|
|
1054
|
-
pub struct ToolExecutionRecord {
|
|
1055
|
-
pub step: usize,
|
|
1056
|
-
pub action: String,
|
|
1057
|
-
pub target: String,
|
|
1058
|
-
pub success: bool,
|
|
1059
|
-
pub retried: bool,
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
1063
|
-
#[serde(rename_all = "camelCase")]
|
|
1064
|
-
pub struct RunTelemetrySummary {
|
|
1065
|
-
pub run_id: String,
|
|
1066
|
-
pub task: String,
|
|
1067
|
-
pub outcome: String, // "SUCCESS" | "FAILED" | "ROLLED_BACK"
|
|
1068
|
-
pub total_tokens: usize,
|
|
1069
|
-
pub tool_calls_count: usize,
|
|
1070
|
-
pub files_changed: Vec<String>,
|
|
1071
|
-
#[serde(default)]
|
|
1072
|
-
pub files_created: Vec<String>,
|
|
1073
|
-
pub duration_secs: f64,
|
|
1074
|
-
pub tool_timeline: Vec<ToolExecutionRecord>,
|
|
1075
|
-
pub retries_count: usize,
|
|
1076
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
1077
|
-
pub trajectory: Option<trajectory_schema::StandardTrajectoryRun>,
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
1081
|
-
#[serde(rename_all = "camelCase")]
|
|
1082
|
-
pub struct PlanTaskItem {
|
|
1083
|
-
pub id: String,
|
|
1084
|
-
pub title: String,
|
|
1085
|
-
pub status: String, // "pending", "in_progress", "completed", "failed"
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
1089
|
-
#[serde(rename_all = "camelCase")]
|
|
1090
|
-
pub struct ActivePlan {
|
|
1091
|
-
pub objective: String,
|
|
1092
|
-
pub tasks: Vec<PlanTaskItem>,
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
1096
|
-
#[serde(rename_all = "camelCase")]
|
|
1097
|
-
pub struct AgentResult {
|
|
1098
|
-
pub provider: String,
|
|
1099
|
-
pub model: String,
|
|
1100
|
-
pub summary: String,
|
|
1101
|
-
pub verification: String,
|
|
1102
|
-
#[serde(default)]
|
|
1103
|
-
pub completion_status: String,
|
|
1104
|
-
pub fallback: Option<String>,
|
|
1105
|
-
#[serde(default)]
|
|
1106
|
-
pub fallback_reason: Option<String>,
|
|
1107
|
-
/// Sum of `total_tokens` across every step's API response this turn —
|
|
1108
|
-
/// see the doc comment on `turn_total_tokens` where it's accumulated.
|
|
1109
|
-
pub total_tokens: u64,
|
|
1110
|
-
/// Prompt/context tokens the final step processed (latest-step, not summed
|
|
1111
|
-
/// — see `AgentProgress::Thinking::input_tokens`). The `↑` half of the
|
|
1112
|
-
/// turn footer's token counter.
|
|
1113
|
-
#[serde(default)]
|
|
1114
|
-
pub input_tokens: u64,
|
|
1115
|
-
/// Sum of completion tokens generated across every step this turn — the
|
|
1116
|
-
/// `↓` half of the turn footer's token counter.
|
|
1117
|
-
#[serde(default)]
|
|
1118
|
-
pub generated_tokens: u64,
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
#[derive(Debug, Serialize)]
|
|
1122
|
-
#[serde(rename_all = "camelCase")]
|
|
1123
|
-
struct AgentDirectoryEntry {
|
|
1124
|
-
name: String,
|
|
1125
|
-
path: PathBuf,
|
|
1126
|
-
kind: &'static str,
|
|
1127
|
-
size: Option<u64>,
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
#[derive(Debug, Deserialize)]
|
|
1131
|
-
struct AgentDecision {
|
|
1132
|
-
#[serde(default)]
|
|
1133
|
-
thought: String,
|
|
1134
|
-
action: String,
|
|
1135
|
-
#[serde(default, deserialize_with = "deserialize_agent_input")]
|
|
1136
|
-
input: AgentInput,
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
|
1140
|
-
#[serde(rename_all = "camelCase")]
|
|
1141
|
-
pub struct SwarmTaskInput {
|
|
1142
|
-
#[serde(default)]
|
|
1143
|
-
pub role: Option<String>,
|
|
1144
|
-
#[serde(default)]
|
|
1145
|
-
pub base: Option<String>,
|
|
1146
|
-
#[serde(default)]
|
|
1147
|
-
pub instruction: String,
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
impl From<SwarmTaskInput> for crate::swarm::SwarmTask {
|
|
1151
|
-
fn from(input: SwarmTaskInput) -> Self {
|
|
1152
|
-
Self {
|
|
1153
|
-
role: input.role,
|
|
1154
|
-
base: input.base,
|
|
1155
|
-
instruction: input.instruction,
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
#[derive(Debug, Default, Deserialize, Serialize)]
|
|
1161
|
-
#[serde(rename_all = "camelCase")]
|
|
1162
|
-
struct AgentInput {
|
|
1163
|
-
#[serde(default)]
|
|
1164
|
-
tasks: Vec<SwarmTaskInput>,
|
|
1165
|
-
#[serde(default)]
|
|
1166
|
-
path: String,
|
|
1167
|
-
#[serde(default)]
|
|
1168
|
-
artifact_id: String,
|
|
1169
|
-
#[serde(
|
|
1170
|
-
default,
|
|
1171
|
-
alias = "q",
|
|
1172
|
-
alias = "keyword",
|
|
1173
|
-
alias = "search",
|
|
1174
|
-
alias = "searchTerm",
|
|
1175
|
-
alias = "search_term"
|
|
1176
|
-
)]
|
|
1177
|
-
query: String,
|
|
1178
|
-
#[serde(default, alias = "ticker")]
|
|
1179
|
-
symbol: String,
|
|
1180
|
-
#[serde(default)]
|
|
1181
|
-
filter: String,
|
|
1182
|
-
#[serde(default)]
|
|
1183
|
-
options: Vec<AskUserOptionInput>,
|
|
1184
|
-
#[serde(default)]
|
|
1185
|
-
header: String,
|
|
1186
|
-
#[serde(default, alias = "multi_select")]
|
|
1187
|
-
multi_select: bool,
|
|
1188
|
-
#[serde(default, alias = "location", alias = "place")]
|
|
1189
|
-
city: String,
|
|
1190
|
-
#[serde(default, alias = "expr", alias = "math")]
|
|
1191
|
-
expression: String,
|
|
1192
|
-
#[serde(
|
|
1193
|
-
default,
|
|
1194
|
-
alias = "cmd",
|
|
1195
|
-
alias = "script",
|
|
1196
|
-
alias = "exec",
|
|
1197
|
-
alias = "code",
|
|
1198
|
-
alias = "bash",
|
|
1199
|
-
alias = "sh"
|
|
1200
|
-
)]
|
|
1201
|
-
command: String,
|
|
1202
|
-
#[serde(default)]
|
|
1203
|
-
background: bool,
|
|
1204
|
-
#[serde(default, alias = "retryReason")]
|
|
1205
|
-
retry_reason: String,
|
|
1206
|
-
#[serde(default, alias = "job_id")]
|
|
1207
|
-
job_id: String,
|
|
1208
|
-
#[serde(default)]
|
|
1209
|
-
commands: Vec<String>,
|
|
1210
|
-
#[serde(default)]
|
|
1211
|
-
steps: Vec<String>,
|
|
1212
|
-
#[serde(default, alias = "file_content")]
|
|
1213
|
-
file_content: String,
|
|
1214
|
-
#[serde(default)]
|
|
1215
|
-
summary: String,
|
|
1216
|
-
#[serde(default)]
|
|
1217
|
-
verification: String,
|
|
1218
|
-
#[serde(default)]
|
|
1219
|
-
completion_status: String,
|
|
1220
|
-
#[serde(default)]
|
|
1221
|
-
plan: String,
|
|
1222
|
-
#[serde(default)]
|
|
1223
|
-
reason: String,
|
|
1224
|
-
#[serde(default, alias = "start_line")]
|
|
1225
|
-
start_line: Option<usize>,
|
|
1226
|
-
#[serde(default, alias = "end_line")]
|
|
1227
|
-
end_line: Option<usize>,
|
|
1228
|
-
#[serde(default)]
|
|
1229
|
-
limit: Option<usize>,
|
|
1230
|
-
#[serde(default)]
|
|
1231
|
-
patch: Option<AgentPatch>,
|
|
1232
|
-
#[serde(default)]
|
|
1233
|
-
server: String,
|
|
1234
|
-
#[serde(default)]
|
|
1235
|
-
tool: String,
|
|
1236
|
-
#[serde(default)]
|
|
1237
|
-
arguments: Value,
|
|
1238
|
-
#[serde(default, alias = "note_path")]
|
|
1239
|
-
note_path: String,
|
|
1240
|
-
#[serde(default)]
|
|
1241
|
-
name: String,
|
|
1242
|
-
#[serde(default)]
|
|
1243
|
-
instruction: String,
|
|
1244
|
-
#[serde(default)]
|
|
1245
|
-
title: String,
|
|
1246
|
-
#[serde(default)]
|
|
1247
|
-
status: String,
|
|
1248
|
-
#[serde(default)]
|
|
1249
|
-
step: Option<usize>,
|
|
1250
|
-
#[serde(default)]
|
|
1251
|
-
kind: String,
|
|
1252
|
-
#[serde(default)]
|
|
1253
|
-
content: String,
|
|
1254
|
-
#[serde(default)]
|
|
1255
|
-
url: String,
|
|
1256
|
-
#[serde(default)]
|
|
1257
|
-
selector: String,
|
|
1258
|
-
#[serde(default)]
|
|
1259
|
-
text: String,
|
|
1260
|
-
#[serde(default)]
|
|
1261
|
-
x: Option<f64>,
|
|
1262
|
-
#[serde(default)]
|
|
1263
|
-
y: Option<f64>,
|
|
1264
|
-
#[serde(default)]
|
|
1265
|
-
button: String,
|
|
1266
|
-
#[serde(default)]
|
|
1267
|
-
key: String,
|
|
1268
|
-
#[serde(default)]
|
|
1269
|
-
element_ref: String,
|
|
1270
|
-
#[serde(default)]
|
|
1271
|
-
tab_id: String,
|
|
1272
|
-
#[serde(default)]
|
|
1273
|
-
operation: String,
|
|
1274
|
-
#[serde(default)]
|
|
1275
|
-
mode: String,
|
|
1276
|
-
#[serde(default)]
|
|
1277
|
-
condition: String,
|
|
1278
|
-
#[serde(default)]
|
|
1279
|
-
value: String,
|
|
1280
|
-
#[serde(default)]
|
|
1281
|
-
timeout_ms: Option<u64>,
|
|
1282
|
-
#[serde(default)]
|
|
1283
|
-
text_offset: Option<u64>,
|
|
1284
|
-
#[serde(default)]
|
|
1285
|
-
element_offset: Option<u64>,
|
|
1286
|
-
#[serde(default)]
|
|
1287
|
-
takeover_complete: bool,
|
|
1288
|
-
// Video tools input fields
|
|
1289
|
-
#[serde(default)]
|
|
1290
|
-
input: String,
|
|
1291
|
-
#[serde(default)]
|
|
1292
|
-
output: String,
|
|
1293
|
-
#[serde(default)]
|
|
1294
|
-
start: Option<f64>,
|
|
1295
|
-
#[serde(default)]
|
|
1296
|
-
end: Option<f64>,
|
|
1297
|
-
#[serde(default)]
|
|
1298
|
-
width: Option<i32>,
|
|
1299
|
-
#[serde(default)]
|
|
1300
|
-
height: Option<i32>,
|
|
1301
|
-
#[serde(default)]
|
|
1302
|
-
frame_count: Option<u32>,
|
|
1303
|
-
#[serde(default)]
|
|
1304
|
-
columns: Option<u32>,
|
|
1305
|
-
#[serde(default)]
|
|
1306
|
-
threshold_db: Option<f64>,
|
|
1307
|
-
#[serde(default)]
|
|
1308
|
-
min_silence_secs: Option<f64>,
|
|
1309
|
-
#[serde(default)]
|
|
1310
|
-
language: Option<String>,
|
|
1311
|
-
#[serde(default)]
|
|
1312
|
-
target_language: Option<String>,
|
|
1313
|
-
#[serde(default)]
|
|
1314
|
-
srt_content: Option<String>,
|
|
1315
|
-
#[serde(default)]
|
|
1316
|
-
preset: Option<String>,
|
|
1317
|
-
#[serde(default)]
|
|
1318
|
-
max_clips: Option<u32>,
|
|
1319
|
-
#[serde(default)]
|
|
1320
|
-
target_duration: Option<f64>,
|
|
1321
|
-
#[serde(default)]
|
|
1322
|
-
inputs: Vec<String>,
|
|
1323
|
-
#[serde(default)]
|
|
1324
|
-
order: Vec<usize>,
|
|
1325
|
-
#[serde(default)]
|
|
1326
|
-
music_input: Option<String>,
|
|
1327
|
-
#[serde(default)]
|
|
1328
|
-
video_input: Option<String>,
|
|
1329
|
-
#[serde(default)]
|
|
1330
|
-
music_volume: Option<f32>,
|
|
1331
|
-
#[serde(default)]
|
|
1332
|
-
zoom_factor: Option<f32>,
|
|
1333
|
-
// Image & Video generation input fields
|
|
1334
|
-
#[serde(default)]
|
|
1335
|
-
prompt: String,
|
|
1336
|
-
#[serde(default)]
|
|
1337
|
-
aspect_ratio: String,
|
|
1338
|
-
#[serde(default)]
|
|
1339
|
-
provider: String,
|
|
1340
|
-
#[serde(default)]
|
|
1341
|
-
duration: Option<f64>,
|
|
1342
|
-
// avatar_signal input fields — Rust-side `avatar_` prefix only to read
|
|
1343
|
-
// unambiguously in this shared flat struct; explicit renames keep the
|
|
1344
|
-
// wire/JSON keys unprefixed (matching the relay's AvatarEvent schema —
|
|
1345
|
-
// see `crate::avatar_bridge`).
|
|
1346
|
-
#[serde(default, rename = "emotions")]
|
|
1347
|
-
avatar_emotions: std::collections::HashMap<String, String>,
|
|
1348
|
-
#[serde(default, rename = "action")]
|
|
1349
|
-
avatar_action: String,
|
|
1350
|
-
#[serde(default, rename = "prop")]
|
|
1351
|
-
avatar_prop: String,
|
|
1352
|
-
#[serde(default, rename = "intensity")]
|
|
1353
|
-
avatar_intensity: String,
|
|
1354
|
-
#[serde(default, rename = "color")]
|
|
1355
|
-
avatar_color: String,
|
|
1356
|
-
#[serde(default, rename = "talking")]
|
|
1357
|
-
avatar_talking: Option<bool>,
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
/// Decode shim for `ask_user`'s `options`: accepts either a bare string
|
|
1361
|
-
/// (the legacy shape, and what a model may still emit even after the schema
|
|
1362
|
-
/// documents the object shape) or `{label, description}`.
|
|
1363
|
-
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
1364
|
-
#[serde(untagged)]
|
|
1365
|
-
enum AskUserOptionInput {
|
|
1366
|
-
Plain(String),
|
|
1367
|
-
Detailed {
|
|
1368
|
-
label: String,
|
|
1369
|
-
#[serde(default)]
|
|
1370
|
-
description: Option<String>,
|
|
1371
|
-
},
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
#[derive(Debug, Deserialize, Serialize)]
|
|
1375
|
-
#[serde(rename_all = "camelCase")]
|
|
1376
|
-
struct AgentPatch {
|
|
1377
|
-
path: PathBuf,
|
|
1378
|
-
#[serde(default)]
|
|
1379
|
-
hunks: Vec<CodePatchHunk>,
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
fn deserialize_agent_input<'de, D>(deserializer: D) -> Result<AgentInput, D::Error>
|
|
1383
|
-
where
|
|
1384
|
-
D: serde::Deserializer<'de>,
|
|
1385
|
-
{
|
|
1386
|
-
let value = Option::<Value>::deserialize(deserializer)?;
|
|
1387
|
-
value
|
|
1388
|
-
.map(decode_agent_input)
|
|
1389
|
-
.transpose()
|
|
1390
|
-
.map_err(serde::de::Error::custom)
|
|
1391
|
-
.map(|input| input.unwrap_or_default())
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
/// Returns true if a string is an AI dummy placeholder such as `"null"`, `"undefined"`, `"none"`, etc.
|
|
1395
|
-
pub fn is_dummy_value(s: &str) -> bool {
|
|
1396
|
-
let lower = s.trim().to_ascii_lowercase();
|
|
1397
|
-
matches!(
|
|
1398
|
-
lower.as_str(),
|
|
1399
|
-
"null" | "undefined" | "none" | "nil" | "(null)" | "\"\"" | "''"
|
|
1400
|
-
)
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
fn prune_null_values(value: &mut Value) {
|
|
1404
|
-
if let Value::Object(map) = value {
|
|
1405
|
-
map.retain(|k, v| {
|
|
1406
|
-
if v.is_null() && k != "path" {
|
|
1407
|
-
return false;
|
|
1408
|
-
}
|
|
1409
|
-
if let Value::String(s) = v {
|
|
1410
|
-
if is_dummy_value(s) {
|
|
1411
|
-
return false;
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
true
|
|
1415
|
-
});
|
|
1416
|
-
for v in map.values_mut() {
|
|
1417
|
-
prune_null_values(v);
|
|
1418
|
-
}
|
|
1419
|
-
} else if let Value::Array(arr) = value {
|
|
1420
|
-
for v in arr.iter_mut() {
|
|
1421
|
-
prune_null_values(v);
|
|
1422
|
-
}
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
// Some providers encode the structured patch twice or double-wrap arguments.
|
|
1427
|
-
// Decode those fields, prune nulls for robust serde defaults, and retain strict
|
|
1428
|
-
// typing for all other arguments.
|
|
1429
|
-
fn decode_agent_input(mut value: Value) -> Result<AgentInput, String> {
|
|
1430
|
-
// 1. If value is a string, check if it's an encoded JSON object
|
|
1431
|
-
if let Value::String(s) = &value {
|
|
1432
|
-
let trimmed = s.trim();
|
|
1433
|
-
if trimmed.starts_with('{') && trimmed.ends_with('}') {
|
|
1434
|
-
if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
|
|
1435
|
-
if parsed.is_object() {
|
|
1436
|
-
value = parsed;
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
// 2. Prune null fields so serde default values apply for missing keys
|
|
1443
|
-
prune_null_values(&mut value);
|
|
1444
|
-
|
|
1445
|
-
// 3. Unpack nested "arguments" if top-level command is missing
|
|
1446
|
-
if let Value::Object(map) = &mut value {
|
|
1447
|
-
let has_command = map
|
|
1448
|
-
.get("command")
|
|
1449
|
-
.or_else(|| map.get("cmd"))
|
|
1450
|
-
.or_else(|| map.get("script"))
|
|
1451
|
-
.or_else(|| map.get("exec"))
|
|
1452
|
-
.or_else(|| map.get("code"))
|
|
1453
|
-
.or_else(|| map.get("bash"))
|
|
1454
|
-
.or_else(|| map.get("sh"))
|
|
1455
|
-
.and_then(Value::as_str)
|
|
1456
|
-
.is_some_and(|s| !s.trim().is_empty());
|
|
1457
|
-
|
|
1458
|
-
if !has_command {
|
|
1459
|
-
if let Some(args_val) = map.get("arguments").cloned() {
|
|
1460
|
-
let inner_obj = match args_val {
|
|
1461
|
-
Value::Object(inner_map) => Some(inner_map),
|
|
1462
|
-
Value::String(inner_str) => serde_json::from_str::<Value>(&inner_str)
|
|
1463
|
-
.ok()
|
|
1464
|
-
.and_then(|v| match v {
|
|
1465
|
-
Value::Object(m) => Some(m),
|
|
1466
|
-
_ => None,
|
|
1467
|
-
}),
|
|
1468
|
-
_ => None,
|
|
1469
|
-
};
|
|
1470
|
-
if let Some(inner) = inner_obj {
|
|
1471
|
-
let mut wrapped = Value::Object(inner);
|
|
1472
|
-
prune_null_values(&mut wrapped);
|
|
1473
|
-
if let Value::Object(cleaned) = wrapped {
|
|
1474
|
-
for (k, v) in cleaned {
|
|
1475
|
-
if !map.contains_key(&k) && !v.is_null() {
|
|
1476
|
-
map.insert(k, v);
|
|
1477
|
-
}
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
}
|
|
1481
|
-
}
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
let outer_path = value.get("path").and_then(Value::as_str).map(str::to_owned);
|
|
1486
|
-
if let Some(patch) = value.get_mut("patch").filter(|patch| !patch.is_null()) {
|
|
1487
|
-
if let Value::String(encoded) = patch {
|
|
1488
|
-
*patch = serde_json::from_str(encoded).map_err(|_| {
|
|
1489
|
-
"patch must contain a valid JSON object with path and hunks".to_owned()
|
|
1490
|
-
})?;
|
|
1491
|
-
}
|
|
1492
|
-
let object = patch
|
|
1493
|
-
.as_object_mut()
|
|
1494
|
-
.ok_or_else(|| "patch must be an object with path and hunks".to_owned())?;
|
|
1495
|
-
if !object.contains_key("path") {
|
|
1496
|
-
if let Some(path) = &outer_path {
|
|
1497
|
-
object.insert("path".into(), Value::String(path.clone()));
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
let path = object
|
|
1501
|
-
.get("path")
|
|
1502
|
-
.and_then(Value::as_str)
|
|
1503
|
-
.filter(|path| !path.trim().is_empty())
|
|
1504
|
-
.ok_or_else(|| {
|
|
1505
|
-
"patch.path is required; supply the target file explicitly".to_owned()
|
|
1506
|
-
})?;
|
|
1507
|
-
if outer_path
|
|
1508
|
-
.as_deref()
|
|
1509
|
-
.is_some_and(|outer| !outer.is_empty() && outer != path)
|
|
1510
|
-
{
|
|
1511
|
-
return Err("path and patch.path must name the same target file".into());
|
|
1512
|
-
}
|
|
1513
|
-
serde_json::from_value::<AgentPatch>(patch.clone())
|
|
1514
|
-
.map_err(|_| "patch requires a string path and hunks containing string oldText/newText and optional boolean replaceAll".to_owned())?;
|
|
1515
|
-
}
|
|
1516
|
-
serde_json::from_value(value).map_err(|error| {
|
|
1517
|
-
let message = error.to_string();
|
|
1518
|
-
if message.chars().count() > 400 {
|
|
1519
|
-
format!(
|
|
1520
|
-
"{}… (argument value omitted)",
|
|
1521
|
-
message.chars().take(400).collect::<String>()
|
|
1522
|
-
)
|
|
1523
|
-
} else {
|
|
1524
|
-
message
|
|
1525
|
-
}
|
|
1526
|
-
})
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
545
|
fn resolve_agent_config(
|
|
1530
546
|
config: &MintConfig,
|
|
1531
547
|
agent_id: Option<&str>,
|
|
@@ -1635,8 +651,12 @@ async fn stream_chat_with_network_retry(
|
|
|
1635
651
|
on_stream_chunk: &mut (dyn FnMut(AgentStreamChunk) + Send),
|
|
1636
652
|
) -> Result<(ChatResponse, Option<String>), ChatError> {
|
|
1637
653
|
let mut attempt = 0;
|
|
1638
|
-
let
|
|
654
|
+
let tools_enabled = request
|
|
655
|
+
.tools
|
|
656
|
+
.as_ref()
|
|
657
|
+
.is_some_and(|tools| !tools.is_empty());
|
|
1639
658
|
loop {
|
|
659
|
+
let mut summary_stream = FinishSummaryStream::default();
|
|
1640
660
|
let result = stream_chat_events_with_fallback(config, request, |event| match event {
|
|
1641
661
|
ChatStreamEvent::ReasoningDelta { delta } if !fast_mode && !delta.is_empty() => {
|
|
1642
662
|
progress(AgentProgress::ThinkingDelta {
|
|
@@ -1650,6 +670,10 @@ async fn stream_chat_with_network_retry(
|
|
|
1650
670
|
summary_stream.push_json_prompt(&delta, &mut |chunk| {
|
|
1651
671
|
on_stream_chunk(AgentStreamChunk::FinalSummary(chunk));
|
|
1652
672
|
});
|
|
673
|
+
} else {
|
|
674
|
+
summary_stream.push_native_text(&delta, tools_enabled, &mut |chunk| {
|
|
675
|
+
on_stream_chunk(AgentStreamChunk::DirectText(chunk));
|
|
676
|
+
});
|
|
1653
677
|
}
|
|
1654
678
|
}
|
|
1655
679
|
ChatStreamEvent::ToolCallDelta {
|
|
@@ -1686,11 +710,11 @@ async fn stream_chat_with_network_retry(
|
|
|
1686
710
|
.is_none_or(|calls| calls.is_empty())
|
|
1687
711
|
&& !response.text.is_empty()
|
|
1688
712
|
{
|
|
1689
|
-
//
|
|
1690
|
-
//
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
713
|
+
// Flush any remaining unstreamed direct text (such as a short
|
|
714
|
+
// single-line reply held while checking if a tool call followed).
|
|
715
|
+
summary_stream.finish_native_text(&response.text, &mut |chunk| {
|
|
716
|
+
on_stream_chunk(AgentStreamChunk::DirectText(chunk));
|
|
717
|
+
});
|
|
1694
718
|
}
|
|
1695
719
|
return Ok((response, fallback));
|
|
1696
720
|
}
|
|
@@ -1705,6 +729,8 @@ struct FinishSummaryStream {
|
|
|
1705
729
|
tool_arguments: std::collections::HashMap<usize, String>,
|
|
1706
730
|
json_prompt: String,
|
|
1707
731
|
emitted: String,
|
|
732
|
+
native_text: String,
|
|
733
|
+
native_emitted: String,
|
|
1708
734
|
}
|
|
1709
735
|
|
|
1710
736
|
impl FinishSummaryStream {
|
|
@@ -1732,10 +758,17 @@ impl FinishSummaryStream {
|
|
|
1732
758
|
.tool_names
|
|
1733
759
|
.get(&index)
|
|
1734
760
|
.is_some_and(|name| name == "finish")
|
|
1735
|
-
&& let Some(arguments) = self.tool_arguments.get(&index)
|
|
1736
|
-
&& let Some(summary) = json_string_field_prefix(arguments, "summary")
|
|
1737
761
|
{
|
|
1738
|
-
|
|
762
|
+
if let Some(summary) = input
|
|
763
|
+
.and_then(|val| val.get("summary"))
|
|
764
|
+
.and_then(Value::as_str)
|
|
765
|
+
{
|
|
766
|
+
self.emit_new_text(summary, on_chunk);
|
|
767
|
+
} else if let Some(arguments) = self.tool_arguments.get(&index)
|
|
768
|
+
&& let Some(summary) = json_string_field_prefix(arguments, "summary")
|
|
769
|
+
{
|
|
770
|
+
self.emit_new_text(&summary, on_chunk);
|
|
771
|
+
}
|
|
1739
772
|
}
|
|
1740
773
|
}
|
|
1741
774
|
|
|
@@ -1748,6 +781,43 @@ impl FinishSummaryStream {
|
|
|
1748
781
|
}
|
|
1749
782
|
}
|
|
1750
783
|
|
|
784
|
+
fn push_native_text(
|
|
785
|
+
&mut self,
|
|
786
|
+
delta: &str,
|
|
787
|
+
tools_enabled: bool,
|
|
788
|
+
on_chunk: &mut (dyn FnMut(String) + Send),
|
|
789
|
+
) {
|
|
790
|
+
if delta.is_empty() {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
self.native_text.push_str(delta);
|
|
794
|
+
if !self.tool_names.is_empty() {
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
let trimmed = self.native_text.trim_start();
|
|
798
|
+
if trimmed.is_empty() {
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
let ready_to_stream = !tools_enabled
|
|
802
|
+
|| !self.native_emitted.is_empty()
|
|
803
|
+
|| trimmed
|
|
804
|
+
.find('\n')
|
|
805
|
+
.is_some_and(|nl| !trimmed[nl + 1..].trim().is_empty())
|
|
806
|
+
|| trimmed.chars().count() > 160;
|
|
807
|
+
if ready_to_stream {
|
|
808
|
+
let current = trimmed.to_owned();
|
|
809
|
+
self.emit_new_native_text(¤t, on_chunk);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
fn finish_native_text(&mut self, full_text: &str, on_chunk: &mut (dyn FnMut(String) + Send)) {
|
|
814
|
+
let trimmed = full_text.trim_start();
|
|
815
|
+
if !self.tool_names.is_empty() || trimmed.is_empty() {
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
self.emit_new_native_text(trimmed, on_chunk);
|
|
819
|
+
}
|
|
820
|
+
|
|
1751
821
|
fn emit_new_text(&mut self, text: &str, on_chunk: &mut (dyn FnMut(String) + Send)) {
|
|
1752
822
|
if !text.starts_with(&self.emitted) || text.len() == self.emitted.len() {
|
|
1753
823
|
return;
|
|
@@ -1756,7 +826,16 @@ impl FinishSummaryStream {
|
|
|
1756
826
|
self.emitted.push_str(&delta);
|
|
1757
827
|
on_chunk(delta);
|
|
1758
828
|
}
|
|
1759
|
-
|
|
829
|
+
|
|
830
|
+
fn emit_new_native_text(&mut self, text: &str, on_chunk: &mut (dyn FnMut(String) + Send)) {
|
|
831
|
+
if !text.starts_with(&self.native_emitted) || text.len() == self.native_emitted.len() {
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
let delta = text[self.native_emitted.len()..].to_owned();
|
|
835
|
+
self.native_emitted.push_str(&delta);
|
|
836
|
+
on_chunk(delta);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
1760
839
|
|
|
1761
840
|
fn unstreamed_summary_remainder<'a>(
|
|
1762
841
|
summary: &'a str,
|
|
@@ -1964,7 +1043,7 @@ where
|
|
|
1964
1043
|
);
|
|
1965
1044
|
let mut mod_usage = crate::mods::UsageTracker::new(&root, chat_id, companion_turn_id);
|
|
1966
1045
|
let resolved_task = resolve_github_links(task, config).await;
|
|
1967
|
-
let agent_result = reliability::scope(Box::pin(
|
|
1046
|
+
let agent_result = reliability::scope(Box::pin(async {
|
|
1968
1047
|
// Subagent runs use a synthetic `{parent_chat_id}::subagent::{name}` chat id
|
|
1969
1048
|
// (see the `dispatch_subagent` arm in `execute_tool`) so their own memory
|
|
1970
1049
|
// interaction doesn't leak into the parent conversation's history. That
|
|
@@ -1993,8 +1072,12 @@ where
|
|
|
1993
1072
|
// multi-agent collaboration can substitute) — matches how `system_prompt`
|
|
1994
1073
|
// itself is only rebuilt on plan-mode transitions, not every step.
|
|
1995
1074
|
let system_prompt_native = config.tool_calling_mode() == ToolCallingMode::Native;
|
|
1996
|
-
let mut system_prompt =
|
|
1075
|
+
let mut system_prompt = construct_system_prompt(
|
|
1997
1076
|
config,
|
|
1077
|
+
&memory,
|
|
1078
|
+
chat_id,
|
|
1079
|
+
&root,
|
|
1080
|
+
&resolved_task,
|
|
1998
1081
|
plan_mode,
|
|
1999
1082
|
system_prompt_native,
|
|
2000
1083
|
user_name,
|
|
@@ -2002,18 +1085,18 @@ where
|
|
|
2002
1085
|
);
|
|
2003
1086
|
let hooks = crate::hooks::list_hooks(config);
|
|
2004
1087
|
|
|
2005
|
-
|
|
2006
|
-
|
|
1088
|
+
// Structured history for native tool-calling providers, pre-populated with
|
|
1089
|
+
// completed conversation turns so multi-turn context (e.g. earlier questions/answers)
|
|
1090
|
+
// is preserved natively in the model's message sequence.
|
|
1091
|
+
let mut native_messages = build_conversation_history_messages(
|
|
1092
|
+
&memory,
|
|
2007
1093
|
chat_id,
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
config.semantic_fact_recall,
|
|
1094
|
+
CONTEXT_LIMIT,
|
|
1095
|
+
MAX_CONTEXT_MESSAGE_CHARS,
|
|
2011
1096
|
);
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
system_prompt = format!("{}\n\n{}", system_prompt.trim(), recall);
|
|
2016
|
-
}
|
|
1097
|
+
let history_prefix_len = native_messages.len();
|
|
1098
|
+
let mut current_turn_added_to_native = false;
|
|
1099
|
+
|
|
2017
1100
|
// Rough token estimate for the very first request going out — lets
|
|
2018
1101
|
// the CLI seed its live counter before step 1's real response comes
|
|
2019
1102
|
// back and there's nothing else to go on yet (~4 chars/token, a
|
|
@@ -2022,8 +1105,16 @@ where
|
|
|
2022
1105
|
// this — so it undercounts a bit for native tool-calling, but only
|
|
2023
1106
|
// needs to be in the right ballpark for a still-updating live label,
|
|
2024
1107
|
// not exact).
|
|
1108
|
+
let history_chars: usize = native_messages
|
|
1109
|
+
.iter()
|
|
1110
|
+
.flat_map(|m| &m.content)
|
|
1111
|
+
.map(|b| match b {
|
|
1112
|
+
ContentBlock::Text { text } => text.chars().count(),
|
|
1113
|
+
_ => 0,
|
|
1114
|
+
})
|
|
1115
|
+
.sum();
|
|
2025
1116
|
let estimated_first_step_tokens =
|
|
2026
|
-
((system_prompt.chars().count() + observation.chars().count()) / 4) as u64;
|
|
1117
|
+
((system_prompt.chars().count() + observation.chars().count() + history_chars) / 4) as u64;
|
|
2027
1118
|
|
|
2028
1119
|
#[allow(unused_assignments)]
|
|
2029
1120
|
let mut final_provider = config.ai_provider.clone();
|
|
@@ -2047,11 +1138,6 @@ where
|
|
|
2047
1138
|
&config.ai_provider,
|
|
2048
1139
|
);
|
|
2049
1140
|
let mut trajectory: Vec<String> = Vec::new();
|
|
2050
|
-
// Structured history for native tool-calling providers, maintained alongside
|
|
2051
|
-
// `trajectory`/`observation` (which remain the source of truth for the
|
|
2052
|
-
// JSON-prompt fallback path and for the web-search/media-append scans in the
|
|
2053
|
-
// finish handler below, which operate on flattened text either way).
|
|
2054
|
-
let mut native_messages: Vec<ChatMessage> = Vec::new();
|
|
2055
1141
|
// Gemini's `thoughtSignature` (see `ToolCall::thought_signature`) arrives on
|
|
2056
1142
|
// `response.tool_calls`, keyed by call id, but the `ContentBlock::ToolUse`
|
|
2057
1143
|
// that gets pushed onto `native_messages` for this call is only rebuilt
|
|
@@ -2121,7 +1207,20 @@ where
|
|
|
2121
1207
|
if !mcp_context.is_empty() { active_system_prompt.push_str(&format!("\n\n{mcp_context}")); }
|
|
2122
1208
|
// Pin the catalog and only successfully read, unchanged skill bodies
|
|
2123
1209
|
// to every request, including after JSON history rebuilds/compaction.
|
|
2124
|
-
let visible_history = if active_config.tool_calling_mode() == ToolCallingMode::Native {
|
|
1210
|
+
let visible_history = if active_config.tool_calling_mode() == ToolCallingMode::Native {
|
|
1211
|
+
if !current_turn_added_to_native {
|
|
1212
|
+
let history = render_messages_as_text(&native_messages);
|
|
1213
|
+
if history.is_empty() {
|
|
1214
|
+
observation.clone()
|
|
1215
|
+
} else {
|
|
1216
|
+
format!("{history}\n\nUser: {observation}")
|
|
1217
|
+
}
|
|
1218
|
+
} else {
|
|
1219
|
+
render_messages_as_text(&native_messages)
|
|
1220
|
+
}
|
|
1221
|
+
} else {
|
|
1222
|
+
observation.clone()
|
|
1223
|
+
};
|
|
2125
1224
|
let skill_context = crate::skills::learned_skills_context_with_history(Some(&root), Some(chat_id), &visible_history)
|
|
2126
1225
|
.unwrap_or_default();
|
|
2127
1226
|
if !skill_context.is_empty() {
|
|
@@ -2186,37 +1285,20 @@ where
|
|
|
2186
1285
|
}
|
|
2187
1286
|
|
|
2188
1287
|
let (response, fallback) = if tool_mode == ToolCallingMode::Native {
|
|
2189
|
-
if
|
|
1288
|
+
if !current_turn_added_to_native {
|
|
1289
|
+
current_turn_added_to_native = true;
|
|
2190
1290
|
// Native tool-calling providers build the request from `messages`
|
|
2191
1291
|
// and ignore ChatRequest.image_data_uri/audio_data_uri/video_data_uri
|
|
2192
1292
|
// entirely, so any pending attachment must be embedded as content
|
|
2193
1293
|
// blocks on the first user turn here, not left for the (unused)
|
|
2194
1294
|
// top-level fields — otherwise Agent Mode would silently drop
|
|
2195
1295
|
// attachments that work fine with Agent Mode off.
|
|
2196
|
-
let
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
data_uri: img.to_owned(),
|
|
2203
|
-
});
|
|
2204
|
-
}
|
|
2205
|
-
}
|
|
2206
|
-
if let Some(audio_data) = pending_audio.take() {
|
|
2207
|
-
for aud in audio_data.split_whitespace() {
|
|
2208
|
-
content.push(ContentBlock::Audio {
|
|
2209
|
-
data_uri: aud.to_owned(),
|
|
2210
|
-
});
|
|
2211
|
-
}
|
|
2212
|
-
}
|
|
2213
|
-
if let Some(video_data) = pending_video.take() {
|
|
2214
|
-
for vid in video_data.split_whitespace() {
|
|
2215
|
-
content.push(ContentBlock::Video {
|
|
2216
|
-
data_uri: vid.to_owned(),
|
|
2217
|
-
});
|
|
2218
|
-
}
|
|
2219
|
-
}
|
|
1296
|
+
let content = build_user_content_blocks(
|
|
1297
|
+
observation.clone(),
|
|
1298
|
+
pending_image.take().as_deref(),
|
|
1299
|
+
pending_audio.take().as_deref(),
|
|
1300
|
+
pending_video.take().as_deref(),
|
|
1301
|
+
);
|
|
2220
1302
|
native_messages.push(ChatMessage {
|
|
2221
1303
|
role: ChatRole::User,
|
|
2222
1304
|
content,
|
|
@@ -2377,6 +1459,7 @@ where
|
|
|
2377
1459
|
let decisions: Vec<(String, AgentDecision)> = if tool_mode == ToolCallingMode::Native {
|
|
2378
1460
|
match response.tool_calls.clone() {
|
|
2379
1461
|
Some(calls) if !calls.is_empty() => {
|
|
1462
|
+
streamed_direct_text.clear();
|
|
2380
1463
|
let (decisions, errors) = prepare_native_tool_calls(
|
|
2381
1464
|
calls, response.text.trim(), &mut invalid_tool_calls,
|
|
2382
1465
|
)?;
|
|
@@ -2553,7 +1636,19 @@ where
|
|
|
2553
1636
|
}
|
|
2554
1637
|
|
|
2555
1638
|
if decision.action == "finish" {
|
|
2556
|
-
let
|
|
1639
|
+
let (inline_summary_thought, cleaned_summary) =
|
|
1640
|
+
crate::chat::extract_think_tag(decision.input.summary.trim());
|
|
1641
|
+
if let Some(th) = inline_summary_thought
|
|
1642
|
+
&& !fast_mode
|
|
1643
|
+
&& !extended_emitted
|
|
1644
|
+
{
|
|
1645
|
+
progress(AgentProgress::ExtendedThinking {
|
|
1646
|
+
id: Some(thought_id.clone()),
|
|
1647
|
+
thought: th,
|
|
1648
|
+
elapsed_ms: Some(thought_started.elapsed().as_millis() as u64),
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
let mut summary = cleaned_summary.trim().to_owned();
|
|
2557
1652
|
if summary.is_empty() {
|
|
2558
1653
|
let err_msg = "Error: Your finish action summary was empty. \
|
|
2559
1654
|
You MUST provide a final answer, explanation, or response to the user's query \
|
|
@@ -2573,13 +1668,6 @@ where
|
|
|
2573
1668
|
);
|
|
2574
1669
|
continue 'steps;
|
|
2575
1670
|
}
|
|
2576
|
-
if crate::browser::session_finish_evidence().await && meaningful_verification(&decision.input.verification).is_empty() {
|
|
2577
|
-
let error = "Browser tasks require observed evidence in finish.verification. Describe the observed outcome, or explicitly state that completion is unverified or blocked.";
|
|
2578
|
-
trajectory.push(format_trajectory_step(step, &decision.thought, &decision.action, error));
|
|
2579
|
-
rebuild_observation(task, &root, &trajectory, &mut observation);
|
|
2580
|
-
reject_native_finish(tool_mode, &mut native_messages, &response.text, error);
|
|
2581
|
-
continue 'steps;
|
|
2582
|
-
}
|
|
2583
1671
|
let completion_status = match reliability::completion(&decision.input.completion_status, &decision.input.verification) {
|
|
2584
1672
|
Ok(status) => status,
|
|
2585
1673
|
Err(error) => {
|
|
@@ -2797,8 +1885,12 @@ where
|
|
|
2797
1885
|
}) {
|
|
2798
1886
|
Ok(ApprovalOutcome::Approved) => {
|
|
2799
1887
|
plan_mode = true;
|
|
2800
|
-
system_prompt =
|
|
1888
|
+
system_prompt = construct_system_prompt(
|
|
2801
1889
|
config,
|
|
1890
|
+
&memory,
|
|
1891
|
+
chat_id,
|
|
1892
|
+
&root,
|
|
1893
|
+
&resolved_task,
|
|
2802
1894
|
plan_mode,
|
|
2803
1895
|
system_prompt_native,
|
|
2804
1896
|
user_name,
|
|
@@ -2834,7 +1926,17 @@ where
|
|
|
2834
1926
|
}) {
|
|
2835
1927
|
Ok(ApprovalOutcome::Approved) => {
|
|
2836
1928
|
plan_mode = false;
|
|
2837
|
-
system_prompt =
|
|
1929
|
+
system_prompt = construct_system_prompt(
|
|
1930
|
+
config,
|
|
1931
|
+
&memory,
|
|
1932
|
+
chat_id,
|
|
1933
|
+
&root,
|
|
1934
|
+
&resolved_task,
|
|
1935
|
+
plan_mode,
|
|
1936
|
+
system_prompt_native,
|
|
1937
|
+
user_name,
|
|
1938
|
+
pinned_mcp_server,
|
|
1939
|
+
);
|
|
2838
1940
|
"Plan approved by the user. Plan mode is now OFF — write_file, apply_patch, run_shell, and other previously blocked tools are now available. Proceed with implementing the plan.".to_string()
|
|
2839
1941
|
}
|
|
2840
1942
|
Ok(ApprovalOutcome::Denied) => {
|
|
@@ -3057,8 +2159,7 @@ where
|
|
|
3057
2159
|
let mut final_result = if result.starts_with("data:image/")
|
|
3058
2160
|
&& matches!(
|
|
3059
2161
|
decision.action.as_str(),
|
|
3060
|
-
"
|
|
3061
|
-
| "video_filmstrip"
|
|
2162
|
+
"video_filmstrip"
|
|
3062
2163
|
| "video_waveform"
|
|
3063
2164
|
| "view_image"
|
|
3064
2165
|
) {
|
|
@@ -3070,11 +2171,10 @@ where
|
|
|
3070
2171
|
"video_waveform" => "[Waveform generated — see attached image: \
|
|
3071
2172
|
audio amplitude over time]"
|
|
3072
2173
|
.to_string(),
|
|
3073
|
-
|
|
3074
|
-
_ => "[Screenshot captured — see attached image]".to_string(),
|
|
2174
|
+
_ => "[Image loaded — see attached image]".to_string(),
|
|
3075
2175
|
}
|
|
3076
2176
|
} else {
|
|
3077
|
-
model_tool_result(&decision.action, &result)
|
|
2177
|
+
model_tool_result(Some(&root), &decision.action, &result)
|
|
3078
2178
|
};
|
|
3079
2179
|
if matches!(
|
|
3080
2180
|
decision.action.as_str(),
|
|
@@ -3103,7 +2203,19 @@ where
|
|
|
3103
2203
|
concise verification read.]",
|
|
3104
2204
|
);
|
|
3105
2205
|
}
|
|
3106
|
-
if
|
|
2206
|
+
if is_observation_action(&decision.action, &decision.input.command) {
|
|
2207
|
+
let mut observed = result.clone();
|
|
2208
|
+
if let Some(images) = step_images.get(&call_id) {
|
|
2209
|
+
for image in images {
|
|
2210
|
+
observed.push_str(image);
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
if let Some(notice) = repetition_guard.observe_result(&decision.action, &observed) {
|
|
2214
|
+
final_result.push_str("\n\n");
|
|
2215
|
+
final_result.push_str(¬ice);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
if action_count >= 3 {
|
|
3107
2219
|
final_result.push_str(
|
|
3108
2220
|
"\n\n[System Tip: You repeated the same tool action three or more times. \
|
|
3109
2221
|
Stop repeating it. If you already have enough information or the requested edit is done, \
|
|
@@ -3182,6 +2294,15 @@ where
|
|
|
3182
2294
|
&mut native_messages,
|
|
3183
2295
|
MAX_RETAINED_IMAGE_STEPS,
|
|
3184
2296
|
);
|
|
2297
|
+
let modified = extract_modified_paths(&step_tool_results);
|
|
2298
|
+
if !modified.is_empty() {
|
|
2299
|
+
supersede_stale_file_reads(&mut native_messages, &modified, step);
|
|
2300
|
+
}
|
|
2301
|
+
prune_historic_tool_results_from_native_messages(
|
|
2302
|
+
&mut native_messages,
|
|
2303
|
+
3,
|
|
2304
|
+
7,
|
|
2305
|
+
);
|
|
3185
2306
|
|
|
3186
2307
|
if let Some(total_tokens) = response.total_tokens {
|
|
3187
2308
|
let window = active_config.context_window_tokens();
|
|
@@ -3191,12 +2312,12 @@ where
|
|
|
3191
2312
|
let compaction_needed = (total_tokens as f64) >= (window as f64) * COMPACTION_TRIGGER_RATIO
|
|
3192
2313
|
|| (step >= ADAPTIVE_COMPACTION_MIN_STEPS && total_tokens >= ADAPTIVE_COMPACTION_TOKEN_CEILING);
|
|
3193
2314
|
if compaction_needed {
|
|
3194
|
-
let eligible = native_messages.len().saturating_sub(1) / 2 > COMPACTION_KEEP_RECENT_STEPS;
|
|
2315
|
+
let eligible = native_messages.len().saturating_sub(history_prefix_len + 1) / 2 > COMPACTION_KEEP_RECENT_STEPS;
|
|
3195
2316
|
if let Ok(Some((compacted, _))) = run_control::run(report_context_compaction(
|
|
3196
2317
|
eligible,
|
|
3197
2318
|
total_tokens,
|
|
3198
2319
|
window,
|
|
3199
|
-
compact_native_messages(&active_config, &native_messages),
|
|
2320
|
+
compact_native_messages(&active_config, &native_messages, history_prefix_len),
|
|
3200
2321
|
&mut progress,
|
|
3201
2322
|
)).await {
|
|
3202
2323
|
native_messages = compacted;
|
|
@@ -3366,7 +2487,7 @@ where
|
|
|
3366
2487
|
input_tokens: last_input_tokens,
|
|
3367
2488
|
generated_tokens: turn_generated_tokens,
|
|
3368
2489
|
});
|
|
3369
|
-
}))
|
|
2490
|
+
})).await;
|
|
3370
2491
|
if agent_result.is_err() && !turn.finished {
|
|
3371
2492
|
if run_control::cancelled() {
|
|
3372
2493
|
turn.interrupt();
|
|
@@ -3406,7 +2527,7 @@ fn provider_truncated_response(stop_reason: Option<&str>) -> bool {
|
|
|
3406
2527
|
)
|
|
3407
2528
|
}
|
|
3408
2529
|
|
|
3409
|
-
fn plan_mode_allows(action: &str, input: &AgentInput) -> bool {
|
|
2530
|
+
pub(super) fn plan_mode_allows(action: &str, input: &AgentInput) -> bool {
|
|
3410
2531
|
if action == "run_shell" {
|
|
3411
2532
|
return classify_shell_command(&input.command).mode.as_str() == "readOnly";
|
|
3412
2533
|
}
|
|
@@ -3575,3061 +2696,11 @@ pub(super) async fn dispatch_swarm(
|
|
|
3575
2696
|
Ok(crate::swarm::format_batch_result(&outcomes))
|
|
3576
2697
|
}
|
|
3577
2698
|
|
|
3578
|
-
#[
|
|
3579
|
-
|
|
3580
|
-
decisions: &[(String, AgentDecision)],
|
|
3581
|
-
step: usize,
|
|
3582
|
-
root: &Path,
|
|
3583
|
-
config: &MintConfig,
|
|
3584
|
-
chat_id: &str,
|
|
3585
|
-
approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
|
|
3586
|
-
progress: &mut (dyn FnMut(AgentProgress) + Send),
|
|
3587
|
-
action_counts: &mut BTreeMap<String, usize>,
|
|
3588
|
-
trajectory: &mut Vec<String>,
|
|
3589
|
-
step_images: &mut std::collections::HashMap<String, Vec<String>>,
|
|
3590
|
-
hooks: &[crate::hooks::HookEntry],
|
|
3591
|
-
pinned_mcp_server: Option<&str>,
|
|
3592
|
-
fast_mode: bool,
|
|
3593
|
-
) -> Vec<ToolResultEntry> {
|
|
3594
|
-
let approve_mutex = std::sync::Mutex::new(approve);
|
|
3595
|
-
let progress_mutex = std::sync::Mutex::new(progress);
|
|
3596
|
-
|
|
3597
|
-
let mut tasks = Vec::with_capacity(decisions.len());
|
|
3598
|
-
for (index, (call_id, decision)) in decisions.iter().enumerate() {
|
|
3599
|
-
let call_id = call_id.clone();
|
|
3600
|
-
let thought = decision.thought.clone();
|
|
3601
|
-
let action = decision.action.clone();
|
|
3602
|
-
let input_val = tool_progress_input(&root, &decision);
|
|
3603
|
-
let action_key = action_fingerprint(decision);
|
|
3604
|
-
let approve_mutex = &approve_mutex;
|
|
3605
|
-
let progress_mutex = &progress_mutex;
|
|
3606
|
-
let decision = decision;
|
|
3607
|
-
|
|
3608
|
-
tasks.push(async move {
|
|
3609
|
-
let activity_call_id=call_id.clone();
|
|
3610
|
-
if !fast_mode && !thought.trim().is_empty() {
|
|
3611
|
-
let t = thought.trim();
|
|
3612
|
-
let mut guard = progress_mutex.lock().unwrap();
|
|
3613
|
-
if is_internal_cot(t) {
|
|
3614
|
-
(*guard)(AgentProgress::ExtendedThinking {
|
|
3615
|
-
id: None,
|
|
3616
|
-
thought: t.to_owned(),
|
|
3617
|
-
elapsed_ms: None,
|
|
3618
|
-
});
|
|
3619
|
-
} else {
|
|
3620
|
-
(*guard)(AgentProgress::Thought {
|
|
3621
|
-
thought: t.to_owned(),
|
|
3622
|
-
});
|
|
3623
|
-
}
|
|
3624
|
-
}
|
|
3625
|
-
|
|
3626
|
-
{
|
|
3627
|
-
let mut guard = progress_mutex.lock().unwrap();
|
|
3628
|
-
(*guard)(AgentProgress::ToolStart { call_id: Some(activity_call_id.clone()),
|
|
3629
|
-
action: action.clone(),
|
|
3630
|
-
input: input_val.clone(),
|
|
3631
|
-
subagent: None,
|
|
3632
|
-
});
|
|
3633
|
-
}
|
|
3634
|
-
|
|
3635
|
-
let mut images=Vec::new();
|
|
3636
|
-
let mut typed_status=None;
|
|
3637
|
-
let result: String = if pinned_mcp_server.is_some_and(|p| {
|
|
3638
|
-
(action == "mcp_tool" || action == "mcp_list_tools") && decision.input.server != p
|
|
3639
|
-
}) {
|
|
3640
|
-
let p = pinned_mcp_server.unwrap();
|
|
3641
|
-
format!(
|
|
3642
|
-
"Blocked: this turn is pinned to the \"{p}\" MCP server only (selected via @{p} in the composer). Retry with \"server\":\"{p}\", or use a different (non-MCP) tool."
|
|
3643
|
-
)
|
|
3644
|
-
} else {
|
|
3645
|
-
match crate::hooks::run_pre_tool_hooks(hooks, &action, &input_val, root) {
|
|
3646
|
-
crate::hooks::PreHookOutcome::Blocked(reason) => {
|
|
3647
|
-
format!(
|
|
3648
|
-
"Blocked by hook: {}\n\n[System Tip: A configured PreToolUse hook rejected this action. Adjust your approach or ask the user for guidance.]",
|
|
3649
|
-
reason
|
|
3650
|
-
)
|
|
3651
|
-
}
|
|
3652
|
-
crate::hooks::PreHookOutcome::Allowed => {
|
|
3653
|
-
let mut approve_adapter =
|
|
3654
|
-
|approval: &AgentApproval| -> Result<ApprovalOutcome, String> {
|
|
3655
|
-
let mut guard = approve_mutex.lock().unwrap();
|
|
3656
|
-
(*guard)(approval)
|
|
3657
|
-
};
|
|
3658
|
-
let mut progress_adapter = |event: AgentProgress| {
|
|
3659
|
-
let mut guard = progress_mutex.lock().unwrap();
|
|
3660
|
-
(*guard)(event);
|
|
3661
|
-
};
|
|
3662
|
-
|
|
3663
|
-
let (tool_result, success) = match execute_tool_outcome(
|
|
3664
|
-
root,
|
|
3665
|
-
config,
|
|
3666
|
-
&decision,
|
|
3667
|
-
chat_id,
|
|
3668
|
-
&mut approve_adapter,
|
|
3669
|
-
&mut progress_adapter,
|
|
3670
|
-
&activity_call_id,
|
|
3671
|
-
)
|
|
3672
|
-
.await
|
|
3673
|
-
{
|
|
3674
|
-
Ok(outcome) => {images=outcome.images;typed_status=Some(outcome.status);(outcome.text,outcome.status==crate::mcp_result::ToolStatus::Success)},
|
|
3675
|
-
Err(err) => (format!("Error: {}", err), false),
|
|
3676
|
-
};
|
|
3677
|
-
|
|
3678
|
-
let hook_messages = crate::hooks::run_post_tool_hooks(
|
|
3679
|
-
hooks,
|
|
3680
|
-
&action,
|
|
3681
|
-
&input_val,
|
|
3682
|
-
&tool_result,
|
|
3683
|
-
success,
|
|
3684
|
-
root,
|
|
3685
|
-
);
|
|
3686
|
-
|
|
3687
|
-
if hook_messages.is_empty() {
|
|
3688
|
-
tool_result
|
|
3689
|
-
} else {
|
|
3690
|
-
format!(
|
|
3691
|
-
"{}\n\n{}",
|
|
3692
|
-
tool_result,
|
|
3693
|
-
hook_messages
|
|
3694
|
-
.iter()
|
|
3695
|
-
.map(|msg| format!("[Hook] {}", msg))
|
|
3696
|
-
.collect::<Vec<_>>()
|
|
3697
|
-
.join("\n")
|
|
3698
|
-
)
|
|
3699
|
-
}
|
|
3700
|
-
}
|
|
3701
|
-
}
|
|
3702
|
-
};
|
|
3703
|
-
|
|
3704
|
-
{
|
|
3705
|
-
let mut guard = progress_mutex.lock().unwrap();
|
|
3706
|
-
(*guard)(AgentProgress::ToolEnd { elapsed_ms: None, call_id: Some(activity_call_id.clone()), status: typed_status,
|
|
3707
|
-
action: action.clone(),
|
|
3708
|
-
input: input_val.clone(),
|
|
3709
|
-
result: result.clone(),
|
|
3710
|
-
subagent: None,
|
|
3711
|
-
});
|
|
3712
|
-
}
|
|
3713
|
-
|
|
3714
|
-
(
|
|
3715
|
-
index, call_id, thought, action, input_val, action_key, result, images, typed_status,
|
|
3716
|
-
)
|
|
3717
|
-
});
|
|
3718
|
-
}
|
|
3719
|
-
|
|
3720
|
-
let mut results = futures_util::stream::iter(tasks)
|
|
3721
|
-
.buffer_unordered(PARALLEL_READ_ONLY_LIMIT)
|
|
3722
|
-
.collect::<Vec<_>>()
|
|
3723
|
-
.await;
|
|
3724
|
-
|
|
3725
|
-
results.sort_by_key(|(index, ..)| *index);
|
|
3726
|
-
|
|
3727
|
-
let mut step_tool_results = Vec::with_capacity(results.len());
|
|
3728
|
-
for (_, call_id, thought, action, input_val, action_key, result, images, typed_status) in
|
|
3729
|
-
results
|
|
3730
|
-
{
|
|
3731
|
-
if !images.is_empty() {
|
|
3732
|
-
step_images.insert(call_id.clone(), images);
|
|
3733
|
-
}
|
|
3734
|
-
let action_count = {
|
|
3735
|
-
let count = action_counts.entry(action_key).or_insert(0);
|
|
3736
|
-
*count += 1;
|
|
3737
|
-
*count
|
|
3738
|
-
};
|
|
3739
|
-
|
|
3740
|
-
let mut final_result = if result.starts_with("data:image/")
|
|
3741
|
-
&& matches!(
|
|
3742
|
-
action.as_str(),
|
|
3743
|
-
"browser_screenshot" | "video_filmstrip" | "video_waveform" | "view_image"
|
|
3744
|
-
) {
|
|
3745
|
-
step_images.insert(call_id.clone(), vec![result.clone()]);
|
|
3746
|
-
match action.as_str() {
|
|
3747
|
-
"video_filmstrip" => {
|
|
3748
|
-
"[Filmstrip generated — see attached image: sampled frames across the video timeline]".to_string()
|
|
3749
|
-
}
|
|
3750
|
-
"video_waveform" => {
|
|
3751
|
-
"[Waveform generated — see attached image: audio amplitude over time]".to_string()
|
|
3752
|
-
}
|
|
3753
|
-
"view_image" => "[Image loaded — see attached image]".to_string(),
|
|
3754
|
-
_ => "[Screenshot captured — see attached image]".to_string(),
|
|
3755
|
-
}
|
|
3756
|
-
} else {
|
|
3757
|
-
model_tool_result(&action, &result)
|
|
3758
|
-
};
|
|
3759
|
-
|
|
3760
|
-
if action_count >= 3 && !action.starts_with("browser_") {
|
|
3761
|
-
final_result.push_str(
|
|
3762
|
-
"\n\n[System Tip: You repeated the same tool action three or more times. \
|
|
3763
|
-
Stop repeating it. If you already have enough information or the requested edit is done, \
|
|
3764
|
-
use the finish action now. Otherwise choose a different necessary action.]",
|
|
3765
|
-
);
|
|
3766
|
-
}
|
|
3767
|
-
|
|
3768
|
-
trajectory.push(format_trajectory_step(
|
|
3769
|
-
step,
|
|
3770
|
-
&thought,
|
|
3771
|
-
&action,
|
|
3772
|
-
&final_result,
|
|
3773
|
-
));
|
|
3774
|
-
|
|
3775
|
-
let mut entry = ToolResultEntry::from((call_id, action, input_val, final_result));
|
|
3776
|
-
if let Some(status) = typed_status {
|
|
3777
|
-
entry.status = status;
|
|
3778
|
-
}
|
|
3779
|
-
step_tool_results.push(entry);
|
|
3780
|
-
}
|
|
3781
|
-
|
|
3782
|
-
step_tool_results
|
|
3783
|
-
}
|
|
3784
|
-
|
|
3785
|
-
// Takes a trait object rather than being generic over `Approve` like it used
|
|
3786
|
-
// to be: `dispatch_subagent` recurses back into `orchestrate_agent_loop`
|
|
3787
|
-
// (which is itself generic over its own `Approve`), and reborrowing a generic
|
|
3788
|
-
// `&mut Approve` into that recursive call makes the type grow by one `&mut`
|
|
3789
|
-
// layer per nesting level during monomorphization — `&mut Approve`, then
|
|
3790
|
-
// `&mut &mut Approve`, then `&mut &mut &mut Approve`, forever, since the type
|
|
3791
|
-
// system has no way to know the runtime depth limit that
|
|
3792
|
-
// `tool_catalog`/`allow_subagent_dispatch` enforces. A `&mut dyn FnMut(...)`
|
|
3793
|
-
// trait object is a single fixed type regardless of recursion depth, so it
|
|
3794
|
-
// doesn't hit that.
|
|
3795
|
-
|
|
3796
|
-
async fn execute_tool(
|
|
3797
|
-
root: &Path,
|
|
3798
|
-
config: &MintConfig,
|
|
3799
|
-
decision: &AgentDecision,
|
|
3800
|
-
chat_id: &str,
|
|
3801
|
-
approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
|
|
3802
|
-
progress: &mut (dyn FnMut(AgentProgress) + Send),
|
|
3803
|
-
) -> Result<String, OrchestrationError> {
|
|
3804
|
-
Ok(execute_tool_outcome(
|
|
3805
|
-
root,
|
|
3806
|
-
config,
|
|
3807
|
-
decision,
|
|
3808
|
-
chat_id,
|
|
3809
|
-
approve,
|
|
3810
|
-
progress,
|
|
3811
|
-
&uuid::Uuid::new_v4().to_string(),
|
|
3812
|
-
)
|
|
3813
|
-
.await?
|
|
3814
|
-
.text)
|
|
3815
|
-
}
|
|
3816
|
-
|
|
3817
|
-
async fn execute_tool_outcome(
|
|
3818
|
-
root: &Path,
|
|
3819
|
-
config: &MintConfig,
|
|
3820
|
-
decision: &AgentDecision,
|
|
3821
|
-
chat_id: &str,
|
|
3822
|
-
approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
|
|
3823
|
-
progress: &mut (dyn FnMut(AgentProgress) + Send),
|
|
3824
|
-
call_id: &str,
|
|
3825
|
-
) -> Result<crate::integrations::mcp_result::ToolOutcome, OrchestrationError> {
|
|
3826
|
-
execute_tool_outcome_reviewed(
|
|
3827
|
-
root, config, decision, chat_id, approve, progress, call_id, None,
|
|
3828
|
-
)
|
|
3829
|
-
.await
|
|
3830
|
-
}
|
|
3831
|
-
|
|
3832
|
-
#[allow(clippy::too_many_arguments)]
|
|
3833
|
-
async fn execute_tool_outcome_reviewed(
|
|
3834
|
-
root: &Path,
|
|
3835
|
-
config: &MintConfig,
|
|
3836
|
-
decision: &AgentDecision,
|
|
3837
|
-
chat_id: &str,
|
|
3838
|
-
approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
|
|
3839
|
-
progress: &mut (dyn FnMut(AgentProgress) + Send),
|
|
3840
|
-
call_id: &str,
|
|
3841
|
-
expected: Option<&mcp_review::McpReview>,
|
|
3842
|
-
) -> Result<crate::integrations::mcp_result::ToolOutcome, OrchestrationError> {
|
|
3843
|
-
run_control::check()?;
|
|
3844
|
-
let origin = MOD_ORIGIN.try_with(Clone::clone).ok().flatten();
|
|
3845
|
-
let input = serde_json::to_value(&decision.input).unwrap_or(Value::Null);
|
|
3846
|
-
crate::mods::event(
|
|
3847
|
-
root,
|
|
3848
|
-
chat_id,
|
|
3849
|
-
"pre_tool_use",
|
|
3850
|
-
serde_json::json!({"callId":call_id,"action":decision.action,"input":input}),
|
|
3851
|
-
origin.as_deref(),
|
|
3852
|
-
)
|
|
3853
|
-
.map_err(OrchestrationError::Agent)?;
|
|
3854
|
-
let execution = execute_tool_outcome_reviewed_inner(
|
|
3855
|
-
root, config, decision, chat_id, approve, progress, call_id, expected,
|
|
3856
|
-
);
|
|
3857
|
-
let result = if matches!(
|
|
3858
|
-
decision.action.as_str(),
|
|
3859
|
-
"run_shell" | "verify" | "run_tests" | "run_typecheck" | "run_linter"
|
|
3860
|
-
) {
|
|
3861
|
-
// Foreground shell cancellation joins process cleanup and imports any
|
|
3862
|
-
// checkpointed screenshots before the turn is marked interrupted.
|
|
3863
|
-
execution.await
|
|
3864
|
-
} else {
|
|
3865
|
-
run_control::run(Box::pin(execution)).await
|
|
3866
|
-
};
|
|
3867
|
-
let _ = crate::mods::event(
|
|
3868
|
-
root,
|
|
3869
|
-
chat_id,
|
|
3870
|
-
"post_tool_use",
|
|
3871
|
-
serde_json::json!({"callId":call_id,"action":decision.action,"input":input,"result":result.as_ref().map(|r|r.text.clone()).unwrap_or_else(|e|e.to_string()),"success":result.is_ok()}),
|
|
3872
|
-
origin.as_deref(),
|
|
3873
|
-
);
|
|
3874
|
-
result
|
|
3875
|
-
}
|
|
3876
|
-
|
|
3877
|
-
tokio::task_local! { static MOD_ORIGIN: Option<String>; }
|
|
3878
|
-
|
|
3879
|
-
/// Run queued requests through the same policy and approval path as agent tools.
|
|
3880
|
-
pub async fn execute_mod_requests(
|
|
3881
|
-
root: &Path,
|
|
3882
|
-
chat: &str,
|
|
3883
|
-
config: &MintConfig,
|
|
3884
|
-
plan_mode: bool,
|
|
3885
|
-
approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
|
|
3886
|
-
progress: &mut (dyn FnMut(AgentProgress) + Send),
|
|
3887
|
-
selected: Option<&[String]>,
|
|
3888
|
-
) -> Result<(), String> {
|
|
3889
|
-
let requests = match selected {
|
|
3890
|
-
Some(ids) => crate::mods::take_selected_requests(root, chat, ids)?,
|
|
3891
|
-
None => crate::mods::take_requests(root, chat)?,
|
|
3892
|
-
};
|
|
3893
|
-
let (remembered_plan, pin) = crate::mods::turn_policy(root, chat);
|
|
3894
|
-
let plan_mode = plan_mode || remembered_plan;
|
|
3895
|
-
for request in requests {
|
|
3896
|
-
let action = request["action"].as_str().unwrap_or("");
|
|
3897
|
-
let id = request["modId"].as_str().unwrap_or("");
|
|
3898
|
-
let input = match decode_agent_input(request["input"].clone()) {
|
|
3899
|
-
Ok(input) => input,
|
|
3900
|
-
Err(error) => {
|
|
3901
|
-
crate::mods::complete_request(
|
|
3902
|
-
root,
|
|
3903
|
-
chat,
|
|
3904
|
-
id,
|
|
3905
|
-
request["hash"].as_str().unwrap_or(""),
|
|
3906
|
-
serde_json::json!({"requestId":request["id"],"action":action,"error":error}),
|
|
3907
|
-
)?;
|
|
3908
|
-
continue;
|
|
3909
|
-
}
|
|
3910
|
-
};
|
|
3911
|
-
let allowed = crate::prompts::agent::base_allowed_actions().contains(&action)
|
|
3912
|
-
&& !action.starts_with("mod_")
|
|
3913
|
-
&& !config.disabled_tools.iter().any(|t| t == action)
|
|
3914
|
-
&& (!plan_mode || plan_mode_allows(action, &input))
|
|
3915
|
-
&& pin.as_deref().is_none_or(|p| {
|
|
3916
|
-
!matches!(action, "mcp_tool" | "mcp_list_tools") || input.server == p
|
|
3917
|
-
});
|
|
3918
|
-
let result = if allowed {
|
|
3919
|
-
let decision = AgentDecision {
|
|
3920
|
-
thought: String::new(),
|
|
3921
|
-
action: action.into(),
|
|
3922
|
-
input,
|
|
3923
|
-
};
|
|
3924
|
-
MOD_ORIGIN
|
|
3925
|
-
.scope(
|
|
3926
|
-
Some(id.into()),
|
|
3927
|
-
execute_tool_outcome_reviewed(
|
|
3928
|
-
root,
|
|
3929
|
-
config,
|
|
3930
|
-
&decision,
|
|
3931
|
-
chat,
|
|
3932
|
-
approve,
|
|
3933
|
-
progress,
|
|
3934
|
-
request["id"].as_str().unwrap_or(""),
|
|
3935
|
-
None,
|
|
3936
|
-
),
|
|
3937
|
-
)
|
|
3938
|
-
.await
|
|
3939
|
-
.map(|r| r.text)
|
|
3940
|
-
.map_err(|e| e.to_string())
|
|
3941
|
-
} else {
|
|
3942
|
-
Err("This tool is unavailable to mods in the current mode".into())
|
|
3943
|
-
};
|
|
3944
|
-
crate::mods::complete_request(
|
|
3945
|
-
root,
|
|
3946
|
-
chat,
|
|
3947
|
-
id,
|
|
3948
|
-
request["hash"].as_str().unwrap_or(""),
|
|
3949
|
-
serde_json::json!({"requestId":request["id"],"action":action,"result":result.as_ref().ok(),"error":result.as_ref().err()}),
|
|
3950
|
-
)?;
|
|
3951
|
-
}
|
|
3952
|
-
Ok(())
|
|
3953
|
-
}
|
|
2699
|
+
#[cfg(test)]
|
|
2700
|
+
mod tests;
|
|
3954
2701
|
|
|
3955
|
-
#[
|
|
3956
|
-
|
|
3957
|
-
root: &Path,
|
|
3958
|
-
config: &MintConfig,
|
|
3959
|
-
decision: &AgentDecision,
|
|
3960
|
-
chat_id: &str,
|
|
3961
|
-
approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
|
|
3962
|
-
progress: &mut (dyn FnMut(AgentProgress) + Send),
|
|
3963
|
-
call_id: &str,
|
|
3964
|
-
expected: Option<&mcp_review::McpReview>,
|
|
3965
|
-
) -> Result<crate::integrations::mcp_result::ToolOutcome, OrchestrationError> {
|
|
3966
|
-
run_control::check()?;
|
|
3967
|
-
if decision.action == "view_image" {
|
|
3968
|
-
if decision.input.path.is_empty() == decision.input.artifact_id.is_empty() {
|
|
3969
|
-
return Err(OrchestrationError::Agent(
|
|
3970
|
-
"Provide exactly one of path or artifactId".into(),
|
|
3971
|
-
));
|
|
3972
|
-
}
|
|
3973
|
-
let root = root.to_path_buf();
|
|
3974
|
-
let config = config.clone();
|
|
3975
|
-
let chat = chat_id.to_owned();
|
|
3976
|
-
let call = call_id.to_owned();
|
|
3977
|
-
let path = decision.input.path.clone();
|
|
3978
|
-
let artifact_id = decision.input.artifact_id.clone();
|
|
3979
|
-
let out = tokio::task::spawn_blocking(
|
|
3980
|
-
move || -> Result<crate::mcp_result::ToolOutcome, String> {
|
|
3981
|
-
if !artifact_id.is_empty() {
|
|
3982
|
-
return crate::media::tool_images::view_artifact(&artifact_id, &chat);
|
|
3983
|
-
}
|
|
3984
|
-
let path = agent_read_path(&root, &path, &config).map_err(|e| e.to_string())?;
|
|
3985
|
-
let path = assert_path_capability(&path, Capability::Read, &config)
|
|
3986
|
-
.map_err(|e| e.to_string())?;
|
|
3987
|
-
if std::fs::metadata(&path).map_err(|e| e.to_string())?.len()
|
|
3988
|
-
> crate::media::tool_images::ORIGINAL_LIMIT as u64
|
|
3989
|
-
{
|
|
3990
|
-
return Err("Image exceeds 20 MiB".into());
|
|
3991
|
-
}
|
|
3992
|
-
let mime = match path
|
|
3993
|
-
.extension()
|
|
3994
|
-
.and_then(|e| e.to_str())
|
|
3995
|
-
.unwrap_or("")
|
|
3996
|
-
.to_ascii_lowercase()
|
|
3997
|
-
.as_str()
|
|
3998
|
-
{
|
|
3999
|
-
"png" => "image/png",
|
|
4000
|
-
"jpg" | "jpeg" => "image/jpeg",
|
|
4001
|
-
"gif" => "image/gif",
|
|
4002
|
-
"webp" => "image/webp",
|
|
4003
|
-
"svg" => "image/svg+xml",
|
|
4004
|
-
_ => return Err("Unsupported image type".into()),
|
|
4005
|
-
};
|
|
4006
|
-
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
|
|
4007
|
-
crate::media::tool_images::save(
|
|
4008
|
-
&bytes,
|
|
4009
|
-
mime,
|
|
4010
|
-
path.file_name().and_then(|n| n.to_str()).unwrap_or("Image"),
|
|
4011
|
-
&chat,
|
|
4012
|
-
&call,
|
|
4013
|
-
"view_image",
|
|
4014
|
-
None,
|
|
4015
|
-
)
|
|
4016
|
-
},
|
|
4017
|
-
)
|
|
4018
|
-
.await
|
|
4019
|
-
.map_err(|e| OrchestrationError::Agent(e.to_string()))?
|
|
4020
|
-
.map_err(OrchestrationError::Agent)?;
|
|
4021
|
-
progress(AgentProgress::ToolArtifacts {
|
|
4022
|
-
call_id: call_id.into(),
|
|
4023
|
-
artifacts: out.artifacts.clone(),
|
|
4024
|
-
warnings: out.warnings.clone(),
|
|
4025
|
-
});
|
|
4026
|
-
return Ok(out);
|
|
4027
|
-
}
|
|
4028
|
-
if matches!(decision.action.as_str(), "mcp_tool" | "mcp_list_tools") {
|
|
4029
|
-
let mut out = tools::plugins_mcp::execute_reviewed(
|
|
4030
|
-
&decision.action,
|
|
4031
|
-
&decision.input,
|
|
4032
|
-
config,
|
|
4033
|
-
chat_id,
|
|
4034
|
-
call_id,
|
|
4035
|
-
expected,
|
|
4036
|
-
approve,
|
|
4037
|
-
progress,
|
|
4038
|
-
)
|
|
4039
|
-
.await?;
|
|
4040
|
-
if !out.images.is_empty()
|
|
4041
|
-
&& config.extra.get("mcpImageInput").and_then(Value::as_bool) == Some(false)
|
|
4042
|
-
{
|
|
4043
|
-
out.images.clear();
|
|
4044
|
-
let warning="Model image input is disabled for MCP. Use attachment metadata only; do not claim visual inspection.".to_string();
|
|
4045
|
-
out.text.push_str(&format!("\nWarning: {warning}"));
|
|
4046
|
-
out.warnings.push(warning);
|
|
4047
|
-
}
|
|
4048
|
-
if !out.artifacts.is_empty() || !out.warnings.is_empty() {
|
|
4049
|
-
progress(AgentProgress::ToolArtifacts {
|
|
4050
|
-
call_id: call_id.into(),
|
|
4051
|
-
artifacts: out.artifacts.clone(),
|
|
4052
|
-
warnings: out.warnings.clone(),
|
|
4053
|
-
});
|
|
4054
|
-
}
|
|
4055
|
-
Ok(out)
|
|
4056
|
-
} else {
|
|
4057
|
-
let mut tool_config = config.clone();
|
|
4058
|
-
let bridge = if decision.action == "run_shell" && !decision.input.background {
|
|
4059
|
-
crate::browser::workflow::ShellBridge::prepare(
|
|
4060
|
-
&decision.input.command,
|
|
4061
|
-
root,
|
|
4062
|
-
&mut tool_config,
|
|
4063
|
-
)
|
|
4064
|
-
.map_err(OrchestrationError::Agent)?
|
|
4065
|
-
} else {
|
|
4066
|
-
None
|
|
4067
|
-
};
|
|
4068
|
-
let checkpoint = reliability::checkpoint();
|
|
4069
|
-
let text = match execute_tool_text(root, &tool_config, decision, chat_id, approve, progress)
|
|
4070
|
-
.await
|
|
4071
|
-
{
|
|
4072
|
-
Ok(text) => text,
|
|
4073
|
-
Err(error) => {
|
|
4074
|
-
if decision.action.starts_with("browser_") {
|
|
4075
|
-
reliability::record_browser(&decision.action, None, false);
|
|
4076
|
-
}
|
|
4077
|
-
reliability::complete_tool(&decision.action, call_id, checkpoint, false);
|
|
4078
|
-
return Err(error);
|
|
4079
|
-
}
|
|
4080
|
-
};
|
|
4081
|
-
let mut out = if decision.action == "browser_screenshot"
|
|
4082
|
-
&& text.starts_with("data:image/png;base64,")
|
|
4083
|
-
{
|
|
4084
|
-
let bytes = BASE64_STANDARD
|
|
4085
|
-
.decode(text.trim_start_matches("data:image/png;base64,"))
|
|
4086
|
-
.map_err(|e| OrchestrationError::Agent(e.to_string()))?;
|
|
4087
|
-
let metadata = crate::browser::capture_metadata().await;
|
|
4088
|
-
let out = crate::media::tool_images::save(
|
|
4089
|
-
&bytes,
|
|
4090
|
-
"image/png",
|
|
4091
|
-
&format!("screenshot-{call_id}.png"),
|
|
4092
|
-
chat_id,
|
|
4093
|
-
call_id,
|
|
4094
|
-
"browser",
|
|
4095
|
-
Some(metadata),
|
|
4096
|
-
)
|
|
4097
|
-
.map_err(OrchestrationError::Agent)?;
|
|
4098
|
-
progress(AgentProgress::ToolArtifacts {
|
|
4099
|
-
call_id: call_id.into(),
|
|
4100
|
-
artifacts: out.artifacts.clone(),
|
|
4101
|
-
warnings: out.warnings.clone(),
|
|
4102
|
-
});
|
|
4103
|
-
out
|
|
4104
|
-
} else {
|
|
4105
|
-
crate::mcp_result::ToolOutcome::from(text)
|
|
4106
|
-
};
|
|
4107
|
-
let mut workflow_verified = None;
|
|
4108
|
-
if let Some(bridge) = bridge {
|
|
4109
|
-
match bridge.images(chat_id, call_id) {
|
|
4110
|
-
Ok((images, verified)) => {
|
|
4111
|
-
workflow_verified = Some(verified);
|
|
4112
|
-
out.text.push_str(&images.text);
|
|
4113
|
-
out.images.extend(images.images);
|
|
4114
|
-
out.artifacts.extend(images.artifacts);
|
|
4115
|
-
out.warnings.extend(images.warnings);
|
|
4116
|
-
progress(AgentProgress::ToolArtifacts {
|
|
4117
|
-
call_id: call_id.into(),
|
|
4118
|
-
artifacts: out.artifacts.clone(),
|
|
4119
|
-
warnings: out.warnings.clone(),
|
|
4120
|
-
});
|
|
4121
|
-
}
|
|
4122
|
-
Err(error) => {
|
|
4123
|
-
out.text.push_str(&format!("\nWarning: {error}"));
|
|
4124
|
-
out.warnings.push(error);
|
|
4125
|
-
workflow_verified = Some(false);
|
|
4126
|
-
}
|
|
4127
|
-
}
|
|
4128
|
-
}
|
|
4129
|
-
if decision.action.starts_with("browser_") {
|
|
4130
|
-
let report = serde_json::from_str::<Value>(&out.text).ok();
|
|
4131
|
-
if report
|
|
4132
|
-
.as_ref()
|
|
4133
|
-
.is_some_and(|r| r.get("error").is_some_and(|e| !e.is_null()))
|
|
4134
|
-
{
|
|
4135
|
-
out.status = crate::mcp_result::ToolStatus::Failed;
|
|
4136
|
-
}
|
|
4137
|
-
reliability::record_browser(
|
|
4138
|
-
&decision.action,
|
|
4139
|
-
report.as_ref(),
|
|
4140
|
-
out.status == crate::mcp_result::ToolStatus::Success,
|
|
4141
|
-
);
|
|
4142
|
-
}
|
|
4143
|
-
let mut entries = reliability::complete_tool(
|
|
4144
|
-
&decision.action,
|
|
4145
|
-
call_id,
|
|
4146
|
-
checkpoint,
|
|
4147
|
-
out.status == crate::mcp_result::ToolStatus::Success,
|
|
4148
|
-
);
|
|
4149
|
-
if let Some(verified) = workflow_verified {
|
|
4150
|
-
entries = reliability::mark_workflow(call_id, verified);
|
|
4151
|
-
}
|
|
4152
|
-
if decision.action == "shell_output" {
|
|
4153
|
-
entries = reliability::for_job(&decision.input.job_id);
|
|
4154
|
-
}
|
|
4155
|
-
if !entries.is_empty() {
|
|
4156
|
-
out.status = if entries
|
|
4157
|
-
.iter()
|
|
4158
|
-
.any(|e| e.state == reliability::ExecutionState::Cancelled)
|
|
4159
|
-
{
|
|
4160
|
-
crate::mcp_result::ToolStatus::Cancelled
|
|
4161
|
-
} else if entries
|
|
4162
|
-
.iter()
|
|
4163
|
-
.any(|e| e.state == reliability::ExecutionState::Failed)
|
|
4164
|
-
{
|
|
4165
|
-
crate::mcp_result::ToolStatus::Failed
|
|
4166
|
-
} else if entries
|
|
4167
|
-
.iter()
|
|
4168
|
-
.any(|e| e.state == reliability::ExecutionState::Running)
|
|
4169
|
-
{
|
|
4170
|
-
crate::mcp_result::ToolStatus::Running
|
|
4171
|
-
} else if entries
|
|
4172
|
-
.iter()
|
|
4173
|
-
.any(|e| e.state == reliability::ExecutionState::Unknown)
|
|
4174
|
-
{
|
|
4175
|
-
crate::mcp_result::ToolStatus::Unknown
|
|
4176
|
-
} else {
|
|
4177
|
-
crate::mcp_result::ToolStatus::Success
|
|
4178
|
-
};
|
|
4179
|
-
progress(AgentProgress::ExecutionUpdate {
|
|
4180
|
-
call_id: call_id.into(),
|
|
4181
|
-
executions: entries,
|
|
4182
|
-
});
|
|
4183
|
-
}
|
|
4184
|
-
Ok(out)
|
|
4185
|
-
}
|
|
4186
|
-
}
|
|
4187
|
-
|
|
4188
|
-
async fn execute_tool_text(
|
|
4189
|
-
root: &Path,
|
|
4190
|
-
config: &MintConfig,
|
|
4191
|
-
decision: &AgentDecision,
|
|
4192
|
-
chat_id: &str,
|
|
4193
|
-
approve_cb: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
|
|
4194
|
-
progress: &mut (dyn FnMut(AgentProgress) + Send),
|
|
4195
|
-
) -> Result<String, OrchestrationError> {
|
|
4196
|
-
if let Some(permissions) = crate::swarm::current_permissions() {
|
|
4197
|
-
if !permissions.allows(&decision.action) {
|
|
4198
|
-
return Err(OrchestrationError::Agent(format!(
|
|
4199
|
-
"action '{}' is not permitted by this subagent's permissions",
|
|
4200
|
-
decision.action
|
|
4201
|
-
)));
|
|
4202
|
-
}
|
|
4203
|
-
}
|
|
4204
|
-
let input = &decision.input;
|
|
4205
|
-
match decision.action.as_str() {
|
|
4206
|
-
"mod_help" | "mod_list" | "mod_validate" | "mod_publish" => {
|
|
4207
|
-
tools::mods::execute(&decision.action, input, root, config, chat_id, approve_cb).await
|
|
4208
|
-
}
|
|
4209
|
-
"list_files" | "read_file" | "note_write" | "apply_patch" | "write_file" => {
|
|
4210
|
-
tools::files::execute(
|
|
4211
|
-
decision.action.as_str(),
|
|
4212
|
-
input,
|
|
4213
|
-
root,
|
|
4214
|
-
config,
|
|
4215
|
-
chat_id,
|
|
4216
|
-
approve_cb,
|
|
4217
|
-
)
|
|
4218
|
-
.await
|
|
4219
|
-
}
|
|
4220
|
-
"search_code" | "symbols" | "find_definition" | "find_references" | "repo_map"
|
|
4221
|
-
| "semantic_index" | "semantic_search" => {
|
|
4222
|
-
tools::code_search::execute(
|
|
4223
|
-
decision.action.as_str(),
|
|
4224
|
-
input,
|
|
4225
|
-
root,
|
|
4226
|
-
config,
|
|
4227
|
-
chat_id,
|
|
4228
|
-
approve_cb,
|
|
4229
|
-
)
|
|
4230
|
-
.await
|
|
4231
|
-
}
|
|
4232
|
-
"knowledge_search" | "web_search" | "image_search" => {
|
|
4233
|
-
tools::web::execute(
|
|
4234
|
-
decision.action.as_str(),
|
|
4235
|
-
input,
|
|
4236
|
-
root,
|
|
4237
|
-
config,
|
|
4238
|
-
chat_id,
|
|
4239
|
-
approve_cb,
|
|
4240
|
-
)
|
|
4241
|
-
.await
|
|
4242
|
-
}
|
|
4243
|
-
"weather" | "stock" | "calculation" | "memory_recall" => {
|
|
4244
|
-
tools::misc::execute(
|
|
4245
|
-
decision.action.as_str(),
|
|
4246
|
-
input,
|
|
4247
|
-
root,
|
|
4248
|
-
config,
|
|
4249
|
-
chat_id,
|
|
4250
|
-
approve_cb,
|
|
4251
|
-
)
|
|
4252
|
-
.await
|
|
4253
|
-
}
|
|
4254
|
-
"browser_open"
|
|
4255
|
-
| "browser_click"
|
|
4256
|
-
| "browser_type"
|
|
4257
|
-
| "browser_read"
|
|
4258
|
-
| "browser_mouse_move"
|
|
4259
|
-
| "browser_mouse_click"
|
|
4260
|
-
| "browser_key_press"
|
|
4261
|
-
| "browser_screenshot"
|
|
4262
|
-
| "browser_tabs"
|
|
4263
|
-
| "browser_observe"
|
|
4264
|
-
| "browser_fill"
|
|
4265
|
-
| "browser_select"
|
|
4266
|
-
| "browser_scroll"
|
|
4267
|
-
| "browser_wait" => {
|
|
4268
|
-
tools::browser::execute(
|
|
4269
|
-
decision.action.as_str(),
|
|
4270
|
-
input,
|
|
4271
|
-
root,
|
|
4272
|
-
config,
|
|
4273
|
-
chat_id,
|
|
4274
|
-
approve_cb,
|
|
4275
|
-
)
|
|
4276
|
-
.await
|
|
4277
|
-
}
|
|
4278
|
-
"git_status" | "git_diff" | "git_log" | "git_branch" | "git_checkpoint"
|
|
4279
|
-
| "git_rollback" | "git_restore_file" | "git_commit" | "git_create_branch" => {
|
|
4280
|
-
tools::git::execute(
|
|
4281
|
-
decision.action.as_str(),
|
|
4282
|
-
input,
|
|
4283
|
-
root,
|
|
4284
|
-
config,
|
|
4285
|
-
chat_id,
|
|
4286
|
-
approve_cb,
|
|
4287
|
-
)
|
|
4288
|
-
.await
|
|
4289
|
-
}
|
|
4290
|
-
"create_plan" | "update_plan" | "request_user_approval" | "ask_user" => {
|
|
4291
|
-
tools::planning::execute(
|
|
4292
|
-
decision.action.as_str(),
|
|
4293
|
-
input,
|
|
4294
|
-
root,
|
|
4295
|
-
config,
|
|
4296
|
-
chat_id,
|
|
4297
|
-
approve_cb,
|
|
4298
|
-
progress,
|
|
4299
|
-
)
|
|
4300
|
-
.await
|
|
4301
|
-
}
|
|
4302
|
-
"detect_project" | "list_tests" | "read_diagnostics" | "view_image" | "search_docs"
|
|
4303
|
-
| "create_project_doc" => {
|
|
4304
|
-
tools::project::execute(
|
|
4305
|
-
decision.action.as_str(),
|
|
4306
|
-
input,
|
|
4307
|
-
root,
|
|
4308
|
-
config,
|
|
4309
|
-
chat_id,
|
|
4310
|
-
approve_cb,
|
|
4311
|
-
)
|
|
4312
|
-
.await
|
|
4313
|
-
}
|
|
4314
|
-
"avatar_signal" => tools::avatar::execute(input).await,
|
|
4315
|
-
"run_plugin" | "dispatch_agents" | "dispatch_subagent" | "mcp_tool" | "mcp_list_tools" => {
|
|
4316
|
-
tools::plugins_mcp::execute(
|
|
4317
|
-
decision.action.as_str(),
|
|
4318
|
-
input,
|
|
4319
|
-
root,
|
|
4320
|
-
config,
|
|
4321
|
-
chat_id,
|
|
4322
|
-
approve_cb,
|
|
4323
|
-
progress,
|
|
4324
|
-
)
|
|
4325
|
-
.await
|
|
4326
|
-
}
|
|
4327
|
-
"run_shell" | "shell_output" | "kill_shell" | "verify" | "run_tests" | "run_typecheck"
|
|
4328
|
-
| "run_linter" => {
|
|
4329
|
-
tools::shell::execute(
|
|
4330
|
-
decision.action.as_str(),
|
|
4331
|
-
input,
|
|
4332
|
-
root,
|
|
4333
|
-
config,
|
|
4334
|
-
chat_id,
|
|
4335
|
-
approve_cb,
|
|
4336
|
-
)
|
|
4337
|
-
.await
|
|
4338
|
-
}
|
|
4339
|
-
"video_trim"
|
|
4340
|
-
| "video.trim"
|
|
4341
|
-
| "video_remove_silence"
|
|
4342
|
-
| "video.remove_silence"
|
|
4343
|
-
| "video_resize"
|
|
4344
|
-
| "video_merge"
|
|
4345
|
-
| "video_export"
|
|
4346
|
-
| "video.export"
|
|
4347
|
-
| "video_extract_audio"
|
|
4348
|
-
| "video_filmstrip"
|
|
4349
|
-
| "video.filmstrip"
|
|
4350
|
-
| "video_waveform"
|
|
4351
|
-
| "video.waveform"
|
|
4352
|
-
| "speech_transcribe"
|
|
4353
|
-
| "subtitle_generate"
|
|
4354
|
-
| "subtitle.generate"
|
|
4355
|
-
| "subtitle_translate"
|
|
4356
|
-
| "subtitle.translate"
|
|
4357
|
-
| "subtitle_burn"
|
|
4358
|
-
| "timeline_reorder"
|
|
4359
|
-
| "timeline.reorder"
|
|
4360
|
-
| "effect_zoom_on_speaker"
|
|
4361
|
-
| "effect.zoom_on_speaker"
|
|
4362
|
-
| "audio_duck_music"
|
|
4363
|
-
| "audio.duck_music"
|
|
4364
|
-
| "make_shorts"
|
|
4365
|
-
| "video.make_shorts"
|
|
4366
|
-
| "generate_image"
|
|
4367
|
-
| "image_studio.generate"
|
|
4368
|
-
| "image_generate"
|
|
4369
|
-
| "generate_video"
|
|
4370
|
-
| "veo.generate"
|
|
4371
|
-
| "video_generate" => {
|
|
4372
|
-
tools::media::execute(
|
|
4373
|
-
decision.action.as_str(),
|
|
4374
|
-
input,
|
|
4375
|
-
root,
|
|
4376
|
-
config,
|
|
4377
|
-
chat_id,
|
|
4378
|
-
approve_cb,
|
|
4379
|
-
)
|
|
4380
|
-
.await
|
|
4381
|
-
}
|
|
4382
|
-
other => Err(OrchestrationError::Agent(format!(
|
|
4383
|
-
"unsupported code-agent action '{}'",
|
|
4384
|
-
other
|
|
4385
|
-
))),
|
|
4386
|
-
}
|
|
4387
|
-
}
|
|
4388
|
-
|
|
4389
|
-
/// Bridge for callers outside this module (e.g. the Gemini Live realtime voice
|
|
4390
|
-
/// session) that receive an action name and raw JSON input rather than a parsed
|
|
4391
|
-
/// `AgentDecision`, and so can't construct one directly since its fields are
|
|
4392
|
-
/// private to this module.
|
|
4393
|
-
pub(crate) async fn execute_tool_from_json<Approve>(
|
|
4394
|
-
root: &Path,
|
|
4395
|
-
config: &MintConfig,
|
|
4396
|
-
action: &str,
|
|
4397
|
-
input: Value,
|
|
4398
|
-
chat_id: &str,
|
|
4399
|
-
approve_cb: &mut Approve,
|
|
4400
|
-
) -> Result<String, OrchestrationError>
|
|
4401
|
-
where
|
|
4402
|
-
Approve: FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send,
|
|
4403
|
-
{
|
|
4404
|
-
let decision = AgentDecision {
|
|
4405
|
-
thought: String::new(),
|
|
4406
|
-
action: action.to_string(),
|
|
4407
|
-
input: parse_tool_input(action, input)?,
|
|
4408
|
-
};
|
|
4409
|
-
// No progress channel from the realtime voice session — tool-call
|
|
4410
|
-
// activity isn't surfaced there the way it is in the CLI/GUI.
|
|
4411
|
-
execute_tool(root, config, &decision, chat_id, approve_cb, &mut |_| {}).await
|
|
4412
|
-
}
|
|
4413
|
-
|
|
4414
|
-
fn parse_tool_input(action: &str, input: Value) -> Result<AgentInput, OrchestrationError> {
|
|
4415
|
-
let normalized = if action == "run_shell" {
|
|
4416
|
-
match &input {
|
|
4417
|
-
Value::String(cmd) if !cmd.trim().is_empty() => {
|
|
4418
|
-
if let Ok(val) = serde_json::from_str::<Value>(cmd)
|
|
4419
|
-
&& val.is_object()
|
|
4420
|
-
{
|
|
4421
|
-
val
|
|
4422
|
-
} else {
|
|
4423
|
-
serde_json::json!({ "command": cmd.trim() })
|
|
4424
|
-
}
|
|
4425
|
-
}
|
|
4426
|
-
_ => input,
|
|
4427
|
-
}
|
|
4428
|
-
} else {
|
|
4429
|
-
input
|
|
4430
|
-
};
|
|
4431
|
-
decode_agent_input(normalized).map_err(|error| {
|
|
4432
|
-
OrchestrationError::Agent(format!("invalid arguments for tool '{action}': {error}"))
|
|
4433
|
-
})
|
|
4434
|
-
}
|
|
4435
|
-
|
|
4436
|
-
fn append_native_tool_results(
|
|
4437
|
-
messages: &mut Vec<ChatMessage>,
|
|
4438
|
-
response_text: &str,
|
|
4439
|
-
results: &[ToolResultEntry],
|
|
4440
|
-
signatures: &std::collections::HashMap<String, String>,
|
|
4441
|
-
images: &std::collections::HashMap<String, Vec<String>>,
|
|
4442
|
-
) {
|
|
4443
|
-
let mut assistant_content: Vec<ContentBlock> = Vec::new();
|
|
4444
|
-
if !response_text.is_empty() {
|
|
4445
|
-
assistant_content.push(ContentBlock::Text {
|
|
4446
|
-
text: response_text.to_string(),
|
|
4447
|
-
});
|
|
4448
|
-
}
|
|
4449
|
-
let mut tool_result_content: Vec<ContentBlock> = Vec::new();
|
|
4450
|
-
for ToolResultEntry {
|
|
4451
|
-
call_id,
|
|
4452
|
-
action,
|
|
4453
|
-
input: input_value,
|
|
4454
|
-
text: final_result,
|
|
4455
|
-
status,
|
|
4456
|
-
} in results
|
|
4457
|
-
{
|
|
4458
|
-
// Activity annotations are for the UI, not parameters of the provider tool schema.
|
|
4459
|
-
let mut tool_input = input_value.clone();
|
|
4460
|
-
if let Some(object) = tool_input.as_object_mut() {
|
|
4461
|
-
object.remove("skill_name");
|
|
4462
|
-
}
|
|
4463
|
-
assistant_content.push(ContentBlock::ToolUse {
|
|
4464
|
-
id: call_id.clone(),
|
|
4465
|
-
name: action.clone(),
|
|
4466
|
-
input: tool_input,
|
|
4467
|
-
thought_signature: signatures.get(call_id).cloned(),
|
|
4468
|
-
});
|
|
4469
|
-
tool_result_content.push(ContentBlock::ToolResult {
|
|
4470
|
-
tool_use_id: call_id.clone(),
|
|
4471
|
-
content: final_result.clone(),
|
|
4472
|
-
is_error: matches!(
|
|
4473
|
-
*status,
|
|
4474
|
-
crate::mcp_result::ToolStatus::Failed
|
|
4475
|
-
| crate::mcp_result::ToolStatus::TimedOut
|
|
4476
|
-
| crate::mcp_result::ToolStatus::Cancelled
|
|
4477
|
-
),
|
|
4478
|
-
});
|
|
4479
|
-
if let Some(data_uris) = images.get(call_id) {
|
|
4480
|
-
for data_uri in data_uris {
|
|
4481
|
-
tool_result_content.push(ContentBlock::Image {
|
|
4482
|
-
data_uri: data_uri.clone(),
|
|
4483
|
-
});
|
|
4484
|
-
}
|
|
4485
|
-
}
|
|
4486
|
-
}
|
|
4487
|
-
if !assistant_content.is_empty() {
|
|
4488
|
-
messages.push(ChatMessage {
|
|
4489
|
-
role: ChatRole::Assistant,
|
|
4490
|
-
content: assistant_content,
|
|
4491
|
-
});
|
|
4492
|
-
}
|
|
4493
|
-
if !tool_result_content.is_empty() {
|
|
4494
|
-
messages.push(ChatMessage {
|
|
4495
|
-
role: ChatRole::Tool,
|
|
4496
|
-
content: tool_result_content,
|
|
4497
|
-
});
|
|
4498
|
-
}
|
|
4499
|
-
}
|
|
4500
|
-
|
|
4501
|
-
#[derive(Debug, Clone)]
|
|
4502
|
-
pub(super) struct ToolResultEntry {
|
|
4503
|
-
pub(super) call_id: String,
|
|
4504
|
-
pub(super) action: String,
|
|
4505
|
-
pub(super) input: Value,
|
|
4506
|
-
pub(super) text: String,
|
|
4507
|
-
pub(super) status: crate::mcp_result::ToolStatus,
|
|
4508
|
-
}
|
|
4509
|
-
|
|
4510
|
-
// Legacy tools determine their outcome once at this adapter; MCP supplies its
|
|
4511
|
-
// explicit status directly, regardless of the wording of server text.
|
|
4512
|
-
impl From<(String, String, Value, String)> for ToolResultEntry {
|
|
4513
|
-
fn from((call_id, action, input, text): (String, String, Value, String)) -> Self {
|
|
4514
|
-
let mut status = crate::mcp_result::ToolOutcome::from(text.clone()).status;
|
|
4515
|
-
if text.starts_with("Skipped") {
|
|
4516
|
-
status = crate::mcp_result::ToolStatus::Skipped;
|
|
4517
|
-
} else if matches!(
|
|
4518
|
-
action.as_str(),
|
|
4519
|
-
"run_shell" | "verify" | "run_tests" | "run_typecheck" | "run_linter"
|
|
4520
|
-
) && shell_result_failed(&text)
|
|
4521
|
-
{
|
|
4522
|
-
status = crate::mcp_result::ToolStatus::Failed;
|
|
4523
|
-
}
|
|
4524
|
-
Self {
|
|
4525
|
-
call_id,
|
|
4526
|
-
action,
|
|
4527
|
-
input,
|
|
4528
|
-
text,
|
|
4529
|
-
status,
|
|
4530
|
-
}
|
|
4531
|
-
}
|
|
4532
|
-
}
|
|
4533
|
-
type PreparedToolCalls = (Vec<(String, AgentDecision)>, Vec<ToolResultEntry>);
|
|
4534
|
-
|
|
4535
|
-
#[derive(Debug, Default)]
|
|
4536
|
-
struct ToolArgumentRetries {
|
|
4537
|
-
failures: BTreeMap<String, usize>,
|
|
4538
|
-
}
|
|
4539
|
-
|
|
4540
|
-
impl ToolArgumentRetries {
|
|
4541
|
-
fn succeeded(&mut self, tool: &str) {
|
|
4542
|
-
self.failures.remove(tool);
|
|
4543
|
-
}
|
|
4544
|
-
|
|
4545
|
-
fn observe_completed(&mut self, results: &[ToolResultEntry]) {
|
|
4546
|
-
for ToolResultEntry {
|
|
4547
|
-
action: tool,
|
|
4548
|
-
status,
|
|
4549
|
-
..
|
|
4550
|
-
} in results
|
|
4551
|
-
{
|
|
4552
|
-
// Parallel batches encode execution errors and hook blocks in
|
|
4553
|
-
// their results. Neither is a successful execution.
|
|
4554
|
-
if *status == crate::mcp_result::ToolStatus::Success {
|
|
4555
|
-
self.succeeded(tool);
|
|
4556
|
-
}
|
|
4557
|
-
}
|
|
4558
|
-
}
|
|
4559
|
-
}
|
|
4560
|
-
|
|
4561
|
-
/// Invalid calls never become executable decisions. Return their original
|
|
4562
|
-
/// arguments and call IDs so the next model turn can repair them.
|
|
4563
|
-
fn prepare_native_tool_calls(
|
|
4564
|
-
calls: Vec<crate::chat::ToolCall>,
|
|
4565
|
-
thought: &str,
|
|
4566
|
-
retries: &mut ToolArgumentRetries,
|
|
4567
|
-
) -> Result<PreparedToolCalls, OrchestrationError> {
|
|
4568
|
-
let mut decisions = Vec::new();
|
|
4569
|
-
let mut errors = Vec::new();
|
|
4570
|
-
for (index, call) in calls.into_iter().enumerate() {
|
|
4571
|
-
match parse_tool_input(&call.name, call.input.clone()) {
|
|
4572
|
-
Ok(input) => decisions.push((
|
|
4573
|
-
call.id,
|
|
4574
|
-
AgentDecision {
|
|
4575
|
-
thought: if index == 0 {
|
|
4576
|
-
thought.to_owned()
|
|
4577
|
-
} else {
|
|
4578
|
-
String::new()
|
|
4579
|
-
},
|
|
4580
|
-
action: call.name,
|
|
4581
|
-
input,
|
|
4582
|
-
},
|
|
4583
|
-
)),
|
|
4584
|
-
Err(error) => {
|
|
4585
|
-
let invalid_count = retries.failures.entry(call.name.clone()).or_default();
|
|
4586
|
-
if *invalid_count >= MAX_TOOL_ARGUMENT_RETRIES {
|
|
4587
|
-
return Err(OrchestrationError::Agent(format!(
|
|
4588
|
-
"Tool '{}' exhausted its {MAX_TOOL_ARGUMENT_RETRIES} argument-correction retries without a successful execution. Other tools have independent budgets. {error}",
|
|
4589
|
-
call.name
|
|
4590
|
-
)));
|
|
4591
|
-
}
|
|
4592
|
-
*invalid_count += 1;
|
|
4593
|
-
errors.push((call.id, call.name, call.input, format!(
|
|
4594
|
-
"Error: {error}. No tool was executed for this call. Correct the arguments to match the tool schema, or choose another suitable tool. Correction retries for this tool: {}/{MAX_TOOL_ARGUMENT_RETRIES}. This counter resets only after this tool executes successfully; changing arguments alone does not reset it. Other tools have independent counters. Continued failures for this tool will stop the run.",
|
|
4595
|
-
*invalid_count,
|
|
4596
|
-
)).into());
|
|
4597
|
-
}
|
|
4598
|
-
}
|
|
4599
|
-
}
|
|
4600
|
-
Ok((decisions, errors))
|
|
4601
|
-
}
|
|
4602
|
-
|
|
4603
|
-
#[cfg(test)]
|
|
4604
|
-
mod tests {
|
|
4605
|
-
use super::*;
|
|
4606
|
-
use crate::config::AgentConfig;
|
|
4607
|
-
|
|
4608
|
-
#[test]
|
|
4609
|
-
fn stringified_patch_uses_explicit_outer_target() {
|
|
4610
|
-
let patch =
|
|
4611
|
-
serde_json::json!({"hunks": [{"oldText": "old", "newText": "new"}]}).to_string();
|
|
4612
|
-
let input = parse_tool_input(
|
|
4613
|
-
"apply_patch",
|
|
4614
|
-
serde_json::json!({"path": "style.css", "patch": patch}),
|
|
4615
|
-
)
|
|
4616
|
-
.unwrap();
|
|
4617
|
-
let patch = input.patch.unwrap();
|
|
4618
|
-
assert_eq!(patch.path, PathBuf::from("style.css"));
|
|
4619
|
-
assert_eq!(patch.hunks.len(), 1);
|
|
4620
|
-
}
|
|
4621
|
-
|
|
4622
|
-
#[tokio::test]
|
|
4623
|
-
async fn stringified_patch_executes_only_after_approval_and_can_be_undone() {
|
|
4624
|
-
let root = std::env::temp_dir().join(format!("mint-patch-repro-{}", uuid::Uuid::new_v4()));
|
|
4625
|
-
std::fs::create_dir_all(&root).unwrap();
|
|
4626
|
-
std::fs::write(root.join("style.css"), "body { color: black; }").unwrap();
|
|
4627
|
-
let config = MintConfig {
|
|
4628
|
-
allowed_read_paths: vec![root.clone()],
|
|
4629
|
-
allowed_write_paths: vec![root.clone()],
|
|
4630
|
-
blocked_paths: vec![],
|
|
4631
|
-
blocked_file_names: vec![],
|
|
4632
|
-
..MintConfig::default()
|
|
4633
|
-
};
|
|
4634
|
-
let input = serde_json::json!({"path": "style.css", "patch": serde_json::json!({"hunks": [{"oldText": "black", "newText": "orange"}]}).to_string()});
|
|
4635
|
-
execute_tool_from_json(
|
|
4636
|
-
&root,
|
|
4637
|
-
&config,
|
|
4638
|
-
"apply_patch",
|
|
4639
|
-
input.clone(),
|
|
4640
|
-
"patch-repro",
|
|
4641
|
-
&mut |_| Ok(ApprovalOutcome::Denied),
|
|
4642
|
-
)
|
|
4643
|
-
.await
|
|
4644
|
-
.unwrap();
|
|
4645
|
-
assert_eq!(
|
|
4646
|
-
std::fs::read_to_string(root.join("style.css")).unwrap(),
|
|
4647
|
-
"body { color: black; }"
|
|
4648
|
-
);
|
|
4649
|
-
execute_tool_from_json(
|
|
4650
|
-
&root,
|
|
4651
|
-
&config,
|
|
4652
|
-
"apply_patch",
|
|
4653
|
-
input,
|
|
4654
|
-
"patch-repro",
|
|
4655
|
-
&mut |_| Ok(ApprovalOutcome::Approved),
|
|
4656
|
-
)
|
|
4657
|
-
.await
|
|
4658
|
-
.unwrap();
|
|
4659
|
-
assert_eq!(
|
|
4660
|
-
std::fs::read_to_string(root.join("style.css")).unwrap(),
|
|
4661
|
-
"body { color: orange; }"
|
|
4662
|
-
);
|
|
4663
|
-
crate::system::workspace_history::undo(&root, None).unwrap();
|
|
4664
|
-
assert_eq!(
|
|
4665
|
-
std::fs::read_to_string(root.join("style.css")).unwrap(),
|
|
4666
|
-
"body { color: black; }"
|
|
4667
|
-
);
|
|
4668
|
-
std::fs::remove_dir_all(root).unwrap();
|
|
4669
|
-
}
|
|
4670
|
-
|
|
4671
|
-
#[test]
|
|
4672
|
-
fn patch_decoding_preserves_strict_types_and_rejects_ambiguous_targets() {
|
|
4673
|
-
let patch = serde_json::json!({"path": "style.css", "hunks": [{"oldText": "old", "newText": "new"}]});
|
|
4674
|
-
assert!(parse_tool_input("apply_patch", serde_json::json!({"patch": patch})).is_ok());
|
|
4675
|
-
assert!(
|
|
4676
|
-
parse_tool_input(
|
|
4677
|
-
"apply_patch",
|
|
4678
|
-
serde_json::json!({"path": "other.css", "patch": patch.to_string()})
|
|
4679
|
-
)
|
|
4680
|
-
.is_err()
|
|
4681
|
-
);
|
|
4682
|
-
assert!(
|
|
4683
|
-
parse_tool_input(
|
|
4684
|
-
"apply_patch",
|
|
4685
|
-
serde_json::json!({"patch": "{\"hunks\": []}"})
|
|
4686
|
-
)
|
|
4687
|
-
.is_err()
|
|
4688
|
-
);
|
|
4689
|
-
assert!(parse_tool_input("apply_patch", serde_json::json!({"patch": "not JSON"})).is_err());
|
|
4690
|
-
assert!(parse_tool_input("list_files", serde_json::json!({"limit": "100"})).is_err());
|
|
4691
|
-
let huge = "private-code-marker".repeat(1000);
|
|
4692
|
-
let error = parse_tool_input(
|
|
4693
|
-
"apply_patch",
|
|
4694
|
-
serde_json::json!({"patch": {"path": "style.css", "hunks": huge}}),
|
|
4695
|
-
)
|
|
4696
|
-
.unwrap_err()
|
|
4697
|
-
.to_string();
|
|
4698
|
-
assert!(error.len() < 600);
|
|
4699
|
-
assert!(!error.contains("private-code-marker"));
|
|
4700
|
-
let decision: AgentDecision = serde_json::from_value(serde_json::json!({"action": "apply_patch", "input": {"path": "style.css", "patch": serde_json::json!({"hunks": [{"oldText": "old", "newText": "new"}]}).to_string()}})).unwrap();
|
|
4701
|
-
assert_eq!(
|
|
4702
|
-
decision.input.patch.unwrap().path,
|
|
4703
|
-
PathBuf::from("style.css")
|
|
4704
|
-
);
|
|
4705
|
-
}
|
|
4706
|
-
|
|
4707
|
-
fn test_list_call(id: &str, input: Value) -> crate::chat::ToolCall {
|
|
4708
|
-
crate::chat::ToolCall {
|
|
4709
|
-
id: id.into(),
|
|
4710
|
-
name: "list_files".into(),
|
|
4711
|
-
input,
|
|
4712
|
-
thought_signature: Some("signature".into()),
|
|
4713
|
-
}
|
|
4714
|
-
}
|
|
4715
|
-
|
|
4716
|
-
#[tokio::test]
|
|
4717
|
-
async fn native_argument_error_is_returned_and_corrected_call_can_execute() {
|
|
4718
|
-
let raw = serde_json::json!({"path": "Downloads/YOUTUBE_DL/playlist", "limit": "100"});
|
|
4719
|
-
let mut count = ToolArgumentRetries::default();
|
|
4720
|
-
let (decisions, errors) =
|
|
4721
|
-
prepare_native_tool_calls(vec![test_list_call("bad", raw.clone())], "", &mut count)
|
|
4722
|
-
.unwrap();
|
|
4723
|
-
assert!(decisions.is_empty());
|
|
4724
|
-
assert_eq!(errors.len(), 1);
|
|
4725
|
-
let mut messages = Vec::new();
|
|
4726
|
-
let signatures = std::collections::HashMap::from([("bad".into(), "signature".into())]);
|
|
4727
|
-
append_native_tool_results(&mut messages, "", &errors, &signatures, &Default::default());
|
|
4728
|
-
assert!(
|
|
4729
|
-
matches!(&messages[0].content[0], ContentBlock::ToolUse { id, input, thought_signature, .. }
|
|
4730
|
-
if id == "bad" && input == &raw && thought_signature.as_deref() == Some("signature"))
|
|
4731
|
-
);
|
|
4732
|
-
assert!(
|
|
4733
|
-
matches!(&messages[1].content[0], ContentBlock::ToolResult { tool_use_id, content, is_error }
|
|
4734
|
-
if tool_use_id == "bad" && *is_error && content.contains("Correct the arguments"))
|
|
4735
|
-
);
|
|
4736
|
-
|
|
4737
|
-
let root =
|
|
4738
|
-
std::env::temp_dir().join(format!("mint-tool-recovery-{}", uuid::Uuid::new_v4()));
|
|
4739
|
-
let nested = root.join("Downloads/YOUTUBE_DL/playlist");
|
|
4740
|
-
std::fs::create_dir_all(&nested).unwrap();
|
|
4741
|
-
std::fs::write(nested.join("song.txt"), "song").unwrap();
|
|
4742
|
-
let config = MintConfig {
|
|
4743
|
-
allowed_read_paths: vec![root.clone()],
|
|
4744
|
-
blocked_paths: vec![],
|
|
4745
|
-
blocked_file_names: vec![],
|
|
4746
|
-
..MintConfig::default()
|
|
4747
|
-
};
|
|
4748
|
-
let (decisions, errors) = prepare_native_tool_calls(
|
|
4749
|
-
vec![test_list_call(
|
|
4750
|
-
"fixed",
|
|
4751
|
-
serde_json::json!({"path": "Downloads/YOUTUBE_DL/playlist", "limit": 100}),
|
|
4752
|
-
)],
|
|
4753
|
-
"",
|
|
4754
|
-
&mut count,
|
|
4755
|
-
)
|
|
4756
|
-
.unwrap();
|
|
4757
|
-
assert!(errors.is_empty());
|
|
4758
|
-
let output = execute_tool(
|
|
4759
|
-
&root,
|
|
4760
|
-
&config,
|
|
4761
|
-
&decisions[0].1,
|
|
4762
|
-
"regression",
|
|
4763
|
-
&mut |_| panic!("listing must not request approval"),
|
|
4764
|
-
&mut |_| {},
|
|
4765
|
-
)
|
|
4766
|
-
.await
|
|
4767
|
-
.unwrap();
|
|
4768
|
-
let entries: Vec<Value> = serde_json::from_str(&output).unwrap();
|
|
4769
|
-
assert_eq!(entries.len(), 1);
|
|
4770
|
-
assert_eq!(
|
|
4771
|
-
entries[0]["path"],
|
|
4772
|
-
serde_json::json!(nested.join("song.txt"))
|
|
4773
|
-
);
|
|
4774
|
-
// Parsing the repaired call alone must not renew its budget.
|
|
4775
|
-
assert_eq!(count.failures["list_files"], 1);
|
|
4776
|
-
count.observe_completed(&[
|
|
4777
|
-
("fixed".into(), "list_files".into(), Value::Null, output).into()
|
|
4778
|
-
]);
|
|
4779
|
-
assert!(!count.failures.contains_key("list_files"));
|
|
4780
|
-
std::fs::remove_dir_all(root).unwrap();
|
|
4781
|
-
}
|
|
4782
|
-
|
|
4783
|
-
#[test]
|
|
4784
|
-
fn native_invalid_calls_are_bounded_without_blocking_valid_alternatives() {
|
|
4785
|
-
let mut count = ToolArgumentRetries::default();
|
|
4786
|
-
for _ in 0..MAX_TOOL_ARGUMENT_RETRIES {
|
|
4787
|
-
let (decisions, errors) = prepare_native_tool_calls(
|
|
4788
|
-
vec![test_list_call(
|
|
4789
|
-
"bad",
|
|
4790
|
-
serde_json::json!({"path": "~/Downloads/YOUTUBE_DL", "limit": "100"}),
|
|
4791
|
-
)],
|
|
4792
|
-
"",
|
|
4793
|
-
&mut count,
|
|
4794
|
-
)
|
|
4795
|
-
.unwrap();
|
|
4796
|
-
assert!(decisions.is_empty());
|
|
4797
|
-
assert_eq!(errors.len(), 1);
|
|
4798
|
-
}
|
|
4799
|
-
let alternative = crate::chat::ToolCall {
|
|
4800
|
-
id: "alternative".into(),
|
|
4801
|
-
name: "run_shell".into(),
|
|
4802
|
-
input: serde_json::json!({"command": "ls ~/Downloads/YOUTUBE_DL"}),
|
|
4803
|
-
thought_signature: None,
|
|
4804
|
-
};
|
|
4805
|
-
let (decisions, errors) =
|
|
4806
|
-
prepare_native_tool_calls(vec![alternative], "", &mut count).unwrap();
|
|
4807
|
-
assert_eq!(decisions[0].1.action, "run_shell");
|
|
4808
|
-
assert!(errors.is_empty());
|
|
4809
|
-
assert_eq!(count.failures["list_files"], MAX_TOOL_ARGUMENT_RETRIES);
|
|
4810
|
-
let error =
|
|
4811
|
-
prepare_native_tool_calls(vec![test_list_call("bad", Value::Null)], "", &mut count)
|
|
4812
|
-
.unwrap_err();
|
|
4813
|
-
assert!(
|
|
4814
|
-
error
|
|
4815
|
-
.to_string()
|
|
4816
|
-
.contains("exhausted its 5 argument-correction retries")
|
|
4817
|
-
);
|
|
4818
|
-
// One exhausted tool must not consume another tool's corrections.
|
|
4819
|
-
let other = crate::chat::ToolCall {
|
|
4820
|
-
id: "other-bad".into(),
|
|
4821
|
-
name: "read_file".into(),
|
|
4822
|
-
input: serde_json::json!({"path": 123}),
|
|
4823
|
-
thought_signature: None,
|
|
4824
|
-
};
|
|
4825
|
-
let (_, errors) = prepare_native_tool_calls(vec![other], "", &mut count).unwrap();
|
|
4826
|
-
assert_eq!(errors.len(), 1);
|
|
4827
|
-
assert_eq!(count.failures["read_file"], 1);
|
|
4828
|
-
for result in [
|
|
4829
|
-
"Error: execution failed",
|
|
4830
|
-
"Blocked by hook",
|
|
4831
|
-
"Skipped duplicate",
|
|
4832
|
-
] {
|
|
4833
|
-
count.observe_completed(&[(
|
|
4834
|
-
"x".into(),
|
|
4835
|
-
"list_files".into(),
|
|
4836
|
-
Value::Null,
|
|
4837
|
-
result.into(),
|
|
4838
|
-
)
|
|
4839
|
-
.into()]);
|
|
4840
|
-
assert_eq!(count.failures["list_files"], MAX_TOOL_ARGUMENT_RETRIES);
|
|
4841
|
-
}
|
|
4842
|
-
count.succeeded("list_files");
|
|
4843
|
-
assert_eq!(count.failures["read_file"], 1);
|
|
4844
|
-
assert!(!count.failures.contains_key("list_files"));
|
|
4845
|
-
assert!(
|
|
4846
|
-
prepare_native_tool_calls(vec![test_list_call("fresh", Value::Null)], "", &mut count)
|
|
4847
|
-
.is_ok()
|
|
4848
|
-
);
|
|
4849
|
-
}
|
|
4850
|
-
|
|
4851
|
-
#[test]
|
|
4852
|
-
fn native_mixed_batch_keeps_valid_calls_and_matches_each_error_to_its_id() {
|
|
4853
|
-
let mut count = ToolArgumentRetries::default();
|
|
4854
|
-
let (decisions, errors) = prepare_native_tool_calls(
|
|
4855
|
-
vec![
|
|
4856
|
-
test_list_call("bad", Value::Null),
|
|
4857
|
-
test_list_call("valid-1", serde_json::json!({"path": "."})),
|
|
4858
|
-
test_list_call("valid-2", serde_json::json!({"path": "src"})),
|
|
4859
|
-
],
|
|
4860
|
-
"",
|
|
4861
|
-
&mut count,
|
|
4862
|
-
)
|
|
4863
|
-
.unwrap();
|
|
4864
|
-
assert_eq!(decisions.len(), 2);
|
|
4865
|
-
assert!(decisions_are_parallel_read_only_batch(&decisions));
|
|
4866
|
-
assert_eq!(errors[0].call_id, "bad");
|
|
4867
|
-
let mut results = errors;
|
|
4868
|
-
results.extend(decisions.iter().map(|(id, d)| {
|
|
4869
|
-
(
|
|
4870
|
-
id.clone(),
|
|
4871
|
-
d.action.clone(),
|
|
4872
|
-
serde_json::to_value(&d.input).unwrap(),
|
|
4873
|
-
"[]".into(),
|
|
4874
|
-
)
|
|
4875
|
-
.into()
|
|
4876
|
-
}));
|
|
4877
|
-
let mut messages = Vec::new();
|
|
4878
|
-
append_native_tool_results(
|
|
4879
|
-
&mut messages,
|
|
4880
|
-
"",
|
|
4881
|
-
&results,
|
|
4882
|
-
&Default::default(),
|
|
4883
|
-
&Default::default(),
|
|
4884
|
-
);
|
|
4885
|
-
assert_eq!(messages[0].content.len(), 3);
|
|
4886
|
-
assert_eq!(messages[1].content.len(), 3);
|
|
4887
|
-
assert!(
|
|
4888
|
-
matches!(&messages[1].content[0], ContentBlock::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "bad")
|
|
4889
|
-
);
|
|
4890
|
-
assert!(
|
|
4891
|
-
matches!(&messages[1].content[1], ContentBlock::ToolResult { tool_use_id, is_error: false, .. } if tool_use_id == "valid-1")
|
|
4892
|
-
);
|
|
4893
|
-
}
|
|
4894
|
-
|
|
4895
|
-
#[tokio::test]
|
|
4896
|
-
async fn list_files_rejects_invalid_arguments_instead_of_listing_workspace() {
|
|
4897
|
-
let config = MintConfig::default();
|
|
4898
|
-
// A nonexistent root ensures no filesystem lookup occurs for bad input.
|
|
4899
|
-
let root = Path::new("/nonexistent-mint-list-files-regression");
|
|
4900
|
-
for input in [
|
|
4901
|
-
serde_json::json!({"path": "~/Downloads/YOUTUBE_DL", "limit": "100"}),
|
|
4902
|
-
serde_json::json!({"path": "~/Downloads/YOUTUBE_DL", "limit": -1}),
|
|
4903
|
-
serde_json::json!({"path": null}),
|
|
4904
|
-
Value::String(r#"{"path":"~/Downloads/YOUTUBE_DL""#.into()),
|
|
4905
|
-
Value::Null,
|
|
4906
|
-
] {
|
|
4907
|
-
let error = execute_tool_from_json(
|
|
4908
|
-
root,
|
|
4909
|
-
&config,
|
|
4910
|
-
"list_files",
|
|
4911
|
-
input,
|
|
4912
|
-
"regression",
|
|
4913
|
-
&mut |_| panic!("invalid arguments must not request approval"),
|
|
4914
|
-
)
|
|
4915
|
-
.await
|
|
4916
|
-
.unwrap_err();
|
|
4917
|
-
assert!(
|
|
4918
|
-
error
|
|
4919
|
-
.to_string()
|
|
4920
|
-
.contains("invalid arguments for tool 'list_files'")
|
|
4921
|
-
);
|
|
4922
|
-
}
|
|
4923
|
-
}
|
|
4924
|
-
|
|
4925
|
-
#[tokio::test]
|
|
4926
|
-
async fn list_files_preserves_nested_paths_and_default_directory() {
|
|
4927
|
-
let root =
|
|
4928
|
-
std::env::temp_dir().join(format!("mint-list-files-paths-{}", std::process::id()));
|
|
4929
|
-
let nested = root.join("Downloads/YOUTUBE_DL/playlist");
|
|
4930
|
-
std::fs::create_dir_all(&nested).unwrap();
|
|
4931
|
-
std::fs::write(nested.join("song.txt"), "song").unwrap();
|
|
4932
|
-
let config = MintConfig {
|
|
4933
|
-
allowed_read_paths: vec![root.clone()],
|
|
4934
|
-
blocked_paths: vec![],
|
|
4935
|
-
blocked_file_names: vec![],
|
|
4936
|
-
..MintConfig::default()
|
|
4937
|
-
};
|
|
4938
|
-
for input in [
|
|
4939
|
-
serde_json::json!({"path": "Downloads/YOUTUBE_DL/playlist", "limit": 100}),
|
|
4940
|
-
serde_json::json!({"path": nested}),
|
|
4941
|
-
serde_json::json!({}),
|
|
4942
|
-
] {
|
|
4943
|
-
let expected = if input.get("path").is_some() {
|
|
4944
|
-
&nested
|
|
4945
|
-
} else {
|
|
4946
|
-
&root
|
|
4947
|
-
};
|
|
4948
|
-
let output = execute_tool_from_json(
|
|
4949
|
-
&root,
|
|
4950
|
-
&config,
|
|
4951
|
-
"list_files",
|
|
4952
|
-
input,
|
|
4953
|
-
"regression",
|
|
4954
|
-
&mut |_| panic!("listing must not request approval"),
|
|
4955
|
-
)
|
|
4956
|
-
.await
|
|
4957
|
-
.unwrap();
|
|
4958
|
-
let entries: Vec<Value> = serde_json::from_str(&output).unwrap();
|
|
4959
|
-
assert_eq!(entries.len(), 1);
|
|
4960
|
-
assert_eq!(
|
|
4961
|
-
Path::new(entries[0]["path"].as_str().unwrap()).parent(),
|
|
4962
|
-
Some(expected.as_path())
|
|
4963
|
-
);
|
|
4964
|
-
}
|
|
4965
|
-
std::fs::remove_dir_all(root).unwrap();
|
|
4966
|
-
}
|
|
4967
|
-
|
|
4968
|
-
#[tokio::test]
|
|
4969
|
-
async fn turn_start_listener_reports_only_the_matching_chat() {
|
|
4970
|
-
let memory = MemoryStore::open(std::env::temp_dir().join(format!(
|
|
4971
|
-
"mint-turn-start-listener-{}.sqlite",
|
|
4972
|
-
uuid::Uuid::new_v4()
|
|
4973
|
-
)));
|
|
4974
|
-
let reported = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
4975
|
-
let callback_reported = reported.clone();
|
|
4976
|
-
with_turn_start_listener(
|
|
4977
|
-
"cli::one".to_string(),
|
|
4978
|
-
move |id| callback_reported.lock().unwrap().push(id),
|
|
4979
|
-
async {
|
|
4980
|
-
let first = TurnLease::start(&memory, "cli::one", "hello")
|
|
4981
|
-
.await
|
|
4982
|
-
.unwrap();
|
|
4983
|
-
let other = TurnLease::start(&memory, "cli::two", "other")
|
|
4984
|
-
.await
|
|
4985
|
-
.unwrap();
|
|
4986
|
-
assert_eq!(*reported.lock().unwrap(), vec![first.id]);
|
|
4987
|
-
drop(first);
|
|
4988
|
-
drop(other);
|
|
4989
|
-
},
|
|
4990
|
-
)
|
|
4991
|
-
.await;
|
|
4992
|
-
}
|
|
4993
|
-
|
|
4994
|
-
#[test]
|
|
4995
|
-
fn test_compact_agent_progress_cleans_deltas_and_preserves_tools() {
|
|
4996
|
-
let events = vec![
|
|
4997
|
-
AgentProgress::ToolStart {
|
|
4998
|
-
call_id: None,
|
|
4999
|
-
action: "read_file".into(),
|
|
5000
|
-
input: serde_json::json!({ "path": "src/main.rs" }),
|
|
5001
|
-
subagent: None,
|
|
5002
|
-
},
|
|
5003
|
-
AgentProgress::ThinkingDelta {
|
|
5004
|
-
id: "cot-1".into(),
|
|
5005
|
-
delta: "Reading ".into(),
|
|
5006
|
-
elapsed_ms: 100,
|
|
5007
|
-
},
|
|
5008
|
-
AgentProgress::ThinkingDelta {
|
|
5009
|
-
id: "cot-1".into(),
|
|
5010
|
-
delta: "the file...".into(),
|
|
5011
|
-
elapsed_ms: 200,
|
|
5012
|
-
},
|
|
5013
|
-
AgentProgress::ExtendedThinking {
|
|
5014
|
-
id: Some("cot-1".into()),
|
|
5015
|
-
thought: "Reading the file...".into(),
|
|
5016
|
-
elapsed_ms: Some(200),
|
|
5017
|
-
},
|
|
5018
|
-
AgentProgress::ToolEnd {
|
|
5019
|
-
elapsed_ms: None,
|
|
5020
|
-
call_id: None,
|
|
5021
|
-
status: None,
|
|
5022
|
-
action: "read_file".into(),
|
|
5023
|
-
input: serde_json::json!({ "path": "src/main.rs" }),
|
|
5024
|
-
result: "fn main() {}".into(),
|
|
5025
|
-
subagent: None,
|
|
5026
|
-
},
|
|
5027
|
-
AgentProgress::ThinkingDelta {
|
|
5028
|
-
id: "cot-interrupted".into(),
|
|
5029
|
-
delta: "Let's check ".into(),
|
|
5030
|
-
elapsed_ms: 300,
|
|
5031
|
-
},
|
|
5032
|
-
AgentProgress::ThinkingDelta {
|
|
5033
|
-
id: "cot-interrupted".into(),
|
|
5034
|
-
delta: "more lines".into(),
|
|
5035
|
-
elapsed_ms: 350,
|
|
5036
|
-
},
|
|
5037
|
-
];
|
|
5038
|
-
|
|
5039
|
-
let compacted = compact_agent_progress(&events);
|
|
5040
|
-
assert!(
|
|
5041
|
-
!compacted
|
|
5042
|
-
.iter()
|
|
5043
|
-
.any(|e| matches!(e, AgentProgress::ThinkingDelta { id, .. } if id == "cot-1"))
|
|
5044
|
-
);
|
|
5045
|
-
assert!(compacted.iter().any(
|
|
5046
|
-
|e| matches!(e, AgentProgress::ToolStart { action, .. } if action == "read_file")
|
|
5047
|
-
));
|
|
5048
|
-
assert!(
|
|
5049
|
-
compacted.iter().any(
|
|
5050
|
-
|e| matches!(e, AgentProgress::ToolEnd { action, .. } if action == "read_file")
|
|
5051
|
-
)
|
|
5052
|
-
);
|
|
5053
|
-
assert!(compacted.iter().any(|e| matches!(e, AgentProgress::ExtendedThinking { id: Some(id), thought, .. } if id == "cot-1" && thought == "Reading the file...")));
|
|
5054
|
-
assert!(compacted.iter().any(|e| matches!(e, AgentProgress::ExtendedThinking { id: Some(id), thought, .. } if id == "cot-interrupted" && thought == "Let's check more lines")));
|
|
5055
|
-
}
|
|
5056
|
-
|
|
5057
|
-
#[tokio::test]
|
|
5058
|
-
async fn test_turn_lease_persists_agent_activity_on_complete() {
|
|
5059
|
-
let memory = MemoryStore::open(std::env::temp_dir().join(format!(
|
|
5060
|
-
"mint-turn-lease-complete-{}.sqlite",
|
|
5061
|
-
uuid::Uuid::new_v4()
|
|
5062
|
-
)));
|
|
5063
|
-
let mut turn = TurnLease::start(&memory, "cli::test_complete", "hello")
|
|
5064
|
-
.await
|
|
5065
|
-
.unwrap();
|
|
5066
|
-
let turn_id = turn.id;
|
|
5067
|
-
turn.activity
|
|
5068
|
-
.lock()
|
|
5069
|
-
.unwrap()
|
|
5070
|
-
.push(AgentProgress::ToolStart {
|
|
5071
|
-
call_id: None,
|
|
5072
|
-
action: "run_shell".into(),
|
|
5073
|
-
input: serde_json::json!({ "command": "ls -la" }),
|
|
5074
|
-
subagent: None,
|
|
5075
|
-
});
|
|
5076
|
-
turn.activity.lock().unwrap().push(AgentProgress::ToolEnd {
|
|
5077
|
-
elapsed_ms: None,
|
|
5078
|
-
call_id: None,
|
|
5079
|
-
status: None,
|
|
5080
|
-
action: "run_shell".into(),
|
|
5081
|
-
input: serde_json::json!({ "command": "ls -la" }),
|
|
5082
|
-
result: "file.txt".into(),
|
|
5083
|
-
subagent: None,
|
|
5084
|
-
});
|
|
5085
|
-
|
|
5086
|
-
turn.complete_with_summary("Done listing files", "test_provider", "test_model", None)
|
|
5087
|
-
.unwrap();
|
|
5088
|
-
|
|
5089
|
-
let rows = memory.interactions_for_chat("cli::test_complete").unwrap();
|
|
5090
|
-
assert_eq!(rows.len(), 1);
|
|
5091
|
-
let row = &rows[0];
|
|
5092
|
-
assert_eq!(row.id, turn_id);
|
|
5093
|
-
assert_eq!(row.status, "completed");
|
|
5094
|
-
assert_eq!(row.ai_text, "Done listing files");
|
|
5095
|
-
assert!(row.agent_activity.is_some());
|
|
5096
|
-
let activity_val = row.agent_activity.as_ref().unwrap();
|
|
5097
|
-
let activity_arr = activity_val.as_array().unwrap();
|
|
5098
|
-
assert_eq!(activity_arr.len(), 2);
|
|
5099
|
-
assert_eq!(activity_arr[0]["type"], "ToolStart");
|
|
5100
|
-
assert_eq!(activity_arr[1]["type"], "ToolEnd");
|
|
5101
|
-
}
|
|
5102
|
-
|
|
5103
|
-
#[tokio::test]
|
|
5104
|
-
async fn test_turn_lease_persists_partial_activity_on_interrupt() {
|
|
5105
|
-
let memory = MemoryStore::open(std::env::temp_dir().join(format!(
|
|
5106
|
-
"mint-turn-lease-interrupt-{}.sqlite",
|
|
5107
|
-
uuid::Uuid::new_v4()
|
|
5108
|
-
)));
|
|
5109
|
-
let turn = TurnLease::start(&memory, "cli::test_interrupt", "run something")
|
|
5110
|
-
.await
|
|
5111
|
-
.unwrap();
|
|
5112
|
-
let turn_id = turn.id;
|
|
5113
|
-
turn.activity
|
|
5114
|
-
.lock()
|
|
5115
|
-
.unwrap()
|
|
5116
|
-
.push(AgentProgress::ToolStart {
|
|
5117
|
-
call_id: None,
|
|
5118
|
-
action: "read_file".into(),
|
|
5119
|
-
input: serde_json::json!({ "path": "test.txt" }),
|
|
5120
|
-
subagent: None,
|
|
5121
|
-
});
|
|
5122
|
-
drop(turn);
|
|
5123
|
-
|
|
5124
|
-
let rows = memory.interactions_for_chat("cli::test_interrupt").unwrap();
|
|
5125
|
-
assert_eq!(rows.len(), 1);
|
|
5126
|
-
let row = &rows[0];
|
|
5127
|
-
assert_eq!(row.id, turn_id);
|
|
5128
|
-
assert_eq!(row.status, "interrupted");
|
|
5129
|
-
assert!(row.agent_activity.is_some());
|
|
5130
|
-
let activity_val = row.agent_activity.as_ref().unwrap();
|
|
5131
|
-
let activity_arr = activity_val.as_array().unwrap();
|
|
5132
|
-
assert_eq!(activity_arr.len(), 1);
|
|
5133
|
-
assert_eq!(activity_arr[0]["type"], "ToolStart");
|
|
5134
|
-
}
|
|
5135
|
-
|
|
5136
|
-
#[test]
|
|
5137
|
-
fn extracts_a_summary_incrementally_without_matching_text_inside_other_strings() {
|
|
5138
|
-
let mut stream = FinishSummaryStream::default();
|
|
5139
|
-
let mut output = String::new();
|
|
5140
|
-
let mut emit = |delta: String| output.push_str(&delta);
|
|
5141
|
-
|
|
5142
|
-
stream.push_tool_call(0, Some("read_file"), None, None, &mut emit);
|
|
5143
|
-
stream.push_tool_call(
|
|
5144
|
-
0,
|
|
5145
|
-
None,
|
|
5146
|
-
Some(r#"{"thought":"the \"summary\" key is data","path":"x"}"#),
|
|
5147
|
-
None,
|
|
5148
|
-
&mut emit,
|
|
5149
|
-
);
|
|
5150
|
-
assert!(stream.emitted.is_empty());
|
|
5151
|
-
|
|
5152
|
-
stream.push_tool_call(1, Some("fin"), None, None, &mut emit);
|
|
5153
|
-
stream.push_tool_call(1, Some("finish"), None, None, &mut emit);
|
|
5154
|
-
stream.push_tool_call(1, None, Some(r#"{"summary":"สวัส"#), None, &mut emit);
|
|
5155
|
-
stream.push_tool_call(
|
|
5156
|
-
1,
|
|
5157
|
-
None,
|
|
5158
|
-
Some(r#"ดี\nโลก","verification":""}"#),
|
|
5159
|
-
None,
|
|
5160
|
-
&mut emit,
|
|
5161
|
-
);
|
|
5162
|
-
|
|
5163
|
-
assert_eq!(output, "สวัสดี\nโลก");
|
|
5164
|
-
}
|
|
5165
|
-
|
|
5166
|
-
#[test]
|
|
5167
|
-
fn extracts_json_prompt_summary_as_chunks_arrive() {
|
|
5168
|
-
let mut stream = FinishSummaryStream::default();
|
|
5169
|
-
let mut output = String::new();
|
|
5170
|
-
let mut emit = |delta: String| output.push_str(&delta);
|
|
5171
|
-
stream.push_json_prompt(
|
|
5172
|
-
r#"{"thought":"done","action":"finish","input":{"summary":"Hello"#,
|
|
5173
|
-
&mut emit,
|
|
5174
|
-
);
|
|
5175
|
-
assert_eq!(stream.emitted, "Hello");
|
|
5176
|
-
stream.push_json_prompt(r#" world"}}"#, &mut emit);
|
|
5177
|
-
assert_eq!(output, "Hello world");
|
|
5178
|
-
}
|
|
5179
|
-
|
|
5180
|
-
#[test]
|
|
5181
|
-
fn direct_native_text_is_not_sent_again_as_a_completed_summary() {
|
|
5182
|
-
assert_eq!(
|
|
5183
|
-
unstreamed_summary_remainder("A streamed answer.", "", "A streamed answer."),
|
|
5184
|
-
""
|
|
5185
|
-
);
|
|
5186
|
-
}
|
|
5187
|
-
|
|
5188
|
-
#[test]
|
|
5189
|
-
fn direct_native_text_appends_only_the_unstreamed_suffix() {
|
|
5190
|
-
assert_eq!(
|
|
5191
|
-
unstreamed_summary_remainder("A streamed answer, continued.", "", "A streamed answer"),
|
|
5192
|
-
", continued."
|
|
5193
|
-
);
|
|
5194
|
-
}
|
|
5195
|
-
|
|
5196
|
-
#[test]
|
|
5197
|
-
fn streamed_finish_summary_takes_precedence_over_direct_text() {
|
|
5198
|
-
assert_eq!(
|
|
5199
|
-
unstreamed_summary_remainder("Final answer.", "Final", "unrelated preamble"),
|
|
5200
|
-
" answer."
|
|
5201
|
-
);
|
|
5202
|
-
}
|
|
5203
|
-
|
|
5204
|
-
#[test]
|
|
5205
|
-
fn truncate_for_context_leaves_short_text_untouched() {
|
|
5206
|
-
assert_eq!(truncate_for_context("hello", 400), "hello");
|
|
5207
|
-
}
|
|
5208
|
-
|
|
5209
|
-
#[test]
|
|
5210
|
-
fn truncate_for_context_truncates_long_text_with_a_marker() {
|
|
5211
|
-
let long = "a".repeat(500);
|
|
5212
|
-
let result = truncate_for_context(&long, 400);
|
|
5213
|
-
assert_eq!(
|
|
5214
|
-
result.chars().count(),
|
|
5215
|
-
400 + "... [truncated]".chars().count()
|
|
5216
|
-
);
|
|
5217
|
-
assert!(result.starts_with(&"a".repeat(400)));
|
|
5218
|
-
assert!(result.ends_with("... [truncated]"));
|
|
5219
|
-
}
|
|
5220
|
-
|
|
5221
|
-
#[test]
|
|
5222
|
-
fn truncate_for_context_never_splits_a_multibyte_char() {
|
|
5223
|
-
// Thai text is multi-byte UTF-8; a byte-index slice here would panic
|
|
5224
|
-
// or produce invalid UTF-8 if it landed mid-character.
|
|
5225
|
-
let thai = "สวัสดี".repeat(200);
|
|
5226
|
-
let result = truncate_for_context(&thai, 5);
|
|
5227
|
-
assert_eq!(
|
|
5228
|
-
result.chars().count(),
|
|
5229
|
-
5 + "... [truncated]".chars().count()
|
|
5230
|
-
);
|
|
5231
|
-
}
|
|
5232
|
-
|
|
5233
|
-
#[test]
|
|
5234
|
-
fn slugify_lowercases_and_collapses_separators() {
|
|
5235
|
-
assert_eq!(
|
|
5236
|
-
slugify("Retry Flaky Playwright Tests!!"),
|
|
5237
|
-
"retry-flaky-playwright-tests"
|
|
5238
|
-
);
|
|
5239
|
-
assert_eq!(
|
|
5240
|
-
slugify(" leading/trailing --dashes-- "),
|
|
5241
|
-
"leading-trailing-dashes"
|
|
5242
|
-
);
|
|
5243
|
-
assert_eq!(slugify("already-a-slug"), "already-a-slug");
|
|
5244
|
-
assert_eq!(slugify("***"), "");
|
|
5245
|
-
}
|
|
5246
|
-
|
|
5247
|
-
#[test]
|
|
5248
|
-
fn skill_revision_defaults_to_zero_without_a_revisions_line() {
|
|
5249
|
-
assert_eq!(skill_revision("no frontmatter at all"), 0);
|
|
5250
|
-
assert_eq!(skill_revision("---\ndescription: a skill\n---\nbody"), 0);
|
|
5251
|
-
}
|
|
5252
|
-
|
|
5253
|
-
#[test]
|
|
5254
|
-
fn skill_revision_reads_back_what_set_skill_revision_wrote() {
|
|
5255
|
-
let original = "---\ndescription: a skill\n---\nstep-by-step body";
|
|
5256
|
-
let bumped = set_skill_revision(original, 1);
|
|
5257
|
-
assert_eq!(skill_revision(&bumped), 1);
|
|
5258
|
-
assert!(
|
|
5259
|
-
bumped.contains("description: a skill"),
|
|
5260
|
-
"must preserve existing frontmatter fields: {bumped}"
|
|
5261
|
-
);
|
|
5262
|
-
assert!(
|
|
5263
|
-
bumped.contains("step-by-step body"),
|
|
5264
|
-
"must preserve the body: {bumped}"
|
|
5265
|
-
);
|
|
5266
|
-
|
|
5267
|
-
// Refining again must replace the old count, not accumulate a
|
|
5268
|
-
// second `revisions:` line alongside it.
|
|
5269
|
-
let bumped_again = set_skill_revision(&bumped, 2);
|
|
5270
|
-
assert_eq!(skill_revision(&bumped_again), 2);
|
|
5271
|
-
assert_eq!(
|
|
5272
|
-
bumped_again.matches("revisions:").count(),
|
|
5273
|
-
1,
|
|
5274
|
-
"expected exactly one revisions line, got: {bumped_again}"
|
|
5275
|
-
);
|
|
5276
|
-
}
|
|
5277
|
-
|
|
5278
|
-
#[test]
|
|
5279
|
-
fn set_skill_revision_adds_a_frontmatter_block_when_content_has_none() {
|
|
5280
|
-
let result = set_skill_revision("just a body, no frontmatter", 1);
|
|
5281
|
-
assert_eq!(skill_revision(&result), 1);
|
|
5282
|
-
assert!(result.contains("just a body, no frontmatter"));
|
|
5283
|
-
}
|
|
5284
|
-
|
|
5285
|
-
#[test]
|
|
5286
|
-
fn existing_workspace_skill_bodies_reports_none_yet_for_a_fresh_workspace() {
|
|
5287
|
-
let root = std::env::temp_dir().join("mint-memory-skill-test-no-skills-yet");
|
|
5288
|
-
let _ = std::fs::remove_dir_all(&root);
|
|
5289
|
-
assert_eq!(existing_workspace_skill_bodies(&root), "(none yet)");
|
|
5290
|
-
}
|
|
5291
|
-
|
|
5292
|
-
#[test]
|
|
5293
|
-
fn existing_workspace_skill_bodies_includes_full_content_by_slug() {
|
|
5294
|
-
let root = std::env::temp_dir().join("mint-memory-skill-test-existing-bodies");
|
|
5295
|
-
let _ = std::fs::remove_dir_all(&root);
|
|
5296
|
-
let skill_dir = root
|
|
5297
|
-
.join(".agents")
|
|
5298
|
-
.join("skills")
|
|
5299
|
-
.join("retry-flaky-tests");
|
|
5300
|
-
std::fs::create_dir_all(&skill_dir).unwrap();
|
|
5301
|
-
std::fs::write(
|
|
5302
|
-
skill_dir.join("SKILL.md"),
|
|
5303
|
-
"---\ndescription: retry flaky tests\n---\nrun with --retries=3",
|
|
5304
|
-
)
|
|
5305
|
-
.unwrap();
|
|
5306
|
-
|
|
5307
|
-
let bodies = existing_workspace_skill_bodies(&root);
|
|
5308
|
-
assert!(bodies.contains("--- retry-flaky-tests ---"));
|
|
5309
|
-
assert!(bodies.contains("run with --retries=3"));
|
|
5310
|
-
|
|
5311
|
-
let _ = std::fs::remove_dir_all(&root);
|
|
5312
|
-
}
|
|
5313
|
-
|
|
5314
|
-
#[test]
|
|
5315
|
-
fn skill_worthy_requires_enough_steps_and_substantive_work() {
|
|
5316
|
-
let mut counts = BTreeMap::new();
|
|
5317
|
-
counts.insert("read_file:foo.rs".to_string(), 1);
|
|
5318
|
-
counts.insert("apply_patch:foo.rs".to_string(), 1);
|
|
5319
|
-
|
|
5320
|
-
// Too few steps, even with a substantive action.
|
|
5321
|
-
assert!(!looks_skill_worthy(2, &counts));
|
|
5322
|
-
// Enough steps, has a substantive action.
|
|
5323
|
-
assert!(looks_skill_worthy(3, &counts));
|
|
5324
|
-
|
|
5325
|
-
let mut read_only = BTreeMap::new();
|
|
5326
|
-
read_only.insert("read_file:foo.rs".to_string(), 5);
|
|
5327
|
-
read_only.insert("web_search::rust patterns".to_string(), 2);
|
|
5328
|
-
// Enough steps, but nothing substantive happened.
|
|
5329
|
-
assert!(!looks_skill_worthy(5, &read_only));
|
|
5330
|
-
}
|
|
5331
|
-
|
|
5332
|
-
#[test]
|
|
5333
|
-
fn shell_result_failed_reads_the_exit_line() {
|
|
5334
|
-
assert!(!shell_result_failed(
|
|
5335
|
-
"exit: 0\nmode: normal\nsandboxed: true\nstdout:\nok\nstderr:\n"
|
|
5336
|
-
));
|
|
5337
|
-
assert!(shell_result_failed(
|
|
5338
|
-
"exit: 1\nmode: normal\nsandboxed: true\nstdout:\nfail\nstderr:\n"
|
|
5339
|
-
));
|
|
5340
|
-
// A killed/unknown-status process isn't treated as a confirmed failure.
|
|
5341
|
-
assert!(shell_result_failed(
|
|
5342
|
-
"exit: unknown\nmode: normal\nsandboxed: true\nstdout:\nstderr:\n"
|
|
5343
|
-
));
|
|
5344
|
-
}
|
|
5345
|
-
|
|
5346
|
-
#[test]
|
|
5347
|
-
fn shell_result_failed_catches_a_later_command_failing_in_a_multi_command_verify() {
|
|
5348
|
-
// A multi-command `verify` joins each command's own `run_shell` block with
|
|
5349
|
-
// "\n\n" — the first command here passes, the second fails, and the scan
|
|
5350
|
-
// must not stop after the first "exit: " line it finds.
|
|
5351
|
-
let joined = "exit: 0\nmode: normal\nsandboxed: true\nstdout:\ncargo check ok\nstderr:\n\n\
|
|
5352
|
-
exit: 1\nmode: normal\nsandboxed: true\nstdout:\n\nstderr:\n2 tests failed";
|
|
5353
|
-
assert!(shell_result_failed(joined));
|
|
5354
|
-
}
|
|
5355
|
-
|
|
5356
|
-
#[test]
|
|
5357
|
-
fn unverified_modification_requires_a_verify_call_after_the_last_edit() {
|
|
5358
|
-
// No edit at all this run — nothing to verify.
|
|
5359
|
-
assert!(!unverified_modification(None, None, ""));
|
|
5360
|
-
// Edited, never verified, no explanation given.
|
|
5361
|
-
assert!(unverified_modification(Some(2), None, ""));
|
|
5362
|
-
// Edited, verified *before* the edit (stale) — still unverified.
|
|
5363
|
-
assert!(unverified_modification(Some(2), Some(1), ""));
|
|
5364
|
-
// Edited, verified after — satisfied regardless of what verify found.
|
|
5365
|
-
assert!(!unverified_modification(Some(2), Some(3), ""));
|
|
5366
|
-
// An explanation cannot promote an unchecked edit to verified.
|
|
5367
|
-
assert!(unverified_modification(
|
|
5368
|
-
Some(2),
|
|
5369
|
-
None,
|
|
5370
|
-
"Docs-only change, no test suite."
|
|
5371
|
-
));
|
|
5372
|
-
}
|
|
5373
|
-
|
|
5374
|
-
#[test]
|
|
5375
|
-
fn unacknowledged_verify_failure_requires_the_agent_to_address_a_real_failure() {
|
|
5376
|
-
// No verify ran yet, or it passed — nothing to block on.
|
|
5377
|
-
assert!(!unacknowledged_verify_failure(None, ""));
|
|
5378
|
-
assert!(!unacknowledged_verify_failure(Some(false), ""));
|
|
5379
|
-
// Verify failed and the agent said nothing about it — this is the
|
|
5380
|
-
// "claims success while a check is actually failing" case.
|
|
5381
|
-
assert!(unacknowledged_verify_failure(Some(true), ""));
|
|
5382
|
-
assert!(unacknowledged_verify_failure(Some(true), "n/a"));
|
|
5383
|
-
// Even an explanation of pre-existing failures does not make them pass.
|
|
5384
|
-
assert!(unacknowledged_verify_failure(
|
|
5385
|
-
Some(true),
|
|
5386
|
-
"2 pre-existing test failures unrelated to this change; see notes."
|
|
5387
|
-
));
|
|
5388
|
-
}
|
|
5389
|
-
|
|
5390
|
-
#[test]
|
|
5391
|
-
fn preserves_request_without_history() {
|
|
5392
|
-
let store = MemoryStore::open(
|
|
5393
|
-
std::env::temp_dir().join(format!("mint-orchestrator-{}.sqlite", std::process::id())),
|
|
5394
|
-
);
|
|
5395
|
-
let request = ChatRequest {
|
|
5396
|
-
message: "hello".into(),
|
|
5397
|
-
system_instruction: "system".into(),
|
|
5398
|
-
chat_id: None,
|
|
5399
|
-
image_data_uri: None,
|
|
5400
|
-
audio_data_uri: None,
|
|
5401
|
-
video_data_uri: None,
|
|
5402
|
-
document_attachment: None,
|
|
5403
|
-
workspace_path: None,
|
|
5404
|
-
agent_id: None,
|
|
5405
|
-
plan_mode: false,
|
|
5406
|
-
pinned_mcp_server: None,
|
|
5407
|
-
messages: None,
|
|
5408
|
-
tools: None,
|
|
5409
|
-
temperature: None,
|
|
5410
|
-
..Default::default()
|
|
5411
|
-
};
|
|
5412
|
-
let config = MintConfig::default();
|
|
5413
|
-
assert!(
|
|
5414
|
-
enrich_request(&config, &store, &request)
|
|
5415
|
-
.unwrap()
|
|
5416
|
-
.system_instruction
|
|
5417
|
-
.starts_with("system")
|
|
5418
|
-
);
|
|
5419
|
-
}
|
|
5420
|
-
|
|
5421
|
-
#[test]
|
|
5422
|
-
fn agent_decision_allows_null_input() {
|
|
5423
|
-
let decision = parse_decision(r#"{"thought":"done","action":"finish","input":null}"#)
|
|
5424
|
-
.expect("null input should parse as default input");
|
|
5425
|
-
|
|
5426
|
-
assert_eq!(decision.action, "finish");
|
|
5427
|
-
assert!(decision.input.summary.is_empty());
|
|
5428
|
-
}
|
|
5429
|
-
|
|
5430
|
-
#[test]
|
|
5431
|
-
fn agent_decision_allows_missing_input() {
|
|
5432
|
-
let decision = parse_decision(r#"{"thought":"done","action":"finish"}"#)
|
|
5433
|
-
.expect("missing input should parse as default input");
|
|
5434
|
-
|
|
5435
|
-
assert_eq!(decision.action, "finish");
|
|
5436
|
-
assert!(decision.input.summary.is_empty());
|
|
5437
|
-
}
|
|
5438
|
-
|
|
5439
|
-
#[test]
|
|
5440
|
-
fn shorthand_finish_allows_null_or_missing_finish() {
|
|
5441
|
-
let decision = parse_decision(r#"{"thought":"done","finish":null}"#)
|
|
5442
|
-
.expect("null finish should parse");
|
|
5443
|
-
assert_eq!(decision.action, "finish");
|
|
5444
|
-
assert!(decision.input.summary.is_empty());
|
|
5445
|
-
|
|
5446
|
-
let decision =
|
|
5447
|
-
parse_decision(r#"{"thought":"done"}"#).expect("missing finish should parse");
|
|
5448
|
-
assert_eq!(decision.action, "finish");
|
|
5449
|
-
assert!(decision.input.summary.is_empty());
|
|
5450
|
-
}
|
|
5451
|
-
|
|
5452
|
-
#[test]
|
|
5453
|
-
fn shorthand_finish_allows_string_finish_as_summary() {
|
|
5454
|
-
let decision = parse_decision(r#"{"thought":"done","finish":"all done!"}"#)
|
|
5455
|
-
.expect("string finish should parse as summary");
|
|
5456
|
-
assert_eq!(decision.action, "finish");
|
|
5457
|
-
assert_eq!(decision.input.summary, "all done!");
|
|
5458
|
-
}
|
|
5459
|
-
|
|
5460
|
-
#[test]
|
|
5461
|
-
fn write_file_policy_rejects_existing_workspace_file() {
|
|
5462
|
-
let root =
|
|
5463
|
-
std::env::temp_dir().join(format!("mint-write-file-policy-{}", std::process::id()));
|
|
5464
|
-
std::fs::create_dir_all(&root).unwrap();
|
|
5465
|
-
let target = root.join("existing.txt");
|
|
5466
|
-
std::fs::write(&target, "already here").unwrap();
|
|
5467
|
-
let config = MintConfig {
|
|
5468
|
-
allowed_read_paths: vec![root.clone()],
|
|
5469
|
-
allowed_write_paths: vec![root.clone()],
|
|
5470
|
-
blocked_paths: vec![],
|
|
5471
|
-
blocked_file_names: vec![],
|
|
5472
|
-
..MintConfig::default()
|
|
5473
|
-
};
|
|
5474
|
-
|
|
5475
|
-
let result = validate_new_workspace_file(&root, &config, Path::new("existing.txt"));
|
|
5476
|
-
|
|
5477
|
-
assert!(
|
|
5478
|
-
matches!(result, Err(OrchestrationError::Agent(message)) if message.contains("Use apply_patch"))
|
|
5479
|
-
);
|
|
5480
|
-
let _ = std::fs::remove_file(target);
|
|
5481
|
-
let _ = std::fs::remove_dir(root);
|
|
5482
|
-
}
|
|
5483
|
-
|
|
5484
|
-
#[test]
|
|
5485
|
-
fn agent_list_files_includes_directories() {
|
|
5486
|
-
let root = std::env::temp_dir().join(format!(
|
|
5487
|
-
"mint-agent-list-directories-{}",
|
|
5488
|
-
std::process::id()
|
|
5489
|
-
));
|
|
5490
|
-
std::fs::create_dir_all(root.join("Bunny Girl")).unwrap();
|
|
5491
|
-
std::fs::write(root.join("note.txt"), "hello").unwrap();
|
|
5492
|
-
let config = MintConfig {
|
|
5493
|
-
allowed_read_paths: vec![root.clone()],
|
|
5494
|
-
allowed_write_paths: vec![root.clone()],
|
|
5495
|
-
blocked_paths: vec![],
|
|
5496
|
-
blocked_file_names: vec![],
|
|
5497
|
-
..MintConfig::default()
|
|
5498
|
-
};
|
|
5499
|
-
|
|
5500
|
-
let entries = list_directory_entries(&root, 100, &config).unwrap();
|
|
5501
|
-
|
|
5502
|
-
assert!(entries.iter().any(|entry| {
|
|
5503
|
-
entry.name == "Bunny Girl" && entry.kind == "directory" && entry.size.is_none()
|
|
5504
|
-
}));
|
|
5505
|
-
assert!(entries.iter().any(|entry| {
|
|
5506
|
-
entry.name == "note.txt" && entry.kind == "file" && entry.size == Some(5)
|
|
5507
|
-
}));
|
|
5508
|
-
let _ = std::fs::remove_file(root.join("note.txt"));
|
|
5509
|
-
let _ = std::fs::remove_dir(root.join("Bunny Girl"));
|
|
5510
|
-
let _ = std::fs::remove_dir(root);
|
|
5511
|
-
}
|
|
5512
|
-
|
|
5513
|
-
#[test]
|
|
5514
|
-
fn grep_is_classified_as_read_only() {
|
|
5515
|
-
let classification = classify_shell_command("ls ~/Downloads | grep -F \"Bunny Girl\"");
|
|
5516
|
-
|
|
5517
|
-
assert_eq!(classification.mode.as_str(), "readOnly");
|
|
5518
|
-
}
|
|
5519
|
-
|
|
5520
|
-
#[test]
|
|
5521
|
-
fn test_resolve_agent_config() {
|
|
5522
|
-
let mut config = MintConfig::default();
|
|
5523
|
-
config.enable_agent_collaboration = true;
|
|
5524
|
-
config.agents = vec![
|
|
5525
|
-
AgentConfig {
|
|
5526
|
-
id: "planner".into(),
|
|
5527
|
-
name: "Planner".into(),
|
|
5528
|
-
provider: "openai".into(),
|
|
5529
|
-
model: "gpt-4o".into(),
|
|
5530
|
-
api_key: Some("test-key".into()),
|
|
5531
|
-
system_instruction: "Planner instruction".into(),
|
|
5532
|
-
enabled: true,
|
|
5533
|
-
},
|
|
5534
|
-
AgentConfig {
|
|
5535
|
-
id: "coder".into(),
|
|
5536
|
-
name: "Coder".into(),
|
|
5537
|
-
provider: "gemini".into(),
|
|
5538
|
-
model: "gemini-2.5-flash".into(),
|
|
5539
|
-
api_key: None,
|
|
5540
|
-
system_instruction: "Coder instruction".into(),
|
|
5541
|
-
enabled: true,
|
|
5542
|
-
},
|
|
5543
|
-
];
|
|
5544
|
-
|
|
5545
|
-
let (cfg, instr, name, model) = resolve_agent_config(&config, Some("planner"), &[]);
|
|
5546
|
-
assert_eq!(cfg.ai_provider, "openai");
|
|
5547
|
-
assert_eq!(cfg.openai_model, "gpt-4o");
|
|
5548
|
-
assert_eq!(cfg.openai_api_key, "test-key");
|
|
5549
|
-
assert_eq!(instr, "Planner instruction");
|
|
5550
|
-
assert_eq!(name, Some("Planner".to_string()));
|
|
5551
|
-
assert_eq!(model, Some("gpt-4o".to_string()));
|
|
5552
|
-
|
|
5553
|
-
let (cfg, instr, name, _model) = resolve_agent_config(&config, None, &[]);
|
|
5554
|
-
assert_eq!(cfg.ai_provider, "openai");
|
|
5555
|
-
assert_eq!(instr, "Planner instruction");
|
|
5556
|
-
assert_eq!(name, Some("Planner".to_string()));
|
|
5557
|
-
|
|
5558
|
-
let trajectory =
|
|
5559
|
-
vec!["Step 1:\n- Action: create_plan\n- Observation: all planned".to_string()];
|
|
5560
|
-
let (cfg, instr, name, model) = resolve_agent_config(&config, None, &trajectory);
|
|
5561
|
-
assert_eq!(cfg.ai_provider, "gemini");
|
|
5562
|
-
assert_eq!(instr, "Coder instruction");
|
|
5563
|
-
assert_eq!(name, Some("Coder".to_string()));
|
|
5564
|
-
assert_eq!(model, Some("gemini-2.5-flash".to_string()));
|
|
5565
|
-
}
|
|
5566
|
-
|
|
5567
|
-
fn step_pair(index: usize) -> [ChatMessage; 2] {
|
|
5568
|
-
[
|
|
5569
|
-
ChatMessage {
|
|
5570
|
-
role: ChatRole::Assistant,
|
|
5571
|
-
content: vec![ContentBlock::ToolUse {
|
|
5572
|
-
id: format!("call_{index}"),
|
|
5573
|
-
name: "read_file".into(),
|
|
5574
|
-
input: serde_json::json!({ "path": format!("file{index}.rs") }),
|
|
5575
|
-
thought_signature: None,
|
|
5576
|
-
}],
|
|
5577
|
-
},
|
|
5578
|
-
ChatMessage {
|
|
5579
|
-
role: ChatRole::Tool,
|
|
5580
|
-
content: vec![ContentBlock::ToolResult {
|
|
5581
|
-
tool_use_id: format!("call_{index}"),
|
|
5582
|
-
content: format!("contents of file{index}.rs"),
|
|
5583
|
-
is_error: false,
|
|
5584
|
-
}],
|
|
5585
|
-
},
|
|
5586
|
-
]
|
|
5587
|
-
}
|
|
5588
|
-
|
|
5589
|
-
fn native_messages_with_steps(step_count: usize) -> Vec<ChatMessage> {
|
|
5590
|
-
let mut messages = vec![ChatMessage::text(ChatRole::User, "do the task")];
|
|
5591
|
-
for i in 0..step_count {
|
|
5592
|
-
messages.extend(step_pair(i));
|
|
5593
|
-
}
|
|
5594
|
-
messages
|
|
5595
|
-
}
|
|
5596
|
-
|
|
5597
|
-
#[tokio::test]
|
|
5598
|
-
async fn compact_native_messages_is_a_noop_when_history_is_short() {
|
|
5599
|
-
// COMPACTION_KEEP_RECENT_STEPS = 3, so exactly 3 step-pairs is nothing to compact yet.
|
|
5600
|
-
let messages = native_messages_with_steps(3);
|
|
5601
|
-
let config = MintConfig::default();
|
|
5602
|
-
let result = compact_native_messages(&config, &messages).await.unwrap();
|
|
5603
|
-
assert!(result.is_none());
|
|
5604
|
-
}
|
|
5605
|
-
|
|
5606
|
-
#[tokio::test]
|
|
5607
|
-
async fn compact_native_messages_is_a_noop_for_empty_history() {
|
|
5608
|
-
let config = MintConfig::default();
|
|
5609
|
-
let result = compact_native_messages(&config, &[]).await.unwrap();
|
|
5610
|
-
assert!(result.is_none());
|
|
5611
|
-
}
|
|
5612
|
-
|
|
5613
|
-
#[test]
|
|
5614
|
-
fn render_messages_as_text_includes_tool_calls_and_results() {
|
|
5615
|
-
let messages = native_messages_with_steps(1);
|
|
5616
|
-
let rendered = render_messages_as_text(&messages);
|
|
5617
|
-
assert!(rendered.contains("Called read_file with"));
|
|
5618
|
-
assert!(rendered.contains("Result: contents of file0.rs"));
|
|
5619
|
-
assert!(rendered.contains("do the task"));
|
|
5620
|
-
}
|
|
5621
|
-
|
|
5622
|
-
#[test]
|
|
5623
|
-
fn no_modification_means_finish_never_needs_verification() {
|
|
5624
|
-
// A pure Q&A run that never touched apply_patch/write_file can finish
|
|
5625
|
-
// immediately, regardless of what (if anything) is in `verification`.
|
|
5626
|
-
assert!(!unverified_modification(None, None, ""));
|
|
5627
|
-
assert!(!unverified_modification(None, Some(1), ""));
|
|
5628
|
-
}
|
|
5629
|
-
|
|
5630
|
-
#[test]
|
|
5631
|
-
fn modification_without_any_verify_call_blocks_finish() {
|
|
5632
|
-
assert!(unverified_modification(Some(2), None, ""));
|
|
5633
|
-
}
|
|
5634
|
-
|
|
5635
|
-
#[test]
|
|
5636
|
-
fn verify_called_before_the_modification_does_not_count() {
|
|
5637
|
-
// Editing again after the last verify invalidates that earlier check.
|
|
5638
|
-
assert!(unverified_modification(Some(3), Some(1), ""));
|
|
5639
|
-
}
|
|
5640
|
-
|
|
5641
|
-
#[test]
|
|
5642
|
-
fn verify_called_after_the_modification_satisfies_the_gate() {
|
|
5643
|
-
assert!(!unverified_modification(Some(2), Some(3), ""));
|
|
5644
|
-
}
|
|
5645
|
-
|
|
5646
|
-
#[test]
|
|
5647
|
-
fn verify_on_the_same_step_as_the_modification_satisfies_the_gate() {
|
|
5648
|
-
// Both markers can land on the same step if a single turn issues
|
|
5649
|
-
// multiple tool calls (apply_patch then verify back to back).
|
|
5650
|
-
assert!(!unverified_modification(Some(2), Some(2), ""));
|
|
5651
|
-
}
|
|
5652
|
-
|
|
5653
|
-
#[test]
|
|
5654
|
-
fn explicit_written_justification_does_not_verify_an_unchecked_edit() {
|
|
5655
|
-
assert!(unverified_modification(
|
|
5656
|
-
Some(2),
|
|
5657
|
-
None,
|
|
5658
|
-
"Documentation-only change; no test suite in this repo."
|
|
5659
|
-
));
|
|
5660
|
-
}
|
|
5661
|
-
|
|
5662
|
-
#[test]
|
|
5663
|
-
fn placeholder_verification_text_does_not_satisfy_the_gate() {
|
|
5664
|
-
// Mirrors `meaningful_verification`'s own placeholder filter — "n/a" etc.
|
|
5665
|
-
// must not be treated as a real justification.
|
|
5666
|
-
assert!(unverified_modification(Some(2), None, "n/a"));
|
|
5667
|
-
assert!(unverified_modification(Some(2), None, "not run"));
|
|
5668
|
-
}
|
|
5669
|
-
|
|
5670
|
-
fn decision_with_action(action: &str) -> (String, AgentDecision) {
|
|
5671
|
-
(
|
|
5672
|
-
"call_0".to_string(),
|
|
5673
|
-
AgentDecision {
|
|
5674
|
-
thought: String::new(),
|
|
5675
|
-
action: action.to_string(),
|
|
5676
|
-
input: AgentInput::default(),
|
|
5677
|
-
},
|
|
5678
|
-
)
|
|
5679
|
-
}
|
|
5680
|
-
|
|
5681
|
-
#[test]
|
|
5682
|
-
fn empty_decisions_never_qualify_for_the_parallel_batch() {
|
|
5683
|
-
assert!(!decisions_are_parallel_read_only_batch(&[]));
|
|
5684
|
-
}
|
|
5685
|
-
|
|
5686
|
-
#[test]
|
|
5687
|
-
fn two_or_more_read_only_decisions_qualify_for_the_parallel_batch() {
|
|
5688
|
-
let decisions = vec![
|
|
5689
|
-
decision_with_action("read_file"),
|
|
5690
|
-
decision_with_action("search_code"),
|
|
5691
|
-
];
|
|
5692
|
-
assert!(decisions_are_parallel_read_only_batch(&decisions));
|
|
5693
|
-
|
|
5694
|
-
let decisions = vec![
|
|
5695
|
-
decision_with_action("read_file"),
|
|
5696
|
-
decision_with_action("web_search"),
|
|
5697
|
-
decision_with_action("git_diff"),
|
|
5698
|
-
];
|
|
5699
|
-
assert!(decisions_are_parallel_read_only_batch(&decisions));
|
|
5700
|
-
}
|
|
5701
|
-
|
|
5702
|
-
#[test]
|
|
5703
|
-
fn a_lone_read_only_decision_stays_sequential() {
|
|
5704
|
-
let decisions = vec![decision_with_action("read_file")];
|
|
5705
|
-
assert!(!decisions_are_parallel_read_only_batch(&decisions));
|
|
5706
|
-
}
|
|
5707
|
-
|
|
5708
|
-
#[test]
|
|
5709
|
-
fn read_only_mixed_with_mutating_action_stays_sequential() {
|
|
5710
|
-
let decisions = vec![
|
|
5711
|
-
decision_with_action("read_file"),
|
|
5712
|
-
decision_with_action("write_file"),
|
|
5713
|
-
];
|
|
5714
|
-
assert!(!decisions_are_parallel_read_only_batch(&decisions));
|
|
5715
|
-
|
|
5716
|
-
let decisions = vec![
|
|
5717
|
-
decision_with_action("read_file"),
|
|
5718
|
-
decision_with_action("run_shell"),
|
|
5719
|
-
];
|
|
5720
|
-
assert!(!decisions_are_parallel_read_only_batch(&decisions));
|
|
5721
|
-
}
|
|
5722
|
-
|
|
5723
|
-
#[test]
|
|
5724
|
-
fn rebuild_observation_reuses_buffer_and_formats_history() {
|
|
5725
|
-
let root = Path::new("/workspace/test-project");
|
|
5726
|
-
let task = "Fix the login bug";
|
|
5727
|
-
let step1 = format_trajectory_step(1, "Inspecting files", "list_files", "src/main.rs");
|
|
5728
|
-
let step2 = format_trajectory_step(2, "Reading code", "read_file", "fn main() {}");
|
|
5729
|
-
let trajectory = vec![step1, step2];
|
|
5730
|
-
|
|
5731
|
-
let mut buffer = String::new();
|
|
5732
|
-
rebuild_observation(task, root, &trajectory, &mut buffer);
|
|
5733
|
-
|
|
5734
|
-
assert!(buffer.contains("Task: Fix the login bug"));
|
|
5735
|
-
assert!(buffer.contains("Workspace: /workspace/test-project"));
|
|
5736
|
-
assert!(buffer.contains("Step 1:\n- Thought: Inspecting files"));
|
|
5737
|
-
assert!(buffer.contains("Step 2:\n- Thought: Reading code"));
|
|
5738
|
-
assert!(buffer.ends_with(
|
|
5739
|
-
"Proceed to the next step. If you have completed the task, use the 'finish' action."
|
|
5740
|
-
));
|
|
5741
|
-
|
|
5742
|
-
// Ensure buffer can be reused cleanly for subsequent steps
|
|
5743
|
-
let step3 = format_trajectory_step(3, "All done", "finish", "Fixed");
|
|
5744
|
-
let mut extended_trajectory = trajectory;
|
|
5745
|
-
extended_trajectory.push(step3);
|
|
5746
|
-
rebuild_observation(task, root, &extended_trajectory, &mut buffer);
|
|
5747
|
-
|
|
5748
|
-
assert!(buffer.contains("Step 3:\n- Thought: All done"));
|
|
5749
|
-
}
|
|
5750
|
-
}
|
|
5751
|
-
|
|
5752
|
-
#[cfg(test)]
|
|
5753
|
-
mod full_skill_result_tests {
|
|
5754
|
-
use super::*;
|
|
5755
|
-
#[test]
|
|
5756
|
-
fn large_loaded_skill_results_reach_the_model_without_losing_middle_instructions() {
|
|
5757
|
-
let content = format!(
|
|
5758
|
-
"[Loaded skill: review]\n{}\nREQUIRED-MIDDLE-INSTRUCTION\n{}",
|
|
5759
|
-
"a".repeat(40_000),
|
|
5760
|
-
"z".repeat(40_000)
|
|
5761
|
-
);
|
|
5762
|
-
assert_eq!(model_tool_result("read_file", &content), content);
|
|
5763
|
-
assert!(model_tool_result("run_shell", &"x".repeat(80_000)).len() < 20_000);
|
|
5764
|
-
}
|
|
5765
|
-
}
|
|
2702
|
+
#[cfg(test)]
|
|
2703
|
+
mod full_skill_result_tests;
|
|
5766
2704
|
|
|
5767
2705
|
#[cfg(all(test, unix))]
|
|
5768
|
-
mod mcp_outcome_tests
|
|
5769
|
-
use super::*;
|
|
5770
|
-
use std::time::Duration;
|
|
5771
|
-
fn generic_server(tool: &str, response: Value) -> String {
|
|
5772
|
-
format!(
|
|
5773
|
-
r#"
|
|
5774
|
-
import sys,json
|
|
5775
|
-
response=json.loads({:?})
|
|
5776
|
-
for line in sys.stdin:
|
|
5777
|
-
r=json.loads(line);m=r.get('method')
|
|
5778
|
-
if m=='initialize': result={{}}
|
|
5779
|
-
elif m=='tools/list': result={{'tools':[{{'name':{:?},'inputSchema':{{'type':'object'}}}}]}}
|
|
5780
|
-
elif m=='tools/call': result=response
|
|
5781
|
-
else: continue
|
|
5782
|
-
print(json.dumps({{'jsonrpc':'2.0','id':r['id'],'result':result}}),flush=True)
|
|
5783
|
-
"#,
|
|
5784
|
-
response.to_string(),
|
|
5785
|
-
tool
|
|
5786
|
-
)
|
|
5787
|
-
}
|
|
5788
|
-
|
|
5789
|
-
#[test]
|
|
5790
|
-
fn compact_device_progress_keeps_acceptance_and_latest_observation() {
|
|
5791
|
-
let events=["accepted","running","running","verified"].into_iter().enumerate().map(|(seq,state)|serde_json::from_value(serde_json::json!({"type":"DeviceState","data":{"callId":"one","server":"motor","tool":"move","update":{"state":state,"target":{"rpm":100},"actual":{"rpm":seq},"observationSeq":seq,"elapsedSecs":seq,"message":"observed"}}})).unwrap()).collect::<Vec<AgentProgress>>();
|
|
5792
|
-
let compacted = compact_agent_progress(&events);
|
|
5793
|
-
assert_eq!(
|
|
5794
|
-
compacted.len(),
|
|
5795
|
-
2,
|
|
5796
|
-
"saved history should retain acceptance and the final fresh observation"
|
|
5797
|
-
);
|
|
5798
|
-
assert!(matches!(
|
|
5799
|
-
&compacted[1],
|
|
5800
|
-
AgentProgress::DeviceState {
|
|
5801
|
-
update: crate::mcp::device::DeviceUpdate {
|
|
5802
|
-
state: crate::mcp::device::DevicePhase::Verified,
|
|
5803
|
-
..
|
|
5804
|
-
},
|
|
5805
|
-
..
|
|
5806
|
-
}
|
|
5807
|
-
));
|
|
5808
|
-
}
|
|
5809
|
-
|
|
5810
|
-
#[tokio::test]
|
|
5811
|
-
async fn mcp_definitions_reach_native_and_json_models_before_any_command() {
|
|
5812
|
-
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
5813
|
-
for native in [true, false] {
|
|
5814
|
-
let marker =
|
|
5815
|
-
std::env::temp_dir().join(format!("mint-model-command-{}", uuid::Uuid::new_v4()));
|
|
5816
|
-
let server = format!("model-mcp-{}", uuid::Uuid::new_v4());
|
|
5817
|
-
let mut cfg = MintConfig::default();
|
|
5818
|
-
let script=generic_server("move",serde_json::json!({"content":[{"type":"text","text":"moved"}]})).replace("elif m=='tools/call': result=response",&format!("elif m=='tools/call':\n open({:?},'a').write('called\\n')\n result=response",marker.to_string_lossy())).replace("'name':\"move\",'inputSchema'","'name':\"move\",'description':'Move the mock axis. RPM are integer units.', 'inputSchema'");
|
|
5819
|
-
cfg.extra.insert("mcpServers".into(),serde_json::json!({server.clone():{"command":"python3","args":["-u","-c",script],"timeoutSecs":5}}));
|
|
5820
|
-
cfg.extra.insert(
|
|
5821
|
-
"allowedMcpTools".into(),
|
|
5822
|
-
serde_json::json!({server.clone():["*"]}),
|
|
5823
|
-
);
|
|
5824
|
-
cfg.ai_provider = "ollama".into();
|
|
5825
|
-
cfg.ollama_model = "qwen2.5".into();
|
|
5826
|
-
cfg.extra
|
|
5827
|
-
.insert("forceJsonPromptMode".into(), serde_json::json!(!native));
|
|
5828
|
-
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
5829
|
-
cfg.ollama_host = format!("http://{}", listener.local_addr().unwrap());
|
|
5830
|
-
let recorded = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
|
|
5831
|
-
let requests = Arc::clone(&recorded);
|
|
5832
|
-
let observed_marker = marker.clone();
|
|
5833
|
-
let name = server.clone();
|
|
5834
|
-
let provider = tokio::spawn(async move {
|
|
5835
|
-
for step in 0..3 {
|
|
5836
|
-
let (mut socket, _) = listener.accept().await.unwrap();
|
|
5837
|
-
let mut bytes = Vec::new();
|
|
5838
|
-
let mut chunk = [0u8; 4096];
|
|
5839
|
-
let (end, len) = loop {
|
|
5840
|
-
let n = socket.read(&mut chunk).await.unwrap();
|
|
5841
|
-
assert!(n > 0);
|
|
5842
|
-
bytes.extend_from_slice(&chunk[..n]);
|
|
5843
|
-
if let Some(end) = bytes.windows(4).position(|w| w == b"\r\n\r\n") {
|
|
5844
|
-
let header = String::from_utf8_lossy(&bytes[..end]);
|
|
5845
|
-
let len = header
|
|
5846
|
-
.lines()
|
|
5847
|
-
.find_map(|l| {
|
|
5848
|
-
l.to_ascii_lowercase()
|
|
5849
|
-
.strip_prefix("content-length:")
|
|
5850
|
-
.and_then(|v| v.trim().parse::<usize>().ok())
|
|
5851
|
-
})
|
|
5852
|
-
.unwrap();
|
|
5853
|
-
break (end + 4, len);
|
|
5854
|
-
}
|
|
5855
|
-
};
|
|
5856
|
-
while bytes.len() < end + len {
|
|
5857
|
-
let n = socket.read(&mut chunk).await.unwrap();
|
|
5858
|
-
assert!(n > 0);
|
|
5859
|
-
bytes.extend_from_slice(&chunk[..n]);
|
|
5860
|
-
}
|
|
5861
|
-
requests
|
|
5862
|
-
.lock()
|
|
5863
|
-
.unwrap()
|
|
5864
|
-
.push(serde_json::from_slice(&bytes[end..end + len]).unwrap());
|
|
5865
|
-
if step == 1 {
|
|
5866
|
-
assert!(
|
|
5867
|
-
!observed_marker.exists(),
|
|
5868
|
-
"initial preparation sent a tools/call"
|
|
5869
|
-
);
|
|
5870
|
-
}
|
|
5871
|
-
let (action, input) = if step < 2 {
|
|
5872
|
-
(
|
|
5873
|
-
"mcp_tool",
|
|
5874
|
-
serde_json::json!({"server":name,"tool":"move","arguments":{}}),
|
|
5875
|
-
)
|
|
5876
|
-
} else {
|
|
5877
|
-
(
|
|
5878
|
-
"finish",
|
|
5879
|
-
serde_json::json!({"summary":"done","verification":"Mock command verified"}),
|
|
5880
|
-
)
|
|
5881
|
-
};
|
|
5882
|
-
let message = if native {
|
|
5883
|
-
serde_json::json!({"role":"assistant","content":"","tool_calls":[{"function":{"name":action,"arguments":input}}]})
|
|
5884
|
-
} else {
|
|
5885
|
-
serde_json::json!({"role":"assistant","content":serde_json::json!({"thought":"test","action":action,"input":input}).to_string()})
|
|
5886
|
-
};
|
|
5887
|
-
let body = format!(
|
|
5888
|
-
"{}\n",
|
|
5889
|
-
serde_json::json!({"model":"qwen2.5","message":message,"done":true,"prompt_eval_count":100,"eval_count":10})
|
|
5890
|
-
);
|
|
5891
|
-
socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/x-ndjson\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()).as_bytes()).await.unwrap();
|
|
5892
|
-
}
|
|
5893
|
-
});
|
|
5894
|
-
let chat = format!("model-review-{}", uuid::Uuid::new_v4());
|
|
5895
|
-
let mut events = Vec::new();
|
|
5896
|
-
let outcome = tokio::time::timeout(
|
|
5897
|
-
Duration::from_secs(15),
|
|
5898
|
-
orchestrate_agent_loop(
|
|
5899
|
-
&cfg,
|
|
5900
|
-
"Use the mock MCP move tool",
|
|
5901
|
-
Path::new("."),
|
|
5902
|
-
None,
|
|
5903
|
-
None,
|
|
5904
|
-
None,
|
|
5905
|
-
Some(&chat),
|
|
5906
|
-
None,
|
|
5907
|
-
None,
|
|
5908
|
-
None,
|
|
5909
|
-
true,
|
|
5910
|
-
false,
|
|
5911
|
-
|_| Ok(ApprovalOutcome::Denied),
|
|
5912
|
-
|e| events.push(e),
|
|
5913
|
-
|_| {},
|
|
5914
|
-
),
|
|
5915
|
-
)
|
|
5916
|
-
.await;
|
|
5917
|
-
provider.abort();
|
|
5918
|
-
crate::mcp::close_mcp_session(&server);
|
|
5919
|
-
assert!(
|
|
5920
|
-
matches!(outcome, Ok(Ok(_))),
|
|
5921
|
-
"native={native}, {outcome:?}; requests={}, events={:?}",
|
|
5922
|
-
recorded.lock().unwrap().len(),
|
|
5923
|
-
events.iter().rev().take(5).collect::<Vec<_>>()
|
|
5924
|
-
);
|
|
5925
|
-
let requests = recorded.lock().unwrap();
|
|
5926
|
-
assert_eq!(requests.len(), 3);
|
|
5927
|
-
assert!(
|
|
5928
|
-
requests[1].to_string().contains("Move the mock axis"),
|
|
5929
|
-
"full tool definition missing from model request: {}",
|
|
5930
|
-
requests[1]
|
|
5931
|
-
);
|
|
5932
|
-
assert!(
|
|
5933
|
-
requests[2].to_string().contains("Move the mock axis"),
|
|
5934
|
-
"definition must remain in later requests"
|
|
5935
|
-
);
|
|
5936
|
-
assert_eq!(std::fs::read_to_string(&marker).unwrap().lines().count(), 1);
|
|
5937
|
-
std::fs::remove_file(marker).unwrap();
|
|
5938
|
-
assert_eq!(
|
|
5939
|
-
events
|
|
5940
|
-
.iter()
|
|
5941
|
-
.filter(
|
|
5942
|
-
|e| matches!(e,AgentProgress::ToolStart {action,..} if action=="mcp_tool")
|
|
5943
|
-
)
|
|
5944
|
-
.count(),
|
|
5945
|
-
1
|
|
5946
|
-
);
|
|
5947
|
-
let memory = MemoryStore::open_default().unwrap();
|
|
5948
|
-
let _ = memory.delete_chat_session(&chat);
|
|
5949
|
-
}
|
|
5950
|
-
}
|
|
5951
|
-
|
|
5952
|
-
#[tokio::test]
|
|
5953
|
-
async fn device_command_waits_for_a_fresh_verified_observation() {
|
|
5954
|
-
let server = format!("device-{}", uuid::Uuid::new_v4());
|
|
5955
|
-
let mut cfg = MintConfig::default();
|
|
5956
|
-
cfg.extra.insert("mcpServers".into(),serde_json::json!({server.clone():{"command":"node","args":[Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/device-mcp/server.mjs")],"timeoutSecs":5}}));
|
|
5957
|
-
cfg.extra.insert(
|
|
5958
|
-
"allowedMcpTools".into(),
|
|
5959
|
-
serde_json::json!({server.clone():["*"]}),
|
|
5960
|
-
);
|
|
5961
|
-
let decision:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"set_speed","arguments":{"deviceId":"mock-device","rpm":100}}})).unwrap();
|
|
5962
|
-
let mut events = Vec::new();
|
|
5963
|
-
let out = execute_tool_outcome(
|
|
5964
|
-
Path::new("."),
|
|
5965
|
-
&cfg,
|
|
5966
|
-
&decision,
|
|
5967
|
-
"device-test",
|
|
5968
|
-
&mut |_| Ok(ApprovalOutcome::Denied),
|
|
5969
|
-
&mut |e| events.push(e),
|
|
5970
|
-
"device-command",
|
|
5971
|
-
)
|
|
5972
|
-
.await
|
|
5973
|
-
.unwrap();
|
|
5974
|
-
crate::mcp::close_mcp_session(&server);
|
|
5975
|
-
assert_eq!(
|
|
5976
|
-
out.status,
|
|
5977
|
-
crate::integrations::mcp_result::ToolStatus::Success
|
|
5978
|
-
);
|
|
5979
|
-
let events = serde_json::to_value(events).unwrap();
|
|
5980
|
-
assert!(
|
|
5981
|
-
events
|
|
5982
|
-
.as_array()
|
|
5983
|
-
.unwrap()
|
|
5984
|
-
.iter()
|
|
5985
|
-
.any(|e| e["type"] == "DeviceState" && e["data"]["update"]["state"] == "verified"),
|
|
5986
|
-
"receipt must not be considered a verified device state: {events}"
|
|
5987
|
-
);
|
|
5988
|
-
}
|
|
5989
|
-
|
|
5990
|
-
#[tokio::test]
|
|
5991
|
-
async fn device_capture_preserves_verified_images_and_explicit_stop_confirms_zero() {
|
|
5992
|
-
let server = format!("device-photo-{}", uuid::Uuid::new_v4());
|
|
5993
|
-
let mut cfg = MintConfig::default();
|
|
5994
|
-
cfg.extra.insert("mcpServers".into(),serde_json::json!({server.clone():{"command":"node","args":[Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/device-mcp/server.mjs")],"timeoutSecs":10}}));
|
|
5995
|
-
cfg.extra.insert(
|
|
5996
|
-
"allowedMcpTools".into(),
|
|
5997
|
-
serde_json::json!({server.clone():["*"]}),
|
|
5998
|
-
);
|
|
5999
|
-
let photo:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"capture_photo","arguments":{"deviceId":"mock-device"}}})).unwrap();
|
|
6000
|
-
let out = execute_tool_outcome(
|
|
6001
|
-
Path::new("."),
|
|
6002
|
-
&cfg,
|
|
6003
|
-
&photo,
|
|
6004
|
-
"photo-test",
|
|
6005
|
-
&mut |_| Ok(ApprovalOutcome::Denied),
|
|
6006
|
-
&mut |_| {},
|
|
6007
|
-
"photo-command",
|
|
6008
|
-
)
|
|
6009
|
-
.await
|
|
6010
|
-
.unwrap();
|
|
6011
|
-
assert_eq!(
|
|
6012
|
-
out.status,
|
|
6013
|
-
crate::integrations::mcp_result::ToolStatus::Success
|
|
6014
|
-
);
|
|
6015
|
-
assert_eq!(out.images.len(), 1);
|
|
6016
|
-
assert!(out.warnings.is_empty());
|
|
6017
|
-
remove_test_artifacts(&out.artifacts);
|
|
6018
|
-
crate::mcp::call_mcp_tool_async(
|
|
6019
|
-
&cfg,
|
|
6020
|
-
&server,
|
|
6021
|
-
"set_speed",
|
|
6022
|
-
serde_json::json!({"deviceId":"mock-device","rpm":1000}),
|
|
6023
|
-
"photo-test",
|
|
6024
|
-
|_| {},
|
|
6025
|
-
)
|
|
6026
|
-
.await
|
|
6027
|
-
.unwrap();
|
|
6028
|
-
let stop:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"stop","arguments":{"deviceId":"mock-device"}}})).unwrap();
|
|
6029
|
-
let mut events = Vec::new();
|
|
6030
|
-
let out = execute_tool_outcome(
|
|
6031
|
-
Path::new("."),
|
|
6032
|
-
&cfg,
|
|
6033
|
-
&stop,
|
|
6034
|
-
"photo-test",
|
|
6035
|
-
&mut |_| Ok(ApprovalOutcome::Denied),
|
|
6036
|
-
&mut |e| events.push(e),
|
|
6037
|
-
"stop-command",
|
|
6038
|
-
)
|
|
6039
|
-
.await
|
|
6040
|
-
.unwrap();
|
|
6041
|
-
crate::mcp::close_mcp_session(&server);
|
|
6042
|
-
assert_eq!(
|
|
6043
|
-
out.status,
|
|
6044
|
-
crate::integrations::mcp_result::ToolStatus::Success
|
|
6045
|
-
);
|
|
6046
|
-
assert!(events.iter().any(|e|matches!(e,AgentProgress::DeviceState {update,..} if matches!(update.state,crate::mcp::device::DevicePhase::Verified) && update.actual["rpm"]==0)));
|
|
6047
|
-
}
|
|
6048
|
-
|
|
6049
|
-
fn device_fixture(status: Value, timeout: u64) -> (MintConfig, String, PathBuf) {
|
|
6050
|
-
let name = format!("device-fault-{}", uuid::Uuid::new_v4());
|
|
6051
|
-
let log = std::env::temp_dir().join(format!("mint-device-calls-{}", uuid::Uuid::new_v4()));
|
|
6052
|
-
let schema = serde_json::json!({"type":"object","properties":{"deviceId":{"type":"string"},"operationId":{"type":"string"},"rpm":{"type":"integer"}},"required":["deviceId"],"additionalProperties":false});
|
|
6053
|
-
let tools = serde_json::json!([{"name":"set_speed","inputSchema":schema,"outputSchema":{"type":"object"},"_meta":{"mint/device":{"version":1,"statusTool":"get_status"}}},{"name":"get_status","inputSchema":schema,"outputSchema":{"type":"object"}}]);
|
|
6054
|
-
let receipt = serde_json::json!({"deviceId":"mock","operationId":"one","state":"accepted","observationSeq":1,"target":{"rpm":100},"actual":{"rpm":0}});
|
|
6055
|
-
let script = format!(
|
|
6056
|
-
r#"
|
|
6057
|
-
import sys,json
|
|
6058
|
-
tools=json.loads({:?});receipt=json.loads({:?});status=json.loads({:?})
|
|
6059
|
-
for line in sys.stdin:
|
|
6060
|
-
r=json.loads(line);m=r.get('method')
|
|
6061
|
-
if m=='initialize': result={{}}
|
|
6062
|
-
elif m=='tools/list': result={{'tools':tools}}
|
|
6063
|
-
elif m=='tools/call':
|
|
6064
|
-
with open({:?},'a') as f: f.write(json.dumps(r['params'])+'\n')
|
|
6065
|
-
result={{'structuredContent':receipt if r['params']['name']=='set_speed' else status,'content':[]}}
|
|
6066
|
-
else: continue
|
|
6067
|
-
print(json.dumps({{'jsonrpc':'2.0','id':r['id'],'result':result}}),flush=True)
|
|
6068
|
-
"#,
|
|
6069
|
-
tools.to_string(),
|
|
6070
|
-
receipt.to_string(),
|
|
6071
|
-
status.to_string(),
|
|
6072
|
-
log.to_string_lossy()
|
|
6073
|
-
);
|
|
6074
|
-
let mut cfg = MintConfig::default();
|
|
6075
|
-
cfg.extra.insert("mcpServers".into(),serde_json::json!({name.clone():{"command":"python3","args":["-u","-c",script],"timeoutSecs":timeout}}));
|
|
6076
|
-
cfg.extra.insert(
|
|
6077
|
-
"allowedMcpTools".into(),
|
|
6078
|
-
serde_json::json!({name.clone():["*"]}),
|
|
6079
|
-
);
|
|
6080
|
-
(cfg, name, log)
|
|
6081
|
-
}
|
|
6082
|
-
|
|
6083
|
-
#[tokio::test]
|
|
6084
|
-
async fn device_stale_observations_cannot_confirm_completion() {
|
|
6085
|
-
let (cfg, server, log) = device_fixture(
|
|
6086
|
-
serde_json::json!({"deviceId":"mock","operationId":"one","state":"completed","observationSeq":1,"target":{"rpm":100},"actual":{"rpm":100}}),
|
|
6087
|
-
1,
|
|
6088
|
-
);
|
|
6089
|
-
let decision:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"set_speed","arguments":{"deviceId":"mock","rpm":100}}})).unwrap();
|
|
6090
|
-
let out = execute_tool_outcome(
|
|
6091
|
-
Path::new("."),
|
|
6092
|
-
&cfg,
|
|
6093
|
-
&decision,
|
|
6094
|
-
"stale-test",
|
|
6095
|
-
&mut |_| Ok(ApprovalOutcome::Denied),
|
|
6096
|
-
&mut |_| {},
|
|
6097
|
-
"stale-command",
|
|
6098
|
-
)
|
|
6099
|
-
.await
|
|
6100
|
-
.unwrap();
|
|
6101
|
-
crate::mcp::close_mcp_session(&server);
|
|
6102
|
-
let calls = std::fs::read_to_string(&log).unwrap();
|
|
6103
|
-
std::fs::remove_file(log).unwrap();
|
|
6104
|
-
assert_eq!(calls.lines().filter(|l| l.contains("set_speed")).count(), 1);
|
|
6105
|
-
assert_eq!(
|
|
6106
|
-
out.status,
|
|
6107
|
-
crate::integrations::mcp_result::ToolStatus::TimedOut
|
|
6108
|
-
);
|
|
6109
|
-
assert!(
|
|
6110
|
-
out.completion_unconfirmed,
|
|
6111
|
-
"a dispatched command remains uncertain after stale observations"
|
|
6112
|
-
);
|
|
6113
|
-
}
|
|
6114
|
-
|
|
6115
|
-
#[tokio::test]
|
|
6116
|
-
async fn review_device_session_loss_preserves_receipt_without_reconnecting_or_repeating() {
|
|
6117
|
-
let (mut cfg, server, log) = device_fixture(
|
|
6118
|
-
serde_json::json!({"deviceId":"mock","operationId":"one","state":"running","observationSeq":2,"target":{"rpm":100},"actual":{"rpm":50}}),
|
|
6119
|
-
5,
|
|
6120
|
-
);
|
|
6121
|
-
let starts = log.with_extension("starts");
|
|
6122
|
-
let script = cfg.extra["mcpServers"][&server]["args"][2]
|
|
6123
|
-
.as_str()
|
|
6124
|
-
.unwrap()
|
|
6125
|
-
.replace(
|
|
6126
|
-
"import sys,json",
|
|
6127
|
-
&format!(
|
|
6128
|
-
"import sys,json,os\nopen({:?},'a').write('start\\n')",
|
|
6129
|
-
starts.to_string_lossy()
|
|
6130
|
-
),
|
|
6131
|
-
);
|
|
6132
|
-
cfg.extra.get_mut("mcpServers").unwrap()[&server]["args"][2] = serde_json::json!(format!(
|
|
6133
|
-
"{script}\n if m=='tools/call' and r['params']['name']=='get_status': os._exit(0)\n"
|
|
6134
|
-
));
|
|
6135
|
-
let decision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"set_speed","arguments":{"deviceId":"mock","rpm":100}}})).unwrap();
|
|
6136
|
-
let mut events = Vec::new();
|
|
6137
|
-
let out = execute_tool_outcome(
|
|
6138
|
-
Path::new("."),
|
|
6139
|
-
&cfg,
|
|
6140
|
-
&decision,
|
|
6141
|
-
"review-device-loss",
|
|
6142
|
-
&mut |_| Ok(ApprovalOutcome::Denied),
|
|
6143
|
-
&mut |e| events.push(e),
|
|
6144
|
-
"command",
|
|
6145
|
-
)
|
|
6146
|
-
.await
|
|
6147
|
-
.unwrap();
|
|
6148
|
-
crate::mcp::close_mcp_session(&server);
|
|
6149
|
-
let calls = std::fs::read_to_string(&log).unwrap();
|
|
6150
|
-
assert_eq!(std::fs::read_to_string(&starts).unwrap(), "start\n");
|
|
6151
|
-
let _ = std::fs::remove_file(log);
|
|
6152
|
-
let _ = std::fs::remove_file(starts);
|
|
6153
|
-
assert_eq!(calls.lines().filter(|l| l.contains("set_speed")).count(), 1);
|
|
6154
|
-
assert_eq!(
|
|
6155
|
-
calls.lines().filter(|l| l.contains("get_status")).count(),
|
|
6156
|
-
1
|
|
6157
|
-
);
|
|
6158
|
-
assert_eq!(out.status, crate::mcp_result::ToolStatus::Failed);
|
|
6159
|
-
assert!(out.completion_unconfirmed);
|
|
6160
|
-
assert!(
|
|
6161
|
-
out.text.contains("one"),
|
|
6162
|
-
"operation receipt lost: {}",
|
|
6163
|
-
out.text
|
|
6164
|
-
);
|
|
6165
|
-
assert!(events.iter().any(|e|matches!(e,AgentProgress::DeviceState{update,..} if matches!(update.state,crate::mcp::device::DevicePhase::Unconfirmed) && update.operation_id.as_deref()==Some("one"))));
|
|
6166
|
-
assert!(!events.iter().any(|e|matches!(e,AgentProgress::DeviceState{update,..} if matches!(update.state,crate::mcp::device::DevicePhase::Verified))));
|
|
6167
|
-
}
|
|
6168
|
-
|
|
6169
|
-
#[tokio::test]
|
|
6170
|
-
async fn device_wrong_operation_and_physical_failure_never_report_success() {
|
|
6171
|
-
for status in [
|
|
6172
|
-
serde_json::json!({"deviceId":"mock","operationId":"other","state":"completed","observationSeq":2,"target":{"rpm":100},"actual":{"rpm":100}}),
|
|
6173
|
-
serde_json::json!({"deviceId":"mock","operationId":"one","state":"failed","observationSeq":2,"target":{"rpm":100},"actual":{"rpm":0},"error":"jammed"}),
|
|
6174
|
-
] {
|
|
6175
|
-
let (cfg, server, log) = device_fixture(status, 3);
|
|
6176
|
-
let decision:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"set_speed","arguments":{"deviceId":"mock","rpm":100}}})).unwrap();
|
|
6177
|
-
let out = execute_tool_outcome(
|
|
6178
|
-
Path::new("."),
|
|
6179
|
-
&cfg,
|
|
6180
|
-
&decision,
|
|
6181
|
-
"wrong-operation",
|
|
6182
|
-
&mut |_| Ok(ApprovalOutcome::Denied),
|
|
6183
|
-
&mut |_| {},
|
|
6184
|
-
"wrong-command",
|
|
6185
|
-
)
|
|
6186
|
-
.await
|
|
6187
|
-
.unwrap();
|
|
6188
|
-
crate::mcp::close_mcp_session(&server);
|
|
6189
|
-
std::fs::remove_file(log).unwrap();
|
|
6190
|
-
assert_eq!(
|
|
6191
|
-
out.status,
|
|
6192
|
-
crate::integrations::mcp_result::ToolStatus::Failed
|
|
6193
|
-
);
|
|
6194
|
-
assert!(
|
|
6195
|
-
out.text.starts_with("Error:"),
|
|
6196
|
-
"Native tool results must carry an error marker for failed verification: {}",
|
|
6197
|
-
out.text
|
|
6198
|
-
);
|
|
6199
|
-
assert!(
|
|
6200
|
-
!out.text.contains("no tool was executed"),
|
|
6201
|
-
"verification errors must acknowledge the already dispatched command: {}",
|
|
6202
|
-
out.text
|
|
6203
|
-
);
|
|
6204
|
-
}
|
|
6205
|
-
}
|
|
6206
|
-
|
|
6207
|
-
#[tokio::test]
|
|
6208
|
-
async fn device_failed_receipt_is_terminal_without_status_polling() {
|
|
6209
|
-
let (mut cfg, server, log) = device_fixture(
|
|
6210
|
-
serde_json::json!({"deviceId":"mock","operationId":"one","state":"completed","observationSeq":2,"target":{"rpm":100},"actual":{"rpm":100}}),
|
|
6211
|
-
3,
|
|
6212
|
-
);
|
|
6213
|
-
let script = cfg.extra["mcpServers"][&server]["args"][2]
|
|
6214
|
-
.as_str()
|
|
6215
|
-
.unwrap()
|
|
6216
|
-
.replace("accepted", "failed");
|
|
6217
|
-
cfg.extra.get_mut("mcpServers").unwrap()[&server]["args"][2] = serde_json::json!(script);
|
|
6218
|
-
let decision:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"set_speed","arguments":{"deviceId":"mock","rpm":100}}})).unwrap();
|
|
6219
|
-
let out = execute_tool_outcome(
|
|
6220
|
-
Path::new("."),
|
|
6221
|
-
&cfg,
|
|
6222
|
-
&decision,
|
|
6223
|
-
"failed-receipt",
|
|
6224
|
-
&mut |_| Ok(ApprovalOutcome::Denied),
|
|
6225
|
-
&mut |_| {},
|
|
6226
|
-
"failed-command",
|
|
6227
|
-
)
|
|
6228
|
-
.await
|
|
6229
|
-
.unwrap();
|
|
6230
|
-
crate::mcp::close_mcp_session(&server);
|
|
6231
|
-
let calls = std::fs::read_to_string(&log).unwrap();
|
|
6232
|
-
std::fs::remove_file(log).unwrap();
|
|
6233
|
-
assert_eq!(
|
|
6234
|
-
out.status,
|
|
6235
|
-
crate::integrations::mcp_result::ToolStatus::Failed
|
|
6236
|
-
);
|
|
6237
|
-
assert!(
|
|
6238
|
-
!calls.contains("get_status"),
|
|
6239
|
-
"failed receipt must not be overwritten by a later status"
|
|
6240
|
-
);
|
|
6241
|
-
}
|
|
6242
|
-
|
|
6243
|
-
#[tokio::test]
|
|
6244
|
-
async fn device_denied_status_access_blocks_movement_before_dispatch() {
|
|
6245
|
-
let (mut cfg, server, log) = device_fixture(serde_json::json!({}), 3);
|
|
6246
|
-
cfg.extra.insert(
|
|
6247
|
-
"allowedMcpTools".into(),
|
|
6248
|
-
serde_json::json!({server.clone():["set_speed"]}),
|
|
6249
|
-
);
|
|
6250
|
-
let decision:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"set_speed","arguments":{"deviceId":"mock","rpm":100}}})).unwrap();
|
|
6251
|
-
let mut approvals = Vec::new();
|
|
6252
|
-
let out = execute_tool_outcome(
|
|
6253
|
-
Path::new("."),
|
|
6254
|
-
&cfg,
|
|
6255
|
-
&decision,
|
|
6256
|
-
"denied-status",
|
|
6257
|
-
&mut |a| {
|
|
6258
|
-
approvals.push(serde_json::to_value(a).unwrap());
|
|
6259
|
-
Ok(ApprovalOutcome::Denied)
|
|
6260
|
-
},
|
|
6261
|
-
&mut |_| {},
|
|
6262
|
-
"blocked-command",
|
|
6263
|
-
)
|
|
6264
|
-
.await
|
|
6265
|
-
.unwrap();
|
|
6266
|
-
crate::mcp::close_mcp_session(&server);
|
|
6267
|
-
assert!(
|
|
6268
|
-
!log.exists(),
|
|
6269
|
-
"command was dispatched without access to status verification"
|
|
6270
|
-
);
|
|
6271
|
-
assert_eq!(
|
|
6272
|
-
out.status,
|
|
6273
|
-
crate::integrations::mcp_result::ToolStatus::Failed
|
|
6274
|
-
);
|
|
6275
|
-
assert!(
|
|
6276
|
-
serde_json::to_string(&approvals)
|
|
6277
|
-
.unwrap()
|
|
6278
|
-
.contains("get_status")
|
|
6279
|
-
);
|
|
6280
|
-
}
|
|
6281
|
-
|
|
6282
|
-
#[tokio::test]
|
|
6283
|
-
async fn canceling_between_device_observations_does_not_issue_another_command() {
|
|
6284
|
-
let (cfg, server, log) = device_fixture(
|
|
6285
|
-
serde_json::json!({"deviceId":"mock","operationId":"one","state":"running","observationSeq":2,"target":{"rpm":100},"actual":{"rpm":20}}),
|
|
6286
|
-
15,
|
|
6287
|
-
);
|
|
6288
|
-
let decision:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"set_speed","arguments":{"deviceId":"mock","rpm":100}}})).unwrap();
|
|
6289
|
-
let chat = format!("cancel-between-{}", uuid::Uuid::new_v4());
|
|
6290
|
-
let name = chat.clone();
|
|
6291
|
-
let (tx, rx) = tokio::sync::oneshot::channel();
|
|
6292
|
-
let mut tx = Some(tx);
|
|
6293
|
-
let running = tokio::spawn(async move {
|
|
6294
|
-
execute_tool_outcome(
|
|
6295
|
-
Path::new("."),
|
|
6296
|
-
&cfg,
|
|
6297
|
-
&decision,
|
|
6298
|
-
&name,
|
|
6299
|
-
&mut |_| Ok(ApprovalOutcome::Denied),
|
|
6300
|
-
&mut |e| {
|
|
6301
|
-
if matches!(
|
|
6302
|
-
e,
|
|
6303
|
-
AgentProgress::DeviceState {
|
|
6304
|
-
update: crate::mcp::device::DeviceUpdate {
|
|
6305
|
-
state: crate::mcp::device::DevicePhase::Running,
|
|
6306
|
-
..
|
|
6307
|
-
},
|
|
6308
|
-
..
|
|
6309
|
-
}
|
|
6310
|
-
) && let Some(tx) = tx.take()
|
|
6311
|
-
{
|
|
6312
|
-
let _ = tx.send(());
|
|
6313
|
-
}
|
|
6314
|
-
},
|
|
6315
|
-
"cancel-command",
|
|
6316
|
-
)
|
|
6317
|
-
.await
|
|
6318
|
-
.unwrap()
|
|
6319
|
-
});
|
|
6320
|
-
tokio::time::timeout(Duration::from_secs(10), rx)
|
|
6321
|
-
.await
|
|
6322
|
-
.unwrap()
|
|
6323
|
-
.unwrap();
|
|
6324
|
-
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
6325
|
-
assert!(crate::mcp::cancel_mcp_calls(&chat) > 0);
|
|
6326
|
-
let out = tokio::time::timeout(Duration::from_secs(2), running)
|
|
6327
|
-
.await
|
|
6328
|
-
.unwrap()
|
|
6329
|
-
.unwrap();
|
|
6330
|
-
crate::mcp::close_mcp_session(&server);
|
|
6331
|
-
assert_eq!(
|
|
6332
|
-
out.status,
|
|
6333
|
-
crate::integrations::mcp_result::ToolStatus::Cancelled
|
|
6334
|
-
);
|
|
6335
|
-
let calls = std::fs::read_to_string(&log).unwrap();
|
|
6336
|
-
std::fs::remove_file(log).unwrap();
|
|
6337
|
-
assert_eq!(calls.lines().filter(|l| l.contains("set_speed")).count(), 1);
|
|
6338
|
-
assert!(!calls.contains("\"stop\""));
|
|
6339
|
-
}
|
|
6340
|
-
|
|
6341
|
-
#[tokio::test]
|
|
6342
|
-
async fn approval_feedback_does_not_mark_an_unexecuted_mcp_call_successful() {
|
|
6343
|
-
let decision: AgentDecision = serde_json::from_value(serde_json::json!({
|
|
6344
|
-
"action":"mcp_tool", "input":{"server":"feedback-server","tool":"render","arguments":{}}
|
|
6345
|
-
}))
|
|
6346
|
-
.unwrap();
|
|
6347
|
-
let mut config = MintConfig::default();
|
|
6348
|
-
config.extra.insert("mcpServers".into(),serde_json::json!({"feedback-server":{"command":"python3","args":["-u","-c",generic_server("render",serde_json::json!({}))]}}));
|
|
6349
|
-
let out = execute_tool_outcome(
|
|
6350
|
-
Path::new("."),
|
|
6351
|
-
&config,
|
|
6352
|
-
&decision,
|
|
6353
|
-
"feedback",
|
|
6354
|
-
&mut |_| {
|
|
6355
|
-
Ok(ApprovalOutcome::Intercepted(
|
|
6356
|
-
"Use a different device".into(),
|
|
6357
|
-
))
|
|
6358
|
-
},
|
|
6359
|
-
&mut |_| {},
|
|
6360
|
-
"not-executed",
|
|
6361
|
-
)
|
|
6362
|
-
.await
|
|
6363
|
-
.unwrap();
|
|
6364
|
-
assert!(out.text.contains("Use a different device"));
|
|
6365
|
-
assert!(out.text.contains("no command was executed"));
|
|
6366
|
-
assert_eq!(
|
|
6367
|
-
out.status,
|
|
6368
|
-
crate::integrations::mcp_result::ToolStatus::Failed
|
|
6369
|
-
);
|
|
6370
|
-
}
|
|
6371
|
-
|
|
6372
|
-
#[tokio::test]
|
|
6373
|
-
async fn text_only_model_keeps_artifacts_without_receiving_image_blocks() {
|
|
6374
|
-
use base64::Engine as _;
|
|
6375
|
-
let mut bytes = std::io::Cursor::new(Vec::new());
|
|
6376
|
-
image::DynamicImage::new_rgb8(1, 1)
|
|
6377
|
-
.write_to(&mut bytes, image::ImageFormat::Png)
|
|
6378
|
-
.unwrap();
|
|
6379
|
-
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes.into_inner());
|
|
6380
|
-
let response =
|
|
6381
|
-
serde_json::json!({"content":[{"type":"image","mimeType":"image/png","data":encoded}]})
|
|
6382
|
-
.to_string();
|
|
6383
|
-
let script = generic_server("preview", serde_json::from_str(&response).unwrap());
|
|
6384
|
-
let server = format!("text-model-{}", uuid::Uuid::new_v4());
|
|
6385
|
-
let mut cfg = MintConfig::default();
|
|
6386
|
-
cfg.extra.insert(
|
|
6387
|
-
"mcpServers".into(),
|
|
6388
|
-
serde_json::json!({server.clone():{"command":"python3","args":["-u","-c",script]}}),
|
|
6389
|
-
);
|
|
6390
|
-
cfg.extra.insert(
|
|
6391
|
-
"allowedMcpTools".into(),
|
|
6392
|
-
serde_json::json!({server.clone():["preview"]}),
|
|
6393
|
-
);
|
|
6394
|
-
cfg.extra.insert("mcpImageInput".into(), Value::Bool(false));
|
|
6395
|
-
let decision:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"preview","arguments":{}}})).unwrap();
|
|
6396
|
-
let out = execute_tool_outcome(
|
|
6397
|
-
Path::new("."),
|
|
6398
|
-
&cfg,
|
|
6399
|
-
&decision,
|
|
6400
|
-
"text-only",
|
|
6401
|
-
&mut |_| Ok(ApprovalOutcome::Denied),
|
|
6402
|
-
&mut |_| {},
|
|
6403
|
-
"preview",
|
|
6404
|
-
)
|
|
6405
|
-
.await
|
|
6406
|
-
.unwrap();
|
|
6407
|
-
assert!(out.images.is_empty());
|
|
6408
|
-
assert_eq!(out.artifacts.len(), 1);
|
|
6409
|
-
assert!(out.text.contains("do not claim visual inspection"));
|
|
6410
|
-
crate::mcp::close_mcp_session(&server);
|
|
6411
|
-
remove_test_artifacts(&out.artifacts);
|
|
6412
|
-
}
|
|
6413
|
-
|
|
6414
|
-
#[test]
|
|
6415
|
-
fn multiple_images_attach_to_the_correct_native_tool_result() {
|
|
6416
|
-
let images = std::collections::HashMap::from([(
|
|
6417
|
-
"call-2".to_string(),
|
|
6418
|
-
vec![
|
|
6419
|
-
"data:image/png;base64,first".into(),
|
|
6420
|
-
"data:image/png;base64,second".into(),
|
|
6421
|
-
],
|
|
6422
|
-
)]);
|
|
6423
|
-
let results = vec![
|
|
6424
|
-
(
|
|
6425
|
-
"call-1".into(),
|
|
6426
|
-
"mcp_tool".into(),
|
|
6427
|
-
Value::Null,
|
|
6428
|
-
"one".into(),
|
|
6429
|
-
)
|
|
6430
|
-
.into(),
|
|
6431
|
-
(
|
|
6432
|
-
"call-2".into(),
|
|
6433
|
-
"mcp_tool".into(),
|
|
6434
|
-
Value::Null,
|
|
6435
|
-
"two".into(),
|
|
6436
|
-
)
|
|
6437
|
-
.into(),
|
|
6438
|
-
];
|
|
6439
|
-
let mut messages = Vec::new();
|
|
6440
|
-
append_native_tool_results(&mut messages, "", &results, &Default::default(), &images);
|
|
6441
|
-
assert!(
|
|
6442
|
-
matches!(&messages[1].content[1],ContentBlock::ToolResult {tool_use_id,..} if tool_use_id=="call-2")
|
|
6443
|
-
);
|
|
6444
|
-
assert!(
|
|
6445
|
-
matches!(&messages[1].content[2],ContentBlock::Image {data_uri} if data_uri.ends_with("first"))
|
|
6446
|
-
);
|
|
6447
|
-
assert!(
|
|
6448
|
-
matches!(&messages[1].content[3],ContentBlock::Image {data_uri} if data_uri.ends_with("second"))
|
|
6449
|
-
);
|
|
6450
|
-
}
|
|
6451
|
-
|
|
6452
|
-
#[tokio::test]
|
|
6453
|
-
async fn mcp_images_do_not_enter_the_text_observation() {
|
|
6454
|
-
let server = format!("image-{}", uuid::Uuid::new_v4());
|
|
6455
|
-
let mut bytes = std::io::Cursor::new(Vec::new());
|
|
6456
|
-
image::DynamicImage::new_rgb8(1, 1)
|
|
6457
|
-
.write_to(&mut bytes, image::ImageFormat::Png)
|
|
6458
|
-
.unwrap();
|
|
6459
|
-
let png = BASE64_STANDARD.encode(bytes.into_inner());
|
|
6460
|
-
let result = serde_json::json!({"content":[{"type":"text","text":"preview"},{"type":"image","mimeType":"image/png","data":png}]}).to_string();
|
|
6461
|
-
let script = generic_server("preview", serde_json::from_str(&result).unwrap());
|
|
6462
|
-
let mut config = MintConfig::default();
|
|
6463
|
-
config.extra.insert(
|
|
6464
|
-
"mcpServers".into(),
|
|
6465
|
-
serde_json::json!({server.clone():{"command":"python3","args":["-u","-c",script]}}),
|
|
6466
|
-
);
|
|
6467
|
-
let decision: AgentDecision = serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"preview","arguments":{}}})).unwrap();
|
|
6468
|
-
let mut events = Vec::new();
|
|
6469
|
-
let out = execute_tool_outcome(
|
|
6470
|
-
Path::new("."),
|
|
6471
|
-
&config,
|
|
6472
|
-
&decision,
|
|
6473
|
-
"image-test",
|
|
6474
|
-
&mut |_| Ok(ApprovalOutcome::Approved),
|
|
6475
|
-
&mut |event| events.push(event),
|
|
6476
|
-
"image-invocation",
|
|
6477
|
-
)
|
|
6478
|
-
.await
|
|
6479
|
-
.unwrap();
|
|
6480
|
-
assert!(
|
|
6481
|
-
!out.text.contains(&png),
|
|
6482
|
-
"binary data must be removed from the model's text observation"
|
|
6483
|
-
);
|
|
6484
|
-
assert_eq!(
|
|
6485
|
-
out.status,
|
|
6486
|
-
crate::integrations::mcp_result::ToolStatus::Success
|
|
6487
|
-
);
|
|
6488
|
-
assert_eq!(out.images.len(), 1);
|
|
6489
|
-
assert_eq!(out.artifacts[0].call_id, "image-invocation");
|
|
6490
|
-
assert!(out.text.contains("preview"));
|
|
6491
|
-
assert!(!serde_json::to_string(&events).unwrap().contains(&png));
|
|
6492
|
-
assert!(
|
|
6493
|
-
!crate::mcp::is_mcp_tool_allowed(&config, &server, "preview"),
|
|
6494
|
-
"one-call approval must not persist a grant"
|
|
6495
|
-
);
|
|
6496
|
-
crate::mcp::close_mcp_session(&server);
|
|
6497
|
-
remove_test_artifacts(&out.artifacts);
|
|
6498
|
-
}
|
|
6499
|
-
|
|
6500
|
-
fn remove_test_artifacts(artifacts: &[crate::integrations::mcp_result::McpArtifact]) {
|
|
6501
|
-
let directory = crate::integrations::mcp_result::artifact_directory().unwrap();
|
|
6502
|
-
for artifact in artifacts {
|
|
6503
|
-
std::fs::remove_file(directory.join(&artifact.id)).unwrap();
|
|
6504
|
-
std::fs::remove_file(directory.join(format!("{}.json", artifact.id))).unwrap();
|
|
6505
|
-
}
|
|
6506
|
-
}
|
|
6507
|
-
|
|
6508
|
-
// A transport-successful tool failure must not become a successful agent step.
|
|
6509
|
-
#[tokio::test]
|
|
6510
|
-
async fn mcp_tool_failure_is_visible_to_the_agent() {
|
|
6511
|
-
let server = format!("outcome-{}", uuid::Uuid::new_v4());
|
|
6512
|
-
let mut config = MintConfig::default();
|
|
6513
|
-
config.extra.insert("mcpServers".into(),serde_json::json!({server.clone():{"command":"python3","args":["-u","-c",generic_server("render",serde_json::json!({"isError":true,"content":[{"type":"text","text":"render failed"}]}))]}}));
|
|
6514
|
-
config.extra.insert(
|
|
6515
|
-
"allowedMcpTools".into(),
|
|
6516
|
-
serde_json::json!({server.clone():["render"]}),
|
|
6517
|
-
);
|
|
6518
|
-
let decision: AgentDecision = serde_json::from_value(serde_json::json!({
|
|
6519
|
-
"thought":"render", "action":"mcp_tool", "input":{"server":server,"tool":"render","arguments":{}}
|
|
6520
|
-
})).unwrap();
|
|
6521
|
-
let result = execute_tool(
|
|
6522
|
-
Path::new("."),
|
|
6523
|
-
&config,
|
|
6524
|
-
&decision,
|
|
6525
|
-
"outcome-test",
|
|
6526
|
-
&mut |_| Ok(ApprovalOutcome::Denied),
|
|
6527
|
-
&mut |_| {},
|
|
6528
|
-
)
|
|
6529
|
-
.await
|
|
6530
|
-
.unwrap();
|
|
6531
|
-
assert!(
|
|
6532
|
-
result.starts_with("Error:"),
|
|
6533
|
-
"tool failure must reach the model as a failure: {result}"
|
|
6534
|
-
);
|
|
6535
|
-
assert!(result.contains("render failed"));
|
|
6536
|
-
crate::mcp::close_mcp_session(&server);
|
|
6537
|
-
}
|
|
6538
|
-
|
|
6539
|
-
#[test]
|
|
6540
|
-
fn test_fault_tolerant_null_handling() {
|
|
6541
|
-
let input = serde_json::json!({
|
|
6542
|
-
"command": "curl -sL https://example.com",
|
|
6543
|
-
"city": null,
|
|
6544
|
-
"columns": null,
|
|
6545
|
-
"verification": null,
|
|
6546
|
-
"filter": null
|
|
6547
|
-
});
|
|
6548
|
-
let parsed = decode_agent_input(input).unwrap();
|
|
6549
|
-
assert_eq!(parsed.command, "curl -sL https://example.com");
|
|
6550
|
-
assert_eq!(parsed.city, "");
|
|
6551
|
-
assert_eq!(parsed.verification, "");
|
|
6552
|
-
}
|
|
6553
|
-
|
|
6554
|
-
#[test]
|
|
6555
|
-
fn test_unpack_nested_arguments() {
|
|
6556
|
-
// Stringified JSON arguments
|
|
6557
|
-
let str_args = serde_json::json!({
|
|
6558
|
-
"action": "run_shell",
|
|
6559
|
-
"arguments": "{\"command\": \"ls -la\", \"city\": null}"
|
|
6560
|
-
});
|
|
6561
|
-
let parsed_str = decode_agent_input(str_args).unwrap();
|
|
6562
|
-
assert_eq!(parsed_str.command, "ls -la");
|
|
6563
|
-
|
|
6564
|
-
// Object arguments
|
|
6565
|
-
let obj_args = serde_json::json!({
|
|
6566
|
-
"action": "run_shell",
|
|
6567
|
-
"arguments": { "command": "pwd" }
|
|
6568
|
-
});
|
|
6569
|
-
let parsed_obj = decode_agent_input(obj_args).unwrap();
|
|
6570
|
-
assert_eq!(parsed_obj.command, "pwd");
|
|
6571
|
-
}
|
|
6572
|
-
|
|
6573
|
-
#[test]
|
|
6574
|
-
fn test_shell_command_aliases() {
|
|
6575
|
-
for (alias, expected) in [
|
|
6576
|
-
("cmd", "echo 1"),
|
|
6577
|
-
("script", "echo 2"),
|
|
6578
|
-
("exec", "echo 3"),
|
|
6579
|
-
("code", "echo 4"),
|
|
6580
|
-
("bash", "echo 5"),
|
|
6581
|
-
("sh", "echo 6"),
|
|
6582
|
-
] {
|
|
6583
|
-
let input = serde_json::json!({ alias: expected });
|
|
6584
|
-
let parsed = decode_agent_input(input).unwrap();
|
|
6585
|
-
assert_eq!(parsed.command, expected, "Failed for alias {alias}");
|
|
6586
|
-
}
|
|
6587
|
-
}
|
|
6588
|
-
|
|
6589
|
-
#[test]
|
|
6590
|
-
fn test_parse_tool_input_raw_shell_string() {
|
|
6591
|
-
let raw = Value::String("curl -sL --max-time 25 https://sndiary02.com".into());
|
|
6592
|
-
let parsed = parse_tool_input("run_shell", raw).unwrap();
|
|
6593
|
-
assert_eq!(
|
|
6594
|
-
parsed.command,
|
|
6595
|
-
"curl -sL --max-time 25 https://sndiary02.com"
|
|
6596
|
-
);
|
|
6597
|
-
}
|
|
6598
|
-
|
|
6599
|
-
#[test]
|
|
6600
|
-
fn test_parse_tool_input_prunes_dummy_null_strings() {
|
|
6601
|
-
let raw = serde_json::json!({
|
|
6602
|
-
"command": "curl -sL https://example.com",
|
|
6603
|
-
"path": "null",
|
|
6604
|
-
"query": "null",
|
|
6605
|
-
"name": "null",
|
|
6606
|
-
"title": "undefined",
|
|
6607
|
-
"url": "none"
|
|
6608
|
-
});
|
|
6609
|
-
let parsed = parse_tool_input("run_shell", raw).unwrap();
|
|
6610
|
-
assert_eq!(parsed.command, "curl -sL https://example.com");
|
|
6611
|
-
assert_eq!(parsed.path, "");
|
|
6612
|
-
assert_eq!(parsed.query, "");
|
|
6613
|
-
assert_eq!(parsed.name, "");
|
|
6614
|
-
assert_eq!(parsed.title, "");
|
|
6615
|
-
assert_eq!(parsed.url, "");
|
|
6616
|
-
}
|
|
6617
|
-
|
|
6618
|
-
#[test]
|
|
6619
|
-
fn test_is_dummy_value() {
|
|
6620
|
-
assert!(is_dummy_value("null"));
|
|
6621
|
-
assert!(is_dummy_value("NULL"));
|
|
6622
|
-
assert!(is_dummy_value(" null "));
|
|
6623
|
-
assert!(is_dummy_value("undefined"));
|
|
6624
|
-
assert!(is_dummy_value("none"));
|
|
6625
|
-
assert!(is_dummy_value("nil"));
|
|
6626
|
-
assert!(is_dummy_value("(null)"));
|
|
6627
|
-
assert!(is_dummy_value("\"\""));
|
|
6628
|
-
assert!(is_dummy_value("''"));
|
|
6629
|
-
|
|
6630
|
-
assert!(!is_dummy_value("echo null"));
|
|
6631
|
-
assert!(!is_dummy_value("cat /dev/null"));
|
|
6632
|
-
assert!(!is_dummy_value("src/main.rs"));
|
|
6633
|
-
assert!(!is_dummy_value("pwd"));
|
|
6634
|
-
}
|
|
6635
|
-
}
|
|
2706
|
+
mod mcp_outcome_tests;
|