From 29d0619aef3cdb0c720be17c073983f5e31411c3 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Wed, 13 May 2026 13:10:01 -0400 Subject: [PATCH 1/7] feat(cli): add `nemo-flow plugins edit` Signed-off-by: Will Killian --- ATTRIBUTIONS-Rust.md | 250 +++++ Cargo.lock | 57 +- crates/cli/Cargo.toml | 1 + crates/cli/README.md | 29 +- crates/cli/src/config.rs | 384 +++---- crates/cli/src/doctor.rs | 211 ++-- crates/cli/src/gateway.rs | 21 +- crates/cli/src/installer.rs | 25 - crates/cli/src/launcher.rs | 52 +- crates/cli/src/main.rs | 9 +- crates/cli/src/plugins.rs | 945 ++++++++++++++++++ crates/cli/src/session.rs | 206 +--- crates/cli/src/setup.rs | 229 +---- crates/cli/tests/cli_tests.rs | 33 +- crates/cli/tests/coverage/config_tests.rs | 318 +++--- crates/cli/tests/coverage/doctor_tests.rs | 25 +- crates/cli/tests/coverage/gateway_tests.rs | 37 +- crates/cli/tests/coverage/installer_tests.rs | 5 +- crates/cli/tests/coverage/launcher_tests.rs | 45 - crates/cli/tests/coverage/plugins_tests.rs | 121 +++ crates/cli/tests/coverage/server_tests.rs | 2 - crates/cli/tests/coverage/session_tests.rs | 637 +----------- crates/cli/tests/coverage/setup_tests.rs | 91 +- crates/core/Cargo.toml | 2 + crates/core/src/config_editor.rs | 158 +++ crates/core/src/lib.rs | 1 + .../src/observability/plugin_component.rs | 121 +++ crates/core/src/plugin.rs | 34 + .../observability/plugin_component_tests.rs | 150 +++ docs/about/concepts/plugins.md | 3 + docs/build-plugins/about.md | 1 + .../plugin-configuration-files.md | 258 +++++ .../observability-plugin.md | 43 +- docs/getting-started/configuration.md | 5 + docs/index.md | 1 + .../coding-agent-claude-code.md | 40 +- .../coding-agent-codex.md | 34 +- .../coding-agent-cursor.md | 31 +- .../coding-agent-gateway.md | 44 +- .../coding-agent-hermes.md | 36 +- integrations/coding-agents/README.md | 44 +- .../coding-agents/claude-code/README.md | 25 +- integrations/coding-agents/codex/README.md | 25 +- integrations/coding-agents/cursor/README.md | 24 +- 44 files changed, 2910 insertions(+), 1903 deletions(-) create mode 100644 crates/cli/src/plugins.rs create mode 100644 crates/cli/tests/coverage/plugins_tests.rs create mode 100644 crates/core/src/config_editor.rs create mode 100644 docs/build-plugins/plugin-configuration-files.md diff --git a/ATTRIBUTIONS-Rust.md b/ATTRIBUTIONS-Rust.md index 589cce930..43c15c3d5 100644 --- a/ATTRIBUTIONS-Rust.md +++ b/ATTRIBUTIONS-Rust.md @@ -6355,6 +6355,36 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` + +## console - 0.16.3 +**Repository URL**: https://github.com/console-rs/console +**License Type(s)**: MIT +### License: https://spdx.org/licenses/MIT.html +``` +The MIT License (MIT) + +Copyright (c) 2017 Armin Ronacher + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + ``` ## const-oid - 0.10.2 @@ -8297,6 +8327,87 @@ limitations under the License. ``` +## dyn-clone - 1.0.20 +**Repository URL**: https://github.com/dtolnay/dyn-clone +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + ## either - 1.15.0 **Repository URL**: https://github.com/rayon-rs/either **License Type(s)**: Apache-2.0 @@ -25739,6 +25850,64 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ``` +## schemars - 0.8.22 +**Repository URL**: https://github.com/GREsau/schemars +**License Type(s)**: MIT +### License: https://spdx.org/licenses/MIT.html +``` +MIT License + +Copyright (c) 2019 Graham Esau + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## schemars_derive - 0.8.22 +**Repository URL**: https://github.com/GREsau/schemars +**License Type(s)**: MIT +### License: https://spdx.org/licenses/MIT.html +``` +MIT License + +Copyright (c) 2019 Graham Esau + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + ## scopeguard - 1.2.0 **Repository URL**: https://github.com/bluss/scopeguard **License Type(s)**: Apache-2.0 @@ -26928,6 +27097,87 @@ limitations under the License. ``` +## serde_derive_internals - 0.29.1 +**Repository URL**: https://github.com/serde-rs/serde +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + ## serde_json - 1.0.149 **Repository URL**: https://github.com/serde-rs/json **License Type(s)**: Apache-2.0 diff --git a/Cargo.lock b/Cargo.lock index fdae05dc4..60db83d5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -396,6 +396,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -467,7 +479,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" dependencies = [ - "console", + "console 0.15.11", "shell-words", "thiserror 1.0.69", ] @@ -494,6 +506,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -1199,6 +1217,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-otlp", "opentelemetry_sdk", + "schemars", "serde", "serde_json", "thiserror 2.0.18", @@ -1240,6 +1259,7 @@ dependencies = [ "bytes", "clap", "clap_complete", + "console 0.16.3", "dialoguer", "futures-util", "http", @@ -2005,6 +2025,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2090,6 +2134,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.149" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 11e3edb5e..7d777b8b3 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -23,6 +23,7 @@ axum = "0.8" bytes = "1" clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" +console = "0.16" futures-util = "0.3" http = "1" http-body-util = "0.1" diff --git a/crates/cli/README.md b/crates/cli/README.md index 5ab4e5881..a07085702 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -103,19 +103,30 @@ Project config lives at `./.nemo-flow/config.toml`; user config lives at The project layer overrides system config, and the user layer overrides the project layer. -Exporter config uses nested per-backend tables: +Observability exporters are configured through the plugin config. Edit the user +plugin config with: + +```bash +nemo-flow plugins edit +``` + +The canonical plugin file is `plugins.toml`; user config lives at +`~/.config/nemo-flow/plugins.toml` or +`$XDG_CONFIG_HOME/nemo-flow/plugins.toml`. Project config lives at +`.nemo-flow/plugins.toml`. + +Minimal ATIF example: ```toml -[exporters.atif] -dir = "./atif" +version = 1 -[exporters.atof] -dir = "./atof" -mode = "append" -filename_template = "{session_id}.jsonl" +[[components]] +kind = "observability" +enabled = true -[exporters.openinference] -endpoint = "http://localhost:6006/v1/traces" +[components.config.atif] +enabled = true +output_directory = "./atif" ``` ## Documentation diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index afa6b3eca..508a9a2c1 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -1,12 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashSet; use std::net::SocketAddr; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use axum::http::HeaderMap; -use clap::{Args, Parser, Subcommand, ValueEnum}; -use nemo_flow::observability::atof::AtofExporterMode; +use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; use serde::Deserialize; use serde_json::Value; @@ -77,6 +77,8 @@ pub(crate) enum Command { Hermes(EasyPathCommand), /// Run the interactive setup (writes `.nemo-flow/config.toml`) Config(ConfigCommand), + /// Create or edit plugin configuration (writes `plugins.toml`) + Plugins(PluginsCommand), /// Diagnose env, agents, config, observability (optionally scoped to one agent) Doctor(DoctorCommand), /// List supported and locally-detected agents (use `--json` for machine output) @@ -146,6 +148,39 @@ pub(crate) struct ConfigCommand { pub(crate) reset: bool, } +/// Args for `nemo-flow plugins`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsCommand { + #[command(subcommand)] + pub(crate) command: PluginsSubcommand, +} + +/// Plugin configuration subcommands. +#[derive(Debug, Clone, Subcommand)] +pub(crate) enum PluginsSubcommand { + /// Interactively create or edit the Observability plugin in `plugins.toml`. + Edit(PluginsEditCommand), +} + +/// Args for `nemo-flow plugins edit`. +#[derive(Debug, Clone, Default, Args)] +#[command(group( + ArgGroup::new("scope") + .args(["user", "project", "global"]) + .multiple(false) +))] +pub(crate) struct PluginsEditCommand { + /// Edit the user config at `$XDG_CONFIG_HOME/nemo-flow/plugins.toml`. + #[arg(long)] + pub(crate) user: bool, + /// Edit the nearest project config at `.nemo-flow/plugins.toml`. + #[arg(long)] + pub(crate) project: bool, + /// Edit the system config at `/etc/nemo-flow/plugins.toml`. + #[arg(long)] + pub(crate) global: bool, +} + #[derive(Debug, Clone, Default, Args)] pub(crate) struct ServerArgs { /// Path to an explicit config file (disables auto-discovery of workspace/global/system) @@ -154,21 +189,12 @@ pub(crate) struct ServerArgs { /// Address for the gateway to listen on in daemon mode (default 127.0.0.1:4040) #[arg(long, env = "NEMO_FLOW_GATEWAY_BIND")] pub(crate) bind: Option, - /// Upstream OpenAI-compatible base URL (e.g. https://api.openai.com, NVIDIA inference) + /// Upstream OpenAI-compatible base URL (e.g. https://api.openai.com/v1, NVIDIA inference) #[arg(long, env = "NEMO_FLOW_OPENAI_BASE_URL")] pub(crate) openai_base_url: Option, /// Upstream Anthropic base URL (e.g. https://api.anthropic.com) #[arg(long, env = "NEMO_FLOW_ANTHROPIC_BASE_URL")] pub(crate) anthropic_base_url: Option, - /// Directory to write ATIF trajectory JSON files into per session - #[arg(long, env = "NEMO_FLOW_ATIF_DIR")] - pub(crate) atif_dir: Option, - /// Directory to write per-event ATOF JSONL files into (one event per line, raw ATOF shape) - #[arg(long, env = "NEMO_FLOW_ATOF_DIR")] - pub(crate) atof_dir: Option, - /// OpenInference-compatible OTLP HTTP endpoint for streaming spans (Phoenix, Arize, etc.) - #[arg(long, env = "NEMO_FLOW_OPENINFERENCE_ENDPOINT")] - pub(crate) openinference_endpoint: Option, /// Generic plugin configuration JSON for process-level gateway plugin activation. #[arg(long, env = "NEMO_FLOW_PLUGIN_CONFIG")] pub(crate) plugin_config: Option, @@ -184,9 +210,6 @@ impl ServerArgs { self.bind.is_some() || self.openai_base_url.is_some() || self.anthropic_base_url.is_some() - || self.atif_dir.is_some() - || self.atof_dir.is_some() - || self.openinference_endpoint.is_some() || self.plugin_config.is_some() || self.config.is_some() } @@ -197,48 +220,10 @@ pub(crate) struct GatewayConfig { pub(crate) bind: SocketAddr, pub(crate) openai_base_url: String, pub(crate) anthropic_base_url: String, - pub(crate) exporters: ExportersConfig, pub(crate) metadata: Option, pub(crate) plugin_config: Option, } -/// Sinks the gateway writes observability data to. Each exporter has its own nested config so -/// exporter-specific options (for example ATOF append/overwrite behavior) do not get flattened -/// into unrelated backends. -#[derive(Debug, Clone, Default)] -pub(crate) struct ExportersConfig { - pub(crate) atif: AtifExporterSettings, - pub(crate) atof: AtofExporterSettings, - pub(crate) openinference: OpenInferenceExporterSettings, -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct AtifExporterSettings { - pub(crate) dir: Option, -} - -#[derive(Debug, Clone)] -pub(crate) struct AtofExporterSettings { - pub(crate) dir: Option, - pub(crate) mode: AtofExporterMode, - pub(crate) filename_template: String, -} - -impl Default for AtofExporterSettings { - fn default() -> Self { - Self { - dir: None, - mode: AtofExporterMode::Append, - filename_template: "{session_id}.jsonl".into(), - } - } -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct OpenInferenceExporterSettings { - pub(crate) endpoint: Option, -} - #[derive(Debug, Clone, Args)] pub(crate) struct HookForwardCommand { #[arg(value_enum)] @@ -246,12 +231,6 @@ pub(crate) struct HookForwardCommand { #[arg(long)] pub(crate) gateway_url: Option, #[arg(long)] - pub(crate) atif_dir: Option, - #[arg(long)] - pub(crate) atof_dir: Option, - #[arg(long)] - pub(crate) openinference_endpoint: Option, - #[arg(long)] pub(crate) profile: Option, #[arg(long)] pub(crate) session_metadata: Option, @@ -265,9 +244,8 @@ pub(crate) struct HookForwardCommand { /// Args for the easy-path agent shortcut (`nemo-flow claude`, `nemo-flow codex`, etc.). /// Holds only pass-through agent args; the agent itself is selected by which subcommand variant -/// is invoked, and all observability/upstream settings come from the resolved config file. If no -/// config file is present, the dispatcher fires setup (Phase 3). Phase 2 errors with a -/// pointer to `nemo-flow config` since setup isn't wired up yet. +/// is invoked, and upstream settings come from the resolved config file. If no config file is +/// present, the dispatcher fires setup. #[derive(Debug, Clone, Args)] pub(crate) struct EasyPathCommand { /// Pass-through args forwarded to the underlying agent process. Use `--` to separate them @@ -287,12 +265,6 @@ pub(crate) struct RunCommand { #[arg(long)] pub(crate) anthropic_base_url: Option, #[arg(long)] - pub(crate) atif_dir: Option, - #[arg(long)] - pub(crate) atof_dir: Option, - #[arg(long)] - pub(crate) openinference_endpoint: Option, - #[arg(long)] pub(crate) session_metadata: Option, #[arg(long)] pub(crate) plugin_config: Option, @@ -327,7 +299,6 @@ pub(crate) enum GatewayMode { #[derive(Debug, Clone, Default)] pub(crate) struct SessionConfig { - pub(crate) exporters: ExportersConfig, pub(crate) metadata: Option, pub(crate) plugin_config: Option, pub(crate) profile: Option, @@ -339,23 +310,6 @@ impl GatewayConfig { // Header JSON fields are parsed opportunistically; invalid JSON is treated as absent here // because install and hook-forward validate generated header values before sending them. pub(crate) fn session_config_from_headers(&self, headers: &HeaderMap) -> SessionConfig { - let exporters = ExportersConfig { - atif: AtifExporterSettings { - dir: header_string(headers, "x-nemo-flow-atif-dir") - .map(PathBuf::from) - .or_else(|| self.exporters.atif.dir.clone()), - }, - atof: AtofExporterSettings { - dir: header_string(headers, "x-nemo-flow-atof-dir") - .map(PathBuf::from) - .or_else(|| self.exporters.atof.dir.clone()), - ..self.exporters.atof.clone() - }, - openinference: OpenInferenceExporterSettings { - endpoint: header_string(headers, "x-nemo-flow-openinference-endpoint") - .or_else(|| self.exporters.openinference.endpoint.clone()), - }, - }; let metadata = header_json(headers, "x-nemo-flow-session-metadata").or_else(|| self.metadata.clone()); let plugin_config = header_json(headers, "x-nemo-flow-plugin-config") @@ -363,7 +317,6 @@ impl GatewayConfig { let profile = header_string(headers, "x-nemo-flow-config-profile"); let gateway_mode = header_string(headers, "x-nemo-flow-gateway-mode"); SessionConfig { - exporters, metadata, plugin_config, profile, @@ -418,9 +371,6 @@ impl Default for CursorAgentConfig { #[derive(Debug, Clone, Default, Deserialize)] struct FileConfig { upstream: Option, - exporters: Option, - observability: Option, - export: Option, plugins: Option, agents: Option, } @@ -431,48 +381,6 @@ struct FileUpstreamConfig { anthropic_base_url: Option, } -#[derive(Debug, Clone, Default, Deserialize)] -struct FileObservabilityConfig { - atif_dir: Option, - atof_dir: Option, - metadata: Option, -} - -#[derive(Debug, Clone, Default, Deserialize)] -struct FileExportersConfig { - atif: Option, - atof: Option, - openinference: Option, - // Legacy flat `[exporters]` keys from early CLI builds. - atif_dir: Option, - atof_dir: Option, - openinference_endpoint: Option, -} - -#[derive(Debug, Clone, Default, Deserialize)] -struct FileAtifExporterConfig { - dir: Option, -} - -#[derive(Debug, Clone, Default, Deserialize)] -struct FileAtofExporterConfig { - dir: Option, - mode: Option, - filename_template: Option, -} - -// Legacy `[export.]` shape. New configs use `[exporters]`; this stays readable so -// existing user files do not break. -#[derive(Debug, Clone, Default, Deserialize)] -struct FileExportConfig { - openinference: Option, -} - -#[derive(Debug, Clone, Default, Deserialize)] -struct FileOpenInferenceConfig { - endpoint: Option, -} - #[derive(Debug, Clone, Default, Deserialize)] struct FilePluginsConfig { // Generic plugin initialization shape. The gateway activates this process-wide at startup. @@ -504,16 +412,15 @@ struct FileCursorAgentConfig { impl Default for GatewayConfig { // Supplies conservative local gateway defaults: bind only to loopback, route OpenAI and - // Anthropic requests to their public bases, and leave exporters/plugins disabled until config, + // Anthropic requests to their public bases, and leave plugins disabled until config, // environment, or headers explicitly opt in. fn default() -> Self { Self { bind: "127.0.0.1:4040" .parse() .expect("valid default bind address"), - openai_base_url: "https://api.openai.com".into(), + openai_base_url: "https://api.openai.com/v1".into(), anthropic_base_url: "https://api.anthropic.com".into(), - exporters: ExportersConfig::default(), metadata: None, plugin_config: None, } @@ -546,7 +453,7 @@ pub(crate) fn resolve_run_config( let mut resolved = load_shared_config(config)?; if let Some(args) = inherited { // Run-subcommand plugin config has higher precedence than inherited top-level plugin - // config. Skip only that inherited field so file/plugin.toml conflicts are still caught + // config. Skip only that inherited field so file/plugins.toml conflicts are still caught // when the run-level override is applied below. if command.plugin_config.is_some() && args.plugin_config.is_some() { let mut inherited = args.clone(); @@ -580,15 +487,6 @@ fn apply_run_url_overrides(config: &mut GatewayConfig, command: &RunCommand) { if let Some(value) = &command.anthropic_base_url { config.anthropic_base_url = value.clone(); } - if let Some(value) = &command.atif_dir { - config.exporters.atif.dir = Some(value.clone()); - } - if let Some(value) = &command.atof_dir { - config.exporters.atof.dir = Some(value.clone()); - } - if let Some(value) = &command.openinference_endpoint { - config.exporters.openinference.endpoint = Some(value.clone()); - } } // Parses JSON-bearing run overrides after simple values. Invalid metadata or plugin config fails @@ -618,23 +516,16 @@ fn apply_server_overrides(config: &mut GatewayConfig, args: &ServerArgs) -> Resu if let Some(value) = &args.anthropic_base_url { config.anthropic_base_url = value.clone(); } - if let Some(value) = &args.atif_dir { - config.exporters.atif.dir = Some(value.clone()); - } - if let Some(value) = &args.atof_dir { - config.exporters.atof.dir = Some(value.clone()); - } - if let Some(value) = &args.openinference_endpoint { - config.exporters.openinference.endpoint = Some(value.clone()); - } if let Some(value) = &args.plugin_config { apply_cli_plugin_config(config, value)?; } Ok(()) } +const PLUGINS_TOML: &str = "plugins.toml"; + // Loads config from the ordered shared locations, deep-merges TOML tables, maps the typed file -// shape onto runtime structs, applies a sibling/discovered plugin.toml when present, then lets +// shape onto runtime structs, applies a sibling/discovered plugins.toml when present, then lets // environment variables override file values. Invalid TOML or typed shapes fail closed because // they indicate an operator configuration error. fn load_shared_config(explicit: Option<&PathBuf>) -> Result { @@ -649,6 +540,15 @@ fn load_shared_config(explicit: Option<&PathBuf>) -> Result) -> Result 1 { return Err(CliError::Config(format!( "plugin config is defined in multiple config.toml files: {}; move it to one \ - [plugins].config block or use plugin.toml", + [plugins].config block or use plugins.toml", format_paths(&config_toml_plugin_sources) ))); } @@ -702,14 +602,14 @@ fn config_paths(explicit: Option<&PathBuf>) -> Vec { paths } -// Returns the plugin config search path. An explicit gateway config path scopes plugin.toml to the -// same directory so `--config path/to/config.toml` can be extended by `path/to/plugin.toml` without +// Returns the plugin config search path. An explicit gateway config path scopes plugins.toml to the +// same directory so `--config path/to/config.toml` can be extended by `path/to/plugins.toml` without // reading unrelated implicit project/user/global plugin files. fn plugin_config_paths(explicit: Option<&PathBuf>) -> Vec { if let Some(path) = explicit { return path .parent() - .map(|parent| vec![parent.join("plugin.toml")]) + .map(|parent| vec![parent.join(PLUGINS_TOML)]) .unwrap_or_default(); } implicit_plugin_config_paths(std::env::current_dir().ok().as_deref(), user_config_dir()) @@ -721,14 +621,14 @@ fn implicit_plugin_config_paths( ) -> Vec { // Ordered from lowest to highest precedence. User-level plugin config intentionally loads last // so an operator can override project-local plugin defaults without editing the checkout. - let mut paths = vec![PathBuf::from("/etc/nemo-flow/plugin.toml")]; + let mut paths = vec![PathBuf::from("/etc/nemo-flow").join(PLUGINS_TOML)]; if let Some(cwd) = cwd && let Some(project) = find_project_plugin_config(cwd) { paths.push(project); } if let Some(user) = user_config_dir { - paths.push(user.join("plugin.toml")); + paths.push(user.join(PLUGINS_TOML)); } paths } @@ -748,7 +648,7 @@ fn find_project_config(start: &std::path::Path) -> Option { // Walks upward from the current directory and returns the nearest project-local plugin config. fn find_project_plugin_config(start: &std::path::Path) -> Option { for ancestor in start.ancestors() { - let path = ancestor.join(".nemo-flow/plugin.toml"); + let path = ancestor.join(".nemo-flow").join(PLUGINS_TOML); if path.exists() { return Some(path); } @@ -756,6 +656,23 @@ fn find_project_plugin_config(start: &std::path::Path) -> Option { None } +pub(crate) fn user_plugin_config_path() -> Option { + user_config_dir().map(|dir| dir.join(PLUGINS_TOML)) +} + +pub(crate) fn project_plugin_config_path(start: &std::path::Path) -> PathBuf { + find_project_plugin_config(start) + .or_else(|| { + find_project_config(start) + .and_then(|path| path.parent().map(|parent| parent.join(PLUGINS_TOML))) + }) + .unwrap_or_else(|| start.join(".nemo-flow").join(PLUGINS_TOML)) +} + +pub(crate) fn global_plugin_config_path() -> PathBuf { + PathBuf::from("/etc/nemo-flow").join(PLUGINS_TOML) +} + // Resolves the user config using XDG first and HOME/USERPROFILE second. Returning `None` keeps // config loading portable in minimal environments where no home directory is visible. fn user_config_path() -> Option { @@ -781,9 +698,6 @@ fn apply_file_config(resolved: &mut ResolvedConfig, value: toml::Value) -> Resul CliError::Config(format!("invalid gateway configuration shape: {error}")) })?; apply_file_upstream_config(&mut resolved.gateway, config.upstream); - apply_file_observability_config(&mut resolved.gateway, config.observability); - apply_file_export_config(&mut resolved.gateway, config.export); - apply_file_exporters_config(&mut resolved.gateway, config.exporters)?; apply_file_plugins_config(&mut resolved.gateway, config.plugins); apply_file_agents_config(&mut resolved.agents, config.agents); Ok(()) @@ -803,85 +717,6 @@ fn apply_file_upstream_config(gateway: &mut GatewayConfig, upstream: Option, -) { - let Some(observability) = observability else { - return; - }; - if let Some(value) = observability.atif_dir { - gateway.exporters.atif.dir = Some(value); - } - if let Some(value) = observability.atof_dir { - gateway.exporters.atof.dir = Some(value); - } - if let Some(value) = observability.metadata { - gateway.metadata = Some(value); - } -} - -// Applies legacy optional OpenInference export config. New configs use `[exporters]`. -fn apply_file_export_config(gateway: &mut GatewayConfig, export: Option) { - let Some(export) = export else { - return; - }; - if let Some(openinference) = export.openinference - && let Some(value) = openinference.endpoint - { - gateway.exporters.openinference.endpoint = Some(value); - } -} - -// Applies the current exporter config shape. This runs after the legacy shapes so `[exporters]` -// wins when a file contains both old and new keys. -fn apply_file_exporters_config( - gateway: &mut GatewayConfig, - exporters: Option, -) -> Result<(), CliError> { - let Some(exporters) = exporters else { - return Ok(()); - }; - if let Some(value) = exporters.atif_dir { - gateway.exporters.atif.dir = Some(value); - } - if let Some(value) = exporters.atof_dir { - gateway.exporters.atof.dir = Some(value); - } - if let Some(value) = exporters.openinference_endpoint { - gateway.exporters.openinference.endpoint = Some(value); - } - if let Some(atif) = exporters.atif - && let Some(value) = atif.dir - { - gateway.exporters.atif.dir = Some(value); - } - if let Some(atof) = exporters.atof { - if let Some(value) = atof.dir { - gateway.exporters.atof.dir = Some(value); - } - if let Some(value) = atof.mode { - gateway.exporters.atof.mode = AtofExporterMode::parse(&value).ok_or_else(|| { - CliError::Config(format!( - "invalid [exporters.atof].mode `{value}`; expected append or overwrite" - )) - })?; - } - if let Some(value) = atof.filename_template { - gateway.exporters.atof.filename_template = value; - } - } - if let Some(openinference) = exporters.openinference - && let Some(value) = openinference.endpoint - { - gateway.exporters.openinference.endpoint = Some(value); - } - Ok(()) -} - // Applies plugin config. The gateway activates process-level plugin config at startup; hook headers // still carry the value as session metadata until scoped plugin activation exists. fn apply_file_plugins_config(gateway: &mut GatewayConfig, plugins: Option) { @@ -923,6 +758,7 @@ where path.display() )) })?; + validate_plugin_toml_component_kinds(&path, &parsed)?; merge_plugin_toml(&mut merged, parsed); sources.push(path); } @@ -990,7 +826,7 @@ fn apply_file_agents_config(agents: &mut AgentConfigs, file_agents: Option Option<&str> { .and_then(toml::Value::as_str) } +fn validate_plugin_toml_component_kinds(path: &Path, value: &toml::Value) -> Result<(), CliError> { + let Some(components) = value.get("components").and_then(toml::Value::as_array) else { + return Ok(()); + }; + let mut seen = HashSet::new(); + let mut duplicates = Vec::new(); + for component in components { + let Some(kind) = component_kind(component) else { + continue; + }; + if !seen.insert(kind.to_string()) { + duplicates.push(kind.to_string()); + } + } + duplicates.sort(); + duplicates.dedup(); + if duplicates.is_empty() { + Ok(()) + } else { + Err(CliError::Config(format!( + "duplicate plugin component kind in {}: {}; declare each kind once per plugins.toml", + path.display(), + duplicates.join(", ") + ))) + } +} + fn has_config_toml_plugin_config(value: &toml::Value) -> bool { value .get("plugins") @@ -1092,6 +946,24 @@ fn has_config_toml_plugin_config(value: &toml::Value) -> bool { .is_some() } +fn legacy_observability_sections(value: &toml::Value) -> Vec<&'static str> { + let mut sections = Vec::new(); + if value.get("exporters").is_some() { + sections.push("[exporters]"); + } + if value.get("observability").is_some() { + sections.push("[observability]"); + } + if value + .get("export") + .and_then(|export| export.get("openinference")) + .is_some() + { + sections.push("[export.openinference]"); + } + sections +} + fn format_paths(paths: &[PathBuf]) -> String { paths .iter() diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/doctor.rs index 35c39c24a..1eded232a 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/doctor.rs @@ -13,7 +13,10 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::Duration; +use nemo_flow::observability::plugin_component::OBSERVABILITY_PLUGIN_KIND; +use nemo_flow::plugin::{DiagnosticLevel, PluginConfig, validate_plugin_config}; use serde::Serialize; +use serde_json::Value; use tokio::time::timeout; use crate::config::{ @@ -466,74 +469,156 @@ async fn probe_version(binary: &Path) -> Option { async fn collect_observability(gateway: &GatewayConfig) -> Vec { let mut checks = Vec::new(); - checks.push(match &gateway.exporters.atif.dir { - None => Check { - name: "ATIF dir", + let Some(plugin_value) = &gateway.plugin_config else { + checks.push(Check { + name: "Plugins", status: Status::Info, - details: "not configured".into(), - }, - Some(path) => match check_dir_writable(path) { - Ok(()) => Check { - name: "ATIF dir", - status: Status::Pass, - details: format!("{} (appears writable)", path.display()), - }, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Check { - name: "ATIF dir", - status: Status::Warn, - details: format!( - "{}: not present; runtime will create it on export", - path.display() - ), - }, - Err(err) => Check { - name: "ATIF dir", - status: Status::Fail, - details: format!("{}: {err}", path.display()), - }, - }, - }); + details: "plugins.toml not configured".into(), + }); + return checks; + }; - checks.push(match &gateway.exporters.atof.dir { - None => Check { - name: "ATOF dir", - status: Status::Info, - details: "not configured".into(), - }, - Some(path) => match check_dir_writable(path) { - Ok(()) => Check { - name: "ATOF dir", - status: Status::Pass, - details: format!("{} (appears writable)", path.display()), - }, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Check { - name: "ATOF dir", - status: Status::Warn, - details: format!( - "{}: not present; runtime will create it on first event", - path.display() - ), - }, - Err(err) => Check { - name: "ATOF dir", + let plugin_config = match serde_json::from_value::(plugin_value.clone()) { + Ok(config) => config, + Err(err) => { + checks.push(Check { + name: "Plugins", status: Status::Fail, - details: format!("{}: {err}", path.display()), - }, - }, - }); + details: format!("invalid plugin config: {err}"), + }); + return checks; + } + }; + let report = validate_plugin_config(&plugin_config); + if report.diagnostics.is_empty() { + checks.push(Check { + name: "Plugins", + status: Status::Pass, + details: "validation passed".into(), + }); + } else { + for diagnostic in report.diagnostics { + checks.push(Check { + name: "Plugin diagnostic", + status: if diagnostic.level == DiagnosticLevel::Error { + Status::Fail + } else { + Status::Warn + }, + details: format!("{}: {}", diagnostic.code, diagnostic.message), + }); + } + } - checks.push(match &gateway.exporters.openinference.endpoint { - None => Check { - name: "OpenInference endpoint", + if let Some(config) = observability_component_config(plugin_value) { + collect_observability_component_checks(&mut checks, config).await; + } else { + checks.push(Check { + name: "Observability plugin", status: Status::Info, - details: "not configured".into(), - }, - Some(url) => probe_http(url).await, - }); + details: "component not configured".into(), + }); + } checks } +async fn collect_observability_component_checks(checks: &mut Vec, config: &Value) { + for section in ["atof", "atif"] { + if section_enabled(config, section) { + let label = if section == "atof" { + "ATOF dir" + } else { + "ATIF dir" + }; + match section_output_directory(config, section) { + Some(path) => checks.push(check_directory(label, &path)), + None => checks.push(Check { + name: label, + status: Status::Info, + details: "enabled; using runtime default output directory".into(), + }), + } + } + } + for section in ["opentelemetry", "openinference"] { + if section_enabled(config, section) { + let label = if section == "opentelemetry" { + "OpenTelemetry endpoint" + } else { + "OpenInference endpoint" + }; + match section_endpoint(config, section) { + Some(endpoint) => checks.push(probe_http_named(label, &endpoint).await), + None => checks.push(Check { + name: label, + status: Status::Info, + details: "enabled; using exporter default endpoint".into(), + }), + } + } + } +} + +fn observability_component_config(plugin_value: &Value) -> Option<&Value> { + plugin_value + .get("components") + .and_then(Value::as_array) + .and_then(|components| { + components.iter().find(|component| { + component + .get("kind") + .and_then(Value::as_str) + .is_some_and(|kind| kind == OBSERVABILITY_PLUGIN_KIND) + }) + }) + .and_then(|component| component.get("config")) +} + +fn section_enabled(config: &Value, section: &str) -> bool { + config + .get(section) + .and_then(|section| section.get("enabled")) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn section_output_directory(config: &Value, section: &str) -> Option { + config + .get(section) + .and_then(|section| section.get("output_directory")) + .and_then(Value::as_str) + .map(PathBuf::from) +} + +fn section_endpoint(config: &Value, section: &str) -> Option { + config + .get(section) + .and_then(|section| section.get("endpoint")) + .and_then(Value::as_str) + .map(str::to_string) +} + +fn check_directory(name: &'static str, path: &Path) -> Check { + match check_dir_writable(path) { + Ok(()) => Check { + name, + status: Status::Pass, + details: format!("{} (appears writable)", path.display()), + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Check { + name, + status: Status::Warn, + details: format!("{}: not present; runtime will create it", path.display()), + }, + Err(err) => Check { + name, + status: Status::Fail, + details: format!("{}: {err}", path.display()), + }, + } +} + fn check_dir_writable(dir: &Path) -> Result<(), std::io::Error> { let metadata = std::fs::metadata(dir)?; if !metadata.is_dir() { @@ -551,12 +636,12 @@ fn check_dir_writable(dir: &Path) -> Result<(), std::io::Error> { Ok(()) } -async fn probe_http(url: &str) -> Check { +async fn probe_http_named(name: &'static str, url: &str) -> Check { let client = match reqwest::Client::builder().timeout(NETWORK_TIMEOUT).build() { Ok(c) => c, Err(err) => { return Check { - name: "OpenInference endpoint", + name, status: Status::Fail, details: format!("could not build HTTP client: {err}"), }; @@ -564,7 +649,7 @@ async fn probe_http(url: &str) -> Check { }; match client.get(url).send().await { Ok(resp) => Check { - name: "OpenInference endpoint", + name, status: if resp.status().is_success() || resp.status().is_redirection() { Status::Pass } else { @@ -573,7 +658,7 @@ async fn probe_http(url: &str) -> Check { details: format!("{} (HTTP {})", url, resp.status().as_u16()), }, Err(err) => Check { - name: "OpenInference endpoint", + name, status: Status::Fail, details: format!("{url}: {err}"), }, diff --git a/crates/cli/src/gateway.rs b/crates/cli/src/gateway.rs index b0561a8b1..b03ade053 100644 --- a/crates/cli/src/gateway.rs +++ b/crates/cli/src/gateway.rs @@ -577,8 +577,8 @@ async fn forward_upstream_request( upstream.send().await } -// Builds the upstream URL for the ChatGPT backend. Codex's standard base URL is -// `api.openai.com/v1` (the `/v1` is part of the base), while the ChatGPT backend base is +// Builds the upstream URL for the ChatGPT backend. OpenAI API bases commonly include `/v1`, while +// the ChatGPT backend base is // `chatgpt.com/backend-api/codex` (no `/v1`). Both append `/responses` to their base, so the // ChatGPT path is `.../codex/responses`, not `.../codex/v1/responses`. Strip any `/v1` prefix // that the gateway's route matcher may have included from the inbound request path. @@ -863,10 +863,8 @@ impl ProviderRoute { fn upstream_url_with_base(self, base: &str, path_and_query: &str) -> String { let base = base.trim_end_matches('/'); let path_and_query = match self { - Self::OpenAiResponses | Self::OpenAiChatCompletions | Self::OpenAiModels - if !path_and_query.starts_with("/v1/") => - { - format!("/v1{path_and_query}") + Self::OpenAiResponses | Self::OpenAiChatCompletions | Self::OpenAiModels => { + normalize_openai_path_for_base(base, path_and_query) } _ => path_and_query.to_string(), }; @@ -874,6 +872,17 @@ impl ProviderRoute { } } +fn normalize_openai_path_for_base(base: &str, path_and_query: &str) -> String { + match (base.ends_with("/v1"), path_and_query.starts_with("/v1/")) { + (true, true) => path_and_query + .strip_prefix("/v1") + .expect("path was checked to start with /v1") + .to_string(), + (false, false) => format!("/v1{path_and_query}"), + _ => path_and_query.to_string(), + } +} + // Reads the gateway session id from explicit gateway headers first, with Claude's session header // accepted for compatibility with Claude Code environments that already propagate it. fn gateway_session_id(headers: &HeaderMap) -> Option { diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index 990189250..973c0320c 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -132,8 +132,6 @@ async fn send_hook_forward_request( .build()? .post(url) .headers(gateway_headers( - command.atif_dir.as_deref(), - command.openinference_endpoint.as_deref(), command.profile.as_deref(), command.session_metadata.as_deref(), command.plugin_config.as_deref(), @@ -402,20 +400,12 @@ fn validate_optional_json(name: &str, value: Option<&str>) -> Result<(), CliErro // Converts optional session/export/gateway settings into gateway headers for hook-forward. Each // absent value is omitted so the server can fall back to file, environment, or default config. fn gateway_headers( - atif_dir: Option<&Path>, - openinference_endpoint: Option<&str>, profile: Option<&str>, session_metadata: Option<&str>, plugin_config: Option<&str>, gateway_mode: Option, ) -> Result { let mut headers = HeaderMap::new(); - insert_header_path(&mut headers, "x-nemo-flow-atif-dir", atif_dir)?; - insert_header( - &mut headers, - "x-nemo-flow-openinference-endpoint", - openinference_endpoint, - )?; insert_header(&mut headers, "x-nemo-flow-config-profile", profile)?; insert_header( &mut headers, @@ -448,21 +438,6 @@ fn insert_header( Ok(()) } -// Converts an optional filesystem path to a header value using loss-tolerant display text. This -// mirrors hook-forward behavior, where paths are passed as strings. -fn insert_header_path( - headers: &mut HeaderMap, - name: &'static str, - value: Option<&Path>, -) -> Result<(), CliError> { - if let Some(value) = value { - let value = value.to_string_lossy(); - insert_header(headers, name, Some(value.as_ref())) - } else { - Ok(()) - } -} - #[cfg(test)] #[path = "../tests/coverage/installer_tests.rs"] mod tests; diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index c6efc2322..f5d575a97 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -70,9 +70,6 @@ pub(crate) async fn easy_path( config: explicit_config.map(std::path::Path::to_path_buf), openai_base_url: None, anthropic_base_url: None, - atif_dir: None, - atof_dir: None, - openinference_endpoint: None, session_metadata: None, plugin_config: None, dry_run: false, @@ -521,22 +518,14 @@ impl PreparedRun { let mut lines: Vec = Vec::new(); lines.push(format!("NeMo Flow → {}", agent.as_arg())); lines.push(format!(" Gateway {gateway_url}")); - match &resolved.gateway.exporters.atif.dir { - Some(path) => lines.push(format!(" ATIF {}", path.display())), - None => lines.push(" ATIF (disabled)".to_string()), - } - match &resolved.gateway.exporters.atof.dir { - Some(path) => lines.push(format!( - " ATOF {} ({})", - path.display(), - resolved.gateway.exporters.atof.mode.as_str() - )), - None => lines.push(" ATOF (disabled)".to_string()), - } - match &resolved.gateway.exporters.openinference.endpoint { - Some(endpoint) => lines.push(format!(" OpenInference {endpoint}")), - None => lines.push(" OpenInference (disabled)".to_string()), - } + lines.push(format!( + " Plugins {}", + if resolved.gateway.plugin_config.is_some() { + "configured" + } else { + "not configured" + } + )); if !self.notes.is_empty() { lines.push(String::new()); for note in &self.notes { @@ -576,23 +565,14 @@ impl PreparedRun { "anthropic_base_url = {}", resolved.gateway.anthropic_base_url ); - if let Some(path) = &resolved.gateway.exporters.atif.dir { - println!("atif_dir = {}", path.display()); - } - if let Some(path) = &resolved.gateway.exporters.atof.dir { - println!("atof_dir = {}", path.display()); - println!( - "atof_mode = {}", - resolved.gateway.exporters.atof.mode.as_str() - ); - println!( - "atof_filename_template = {}", - resolved.gateway.exporters.atof.filename_template - ); - } - if let Some(endpoint) = &resolved.gateway.exporters.openinference.endpoint { - println!("openinference_endpoint = {endpoint}"); - } + println!( + "plugins = {}", + if resolved.gateway.plugin_config.is_some() { + "configured" + } else { + "not_configured" + } + ); println!("argv = {}", self.argv.join(" ")); for (name, value) in &self.env { println!("env.{name} = {value}"); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 52500f044..4122ac49e 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -13,6 +13,7 @@ mod gateway; mod installer; mod launcher; mod model; +mod plugins; mod server; mod session; mod setup; @@ -21,7 +22,7 @@ use std::process::ExitCode; use clap::Parser; -use crate::config::{Cli, CodingAgent, Command}; +use crate::config::{Cli, CodingAgent, Command, PluginsSubcommand}; #[tokio::main] // Runs the async CLI entrypoint and converts any surfaced gateway error into a non-zero process @@ -67,6 +68,12 @@ async fn run() -> Result { } Ok(ExitCode::SUCCESS) } + Some(Command::Plugins(command)) => { + match command.command { + PluginsSubcommand::Edit(command) => plugins::edit(command)?, + } + Ok(ExitCode::SUCCESS) + } Some(Command::Doctor(command)) => doctor::run_doctor(command.agent, command.json).await, Some(Command::Agents(command)) => doctor::run_agents(command.json).await, Some(Command::Completions(command)) => { diff --git a/crates/cli/src/plugins.rs b/crates/cli/src/plugins.rs new file mode 100644 index 000000000..dd1f75c05 --- /dev/null +++ b/crates/cli/src/plugins.rs @@ -0,0 +1,945 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Interactive plugin configuration editing. + +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; + +use console::{Key, Term}; +use dialoguer::theme::ColorfulTheme; +use dialoguer::{Input, Select}; +use nemo_flow::config_editor::{EditorConfig, EditorFieldKind, EditorFieldSpec}; +use nemo_flow::observability::plugin_component::{OBSERVABILITY_PLUGIN_KIND, ObservabilityConfig}; +use nemo_flow::plugin::{ConfigPolicy, PluginComponentSpec, PluginConfig, validate_plugin_config}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::{Map, Value, json}; + +use crate::config::{ + PluginsEditCommand, global_plugin_config_path, project_plugin_config_path, + user_plugin_config_path, +}; +use crate::error::CliError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TargetScope { + User, + Project, + Global, +} + +const POLICY_SECTION: &str = "policy"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MenuShortcut { + Preview, + Save, + Help, + Reset, + Clear, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MenuResponse { + Selected(usize), + Shortcut(MenuShortcut, usize), + Cancel, +} + +#[derive(Debug)] +struct MenuItem { + label: String, +} + +impl MenuItem { + fn new(label: impl Into) -> Self { + Self { + label: label.into(), + } + } +} + +pub(crate) fn edit(command: PluginsEditCommand) -> Result<(), CliError> { + ensure_tty()?; + let scope = target_scope(&command)?; + let path = target_path(scope)?; + let mut config = read_plugin_config(&path)?; + ensure_observability_component(&mut config)?; + let mut observability = component_observability_config(&config)?; + + let theme = ColorfulTheme::default(); + loop { + let summary = observability_summary(&config, &observability); + let section_fields = ObservabilityConfig::editor_schema().fields; + let mut items = vec![MenuItem::new(format!( + "Toggle Observability component [{}]", + if component_enabled(&config) { + "on" + } else { + "off" + } + ))]; + items.extend( + section_fields + .iter() + .map(|section| MenuItem::new(format!("Edit {}", section.label))), + ); + items.push(MenuItem::new("Preview TOML [p]")); + items.push(MenuItem::new(format!("Save to {} [s]", path.display()))); + items.push(MenuItem::new("Cancel [q]")); + println!(); + println!("Observability: {summary}"); + let preview_index = section_fields.len() + 1; + let save_index = section_fields.len() + 2; + let cancel_index = section_fields.len() + 3; + let selection = prompt_menu("plugins.toml", &items, 0)?; + match selection { + MenuResponse::Selected(0) => { + let enabled = !component_enabled(&config); + set_component_enabled(&mut config, enabled); + } + MenuResponse::Selected(selection) + if (1..=section_fields.len()).contains(&selection) => + { + edit_section(&theme, &mut observability, section_fields[selection - 1])? + } + MenuResponse::Selected(selection) if selection == preview_index => { + let preview_config = config_with_observability(&config, &observability)?; + print_preview(&preview_config)?; + } + MenuResponse::Selected(selection) if selection == save_index => { + store_observability_config(&mut config, &observability)?; + validate_config(&config)?; + write_plugin_config(&path, &config)?; + println!(" Saved {}", path.display()); + return Ok(()); + } + MenuResponse::Selected(selection) if selection == cancel_index => { + return Err(CliError::Config( + "plugin edit cancelled; no config saved".into(), + )); + } + MenuResponse::Shortcut(MenuShortcut::Preview, _) => { + let preview_config = config_with_observability(&config, &observability)?; + print_preview(&preview_config)?; + } + MenuResponse::Shortcut(MenuShortcut::Save, _) => { + store_observability_config(&mut config, &observability)?; + validate_config(&config)?; + write_plugin_config(&path, &config)?; + println!(" Saved {}", path.display()); + return Ok(()); + } + MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), + MenuResponse::Shortcut(MenuShortcut::Reset | MenuShortcut::Clear, _) => { + println!("Select a section first, then use reset or clear on a field."); + } + MenuResponse::Cancel | MenuResponse::Selected(_) => { + return Err(CliError::Config( + "plugin edit cancelled; no config saved".into(), + )); + } + } + } +} + +fn prompt_menu(prompt: &str, items: &[MenuItem], default: usize) -> Result { + if items.is_empty() { + return Err(CliError::Config(format!("{prompt} menu has no items"))); + } + let term = Term::stdout(); + let mut selected = default.min(items.len() - 1); + let mut rendered_lines = 0; + loop { + if rendered_lines > 0 { + term.clear_last_lines(rendered_lines).map_err(menu_error)?; + } + let lines = render_menu(prompt, items, selected); + rendered_lines = lines.len(); + for line in &lines { + term.write_line(line).map_err(menu_error)?; + } + term.flush().map_err(menu_error)?; + match term.read_key().map_err(menu_error)? { + Key::ArrowUp | Key::Char('k') => { + selected = if selected == 0 { + items.len() - 1 + } else { + selected - 1 + }; + } + Key::ArrowDown | Key::Char('j') => { + selected = (selected + 1) % items.len(); + } + Key::Enter | Key::Char(' ') => { + clear_menu(&term, rendered_lines)?; + return Ok(MenuResponse::Selected(selected)); + } + Key::Char('p') => { + clear_menu(&term, rendered_lines)?; + return Ok(MenuResponse::Shortcut(MenuShortcut::Preview, selected)); + } + Key::Char('s') => { + clear_menu(&term, rendered_lines)?; + return Ok(MenuResponse::Shortcut(MenuShortcut::Save, selected)); + } + Key::Char('r') => { + clear_menu(&term, rendered_lines)?; + return Ok(MenuResponse::Shortcut(MenuShortcut::Reset, selected)); + } + Key::Backspace | Key::Del => { + clear_menu(&term, rendered_lines)?; + return Ok(MenuResponse::Shortcut(MenuShortcut::Clear, selected)); + } + Key::Char('?') => { + clear_menu(&term, rendered_lines)?; + return Ok(MenuResponse::Shortcut(MenuShortcut::Help, selected)); + } + Key::Escape | Key::CtrlC | Key::Char('q') => { + clear_menu(&term, rendered_lines)?; + return Ok(MenuResponse::Cancel); + } + _ => {} + } + } +} + +fn render_menu(prompt: &str, items: &[MenuItem], selected: usize) -> Vec { + let mut lines = Vec::with_capacity(items.len() + 2); + lines.push(format!("{prompt}:")); + lines.push( + "Keys: arrows/j/k move, Enter/Space select, p preview, s save, r reset, Backspace/Delete clear, ? help, q cancel." + .to_string(), + ); + lines.extend(items.iter().enumerate().map(|(index, item)| { + format!( + "{} {}", + if index == selected { ">" } else { " " }, + item.label + ) + })); + lines +} + +fn clear_menu(term: &Term, rendered_lines: usize) -> Result<(), CliError> { + if rendered_lines > 0 { + term.clear_last_lines(rendered_lines).map_err(menu_error)?; + } + Ok(()) +} + +fn menu_error(error: std::io::Error) -> CliError { + if matches!( + error.kind(), + std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof + ) { + CliError::Config("plugin edit cancelled; no config saved".into()) + } else { + CliError::Config(format!("plugin editor terminal error: {error}")) + } +} + +fn print_editor_help() { + println!(); + println!("Plugin editor keys:"); + println!(" arrows or j/k move"); + println!(" Enter or Space select/toggle the highlighted item"); + println!(" r reset the highlighted field or section"); + println!(" Backspace/Del clear the highlighted optional field"); + println!(" p preview TOML from the main menu"); + println!(" s save from the main menu"); + println!(" q or Esc go back/cancel"); +} + +fn ensure_tty() -> Result<(), CliError> { + if !std::io::stdin().is_terminal() { + return Err(CliError::Config( + "interactive plugin editing requires a TTY".into(), + )); + } + Ok(()) +} + +fn target_scope(command: &PluginsEditCommand) -> Result { + let selected = [command.user, command.project, command.global] + .into_iter() + .filter(|selected| *selected) + .count(); + if selected > 1 { + return Err(CliError::Config( + "choose only one of --user, --project, or --global".into(), + )); + } + if command.project { + Ok(TargetScope::Project) + } else if command.global { + Ok(TargetScope::Global) + } else { + Ok(TargetScope::User) + } +} + +fn target_path(scope: TargetScope) -> Result { + match scope { + TargetScope::User => user_plugin_config_path().ok_or_else(|| { + CliError::Config( + "cannot determine user config directory; set HOME or XDG_CONFIG_HOME".into(), + ) + }), + TargetScope::Project => { + let cwd = std::env::current_dir()?; + Ok(project_plugin_config_path(&cwd)) + } + TargetScope::Global => Ok(global_plugin_config_path()), + } +} + +fn read_plugin_config(path: &Path) -> Result { + if !path.exists() { + return Ok(PluginConfig::default()); + } + let raw = std::fs::read_to_string(path)?; + let parsed = raw + .parse::() + .map(toml::Value::Table) + .map_err(|error| { + CliError::Config(format!( + "invalid plugin TOML in {}: {error}", + path.display() + )) + })?; + serde_json::from_value( + serde_json::to_value(parsed) + .map_err(|error| CliError::Config(format!("invalid plugin TOML shape: {error}")))?, + ) + .map_err(|error| CliError::Config(format!("invalid plugin config: {error}"))) +} + +fn write_plugin_config(path: &Path, config: &PluginConfig) -> Result<(), CliError> { + let mut value = serde_json::to_value(config) + .map_err(|error| CliError::Config(format!("could not serialize plugin config: {error}")))?; + prune_plugin_defaults(&mut value); + let toml_value: toml::Value = serde_json::from_value(value).map_err(|error| { + CliError::Config(format!("could not convert plugin config to TOML: {error}")) + })?; + let rendered = toml::to_string_pretty(&toml_value) + .map_err(|error| CliError::Config(format!("could not render plugin TOML: {error}")))?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, rendered)?; + Ok(()) +} + +fn print_preview(config: &PluginConfig) -> Result<(), CliError> { + println!(); + println!("--- plugins.toml preview ---------------------------------"); + let mut value = serde_json::to_value(config) + .map_err(|error| CliError::Config(format!("could not serialize plugin config: {error}")))?; + prune_plugin_defaults(&mut value); + let toml_value: toml::Value = serde_json::from_value(value).map_err(|error| { + CliError::Config(format!("could not convert plugin config to TOML: {error}")) + })?; + let rendered = toml::to_string_pretty(&toml_value) + .map_err(|error| CliError::Config(format!("could not render plugin TOML: {error}")))?; + print!("{rendered}"); + println!("----------------------------------------------------------"); + Ok(()) +} + +fn validate_config(config: &PluginConfig) -> Result<(), CliError> { + let report = validate_plugin_config(config); + if report.has_errors() { + let messages = report + .diagnostics + .into_iter() + .filter(|diagnostic| diagnostic.level == nemo_flow::plugin::DiagnosticLevel::Error) + .map(|diagnostic| diagnostic.message) + .collect::>() + .join("; "); + return Err(CliError::Config(format!( + "plugin validation failed: {messages}" + ))); + } + Ok(()) +} + +fn edit_section( + theme: &ColorfulTheme, + config: &mut ObservabilityConfig, + section: EditorFieldSpec, +) -> Result<(), CliError> { + ensure_section(config, section); + let fields = section + .schema() + .ok_or_else(|| CliError::Config(format!("{} is not an editable section", section.name)))? + .fields; + loop { + let mut items = Vec::new(); + if section_has_enabled_toggle(section) { + let enabled = section_enabled(config, section).unwrap_or(false); + items.push(MenuItem::new(format!( + "Toggle section [{}]", + if enabled { "on" } else { "off" } + ))); + } + for field in fields { + items.push(MenuItem::new(format!( + "{} = {}", + field.name, + section_field_value(config, section, field.name)? + .map(|value| display_field_value(section, *field, &value)) + .or_else(|| default_field_value(section, *field) + .map(|value| format!("{} (default)", display_value(&value)))) + .unwrap_or_else(|| "(default)".to_string()) + ))); + } + items.push(MenuItem::new("Reset section [r]")); + items.push(MenuItem::new("Back [q]")); + let selection = prompt_menu(section.name, &items, 0)?; + let selection = match selection { + MenuResponse::Selected(selection) => selection, + MenuResponse::Shortcut(MenuShortcut::Help, _) => { + print_editor_help(); + continue; + } + MenuResponse::Shortcut(MenuShortcut::Reset, selected) => { + if reset_selected_field(config, section, fields, selected)? { + continue; + } + reset_section(config, section); + continue; + } + MenuResponse::Shortcut(MenuShortcut::Clear, selected) => { + if reset_selected_field(config, section, fields, selected)? { + continue; + } + println!("Select a field to clear."); + continue; + } + MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { + println!("Preview and save are available from the main plugins.toml menu."); + continue; + } + MenuResponse::Cancel => return Ok(()), + }; + let mut index = selection; + if section_has_enabled_toggle(section) { + if index == 0 { + toggle_section(config, section); + continue; + } + index -= 1; + } + if index < fields.len() { + edit_field(theme, config, section, &fields[index])?; + } else if index == fields.len() { + reset_section(config, section); + } else { + return Ok(()); + } + } +} + +fn edit_field( + theme: &ColorfulTheme, + config: &mut ObservabilityConfig, + section: EditorFieldSpec, + field: &EditorFieldSpec, +) -> Result<(), CliError> { + let current = section_field_value(config, section, field.name)?; + let actions = [ + MenuItem::new("Set value"), + MenuItem::new("Reset to default/none [r, Backspace, Delete]"), + MenuItem::new("Back [q]"), + ]; + let action = prompt_menu( + &format!( + "{}.{}, current {}", + section.name, + field.name, + current + .as_ref() + .map(|value| display_field_value(section, *field, value)) + .unwrap_or_else(|| "(default)".to_string()) + ), + &actions, + 0, + )?; + match action { + MenuResponse::Selected(0) => { + let value = prompt_value(theme, field, current.as_ref())?; + set_section_field(config, section, field.name, value)?; + } + MenuResponse::Selected(1) + | MenuResponse::Shortcut(MenuShortcut::Reset | MenuShortcut::Clear, _) => { + remove_section_field(config, section, field.name)? + } + MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), + MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { + println!("Preview and save are available from the main plugins.toml menu."); + } + _ => {} + } + Ok(()) +} + +fn prompt_value( + theme: &ColorfulTheme, + field: &EditorFieldSpec, + current: Option<&Value>, +) -> Result { + match field.kind { + EditorFieldKind::Boolean => { + let values = ["false", "true"]; + let default_idx = current + .and_then(Value::as_bool) + .map(usize::from) + .unwrap_or(0); + let idx = Select::with_theme(theme) + .with_prompt(field.name) + .items(&values) + .default(default_idx) + .interact() + .map_err(editor_error)?; + Ok(json!(idx == 1)) + } + EditorFieldKind::Integer => { + let initial = current.map(display_value).unwrap_or_default(); + let value: String = Input::with_theme(theme) + .with_prompt(field.name) + .with_initial_text(initial) + .interact_text() + .map_err(editor_error)?; + let parsed = value.trim().parse::().map_err(|error| { + CliError::Config(format!("{} must be an integer: {error}", field.name)) + })?; + Ok(json!(parsed)) + } + EditorFieldKind::StringMap | EditorFieldKind::Json => { + let initial = current.map(display_value).unwrap_or_else(|| { + if field.name == "tool_definitions" { + "[]".to_string() + } else { + "{}".to_string() + } + }); + let value: String = Input::with_theme(theme) + .with_prompt(format!("{} as JSON", field.name)) + .with_initial_text(initial) + .interact_text() + .map_err(editor_error)?; + serde_json::from_str(value.trim()).map_err(|error| { + CliError::Config(format!("invalid JSON for {}: {error}", field.name)) + }) + } + EditorFieldKind::Enum => { + let values = field.enum_values; + let default_idx = current + .and_then(Value::as_str) + .and_then(|value| values.iter().position(|candidate| *candidate == value)) + .unwrap_or(0); + let idx = Select::with_theme(theme) + .with_prompt(field.name) + .items(values) + .default(default_idx) + .interact() + .map_err(editor_error)?; + Ok(json!(values[idx])) + } + EditorFieldKind::String => { + let initial = current.and_then(Value::as_str).unwrap_or_default(); + let value: String = Input::with_theme(theme) + .with_prompt(field.name) + .with_initial_text(initial) + .interact_text() + .map_err(editor_error)?; + Ok(json!(value)) + } + EditorFieldKind::Section => Err(CliError::Config(format!( + "{} is a nested section and cannot be edited as a scalar", + field.name + ))), + } +} + +fn ensure_observability_component(config: &mut PluginConfig) -> Result<(), CliError> { + if !config + .components + .iter() + .any(|component| component.kind == OBSERVABILITY_PLUGIN_KIND) + { + config.components.push(PluginComponentSpec { + kind: OBSERVABILITY_PLUGIN_KIND.to_string(), + enabled: true, + config: observability_config_map(&ObservabilityConfig::default())?, + }); + } + Ok(()) +} + +fn component_enabled(config: &PluginConfig) -> bool { + observability_component(config) + .map(|component| component.enabled) + .unwrap_or(true) +} + +fn set_component_enabled(config: &mut PluginConfig, enabled: bool) { + if let Some(component) = observability_component_mut(config) { + component.enabled = enabled; + } +} + +fn component_observability_config(config: &PluginConfig) -> Result { + observability_component(config) + .map(|component| serde_json::from_value(Value::Object(component.config.clone()))) + .transpose() + .map_err(|error| CliError::Config(format!("invalid observability plugin config: {error}")))? + .ok_or_else(|| CliError::Config("observability plugin component is missing".into())) +} + +fn config_with_observability( + config: &PluginConfig, + observability: &ObservabilityConfig, +) -> Result { + let mut config = config.clone(); + store_observability_config(&mut config, observability)?; + Ok(config) +} + +fn store_observability_config( + config: &mut PluginConfig, + observability: &ObservabilityConfig, +) -> Result<(), CliError> { + if let Some(component) = observability_component_mut(config) { + merge_observability_editor_config( + &mut component.config, + observability_config_map(observability)?, + ); + } + Ok(()) +} + +fn ensure_section(config: &mut ObservabilityConfig, section: EditorFieldSpec) { + if let Ok(Some(Value::Object(_))) = section_value(config, section) { + return; + } + let Some(default) = section.default_value() else { + return; + }; + let _ = set_struct_field(config, section.name, default); +} + +fn toggle_section(config: &mut ObservabilityConfig, section: EditorFieldSpec) { + ensure_section(config, section); + let enabled = section_enabled(config, section).unwrap_or(false); + let _ = set_section_field(config, section, "enabled", json!(!enabled)); +} + +fn reset_section(config: &mut ObservabilityConfig, section: EditorFieldSpec) { + let value = section.default_value().unwrap_or_else(|| json!({})); + let _ = set_struct_field(config, section.name, value); +} + +fn reset_selected_field( + config: &mut ObservabilityConfig, + section: EditorFieldSpec, + fields: &[EditorFieldSpec], + selected: usize, +) -> Result { + let offset = usize::from(section_has_enabled_toggle(section)); + let Some(index) = selected.checked_sub(offset) else { + return Ok(false); + }; + let Some(field) = fields.get(index) else { + return Ok(false); + }; + remove_section_field(config, section, field.name)?; + Ok(true) +} + +fn section_has_enabled_toggle(section: EditorFieldSpec) -> bool { + section.name != POLICY_SECTION + && section + .schema() + .and_then(|schema| schema.field("enabled")) + .is_some_and(|field| field.kind == EditorFieldKind::Boolean) +} + +fn section_enabled(config: &ObservabilityConfig, section: EditorFieldSpec) -> Option { + section_value(config, section) + .ok() + .flatten() + .and_then(|section| section.get("enabled").cloned()) + .and_then(|enabled| enabled.as_bool()) +} + +fn section_field_value( + config: &ObservabilityConfig, + section: EditorFieldSpec, + field: &str, +) -> Result, CliError> { + Ok(section_value(config, section)? + .and_then(|section| section.as_object().cloned()) + .and_then(|section| section.get(field).cloned())) +} + +fn section_value( + config: &ObservabilityConfig, + section: EditorFieldSpec, +) -> Result, CliError> { + let value = serde_json::to_value(config).map_err(serde_error)?; + Ok(value + .as_object() + .and_then(|config| config.get(section.name)) + .filter(|section| !section.is_null()) + .cloned()) +} + +fn set_section_field( + config: &mut ObservabilityConfig, + section: EditorFieldSpec, + field: &str, + value: Value, +) -> Result<(), CliError> { + ensure_section(config, section); + let mut object = serde_json::to_value(&*config).map_err(serde_error)?; + let config_object = ensure_object(&mut object); + let section_object = config_object + .entry(section.name) + .or_insert_with(|| section.default_value().unwrap_or_else(|| json!({}))); + ensure_object(section_object).insert(field.to_string(), value); + *config = serde_json::from_value(object).map_err(serde_error)?; + Ok(()) +} + +fn remove_section_field( + config: &mut ObservabilityConfig, + section: EditorFieldSpec, + field: &str, +) -> Result<(), CliError> { + let mut object = serde_json::to_value(&*config).map_err(serde_error)?; + if let Some(section_object) = object + .as_object_mut() + .and_then(|config| config.get_mut(section.name)) + .and_then(Value::as_object_mut) + { + section_object.remove(field); + } + *config = serde_json::from_value(object).map_err(serde_error)?; + Ok(()) +} + +fn set_struct_field(target: &mut T, field: &str, value: Value) -> Result<(), CliError> +where + T: Serialize + DeserializeOwned, +{ + let mut object = serde_json::to_value(&*target).map_err(serde_error)?; + ensure_object(&mut object).insert(field.to_string(), value); + *target = serde_json::from_value(object).map_err(serde_error)?; + Ok(()) +} + +fn observability_component(config: &PluginConfig) -> Option<&PluginComponentSpec> { + config + .components + .iter() + .find(|component| component.kind == OBSERVABILITY_PLUGIN_KIND) +} + +fn observability_component_mut(config: &mut PluginConfig) -> Option<&mut PluginComponentSpec> { + config + .components + .iter_mut() + .find(|component| component.kind == OBSERVABILITY_PLUGIN_KIND) +} + +fn ensure_object(value: &mut Value) -> &mut Map { + if !value.is_object() { + *value = json!({}); + } + value.as_object_mut().expect("value initialized as object") +} + +fn observability_config_map(config: &ObservabilityConfig) -> Result, CliError> { + let value = serde_json::to_value(config).map_err(serde_error)?; + match value { + Value::Object(map) => Ok(map), + _ => Err(CliError::Config( + "observability config must serialize to an object".into(), + )), + } +} + +fn merge_observability_editor_config( + existing: &mut Map, + edited: Map, +) { + merge_known_editor_object( + existing, + edited, + &observability_editor_fields_with_version(), + ObservabilityConfig::editor_schema(), + ); +} + +fn merge_known_editor_object( + existing: &mut Map, + edited: Map, + known_keys: &[&str], + schema: &nemo_flow::config_editor::EditorSchema, +) { + for key in known_keys { + let Some(edited_value) = edited.get(*key) else { + existing.remove(*key); + continue; + }; + if let Some(field) = schema.field(key) + && field.kind == EditorFieldKind::Section + && let Some(nested_schema) = field.schema() + && let (Some(existing_object), Some(edited_object)) = ( + existing.get_mut(*key).and_then(Value::as_object_mut), + edited_value.as_object(), + ) + { + merge_known_editor_object( + existing_object, + edited_object.clone(), + &nested_editor_keys(nested_schema), + nested_schema, + ); + continue; + } + existing.insert((*key).to_string(), edited_value.clone()); + } +} + +fn observability_editor_fields_with_version() -> Vec<&'static str> { + let mut keys = vec!["version"]; + keys.extend( + ObservabilityConfig::editor_schema() + .fields + .iter() + .map(|field| field.name), + ); + keys +} + +fn nested_editor_keys(schema: &nemo_flow::config_editor::EditorSchema) -> Vec<&'static str> { + schema.fields.iter().map(|field| field.name).collect() +} + +fn prune_plugin_defaults(value: &mut Value) { + let Some(object) = value.as_object_mut() else { + return; + }; + remove_default_field( + object, + "policy", + serde_json::to_value(ConfigPolicy::default()).expect("policy default serializes"), + ); + if let Some(components) = object.get_mut("components").and_then(Value::as_array_mut) { + for component in components { + if let Some(component) = component.as_object_mut() + && component.get("enabled") == Some(&Value::Bool(true)) + { + component.remove("enabled"); + } + } + } +} + +fn remove_default_field(object: &mut Map, key: &str, default: Value) { + let Some(value) = object.get_mut(key) else { + return; + }; + remove_matching_defaults(value, &default); + if value == &default || value.as_object().is_some_and(|value| value.is_empty()) { + object.remove(key); + } +} + +fn remove_matching_defaults(value: &mut Value, default: &Value) { + let (Some(value), Some(default)) = (value.as_object_mut(), default.as_object()) else { + return; + }; + let default_keys = default.keys().cloned().collect::>(); + for key in default_keys { + if value.get(&key) == default.get(&key) { + value.remove(&key); + } + } +} + +fn serde_error(error: serde_json::Error) -> CliError { + CliError::Config(format!("invalid plugin editor value: {error}")) +} + +fn display_field_value(section: EditorFieldSpec, field: EditorFieldSpec, value: &Value) -> String { + if default_field_value(section, field) + .as_ref() + .is_some_and(|default| default == value) + { + format!("{} (default)", display_value(value)) + } else { + display_value(value) + } +} + +fn default_field_value(section: EditorFieldSpec, field: EditorFieldSpec) -> Option { + section + .default_value() + .and_then(|section| section.as_object().cloned()) + .and_then(|section| section.get(field.name).cloned()) +} + +fn display_value(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + _ => serde_json::to_string(value).unwrap_or_else(|_| "".to_string()), + } +} + +fn observability_summary(config: &PluginConfig, observability: &ObservabilityConfig) -> String { + let enabled_sections = ObservabilityConfig::editor_schema() + .fields + .iter() + .filter(|section| section.name != POLICY_SECTION) + .filter(|section| section_enabled(observability, **section).unwrap_or(false)) + .map(|section| section.label) + .collect::>(); + format!( + "component {}, sections {}", + if component_enabled(config) { + "enabled" + } else { + "disabled" + }, + if enabled_sections.is_empty() { + "none".into() + } else { + enabled_sections.join(", ") + } + ) +} + +fn editor_error(err: dialoguer::Error) -> CliError { + match err { + dialoguer::Error::IO(io_err) + if matches!( + io_err.kind(), + std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof + ) => + { + CliError::Config("plugin edit cancelled; no config saved".into()) + } + other => CliError::Config(format!("plugin edit error: {other}")), + } +} + +#[cfg(test)] +#[path = "../tests/coverage/plugins_tests.rs"] +mod tests; diff --git a/crates/cli/src/session.rs b/crates/cli/src/session.rs index 98cfc11e9..c4503a509 100644 --- a/crates/cli/src/session.rs +++ b/crates/cli/src/session.rs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 use std::collections::HashMap; -use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -15,13 +14,9 @@ use nemo_flow::api::scope::{ EmitMarkEventParams, PopScopeParams, PushScopeParams, ScopeHandle, ScopeType, event as emit_mark_event, get_handle, pop_scope, push_scope, }; -use nemo_flow::api::subscriber::scope_register_subscriber; use nemo_flow::api::tool::{ ToolCallEndParams, ToolCallParams, ToolHandle, tool_call, tool_call_end, }; -use nemo_flow::observability::atif::{AtifAgentInfo, AtifExporter}; -use nemo_flow::observability::atof::{AtofExporter, AtofExporterConfig}; -use nemo_flow::observability::openinference::{OpenInferenceConfig, OpenInferenceSubscriber}; use serde_json::{Map, Value, json}; use tokio::sync::Mutex; @@ -99,9 +94,6 @@ struct Session { pending_tool_hints: Vec, last_llm_owner: Option, config: SessionConfig, - atif: Option, - atof: Option, - openinference: Option, } #[derive(Debug, Clone)] @@ -162,7 +154,7 @@ impl SessionManager { /// Applies normalized hook events to their owning sessions in arrival order. /// /// Session configuration is re-read from headers for each request so installed hook commands can - /// override exporters or metadata per invocation. Empty sessions are removed after lifecycle + /// override metadata per invocation. Empty sessions are removed after lifecycle /// closure to avoid retaining stale correlation state. /// /// When an `AgentStarted` event arrives for a session that was already created by the gateway @@ -332,11 +324,6 @@ impl SessionManager { session.add_tool_hints_from_llm_response(response, owner_subagent_id); } } - - #[cfg(test)] - pub(crate) async fn open_session_count(&self) -> usize { - self.inner.lock().await.len() - } } impl Session { @@ -357,9 +344,6 @@ impl Session { pending_tool_hints: Vec::new(), last_llm_owner: None, config, - atif: None, - atof: None, - openinference: None, } } @@ -372,7 +356,7 @@ impl Session { match event { NormalizedEvent::AgentStarted(event) => self.start_agent(event), NormalizedEvent::AgentEnded(event) => self.end_agent(event), - NormalizedEvent::TurnEnded(_) => self.snapshot_atif(), + NormalizedEvent::TurnEnded(_) => Ok(()), NormalizedEvent::SubagentStarted(event) => self.start_subagent(event), NormalizedEvent::SubagentEnded(event) => self.end_subagent(event), NormalizedEvent::LlmHint(event) => self.add_llm_hint(event), @@ -389,22 +373,6 @@ impl Session { .await } - /// Writes ATIF for the current session without closing the agent scope or shutting observers - /// down. Triggered by `TurnEnded` (per-turn `Stop` hooks). Each turn produces a cumulative - /// snapshot — `AtifExporter::export()` is documented as non-destructive, so subsequent turns - /// add events on top and last-write-wins semantics yield a complete trajectory by the final - /// turn. No-op when `agent_scope` was never opened or when the session has no ATIF observer - /// installed (e.g., `atif_dir` not configured). - fn snapshot_atif(&mut self) -> Result<(), CliError> { - if self.agent_scope.is_none() { - return Ok(()); - } - if let (Some(exporter), Some(directory)) = (&self.atif, &self.config.exporters.atif.dir) { - write_atif(directory, &self.session_id, exporter)?; - } - Ok(()) - } - // Legacy manual-lifecycle gateway start used by tests. Production code uses // `prepare_gateway_call` + managed execution. #[cfg(test)] @@ -494,15 +462,13 @@ impl Session { self.ensure_agent_started(event.metadata) } - // Lazily opens the root agent scope, installs observers on the root handle, and merges metadata - // from config, event payload, and gateway headers. Later calls are no-ops to keep duplicate - // hooks from nesting agent scopes. + // Lazily opens the root agent scope and merges metadata from config, event payload, and + // gateway headers. Later calls are no-ops to keep duplicate hooks from nesting agent scopes. fn ensure_agent_started(&mut self, event_metadata: Value) -> Result<(), CliError> { if self.agent_scope.is_some() { return Ok(()); } - let root = get_handle()?; - self.install_observers(&root)?; + let _root = get_handle()?; let metadata = merge_metadata( merge_metadata( self.config.metadata.clone().unwrap_or(Value::Null), @@ -526,99 +492,11 @@ impl Session { Ok(()) } - // Installs configured exporters exactly once per session root. ATIF, ATOF, and OpenInference - // are scope-local subscribers so they disappear with the session and do not affect unrelated - // concurrent agent runs. - fn install_observers(&mut self, root: &ScopeHandle) -> Result<(), CliError> { - self.install_atif_observer(root)?; - self.install_atof_observer(root)?; - self.install_openinference_observer(root)?; - Ok(()) - } - - // Registers the ATOF JSONL exporter once when a session has an ATOF directory configured. - // The file is named after the session id so concurrent sessions never share a writer. - // Append mode keeps existing per-session files intact across re-runs of the same session id - // (e.g., a resumed conversation). - fn install_atof_observer(&mut self, root: &ScopeHandle) -> Result<(), CliError> { - if self.atof.is_some() { - return Ok(()); - } - let Some(directory) = self.config.exporters.atof.dir.clone() else { - return Ok(()); - }; - // Ensure the directory exists; AtofExporter opens the file via OpenOptions which won't - // create parent dirs. Failure is non-fatal — surfaced as a CliError so the caller can - // decide to continue without ATOF rather than aborting the whole session. - std::fs::create_dir_all(&directory).map_err(|err| { - CliError::Config(format!( - "could not create ATOF directory {}: {err}", - directory.display() - )) - })?; - let filename = render_atof_filename_template( - &self.config.exporters.atof.filename_template, - &self.session_id, - )?; - let config = AtofExporterConfig::default() - .with_output_directory(directory) - .with_mode(self.config.exporters.atof.mode) - .with_filename(filename); - let exporter = AtofExporter::new(config) - .map_err(|err| CliError::Config(format!("could not open ATOF file: {err}")))?; - scope_register_subscriber(&root.uuid, "gateway-atof", exporter.subscriber())?; - self.atof = Some(exporter); - Ok(()) - } - - // Registers the ATIF exporter once when a session has ATIF output configured. The exporter keeps - // the session agent metadata so downstream trajectory files can be attributed to this run. - fn install_atif_observer(&mut self, root: &ScopeHandle) -> Result<(), CliError> { - if self.atif.is_some() || self.config.exporters.atif.dir.is_none() { - return Ok(()); - } - let exporter = AtifExporter::new( - self.session_id.clone(), - AtifAgentInfo { - name: self.agent_kind.as_str().to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - model_name: None, - tool_definitions: None, - extra: self.config.metadata.clone(), - }, - ); - scope_register_subscriber(&root.uuid, "gateway-atif", exporter.subscriber())?; - self.atif = Some(exporter); - Ok(()) - } - - // Registers the OpenInference subscriber once when an endpoint is configured. Endpoint ownership - // remains on the session config so repeated start events cannot duplicate subscribers. - fn install_openinference_observer(&mut self, root: &ScopeHandle) -> Result<(), CliError> { - if self.openinference.is_some() { - return Ok(()); - } - let Some(endpoint) = &self.config.exporters.openinference.endpoint else { - return Ok(()); - }; - let subscriber = OpenInferenceSubscriber::new( - OpenInferenceConfig::new() - .with_endpoint(endpoint.clone()) - .with_service_name("nemo-flow-cli"), - )?; - scope_register_subscriber(&root.uuid, "gateway-openinference", subscriber.subscriber())?; - self.openinference = Some(subscriber); - Ok(()) - } - // Closes the session in a fail-safe order: active LLMs/tools first, nested subagents from the - // top down, correlation state, then the root agent scope. Observer flush/export happens after - // the root scope ends so terminal events are included. + // top down, correlation state, then the root agent scope. fn end_agent(&mut self, event: SessionEvent) -> Result<(), CliError> { // Duplicate agent-end hooks (e.g., hermes-agent emitting `on_session_end` more than once - // per session) must not reopen the agent scope. Without this guard, `ensure_agent_started` - // would create an empty scope and `flush_observers` would overwrite the already-written - // ATIF trajectory with an empty session. + // per session) must not reopen the agent scope. if self.agent_scope.is_none() { return Ok(()); } @@ -628,7 +506,6 @@ impl Session { self.close_active_subagents_for_agent_end()?; self.clear_correlation_state(); self.close_agent_scope(event.payload)?; - self.flush_observers()?; Ok(()) } @@ -944,29 +821,6 @@ impl Session { Ok(()) } - // Flushes and shuts down configured observers, then writes ATIF output if requested. This runs - // only on agent end, so long-lived sessions keep subscribers active across intermediate hooks. - fn flush_observers(&mut self) -> Result<(), CliError> { - if let Some(subscriber) = &self.openinference { - subscriber.force_flush()?; - subscriber.shutdown()?; - } - if let (Some(exporter), Some(directory)) = (&self.atif, &self.config.exporters.atif.dir) { - write_atif(directory, &self.session_id, exporter)?; - } - // ATOF writes per-event JSONL as events arrive; flush + shutdown here just ensure the - // BufWriter is drained and the file is closed cleanly before the session record is dropped. - if let Some(exporter) = &self.atof { - exporter - .force_flush() - .map_err(|err| CliError::Config(format!("ATOF flush failed: {err}")))?; - exporter - .shutdown() - .map_err(|err| CliError::Config(format!("ATOF shutdown failed: {err}")))?; - } - Ok(()) - } - // Prunes expired LLM hints and sticky owner state. The TTLs prevent old hook activity from // incorrectly capturing later gateway calls when agents reuse a process or session id. fn cleanup_correlation_state(&mut self) { @@ -1276,52 +1130,6 @@ impl Session { } } -// Writes the complete ATIF trajectory for a finished session to `{session_id}.atif.json`, creating -// the target directory lazily. Serialization failures are reported as invalid payloads because they -// indicate exporter output could not be represented as JSON. -fn write_atif( - directory: &PathBuf, - session_id: &str, - exporter: &AtifExporter, -) -> Result<(), CliError> { - std::fs::create_dir_all(directory)?; - validate_atif_session_id(session_id)?; - let path = directory.join(format!("{session_id}.atif.json")); - let trajectory = exporter.export(); - let serialized = serde_json::to_vec_pretty(&trajectory) - .map_err(|error| CliError::InvalidPayload(error.to_string()))?; - std::fs::write(path, serialized)?; - Ok(()) -} - -fn validate_atif_session_id(session_id: &str) -> Result<(), CliError> { - if session_id.is_empty() - || session_id == "." - || session_id == ".." - || !session_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - { - return Err(CliError::InvalidPayload( - "session id is not safe for ATIF export filename".into(), - )); - } - Ok(()) -} - -fn render_atof_filename_template(template: &str, session_id: &str) -> Result { - validate_atif_session_id(session_id)?; - let filename = template.replace("{session_id}", session_id); - let path = std::path::Path::new(&filename); - if filename.is_empty() || filename == "." || filename == ".." || path.components().count() != 1 - { - return Err(CliError::InvalidPayload( - "ATOF filename template must render to a single safe filename".into(), - )); - } - Ok(filename) -} - // Scores how strongly a pending hint matches a gateway LLM request. Subagent/agent identity is // weighted highest, request/conversation/generation identifiers are equal, and model match is only // a low-confidence tie breaker. diff --git a/crates/cli/src/setup.rs b/crates/cli/src/setup.rs index 8a9183b13..c3efc49b1 100644 --- a/crates/cli/src/setup.rs +++ b/crates/cli/src/setup.rs @@ -3,8 +3,7 @@ //! First-run setup for `nemo-flow` configuration. //! -//! Drives the three required prompts (scope, agents, observability backends) plus an optional -//! OpenInference endpoint follow-up, then writes a `config.toml` to the chosen scope. Pure +//! Drives the required scope and agent prompts, then writes a `config.toml` to the chosen scope. Pure //! helpers (`detect_installed_agents`, `build_config`, `save_config`) are split out from the //! `dialoguer`-driven orchestrator so the data path can be unit-tested without a TTY. @@ -12,7 +11,7 @@ use std::io::IsTerminal; use std::path::{Path, PathBuf}; use dialoguer::theme::ColorfulTheme; -use dialoguer::{Confirm, Input, MultiSelect, Select}; +use dialoguer::{Confirm, MultiSelect, Select}; use toml_edit::{DocumentMut, Item, Table, value}; use crate::config::CodingAgent; @@ -40,36 +39,11 @@ impl ConfigScope { } } -/// One of the built-in observability backends offered in setup. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ObservabilityBackend { - /// Local ATIF trajectory files (one JSON file per session). - Atif, - /// Local ATOF raw-event JSONL streams (one line per event, raw ATOF shape). - Atof, - /// OpenInference spans streamed to an HTTP endpoint (Phoenix, Arize, OTLP-compatible). - OpenInference, -} - -impl ObservabilityBackend { - fn label(self) -> &'static str { - match self { - Self::Atif => "ATIF trajectory files ./atif/ (recommended)", - Self::Atof => "ATOF event JSONL stream ./atof/ (raw events)", - Self::OpenInference => { - "OpenInference spans (Phoenix / Arize / OTLP)" - } - } - } -} - /// Resolved answers from setup. Built either by `prompt_user` (interactive) or by tests. #[derive(Debug, Clone)] pub(crate) struct SetupAnswers { pub scope: ConfigScope, pub agents: Vec, - pub backends: Vec, - pub openinference_endpoint: Option, /// Path recorded under `[agents.hermes].hooks_path` when hermes is selected. Set by `run` /// from `hermes_hooks_path_for_scope` so the wizard preview shows the file the launcher /// will reference. `None` when hermes wasn't selected. @@ -110,16 +84,12 @@ pub(crate) fn detect_installed_agents_in(path_var: Option<&std::ffi::OsStr>) -> /// Builds the TOML document that represents the setup's answers. Pure and testable. /// -/// The shape mirrors the runtime model: exporter sinks live under `[exporters]`, agents under -/// `[agents.]`, and upstream overrides under `[upstream]`. Sections are only emitted when -/// the user opted into the corresponding behavior so the resulting file stays minimal. +/// The shape mirrors the runtime model: agents live under `[agents.]`. +/// Sections are only emitted when the user opted into the corresponding behavior so the resulting +/// file stays minimal. pub(crate) fn build_config(answers: &SetupAnswers) -> DocumentMut { let mut doc = DocumentMut::new(); - if let Some(exporters) = build_exporters_table(answers) { - doc["exporters"] = Item::Table(exporters); - } - if let Some(agents_table) = build_agents_table(answers) { doc["agents"] = Item::Table(agents_table); } @@ -127,57 +97,6 @@ pub(crate) fn build_config(answers: &SetupAnswers) -> DocumentMut { doc } -fn build_exporters_table(answers: &SetupAnswers) -> Option { - if !has_selected_exporter(answers) { - return None; - } - - let mut exporters = Table::new(); - if answers.backends.contains(&ObservabilityBackend::Atif) { - insert_atif_exporter(&mut exporters); - } - if answers.backends.contains(&ObservabilityBackend::Atof) { - insert_atof_exporter(&mut exporters); - } - if answers - .backends - .contains(&ObservabilityBackend::OpenInference) - && let Some(endpoint) = answers.openinference_endpoint.as_deref() - { - insert_openinference_exporter(&mut exporters, endpoint); - } - Some(exporters) -} - -fn has_selected_exporter(answers: &SetupAnswers) -> bool { - answers.backends.contains(&ObservabilityBackend::Atif) - || answers.backends.contains(&ObservabilityBackend::Atof) - || (answers - .backends - .contains(&ObservabilityBackend::OpenInference) - && answers.openinference_endpoint.is_some()) -} - -fn insert_atif_exporter(exporters: &mut Table) { - let mut atif = Table::new(); - atif["dir"] = value("./atif"); - exporters.insert("atif", Item::Table(atif)); -} - -fn insert_atof_exporter(exporters: &mut Table) { - let mut atof = Table::new(); - atof["dir"] = value("./atof"); - atof["mode"] = value("append"); - atof["filename_template"] = value("{session_id}.jsonl"); - exporters.insert("atof", Item::Table(atof)); -} - -fn insert_openinference_exporter(exporters: &mut Table, endpoint: &str) { - let mut openinference = Table::new(); - openinference["endpoint"] = value(endpoint); - exporters.insert("openinference", Item::Table(openinference)); -} - fn build_agents_table(answers: &SetupAnswers) -> Option
{ if answers.agents.is_empty() { return None; @@ -201,10 +120,10 @@ fn build_agents_table(answers: &SetupAnswers) -> Option
{ /// Writes the setup's TOML document to the scope-appropriate path(s). /// /// When `merge_scope` is `Some(agent)`, an existing `config.toml` at the target path is parsed -/// and only the sections owned by THIS wizard run are replaced: `[exporters]`, -/// legacy `[observability]` / `[export]`, `[plugins]`, and the single `[agents.]` block. Other -/// `[agents.*]` blocks are preserved. When `merge_scope` is `None`, the file is overwritten -/// outright with the wizard's full output (the user explicitly chose which agents to include). +/// and only the single `[agents.]` block owned by THIS wizard run is replaced. Other +/// `[agents.*]` blocks and hand-edited shared sections such as `[plugins]` are preserved when +/// omitted from the wizard output. When `merge_scope` is `None`, the file is overwritten outright +/// with the wizard's full output (the user explicitly chose which agents to include). /// /// Returns the list of paths written. `home` and `cwd` are explicit so tests can drive this with /// tempdirs. @@ -264,14 +183,6 @@ fn write_or_merge( .parse() .map_err(|err| CliError::Config(format!("could not parse existing config: {err}")))?; let agent_key = agent_key_and_command(agent).0; - // Wizard-owned sections use REPLACE semantics: if the user re-runs setup and the new doc - // omits a section, the previous override is removed too. Otherwise accepting the default - // (e.g. dropping a custom `openai_base_url`) could not actually revert the override — - // the old value would silently survive. - replace_section(&mut existing, doc, "exporters"); - replace_section(&mut existing, doc, "observability"); - replace_section(&mut existing, doc, "export"); - replace_section(&mut existing, doc, "upstream"); // `plugins` is not wizard-owned (users may hand-edit it). Preserve on omission. merge_section(&mut existing, doc, "plugins"); merge_agents_entry(&mut existing, doc, agent_key); @@ -288,17 +199,6 @@ fn merge_section(dst: &mut DocumentMut, src: &DocumentMut, key: &str) { } } -// Like `merge_section`, but when `src` omits the key the existing entry in `dst` is removed. -// Use for wizard-owned sections (the wizard's output is authoritative for these keys). -fn replace_section(dst: &mut DocumentMut, src: &DocumentMut, key: &str) { - match src.get(key) { - Some(item) => dst[key] = item.clone(), - None => { - dst.remove(key); - } - } -} - // Replaces the single `[agents.]` block in `dst` with the one from `src`. If `src` does // not contain that block, the existing entry in `dst` is left as-is. fn merge_agents_entry(dst: &mut DocumentMut, src: &DocumentMut, agent_key: &str) { @@ -385,7 +285,7 @@ pub(crate) fn reset(agent_hint: Option) -> Result<(), CliError> { /// /// When `agent_hint` is `Some`, the agent multi-select is skipped — the user already declared /// intent by typing `nemo-flow claude` (or another agent name), so respect that and only ask -/// scope + backends. To set up multiple agents, the user re-runs `nemo-flow config` later. +/// scope and agents. To set up multiple agents, the user re-runs `nemo-flow config` later. pub(crate) fn prompt_user( detected_agents: &[CodingAgent], agent_hint: Option, @@ -396,11 +296,11 @@ pub(crate) fn prompt_user( match agent_hint { Some(agent) => { let (name, _) = agent_key_and_command(agent); - println!(" Setting up observability for {name}."); + println!(" Setting up {name}."); println!(" Re-run `nemo-flow config` later to configure additional agents."); } None => { - println!(" Let's set up observability for your coding agent."); + println!(" Let's set up your coding agent."); println!(" This runs once. Re-run later with `nemo-flow config`."); } } @@ -430,8 +330,6 @@ pub(crate) fn prompt_user( Some(agent) => vec![agent], None => ask_agents(&theme, detected_agents, &defaults.agents)?, }; - let (backends, openinference_endpoint) = ask_backends(&theme, &defaults)?; - if agents.contains(&CodingAgent::Codex) { print_codex_api_key_guide(); } @@ -439,8 +337,6 @@ pub(crate) fn prompt_user( Ok(SetupAnswers { scope, agents, - backends, - openinference_endpoint, hermes_hooks_path: None, }) } @@ -508,18 +404,11 @@ fn hermes_hook_targets(scope: ConfigScope, cwd: &Path, home: &Path) -> Vec, agents: Vec, - atif_enabled: bool, - atof_enabled: bool, - openinference_endpoint: Option, } impl Defaults { fn has_any(&self) -> bool { - self.scope.is_some() - || !self.agents.is_empty() - || self.atif_enabled - || self.atof_enabled - || self.openinference_endpoint.is_some() + self.scope.is_some() || !self.agents.is_empty() } } @@ -554,49 +443,9 @@ fn read_existing_defaults() -> Option { (false, false) => None, }; - let exporters = doc.get("exporters").and_then(|i| i.as_table()); - let legacy_observability = doc.get("observability").and_then(|i| i.as_table()); - let legacy_export = doc.get("export").and_then(|i| i.as_table()); - Some(Defaults { scope, agents: read_agents_from_doc(&doc), - atif_enabled: exporters - .and_then(|t| t.get("atif")) - .and_then(|i| i.as_table()) - .and_then(|t| t.get("dir")) - .is_some() - || exporters.and_then(|t| t.get("atif_dir")).is_some() - || legacy_observability - .and_then(|t| t.get("atif_dir")) - .is_some(), - atof_enabled: exporters - .and_then(|t| t.get("atof")) - .and_then(|i| i.as_table()) - .and_then(|t| t.get("dir")) - .is_some() - || exporters.and_then(|t| t.get("atof_dir")).is_some() - || legacy_observability - .and_then(|t| t.get("atof_dir")) - .is_some(), - openinference_endpoint: exporters - .and_then(|t| t.get("openinference")) - .and_then(|i| i.as_table()) - .and_then(|t| t.get("endpoint")) - .and_then(|i| i.as_str()) - .or_else(|| { - exporters - .and_then(|t| t.get("openinference_endpoint")) - .and_then(|i| i.as_str()) - }) - .or_else(|| { - legacy_export - .and_then(|t| t.get("openinference")) - .and_then(|i| i.as_table()) - .and_then(|t| t.get("endpoint")) - .and_then(|i| i.as_str()) - }) - .map(str::to_string), }) } @@ -654,7 +503,7 @@ fn print_detected_agents(detected: &[CodingAgent]) { println!(" ✓ {name}"); } if detected.is_empty() { - println!(" (none — you can still configure observability and add agents later)"); + println!(" (none — you can still add agents later)"); } } @@ -717,55 +566,6 @@ fn ask_agents( Ok(selected_idx.into_iter().map(|i| all_supported[i]).collect()) } -fn ask_backends( - theme: &ColorfulTheme, - existing: &Defaults, -) -> Result<(Vec, Option), CliError> { - let options = [ - ObservabilityBackend::Atif, - ObservabilityBackend::Atof, - ObservabilityBackend::OpenInference, - ]; - let labels: Vec<&str> = options.iter().map(|b| b.label()).collect(); - // Pre-check from existing config when present. On first run, falls back to ATIF on (zero - // infra, trajectory replay is the common case), ATOF off (raw event noise — users opt in), - // and OpenInference off (needs an endpoint running). - let defaults = if existing.has_any() { - [ - existing.atif_enabled, - existing.atof_enabled, - existing.openinference_endpoint.is_some(), - ] - } else { - [true, false, false] - }; - let selected_idx = MultiSelect::with_theme(theme) - .with_prompt("Observability backends?") - .items(&labels) - .defaults(&defaults) - .interact() - .map_err(setup_error)?; - let backends: Vec = - selected_idx.into_iter().map(|i| options[i]).collect(); - - let openinference_endpoint = if backends.contains(&ObservabilityBackend::OpenInference) { - let initial = existing - .openinference_endpoint - .as_deref() - .unwrap_or("http://localhost:6006/v1/traces"); - let endpoint: String = Input::with_theme(theme) - .with_prompt("OpenInference endpoint URL") - .with_initial_text(initial) - .interact_text() - .map_err(setup_error)?; - Some(endpoint) - } else { - None - }; - - Ok((backends, openinference_endpoint)) -} - /// Confirms the summary with the user before writing the file. Returns true if the user accepted. /// Shows both the destination path(s) and the exact TOML body about to be written so the user /// can verify what they're committing to instead of confirming a path blind. @@ -855,6 +655,7 @@ pub(crate) async fn run(agent_hint: Option) -> Result<(), CliError> for path in &written { println!(" {}", path.display()); } + println!(" Configure observability with `nemo-flow plugins edit`."); println!(); Ok(()) } diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 14bf4f252..f692733fb 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -109,7 +109,7 @@ fn cli_bare_invocation_runs_doctor_when_config_exists() { std::fs::create_dir_all(&xdg).unwrap(); let cwd = temp.path().join("workdir"); std::fs::create_dir_all(cwd.join(".nemo-flow")).unwrap(); - std::fs::write(cwd.join(".nemo-flow/config.toml"), "[observability]\n").unwrap(); + std::fs::write(cwd.join(".nemo-flow/config.toml"), "[upstream]\n").unwrap(); let output = Command::new(gateway_bin()) .current_dir(&cwd) @@ -136,15 +136,8 @@ fn cli_bare_invocation_reports_invalid_config_resolution() { std::fs::create_dir_all(&xdg).unwrap(); let cwd = temp.path().join("workdir"); std::fs::create_dir_all(cwd.join(".nemo-flow")).unwrap(); - std::fs::write( - cwd.join(".nemo-flow/config.toml"), - r#" -[exporters.atof] -dir = "./atof" -mode = "replace" -"#, - ) - .unwrap(); + std::fs::write(cwd.join(".nemo-flow/config.toml"), "[upstream]\n").unwrap(); + std::fs::write(cwd.join(".nemo-flow/plugins.toml"), "components = [\n").unwrap(); let output = Command::new(gateway_bin()) .current_dir(&cwd) @@ -160,7 +153,7 @@ mode = "replace" let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("Configuration")); assert!(stdout.contains("Resolution")); - assert!(stdout.contains("invalid [exporters.atof].mode")); + assert!(stdout.contains("invalid plugin TOML")); } #[test] @@ -174,12 +167,6 @@ fn cli_run_dry_run_resolves_config_and_command() { openai_base_url = "http://file-openai" anthropic_base_url = "http://file-anthropic" -[observability] -atif_dir = "file-atif" - -[export.openinference] -endpoint = "http://otel" - [agents.hermes] command = "hermes --yolo chat" "#, @@ -240,8 +227,6 @@ command = "codex --full-auto" .env("NEMO_FLOW_GATEWAY_BIND", "127.0.0.1:0") .env("NEMO_FLOW_OPENAI_BASE_URL", "http://env-openai") .env("NEMO_FLOW_ANTHROPIC_BASE_URL", "http://env-anthropic") - .env("NEMO_FLOW_ATIF_DIR", "env-atif") - .env("NEMO_FLOW_OPENINFERENCE_ENDPOINT", "http://env-otel") .args(["run", "--agent", "codex", "--dry-run"]) .output() .unwrap(); @@ -250,8 +235,8 @@ command = "codex --full-auto" let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("openai_base_url = http://env-openai")); assert!(stdout.contains("anthropic_base_url = http://env-anthropic")); - assert!(stdout.contains("atif_dir = env-atif")); - assert!(stdout.contains("openinference_endpoint = http://env-otel")); + assert!(!stdout.contains("atif_dir")); + assert!(!stdout.contains("openinference_endpoint")); assert!(stdout.contains("argv = codex")); } @@ -298,10 +283,6 @@ fn cli_hook_forward_posts_payload_headers_and_prints_response() { "codex", "--gateway-url", &server_url, - "--atif-dir", - "atif", - "--openinference-endpoint", - "http://otel", "--profile", "coverage", "--session-metadata", @@ -332,8 +313,6 @@ fn cli_hook_forward_posts_payload_headers_and_prints_response() { r#"{"continue":true}"# ); assert!(request.contains("POST /hooks/codex HTTP/1.1")); - assert!(request.contains("x-nemo-flow-atif-dir: atif")); - assert!(request.contains("x-nemo-flow-openinference-endpoint: http://otel")); assert!(request.contains("x-nemo-flow-config-profile: coverage")); assert!(request.contains("x-nemo-flow-gateway-mode: passthrough")); assert!(request.contains(r#"{"hook_event_name":"sessionStart"}"#)); diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index 9c0d39b89..6876dbde2 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -11,15 +11,6 @@ fn config() -> GatewayConfig { openai_base_url: "http://openai".into(), anthropic_base_url: "http://anthropic".into(), - exporters: ExportersConfig { - atif: AtifExporterSettings { - dir: Some(PathBuf::from("default-atif")), - }, - openinference: OpenInferenceExporterSettings { - endpoint: Some("http://default-otel".into()), - }, - ..Default::default() - }, metadata: None, plugin_config: None, } @@ -28,14 +19,6 @@ fn config() -> GatewayConfig { #[test] fn session_config_prefers_headers_and_parses_json() { let mut headers = HeaderMap::new(); - headers.insert( - "x-nemo-flow-atif-dir", - HeaderValue::from_static("header-atif"), - ); - headers.insert( - "x-nemo-flow-openinference-endpoint", - HeaderValue::from_static("http://header-otel"), - ); headers.insert( "x-nemo-flow-config-profile", HeaderValue::from_static("profile-a"), @@ -55,14 +38,6 @@ fn session_config_prefers_headers_and_parses_json() { let session = config().session_config_from_headers(&headers); - assert_eq!( - session.exporters.atif.dir, - Some(PathBuf::from("header-atif")) - ); - assert_eq!( - session.exporters.openinference.endpoint.as_deref(), - Some("http://header-otel") - ); assert_eq!(session.profile.as_deref(), Some("profile-a")); assert_eq!(session.metadata, Some(json!({ "team": "obs" }))); assert_eq!(session.plugin_config, Some(json!({ "components": [] }))); @@ -80,14 +55,6 @@ fn session_config_uses_defaults_and_ignores_bad_json() { let session = config().session_config_from_headers(&headers); - assert_eq!( - session.exporters.atif.dir, - Some(PathBuf::from("default-atif")) - ); - assert_eq!( - session.exporters.openinference.endpoint.as_deref(), - Some("http://default-otel") - ); assert_eq!(session.metadata, None); assert_eq!(header_string(&headers, "x-empty"), None); } @@ -129,20 +96,6 @@ fn explicit_toml_config_maps_supported_sections() { openai_base_url = "http://openai" anthropic_base_url = "http://anthropic" -[exporters.atif] -dir = "atif" - -[exporters.atof] -dir = "atof" -mode = "overwrite" -filename_template = "{session_id}-events.jsonl" - -[exporters.openinference] -endpoint = "http://otel" - -[observability] -metadata = { team = "obs" } - [plugins] config = { components = [] } @@ -166,11 +119,6 @@ command = "hermes --yolo chat" config: Some(path), openai_base_url: None, anthropic_base_url: None, - atif_dir: None, - - atof_dir: None, - - openinference_endpoint: None, session_metadata: None, plugin_config: None, dry_run: false, @@ -183,24 +131,7 @@ command = "hermes --yolo chat" assert_eq!(resolved.gateway.bind.to_string(), "127.0.0.1:0"); assert_eq!(resolved.gateway.openai_base_url, "http://openai"); assert_eq!(resolved.gateway.anthropic_base_url, "http://anthropic"); - assert_eq!( - resolved.gateway.exporters.atif.dir, - Some(PathBuf::from("atif")) - ); - assert_eq!( - resolved.gateway.exporters.atof.dir, - Some(PathBuf::from("atof")) - ); - assert_eq!(resolved.gateway.exporters.atof.mode.as_str(), "overwrite"); - assert_eq!( - resolved.gateway.exporters.atof.filename_template, - "{session_id}-events.jsonl" - ); - assert_eq!( - resolved.gateway.exporters.openinference.endpoint.as_deref(), - Some("http://otel") - ); - assert_eq!(resolved.gateway.metadata, Some(json!({ "team": "obs" }))); + assert_eq!(resolved.gateway.metadata, None); assert_eq!( resolved.gateway.plugin_config, Some(json!({ "components": [] })) @@ -217,7 +148,50 @@ command = "hermes --yolo chat" } #[test] -fn explicit_plugin_toml_maps_root_plugin_config() { +fn legacy_observability_config_sections_fail_clearly() { + let temp = tempfile::tempdir().unwrap(); + for (name, contents, expected) in [ + ( + "exporters.toml", + "[exporters]\natof_dir = \"atof\"\n", + "[exporters]", + ), + ( + "observability.toml", + "[observability]\natif_dir = \"atif\"\n", + "[observability]", + ), + ( + "openinference.toml", + "[export.openinference]\nendpoint = \"http://localhost:4318\"\n", + "[export.openinference]", + ), + ] { + let path = temp.path().join(name); + std::fs::write(&path, contents).unwrap(); + let command = RunCommand { + agent: None, + config: Some(path), + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config: None, + dry_run: false, + print: false, + command: vec![], + }; + + let error = resolve_run_config(&command, None).unwrap_err().to_string(); + + assert!(error.contains("legacy observability config")); + assert!(error.contains(expected)); + assert!(error.contains("plugins.toml")); + assert!(error.contains("nemo-flow plugins edit")); + } +} + +#[test] +fn explicit_plugins_toml_maps_root_plugin_config() { let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("config.toml"); std::fs::write( @@ -229,7 +203,7 @@ openai_base_url = "http://openai" ) .unwrap(); std::fs::write( - temp.path().join("plugin.toml"), + temp.path().join("plugins.toml"), r#" version = 1 @@ -253,9 +227,6 @@ mode = "overwrite" config: Some(config_path), openai_base_url: None, anthropic_base_url: None, - atif_dir: None, - atof_dir: None, - openinference_endpoint: None, session_metadata: None, plugin_config: None, dry_run: false, @@ -289,38 +260,50 @@ mode = "overwrite" } #[test] -fn plugin_toml_path_resolution_tracks_config_scope() { +fn plugins_toml_path_resolution_tracks_config_scope() { let temp = tempfile::tempdir().unwrap(); let explicit = temp.path().join("custom-config.toml"); assert_eq!( plugin_config_paths(Some(&explicit)), - vec![temp.path().join("plugin.toml")] + vec![temp.path().join("plugins.toml")] ); let project = temp.path().join("workspace"); let nested = project.join("a/b/c"); std::fs::create_dir_all(project.join(".nemo-flow")).unwrap(); std::fs::create_dir_all(&nested).unwrap(); - let plugin_path = project.join(".nemo-flow/plugin.toml"); + let plugin_path = project.join(".nemo-flow/plugins.toml"); std::fs::write(&plugin_path, "version = 1").unwrap(); let user_config = temp.path().join("xdg/nemo-flow"); assert_eq!(find_project_plugin_config(&nested), Some(plugin_path)); + assert_eq!( + project_plugin_config_path(&nested), + project.join(".nemo-flow/plugins.toml") + ); assert_eq!( implicit_plugin_config_paths(Some(&nested), Some(user_config.clone())), vec![ - PathBuf::from("/etc/nemo-flow/plugin.toml"), - project.join(".nemo-flow/plugin.toml"), - user_config.join("plugin.toml"), + PathBuf::from("/etc/nemo-flow/plugins.toml"), + project.join(".nemo-flow/plugins.toml"), + user_config.join("plugins.toml"), ] ); + + std::fs::remove_file(project.join(".nemo-flow/plugins.toml")).unwrap(); + std::fs::write(project.join(".nemo-flow/config.toml"), "").unwrap(); + assert_eq!(find_project_plugin_config(&nested), None); + assert_eq!( + project_plugin_config_path(&nested), + project.join(".nemo-flow/plugins.toml") + ); } #[test] -fn discovered_plugin_toml_upserts_components_by_kind() { +fn discovered_plugins_toml_upserts_components_by_kind() { let temp = tempfile::tempdir().unwrap(); - let project_plugin = temp.path().join("project-plugin.toml"); - let user_plugin = temp.path().join("user-plugin.toml"); + let project_plugin = temp.path().join("project-plugins.toml"); + let user_plugin = temp.path().join("user-plugins.toml"); std::fs::write( &project_plugin, r#" @@ -417,7 +400,100 @@ source = "user" } #[test] -fn plugin_toml_conflicts_with_config_toml_plugins_config() { +fn discovered_plugins_toml_can_disable_lower_priority_observability_section() { + let temp = tempfile::tempdir().unwrap(); + let project_plugin = temp.path().join("project-plugins.toml"); + let user_plugin = temp.path().join("user-plugins.toml"); + std::fs::write( + &project_plugin, + r#" +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 1 + +[components.config.atof] +enabled = true +output_directory = "project-atof" +mode = "overwrite" +"#, + ) + .unwrap(); + std::fs::write( + &user_plugin, + r#" +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 1 + +[components.config.atof] +enabled = false +mode = "append" +"#, + ) + .unwrap(); + + let resolved = load_plugin_toml_config_from_paths(vec![project_plugin, user_plugin]).unwrap(); + + assert_eq!( + resolved.map(|config| config.value), + Some(json!({ + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": true, + "config": { + "version": 1, + "atof": { + "enabled": false, + "output_directory": "project-atof", + "mode": "append" + } + } + } + ] + })) + ); +} + +#[test] +fn plugins_toml_rejects_duplicate_component_kinds_per_file() { + let temp = tempfile::tempdir().unwrap(); + let plugin_path = temp.path().join("plugins.toml"); + std::fs::write( + &plugin_path, + r#" +version = 1 + +[[components]] +kind = "observability" + +[[components]] +kind = "observability" +"#, + ) + .unwrap(); + + let error = load_plugin_toml_config_from_paths(vec![plugin_path]) + .unwrap_err() + .to_string(); + + assert!(error.contains("duplicate plugin component kind")); + assert!(error.contains("observability")); +} + +#[test] +fn plugins_toml_conflicts_with_config_toml_plugins_config() { let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("config.toml"); std::fs::write( @@ -428,7 +504,7 @@ config = { version = 1, components = [] } "#, ) .unwrap(); - std::fs::write(temp.path().join("plugin.toml"), "version = 1\n").unwrap(); + std::fs::write(temp.path().join("plugins.toml"), "version = 1\n").unwrap(); let args = ServerArgs { config: Some(config_path), ..ServerArgs::default() @@ -438,7 +514,7 @@ config = { version = 1, components = [] } assert!(error.contains("plugin config is defined in both")); assert!(error.contains("config.toml")); - assert!(error.contains("plugin.toml")); + assert!(error.contains("plugins.toml")); } #[test] @@ -446,15 +522,12 @@ fn cli_plugin_config_conflicts_with_file_plugin_config() { let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("config.toml"); std::fs::write(&config_path, "").unwrap(); - std::fs::write(temp.path().join("plugin.toml"), "version = 1\n").unwrap(); + std::fs::write(temp.path().join("plugins.toml"), "version = 1\n").unwrap(); let command = RunCommand { agent: Some(CodingAgent::Codex), config: Some(config_path), openai_base_url: None, anthropic_base_url: None, - atif_dir: None, - atof_dir: None, - openinference_endpoint: None, session_metadata: None, plugin_config: Some(r#"{"version":1,"components":[]}"#.into()), dry_run: false, @@ -477,10 +550,6 @@ fn cli_run_overrides_config_values() { r#" [upstream] openai_base_url = "http://file-openai" - -[observability] -atif_dir = "file-atif" -metadata = { team = "file" } "#, ) .unwrap(); @@ -489,9 +558,6 @@ metadata = { team = "file" } config: Some(path), openai_base_url: Some("http://cli-openai".into()), anthropic_base_url: None, - atif_dir: Some(PathBuf::from("cli-atif")), - atof_dir: None, - openinference_endpoint: None, session_metadata: Some(r#"{"team":"cli"}"#.into()), plugin_config: None, dry_run: false, @@ -502,10 +568,6 @@ metadata = { team = "file" } let resolved = resolve_run_config(&command, None).unwrap(); assert_eq!(resolved.gateway.openai_base_url, "http://cli-openai"); - assert_eq!( - resolved.gateway.exporters.atif.dir, - Some(PathBuf::from("cli-atif")) - ); assert_eq!(resolved.gateway.metadata, Some(json!({ "team": "cli" }))); } @@ -531,11 +593,6 @@ openai_base_url = "http://file-openai" config: None, openai_base_url: None, anthropic_base_url: None, - atif_dir: None, - - atof_dir: None, - - openinference_endpoint: None, session_metadata: None, plugin_config: None, dry_run: false, @@ -559,9 +616,6 @@ fn run_plugin_config_overrides_inherited_top_level_plugin_config() { config: None, openai_base_url: None, anthropic_base_url: None, - atif_dir: None, - atof_dir: None, - openinference_endpoint: None, session_metadata: None, plugin_config: Some(r#"{"components":["run"]}"#.into()), dry_run: false, @@ -584,9 +638,6 @@ fn server_resolution_applies_all_server_overrides() { bind: Some("127.0.0.1:0".parse().unwrap()), openai_base_url: Some("http://cli-openai".into()), anthropic_base_url: Some("http://cli-anthropic".into()), - atif_dir: Some(PathBuf::from("cli-atif")), - atof_dir: None, - openinference_endpoint: Some("http://cli-otel".into()), plugin_config: Some(r#"{"version":1,"components":[]}"#.into()), }; @@ -595,14 +646,6 @@ fn server_resolution_applies_all_server_overrides() { assert_eq!(resolved.gateway.bind.to_string(), "127.0.0.1:0"); assert_eq!(resolved.gateway.openai_base_url, "http://cli-openai"); assert_eq!(resolved.gateway.anthropic_base_url, "http://cli-anthropic"); - assert_eq!( - resolved.gateway.exporters.atif.dir, - Some(PathBuf::from("cli-atif")) - ); - assert_eq!( - resolved.gateway.exporters.openinference.endpoint.as_deref(), - Some("http://cli-otel") - ); assert_eq!( resolved.gateway.plugin_config, Some(json!({ "version": 1, "components": [] })) @@ -617,9 +660,6 @@ fn run_resolution_applies_all_run_overrides() { config: None, openai_base_url: Some("http://run-openai".into()), anthropic_base_url: Some("http://run-anthropic".into()), - atif_dir: Some(PathBuf::from("run-atif")), - atof_dir: None, - openinference_endpoint: Some("http://run-otel".into()), session_metadata: Some(r#"{"team":"run"}"#.into()), plugin_config: Some(r#"{"components":["x"]}"#.into()), dry_run: false, @@ -631,14 +671,6 @@ fn run_resolution_applies_all_run_overrides() { assert_eq!(resolved.gateway.openai_base_url, "http://run-openai"); assert_eq!(resolved.gateway.anthropic_base_url, "http://run-anthropic"); - assert_eq!( - resolved.gateway.exporters.atif.dir, - Some(PathBuf::from("run-atif")) - ); - assert_eq!( - resolved.gateway.exporters.openinference.endpoint.as_deref(), - Some("http://run-otel") - ); assert_eq!(resolved.gateway.metadata, Some(json!({ "team": "run" }))); assert_eq!( resolved.gateway.plugin_config, @@ -671,9 +703,9 @@ fn malformed_shared_config_reports_context() { assert!(error.contains("invalid gateway configuration shape")); - let plugin_config = temp.path().join("config-with-invalid-plugin.toml"); + let plugin_config = temp.path().join("config-with-invalid-plugins.toml"); std::fs::write(&plugin_config, "").unwrap(); - std::fs::write(temp.path().join("plugin.toml"), "version = [").unwrap(); + std::fs::write(temp.path().join("plugins.toml"), "version = [").unwrap(); let args = ServerArgs { config: Some(plugin_config), ..ServerArgs::default() @@ -691,9 +723,9 @@ fn recursive_toml_merge_replaces_scalars_and_preserves_tables() { openai_base_url = "http://old" anthropic_base_url = "http://anthropic" -[observability.metadata] -team = "old" -env = "dev" +[plugins.config] +version = 1 +policy = { unknown_component = "warn", unknown_field = "warn" } "# .parse::() .map(toml::Value::Table) @@ -702,8 +734,8 @@ env = "dev" [upstream] openai_base_url = "http://new" -[observability.metadata] -team = "new" +[plugins.config.policy] +unknown_component = "error" "# .parse::() .map(toml::Value::Table) @@ -720,11 +752,11 @@ team = "new" Some("http://anthropic") ); assert_eq!( - left["observability"]["metadata"]["team"].as_str(), - Some("new") + left["plugins"]["config"]["policy"]["unknown_component"].as_str(), + Some("error") ); assert_eq!( - left["observability"]["metadata"]["env"].as_str(), - Some("dev") + left["plugins"]["config"]["policy"]["unknown_field"].as_str(), + Some("warn") ); } diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/doctor_tests.rs index 5ae70fc94..3310745b0 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use crate::config::{AtifExporterSettings, ExportersConfig}; use std::path::PathBuf; fn empty_report() -> DoctorReport { @@ -186,7 +185,7 @@ fn format_human_reports_config_resolution_failure() { let mut report = empty_report(); report.configuration.resolution.status = Status::Fail; report.configuration.resolution.details = - "could not resolve merged config: invalid [exporters.atof].mode".into(); + "could not resolve merged config: invalid plugin TOML".into(); let rendered = format_human(&report); @@ -241,18 +240,26 @@ async fn collect_observability_warns_for_missing_atif_dir_without_creating_it() let temp = tempfile::tempdir().unwrap(); let missing = temp.path().join("missing-atif"); let gateway = GatewayConfig { - exporters: ExportersConfig { - atif: AtifExporterSettings { - dir: Some(missing.clone()), - }, - ..Default::default() - }, + plugin_config: Some(serde_json::json!({ + "version": 1, + "components": [{ + "kind": "observability", + "enabled": true, + "config": { + "version": 1, + "atif": { + "enabled": true, + "output_directory": missing + } + } + }] + })), ..GatewayConfig::default() }; let checks = collect_observability(&gateway).await; - assert_eq!(checks[0].status, Status::Warn); + assert!(checks.iter().any(|check| check.status == Status::Warn)); assert!(!missing.exists()); } diff --git a/crates/cli/tests/coverage/gateway_tests.rs b/crates/cli/tests/coverage/gateway_tests.rs index e45a74cf7..45818856f 100644 --- a/crates/cli/tests/coverage/gateway_tests.rs +++ b/crates/cli/tests/coverage/gateway_tests.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use crate::config::{ExportersConfig, GatewayConfig}; +use crate::config::GatewayConfig; use crate::server::AppState; use crate::session::SessionManager; use axum::body::Body; @@ -80,10 +80,9 @@ fn selects_provider_routes() { fn provider_routes_preserve_path_query_and_choose_upstream() { let config = GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), - openai_base_url: "http://openai/".into(), + openai_base_url: "http://openai/v1/".into(), anthropic_base_url: "http://anthropic/".into(), - exporters: ExportersConfig::default(), metadata: None, plugin_config: None, }; @@ -106,6 +105,36 @@ fn provider_routes_preserve_path_query_and_choose_upstream() { ); } +#[test] +fn openai_upstream_url_accepts_origin_or_v1_base() { + let mut config = GatewayConfig { + bind: "127.0.0.1:0".parse().unwrap(), + openai_base_url: "http://openai".into(), + anthropic_base_url: "http://anthropic".into(), + metadata: None, + plugin_config: None, + }; + + assert_eq!( + ProviderRoute::OpenAiResponses.upstream_url(&config, "/responses"), + "http://openai/v1/responses" + ); + assert_eq!( + ProviderRoute::OpenAiResponses.upstream_url(&config, "/v1/responses"), + "http://openai/v1/responses" + ); + + config.openai_base_url = "http://openai/v1".into(); + assert_eq!( + ProviderRoute::OpenAiResponses.upstream_url(&config, "/responses"), + "http://openai/v1/responses" + ); + assert_eq!( + ProviderRoute::OpenAiResponses.upstream_url(&config, "/v1/responses"), + "http://openai/v1/responses" + ); +} + #[test] fn gateway_session_id_prefers_headers_and_has_fallbacks() { let mut headers = HeaderMap::new(); @@ -407,7 +436,6 @@ async fn passthrough_rejects_unsupported_provider_path_directly() { openai_base_url: "http://openai".into(), anthropic_base_url: "http://anthropic".into(), - exporters: ExportersConfig::default(), metadata: None, plugin_config: None, }; @@ -434,7 +462,6 @@ async fn models_rejects_non_get_requests_directly() { openai_base_url: "http://openai".into(), anthropic_base_url: "http://anthropic".into(), - exporters: ExportersConfig::default(), metadata: None, plugin_config: None, }; diff --git a/crates/cli/tests/coverage/installer_tests.rs b/crates/cli/tests/coverage/installer_tests.rs index 9ad05aa3e..712a6b5e4 100644 --- a/crates/cli/tests/coverage/installer_tests.rs +++ b/crates/cli/tests/coverage/installer_tests.rs @@ -93,10 +93,7 @@ fn helper_formatting_and_headers_cover_optional_paths() { assert!(event_matches_tools("PermissionRequest")); assert!(!event_matches_tools("SessionStart")); - let temp = tempfile::tempdir().unwrap(); let headers = gateway_headers( - Some(temp.path()), - Some("http://otel"), Some("profile"), Some(r#"{"team":"obs"}"#), Some(r#"{"plugins":[]}"#), @@ -118,7 +115,7 @@ fn helper_formatting_and_headers_cover_optional_paths() { .is_err() ); - let headers = gateway_headers(None, None, None, None, None, None).unwrap(); + let headers = gateway_headers(None, None, None, None).unwrap(); assert!(headers.is_empty()); } diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index 1857fd6e2..5ff40233f 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -17,11 +17,6 @@ fn infers_agent_from_command_or_uses_override() { config: None, openai_base_url: None, anthropic_base_url: None, - atif_dir: None, - - atof_dir: None, - - openinference_endpoint: None, session_metadata: None, plugin_config: None, dry_run: false, @@ -55,11 +50,6 @@ fn uses_configured_command_when_no_argv_is_supplied() { config: None, openai_base_url: None, anthropic_base_url: None, - atif_dir: None, - - atof_dir: None, - - openinference_endpoint: None, session_metadata: None, plugin_config: None, dry_run: false, @@ -87,11 +77,6 @@ fn uses_configured_hermes_command_when_no_argv_is_supplied() { config: None, openai_base_url: None, anthropic_base_url: None, - atif_dir: None, - - atof_dir: None, - - openinference_endpoint: None, session_metadata: None, plugin_config: None, dry_run: false, @@ -112,11 +97,6 @@ fn inference_failure_has_actionable_message() { config: None, openai_base_url: None, anthropic_base_url: None, - atif_dir: None, - - atof_dir: None, - - openinference_endpoint: None, session_metadata: None, plugin_config: None, dry_run: false, @@ -142,11 +122,6 @@ fn missing_command_without_agent_errors() { config: None, openai_base_url: None, anthropic_base_url: None, - atif_dir: None, - - atof_dir: None, - - openinference_endpoint: None, session_metadata: None, plugin_config: None, dry_run: false, @@ -170,11 +145,6 @@ fn agent_without_configured_command_falls_back_to_default_binary() { config: None, openai_base_url: None, anthropic_base_url: None, - atif_dir: None, - - atof_dir: None, - - openinference_endpoint: None, session_metadata: None, plugin_config: None, dry_run: false, @@ -196,11 +166,6 @@ fn agent_with_passthrough_args_appends_to_configured_command() { config: None, openai_base_url: None, anthropic_base_url: None, - atif_dir: None, - - atof_dir: None, - - openinference_endpoint: None, session_metadata: None, plugin_config: None, dry_run: false, @@ -585,11 +550,6 @@ async fn run_starts_gateway_injects_env_and_returns_agent_exit_code() { config: None, openai_base_url: None, anthropic_base_url: None, - atif_dir: None, - - atof_dir: None, - - openinference_endpoint: None, session_metadata: None, plugin_config: None, dry_run: false, @@ -631,11 +591,6 @@ async fn dry_run_does_not_spawn_agent() { config: None, openai_base_url: None, anthropic_base_url: None, - atif_dir: None, - - atof_dir: None, - - openinference_endpoint: None, session_metadata: None, plugin_config: None, dry_run: true, diff --git a/crates/cli/tests/coverage/plugins_tests.rs b/crates/cli/tests/coverage/plugins_tests.rs new file mode 100644 index 000000000..ec1c0910d --- /dev/null +++ b/crates/cli/tests/coverage/plugins_tests.rs @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +#[test] +fn typed_editor_model_contains_observability_sections() { + let schema = ObservabilityConfig::editor_schema(); + let atof = schema.field("atof").unwrap().schema().unwrap(); + let atif = schema.field("atif").unwrap().schema().unwrap(); + let openinference = schema.field("openinference").unwrap().schema().unwrap(); + assert!(atof.fields.iter().any(|field| field.name == "mode")); + assert!( + atif.fields + .iter() + .any(|field| field.name == "filename_template") + ); + assert!( + openinference + .fields + .iter() + .any(|field| field.name == "endpoint") + ); +} + +#[test] +fn editor_model_renders_valid_observability_plugin_config() { + let mut config = PluginConfig::default(); + ensure_observability_component(&mut config).unwrap(); + let mut observability = component_observability_config(&config).unwrap(); + let atof = ObservabilityConfig::editor_schema().field("atof").unwrap(); + toggle_section(&mut observability, atof); + set_section_field(&mut observability, atof, "output_directory", json!("logs")).unwrap(); + set_section_field(&mut observability, atof, "filename", json!("events.jsonl")).unwrap(); + store_observability_config(&mut config, &observability).unwrap(); + + validate_config(&config).unwrap(); +} + +#[test] +fn typed_editor_serializes_explicit_observability_overrides() { + let mut observability = ObservabilityConfig::default(); + let atof = ObservabilityConfig::editor_schema().field("atof").unwrap(); + toggle_section(&mut observability, atof); + set_section_field(&mut observability, atof, "output_directory", json!("logs")).unwrap(); + + let map = observability_config_map(&observability).unwrap(); + let atof = map + .get("atof") + .and_then(Value::as_object) + .expect("atof section is serialized"); + assert_eq!(atof.get("enabled"), Some(&Value::Bool(true))); + assert_eq!(atof.get("output_directory"), Some(&json!("logs"))); + assert_eq!(atof.get("mode"), Some(&json!("append"))); + assert!(map.contains_key("policy")); +} + +#[test] +fn typed_editor_serializes_disabled_section_override() { + let mut observability = ObservabilityConfig::default(); + let atif = ObservabilityConfig::editor_schema().field("atif").unwrap(); + toggle_section(&mut observability, atif); + toggle_section(&mut observability, atif); + + let map = observability_config_map(&observability).unwrap(); + let atif = map + .get("atif") + .and_then(Value::as_object) + .expect("disabled atif section is serialized"); + assert_eq!(atif.get("enabled"), Some(&Value::Bool(false))); + assert_eq!( + atif.get("filename_template"), + Some(&json!("nemo-flow-atif-{session_id}.json")) + ); +} + +#[test] +fn editor_save_preserves_unknown_observability_fields() { + let mut config = PluginConfig { + components: vec![PluginComponentSpec { + kind: OBSERVABILITY_PLUGIN_KIND.to_string(), + enabled: true, + config: json!({ + "version": 1, + "future_top_level": "preserve", + "atof": { + "enabled": true, + "output_directory": "old-logs", + "future_atof_field": "preserve" + } + }) + .as_object() + .unwrap() + .clone(), + }], + ..PluginConfig::default() + }; + let mut observability = component_observability_config(&config).unwrap(); + let atof = ObservabilityConfig::editor_schema().field("atof").unwrap(); + remove_section_field(&mut observability, atof, "output_directory").unwrap(); + set_section_field(&mut observability, atof, "filename", json!("events.jsonl")).unwrap(); + + store_observability_config(&mut config, &observability).unwrap(); + + let component = observability_component(&config).unwrap(); + assert_eq!( + component.config.get("future_top_level"), + Some(&json!("preserve")) + ); + let atof_config = component + .config + .get("atof") + .and_then(Value::as_object) + .unwrap(); + assert_eq!( + atof_config.get("future_atof_field"), + Some(&json!("preserve")) + ); + assert_eq!(atof_config.get("filename"), Some(&json!("events.jsonl"))); + assert!(!atof_config.contains_key("output_directory")); +} diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/server_tests.rs index 99070b99d..c058d041d 100644 --- a/crates/cli/tests/coverage/server_tests.rs +++ b/crates/cli/tests/coverage/server_tests.rs @@ -23,7 +23,6 @@ use tokio::task::JoinHandle; use tower::ServiceExt; use super::*; -use crate::config::ExportersConfig; use crate::error::CliError; static PLUGIN_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); @@ -85,7 +84,6 @@ fn test_config() -> GatewayConfig { openai_base_url: "http://127.0.0.1".into(), anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig::default(), metadata: None, plugin_config: None, } diff --git a/crates/cli/tests/coverage/session_tests.rs b/crates/cli/tests/coverage/session_tests.rs index f7aac3484..9318f4367 100644 --- a/crates/cli/tests/coverage/session_tests.rs +++ b/crates/cli/tests/coverage/session_tests.rs @@ -2,12 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 use axum::http::HeaderMap; -use nemo_flow::observability::atof::AtofExporterMode; use serde_json::json; use super::*; -use crate::config::{AtifExporterSettings, AtofExporterSettings, ExportersConfig}; -use crate::model::{LlmEvent, LlmHintEvent, SessionEvent, ToolEvent}; +use crate::model::{LlmHintEvent, SessionEvent, ToolEvent}; #[tokio::test] async fn nests_agent_subagent_and_tool_lifecycle() { @@ -16,7 +14,6 @@ async fn nests_agent_subagent_and_tool_lifecycle() { openai_base_url: "http://127.0.0.1".into(), anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig::default(), metadata: None, plugin_config: None, }; @@ -84,280 +81,6 @@ async fn nests_agent_subagent_and_tool_lifecycle() { assert!(manager.inner.lock().await.is_empty()); } -#[tokio::test] -async fn writes_atif_on_session_end_from_header_config() { - let temp = tempfile::tempdir().unwrap(); - let config = GatewayConfig { - bind: "127.0.0.1:0".parse().unwrap(), - openai_base_url: "http://127.0.0.1".into(), - - anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig::default(), - metadata: None, - plugin_config: None, - }; - let manager = SessionManager::new(config); - let mut headers = HeaderMap::new(); - headers.insert( - "x-nemo-flow-atif-dir", - temp.path().to_string_lossy().parse().unwrap(), - ); - headers.insert( - "x-nemo-flow-session-metadata", - r#"{"team":"coverage"}"#.parse().unwrap(), - ); - headers.insert("x-nemo-flow-gateway-mode", "required".parse().unwrap()); - - manager - .apply_events( - &headers, - vec![ - NormalizedEvent::AgentStarted(SessionEvent { - session_id: "atif-session".into(), - agent_kind: AgentKind::Codex, - event_name: "sessionStart".into(), - payload: json!({ "start": true }), - metadata: json!({ "agent": "codex" }), - }), - NormalizedEvent::PromptSubmitted(SessionEvent { - session_id: "atif-session".into(), - agent_kind: AgentKind::Codex, - event_name: "UserPromptSubmit".into(), - payload: json!({ "prompt": "hello" }), - metadata: json!({}), - }), - NormalizedEvent::AgentEnded(SessionEvent { - session_id: "atif-session".into(), - agent_kind: AgentKind::Codex, - event_name: "sessionEnd".into(), - payload: json!({ "done": true }), - metadata: json!({}), - }), - ], - ) - .await - .unwrap(); - - let path = temp.path().join("atif-session.atif.json"); - let atif: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); - assert_eq!(atif["agent"]["name"], json!("codex")); -} - -#[tokio::test] -async fn writes_atof_with_configured_mode_and_filename_template() { - let temp = tempfile::tempdir().unwrap(); - let output = temp.path().join("custom-atof-mode.jsonl"); - std::fs::write(&output, "{\"existing\":true}\n").unwrap(); - let config = GatewayConfig { - bind: "127.0.0.1:0".parse().unwrap(), - openai_base_url: "http://127.0.0.1".into(), - anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig { - atof: AtofExporterSettings { - dir: Some(temp.path().to_path_buf()), - mode: AtofExporterMode::Overwrite, - filename_template: "custom-{session_id}.jsonl".into(), - }, - ..Default::default() - }, - metadata: None, - plugin_config: None, - }; - let manager = SessionManager::new(config); - - manager - .apply_events( - &HeaderMap::new(), - vec![ - NormalizedEvent::AgentStarted(SessionEvent { - session_id: "atof-mode".into(), - agent_kind: AgentKind::Codex, - event_name: "SessionStart".into(), - payload: json!({}), - metadata: json!({}), - }), - NormalizedEvent::AgentEnded(SessionEvent { - session_id: "atof-mode".into(), - agent_kind: AgentKind::Codex, - event_name: "SessionEnd".into(), - payload: json!({}), - metadata: json!({}), - }), - ], - ) - .await - .unwrap(); - - let contents = std::fs::read_to_string(output).unwrap(); - assert!(!contents.contains("existing")); - assert!(contents.contains("atof-mode")); -} - -#[tokio::test] -async fn duplicate_agent_end_does_not_overwrite_atif_with_empty_session() { - // Regression test: hermes-agent and other integrations can emit terminal hooks more than once - // per session. Without idempotency in `end_agent`, the second AgentEnded would re-open an - // empty agent scope via `ensure_agent_started`, close it, and `flush_observers` would write - // an empty ATIF on top of the just-written real trajectory. - let temp = tempfile::tempdir().unwrap(); - let config = GatewayConfig { - bind: "127.0.0.1:0".parse().unwrap(), - openai_base_url: "http://127.0.0.1".into(), - - anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig { - atif: AtifExporterSettings { - dir: Some(temp.path().to_path_buf()), - }, - ..Default::default() - }, - metadata: None, - plugin_config: None, - }; - let manager = SessionManager::new(config); - let headers = HeaderMap::new(); - - manager - .apply_events( - &headers, - vec![ - NormalizedEvent::AgentStarted(SessionEvent { - session_id: "dup-end".into(), - agent_kind: AgentKind::ClaudeCode, - event_name: "SessionStart".into(), - payload: json!({}), - metadata: json!({}), - }), - NormalizedEvent::PromptSubmitted(SessionEvent { - session_id: "dup-end".into(), - agent_kind: AgentKind::ClaudeCode, - event_name: "UserPromptSubmit".into(), - payload: json!({ "prompt": "hello" }), - metadata: json!({}), - }), - NormalizedEvent::AgentEnded(SessionEvent { - session_id: "dup-end".into(), - agent_kind: AgentKind::ClaudeCode, - event_name: "SessionEnd".into(), - payload: json!({ "done": true }), - metadata: json!({}), - }), - ], - ) - .await - .unwrap(); - - let path = temp.path().join("dup-end.atif.json"); - let first: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - let first_steps = first["steps"].as_array().unwrap().len(); - assert!( - first_steps > 0, - "first AgentEnded should produce a non-empty ATIF" - ); - - // Second AgentEnded for the same session — must be a no-op, not overwrite with empty. - manager - .apply_events( - &headers, - vec![NormalizedEvent::AgentEnded(SessionEvent { - session_id: "dup-end".into(), - agent_kind: AgentKind::ClaudeCode, - event_name: "SessionEnd".into(), - payload: json!({ "done_again": true }), - metadata: json!({}), - })], - ) - .await - .unwrap(); - - let second: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - let second_steps = second["steps"].as_array().unwrap().len(); - assert_eq!( - first_steps, second_steps, - "duplicate AgentEnded must not change the ATIF step count" - ); -} - -#[tokio::test] -async fn writes_hermes_api_hook_usage_to_atif_metrics() { - let temp = tempfile::tempdir().unwrap(); - let config = GatewayConfig { - bind: "127.0.0.1:0".parse().unwrap(), - openai_base_url: "http://127.0.0.1".into(), - - anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig::default(), - metadata: None, - plugin_config: None, - }; - let manager = SessionManager::new(config); - let mut headers = HeaderMap::new(); - headers.insert( - "x-nemo-flow-atif-dir", - temp.path().to_string_lossy().parse().unwrap(), - ); - - manager - .apply_events( - &headers, - vec![ - NormalizedEvent::AgentStarted(SessionEvent { - session_id: "hermes-usage".into(), - agent_kind: AgentKind::Hermes, - event_name: "on_session_start".into(), - payload: json!({}), - metadata: json!({}), - }), - NormalizedEvent::LlmStarted(LlmEvent { - session_id: "hermes-usage".into(), - agent_kind: AgentKind::Hermes, - event_name: "pre_api_request".into(), - api_call_id: "hermes-usage:task-1:1".into(), - provider: "custom".into(), - model_name: Some("qwen".into()), - request: json!({ "model": "qwen" }), - response: Value::Null, - metadata: json!({}), - }), - NormalizedEvent::LlmEnded(LlmEvent { - session_id: "hermes-usage".into(), - agent_kind: AgentKind::Hermes, - event_name: "post_api_request".into(), - api_call_id: "hermes-usage:task-1:1".into(), - provider: "custom".into(), - model_name: Some("qwen".into()), - request: json!({}), - response: json!({ - "usage": { - "prompt_tokens": 10, - "completion_tokens": 5, - "prompt_tokens_details": { "cached_tokens": 3 } - } - }), - metadata: json!({}), - }), - NormalizedEvent::AgentEnded(SessionEvent { - session_id: "hermes-usage".into(), - agent_kind: AgentKind::Hermes, - event_name: "on_session_finalize".into(), - payload: json!({}), - metadata: json!({}), - }), - ], - ) - .await - .unwrap(); - - let path = temp.path().join("hermes-usage.atif.json"); - let atif: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); - assert_eq!(atif["steps"][1]["metrics"]["prompt_tokens"], json!(10)); - assert_eq!(atif["steps"][1]["metrics"]["completion_tokens"], json!(5)); - assert_eq!(atif["steps"][1]["metrics"]["cached_tokens"], json!(3)); - assert_eq!(atif["final_metrics"]["total_prompt_tokens"], json!(10)); - assert_eq!(atif["final_metrics"]["total_completion_tokens"], json!(5)); - assert_eq!(atif["final_metrics"]["total_cached_tokens"], json!(3)); -} - #[tokio::test] async fn handles_out_of_order_subagent_and_tool_end_events() { let config = GatewayConfig { @@ -365,7 +88,6 @@ async fn handles_out_of_order_subagent_and_tool_end_events() { openai_base_url: "http://127.0.0.1".into(), anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig::default(), metadata: None, plugin_config: None, }; @@ -415,8 +137,7 @@ async fn handles_out_of_order_subagent_and_tool_end_events() { #[tokio::test] async fn terminal_retry_for_unknown_session_is_ignored() { let temp = tempfile::tempdir().unwrap(); - let mut config = session_test_config(); - config.exporters.atif.dir = Some(temp.path().to_path_buf()); + let config = session_test_config(); let manager = SessionManager::new(config); manager @@ -444,7 +165,6 @@ async fn out_of_order_started_subagent_end_does_not_leak_scope() { openai_base_url: "http://127.0.0.1".into(), anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig::default(), metadata: None, plugin_config: None, }; @@ -516,7 +236,6 @@ async fn agent_end_closes_nested_active_subagents_lifo() { openai_base_url: "http://127.0.0.1".into(), anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig::default(), metadata: None, plugin_config: None, }; @@ -572,7 +291,6 @@ async fn llm_lifecycle_starts_implicit_gateway_session() { openai_base_url: "http://127.0.0.1".into(), anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig::default(), metadata: None, plugin_config: None, }; @@ -611,53 +329,6 @@ async fn llm_lifecycle_starts_implicit_gateway_session() { assert!(sessions.contains_key("llm-session")); } -#[tokio::test] -async fn agent_end_closes_in_flight_gateway_llm() { - let temp = tempfile::tempdir().unwrap(); - let mut config = session_test_config(); - config.exporters.atif.dir = Some(temp.path().to_path_buf()); - let manager = SessionManager::new(config); - let _active = manager - .start_llm( - &HeaderMap::new(), - LlmGatewayStart { - session_id: Some("gateway-cleanup".into()), - provider: "openai.responses".into(), - model_name: Some("gpt-test".into()), - subagent_id: None, - conversation_id: None, - generation_id: None, - request_id: None, - request: LlmRequest { - headers: Map::new(), - content: json!({ "model": "gpt-test", "input": "hello" }), - }, - streaming: true, - metadata: json!({ "gateway_path": "/v1/responses" }), - }, - ) - .await - .unwrap(); - - manager - .apply_events( - &HeaderMap::new(), - vec![NormalizedEvent::AgentEnded(SessionEvent { - session_id: "gateway-cleanup".into(), - agent_kind: AgentKind::Gateway, - event_name: "SessionEnd".into(), - payload: json!({}), - metadata: json!({}), - })], - ) - .await - .unwrap(); - - assert!(manager.inner.lock().await.is_empty()); - let atif = std::fs::read_to_string(temp.path().join("gateway-cleanup.atif.json")).unwrap(); - assert!(atif.contains("closed_by_agent_end")); -} - #[tokio::test] async fn llm_lifecycle_uses_single_active_hook_session_when_header_is_missing() { let config = GatewayConfig { @@ -665,7 +336,6 @@ async fn llm_lifecycle_uses_single_active_hook_session_when_header_is_missing() openai_base_url: "http://127.0.0.1".into(), anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig::default(), metadata: None, plugin_config: None, }; @@ -722,7 +392,6 @@ async fn single_pending_llm_hint_claims_next_gateway_llm() { openai_base_url: "http://127.0.0.1".into(), anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig::default(), metadata: None, plugin_config: None, }; @@ -819,7 +488,6 @@ async fn multiple_llm_hints_resolve_by_generation_id() { openai_base_url: "http://127.0.0.1".into(), anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig::default(), metadata: None, plugin_config: None, }; @@ -934,7 +602,6 @@ async fn ambiguous_llm_hints_fall_back_to_agent_scope() { openai_base_url: "http://127.0.0.1".into(), anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig::default(), metadata: None, plugin_config: None, }; @@ -1033,7 +700,6 @@ async fn no_active_hint_reuses_last_llm_owner() { openai_base_url: "http://127.0.0.1".into(), anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig::default(), metadata: None, plugin_config: None, }; @@ -1136,34 +802,6 @@ async fn no_active_hint_reuses_last_llm_owner() { .unwrap(); } -#[tokio::test] -async fn session_marks_cover_compaction_notifications_and_hook_marks() { - let temp = tempfile::tempdir().unwrap(); - let mut config = session_test_config(); - config.exporters.atif.dir = Some(temp.path().to_path_buf()); - let manager = SessionManager::new(config); - let headers = HeaderMap::new(); - - manager - .apply_events( - &headers, - vec![ - NormalizedEvent::AgentStarted(session_event("marks", "SessionStart")), - NormalizedEvent::Compaction(session_event("marks", "PreCompact")), - NormalizedEvent::Notification(session_event("marks", "Notification")), - NormalizedEvent::HookMark(session_event("marks", "CustomHook")), - NormalizedEvent::AgentEnded(session_event("marks", "SessionEnd")), - ], - ) - .await - .unwrap(); - - let atif = std::fs::read_to_string(temp.path().join("marks.atif.json")).unwrap(); - assert!(atif.contains("PreCompact")); - assert!(atif.contains("Notification")); - assert!(atif.contains("CustomHook")); -} - #[tokio::test] async fn agent_end_closes_active_tools_and_duplicate_starts_are_ignored() { let manager = SessionManager::new(session_test_config()); @@ -1457,26 +1095,6 @@ fn openai_response_tool_hints_ignore_non_tool_output_items() { assert_eq!(hints[0].tool_call_id.as_deref(), Some("call-1")); } -#[test] -fn write_atif_rejects_unsafe_session_id_filename() { - let temp = tempfile::tempdir().unwrap(); - let exporter = AtifExporter::new( - "safe-session".to_string(), - AtifAgentInfo { - name: "test-agent".to_string(), - version: "1.0.0".to_string(), - model_name: None, - tool_definitions: None, - extra: None, - }, - ); - - let error = write_atif(&temp.path().to_path_buf(), "../escape", &exporter).unwrap_err(); - - assert!(matches!(error, CliError::InvalidPayload(_))); - assert!(!temp.path().join("../escape.atif.json").exists()); -} - #[tokio::test] async fn multiple_tool_hints_resolve_by_tool_call_id() { let manager = SessionManager::new(session_test_config()); @@ -1693,256 +1311,11 @@ fn session_test_config() -> GatewayConfig { openai_base_url: "http://127.0.0.1".into(), anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig::default(), metadata: None, plugin_config: None, } } -// Regression: an Anthropic Messages gateway request that arrives before SessionStart used to -// freeze the session label as "gateway" (default agent_kind) for the rest of the session, -// because observer identities are baked at scope-open time. The session must instead be labeled -// `claude-code` from the provider, so ATIF and Phoenix root spans reflect the real agent. -#[tokio::test] -async fn gateway_first_anthropic_call_labels_session_as_claude_code() { - let temp = tempfile::tempdir().unwrap(); - let config = GatewayConfig { - bind: "127.0.0.1:0".parse().unwrap(), - openai_base_url: "http://127.0.0.1".into(), - - anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig { - atif: AtifExporterSettings { - dir: Some(temp.path().to_path_buf()), - }, - ..Default::default() - }, - metadata: None, - plugin_config: None, - }; - let manager = SessionManager::new(config); - let mut start = llm_start(); - start.session_id = Some("claude-uuid".into()); - start.provider = "anthropic.messages".into(); - let active = manager.start_llm(&HeaderMap::new(), start).await.unwrap(); - manager - .end_llm(active, json!({ "ok": true }), json!({})) - .await - .unwrap(); - // Drive an explicit AgentEnded so flush_observers writes ATIF. - manager - .apply_events( - &HeaderMap::new(), - vec![NormalizedEvent::AgentEnded(SessionEvent { - session_id: "claude-uuid".into(), - agent_kind: AgentKind::ClaudeCode, - event_name: "SessionEnd".into(), - payload: json!({}), - metadata: json!({}), - })], - ) - .await - .unwrap(); - - let atif: Value = serde_json::from_str( - &std::fs::read_to_string(temp.path().join("claude-uuid.atif.json")).unwrap(), - ) - .unwrap(); - assert_eq!( - atif["agent"]["name"], - json!("claude-code"), - "session created from anthropic.messages gateway request must be labeled claude-code, not gateway" - ); -} - -// OpenAI Responses gateway requests (codex's API path) must label the session as `codex`. -#[tokio::test] -async fn gateway_first_openai_responses_call_labels_session_as_codex() { - let temp = tempfile::tempdir().unwrap(); - let config = GatewayConfig { - bind: "127.0.0.1:0".parse().unwrap(), - openai_base_url: "http://127.0.0.1".into(), - - anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig { - atif: AtifExporterSettings { - dir: Some(temp.path().to_path_buf()), - }, - ..Default::default() - }, - metadata: None, - plugin_config: None, - }; - let manager = SessionManager::new(config); - let mut start = llm_start(); - start.session_id = Some("codex-uuid".into()); - start.provider = "openai.responses".into(); - let active = manager.start_llm(&HeaderMap::new(), start).await.unwrap(); - manager - .end_llm(active, json!({ "ok": true }), json!({})) - .await - .unwrap(); - manager - .apply_events( - &HeaderMap::new(), - vec![NormalizedEvent::AgentEnded(SessionEvent { - session_id: "codex-uuid".into(), - agent_kind: AgentKind::Codex, - event_name: "SessionEnd".into(), - payload: json!({}), - metadata: json!({}), - })], - ) - .await - .unwrap(); - - let atif: Value = serde_json::from_str( - &std::fs::read_to_string(temp.path().join("codex-uuid.atif.json")).unwrap(), - ) - .unwrap(); - assert_eq!(atif["agent"]["name"], json!("codex")); -} - -// Synthetic gateway-only sessions (pure proxy traffic, unknown provider) keep the legacy -// `gateway` label so existing observability semantics for unattributed traffic are preserved. -#[tokio::test] -async fn synthetic_gateway_session_keeps_gateway_label() { - let temp = tempfile::tempdir().unwrap(); - let config = GatewayConfig { - bind: "127.0.0.1:0".parse().unwrap(), - openai_base_url: "http://127.0.0.1".into(), - - anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig { - atif: AtifExporterSettings { - dir: Some(temp.path().to_path_buf()), - }, - ..Default::default() - }, - metadata: None, - plugin_config: None, - }; - let manager = SessionManager::new(config); - let mut start = llm_start(); - start.session_id = None; - start.provider = "openai.chat_completions".into(); // ambiguous → Gateway - let active = manager.start_llm(&HeaderMap::new(), start).await.unwrap(); - manager - .end_llm(active, json!({ "ok": true }), json!({})) - .await - .unwrap(); - manager - .apply_events( - &HeaderMap::new(), - vec![NormalizedEvent::AgentEnded(SessionEvent { - session_id: "gateway-gateway".into(), - agent_kind: AgentKind::Gateway, - event_name: "SessionEnd".into(), - payload: json!({}), - metadata: json!({}), - })], - ) - .await - .unwrap(); - - let atif: Value = serde_json::from_str( - &std::fs::read_to_string(temp.path().join("gateway-gateway.atif.json")).unwrap(), - ) - .unwrap(); - assert_eq!(atif["agent"]["name"], json!("gateway")); -} - -// `TurnEnded` (synthesized from per-turn `Stop` hooks) writes ATIF without closing the agent -// scope. This is the codex-0.129 workaround: codex has no `SessionEnd` hook, so per-turn -// snapshots are how its ATIF gets written. After several turns the agent scope must remain open -// and the trajectory file must reflect cumulative state. -#[tokio::test] -async fn turn_ended_snapshots_atif_without_closing_scope() { - let temp = tempfile::tempdir().unwrap(); - let config = GatewayConfig { - bind: "127.0.0.1:0".parse().unwrap(), - openai_base_url: "http://127.0.0.1".into(), - - anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig { - atif: AtifExporterSettings { - dir: Some(temp.path().to_path_buf()), - }, - ..Default::default() - }, - metadata: None, - plugin_config: None, - }; - let manager = SessionManager::new(config); - let headers = HeaderMap::new(); - - // Open a codex session. - manager - .apply_events( - &headers, - vec![NormalizedEvent::AgentStarted(SessionEvent { - session_id: "codex-multi-turn".into(), - agent_kind: AgentKind::Codex, - event_name: "SessionStart".into(), - payload: json!({}), - metadata: json!({}), - })], - ) - .await - .unwrap(); - assert_eq!(manager.open_session_count().await, 1); - - // First turn ends — ATIF should be written even though SessionEnd never arrived. - manager - .apply_events( - &headers, - vec![NormalizedEvent::TurnEnded(SessionEvent { - session_id: "codex-multi-turn".into(), - agent_kind: AgentKind::Codex, - event_name: "Stop".into(), - payload: json!({}), - metadata: json!({}), - })], - ) - .await - .unwrap(); - - let atif_path = temp.path().join("codex-multi-turn.atif.json"); - assert!( - atif_path.exists(), - "TurnEnded must produce an ATIF file during an open session" - ); - // Session is still open — TurnEnded must not have torn it down. - assert_eq!( - manager.open_session_count().await, - 1, - "TurnEnded must NOT close the agent scope or remove the session" - ); - - // Second turn ends — file should be overwritten with a cumulative trajectory. - manager - .apply_events( - &headers, - vec![NormalizedEvent::TurnEnded(SessionEvent { - session_id: "codex-multi-turn".into(), - agent_kind: AgentKind::Codex, - event_name: "Stop".into(), - payload: json!({}), - metadata: json!({}), - })], - ) - .await - .unwrap(); - assert!(atif_path.exists()); - assert_eq!(manager.open_session_count().await, 1); - - let trajectory: Value = serde_json::from_slice(&std::fs::read(&atif_path).unwrap()).unwrap(); - assert_eq!(trajectory["session_id"], json!("codex-multi-turn")); - assert_eq!(trajectory["agent"]["name"], json!("codex")); -} - -// TurnEnded for a session that was never opened (no AgentStarted, no gateway LLM) is a no-op — -// no observers were ever installed, so there's nothing to flush. #[tokio::test] async fn turn_ended_is_noop_for_session_with_no_agent_scope() { let temp = tempfile::tempdir().unwrap(); @@ -1951,12 +1324,6 @@ async fn turn_ended_is_noop_for_session_with_no_agent_scope() { openai_base_url: "http://127.0.0.1".into(), anthropic_base_url: "http://127.0.0.1".into(), - exporters: ExportersConfig { - atif: AtifExporterSettings { - dir: Some(temp.path().to_path_buf()), - }, - ..Default::default() - }, metadata: None, plugin_config: None, }; diff --git a/crates/cli/tests/coverage/setup_tests.rs b/crates/cli/tests/coverage/setup_tests.rs index 28b163058..d3b1aefc8 100644 --- a/crates/cli/tests/coverage/setup_tests.rs +++ b/crates/cli/tests/coverage/setup_tests.rs @@ -75,78 +75,20 @@ fn detect_installed_agents_finds_binaries_on_path() { } #[test] -fn build_config_emits_exporters_section_when_atif_selected() { +fn build_config_does_not_emit_observability_exporters() { let answers = SetupAnswers { scope: ConfigScope::Project, agents: vec![], - backends: vec![ObservabilityBackend::Atif], - openinference_endpoint: None, hermes_hooks_path: None, }; - let doc = build_config(&answers); - let rendered = doc.to_string(); - - assert!(rendered.contains("[exporters]")); - assert!(rendered.contains("[exporters.atif]")); - assert!(rendered.contains(r#"dir = "./atif""#)); - assert!(!rendered.contains("[export.")); - assert!(!rendered.contains("[observability]")); -} - -#[test] -fn build_config_emits_exporters_section_when_openinference_selected() { - let answers = SetupAnswers { - scope: ConfigScope::Project, - agents: vec![], - backends: vec![ObservabilityBackend::OpenInference], - openinference_endpoint: Some("http://localhost:6006/v1/traces".into()), - hermes_hooks_path: None, - }; - - let doc = build_config(&answers); - let rendered = doc.to_string(); + let rendered = build_config(&answers).to_string(); - assert!(rendered.contains("[exporters]")); - assert!(rendered.contains("[exporters.openinference]")); - assert!(rendered.contains(r#"endpoint = "http://localhost:6006/v1/traces""#)); + assert!(!rendered.contains("[exporters]")); assert!(!rendered.contains("[export.")); assert!(!rendered.contains("[observability]")); -} - -#[test] -fn build_config_ignores_openinference_endpoint_when_backend_not_selected() { - let answers = SetupAnswers { - scope: ConfigScope::Project, - agents: vec![], - backends: vec![ObservabilityBackend::Atif], - openinference_endpoint: Some("http://localhost:6006/v1/traces".into()), - hermes_hooks_path: None, - }; - - let rendered = build_config(&answers).to_string(); - - assert!(rendered.contains("[exporters.atif]")); + assert!(!rendered.contains("[exporters.atif]")); assert!(!rendered.contains("[exporters.openinference]")); - assert!(!rendered.contains("http://localhost:6006/v1/traces")); -} - -#[test] -fn build_config_emits_atof_write_options_when_atof_selected() { - let answers = SetupAnswers { - scope: ConfigScope::Project, - agents: vec![], - backends: vec![ObservabilityBackend::Atof], - openinference_endpoint: None, - hermes_hooks_path: None, - }; - - let rendered = build_config(&answers).to_string(); - - assert!(rendered.contains("[exporters.atof]")); - assert!(rendered.contains(r#"dir = "./atof""#)); - assert!(rendered.contains(r#"mode = "append""#)); - assert!(rendered.contains(r#"filename_template = "{session_id}.jsonl""#)); } #[test] @@ -154,8 +96,6 @@ fn build_config_skips_empty_sections_when_no_backends_selected() { let answers = SetupAnswers { scope: ConfigScope::Project, agents: vec![], - backends: vec![], - openinference_endpoint: None, hermes_hooks_path: None, }; @@ -173,8 +113,6 @@ fn build_config_emits_agents_block_with_user_facing_keys() { let answers = SetupAnswers { scope: ConfigScope::Project, agents: vec![CodingAgent::ClaudeCode, CodingAgent::Codex], - backends: vec![], - openinference_endpoint: None, hermes_hooks_path: None, }; @@ -193,8 +131,6 @@ fn save_config_writes_project_scope_to_workspace_dir() { let answers = SetupAnswers { scope: ConfigScope::Project, agents: vec![CodingAgent::ClaudeCode], - backends: vec![ObservabilityBackend::Atif], - openinference_endpoint: None, hermes_hooks_path: None, }; let doc = build_config(&answers); @@ -206,7 +142,7 @@ fn save_config_writes_project_scope_to_workspace_dir() { assert_eq!(written.len(), 1); assert_eq!(written[0], temp.path().join(".nemo-flow/config.toml")); let contents = std::fs::read_to_string(&written[0]).unwrap(); - assert!(contents.contains("[exporters]")); + assert!(!contents.contains("[exporters]")); assert!(contents.contains("[agents.claude]")); } @@ -237,8 +173,6 @@ command = "codex --full-auto" let answers = SetupAnswers { scope: ConfigScope::Project, agents: vec![CodingAgent::ClaudeCode], - backends: vec![ObservabilityBackend::Atif], - openinference_endpoint: None, hermes_hooks_path: None, }; let doc = build_config(&answers); @@ -252,8 +186,7 @@ command = "codex --full-auto" .unwrap(); let merged = std::fs::read_to_string(&existing_path).unwrap(); - // Wizard-owned sections are replaced with the new doc's content. - assert!(merged.contains("[exporters]")); + assert!(!merged.contains("[exporters]")); assert!(merged.contains("[agents.claude]")); assert!(merged.contains(r#"command = "claude""#)); // Other agents (not touched by this scoped run) survive. @@ -265,12 +198,10 @@ command = "codex --full-auto" merged.contains("codex --full-auto"), "expected scoped merge to preserve codex command, got:\n{merged}" ); - // `[upstream]` is wizard-owned: the new doc omits it (no custom openai_base_url), so the - // prior override must be cleared. If we preserved it, accepting the default in a re-run - // could not actually revert a custom upstream URL. + // Setup no longer owns upstream/provider settings. assert!( - !merged.contains("http://old-openai"), - "expected scoped merge to clear stale [upstream] override when new doc omits it, got:\n{merged}" + merged.contains("http://old-openai"), + "expected scoped merge to preserve [upstream], got:\n{merged}" ); // Old claude command should be gone. assert!( @@ -285,8 +216,6 @@ fn save_config_writes_both_scopes_when_both_selected() { let answers = SetupAnswers { scope: ConfigScope::Both, agents: vec![], - backends: vec![ObservabilityBackend::Atif], - openinference_endpoint: None, hermes_hooks_path: None, }; let doc = build_config(&answers); @@ -305,8 +234,6 @@ fn build_config_emits_hooks_path_for_hermes_when_set() { let answers = SetupAnswers { scope: ConfigScope::Project, agents: vec![CodingAgent::Hermes], - backends: vec![], - openinference_endpoint: None, hermes_hooks_path: Some(std::path::PathBuf::from("/tmp/proj/.hermes/config.yaml")), }; let rendered = build_config(&answers).to_string(); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index f2bf5472d..5818924f1 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -15,6 +15,7 @@ workspace = true [features] default = ["otel", "openinference"] +schema = ["dep:schemars"] otel = [ "dep:async-trait", "dep:getrandom", @@ -47,6 +48,7 @@ openinference = [ uuid = { workspace = true, features = ["v7", "serde"] } serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" +schemars = { version = "0.8", optional = true } chrono = { version = "0.4", features = ["serde"] } bitflags = { version = "2", features = ["serde"] } thiserror = "2" diff --git a/crates/core/src/config_editor.rs b/crates/core/src/config_editor.rs new file mode 100644 index 000000000..4094d5fc8 --- /dev/null +++ b/crates/core/src/config_editor.rs @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Typed configuration editor metadata. +//! +//! This module provides a small compile-time reflection surface for interactive +//! configuration editors. Config structs use [`editor_config!`] to expose +//! ordered field metadata without making editor UIs depend on JSON Schema. + +use serde_json::Value as Json; + +/// Editor control shape for one configuration field. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditorFieldKind { + /// Boolean toggle. + Boolean, + /// String-like value, including paths. + String, + /// Integer value. + Integer, + /// String enum with a fixed set of allowed values. + Enum, + /// Object with string keys and string values. + StringMap, + /// Arbitrary JSON value. + Json, + /// Nested configuration section. + Section, +} + +/// Static editor metadata for one configuration field. +#[derive(Clone, Copy)] +pub struct EditorFieldSpec { + /// Serialized field name. + pub name: &'static str, + /// Human-readable label. + pub label: &'static str, + /// Editor control shape. + pub kind: EditorFieldKind, + /// Allowed string enum values, when [`EditorFieldKind::Enum`] is used. + pub enum_values: &'static [&'static str], + /// Whether the field is represented as an `Option` in Rust. + pub optional: bool, + /// Nested editor schema for section fields. + pub nested_schema: Option &'static EditorSchema>, + /// Default value for a nested section. + pub nested_default: Option Json>, +} + +impl EditorFieldSpec { + /// Returns the nested schema for this field, if it is a section. + pub fn schema(self) -> Option<&'static EditorSchema> { + self.nested_schema.map(|schema| schema()) + } + + /// Returns the typed default value for this field's nested section. + pub fn default_value(self) -> Option { + self.nested_default.map(|default_value| default_value()) + } +} + +/// Static editor metadata for one configuration struct. +#[derive(Clone, Copy)] +pub struct EditorSchema { + /// Ordered editor fields. + pub fields: &'static [EditorFieldSpec], +} + +impl EditorSchema { + /// Finds a field by serialized name. + pub fn field(self, name: &str) -> Option { + self.fields.iter().copied().find(|field| field.name == name) + } +} + +/// Trait implemented by configuration structs that expose editor metadata. +pub trait EditorConfig { + /// Returns the static editor schema for this config type. + fn editor_schema() -> &'static EditorSchema; +} + +/// Implements [`EditorConfig`] for a configuration type. +/// +/// This macro intentionally keeps editor metadata next to the Rust config type +/// while avoiding proc-macro reflection. Field order is declaration order inside +/// the macro invocation. +#[macro_export] +macro_rules! editor_config { + ( + impl $ty:ty { + $( + $field:ident => { + label: $label:literal, + kind: $kind:ident + $(, values: [$($value:literal),* $(,)?])? + $(, optional: $optional:literal)? + $(, nested: $nested:ty)? + $(, default: $default:ty)? + $(,)? + } + ),* $(,)? + } + ) => { + const _: fn(&$ty) = |value: &$ty| { + $( + let _ = &value.$field; + )* + }; + + impl $crate::config_editor::EditorConfig for $ty { + fn editor_schema() -> &'static $crate::config_editor::EditorSchema { + static SCHEMA: $crate::config_editor::EditorSchema = $crate::config_editor::EditorSchema { + fields: &[ + $( + $crate::config_editor::EditorFieldSpec { + name: stringify!($field), + label: $label, + kind: $crate::editor_config!(@kind $kind), + enum_values: $crate::editor_config!(@values $($($value),*)?), + optional: $crate::editor_config!(@optional $($optional)?), + nested_schema: $crate::editor_config!(@nested $($nested)?), + nested_default: $crate::editor_config!(@default $($default)?), + } + ),* + ], + }; + &SCHEMA + } + } + }; + + (@kind Boolean) => { $crate::config_editor::EditorFieldKind::Boolean }; + (@kind String) => { $crate::config_editor::EditorFieldKind::String }; + (@kind Integer) => { $crate::config_editor::EditorFieldKind::Integer }; + (@kind Enum) => { $crate::config_editor::EditorFieldKind::Enum }; + (@kind StringMap) => { $crate::config_editor::EditorFieldKind::StringMap }; + (@kind Json) => { $crate::config_editor::EditorFieldKind::Json }; + (@kind Section) => { $crate::config_editor::EditorFieldKind::Section }; + + (@values) => { &[] }; + (@values $($value:literal),*) => { &[$($value),*] }; + + (@optional) => { false }; + (@optional $optional:literal) => { $optional }; + + (@nested) => { None }; + (@nested $nested:ty) => { + Some(<$nested as $crate::config_editor::EditorConfig>::editor_schema) + }; + + (@default) => { None }; + (@default $default:ty) => { + Some(|| { + serde_json::to_value(<$default as Default>::default()) + .expect("editor default value should serialize") + }) + }; +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index d580f8a80..fe0d672aa 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -57,6 +57,7 @@ //! easy addition and removal at runtime. pub mod api; pub mod codec; +pub mod config_editor; mod context; pub mod error; pub mod json; diff --git a/crates/core/src/observability/plugin_component.rs b/crates/core/src/observability/plugin_component.rs index 2aa3fbd2e..8b0e3c4af 100644 --- a/crates/core/src/observability/plugin_component.rs +++ b/crates/core/src/observability/plugin_component.rs @@ -99,6 +99,7 @@ impl From for PluginComponentSpec { /// behavior as a section with `enabled = false`: it contributes no runtime /// subscribers and performs no export work. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct ObservabilityConfig { /// Observability config schema version. #[serde(default = "default_observability_config_version")] @@ -140,6 +141,7 @@ impl Default for ObservabilityConfig { /// stream as JSONL. The exporter uses the current working directory and a /// timestamped filename when no explicit path settings are supplied. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct AtofSectionConfig { /// Whether ATOF JSONL export is active. #[serde(default)] @@ -152,6 +154,7 @@ pub struct AtofSectionConfig { pub filename: Option, /// File open mode: `append` or `overwrite`. #[serde(default = "default_atof_mode")] + #[cfg_attr(feature = "schema", schemars(schema_with = "atof_mode_schema"))] pub mode: String, } @@ -173,6 +176,7 @@ impl Default for AtofSectionConfig { /// placeholder in [`AtifSectionConfig::filename_template`] is required so /// concurrent sibling agents cannot overwrite each other's trajectory files. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct AtifSectionConfig { /// Whether ATIF export is active. #[serde(default)] @@ -221,12 +225,14 @@ impl Default for AtifSectionConfig { /// construct different subscriber implementations. Both sections are disabled /// by default and use `http_binary` transport unless configured otherwise. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct OtlpSectionConfig { /// Whether the subscriber is active. #[serde(default)] pub enabled: bool, /// OTLP transport: `http_binary` or `grpc`. #[serde(default = "default_otlp_transport")] + #[cfg_attr(feature = "schema", schemars(schema_with = "otlp_transport_schema"))] pub transport: String, /// OTLP endpoint. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -271,6 +277,82 @@ impl Default for OtlpSectionConfig { } } +crate::editor_config! { + impl ObservabilityConfig { + atof => { + label: "ATOF", + kind: Section, + optional: true, + nested: AtofSectionConfig, + default: AtofSectionConfig, + }, + atif => { + label: "ATIF", + kind: Section, + optional: true, + nested: AtifSectionConfig, + default: AtifSectionConfig, + }, + opentelemetry => { + label: "OpenTelemetry", + kind: Section, + optional: true, + nested: OtlpSectionConfig, + default: OtlpSectionConfig, + }, + openinference => { + label: "OpenInference", + kind: Section, + optional: true, + nested: OtlpSectionConfig, + default: OtlpSectionConfig, + }, + policy => { + label: "policy", + kind: Section, + nested: ConfigPolicy, + default: ConfigPolicy, + }, + } +} + +crate::editor_config! { + impl AtofSectionConfig { + enabled => { label: "enabled", kind: Boolean }, + output_directory => { label: "output_directory", kind: String, optional: true }, + filename => { label: "filename", kind: String, optional: true }, + mode => { label: "mode", kind: Enum, values: ["append", "overwrite"] }, + } +} + +crate::editor_config! { + impl AtifSectionConfig { + enabled => { label: "enabled", kind: Boolean }, + agent_name => { label: "agent_name", kind: String }, + agent_version => { label: "agent_version", kind: String }, + model_name => { label: "model_name", kind: String }, + tool_definitions => { label: "tool_definitions", kind: Json, optional: true }, + extra => { label: "extra", kind: Json, optional: true }, + output_directory => { label: "output_directory", kind: String, optional: true }, + filename_template => { label: "filename_template", kind: String }, + } +} + +crate::editor_config! { + impl OtlpSectionConfig { + enabled => { label: "enabled", kind: Boolean }, + transport => { label: "transport", kind: Enum, values: ["http_binary", "grpc"] }, + endpoint => { label: "endpoint", kind: String, optional: true }, + headers => { label: "headers", kind: StringMap }, + resource_attributes => { label: "resource_attributes", kind: StringMap }, + service_name => { label: "service_name", kind: String }, + service_namespace => { label: "service_namespace", kind: String, optional: true }, + service_version => { label: "service_version", kind: String, optional: true }, + instrumentation_scope => { label: "instrumentation_scope", kind: String, optional: true }, + timeout_millis => { label: "timeout_millis", kind: Integer }, + } +} + struct ObservabilityPlugin; impl Plugin for ObservabilityPlugin { @@ -323,6 +405,45 @@ pub fn deregister_observability_component() -> bool { deregister_plugin(OBSERVABILITY_PLUGIN_KIND) } +/// Returns the JSON Schema for the observability component configuration. +#[cfg(feature = "schema")] +pub fn observability_config_schema() -> serde_json::Value { + serde_json::to_value(schemars::schema_for!(ObservabilityConfig)) + .expect("observability config schema should serialize") +} + +#[cfg(feature = "schema")] +fn atof_mode_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + string_enum_schema(generator, &["append", "overwrite"], Some("append")) +} + +#[cfg(feature = "schema")] +fn otlp_transport_schema( + generator: &mut schemars::r#gen::SchemaGenerator, +) -> schemars::schema::Schema { + string_enum_schema(generator, &["http_binary", "grpc"], Some("http_binary")) +} + +#[cfg(feature = "schema")] +fn string_enum_schema( + generator: &mut schemars::r#gen::SchemaGenerator, + values: &[&str], + default: Option<&str>, +) -> schemars::schema::Schema { + let mut schema: schemars::schema::SchemaObject = + ::json_schema(generator).into(); + schema.enum_values = Some( + values + .iter() + .map(|value| Json::String((*value).into())) + .collect(), + ); + if let Some(default) = default { + schema.metadata().default = Some(Json::String(default.into())); + } + schema.into() +} + fn register_observability( config: ObservabilityConfig, ctx: &mut PluginRegistrationContext, diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 3f8187f1a..b1c4d9392 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -75,6 +75,7 @@ pub type Result = std::result::Result; /// Canonical plugin configuration document. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct PluginConfig { /// Plugin config schema version. #[serde(default = "default_plugin_config_version")] @@ -99,6 +100,7 @@ impl Default for PluginConfig { /// One configured plugin component. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct PluginComponentSpec { /// Registered plugin kind string. pub kind: String, @@ -126,6 +128,7 @@ impl PluginComponentSpec { /// Structured validation report. #[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct ConfigReport { /// Validation and compatibility diagnostics in evaluation order. #[serde(default)] @@ -143,6 +146,7 @@ impl ConfigReport { /// One validation or compatibility diagnostic. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct ConfigDiagnostic { /// Severity level for the diagnostic. pub level: DiagnosticLevel, @@ -160,6 +164,7 @@ pub struct ConfigDiagnostic { /// Diagnostic severity. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "lowercase")] pub enum DiagnosticLevel { /// Non-fatal compatibility or validation issue. @@ -170,6 +175,7 @@ pub enum DiagnosticLevel { /// Policy for how unsupported plugin/runtime config is handled. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct ConfigPolicy { /// Policy applied when a component kind is unknown to the plugin registry. #[serde(default = "default_warn")] @@ -192,8 +198,29 @@ impl Default for ConfigPolicy { } } +crate::editor_config! { + impl ConfigPolicy { + unknown_component => { + label: "unknown_component", + kind: Enum, + values: ["warn", "ignore", "error"], + }, + unknown_field => { + label: "unknown_field", + kind: Enum, + values: ["warn", "ignore", "error"], + }, + unsupported_value => { + label: "unsupported_value", + kind: Enum, + values: ["warn", "ignore", "error"], + }, + } +} + /// Per-policy behavior for unsupported configuration. #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "lowercase")] pub enum UnsupportedBehavior { /// Suppress the diagnostic entirely. @@ -870,6 +897,13 @@ pub fn validate_plugin_config(config: &PluginConfig) -> ConfigReport { report } +/// Returns the JSON Schema for the canonical plugin configuration document. +#[cfg(feature = "schema")] +pub fn plugin_config_schema() -> Json { + serde_json::to_value(schemars::schema_for!(PluginConfig)) + .expect("plugin config schema should serialize") +} + /// Configures the active global plugin components. /// /// Initialization validates the supplied config, replaces the active diff --git a/crates/core/tests/unit/observability/plugin_component_tests.rs b/crates/core/tests/unit/observability/plugin_component_tests.rs index 97b83c34d..7a904e0c9 100644 --- a/crates/core/tests/unit/observability/plugin_component_tests.rs +++ b/crates/core/tests/unit/observability/plugin_component_tests.rs @@ -8,6 +8,9 @@ use crate::api::event::{BaseEvent, EventCategory, ScopeEvent}; use crate::api::runtime::NemoFlowContextState; use crate::api::runtime::global_context; use crate::api::scope::{PopScopeParams, PushScopeParams}; +use crate::config_editor::{EditorConfig, EditorFieldKind}; +#[cfg(feature = "schema")] +use crate::plugin::plugin_config_schema; use crate::plugin::{ PluginComponentSpec, PluginConfig, clear_plugin_configuration, initialize_plugins, list_plugin_kinds, lookup_plugin, validate_plugin_config, @@ -52,6 +55,28 @@ fn plugin_config(config: Json) -> PluginConfig { } } +#[test] +fn editor_schema_tracks_observability_config_types() { + let schema = ObservabilityConfig::editor_schema(); + let atof = schema.field("atof").expect("atof section"); + assert_eq!(atof.label, "ATOF"); + assert_eq!(atof.kind, EditorFieldKind::Section); + assert!(atof.optional); + + let atof_schema = atof.schema().expect("atof editor schema"); + let mode = atof_schema.field("mode").expect("atof mode field"); + assert_eq!(mode.kind, EditorFieldKind::Enum); + assert_eq!(mode.enum_values, &["append", "overwrite"]); + + let otlp = schema + .field("openinference") + .expect("openinference section") + .schema() + .expect("openinference editor schema"); + let headers = otlp.field("headers").expect("headers field"); + assert_eq!(headers.kind, EditorFieldKind::StringMap); +} + fn push_agent(name: &str) -> crate::api::scope::ScopeHandle { crate::api::scope::push_scope( PushScopeParams::builder() @@ -84,6 +109,50 @@ fn pop(handle: &crate::api::scope::ScopeHandle) { .unwrap(); } +#[cfg(feature = "schema")] +fn schema_has_property(schema: &Json, name: &str) -> bool { + schema_property(schema, name).is_some() +} + +#[cfg(feature = "schema")] +fn schema_property_has_enum(schema: &Json, name: &str, expected: &[&str]) -> bool { + schema_property(schema, name) + .and_then(|property| property.get("enum")) + .and_then(Json::as_array) + .is_some_and(|values| { + expected + .iter() + .all(|expected| values.iter().any(|value| value == *expected)) + }) +} + +#[cfg(feature = "schema")] +fn schema_property_has_default(schema: &Json, name: &str, expected: Json) -> bool { + schema_property(schema, name) + .and_then(|property| property.get("default")) + .is_some_and(|default| default == &expected) +} + +#[cfg(feature = "schema")] +fn schema_property<'a>(schema: &'a Json, name: &str) -> Option<&'a Json> { + match schema { + Json::Object(object) => { + if let Some(property) = object + .get("properties") + .and_then(Json::as_object) + .and_then(|properties| properties.get(name)) + { + return Some(property); + } + object + .values() + .find_map(|value| schema_property(value, name)) + } + Json::Array(values) => values.iter().find_map(|value| schema_property(value, name)), + _ => None, + } +} + #[test] fn default_config_and_component_conversion_cover_public_shape() { let _guard = crate::observability::test_mutex().lock().unwrap(); @@ -129,6 +198,87 @@ fn default_config_and_component_conversion_cover_public_shape() { assert_eq!(generic.config["atif"]["agent_name"], json!("NeMo Flow")); } +#[cfg(feature = "schema")] +#[test] +fn schema_contains_every_supported_observability_option() { + let schema = observability_config_schema(); + for field in [ + "version", + "atof", + "atif", + "opentelemetry", + "openinference", + "policy", + "enabled", + "output_directory", + "filename", + "mode", + "agent_name", + "agent_version", + "model_name", + "tool_definitions", + "extra", + "filename_template", + "transport", + "endpoint", + "headers", + "resource_attributes", + "service_name", + "service_namespace", + "service_version", + "instrumentation_scope", + "timeout_millis", + "unknown_component", + "unknown_field", + "unsupported_value", + ] { + assert!( + schema_has_property(&schema, field), + "schema missing property `{field}`:\n{}", + serde_json::to_string_pretty(&schema).unwrap() + ); + } + assert!(schema_property_has_enum( + &schema, + "mode", + &["append", "overwrite"] + )); + assert!(schema_property_has_enum( + &schema, + "transport", + &["http_binary", "grpc"] + )); + assert!(schema_property_has_default( + &schema, + "mode", + json!("append") + )); + assert!(schema_property_has_default( + &schema, + "transport", + json!("http_binary") + )); +} + +#[cfg(feature = "schema")] +#[test] +fn plugin_schema_contains_generic_plugin_surface() { + let schema = plugin_config_schema(); + for field in [ + "version", + "components", + "policy", + "kind", + "enabled", + "config", + ] { + assert!( + schema_has_property(&schema, field), + "plugin schema missing property `{field}`" + ); + } +} + #[test] fn built_in_registration_is_automatic() { let _guard = crate::observability::test_mutex().lock().unwrap(); diff --git a/docs/about/concepts/plugins.md b/docs/about/concepts/plugins.md index a0c002a7f..e383ab20a 100644 --- a/docs/about/concepts/plugins.md +++ b/docs/about/concepts/plugins.md @@ -155,6 +155,9 @@ inferred from the plugin namespace instead of exposed in public config. Detailed observability plugin configuration belongs in [Configure the Observability Plugin](../../export-observability-data/observability-plugin.md). +For the CLI gateway's `plugins.toml` discovery, precedence, merge, and editing +rules, see [Plugin Configuration Files](../../build-plugins/plugin-configuration-files.md). + ## Practical Guidance Use these practices when applying the concept in application or integration code. diff --git a/docs/build-plugins/about.md b/docs/build-plugins/about.md index c1fcaba92..53d338bff 100644 --- a/docs/build-plugins/about.md +++ b/docs/build-plugins/about.md @@ -33,6 +33,7 @@ Use these guide links to move from the overview into task-specific instructions. - [Basic Guide: Define a Plugin](basic-guide.md) explains plugin kinds, shape, runtime ownership, and the activation lifecycle. - [Basic Guide: Validate Plugin Configuration](validate-configuration.md) covers JSON-compatible config, validation rules, and structured diagnostics. +- [Basic Guide: Plugin Configuration Files](plugin-configuration-files.md) documents `plugins.toml` file discovery, precedence, merge behavior, and editor controls for the CLI gateway. - [Basic Guide: Register Plugin Behavior](register-behavior.md) shows how to initialize config and install subscribers or middleware through `PluginContext`. - [Advanced Guide: Design Plugin Configuration](advanced-configuration.md) covers validation rules, advanced configuration patterns, rollout controls, and `PluginContext` usage. - [NeMo Guardrails Example Plugin](nemoguardrails.md) shows an external Python plugin that applies NeMo Guardrails checks around NeMo Flow LLM and tool calls. diff --git a/docs/build-plugins/plugin-configuration-files.md b/docs/build-plugins/plugin-configuration-files.md new file mode 100644 index 000000000..0e186a7fb --- /dev/null +++ b/docs/build-plugins/plugin-configuration-files.md @@ -0,0 +1,258 @@ + + +# Basic Guide: Plugin Configuration Files + +Use `plugins.toml` when the `nemo-flow` CLI gateway should activate plugins at +startup. The file contains the same generic plugin configuration document used +by the Rust, Python, and Node.js plugin APIs, but encoded as TOML at the file +root. + +This page documents file discovery, precedence, merge behavior, editor behavior, +and conflict rules for the CLI gateway. Component-specific fields are documented +in the guide for each plugin component. + +## File Shape + +`plugins.toml` uses the canonical plugin document shape: + +```toml +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 1 + +[components.config.atof] +enabled = true +output_directory = "logs" +filename = "events.jsonl" +mode = "append" + +[policy] +unknown_component = "warn" +unknown_field = "warn" +unsupported_value = "error" +``` + +The top-level fields are: + +| Field | Default | Notes | +|---|---|---| +| `version` | `1` | Plugin configuration format version. Non-`1` versions fail validation by default. | +| `components` | `[]` | Ordered plugin components to validate and activate. | +| `policy` | warn unknown components and fields, error on unsupported values | Global validation policy. | + +Each component has: + +| Field | Default | Notes | +|---|---|---| +| `kind` | Required | Registered plugin kind, such as `observability` or `adaptive`. | +| `enabled` | `true` | Disabled components are validated but not initialized. | +| `config` | `{}` | Component-local configuration object. The shape depends on `kind`. | + +The gateway reads only files named `plugins.toml`. + +## Discovery + +The gateway can receive plugin configuration from three source classes: + +| Source | Use case | +|---|---| +| `plugins.toml` | Normal operator- and project-managed gateway plugin configuration. | +| `[plugins].config` in `config.toml` | Inline gateway config for small or generated setups. | +| `--plugin-config ''` | CI, tests, wrappers, or one-off automation. | + +Use only one source class for a given gateway run. The gateway fails clearly if +file-based plugin config and `--plugin-config` are both present, or if +`plugins.toml` and `[plugins].config` are both present. + +When `--config path/to/config.toml` is supplied, plugin file discovery is scoped +to `path/to/plugins.toml`. Implicit system, project, and user plugin files are +not loaded for that run. + +When no explicit `--config` path is supplied, the gateway checks these +`plugins.toml` locations from lowest to highest precedence: + +1. System: `/etc/nemo-flow/plugins.toml` +2. Project: the nearest `.nemo-flow/plugins.toml` found by walking upward from + the current directory +3. User: `$XDG_CONFIG_HOME/nemo-flow/plugins.toml`, or + `~/.config/nemo-flow/plugins.toml` when `XDG_CONFIG_HOME` is not set + +Missing files are skipped. If no plugin config source exists, the gateway starts +without process-level plugin activation. + +## Editing Files + +Use the interactive editor for Observability plugin configuration: + +```bash +nemo-flow plugins edit +``` + +By default, the editor writes the user plugin file: + +```text +$XDG_CONFIG_HOME/nemo-flow/plugins.toml +``` + +or: + +```text +~/.config/nemo-flow/plugins.toml +``` + +Use a scope flag to edit another location: + +```bash +nemo-flow plugins edit --project +nemo-flow plugins edit --global +``` + +Scope flags are mutually exclusive. + +`--project` writes the nearest existing `.nemo-flow/plugins.toml`. If none +exists, it writes next to the nearest `.nemo-flow/config.toml`. If neither file +exists in the parent directories, it writes `./.nemo-flow/plugins.toml` from the +current directory. + +`--global` writes `/etc/nemo-flow/plugins.toml` and usually requires elevated +filesystem permissions. + +The editor menus support these controls: + +| Key | Behavior | +|---|---| +| Arrow keys, `j`, `k` | Move through menu items. | +| `Enter`, `Space` | Select or toggle the highlighted item. | +| `Backspace`, `Delete` | Clear the highlighted optional field. | +| `r` | Reset the highlighted field or section to its default. | +| `p` | Preview TOML from the main menu. | +| `s` | Save from the main menu. | +| `?` | Show help. | +| `q`, `Esc` | Go back or cancel without saving. | + +Text and JSON value prompts use normal line editing. Use the surrounding field +menu to reset, clear, preview, or save. + +## Precedence And Merge Behavior + +When more than one `plugins.toml` file is discovered, later files have higher +precedence. User config overrides project config, and project config overrides +system config. + +TOML tables merge recursively: + +```toml +# system plugins.toml +[[components]] +kind = "observability" + +[components.config.atof] +enabled = true +output_directory = "/var/log/nemo-flow" +mode = "append" +``` + +```toml +# user plugins.toml +[[components]] +kind = "observability" + +[components.config.atof] +mode = "overwrite" +``` + +The effective ATOF config keeps `enabled` and `output_directory` from the system +file and uses `mode = "overwrite"` from the user file. + +The top-level `components` array is special. Components are matched by `kind` +across files. A higher-precedence component with the same `kind` merges into the +lower-precedence component. A component with a different `kind` is added to the +effective configuration. + +Declare each `kind` at most once inside one `plugins.toml` file. Duplicate +component kinds in the same file fail before merge. Duplicate singleton +components that reach plugin validation also fail validation. + +Arrays inside component config are replaced by the higher-precedence value. +Tables inside component config merge recursively. + +## Explicit Defaults And Overrides + +The editor writes explicit defaults for edited Observability sections. This is +intentional. In a layered config model, omitting a field means "inherit a lower +precedence value"; it does not mean "delete that value." + +For example, this user file disables ATOF even if a project file enables it: + +```toml +[[components]] +kind = "observability" + +[components.config.atof] +enabled = false +mode = "append" +``` + +The merged config may still contain inherited ATOF sibling fields such as +`output_directory`, but the runtime ignores the section because `enabled = +false`. + +To override an inherited non-default field with its default value, write the +default explicitly in the higher-precedence file. For example, use +`mode = "append"` to override a lower-precedence `mode = "overwrite"`. + +There is no tombstone syntax for deleting an inherited nested field while +keeping the rest of the lower-precedence component. To remove inherited settings +entirely, edit the lower-precedence file or override the behavior with another +field such as `enabled = false`. + +## Validation + +Plugin validation runs before activation. Invalid plugin config blocks gateway +startup instead of starting with a partially installed plugin set. + +Common validation failures include: + +- Unknown component kinds when policy treats them as errors. +- Unknown fields when policy treats them as errors. +- Unsupported field values, such as an invalid exporter mode or transport. +- Duplicate singleton components. +- Enabled components whose build-time features are unavailable. +- Component-specific semantic failures, such as an ATIF filename template that + does not contain `{session_id}`. + +Use `nemo-flow doctor` to inspect the resolved gateway configuration and plugin +diagnostics. For Observability, doctor also reports enabled exporter sections and +checks writable file exporter directories or reachable OTLP endpoints when those +settings are present. + +## Relationship To `config.toml` + +`config.toml` owns gateway and agent setup, such as upstream provider base URLs +and agent command configuration. `plugins.toml` owns reusable runtime behavior +installed by the plugin system. + +Keep long-lived plugin setup in `plugins.toml`. Use `[plugins].config` in +`config.toml` only when a generated or embedded config must keep all gateway +settings in one file. Use `--plugin-config` for automation that should not write +files. + +Legacy observability config sections in `config.toml`, such as `[exporters]`, +`[observability]`, and `[export.openinference]`, are not supported. Configure +Observability exporters through `plugins.toml`. + +## Component Guides + +Use the component guides for field-level configuration: + +- [Configure the Observability Plugin](../export-observability-data/observability-plugin.md) +- [Configure Adaptive Optimization](../use-adaptive-optimization/configure.md) +- [Advanced Guide: Configure Adaptive Components](../use-adaptive-optimization/adaptive-components.md) diff --git a/docs/export-observability-data/observability-plugin.md b/docs/export-observability-data/observability-plugin.md index fcaccca7d..efddc23cf 100644 --- a/docs/export-observability-data/observability-plugin.md +++ b/docs/export-observability-data/observability-plugin.md @@ -59,40 +59,27 @@ names from the plugin namespace: The active runtime names include the component namespace prefix used by the plugin system. -## CLI Gateway `plugin.toml` +## CLI Gateway `plugins.toml` The `nemo-flow` CLI gateway can activate one process-level plugin config at -startup. Define it with one of these sources: +startup from `plugins.toml`. Use the interactive editor for the Observability +component: -- `--plugin-config` JSON on the command line. -- `[plugins].config` in `config.toml`. -- `plugin.toml` next to the resolved `config.toml`, or in the same discovered - system, project, and user scopes as `config.toml`. - -When multiple discovered `plugin.toml` files are present, the gateway loads -them from lowest to highest precedence: - -1. System: `/etc/nemo-flow/plugin.toml` -2. Project: `.nemo-flow/plugin.toml` -3. User: `$XDG_CONFIG_HOME/nemo-flow/plugin.toml`, or - `~/.config/nemo-flow/plugin.toml` - -Later files override earlier files. TOML tables merge recursively, so a -higher-precedence file can override one nested key while preserving sibling -keys from lower-precedence files. - -The top-level `[[components]]` array is merged by component `kind`. A -higher-precedence component with the same `kind` is merged into the lower -precedence component, and higher-precedence values win on conflicts. Components -with different `kind` values compose, so a project `observability` component and -a user `adaptive` component are both active in the effective config. +```bash +nemo-flow plugins edit +nemo-flow plugins edit --project +``` -Use only one source for plugin config. The gateway reports an error when -`plugin.toml`, `[plugins].config`, or `--plugin-config` are used together. +See [Plugin Configuration Files](../build-plugins/plugin-configuration-files.md) +for discovery locations, precedence, merge behavior, editor controls, conflicts +with `[plugins].config` or `--plugin-config`, and validation behavior. -`plugin.toml` uses the generic plugin config shape at the file root. The +`plugins.toml` uses the generic plugin config shape at the file root. The example below shows every observability section; include only the sections you -want to configure. Missing sections behave like disabled sections. +want to configure. Missing sections behave like disabled sections when no +lower-precedence `plugins.toml` supplies that section. In a layered +`plugins.toml` setup, omission inherits lower-precedence values; write +`enabled = false` to disable an inherited section. `version = 1` is recommended for clarity but not required. The root plugin config version and observability component config version both default to `1` diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 7f62ce0c2..c7e6c306f 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -28,6 +28,11 @@ Plugins use a structured plugin configuration with: Start with [Basic Guide: Define a Plugin](../build-plugins/basic-guide.md) when you need reusable middleware, subscribers, or adaptive behavior. +The `nemo-flow` CLI gateway reads plugin files named `plugins.toml`. See +[Plugin Configuration Files](../build-plugins/plugin-configuration-files.md) +for file locations, precedence, merge behavior, editor controls, and validation +rules. + ## Observability Setup ATOF exporters, ATIF exporters, OpenTelemetry subscribers, and OpenInference diff --git a/docs/index.md b/docs/index.md index 8f182797a..2c2522f31 100644 --- a/docs/index.md +++ b/docs/index.md @@ -182,6 +182,7 @@ Code Examples About Basic Guide: Define a Plugin Basic Guide: Validate Plugin Configuration +Basic Guide: Plugin Configuration Files Basic Guide: Register Plugin Behavior Advanced Guide: Design Plugin Configuration NeMo Guardrails Example Plugin diff --git a/docs/integrate-frameworks/coding-agent-claude-code.md b/docs/integrate-frameworks/coding-agent-claude-code.md index 13bdfa69a..dd88fac26 100644 --- a/docs/integrate-frameworks/coding-agent-claude-code.md +++ b/docs/integrate-frameworks/coding-agent-claude-code.md @@ -15,7 +15,7 @@ gateway controls as Claude Code. Use the wrapper for no-install local observability: ```bash -nemo-flow run --atif-dir .nemo-flow/atif -- claude +nemo-flow run -- claude ``` The wrapper infers Claude Code from `claude`, starts a gateway on a dynamic @@ -27,8 +27,6 @@ Inspect what would be launched without starting Claude Code: ```bash nemo-flow run \ - --atif-dir .nemo-flow/atif \ - --openinference-endpoint http://127.0.0.1:4318/v1/traces \ --dry-run \ --print \ -- claude @@ -46,19 +44,31 @@ Create `.nemo-flow/config.toml` for project defaults or `~/.config/nemo-flow/config.toml` for user defaults: ```toml -[observability] -atif_dir = ".nemo-flow/atif" -metadata = { team = "agent-observability" } - -[export.openinference] -endpoint = "http://127.0.0.1:4318/v1/traces" - [agents.claude] command = "claude" ``` -Then run `nemo-flow run --agent claude` to use the configured -command. User config takes priority over project and global config. +Then configure observability with `nemo-flow plugins edit --project` or +`.nemo-flow/plugins.toml`: + +```toml +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config.atif] +enabled = true +output_directory = ".nemo-flow/atif" + +[components.config.openinference] +enabled = true +endpoint = "http://127.0.0.1:4318/v1/traces" +``` + +Run `nemo-flow run --agent claude` to use the configured command and plugin +config. User config takes priority over project and global config. ## Standalone Gateway @@ -66,7 +76,7 @@ Use the long-running gateway only when you want Claude Code running outside the wrapper (e.g., already configured by an IDE): ```bash -NEMO_FLOW_ATIF_DIR=.nemo-flow/atif nemo-flow --bind 127.0.0.1:4040 +nemo-flow --bind 127.0.0.1:4040 ``` Launch Claude Code from another terminal with the gateway environment: @@ -119,8 +129,8 @@ ls .nemo-flow/atif ``` The gateway exports `.atif.json` on session end. If no file appears, -confirm that `SessionEnd` hooks fire, `--atif-dir` or `NEMO_FLOW_ATIF_DIR` is -set, and the gateway process can write to the directory. +confirm that `SessionEnd` hooks fire, `plugins.toml` enables the ATIF exporter, +and the gateway process can write to the configured directory. ## Troubleshoot LLM Lifecycle diff --git a/docs/integrate-frameworks/coding-agent-codex.md b/docs/integrate-frameworks/coding-agent-codex.md index 5a86eff09..201d1f5ee 100644 --- a/docs/integrate-frameworks/coding-agent-codex.md +++ b/docs/integrate-frameworks/coding-agent-codex.md @@ -22,7 +22,7 @@ feature flag. Use the wrapper for no-install local observability: ```bash -nemo-flow run --atif-dir .nemo-flow/atif -- codex +nemo-flow run -- codex ``` The wrapper infers Codex from `codex`, starts a gateway on a dynamic @@ -35,8 +35,6 @@ Inspect what would be launched without starting Codex: ```bash nemo-flow run \ - --atif-dir .nemo-flow/atif \ - --openinference-endpoint http://127.0.0.1:4318/v1/traces \ --dry-run \ --print \ -- codex @@ -55,18 +53,29 @@ Create `.nemo-flow/config.toml` for project defaults or ```toml [upstream] -openai_base_url = "https://api.openai.com" - -[observability] -atif_dir = ".nemo-flow/atif" -metadata = { team = "agent-observability" } +openai_base_url = "https://api.openai.com/v1" [agents.codex] command = "codex" ``` -Then run `nemo-flow run --agent codex` to use the configured command. -User config takes priority over project and global config. +Then configure observability with `nemo-flow plugins edit --project` or +`.nemo-flow/plugins.toml`: + +```toml +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config.atif] +enabled = true +output_directory = ".nemo-flow/atif" +``` + +Run `nemo-flow run --agent codex` to use the configured command and plugin +config. User config takes priority over project and global config. ## Standalone Gateway @@ -74,7 +83,7 @@ Use the long-running gateway only when you want Codex running outside the wrapper: ```bash -NEMO_FLOW_ATIF_DIR=.nemo-flow/atif nemo-flow --bind 127.0.0.1:4040 +nemo-flow --bind 127.0.0.1:4040 ``` Then configure local Codex to use a gateway provider alias instead of @@ -139,7 +148,8 @@ the gateway uses each per-turn `Stop` hook to snapshot the trajectory; the file grows cumulatively across turns and the final write reflects the full session). For agents that do emit a session-end hook, the same file is written once on session close. If the file is missing, confirm `hooks = true`, hook config -loading, and `--atif-dir` or `NEMO_FLOW_ATIF_DIR`. +loading, and that `plugins.toml` enables the ATIF exporter with a writable +`output_directory`. ## Troubleshoot LLM Lifecycle diff --git a/docs/integrate-frameworks/coding-agent-cursor.md b/docs/integrate-frameworks/coding-agent-cursor.md index a258cd453..8964f9b83 100644 --- a/docs/integrate-frameworks/coding-agent-cursor.md +++ b/docs/integrate-frameworks/coding-agent-cursor.md @@ -24,7 +24,7 @@ model routing is configurable. Use the wrapper for no-install local observability: ```bash -nemo-flow run --atif-dir .nemo-flow/atif -- cursor-agent +nemo-flow run -- cursor-agent ``` The wrapper infers Cursor from `cursor` or `cursor-agent`, starts a gateway on a @@ -36,7 +36,6 @@ Inspect what would be launched without starting Cursor: ```bash nemo-flow run \ - --atif-dir .nemo-flow/atif \ --dry-run \ --print \ -- cursor-agent @@ -54,17 +53,28 @@ Create `.nemo-flow/config.toml` for project defaults or `~/.config/nemo-flow/config.toml` for user defaults: ```toml -[observability] -atif_dir = ".nemo-flow/atif" -metadata = { team = "agent-observability" } - [agents.cursor] command = "cursor-agent" patch_restore_hooks = true ``` -Then run `nemo-flow run --agent cursor` to use the configured command. -User config takes priority over project and global config. +Then configure observability with `nemo-flow plugins edit --project` or +`.nemo-flow/plugins.toml`: + +```toml +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config.atif] +enabled = true +output_directory = ".nemo-flow/atif" +``` + +Run `nemo-flow run --agent cursor` to use the configured command and plugin +config. User config takes priority over project and global config. ## Standalone Gateway @@ -72,7 +82,7 @@ Use the long-running gateway only when you want Cursor running outside the wrapper (e.g., the Cursor GUI). Start the gateway manually: ```bash -NEMO_FLOW_ATIF_DIR=.nemo-flow/atif nemo-flow --bind 127.0.0.1:4040 +nemo-flow --bind 127.0.0.1:4040 ``` Then point Cursor provider traffic at `http://127.0.0.1:4040` wherever Cursor @@ -122,7 +132,8 @@ ls .nemo-flow/atif The gateway writes `.atif.json` on session end. If the file is missing, confirm Cursor loaded `.cursor/hooks.json`, the gateway binary is on -`PATH`, and `--atif-dir` or `NEMO_FLOW_ATIF_DIR` is configured. +`PATH`, and `plugins.toml` enables the ATIF exporter with a writable +`output_directory`. ## Troubleshoot LLM Lifecycle diff --git a/docs/integrate-frameworks/coding-agent-gateway.md b/docs/integrate-frameworks/coding-agent-gateway.md index f693b9756..0d94af953 100644 --- a/docs/integrate-frameworks/coding-agent-gateway.md +++ b/docs/integrate-frameworks/coding-agent-gateway.md @@ -95,19 +95,9 @@ Example: ```toml [upstream] -openai_base_url = "https://api.openai.com" +openai_base_url = "https://api.openai.com/v1" anthropic_base_url = "https://api.anthropic.com" -[observability] -atif_dir = ".nemo-flow/atif" -metadata = { team = "agent-observability" } - -[plugins] -config = { components = [] } - -[export.openinference] -endpoint = "http://127.0.0.1:4318/v1/traces" - [agents.claude] command = "claude" @@ -122,6 +112,26 @@ patch_restore_hooks = true command = "hermes" ``` +Observability exporters are configured in `plugins.toml`. Use +`nemo-flow plugins edit` for the user file, `nemo-flow plugins edit --project` +for `.nemo-flow/plugins.toml`, or write the plugin config directly: + +```toml +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config.atif] +enabled = true +output_directory = ".nemo-flow/atif" + +[components.config.openinference] +enabled = true +endpoint = "http://127.0.0.1:4318/v1/traces" +``` + Transparent runs always bind the managed gateway to `127.0.0.1:0`. The selected port is discovered by the wrapper and exposed to hooks through `NEMO_FLOW_GATEWAY_URL`. @@ -131,17 +141,13 @@ Common environment variables for direct gateway server use are: - `NEMO_FLOW_GATEWAY_BIND` - `NEMO_FLOW_OPENAI_BASE_URL` - `NEMO_FLOW_ANTHROPIC_BASE_URL` -- `NEMO_FLOW_OPENINFERENCE_ENDPOINT` -- `NEMO_FLOW_ATIF_DIR` -Per-session configuration controls the scope-local OpenInference subscriber, -the ATIF exporter, structured metadata on the top-level agent begin event, and -the plugin configuration metadata associated with the session. +Plugin configuration controls process-level Observability exporters. Per-session +configuration controls structured metadata on the top-level agent begin event +and the plugin configuration metadata associated with the session. `hook-forward` can also pass per-session configuration through headers: -- `x-nemo-flow-atif-dir` -- `x-nemo-flow-openinference-endpoint` - `x-nemo-flow-config-profile` - `x-nemo-flow-session-metadata` - `x-nemo-flow-plugin-config` @@ -228,8 +234,6 @@ default so observability outages do not block the coding agent. Add Optional flags map to gateway headers: -- `--atif-dir` sets `x-nemo-flow-atif-dir`. -- `--openinference-endpoint` sets `x-nemo-flow-openinference-endpoint`. - `--session-metadata` sets `x-nemo-flow-session-metadata`. - `--plugin-config` sets `x-nemo-flow-plugin-config`. - `--profile` sets `x-nemo-flow-config-profile`. diff --git a/docs/integrate-frameworks/coding-agent-hermes.md b/docs/integrate-frameworks/coding-agent-hermes.md index 09da2a529..3259dafc4 100644 --- a/docs/integrate-frameworks/coding-agent-hermes.md +++ b/docs/integrate-frameworks/coding-agent-hermes.md @@ -21,7 +21,7 @@ Use the wrapper when you want the gateway lifetime managed for a local Hermes process: ```bash -nemo-flow run --atif-dir .nemo-flow/atif -- hermes +nemo-flow run -- hermes ``` The wrapper infers Hermes from `hermes` or `hermes-agent`, starts a gateway on a @@ -34,8 +34,6 @@ Inspect what would be launched without starting Hermes: ```bash nemo-flow run \ - --atif-dir .nemo-flow/atif \ - --openinference-endpoint http://127.0.0.1:4318/v1/traces \ --dry-run \ --print \ -- hermes @@ -53,19 +51,31 @@ Create `.nemo-flow/config.toml` for project defaults or `~/.config/nemo-flow/config.toml` for user defaults: ```toml -[observability] -atif_dir = ".nemo-flow/atif" -metadata = { team = "agent-observability" } - -[export.openinference] -endpoint = "http://127.0.0.1:4318/v1/traces" - [agents.hermes] command = "hermes" ``` -Then run `nemo-flow run --agent hermes` to use the configured command. -User config takes priority over project and global config. +Then configure observability with `nemo-flow plugins edit --project` or +`.nemo-flow/plugins.toml`: + +```toml +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config.atif] +enabled = true +output_directory = ".nemo-flow/atif" + +[components.config.openinference] +enabled = true +endpoint = "http://127.0.0.1:4318/v1/traces" +``` + +Run `nemo-flow run --agent hermes` to use the configured command and plugin +config. User config takes priority over project and global config. ## Hermes Hook Setup @@ -87,7 +97,7 @@ back to `--gateway-url http://127.0.0.1:4040`. For standalone gateway mode, start the daemon manually: ```bash -NEMO_FLOW_ATIF_DIR=.nemo-flow/atif nemo-flow --bind 127.0.0.1:4040 +nemo-flow --bind 127.0.0.1:4040 ``` Then point Hermes provider traffic at `http://127.0.0.1:4040` for any provider diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index bb6e1c8eb..1ebe7f06c 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -44,10 +44,10 @@ temporary hook and gateway configuration, runs the agent, and shuts the gateway down when the agent exits. ```bash -nemo-flow run --atif-dir .nemo-flow/atif -- claude -nemo-flow run --atif-dir .nemo-flow/atif -- codex -nemo-flow run --atif-dir .nemo-flow/atif -- cursor-agent -nemo-flow run --atif-dir .nemo-flow/atif -- hermes +nemo-flow run -- claude +nemo-flow run -- codex +nemo-flow run -- cursor-agent +nemo-flow run -- hermes ``` Use `--agent claude|codex|cursor|hermes` when a wrapper hides the agent @@ -78,20 +78,6 @@ project `.nemo-flow/config.toml`, then `~/.config/nemo-flow/config.toml`. ```toml -[exporters.atif] -dir = ".nemo-flow/atif" - -[exporters.atof] -dir = ".nemo-flow/atof" -mode = "append" # append | overwrite -filename_template = "{session_id}.jsonl" - -[exporters.openinference] -endpoint = "http://127.0.0.1:4318/v1/traces" - -[observability] -metadata = { team = "agent-observability" } - [agents.codex] command = "codex" @@ -99,6 +85,26 @@ command = "codex" command = "hermes" ``` +Observability exporters are configured in `plugins.toml`. Run +`nemo-flow plugins edit --project` to create `.nemo-flow/plugins.toml`, or +write the plugin config directly: + +```toml +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config.atif] +enabled = true +output_directory = ".nemo-flow/atif" + +[components.config.openinference] +enabled = true +endpoint = "http://127.0.0.1:4318/v1/traces" +``` + ## Hook Forwarding Hooks call `nemo-flow hook-forward ` with the canonical hook payload on @@ -112,8 +118,6 @@ generated hook commands when policy requires hook delivery to block the agent. Useful wrapper options: -- `--atif-dir ` writes ATIF trajectories on session end. -- `--openinference-endpoint ` exports OpenInference traces. - `--session-metadata ''` adds structured metadata to the agent begin event. - `--plugin-config ''` records scope-local plugin configuration metadata. diff --git a/integrations/coding-agents/claude-code/README.md b/integrations/coding-agents/claude-code/README.md index 9b7f4e077..67ab2df06 100644 --- a/integrations/coding-agents/claude-code/README.md +++ b/integrations/coding-agents/claude-code/README.md @@ -33,7 +33,7 @@ Build or install the gateway binary so `nemo-flow` is on `PATH`. Run Claude Code through the wrapper: ```bash -nemo-flow run --atif-dir .nemo-flow/atif -- claude +nemo-flow run -- claude ``` The wrapper starts a per-invocation gateway on a dynamic localhost port, @@ -45,8 +45,6 @@ Inspect the launch without starting Claude Code: ```bash nemo-flow run \ - --atif-dir .nemo-flow/atif \ - --openinference-endpoint http://127.0.0.1:4318/v1/traces \ --dry-run \ --print \ -- claude @@ -58,14 +56,25 @@ Use `.nemo-flow/config.toml` for project defaults or `~/.config/nemo-flow/config.toml` for user defaults: ```toml -[observability] -atif_dir = ".nemo-flow/atif" -metadata = { team = "agent-observability" } - [agents.claude] command = "claude" ``` +Configure observability with `nemo-flow plugins edit --project` or +`.nemo-flow/plugins.toml`: + +```toml +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config.atif] +enabled = true +output_directory = ".nemo-flow/atif" +``` + Then run: ```bash @@ -78,7 +87,7 @@ Use the long-running gateway only when you do not want to launch Claude Code through the wrapper. Start the gateway in one terminal: ```bash -NEMO_FLOW_ATIF_DIR=.nemo-flow/atif nemo-flow --bind 127.0.0.1:4040 +nemo-flow --bind 127.0.0.1:4040 ``` Launch Claude Code from another terminal with the gateway environment: diff --git a/integrations/coding-agents/codex/README.md b/integrations/coding-agents/codex/README.md index 6c76c01e7..72001f0f5 100644 --- a/integrations/coding-agents/codex/README.md +++ b/integrations/coding-agents/codex/README.md @@ -41,7 +41,7 @@ Build or install the gateway binary so `nemo-flow` is on `PATH`. Run Codex through the wrapper: ```bash -nemo-flow run --atif-dir .nemo-flow/atif -- codex +nemo-flow run -- codex ``` The wrapper starts a per-invocation gateway on a dynamic localhost port, @@ -54,8 +54,6 @@ Inspect the launch without starting Codex: ```bash nemo-flow run \ - --atif-dir .nemo-flow/atif \ - --openinference-endpoint http://127.0.0.1:4318/v1/traces \ --dry-run \ --print \ -- codex @@ -67,14 +65,25 @@ Use `.nemo-flow/config.toml` for project defaults or `~/.config/nemo-flow/config.toml` for user defaults: ```toml -[observability] -atif_dir = ".nemo-flow/atif" -metadata = { team = "agent-observability" } - [agents.codex] command = "codex" ``` +Configure observability with `nemo-flow plugins edit --project` or +`.nemo-flow/plugins.toml`: + +```toml +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config.atif] +enabled = true +output_directory = ".nemo-flow/atif" +``` + Then run: ```bash @@ -87,7 +96,7 @@ Use the long-running gateway only when you do not want to launch Codex through the wrapper. Start the gateway manually: ```bash -NEMO_FLOW_ATIF_DIR=.nemo-flow/atif nemo-flow --bind 127.0.0.1:4040 +nemo-flow --bind 127.0.0.1:4040 ``` Then configure local Codex to use a gateway provider alias instead of diff --git a/integrations/coding-agents/cursor/README.md b/integrations/coding-agents/cursor/README.md index 967aa9d28..560ce063e 100644 --- a/integrations/coding-agents/cursor/README.md +++ b/integrations/coding-agents/cursor/README.md @@ -39,7 +39,7 @@ Build or install the gateway binary so `nemo-flow` is on `PATH`. Run Cursor through the wrapper: ```bash -nemo-flow run --atif-dir .nemo-flow/atif -- cursor-agent +nemo-flow run -- cursor-agent ``` The wrapper starts a per-invocation gateway on a dynamic localhost port, @@ -50,7 +50,6 @@ Inspect the launch without starting Cursor: ```bash nemo-flow run \ - --atif-dir .nemo-flow/atif \ --dry-run \ --print \ -- cursor-agent @@ -62,15 +61,26 @@ Use `.nemo-flow/config.toml` for project defaults or `~/.config/nemo-flow/config.toml` for user defaults: ```toml -[observability] -atif_dir = ".nemo-flow/atif" -metadata = { team = "agent-observability" } - [agents.cursor] command = "cursor-agent" patch_restore_hooks = true ``` +Configure observability with `nemo-flow plugins edit --project` or +`.nemo-flow/plugins.toml`: + +```toml +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config.atif] +enabled = true +output_directory = ".nemo-flow/atif" +``` + Then run: ```bash @@ -83,7 +93,7 @@ Use the long-running gateway only when you do not want to launch Cursor through the wrapper (e.g., the Cursor GUI). Start the gateway manually: ```bash -NEMO_FLOW_ATIF_DIR=.nemo-flow/atif nemo-flow --bind 127.0.0.1:4040 +nemo-flow --bind 127.0.0.1:4040 ``` Then point Cursor provider traffic at `http://127.0.0.1:4040` where Cursor From 948a01d27bf87baabbc38a6b9fff1c4a6c511a3b Mon Sep 17 00:00:00 2001 From: Will Killian Date: Wed, 13 May 2026 13:17:29 -0400 Subject: [PATCH 2/7] Style plugins edit TUI like setup Signed-off-by: Will Killian --- crates/cli/src/plugins.rs | 165 +++++++++++++++------ crates/cli/tests/coverage/plugins_tests.rs | 18 +++ 2 files changed, 140 insertions(+), 43 deletions(-) diff --git a/crates/cli/src/plugins.rs b/crates/cli/src/plugins.rs index dd1f75c05..8e13a403f 100644 --- a/crates/cli/src/plugins.rs +++ b/crates/cli/src/plugins.rs @@ -6,7 +6,7 @@ use std::io::IsTerminal; use std::path::{Path, PathBuf}; -use console::{Key, Term}; +use console::{Key, Term, style}; use dialoguer::theme::ColorfulTheme; use dialoguer::{Input, Select}; use nemo_flow::config_editor::{EditorConfig, EditorFieldKind, EditorFieldSpec}; @@ -60,6 +60,26 @@ impl MenuItem { } } +fn status_label(enabled: bool) -> String { + if enabled { + style("on").green().to_string() + } else { + style("off").red().to_string() + } +} + +fn shortcut_label(label: impl AsRef, shortcut: &str) -> String { + format!( + "{} {}", + label.as_ref(), + style(format!("[{shortcut}]")).black().bright() + ) +} + +fn print_save_success(path: &Path) { + println!(" {} Saved {}", style("✔").green(), path.display()); +} + pub(crate) fn edit(command: PluginsEditCommand) -> Result<(), CliError> { ensure_tty()?; let scope = target_scope(&command)?; @@ -69,31 +89,37 @@ pub(crate) fn edit(command: PluginsEditCommand) -> Result<(), CliError> { let mut observability = component_observability_config(&config)?; let theme = ColorfulTheme::default(); + crate::banner::print_intro(); + println!( + " Editing Observability plugin config at {}", + path.display() + ); + println!(" Tip: ↑/↓ or j/k to move, SPACE/ENTER to select, p to preview, s to save."); + println!(); loop { let summary = observability_summary(&config, &observability); let section_fields = ObservabilityConfig::editor_schema().fields; let mut items = vec![MenuItem::new(format!( "Toggle Observability component [{}]", - if component_enabled(&config) { - "on" - } else { - "off" - } + status_label(component_enabled(&config)) ))]; items.extend( section_fields .iter() .map(|section| MenuItem::new(format!("Edit {}", section.label))), ); - items.push(MenuItem::new("Preview TOML [p]")); - items.push(MenuItem::new(format!("Save to {} [s]", path.display()))); - items.push(MenuItem::new("Cancel [q]")); + items.push(MenuItem::new(shortcut_label("Preview TOML", "p"))); + items.push(MenuItem::new(shortcut_label( + format!("Save to {}", path.display()), + "s", + ))); + items.push(MenuItem::new(shortcut_label("Cancel", "q"))); println!(); println!("Observability: {summary}"); let preview_index = section_fields.len() + 1; let save_index = section_fields.len() + 2; let cancel_index = section_fields.len() + 3; - let selection = prompt_menu("plugins.toml", &items, 0)?; + let selection = prompt_menu(&theme, "plugins.toml", &items, 0)?; match selection { MenuResponse::Selected(0) => { let enabled = !component_enabled(&config); @@ -112,7 +138,7 @@ pub(crate) fn edit(command: PluginsEditCommand) -> Result<(), CliError> { store_observability_config(&mut config, &observability)?; validate_config(&config)?; write_plugin_config(&path, &config)?; - println!(" Saved {}", path.display()); + print_save_success(&path); return Ok(()); } MenuResponse::Selected(selection) if selection == cancel_index => { @@ -128,12 +154,12 @@ pub(crate) fn edit(command: PluginsEditCommand) -> Result<(), CliError> { store_observability_config(&mut config, &observability)?; validate_config(&config)?; write_plugin_config(&path, &config)?; - println!(" Saved {}", path.display()); + print_save_success(&path); return Ok(()); } MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), MenuResponse::Shortcut(MenuShortcut::Reset | MenuShortcut::Clear, _) => { - println!("Select a section first, then use reset or clear on a field."); + println!(" Select a section first, then use reset or clear on a field."); } MenuResponse::Cancel | MenuResponse::Selected(_) => { return Err(CliError::Config( @@ -144,18 +170,23 @@ pub(crate) fn edit(command: PluginsEditCommand) -> Result<(), CliError> { } } -fn prompt_menu(prompt: &str, items: &[MenuItem], default: usize) -> Result { +fn prompt_menu( + theme: &ColorfulTheme, + prompt: &str, + items: &[MenuItem], + default: usize, +) -> Result { if items.is_empty() { return Err(CliError::Config(format!("{prompt} menu has no items"))); } - let term = Term::stdout(); + let term = Term::stderr(); let mut selected = default.min(items.len() - 1); let mut rendered_lines = 0; loop { if rendered_lines > 0 { term.clear_last_lines(rendered_lines).map_err(menu_error)?; } - let lines = render_menu(prompt, items, selected); + let lines = render_menu(theme, prompt, items, selected); rendered_lines = lines.len(); for line in &lines { term.write_line(line).map_err(menu_error)?; @@ -205,19 +236,39 @@ fn prompt_menu(prompt: &str, items: &[MenuItem], default: usize) -> Result Vec { +fn render_menu( + theme: &ColorfulTheme, + prompt: &str, + items: &[MenuItem], + selected: usize, +) -> Vec { let mut lines = Vec::with_capacity(items.len() + 2); - lines.push(format!("{prompt}:")); + lines.push(format!( + "{} {} {}", + theme.prompt_prefix, + theme.prompt_style.apply_to(prompt), + theme.prompt_suffix + )); lines.push( - "Keys: arrows/j/k move, Enter/Space select, p preview, s save, r reset, Backspace/Delete clear, ? help, q cancel." + theme + .hint_style + .apply_to(" ↑/↓ or j/k move, Enter/Space select, p preview, s save, r reset, Backspace/Delete clear, ? help, q cancel.") .to_string(), ); lines.extend(items.iter().enumerate().map(|(index, item)| { - format!( - "{} {}", - if index == selected { ">" } else { " " }, - item.label - ) + if index == selected { + format!( + "{} {}", + theme.active_item_prefix, + theme.active_item_style.apply_to(&item.label) + ) + } else { + format!( + "{} {}", + theme.inactive_item_prefix, + theme.inactive_item_style.apply_to(&item.label) + ) + } })); lines } @@ -242,14 +293,33 @@ fn menu_error(error: std::io::Error) -> CliError { fn print_editor_help() { println!(); - println!("Plugin editor keys:"); - println!(" arrows or j/k move"); - println!(" Enter or Space select/toggle the highlighted item"); - println!(" r reset the highlighted field or section"); - println!(" Backspace/Del clear the highlighted optional field"); - println!(" p preview TOML from the main menu"); - println!(" s save from the main menu"); - println!(" q or Esc go back/cancel"); + println!( + "{} {}", + style("?").yellow(), + style("Plugin editor keys").bold() + ); + println!(" {} move", style("↑/↓ or j/k").cyan()); + println!( + " {} select/toggle the highlighted item", + style("Enter/Space").cyan() + ); + println!( + " {} reset the highlighted field or section", + style("r").cyan() + ); + println!( + " {} clear the highlighted optional field", + style("Backspace/Del").cyan() + ); + println!( + " {} preview TOML from the main menu", + style("p").cyan() + ); + println!( + " {} save from the main menu", + style("s").cyan() + ); + println!(" {} go back/cancel", style("q or Esc").cyan()); } fn ensure_tty() -> Result<(), CliError> { @@ -334,7 +404,12 @@ fn write_plugin_config(path: &Path, config: &PluginConfig) -> Result<(), CliErro fn print_preview(config: &PluginConfig) -> Result<(), CliError> { println!(); - println!("--- plugins.toml preview ---------------------------------"); + println!( + "{} {}", + style("❯").green(), + style("plugins.toml preview").bold() + ); + println!("{}", style("─".repeat(58)).black().bright()); let mut value = serde_json::to_value(config) .map_err(|error| CliError::Config(format!("could not serialize plugin config: {error}")))?; prune_plugin_defaults(&mut value); @@ -344,7 +419,7 @@ fn print_preview(config: &PluginConfig) -> Result<(), CliError> { let rendered = toml::to_string_pretty(&toml_value) .map_err(|error| CliError::Config(format!("could not render plugin TOML: {error}")))?; print!("{rendered}"); - println!("----------------------------------------------------------"); + println!("{}", style("─".repeat(58)).black().bright()); Ok(()) } @@ -381,7 +456,7 @@ fn edit_section( let enabled = section_enabled(config, section).unwrap_or(false); items.push(MenuItem::new(format!( "Toggle section [{}]", - if enabled { "on" } else { "off" } + status_label(enabled) ))); } for field in fields { @@ -395,9 +470,9 @@ fn edit_section( .unwrap_or_else(|| "(default)".to_string()) ))); } - items.push(MenuItem::new("Reset section [r]")); - items.push(MenuItem::new("Back [q]")); - let selection = prompt_menu(section.name, &items, 0)?; + items.push(MenuItem::new(shortcut_label("Reset section", "r"))); + items.push(MenuItem::new(shortcut_label("Back", "q"))); + let selection = prompt_menu(theme, section.name, &items, 0)?; let selection = match selection { MenuResponse::Selected(selection) => selection, MenuResponse::Shortcut(MenuShortcut::Help, _) => { @@ -415,11 +490,11 @@ fn edit_section( if reset_selected_field(config, section, fields, selected)? { continue; } - println!("Select a field to clear."); + println!(" Select a field to clear."); continue; } MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { - println!("Preview and save are available from the main plugins.toml menu."); + println!(" Preview and save are available from the main plugins.toml menu."); continue; } MenuResponse::Cancel => return Ok(()), @@ -451,10 +526,14 @@ fn edit_field( let current = section_field_value(config, section, field.name)?; let actions = [ MenuItem::new("Set value"), - MenuItem::new("Reset to default/none [r, Backspace, Delete]"), - MenuItem::new("Back [q]"), + MenuItem::new(shortcut_label( + "Reset to default/none", + "r, Backspace, Delete", + )), + MenuItem::new(shortcut_label("Back", "q")), ]; let action = prompt_menu( + theme, &format!( "{}.{}, current {}", section.name, @@ -478,7 +557,7 @@ fn edit_field( } MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { - println!("Preview and save are available from the main plugins.toml menu."); + println!(" Preview and save are available from the main plugins.toml menu."); } _ => {} } diff --git a/crates/cli/tests/coverage/plugins_tests.rs b/crates/cli/tests/coverage/plugins_tests.rs index ec1c0910d..0c07910ac 100644 --- a/crates/cli/tests/coverage/plugins_tests.rs +++ b/crates/cli/tests/coverage/plugins_tests.rs @@ -23,6 +23,24 @@ fn typed_editor_model_contains_observability_sections() { ); } +#[test] +fn plugin_menu_uses_setup_theme_markers() { + let theme = ColorfulTheme::default(); + let lines = render_menu( + &theme, + "plugins.toml", + &[MenuItem::new("First"), MenuItem::new("Second")], + 0, + ); + let rendered = lines.join("\n"); + + assert!(rendered.contains('?')); + assert!(rendered.contains('›')); + assert!(rendered.contains('❯')); + assert!(rendered.contains("↑/↓")); + assert!(!rendered.contains("> First")); +} + #[test] fn editor_model_renders_valid_observability_plugin_config() { let mut config = PluginConfig::default(); From 493fc692bd225c14ce2e9c8cb9fa8b5c2792cd44 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Wed, 13 May 2026 13:46:42 -0400 Subject: [PATCH 3/7] fix: improve Codex hook setup visibility Signed-off-by: Will Killian --- crates/cli/src/launcher.rs | 146 ++++++++++++++++-- crates/cli/src/plugins.rs | 51 +++++- crates/cli/tests/coverage/launcher_tests.rs | 80 ++++++++++ crates/cli/tests/coverage/plugins_tests.rs | 19 +++ .../coding-agent-codex.md | 21 ++- integrations/coding-agents/README.md | 2 +- integrations/coding-agents/codex/README.md | 4 +- 7 files changed, 294 insertions(+), 29 deletions(-) diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index f5d575a97..8fd9f3290 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -5,6 +5,8 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use nemo_flow::observability::plugin_component::{OBSERVABILITY_PLUGIN_KIND, ObservabilityConfig}; +use nemo_flow::plugin::PluginConfig; use reqwest::Client; use serde_json::{Value, json}; use tokio::net::TcpListener; @@ -291,6 +293,9 @@ impl PreparedRun { cursor_restore: None, notes: Vec::new(), }; + if let Some(path) = path_with_transparent_hook_dir() { + run.env.push(("PATH".into(), path)); + } match agent { CodingAgent::ClaudeCode => { if dry_run { @@ -518,14 +523,22 @@ impl PreparedRun { let mut lines: Vec = Vec::new(); lines.push(format!("NeMo Flow → {}", agent.as_arg())); lines.push(format!(" Gateway {gateway_url}")); - lines.push(format!( - " Plugins {}", - if resolved.gateway.plugin_config.is_some() { - "configured" - } else { - "not configured" + let destinations = exporter_destinations(&resolved.gateway); + if destinations.is_empty() { + lines.push(" Exporters not configured".into()); + } else { + for (index, destination) in destinations.iter().enumerate() { + lines.push(format!( + " {}{}", + if index == 0 { + "Exporters " + } else { + " " + }, + destination + )); } - )); + } if !self.notes.is_empty() { lines.push(String::new()); for note in &self.notes { @@ -565,14 +578,14 @@ impl PreparedRun { "anthropic_base_url = {}", resolved.gateway.anthropic_base_url ); - println!( - "plugins = {}", - if resolved.gateway.plugin_config.is_some() { - "configured" - } else { - "not_configured" + let destinations = exporter_destinations(&resolved.gateway); + if destinations.is_empty() { + println!("exporters = not_configured"); + } else { + for destination in destinations { + println!("exporter = {destination}"); } - ); + } println!("argv = {}", self.argv.join(" ")); for (name, value) in &self.env { println!("env.{name} = {value}"); @@ -586,6 +599,89 @@ impl PreparedRun { } } +fn exporter_destinations(config: &GatewayConfig) -> Vec { + let Some(plugin_config) = config.plugin_config.as_ref() else { + return Vec::new(); + }; + let Ok(plugin_config) = serde_json::from_value::(plugin_config.clone()) else { + return vec!["configured (invalid plugin config)".into()]; + }; + let Some(component) = plugin_config + .components + .iter() + .find(|component| component.kind == OBSERVABILITY_PLUGIN_KIND) + else { + return Vec::new(); + }; + if !component.enabled { + return Vec::new(); + } + let Ok(observability) = + serde_json::from_value::(Value::Object(component.config.clone())) + else { + return vec!["Observability configured (invalid config)".into()]; + }; + observability_exporter_destinations(&observability) +} + +fn observability_exporter_destinations(config: &ObservabilityConfig) -> Vec { + let mut destinations = Vec::new(); + if let Some(section) = config.atof.as_ref().filter(|section| section.enabled) { + let directory = section + .output_directory + .clone() + .unwrap_or_else(current_output_directory); + let path = directory.join( + section + .filename + .clone() + .unwrap_or_else(|| "nemo-flow-events-.jsonl".into()), + ); + destinations.push(format!("ATOF {}", path.display())); + } + if let Some(section) = config.atif.as_ref().filter(|section| section.enabled) { + let directory = section + .output_directory + .clone() + .unwrap_or_else(current_output_directory); + destinations.push(format!( + "ATIF {}", + directory.join(§ion.filename_template).display() + )); + } + if let Some(section) = config + .opentelemetry + .as_ref() + .filter(|section| section.enabled) + { + destinations.push(format!( + "OpenTelemetry {}", + section + .endpoint + .as_deref() + .unwrap_or("OTLP endpoint from environment/default") + )); + } + if let Some(section) = config + .openinference + .as_ref() + .filter(|section| section.enabled) + { + destinations.push(format!( + "OpenInference {}", + section + .endpoint + .as_deref() + .unwrap_or("OTLP endpoint from environment/default") + )); + } + destinations +} + +fn current_output_directory() -> PathBuf { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + // Converts a process status into the launcher status code while preserving normal 0-255 exits. Signal // exits and platform-specific out-of-range codes become generic failure. fn exit_code(status: std::process::ExitStatus) -> ExitCode { @@ -658,6 +754,28 @@ fn transparent_hook_executable() -> String { .unwrap_or_else(|| "nemo-flow".to_string()) } +// Appends the running gateway binary's directory to the child agent PATH. Transparent hooks use +// the absolute executable path when possible, but adding the directory also covers hook loaders or +// user-managed hook commands that resolve `nemo-flow` through PATH inside the launched agent. Keep +// user PATH precedence intact so normal agent tool resolution does not change. +fn path_with_transparent_hook_dir() -> Option { + let dir = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf))?; + let mut paths: Vec = std::env::var_os("PATH") + .as_deref() + .map(std::env::split_paths) + .into_iter() + .flatten() + .collect(); + if !paths.iter().any(|path| path == &dir) { + paths.push(dir); + } + std::env::join_paths(paths) + .ok() + .map(|path| path.to_string_lossy().into_owned()) +} + // Inserts generated agent flags immediately after the last argv element that looks like the agent // executable. Falling back to index 0 keeps wrapper commands usable by inserting after the first // word when the agent cannot be found later in argv. diff --git a/crates/cli/src/plugins.rs b/crates/cli/src/plugins.rs index 8e13a403f..f6333586b 100644 --- a/crates/cli/src/plugins.rs +++ b/crates/cli/src/plugins.rs @@ -76,6 +76,14 @@ fn shortcut_label(label: impl AsRef, shortcut: &str) -> String { ) } +fn configured_label(configured: bool, label: impl AsRef) -> String { + if configured { + format!("{} {}", style("✓").green(), label.as_ref()) + } else { + format!(" {}", label.as_ref()) + } +} + fn print_save_success(path: &Path) { println!(" {} Saved {}", style("✔").green(), path.display()); } @@ -103,11 +111,12 @@ pub(crate) fn edit(command: PluginsEditCommand) -> Result<(), CliError> { "Toggle Observability component [{}]", status_label(component_enabled(&config)) ))]; - items.extend( - section_fields - .iter() - .map(|section| MenuItem::new(format!("Edit {}", section.label))), - ); + items.extend(section_fields.iter().map(|section| { + MenuItem::new(configured_label( + section_configured(&observability, *section), + format!("Edit {}", section.label), + )) + })); items.push(MenuItem::new(shortcut_label("Preview TOML", "p"))); items.push(MenuItem::new(shortcut_label( format!("Save to {}", path.display()), @@ -460,9 +469,10 @@ fn edit_section( ))); } for field in fields { + let configured = section_field_configured(config, section, *field)?; items.push(MenuItem::new(format!( "{} = {}", - field.name, + configured_label(configured, field.name), section_field_value(config, section, field.name)? .map(|value| display_field_value(section, *field, &value)) .or_else(|| default_field_value(section, *field) @@ -754,6 +764,35 @@ fn section_enabled(config: &ObservabilityConfig, section: EditorFieldSpec) -> Op .and_then(|enabled| enabled.as_bool()) } +fn section_configured(config: &ObservabilityConfig, section: EditorFieldSpec) -> bool { + let Ok(Some(value)) = section_value(config, section) else { + return false; + }; + if section.optional { + return true; + } + section + .default_value() + .as_ref() + .is_none_or(|default| default != &value) +} + +fn section_field_configured( + config: &ObservabilityConfig, + section: EditorFieldSpec, + field: EditorFieldSpec, +) -> Result { + let Some(value) = section_field_value(config, section, field.name)? else { + return Ok(false); + }; + if field.optional { + return Ok(true); + } + Ok(default_field_value(section, field) + .as_ref() + .is_none_or(|default| default != &value)) +} + fn section_field_value( config: &ObservabilityConfig, section: EditorFieldSpec, diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index 5ff40233f..77da6dd8b 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -226,6 +226,86 @@ fn prepares_codex_config_overrides() { .iter() .any(|arg| arg.contains("hooks.SessionStart")) ); + let path = prepared + .env + .iter() + .find_map(|(name, value)| (name == "PATH").then_some(value)) + .expect("transparent run should set PATH for hook subprocesses"); + let current_exe_dir = std::env::current_exe() + .unwrap() + .parent() + .unwrap() + .to_path_buf(); + let entries = std::env::split_paths(path).collect::>(); + assert!(entries.iter().any(|entry| entry == ¤t_exe_dir)); + if !std::env::var_os("PATH") + .as_deref() + .map(std::env::split_paths) + .into_iter() + .flatten() + .any(|entry| entry == current_exe_dir) + { + assert_eq!(entries.last(), Some(¤t_exe_dir)); + } +} + +#[test] +fn exporter_destinations_describe_observability_outputs() { + let gateway = GatewayConfig { + plugin_config: Some(json!({ + "version": 1, + "components": [{ + "kind": OBSERVABILITY_PLUGIN_KIND, + "enabled": true, + "config": { + "version": 1, + "atof": { + "enabled": true, + "output_directory": "logs", + "filename": "events.jsonl" + }, + "atif": { + "enabled": true, + "output_directory": "trajectories", + "filename_template": "agent-{session_id}.json" + }, + "opentelemetry": { + "enabled": true, + "endpoint": "http://127.0.0.1:4318/v1/traces" + }, + "openinference": { + "enabled": true + } + } + }] + })), + ..GatewayConfig::default() + }; + + let destinations = exporter_destinations(&gateway); + + assert!(destinations.iter().any(|line| line + == &format!( + "ATOF {}", + PathBuf::from("logs").join("events.jsonl").display() + ))); + assert!(destinations.iter().any(|line| line + == &format!( + "ATIF {}", + PathBuf::from("trajectories") + .join("agent-{session_id}.json") + .display() + ))); + assert!( + destinations + .iter() + .any(|line| line == "OpenTelemetry http://127.0.0.1:4318/v1/traces") + ); + assert!( + destinations + .iter() + .any(|line| line == "OpenInference OTLP endpoint from environment/default") + ); } #[test] diff --git a/crates/cli/tests/coverage/plugins_tests.rs b/crates/cli/tests/coverage/plugins_tests.rs index 0c07910ac..3eda9d935 100644 --- a/crates/cli/tests/coverage/plugins_tests.rs +++ b/crates/cli/tests/coverage/plugins_tests.rs @@ -41,6 +41,25 @@ fn plugin_menu_uses_setup_theme_markers() { assert!(!rendered.contains("> First")); } +#[test] +fn plugin_menu_marks_configured_sections_and_fields() { + let mut observability = ObservabilityConfig::default(); + let atof = ObservabilityConfig::editor_schema().field("atof").unwrap(); + let mode = atof.schema().unwrap().field("mode").unwrap(); + let output_directory = atof.schema().unwrap().field("output_directory").unwrap(); + + assert!(!section_configured(&observability, atof)); + ensure_section(&mut observability, atof); + assert!(section_configured(&observability, atof)); + assert!(!section_field_configured(&observability, atof, mode).unwrap()); + assert!(!section_field_configured(&observability, atof, output_directory).unwrap()); + + set_section_field(&mut observability, atof, "output_directory", json!("logs")).unwrap(); + assert!(section_field_configured(&observability, atof, output_directory).unwrap()); + assert!(configured_label(true, "Edit ATOF").contains('✓')); + assert!(!configured_label(false, "Edit ATIF").contains('✓')); +} + #[test] fn editor_model_renders_valid_observability_plugin_config() { let mut config = PluginConfig::default(); diff --git a/docs/integrate-frameworks/coding-agent-codex.md b/docs/integrate-frameworks/coding-agent-codex.md index 201d1f5ee..f53376bb0 100644 --- a/docs/integrate-frameworks/coding-agent-codex.md +++ b/docs/integrate-frameworks/coding-agent-codex.md @@ -17,6 +17,14 @@ local gateway cannot observe provider traffic that never reaches the machine. versions either reject the provider override or do not recognize the hooks feature flag. +```{warning} +As of Codex 0.129, Codex requires hooks to be manually reviewed and activated +before they run. Generated NeMo Flow hook configuration is not enough on its own +if Codex leaves those hooks inactive. Review and activate the installed or +injected hooks in Codex before expecting NeMo Flow events. This is being tracked +upstream as [openai/codex#21639](https://github.com/openai/codex/issues/21639). +``` + ## Transparent Run Use the wrapper for no-install local observability: @@ -116,9 +124,10 @@ NeMo Flow events. The transparent wrapper passes hook entries as Codex CLI config overrides and sets `features.hooks=true` for that launched process. Persistent install writes -`.codex/config.toml` with `hooks = true` and merges generated hook entries into -`.codex/hooks.json`. (`features.codex_hooks` is the legacy alias of -`features.hooks`; new docs and configurations should prefer the canonical name.) +`.codex/config.toml` with `[features].hooks = true` and merges generated hook +entries into `.codex/hooks.json`. (`features.codex_hooks` is the legacy alias +of `features.hooks`; new docs and configurations should prefer the canonical +name.) ## Smoke Test @@ -147,9 +156,9 @@ Codex sessions (Codex's hook surface has no `SessionEnd`-equivalent event, so the gateway uses each per-turn `Stop` hook to snapshot the trajectory; the file grows cumulatively across turns and the final write reflects the full session). For agents that do emit a session-end hook, the same file is written once on -session close. If the file is missing, confirm `hooks = true`, hook config -loading, and that `plugins.toml` enables the ATIF exporter with a writable -`output_directory`. +session close. If the file is missing, confirm `[features].hooks = true`, hook +config loading, and that `plugins.toml` enables the ATIF exporter with a +writable `output_directory`. ## Troubleshoot LLM Lifecycle diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index 1ebe7f06c..90e49ce39 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -25,7 +25,7 @@ environment variables, or shared TOML config. - `claude-code/` installs Claude Code hook entries targeting `POST /hooks/claude-code`. - `codex/` installs Codex hook entries targeting `POST /hooks/codex` and enables - `codex_hooks = true`. Use `nemo-flow run` or a gateway provider alias + `[features].hooks = true`. Use `nemo-flow run` or a gateway provider alias for Codex LLM gateway routing. - `cursor/` installs a Cursor `.cursor/hooks.json` bundle targeting `POST /hooks/cursor`. diff --git a/integrations/coding-agents/codex/README.md b/integrations/coding-agents/codex/README.md index 72001f0f5..d5f5b0c1b 100644 --- a/integrations/coding-agents/codex/README.md +++ b/integrations/coding-agents/codex/README.md @@ -31,8 +31,8 @@ The bundle forwards `SessionStart`, `SessionEnd`, `SubagentStart`, provide private LLM correlation hints for gateway requests. Transparent setup injects these hooks with CLI config overrides. Persistent -setup writes `hooks = true` in `.codex/config.toml` and merges the hook -entries into `.codex/hooks.json`. +setup writes `[features].hooks = true` in `.codex/config.toml` and merges the +hook entries into `.codex/hooks.json`. ## Transparent Setup From deda038c91232011c355c6f01779d2a2cacf78d1 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Wed, 13 May 2026 14:01:10 -0400 Subject: [PATCH 4/7] fix: apply coderabbit feedback Signed-off-by: Will Killian --- crates/cli/src/launcher.rs | 13 +++++++------ crates/cli/src/plugins.rs | 11 +++++++++-- crates/cli/tests/coverage/doctor_tests.rs | 6 +++++- crates/cli/tests/coverage/session_tests.rs | 2 -- docs/integrate-frameworks/coding-agent-codex.md | 4 ++-- integrations/coding-agents/README.md | 2 +- integrations/coding-agents/codex/README.md | 2 +- 7 files changed, 25 insertions(+), 15 deletions(-) diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index 8fd9f3290..96c378681 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -507,12 +507,13 @@ impl PreparedRun { Ok(()) } - // Prints a compact pre-launch status banner so users see at a glance where their observability - // data is going (gateway URL, ATIF dir, OpenInference endpoint) before the agent's own UI takes - // over the terminal. Always emitted on stderr so it never contaminates piped/redirected agent - // output, and suppressed entirely when stdout is not a TTY — scripts capturing the agent stream - // get a clean pipe, interactive users still get the bordered frame. Distinct from `print()`, - // which is the verbose `--print` / `--dry-run` dump intended for inspection. + // Prints a compact pre-launch status banner so users see at a glance which plugin + // configuration is active, including plugin names and enabled/disabled state, before the + // agent's own UI takes over the terminal. Always emitted on stderr so it never contaminates + // piped/redirected agent output, and suppressed entirely when stdout is not a TTY — scripts + // capturing the agent stream get a clean pipe, interactive users still get the bordered frame. + // Distinct from `print()`, which is the verbose `--print` / `--dry-run` dump intended for + // inspection. fn print_live_status(&self, agent: CodingAgent, gateway_url: &str, resolved: &ResolvedConfig) { // Suppress entirely on non-TTY stdout: when the user redirects the agent's stream to a // file or pipes it into another tool, no banner should appear ahead of that output. diff --git a/crates/cli/src/plugins.rs b/crates/cli/src/plugins.rs index f6333586b..d92258c32 100644 --- a/crates/cli/src/plugins.rs +++ b/crates/cli/src/plugins.rs @@ -332,7 +332,10 @@ fn print_editor_help() { } fn ensure_tty() -> Result<(), CliError> { - if !std::io::stdin().is_terminal() { + if !std::io::stdin().is_terminal() + || !std::io::stdout().is_terminal() + || !std::io::stderr().is_terminal() + { return Err(CliError::Config( "interactive plugin editing requires a TTY".into(), )); @@ -493,7 +496,11 @@ fn edit_section( if reset_selected_field(config, section, fields, selected)? { continue; } - reset_section(config, section); + let reset_section_index = + usize::from(section_has_enabled_toggle(section)) + fields.len(); + if selected == reset_section_index { + reset_section(config, section); + } continue; } MenuResponse::Shortcut(MenuShortcut::Clear, selected) => { diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/doctor_tests.rs index 3310745b0..baad81a7e 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -259,7 +259,11 @@ async fn collect_observability_warns_for_missing_atif_dir_without_creating_it() let checks = collect_observability(&gateway).await; - assert!(checks.iter().any(|check| check.status == Status::Warn)); + let atif_check = checks + .iter() + .find(|check| check.name == "ATIF dir") + .expect("ATIF directory check"); + assert_eq!(atif_check.status, Status::Warn); assert!(!missing.exists()); } diff --git a/crates/cli/tests/coverage/session_tests.rs b/crates/cli/tests/coverage/session_tests.rs index 9318f4367..0276c1f0e 100644 --- a/crates/cli/tests/coverage/session_tests.rs +++ b/crates/cli/tests/coverage/session_tests.rs @@ -136,7 +136,6 @@ async fn handles_out_of_order_subagent_and_tool_end_events() { #[tokio::test] async fn terminal_retry_for_unknown_session_is_ignored() { - let temp = tempfile::tempdir().unwrap(); let config = session_test_config(); let manager = SessionManager::new(config); @@ -155,7 +154,6 @@ async fn terminal_retry_for_unknown_session_is_ignored() { .unwrap(); assert!(manager.inner.lock().await.is_empty()); - assert!(!temp.path().join("retry-session.atif.json").exists()); } #[tokio::test] diff --git a/docs/integrate-frameworks/coding-agent-codex.md b/docs/integrate-frameworks/coding-agent-codex.md index f53376bb0..fa970fc61 100644 --- a/docs/integrate-frameworks/coding-agent-codex.md +++ b/docs/integrate-frameworks/coding-agent-codex.md @@ -124,7 +124,7 @@ NeMo Flow events. The transparent wrapper passes hook entries as Codex CLI config overrides and sets `features.hooks=true` for that launched process. Persistent install writes -`.codex/config.toml` with `[features].hooks = true` and merges generated hook +`.codex/config.toml` with `features.hooks = true` and merges generated hook entries into `.codex/hooks.json`. (`features.codex_hooks` is the legacy alias of `features.hooks`; new docs and configurations should prefer the canonical name.) @@ -156,7 +156,7 @@ Codex sessions (Codex's hook surface has no `SessionEnd`-equivalent event, so the gateway uses each per-turn `Stop` hook to snapshot the trajectory; the file grows cumulatively across turns and the final write reflects the full session). For agents that do emit a session-end hook, the same file is written once on -session close. If the file is missing, confirm `[features].hooks = true`, hook +session close. If the file is missing, confirm `features.hooks = true`, hook config loading, and that `plugins.toml` enables the ATIF exporter with a writable `output_directory`. diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index 90e49ce39..56b6cc9f6 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -25,7 +25,7 @@ environment variables, or shared TOML config. - `claude-code/` installs Claude Code hook entries targeting `POST /hooks/claude-code`. - `codex/` installs Codex hook entries targeting `POST /hooks/codex` and enables - `[features].hooks = true`. Use `nemo-flow run` or a gateway provider alias + `features.hooks = true`. Use `nemo-flow run` or a gateway provider alias for Codex LLM gateway routing. - `cursor/` installs a Cursor `.cursor/hooks.json` bundle targeting `POST /hooks/cursor`. diff --git a/integrations/coding-agents/codex/README.md b/integrations/coding-agents/codex/README.md index d5f5b0c1b..fb6d6731f 100644 --- a/integrations/coding-agents/codex/README.md +++ b/integrations/coding-agents/codex/README.md @@ -31,7 +31,7 @@ The bundle forwards `SessionStart`, `SessionEnd`, `SubagentStart`, provide private LLM correlation hints for gateway requests. Transparent setup injects these hooks with CLI config overrides. Persistent -setup writes `[features].hooks = true` in `.codex/config.toml` and merges the +setup writes `features.hooks = true` in `.codex/config.toml` and merges the hook entries into `.codex/hooks.json`. ## Transparent Setup From 74a30c54c5905aac88cbabca0b331e12ac9a48c7 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Wed, 13 May 2026 11:35:57 -0700 Subject: [PATCH 5/7] fix(cli): close Codex spans on gateway shutdown PR #98 moved gateway LLM traffic onto the managed core execution APIs. Non-streaming calls still close before returning, but streaming calls now rely on LlmStreamWrapper to emit the LLM end event. When Codex drops or cancels a streaming response before the stream is fully polled, the wrapper was dropped without closing the LLM span. Close streaming LLM spans from LlmStreamWrapper::drop so partial streams still emit an end event. Also close any remaining CLI sessions before gateway plugin teardown so Codex transparent runs that do not emit a native SessionEnd still pop active tool, subagent, and agent scopes while exporters are registered. Add focused regression coverage for partial stream drops and Codex gateway shutdown without a SessionEnd hook. Signed-off-by: Ajay Thorve --- crates/cli/src/server.rs | 43 ++++++++++----- crates/cli/src/session.rs | 37 +++++++++++++ crates/cli/tests/coverage/session_tests.rs | 39 ++++++++++++++ crates/core/src/stream.rs | 6 +++ crates/core/tests/integration/stream_tests.rs | 52 +++++++++++++++++++ 5 files changed, 164 insertions(+), 13 deletions(-) diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index 5e90bb4e4..2ea6dac81 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -66,7 +66,9 @@ pub(crate) async fn serve_listener( shutdown: Option>, ) -> Result<(), CliError> { let plugin_activation = PluginActivation::initialize(config.plugin_config.clone()).await?; - let app = router(config); + let state = AppState::new(config); + let sessions = state.sessions.clone(); + let app = router_with_state(state); let serve_result = match shutdown { Some(receiver) => { axum::serve(listener, app) @@ -77,13 +79,18 @@ pub(crate) async fn serve_listener( } None => axum::serve(listener, app).await, }; + let close_result = sessions.close_all("gateway_shutdown").await; let clear_result = plugin_activation.clear(); if let Err(serve_error) = serve_result { + if let Err(close_error) = close_result { + eprintln!("session teardown failed after server error: {close_error}"); + } if let Err(clear_error) = clear_result { eprintln!("plugin teardown failed after server error: {clear_error}"); } return Err(serve_error.into()); } + close_result?; clear_result } @@ -91,19 +98,29 @@ pub(crate) async fn serve_listener( /// /// Hook endpoints normalize agent-specific payloads into session events, while gateway endpoints /// proxy model traffic and emit LLM runtime events against the same `SessionManager`. +#[cfg(test)] pub(crate) fn router(config: GatewayConfig) -> Router { - let sessions = SessionManager::new(config.clone()); - let http = Client::builder() - .connect_timeout(HTTP_CONNECT_TIMEOUT) - .timeout(HTTP_REQUEST_TIMEOUT) - .read_timeout(HTTP_READ_TIMEOUT) - .build() - .expect("gateway HTTP client configuration is valid"); - let state = AppState { - config, - http, - sessions, - }; + router_with_state(AppState::new(config)) +} + +impl AppState { + fn new(config: GatewayConfig) -> Self { + let sessions = SessionManager::new(config.clone()); + let http = Client::builder() + .connect_timeout(HTTP_CONNECT_TIMEOUT) + .timeout(HTTP_REQUEST_TIMEOUT) + .read_timeout(HTTP_READ_TIMEOUT) + .build() + .expect("gateway HTTP client configuration is valid"); + Self { + config, + http, + sessions, + } + } +} + +fn router_with_state(state: AppState) -> Router { Router::new() .route("/healthz", get(healthz)) .route("/hooks/codex", post(codex_hook)) diff --git a/crates/cli/src/session.rs b/crates/cli/src/session.rs index c4503a509..6f306b352 100644 --- a/crates/cli/src/session.rs +++ b/crates/cli/src/session.rs @@ -324,6 +324,25 @@ impl SessionManager { session.add_tool_hints_from_llm_response(response, owner_subagent_id); } } + + /// Closes every still-open session before gateway teardown. + /// + /// Codex transparent runs can exit without a native `SessionEnd` hook. Gateway shutdown is the + /// last deterministic lifecycle boundary for those sessions, so close open scopes while + /// observability plugins are still active. + pub(crate) async fn close_all(&self, reason: &str) -> Result<(), CliError> { + let mut sessions = { + let mut guard = self.inner.lock().await; + guard + .drain() + .map(|(_, session)| session) + .collect::>() + }; + for session in &mut sessions { + session.close_for_shutdown(reason).await?; + } + Ok(()) + } } impl Session { @@ -509,6 +528,24 @@ impl Session { Ok(()) } + async fn close_for_shutdown(&mut self, reason: &str) -> Result<(), CliError> { + let stack = self.scope_stack.clone(); + let payload = json!({ "status": reason }); + TASK_SCOPE_STACK + .scope(stack, async move { + if self.agent_scope.is_none() { + return Ok(()); + } + self.close_active_llms_for_agent_end()?; + self.close_active_tools_for_agent_end()?; + self.close_active_subagents_for_agent_end()?; + self.clear_correlation_state(); + self.close_agent_scope(payload)?; + Ok(()) + }) + .await + } + // Ends all active hook-observed LLM calls before closing their containing scopes. fn close_active_llms_for_agent_end(&mut self) -> Result<(), CliError> { let active_llms: Vec<_> = self.llms.drain().map(|(_, handle)| handle).collect(); diff --git a/crates/cli/tests/coverage/session_tests.rs b/crates/cli/tests/coverage/session_tests.rs index 0276c1f0e..9f1111b3c 100644 --- a/crates/cli/tests/coverage/session_tests.rs +++ b/crates/cli/tests/coverage/session_tests.rs @@ -861,6 +861,45 @@ async fn agent_end_closes_active_tools_and_duplicate_starts_are_ignored() { assert!(manager.inner.lock().await.is_empty()); } +#[tokio::test] +async fn gateway_shutdown_closes_codex_sessions_without_session_end_hook() { + let manager = SessionManager::new(session_test_config()); + let headers = HeaderMap::new(); + + manager + .apply_events( + &headers, + vec![ + NormalizedEvent::AgentStarted(SessionEvent { + session_id: "codex-no-session-end".into(), + agent_kind: AgentKind::Codex, + event_name: "SessionStart".into(), + payload: json!({}), + metadata: json!({}), + }), + NormalizedEvent::ToolStarted(ToolEvent { + session_id: "codex-no-session-end".into(), + agent_kind: AgentKind::Codex, + event_name: "PreToolUse".into(), + tool_call_id: "tool-1".into(), + tool_name: "shell".into(), + subagent_id: None, + arguments: json!({ "cmd": "pwd" }), + result: Value::Null, + status: None, + payload: json!({}), + metadata: json!({}), + }), + ], + ) + .await + .unwrap(); + + manager.close_all("gateway_shutdown").await.unwrap(); + + assert!(manager.inner.lock().await.is_empty()); +} + #[tokio::test] async fn explicit_gateway_subagent_header_sets_llm_parent() { let manager = SessionManager::new(session_test_config()); diff --git a/crates/core/src/stream.rs b/crates/core/src/stream.rs index f4810cc7f..cda5de290 100644 --- a/crates/core/src/stream.rs +++ b/crates/core/src/stream.rs @@ -209,3 +209,9 @@ impl Stream for LlmStreamWrapper { } } } + +impl Drop for LlmStreamWrapper { + fn drop(&mut self) { + self.finish(); + } +} diff --git a/crates/core/tests/integration/stream_tests.rs b/crates/core/tests/integration/stream_tests.rs index 9eb890b30..5cfdba74d 100644 --- a/crates/core/tests/integration/stream_tests.rs +++ b/crates/core/tests/integration/stream_tests.rs @@ -204,6 +204,58 @@ async fn test_stream_wrapper_emits_end_event() { deregister_subscriber("stream_end_test").unwrap(); } +#[tokio::test] +async fn test_stream_wrapper_drop_emits_end_event_for_partial_stream() { + let _lock = TEST_MUTEX.lock().unwrap(); + reset_global(); + + let events = Arc::new(Mutex::new(Vec::new())); + let captured = events.clone(); + register_subscriber( + "stream_drop_end_test", + Arc::new(move |e: &Event| { + captured.lock().unwrap().push(e.clone()); + }), + ) + .unwrap(); + + let inner = make_stream(vec![ + Ok(json!({"token": "partial"})), + Ok(json!({"token": "unread"})), + ]); + let request = LlmRequest { + headers: serde_json::Map::new(), + content: json!({"messages": []}), + }; + let handle = llm_call( + LlmCallParams::builder() + .name("stream_drop_llm") + .request(&request) + .attributes(LlmAttributes::STREAMING) + .build(), + ) + .unwrap(); + + let (collector, finalizer, _collected) = make_collector_finalizer(); + let mut wrapper = LlmStreamWrapper::new(inner, handle, collector, finalizer, None, None, None); + + assert_eq!( + wrapper.next().await.unwrap().unwrap(), + json!({"token": "partial"}) + ); + drop(wrapper); + + let events = events.lock().unwrap(); + let end_event = events + .iter() + .find(|event| is_llm_end(event)) + .expect("expected END event when a partial stream is dropped"); + assert_eq!(end_event.output(), Some(&json!([{"token": "partial"}]))); + + drop(events); + deregister_subscriber("stream_drop_end_test").unwrap(); +} + #[tokio::test] async fn test_stream_wrapper_error_propagation() { let _lock = TEST_MUTEX.lock().unwrap(); From 5a3eeb77956a216f32903b1f5e9dfbab71a5add7 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Wed, 13 May 2026 11:51:18 -0700 Subject: [PATCH 6/7] docs: clarify coding-agent CLI guidance Document nemo-flow-cli as the published install path and keep the README wording concise. Lead the coding-agent gateway docs with the new agent shortcut commands while keeping the explicit run form for custom launchers. Remove the benchmark/eval recipe from user-facing docs. Signed-off-by: Ajay Thorve --- README.md | 2 + crates/cli/README.md | 5 ++- docs/getting-started/installation.md | 6 +-- docs/getting-started/rust.md | 4 +- docs/index.md | 1 + .../coding-agent-claude-code.md | 24 ++++++------ .../coding-agent-codex.md | 26 ++++++------- .../coding-agent-cursor.md | 24 ++++++------ .../coding-agent-gateway.md | 37 +++++++++++++------ .../coding-agent-hermes.md | 26 ++++++------- 10 files changed, 86 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index dc298bc58..6e3cda34c 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ The table below summarizes the support level for each binding surface. | Python | ✅ Fully Supported | Fully documented with Quick Start and Guides | | Node.js | ✅ Fully Supported | Fully documented with Quick Start and Guides | | Rust | ✅ Fully Supported | Fully documented with Quick Start and Guides | +| Coding-Agent CLI | 🚧 Experimental | Install with `cargo install nemo-flow-cli`. | | Go | 🚧 Experimental | Source-first under `go/nemo_flow`. | | WebAssembly | 🚧 Experimental | Source-first under `crates/wasm`. | | FFI | 🚧 Experimental | Source-first under `crates/ffi`. | @@ -161,6 +162,7 @@ The following table summarizes maintained third-party integrations and whether e | [LangChain](third_party/README-langchain.md), [LangGraph](third_party/README-langgraph.md), [LangChain NVIDIA](third_party/README-langchain-nvidia.md) | 🚧 Patch | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | | [opencode](third_party/README-opencode.md) | 🚧 Patch | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | | [OpenClaw](integrations/openclaw/README.md) | `nemo-flow-openclaw` package, `nemo-flow` plugin ID | ✅ Yes | ❌ No | ❌ No | ❌ No | +| [Coding-Agent CLI](docs/integrate-frameworks/coding-agent-gateway.md) | `nemo-flow-cli` package for closed harnesses | ✅ Yes | ❌ No | ❌ No | ❌ No | | [Hermes Agent](third_party/README-hermes-agent.md) | 🚧 Patch | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | Patch-based integrations offer experimental support. Our roadmap includes switching over to first-party plugins and packages where upstream extension points allow it. diff --git a/crates/cli/README.md b/crates/cli/README.md index a07085702..39313e1a1 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -12,6 +12,7 @@ SPDX-License-Identifier: Apache-2.0 [![npm wasm](https://img.shields.io/npm/v/nemo-flow-wasm?label=nemo-flow-wasm&color=CC3534&logo=npm)](https://www.npmjs.com/package/nemo-flow-wasm) [![Crates.io](https://img.shields.io/crates/v/nemo-flow?label=nemo-flow&color=B7410E&logo=rust)](https://crates.io/crates/nemo-flow) [![Crates.io](https://img.shields.io/crates/v/nemo-flow-adaptive?label=nemo-flow-adaptive&color=B7410E&logo=rust)](https://crates.io/crates/nemo-flow-adaptive) +[![Crates.io](https://img.shields.io/crates/v/nemo-flow-cli?label=nemo-flow-cli&color=B7410E&logo=rust)](https://crates.io/crates/nemo-flow-cli) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/NVIDIA/NeMo-Flow) # nemo-flow-cli @@ -50,10 +51,10 @@ with the installed `nemo-flow` command rather than link against the crate. ## Installation -Install the CLI from a repository checkout: +Install the CLI: ```bash -cargo install --path crates/cli +cargo install nemo-flow-cli ``` That command installs the binary as: diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index b1273f343..1beabf2a7 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -103,11 +103,11 @@ nemo-flow = { path = "../NeMo-Flow/crates/core" } nemo-flow-adaptive = { path = "../NeMo-Flow/crates/adaptive" } ``` -Install the local gateway binary from a source checkout when you need to run the -gateway during development: +Install the published gateway binary when you need coding-agent hook and LLM +gateway observability: ```bash -cargo install --path ../NeMo-Flow/crates/cli +cargo install nemo-flow-cli ``` ## Install from the Repository diff --git a/docs/getting-started/rust.md b/docs/getting-started/rust.md index e5a033755..478c16492 100644 --- a/docs/getting-started/rust.md +++ b/docs/getting-started/rust.md @@ -25,8 +25,8 @@ serde_json = "1" - `nemo-flow` is the core Rust runtime surface. - `nemo-flow-adaptive` is the companion crate for adaptive runtime primitives and Redis-backed learning components. -- `nemo-flow-cli` is a binary crate. Use `cargo install --path - ../NeMo-Flow/crates/cli` when you need the local coding-agent gateway. +- `nemo-flow-cli` is a binary crate. Use `cargo install nemo-flow-cli` when + you need the coding-agent gateway. ### Install from a Package Manager diff --git a/docs/index.md b/docs/index.md index 2c2522f31..adf5cd62f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -56,6 +56,7 @@ targets. These paths map common reader goals to the most relevant documentation entry points. - **End Users**: Start with [Prerequisites](getting-started/prerequisites.md) and [Quick Start](getting-started/quick-start.md). +- **Coding-Agent CLI Users**: Start with [Advanced Guide: Coding-Agent Gateway](integrate-frameworks/coding-agent-gateway.md), then use the per-agent guide for Claude Code, Codex, Cursor, or Hermes. - **Agent Framework Developers**: Start with [Integrate into Frameworks](integrate-frameworks/about.md). - **Plugin Writers**: Start with [Build Plugins](build-plugins/about.md), then continue to [Basic Guide: Define a Plugin](build-plugins/basic-guide.md). - **Contributors**: Start with [Contribute](contribute/about.md) and the repository root `CONTRIBUTING.md` guide. diff --git a/docs/integrate-frameworks/coding-agent-claude-code.md b/docs/integrate-frameworks/coding-agent-claude-code.md index dd88fac26..12b999253 100644 --- a/docs/integrate-frameworks/coding-agent-claude-code.md +++ b/docs/integrate-frameworks/coding-agent-claude-code.md @@ -15,13 +15,19 @@ gateway controls as Claude Code. Use the wrapper for no-install local observability: ```bash -nemo-flow run -- claude +nemo-flow claude ``` -The wrapper infers Claude Code from `claude`, starts a gateway on a dynamic -`127.0.0.1` port, creates a temporary Claude plugin directory with NeMo Flow -hooks, passes that plugin with `--plugin-dir`, and sets -`ANTHROPIC_BASE_URL` to the gateway URL for the launched process. +Pass Claude Code arguments after `--`: + +```bash +nemo-flow claude -- "summarize this repository" +``` + +This shortcut is equivalent to `nemo-flow run -- claude`. The wrapper starts a +gateway on a dynamic `127.0.0.1` port, creates a temporary Claude plugin +directory with NeMo Flow hooks, passes that plugin with `--plugin-dir`, and +sets `ANTHROPIC_BASE_URL` to the gateway URL for the launched process. Inspect what would be launched without starting Claude Code: @@ -32,12 +38,6 @@ nemo-flow run \ -- claude ``` -If a launcher hides the command name, pass the agent explicitly: - -```bash -nemo-flow run --agent claude -- my-claude-wrapper -``` - ## Shared Config Create `.nemo-flow/config.toml` for project defaults or @@ -68,7 +68,7 @@ endpoint = "http://127.0.0.1:4318/v1/traces" ``` Run `nemo-flow run --agent claude` to use the configured command and plugin -config. User config takes priority over project and global config. +config. User config takes priority over project and system config. ## Standalone Gateway diff --git a/docs/integrate-frameworks/coding-agent-codex.md b/docs/integrate-frameworks/coding-agent-codex.md index fa970fc61..3d6e65425 100644 --- a/docs/integrate-frameworks/coding-agent-codex.md +++ b/docs/integrate-frameworks/coding-agent-codex.md @@ -30,14 +30,20 @@ upstream as [openai/codex#21639](https://github.com/openai/codex/issues/21639). Use the wrapper for no-install local observability: ```bash -nemo-flow run -- codex +nemo-flow codex ``` -The wrapper infers Codex from `codex`, starts a gateway on a dynamic -`127.0.0.1` port, enables Codex hooks with CLI config overrides, injects hook -commands that use `NEMO_FLOW_GATEWAY_URL`, and points Codex at a temporary -`nemo-flow-openai` provider alias that uses the gateway URL while preserving -Codex's OpenAI auth path. +Pass Codex arguments after `--`: + +```bash +nemo-flow codex -- exec "Summarize this repository." +``` + +This shortcut is equivalent to `nemo-flow run -- codex`. The wrapper starts a +gateway on a dynamic `127.0.0.1` port, enables Codex hooks with CLI config +overrides, injects hook commands that use `NEMO_FLOW_GATEWAY_URL`, and points +Codex at a temporary `nemo-flow-openai` provider alias that uses the gateway +URL while preserving Codex's OpenAI auth path. Inspect what would be launched without starting Codex: @@ -48,12 +54,6 @@ nemo-flow run \ -- codex ``` -If a launcher hides the command name, pass the agent explicitly: - -```bash -nemo-flow run --agent codex -- my-codex-wrapper -``` - ## Shared Config Create `.nemo-flow/config.toml` for project defaults or @@ -83,7 +83,7 @@ output_directory = ".nemo-flow/atif" ``` Run `nemo-flow run --agent codex` to use the configured command and plugin -config. User config takes priority over project and global config. +config. User config takes priority over project and system config. ## Standalone Gateway diff --git a/docs/integrate-frameworks/coding-agent-cursor.md b/docs/integrate-frameworks/coding-agent-cursor.md index 8964f9b83..67117d346 100644 --- a/docs/integrate-frameworks/coding-agent-cursor.md +++ b/docs/integrate-frameworks/coding-agent-cursor.md @@ -24,13 +24,19 @@ model routing is configurable. Use the wrapper for no-install local observability: ```bash -nemo-flow run -- cursor-agent +nemo-flow cursor ``` -The wrapper infers Cursor from `cursor` or `cursor-agent`, starts a gateway on a -dynamic `127.0.0.1` port, temporarily merges NeMo Flow hook entries into the -project `.cursor/hooks.json`, launches Cursor, and restores the original hook -file after the agent exits. +Pass Cursor arguments after `--`: + +```bash +nemo-flow cursor -- agent --resume +``` + +This shortcut is equivalent to `nemo-flow run -- cursor-agent`. The wrapper +starts a gateway on a dynamic `127.0.0.1` port, temporarily merges NeMo Flow +hook entries into the project `.cursor/hooks.json`, launches Cursor, and +restores the original hook file after the agent exits. Inspect what would be launched without starting Cursor: @@ -41,12 +47,6 @@ nemo-flow run \ -- cursor-agent ``` -If a launcher hides the command name, pass the agent explicitly: - -```bash -nemo-flow run --agent cursor -- my-cursor-wrapper -``` - ## Shared Config Create `.nemo-flow/config.toml` for project defaults or @@ -74,7 +74,7 @@ output_directory = ".nemo-flow/atif" ``` Run `nemo-flow run --agent cursor` to use the configured command and plugin -config. User config takes priority over project and global config. +config. User config takes priority over project and system config. ## Standalone Gateway diff --git a/docs/integrate-frameworks/coding-agent-gateway.md b/docs/integrate-frameworks/coding-agent-gateway.md index 0d94af953..56910b6e4 100644 --- a/docs/integrate-frameworks/coding-agent-gateway.md +++ b/docs/integrate-frameworks/coding-agent-gateway.md @@ -52,23 +52,36 @@ under the active session scope. ## Transparent Run -Use `nemo-flow run` for no-install local observability. The wrapper -starts a gateway on a dynamic `127.0.0.1` port, injects the resolved hook and -gateway configuration into the launched coding agent, and stops the gateway -when the agent exits. +Use the agent shortcuts for no-install local observability. The wrapper starts +a gateway on a dynamic `127.0.0.1` port, injects the resolved hook and gateway +configuration into the launched coding agent, and stops the gateway when the +agent exits. + +```bash +nemo-flow codex +nemo-flow claude +nemo-flow cursor +nemo-flow hermes +``` + +Use `nemo-flow run -- ` when you want to launch an explicit command +instead of the built-in shortcut: ```bash nemo-flow run -- codex -nemo-flow run -- claude -nemo-flow run -- cursor-agent -nemo-flow run -- hermes ``` -The wrapper infers the agent from the command basename. Use `--agent` when a -launcher or wrapper hides the real agent name: +If a launcher or wrapper hides the real agent name, set that wrapper as the +configured command and pass `--agent`. The same pattern applies to Claude Code, +Codex, Cursor, and Hermes: + +```toml +[agents.codex] +command = "my-codex-wrapper" +``` ```bash -nemo-flow run --agent codex -- my-codex-wrapper +nemo-flow run --agent codex ``` Hermes is different from the other transparent modes: `run --agent hermes` @@ -80,8 +93,8 @@ environment, gateway URL, and final command without launching the agent. ## Shared Configuration -Shared TOML config is optional. The gateway loads defaults, then global config, -then project config, then user config. User config takes priority over global +Shared TOML config is optional. The gateway loads defaults, then system config, +then project config, then user config. User config takes priority over system and project config. CLI flags and environment variables override file config. Config file locations are: diff --git a/docs/integrate-frameworks/coding-agent-hermes.md b/docs/integrate-frameworks/coding-agent-hermes.md index 3259dafc4..aa87dae4a 100644 --- a/docs/integrate-frameworks/coding-agent-hermes.md +++ b/docs/integrate-frameworks/coding-agent-hermes.md @@ -21,14 +21,20 @@ Use the wrapper when you want the gateway lifetime managed for a local Hermes process: ```bash -nemo-flow run -- hermes +nemo-flow hermes ``` -The wrapper infers Hermes from `hermes` or `hermes-agent`, starts a gateway on a -dynamic `127.0.0.1` port, and exports `NEMO_FLOW_GATEWAY_URL` for the launched -process. Hermes hook configuration is not temporary in this mode. Install hooks -first, or configure equivalent Hermes shell hooks, so approved hook commands can -discover the dynamic gateway URL. +Pass Hermes arguments after `--`: + +```bash +nemo-flow hermes -- chat --provider custom +``` + +This shortcut is equivalent to `nemo-flow run -- hermes`. The wrapper starts a +gateway on a dynamic `127.0.0.1` port and exports `NEMO_FLOW_GATEWAY_URL` for +the launched process. Hermes hook configuration is not temporary in this mode. +Install hooks first, or configure equivalent Hermes shell hooks, so approved +hook commands can discover the dynamic gateway URL. Inspect what would be launched without starting Hermes: @@ -39,12 +45,6 @@ nemo-flow run \ -- hermes ``` -If a launcher hides the command name, pass the agent explicitly: - -```bash -nemo-flow run --agent hermes -- my-hermes-wrapper -``` - ## Shared Config Create `.nemo-flow/config.toml` for project defaults or @@ -75,7 +75,7 @@ endpoint = "http://127.0.0.1:4318/v1/traces" ``` Run `nemo-flow run --agent hermes` to use the configured command and plugin -config. User config takes priority over project and global config. +config. User config takes priority over project and system config. ## Hermes Hook Setup From c8708cb1b3f7456164a4e69ea60aee1946eccbf1 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Wed, 13 May 2026 11:56:27 -0700 Subject: [PATCH 7/7] fix(cli): continue closing sessions after shutdown errors close_all drained every session but returned on the first close_for_shutdown failure, so any later drained sessions skipped shutdown cleanup. Capture the first close error, keep closing the remaining sessions, and return the captured error after the loop. Add a regression test that proves a later valid session still closes after an earlier close fails. Signed-off-by: Ajay Thorve --- crates/cli/src/session.rs | 18 +++++-- crates/cli/tests/coverage/session_tests.rs | 57 ++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/session.rs b/crates/cli/src/session.rs index 6f306b352..e033194cc 100644 --- a/crates/cli/src/session.rs +++ b/crates/cli/src/session.rs @@ -338,11 +338,23 @@ impl SessionManager { .map(|(_, session)| session) .collect::>() }; - for session in &mut sessions { - session.close_for_shutdown(reason).await?; + close_sessions_for_shutdown(&mut sessions, reason).await + } +} + +async fn close_sessions_for_shutdown( + sessions: &mut [Session], + reason: &str, +) -> Result<(), CliError> { + let mut first_error = None; + for session in sessions { + if let Err(error) = session.close_for_shutdown(reason).await + && first_error.is_none() + { + first_error = Some(error); } - Ok(()) } + first_error.map_or(Ok(()), Err) } impl Session { diff --git a/crates/cli/tests/coverage/session_tests.rs b/crates/cli/tests/coverage/session_tests.rs index 9f1111b3c..c447bbe29 100644 --- a/crates/cli/tests/coverage/session_tests.rs +++ b/crates/cli/tests/coverage/session_tests.rs @@ -2,7 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 use axum::http::HeaderMap; +use nemo_flow::api::event::ScopeCategory; +use nemo_flow::api::subscriber::{deregister_subscriber, register_subscriber}; use serde_json::json; +use std::sync::{Arc, Mutex as StdMutex}; use super::*; use crate::model::{LlmHintEvent, SessionEvent, ToolEvent}; @@ -900,6 +903,60 @@ async fn gateway_shutdown_closes_codex_sessions_without_session_end_hook() { assert!(manager.inner.lock().await.is_empty()); } +#[tokio::test] +async fn gateway_shutdown_attempts_remaining_sessions_after_close_error() { + let subscriber_name = "cli-close-all-deferred-error-test"; + let _ = deregister_subscriber(subscriber_name); + + let closed_sessions = Arc::new(StdMutex::new(Vec::::new())); + let captured = closed_sessions.clone(); + register_subscriber( + subscriber_name, + Arc::new(move |event| { + if event.scope_category() == Some(ScopeCategory::End) + && let Some(session_id) = event + .metadata() + .and_then(|metadata| metadata.get("session_id")) + .and_then(Value::as_str) + { + captured.lock().unwrap().push(session_id.to_string()); + } + }), + ) + .unwrap(); + + let config = SessionConfig::default(); + let mut bad = Session::new("bad-shutdown".into(), AgentKind::Codex, config.clone()); + bad.agent_scope = Some( + ScopeHandle::builder() + .name("missing-agent-scope") + .scope_type(ScopeType::Agent) + .build(), + ); + + let mut good = Session::new("good-shutdown".into(), AgentKind::Codex, config); + let stack = good.scope_stack.clone(); + TASK_SCOPE_STACK + .scope(stack, async { + good.ensure_agent_started(json!({})).unwrap(); + }) + .await; + + let mut sessions = vec![bad, good]; + let error = close_sessions_for_shutdown(&mut sessions, "gateway_shutdown") + .await + .unwrap_err(); + assert!(error.to_string().contains("scope handle not found")); + + let closed = closed_sessions.lock().unwrap().clone(); + assert!( + closed.contains(&"good-shutdown".to_string()), + "expected later valid session to close after first error, got {closed:?}" + ); + + deregister_subscriber(subscriber_name).unwrap(); +} + #[tokio::test] async fn explicit_gateway_subagent_header_sets_llm_parent() { let manager = SessionManager::new(session_test_config());